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
Owner

Closes the item the 2026-10-04 sweep ranked as the highest strategic leverage per unit of work, and records a measurement that bears on an open PR I cannot comment on.

The gap

Everything jcp-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.

Both halves

The advertisement. 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 (science.transaction/jcp) — 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 this has a defined place to live rather than an invented one. It names the receipt profile it seals with and the methods it enforces, so a client can require the parts it needs rather than the whole bundle.

The refusal. require_jcp(initialize_response, required) is what 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 — the case that matters, since the resource read was the egress door standing open until #153.

Tests

Three, each falsified from the committed baseline:

  • the advertisement round-trips through require_jcp with the right profile and method list — disabling the initialize branch fails it with NotAdvertised("science.transaction/jcp");
  • a client refuses a bare endpoint, refuses a partially-enforcing one for the method it does not enforce, and refuses non-JSON — making require_jcp tolerate a missing extension fails it;
  • advertising preserves everything else: an upstream's protocolVersion, serverInfo, its own capabilities.tools and a third party's existing extensions entry all survive, and a response that is not a JSON-RPC result comes back byte-identical. That last property is the one attach_receipt must also have — never mangle a payload you do not understand.

RequireError's Display is hand-written: 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.

⚠️ A measurement for #146, which I could not post there

The forge credential available to me is repo-scoped with no issue scope — even listing issue comments returns 403 — so this is recorded in jcp/tools/counter-resolution/README.md where the constant it concerns will be checked, rather than on the PR.

#146's RAIL_EDGE_WAIT = 1000 ms is exceeded at about 1 in 1000 idle gaps. Sampling the Apple GPU Energy rail every 2 ms over 90 s (n=2936), three gaps ran past a second — 1446, 1919 and 2033 ms — the worst being 2× the wait. A 6 s run had given a max of 992.2 ms, which sits just inside 1 s and invites exactly the wrong conclusion; the sample max is not a bound. #146's design is right to wait for the next republish rather than assume a period, and per its own docs a timed-out snapshot "bounds no span" — but that makes a path intended to be deterministic drop a figure ~0.1% of the time, invisibly unless counted. Bounding above ~2.5 s, or counting the timeout, would close it.

And the condition attribution is inverted. #146's body says the rail "sometimes hold[s] 300–600 ms under load". Measured, under sustained load it is tight — p50 13.1 ms, max 42.0 ms (n=435) — and the long tail belongs to idle (p99 436 ms, max 2033 ms). Both observations reconcile once the cause is named: the GGUF decode the 300–600 ms figure 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. This matters for the constant, because it has to survive the condition a snapshot is most likely taken in — and a decode's inter-token gaps are that condition.

Harness is jcp/tools/counter-resolution/gpu_rail_idle_tail.py, so it is re-runnable rather than quoted.


Second commit: refuse a tool whose parameter schema changed under its name

This is the other hole the sweep named, and it belongs with the first: both are about enforcement a client cannot currently see or insist on.

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 a server can advertise read_file(path: string), take a grant, and later advertise read_file(command: string): same name, same content-addressed resource, same action, same signature. Every check passes, the chain still verifies, and the authority the client issued now covers arguments it never saw.

Closed at the enforcement point, the only place the shape is observable without changing the signed wire format. tools/list is read on the way back — read, never rewritten — each inputSchema is content-addressed, and a tools/call to a tool whose digest has since changed is refused before any flow labelling or debit, sealing like every other refusal.

Three deliberate properties:

  • Reverting is still a change. Swap the shape and swap it back and the flag stays set — the grant was issued against one of the two, and nothing says which a later call means.
  • Gaining a schema is a change. A listing with no inputSchema records the null schema, so the first real one is a contradiction rather than a first sighting.
  • Key order is not a change. The digest is over the canonical form.

And the honest limit, stated 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 is the stronger fix and a wire change; this is what is enforceable today.

It composes with the first commit: tool_schema_pinned is advertised in initialize and surfaced on JcpAdvertisement, so a client can require it — and an absent field does not read as enforced.

Falsified three ways from the committed baseline: stop observing the listing, notice the change but serve anyway, or let reverting clear the flag — each fails the test. The middle one is the failure mode in miniature, since it shows the rug-pulled call being served with a complete sealed receipt.

398 tests across the jcp workspace, 0 clippy findings in the crate.

🤖 Generated with Claude Code

