jcp-gateway: make enforcement discoverable, and refusable #157
3 changed files with 502 additions and 2 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
20
jcp/tools/counter-resolution/gpu_rail_idle_tail.py
Normal file
20
jcp/tools/counter-resolution/gpu_rail_idle_tail.py
Normal 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]]}")
|
||||
Loading…
Reference in a new issue