Skip to content

feat: host-facing ToolRegistry with typed native and MCP catalog entries #1906

Description

@canardleteer
  • I have looked for existing issues (including closed) about this

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:

  1. RegistrationToolRegistry::register_native(tool) / register_mcp(slot) populate typed rows.
  2. 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.

  1. 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.

  1. Export to Rigregistry.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.
  2. Export to LLMregistry.tool_definitions() -> Vec<ToolDefinition> still produces parameters: Value per row (coordinate MCP outputSchema with #1613 / our comment).
  3. Completeness (phased) — v1: static native + MCP. Follow-on in same type: Dynamic, ProviderHosted, multi-MCP server_id namespacing.
  4. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions