#2442·rig

feat: derive per-call MCP _meta from the chosen tool call

Author: aimable100Created Sep 2, 2026Updated Sep 8, 2026
  • 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::<rmcp::model::Meta>().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<Meta>;
}
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.