Feature Request
Human-facing feature request: I want a simple uniform ToolRegistry for both async fn native &
MCP tools,so we're not just bouncing JSON around all day, when we have a better and safer path for
async fn.
All the MCP ToolCapabilities aspects of this, are obviously "future looking," and might not even have to
be included in this, but we might as well capture what's already here in MCP 2025-11-25, and drafted
for 2026-07-28 all at once. I just don't want to send in like 10 issues that half-overlap with issues
and PR's already in place I see you working on. Few to no frameworks support all the async / streaming
aspects of MCP today, and I don't want to suggest that it's something easy to "just roll in," because it's not.
Same with the related Context aspects of this. Would be awesome to align with.
All the rest of this is clanker generated noise trying to be polite about it, while I think it's fine to break
the API with rig's churn rate at the moment :)
The framework is awesome, thanks!
Feature Request
Add a host-facing tool registry where native and MCP tools are first-class, typed rows — not anonymous entries in a homogeneous HashMap<String, ToolDyn>. The default agent loop may keep ToolDyn::call(String) → String; hosts get a catalog they can introspect and dispatch through without treating every tool as the same erased shape.
Motivation
Rig today registers native Tool and McpTool in one ToolSet, both stored as Arc<dyn ToolDyn> (reference/rig/crates/rig-core/src/tool/mod.rs:251-277, :335-338). That fits the built-in agent loop but collapses two different tool families into one runtime shape:
| Concern |
Native Tool |
MCP McpTool |
| Author-time args |
Tool::Args (typed struct) |
No Rust Args — server input_schema |
| Host dispatch |
Could stay typed after deserialize |
JSON object → MCP RPC |
| Registry introspection |
Indistinguishable from MCP after add_tool |
Same |
| LLM-facing schema |
parameters: serde_json::Value |
Same |
Schema projection to JSON for the model is unavoidable (ToolDefinition.parameters, reference/rig/crates/rig-core/src/completion/request.rs:203-211). The gap is runtime registry and host dispatch: hosts building policy layers, manual tool loops, or unified native+MCP catalogs should not have to pretend all tools share one erased ToolDyn row.
Today’s workarounds:
// Erased dispatch — manual_tool_calls.rs pattern
toolset.call("add", serde_json::to_string(&args)?).await?; // → String
// Typed dispatch — bypasses the registry
add.call(OperationArgs { x: 20, y: 5 }).await?; // → i32, but no MCP sibling in same map
Hosts need one registry that says “this row is native (typed dispatch path)” vs “this row is MCP (JSON + rmcp client)” without codegen fake Args for MCP tools.
Proposal
Types in Rig today vs proposed (all registry/caps types below are new — not in rig-core today):
| Type |
On main? |
Role |
ToolDefinition |
Yes (name, description, parameters only) |
LLM-facing schema; unchanged |
ToolSet / ToolDyn |
Yes |
Default agent dispatch; unchanged |
ToolRegistry, RegistryEntry, NativeEntry, McpEntry |
No |
Host-facing typed catalog |
ToolCapabilities, ToolIoMode, RigBridgeMode |
No |
Host-only metadata (not sent to model as-is) |
Introduce a ToolRegistry (name bikesheddable) alongside — not replacing — ToolSet:
pub enum RegistryEntry {
Native(NativeEntry),
Mcp(McpEntry),
}
pub struct NativeEntry {
pub name: String,
pub definition: ToolDefinition,
pub caps: ToolCapabilities,
// dispatch: typed Args → Output (TypeId map, fn ptr, or stored Tool impl — bikeshed)
}
pub struct McpEntry {
pub name: String,
pub definition: ToolDefinition,
pub caps: ToolCapabilities, // populate task_support, output_schema from rmcp Tool
pub client: rmcp::service::ServerSink,
pub server_tool: rmcp::model::Tool,
}
/// Host-only flags populated at registration. Not in rig-core today.
pub struct ToolCapabilities {
/// MCP `ToolExecution.taskSupport`; None for native rows.
pub task_support: Option<McpTaskSupport>,
/// From MCP tool `outputSchema` or native override; may also feed #1613 LLM field.
pub output_schema: Option<serde_json::Value>,
pub emits_progress: bool,
pub may_elicit: bool,
pub may_sample: bool,
/// Optional v1 — default Unary / NativeUnary.
pub io_mode: ToolIoMode,
pub bridge: RigBridgeMode,
pub notes: Option<String>,
}
Populate MCP caps from rmcp::model::Tool at connect / on_tool_list_changed; native rows default conservative caps.
Compatibility vs simplicity
We split the design into what must stay for backward compatibility vs what a greenfield API could do more simply.
| Piece |
Compatibility-preserving (proposed) |
Simpler if breaking were OK |
| Agent loop |
Agent::prompt() keeps calling ToolSet::call(String) → String |
Teach loop to dispatch via ToolRegistry directly; drop string boundary |
ToolDyn / Tool traits |
Unchanged; registry bridges to them at export |
Change ToolDyn::call return type or add required streaming methods on all tools |
ToolSet storage |
Keep HashMap<String, ToolType> + add_tool; registry is parallel or built then synced |
Replace ToolSet internals with RegistryEntry only; one map |
| Registration API |
Keep ToolServer::tool / rmcp_tool; add ToolRegistry::register_* (or builder opt-in) |
Single registration path; deprecate direct add_tool |
| LLM tool list |
Still Vec<ToolDefinition> with parameters: Value |
Same — even a breaking redesign cannot avoid JSON Schema on the wire |
| Native host dispatch |
Typed inside registry; erasure only in bridge_to_toolset() |
Typed everywhere; no bridge |
| MCP dispatch |
JSON args in registry row (matches MCP; no fake Args struct) |
Same — MCP was never Rust-typed in Rig |
| Capability metadata |
New ToolCapabilities on each row; optional fields default safely |
Could overload ToolDefinition (rejected — pollutes LLM schema) |
Guardrails (io_mode, bridge) |
Optional on entry; warn-only v1 |
Hard-error at registration when misconfigured with default loop |
Why the extra layer: Rig’s ecosystem (agents, MCP adapter, WASM ToolDyn, cassette tests in #1902) assumes the erased edge. A parallel ToolRegistry gives hosts typed native + explicit MCP rows without forcing every caller through a breaking ToolSet redesign.
Smallest useful v1 (if scope must shrink): RegistryEntry { Native, Mcp } + tool_definitions() + dispatch_with_context(name, args, &ctx) (single entry; routes internally) + bridge_to_toolset(). Defer full ToolCapabilities until #1613 lands.
Behavior:
- Registration —
ToolRegistry::register_native(tool) / register_mcp(slot) populate typed rows.
- Host dispatch (primary) — one method, name-driven — the manual-loop path the model gives you (
tool_call.function.name + JSON args):
registry.dispatch_with_context(name, args_json, &ctx).await?; // → String (or rich result later)
// internally: match RegistryEntry::Native → deserialize → call_with_context
// RegistryEntry::Mcp → forward JSON + ctx MCP projection to rmcp
The caller does not choose native vs MCP at the call site; the registry row does. That is the point of the unified catalog.
- Optional typed shortcut — when the host already knows
T: Tool (tests, single-tool middleware), not when driving arbitrary model tool calls:
registry.dispatch_native::<T>(args, &ctx).await?; // → T::Output; skips JSON stringify/parse
Split dispatch_native / dispatch_mcp APIs are not the primary surface — they duplicate the “which family is this?” branch the registry exists to own.
- Export to Rig —
registry.bridge_to_toolset() -> ToolSet (or sync into ToolServer) materializes ToolDyn adapters for tools the model must see. One erasure point at the Rig edge, not inside the host registry.
- Export to LLM —
registry.tool_definitions() -> Vec<ToolDefinition> still produces parameters: Value per row (coordinate MCP outputSchema with #1613 / our comment).
- Completeness (phased) — v1: static native + MCP. Follow-on in same type:
Dynamic, ProviderHosted, multi-MCP server_id namespacing.
- Default agent loop unchanged unless opt-in —
Agent::prompt() continues using ToolSet::call; registry is for hosts that need typed catalog semantics.
Per-call context (#1537) — dependency, not duplicate
The registry does not store per-call context. The caller builds ToolCallContext once per tool call and passes &ctx into the same dispatch_with_context — not into parallel native/MCP methods.
let ctx = ToolCallContext::new(call_id).with_cancel(cancel.child_token());
// ... extensions: sandbox, trace, etc.
for tool_call in &tool_calls {
let args = serde_json::to_string(&tool_call.function.arguments)?;
let output = registry
.dispatch_with_context(&tool_call.function.name, &args, &ctx)
.await?;
}
| Who |
Role |
| Caller |
One ctx per invocation; same for every tool name in the turn |
| Registry |
match entry { Native(..) => .., Mcp(..) => .. }; forwards &ctx on both arms |
| Native arm |
Deserialize JSON → Tool::call_with_context(typed_args, &ctx) |
| MCP arm |
Attach ctx MCP projection (_meta.progressToken, …) → rmcp call_tool |
bridge_to_toolset() |
Bridged ToolDyn::call_with_context delegates to dispatch_with_context(name, args, ctx) |
Why #1537 is related but separate: #1906 is catalog shape + unified name-based dispatch. #1537 defines the ToolCallContext type and threads it through ToolSet / agent loop. Same ctx type at the registry boundary; v1 can default to empty ctx if #1537 is not merged yet.
Backward compatibility: purely additive. Existing ToolServer::tool / rmcp_tool / ToolSet::call unchanged.
Alternatives
- Metadata sidecar only (name → caps, keep
ToolDyn map): lighter but native tools still erased at dispatch; does not unify typed native + MCP rows.
ToolSet::call_typed only: typed native helper on existing map; no MCP row typing, no guardrail meta, no dynamic/provider kinds.
- Downstream-only registry (external crate): works but every integrator reimplements native/MCP split and Rig bridging.
- Replace
ToolDyn globally: breaking; rejected.
Related upstream (issues & PRs)
Feature Request
Feature Request
Add a host-facing tool registry where native and MCP tools are first-class, typed rows — not anonymous entries in a homogeneous
HashMap<String, ToolDyn>. The default agent loop may keepToolDyn::call(String) → String; hosts get a catalog they can introspect and dispatch through without treating every tool as the same erased shape.Motivation
Rig today registers native
ToolandMcpToolin oneToolSet, both stored asArc<dyn ToolDyn>(reference/rig/crates/rig-core/src/tool/mod.rs:251-277,:335-338). That fits the built-in agent loop but collapses two different tool families into one runtime shape:ToolMcpToolTool::Args(typed struct)Args— serverinput_schemaadd_toolparameters: serde_json::ValueSchema projection to JSON for the model is unavoidable (
ToolDefinition.parameters,reference/rig/crates/rig-core/src/completion/request.rs:203-211). The gap is runtime registry and host dispatch: hosts building policy layers, manual tool loops, or unified native+MCP catalogs should not have to pretend all tools share one erasedToolDynrow.Today’s workarounds:
Hosts need one registry that says “this row is native (typed dispatch path)” vs “this row is MCP (JSON + rmcp client)” without codegen fake
Argsfor MCP tools.Proposal
Types in Rig today vs proposed (all registry/caps types below are new — not in
rig-coretoday):main?ToolDefinitionname,description,parametersonly)ToolSet/ToolDynToolRegistry,RegistryEntry,NativeEntry,McpEntryToolCapabilities,ToolIoMode,RigBridgeModeIntroduce a
ToolRegistry(name bikesheddable) alongside — not replacing —ToolSet:Populate MCP
capsfromrmcp::model::Toolat connect /on_tool_list_changed; native rows default conservative caps.Compatibility vs simplicity
We split the design into what must stay for backward compatibility vs what a greenfield API could do more simply.
Agent::prompt()keeps callingToolSet::call(String) → StringToolRegistrydirectly; drop string boundaryToolDyn/TooltraitsToolDyn::callreturn type or add required streaming methods on all toolsToolSetstorageHashMap<String, ToolType>+add_tool; registry is parallel or built then syncedToolSetinternals withRegistryEntryonly; one mapToolServer::tool/rmcp_tool; addToolRegistry::register_*(or builder opt-in)add_toolVec<ToolDefinition>withparameters: Valuebridge_to_toolset()Argsstruct)ToolCapabilitieson each row; optional fields default safelyToolDefinition(rejected — pollutes LLM schema)io_mode,bridge)Why the extra layer: Rig’s ecosystem (agents, MCP adapter, WASM
ToolDyn, cassette tests in #1902) assumes the erased edge. A parallelToolRegistrygives hosts typed native + explicit MCP rows without forcing every caller through a breakingToolSetredesign.Smallest useful v1 (if scope must shrink):
RegistryEntry { Native, Mcp }+tool_definitions()+dispatch_with_context(name, args, &ctx)(single entry; routes internally) +bridge_to_toolset(). Defer fullToolCapabilitiesuntil #1613 lands.Behavior:
ToolRegistry::register_native(tool)/register_mcp(slot)populate typed rows.tool_call.function.name+ JSON args):The caller does not choose native vs MCP at the call site; the registry row does. That is the point of the unified catalog.
T: Tool(tests, single-tool middleware), not when driving arbitrary model tool calls:Split
dispatch_native/dispatch_mcpAPIs are not the primary surface — they duplicate the “which family is this?” branch the registry exists to own.registry.bridge_to_toolset() -> ToolSet(or sync intoToolServer) materializesToolDynadapters for tools the model must see. One erasure point at the Rig edge, not inside the host registry.registry.tool_definitions() -> Vec<ToolDefinition>still producesparameters: Valueper row (coordinate MCPoutputSchemawith #1613 / our comment).Dynamic,ProviderHosted, multi-MCPserver_idnamespacing.Agent::prompt()continues usingToolSet::call; registry is for hosts that need typed catalog semantics.Per-call context (#1537) — dependency, not duplicate
The registry does not store per-call context. The caller builds
ToolCallContextonce per tool call and passes&ctxinto the samedispatch_with_context— not into parallel native/MCP methods.ctxper invocation; same for every tool name in the turnmatch entry { Native(..) => .., Mcp(..) => .. }; forwards&ctxon both armsTool::call_with_context(typed_args, &ctx)ctxMCP projection (_meta.progressToken, …) → rmcpcall_toolbridge_to_toolset()ToolDyn::call_with_contextdelegates todispatch_with_context(name, args, ctx)Why #1537 is related but separate: #1906 is catalog shape + unified name-based dispatch. #1537 defines the
ToolCallContexttype and threads it throughToolSet/ agent loop. Samectxtype at the registry boundary; v1 can default to emptyctxif #1537 is not merged yet.Backward compatibility: purely additive. Existing
ToolServer::tool/rmcp_tool/ToolSet::callunchanged.Alternatives
ToolDynmap): lighter but native tools still erased at dispatch; does not unify typed native + MCP rows.ToolSet::call_typedonly: typed native helper on existing map; no MCP row typing, no guardrail meta, no dynamic/provider kinds.ToolDynglobally: breaking; rejected.Related upstream (issues & PRs)
ToolDefinition#output_schema(LLM-facing). Our comment (2026-06-15) — split LLM schema vs host registry; MCPoutputSchemaplumbing.ToolCallContext/call_tool_with_context. Related plumbing: same&ctxon unifieddispatch_with_context(name, …); mirrorsToolServerHandleAPI. Comment on feat(rig-core): add ToolCallContext for per-call runtime context in tool dispatch #1537 for MCP_meta/ cancel gaps.RegistryEntry::ProviderHostedis a follow-on variant, not v1 blocker.ToolServeras trait; tangential extensibility.