diff --git a/.github/workflows/elicitations-poc.yml b/.github/workflows/elicitations-poc.yml new file mode 100644 index 00000000..7ac725a5 --- /dev/null +++ b/.github/workflows/elicitations-poc.yml @@ -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 diff --git a/packages/mcp-elicitations-poc/FINDINGS.md b/packages/mcp-elicitations-poc/FINDINGS.md new file mode 100644 index 00000000..ef26d9dd --- /dev/null +++ b/packages/mcp-elicitations-poc/FINDINGS.md @@ -0,0 +1,116 @@ +# MCP Elicitations PoC findings + +## Summary + +The PoC supports proceeding to an RFC: form-mode multi round-trip request (MRTR) elicitation can replace `confirm_cost` for capable clients, preserve a legacy fallback for other clients, and carry signed state without server-side conversation state; URL-mode secret handling was also exercised with a mock connect page and fake sessions. The RFC must treat the state as readable, require shared single-use enforcement for replay protection, specify fail-closed store behavior and production key management, and define the capability-policy edges. Inspector CLI coverage is insufficient for manual MRTR validation. + +## 1. Happy path: PASS + +**Asserted:** [`test/happy-path.test.ts`](test/happy-path.test.ts), suite `create_project MRTR happy path`, proves that acceptance creates exactly one project; decline returns a normal, agent-readable non-error result and creates none; cancellation is distinct from decline; and absent or empty `inputResponses` reissues `input_required` with fresh state and no side effect. + +**Surprises and RFC impact:** The exact intermediate wire discriminator is `input_required`. The public `client.callTool()` result omits `resultType`, so the test observes raw HTTP frames. Missing responses are a normal additional round, not an error; the RFC should require fresh state when reissuing. + +## 2. Token properties: PASS-WITH-SURPRISES + +**Asserted:** [`test/token-properties.test.ts`](test/token-properties.test.ts), suite `requestState token security properties`, rejects a single-character mutation, a decoded-and-re-encoded payload mutation, expiry, redemption by another principal, changed tool arguments, and a payload signed with an attacker key. Each rejection is followed by a successful fresh flow. The suite also proves the contrast: a non-declaring client can precompute the legacy deterministic token and create a project in one call. + +**Surprises and RFC impact:** The SDK codec provides HMAC-SHA256 integrity, not AEAD encryption. The test decodes the client-readable body and observes state fields including principal, tool, cost, argument digest, and `jti`; [`NOTES.risk2.md`](NOTES.risk2.md) records the envelope fields too. State must contain no secrets. Expiry uses integer-second boundaries. The RFC should state that MRTR prevents client precomputation only while the signing key remains server-held, and that the legacy path intentionally retains its precompute property. + +## 3. Replay residual and dedupe: PASS-WITH-SURPRISES + +**Asserted:** [`test/replay-dedupe.test.ts`](test/replay-dedupe.test.ts), suite `requestState replay and jti dedupe`, proves that valid state is replayable within its TTL without a `jti` store; one in-memory store rejects a second redemption; two instances with separate in-memory stores both accept the same state; two instances sharing a store reject the cross-instance replay; and a missing-response round can consume the old `jti`, receive fresh state, and still complete. + +**Surprises and RFC impact:** Signed state alone is not single-use. A `jti` policy requires an atomic, shared consumption store across all instances. The shared object in this PoC demonstrates the required semantics, not a production store. Store-outage behavior was not implemented or tested; the RFC must require fail-closed behavior. + +## 4. Capability gating: PASS-WITH-SURPRISES + +**Asserted:** [`test/capability-gating.test.ts`](test/capability-gating.test.ts), suite `risk 4: capability gating`, proves that a non-declaring client receives only the legacy confirmation flow; a declaring client receives exactly one `$10/month` form request and creates one project after acceptance; the wire discriminator is exactly `input_required`; a precomputed legacy token cannot bypass elicitation for a capable client; and state plus accepted responses minted on a declaring request do not create a project when replayed without the capability. + +**Surprises and RFC impact:** Gating is server-side policy based on per-request client metadata, not an automatic SDK rejection: the SDK's own gate is a hard 400 (`-32021`), so a graceful fallback has to be authored. This PoC does it inside the single tool it ships, which is fine at PoC scale but is NOT the shape to carry forward. The approved design puts effective-form-support resolution and authority-path routing in the shared server runtime, so business tools only declare a policy and never inspect capabilities. The current policy prioritizes elicitation for capable clients and ignores a supplied legacy token. A cross-capability redemption is also ignored and returns a fresh legacy confirmation rather than an error. The RFC should make both choices explicit. + +## 5. Inspector stretch: PASS + +**Asserted/observed:** This risk has no automated test suite. [`NOTES.risk5.md`](NOTES.risk5.md) records two manual Inspector 2.0.0 runs. CLI: connected over Streamable HTTP, listed `create_project`, and negotiated MCP `2026-07-28` when configured with `protocolEra: "modern"`. Web UI (browser-driven follow-up): with the server's Protocol Era set to Modern, `create_project` paused at an "Elicitation Request" modal showing the `$10/month` message, `input_required` tag, and required `confirm` checkbox; accepting completed the MRTR retry and created the mock project (2 rounds, completed). + +**Surprises and RFC impact:** The CLI does not advertise form elicitation, so the server correctly falls back to the legacy path; it has no option to advertise the capability or submit a form response, so headless CI needs a capable programmatic client. The web UI completes the form-elicitation MRTR flow end to end, but its Protocol Era defaults to Legacy per server: manual testers must switch it to Modern or they will silently exercise the 2025-era path. + +## 6. URL-mode elicitation (secret handling): PASS-WITH-SURPRISES + +**Asserted:** [`test/url-happy-path.test.ts`](test/url-happy-path.test.ts), [`test/url-security.test.ts`](test/url-security.test.ts), and [`test/url-lifecycle.test.ts`](test/url-lifecycle.test.ts) prove that a URL-capable client receives a `mode: "url"` request with `url` and no `requestedSchema`; [`src/url-server.ts`](src/url-server.ts) also supplies the required `message`. An accepted retry reissues fresh state for the same opaque interaction ID while pending, then completes after the out-of-band submission, so `accept` records consent to open rather than completion. Two separate claims about secret isolation, deliberately not conflated. **By design:** the result schema contains only an opaque `secret_ref` and no credential-derived field at all, asserted structurally (`not.toHaveProperty('last4')`) and readable in `src/url-server.ts`. **By scan:** no plaintext substring of the sentinel four characters or longer, and no base64, base64url, or URI encoding of the full value or its last eight characters, appears in any captured frame in either direction. The scan is finite: shorter plaintext fragments and untested encodings of arbitrary slices are outside it, so it is regression detection for the design choice, not a proof of absence. The URL contains only the opaque `i` identifier. Missing, unknown, and mismatched mock sessions cannot open or submit Alice's interaction; the URL grants no authority. Repeated pending accepts re-prompt without an error, and decline or cancel remains available. The suites also prove interaction expiry, one-time redemption, and `elicitation.url` gating: form-only and capability-free clients receive `unsupported_client` without seeing a URL request. + +**Surprises and RFC impact:** URL-mode completion is application state. The PoC correlates the pending interaction with the principal, then stores the credential by principal and name; MRTR remains stateless because the retry can use echoed signed `requestState` to decide completion. Inspector 2.0.0's web UI renders the full URL, waits for explicit consent, and offers the spec's manual "I've completed it" control without polling. **That full round is now verified by hand (2026-08-03):** clicking "I've completed it" after the out-of-band submission closed the round, returning `Stored API key "openai-key".` with the MRTR conversation settling at 2 rounds complete. The earlier automation-only gap is closed. Form mode was verified the same way, returning `Created project "test_project".` at 2 rounds. + +**One observed counterexample to a shared 120 second lifetime.** That manual URL round took about 175 seconds of wall time between round 1 (13:27:30) and round 2 (13:30:25) at an unhurried human pace: open the page, authenticate, locate the key, paste it, return to the client. It survived because the PoC's URL flow uses a 300 second interaction lifetime (`src/url-server.ts:75,81`) against 120 seconds for form mode (`src/server.ts:126`). The approved design pins Continuation State at "120 seconds and cannot be configured above 120 seconds", so this particular run would have expired mid-flow under that cap. This is a single observation, not a measured minimum: it does not establish what the right Secret Collection lifetime is, only that the shared 120 second cap needs a deliberate per-policy decision before Secret Collection could ship. It also reinforces the design's own sequencing, since longer-lived Elicitation state is already blocked until the dedicated request-state secret replaces `JWT_SECRET`. + +## Explicit RFC flags + +### SDK API gaps and naming + +Observed against `@modelcontextprotocol/server` 2.0.0 and recorded in [`NOTES.md`](NOTES.md): + +- The expected helper names `inputRequired`, `acceptedContent`, and `createRequestStateCodec` matched. The client auto-fulfil loop also worked: a handler registered for `elicitation/create` echoes `requestState` and retries with a fresh JSON-RPC id. +- Related actual APIs are `inputResponse(...)`, `codec.mint(...)`, and `ServerOptions.requestState.verify`. `mint` is async. The client option is named `capabilities`, and the SDK serializes it under `_meta["io.modelcontextprotocol/clientCapabilities"]`. +- `acceptedContent(responses, key, schema)` performs schema validation and returns typed content or `undefined`. The typed client form-content index permits only primitive form values, narrower than the harness's `Record` callback contract; the harness casts at that boundary and the server validates with Zod. +- `createMcpHandler` returns web-standard `{ fetch, close, notify, bus }`; the runnable Node adapter is `toNodeHandler` from `@modelcontextprotocol/node`. +- The codec is HMAC-signed, not AEAD-encrypted. Its payload is client-readable, so state contents must not be secret. + +Codec verification failures, including tampering, expiry, and an invalid MAC, surface as JSON-RPC `-32602` with `Invalid or expired requestState`. Principal, argument, and replay-policy failures occur in the tool callback and return an `isError: true` tool result. The former may look like a protocol/request failure to an agent; the latter can carry policy-specific, agent-readable recovery text. The RFC should decide whether this split is acceptable for agent UX. + +### Exact discriminator + +The observed intermediate `resultType` value is exactly: + +```text +input_required +``` + +This is pinned by `pins the SDK's observed intermediate result discriminator` in [`test/capability-gating.test.ts`](test/capability-gating.test.ts) and also observed in the happy-path suite. + +### What Human Confirmation does and does not prove + +The PoC proves the server side of the claim and bounds it precisely. Continuation State +verification establishes **integrity and binding**: the state was minted by this server, +has not been tampered with, has not expired, is presented by the same Approver, matches +the same Confirmed Action, and has not already been redeemed on this process. None of +that establishes **human presence**. + +MRTR moves the interaction to the client by design, so the client decides whether a human +is involved. Claude Code's MCP documentation describes an `Elicitation` hook that can +return `accept` with form content and skip the dialog, and an `ElicitationResult` hook +that can override a user's response. Default behaviour is interactive, but automation is a +supported client feature, not an abuse of one. This is read from vendor documentation and +was not exercised against this PoC. + +Consequences for the RFC and for external wording: + +- "The approval cannot be forged or precomputed" is supportable: it is a cryptographic + property of the state, and the precompute contrast test demonstrates it. +- "A real human approved this" is not supportable from the server alone. It requires an + explicit trusted-client-policy assumption, and should be worded as approval conveyed + through a conforming client, which is what the design already says. +- The gap is not closed by the legacy sunset. Capability downgrade and client-side + auto-answer are separate paths, and only the first is measured by adoption telemetry. +- Asymmetry, stated carefully: form acceptance is a boolean a client can synthesise, + whereas Secret Collection completion attests that **an authenticated out-of-band write + occurred**. That is a stronger statement about the side effect, but it is still not + human presence: a companion process holding a valid session could read a secret from + the environment or a keychain and POST it to the connect endpoint without a person and + without exposing it to the model. Moving the interaction out of band does not by itself + produce attested human approval. That requires an explicit human-presence control on + the page, such as reauthentication or a WebAuthn step-up, which nothing in this PoC + implements or tests. + +### Design consequences + +- State-in-token works without server-side conversation state. [`src/server.ts`](src/server.ts) embeds version, principal, tool, argument digest, cost, `jti`, and issue time; the codec adds expiry and method binding. +- Single-use `jti` enforcement requires an atomic shared consumption store in multi-instance deployments. The two-instance tests prove the gap and the shared-store semantics. +- Fail-closed behavior during consumption-store outage was not implemented here. The RFC must specify it. +- The PoC key comes from `POC_STATE_KEY` or one random per-process value. Production needs a real server-held secret and a rotation design. All instances that redeem the same state need compatible keys during rotation. +- Inspector CLI can negotiate `2026-07-28` but sends no form-elicitation capability and therefore exercises only the legacy path. The web UI completes the form MRTR flow end to end (verified against this PoC); its per-server Protocol Era defaults to Legacy and must be set to Modern. +- URL mode requires application storage for pending-interaction correlation and for the credential bound to its principal. The real connect page must derive that principal from an authenticated dashboard session and compare it with the interaction record; the PoC cookie is only a fake session. +- The RFC must decide whether any credential fingerprint belongs in a tool result. It can help a user identify a stored key, but it exposes credential material to model context, transcripts, and logs. +- This application state is not an MRTR transport requirement. MRTR remains stateless, and the server can decide completion from the signed `requestState` echoed by the client. + +## Scope + +The PoC's tests are fully mocked, but an env-gated opt-in adapter (`MANAGEMENT_API_URL`/`MANAGEMENT_API_TOKEN`, restricted to HTTPS and `*.supabase.green` hosts) was added for manual staging validation; it is off by default and exercised only by stubbed-fetch tests. URL-mode elicitation used a mock connect page, a cookie-based fake session, and in-memory interaction and secret stores. It did not use a real Supabase auth session, a real secret manager, a dashboard-hosted page, or a third-party OAuth flow. Legacy stateful transports were out of scope. Only project creation at $10/month was exercised; branch pricing at $0.01344/hour was not. This package pins SDK 2.0.0, while the rest of the PR #327 base monorepo remains on 2.0.0-beta.3. diff --git a/packages/mcp-elicitations-poc/NOTES.md b/packages/mcp-elicitations-poc/NOTES.md new file mode 100644 index 00000000..7f978419 --- /dev/null +++ b/packages/mcp-elicitations-poc/NOTES.md @@ -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 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 ` 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`. 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. diff --git a/packages/mcp-elicitations-poc/NOTES.risk2.md b/packages/mcp-elicitations-poc/NOTES.risk2.md new file mode 100644 index 00000000..d44e3128 --- /dev/null +++ b/packages/mcp-elicitations-poc/NOTES.risk2.md @@ -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 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. diff --git a/packages/mcp-elicitations-poc/NOTES.risk3.md b/packages/mcp-elicitations-poc/NOTES.risk3.md new file mode 100644 index 00000000..2ef4b8ac --- /dev/null +++ b/packages/mcp-elicitations-poc/NOTES.risk3.md @@ -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. diff --git a/packages/mcp-elicitations-poc/NOTES.risk4.md b/packages/mcp-elicitations-poc/NOTES.risk4.md new file mode 100644 index 00000000..ed7410e5 --- /dev/null +++ b/packages/mcp-elicitations-poc/NOTES.risk4.md @@ -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. diff --git a/packages/mcp-elicitations-poc/NOTES.risk5.md b/packages/mcp-elicitations-poc/NOTES.risk5.md new file mode 100644 index 00000000..0a5be4e4 --- /dev/null +++ b/packages/mcp-elicitations-poc/NOTES.risk5.md @@ -0,0 +1,348 @@ +# Risk 5: MCP Inspector 2.0 CLI + +Date tested: 2026-07-31 + +## Verdict + +Inspector 2.0.0 can connect to this server over Streamable HTTP, list +`create_project` with its schema, and negotiate the modern `2026-07-28` +protocol revision when `protocolEra: "modern"` is set in server config. + +The non-interactive CLI did **not** exercise the PoC's MRTR path. CLI mode +constructs its client with elicitation disabled, so it does not advertise +`capabilities.elicitation.form`. This server detects that and returns its +legacy `confirmation_required` result instead of an MRTR `input_required` +result. The CLI prints that result and exits successfully. It does not pause, +show a pending form, print a raw `InputRequiredResult`, or error. + +There are no CLI help flags for advertising form elicitation or supplying an +MRTR form response. The legacy token can be copied into a second call, but +that proves only the PoC fallback, not MRTR. + +Therefore: + +- **2026-07-28 protocol, CLI:** yes, when explicitly configured for the modern + era. An ad-hoc URL invocation defaults to the legacy era. +- **MRTR, non-interactive CLI against this capability-gated PoC:** not + completable or directly observable. +- **Web UI:** the packaged README explicitly describes manual MRTR support: + `inputRequired: { autoFulfill: false }`, an `input_required` pending-request + modal, a user-supplied form response, and retry to completion. I did not + launch a browser, so that UI behavior was not independently verified here. + +## Version and endpoint + +- Inspector: `@modelcontextprotocol/inspector@2.0.0` +- Inspector's packaged dependencies include + `@modelcontextprotocol/{client,core,server}@2.0.0-beta.5`. +- Server endpoint, from `src/main.ts` and startup output: + `http://localhost:3900/mcp` +- Node requirement printed in the package metadata: `>=22.19.0` + +The initial `npx` attempt hit a root-owned default npm cache. All subsequent +Inspector commands used `npm_config_cache=/private/tmp/risk5-npm-cache`; no +repository dependency or user npm cache was changed. + +## Commands and transcripts + +### CLI help + +```sh +npm_config_cache=/private/tmp/risk5-npm-cache \ + npx --yes @modelcontextprotocol/inspector@2.0.0 --help +``` + +Trimmed output: + +```text +Usage: mcp-inspector [options] + +MCP Inspector – run web UI, CLI, or TUI + +Options: + --web Run web UI (default) + --cli Run CLI + --tui Run TUI +``` + +```sh +npm_config_cache=/private/tmp/risk5-npm-cache \ + npx --yes @modelcontextprotocol/inspector@2.0.0 --cli --help +``` + +Relevant output: + +```text +Usage: inspector-cli [options] [target...] + --method + --tool-name + --tool-arg + --metadata + --tool-metadata + --format + --tool-args-json +``` + +No elicitation, pending-request, MRTR-response, or protocol-era option is +exposed as a direct CLI flag. Protocol era is a per-server config field. + +### Start server + +```sh +pnpm --filter @supabase/mcp-elicitations-poc dev +``` + +Output: + +```text +> @supabase/mcp-elicitations-poc@0.0.0 dev +> tsx src/main.ts + +MCP Elicitations PoC listening on http://localhost:3900/mcp +``` + +The process was stopped with Ctrl-C after testing. + +### List tools, ad-hoc URL + +```sh +npm_config_cache=/private/tmp/risk5-npm-cache \ +MCP_CATALOG_PATH=/private/tmp/risk5-mcp-catalog.json \ +MCP_CLIENT_CONFIG_PATH=/private/tmp/risk5-client.json \ + npx --yes @modelcontextprotocol/inspector@2.0.0 --cli \ + http://localhost:3900/mcp \ + --method tools/list --format json +``` + +Output, formatted and trimmed only to remove Node warnings: + +```json +{ + "result": { + "tools": [{ + "name": "create_project", + "description": "Create a mock Supabase project.", + "inputSchema": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "organization_id": { "type": "string" }, + "confirm_cost_token": { "type": "string" } + }, + "required": ["name", "organization_id"], + "$schema": "https://json-schema.org/draft/2020-12/schema" + } + }] + } +} +``` + +### Call tool, ad-hoc URL + +```sh +npm_config_cache=/private/tmp/risk5-npm-cache \ +MCP_CATALOG_PATH=/private/tmp/risk5-mcp-catalog.json \ +MCP_CLIENT_CONFIG_PATH=/private/tmp/risk5-client.json \ + npx --yes @modelcontextprotocol/inspector@2.0.0 --cli \ + http://localhost:3900/mcp \ + --method tools/call \ + --tool-name create_project \ + --tool-args-json \ + '{"name":"risk5-cli-project","organization_id":"org_risk5"}' \ + --format json +``` + +Exact stdout, line-wrapped: + +```json +{ + "result": { + "content": [{ + "type": "text", + "text": "Confirmation required. Retry with the supplied confirm_cost_token." + }], + "structuredContent": { + "status": "confirmation_required", + "confirm_cost_token": "bd0e0103516520447bb35122c84c7f3c60cc6c92fac637c0ea7b256c8dbf609b" + } + } +} +``` + +Exit code: `0`. It printed immediately and did not pause. + +### Explicit modern-era call + +The equivalent read-only Inspector config used for this run was: + +```json +{ + "mcpServers": { + "poc": { + "url": "http://localhost:3900/mcp", + "type": "streamable-http", + "protocolEra": "modern" + } + } +} +``` + +```sh +npm_config_cache=/private/tmp/risk5-npm-cache \ +MCP_CLIENT_CONFIG_PATH=/private/tmp/risk5-client.json \ + npx --yes @modelcontextprotocol/inspector@2.0.0 --cli \ + --config packages/mcp-elicitations-poc/NOTES.risk5.md \ + --server poc \ + --method tools/call \ + --tool-name create_project \ + --tool-args-json \ + '{"name":"risk5-modern-project","organization_id":"org_risk5"}' \ + --format json +``` + +At execution time `NOTES.risk5.md` temporarily contained the JSON config +above; it was then replaced with these notes. + +Exact stdout, line-wrapped: + +```json +{ + "result": { + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "mcp-elicitations-poc", + "version": "0.0.0" + } + }, + "content": [{ + "type": "text", + "text": "Confirmation required. Retry with the supplied confirm_cost_token." + }], + "structuredContent": { + "status": "confirmation_required", + "confirm_cost_token": "b9ebb773d7960c9860aea2622e471c05a46d8c1b4dd6bf05418aaeb25d4181d0" + } + } +} +``` + +Exit code: `0`. Explicit modern negotiation did not change the result because +the CLI still did not advertise form elicitation. + +### Legacy fallback completion + +```sh +npm_config_cache=/private/tmp/risk5-npm-cache \ +MCP_CLIENT_CONFIG_PATH=/private/tmp/risk5-client.json \ + npx --yes @modelcontextprotocol/inspector@2.0.0 --cli \ + --config packages/mcp-elicitations-poc/NOTES.risk5.md \ + --server poc \ + --method tools/call \ + --tool-name create_project \ + --tool-args-json \ + '{"name":"risk5-modern-project","organization_id":"org_risk5","confirm_cost_token":"b9ebb773d7960c9860aea2622e471c05a46d8c1b4dd6bf05418aaeb25d4181d0"}' \ + --format json +``` + +Trimmed result: + +```json +{ + "result": { + "content": [{ + "type": "text", + "text": "Created project \"risk5-modern-project\"." + }], + "structuredContent": { + "status": "created", + "project": { + "name": "risk5-modern-project", + "organization_id": "org_risk5", + "cost": { "amount": 10, "recurrence": "monthly" } + } + } + } +} +``` + +This was a second independent CLI invocation using the server's fallback +token. It was not an MRTR retry carrying `requestState` and `inputResponses`. + +### Capability injection attempt and protocol evidence + +I also checked whether generic `--metadata` could inject the required client +capability: + +```sh +npm_config_cache=/private/tmp/risk5-npm-cache \ +MCP_CLIENT_CONFIG_PATH=/private/tmp/risk5-client.json \ + npx --yes @modelcontextprotocol/inspector@2.0.0 --cli \ + --config packages/mcp-elicitations-poc/NOTES.risk5.md \ + --server poc \ + --method tools/call \ + --tool-name create_project \ + --tool-args-json \ + '{"name":"risk5-forced-capability","organization_id":"org_risk5"}' \ + --metadata \ + 'io.modelcontextprotocol/clientCapabilities={"elicitation":{"form":{}}}' \ + --format json +``` + +Exact stdout: + +```json +{ + "error": { + "code": "error", + "message": "Invalid _meta envelope for protocol revision 2026-07-28: io.modelcontextprotocol/clientCapabilities: Invalid input: expected object, received string" + } +} +``` + +Exit code: `1`. This is direct evidence that the configured run negotiated +`2026-07-28`. It also shows that `--metadata key=value` treats the value as a +string, so it cannot be used as a hidden JSON-valued capability workaround. + +## Where the CLI stops + +For this PoC, the CLI stops after the first ordinary `tools/call` response. +The server sees no `elicitation.form` client capability and never emits +`resultType: "input_required"`. Consequently there is no MRTR request state, +embedded `confirm_cost` elicitation, pending prompt, or form-answer retry for +the CLI to process. + +The team's manual testing story should use Inspector's web UI in Modern +protocol mode for the actual MRTR form flow. The CLI remains useful for +connectivity, schema discovery, protocol-era checks, and verifying the +non-capable-client fallback. Headless CI coverage of the real MRTR path needs +a capable programmatic client unless a later Inspector CLI adds capability +and response flags. + +## Web UI verification (2026-07-31 follow-up, orchestrator-driven browser session) + +The gap above ("web UI not independently verified") is now closed. A headless +Chromium session drove Inspector 2.0.0's web UI end to end against +`http://localhost:3900/mcp`: + +1. Added the server manually (transport `streamable-http`), Server Settings → + Protocol Era → "Modern (2026-07-28, sessionless)" (default is Legacy; the + first connect negotiated `MCP 2025-11-25` until the era was switched and + the server reconnected, after which the card showed `MCP 2026-07-28`). +2. Tools tab → `create_project` → name `inspector-demo`, organization_id + `org-1` → Execute Tool. +3. The call paused ("Awaiting input"); the monitoring sidebar showed an + "MRTR conversation" entry carrying the `v1..` requestState. +4. A modal `dialog "Elicitation Request"` appeared with: the exact server + message (`Creating project "inspector-demo" costs $10/month. Do you + confirm?`), an `input_required` tag ("your answer is sent back as a retry + of the original request (MRTR)"), the `confirm` checkbox (Submit disabled + until checked — required-field enforcement from requestedSchema), a trust + warning naming the requesting server, and Cancel / Decline / Submit. +5. Checking `confirm` and submitting completed the retry: Results panel + showed `Created project "inspector-demo".` and the MRTR conversation + settled at 2 rounds, completed. + +Verdict update: Inspector 2.0.0 **web UI** fully supports the 2026-07-28 +form-elicitation MRTR flow, verified against this PoC. The principal was +`anonymous` (no Authorization header configured in the UI session), so +principal binding was consistent across both legs. diff --git a/packages/mcp-elicitations-poc/NOTES.risk6-inspector.md b/packages/mcp-elicitations-poc/NOTES.risk6-inspector.md new file mode 100644 index 00000000..c993453b --- /dev/null +++ b/packages/mcp-elicitations-poc/NOTES.risk6-inspector.md @@ -0,0 +1,105 @@ +# Risk 6: Inspector 2.0 web UI vs URL-mode elicitation + +Date tested: 2026-08-03. Method: headless Chromium driving Inspector 2.0.0's web UI +(`npx @modelcontextprotocol/inspector`, page at `localhost:6274`) against the PoC's +url-mode MCP endpoint (`http://localhost:3902/mcp`), connect page on `:3901`. +Recorded by the orchestrator (not a worker: workers have no browser). + +## Verdict + +Inspector 2.0's web UI **does support URL-mode elicitation on 2026-07-28**, and its +implementation matches the spec's client MUSTs/SHOULDs that we care about. + +## Verified by direct observation + +1. **Connects modern.** Server added as `streamable-http`, per-server Protocol Era set + to "Modern (2026-07-28, sessionless)"; the card then reported `MCP 2026-07-28`. + (Default is Legacy — same gotcha as form mode.) +2. **URL-mode capability is declared.** Our handler only emits a url elicitation to a + client declaring `elicitation.url`; Inspector received one, so it declares it. + (Its CLI does not — see `NOTES.risk5.md`.) +3. **The modal renders correctly.** `dialog "Elicitation Request"` containing: + - our exact message: *Open this page to enter your API key for "openai-key". It is + stored by Supabase and never passes through your MCP client.* + - an `input_required` tag plus the explanation "your answer is sent back as a retry + of the original request (MRTR)" + - **the full URL displayed as text**, not a bare link: + `http://localhost:3901/connect?i=439fb04b-b4a2-4a56-a6ed-b99dfe7f598d` + (spec MUST: show the full URL for examination) + - `Copy URL` and `Open in Browser` buttons — **no auto-navigation, no prefetch** + (spec MUSTs: explicit consent, no automatic open) + - a warning: "This will open an external URL. Verify the domain before proceeding." + - `Cancel` + - a `Request ID: elicitation-` line (Inspector synthesizes an id for display) +4. **No premature accept.** Before consent the MRTR conversation in the monitoring + sidebar stayed at **1 round / "Awaiting input"** — Inspector does not send + `{action:'accept'}` until the user acts. +5. **After clicking "Open in Browser"**: the button relabels to `Reopen in Browser`, the + modal adds **"Waiting for completion..."**, and a new button appears: + **"I've completed it"**. So Inspector does NOT poll or auto-advance; it implements + exactly the spec's "clients SHOULD provide manual controls that let the user retry or + cancel" — the retry fires when the user asserts the out-of-band work is done. +6. **The out-of-band leg works against the mock connect page** (driven in a second tab): + - no session cookie → "A valid mock dashboard session is required." (the URL alone + grants nothing; spec MUST: not pre-authenticated) + - with the matching session cookie → the key form renders + - submitting → "Your API key is stored. You can return to your client." + The secret was typed only into the connect page, never into any MCP field. + +## NOT verified (honest gap) + +The **final click of "I've completed it" through the UI** was not observed to close the +round. My first click timed out at the automation layer (8s) and a retry hung the cell; +afterwards the Inspector client showed "Tool Call Failed / Not connected". The PoC +server process stayed healthy throughout (its log shows no error and it kept serving), +so this looks like automation/transport flakiness in the headless session rather than an +Inspector or server defect — but I did not reproduce it cleanly, so treat +"accept-after-completion completes the round **in Inspector**" as unverified. + +The equivalent server-side path IS covered programmatically: the url happy-path and +lifecycle suites drive accept-while-pending → re-issue → connect-page completion → +accept → `{ status: 'stored' }`. + +By elapsed time the captured `requestState` (exp `1785757210`, ttl 300s) had expired, so +a replay probe to determine whether that click had in fact reached the server would have +returned "expired" and been inconclusive. Not attempted. + +## Practical notes for the team's demo plan + +- Set Protocol Era to **Modern** per server, or you silently test the 2025 path. +- URL mode needs the **web UI**; the CLI declares no elicitation capability at all. +- The demo has two windows by nature: the client (modal) and the browser (connect page). + Inspector's "I've completed it" is the hand-off point between them. + +## Manual confirmation, 2026-08-03 (by Barry, in Chrome) + +The "NOT verified" gap above is closed. Both browser flows completed by hand: + +- **Form mode:** `Created project "test_project".`, MRTR conversation 2 rounds, + round 1 `INPUT REQUIRED` 13:24:08 (26ms), round 2 `COMPLETE` 13:24:28 (10ms). +- **URL mode:** `Stored API key "openai-key".`, MRTR 2 rounds, round 1 + `INPUT REQUIRED` 13:27:30 (19ms), round 2 `COMPLETE` 13:30:25 (24ms), after + clicking "I've completed it". So the manual completion control does close the + round; my earlier headless failure was automation flakiness, not a defect. + +## Browser support: Inspector's Tools view hung in Safari in this run + +The earlier "empty Tools panel" symptom appeared in **Safari** and not in Chrome. In Safari, +`server/discover` succeeds and the follow-up `tools/list` hangs client-side, so no +tool is ever listed. Ruled out by elimination against the live server: `tools/list` +answers in 10-20ms over fresh and keep-alive connections, on both IPv4 and IPv6, +with and without `logLevel`/`progressToken`; `subscriptions/listen` returns +immediately; discover advertises only `tools` (so the `prompts/list` and +`resources/list` errors are expected `-32601`s). The identical flow renders +correctly in Chromium against the same Inspector process and the same server. + +Runbook consequence: the canary and any demo must specify Chrome. A Safari user sees +"no tools available" and would reasonably blame the server. + +## Timing observation worth carrying to the RFC + +The URL round above spent ~175s between rounds at an ordinary human pace. The PoC's +URL flow allows 300s (`src/url-server.ts:75,81`); form mode uses 120s +(`src/server.ts:126`). Under the approved design's fixed 120s cap this run would +have expired. Single observation, not a measured minimum: it argues for a +per-policy lifetime decision, not for a specific number. diff --git a/packages/mcp-elicitations-poc/NOTES.risk6-lifecycle.md b/packages/mcp-elicitations-poc/NOTES.risk6-lifecycle.md new file mode 100644 index 00000000..d0928467 --- /dev/null +++ b/packages/mcp-elicitations-poc/NOTES.risk6-lifecycle.md @@ -0,0 +1,52 @@ +# Risk 6: URL lifecycle + +## Outcomes + +- A pending accept returns `input_required`. Each retry has fresh request state and the same interaction ID. +- Three pending accepts succeed without an error. The fourth accept stores the completed secret. +- A missing `provide_api_key` response and an empty response map both reissue the request. +- An expired connect page returns HTTP 404 with `Interaction not found or expired.` +- An accepted retry after expiry returns `isError: true`, status `error`, and `The interaction is missing or expired.` +- A new flow succeeds after an expired retry. The server does not remain blocked. +- A byte-identical completing retry returns `isError: true`, status `error`, and `The interaction replay was rejected.` +- The replay keeps the first secret reference. The store has one value for the principal and name. +- `InMemoryInteractionStore.consume(id)` returns `true` once and then returns `false`. +- Form-only and capability-free clients receive status `unsupported_client`. +- Both clients receive `A browser-capable client that declares URL elicitation is required.` +- Those clients receive no `inputRequests` or URL-mode request on the wire. +- A URL-capable request contains `mode: url` and a URL. It has no `requestedSchema`. + +## Driver limit + +The lifecycle tests use raw calls for precise request-state checks. Three re-prompt rounds pass, and no SDK driver cap applies. + +## Cross-capability state + +A form-only client can present valid state from a URL-capable call. The server checks current capabilities first and returns `unsupported_client`. + +The result is a normal tool result. It has no `isError: true`, and it does not store a secret. + +## Client driver round cap (verified 2026-08-03) + +The installed stable client DOES have an auto-fulfil round cap, so earlier wording +implying uncertainty about its existence was wrong: + +- `@modelcontextprotocol/client` 2.0.0 (the version this package resolves; a beta.3 copy + also sits in the pnpm store for the base-branch packages) +- `dist/src-D_zzAWoS.mjs:5049` — `const DEFAULT_INPUT_REQUIRED_MAX_ROUNDS = 10;` +- exceeding it throws `SdkError(InputRequiredRoundsExceeded)` with the message + "Multi-round-trip request '' still required input after rounds + (inputRequired.maxRounds)" + +What the lifecycle suite exercised: three re-prompt rounds via RAW calls, which bypass the +driver entirely, so the cap was never hit here. What remains unknown is therefore not +whether a cap exists, but whether a given harness uses this driver and its default, and +how it surfaces exhaustion to the user. + +Keep two mechanisms distinct when reasoning about URL mode: + +- **Manual completion** (what Inspector implements): the client waits for a human signal + ("I've completed it") and then retries once. Round usage stays at 2 in the observed run. +- **Auto-polling** (hypothetical): a client that retries on a timer consumes one round per + poll and would hit the cap after 10, failing the call rather than waiting. Any harness + choosing this needs a poll interval reconciled against the interaction lifetime. diff --git a/packages/mcp-elicitations-poc/NOTES.risk6-security.md b/packages/mcp-elicitations-poc/NOTES.risk6-security.md new file mode 100644 index 00000000..e7196ce9 --- /dev/null +++ b/packages/mcp-elicitations-poc/NOTES.risk6-security.md @@ -0,0 +1,26 @@ +# Risk 6: URL-mode security results + +## Assertion outcomes + +- **Secret isolation: pass after correction.** The original result returned `last4`, so four credential characters reached model context. The result now returns only an opaque `secret_ref`. +- **Wire scan: pass, and finite.** The test applies `JSON.stringify` to every captured request and response frame in both directions. It rejects all sentinel substrings of at least four characters, plus base64, base64url, and URI encodings of the full value and its last eight characters. Shorter plaintext fragments and encodings of arbitrary slices are outside the scan, so it is regression detection for the design choice (opaque ref only), not a proof of absence. +- **Opaque URL: pass.** The connect URL has one `i` query parameter. It contains no bearer, subject, or secret text. +- **Server-side storage: pass.** The secret store returns Alice's reference and last four characters after the browser submission. +- **Phishing binding: pass.** Bob and an unknown session cannot open or submit against Alice's interaction. Each rejection leaves the interaction pending and stores nothing. +- **Unauthenticated access: pass.** A request without a session cannot open the connect page. The interaction remains pending. +- **Recovery after rejection: pass.** Alice can use the same interaction after the rejected requests. Her accepted retry returns stored metadata. +- **Cross-principal redemption: pass.** Mallory cannot redeem Alice's signed request state. Mallory receives no secret reference or secret value. Alice's stored metadata remains unchanged. + +## Rejection surfaces + +- A missing session returns HTTP 401 with `A valid mock dashboard session is required.` +- A mismatched browser session returns HTTP 403 with `Session identity mismatch for this interaction.` This applies to GET and POST requests. +- A cross-principal MCP retry returns HTTP 200 with an MCP tool error. The text is `Request state principal mismatch.` The structured status is `error`. + +## RFC design effect + +The server must store the interaction ID, principal, tool, argument digest, expiry, and completion state. It must also store the secret by principal and name. + +The real connect page needs the dashboard's authenticated session. The server must derive the principal from that session and compare it with the interaction record. The URL stays an opaque locator and grants no authority. + +The RFC must decide whether any credential fingerprint belongs in a tool result. A fingerprint helps a user identify a stored key, but it remains credential material in model context, transcripts, and logs. diff --git a/packages/mcp-elicitations-poc/NOTES.risk6.md b/packages/mcp-elicitations-poc/NOTES.risk6.md new file mode 100644 index 00000000..18d1bb3e --- /dev/null +++ b/packages/mcp-elicitations-poc/NOTES.risk6.md @@ -0,0 +1,36 @@ +# Risk 6: URL-mode elicitation + +## SDK observations + +- The installed server package exports `inputRequired.elicitUrl(params)`. Its + type accepts `Omit`. + The caller supplies `message` and `url`. The builder adds `mode: "url"` and + an elicitation ID. This matches the assumed helper, with the added generated + ID detail. +- The client emits URL support as `elicitation: { url: {} }` in the per-request + client capability metadata. Form support uses `elicitation: { form: {} }`. +- The automatic multi round-trip driver dispatches a URL input request through + the registered `elicitation/create` handler. It accepts an action-only result. + The happy-path test uses raw calls because the test must inspect the first + accepted retry before the connect page completes. +- The SDK validates URL elicitation requests and results. It does not enforce + this tool's per-request URL capability rule before the handler returns an + `input_required` result. The tool handler checks `elicitation.url` first and + returns `unsupported_client` for form-only or absent support. +- A retry that accepts before the browser flow completes returns + `resultType: "input_required"`. It carries fresh request state and the same + opaque interaction ID. + +## Security and state + +The URL contains only an opaque interaction ID. A request without the mock +dashboard session gets HTTP 401. A session for another principal gets HTTP 403, +and the interaction stays pending. + +The connect app stores the secret by principal and name. The MCP result returns +only a reference and the final four characters. + +MRTR can keep the transport stateless. A server can decide completion from +echoed signed request state. This PoC needs application storage for the +credential and for pending-flow correlation. A production deployment needs +durable shared storage for those records. diff --git a/packages/mcp-elicitations-poc/README.md b/packages/mcp-elicitations-poc/README.md new file mode 100644 index 00000000..99b4ca4d --- /dev/null +++ b/packages/mcp-elicitations-poc/README.md @@ -0,0 +1,71 @@ +# MCP Elicitations PoC + +Standalone proof of concept for MCP 2026-07-28 form-mode multi round-trip +elicitation around mock project cost confirmation. + +From the repository root: + +```sh +pnpm install +``` + +Run the server from the repository root: + +```sh +POC_STATE_KEY="at-least-32-bytes-of-development-key" \ + pnpm --filter @supabase/mcp-elicitations-poc dev +``` + +Run the tests: + +```sh +pnpm --filter @supabase/mcp-elicitations-poc test +``` + +The development server listens at `http://localhost:3900/mcp`. By default, +project operations use an in-memory mock registry. Setting both staging +Management API variables enables real project creation instead. + +## Optional: real staging creation + +Set both required Management API variables to create projects on staging: + +```sh +MANAGEMENT_API_URL="https://api.supabase.green" \ +MANAGEMENT_API_TOKEN="" \ +MANAGEMENT_API_REGION="us-east-1" \ +POC_STATE_KEY="at-least-32-bytes-of-development-key" \ + pnpm --filter @supabase/mcp-elicitations-poc dev +``` + +`MANAGEMENT_API_REGION` is optional and defaults to `us-east-1`. + +**Warning: this creates real projects on the target host. It is intended for +staging (`supabase.green`) only, never production.** Mock mode applies when +neither `MANAGEMENT_API_URL` nor `MANAGEMENT_API_TOKEN` is set, and the server +prints an explicit mock-mode startup line. Setting exactly one variable prints +an error and exits with status 1. + +**Replay protection: when staging variables are set, the development server uses +an in-memory `jti` store and rejects replay of an accepted request. This enforces +single use within one server instance.** The duplicate-POST residual applies to +store-less deployments (mock mode by default) and multi-instance deployments +without a shared store. See [FINDINGS.md risk 3](FINDINGS.md#3-replay-residual-and-dedupe). + +Set `POC_STATE_KEY` to the same value (at least 32 bytes) when multiple +development instances need to accept each other's request states. Otherwise, +the PoC generates one random key per process. + +See [FINDINGS.md](FINDINGS.md) for the RFC findings. Supporting observations are +in [NOTES.md](NOTES.md), [NOTES.risk2.md](NOTES.risk2.md), +[NOTES.risk3.md](NOTES.risk3.md), [NOTES.risk4.md](NOTES.risk4.md), and +[NOTES.risk5.md](NOTES.risk5.md). + +## URL-mode PoC + +The same `dev` command starts a separate URL-mode MCP endpoint at +`http://localhost:3902/mcp`. Its connect page runs at +`http://localhost:3901/connect`. + +The connect page uses a mock `poc_session=` cookie. This cookie stands +in for a dashboard session and provides no production authentication. diff --git a/packages/mcp-elicitations-poc/package.json b/packages/mcp-elicitations-poc/package.json new file mode 100644 index 00000000..2b51d53a --- /dev/null +++ b/packages/mcp-elicitations-poc/package.json @@ -0,0 +1,24 @@ +{ + "name": "@supabase/mcp-elicitations-poc", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit", + "dev": "tsx src/main.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", + "zod": "catalog:" + }, + "devDependencies": { + "@total-typescript/tsconfig": "^1.0.4", + "@types/node": "^22.8.6", + "tsx": "^4.19.2", + "typescript": "^5.6.3", + "vitest": "^2.1.9" + } +} diff --git a/packages/mcp-elicitations-poc/src/connect-app.ts b/packages/mcp-elicitations-poc/src/connect-app.ts new file mode 100644 index 00000000..836d214f --- /dev/null +++ b/packages/mcp-elicitations-poc/src/connect-app.ts @@ -0,0 +1,101 @@ +import { createHash } from 'node:crypto'; + +import type { InteractionStore, SecretStore } from './url-stores.js'; + +function digest(name: string): string { + return createHash('sha256').update(JSON.stringify({ name })).digest('hex'); +} + +function session(req: Request): string | undefined { + const cookie = req.headers.get('cookie') ?? ''; + for (const part of cookie.split(';')) { + const [key, ...value] = part.trim().split('='); + if (key === 'poc_session') return decodeURIComponent(value.join('=')); + } +} + +function response(body: string, status: number, contentType = 'text/plain') { + return new Response(body, { + status, + headers: { 'Content-Type': `${contentType}; charset=utf-8` }, + }); +} + +export function createConnectApp(opts: { + interactions: InteractionStore; + secrets: SecretStore; + clock?: () => number; +}): { fetch(req: Request): Promise } { + return { + async fetch(req) { + const url = new URL(req.url); + if (url.pathname !== '/connect') return response('Not found', 404); + + const principal = session(req); + if (!principal) + return response('A valid mock dashboard session is required.', 401); + + let id: string | null; + let name: string | null = null; + let secret: string | null = null; + if (req.method === 'GET') { + id = url.searchParams.get('i'); + } else if (req.method === 'POST') { + const form = await req.formData(); + id = typeof form.get('i') === 'string' ? String(form.get('i')) : null; + name = + typeof form.get('name') === 'string' + ? String(form.get('name')) + : null; + secret = + typeof form.get('secret') === 'string' + ? String(form.get('secret')) + : null; + } else { + return response('Method not allowed', 405); + } + + if (!id) return response('Interaction not found.', 404); + const interaction = opts.interactions.get(id); + if (!interaction) + return response('Interaction not found or expired.', 404); + if (principal !== interaction.principal) { + return response('Session identity mismatch for this interaction.', 403); + } + + if (req.method === 'GET') { + return response( + `
`, + 200, + 'text/html' + ); + } + + if (!name || secret === null || digest(name) !== interaction.argsDigest) { + return response('The key name does not match this interaction.', 400); + } + if (!opts.interactions.complete(id)) { + return response('Interaction is expired or already complete.', 410); + } + opts.secrets.put(principal, name, secret); + return response( + 'Your API key is stored. You can return to your client.', + 200 + ); + }, + }; +} + +function escapeHtml(value: string): string { + return value.replace( + /[&<>"']/g, + (char) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[char]! + ); +} diff --git a/packages/mcp-elicitations-poc/src/main.ts b/packages/mcp-elicitations-poc/src/main.ts new file mode 100644 index 00000000..8a2c2ddf --- /dev/null +++ b/packages/mcp-elicitations-poc/src/main.ts @@ -0,0 +1,90 @@ +import { createServer } from 'node:http'; + +import { toNodeHandler } from '@modelcontextprotocol/node'; + +import { createManagementProjectCreator } from './management.js'; +import { createPoc, InMemoryJtiStore } from './server.js'; +import { createUrlPoc } from './url-server.js'; + +const managementApiUrl = process.env.MANAGEMENT_API_URL; +const managementApiToken = process.env.MANAGEMENT_API_TOKEN; + +if (managementApiUrl && !managementApiToken) { + console.error( + 'MANAGEMENT_API_TOKEN is required when MANAGEMENT_API_URL is set' + ); + process.exit(1); +} + +if (!managementApiUrl && managementApiToken) { + console.error( + 'MANAGEMENT_API_URL is required when MANAGEMENT_API_TOKEN is set' + ); + process.exit(1); +} + +if (managementApiUrl && managementApiToken) { + let hostname: string; + let protocol: string; + try { + const url = new URL(managementApiUrl); + hostname = url.hostname; + protocol = url.protocol; + } catch { + console.error( + `Refusing Management API URL with invalid host: ${managementApiUrl}` + ); + process.exit(1); + } + + if (protocol !== 'https:') { + console.error(`Refusing Management API protocol: ${protocol}`); + process.exit(1); + } + + if (hostname !== 'supabase.green' && !hostname.endsWith('.supabase.green')) { + console.error(`Refusing Management API host: ${hostname}`); + process.exit(1); + } +} + +const projectCreator = + managementApiUrl && managementApiToken + ? createManagementProjectCreator({ + baseUrl: managementApiUrl, + token: managementApiToken, + region: process.env.MANAGEMENT_API_REGION, + }) + : undefined; + +if (projectCreator) { + console.log( + `Real Management API project creation ENABLED against ${managementApiUrl}; single-use enforcement ENABLED (in-memory, single instance)` + ); +} else { + console.log( + 'Project creation: MOCK (in-memory registry; set MANAGEMENT_API_URL + MANAGEMENT_API_TOKEN for staging)' + ); +} + +const { handler } = createPoc({ + projectCreator, + jtiStore: projectCreator ? new InMemoryJtiStore() : undefined, +}); +const server = createServer(toNodeHandler(handler)); + +server.listen(3900, () => { + console.log('MCP Elicitations PoC listening on http://localhost:3900/mcp'); +}); + +const urlPoc = createUrlPoc(); +const urlMcpServer = createServer(toNodeHandler(urlPoc.handler)); +const connectServer = createServer(toNodeHandler(urlPoc.connect)); + +urlMcpServer.listen(3902, () => { + console.log('URL-mode MCP PoC listening on http://localhost:3902/mcp'); +}); + +connectServer.listen(3901, () => { + console.log('Mock connect page listening on http://localhost:3901/connect'); +}); diff --git a/packages/mcp-elicitations-poc/src/management.ts b/packages/mcp-elicitations-poc/src/management.ts new file mode 100644 index 00000000..eb7fc583 --- /dev/null +++ b/packages/mcp-elicitations-poc/src/management.ts @@ -0,0 +1,44 @@ +import { randomBytes } from 'node:crypto'; + +type ProjectCreatorInput = { + name: string; + organization_id: string; +}; + +export function createManagementProjectCreator(opts: { + baseUrl: string; + token: string; + region?: string; + fetchImpl?: typeof fetch; +}) { + const fetchImpl = opts.fetchImpl ?? fetch; + const baseUrl = opts.baseUrl.replace(/\/+$/, ''); + const region = opts.region ?? 'us-east-1'; + + return async (input: ProjectCreatorInput): Promise<{ id: string }> => { + const response = await fetchImpl(`${baseUrl}/v1/projects`, { + method: 'POST', + headers: { + Authorization: `Bearer ${opts.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: input.name, + organization_slug: input.organization_id, + region, + db_pass: randomBytes(32).toString('base64url'), + }), + }); + + if (!response.ok) { + const responseBody = (await response.text()).slice(0, 300); + throw new Error( + `Management API project creation failed (${response.status}): ${responseBody}` + ); + } + + const project = (await response.json()) as { ref: string }; + // V1ProjectResponse marks id deprecated and names ref as the project ref. + return { id: project.ref }; + }; +} diff --git a/packages/mcp-elicitations-poc/src/mock-management.ts b/packages/mcp-elicitations-poc/src/mock-management.ts new file mode 100644 index 00000000..76e60a50 --- /dev/null +++ b/packages/mcp-elicitations-poc/src/mock-management.ts @@ -0,0 +1,23 @@ +import { randomUUID } from 'node:crypto'; + +import type { CreateProjectParams, MockProject, Registry } from './types.js'; + +export function createRegistry(): Registry { + const projects: MockProject[] = []; + + return { + createProject(params) { + const project = { id: randomUUID(), ...params }; + projects.push(project); + return project; + }, + list() { + return projects.map((project) => ({ ...project })); + }, + countByName(name) { + return projects.filter((project) => project.name === name).length; + }, + }; +} + +export type { MockProject, Registry } from './types.js'; diff --git a/packages/mcp-elicitations-poc/src/server.ts b/packages/mcp-elicitations-poc/src/server.ts new file mode 100644 index 00000000..4b58dd53 --- /dev/null +++ b/packages/mcp-elicitations-poc/src/server.ts @@ -0,0 +1,304 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; + +import { + CLIENT_CAPABILITIES_META_KEY, + McpServer, + acceptedContent, + createMcpHandler, + createRequestStateCodec, + inputRequired, + inputResponse, + type ServerContext, +} from '@modelcontextprotocol/server'; +import { z } from 'zod'; + +import { createRegistry } from './mock-management.js'; +import type { Registry } from './types.js'; + +export interface JtiStore { + consume(jti: string): boolean; +} + +export class InMemoryJtiStore implements JtiStore { + readonly #consumed = new Set(); + + consume(jti: string): boolean { + if (this.#consumed.has(jti)) return false; + this.#consumed.add(jti); + return true; + } +} + +export type PocOptions = { + stateKey?: string; + ttlSeconds?: number; + jtiStore?: JtiStore | null; + projectCreator?: (input: { + name: string; + organization_id: string; + }) => Promise<{ id: string }>; +}; + +export type Poc = { + handler: { fetch(req: Request): Promise }; + registry: Registry; +}; + +export const DEFAULT_STATE_KEY = + process.env.POC_STATE_KEY ?? randomBytes(32).toString('hex'); +export const PROJECT_COST = { amount: 10, recurrence: 'monthly' } as const; + +type State = { + v: 1; + sub: string; + tool: 'create_project'; + argsDigest: string; + cost: typeof PROJECT_COST; + jti: string; + iat: number; +}; + +const inputSchema = z.object({ + name: z.string(), + organization_id: z.string(), + confirm_cost_token: z.string().optional(), +}); +const confirmationSchema = z.object({ confirm: z.boolean() }); + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function canonicalArgs(name: string, organizationId: string): string { + return JSON.stringify({ name, organization_id: organizationId }); +} + +function argsDigest(name: string, organizationId: string): string { + return sha256(canonicalArgs(name, organizationId)); +} + +export function legacyConfirmToken( + name: string, + organizationId: string +): string { + return sha256( + JSON.stringify({ + tool: 'create_project', + args: { name, organization_id: organizationId }, + cost: PROJECT_COST, + }) + ); +} + +function principal(ctx: ServerContext): string { + const authorization = ctx.http?.req?.headers.get('authorization'); + const match = authorization?.match(/^Bearer (.+)$/i); + return match?.[1] ?? 'anonymous'; +} + +function declaresFormElicitation(ctx: ServerContext): boolean { + const envelope = ctx.mcpReq.envelope as Record | undefined; + const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY] as + | { elicitation?: { form?: unknown } } + | undefined; + return capabilities?.elicitation?.form !== undefined; +} + +function result( + structuredContent: Record, + text: string, + isError = false +) { + return { + content: [{ type: 'text' as const, text }], + structuredContent, + ...(isError ? { isError: true } : {}), + }; +} + +export function createPoc(opts: PocOptions = {}): Poc { + const registry = createRegistry(); + const jtiStore = opts.jtiStore ?? null; + const codec = createRequestStateCodec({ + key: opts.stateKey ?? DEFAULT_STATE_KEY, + ttlSeconds: opts.ttlSeconds ?? 120, + bind: (ctx) => ctx.mcpReq.method, + }); + + const mintState = ( + ctx: ServerContext, + sub: string, + name: string, + organizationId: string + ) => + codec.mint( + { + v: 1, + sub, + tool: 'create_project', + argsDigest: argsDigest(name, organizationId), + cost: PROJECT_COST, + jti: randomUUID(), + iat: Math.floor(Date.now() / 1000), + }, + ctx + ); + + const createProject = async (name: string, organization_id: string) => { + try { + const created = await opts.projectCreator?.({ name, organization_id }); + const project = registry.createProject({ + name, + organization_id, + cost: PROJECT_COST, + }); + if (created) project.id = created.id; + return result( + { status: 'created', project }, + `Created project "${name}".` + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return result({ status: 'error' }, message, true); + } + }; + + const askForConfirmation = async ( + ctx: ServerContext, + sub: string, + name: string, + organizationId: string + ) => + inputRequired({ + inputRequests: { + confirm_cost: inputRequired.elicit({ + mode: 'form', + message: `Creating project "${name}" costs $10/month. Do you confirm?`, + requestedSchema: { + type: 'object', + properties: { + confirm: { + type: 'boolean', + description: 'Confirm the recurring project cost.', + }, + }, + required: ['confirm'], + }, + }), + }, + requestState: await mintState(ctx, sub, name, organizationId), + }); + + const handler = createMcpHandler((requestContext) => { + const server = new McpServer( + { name: 'mcp-elicitations-poc', version: '0.0.0' }, + { requestState: { verify: codec.verify } } + ); + + server.registerTool( + 'create_project', + { + description: opts.projectCreator + ? 'Create a Supabase project via the configured Management API.' + : 'Create a mock Supabase project.', + inputSchema, + }, + async ({ name, organization_id, confirm_cost_token }, ctx) => { + const sub = principal(ctx); + + if (!declaresFormElicitation(ctx)) { + const expected = legacyConfirmToken(name, organization_id); + if (confirm_cost_token === undefined) { + return result( + { + status: 'confirmation_required', + confirm_cost_token: expected, + }, + 'Confirmation required. Retry with the supplied confirm_cost_token.' + ); + } + if (confirm_cost_token !== expected) { + return result( + { status: 'error' }, + 'The confirm_cost_token is invalid.', + true + ); + } + return createProject(name, organization_id); + } + + const state = ctx.mcpReq.requestState(); + if (!state) { + return askForConfirmation(ctx, sub, name, organization_id); + } + if (state.sub !== sub) { + return result( + { status: 'error' }, + 'Request state principal does not match the current principal.', + true + ); + } + if (state.argsDigest !== argsDigest(name, organization_id)) { + return result( + { status: 'error' }, + 'Request state arguments do not match the current arguments.', + true + ); + } + if (jtiStore && !jtiStore.consume(state.jti)) { + return result( + { status: 'error' }, + 'Request state replay was rejected.', + true + ); + } + + const response = inputResponse( + ctx.mcpReq.inputResponses, + 'confirm_cost' + ); + if (response.kind === 'missing') { + return askForConfirmation(ctx, sub, name, organization_id); + } + if (response.kind !== 'elicit') { + return askForConfirmation(ctx, sub, name, organization_id); + } + if (response.action === 'decline') { + return result( + { status: 'declined' }, + 'Project creation was declined.' + ); + } + if (response.action === 'cancel') { + return result( + { status: 'cancelled' }, + 'Project creation was cancelled.' + ); + } + + const content = acceptedContent( + ctx.mcpReq.inputResponses, + 'confirm_cost', + confirmationSchema + ); + if (!content) { + return askForConfirmation(ctx, sub, name, organization_id); + } + if (!content.confirm) { + return result( + { status: 'declined' }, + 'Project creation was declined.' + ); + } + + return createProject(name, organization_id); + } + ); + + // requestInfo is surfaced again as ctx.http.req by the HTTP transport. + void requestContext; + return server; + }); + + return { handler, registry }; +} diff --git a/packages/mcp-elicitations-poc/src/types.ts b/packages/mcp-elicitations-poc/src/types.ts new file mode 100644 index 00000000..6629fd5b --- /dev/null +++ b/packages/mcp-elicitations-poc/src/types.ts @@ -0,0 +1,19 @@ +export type ProjectCost = { + amount: number; + recurrence: 'monthly'; +}; + +export type MockProject = { + id: string; + name: string; + organization_id: string; + cost: ProjectCost; +}; + +export type CreateProjectParams = Omit; + +export interface Registry { + createProject(params: CreateProjectParams): MockProject; + list(): MockProject[]; + countByName(name: string): number; +} diff --git a/packages/mcp-elicitations-poc/src/url-server.ts b/packages/mcp-elicitations-poc/src/url-server.ts new file mode 100644 index 00000000..99a299be --- /dev/null +++ b/packages/mcp-elicitations-poc/src/url-server.ts @@ -0,0 +1,237 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; + +import { + CLIENT_CAPABILITIES_META_KEY, + McpServer, + createMcpHandler, + createRequestStateCodec, + inputRequired, + inputResponse, + type ServerContext, +} from '@modelcontextprotocol/server'; +import { z } from 'zod'; + +import { createConnectApp } from './connect-app.js'; +import { + InMemoryInteractionStore, + InMemorySecretStore, + type InteractionStore, + type SecretStore, +} from './url-stores.js'; + +export type UrlPocOptions = { + stateKey?: string; + ttlSeconds?: number; + interactions?: InteractionStore; + secrets?: SecretStore; + connectBaseUrl?: string; + clock?: () => number; +}; + +export type UrlPoc = { + handler: { fetch(req: Request): Promise }; + connect: { fetch(req: Request): Promise }; + interactions: InteractionStore; + secrets: SecretStore; +}; + +type State = { + v: 1; + sub: string; + tool: 'store_api_key'; + argsDigest: string; + interactionId: string; + jti: string; + iat: number; +}; + +function digest(name: string): string { + return createHash('sha256').update(JSON.stringify({ name })).digest('hex'); +} + +function principal(ctx: ServerContext): string { + const authorization = ctx.http?.req?.headers.get('authorization'); + return authorization?.match(/^Bearer (.+)$/i)?.[1] ?? 'anonymous'; +} + +function declaresUrl(ctx: ServerContext): boolean { + const envelope = ctx.mcpReq.envelope as Record | undefined; + const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY] as + | { elicitation?: { url?: unknown } } + | undefined; + return capabilities?.elicitation?.url !== undefined; +} + +function result( + structuredContent: Record, + text: string, + isError = false +) { + return { + content: [{ type: 'text' as const, text }], + structuredContent, + ...(isError ? { isError: true } : {}), + }; +} + +export function createUrlPoc(opts: UrlPocOptions = {}): UrlPoc { + const clock = opts.clock ?? (() => Date.now()); + const ttlSeconds = opts.ttlSeconds ?? 300; + const interactions = opts.interactions ?? new InMemoryInteractionStore(clock); + const secrets = opts.secrets ?? new InMemorySecretStore(); + const connectBaseUrl = ( + opts.connectBaseUrl ?? 'http://localhost:3901' + ).replace(/\/$/, ''); + const codec = createRequestStateCodec({ + key: opts.stateKey ?? randomBytes(32).toString('hex'), + ttlSeconds, + bind: (ctx) => ctx.mcpReq.method, + }); + + const mintState = ( + ctx: ServerContext, + sub: string, + name: string, + interactionId: string + ) => + codec.mint( + { + v: 1, + sub, + tool: 'store_api_key', + argsDigest: digest(name), + interactionId, + jti: randomUUID(), + iat: Math.floor(clock() / 1000), + }, + ctx + ); + + const ask = async ( + ctx: ServerContext, + sub: string, + name: string, + interactionId: string, + waiting = false + ) => + inputRequired({ + inputRequests: { + provide_api_key: inputRequired.elicitUrl({ + message: waiting + ? `Still waiting for the API key "${name}". Open this page to finish.` + : `Open this page to enter your API key for "${name}". It is stored by Supabase and never passes through your MCP client.`, + url: `${connectBaseUrl}/connect?i=${encodeURIComponent(interactionId)}`, + }), + }, + requestState: await mintState(ctx, sub, name, interactionId), + }); + + const handler = createMcpHandler(() => { + const server = new McpServer( + { name: 'mcp-url-elicitations-poc', version: '0.0.0' }, + { requestState: { verify: codec.verify } } + ); + server.registerTool( + 'store_api_key', + { + description: 'Store an API key through a browser page.', + inputSchema: z.object({ name: z.string() }), + }, + async ({ name }, ctx) => { + const sub = principal(ctx); + if (!declaresUrl(ctx)) { + return result( + { + status: 'unsupported_client', + message: + 'A browser-capable client that declares URL elicitation is required.', + }, + 'A browser-capable client that declares URL elicitation is required.' + ); + } + + const state = ctx.mcpReq.requestState(); + if (!state) { + const interactionId = randomUUID(); + interactions.create({ + id: interactionId, + principal: sub, + tool: 'store_api_key', + argsDigest: digest(name), + exp: clock() + ttlSeconds * 1000, + }); + return ask(ctx, sub, name, interactionId); + } + if (state.sub !== sub) + return result( + { status: 'error' }, + 'Request state principal mismatch.', + true + ); + if (state.argsDigest !== digest(name)) + return result( + { status: 'error' }, + 'Request state arguments mismatch.', + true + ); + + const response = inputResponse( + ctx.mcpReq.inputResponses, + 'provide_api_key' + ); + if (response.kind === 'elicit' && response.action === 'decline') { + return result( + { status: 'declined' }, + 'API key storage was declined.' + ); + } + if (response.kind === 'elicit' && response.action === 'cancel') { + return result( + { status: 'cancelled' }, + 'API key storage was cancelled.' + ); + } + if (response.kind !== 'elicit' || response.action !== 'accept') { + return ask(ctx, sub, name, state.interactionId, true); + } + + const interaction = interactions.get(state.interactionId); + if (!interaction) + return result( + { status: 'error' }, + 'The interaction is missing or expired.', + true + ); + if (interaction.status === 'pending') + return ask(ctx, sub, name, state.interactionId, true); + if (!interactions.consume(state.interactionId)) { + return result( + { status: 'error' }, + 'The interaction replay was rejected.', + true + ); + } + const secret = secrets.get(sub, name); + if (!secret) + return result( + { status: 'error' }, + 'The stored secret reference is missing.', + true + ); + // Any suffix is credential material in model context. Fingerprints need an RFC decision, not a PoC default. + return result( + { status: 'stored', name, secret_ref: secret.ref }, + `Stored API key "${name}".` + ); + } + ); + return server; + }); + + return { + handler, + connect: createConnectApp({ interactions, secrets, clock }), + interactions, + secrets, + }; +} diff --git a/packages/mcp-elicitations-poc/src/url-stores.ts b/packages/mcp-elicitations-poc/src/url-stores.ts new file mode 100644 index 00000000..84b4926f --- /dev/null +++ b/packages/mcp-elicitations-poc/src/url-stores.ts @@ -0,0 +1,100 @@ +import { randomUUID } from 'node:crypto'; + +export type Interaction = { + id: string; + principal: string; + tool: string; + argsDigest: string; + status: 'pending' | 'complete'; + exp: number; +}; + +export interface InteractionStore { + create(i: Omit): Interaction; + get(id: string): Interaction | undefined; + complete(id: string): boolean; + consume(id: string): boolean; +} + +export class InMemoryInteractionStore implements InteractionStore { + readonly #items = new Map(); + readonly #consumed = new Set(); + readonly #clock: () => number; + + constructor(clock: () => number = () => Date.now()) { + this.#clock = clock; + } + + create(i: Omit): Interaction { + const interaction: Interaction = { ...i, status: 'pending' }; + this.#items.set(i.id, interaction); + return { ...interaction }; + } + + get(id: string): Interaction | undefined { + const interaction = this.#items.get(id); + if (!interaction || interaction.exp <= this.#clock()) return undefined; + return { ...interaction }; + } + + complete(id: string): boolean { + const interaction = this.#items.get(id); + if ( + !interaction || + interaction.exp <= this.#clock() || + interaction.status !== 'pending' + ) { + return false; + } + interaction.status = 'complete'; + return true; + } + + consume(id: string): boolean { + const interaction = this.#items.get(id); + if ( + !interaction || + interaction.exp <= this.#clock() || + interaction.status !== 'complete' || + this.#consumed.has(id) + ) { + return false; + } + this.#consumed.add(id); + return true; + } +} + +export interface SecretStore { + put( + principal: string, + name: string, + value: string + ): { ref: string; last4: string }; + get( + principal: string, + name: string + ): { ref: string; last4: string } | undefined; +} + +export class InMemorySecretStore implements SecretStore { + readonly #items = new Map< + string, + { value: string; ref: string; last4: string } + >(); + + put(principal: string, name: string, value: string) { + const stored = { + value, + ref: `secret_${randomUUID()}`, + last4: value.slice(-4), + }; + this.#items.set(JSON.stringify([principal, name]), stored); + return { ref: stored.ref, last4: stored.last4 }; + } + + get(principal: string, name: string) { + const stored = this.#items.get(JSON.stringify([principal, name])); + return stored ? { ref: stored.ref, last4: stored.last4 } : undefined; + } +} diff --git a/packages/mcp-elicitations-poc/test/capability-gating.test.ts b/packages/mcp-elicitations-poc/test/capability-gating.test.ts new file mode 100644 index 00000000..0d4f33f1 --- /dev/null +++ b/packages/mcp-elicitations-poc/test/capability-gating.test.ts @@ -0,0 +1,210 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createPoc, legacyConfirmToken } from '../src/server.js'; +import { createTestClient, rawToolCall, type WireFrame } from './harness.js'; + +const clients: Array<{ close(): Promise }> = []; + +afterEach(async () => { + await Promise.all(clients.splice(0).map((client) => client.close())); +}); + +function structuredContent(result: unknown): Record { + return (result as { structuredContent: Record }) + .structuredContent; +} + +function intermediateResults(wire: WireFrame[]): Array> { + return wire + .filter((frame) => frame.direction === 'response') + .map((frame) => frame.body?.result) + .filter((result) => result?.inputRequests !== undefined); +} + +describe('risk 4: capability gating', () => { + it('uses the legacy confirmation token without sending input requests to a non-declaring client', async () => { + const poc = createPoc(); + const connection = await createTestClient({ + poc, + elicitation: false, + }); + clients.push(connection); + + const first = await connection.client.callTool({ + name: 'create_project', + arguments: { + name: 'legacy-project', + organization_id: 'org-1', + }, + }); + const firstContent = structuredContent(first); + + expect(firstContent.status).toBe('confirmation_required'); + expect(firstContent.confirm_cost_token).toEqual(expect.any(String)); + + for (const frame of connection.wire.filter( + ({ direction }) => direction === 'response' + )) { + const body = JSON.stringify(frame.body); + expect(body).not.toContain('inputRequests'); + expect(body).not.toContain('input_required'); + } + + const retry = await connection.client.callTool({ + name: 'create_project', + arguments: { + name: 'legacy-project', + organization_id: 'org-1', + confirm_cost_token: firstContent.confirm_cost_token, + }, + }); + + expect(structuredContent(retry).status).toBe('created'); + expect(poc.registry.list()).toHaveLength(1); + }); + + it('elicits exactly once from a declaring client and exposes the form on the wire', async () => { + const poc = createPoc(); + const responder = vi.fn((_request: { message: string }) => ({ + action: 'accept' as const, + content: { confirm: true }, + })); + const connection = await createTestClient({ + poc, + elicitation: responder, + }); + clients.push(connection); + + const result = await connection.client.callTool({ + name: 'create_project', + arguments: { + name: 'elicited-project', + organization_id: 'org-1', + }, + }); + + expect(responder).toHaveBeenCalledOnce(); + expect(responder.mock.calls[0]?.[0].message).toContain('$10/month'); + expect(structuredContent(result).status).toBe('created'); + expect(poc.registry.list()).toHaveLength(1); + + const intermediate = intermediateResults(connection.wire); + expect(intermediate).toHaveLength(1); + const intermediateResult = intermediate[0]; + if (intermediateResult === undefined) { + throw new Error('Expected one intermediate result'); + } + expect(Object.keys(intermediateResult.inputRequests)).toEqual([ + 'confirm_cost', + ]); + expect(intermediateResult.inputRequests.confirm_cost).toMatchObject({ + method: 'elicitation/create', + params: { + mode: 'form', + requestedSchema: { + properties: { + confirm: { type: 'boolean' }, + }, + }, + }, + }); + }); + + it("pins the SDK's observed intermediate result discriminator", async () => { + const poc = createPoc(); + const connection = await createTestClient({ + poc, + elicitation: () => ({ + action: 'accept', + content: { confirm: true }, + }), + }); + clients.push(connection); + + await connection.client.callTool({ + name: 'create_project', + arguments: { + name: 'discriminator-project', + organization_id: 'org-1', + }, + }); + + const intermediate = intermediateResults(connection.wire); + expect(intermediate).toHaveLength(1); + const intermediateResult = intermediate[0]; + if (intermediateResult === undefined) { + throw new Error('Expected one intermediate result'); + } + expect(intermediateResult.resultType).toBe('input_required'); + }); + + it('does not let a precomputed legacy token bypass elicitation for a capable client', async () => { + const poc = createPoc(); + const name = 'capable-token-project'; + const organizationId = 'org-1'; + const responder = vi.fn(() => { + expect(poc.registry.list()).toHaveLength(0); + return { + action: 'accept' as const, + content: { confirm: true }, + }; + }); + const connection = await createTestClient({ + poc, + elicitation: responder, + }); + clients.push(connection); + + const result = await connection.client.callTool({ + name: 'create_project', + arguments: { + name, + organization_id: organizationId, + confirm_cost_token: legacyConfirmToken(name, organizationId), + }, + }); + + expect(responder).toHaveBeenCalledOnce(); + expect(intermediateResults(connection.wire)).toHaveLength(1); + expect(structuredContent(result).status).toBe('created'); + expect(poc.registry.list()).toHaveLength(1); + }); + + it('does not redeem declaring-client state and responses on a non-declaring request', async () => { + const poc = createPoc(); + const args = { + name: 'cross-capability-project', + organization_id: 'org-1', + }; + const initial = await rawToolCall({ + poc, + declareElicitation: true, + args, + }); + const requestState = initial.body?.result?.requestState; + + expect(initial.status).toBe(200); + expect(initial.body?.result?.resultType).toBe('input_required'); + expect(requestState).toEqual(expect.any(String)); + + const redemption = await rawToolCall({ + poc, + declareElicitation: false, + args, + requestState, + inputResponses: { + confirm_cost: { + action: 'accept', + content: { confirm: true }, + }, + }, + }); + + expect(redemption.status).toBe(200); + expect(redemption.body?.result?.structuredContent).toMatchObject({ + status: 'confirmation_required', + confirm_cost_token: expect.any(String), + }); + expect(poc.registry.list()).toHaveLength(0); + }); +}); diff --git a/packages/mcp-elicitations-poc/test/happy-path.test.ts b/packages/mcp-elicitations-poc/test/happy-path.test.ts new file mode 100644 index 00000000..40a489ed --- /dev/null +++ b/packages/mcp-elicitations-poc/test/happy-path.test.ts @@ -0,0 +1,172 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createPoc } from '../src/server.js'; +import { createTestClient, rawToolCall } from './harness.js'; + +const openClients: Array<{ close(): Promise }> = []; + +afterEach(async () => { + await Promise.all(openClients.splice(0).map((client) => client.close())); +}); + +const args = { name: 'test-project', organization_id: 'org-1' }; + +function structured(result: unknown): Record { + return (result as { structuredContent: Record }) + .structuredContent; +} + +describe('create_project MRTR happy path', () => { + it('creates exactly one project after acceptance', async () => { + const poc = createPoc(); + const testClient = await createTestClient({ + poc, + elicitation: () => ({ + action: 'accept', + content: { confirm: true }, + }), + }); + openClients.push(testClient); + + const result = await testClient.client.callTool({ + name: 'create_project', + arguments: args, + }); + + expect(structured(result).status).toBe('created'); + expect(poc.registry.list()).toHaveLength(1); + expect(poc.registry.countByName(args.name)).toBe(1); + + const intermediate = testClient.wire.find( + (frame) => + frame.direction === 'response' && + frame.body?.result?.resultType === 'input_required' + ); + expect(intermediate?.body.result.resultType).toBe('input_required'); + }); + + it('returns an agent-readable normal result when declined', async () => { + const poc = createPoc(); + const testClient = await createTestClient({ + poc, + elicitation: () => ({ action: 'decline' }), + }); + openClients.push(testClient); + + const result = await testClient.client.callTool({ + name: 'create_project', + arguments: args, + }); + + expect(structured(result).status).toBe('declined'); + expect(result.isError).not.toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: expect.stringMatching(/declined/i), + }), + ]) + ); + expect(poc.registry.list()).toEqual([]); + }); + + it('distinguishes cancellation from decline', async () => { + const poc = createPoc(); + const testClient = await createTestClient({ + poc, + elicitation: () => ({ action: 'cancel' }), + }); + openClients.push(testClient); + + const result = await testClient.client.callTool({ + name: 'create_project', + arguments: args, + }); + + expect(structured(result).status).toBe('cancelled'); + expect(structured(result).status).not.toBe('declined'); + expect(poc.registry.list()).toEqual([]); + }); + + it.each([ + ['absent', undefined], + ['empty', {}], + ])( + 'reissues input_required when inputResponses are %s', + async (_, responses) => { + const poc = createPoc(); + const first = await rawToolCall({ + poc, + declareElicitation: true, + args, + }); + const requestState = first.body.result.requestState as string; + + const retry = await rawToolCall({ + poc, + declareElicitation: true, + args, + inputResponses: responses, + requestState, + }); + + expect(retry.status).toBe(200); + expect(retry.body.error).toBeUndefined(); + expect(retry.body.result.resultType).toBe('input_required'); + expect(retry.body.result.requestState).not.toBe(requestState); + expect(poc.registry.list()).toEqual([]); + } + ); + + it.each([ + ['missing confirm', {}], + ['wrong-typed confirm', { confirm: 'yes' }], + ])( + 'reissues input_required when accepted content has %s', + async (_, content) => { + const poc = createPoc(); + const first = await rawToolCall({ + poc, + declareElicitation: true, + args, + }); + const requestState = first.body.result.requestState as string; + + const retry = await rawToolCall({ + poc, + declareElicitation: true, + args, + inputResponses: { + confirm_cost: { action: 'accept', content }, + }, + requestState, + }); + + expect(retry.status).toBe(200); + expect(retry.body.error).toBeUndefined(); + expect(retry.body.result.resultType).toBe('input_required'); + expect(retry.body.result.requestState).not.toBe(requestState); + expect(poc.registry.list()).toEqual([]); + + const completed = await rawToolCall({ + poc, + declareElicitation: true, + args, + inputResponses: { + confirm_cost: { + action: 'accept', + content: { confirm: true }, + }, + }, + requestState: retry.body.result.requestState as string, + }); + + expect(completed.status).toBe(200); + expect(completed.body.error).toBeUndefined(); + expect(structured(completed.body.result).status).toBe('created'); + expect(poc.registry.list()).toHaveLength(1); + expect(poc.registry.countByName(args.name)).toBe(1); + } + ); +}); diff --git a/packages/mcp-elicitations-poc/test/harness.ts b/packages/mcp-elicitations-poc/test/harness.ts new file mode 100644 index 00000000..2d47b84f --- /dev/null +++ b/packages/mcp-elicitations-poc/test/harness.ts @@ -0,0 +1,159 @@ +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + Client, + PROTOCOL_VERSION_META_KEY, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; + +import type { Poc } from '../src/server.js'; + +export type WireFrame = { + direction: 'request' | 'response'; + status?: number; + body: any; +}; + +export type TestClientOptions = { + poc: Poc; + bearer?: string; + elicitation?: + | false + | ((req: { message: string; requestedSchema: any }) => { + action: 'accept' | 'decline' | 'cancel'; + content?: Record; + }); +}; + +function parseBody(text: string): unknown { + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} + +export async function createTestClient(opts: TestClientOptions): Promise<{ + client: Client; + wire: WireFrame[]; + close(): Promise; +}> { + const wire: WireFrame[] = []; + const bearer = opts.bearer ?? 'user-alice'; + const capturedFetch: typeof fetch = async (input, init) => { + const outgoing = new Request(input, init); + wire.push({ + direction: 'request', + body: parseBody(await outgoing.clone().text()), + }); + const response = await opts.poc.handler.fetch(outgoing); + wire.push({ + direction: 'response', + status: response.status, + body: parseBody(await response.clone().text()), + }); + return response; + }; + + const responder = opts.elicitation; + const declaresElicitation = typeof responder === 'function'; + const client = new Client( + { name: 'mcp-elicitations-poc-test', version: '0.0.0' }, + { + versionNegotiation: { mode: { pin: '2026-07-28' } }, + capabilities: declaresElicitation ? { elicitation: { form: {} } } : {}, + } + ); + + if (typeof responder === 'function') { + client.setRequestHandler('elicitation/create', async (request) => { + const response = responder({ + message: request.params.message, + requestedSchema: + 'requestedSchema' in request.params + ? request.params.requestedSchema + : undefined, + }); + return response as any; + }); + } + + const transport = new StreamableHTTPClientTransport( + new URL('http://poc.local/mcp'), + { + fetch: capturedFetch, + requestInit: { + headers: { Authorization: `Bearer ${bearer}` }, + }, + } + ); + await client.connect(transport); + + return { + client, + wire, + close: () => client.close(), + }; +} + +export async function rawToolCall(opts: { + poc: Poc; + bearer?: string; + declareElicitation?: boolean; + args: Record; + inputResponses?: Record; + requestState?: string; +}): Promise<{ status: number; body: any }> { + const capabilities = opts.declareElicitation + ? { elicitation: { form: {} } } + : {}; + const params: Record = { + name: 'create_project', + arguments: opts.args, + }; + if (opts.inputResponses !== undefined) { + params.inputResponses = opts.inputResponses; + } + if (opts.requestState !== undefined) params.requestState = opts.requestState; + + const body = { + jsonrpc: '2.0', + id: randomId(), + method: 'tools/call', + params: { + ...params, + _meta: { + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_INFO_META_KEY]: { + name: 'mcp-elicitations-poc-raw-test', + version: '0.0.0', + }, + [CLIENT_CAPABILITIES_META_KEY]: capabilities, + }, + }, + }; + const response = await opts.poc.handler.fetch( + new Request('http://poc.local/mcp', { + method: 'POST', + headers: { + Authorization: `Bearer ${opts.bearer ?? 'user-alice'}`, + Accept: 'application/json, text/event-stream', + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': '2026-07-28', + 'Mcp-Method': 'tools/call', + 'Mcp-Name': 'create_project', + }, + body: JSON.stringify(body), + }) + ); + return { + status: response.status, + body: parseBody(await response.text()), + }; +} + +let nextId = 1; +function randomId(): number { + return nextId++; +} diff --git a/packages/mcp-elicitations-poc/test/management.test.ts b/packages/mcp-elicitations-poc/test/management.test.ts new file mode 100644 index 00000000..8239cd99 --- /dev/null +++ b/packages/mcp-elicitations-poc/test/management.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createManagementProjectCreator } from '../src/management.js'; +import { createPoc, InMemoryJtiStore } from '../src/server.js'; +import { rawToolCall } from './harness.js'; + +const args = { name: 'staging-project', organization_id: 'org-slug' }; + +async function confirmedCall(poc: ReturnType) { + const initial = await rawToolCall({ + poc, + declareElicitation: true, + args, + }); + return rawToolCall({ + poc, + declareElicitation: true, + args, + inputResponses: { + confirm_cost: { + action: 'accept', + content: { confirm: true }, + }, + }, + requestState: initial.body.result.requestState as string, + }); +} + +describe('Management API project creation', () => { + it('posts the create-project schema and returns the project ref', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ ref: 'project-ref' }), { status: 201 }) + ); + const creator = createManagementProjectCreator({ + baseUrl: 'https://api.supabase.green/', + token: 'staging-token', + region: 'eu-west-1', + fetchImpl, + }); + + await expect(creator(args)).resolves.toEqual({ id: 'project-ref' }); + expect(fetchImpl).toHaveBeenCalledOnce(); + const [url, init] = fetchImpl.mock.calls[0]!; + expect(url).toBe('https://api.supabase.green/v1/projects'); + expect(init?.method).toBe('POST'); + expect(init?.headers).toMatchObject({ + Authorization: 'Bearer staging-token', + 'Content-Type': 'application/json', + }); + const body = JSON.parse(init?.body as string); + expect(body).toMatchObject({ + name: args.name, + organization_slug: args.organization_id, + region: 'eu-west-1', + db_pass: expect.any(String), + }); + expect(body).not.toHaveProperty('organization_id'); + }); + + it('includes the response status when creation fails', async () => { + const creator = createManagementProjectCreator({ + baseUrl: 'https://api.supabase.green', + token: 'staging-token', + fetchImpl: vi + .fn() + .mockResolvedValue(new Response('no capacity', { status: 503 })), + }); + + await expect(creator(args)).rejects.toThrow(/503/); + }); + + it('creates through the injected creator after confirmation', async () => { + const projectCreator = vi + .fn() + .mockResolvedValue({ id: 'real-project-ref' }); + const poc = createPoc({ projectCreator }); + + const response = await confirmedCall(poc); + + expect(projectCreator).toHaveBeenCalledWith(args); + expect(response.body.result.structuredContent.project.id).toBe( + 'real-project-ref' + ); + expect(poc.registry.list()).toHaveLength(1); + }); + + it('rejects replay when real creation uses an in-memory jti store', async () => { + const projectCreator = vi + .fn() + .mockResolvedValue({ id: 'real-project-ref' }); + const poc = createPoc({ + projectCreator, + jtiStore: new InMemoryJtiStore(), + }); + const initial = await rawToolCall({ + poc, + declareElicitation: true, + args, + }); + const confirmed = { + poc, + declareElicitation: true, + args, + inputResponses: { + confirm_cost: { + action: 'accept' as const, + content: { confirm: true }, + }, + }, + requestState: initial.body.result.requestState as string, + }; + + const created = await rawToolCall(confirmed); + const replay = await rawToolCall(confirmed); + + expect(created.body.result.structuredContent.status).toBe('created'); + expect(projectCreator).toHaveBeenCalledOnce(); + expect(replay.body.result).toMatchObject({ + isError: true, + content: [ + { + text: expect.stringMatching(/replay|consumed/i), + }, + ], + }); + expect(poc.registry.list()).toHaveLength(1); + }); + + it('creates in mock mode without using global fetch', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + try { + const poc = createPoc(); + const response = await confirmedCall(poc); + + expect(response.body.result.structuredContent.project).toMatchObject( + args + ); + expect(poc.registry.list()).toHaveLength(1); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('returns a tool error and leaves the registry empty on failure', async () => { + const poc = createPoc({ + projectCreator: vi + .fn() + .mockRejectedValue(new Error('staging unavailable')), + }); + + const response = await confirmedCall(poc); + + expect(response.body.result.isError).toBe(true); + expect(response.body.result.content[0].text).toContain( + 'staging unavailable' + ); + expect(poc.registry.list()).toEqual([]); + }); +}); diff --git a/packages/mcp-elicitations-poc/test/replay-dedupe.test.ts b/packages/mcp-elicitations-poc/test/replay-dedupe.test.ts new file mode 100644 index 00000000..1211c9a4 --- /dev/null +++ b/packages/mcp-elicitations-poc/test/replay-dedupe.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; + +import { createPoc, InMemoryJtiStore } from '../src/server.js'; +import { rawToolCall } from './harness.js'; + +const args = { + name: 'replay-test-project', + organization_id: 'org-1', +}; +const inputResponses = { + confirm_cost: { + action: 'accept', + content: { confirm: true }, + }, +}; + +async function obtainState(poc: ReturnType): Promise { + const response = await rawToolCall({ + poc, + declareElicitation: true, + args, + }); + + expect(response.status).toBe(200); + expect(response.body.result.resultType).toBe('input_required'); + return response.body.result.requestState as string; +} + +async function redeem(poc: ReturnType, requestState: string) { + return rawToolCall({ + poc, + declareElicitation: true, + args, + inputResponses, + requestState, + }); +} + +function expectCreated(response: Awaited>): void { + expect(response.status).toBe(200); + expect(response.body.error).toBeUndefined(); + expect(response.body.result.structuredContent.status).toBe('created'); +} + +function expectReplayRejected( + response: Awaited> +): void { + expect(response.status).toBe(200); + expect(response.body.error).toBeUndefined(); + expect(response.body.result.isError).toBe(true); + expect(response.body.result.structuredContent.status).toBe('error'); + expect(response.body.result.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: expect.stringMatching(/replay|consumed/i), + }), + ]) + ); +} + +describe('requestState replay and jti dedupe', () => { + it('allows a replay within the TTL when no jti store is configured', async () => { + const poc = createPoc({ jtiStore: null }); + const requestState = await obtainState(poc); + + expectCreated(await redeem(poc, requestState)); + expectCreated(await redeem(poc, requestState)); + + expect(poc.registry.countByName(args.name)).toBe(2); + }); + + it('rejects a consumed jti with an in-memory store', async () => { + const poc = createPoc({ jtiStore: new InMemoryJtiStore() }); + const requestState = await obtainState(poc); + + expectCreated(await redeem(poc, requestState)); + expectReplayRejected(await redeem(poc, requestState)); + + expect(poc.registry.countByName(args.name)).toBe(1); + }); + + it('allows replay across instances with separate in-memory stores', async () => { + const stateKey = 'risk-3-multi-instance-state-key-2026-07-31'; + const instanceA = createPoc({ + stateKey, + jtiStore: new InMemoryJtiStore(), + }); + const instanceB = createPoc({ + stateKey, + jtiStore: new InMemoryJtiStore(), + }); + const requestState = await obtainState(instanceA); + + expectCreated(await redeem(instanceA, requestState)); + expectCreated(await redeem(instanceB, requestState)); + + expect(instanceA.registry.countByName(args.name)).toBe(1); + expect(instanceB.registry.countByName(args.name)).toBe(1); + }); + + it('rejects replay across instances that share a jti store', async () => { + const stateKey = 'risk-3-shared-store-state-key-2026-07-31'; + const jtiStore = new InMemoryJtiStore(); + const instanceA = createPoc({ stateKey, jtiStore }); + const instanceB = createPoc({ stateKey, jtiStore }); + const requestState = await obtainState(instanceA); + + expectCreated(await redeem(instanceA, requestState)); + expectReplayRejected(await redeem(instanceB, requestState)); + + expect(instanceA.registry.countByName(args.name)).toBe(1); + expect(instanceB.registry.countByName(args.name)).toBe(0); + }); + + it('allows completion with a fresh state after missing inputResponses', async () => { + const poc = createPoc({ jtiStore: new InMemoryJtiStore() }); + const originalState = await obtainState(poc); + const reissue = await rawToolCall({ + poc, + declareElicitation: true, + args, + requestState: originalState, + }); + + expect(reissue.status).toBe(200); + expect(reissue.body.error).toBeUndefined(); + expect(reissue.body.result.resultType).toBe('input_required'); + expect(reissue.body.result.requestState).not.toBe(originalState); + + expectCreated(await redeem(poc, reissue.body.result.requestState)); + expect(poc.registry.countByName(args.name)).toBe(1); + }); +}); diff --git a/packages/mcp-elicitations-poc/test/token-properties.test.ts b/packages/mcp-elicitations-poc/test/token-properties.test.ts new file mode 100644 index 00000000..85590402 --- /dev/null +++ b/packages/mcp-elicitations-poc/test/token-properties.test.ts @@ -0,0 +1,225 @@ +import { createHmac } from 'node:crypto'; + +import { describe, expect, it } from 'vitest'; + +import { + PROJECT_COST, + createPoc, + legacyConfirmToken, + type Poc, +} from '../src/server.js'; +import { rawToolCall } from './harness.js'; + +const accept = { + confirm_cost: { action: 'accept', content: { confirm: true } }, +}; + +type ProjectArgs = { name: string; organization_id: string }; + +async function mint( + poc: Poc, + args: ProjectArgs, + bearer = 'user-alice' +): Promise { + const response = await rawToolCall({ + poc, + bearer, + declareElicitation: true, + args, + }); + expect(response.status).toBe(200); + expect(response.body.result.resultType).toBe('input_required'); + return response.body.result.requestState as string; +} + +async function redeem( + poc: Poc, + args: ProjectArgs, + requestState: string, + bearer = 'user-alice' +) { + return rawToolCall({ + poc, + bearer, + declareElicitation: true, + args, + requestState, + inputResponses: accept, + }); +} + +function expectCodecRejection(response: Awaited>) { + expect(response.status).toBe(200); + expect(response.body.error).toMatchObject({ + code: -32602, + message: expect.stringMatching(/invalid or expired requestState/i), + }); +} + +function expectHandlerRejection( + response: Awaited>, + message: RegExp +) { + expect(response.status).toBe(200); + expect(response.body.error).toBeUndefined(); + expect(response.body.result.isError).toBe(true); + expect(response.body.result.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: expect.stringMatching(message), + }), + ]) + ); +} + +async function expectFreshFlowSucceeds( + poc: Poc, + args: ProjectArgs, + bearer = 'user-alice' +) { + const freshState = await mint(poc, args, bearer); + const response = await redeem(poc, args, freshState, bearer); + expect(response.body.error).toBeUndefined(); + expect(response.body.result.isError).not.toBe(true); + expect(response.body.result.structuredContent.status).toBe('created'); +} + +function flipMiddleCharacter(value: string): string { + const index = Math.floor(value.length / 2); + const replacement = value[index] === 'A' ? 'B' : 'A'; + return `${value.slice(0, index)}${replacement}${value.slice(index + 1)}`; +} + +function alterEncodedPayload(state: string): string { + const segments = state.split('.'); + expect(segments).toHaveLength(3); + const encodedPayload = segments[1]; + if (encodedPayload === undefined) { + throw new Error('Expected an encoded payload segment'); + } + const payload = JSON.parse( + Buffer.from(encodedPayload, 'base64url').toString('utf8') + ) as Record; + payload.sub = 'user-mallory'; + segments[1] = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return segments.join('.'); +} + +describe('requestState token security properties', () => { + it.each([ + ['single-character mutation', flipMiddleCharacter], + ['decoded payload mutation', alterEncodedPayload], + ])('rejects tampered state: %s', async (_, mutate) => { + const poc = createPoc(); + const args = { name: 'tamper-target', organization_id: 'org-1' }; + const state = await mint(poc, args); + + expectCodecRejection(await redeem(poc, args, mutate(state))); + expect(poc.registry.list()).toEqual([]); + + await expectFreshFlowSucceeds(poc, args); + expect(poc.registry.countByName(args.name)).toBe(1); + }); + + it('rejects expired state', async () => { + const poc = createPoc({ ttlSeconds: 1 }); + const args = { name: 'expired-target', organization_id: 'org-1' }; + const state = await mint(poc, args); + + // The codec stores integer-second exp and accepts the token at the boundary. + await new Promise((resolve) => setTimeout(resolve, 2_100)); + + expectCodecRejection(await redeem(poc, args, state)); + expect(poc.registry.list()).toEqual([]); + + await expectFreshFlowSucceeds(poc, args); + expect(poc.registry.countByName(args.name)).toBe(1); + }); + + it('rejects state redeemed by a different principal', async () => { + const poc = createPoc(); + const args = { name: 'principal-target', organization_id: 'org-1' }; + const state = await mint(poc, args, 'user-alice'); + + const rejected = await redeem(poc, args, state, 'user-mallory'); + expectHandlerRejection(rejected, /principal.*does not match/i); + expect(poc.registry.list()).toEqual([]); + + await expectFreshFlowSucceeds(poc, args, 'user-alice'); + expect(poc.registry.countByName(args.name)).toBe(1); + }); + + it('rejects state redeemed with different arguments', async () => { + const poc = createPoc(); + const original = { name: 'proj-a', organization_id: 'org-1' }; + const changed = { name: 'proj-evil', organization_id: 'org-1' }; + const state = await mint(poc, original); + + const rejected = await redeem(poc, changed, state); + expectHandlerRejection(rejected, /arguments.*do not match/i); + expect(poc.registry.countByName(original.name)).toBe(0); + expect(poc.registry.countByName(changed.name)).toBe(0); + + await expectFreshFlowSucceeds(poc, original); + expect(poc.registry.countByName(original.name)).toBe(1); + expect(poc.registry.countByName(changed.name)).toBe(0); + }); + + it('rejects a client-readable payload signed with an attacker key', async () => { + const poc = createPoc(); + const args = { name: 'forgery-target', organization_id: 'org-1' }; + const genuine = await mint(poc, args); + const [version, encodedPayload] = genuine.split('.'); + if (version === undefined || encodedPayload === undefined) { + throw new Error('Expected version and payload segments'); + } + const payload = JSON.parse( + Buffer.from(encodedPayload, 'base64url').toString('utf8') + ) as Record; + + expect(payload).toMatchObject({ + p: { + sub: 'user-alice', + tool: 'create_project', + cost: PROJECT_COST, + }, + }); + + payload.p.argsDigest = '0'.repeat(64); + payload.p.jti = 'attacker-chosen-jti'; + const forgedBody = Buffer.from(JSON.stringify(payload)).toString( + 'base64url' + ); + const forgedMac = createHmac('sha256', 'attacker-guess') + .update(`${version}.${forgedBody}`) + .digest('base64url'); + const forgedState = `${version}.${forgedBody}.${forgedMac}`; + + expectCodecRejection(await redeem(poc, args, forgedState)); + expect(poc.registry.list()).toEqual([]); + + await expectFreshFlowSucceeds(poc, args); + expect(poc.registry.countByName(args.name)).toBe(1); + }); + + it('contrasts signed state with the legacy precompute path', async () => { + const poc = createPoc(); + const args = { name: 'legacy-precomputed', organization_id: 'org-1' }; + const confirm_cost_token = legacyConfirmToken( + args.name, + args.organization_id + ); + + const response = await rawToolCall({ + poc, + declareElicitation: false, + args: { ...args, confirm_cost_token }, + }); + + expect(response.body.error).toBeUndefined(); + expect(response.body.result.isError).not.toBe(true); + expect(response.body.result.structuredContent.status).toBe('created'); + expect(poc.registry.countByName(args.name)).toBe(1); + }); +}); diff --git a/packages/mcp-elicitations-poc/test/url-happy-path.test.ts b/packages/mcp-elicitations-poc/test/url-happy-path.test.ts new file mode 100644 index 00000000..3e763bc1 --- /dev/null +++ b/packages/mcp-elicitations-poc/test/url-happy-path.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; + +import { createUrlPoc } from '../src/url-server.js'; +import { + openConnectPage, + rawUrlToolCall, + submitSecret, +} from './url-harness.js'; + +function elicitation(result: any) { + return result.body.result.inputRequests.provide_api_key.params; +} + +function interactionId(url: string): string { + return new URL(url).searchParams.get('i')!; +} + +describe('URL-mode elicitation happy path', () => { + it('waits for the out-of-band interaction, then returns only secret metadata', async () => { + const poc = createUrlPoc({ + stateKey: 'url-happy-path-state-key-at-least-32-bytes', + }); + const first = await rawUrlToolCall({ poc, args: { name: 'github' } }); + const firstRequest = elicitation(first); + expect(first.body.result.resultType).toBe('input_required'); + expect(firstRequest).toMatchObject({ + mode: 'url', + url: expect.any(String), + }); + expect(firstRequest).not.toHaveProperty('requestedSchema'); + const id = interactionId(firstRequest.url); + + const waiting = await rawUrlToolCall({ + poc, + args: { name: 'github' }, + requestState: first.body.result.requestState, + inputResponses: { provide_api_key: { action: 'accept' } }, + }); + expect(waiting.body.result.resultType).toBe('input_required'); + expect(waiting.body.result.requestState).not.toBe( + first.body.result.requestState + ); + expect(interactionId(elicitation(waiting).url)).toBe(id); + + expect((await openConnectPage({ poc, url: firstRequest.url })).status).toBe( + 401 + ); + expect( + ( + await openConnectPage({ + poc, + url: firstRequest.url, + session: 'user-bob', + }) + ).status + ).toBe(403); + expect( + ( + await openConnectPage({ + poc, + url: firstRequest.url, + session: 'user-alice', + }) + ).status + ).toBe(200); + expect( + ( + await submitSecret({ + poc, + interactionId: id, + secret: 'sk-test-1234', + session: 'user-alice', + }) + ).status + ).toBe(200); + + const complete = await rawUrlToolCall({ + poc, + args: { name: 'github' }, + requestState: waiting.body.result.requestState, + inputResponses: { provide_api_key: { action: 'accept' } }, + }); + expect(complete.body.result.structuredContent).toMatchObject({ + status: 'stored', + name: 'github', + secret_ref: expect.any(String), + }); + expect(JSON.stringify(complete.body)).not.toContain('sk-test-1234'); + expect(poc.secrets.get('user-alice', 'github')).toEqual({ + ref: complete.body.result.structuredContent.secret_ref, + last4: '1234', + }); + }); + + for (const action of ['decline', 'cancel'] as const) { + it(`returns ${action} as a normal, distinct result`, async () => { + const poc = createUrlPoc(); + const first = await rawUrlToolCall({ poc, args: { name: 'github' } }); + const final = await rawUrlToolCall({ + poc, + args: { name: 'github' }, + requestState: first.body.result.requestState, + inputResponses: { provide_api_key: { action } }, + }); + expect(final.body.result.structuredContent).toEqual({ + status: `${action}${action === 'cancel' ? 'led' : 'd'}`, + }); + expect(final.body.result.isError).not.toBe(true); + expect(poc.secrets.get('user-alice', 'github')).toBeUndefined(); + }); + } +}); diff --git a/packages/mcp-elicitations-poc/test/url-harness.ts b/packages/mcp-elicitations-poc/test/url-harness.ts new file mode 100644 index 00000000..9fecdf90 --- /dev/null +++ b/packages/mcp-elicitations-poc/test/url-harness.ts @@ -0,0 +1,182 @@ +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + Client, + PROTOCOL_VERSION_META_KEY, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; + +import type { UrlPoc } from '../src/url-server.js'; + +export type WireFrame = { + direction: 'request' | 'response'; + status?: number; + body: any; +}; + +export type UrlTestClientOptions = { + poc: UrlPoc; + bearer?: string; + capabilities?: 'url' | 'form-only' | 'none'; + onUrl?: ( + url: string, + message: string + ) => { + action: 'accept' | 'decline' | 'cancel'; + }; +}; + +function parseBody(text: string): any { + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function capabilities( + value: 'url' | 'form-only' | 'none' = 'url' +): Record { + if (value === 'url') return { elicitation: { url: {} } }; + if (value === 'form-only') return { elicitation: { form: {} } }; + return {}; +} + +export async function createUrlTestClient(opts: UrlTestClientOptions): Promise<{ + client: Client; + wire: WireFrame[]; + close(): Promise; +}> { + const wire: WireFrame[] = []; + const capturedFetch: typeof fetch = async (input, init) => { + const outgoing = new Request(input, init); + wire.push({ + direction: 'request', + body: parseBody(await outgoing.clone().text()), + }); + const response = await opts.poc.handler.fetch(outgoing); + wire.push({ + direction: 'response', + status: response.status, + body: parseBody(await response.clone().text()), + }); + return response; + }; + const client = new Client( + { name: 'mcp-url-elicitations-poc-test', version: '0.0.0' }, + { + versionNegotiation: { mode: { pin: '2026-07-28' } }, + capabilities: capabilities(opts.capabilities), + } + ); + if (opts.onUrl) { + client.setRequestHandler('elicitation/create', async (request) => { + if (request.params.mode !== 'url') + throw new Error('Expected URL elicitation'); + return opts.onUrl!(request.params.url, request.params.message); + }); + } + const transport = new StreamableHTTPClientTransport( + new URL('http://poc.local/mcp'), + { + fetch: capturedFetch, + requestInit: { + headers: { Authorization: `Bearer ${opts.bearer ?? 'user-alice'}` }, + }, + } + ); + await client.connect(transport); + return { client, wire, close: () => client.close() }; +} + +let nextId = 1000; +export async function rawUrlToolCall(opts: { + poc: UrlPoc; + bearer?: string; + capabilities?: 'url' | 'form-only' | 'none'; + args: Record; + inputResponses?: Record; + requestState?: string; +}): Promise<{ status: number; body: any }> { + const params: Record = { + name: 'store_api_key', + arguments: opts.args, + }; + if (opts.inputResponses !== undefined) + params.inputResponses = opts.inputResponses; + if (opts.requestState !== undefined) params.requestState = opts.requestState; + const body = { + jsonrpc: '2.0', + id: nextId++, + method: 'tools/call', + params: { + ...params, + _meta: { + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_INFO_META_KEY]: { + name: 'mcp-url-poc-raw-test', + version: '0.0.0', + }, + [CLIENT_CAPABILITIES_META_KEY]: capabilities(opts.capabilities), + }, + }, + }; + const response = await opts.poc.handler.fetch( + new Request('http://poc.local/mcp', { + method: 'POST', + headers: { + Authorization: `Bearer ${opts.bearer ?? 'user-alice'}`, + Accept: 'application/json, text/event-stream', + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': '2026-07-28', + 'Mcp-Method': 'tools/call', + 'Mcp-Name': 'store_api_key', + }, + body: JSON.stringify(body), + }) + ); + return { status: response.status, body: parseBody(await response.text()) }; +} + +export async function openConnectPage(opts: { + poc: UrlPoc; + url: string; + session?: string; +}): Promise<{ status: number; body: string }> { + const response = await opts.poc.connect.fetch( + new Request(opts.url, { + headers: opts.session + ? { Cookie: `poc_session=${encodeURIComponent(opts.session)}` } + : {}, + }) + ); + return { status: response.status, body: await response.text() }; +} + +export async function submitSecret(opts: { + poc: UrlPoc; + interactionId: string; + secret: string; + name?: string; + session?: string; +}): Promise<{ status: number; body: string }> { + const form = new URLSearchParams({ + i: opts.interactionId, + name: opts.name ?? 'github', + secret: opts.secret, + }); + const response = await opts.poc.connect.fetch( + new Request('http://localhost:3901/connect', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(opts.session + ? { Cookie: `poc_session=${encodeURIComponent(opts.session)}` } + : {}), + }, + body: form, + }) + ); + return { status: response.status, body: await response.text() }; +} diff --git a/packages/mcp-elicitations-poc/test/url-lifecycle.test.ts b/packages/mcp-elicitations-poc/test/url-lifecycle.test.ts new file mode 100644 index 00000000..cdad60ed --- /dev/null +++ b/packages/mcp-elicitations-poc/test/url-lifecycle.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from 'vitest'; + +import { createUrlPoc } from '../src/url-server.js'; +import { InMemoryInteractionStore } from '../src/url-stores.js'; +import { + createUrlTestClient, + openConnectPage, + rawUrlToolCall, + submitSecret, +} from './url-harness.js'; + +function request(result: any) { + return result.body.result.inputRequests.provide_api_key.params; +} + +function interactionId(result: any): string { + return new URL(request(result).url).searchParams.get('i')!; +} + +function accept(requestState: string) { + return { + requestState, + inputResponses: { provide_api_key: { action: 'accept' } }, + }; +} + +async function start(poc: ReturnType) { + return rawUrlToolCall({ poc, args: { name: 'github' } }); +} + +async function retry( + poc: ReturnType, + requestState: string, + inputResponses: Record = { + provide_api_key: { action: 'accept' }, + } +) { + return rawUrlToolCall({ + poc, + args: { name: 'github' }, + requestState, + inputResponses, + }); +} + +async function finish( + poc: ReturnType, + current: any, + secret = 'sk-lifecycle-1234' +) { + const id = interactionId(current); + expect( + ( + await submitSecret({ + poc, + interactionId: id, + secret, + session: 'user-alice', + }) + ).status + ).toBe(200); + return retry(poc, current.body.result.requestState); +} + +describe('URL-mode lifecycle', () => { + describe('accept before completion', () => { + it('reissues fresh state for the same pending interaction', async () => { + const poc = createUrlPoc(); + const first = await start(poc); + const waiting = await retry(poc, first.body.result.requestState); + + expect(waiting.body.result.resultType).toBe('input_required'); + expect(waiting.body.result.requestState).not.toBe( + first.body.result.requestState + ); + expect(interactionId(waiting)).toBe(interactionId(first)); + expect(poc.secrets.get('user-alice', 'github')).toBeUndefined(); + }); + + it('keeps one interaction through three accepted pending retries, then completes', async () => { + const poc = createUrlPoc(); + const first = await start(poc); + const id = interactionId(first); + let current = first; + + for (let round = 0; round < 3; round += 1) { + const previousState = current.body.result.requestState; + current = await retry(poc, previousState); + expect(current.body.result.resultType).toBe('input_required'); + expect(current.body.result.requestState).not.toBe(previousState); + expect(interactionId(current)).toBe(id); + expect(poc.secrets.get('user-alice', 'github')).toBeUndefined(); + } + + const complete = await finish(poc, current); + expect(complete.body.result.structuredContent).toMatchObject({ + status: 'stored', + name: 'github', + }); + expect(poc.secrets.get('user-alice', 'github')?.last4).toBe('1234'); + }); + + for (const [label, responses] of [ + ['a missing response entry', { unrelated: { action: 'accept' } }], + ['an empty response map', {}], + ] as const) { + it(`reissues for ${label}`, async () => { + const poc = createUrlPoc(); + const first = await start(poc); + const waiting = await retry( + poc, + first.body.result.requestState, + responses + ); + + expect(waiting.body.result.resultType).toBe('input_required'); + expect(waiting.body.result.requestState).not.toBe( + first.body.result.requestState + ); + expect(interactionId(waiting)).toBe(interactionId(first)); + expect(waiting.body.result.isError).not.toBe(true); + expect(poc.secrets.get('user-alice', 'github')).toBeUndefined(); + }); + } + }); + + describe('expiry', () => { + it('returns 404 when the connect page interaction has expired', async () => { + let now = 1_000_000; + const poc = createUrlPoc({ ttlSeconds: 1, clock: () => now }); + const first = await start(poc); + now += 1_001; + + const page = await openConnectPage({ + poc, + url: request(first).url, + session: 'user-alice', + }); + expect(page).toEqual({ + status: 404, + body: 'Interaction not found or expired.', + }); + expect(poc.secrets.get('user-alice', 'github')).toBeUndefined(); + }); + + it('rejects an accepted retry after interaction expiry', async () => { + let now = 2_000_000; + const poc = createUrlPoc({ ttlSeconds: 1, clock: () => now }); + const first = await start(poc); + now += 1_001; + const expired = await retry(poc, first.body.result.requestState); + + expect(expired.body.result.isError).toBe(true); + expect(expired.body.result.content[0].text).toBe( + 'The interaction is missing or expired.' + ); + expect(expired.body.result.structuredContent).toEqual({ + status: 'error', + }); + expect(poc.secrets.get('user-alice', 'github')).toBeUndefined(); + }); + + it('can complete a fresh flow after an expiry rejection', async () => { + let now = 3_000_000; + const poc = createUrlPoc({ ttlSeconds: 1, clock: () => now }); + const expiredFlow = await start(poc); + now += 1_001; + expect( + (await retry(poc, expiredFlow.body.result.requestState)).body.result + .isError + ).toBe(true); + + const fresh = await start(poc); + const complete = await finish(poc, fresh, 'sk-fresh-5678'); + expect(complete.body.result.structuredContent).toMatchObject({ + status: 'stored', + }); + expect(poc.secrets.get('user-alice', 'github')?.last4).toBe('5678'); + }); + }); + + describe('one-time redemption', () => { + it('rejects an identical completing retry without a second secret write', async () => { + const poc = createUrlPoc(); + const first = await start(poc); + const id = interactionId(first); + expect( + ( + await submitSecret({ + poc, + interactionId: id, + secret: 'sk-replay-9999', + session: 'user-alice', + }) + ).status + ).toBe(200); + const completing = accept(first.body.result.requestState); + const complete = await rawUrlToolCall({ + poc, + args: { name: 'github' }, + ...completing, + }); + const stored = poc.secrets.get('user-alice', 'github'); + const replay = await rawUrlToolCall({ + poc, + args: { name: 'github' }, + ...completing, + }); + + expect(complete.body.result.structuredContent.status).toBe('stored'); + expect(replay.body.result.isError).toBe(true); + expect(JSON.stringify(replay.body)).toMatch(/replay|consumed/i); + expect(poc.secrets.get('user-alice', 'github')).toEqual(stored); + expect(stored?.ref).toBe( + complete.body.result.structuredContent.secret_ref + ); + }); + + it('allows an interaction store record to be consumed once', () => { + const interactions = new InMemoryInteractionStore(() => 100); + interactions.create({ + id: 'one-time', + principal: 'user-alice', + tool: 'store_api_key', + argsDigest: 'digest', + exp: 200, + }); + expect(interactions.complete('one-time')).toBe(true); + expect(interactions.consume('one-time')).toBe(true); + expect(interactions.consume('one-time')).toBe(false); + }); + }); + + describe('URL capability gating', () => { + for (const capability of ['form-only', 'none'] as const) { + it(`returns unsupported_client without a URL request for ${capability}`, async () => { + const poc = createUrlPoc(); + const connection = await createUrlTestClient({ + poc, + capabilities: capability, + }); + try { + const result: any = await connection.client.callTool({ + name: 'store_api_key', + arguments: { name: 'github' }, + }); + expect(result.structuredContent).toEqual({ + status: 'unsupported_client', + message: + 'A browser-capable client that declares URL elicitation is required.', + }); + expect(JSON.stringify(connection.wire)).not.toMatch( + /inputRequests|\"mode\":\"url\"/ + ); + expect(poc.secrets.get('user-alice', 'github')).toBeUndefined(); + } finally { + await connection.close(); + } + }); + } + + it('sends a URL request without a requested schema to a URL-capable client', async () => { + const poc = createUrlPoc(); + const first = await start(poc); + + expect(request(first)).toMatchObject({ + mode: 'url', + url: expect.any(String), + }); + expect(request(first)).not.toHaveProperty('requestedSchema'); + }); + + it('rejects valid URL state presented by a form-only client', async () => { + const poc = createUrlPoc(); + const first = await start(poc); + const crossCapability = await rawUrlToolCall({ + poc, + capabilities: 'form-only', + args: { name: 'github' }, + ...accept(first.body.result.requestState), + }); + + expect(crossCapability.body.result.isError).not.toBe(true); + expect(crossCapability.body.result.structuredContent).toEqual({ + status: 'unsupported_client', + message: + 'A browser-capable client that declares URL elicitation is required.', + }); + expect(poc.secrets.get('user-alice', 'github')).toBeUndefined(); + }); + }); +}); diff --git a/packages/mcp-elicitations-poc/test/url-security.test.ts b/packages/mcp-elicitations-poc/test/url-security.test.ts new file mode 100644 index 00000000..cd1102d1 --- /dev/null +++ b/packages/mcp-elicitations-poc/test/url-security.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest'; + +import { createUrlPoc } from '../src/url-server.js'; +import { + createUrlTestClient, + openConnectPage, + rawUrlToolCall, + submitSecret, +} from './url-harness.js'; + +const SENTINEL = 'sk-live-SENTINEL-9f3a2b'; + +function leakCandidates(value: string): Set { + const candidates = new Set(); + for (let start = 0; start <= value.length - 4; start += 1) { + for (let end = start + 4; end <= value.length; end += 1) { + candidates.add(value.slice(start, end)); + } + } + + for (const part of [value, value.slice(-8)]) { + candidates.add(Buffer.from(part).toString('base64')); + candidates.add(Buffer.from(part).toString('base64url')); + candidates.add(encodeURIComponent(part)); + } + return candidates; +} + +function elicitation(result: any) { + return result.body.result.inputRequests.provide_api_key.params; +} + +function interactionId(url: string): string { + return new URL(url).searchParams.get('i')!; +} + +describe('URL-mode security', () => { + describe('secret isolation from the MCP wire', () => { + it('keeps the secret on the connect channel and returns metadata only', async () => { + const poc = createUrlPoc({ + stateKey: 'url-security-wire-state-key-at-least-32-bytes', + }); + let connectUrl = ''; + const testClient = await createUrlTestClient({ + poc, + bearer: 'user-alice', + onUrl: (async (url: string) => { + connectUrl = url; + const opened = await openConnectPage({ + poc, + url, + session: 'user-alice', + }); + expect(opened.status).toBe(200); + const submitted = await submitSecret({ + poc, + interactionId: interactionId(url), + secret: SENTINEL, + session: 'user-alice', + }); + expect(submitted.status).toBe(200); + return { action: 'accept' as const }; + }) as any, + }); + + try { + const final = await testClient.client.callTool({ + name: 'store_api_key', + arguments: { name: 'github' }, + }); + expect( + new Set(testClient.wire.map((frame) => frame.direction)) + ).toEqual(new Set(['request', 'response'])); + const candidates = leakCandidates(SENTINEL); + for (const frame of testClient.wire) { + const serialized = JSON.stringify(frame); + for (const candidate of candidates) { + expect(serialized).not.toContain(candidate); + } + } + + expect(final.structuredContent).toEqual({ + status: 'stored', + name: 'github', + secret_ref: expect.any(String), + }); + expect(final.structuredContent).not.toHaveProperty('last4'); + expect(final.structuredContent).toHaveProperty('secret_ref'); + + const url = new URL(connectUrl); + expect(url.searchParams.size).toBe(1); + expect([...url.searchParams.keys()]).toEqual(['i']); + expect(url.searchParams.get('i')).toMatch(/^[0-9a-f-]{36}$/i); + expect(connectUrl).not.toMatch(/bearer|sub|secret/i); + + expect(poc.secrets.get('user-alice', 'github')).toEqual({ + ref: (final.structuredContent as any).secret_ref, + last4: '3a2b', + }); + } finally { + await testClient.close(); + } + }); + }); + + describe('phishing binding', () => { + it("rejects missing and mismatched sessions without wedging Alice's flow", async () => { + const poc = createUrlPoc({ + stateKey: 'url-security-phishing-state-key-at-least-32-bytes', + }); + const first = await rawUrlToolCall({ + poc, + bearer: 'user-alice', + args: { name: 'github' }, + }); + const url = elicitation(first).url; + const id = interactionId(url); + + const unauthenticated = await openConnectPage({ poc, url }); + expect(unauthenticated).toEqual({ + status: 401, + body: 'A valid mock dashboard session is required.', + }); + expect(poc.interactions.get(id)?.status).toBe('pending'); + expect(poc.secrets.get('user-alice', 'github')).toBeUndefined(); + + const nobody = await openConnectPage({ + poc, + url, + session: 'user-nobody', + }); + expect(nobody.status).toBe(403); + expect(nobody.body).toMatch(/identity mismatch/i); + expect(poc.interactions.get(id)?.status).toBe('pending'); + expect(poc.secrets.get('user-nobody', 'github')).toBeUndefined(); + + const bobOpen = await openConnectPage({ poc, url, session: 'user-bob' }); + expect(bobOpen.status).toBe(403); + expect(bobOpen.body).toMatch(/identity mismatch/i); + expect(poc.interactions.get(id)?.status).toBe('pending'); + expect(poc.secrets.get('user-alice', 'github')).toBeUndefined(); + + const bobPost = await submitSecret({ + poc, + interactionId: id, + secret: SENTINEL, + session: 'user-bob', + }); + expect(bobPost.status).toBe(403); + expect(bobPost.body).toMatch(/identity mismatch/i); + expect(poc.interactions.get(id)?.status).toBe('pending'); + expect(poc.secrets.get('user-alice', 'github')).toBeUndefined(); + expect(poc.secrets.get('user-bob', 'github')).toBeUndefined(); + + expect( + await openConnectPage({ poc, url, session: 'user-alice' }) + ).toMatchObject({ status: 200 }); + expect( + await submitSecret({ + poc, + interactionId: id, + secret: SENTINEL, + session: 'user-alice', + }) + ).toMatchObject({ status: 200 }); + + const complete = await rawUrlToolCall({ + poc, + bearer: 'user-alice', + args: { name: 'github' }, + requestState: first.body.result.requestState, + inputResponses: { provide_api_key: { action: 'accept' } }, + }); + expect(complete.body.result.structuredContent).toMatchObject({ + status: 'stored', + secret_ref: expect.any(String), + }); + expect(complete.body.result.structuredContent).not.toHaveProperty( + 'last4' + ); + expect(poc.secrets.get('user-alice', 'github')).toEqual({ + ref: complete.body.result.structuredContent.secret_ref, + last4: '3a2b', + }); + }); + }); + + describe('cross-principal state redemption', () => { + it("rejects Mallory's retry and leaves Alice's stored secret untouched", async () => { + const poc = createUrlPoc({ + stateKey: 'url-security-redemption-state-key-at-least-32-bytes', + }); + const first = await rawUrlToolCall({ + poc, + bearer: 'user-alice', + args: { name: 'github' }, + }); + const url = elicitation(first).url; + await openConnectPage({ poc, url, session: 'user-alice' }); + await submitSecret({ + poc, + interactionId: interactionId(url), + secret: SENTINEL, + session: 'user-alice', + }); + const aliceSecret = poc.secrets.get('user-alice', 'github'); + + const mallory = await rawUrlToolCall({ + poc, + bearer: 'user-mallory', + args: { name: 'github' }, + requestState: first.body.result.requestState, + inputResponses: { provide_api_key: { action: 'accept' } }, + }); + expect(mallory.status).toBe(200); + expect(mallory.body.result).toMatchObject({ + isError: true, + structuredContent: { status: 'error' }, + content: [{ type: 'text', text: 'Request state principal mismatch.' }], + }); + expect(JSON.stringify(mallory.body)).not.toMatch( + /secret_ref|sk-live-SENTINEL/ + ); + expect(poc.secrets.get('user-mallory', 'github')).toBeUndefined(); + expect(poc.secrets.get('user-alice', 'github')).toEqual(aliceSecret); + expect(poc.interactions.get(interactionId(url))?.status).toBe('complete'); + }); + }); +}); diff --git a/packages/mcp-elicitations-poc/tsconfig.json b/packages/mcp-elicitations-poc/tsconfig.json new file mode 100644 index 00000000..f9f2c4db --- /dev/null +++ b/packages/mcp-elicitations-poc/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@total-typescript/tsconfig/tsc/dom/library", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6d9d917..d0948d7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,37 @@ importers: specifier: ^2.1.1 version: 2.34.3 + packages/mcp-elicitations-poc: + dependencies: + '@modelcontextprotocol/client': + specifier: 2.0.0 + version: 2.0.0 + '@modelcontextprotocol/node': + specifier: 2.0.0 + version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.11.3) + '@modelcontextprotocol/server': + specifier: 2.0.0 + version: 2.0.0 + zod: + specifier: 'catalog:' + version: 4.2.1 + devDependencies: + '@total-typescript/tsconfig': + specifier: ^1.0.4 + version: 1.0.4 + '@types/node': + specifier: ^22.8.6 + version: 22.17.2 + tsx: + specifier: ^4.19.2 + version: 4.20.4 + typescript: + specifier: ^5.6.3 + version: 5.9.2 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.17.2)(msw@2.10.5(@types/node@22.17.2)(typescript@5.9.2)) + packages/mcp-server-postgrest: dependencies: '@supabase/mcp-utils': @@ -627,6 +658,12 @@ packages: cpu: [x64] os: [win32] + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hono/node-server@1.19.7': resolution: {integrity: sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==} engines: {node: '>=18.14.1'} @@ -699,6 +736,24 @@ packages: '@mjackson/multipart-parser@0.10.1': resolution: {integrity: sha512-cHMD6+ErH/DrEfC0N6Ru/+1eAdavxdV0C35PzSb5/SD7z3XoaDMc16xPJcb8CahWjSpqHY+Too9sAb6/UNuq7A==} + '@modelcontextprotocol/client@2.0.0': + resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==} + engines: {node: '>=20'} + + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + + '@modelcontextprotocol/node@2.0.0': + resolution: {integrity: sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==} + engines: {node: '>=20'} + peerDependencies: + '@modelcontextprotocol/server': ^2.0.0 + hono: ^4.11.4 + peerDependenciesMeta: + hono: + optional: true + '@modelcontextprotocol/sdk@1.25.2': resolution: {integrity: sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==} engines: {node: '>=18'} @@ -709,6 +764,10 @@ packages: '@cfworker/json-schema': optional: true + '@modelcontextprotocol/server@2.0.0': + resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + engines: {node: '>=20'} + '@mswjs/interceptors@0.39.6': resolution: {integrity: sha512-bndDP83naYYkfayr/qhBHMhk0YGwS1iv6vaEGcr0SQbO0IZtbOPqjKjds/WcG+bJA+1T5vCx6kprKOzn5Bg+Vw==} engines: {node: '>=18'} @@ -2497,6 +2556,10 @@ snapshots: '@esbuild/win32-x64@0.25.9': optional: true + '@hono/node-server@1.19.17(hono@4.11.3)': + dependencies: + hono: 4.11.3 + '@hono/node-server@1.19.7(hono@4.11.3)': dependencies: hono: 4.11.3 @@ -2569,6 +2632,27 @@ snapshots: dependencies: '@mjackson/headers': 0.11.1 + '@modelcontextprotocol/client@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + jose: 6.1.3 + pkce-challenge: 5.0.0 + zod: 4.2.1 + + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.2.1 + + '@modelcontextprotocol/node@2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.11.3)': + dependencies: + '@hono/node-server': 1.19.17(hono@4.11.3) + '@modelcontextprotocol/server': 2.0.0 + optionalDependencies: + hono: 4.11.3 + '@modelcontextprotocol/sdk@1.25.2(hono@4.11.3)(zod@4.2.1)': dependencies: '@hono/node-server': 1.19.7(hono@4.11.3) @@ -2591,6 +2675,11 @@ snapshots: - hono - supports-color + '@modelcontextprotocol/server@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + zod: 4.2.1 + '@mswjs/interceptors@0.39.6': dependencies: '@open-draft/deferred-promise': 2.2.0