Skip to content

Slice 4d.4: namespaced MCP tool forwarder (DoD #3 dispatch half) - #294

Merged
Pal Lakatos-Toth (pallakatos) merged 1 commit into
devfrom
slice-4d.4-router-mcp-forwarder
May 13, 2026
Merged

Pal Lakatos-Toth (pallakatos) merged 1 commit into
devfrom
slice-4d.4-router-mcp-forwarder

Conversation

@pallakatos

Copy link
Copy Markdown
Collaborator

Summary

Closes the dispatch half of Slice 4 DoD #3 ("per-server JWKS + namespaced tools"). The OAuth half landed in PR #293 (Slice 4d.3); this PR wires the actual upstream forwarder so /mcp no longer ships only the in-tree EchoDispatcher.

When the registry advertises at least one McpServer with a usable upstream URL, RouterToolDispatcher discovers each upstream's tools/list at startup, filters through spec.allowedTools, and exposes the surviving tools under a {server_snake_case}.{tool} namespace. Tool calls are then forwarded to the matching upstream URL.

Wire contract — what the controller emits

meta.json (per-server ConfigMap) gains two fields alongside Slice 4d.3's OAuth metadata:

Field Source Purpose
url McpServer.spec.url Upstream MCP server URL (POST destination)
allowedTools McpServer.spec.allowedTools Per-server allow-list ([\"*\"] exposes all)

Wire contract — what the router consumes

`mcp::registry::DiscoveredMcpServerMeta` matches the producer shape, and the new `mcp::forwarder::RouterToolDispatcher`:

  1. Discovers each upstream's tool catalog at router startup.
  2. Filters through `allowedTools` (`["*"]` exposes all; explicit list selects subset; empty fails closed).
  3. Builds a namespaced catalog: `github-mcp` exposing `search` becomes `github_mcp.search` agent-side.
  4. Skips (with structured logs) any server with empty URL, outbound-OAuth requirement, unreachable upstream, or HTTP/JSON-RPC errors. Router still starts.

Out-of-scope (Slice 4d.5)

  • Outbound OAuth (client-credentials / OBO). Servers whose meta carries an `issuer` are deliberately skipped with a clear reason. This is the §5 anti-scaffolding boundary: we only ship the consumer we can drive end-to-end today.

Test coverage (15 new unit tests)

Every skip reason is asserted with the expected substring (operator-actionable). The happy path runs against a real `axum::Router` mock upstream — full JSON-RPC `tools/list` → `tools/call` round-trip through the dispatcher.

Negative branches:

  • Unknown tool (wrong prefix / unknown suffix / no `.` at all) → `UnknownTool`.
  • Upstream JSON-RPC error → `is_error: true` content (per MCP spec).
  • Upstream HTTP 5xx → `ExecutionFailed`.

Multi-server dispatch is asserted with two distinct mock upstreams — each invocation lands at the correct URL.

Backward compatibility

Registries without `meta.json` files or with empty `url` fall through to the existing `EchoDispatcher`. The single-issuer `MCP_JWKS_PATH` legacy path remains as the third fallback.

Verification

  • `cargo test --package azureclaw-inference-router` → 835 lib tests (+15)
  • `cargo test --package azureclaw-controller` → 559 (unchanged shape, +1 from struct field addition)
  • `cargo clippy --workspace --all-targets -- -D warnings` clean
  • `cargo fmt --all --check` clean

Slice 4 DoD scoreboard after this PR

# Item State
1 plural ≥ 3 servers e2e ✅ #292
2 singular alias deprecation ✅ #291
3 per-server JWKS + namespaced tools ✅ (OAuth #293, dispatch this PR)
4 audit persistence ✅ #287
5 remote audit sink ✅ #290
6 stale-file sweep ✅ #292
7 `azureclaw audit tail` CLI ✅ #289
8 CHANGELOG + docs 🟡 Slice 4e queued

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

Closes Slice 4 DoD #3 dispatch half: /mcp now forwards tool calls
to upstream McpServer.spec.url instead of serving only the in-tree
EchoDispatcher.

Producer (controller):
- McpServerMeta gains url + allowed_tools fields, written into
  meta.json alongside the OAuth metadata from Slice 4d.3.

Consumer (router):
- New mcp::forwarder module with RouterToolDispatcher implementing
  AsyncToolDispatcher.
- Startup discovery POSTs tools/list to each upstream, filters
  through allowed_tools ("*" exposes all; explicit list selects
  named subset; empty fails closed), prefixes each tool with
  snake_case(server_name).
- Servers with empty URL, empty allow-list, outbound OAuth
  requirement (deferred to 4d.5), unreachable upstream, or HTTP
  errors are recorded in skipped[] with operator-actionable reason
  and excluded from the catalog. Router still starts.
- Dispatch splits name on first '.', looks up server, forwards
  tools/call to upstream URL. JSON-RPC errors collapse to
  is_error:true; 5xx surfaces as DispatchError::ExecutionFailed;
  bad names surface as DispatchError::UnknownTool.
- McpRouteState gains with_tools() builder; build_mcp_router (now
  async) swaps the dispatcher when registry is non-empty.

Scope:
- Unauthenticated upstreams only. Outbound OAuth is Slice 4d.5
  (requires per-server credential source). Servers requiring
  bearer credentials are deliberately skipped per §5 (no
  scaffolding — only ship the consumer we can drive end-to-end).
- Single tools/list page (multi-page support deferred until a
  real upstream hits the cap).

Test coverage: 15 new unit tests covering every skip reason, both
allow-list semantics, namespaced dispatch (single + multi server),
upstream JSON-RPC errors, HTTP 5xx, and unknown-tool branches.

Backward compatibility: registries with no meta.json or no url
fall through to the existing EchoDispatcher unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@pallakatos
Pal Lakatos-Toth (pallakatos) merged commit 660391c into dev May 13, 2026
21 checks passed
@pallakatos
Pal Lakatos-Toth (pallakatos) deleted the slice-4d.4-router-mcp-forwarder branch May 13, 2026 20:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant