Skip to content

feat(sep-1932): DPoP discovery signals (stacked on #528) - #529

Open
nbarbettini wants to merge 9 commits into
modelcontextprotocol:mainfrom
nbarbettini:feat/1932-server-dpop-discovery
Open

nbarbettini wants to merge 9 commits into
modelcontextprotocol:mainfrom
nbarbettini:feat/1932-server-dpop-discovery

Conversation

@nbarbettini

@nbarbettini nbarbettini commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #528 -> #527 -> #395. Rebase onto main once #528, #527, and #395 merge.

Review the commits after 9771ecb (5d213b9 onward). Everything before that belongs to the PRs below it in the stack.

The grading follows the "MCP Server DPoP Advertisement" section of SEP-1932 proposed in modelcontextprotocol/ext-auth#33, and the yaml rows quote it word for word.

What each check asserts

#395 checks that a malformed proof is rejected. This PR checks how a client that has never met the server learns that it supports or requires DPoP, and that the advertisement matches what the server enforces.

A server that supports DPoP MUST publish dpop_signing_alg_values_supported. It does not have to publish dpop_bound_access_tokens_required. When that field is absent, the RFC 9728 default applies: DPoP-bound tokens are not required.

sep-1932-server-advertises-dpop

Sends an unauthenticated POST, with no Authorization and no DPoP header. This check is graded after metadata discovery, because whether the challenge is a SHOULD or a MUST depends on dpop_bound_access_tokens_required.

  • 401 with a DPoP challenge that includes algs: SUCCESS
  • DPoP challenge without algs: WARNING (SHOULD)
  • No DPoP challenge, and the metadata sets dpop_bound_access_tokens_required: true: FAILURE (MUST)
  • No DPoP challenge, and the metadata omits the field, sets it to false, or could not be read: WARNING (SHOULD)
  • Response is not a 401, or the probe failed: not testable (WARNING)

The check looks for a DPoP challenge anywhere in the header; other challenges alongside it are fine. resource_metadata is read from the DPoP challenge, or from a sibling challenge such as Bearer (RFC 9728 §5.1).

sep-1932-server-prm-dpop

Fetches the Protected Resource Metadata (PRM). It tries the resource_metadata URL when the challenge has one; otherwise the path-based well-known URL, then the root one. That's the same order as the client discovery scenarios.

  • If the resource_metadata URL fails, the check does not fall back to a different document.
  • A path-based URL that returns 200 with a body that isn't usable metadata ends discovery there, rather than trying the root URL.
  • It follows up to two redirects, then requires HTTP 200 and application/json (RFC 9728 §3.2).

resource must identify the server (RFC 9728 §3.3). For the resource_metadata and path-based URLs, that means exactly the server URL. For the root URL, the RFC's identifier is the origin. The check also accepts the server URL there, because MCP clients such as the TypeScript SDK accept both.

  • dpop_signing_alg_values_supported missing, not an array, empty, or containing a non-string entry: FAILURE, with a separate message for each case
  • dpop_signing_alg_values_supported lists none or HS*: FAILURE
  • dpop_bound_access_tokens_required absent: fine (it defaults to false)
  • dpop_bound_access_tokens_required present but not a boolean: FAILURE (malformed)
  • resource missing or not matching, metadata unreachable, not 200, or the wrong Content-Type: not testable (FAILURE). The document is unusable, so its DPoP fields are not graded.

sep-1932-server-prm-consistency

Sends an unbound token (omitCnf) as Bearer, with no DPoP header. The probe differs from the accepted valid-proof request only in its token, so the response can be attributed to the token. Runs only if the valid-proof check passed and a usable PRM document was read. Otherwise it is not testable, and it doesn't report a status.

When the metadata sets dpop_bound_access_tokens_required: true:

  • Server accepts the token: FAILURE
  • Server rejects with any 4xx: SUCCESS. The SEP says "reject" without naming a status, and RFC 6750 uses 400, 401, and 403.
  • Server answers with a 5xx: FAILURE

When the metadata omits the field or sets it to false:

  • Server rejects with 401: FAILURE ("server requires DPoP but does not advertise it")
  • Server answers with a use_dpop_nonce 401: not testable (the challenge is about the nonce, not the token)
  • Server accepts the token: SUCCESS
  • Any other status: WARNING

The check's details include the response's WWW-Authenticate header and its error code.

Testing

  • DPoP server scenario tests pass (37). The fixture modes cover:

    • Metadata fields: each DPoP field omitted on its own; empty, non-array, and non-string algorithms; a non-boolean dpop_bound_access_tokens_required; none and HS256 listed.
    • Metadata documents: a wrong or missing resource; metadata served only at the root URL, with the origin and with a different origin as resource; no metadata route; an HTML Content-Type; a redirect to the document.
    • Challenges: a Bearer-only challenge, with DPoP both required and optional; no resource_metadata in the challenge, which exercises the well-known fallback.
    • The unbound Bearer token: answered with 400, 403, 500, or a use_dpop_nonce 401.

    Each mode changes only the check it targets; every other check stays as it is against the compliant server.

  • Full suite passes

  • npm run check (typecheck, eslint, prettier) and npm run build are clean

Made with Cursor

PieterKas and others added 6 commits September 9, 2026 18:52
…ocol#369)

Follow-up on the DPoP client PR (shared foundation). Adds the server-role
conformance for SEP-1932 / RFC 9449: the framework acts as a DPoP client
against the MCP server under test and emits the sep-1932-server-* checks
across the RFC 9449 §4.3 validation surface — proof validation, the ±5-minute
iat window, asymmetric-only algorithms, the 401 + WWW-Authenticate challenge,
token audience validation under DPoP, and the optional server-provided nonce.

- src/scenarios/server/auth/dpop.ts (+ test, spec-references): the scenario.
- examples/servers/typescript/sep-1932-{compliant,broken}-server.ts: passing
  and failing fixtures proving every check passes and fails.
- Registered in the pending + all-client scenario lists.

Depends only on the shared DPoP foundation (dpopProof/dpopToken); independent
of the authorization-server PR. No DPoP-capable MCP SDK exists yet, so
correctness rests on RFC vectors + an independent verifier + the fixtures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Refresh the held DPoP nonce from every response's DPoP-Nonce header
  (RFC 9449 §8.2 newest-wins) instead of capturing it once, so a server that
  rotates or single-uses its nonce no longer turns the negative probes into
  false "not testable" failures (or vacuous passes). (modelcontextprotocol#1)
- Widen the stale iat probe to -303 s (matching the future side) so the ±1 s
  quantization of the whole-second Date header can't pull it onto the ±300 s
  boundary and be false-accepted. (modelcontextprotocol#2)
- Update the isNonceChallenge comment and the untestable message: probes now
  carry the held nonce, so a use_dpop_nonce challenge is about the nonce
  lifetime (rotated/stale/single-use), not a missing nonce. (modelcontextprotocol#3)
- Fixture: parse integer env vars NaN-safely (intEnv helper) so a malformed
  DPOP_CLOCK_OFFSET_SECONDS / DPOP_IAT_SKEW_SECONDS falls back to its default
  instead of silently disabling the iat window. (modelcontextprotocol#4)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Soften the heldNonce comment: newest-wins refresh is correct for ROTATING
  servers; a strict single-use server that re-arms only via challenges can still
  push alternate negatives to untestable (correctly reported, not mis-scored) —
  the previous "rotate or single-use" over-claimed. (R5)
- Fix the clock-skew test comment: the stale probe is now -303s (~273s old),
  not -301s/~271s. (R5)

Comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A crash or 404 must not count as correctly refusing a DPoP-bound token presented as Bearer.

Co-authored-by: Cursor <cursoragent@cursor.com>
A challenge with no error code was passing. The code is a SHOULD, so a wrong or missing value is a warning and the existing MUST checks stay as they are.

Co-authored-by: Cursor <cursoragent@cursor.com>
A client that has never met the server has to learn the requirement from the 401 challenge and from protected resource metadata. A missing challenge or metadata field is a failure, and the advertisement has to match what the server enforces.

Co-authored-by: Cursor <cursoragent@cursor.com>
@nbarbettini
nbarbettini force-pushed the feat/1932-server-dpop-discovery branch from f20bf07 to 5d213b9 Compare September 25, 2026 18:39
@PieterKas

Copy link
Copy Markdown
Contributor

@nbarbettini I am reviewing the PR and also revisiting the SEP1932 text. I think the following is overly strict (or at least as I think the tests are implemented):

With that assumption, a missing DPoP challenge or a missing Protected Resource Metadata field is a failure.

Here are my thoughts process. I think if the MCP server supports DPoP it MUST advertise that support in protected resource metadata (PRM). However, there will be a lot of MCP servers out there that does not support it and won't advertise that they don't support it, they just won't have that field set. According to RFC9728, absence of a field means "not supported". I don''t think we want to register absence of the field as a failure, but rather as being equivalent to being set to "false". I feel like this is a more reasonable approach and avoids a lot of servers having to advertise "false" when they can achieve the same by just not advertising support.

Comment thread src/scenarios/server/auth/dpop.ts Outdated
Comment thread src/scenarios/server/auth/dpop.ts
Comment thread src/scenarios/server/auth/dpop.ts
Comment thread src/scenarios/server/auth/dpop.ts Outdated
Comment thread src/scenarios/server/auth/dpop.ts Outdated
Comment thread src/scenarios/server/auth/dpop.ts Outdated
Comment thread src/scenarios/server/auth/dpop.ts Outdated
Comment thread src/seps/sep-1932.yaml Outdated
@PieterKas

Copy link
Copy Markdown
Contributor

Big-picture: the test fixtures (the fake servers the suite runs against) have no way to trigger the situations described in the other review comments — so the grading fixes those comments ask for can't be tested, and could silently regress later. This comment collects the fixture modes and test cases needed to close that gap.

Fixture modes (sep-1932-compliant-server.ts):

  • Split DPOP_PRM_OMIT_FIELDS: it currently drops both DPoP fields at once, but under the corrected grading those two omissions have opposite meanings — missing dpop_signing_alg_values_supported is a MUST violation (FAILURE), while an absent dpop_bound_access_tokens_required is a valid configuration (SUCCESS). One mode per field (DPOP_PRM_OMIT_ALGS, DPOP_PRM_OMIT_REQUIRED), each flipping exactly its targeted outcome.
  • DPOP_PRM_DISABLED (no PRM route, DPoP-enforcing backend): pins the metadata-unreachable paths — PrmDpop's unreachable reason and the consistency check reporting "cannot evaluate" instead of a fabricated verdict (line 666 comment).
  • A wrong-resource mode (metadata served with resource naming a different URL): pins the RFC 9728 §3.3 gate (line 507 comment).
  • A Bearer-only-challenge mode (PRM correct, 401 carries no DPoP scheme): pins the two-tier advertises grading (line 524 comment) — WARNING when required is absent/false, FAILURE when the same fixture also sets required: true.

Test arms with no coverage today, worth pinning while in here:

  • dpop_signing_alg_values_supported: [] and non-array values → FAILURE (and the message distinguishing "empty"/"malformed" from "missing");
  • an HS256 entry in the PRM algs list → FAILURE (only none is currently exercised);
  • present-but-non-boolean dpop_bound_access_tokens_required → FAILURE;
  • consistency says-required arm satisfied by a 403 rejection (line 622 comment);
  • the well-known fallback fetch exercised end-to-end — every fixture 401 currently carries resource_metadata, so the path-based/root candidate loop only ever runs in URL-builder unit tests; a mode that omits the challenge parameter would cover it.

Each new mode should flip exactly its targeted check and leave the rest green, per the suite's one-defect convention.

Generated with the aid of Claude (Fable 5).

@PieterKas PieterKas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thorough review done (adversarial pass with the aid of Claude, Fable 5 — findings verified empirically against live fixtures). The architecture is good: discovery probes are cleanly separated, the consistency probe reuses the positive token minus cnf so rejections are attributable, and the suites run green.

Requesting changes for two themes, detailed in 9 comments:

  1. Grading drift against the proposed SEP text: the "MCP Server DPoP Advertisement" section proposed in modelcontextprotocol/ext-auth#33 grades these behaviors differently than this PR assumes — absent dpop_bound_access_tokens_required is valid (line 591), the challenge is two-tier SHOULD/MUST (line 524), "reject" isn't "401" (line 622), and the yaml rows should quote the SEP sentences (yaml comment, with suggestion).
  2. Verdicts without evidence: the consistency check asserts metadata contents when no document was read (line 666), and documents are consumed without the RFC 9728 §3.3 resource check (line 507); plus fetcher robustness (line 456) and a details nit (line 623).

The Conversation-tab comment collects the fixture modes and tests needed to pin all of the above. Sequencing: ext-auth#33 needs to land first so the yaml quotes track its final wording; happy to re-review quickly after.

@nbarbettini

Copy link
Copy Markdown
Contributor Author

@nbarbettini I am reviewing the PR and also revisiting the SEP1932 text. I think the following is overly strict (or at least as I think the tests are implemented):

With that assumption, a missing DPoP challenge or a missing Protected Resource Metadata field is a failure.

Here are my thoughts process. I think if the MCP server supports DPoP it MUST advertise that support in protected resource metadata (PRM). However, there will be a lot of MCP servers out there that does not support it and won't advertise that they don't support it, they just won't have that field set. According to RFC9728, absence of a field means "not supported". I don''t think we want to register absence of the field as a failure, but rather as being equivalent to being set to "false". I feel like this is a more reasonable approach and avoids a lot of servers having to advertise "false" when they can achieve the same by just not advertising support.

I completely agree - I was too eager here and didn't mean to imply that all servers MUST advertise their DPoP stance. I meant it in the same way you described above: "if the MCP server supports DPoP it MUST advertise that support in protected resource metadata (PRM)"

I will take a pass at updating this PR with your feedback, thanks!

nbarbettini and others added 2 commits October 13, 2026 16:09
An omitted dpop_bound_access_tokens_required is the RFC 9728 default, a DPoP challenge is a SHOULD unless that field is true, and metadata is only usable when its resource identifier matches.

Co-authored-by: Cursor <cursoragent@cursor.com>
A use_dpop_nonce 401 to an unbound Bearer token is about the nonce, so it no longer counts as unadvertised DPoP enforcement. Any 4xx now counts as rejecting that token when metadata says DPoP is required. Metadata at the root well-known URL may name the origin as its resource, which is the RFC 9728 identifier for that URL and what MCP clients accept. Details record the URL that served the metadata after a redirect.

Co-authored-by: Cursor <cursoragent@cursor.com>
@nbarbettini

nbarbettini commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

@PieterKas thanks again for the thorough review. I pushed three more commits and updated the PR description to stay current.

I think I captured all the points in the updated SEP/ext-auth text, but let me know if I missed something!

Grading against ext-auth#33

  • Absent dpop_bound_access_tokens_required is now valid; it's treated as the RFC 9728 default of false. A present but non-boolean value fails as "malformed". A missing, empty, or malformed dpop_signing_alg_values_supported each get their own message.
  • The challenge is two-tier. The check records the 401 and grades it after reading the metadata. With required: true, a missing DPoP challenge is a FAILURE; otherwise it's a WARNING. Both untestable cases are WARNING: the probe failing, or the response not being a 401. The check looks for a DPoP challenge anywhere in the header, never "DPoP only".
  • "Reject" isn't "401". On the required: true arm, any 4xx counts as a rejection. One small change to your suggestion: a 5xx still fails, since it's a server error rather than a refusal. The silent arm stays 401-only. A use_dpop_nonce 401 there is reported not testable, the same way rejectionCheck handles it.
  • The yaml rows quote your suggested SEP sentences. I'll re-sync them once ext-auth#33 merges if the wording moves.

Evidence

  • No verdict without a document. When no usable metadata was read, the consistency check is not testable ("cannot evaluate advertisement consistency: …"). The PRM_DISABLED fixture pins it.
  • RFC 9728 §3.3 gate. Both fetch paths now check resource before grading anything. A document that fails the check is treated as unusable, the same as an unreachable one or the wrong Content-Type: PrmDpop and consistency both report it not testable, with the mismatched values in the reason. That keeps PrmDpop about DPoP; the §3.3 failure itself belongs in a base PRM scenario, which I'm proposing separately. One addition beyond your comment: for metadata fetched from the root well-known URL, §3.3's identifier is the origin, not the endpoint. Comparing against the endpoint there would fail an RFC-correct server, including the shape our own client-auth fixtures serve. So the root URL accepts the origin, or the endpoint, which is what MCP clients like the TS SDK accept (checkResourceAllowed). The other two paths still require an exact match.
  • The PRM fetcher follows up to two redirects, requires exactly 200, and requires application/json. Failure reasons now say what happened ("responded HTTP 404, not 200", "Content-Type is text/html…"). The details record both the URL that served the document and the one first requested. One judgment call: a path-based URL that returns 200 with a body that isn't usable metadata ends discovery, rather than falling through to the root URL, because that 200 is what a client would actually have received.
  • The consistency check's details now include WWW-Authenticate and the parsed error.

Fixtures: every mode you suggested is in, plus a few more:

  • 400, 403, 500, and use_dpop_nonce answers to the unbound token;
  • a missing resource;
  • metadata served only at the root URL;
  • HTML Content-Type and redirect modes.

DPOP_PRM_OMIT_FIELDS is now DPOP_PRM_OMIT_ALGS and DPOP_PRM_OMIT_REQUIRED. Each mode changes only the check it targets.

Updated with the help of Cursor!

A resource identifier that fails RFC 9728 §3.3 makes the document unusable, the same as an unreachable one or the wrong Content-Type. PrmDpop now reports it not testable instead of failing it as a DPoP problem.

Co-authored-by: Cursor <cursoragent@cursor.com>
@nbarbettini

Copy link
Copy Markdown
Contributor Author

One follow-up for outside this PR:

If a server's metadata says DPoP is required, the SEP says every WWW-Authenticate response it sends must include a DPoP challenge. Right now we only check that on the first, unauthenticated request. For example, a server that says "DPoP required" but answers our downgrade probe with only Bearer error="invalid_token" passes today. The downgrade check accepts a Bearer-only challenge, because the downgrade section on its own allows it. I'd fix this by having the scenario remember every rejection it gets back when the server says DPoP is required. Any of those responses without a DPoP challenge would fail the existing challenge check, which lists them. The downgrade check would stay as it is. Happy to open an issue for it, if you agree @PieterKas!

This branch has not been deployed

No deployments
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.

2 participants