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

> Source: <https://workos.com/blog/mcp-sdk-dpop-and-scope-challenges>
> Published: 2026-09-29 00:00:00+00:00

# 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.

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:**

## 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.

## 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:

A minimal client-side implementation is small:

(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:

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

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.
