Produced by Task A0 spike (2026-06-29). Later tasks depend on the exact symbol names recorded here — update this file if a package version bump changes the API surface.
| Package | Resolved version | Notes |
|---|---|---|
@cloudflare/workers-oauth-provider |
0.8.1 | latest stable |
agents |
0.17.1 | latest stable |
@modelcontextprotocol/sdk |
1.29.0 | pinned by agents too |
zod (project) |
4.4.3 | our existing floor |
No conflict. @modelcontextprotocol/sdk@1.29.0 declares peerDependencies: { zod: "^3.25 || ^4.0" }.
agents@0.17.1 declares peerDependencies: { zod: "^4.0.0" }. Both work with our existing
zod@^4.4.3. npm ls confirms a single zod@4.4.3 at the root, with only
@cloudflare/vitest-pool-workers nesting an unrelated zod@3.25.76 under itself.
No overrides entry is needed for zod.
agents@0.17.1 requires react@^19.0.0 as a peer dependency (not listed in
peerDependenciesMeta as optional).
react@^18.3.1 and said an upgrade was out of scope; the project has since
moved to React 19 (react@^19.2.8), so the peer dependency is satisfied on its own terms.
The committed project-root .npmrc still carries legacy-peer-deps=true, and its comment
still gives the React 18 reason. Nothing has been changed here on that basis — removing an
install-wide flag is a dependency-tree decision, not a documentation one — but a reader
should not take that comment as a current statement about React. Re-audit with
npm ls --depth=0 before assuming the flag is still load-bearing.
We import only from agents/mcp (the Durable Object base class), which has no React runtime
dependency; agents/react (the frontend hooks the peer dep exists for) is not imported.
Import path: import { OAuthProvider, OAuthHelpers } from '@cloudflare/workers-oauth-provider'
All fields are on OAuthProviderOptions<Env>:
| Option | Type | Required | Notes |
|---|---|---|---|
apiRoute |
string | string[] |
exclusive with apiHandlers |
Literal prefix match(es); no pattern/glob support |
apiHandler |
ExportedHandler | WorkerEntrypoint class |
with apiRoute |
Single-handler config |
apiHandlers |
Record<string, handler> |
exclusive with apiRoute+apiHandler |
Multi-handler: route → handler map |
defaultHandler |
ExportedHandler | WorkerEntrypoint class |
required | Handles non-API and unauthenticated requests |
authorizeEndpoint |
string |
required | Path or URL of the consent UI (not implemented by the provider) |
tokenEndpoint |
string |
required | Provider implements this endpoint |
clientRegistrationEndpoint |
string |
optional | Provider implements RFC 7591 DCR here |
scopesSupported |
string[] |
optional | Included as scopes_supported in RFC 8414 metadata |
accessTokenTTL |
number |
optional | Seconds; default 3600 |
refreshTokenTTL |
number |
optional | Seconds; default 2,592,000 (30 days); 0 disables |
clientRegistrationTTL |
number |
optional | Seconds; default 7,776,000 (90 days) |
allowImplicitFlow |
boolean |
optional | Default false |
allowPlainPKCE |
boolean |
optional | Default true — false, and the default is why. See below. |
allowTokenExchangeGrant |
boolean |
optional | Default false |
disallowPublicClientRegistration |
boolean |
optional | Default false |
resourceMetadata |
{ resource?, authorization_servers?, scopes_supported?, bearer_methods_supported?, resource_name? } |
optional | Customises /.well-known/oauth-protected-resource |
resourceMatchOriginOnly |
boolean |
optional | Default false; when true, compares origins only during resource validation |
tokenExchangeCallback |
fn |
optional | Mutate props on authorization_code / refresh_token exchange |
clientRegistrationCallback |
fn |
optional | Intercept and approve/reject DCR requests |
resolveExternalToken |
fn |
optional | Bridge external OAuth tokens (non-KV) |
onError |
fn |
optional | Error hook; return Response to override |
clientIdMetadataDocumentEnabled |
boolean |
optional | Default false; requires global_fetch_strictly_public compat flag |
enterpriseManagedAuthorization |
EmaOptions |
optional | Experimental EMA/ID-JAG grant |
server/lib/mcp/oauth-provider.ts passes allowPlainPKCE: false. Leaving it out
is not "taking the safe default" — the library's default is true, and three
things follow from it:
/.well-known/oauth-authorization-serveradvertisescode_challenge_methods_supported: ["plain", "S256"], so a client is toldplainis acceptable on this deployment.- An authorize request that simply omits
code_challenge_methodis read asplainrather than refused. The weaker method is what you get by saying nothing, which is the opposite of how a security default should fail. - The token endpoint's
plainbranch compares the submitted verifier to the stored challenge verbatim. That is not a check. The challenge travelled to the browser in the same redirect URL as the authorization code, so anyone who intercepted the code already holds the value needed to redeem it.
PKCE exists to make a stolen authorization code unusable. plain returns it to
being usable, which makes advertising it worse than offering no PKCE at all:
a client that negotiates down believes it is protected and is not.
Setting the option to false does both halves — it narrows the advertisement to
["S256"] and makes the authorize parse refuse plain instead of serving
it. Narrowing the document alone would not be enough, because a client that
ignores the document, or that omits the parameter, would still be served.
Held by tests/unit/mcp/oauth-pkce.spec.ts, which deliberately asserts against
the INSTALLED LIBRARY rather than only against our options object: the defect was
an omission, so a test that checked only "we pass the flag" would stay green
through a rename of the option and leave the deployment advertising plain
again.
Unrelated, and worth not confusing with the above: the outbound Google
Calendar client in server/lib/calendar/google.ts also does PKCE, as a client
rather than as a server, and has always sent S256.
Available inside defaultHandler.fetch(request, env, ctx) and apiHandler.fetch(request) via this.env:
// Parse the incoming OAuth authorization request
const oauthReqInfo: AuthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
// Look up a registered client
const client: ClientInfo | null = await env.OAUTH_PROVIDER.lookupClient(clientId: string);
// Complete an authorization (returns redirect URL)
const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
request: oauthReqInfo, // AuthRequest from parseAuthRequest()
userId: string, // opaque user identifier
metadata: unknown, // audit metadata (not encrypted, visible in listUserGrants)
scope: string[], // granted scopes
props: unknown, // arbitrary payload — encrypted into token, passed to apiHandler as ctx.props
revokeExistingGrants?: boolean, // default true
revokeExistingGrantsBatchSize?: number, // default 50
});
// Enumerate grants for a user (for audit/revocation UI)
const grants: ListResult<GrantSummary> = await env.OAUTH_PROVIDER.listUserGrants(
userId: string,
options?: { limit?: number; cursor?: string }
);
// Revoke a single grant
await env.OAUTH_PROVIDER.revokeGrant(grantId: string, userId: string);
// Inspect / decode an existing token
const summary: TokenSummary<T> | null = await env.OAUTH_PROVIDER.unwrapToken<T>(token: string);
// Client management
await env.OAUTH_PROVIDER.createClient(partial: Partial<ClientInfo>): Promise<ClientInfo>;
await env.OAUTH_PROVIDER.listClients(opts?: ListOptions): Promise<ListResult<ClientInfo>>;
await env.OAUTH_PROVIDER.updateClient(clientId, updates): Promise<ClientInfo | null>;
await env.OAUTH_PROVIDER.deleteClient(clientId): Promise<void>;
// Garbage collection (call from a Cron Trigger)
await env.OAUTH_PROVIDER.purgeExpiredData(opts?: PurgeOptions): Promise<PurgeResult>;The props passed to completeAuthorization() are end-to-end encrypted at rest in KV using
the access token as key material. On every authenticated API request the provider decrypts them
and injects them into the execution context as ctx.props. Inside a WorkerEntrypoint
apiHandler, this.ctx.props holds the decrypted props object.
import { McpAgent, getMcpAuthContext } from 'agents/mcp';The agents/mcp export (defined in agents package.json exports[./mcp]) maps to
dist/mcp/index.js. McpAgent is the only abstract base class needed for a DO-hosted MCP server.
abstract class McpAgent<
Env extends Cloudflare.Env = Cloudflare.Env,
State = unknown,
Props extends Record<string, unknown> = Record<string, unknown>
> extends Agent<Env, State, Props>Generic position: Env, State, Props (in that order). For a tenant-aware agent:
type MyProps = { tenantSlug: string; userId: string; scopes: string[] };
class InspectorMcp extends McpAgent<Env, never, MyProps> { ... }static serve(
path: string,
{
binding = 'MCP_OBJECT', // wrangler.jsonc DO binding name; change if needed
corsOptions?: CORSOptions,
transport?: 'streamable-http' | 'sse' | 'auto', // default 'streamable-http'
jurisdiction?: DurableObjectJurisdiction
}?: ServeOptions
): { fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> }McpAgent.serve() returns a fetch handler object that routes incoming HTTP/WebSocket requests
to the Durable Object. The DO instance name is streamable-http:{sessionId} (where sessionId
comes from the mcp-session-id request header, or a new unique ID for a fresh session).
- Authorization flow: The consent handler calls
env.OAUTH_PROVIDER.completeAuthorization({ ..., props: { tenantSlug, userId, ... } }). The provider encryptspropsinto the access token. - Authenticated API request:
OAuthProviderdecrypts the token and setsctx.propson the execution context before forwarding the request to theapiHandler. - Inside
McpAgent.serve(): The handler callsgetAgentByName(namespace, doName, { props: ctx.props }). The framework callsonStart(props?: Props)on the DO, which setsthis.props. - Inside the DO:
this.props(typed asProps | undefined) holds the decrypted OAuth grant props. Access them ininit(), tool handlers, etc.
The updateProps(props?: Props) method re-runs onStart and persists new props to DO storage.
For non-DO MCP servers (using createMcpHandler), use getMcpAuthContext() which returns
McpAuthContext | undefined — reads { props: Record<string, unknown> } from AsyncLocalStorage.
Yes, with dynamic derivation. The provider serves RFC 9728 Protected Resource Metadata not only
at /.well-known/oauth-protected-resource (host-root) but also at any path-suffixed variant per
RFC 9728 §3.1:
/.well-known/oauth-protected-resource → resource: https://host.com
/.well-known/oauth-protected-resource/mcp → resource: https://host.com/mcp
/.well-known/oauth-protected-resource/mcp/acme → resource: https://host.com/mcp/acme
Source: deriveResourceIdentifier(requestUrl) in oauth-provider.js (line ~978):
// pathname.slice(37) strips the 37-char "/.well-known/oauth-protected-resource" prefix
const suffix = requestUrl.pathname.slice(37);
if (!suffix || suffix === '/') return requestUrl.origin;
return `${requestUrl.origin}${suffix}`;This derivation is used only when resourceMetadata.resource is not statically configured.
If resourceMetadata.resource is set, it overrides dynamic derivation for all PRM requests.
The authorization_servers, scopes_supported, and bearer_methods_supported fields in the
PRM response are the same for all path variants — only the resource identifier differs.
Conclusion: both modes use apiRoute: '/mcp'. Standalone serves the single fixed
endpoint /mcp; SaaS serves per-workspace endpoint URLs /mcp/{slug} under the same
prefix. The tenant slug is present in BOTH the URL path (for per-workspace URLs +
per-workspace RFC 8707 resource binding) AND the OAuth grant props (the authoritative
value the DO trusts for tenant scoping).
Superseded — the SaaS mount used to be
apiRoute: '/company/'.apiRouteis a literal prefix, so that value made the OAuth provider treat EVERY/company/*request as an authenticated API request. It held a whole top-level namespace hostage to a "do not add a/company/*route" caveat written in this file — a rule enforced by nothing, in a document a route author has no reason to open. Narrowing the mount to/mcpreleases/company/*and deletes the caveat instead of restating it. An even earlier draft used the abbreviated/t/prefix — also superseded.
The design deliberately keeps tenant-in-URL + RFC 8707 resource indicator; only the prefix changed. The path-based approach is REQUIRED — not merely cosmetic — because two things a query-string approach cannot provide are mandatory here:
- Per-workspace distinct URLs — each workspace gets its own canonical MCP endpoint
(
https://host.com/mcp/acme), so MCP clients register and store one URL per workspace. - Per-workspace RFC 8707 resource binding — path-scoped RFC 9728 PRM derives a distinct
resource identifier per workspace (
/.well-known/oauth-protected-resource/mcp/acme→resource: 'https://host.com/mcp/acme'). Access tokens are then bound (via the RFC 8707resourceindicator) to that specific workspace's resource, so a token minted foracmecannot be replayed againstglobex's endpoint.
How apiRoute matching works (mechanical facts that shape the implementation):
apiRouteis a literal string prefix match — no patterns, no wildcards, no:slugexpansion. A slug-parameterized value would only match URLs literally containing the characters of the parameter (the colon is literal), so it CANNOT be used. The prefix'/mcp'covers/mcpand/mcp/{slug}alike, and the handler matches the precise shape.- The tenant slug is parsed from the URL path segment to select the workspace endpoint, but the
DO trusts
this.props.tenantSlug(encrypted in the OAuth grant) as the authoritative tenant for all data access. The URL slug and the props slug MUST agree — the handler rejects any request where the path slug ≠ the grantedprops.tenantSlug(prevents a token for one workspace being used against another workspace's URL). - The DO instance name is determined by session ID (
streamable-http:{sessionId}), not by slug.
Standalone: endpoint /mcp
- Single fixed endpoint; no slug segment, so the slug guard is inert by construction
- PRM auto-served at
/.well-known/oauth-protected-resource/mcp→resource: 'https://host.com/mcp' this.props.tenantSlugcarries the tenant from the OAuth grant (SINGLE_TENANT_ID is the only tenant)
SaaS: endpoint /mcp/{slug}
- Same registered prefix
'/mcp'; the slug is the segment after it - Per-workspace PRM is auto-derived:
/.well-known/oauth-protected-resource/mcp/acme→resource: 'https://host.com/mcp/acme'(distinct resource id per workspace = RFC 8707 binding) - The MCP agent (DO) reads
this.props.tenantSlugfor tenant scoping; path slug is validated against it McpAgent.serve('/mcp')matches its mount literally via URLPattern, so the handler reduces/mcp/{slug}back to/mcpbefore delegating
Why one handler covers both modes. The slug guard fires on the PATH shape, not on the mode:
/mcphas no slug segment and is delegated untouched,/mcp/{slug}is checked and reduced. Deriving the guard from the request rather than from a second mode flag is what stops the two from drifting apart (OI #308).
Rejected alternative — apiRoute: '/mcp' + ?workspace={slug} query param. Simpler to
route, but it FAILS the spec's requirements: all workspaces would share the single endpoint URL
https://host.com/mcp and therefore a single PRM resource identifier (https://host.com/mcp).
That collapses the per-workspace RFC 8707 resource binding — one token would be valid for the
shared resource regardless of workspace, losing the per-workspace token isolation §4.2 requires.
For any MCP integration to work, the following must be present in wrangler.jsonc:
What actually shipped, in the committed wrangler.jsonc — the binding name and class are
not McpAgent's defaults, and the migration tag is whichever one is next in that file's
own chain (v3 here, because two DO classes preceded it):
The OAUTH_KV binding name is hardcoded in @cloudflare/workers-oauth-provider — it cannot be
changed without forking the library.
McpAgent.serve() defaults its binding to MCP_OBJECT; this deployment overrides it by
passing { binding: 'INSPECTOR_MCP' }.
{ "durable_objects": { "bindings": [ { "name": "INSPECTOR_MCP", "class_name": "InspectorMcp" } ] }, "kv_namespaces": [ { "binding": "OAUTH_KV", "id": "<your-kv-id>" } ], "migrations": [ { "tag": "v3", "new_sqlite_classes": ["InspectorMcp"] } ] }