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

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Sam Morrow 2026-09-16 18:51:56 +02:00 • committed by GitHub
commit 6032170087
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -22,7 +22,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 };
@ -72,14 +73,36 @@ function buildServer(): McpServer {
});
//#endregion authInfo_handler
//#region 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' }] };
});
//#endregion perToolScopes_handler
//#region 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` }] })
);
//#endregion perOperationScopes_challenge
return server;
}