Closes the item the 2026-10-04 sweep ranked as the highest strategic leverage per unit of work, and records a measurement that bears on an open PR I cannot comment on. ### The gap Everything `jcp-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. ### Both halves **The advertisement.** `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 (`science.transaction/jcp`) — 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 this has a defined place to live rather than an invented one. It names the receipt profile it seals with and the methods it enforces, so a client can require the parts it needs rather than the whole bundle. **The refusal.** `require_jcp(initialize_response, required)` is what 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` — the case that matters, since the resource read was the egress door standing open until #153. ### Tests Three, each falsified from the committed baseline: - the advertisement round-trips through `require_jcp` with the right profile and method list — disabling the `initialize` branch fails it with `NotAdvertised("science.transaction/jcp")`; - a client refuses a bare endpoint, refuses a partially-enforcing one for the method it does not enforce, and refuses non-JSON — making `require_jcp` tolerate a missing extension fails it; - advertising **preserves everything else**: an upstream's `protocolVersion`, `serverInfo`, its own `capabilities.tools` and a third party's existing `extensions` entry all survive, and a response that is not a JSON-RPC result comes back byte-identical. That last property is the one `attach_receipt` must also have — never mangle a payload you do not understand. `RequireError`'s `Display` is hand-written: 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. ### ⚠️ A measurement for #146, which I could not post there The forge credential available to me is repo-scoped with no issue scope — even *listing* issue comments returns 403 — so this is recorded in `jcp/tools/counter-resolution/README.md` where the constant it concerns will be checked, rather than on the PR. **#146's `RAIL_EDGE_WAIT = 1000 ms` is exceeded at about 1 in 1000 idle gaps.** Sampling the Apple `GPU Energy` rail every 2 ms over 90 s (n=2936), three gaps ran past a second — **1446, 1919 and 2033 ms** — the worst being 2× the wait. A 6 s run had given a max of 992.2 ms, which sits just inside 1 s and invites exactly the wrong conclusion; the sample max is not a bound. #146's design is right to wait for the next republish rather than assume a period, and per its own docs a timed-out snapshot "bounds no span" — but that makes a path intended to be deterministic drop a figure ~0.1% of the time, invisibly unless counted. Bounding above ~2.5 s, or counting the timeout, would close it. **And the condition attribution is inverted.** #146's body says the rail "sometimes hold[s] 300–600 ms **under load**". Measured, under *sustained* load it is tight — p50 13.1 ms, max 42.0 ms (n=435) — and the long tail belongs to **idle** (p99 436 ms, max 2033 ms). Both observations reconcile once the cause is named: the GGUF decode the 300–600 ms figure 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. This matters for the constant, because it has to survive the condition a snapshot is most likely taken in — and a decode's inter-token gaps are that condition. Harness is `jcp/tools/counter-resolution/gpu_rail_idle_tail.py`, so it is re-runnable rather than quoted. --- ## Second commit: refuse a tool whose parameter schema changed under its name This is the other hole the sweep named, and it belongs with the first: both are about enforcement a client cannot currently see or insist on. 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 a server can advertise `read_file(path: string)`, take a grant, and later advertise `read_file(command: string)`: same name, same content-addressed resource, same action, same signature. Every check passes, the chain still verifies, and the authority the client issued now covers arguments it never saw. Closed at the enforcement point, the only place the shape is observable without changing the signed wire format. `tools/list` is read on the way back — read, never rewritten — each `inputSchema` is content-addressed, and a `tools/call` to a tool whose digest has since changed is refused **before any flow labelling or debit**, sealing like every other refusal. Three deliberate properties: - **Reverting is still a change.** Swap the shape and swap it back and the flag stays set — the grant was issued against one of the two, and nothing says which a later call means. - **Gaining a schema is a change.** A listing with no `inputSchema` records the *null* schema, so the first real one is a contradiction rather than a first sighting. - **Key order is not a change.** The digest is over the canonical form. And the honest limit, stated 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` is the stronger fix and a wire change; this is what is enforceable today. It composes with the first commit: `tool_schema_pinned` is advertised in `initialize` and surfaced on `JcpAdvertisement`, so a client can *require* it — and an absent field does not read as enforced. Falsified three ways from the committed baseline: stop observing the listing, notice the change but serve anyway, or let reverting clear the flag — each fails the test. The middle one is the failure mode in miniature, since it shows the rug-pulled call being served with a complete sealed receipt. 398 tests across the jcp workspace, 0 clippy findings in the crate. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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
6b8f237f73
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]>
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
0b9be6ce7c
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]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!157
No description provided.