
The Model Context Protocol TypeScript SDK's 2.1.0 release on September 23, 2026 adds two things at once: DPoP (RFC 9449) sender-constrained access tokens on the client, and request-time OAuth scope challenges on the server. Together they answer the question every security review of an agent-facing MCP server asks first: what stops a stolen bearer token from being replayed against a tool that touches production data.
If you are wiring an MCP server into Docusign, NetSuite, or any system that isn't a toy, this release is the first SDK-level baseline for that answer. Here's what actually shipped, what it changes for an integration, and where the gaps still are.
DPoP (Demonstrating Proof of Possession) is an OAuth extension, standardized as RFC 9449 in September 2023, that binds an access token to a public/private key pair the client holds. Instead of presenting a bearer token that works for anyone who has a copy of it, the client signs a fresh proof JWT on every request. A resource server checks that proof against the key the token was issued to. A stolen DPoP-bound token without the matching private key is inert.
That distinction matters more for an MCP server than for a typical web API, because MCP clients are frequently long-lived processes (a coding agent, an orchestration pipeline, a scheduled job) that hold an access token in memory or on disk for hours or days. A bearer token leaked from a log line, a crash dump, or a misconfigured proxy is immediately usable by an attacker. A DPoP-bound token leaked the same way is useless without the private key, which typically never leaves the client's key store.
The MCP authorization spec has always required an MCP server to act as an OAuth 2.1 resource server and publish OAuth 2.0 Protected Resource Metadata under RFC 9728. Sender-constraining the token itself is the piece the spec left to the ecosystem, and the 2.1.0 SDK release is the first to ship it end to end.
The @modelcontextprotocol/client and @modelcontextprotocol/core packages add DPoP support as an opt-in feature. You turn it on by implementing OAuthClientProvider.dpop(), which returns a DpopSession. The SDK ships helpers alongside it: generateDpopKeyPair, accessTokenHash, and isDpopNonceChallenge.
Once enabled, the SDK's auth(), exchangeAuthorization(), refreshAuthorization() and fetchToken() functions sign a DPoP proof into every token request, retrying once if the authorization server responds with a use_dpop_nonce challenge. StreamableHTTPClientTransport, SSEClientTransport and withOAuth then present the resulting token as Authorization: DPoP <token> with a fresh proof on every resource request, retrying a resource-server use_dpop_nonce challenge the same way. Tokens an authorization server issues as plain Bearer are still sent as Bearer, so DPoP support does not break a server that hasn't adopted it yet.
The implementation detail that matters for integrators: DPoP is applied at the fetch layer. The transports wrap the resource-server fetch, including a caller-supplied fetch or eventSourceInit.fetch, with a new withDpopFromProvider(provider) middleware. That means a proof is bound to the request actually sent, not to some earlier snapshot of the URL and method, which closes a class of bugs where a proxy or retry layer silently changes the request after the proof was generated. This is documented in the @modelcontextprotocol/core 2.1.0 release notes.
import { generateDpopKeyPair } from "@modelcontextprotocol/core";
const dpopKeyPair = await generateDpopKeyPair();
class MyOAuthProvider implements OAuthClientProvider {
dpop() {
return {
keyPair: dpopKeyPair,
// session bookkeeping for nonce retries goes here
};
}
// ...redirectUrl, clientMetadata, tokens(), saveTokens(), etc.
}Client-side error handling also got more specific: OAuthErrorCode gains InvalidDpopProof and UseDpopNonce, and auth() now recovers from an invalid_dpop_proof error on refresh (for example, a refresh token bound to a key the client no longer holds) by discarding the tokens and re-authorizing, the same way it already handled invalid_grant.
The companion change lives in @modelcontextprotocol/node and @modelcontextprotocol/server 2.1.0: request-time OAuth scope challenges for tools, resources, resource templates, and prompts. Each registered primitive can now carry a scopeChallenge callback that receives the parsed request and the verified AuthInfo, then either lets the call continue or returns the exact scope set the caller is missing. A requireScopes helper covers the common case of a static "must have all of these scopes" check, per the @modelcontextprotocol/node 2.1.0 release notes.
When a primitive's scope check fails, createMcpHandler and the Streamable HTTP transports return an HTTP 403 with an insufficient_scope challenge before the handler runs or an SSE stream opens. There is no separate configuration flag: the preflight is active automatically on any primitive that declares a scopeChallenge callback.
This is the part that actually changes the security posture of a production MCP server. Before 2.1.0, an MCP server that wanted per-tool authorization had to build that check itself, inside the tool handler, after the request had already been parsed and (for a streaming response) potentially after output had started. Now the SDK enforces it at the transport boundary, before any handler logic or side effect runs, and it tells the caller exactly which scope was missing instead of a generic "forbidden."
server.registerTool("void-agreement", {
scopeChallenge: requireScopes(["agreements:void"]),
handler: async (params, authInfo) => {
// only reached if the token actually carries agreements:void
},
});Per a follow-up review on the SDK's own pull request, the WWW-Authenticate header on that 403 now advertises only the scopes the specific operation needs by default, not an additive union of every scope the server has ever required, matching RFC 6750 Section 3.1. Servers that need the old broader hint back can opt in with scopeChallenge.includeGrantedScopes.
The MCP authorization spec already describes a step-up flow: a client tries an operation, gets a 403 with an insufficient_scope error, and re-authorizes with the expanded scope set before retrying, capped to avoid infinite loops. What 2.1.0 changes is precision. Before this release, a server that wanted to challenge for a specific scope at the specific tool or resource being called had to hand-roll that logic per handler, often inconsistently across tools written by different people at different times. Now the SDK exposes the exact scope needed, per primitive, as a first-class server capability, and the client-side DPoP work means the token that eventually gets minted for that scope is bound to a key instead of floating free as a bearer credential.
For an integration builder, that combination changes the default architecture question from "should we sender-constrain tokens and scope individual tools" to "why wouldn't we." Treat DPoP opt-in and per-primitive scope challenges as the baseline for any MCP server that reaches production data, the same way you'd treat TLS as non-negotiable rather than a hardening pass you get to later.
If you're building or exposing an MCP server that fronts Docusign, NetSuite, or an internal system with write access, three things change with 2.1.0 in production:
Scope your tools narrowly, not your whole server. A single "docusign" scope on the whole MCP server means any authorized caller can void an envelope, read a signed agreement, or kick off a workflow with the same token. Per-primitive scope challenges let you separate agreements:read from agreements:void from workflows:trigger, and reject the ones a given token wasn't granted, with the exact missing scope in the 403. That maps cleanly onto how Docusign's own App Center extension apps are reviewed, where least-privilege scoping is already an expectation.
DPoP protects the credential your MCP server is trusting, not the webhook layer behind it. If your architecture also relies on Docusign Connect webhooks to notify a downstream system when a workflow completes, that's a separate trust boundary with its own signature scheme (HMAC, not DPoP), covered in Docusign's Connect HMAC documentation. Production-grade webhook relay and retry logic between a source platform and Docusign Workflow Builder (formerly Maestro) is exactly what Baton exists for, and it's a separate concern from the MCP client-to-server auth this release addresses. Don't conflate the two when you're threat-modeling an integration; a reviewer will ask about both.
DPoP is not free, and almost nobody's authorization server advertises it yet. DPoP is opt-in in the client SDK for a reason: it adds a signed proof to every token and resource request, and as of a mid-2026 survey of mainstream platform and CI OAuth issuers, effectively none advertised dpop_signing_alg_values_supported in their authorization server metadata. If the authorization server your MCP client talks to doesn't support DPoP, the SDK falls back to Bearer transparently, which is good for compatibility but means you should check your authorization server's metadata before assuming DPoP is protecting anything.
Does MCP SDK 2.1.0 make DPoP mandatory?
No. DPoP is opt-in on the client, enabled by implementing OAuthClientProvider.dpop(). Tokens an authorization server issues as Bearer are still sent as Bearer if DPoP isn't configured, per the SDK release notes.
What HTTP status code does a scope challenge return?
insufficient_scope challenges from createMcpHandler and the Streamable HTTP transports return HTTP 403, with the required scope named in the WWW-Authenticate header, before the tool handler executes.
Do I need my own authorization server to use DPoP with MCP? No, but your authorization server needs to support DPoP token binding (RFC 9449) for the client's proof to do anything. If it doesn't, the SDK negotiates back to Bearer automatically.
Is DPoP the same thing as mutual TLS client certificates? No. Both sender-constrain a token, but DPoP works at the application layer with a signed JWT proof, while mutual TLS (RFC 8705) binds the token to a certificate presented during the TLS handshake and needs PKI infrastructure most browsers and lightweight clients can't use.
MCP SDK 2.1.0 gives you the primitives, not a finished security posture. Enabling DPoP on the client and adding scopeChallenge to every write-capable tool is the concrete first step for any MCP server that reaches Docusign, NetSuite, or another production system. If you're scoping that work now, a Docusign IAM working session with the fluidlabs team is a good place to pressure-test the tool-level scope design before you ship it, especially if the same server also needs to trigger workflows or handle webhook callbacks downstream.
Schedule a 30-minute strategy session. We'll identify the highest-value vertical solution for your organization, walk through the architecture, and map out a build plan — no commitment required.
Submit Your Project Details →or email us at hello@fluidlabs.com