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

2026-10-05

Transaction Science
jcp-gateway: refuse a tool whose parameter schema changed under its name
All checks were successful
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
no-vanity-metrics / WAI public text names no comparison (pull_request) Successful in 3s
no-vanity-metrics / WAI public text names no company or product (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m58s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m27s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m15s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m17s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 3m44s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 11m25s
standards workspaces build + test / aac-rs (pull_request) Successful in 6m26s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 3m40s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 2m6s
standards workspaces build + test / map-rs (pull_request) Successful in 1m46s
standards workspaces build + test / mesh-rs (pull_request) Successful in 2m41s
standards workspaces build + test / openpay (pull_request) Successful in 12m23s
standards workspaces build + test / arl-rs (pull_request) Successful in 46m19s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 31m47s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 8m22s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 4m58s
standards workspaces build + test / sense-rs (pull_request) Successful in 4m10s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 5m46s
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 <[email protected]>
2026-10-05 10:20:47 -04:00
Transaction Science
jcp-gateway: let a server advertise that it enforces, and a client require it
Some checks failed
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Has been cancelled
no-vanity-metrics / WAI public text names no comparison (pull_request) Has been cancelled
no-vanity-metrics / WAI public text names no company or product (pull_request) Has been cancelled
standards workspaces build + test / jouleclaw-rs (pull_request) Has been cancelled
standards workspaces build + test / aac-rs (pull_request) Has been cancelled
standards workspaces build + test / arl-rs (pull_request) Has been cancelled
standards workspaces build + test / eoc-rs (pull_request) Has been cancelled
standards workspaces build + test / jcp-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-code-rs (pull_request) Has been cancelled
standards workspaces build + test / joule-ui-rs (pull_request) Has been cancelled
standards workspaces build + test / joulecontract-rs (pull_request) Has been cancelled
standards workspaces build + test / jouledesk-rs (pull_request) Has been cancelled
standards workspaces build + test / joulehook-rs (pull_request) Has been cancelled
standards workspaces build + test / map-rs (pull_request) Has been cancelled
standards workspaces build + test / mesh-rs (pull_request) Has been cancelled
standards workspaces build + test / openpay (pull_request) Has been cancelled
standards workspaces build + test / proof-rs (pull_request) Has been cancelled
standards workspaces build + test / sandbox-rs (pull_request) Has been cancelled
standards workspaces build + test / sense-rs (pull_request) Has been cancelled
standards workspaces build + test / smart-byte-rs (pull_request) Has been cancelled
standards workspaces build + test / macOS energy backends (pull_request) Has been cancelled
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 <[email protected]>
2026-10-05 09:16:08 -04:00