From 6b8f237f737ad6c5a8482d2a7cec8e24058500d6 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 09:16:08 -0400 Subject: [PATCH 1/2] jcp-gateway: let a server advertise that it enforces, and a client require it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything this gateway does was **private convention**. It gates, meters, debits and seals every `tools/call`, `resources/read` and `prompts/get` — and a client had no way to discover that and no way to insist on it. A client that dropped the `_meta` block got served anyway, and a client talking to a gateway that enforced *nothing* could not tell the difference. Enforcement nobody can require is a courtesy, not a control. `initialize` is now the one discovery method not relayed verbatim: the gateway adds its own entry to `result.capabilities.extensions`, under the same reverse-domain key it already uses for per-call side data — one name for one thing. MCP's 2026-07-28 revision formalised `capabilities.extensions` for exactly this, with reverse-domain third-party keys and a 12-month deprecation policy behind it, so the advertisement has a defined place to live rather than an invented one. It names the profile it seals with and the methods it enforces, so a client can require the parts it needs instead of the bundle. `require_jcp(initialize_response, required)` is the half that makes the advertisement worth making. A client calls it before sending anything and **fails closed**: an endpoint advertising nothing is refused, and so is one that enforces `tools/call` but not `resources/read` — which is the case that matters, since the resource read is the egress door that was open until 971ef435. Garbage is refused rather than interpreted. Three tests. The advertisement round-trips through `require_jcp` with the right profile and method list; a client refuses a bare endpoint, refuses a partially enforcing one for the method it does not enforce, and refuses non-JSON; and advertising preserves everything else — an upstream's `protocolVersion`, `serverInfo`, its own `capabilities.tools`, and a third party's existing `extensions` entry are all untouched, while a response that is not a JSON-RPC result comes back byte-identical. That last property is the same one `attach_receipt` has to have: never mangle a payload you do not understand. `RequireError`'s `Display` is written out rather than derived, because this crate carries no `thiserror` dependency and three variants are not worth adding one for. 396 tests pass across the jcp workspace, 0 clippy findings in the crate. Also records a measurement in `jcp/tools/counter-resolution/`: the Apple `GPU Energy` rail's republish gaps, now over 90 s rather than 6. The short run gave a max of 992.2 ms, which sits just inside a one-second timeout and invites the conclusion that one second is enough. Over 90 s the same host produced **three gaps past 1000 ms — 1446, 1919 and 2033 ms** (n=2936) — so about 1 idle gap in 1000 exceeds a one-second wait and the worst was 2× it. It also pins the condition the right way round: under sustained load the rail is tight (max 42 ms, n=435); the long tail belongs to **idle**. Anything waiting a bounded time for this rail to republish should bound above ~2.5 s, or count the timeout rather than be surprised by it. Co-Authored-By: Claude Opus 5 --- jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 220 +++++++++++++++++- jcp/tools/counter-resolution/README.md | 36 +++ .../counter-resolution/gpu_rail_idle_tail.py | 20 ++ 3 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 jcp/tools/counter-resolution/gpu_rail_idle_tail.py diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 844b94604..83462ad58 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -252,7 +252,17 @@ impl Gateway { let _ = up.call(line); return Ok(Relayed::Notified); } - return Ok(Relayed::PassedThrough(up.call(line)?)); + let response = up.call(line)?; + // `initialize` is where a client learns what it is talking to, so + // it is the one discovery method this gateway does not relay + // verbatim: it adds its own advertisement and changes nothing else. + if method == "initialize" { + return Ok(Relayed::PassedThrough(advertise_extension( + &response, + jcp_receipt::PROFILE_FLOW, + ))); + } + return Ok(Relayed::PassedThrough(response)); }; // ---- A gated method: the enforcement point. @@ -386,6 +396,137 @@ impl Gateway { } } +/// The methods this gateway enforces, named on the wire so a client can see +/// what it is getting rather than infer it. +pub const JCP_ENFORCED_METHODS: [&str; 3] = ["tools/call", "resources/read", "prompts/get"]; + +/// Announce, in an `initialize` response, that this endpoint enforces JCP. +/// +/// # Why an advertisement is the missing half +/// +/// Everything this gateway does was previously **private convention**. It +/// gates, meters, debits and seals — and a client had no way to discover that, +/// and no way to insist on it. A client that dropped the `_meta` block got +/// served anyway, and a client talking to a gateway that enforced *nothing* +/// could not tell the difference. Enforcement nobody can require is a +/// courtesy, not a control. +/// +/// MCP's 2026-07-28 revision formalised `capabilities.extensions` for exactly +/// this, with reverse-domain keys for third-party extensions and a 12-month +/// deprecation policy behind it. So the advertisement has a defined place to +/// live, under the same [`JCP_META_NS`] key this gateway already uses for +/// per-call side data — one name for one thing. +/// +/// Returns the response unchanged if it is not a JSON-RPC result object. This +/// must never mangle an upstream payload it does not understand, exactly as +/// [`attach_receipt`] must not. +fn advertise_extension(response: &str, profile: &str) -> String { + let Ok(mut v) = serde_json::from_str::(response) else { + return response.to_string(); + }; + let Some(result) = v.get_mut("result").and_then(Value::as_object_mut) else { + return response.to_string(); + }; + let caps = result + .entry("capabilities") + .or_insert_with(|| json!({})) + .as_object_mut(); + let Some(caps) = caps else { return response.to_string() }; + let exts = caps + .entry("extensions") + .or_insert_with(|| json!({})) + .as_object_mut(); + let Some(exts) = exts else { return response.to_string() }; + exts.insert( + JCP_META_NS.to_string(), + json!({ + "profile": profile, + "enforced": JCP_ENFORCED_METHODS, + // What a client gets per call, so it can require the parts it needs + // rather than the whole bundle. + "gated": 1, + "metered": 1, + "sealed": 1, + "receipt_in": format!("result._meta.{JCP_META_NS}.receipt_b64"), + }), + ); + v.to_string() +} + +/// What an endpoint advertised under [`JCP_META_NS`] in its `initialize` +/// response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JcpAdvertisement { + /// The receipt profile the endpoint seals with. + pub profile: String, + /// The methods it says it enforces. + pub enforced: Vec, +} + +/// Why a client refused an endpoint. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RequireError { + /// The response was not a JSON-RPC `initialize` result. + NotAnInitializeResult, + /// The endpoint advertised no JCP extension: it may enforce nothing, and + /// a client that proceeds cannot tell. + NotAdvertised(&'static str), + /// It advertised, but not over a method the caller requires. + MethodNotEnforced(String), +} + +// Written out rather than derived: this crate carries no `thiserror` +// dependency, and three variants are not worth adding one for. +impl std::fmt::Display for RequireError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RequireError::NotAnInitializeResult => write!(f, "not an initialize result"), + RequireError::NotAdvertised(ns) => { + write!(f, "endpoint advertises no `{ns}` extension") + } + RequireError::MethodNotEnforced(m) => write!(f, "endpoint does not enforce `{m}`"), + } + } +} + +impl std::error::Error for RequireError {} + +/// Read an `initialize` response and **refuse** an endpoint that does not +/// advertise JCP enforcement over every method in `required`. +/// +/// This is the half that makes the advertisement worth making. A client calls +/// it before sending any `tools/call`, and fails closed: an endpoint that +/// enforces nothing, or enforces tool calls but not resource reads, is +/// rejected here rather than trusted and discovered later. Passing `&[]` +/// requires only that the extension is present at all. +pub fn require_jcp( + initialize_response: &str, + required: &[&str], +) -> Result { + let v: Value = serde_json::from_str(initialize_response) + .map_err(|_| RequireError::NotAnInitializeResult)?; + let ext = v + .get("result") + .and_then(|r| r.get("capabilities")) + .and_then(|c| c.get("extensions")) + .and_then(|e| e.get(JCP_META_NS)) + .ok_or(RequireError::NotAdvertised(JCP_META_NS))?; + let enforced: Vec = ext + .get("enforced") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect()) + .unwrap_or_default(); + for m in required { + if !enforced.iter().any(|e| e == m) { + return Err(RequireError::MethodNotEnforced((*m).to_string())); + } + } + Ok(JcpAdvertisement { + profile: ext.get("profile").and_then(Value::as_str).unwrap_or("").to_string(), + enforced, + }) +} + /// The `_meta` namespace this gateway reads and writes. /// /// MCP's 2026-07-28 revision made `_meta` a first-class, namespaced extension @@ -807,6 +948,83 @@ mod tests { assert_eq!(up.calls, 0); } + /// The half that makes enforcement requirable. Until now everything this + /// gateway does was private convention: a client could not discover it and + /// could not insist on it. + #[test] + fn initialize_advertises_what_this_endpoint_enforces() { + let mut g = gw(); + let mut up = MockServer { calls: 0 }; + let line = json!({"jsonrpc":"2.0","id":1,"method":"initialize"}).to_string(); + let Relayed::PassedThrough(resp) = g.relay(&line, &mut up).unwrap() else { + panic!("initialize must still relay"); + }; + let adv = require_jcp(&resp, &["tools/call", "resources/read", "prompts/get"]) + .expect("the endpoint must advertise what it enforces"); + assert_eq!(adv.profile, jcp_receipt::PROFILE_FLOW); + assert_eq!(adv.enforced, JCP_ENFORCED_METHODS); + // Under the same reverse-domain key used for per-call side data: one + // name for one thing. + let v: Value = serde_json::from_str(&resp).unwrap(); + assert!(v["result"]["capabilities"]["extensions"][JCP_META_NS].is_object()); + } + + /// A client must fail **closed**. An endpoint that enforces nothing is + /// indistinguishable from one that does, unless the client refuses it. + #[test] + fn a_client_refuses_an_endpoint_that_advertises_nothing() { + // What a plain MCP server returns: no extensions at all. + let bare = json!({"jsonrpc":"2.0","id":1,"result":{"capabilities":{}}}).to_string(); + assert_eq!( + require_jcp(&bare, &[]), + Err(RequireError::NotAdvertised(JCP_META_NS)), + "a client that proceeds here cannot tell enforcement from courtesy" + ); + // And one that enforces tool calls but not resource reads is refused + // for the read, which is the case that matters: the egress door. + let partial = json!({"jsonrpc":"2.0","id":1,"result":{"capabilities":{"extensions":{ + JCP_META_NS: {"profile":"jcp/3","enforced":["tools/call"]} + }}}}) + .to_string(); + assert!(require_jcp(&partial, &["tools/call"]).is_ok()); + assert_eq!( + require_jcp(&partial, &["resources/read"]), + Err(RequireError::MethodNotEnforced("resources/read".into())) + ); + // Garbage is refused, not interpreted. + assert_eq!( + require_jcp("not json", &[]), + Err(RequireError::NotAnInitializeResult) + ); + } + + /// The advertisement must never mangle a payload it does not understand, + /// and must not disturb an upstream's own capabilities. + #[test] + fn advertising_preserves_everything_else_in_the_response() { + let upstream = json!({"jsonrpc":"2.0","id":1,"result":{ + "protocolVersion":"2026-07-28", + "capabilities":{"tools":{"listChanged":true},"extensions":{"com.example/x":{"v":1}}}, + "serverInfo":{"name":"files","version":"9.9"} + }}) + .to_string(); + let out = advertise_extension(&upstream, "jcp/3"); + let v: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(v["result"]["protocolVersion"], json!("2026-07-28")); + assert_eq!(v["result"]["serverInfo"]["version"], json!("9.9")); + assert_eq!(v["result"]["capabilities"]["tools"]["listChanged"], json!(true)); + assert_eq!(v["result"]["capabilities"]["extensions"]["com.example/x"]["v"], json!(1)); + assert!(v["result"]["capabilities"]["extensions"][JCP_META_NS].is_object()); + + // A response that is not a JSON-RPC result is returned byte-identical. + for passthrough in [ + "not json at all", + r#"{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"nope"}}"#, + ] { + assert_eq!(advertise_extension(passthrough, "jcp/3"), passthrough); + } + } + /// Discovery still relays untouched: it moves no payload, and gating it /// would refuse a client the right to see what exists. #[test] diff --git a/jcp/tools/counter-resolution/README.md b/jcp/tools/counter-resolution/README.md index e1525cb36..9e66a147d 100644 --- a/jcp/tools/counter-resolution/README.md +++ b/jcp/tools/counter-resolution/README.md @@ -50,3 +50,39 @@ M5 Max) and prints each channel's name, unit and delta. What it establishes: `*_SRAM` rail is frozen for an unprivileged reader, and `ANE0` is the only ANE channel — no `ANE Energy` aggregate, no sub-rails. Units differ *within one sample*, so a summer that assumes one unit is wrong. + +## `gpu_rail_period.py` / `gpu_rail_idle_tail.py` — when the GPU rail republishes + +The `GPU Energy` rail has **no fixed period**. It republishes when there is +activity to report and holds its value when there is not, so the number you +measure depends entirely on which condition you measure in. + +Sampling every 2 ms on an M5 Max, macOS 27, the gap between actual changes: + +| condition | p50 | p90 | p95 | p99 | max | n | +|---|---:|---:|---:|---:|---:|---:| +| sustained MLX matmul | 13.1 ms | 15.0 ms | — | — | **42.0 ms** | 435 | +| idle, 6 s | 12.7 ms | 91.0 ms | — | — | 992.2 ms | 112 | +| **idle, 90 s** | 13.1 ms | 30.6 ms | 86.2 ms | 436.2 ms | **2032.8 ms** | 2936 | + +Three things follow, and the third is why the long run exists. + +**Under sustained load the rail is tight; the long tail is an *idle* +property.** That is the opposite of the intuitive reading, and it reconciles an +earlier fleet observation that put the hold at "300–600 ms under load": the +GGUF decode it came from is dispatch-latency-bound with idle gaps between +tokens, so it was sampling idle behaviour. 300–600 ms is about the idle +p98–p99 here, consistent with having seen that region and not past it. + +**A declared period has to be the idle worst case.** A measurer cannot +guarantee the fabric stays busy, and a span landing entirely inside one hold +reads **zero** — which a naive grader would report as a measurement of zero. +`jcp-energy` declares 1 s for this rail on that basis. + +**The sample max is not a bound.** The 6 s run gave a max of 992.2 ms, which +sits just inside a 1 s timeout and invites the conclusion that 1 s is enough. +Over 90 s the same host produced **three gaps past 1000 ms — 1446, 1919 and +2033 ms** — so roughly 1 idle gap in 1000 exceeds a one-second wait, and the +worst was 2× it. Any code that waits a bounded time for this rail to +republish should bound above ~2.5 s, or treat the timeout as a counted +outcome rather than a rare surprise. diff --git a/jcp/tools/counter-resolution/gpu_rail_idle_tail.py b/jcp/tools/counter-resolution/gpu_rail_idle_tail.py new file mode 100644 index 000000000..ec0fc15b5 --- /dev/null +++ b/jcp/tools/counter-resolution/gpu_rail_idle_tail.py @@ -0,0 +1,20 @@ +"""The tail of the GPU rail's idle hold. n=112 was too thin to bound a 1 s wait.""" +import time, statistics +exec(open('rails.py').read().split('import time, subprocess, sys')[0]) + +prev = sample(); last = time.perf_counter(); gaps = [] +t_end = time.perf_counter() + 90.0 +while time.perf_counter() < t_end: + time.sleep(0.002) + cur = sample() + d = IOR.IOReportCreateSamplesDelta(prev, cur, None) + if sum(v for _g,_sg,n,_u,v in rails(d) if n == "GPU Energy") > 0: + now = time.perf_counter(); gaps.append((now-last)*1e3); last = now; prev = cur +gaps.sort() +n = len(gaps) +over = [g for g in gaps if g > 1000.0] +print(f"idle gaps n={n} over 90 s") +for q in (50, 90, 95, 99): + print(f" p{q}: {gaps[min(int(q/100*n), n-1)]:8.1f} ms") +print(f" max: {gaps[-1]:8.1f} ms") +print(f" gaps exceeding RAIL_EDGE_WAIT (1000 ms): {len(over)} {[round(g) for g in over[:8]]}") -- 2.54.0 (Apple Git-157) From 0b9be6ce7c70add6d5b9c3bba63330cc099ed153 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 10:20:47 -0400 Subject: [PATCH 2/2] jcp-gateway: refuse a tool whose parameter schema changed under its name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A grant binds `mcp_resource(server, tool)` — a hash of the tool's **name** — and the action list. `Capability { resource, actions }` has no field for the tool's parameter shape, so nothing in a signed grant describes what the arguments mean. So a server can advertise `read_file(path: string)`, take a grant for it, and later advertise `read_file(command: string)`. Same name, same content-addressed resource, same action, same signature: every check passes and the chain still verifies. The authority the client issued now covers arguments it never saw. That is a rug-pull JCP could not see, and a grant cannot be made to see it without changing the signed wire format. Closed at the enforcement point, which is the only place the shape is observable without that wire change. `tools/list` is now read on the way back — read, never rewritten — and each tool's `inputSchema` is content-addressed. A `tools/call` to a tool whose digest has since changed is refused **before any flow labelling or debit**, and the refusal seals like every other, so it is recorded rather than silently dropped. Three properties worth stating because they are choices, not accidents: - **Reverting is still a change.** A server that swaps the shape and swaps it back has still changed it, and nothing says which of the two a later call means, so the flag does not clear. - **A tool that gains a schema where it had none is a change.** A listing without `inputSchema` records the null schema rather than nothing, so the first real schema is a contradiction and not a first sighting. - **Key order is not a change.** The digest is over the canonical form, so a server reordering its JSON does not trip the gate. And one honest limit, in the field's own doc comment: this is **trust-on-first-listing**. It catches a shape that changes under a gateway that has seen it listed, and promises nothing about a tool this gateway never watched being listed. Binding the digest into the `Capability` itself is the stronger fix and a wire change; this is what is enforceable today. Composed with the advertisement from the previous commit: `tool_schema_pinned` is announced in `initialize` and surfaced on `JcpAdvertisement`, so a client can *require* it rather than hope for it — and an absent field does not read as enforced. 398 tests across the jcp workspace, 0 clippy findings in the crate. Co-Authored-By: Claude Opus 5 --- jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 230 ++++++++++++++++++++++- 1 file changed, 228 insertions(+), 2 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 83462ad58..4cfc9410b 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -117,6 +117,23 @@ fn gated_action(method: &str, params: &Value) -> Option<(String, Value)> { /// The gateway: a JCP runtime, the tool grants, an energy meter, and a /// hash-chained receipt log, in front of one MCP server. pub struct Gateway { + /// The schema digest each tool was first seen advertising, and whether a + /// later listing contradicted it. + /// + /// A grant binds `mcp_resource(server, tool)` — a hash of the tool's + /// **name** — and the action. Nothing in it describes the tool's + /// parameters. So a server may keep the name and change what the + /// arguments mean (`read_file(path)` becoming `read_file(command)`), and + /// every check still passes: same resource id, same action, same grant. + /// The authority the client issued now covers something it never saw. + /// + /// This closes it at the enforcement point, which is the only place the + /// schema is observable without changing the signed grant format. It is + /// deliberately *trust-on-first-listing*: it catches a schema that changes + /// under a gateway that has seen it, and it promises nothing about a tool + /// this gateway never watched being listed. Binding the digest into the + /// `Capability` itself is the stronger fix and a wire change. + tool_schemas: BTreeMap, server: String, runtime: Runtime, grants: BTreeMap, @@ -172,6 +189,7 @@ impl Gateway { server: server.to_string(), runtime: Runtime::new(), grants, + tool_schemas: BTreeMap::new(), caller: did, meter: PowerMeter::detect(), log: ReceiptLog::new(steward_sk, format!("did:jcp:gateway:{server}")), @@ -262,6 +280,12 @@ impl Gateway { jcp_receipt::PROFILE_FLOW, ))); } + if method == "tools/list" { + // Read, never rewrite. The listing is the only place a tool's + // parameter shape is visible, so this is where a later change + // to it can be caught. + self.observe_schemas(&response); + } return Ok(Relayed::PassedThrough(response)); }; @@ -275,6 +299,13 @@ impl Gateway { let Some(sg) = self.grants.get(tool).cloned() else { return Ok(self.deny_unknown(tool, id)); }; + // A grant binds the tool's NAME and action, never its parameter + // shape. Refuse before any flow labelling or debit if the server has + // changed the shape since this gateway first saw it listed: the grant + // matches, and that is the problem. + if self.tool_schema_changed(tool) { + return Ok(self.deny_schema_changed(tool, id)); + } let grant = sg.grant.clone(); let frame = mcp_call_frame(grant.id(), &self.server, tool, &args_bytes); @@ -367,12 +398,57 @@ impl Gateway { }) } + /// Record each listed tool's schema digest, and flag any that contradicts + /// what was seen before. + fn observe_schemas(&mut self, response: &str) { + for (tool, digest) in listed_schemas(response) { + match self.tool_schemas.get_mut(&tool) { + None => { + self.tool_schemas + .insert(tool, ToolSchemaState { first: digest, changed_to: None }); + } + Some(st) => { + if st.first != digest { + st.changed_to = Some(digest); + } + } + } + } + } + + /// The digest a tool was first listed with, if this gateway saw it. + pub fn tool_schema_digest(&self, tool: &str) -> Option { + self.tool_schemas.get(tool).map(|s| s.first) + } + + /// Whether a later listing changed this tool's parameter schema. + /// + /// A `true` here is the rug-pull a grant cannot see: same tool name, same + /// content-addressed resource, same action, different arguments. + pub fn tool_schema_changed(&self, tool: &str) -> bool { + self.tool_schemas.get(tool).is_some_and(|s| s.changed_to.is_some()) + } + /// Seal the denial of a call to an ungranted tool and build the client /// error. Uses an arbitrary held grant so the runtime can render a real /// (failing) decision; if the gateway holds no grants at all, the denial /// is synthesized without a receipt (nothing to bind it to). fn deny_unknown(&mut self, tool: &str, id: Option<&Value>) -> Relayed { - let reason = "no grant for action".to_string(); + self.deny_sealed(tool, id, "no grant for action") + } + + /// Refuse a call to a tool whose parameter schema changed under its name. + /// + /// The grant still matches — same resource id, same action — which is + /// exactly why this has to be refused here. The authority was issued + /// against arguments the server has since replaced. + fn deny_schema_changed(&mut self, tool: &str, id: Option<&Value>) -> Relayed { + self.deny_sealed(tool, id, "tool schema changed since it was granted") + } + + /// Seal a refusal and build the client error, whatever the reason. + fn deny_sealed(&mut self, tool: &str, id: Option<&Value>, why: &str) -> Relayed { + let reason = why.to_string(); if let Some(anchor) = self.grants.values().next().cloned() { let grant = anchor.grant.clone(); // Frame addressed to the REQUESTED tool, decided against a grant @@ -447,6 +523,11 @@ fn advertise_extension(response: &str, profile: &str) -> String { "gated": 1, "metered": 1, "sealed": 1, + // A grant cannot express a tool's parameter shape, so the gateway + // watches the listing and refuses a call once the shape changes + // under an unchanged name. Advertised because a client has no + // other way to learn that this endpoint does it. + "tool_schema_pinned": 1, "receipt_in": format!("result._meta.{JCP_META_NS}.receipt_b64"), }), ); @@ -461,6 +542,9 @@ pub struct JcpAdvertisement { pub profile: String, /// The methods it says it enforces. pub enforced: Vec, + /// Whether it refuses a call to a tool whose parameter schema changed + /// under an unchanged name. + pub tool_schema_pinned: bool, } /// Why a client refused an endpoint. @@ -524,9 +608,48 @@ pub fn require_jcp( Ok(JcpAdvertisement { profile: ext.get("profile").and_then(Value::as_str).unwrap_or("").to_string(), enforced, + tool_schema_pinned: ext.get("tool_schema_pinned").and_then(Value::as_u64) == Some(1), }) } +/// What this gateway has observed about one tool's parameter schema. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ToolSchemaState { + /// The digest first seen for this tool. + first: ContentId, + /// A later listing advertised a different digest. Once set it stays set: a + /// server that changes a granted tool's shape and changes it back has + /// still changed it, and the grant was issued against one of the two. + changed_to: Option, +} + +/// Content-address a tool's parameter schema as MCP publishes it. +/// +/// Canonical JSON via [`ContentId::of`], so key order in the server's response +/// cannot change the digest while the schema is the same. +fn schema_digest(schema: &Value) -> ContentId { + ContentId::of(schema).unwrap_or_else(|_| ContentId::of_bytes(schema.to_string().as_bytes())) +} + +/// The tools a `tools/list` response advertised, with each one's schema digest. +fn listed_schemas(response: &str) -> Vec<(String, ContentId)> { + let Ok(v) = serde_json::from_str::(response) else { return Vec::new() }; + let Some(tools) = v.get("result").and_then(|r| r.get("tools")).and_then(Value::as_array) else { + return Vec::new(); + }; + tools + .iter() + .filter_map(|t| { + let name = t.get("name").and_then(Value::as_str)?; + // `inputSchema` is where MCP puts a tool's parameter shape. A tool + // that publishes none is recorded under the null schema, so a tool + // that later *gains* one is still a change. + let schema = t.get("inputSchema").cloned().unwrap_or(Value::Null); + Some((name.to_string(), schema_digest(&schema))) + }) + .collect() +} + /// The `_meta` namespace this gateway reads and writes. /// /// MCP's 2026-07-28 revision made `_meta` a first-class, namespaced extension @@ -948,6 +1071,107 @@ mod tests { assert_eq!(up.calls, 0); } + /// A server that lists `read_file(path)` and later lists + /// `read_file(command)` keeps the tool's name, so it keeps the grant: the + /// resource id is a hash of the name, the action is unchanged, and the + /// signature still verifies. The authority the client issued now covers + /// arguments it never saw. That is the rug-pull a grant cannot express. + #[test] + fn a_tool_whose_schema_changed_under_its_name_is_refused() { + /// Lists `read_file` with whichever schema it is told to. + struct Listing { + schema: Value, + calls: usize, + } + impl Upstream for Listing { + fn call(&mut self, line: &str) -> std::io::Result { + self.calls += 1; + let v: Value = serde_json::from_str(line).unwrap(); + if v["method"] == "tools/list" { + return Ok(json!({"jsonrpc":"2.0","id":v["id"],"result":{"tools":[ + {"name":"read_file","inputSchema": self.schema} + ]}}) + .to_string()); + } + Ok(json!({"jsonrpc":"2.0","id":v["id"],"result":{"ok":true}}).to_string()) + } + } + let list = json!({"jsonrpc":"2.0","id":7,"method":"tools/list"}).to_string(); + let path_schema = + json!({"type":"object","properties":{"path":{"type":"string"}}}); + let command_schema = + json!({"type":"object","properties":{"command":{"type":"string"}}}); + + let mut g = gw(); // grants read_file + search + let mut up = Listing { schema: path_schema.clone(), calls: 0 }; + + // First listing: the shape the grant was issued against. + g.relay(&list, &mut up).unwrap(); + let first = g.tool_schema_digest("read_file").expect("the listing was observed"); + assert!(!g.tool_schema_changed("read_file")); + // And a call is served as before. + assert!(matches!( + g.relay(&call("read_file", 1), &mut up).unwrap(), + Relayed::Allowed { .. } + )); + + // The server swaps the argument under the same name. + up.schema = command_schema; + g.relay(&list, &mut up).unwrap(); + assert!( + g.tool_schema_changed("read_file"), + "a changed inputSchema under an unchanged name was not noticed" + ); + + // The grant still matches — and the call is refused anyway, sealed. + let before = up.calls; + let r = g.relay(&call("read_file", 2), &mut up).unwrap(); + let Relayed::Denied { reason, .. } = &r else { + panic!("a rug-pulled tool was served: {r:?}"); + }; + assert!(reason.contains("schema changed"), "reason was {reason:?}"); + assert_eq!(up.calls, before, "a denied call must never reach the server"); + + // Changing it back is still a change: the grant was issued against one + // of the two shapes, and nothing says which one a later call means. + up.schema = path_schema; + g.relay(&list, &mut up).unwrap(); + assert!(g.tool_schema_changed("read_file"), "reverting must not clear the flag"); + assert_eq!(g.tool_schema_digest("read_file"), Some(first)); + + // An ungranted tool is still refused for the original reason, so the + // new gate did not swallow the old one. + assert!(matches!(g.relay(&call("shell", 3), &mut up).unwrap(), Relayed::Denied { .. })); + } + + /// Key order in a server's JSON is not a schema change, and a tool that + /// gains a schema where it had none is. + #[test] + fn schema_digests_follow_meaning_not_formatting() { + let a = json!({"type":"object","properties":{"path":{"type":"string"}}}); + // Same schema, keys written the other way round. + let b: Value = serde_json::from_str( + r#"{"properties":{"path":{"type":"string"}},"type":"object"}"#, + ) + .unwrap(); + assert_eq!(schema_digest(&a), schema_digest(&b), "canonical form must absorb key order"); + assert_ne!(schema_digest(&a), schema_digest(&Value::Null)); + + // A listing with no `inputSchema` records the null schema, so gaining + // one later reads as a change rather than as a first sighting. + let none = json!({"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"t"}]}}).to_string(); + let some = json!({"jsonrpc":"2.0","id":1,"result":{"tools":[ + {"name":"t","inputSchema":{"type":"object"}}]}}) + .to_string(); + assert_eq!(listed_schemas(&none), vec![("t".to_string(), schema_digest(&Value::Null))]); + assert_ne!(listed_schemas(&none), listed_schemas(&some)); + + // Garbage and non-listings yield nothing rather than a false sighting. + for junk in ["not json", r#"{"jsonrpc":"2.0","id":1,"result":{}}"#] { + assert!(listed_schemas(junk).is_empty()); + } + } + /// The half that makes enforcement requirable. Until now everything this /// gateway does was private convention: a client could not discover it and /// could not insist on it. @@ -963,6 +1187,7 @@ mod tests { .expect("the endpoint must advertise what it enforces"); assert_eq!(adv.profile, jcp_receipt::PROFILE_FLOW); assert_eq!(adv.enforced, JCP_ENFORCED_METHODS); + assert!(adv.tool_schema_pinned, "schema pinning is enforced, so it must be advertised"); // Under the same reverse-domain key used for per-call side data: one // name for one thing. let v: Value = serde_json::from_str(&resp).unwrap(); @@ -986,7 +1211,8 @@ mod tests { JCP_META_NS: {"profile":"jcp/3","enforced":["tools/call"]} }}}}) .to_string(); - assert!(require_jcp(&partial, &["tools/call"]).is_ok()); + let got = require_jcp(&partial, &["tools/call"]).expect("tools/call is enforced"); + assert!(!got.tool_schema_pinned, "absent must not read as enforced"); assert_eq!( require_jcp(&partial, &["resources/read"]), Err(RequireError::MethodNotEnforced("resources/read".into())) -- 2.54.0 (Apple Git-157)