jcp-gateway: make enforcement discoverable, and refusable #157

Merged
dcharlot merged 2 commits from feat/mcp-extensions into main 2026-10-05 10:23:54 -04:00
3 changed files with 502 additions and 2 deletions

View file

@ -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<String, ToolSchemaState>,
server: String,
runtime: Runtime,
grants: BTreeMap<String, SignedGrant>,
@ -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<ContentId> {
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::<Value>(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<String>,
/// 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<JcpAdvertisement, RequireError> {
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<String> = 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<ContentId>,
}
/// 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::<Value>(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<String> {
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]

View file

@ -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.

View file

@ -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]]}")