Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/scope-challenge-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@modelcontextprotocol/server': minor
'@modelcontextprotocol/node': minor
---

Add server-side OAuth scope challenge support (step-up auth) per MCP spec §10.1
and [SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350).

Servers can now declare required OAuth scopes per tool and the Streamable HTTP transport
automatically returns HTTP 403 with `WWW-Authenticate` headers when a client's token
lacks sufficient scopes, triggering the client's existing re-authorization flow.

Per RFC 6750 §3.1 / SEP-2350, the `WWW-Authenticate` `scope` parameter advertises
only the scopes required for the current operation — clients are expected to
accumulate scopes across step-up challenges (see #1657). An opt-in
`scopeChallenge.includeGrantedScopes: true` restores the additive union behavior
for servers that need to defend against non-accumulating clients.

New APIs:
- `ToolScopeConfig` type for declaring `required` (AND) and `accepted` (OR / hierarchy) scopes per tool
- `ScopeChallengeConfig` transport option with `resourceMetadataUrl` and optional `includeGrantedScopes`
- `McpServer.registerTool()` accepts a `scopes` option (`string[]` or `ToolScopeConfig`)
- `McpServer.setToolScopes()` for decoupled/centralized scope declaration
- `McpServer.getToolScopes()` to query resolved scope config
- `setScopeResolver()` on both `WebStandardStreamableHTTPServerTransport` and `NodeStreamableHTTPServerTransport`
- Auto-wiring of scope resolver in `McpServer.connect()`
485 changes: 485 additions & 0 deletions docs/proposals/scope-challenge-server-sdk.md

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion packages/middleware/node/src/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';

import { getRequestListener } from '@hono/node-server';
import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core';
import type { WebStandardStreamableHTTPServerTransportOptions } from '@modelcontextprotocol/server';
import type { ScopeResolver, WebStandardStreamableHTTPServerTransportOptions } from '@modelcontextprotocol/server';
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';

/**
Expand Down Expand Up @@ -152,6 +152,17 @@ export class NodeStreamableHTTPServerTransport implements Transport {
return this._webStandardTransport.send(message, options);
}

/**
* Sets the scope resolver for pre-execution scope challenge checks.
* Delegates to the underlying {@linkcode WebStandardStreamableHTTPServerTransport}.
*
* This is auto-wired by `McpServer.connect()` when `scopeChallenge`
* is configured on the transport.
*/
setScopeResolver(resolver: ScopeResolver): void {
this._webStandardTransport.setScopeResolver(resolver);
}

/**
* Handles an incoming HTTP request, whether `GET` or `POST`.
*
Expand Down
8 changes: 6 additions & 2 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export type {
RegisteredResourceTemplate,
RegisteredTool,
ResourceMetadata,
ToolCallback
ToolCallback,
ToolScopeConfig
} from './server/mcp.js';
export { McpServer, ResourceTemplate } from './server/mcp.js';
export type { HostHeaderValidationResult } from './server/middleware/hostHeaderValidation.js';
Expand All @@ -35,10 +36,13 @@ export type {
EventId,
EventStore,
HandleRequestOptions,
ScopeAware,
ScopeChallengeConfig,
ScopeResolver,
StreamId,
WebStandardStreamableHTTPServerTransportOptions
} from './server/streamableHttp.js';
export { WebStandardStreamableHTTPServerTransport } from './server/streamableHttp.js';
export { isScopeAware, WebStandardStreamableHTTPServerTransport } from './server/streamableHttp.js';

// experimental exports
export type { CreateTaskRequestHandler, TaskRequestHandler, ToolTaskHandler } from './experimental/tasks/interfaces.js';
Expand Down
135 changes: 133 additions & 2 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js';
import { getCompleter, isCompletable } from './completable.js';
import type { ServerOptions } from './server.js';
import { Server } from './server.js';
import { isScopeAware } from './streamableHttp.js';

/**
* High-level MCP server that provides a simpler API for working with resources, tools, and prompts.
Expand Down Expand Up @@ -107,6 +108,10 @@ export class McpServer {
* ```
*/
async connect(transport: Transport): Promise<void> {
// Auto-wire scope resolver if the transport supports scope challenges.
if (isScopeAware(transport)) {
transport.setScopeResolver(toolName => this.getToolScopes(toolName));
}
return await this.server.connect(transport);
}

Expand All @@ -117,6 +122,51 @@ export class McpServer {
await this.server.close();
}

/**
* Returns the scope configuration for a registered tool, if any.
* Checks tool-level scopes first, then falls back to server-level scope overrides.
* Used by the transport layer for pre-execution scope challenge checks.
*/
getToolScopes(toolName: string): ToolScopeConfig | undefined {
return this._toolScopeOverrides[toolName] ?? this._registeredTools[toolName]?.scopes;
}

private _toolScopeOverrides: { [name: string]: ToolScopeConfig } = {};

/**
* Sets scope requirements for a tool independently of tool registration.
*
* This allows defining scopes separately — from a config file, a central
* mapping, or dynamically at runtime — rather than co-locating them with
* the tool definition. Scopes set here take precedence over any `scopes`
* provided during tool registration.
*
* @example Central scope mapping
* ```typescript
* // Define all scopes in one place
* const TOOL_SCOPES: Record<string, string[]> = {
* 'get_repo': ['repo:read'],
* 'create_issue': ['repo:write'],
* 'list_orgs': ['read:org'],
* };
*
* for (const [tool, scopes] of Object.entries(TOOL_SCOPES)) {
* server.setToolScopes(tool, scopes);
* }
* ```
*
* @example With scope hierarchy
* ```typescript
* server.setToolScopes('get_repo', {
* required: ['public_repo'],
* accepted: ['public_repo', 'repo'],
* });
* ```
*/
setToolScopes(toolName: string, scopes: string[] | ToolScopeConfig): void {
this._toolScopeOverrides[toolName] = Array.isArray(scopes) ? { required: scopes } : scopes;
}

private _toolHandlersInitialized = false;

private setToolRequestHandlers() {
Expand Down Expand Up @@ -872,6 +922,34 @@ export class McpServer {
inputSchema?: InputArgs;
outputSchema?: OutputArgs;
annotations?: ToolAnnotations;
/**
* OAuth scopes required for this tool.
*
* When provided alongside a `ScopeChallengeConfig` on the transport,
* the transport checks the client's token scopes before executing the tool.
* If the token lacks required scopes, the transport returns HTTP 403 with a
* `WWW-Authenticate` header, triggering the client's step-up authorization flow.
*
* Can be a simple array of required scope strings, or an object with `required`
* and optional `accepted` arrays (for scope hierarchy support).
*
* @example Simple scopes
* ```typescript
* server.registerTool('get_repo', {
* description: 'Get repository details',
* scopes: ['repo:read'],
* }, handler);
* ```
*
* @example With scope hierarchy
* ```typescript
* server.registerTool('get_repo', {
* description: 'Get repository details',
* scopes: { required: ['public_repo'], accepted: ['public_repo', 'repo'] },
* }, handler);
* ```
*/
scopes?: string[] | ToolScopeConfig;
_meta?: Record<string, unknown>;
},
cb: ToolCallback<InputArgs>
Expand All @@ -897,6 +975,7 @@ export class McpServer {
inputSchema?: StandardSchemaWithJSON | ZodRawShape;
outputSchema?: StandardSchemaWithJSON | ZodRawShape;
annotations?: ToolAnnotations;
scopes?: string[] | ToolScopeConfig;
_meta?: Record<string, unknown>;
},
cb: ToolCallback<StandardSchemaWithJSON | undefined> | LegacyToolCallback<ZodRawShape>
Expand All @@ -905,9 +984,9 @@ export class McpServer {
throw new Error(`Tool ${name} is already registered`);
}

const { title, description, inputSchema, outputSchema, annotations, _meta } = config;
const { title, description, inputSchema, outputSchema, annotations, scopes, _meta } = config;

return this._createRegisteredTool(
const tool = this._createRegisteredTool(
name,
title,
description,
Expand All @@ -918,6 +997,13 @@ export class McpServer {
_meta,
cb as ToolCallback<StandardSchemaWithJSON | undefined>
);

// Normalize and attach scope metadata
if (scopes) {
tool.scopes = Array.isArray(scopes) ? { required: scopes } : scopes;
}

return tool;
}

/**
Expand Down Expand Up @@ -1164,6 +1250,7 @@ export type RegisteredTool = {
outputSchema?: StandardSchemaWithJSON;
annotations?: ToolAnnotations;
execution?: ToolExecution;
scopes?: ToolScopeConfig;
_meta?: Record<string, unknown>;
handler: AnyToolHandler<StandardSchemaWithJSON | undefined>;
/** @hidden */
Expand All @@ -1185,6 +1272,50 @@ export type RegisteredTool = {
remove(): void;
};

/**
* Scope metadata for a tool, used for pre-execution scope challenge checks.
*
* When configured alongside a `ScopeChallengeConfig` on the transport,
* the transport will check the client's token scopes against these requirements
* before executing the tool. If the token lacks the required scopes, the transport
* returns HTTP 403 with a `WWW-Authenticate` header per RFC 6750 §3.1.
*/
export interface ToolScopeConfig {
/**
* Scopes required for this tool, with **AND** semantics — the token must
* contain every scope in this array for the call to proceed (unless
* `accepted` is provided, see below). These are the scopes advertised in
* the 403 `WWW-Authenticate` challenge's `scope` parameter when the token
* is insufficient.
*
* @example Single scope
* ```typescript
* { required: ['repo:read'] }
* ```
*
* @example Multiple scopes (all must be present)
* ```typescript
* { required: ['repo:read', 'user:read'] }
* ```
*/
required: string[];
/**
* Optional **OR** escape hatch for the satisfaction check. When provided,
* the satisfaction check switches from "token has all `required`" to
* "token has ANY of `accepted`". Use this for scope hierarchies where a
* broader scope subsumes a narrower one.
*
* Note: `accepted` only affects whether the request is allowed through —
* the scope challenge advertised on a 403 is still based on `required`.
*
* @example Hierarchy: `repo` (broad) satisfies `repo:read` (narrow)
* ```typescript
* { required: ['repo:read'], accepted: ['repo:read', 'repo'] }
* ```
*/
accepted?: string[];
}

/**
* Creates an executor that invokes the handler with the appropriate arguments.
* When `inputSchema` is defined, the handler is called with `(args, ctx)`.
Expand Down
Loading