diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 844b94604..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}")), @@ -252,7 +270,23 @@ 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, + ))); + } + 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)); }; // ---- A gated method: the enforcement point. @@ -265,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); @@ -357,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 @@ -386,6 +472,184 @@ 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, + // 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"), + }), + ); + 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, + /// 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. +#[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, + 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 @@ -807,6 +1071,186 @@ 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. + #[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); + 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(); + 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(); + 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())) + ); + // 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]]}")