In this article
September 29, 2026
September 29, 2026

The MCP SDK just shipped DPoP and scope challenges. Here's what to do about it.

A first look at how the official Model Context Protocol SDK is turning the 2026-07-28 spec's authorization hardening into code, and what it means for anyone running an MCP server.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

For most of this year, the MCP authorization spec has described a stronger security model faster than any SDK could implement it. That gap closed a little this week. The 2.1.0 release of the official TypeScript SDK (@modelcontextprotocol/core, client, server, and node) adds two features straight out of the 2026-07-28 spec's authorization hardening work: DPoP sender-constrained access tokens and request-time OAuth scope challenges. Neither is optional plumbing. Both change how you should be thinking about token theft and permission scope on any MCP server you run today.

At a glance:

Package Version What's new Spec reference
@modelcontextprotocol/client 2.1.0 dpop() on OAuthClientProvider, automatic proof signing and nonce retry SEP-1932 (RFC 9449)
@modelcontextprotocol/core 2.1.0 Shared helpers: generateDpopKeyPair, accessTokenHash, isDpopNonceChallenge SEP-1932
@modelcontextprotocol/server 2.1.0 scopeChallenge on tool / resource / prompt registration, requireScopes helper Authorization hardening SEPs
@modelcontextprotocol/node 2.1.0 Scope-challenge support wired into createMcpHandler same
@modelcontextprotocol/sdk (legacy 1.x line) 1.30.1 Request body size limits, JSON-RPC batch length bounds —

Why bearer tokens were the weak point

MCP servers are, by design, reachable by an agent over the network, often from infrastructure you don't fully control: a developer's laptop, a third-party host, a CI runner. A plain OAuth bearer token is a bearer instrument. Anyone who gets a copy of it, through a logging mistake, a compromised dependency, or a leaked environment variable, can use it exactly as the legitimate client would. There's no way for the resource server to tell the difference.

DPoP (RFC 9449, shipped in MCP as SEP-1932) closes that gap by binding the access token to a key pair the client holds. Every request carries a signed proof that the caller possesses the private key the token was issued to. A stolen token without the matching key is worthless.

Scenario Bearer token DPoP-bound token
Token copied from logs or a leaked env var Usable by whoever has it Useless without the matching private key
Token replayed from a different machine Works Rejected: proof key doesn't match the token
Cost to the client implementer None, already the default One method (dpop()) plus a stored keypair

What actually shipped

On the client side, the SDK adds an OAuthClientProvider.dpop() method that returns a DpopSession. Implement it and the SDK takes care of the rest: generateDpopKeyPair, accessTokenHash, and isDpopNonceChallenge are exposed as helpers, and the existing auth flow methods, auth(), exchangeAuthorization(), refreshAuthorization(), and fetchToken(), all sign a DPoP proof into the token request automatically. If the authorization server responds with a use_dpop_nonce challenge, the SDK retries once with the nonce and reapplies client authentication. Streamable HTTP, SSE, and withOAuth transports all present the resulting token correctly without extra wiring.

Roughly, the flow looks like this:

  
Client                        Authorization server              MCP server
  |                                    |                              |
  |  generate keypair (once, cached)   |                              |
  |                                    |                              |
  |-- token request + DPoP proof ----> |                              |
  |   (proof signed with private key)  |                              |
  |                                    |                              |
  |<- access token bound to public key |                              |
  |   (token's "cnf" claim = jkt)      |                              |
  |                                    |                              |
  |-- tool call --------------------------------------------------->  |
  |   Authorization: DPoP <token>                                     |
  |   DPoP: <fresh proof, this request only, signed with same key>    |
  |                                                                   |
  |                                   server checks: does the proof's key
  |                                   match the token's "cnf" claim, and
  |                                   is the proof fresh?
  |                                                                   |
  |<- 200 OK  (401 if the key doesn't match or the proof is stale) -- |
  

A minimal client-side implementation is small:

  
import { generateDpopKeyPair } from "@modelcontextprotocol/core";

class MyOAuthClientProvider implements OAuthClientProvider {
  private readonly dpopKeyPair = generateDpopKeyPair();

  async dpop() {
    // The SDK signs a proof from this per request, and retries
    // automatically if the server replies with `use_dpop_nonce`.
    return { keyPair: this.dpopKeyPair };
  }

  // ...redirectUrl(), clientMetadata(), tokens(), saveTokens(), etc.
}
  

(Check the SDK's current type definitions for the exact DpopSession shape before shipping this. The release is brand new and details may shift.)

On the server side, @modelcontextprotocol/server and @modelcontextprotocol/node add a scopeChallenge callback that registers alongside a tool, resource, or prompt. When it returns a challenge, the transport sends a proper HTTP 403 with a WWW-Authenticate header before the handler runs or an SSE stream opens, rather than letting a partially authorized call leak tool behavior. The header is built with the same formatter used for the resource_metadata parameter, and requireBearerAuth/verifyBearerToken now attach resourceMetadataUrl to the AuthInfo object they return, so a client always knows where to go to re-authorize.

For the common case, static scope requirements, there's a helper so you don't have to write the check by hand:

  
import { requireScopes } from "@modelcontextprotocol/server";

server.registerTool(
  "delete-record",
  {
    description: "Delete a record",
    inputSchema: { id: z.string() },
    scopeChallenge: requireScopes("records:delete"),
  },
  async ({ id }) => { /* ... */ }
);
  

For anything conditional, the callback can inspect the caller's existing scopes and decide at call time:

  
server.registerTool(
  "delete-record",
  {
    description: "Delete a record",
    inputSchema: { id: z.string() },
    scopeChallenge: ({ authInfo }) => {
      if (!authInfo?.scopes.includes("records:delete")) {
        return { scopes: ["records:delete"] };
      }
      // returning undefined lets the call proceed
    },
  },
  async ({ id }) => { /* ... */ }
);
  

Alongside the auth work, the release also bounds request body size and JSON-RPC batch length in the Streamable HTTP handling, which matters for the same reason: an MCP server accepting unbounded input from a network caller is a server that hasn't fully priced in what "reachable by an agent" means.

What this means if you're running an MCP server

If your MCP server issues or validates OAuth tokens today, this release gives you two concrete things to go do, not just read about.

First, decide whether your authorization server needs to support DPoP now or can wait for the next revision. The spec doesn't mandate DPoP yet, but the client-side SDK support just went from theoretical to a five-line integration. Once client authors start calling dpop() by default, an authorization server that only understands bearer tokens is the one place in the chain still assuming the old threat model.

Second, look at where your server currently treats "has a valid token" as equivalent to "is allowed to do this specific thing." Scope challenges exist so a server can grant a narrow token up front and ask for more only when a specific tool call needs it, rather than forcing a client to request every possible scope at authorization time. That's a better default for anything destructive: a tool that deletes records, sends money, or writes to a shared resource should live behind its own scope, challenged for at call time, not bundled into whatever scope got granted at login.

Neither of these is a rewrite. They're the kind of change that's cheap today and expensive to retrofit once a client population has settled on assuming bearer tokens are good enough. The SDK just removed the excuse for waiting.