0xPlaygrounds / 0xPlaygrounds/rig
feat: derive per-call MCP _meta from the chosen tool call
- Dominant language
- Rust
- Stars
- 8.6k
- Forks
- 959
- Avg merge
- 4h 32m
- Merged PRs (30d)
- 117
Description
- [x] I have looked for existing issues (including closed) about this
## Feature Request
Allow per-call MCP `_meta` to be derived from the tool call the model chose.
The `_meta` channel added in #1954 and kept through #2141 and #2398 is read from the run's `ToolContext` at dispatch time: `context.get::().cloned()` at `rig-agent/src/tool/rmcp.rs:487` on 0.42.0, and `crates/rig-rmcp/src/native.rs:478` on `main`, then `request.meta = meta`. That context is set once by the caller through `PromptRequest::tool_context` before the model has picked a tool or its arguments. Metadata that has to be computed from the call itself has no way onto the request.
The pre-tool hook sees the call but cannot contribute to it. `on_tool_call(&self, &HookContext, ToolCall<'_>)` receives `tool_name`, `tool_call_id`, `internal_call_id`, and `args`, and returns `ToolCallAction::{Run, Rewrite(Value), Skip(String), Stop(String)}`. No hook method receives a mutable `ToolContext`; the only context exposed to hooks is the immutable `tool_context` on the result event.
### Motivation
Some metadata is inherently per call because it binds to the arguments:
- **Request signing.** A signature or proof of possession over `(tool, arguments, time)` lets the MCP server verify that the caller authorized *this* call, and a captured signature cannot be replayed with different arguments. This is my case (Tenuo warrants); it is the same shape as any HMAC-signed request.
- **Idempotency keys** derived from the argument set, so retries of one logical call coalesce downstream.
- **Trace or budget context** that depends on which tool was chosen.
A run-level `Meta` covers auth tokens, session ids, and A2A `context_id`/`task_id`, which is what #1536 asked for and what #1954 delivered. It cannot cover a value that does not exist until the model decides.
Current workaround: implement the MCP tool by hand (`impl Tool`), compute the metadata inside `call()` from the deserialized arguments, and drive the `rmcp` client directly, setting `CallToolRequestParams::meta`. That works, but it gives up the registration path (`rmcp_tools()` on 0.42; `PortableDynamicTool` on `main`), `tools/list_changed` reconciliation through `McpClientHandler`, and the result preservation in `preserve_mcp_result`, and it has to be repeated for every tool.
### Prior art
In this repo:
- #1536 → #1954 (merged 2026-06-25): per-call context forwarded as `_meta` (SEP-1319). The value is supplied by the caller before the run.
- #1680 asked for argument rewriting from the hook; the requester closed it. Rewriting exists today as `ToolCallAction::Rewrite`. Nobody has asked for hooks to *add* context.
- #2398 (merged 2026-08-22): `ToolContext` moves to `rig-core`, `PortableDynamicTool::new_with_context` gives dynamic tools the per-call context, `_meta` forwarding unchanged.
- #2278 (open since 2026-08-10): `AgentDriver`'s `DriveStep::ExecuteTools { calls, tools }` hands the host the pending calls before dispatch and the host builds the `ToolContext`. That is exactly the insertion point, but only on the sans-IO path, which by design runs no hooks, memory, retrieval, or telemetry.
Elsewhere:
- LangChain's `@wrap_tool_call` middleware receives a `ToolCallRequest` and a handler, runs after the model selects a tool and before it executes, and can inspect or modify the call. Tools also read `runtime.tool_call_id` from `ToolRuntime`.
- pydantic-ai passes a per-call `RunContext` carrying `tool_call_id` and `tool_name` (no arguments), and tools have a `prepare(RunContext, ToolDefinition) -> ToolDefinition | None` step.
### Describe the solution you'd like
Smallest first. Any one closes the gap.
**A. Per-call context from the hook.** Let `on_tool_call` contribute values merged into that call's dispatch snapshot. Sketch, not a proposal for exact naming:
```rust
async fn on_tool_call(&self, ctx: &HookContext, event: ToolCall<'_>) -> ToolCallAction {
let meta = self.sign(event.tool_name, event.args); // computed from the chosen call
ToolCallAction::Run.with_context(|c: &mut ToolContext| { c.insert(meta); })
}
```
This generalizes beyond MCP, since sub-agents and native tools read the same context, and leaves the `Meta` forwarding in `rig-rmcp` unchanged.
**B. A per-call meta provider on the MCP registration.** Narrower, MCP only:
```rust
trait McpMetaProvider: Send + Sync {
fn meta_for(&self, tool: &str, arguments: Option<&JsonObject>, ctx: &ToolContext) -> Option;
}
McpTool::from_mcp_server(..).with_meta_provider(provider)
```
Falls back to the context `Meta` on `None`, so it is additive.
**C. Land #2278 and document `ExecuteTools` as the supported path.** Works for hand-driven loops today; the cost is the hook lifecycle.
I'd suggest A, with B as a reasonable narrow alternative. Happy to open a PR for either once there is agreement on the shape.
### Reproduction
Register any MCP tool via the portable path and try to set a `Meta` whose value depends on `arguments`. The only place to set it is before `prompt()` runs. An end-to-end example of the hand-written workaround (a Rig agent, a guarded MCP tool, and an rmcp server verifying per-call `_meta`) is at https://github.com/tenuo-ai/tenuo-rig-demo — see `src/tools/incident_mcp.rs` for the tool and `src/bin/incident_mcp_server.rs` for the server.
Contributor guide
Assessment
This issue has not been assessed yet.