0xPlaygrounds / 0xPlaygrounds/rig
feat: host-facing ToolRegistry with typed native and MCP catalog entries
- Linguagem predominante
- Rust
- Estrelas
- 8.6k
- Forks
- 959
- Merge médio
- 4h 32min
- PRs com merge (30d)
- 117
Descrição
- [X] 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`. 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` (`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:
```rust
// 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`:
```rust
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,
/// From MCP tool `outputSchema` or native override; may also feed #1613 LLM field.
pub output_schema: Option,
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,
}
```
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` + `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` 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](https://github.com/0xPlaygrounds/rig/pull/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. **Registration** — `ToolRegistry::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):
```rust
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.
3. **Optional typed shortcut** — when the host **already knows** `T: Tool` (tests, single-tool middleware), not when driving arbitrary model tool calls:
```rust
registry.dispatch_native::(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.
4. **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.
5. **Export to LLM** — `registry.tool_definitions() -> Vec` still produces `parameters: Value` per row (coordinate MCP `outputSchema` with [#1613](https://github.com/0xPlaygrounds/rig/issues/1613) / [our comment](https://github.com/0xPlaygrounds/rig/issues/1613#issuecomment-4703790577)).
6. **Completeness (phased)** — v1: static native + MCP. Follow-on in same type: `Dynamic`, `ProviderHosted`, multi-MCP `server_id` namespacing.
7. **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](https://github.com/0xPlaygrounds/rig/pull/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.
```rust
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)
- **[#1613](https://github.com/0xPlaygrounds/rig/issues/1613)** (open) — `ToolDefinition#output_schema` (LLM-facing). **[Our comment (2026-06-15)](https://github.com/0xPlaygrounds/rig/issues/1613#issuecomment-4703790577)** — split LLM schema vs host registry; MCP `outputSchema` plumbing.
- **[#1537](https://github.com/0xPlaygrounds/rig/pull/1537)** (open) — `ToolCallContext` / `call_tool_with_context`. **Related plumbing:** same `&ctx` on unified `dispatch_with_context(name, …)`; mirrors `ToolServerHandle` API. Comment on #1537 for MCP `_meta` / cancel gaps.
- **[#1890](https://github.com/0xPlaygrounds/rig/issues/1890)** (open) — provider-hosted tools; `RegistryEntry::ProviderHosted` is a follow-on variant, not v1 blocker.
- **[#1026](https://github.com/0xPlaygrounds/rig/issues/1026)** (open) — `ToolServer` as trait; tangential extensibility.
Guia de contribuição
Direção de pesquisa
The issue proposes a new host-facing ToolRegistry with typed entries for native and MCP tools. Start by reading the existing tool registration code in rig-core/src/tool/mod.rs around lines 251-277 and 335-338. Understand the current ToolSet and ToolDyn traits. Then examine the linked issues #1613 and #1537 for context on output_schema and ToolCallContext. The goal is to design and implement the new registry types (RegistryEntry, NativeEntry, McpEntry, ToolCapabilities) and the dispatch_with_context method, ensuring backward compatibility with the existing agent loop.
Escrita pelo modelo de indexação a partir do texto da issue.
Avaliação
- Stack de tecnologia
- rust
- Domínio
- backend-api-design, tooling
- Tipo de issue
- Funcionalidade
- Dificuldade
- 5/5
- Tempo estimado
- Mais de uma semana
- Status de atividade
- Pouca atividade
- Clareza
- Razoavelmente clara
- Facilidade para iniciantes
- 25/100