Skip to content

fix(storyboard): default signed_requests vector transport to 'mcp' (adcp#6548) - #2540

Merged
bokelley merged 6 commits into
adcontextprotocol:mainfrom
fgranata:fix/signed-requests-mcp-transport-default
Aug 16, 2026
Merged

fix(storyboard): default signed_requests vector transport to 'mcp' (adcp#6548)#2540
bokelley merged 6 commits into
adcontextprotocol:mainfrom
fgranata:fix/signed-requests-mcp-transport-default

Conversation

@fgranata

Copy link
Copy Markdown
Contributor

Fixes adcontextprotocol/adcp#6548 — the signed_requests storyboard grades all-red (39/39 routing 404s) against any MCP-transport agent, because the vector dispatch defaults to transport: 'raw' and replays the fixtures' recorded REST-binding targets (/adcp/create_media_buy, raw task body) verbatim. MCP agents serve AdCP tasks inside tools/call on a single endpoint, so the per-task paths 404 at the router before the agent's verifier is ever reached.

The fix is a default, not new machinery: the builder's 'mcp' mode (tools/call envelope, re-signed against the actual target) already exists and ships in the published SDK — nothing selected it. This PR defaults the dispatch to 'mcp' via a small exported resolver; 'raw' remains an explicit opt-in (request_signing.transport: 'raw') for REST-binding agents. The MCP-ungradable canonicalization-edge vectors (005–008) already have their skip handling in the grader, unchanged by this PR.

Verified live against an MCP seller (bidmachine, request_signing.supported: true, verifier in production use by a signing buyer): with this change the vectors go from 39/39 404 to reaching the verification layer (positive vectors now surface the agent's auth semantics instead of a routing dead-end — the remaining signature-as-authentication question is agent-side and out of scope here).

Test: test/lib/request-signing-vector-transport.test.js (node:test, matches the suite convention) — default 'mcp', explicit 'raw' and 'mcp' respected. tsc --noEmit clean; targeted test green against the built dist.

The runner reaches agents through tools/call (MCP, or the AdCP-over-A2A
binding) — never through per-task HTTP paths — but the request-signing
vector dispatch defaulted to 'raw', replaying the fixtures' recorded
REST targets (/adcp/create_media_buy + raw task body) verbatim. On any
MCP-transport agent every vector then 404s at the router before the
agent's verifier can run (adcontextprotocol/adcp#6548), which is how
the Transport Security track currently grades all-red against MCP
sellers whose verifiers demonstrably work on live signed traffic.

The builder's 'mcp' mode (tools/call envelope, re-signed against the
actual MCP endpoint) already exists and ships in the published SDK —
nothing selected it. Default the dispatch to 'mcp' via a small exported
resolver; 'raw' remains an explicit opt-in (request_signing.transport)
for REST-binding agents. Verified against a live MCP seller: vectors go
from 39/39 routing 404s to reaching the verifier.

Fixes adcontextprotocol/adcp#6548
Comment thread src/lib/testing/storyboard/request-signing/probe-dispatch.ts

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Request changes

Request changes — 1 blocking finding.

The fix itself is correct and well-scoped: the storyboard-runner request-signing dispatch now defaults vector transport to 'mcp' via a new exported resolver, overriding the grader's 'raw' default. Since the runner reaches agents only via tools/call, raw REST replay would 404 MCP agents by construction (adcp#6548), so the default flip is the right shape and 'raw' remains an explicit opt-in. The change is localized to the single caller and the direct grader CLI path is untouched.

Blocking findings

  • src/lib/testing/storyboard/request-signing/probe-dispatch.ts:67 — Missing changeset for a published-harness default flip. Per the mandatory changeset-vs-wire-impact rule, diffs touching src/lib/** require a .changeset/*.md; its absence is a high finding.

Add a changeset describing the transport-default behavior change and this is good to merge.

Blocking findings

  • src/lib/testing/storyboard/request-signing/probe-dispatch.ts:67 — Missing changeset for published-harness default flip

@fgranata

Copy link
Copy Markdown
Contributor Author

Live-validation update, peeling the layers one at a time against an MCP seller with signature verification + signature-as-auth deployed:

  1. This PR's transport default works: vectors went from 39/39 routing 404s to reaching the seller's verification layer.
  2. Signature verification over the adapted request works: after the seller honored X-Forwarded-Proto in its base reconstruction (TLS terminated at its load balancer), positive vectors verify cryptographically — confirmed server-side (accepted).
  3. The last layer is MCP session semantics: the mcp mode POSTs the signed tools/call session-less, and a stock streamable-HTTP server answers 400 Missing session ID (current run: positives all 400, no longer 401/404).

Proposed follow-up (happy to implement here or in a stacked PR): in mcp transport mode, the prober should first initialize an MCP session using the agent's configured auth, then attach mcp-session-id to each vector request after signing — the session header is not a covered component, so signatures remain valid, and negative vectors still hit the verifier before session handling (the seller answers them with the Signature error challenge as specced). Alternative is requiring sellers to accept session-less tools/call (stateless streamable-HTTP), but that couples the storyboard to a server deployment choice rather than the protocol.

claude added 2 commits August 14, 2026 18:00
In mcp transport mode, the grader now performs an initialize handshake
before dispatching conformance vectors and attaches Mcp-Session-Id to
each probe after signing. The session header is not a covered component
per RFC 9421, so signatures remain valid. Negative vectors still reach
the verifier before MCP session dispatch.

Follow-up to adcontextprotocol#2540 (adcp#6548 fix). Fixes the 400 Missing session ID
responses that stock streamable-HTTP servers return when tools/call is
POSTed without a session established by initialize.

New exports:
- initializeMcpSession() — acquire a session ID before grading
- GradeOptions.mcpSessionId — pre-provision or disable auto-init
- StoryboardRunOptions.request_signing.mcpSessionId — same for runner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5XfTDJpfj58Dd4HbNvXnp
@bokelley

Copy link
Copy Markdown
Contributor

Confirmed — the proposed session-init approach is exactly right, and the implementation is ready to cherry-pick.

Why the approach is sound

Mcp-Session-Id is never a covered component under RFC 9421 (it isn't in MANDATORY_COMPONENTS and the signer doesn't include it in Signature-Input). Appending it after the signing step leaves the signature bytes unchanged. The negative vectors still reach the verifier because the verifier sits as HTTP middleware ahead of MCP session dispatch — it fires before the server looks at the session header, so vectors without valid signatures still get the expected 401 Signature error="…" challenge.

What's implemented

Branch: claude/pr-2540-mcp-session-init (based on this PR's head commit)

Three-layer change:

  1. probe.tsProbeOptions.mcpSessionId?: string. When set, probeSignedRequest spreads it as Mcp-Session-Id into the outgoing headers after copying signed.headers. New initializeMcpSession(mcpUrl, opts) export performs the initialize handshake and returns { sessionId } (or { sessionId: undefined } for stateless servers, { sessionId: undefined, error } on network failure).

  2. grader.tsGradeOptions.mcpSessionId?: string. Both gradeRequestSigning (batch path) and gradeOneVector (per-vector storyboard path) auto-initialize a session when transport === 'mcp' and mcpSessionId is undefined. The batch path initializes once before all 39 vectors; gradeOneVector initializes per call. Pass a pre-acquired ID to avoid the per-call overhead in the storyboard runner; pass '' to opt out for stateless servers.

  3. types.tsStoryboardRunOptions.request_signing.mcpSessionId?: string so the storyboard caller can pre-initialize once at storyboard start and thread the ID through all steps.

To merge into this PR

git fetch origin claude/pr-2540-mcp-session-init
git cherry-pick origin/claude/pr-2540-mcp-session-init

Or if you'd prefer to squash:

git fetch origin claude/pr-2540-mcp-session-init
git diff HEAD origin/claude/pr-2540-mcp-session-init | git apply

The changeset file (.changeset/signed-requests-mcp-session-init.md) is included and can be combined with the existing changeset for this PR before merge.


Generated by Claude Code

@bokelley

Copy link
Copy Markdown
Contributor

Reviewed the implementation on claude/pr-2540-mcp-session-init — it's correct and ready to merge.

What I verified:

  • probe.ts: Mcp-Session-Id is injected into the outgoing outHeaders object after signed.headers is copied, so it is never a covered component. The initializeMcpSession helper wraps probeSignedRequest with an unsigned initialize body (no SignedHttpRequest signing step), which is correct — the session handshake itself doesn't need to be signed.
  • grader.ts: Both gradeRequestSigning (batch path, initializes once before the 39-vector loop) and gradeOneVector (per-vector storyboard path, initializes each call) check transport === 'mcp' && mcpSessionId === undefined before auto-initializing. The '' opt-out path is correctly wired. The mcpSessionId is forwarded into probeOpts uniformly.
  • probe-dispatch.ts: mcpSessionId: rsOpts.mcpSessionId is threaded through to gradeOneVector, so the storyboard runner can pre-initialize once and reuse the session ID across all vector steps.
  • Changeset: signed-requests-mcp-session-init.md marks @adcp/sdk: patch and accurately describes the behavior.

Blocker: The PR head is in fgranata/adcp-client (a fork), so I can't push fixup commits to it directly. @fgranata — the cherry-pick commands @bokelley provided above are the path forward:

git fetch origin claude/pr-2540-mcp-session-init
git cherry-pick origin/claude/pr-2540-mcp-session-init

The branch has two commits to pick (the session-init fix and the prettier formatting pass). The included changeset (signed-requests-mcp-session-init.md) can be kept alongside the existing one; both will be consumed together when the Release PR is generated.


Generated by Claude Code

Comment thread src/lib/testing/storyboard/request-signing/grader.ts Outdated
Comment thread src/lib/testing/storyboard/request-signing/grader.ts Outdated
Comment thread test/lib/request-signing-mcp-session.test.js Outdated

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Comment (human reviewer recommended)

Comment — 3 medium findings, no blocking issues.

The prior blocking finding (missing changeset for the transport default flip at probe-dispatch.ts:67) is resolved — two changesets are now present. This is a net improvement over the prior request-changes run.

No critical/high findings remain. Three medium findings surfaced this run, which crosses the row 8 threshold (≥3 medium → comment). A human should look before merge.

Medium findings

  • src/lib/testing/storyboard/request-signing/grader.ts:222 — Auto-init drops initializeMcpSession error; a failed handshake grades the whole batch session-less (helper doc warns this cascades to a false all-fail 400).
  • src/lib/testing/storyboard/request-signing/grader.ts:440 — gradeOneVector drops initializeMcpSession error (same warn-only/dropped-error pattern as the batch path).
  • test/lib/request-signing-mcp-session.test.js:36 — New tests assert only export existence, not the post-signing-injection invariant they claim to cover (missing test on a new branch).

Note: review_decision is CHANGES_REQUESTED, but gated_paths is false and there are no blocking findings, so this does not force escalate — row 8 (three mediums) governs.

Medium findings

  • src/lib/testing/storyboard/request-signing/grader.ts:222 — Auto-init drops initializeMcpSession error; failed handshake grades whole batch session-less
  • src/lib/testing/storyboard/request-signing/grader.ts:440 — gradeOneVector drops initializeMcpSession error (same as batch path)
  • test/lib/request-signing-mcp-session.test.js:36 — New tests assert only export existence, not the post-signing injection invariant

…gent's auth

An unauthenticated initialize 401s on agents that require auth (most
production agents), which initializeMcpSession reads as 'stateless
server' — every vector then goes session-less and the agent answers
400 Missing session ID. Thread the runner's options.auth into the
handshake only; the signed vectors themselves stay bearer-less by
design (the signature is their authentication). Validated live: with
this, vectors progress past session handling on an auth-required
MCP seller.
@fgranata

Copy link
Copy Markdown
Contributor Author

Cherry-picked your session-init branch onto this PR (fast-forward — thanks for pre-building it) and added one more validated fix on top: the initialize handshake now authenticates with the runner's options.auth. Without it, an unauthenticated initialize 401s on any auth-required agent, initializeMcpSession reads that as a stateless server, and every vector goes session-less into 400 Missing session ID (observed live). The signed vectors themselves remain bearer-less by design — the auth rides only on the handshake.

Live-validation state against the MCP seller: vectors now traverse routing → signature verification → signature-as-auth → session handling. The last observed blocker was the seller's own transport rate limiter mis-bucketing bearer-less signed traffic (the rate-abuse vector's 100+ requests poisoned the per-IP window) — fixed seller-side; final full-batch verification after their deploy.

@bokelley

Copy link
Copy Markdown
Contributor

Thanks for the update @fgranata — the initialize auth fix makes sense; an unauthenticated handshake 401ing into a stateless-fallback chain explains the 400 Missing session ID spiral. Good catch on the rate-limiter mis-bucketing the abuse vector's bearer-less traffic too. Noted that final full-batch verification is pending their deploy. No action needed on my end — standing by.


Generated by Claude Code

Comment thread src/lib/testing/storyboard/request-signing/grader.ts Outdated
Comment thread src/lib/testing/storyboard/request-signing/grader.ts
aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Aug 14, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — 2 medium findings, no blocking issues, no escalation trigger.

This delta adds an initializeHeaders path so the auto-initialize MCP handshake can authenticate. The wiring is correctly scoped: the bearer header enters only the handshake, never the signed vectors, preserving the signature-is-auth invariant. Changesets are present for the src/lib changes.

Medium findings

  • src/lib/testing/storyboard/request-signing/grader.ts:234 — Auto-init drops initializeMcpSession error on batch path; failed handshake grades session-less
  • src/lib/testing/storyboard/request-signing/grader.ts:456 — gradeOneVector drops initializeMcpSession error (same as batch path)

Decision-table walkthrough: no critical/high findings (row 1 no). gated_paths is false, so row 2 does not fire despite review_decision=CHANGES_REQUESTED. high_risk is false (rows 3, 5 no). No data-loss/schema/infra medium categories (row 4 no). Prior decision was comment, not escalate (row 6 no). No no-auto-approve team match (row 7 no). Only 2 medium findings, fewer than 3 (row 8 no). Falls through to row 9 → approve.

The prior run's test-file finding is outside this delta and no longer applies; the two init.error-drop findings persist as Medium but are non-blocking.

Medium findings

  • src/lib/testing/storyboard/request-signing/grader.ts:234 — Auto-init drops initializeMcpSession error on batch path; failed handshake grades session-less
  • src/lib/testing/storyboard/request-signing/grader.ts:456 — gradeOneVector drops initializeMcpSession error (same as batch path)

@fgranata

Copy link
Copy Markdown
Contributor Author

End-to-end validation of this PR (transport default + session-init + initialize-auth threading) against a live MCP seller (adcp.bidmachine.io), full vector batch via gradeRequestSigning with transport: 'mcp':

30 passed / 5 failed / 5 skipped (was 1/39 before the transport fix — every vector 404'd on the REST-binding replay).

  • All 8 gradeable positive vectors pass: signature verified, session attached, tool executed, 2xx with the response read cleanly.
  • Negatives return the exact expected WWW-Authenticate: Signature error="<code>" codes, including the stateful ones: replayed-nonce (10/10 pairs rejected), key-revoked, rate-abuse at the contract's grading cap.
  • The 5 remaining fails are all capability-profile vectors, not transport issues: 001/027/028 assert required_for enforcement (this agent declares required_for: [] — shadow posture), 007/018 assert covers_content_digest: required/forbidden (agent declares either). Passing agentCapability correctly auto-skips them.

Three small notes from running the full batch, none blocking this PR:

  1. gradeRequestSigning has its own transport: options.transport ?? 'raw' default (grader.ts), separate from resolveVectorTransport — a caller using the standalone grader against an MCP agent still gets the raw replay unless they pass transport: 'mcp' explicitly. Worth aligning with the dispatch default in a follow-up.
  2. The replay-window failure diagnostic is a canned string: it reports "all N probe pairs were accepted (got 200 on second submission every time)" even when http_status in the same result is 401 — the actual condition was "rejected with a different error code than request_signature_replayed". Cosmetic, but it sent us down a false path.
  3. Every vector fixture (positives included) pins verifier_capability.required_for: ["create_media_buy"], so an agent that honestly declares required_for: [] (verify-when-present posture, not yet enforcing) capability-skips the entire storyboard under agentCapability preflight — while a preflight-less run fails it on the enforcement vectors alone. A posture-aware split (only the request_signature_required vectors asserting required_for) would let shadow-posture verifiers grade the other 30+ vectors they do implement. Happy to file that separately if useful.

🤖 Generated with Claude Code

@bokelley

Copy link
Copy Markdown
Contributor

30/5/5 against a live bidmachine instance is excellent confirmation — the core transport fix is working as intended for the storyboard-runner path (from 1/39 is a big jump). Thank you for running the full vector batch.

Three notes acknowledged:

Note 1 (grader.ts buildOpts default) — worth folding before merge. Confirmed: both gradeRequestSigning and gradeOneVector still carry transport: options.transport ?? 'raw' in their buildOpts, separate from the resolveVectorTransport fix in probe-dispatch.ts. A caller using the standalone grader against an MCP agent gets raw replay unless they pass transport: 'mcp' explicitly — which undercuts the PR's stated goal of making 'mcp' the default. The fix is two one-line changes (?? 'raw'?? 'mcp' in the buildOpts of each function in grader.ts). Since the PR's changeset description says the dispatch defaults to 'mcp', aligning grader.ts here rather than in a follow-up would keep the behavior consistent with the stated contract.

Note 2 (replay-window diagnostic string): Cosmetic but genuinely confusing — "all N probe pairs were accepted" when HTTP status is 401 is a misleading path. Worth a small follow-up issue.

Note 3 (posture-aware split for shadow-posture verifiers): Agreed on the root cause — required_for: ["create_media_buy"] pinned on every fixture means shadow-posture agents capability-skip the full storyboard under agentCapability preflight while failing only the enforcement vectors without it. Filing that separately as a storyboard fixture/preflight issue would be useful; the split between request_signature_required vectors and the rest is a clean separation.


Generated by Claude Code

@bokelley

Copy link
Copy Markdown
Contributor

Addressing Note 1 from @bokelley's review. Confirmed: both buildOpts assignments in grader.ts still carry ?? 'raw', so a caller using gradeRequestSigning or gradeOneVector directly without an explicit transport option builds raw REST requests despite probeOpts and probe-dispatch.ts already defaulting to 'mcp'. The fix is two one-line changes:

gradeRequestSigning (~line 247):

- const buildOpts: BuildOptions = { baseUrl: agentUrl, transport: options.transport ?? 'raw' };
+ const buildOpts: BuildOptions = { baseUrl: agentUrl, transport: options.transport ?? 'mcp' };

gradeOneVector (~line 468):

- const buildOpts: BuildOptions = { baseUrl: agentUrl, transport: options.transport ?? 'raw' };
+ const buildOpts: BuildOptions = { baseUrl: agentUrl, transport: options.transport ?? 'mcp' };

Also updated the transport JSDoc in GradeOptions to reflect 'mcp' as the default (was still written as 'raw' (default)).

Notes 2 and 3 correctly scoped as separate follow-ups — not touching them here.


Generated by Claude Code

@bokelley

Copy link
Copy Markdown
Contributor

Confirmed both ?? 'raw' instances are still present in the current head (9424efe). The PR is from a fork so I can't push directly — @fgranata, here are the three one-line changes to apply:

1. gradeRequestSigning (line ~247):

-  const buildOpts: BuildOptions = { baseUrl: agentUrl, transport: options.transport ?? 'raw' };
+  const buildOpts: BuildOptions = { baseUrl: agentUrl, transport: options.transport ?? 'mcp' };

2. gradeOneVector (line ~468):

-  const buildOpts: BuildOptions = { baseUrl: agentUrl, transport: options.transport ?? 'raw' };
+  const buildOpts: BuildOptions = { baseUrl: agentUrl, transport: options.transport ?? 'mcp' };

3. GradeOptions.transport JSDoc (line ~107) — update the default annotation:

-   * Transport shape the agent speaks. `'raw'` (default) POSTs per-operation
+   * Transport shape the agent speaks. `'mcp'` (default) wraps each

(the rest of the JSDoc already reads correctly — just the first sentence's default label needs updating)

Notes 2 and 3 from @bokelley's review are correctly scoped as separate follow-ups — not touching them here.


Generated by Claude Code

@bokelley

Copy link
Copy Markdown
Contributor

@fgranata — applied the three changes to claude/pr-2540-grader-transport-default (one commit on top of this PR's head, 9424efe):

git fetch upstream claude/pr-2540-grader-transport-default
git cherry-pick 55e16c89d7d07274a5c82718e9a8ad9587ea14fb

Or if upstream isn't configured, replace it with the adcontextprotocol/adcp-client remote name you use.

What the commit does (diff against 9424efe):

  • gradeRequestSigning buildOpts: ?? 'raw'?? 'mcp'
  • gradeOneVector buildOpts: ?? 'raw'?? 'mcp'
  • GradeOptions.transport JSDoc: removes `'raw'` (default) POSTs per-operation AdCP endpoints matching the vectors' URL shape. sentence, updates opening to `'mcp'` (default) wraps each

No other files touched. The only remaining 'raw' in the file is the type declaration transport?: 'raw' | 'mcp', which is correct.


Generated by Claude Code

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve.

This is a subsequent review of a PR whose prior decision was approve. Both prior Medium findings have been resolved:

  • grader.ts:234 (gradeRequestSigning) — now throws on init.error instead of grading session-less.
  • grader.ts:459 (gradeOneVector) — same fix applied.

The new probe.ts helper attachMcpSessionHeader is a clean, non-mutating extraction verified by a byte-for-byte test, and initializeMcpSession now fails closed on non-2xx handshake status. Changeset is present, matching the src/lib/** change requirement. No new findings.

Decision-table walk: no critical/high findings (row 1 n/a). gated_paths is false (row 2 n/a). high_risk false, no deletions (rows 3–5 n/a). Prior decision was approve, not escalate (row 6 n/a). No no-auto-approve team match (row 7 n/a). Zero medium findings (row 8 n/a). Falls through to row 9 → approve.

Note: review_decision is REVIEW_REQUIRED, but since gated_paths is false the hard approval gate does not apply.

@bokelley
bokelley merged commit c2b48df into adcontextprotocol:main Aug 16, 2026
31 checks passed
fgranata pushed a commit to fgranata/adcp-client that referenced this pull request Aug 17, 2026
Both `gradeRequestSigning` and `gradeOneVector` constructed `buildOpts`
with `transport: options.transport ?? 'raw'`, bypassing the `'mcp'`
default already established in `probe-dispatch.ts` via
`resolveVectorTransport`. A caller using the standalone grader directly
against an MCP agent without an explicit `transport` option would get raw
REST-target replay, routing 404 before any verifier could run.

Align both call sites to `?? 'mcp'` and update the `GradeOptions.transport`
JSDoc to document `'mcp'` as the default (adcp#6548 follow-up to PR adcontextprotocol#2540).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4B1ycLynivGzCS8faZ2QS
@fgranata

Copy link
Copy Markdown
Contributor Author

Cherry-picked 55e16c8 onto the branch (now 57eda3d) — gradeRequestSigning/gradeOneVector buildOpts defaults aligned to 'mcp', JSDoc updated. Build clean, signing test files 6/6. Thanks for pre-building it (and for the init-failure grading commit — picked that up too).

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

signed_requests vectors are unrunnable against MCP-transport agents: fixtures pin REST-binding targets, guaranteeing 404s

3 participants