feat(server): add request-time OAuth scope challenges (#1624)
Some checks failed
Conformance Tests / client-conformance (push) Has been cancelled
Conformance Tests / server-conformance (push) Has been cancelled
Deploy Docs / deploy-docs (push) Has been cancelled
Examples / examples (build + e2e) (push) Has been cancelled
/ build (push) Has been cancelled
/ test (20) (push) Has been cancelled
/ test (22) (push) Has been cancelled
/ test (24) (push) Has been cancelled
/ test-e2e (20) (push) Has been cancelled
/ test-e2e (22) (push) Has been cancelled
/ test-e2e (24) (push) Has been cancelled
/ test-runtimes (bun, 1.x) (push) Has been cancelled
/ test-runtimes (deno, v2.x) (push) Has been cancelled
Publish Any Commit / pkg-publish (push) Has been cancelled
Release / Version (push) Has been cancelled
Release / Publish (push) Has been cancelled
Some checks failed
Conformance Tests / client-conformance (push) Has been cancelled
Conformance Tests / server-conformance (push) Has been cancelled
Deploy Docs / deploy-docs (push) Has been cancelled
Examples / examples (build + e2e) (push) Has been cancelled
/ build (push) Has been cancelled
/ test (20) (push) Has been cancelled
/ test (22) (push) Has been cancelled
/ test (24) (push) Has been cancelled
/ test-e2e (20) (push) Has been cancelled
/ test-e2e (22) (push) Has been cancelled
/ test-e2e (24) (push) Has been cancelled
/ test-runtimes (bun, 1.x) (push) Has been cancelled
/ test-runtimes (deno, v2.x) (push) Has been cancelled
Publish Any Commit / pkg-publish (push) Has been cancelled
Release / Version (push) Has been cancelled
Release / Publish (push) Has been cancelled
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b65426158e
commit
6032170087
1 changed files with 1310 additions and 104 deletions
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
shape: how-to
|
||||
description: 'Require a bearer token on a server you run: verification, protected-resource metadata, and per-tool scopes.'
|
||||
description: 'Require a bearer token on a server you run: verification, protected-resource metadata, and per-operation scopes.'
|
||||
---
|
||||
|
||||
# Require authorization
|
||||
|
|
@ -21,7 +21,8 @@ import {
|
|||
} from '@modelcontextprotocol/express';
|
||||
import { toNodeHandler } from '@modelcontextprotocol/node';
|
||||
import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server';
|
||||
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
|
||||
import { createMcpHandler, McpServer, requireScopes } from '@modelcontextprotocol/server';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const mcpServerUrl = new URL('https://api.example.com/mcp');
|
||||
const verifier: OAuthTokenVerifier = { verifyAccessToken };
|
||||
|
|
@ -116,23 +117,49 @@ server.registerTool('whoami', { description: 'Report the authenticated caller' }
|
|||
The per-request factory itself receives the same value as `ctx.authInfo`, so it can register a different tool set per caller before any handler runs.
|
||||
:::
|
||||
|
||||
## Enforce per-tool scopes
|
||||
## Enforce per-operation scopes
|
||||
|
||||
`requiredScopes` gates the whole endpoint. For a scope only some tools need, check inside the handler — the handler is the only place that knows which tool is executing.
|
||||
`requiredScopes` gates the whole endpoint. For scope step-up on an individual tool call, resource read, or prompt retrieval, set `scopeChallenge` on its registration — no handler or transport configuration is needed. The callback receives the full parsed request and verified `authInfo`. Return `undefined` to continue, or return the exact, complete scope set to send `403 insufficient_scope` before invocation or SSE. Throwing or rejecting fails closed.
|
||||
|
||||
```ts source="../../examples/guides/serving/authorization.examples.ts#perToolScopes_handler"
|
||||
server.registerTool('purge-notes', { description: 'Delete every note' }, async ctx => {
|
||||
if (!ctx.http?.authInfo?.scopes.includes('notes:write')) {
|
||||
return { content: [{ type: 'text', text: 'insufficient_scope: purge-notes requires notes:write' }], isError: true };
|
||||
}
|
||||
return { content: [{ type: 'text', text: 'All notes deleted' }] };
|
||||
});
|
||||
The challenge uses the same OAuth `insufficient_scope` JSON body and `WWW-Authenticate` formatter as `requireBearerAuth`'s own `403` answer. Its `resource_metadata` parameter comes from the verified `AuthInfo`: the gate stamps its configured `resourceMetadataUrl` onto the `AuthInfo` it returns, so the metadata URL is configured exactly once — on `requireBearerAuth`. Without a stamped value the parameter falls back to the well-known location for the token's RFC 8707 `resource` identifier, or is omitted.
|
||||
|
||||
Use `requireScopes` for a static exact all-of check. Use a callback when the required scope set depends on the request:
|
||||
|
||||
```ts source="../../examples/guides/serving/authorization.examples.ts#perOperationScopes_challenge"
|
||||
server.registerTool('purge-notes', { scopeChallenge: requireScopes('notes:write') }, async () => ({
|
||||
content: [{ type: 'text', text: 'All notes deleted' }]
|
||||
}));
|
||||
|
||||
server.registerResource('private-notes', 'notes://private', { scopeChallenge: requireScopes('notes:read') }, async uri => ({
|
||||
contents: [{ uri: uri.href, text: 'Private notes' }]
|
||||
}));
|
||||
|
||||
server.registerPrompt('summarize-notes', { scopeChallenge: requireScopes('notes:read') }, async () => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Summarize my private notes' } }]
|
||||
}));
|
||||
|
||||
server.registerTool(
|
||||
'read-repository',
|
||||
{
|
||||
inputSchema: z.object({ visibility: z.enum(['public', 'private']) }),
|
||||
scopeChallenge: ({ request, authInfo }) => {
|
||||
const visibility = (request.params as { arguments?: { visibility?: unknown } }).arguments?.visibility;
|
||||
if (visibility !== 'public' && visibility !== 'private') return;
|
||||
|
||||
const scopes = visibility === 'private' ? (['repo:read'] as const) : (['public_repo'] as const);
|
||||
return scopes.every(scope => authInfo?.scopes.includes(scope))
|
||||
? undefined
|
||||
: { scopes, errorDescription: `${visibility} repository access is required` };
|
||||
}
|
||||
},
|
||||
async ({ visibility }) => ({ content: [{ type: 'text', text: `Read ${visibility} repository` }] })
|
||||
);
|
||||
```
|
||||
|
||||
A caller holding only `mcp` gets an ordinary tool result with `isError: true`, so the model reads the refusal and moves on instead of losing the connection.
|
||||
Scope interpretation belongs to your callback; the SDK does not infer hierarchies, alternatives, or missing scopes. Challenged primitives remain visible in their list operations.
|
||||
|
||||
::: info
|
||||
Responding `403 insufficient_scope` at the HTTP layer instead triggers the client transport's automatic scope step-up (SEP-2350) — see [Authenticate a user with OAuth](../clients/oauth.md).
|
||||
::: warning
|
||||
The callback runs before the primitive's input schema is validated or transformed. Its `request` contains the JSON-parsed wire values, so dynamic authorization should validate or canonicalize any value whose schema changes its meaning before handler invocation. Scope names must follow the OAuth `scope-token` grammar; `errorDescription`, when provided, must follow RFC 6750's `error-description` grammar.
|
||||
:::
|
||||
|
||||
## Recap
|
||||
|
|
@ -141,5 +168,5 @@ Responding `403 insufficient_scope` at the HTTP layer instead triggers the clien
|
|||
- `requireBearerAuth` plus a `verifyAccessToken` you write turn an Express-mounted MCP route into an OAuth resource server; the SDK never issues tokens.
|
||||
- Missing, invalid, or expired tokens get `401 invalid_token`; a token missing a `requiredScopes` entry gets `403 insufficient_scope`; both carry a `WWW-Authenticate: Bearer` challenge.
|
||||
- `mcpAuthMetadataRouter` publishes the RFC 9728 document that challenge points at, plus a mirror of the AS metadata.
|
||||
- Verified auth flows `req.auth` → `ctx.http.authInfo`; per-tool scopes are a check inside the handler that returns `isError: true`.
|
||||
- Verified auth flows `req.auth` → `ctx.http.authInfo`; per-operation callbacks can trigger HTTP `403` scope step-up before invocation, advertising the metadata URL the gate stamped onto `AuthInfo`.
|
||||
- The v1 Authorization Server helpers are frozen in `@modelcontextprotocol/server-legacy/auth`.
|
||||
|
|
|
|||
Loading…
Reference in a new issue