Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c3ad5ed
feat(poc): elicitations PoC package — MRTR server, harness, happy-pat…
barryroodt Jul 31, 2026
c873493
test(poc): risk 2-4 assertion suites; state key from env/random inste…
barryroodt Jul 31, 2026
5985fa0
docs(poc): FINDINGS.md risk-factor verdicts, inspector stretch notes,…
barryroodt Jul 31, 2026
f483360
test(poc): partial inputResponses cases — invalid accepted content re…
barryroodt Jul 31, 2026
c87ab39
docs(poc): risk 5 web-UI verification — Inspector 2.0 completes form …
barryroodt Jul 31, 2026
ee861ef
feat(poc): env-gated staging project creation — https + supabase.gree…
barryroodt Jul 31, 2026
933419a
fix(poc): tool description reflects real-creation mode; FINDINGS scop…
barryroodt Jul 31, 2026
d84aeb8
fix(poc): enable in-memory jti store when staging creation is configu…
barryroodt Jul 31, 2026
85c765e
feat(poc): risk-6 url-mode elicitation — connect page, interaction/se…
barryroodt Aug 3, 2026
75c196f
test(poc): risk-6 url-mode security + lifecycle suites; inspector url…
barryroodt Aug 3, 2026
93e4641
docs(poc): FINDINGS risk-6 url-mode section, scope and RFC-flag amend…
barryroodt Aug 3, 2026
952ca56
fix(poc): no credential fingerprint in MCP results; leak scan covers …
barryroodt Aug 3, 2026
e65a461
docs(poc): separate design guarantee from finite scan coverage in sec…
barryroodt Aug 3, 2026
52ac044
docs(poc): capability routing is runtime-owned, per approved design
barryroodt Aug 3, 2026
56fcead
docs(poc): manual browser passes for both flows; Safari gap; 120s lif…
barryroodt Aug 3, 2026
dbe3966
docs(poc): scope Safari observation to this Inspector run
barryroodt Aug 3, 2026
077d364
docs(poc): bound the Human Confirmation claim to integrity and bindin…
barryroodt Aug 3, 2026
7b9792c
docs(poc): pin client round cap at 10; narrow out-of-band attestation…
barryroodt Aug 3, 2026
6a53898
style(poc): apply the repo biome quote style to the PoC sources
barryroodt Aug 12, 2026
59af11a
ci(poc): typecheck and test the elicitations PoC on pull requests
barryroodt Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/elicitations-poc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Elicitations PoC
on:
pull_request:
paths:
- packages/mcp-elicitations-poc/**
- .github/workflows/elicitations-poc.yml
jobs:
elicitations-poc:
name: Elicitations PoC tests
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3.6.3
with:
install: true
cache: true
- name: Install dependencies
run: pnpm install --ignore-scripts
- name: Typecheck
run: pnpm --filter @supabase/mcp-elicitations-poc typecheck
- name: Tests
run: pnpm --filter @supabase/mcp-elicitations-poc test
116 changes: 116 additions & 0 deletions packages/mcp-elicitations-poc/FINDINGS.md

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions packages/mcp-elicitations-poc/NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# SDK findings

- The installed `@modelcontextprotocol/server@2.0.0` exports
`inputRequired`, `acceptedContent`, `inputResponse`,
`createRequestStateCodec`, and `createMcpHandler` with the documented names.
The schema-aware `acceptedContent(responses, key, schema)` overload returns
validated typed content or `undefined`.
- `createRequestStateCodec({ key, ttlSeconds, bind })` requires a key of at
least 32 bytes. `mint(payload, ctx?)` is async, and `verify(state, ctx)` is
assigned to `ServerOptions.requestState.verify`.
- The codec adds its own `exp` envelope field from `ttlSeconds`; the PoC payload
therefore includes `iat` but not a duplicate `exp`.
- The codec is HMAC-SHA256 signed, not encrypted. Its `v1.<body>.<mac>` body is
client-readable base64url JSON.
- `bind` receives `ServerContext` at mint and verify time. It binds an arbitrary
context-derived string by storing a truncated, domain-separated HMAC tag.
The PoC binds the originating MCP method and separately checks the principal
and argument digest in the required handler order.
- HTTP headers are available as `ctx.http.req.headers` in a tool handler.
`createMcpHandler` also passes the original request to the factory as
`requestInfo`. This PoC mock-decodes `Authorization: Bearer <token>` by using
the raw token as `sub`; no header means `anonymous`.
- A codec verification rejection is handled above the tool callback as JSON-RPC
`-32602` (`Invalid or expired requestState`). Principal, argument, and replay
policy rejections happen inside the tool callback and return tool results
with `isError: true`.
- The exact intermediate wire discriminator observed in the happy-path test is
`input_required`. The public `client.callTool()` result omits `resultType`, so
the harness captures raw HTTP response frames.
- The client option is `capabilities`, with
`{ elicitation: { form: {} } }`; the SDK writes this under
`_meta["io.modelcontextprotocol/clientCapabilities"]`. Auto-fulfilment is on
by default, uses `client.setRequestHandler("elicitation/create", handler)`,
and echoes `requestState` while retrying with a fresh JSON-RPC id.
- The SDK's typed `ElicitResult.content` index is narrower than the harness
contract: it accepts only primitive form values (`string`, `number`,
`boolean`, or `string[]`), while the requested harness callback exposes
`Record<string, unknown>`. The harness casts only at that adapter boundary;
the server validates accepted content with Zod.
- `createMcpHandler` returns the documented web-standard
`{ fetch, close, notify, bus }`. `@modelcontextprotocol/node` exports
`toNodeHandler` for the runnable `node:http` entry.
- The request-state key comes from `POC_STATE_KEY` or is generated once per
process. A repo literal was unacceptable because it let clients mint valid
request states without possessing a deployed server's key.
40 changes: 40 additions & 0 deletions packages/mcp-elicitations-poc/NOTES.risk2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Risk 2: token-property outcomes

Results are recorded from the package-scoped `token-properties` test suite.

| Property | Outcome | Observed rejection surface and message |
| --- | --- | --- |
| Middle-character tampering | Pass | JSON-RPC error `-32602`: `Invalid or expired requestState` |
| Decode, alter, and re-encode payload | Pass | JSON-RPC error `-32602`: `Invalid or expired requestState` |
| Expired state (`ttlSeconds: 1`, checked after 2.1 seconds) | Pass | JSON-RPC error `-32602`: `Invalid or expired requestState` |
| Different principal | Pass | Tool result with `isError: true`: `Request state principal does not match the current principal.` |
| Different arguments | Pass | Tool result with `isError: true`: `Request state arguments do not match the current arguments.` |
| Payload signed with attacker key | Pass | JSON-RPC error `-32602`: `Invalid or expired requestState` |
| Legacy token precomputed without a server round trip | Pass, expected contrast | No rejection. A non-declaring client creates the project in one call. |

Every rejection case also mints a fresh state and successfully creates a project
afterward. A rejected token therefore does not wedge the server.

## Payload visibility

The `v1.<body>.<mac>` body is base64url JSON and was decoded client-side. Its
application payload is under `p`; `sub`, `tool`, `argsDigest`, `cost`, `jti`,
and `iat` are readable. The envelope also exposes `exp` and the method-binding
tag `b`. This confirms the state is signed, not encrypted. Confidential values
must not be placed in it.

The expiry comparison has integer-second boundary behavior. A 1.5-second wait
can still land on the accepted boundary for a one-second TTL, so the stable test
waits 2.1 seconds.

## RFC design impact

The signed MRTR state prevents the `confirm_cost` precompute attack because a
client cannot produce a valid MAC without the server key. The legacy fallback
retains that attack by design: its confirmation token is a deterministic,
publicly computable digest and can be supplied on the first request.

The RFC should state both points explicitly: request state provides integrity,
not confidentiality, and retaining the legacy path retains the precompute
property for clients that use it. No server or harness gaps blocked these
assertions.
23 changes: 23 additions & 0 deletions packages/mcp-elicitations-poc/NOTES.risk3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Risk 3: requestState replay and jti dedupe

## Test outcomes

- **Replay residual:** With no jti store, the same accepted retry creates the
project twice. A valid requestState is replayable within its TTL.
- **In-memory dedupe:** A single `InMemoryJtiStore` accepts the first redemption,
rejects the second as a replay, and leaves one project in the registry.
- **Multi-instance gap:** Two instances with the same state key but separate
in-memory stores each accept the same requestState. In-memory dedupe therefore
protects only one instance.
- **Shared-store fix:** Two instances sharing one jti store reject the replay on
the second instance. The shared in-memory object stands in for a durable shared
store such as Redis.
- **Re-issue boundary:** With dedupe enabled, a retry missing `inputResponses`
consumes its current jti and receives a fresh requestState. That fresh state can
still be completed, so legitimate multi-round flows are not blocked.

## RFC recommendation

Production deployments should use a shared jti consumption store and fail closed
when that store is unavailable. The PoC demonstrates shared-store semantics, but
does not implement or test fail-closed behavior on store outage.
27 changes: 27 additions & 0 deletions packages/mcp-elicitations-poc/NOTES.risk4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Risk 4: capability-gating findings

1. A client that does not declare elicitation receives a normal
`confirmation_required` tool result and a `confirm_cost_token`. Its captured
responses contain neither `inputRequests` nor `input_required`. Retrying with
the token creates exactly one project.
2. A declaring client receives one form elicitation. The message includes
`$10/month`, and accepting it creates exactly one project. The intermediate
wire result has one `inputRequests` entry, `confirm_cost`. That entry is an
`elicitation/create` request whose params have mode `form` and a schema with
a boolean `confirm` property.
3. The exact observed SDK wire discriminator is **`input_required`**. The public
tool result hides it, so the test pins the value from the raw response frame.
4. A precomputed legacy token does not bypass elicitation for a capable client.
The handler deliberately chooses the capability branch first and ignores the
token there. The project does not exist when the responder runs, and is
created only after acceptance. This makes “capable clients must elicit” the
current policy answer, though the RFC should confirm it explicitly.
5. A non-declaring raw request cannot redeem valid state and accepted responses
minted for a declaring request. It is not rejected: the handler ignores both
fields and returns the normal legacy `confirmation_required` result with a
token. No project is created. Cross-capability redemption should be called
out as an RFC edge case.

The PoC handler enforces capability gating. It checks per-request
`_meta` client capabilities before calling `inputRequired`; the SDK does not
refuse first because no input-required result is produced for it to process.
Loading
Loading