From de305edd1c78d4d6f127cd451b203f57dbd0db3d Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 23 Apr 2026 11:27:34 +0100 Subject: [PATCH 1/4] feat(mcp,a2a): opt-in tool exposure with hard floor + tier filter + no-builtins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both workers previously gated on a single boolean metadata flag (mcp.expose / a2a.expose) with --expose-all as the only escape hatch. Past ~20 functions the resulting tool list becomes noise: engine internals sit next to agent-facing functions as if equivalent, and Claude Desktop / MCP Inspector burn context just reading it. This PR tightens the surface: Hard floor — ALWAYS_HIDDEN_PREFIXES (engine::, state::, stream::, iii., mcp:: or a2a::). These NEVER appear in tools/list or agent-card skills, even under --expose-all. Matches the agent worker's existing DEFAULT_EXCLUDED_PREFIXES carve-out. Tier filter — optional mcp.tier / a2a.tier string on the function metadata plus a --tier CLI flag. One worker can tag functions as 'user' / 'agent' / 'ops' and three server instances behind three different auth proxies show three different tool lists. no-builtins — new --no-builtins flag on iii-mcp hides the 6 built-in worker-management tools (iii_worker_register, iii_worker_stop, iii_trigger_register, iii_trigger_unregister, iii_trigger_void, iii_trigger_enqueue). Defaults: off for stdio (common Claude Desktop path needs them), on for --no-stdio HTTP (over HTTP worker management rejects itself as 'requires stdio' anyway — pure noise). Internals: - mcp/handler.rs: new ExposureConfig carrying { expose_all, no_builtins, tier } threaded through McpHandler and dispatch_http. is_function_exposed centralizes the three gates (hard floor, metadata flag, tier match) and is reused in tools/list + tools/call + HTTP tools/call. - a2a/handler.rs: same ExposureConfig { expose_all, tier } threaded through register/build_agent_card/handle_a2a_request/handle_send. is_exposed helper for in-hand FunctionInfo, is_function_exposed async helper for looking up a function_id before dispatch. - mcp/main.rs + a2a/main.rs: add --tier CLI, mcp adds --no-builtins. - docs: add mcp/README.md and a2a/README.md documenting the opt-in pattern with Rust/Node/Python registration snippets and a worked example of the tier flag across three audiences. - initialize_result() instructions string updated to describe the new gate. Verification: - cargo fmt --all -- --check ok on both - cargo clippy --all-targets --all-features -- -D warnings ok on both - cargo test --all-features 0/0 + 0/0 (integration tests live elsewhere) --- a2a/README.md | 137 +++++++++++++++++++++++ a2a/src/handler.rs | 94 +++++++++++----- a2a/src/main.rs | 14 ++- mcp/README.md | 147 +++++++++++++++++++++++++ mcp/src/handler.rs | 222 +++++++++++++++++++++++++++++--------- mcp/src/main.rs | 31 +++++- mcp/src/transport.rs | 5 +- mcp/src/worker_manager.rs | 5 +- 8 files changed, 565 insertions(+), 90 deletions(-) create mode 100644 a2a/README.md create mode 100644 mcp/README.md diff --git a/a2a/README.md b/a2a/README.md new file mode 100644 index 0000000000..753eafabc3 --- /dev/null +++ b/a2a/README.md @@ -0,0 +1,137 @@ +# iii-a2a + +A2A (Agent-to-Agent) JSON-RPC surface for the iii engine. Registers two +HTTP triggers on the engine: + +- `GET /.well-known/agent-card.json` — discovery / capability card +- `POST /a2a` — JSON-RPC dispatch (`message/send`, `tasks/get`, + `tasks/cancel`, `tasks/list`) + +## Exposure model + +Functions are **invisible to remote agents by default**. Authors opt in +per-function. Mirrors the iii-mcp design — same metadata keys, same hard +floor, same optional tier filter. + +### Opt-in metadata + +```rust +// Rust worker +iii.register_function_with( + RegisterFunctionMessage { + id: "pricing::quote".into(), + description: Some("Quote a price for SKU + quantity".into()), + metadata: Some(json!({ + "a2a.expose": true, + "a2a.tier": "partner", // optional — free-form string + })), + ..Default::default() + }, + handler, +); +``` + +```ts +// Node worker +iii.registerFunction("pricing::quote", handler, { + metadata: { "a2a.expose": true, "a2a.tier": "partner" }, +}) +``` + +```python +# Python worker +iii.register_function( + "pricing::quote", + handler, + metadata={"a2a.expose": True, "a2a.tier": "partner"}, +) +``` + +### Hard floor (never exposed) + +Even with `--expose-all`, these namespaces never appear as agent-card +skills and cannot be invoked via `message/send`: + +| Prefix | Why | +|---|---| +| `engine::*` | iii engine internals | +| `state::*` | KV plumbing | +| `stream::*` | channel plumbing | +| `iii.*` | SDK callbacks | +| `a2a::*` | this worker's own JSON-RPC entry | + +### CLI flags + +``` +--engine-url WebSocket URL of the iii engine (default ws://localhost:49134) +--expose-all Ignore the a2a.expose gate (dev only). Hard floor still applies. +--tier Show only functions whose a2a.tier metadata equals . + E.g. `--tier partner` for B2B surfaces, `--tier public` for open. +--base-url Public origin advertised in the agent card. The card is served + at /a2a. Default: http://localhost:3111 +--debug Verbose logging +``` + +## Function resolution inside `message/send` + +1. **Data part** with `{ "function_id": "foo::bar", "payload": {...} }` — + direct invocation. +2. **Text part** beginning with `foo::bar ` — first token is the + function id; rest is parsed as the payload. +3. Anything else — task fails with "No function_id found". + +Before dispatch, the resolved `function_id` is re-checked against the +same exposure gate as `tools/list`. Hard-floor namespaces reject +unconditionally. + +## Task model + +Tasks are persisted in engine state (`a2a:tasks` scope). Terminal states +(`Completed`, `Canceled`, `Failed`, `Rejected`) are idempotent — repeated +`message/send` on the same `task_id` returns the stored result rather +than re-invoking the function. Mid-flight cancel is honoured: if a +`tasks/cancel` lands while a function call is in progress, the result is +discarded and the task keeps its `Canceled` state. + +## Not implemented + +- `message/stream`, `tasks/resubscribe` (streaming) +- Push notifications + +Returns JSON-RPC errors `-32004` and `-32003` respectively. + +## Example: multi-tier partner API + +One worker, three audiences. + +```rust +iii.register_function_with( + RegisterFunctionMessage { + id: "pricing::public_quote".into(), + metadata: Some(json!({ "a2a.expose": true, "a2a.tier": "public" })), + ..Default::default() + }, + public_quote_handler, +); + +iii.register_function_with( + RegisterFunctionMessage { + id: "pricing::partner_quote".into(), + metadata: Some(json!({ "a2a.expose": true, "a2a.tier": "partner" })), + ..Default::default() + }, + partner_quote_handler, +); + +iii.register_function_with( + RegisterFunctionMessage { + id: "pricing::internal_cost".into(), + metadata: Some(json!({ "a2a.expose": true, "a2a.tier": "ops" })), + ..Default::default() + }, + internal_cost_handler, +); +``` + +Three a2a workers behind three different auth proxies, each with a +different `--tier`, advertise three different agent cards. diff --git a/a2a/src/handler.rs b/a2a/src/handler.rs index 83bbfe5b45..2ebadbd288 100644 --- a/a2a/src/handler.rs +++ b/a2a/src/handler.rs @@ -13,20 +13,67 @@ fn has_metadata_flag(f: &iii_sdk::FunctionInfo, key: &str) -> bool { .unwrap_or(false) } -async fn is_function_exposed(iii: &III, function_id: &str, expose_all: bool) -> bool { - if expose_all { - return true; +fn metadata_string(f: &iii_sdk::FunctionInfo, key: &str) -> Option { + f.metadata + .as_ref() + .and_then(|m| m.get(key)) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +// Infra namespaces that never appear in the agent card, even under +// --expose-all. Mirrors the mcp worker's ALWAYS_HIDDEN_PREFIXES — surfacing +// `state::*` / `engine::*` / the a2a worker's own jsonrpc entry as "skills" +// is categorically not a cross-agent surface. +pub const ALWAYS_HIDDEN_PREFIXES: &[&str] = &["engine::", "state::", "stream::", "iii.", "a2a::"]; + +fn is_always_hidden(function_id: &str) -> bool { + ALWAYS_HIDDEN_PREFIXES + .iter() + .any(|p| function_id.starts_with(p)) +} + +#[derive(Debug, Clone)] +pub struct ExposureConfig { + pub expose_all: bool, + pub tier: Option, +} + +impl ExposureConfig { + pub fn new(expose_all: bool, tier: Option) -> Self { + Self { expose_all, tier } + } +} + +fn is_exposed(f: &iii_sdk::FunctionInfo, cfg: &ExposureConfig) -> bool { + if is_always_hidden(&f.function_id) { + return false; + } + if !cfg.expose_all && !has_metadata_flag(f, "a2a.expose") { + return false; + } + if let Some(want_tier) = &cfg.tier { + match metadata_string(f, "a2a.tier") { + Some(got) if &got == want_tier => {} + _ => return false, + } + } + true +} + +async fn is_function_exposed(iii: &III, function_id: &str, cfg: &ExposureConfig) -> bool { + if is_always_hidden(function_id) { + return false; } match iii.list_functions().await { Ok(fns) => fns .iter() - .any(|f| f.function_id == function_id && has_metadata_flag(f, "a2a.expose")), + .find(|f| f.function_id == function_id) + .map(|f| is_exposed(f, cfg)) + .unwrap_or(false), Err(e) => { - // Log the registry error distinctly so an engine outage doesn't - // surface to callers as "not exposed" (a policy failure). Returning - // false here still denies access — fail closed is the right default - // for a security gate — but the log lets operators tell the two - // cases apart. + // Log registry errors distinctly so an engine outage doesn't + // look like a policy denial. Still fail closed. tracing::error!( error = %e, function_id = %function_id, @@ -37,9 +84,9 @@ async fn is_function_exposed(iii: &III, function_id: &str, expose_all: bool) -> } } -pub fn register(iii: &III, expose_all: bool, base_url: String) { +pub fn register(iii: &III, exposure: ExposureConfig, base_url: String) { let iii_card = iii.clone(); - let card_expose_all = expose_all; + let card_cfg = exposure.clone(); let card_base_url = base_url.clone(); iii.register_function_with( RegisterFunctionMessage { @@ -52,9 +99,10 @@ pub fn register(iii: &III, expose_all: bool, base_url: String) { }, move |_input: Value| { let iii_inner = iii_card.clone(); + let cfg = card_cfg.clone(); let base = card_base_url.clone(); async move { - let card = build_agent_card(&iii_inner, card_expose_all, &base).await; + let card = build_agent_card(&iii_inner, &cfg, &base).await; Ok(json!({ "status_code": 200, "headers": { "content-type": "application/json" }, @@ -65,7 +113,7 @@ pub fn register(iii: &III, expose_all: bool, base_url: String) { ); let iii_rpc = iii.clone(); - let rpc_expose_all = expose_all; + let rpc_cfg = exposure; iii.register_function_with( RegisterFunctionMessage { id: "a2a::jsonrpc".to_string(), @@ -80,6 +128,7 @@ pub fn register(iii: &III, expose_all: bool, base_url: String) { }, move |input: Value| { let iii_inner = iii_rpc.clone(); + let cfg = rpc_cfg.clone(); async move { let body = if let Some(b) = input.get("body") { b.clone() @@ -98,7 +147,7 @@ pub fn register(iii: &III, expose_all: bool, base_url: String) { } }; - let response = handle_a2a_request(&iii_inner, request, rpc_expose_all).await; + let response = handle_a2a_request(&iii_inner, request, &cfg).await; Ok(json!({ "status_code": 200, @@ -130,11 +179,11 @@ pub fn register(iii: &III, expose_all: bool, base_url: String) { tracing::info!("A2A registered: GET /.well-known/agent-card.json, POST /a2a"); } -async fn build_agent_card(iii: &III, expose_all: bool, base_url: &str) -> AgentCard { +async fn build_agent_card(iii: &III, cfg: &ExposureConfig, base_url: &str) -> AgentCard { let skills = match iii.list_functions().await { Ok(fns) => fns .iter() - .filter(|f| expose_all || has_metadata_flag(f, "a2a.expose")) + .filter(|f| is_exposed(f, cfg)) .map(|f| AgentSkill { id: f.function_id.clone(), name: f @@ -177,10 +226,10 @@ async fn build_agent_card(iii: &III, expose_all: bool, base_url: &str) -> AgentC } } -async fn handle_a2a_request(iii: &III, request: A2ARequest, expose_all: bool) -> A2AResponse { +async fn handle_a2a_request(iii: &III, request: A2ARequest, cfg: &ExposureConfig) -> A2AResponse { let id = request.id.clone(); match request.method.as_str() { - "message/send" | "SendMessage" => handle_send(iii, id, request.params, expose_all).await, + "message/send" | "SendMessage" => handle_send(iii, id, request.params, cfg).await, "tasks/get" | "GetTask" => handle_get(iii, id, request.params).await, "tasks/cancel" | "CancelTask" => handle_cancel(iii, id, request.params).await, "tasks/list" | "ListTasks" => handle_list(iii, id).await, @@ -297,7 +346,7 @@ async fn handle_send( iii: &III, id: Option, params: Option, - expose_all: bool, + cfg: &ExposureConfig, ) -> A2AResponse { let params: SendMessageParams = match params { Some(p) => match serde_json::from_value(p) { @@ -382,7 +431,7 @@ async fn handle_send( } let fn_name = function_id.clone(); - if !is_function_exposed(iii, &function_id, expose_all).await { + if !is_function_exposed(iii, &function_id, cfg).await { task.status = TaskStatus { state: TaskState::Failed, message: Some(Message { @@ -549,10 +598,7 @@ fn resolve_function(message: &Message) -> (String, Value) { // text like "please run orders::process" would resolve the function_id // to "please", then fail with a confusing not-exposed error. let text = text.trim(); - let first_token = text - .split(char::is_whitespace) - .next() - .unwrap_or(""); + let first_token = text.split(char::is_whitespace).next().unwrap_or(""); if first_token.contains("::") { let rest = text[first_token.len()..].trim_start(); if !rest.is_empty() { diff --git a/a2a/src/main.rs b/a2a/src/main.rs index d3a3c0a8d2..aab8349da9 100644 --- a/a2a/src/main.rs +++ b/a2a/src/main.rs @@ -18,10 +18,19 @@ struct Args { #[arg( long, - help = "Expose all functions as skills (ignore a2a.expose metadata)" + help = "Expose all functions as skills (ignore a2a.expose metadata). \ + Infra namespaces (engine::*, state::*, stream::*, iii.*, a2a::*) \ + stay hidden even with this flag." )] expose_all: bool, + #[arg( + long, + help = "Show only functions whose `a2a.tier` metadata equals this value \ + (e.g. `user`, `agent`, `ops`). When unset, tier filtering is off." + )] + tier: Option, + #[arg( long, default_value = "http://localhost:3111", @@ -49,7 +58,8 @@ async fn main() -> anyhow::Result<()> { let iii = register_worker(&args.engine_url, InitOptions::default()); - handler::register(&iii, args.expose_all, args.base_url); + let exposure = handler::ExposureConfig::new(args.expose_all, args.tier.clone()); + handler::register(&iii, exposure, args.base_url); tracing::info!("A2A endpoints registered on engine port. Ctrl+C to stop."); tokio::signal::ctrl_c().await?; diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000000..16f15c2c9e --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,147 @@ +# iii-mcp + +Model Context Protocol surface for the iii engine. Speaks MCP JSON-RPC on +two transports: + +- **stdio** (default): for Claude Desktop, Cursor, MCP Inspector launching + the binary directly. +- **Streamable HTTP**: `POST /mcp` on the engine's HTTP trigger port. + Enable with `--no-stdio`. + +## Exposure model + +Functions registered on the engine are **invisible to MCP clients by +default**. Authors opt in per-function. + +### Opt-in metadata + +```rust +// Rust worker +iii.register_function_with( + RegisterFunctionMessage { + id: "eval::metrics".into(), + description: Some("P50/P95/P99 latency + error rate".into()), + metadata: Some(json!({ + "mcp.expose": true, + "mcp.tier": "agent", // optional — free-form string + })), + ..Default::default() + }, + handler, +); +``` + +```ts +// Node worker (iii-sdk 0.11.3+) +iii.registerFunction("eval::metrics", handler, { + metadata: { "mcp.expose": true, "mcp.tier": "agent" }, +}) +``` + +```python +# Python worker +iii.register_function( + "eval::metrics", + handler, + metadata={"mcp.expose": True, "mcp.tier": "agent"}, +) +``` + +### Hard floor (never exposed) + +Even with `--expose-all`, these namespaces are **always** filtered out: + +| Prefix | Why | +|---|---| +| `engine::*` | iii engine internals | +| `state::*` | KV plumbing, not an agent tool | +| `stream::*` | channel plumbing | +| `iii.*` | SDK internals (callback functions) | +| `mcp::*` | this worker's own entry point | + +### CLI flags + +``` +--engine-url WebSocket URL of the iii engine (default ws://localhost:49134) +--no-stdio Skip stdio, run HTTP-only +--expose-all Ignore the mcp.expose gate (dev only). Hard floor still applies. +--no-builtins Hide the 6 built-in management tools from tools/list. + Default: on for HTTP, off for stdio. +--tier Show only functions whose mcp.tier metadata equals . + E.g. `--tier user` for end-user Claude Desktop config, + `--tier agent` for an agent client, `--tier ops` for dashboards. +--debug Verbose logging +``` + +## Built-in management tools + +`iii-mcp` emits 6 built-in tools that let an MCP client drive an iii engine: + +- `iii_worker_register` — spawn a Node/Python worker from source +- `iii_worker_stop` — kill a spawned worker +- `iii_trigger_register` / `iii_trigger_unregister` — attach or detach + HTTP/cron/queue triggers +- `iii_trigger_void` — fire-and-forget invocation +- `iii_trigger_enqueue` — enqueue to a named queue + +**Stdio transport keeps these visible by default.** HTTP transport hides +them by default because worker spawning requires the stdio-attached +process — the tools would error on invocation anyway. Flip with +`--no-builtins=false` if a specific deploy needs them over HTTP. + +## Invocation path + +`tools/call` with name `foo__bar` → +1. Reverse-mangle to `foo::bar`. +2. Hard-floor check: reject if prefix matches `ALWAYS_HIDDEN_PREFIXES`. +3. Re-check the function actually has `mcp.expose: true` (and optional + tier match) — `tools/list` snapshots can go stale. +4. `iii.trigger(function_id, payload)` with the arguments object. + +Result is wrapped as `{ content: [{ type: "text", text: ... }] }` per MCP +spec. Errors surface as `isError: true`. + +## Example: minimal agent-facing config + +One worker that exposes two agent-facing functions and one ops-only function: + +```rust +// user-facing +iii.register_function_with( + RegisterFunctionMessage { + id: "reports::weekly".into(), + metadata: Some(json!({ "mcp.expose": true, "mcp.tier": "user" })), + ..Default::default() + }, + weekly_handler, +); + +// agent-only planning tool +iii.register_function_with( + RegisterFunctionMessage { + id: "reports::plan".into(), + metadata: Some(json!({ "mcp.expose": true, "mcp.tier": "agent" })), + ..Default::default() + }, + plan_handler, +); + +// ops dashboards — not exposed to user/agent tiers +iii.register_function_with( + RegisterFunctionMessage { + id: "reports::rebuild_cache".into(), + metadata: Some(json!({ "mcp.expose": true, "mcp.tier": "ops" })), + ..Default::default() + }, + rebuild_handler, +); +``` + +Claude Desktop user config: + +```json +{ "mcpServers": { "iii": { "command": "iii-mcp", "args": ["--tier", "user", "--no-builtins"] } } } +``` + +Agent client hits the engine over HTTP with `--tier agent`. Ops dashboard +uses `--tier ops`. Same worker, three audiences. diff --git a/mcp/src/handler.rs b/mcp/src/handler.rs index 57925ba646..abc6b5215d 100644 --- a/mcp/src/handler.rs +++ b/mcp/src/handler.rs @@ -25,6 +25,70 @@ fn has_metadata_flag(f: &FunctionInfo, key: &str) -> bool { .unwrap_or(false) } +fn metadata_string(f: &FunctionInfo, key: &str) -> Option { + f.metadata + .as_ref() + .and_then(|m| m.get(key)) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +// Prefixes that are NEVER surfaced as MCP tools, even under --expose-all. +// These are iii-engine internals — surfacing `state::set` or `engine::*` as +// an MCP tool lets an agent poke at engine plumbing, which is categorically +// not an agent-facing surface. Matches the same carve-out the `agent` +// worker enforces via DEFAULT_EXCLUDED_PREFIXES. +pub const ALWAYS_HIDDEN_PREFIXES: &[&str] = &[ + "engine::", "state::", "stream::", "iii.", + // The MCP worker's own JSON-RPC entry shouldn't recurse into itself. + "mcp::", +]; + +fn is_always_hidden(function_id: &str) -> bool { + ALWAYS_HIDDEN_PREFIXES + .iter() + .any(|p| function_id.starts_with(p)) +} + +#[derive(Debug, Clone)] +pub struct ExposureConfig { + /// Ignore the `mcp.expose` metadata gate (dev only). + pub expose_all: bool, + /// Skip the 6 built-in management tools in tools/list. Default true for + /// HTTP transport where worker-management isn't applicable anyway. + pub no_builtins: bool, + /// Optional tier filter. When set, only functions whose `mcp.tier` + /// metadata string equals this value survive the filter. + pub tier: Option, +} + +impl ExposureConfig { + pub fn new(expose_all: bool, no_builtins: bool, tier: Option) -> Self { + Self { + expose_all, + no_builtins, + tier, + } + } +} + +fn is_function_exposed(f: &FunctionInfo, cfg: &ExposureConfig) -> bool { + // Hard floor: infra prefixes never surface, even with --expose-all. + if is_always_hidden(&f.function_id) { + return false; + } + if !cfg.expose_all && !has_metadata_flag(f, "mcp.expose") { + return false; + } + if let Some(want_tier) = &cfg.tier { + match metadata_string(f, "mcp.tier") { + Some(got) if &got == want_tier => {} + _ => return false, + } + } + true +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JsonRpcResponse { pub jsonrpc: String, @@ -68,7 +132,7 @@ impl JsonRpcResponse { pub struct McpHandler { initialized: AtomicBool, iii: III, - expose_all: bool, + exposure: ExposureConfig, worker_manager: WorkerManager, triggers: Mutex>, notification_rx: tokio::sync::Mutex>, @@ -76,7 +140,7 @@ pub struct McpHandler { } impl McpHandler { - pub fn new(iii: III, engine_url: String, expose_all: bool) -> Self { + pub fn new(iii: III, engine_url: String, exposure: ExposureConfig) -> Self { let (tx, notification_rx) = mpsc::channel(16); let guard = iii.on_functions_available(move |_| { @@ -91,7 +155,7 @@ impl McpHandler { Self { initialized: AtomicBool::new(false), iii, - expose_all, + exposure, worker_manager: WorkerManager::new(engine_url), triggers: Mutex::new(HashMap::new()), notification_rx: tokio::sync::Mutex::new(notification_rx), @@ -136,9 +200,15 @@ impl McpHandler { "initialize" => Ok(initialize_result()), "ping" => Ok(json!({})), "tools/list" => self.tools_list().await.map_err(|e| (INTERNAL_ERROR, e)), - "tools/call" => self.tools_call(params).await.map_err(|e| (INVALID_PARAMS, e)), + "tools/call" => self + .tools_call(params) + .await + .map_err(|e| (INVALID_PARAMS, e)), "resources/list" => Ok(self.resources_list()), - "resources/read" => self.resources_read(params).await.map_err(|e| (INVALID_PARAMS, e)), + "resources/read" => self + .resources_read(params) + .await + .map_err(|e| (INVALID_PARAMS, e)), "resources/templates/list" => Ok(json!({ "resourceTemplates": [] })), "prompts/list" => Ok(prompts::list()), "prompts/get" => Ok(prompts::get(params)), @@ -152,12 +222,16 @@ impl McpHandler { } async fn tools_list(&self) -> Result { - let mut tools = builtin_tools(); + let mut tools = if self.exposure.no_builtins { + Vec::new() + } else { + builtin_tools() + }; if let Ok(functions) = self.iii.list_functions().await { tools.extend( functions .iter() - .filter(|f| self.expose_all || has_metadata_flag(f, "mcp.expose")) + .filter(|f| is_function_exposed(f, &self.exposure)) .map(function_to_tool), ); } @@ -237,17 +311,24 @@ impl McpHandler { } let function_id = params.name.replace("__", "::"); - if !self.expose_all { - if let Ok(fns) = self.iii.list_functions().await { - let exposed = fns.iter().any(|f| { - f.function_id == function_id && has_metadata_flag(f, "mcp.expose") - }); - if !exposed { - return Ok(tool_error(&format!( - "Function '{}' is not exposed via mcp.expose metadata", - function_id - ))); - } + // Hard floor applies before the metadata gate — a hidden infra + // function stays unreachable even when --expose-all is set. + if is_always_hidden(&function_id) { + return Ok(tool_error(&format!( + "Function '{}' is in the iii-engine internal namespace and is not exposed as an MCP tool", + function_id + ))); + } + if let Ok(fns) = self.iii.list_functions().await { + let matched = fns.iter().find(|f| f.function_id == function_id); + let exposed = matched + .map(|f| is_function_exposed(f, &self.exposure)) + .unwrap_or(false); + if !exposed { + return Ok(tool_error(&format!( + "Function '{}' is not exposed via mcp.expose metadata (or does not match the active --tier filter)", + function_id + ))); } } match self @@ -378,8 +459,9 @@ impl McpHandler { } } -pub fn register_http(iii: &III, expose_all: bool) { +pub fn register_http(iii: &III, exposure: ExposureConfig) { let iii_fn = iii.clone(); + let cfg = exposure; iii.register_function_with( RegisterFunctionMessage { id: "mcp::handler".to_string(), @@ -389,9 +471,10 @@ pub fn register_http(iii: &III, expose_all: bool) { }, move |input: Value| { let iii_inner = iii_fn.clone(); + let cfg_inner = cfg.clone(); async move { let body = input.get("body").cloned().unwrap_or(input); - let response = dispatch_http(&iii_inner, &body, expose_all).await; + let response = dispatch_http(&iii_inner, &body, &cfg_inner).await; Ok(json!({ "status_code": 200, "headers": { "content-type": "application/json" }, "body": response })) } }, @@ -414,11 +497,11 @@ fn initialize_result() -> Value { "protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": { "tools": { "listChanged": true }, "resources": { "subscribe": false, "listChanged": true }, "prompts": { "listChanged": false } }, "serverInfo": { "name": "iii-mcp", "version": env!("CARGO_PKG_VERSION") }, - "instructions": "iii-engine MCP server (SDK v0.10). Functions with metadata mcp.expose: true are exposed as tools." + "instructions": "iii-engine MCP server. Only functions with metadata `mcp.expose: true` are exposed as tools. Infra namespaces (engine::*, state::*, stream::*, iii.*, mcp::*) are always hidden. Optional `mcp.tier` metadata is filtered by the server's --tier flag." }) } -async fn dispatch_http(iii: &III, body: &Value, expose_all: bool) -> Value { +async fn dispatch_http(iii: &III, body: &Value, cfg: &ExposureConfig) -> Value { let method = body.get("method").and_then(|v| v.as_str()).unwrap_or(""); let id = body.get("id").cloned(); let params = body.get("params").cloned(); @@ -431,11 +514,18 @@ async fn dispatch_http(iii: &III, body: &Value, expose_all: bool) -> Value { "initialize" => Ok(initialize_result()), "ping" => Ok(json!({})), "tools/list" => { - let mut tools = builtin_tools(); + // HTTP transport hides builtins by default — worker/trigger + // management requires stdio, so listing them over HTTP is pure + // noise that errors on invocation anyway. + let mut tools = if cfg.no_builtins { + Vec::new() + } else { + builtin_tools() + }; if let Ok(fns) = iii.list_functions().await { tools.extend( fns.iter() - .filter(|f| expose_all || has_metadata_flag(f, "mcp.expose")) + .filter(|f| is_function_exposed(f, cfg)) .map(function_to_tool), ); } @@ -452,19 +542,27 @@ async fn dispatch_http(iii: &III, body: &Value, expose_all: bool) -> Value { None => return json!(JsonRpcResponse::error(id, INVALID_PARAMS, "Missing params")), }; match p.name.as_str() { - "iii_worker_register" | "iii_worker_stop" | "iii_trigger_register" | "iii_trigger_unregister" => { - Ok(tool_error("This tool requires stdio transport (worker/trigger management is not available over HTTP)")) - } + "iii_worker_register" + | "iii_worker_stop" + | "iii_trigger_register" + | "iii_trigger_unregister" => Ok(tool_error( + "This tool requires stdio transport (worker/trigger management is not available over HTTP)", + )), "iii_trigger_void" => { let fid = str_field(&p.arguments, "function_id"); if fid.is_empty() { Ok(tool_error("Missing required field: function_id")) } else { let payload = p.arguments.get("payload").cloned().unwrap_or(json!({})); - match iii.trigger(TriggerRequest { - function_id: fid.clone(), payload, - action: Some(TriggerAction::Void), timeout_ms: None, - }).await { + match iii + .trigger(TriggerRequest { + function_id: fid.clone(), + payload, + action: Some(TriggerAction::Void), + timeout_ms: None, + }) + .await + { Ok(_) => Ok(tool_result(&format!("Triggered (void): {}", fid))), Err(e) => Ok(tool_error(&format!("Error: {}", e))), } @@ -477,10 +575,15 @@ async fn dispatch_http(iii: &III, body: &Value, expose_all: bool) -> Value { } else { let payload = p.arguments.get("payload").cloned().unwrap_or(json!({})); let queue = str_field_or(&p.arguments, "queue", "default"); - match iii.trigger(TriggerRequest { - function_id: fid, payload, - action: Some(TriggerAction::Enqueue { queue }), timeout_ms: None, - }).await { + match iii + .trigger(TriggerRequest { + function_id: fid, + payload, + action: Some(TriggerAction::Enqueue { queue }), + timeout_ms: None, + }) + .await + { Ok(r) => Ok(tool_json(&r)), Err(e) => Ok(tool_error(&format!("Error: {}", e))), } @@ -488,26 +591,39 @@ async fn dispatch_http(iii: &III, body: &Value, expose_all: bool) -> Value { } _ => { let function_id = p.name.replace("__", "::"); - if !expose_all { - if let Ok(fns) = iii.list_functions().await { - let exposed = fns.iter().any(|f| { - f.function_id == function_id && has_metadata_flag(f, "mcp.expose") - }); - if !exposed { - return json!(JsonRpcResponse::success( - id, - tool_error(&format!( - "Function '{}' is not exposed via mcp.expose metadata", - function_id - )) - )); - } + if is_always_hidden(&function_id) { + return json!(JsonRpcResponse::success( + id, + tool_error(&format!( + "Function '{}' is in the iii-engine internal namespace and is not exposed as an MCP tool", + function_id + )) + )); + } + if let Ok(fns) = iii.list_functions().await { + let matched = fns.iter().find(|f| f.function_id == function_id); + let exposed = matched + .map(|f| is_function_exposed(f, cfg)) + .unwrap_or(false); + if !exposed { + return json!(JsonRpcResponse::success( + id, + tool_error(&format!( + "Function '{}' is not exposed via mcp.expose metadata (or does not match the active --tier filter)", + function_id + )) + )); } } - match iii.trigger(TriggerRequest { - function_id, payload: p.arguments, - action: None, timeout_ms: None, - }).await { + match iii + .trigger(TriggerRequest { + function_id, + payload: p.arguments, + action: None, + timeout_ms: None, + }) + .await + { Ok(r) => Ok(tool_json(&r)), Err(e) => Ok(tool_error(&format!("Error: {}", e))), } diff --git a/mcp/src/main.rs b/mcp/src/main.rs index aa517c5d26..7e20a3f0cc 100644 --- a/mcp/src/main.rs +++ b/mcp/src/main.rs @@ -9,6 +9,8 @@ use clap::Parser; use iii_sdk::{InitOptions, register_worker}; use tracing_subscriber::{EnvFilter, fmt, prelude::*}; +use handler::ExposureConfig; + #[derive(Parser, Debug)] #[command(name = "iii-mcp")] #[command(version)] @@ -25,9 +27,25 @@ struct Args { #[arg( long, - help = "Expose all functions as tools (ignore mcp.expose metadata)" + help = "Expose all functions as tools (ignore mcp.expose metadata). \ + Infra namespaces (engine::*, state::*, stream::*, iii.*, mcp::*) \ + stay hidden even with this flag." )] expose_all: bool, + + #[arg( + long, + help = "Hide the 6 built-in management tools (iii_worker_register, \ + iii_worker_stop, iii_trigger_*). Default: on for HTTP, off for stdio." + )] + no_builtins: bool, + + #[arg( + long, + help = "Show only functions whose `mcp.tier` metadata equals this value \ + (e.g. `user`, `agent`, `ops`). When unset, tier filtering is off." + )] + tier: Option, } #[tokio::main] @@ -49,16 +67,23 @@ async fn main() -> anyhow::Result<()> { let iii = register_worker(&args.engine_url, InitOptions::default()); - handler::register_http(&iii, args.expose_all); + // HTTP transport defaults to hiding builtins — worker/trigger management + // requires stdio, so listing those over HTTP is pure noise. stdio keeps + // the default of showing builtins (the common Claude Desktop path). + let http_no_builtins = args.no_builtins || args.no_stdio; + let http_exposure = ExposureConfig::new(args.expose_all, http_no_builtins, args.tier.clone()); + handler::register_http(&iii, http_exposure); if args.no_stdio { tracing::info!("MCP HTTP-only mode. POST /mcp on engine port. Ctrl+C to stop."); tokio::signal::ctrl_c().await?; } else { + let stdio_exposure = + ExposureConfig::new(args.expose_all, args.no_builtins, args.tier.clone()); let h = Arc::new(handler::McpHandler::new( iii, args.engine_url, - args.expose_all, + stdio_exposure, )); transport::run_stdio(h).await?; } diff --git a/mcp/src/transport.rs b/mcp/src/transport.rs index e92bf91ccf..7b44200fc0 100644 --- a/mcp/src/transport.rs +++ b/mcp/src/transport.rs @@ -61,10 +61,7 @@ pub async fn run_stdio(handler: Arc) -> anyhow::Result<()> { Ok(()) } -async fn write_json( - writer: &mut BufWriter, - v: &Value, -) -> anyhow::Result<()> { +async fn write_json(writer: &mut BufWriter, v: &Value) -> anyhow::Result<()> { let json = serde_json::to_string(v)?; writer.write_all(json.as_bytes()).await?; writer.write_all(b"\n").await?; diff --git a/mcp/src/worker_manager.rs b/mcp/src/worker_manager.rs index 5d5954274b..0c35f0c72c 100644 --- a/mcp/src/worker_manager.rs +++ b/mcp/src/worker_manager.rs @@ -109,10 +109,7 @@ impl WorkerManager { match child.try_wait() { Ok(Some(status)) => { let _ = tokio::fs::remove_dir_all(&temp_dir).await; - return Err(format!( - "Worker exited immediately with status: {}", - status - )); + return Err(format!("Worker exited immediately with status: {}", status)); } Err(e) => { let _ = tokio::fs::remove_dir_all(&temp_dir).await; From ad9aa1004065a47fd2f5de10e949544df58ca68b Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 23 Apr 2026 14:57:38 +0100 Subject: [PATCH 2/4] fix(mcp,a2a): close cross-protocol infra leak, distinct a2a hard-floor message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local integration test caught two issues: F1 (major): Under --expose-all, each protocol worker's ALWAYS_HIDDEN_PREFIXES only blocked its own namespace. mcp --expose-all surfaced a2a::agent_card + a2a::jsonrpc as MCP tools; a2a --expose-all surfaced mcp::handler as an A2A skill. Both are stateless RPC dispatchers — routing an MCP call through mcp::handler recurses into ourselves, and double-enveloping an A2A request inside MCP (or vice versa) is not a meaningful tool surface. Fix: expand both hard-floor lists to include the sibling protocol namespace. F2 (minor): A2A hard-floor rejections reused the metadata-miss message, so 'function is in engine namespace' and 'function forgot the a2a.expose flag' looked identical to callers. MCP already distinguishes them. Mirror the distinct phrasing on A2A so operators can tell a policy miss from a protocol-level block. Verification (live engine + workers, local run): - default MCP tools/list: 3 opt-in demo tools, nothing else - default A2A agent card: 3 opt-in demo skills, nothing else - --expose-all MCP: no a2a::*, no mcp::handler - --expose-all A2A: no mcp::handler, no a2a::* - --tier user: only the user-tier demo tool - tools/call on hidden function: rejected, metadata-miss message - tools/call on state:: prefix: rejected, hard-floor message - message/send on state:: prefix: rejected, distinct hard-floor message Full test matrix in the PR description. --- a2a/src/handler.rs | 28 +++++++++++++++++++++++----- mcp/src/handler.rs | 11 +++++++++-- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/a2a/src/handler.rs b/a2a/src/handler.rs index 2ebadbd288..47dbb468c2 100644 --- a/a2a/src/handler.rs +++ b/a2a/src/handler.rs @@ -25,7 +25,17 @@ fn metadata_string(f: &iii_sdk::FunctionInfo, key: &str) -> Option { // --expose-all. Mirrors the mcp worker's ALWAYS_HIDDEN_PREFIXES — surfacing // `state::*` / `engine::*` / the a2a worker's own jsonrpc entry as "skills" // is categorically not a cross-agent surface. -pub const ALWAYS_HIDDEN_PREFIXES: &[&str] = &["engine::", "state::", "stream::", "iii.", "a2a::"]; +pub const ALWAYS_HIDDEN_PREFIXES: &[&str] = &[ + "engine::", + "state::", + "stream::", + "iii.", + // Sibling protocol worker entry point. Calling `mcp::handler` via A2A + // message/send double-envelopes an MCP request inside A2A — not a + // meaningful skill. Hide symmetrically with mcp's a2a:: carve-out. + "mcp::", + "a2a::", +]; fn is_always_hidden(function_id: &str) -> bool { ALWAYS_HIDDEN_PREFIXES @@ -432,15 +442,23 @@ async fn handle_send( let fn_name = function_id.clone(); if !is_function_exposed(iii, &function_id, cfg).await { + let reason = if is_always_hidden(&function_id) { + format!( + "Function '{}' is in the iii-engine internal namespace and is not exposed as an A2A skill", + function_id + ) + } else { + format!( + "Function '{}' is not exposed via a2a.expose metadata (or does not match the active --tier filter)", + function_id + ) + }; task.status = TaskStatus { state: TaskState::Failed, message: Some(Message { message_id: msg_id(), role: MessageRole::Agent, - parts: vec![text_part(format!( - "Function '{}' is not exposed via a2a.expose metadata", - function_id - ))], + parts: vec![text_part(reason)], task_id: None, context_id: None, metadata: None, diff --git a/mcp/src/handler.rs b/mcp/src/handler.rs index abc6b5215d..910f58719a 100644 --- a/mcp/src/handler.rs +++ b/mcp/src/handler.rs @@ -39,9 +39,16 @@ fn metadata_string(f: &FunctionInfo, key: &str) -> Option { // not an agent-facing surface. Matches the same carve-out the `agent` // worker enforces via DEFAULT_EXCLUDED_PREFIXES. pub const ALWAYS_HIDDEN_PREFIXES: &[&str] = &[ - "engine::", "state::", "stream::", "iii.", - // The MCP worker's own JSON-RPC entry shouldn't recurse into itself. + "engine::", + "state::", + "stream::", + "iii.", + // Protocol-worker entry points are stateless RPC dispatchers. Routing + // an MCP tools/call through `mcp::handler` recurses into ourselves; + // through `a2a::jsonrpc` double-envelopes an A2A request inside MCP. + // Neither is a useful tool surface. Hide both even under --expose-all. "mcp::", + "a2a::", ]; fn is_always_hidden(function_id: &str) -> bool { From f6d3801554eff026f2342f37d2e1e6f7eb8c4683 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 23 Apr 2026 15:20:46 +0100 Subject: [PATCH 3/4] fix(mcp): advertise real MCP protocol version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handler hardcoded protocolVersion: '2025-11-25' — a future date that no real MCP client recognizes. MCP Inspector rejected it with: Failed to connect to MCP server: Server's protocol version is not supported: 2025-11-25 Real spec revisions are stamped dates from spec.modelcontextprotocol.io. Move to '2025-06-18' (current published spec at the time of this commit). Bump when moving to a newer one. Caught by live Inspector smoke test over Streamable HTTP. Post-fix: Inspector lists tools, calls exposed tools, and gets the expected rejection on hidden tools on both stdio and HTTP transports. --- mcp/src/handler.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mcp/src/handler.rs b/mcp/src/handler.rs index 910f58719a..5b0e87e129 100644 --- a/mcp/src/handler.rs +++ b/mcp/src/handler.rs @@ -12,7 +12,13 @@ use tokio::sync::{Mutex, mpsc}; use crate::prompts; use crate::worker_manager::{WorkerCreateParams, WorkerManager, WorkerStopParams}; -const MCP_PROTOCOL_VERSION: &str = "2025-11-25"; +// Current published MCP spec revision. Bump when moving to a newer one. +// Real spec versions are date-stamps from +// https://spec.modelcontextprotocol.io — not arbitrary dates. The earlier +// "2025-11-25" value in this file was a future date that no real client +// recognized; MCP Inspector rejected it with "protocol version is not +// supported" until this was corrected. +const MCP_PROTOCOL_VERSION: &str = "2025-06-18"; const INTERNAL_ERROR: i32 = -32603; const INVALID_PARAMS: i32 = -32602; const METHOD_NOT_FOUND: i32 = -32601; From e359bb3ead6cbddd2f9e24b579167875a03221de Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 23 Apr 2026 15:28:28 +0100 Subject: [PATCH 4/4] =?UTF-8?q?fix(mcp,a2a):=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20hard-floor=20coverage,=20fail-closed=20policy,=20bu?= =?UTF-8?q?iltin=20invocation=20gate,=20resource=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 (hard floor gap). Both workers' ALWAYS_HIDDEN_PREFIXES listed 'iii.' but missed 'iii::'. The SDK uses both notations — callback-style 'iii.on_functions_available.' AND namespace-style 'iii::durable::publish', 'iii::config'. Only the dot form was blocked; double-colon SDK internals slipped past the hard floor under --expose-all. Add both to the slice on both workers. F2 (builtin invocation gate). --no-builtins hid the 6 management tools from tools/list but tools_call still dispatched them by name. A client that knew the name could call iii_worker_register on a server that claimed it had no such tool, bypassing policy. Add BUILTIN_TOOL_NAMES + is_builtin_tool helper; gate tools/call (stdio AND HTTP dispatch) on cfg.no_builtins && is_builtin_tool(name). F3 (fail-open on list_functions error). Both stdio tools_call and HTTP dispatch_http used 'if let Ok(fns)' — when list_functions errored, the block was skipped and the call fell through to iii.trigger. An engine outage hid the policy gate. Flip to fail-closed: list_functions error returns an explicit tool_error 'exposure could not be verified; denying call'. F4 (HTTP builtins default). Previous main.rs computed http_no_builtins = args.no_builtins || args.no_stdio, which meant stdio-default mode (the common case) registered HTTP WITH builtins. Flip to hide by default: http_no_builtins = args.no_builtins || !args.http_builtins. Add --http-builtins opt-in flag for deploys that want builtins over HTTP. --no-builtins always wins. F5 (resource surface bypass). resources_read 'iii://functions' handed out the raw list_functions() output. A client could skip tools/list and read the resource to enumerate hidden functions — bypassing mcp.expose, tier, and hard floor. Apply is_function_exposed filter inside resources_read so the resource surface matches the tool surface. Doc fixes: - mcp/README: correct the bogus --no-builtins=false example; document --http-builtins; expand hard-floor table to show iii.* AND iii::* plus a2a::*. - a2a/README: add 'text' language tag to the CLI fenced block (MD040); expand hard-floor table to show iii.* AND iii::* plus mcp::*. Verification (live engine + workers): - HTTP default: 3 opt-in demo tools, no builtins - HTTP --http-builtins: 3 demo + 6 builtins - --no-builtins + tools/call iii_trigger_void: rejected with 'disabled on this server' message - stdio resources/read iii://functions: demo::exposed present, demo::hidden + state::bogus_demo + engine::* + iii.* + iii::* all absent - cargo fmt + clippy -D warnings clean on both workers --- a2a/README.md | 5 +- a2a/src/handler.rs | 12 ++--- mcp/README.md | 17 ++++--- mcp/src/handler.rs | 114 ++++++++++++++++++++++++++++++++++++--------- mcp/src/main.rs | 22 +++++++-- 5 files changed, 130 insertions(+), 40 deletions(-) diff --git a/a2a/README.md b/a2a/README.md index 753eafabc3..996f732b47 100644 --- a/a2a/README.md +++ b/a2a/README.md @@ -57,12 +57,13 @@ skills and cannot be invoked via `message/send`: | `engine::*` | iii engine internals | | `state::*` | KV plumbing | | `stream::*` | channel plumbing | -| `iii.*` | SDK callbacks | +| `iii.*` / `iii::*` | SDK callbacks and namespaced SDK APIs | +| `mcp::*` | sibling protocol worker's entry point | | `a2a::*` | this worker's own JSON-RPC entry | ### CLI flags -``` +```text --engine-url WebSocket URL of the iii engine (default ws://localhost:49134) --expose-all Ignore the a2a.expose gate (dev only). Hard floor still applies. --tier Show only functions whose a2a.tier metadata equals . diff --git a/a2a/src/handler.rs b/a2a/src/handler.rs index 47dbb468c2..3d0b6e1bcc 100644 --- a/a2a/src/handler.rs +++ b/a2a/src/handler.rs @@ -26,15 +26,15 @@ fn metadata_string(f: &iii_sdk::FunctionInfo, key: &str) -> Option { // `state::*` / `engine::*` / the a2a worker's own jsonrpc entry as "skills" // is categorically not a cross-agent surface. pub const ALWAYS_HIDDEN_PREFIXES: &[&str] = &[ - "engine::", - "state::", - "stream::", - "iii.", + "engine::", "state::", "stream::", + // SDK internals come in two notations — callback-style + // `iii.on_functions_available.` and namespace-style + // `iii::durable::publish`, `iii::config`. Match both. + "iii.", "iii::", // Sibling protocol worker entry point. Calling `mcp::handler` via A2A // message/send double-envelopes an MCP request inside A2A — not a // meaningful skill. Hide symmetrically with mcp's a2a:: carve-out. - "mcp::", - "a2a::", + "mcp::", "a2a::", ]; fn is_always_hidden(function_id: &str) -> bool { diff --git a/mcp/README.md b/mcp/README.md index 16f15c2c9e..fe93dd5f43 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -56,17 +56,19 @@ Even with `--expose-all`, these namespaces are **always** filtered out: | `engine::*` | iii engine internals | | `state::*` | KV plumbing, not an agent tool | | `stream::*` | channel plumbing | -| `iii.*` | SDK internals (callback functions) | +| `iii.*` / `iii::*` | SDK internals — callback functions and namespaced SDK APIs | | `mcp::*` | this worker's own entry point | +| `a2a::*` | sibling protocol worker's entry point | ### CLI flags -``` +```text --engine-url WebSocket URL of the iii engine (default ws://localhost:49134) --no-stdio Skip stdio, run HTTP-only --expose-all Ignore the mcp.expose gate (dev only). Hard floor still applies. ---no-builtins Hide the 6 built-in management tools from tools/list. - Default: on for HTTP, off for stdio. +--no-builtins Hide the 6 built-in management tools on stdio. Also hidden over HTTP by default. +--http-builtins Opt in to exposing built-ins on HTTP (default: hidden). + --no-builtins always wins and hides them everywhere. --tier Show only functions whose mcp.tier metadata equals . E.g. `--tier user` for end-user Claude Desktop config, `--tier agent` for an agent client, `--tier ops` for dashboards. @@ -86,8 +88,11 @@ Even with `--expose-all`, these namespaces are **always** filtered out: **Stdio transport keeps these visible by default.** HTTP transport hides them by default because worker spawning requires the stdio-attached -process — the tools would error on invocation anyway. Flip with -`--no-builtins=false` if a specific deploy needs them over HTTP. +process — the tools would error on invocation anyway. Opt in per-deploy +with `--http-builtins` to advertise them over HTTP as well (they will +still error on `iii_worker_*` and `iii_trigger_register*` because those +paths need the attached process). `--no-builtins` always wins and hides +them on both transports. ## Invocation path diff --git a/mcp/src/handler.rs b/mcp/src/handler.rs index 5b0e87e129..d4f1f4262d 100644 --- a/mcp/src/handler.rs +++ b/mcp/src/handler.rs @@ -45,16 +45,16 @@ fn metadata_string(f: &FunctionInfo, key: &str) -> Option { // not an agent-facing surface. Matches the same carve-out the `agent` // worker enforces via DEFAULT_EXCLUDED_PREFIXES. pub const ALWAYS_HIDDEN_PREFIXES: &[&str] = &[ - "engine::", - "state::", - "stream::", - "iii.", + "engine::", "state::", "stream::", + // SDK internals come in two notations — callback-style + // `iii.on_functions_available.` and namespace-style + // `iii::durable::publish`, `iii::config`. Match both. + "iii.", "iii::", // Protocol-worker entry points are stateless RPC dispatchers. Routing // an MCP tools/call through `mcp::handler` recurses into ourselves; // through `a2a::jsonrpc` double-envelopes an A2A request inside MCP. // Neither is a useful tool surface. Hide both even under --expose-all. - "mcp::", - "a2a::", + "mcp::", "a2a::", ]; fn is_always_hidden(function_id: &str) -> bool { @@ -254,6 +254,16 @@ impl McpHandler { async fn tools_call(&self, params: Option) -> Result { let params: CallParams = parse(params)?; + // When builtins are hidden from tools/list, also refuse to dispatch + // them by name. Otherwise a client could invoke `iii_worker_register` + // on a server that claimed it had no such tool — bypassing the policy. + if self.exposure.no_builtins && is_builtin_tool(¶ms.name) { + return Ok(tool_error(&format!( + "Tool '{}' is disabled on this server (--no-builtins is set)", + params.name + ))); + } + match params.name.as_str() { "iii_worker_register" => { let p: WorkerCreateParams = parse(Some(params.arguments))?; @@ -332,17 +342,29 @@ impl McpHandler { function_id ))); } - if let Ok(fns) = self.iii.list_functions().await { - let matched = fns.iter().find(|f| f.function_id == function_id); - let exposed = matched - .map(|f| is_function_exposed(f, &self.exposure)) - .unwrap_or(false); - if !exposed { + // Fail closed: if we can't verify exposure (engine unreachable, + // list_functions errored), refuse the call. The earlier code + // silently dropped the check on Err and handed through to + // iii.trigger — that meant an engine outage masked the policy gate. + let fns = match self.iii.list_functions().await { + Ok(fns) => fns, + Err(e) => { + tracing::error!(error = %e, function_id = %function_id, "list_functions failed; denying call"); return Ok(tool_error(&format!( - "Function '{}' is not exposed via mcp.expose metadata (or does not match the active --tier filter)", + "Function '{}' exposure could not be verified (engine list_functions error); denying call", function_id ))); } + }; + let matched = fns.iter().find(|f| f.function_id == function_id); + let exposed = matched + .map(|f| is_function_exposed(f, &self.exposure)) + .unwrap_or(false); + if !exposed { + return Ok(tool_error(&format!( + "Function '{}' is not exposed via mcp.expose metadata (or does not match the active --tier filter)", + function_id + ))); } match self .iii @@ -379,13 +401,22 @@ impl McpHandler { let p: P = parse(params)?; let (text, mime) = match p.uri.as_str() { "iii://functions" => { + // Apply the same exposure filter that tools/list uses. + // Without this, a client could skip tools/list and read + // the resource to enumerate hidden functions — bypassing + // the mcp.expose gate, the tier filter, and the hard + // floor. resource surface must match tool surface. let v = self .iii .list_functions() .await .map_err(|e| format!("{}", e))?; + let filtered: Vec<_> = v + .into_iter() + .filter(|f| is_function_exposed(f, &self.exposure)) + .collect(); ( - serde_json::to_string_pretty(&v).unwrap_or_else(|_| "[]".into()), + serde_json::to_string_pretty(&filtered).unwrap_or_else(|_| "[]".into()), "application/json", ) } @@ -554,6 +585,18 @@ async fn dispatch_http(iii: &III, body: &Value, cfg: &ExposureConfig) -> Value { }, None => return json!(JsonRpcResponse::error(id, INVALID_PARAMS, "Missing params")), }; + // When --no-builtins is active, the six management tools are + // also not invocable by name. tools/list hides them; this + // matches the listing shape at call time. + if cfg.no_builtins && is_builtin_tool(&p.name) { + return json!(JsonRpcResponse::success( + id, + tool_error(&format!( + "Tool '{}' is disabled on this server (--no-builtins is set)", + p.name + )) + )); + } match p.name.as_str() { "iii_worker_register" | "iii_worker_stop" @@ -613,20 +656,33 @@ async fn dispatch_http(iii: &III, body: &Value, cfg: &ExposureConfig) -> Value { )) )); } - if let Ok(fns) = iii.list_functions().await { - let matched = fns.iter().find(|f| f.function_id == function_id); - let exposed = matched - .map(|f| is_function_exposed(f, cfg)) - .unwrap_or(false); - if !exposed { + // Fail closed on list_functions error — see stdio + // tools_call for the reasoning. + let fns = match iii.list_functions().await { + Ok(fns) => fns, + Err(e) => { + tracing::error!(error = %e, function_id = %function_id, "list_functions failed; denying call"); return json!(JsonRpcResponse::success( id, tool_error(&format!( - "Function '{}' is not exposed via mcp.expose metadata (or does not match the active --tier filter)", + "Function '{}' exposure could not be verified (engine list_functions error); denying call", function_id )) )); } + }; + let matched = fns.iter().find(|f| f.function_id == function_id); + let exposed = matched + .map(|f| is_function_exposed(f, cfg)) + .unwrap_or(false); + if !exposed { + return json!(JsonRpcResponse::success( + id, + tool_error(&format!( + "Function '{}' is not exposed via mcp.expose metadata (or does not match the active --tier filter)", + function_id + )) + )); } match iii .trigger(TriggerRequest { @@ -677,6 +733,22 @@ fn function_to_tool(f: &FunctionInfo) -> McpTool { } } +// Single source of truth for the built-in tool name list. Both the +// listing path (builtin_tools()) and the invocation gate +// (tools_call --no-builtins check) consult this. +const BUILTIN_TOOL_NAMES: &[&str] = &[ + "iii_worker_register", + "iii_worker_stop", + "iii_trigger_register", + "iii_trigger_unregister", + "iii_trigger_void", + "iii_trigger_enqueue", +]; + +fn is_builtin_tool(name: &str) -> bool { + BUILTIN_TOOL_NAMES.contains(&name) +} + fn builtin_tools() -> Vec { vec![ McpTool { diff --git a/mcp/src/main.rs b/mcp/src/main.rs index 7e20a3f0cc..d153cdcee0 100644 --- a/mcp/src/main.rs +++ b/mcp/src/main.rs @@ -36,10 +36,19 @@ struct Args { #[arg( long, help = "Hide the 6 built-in management tools (iii_worker_register, \ - iii_worker_stop, iii_trigger_*). Default: on for HTTP, off for stdio." + iii_worker_stop, iii_trigger_*) from stdio. HTTP hides them \ + by default — see --http-builtins to opt in." )] no_builtins: bool, + #[arg( + long, + help = "Opt in to exposing built-in management tools on the HTTP \ + transport. Default: HTTP hides them (worker/trigger management \ + requires stdio-attached process anyway)." + )] + http_builtins: bool, + #[arg( long, help = "Show only functions whose `mcp.tier` metadata equals this value \ @@ -67,10 +76,13 @@ async fn main() -> anyhow::Result<()> { let iii = register_worker(&args.engine_url, InitOptions::default()); - // HTTP transport defaults to hiding builtins — worker/trigger management - // requires stdio, so listing those over HTTP is pure noise. stdio keeps - // the default of showing builtins (the common Claude Desktop path). - let http_no_builtins = args.no_builtins || args.no_stdio; + // HTTP transport hides builtins by default — worker/trigger management + // needs the stdio-attached process anyway, so exposing them over HTTP + // was pure noise that errored on invocation. Opt in with + // --http-builtins when a deploy genuinely needs it. --no-builtins + // still wins (forces hidden everywhere). stdio keeps the default of + // showing builtins (common Claude Desktop path). + let http_no_builtins = args.no_builtins || !args.http_builtins; let http_exposure = ExposureConfig::new(args.expose_all, http_no_builtins, args.tier.clone()); handler::register_http(&iii, http_exposure);