[v2] Мonorepo, sdk-client, sdk-server, sdk-core package split (#1279)
This commit is contained in:
parent
384311b9b8
commit
71ae3acee0
29 changed files with 12067 additions and 4817 deletions
13
packages/server/src/experimental/index.ts
Normal file
13
packages/server/src/experimental/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Experimental MCP SDK features.
|
||||
* WARNING: These APIs are experimental and may change without notice.
|
||||
*
|
||||
* Import experimental features from this module:
|
||||
* ```typescript
|
||||
* import { TaskStore, InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental';
|
||||
* ```
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
|
||||
export * from './tasks/index.js';
|
||||
13
packages/server/src/experimental/tasks/index.ts
Normal file
13
packages/server/src/experimental/tasks/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Experimental task features for MCP SDK.
|
||||
* WARNING: These APIs are experimental and may change without notice.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
|
||||
// SDK implementation interfaces
|
||||
export * from './interfaces.js';
|
||||
|
||||
// Wrapper classes
|
||||
export * from './mcp-server.js';
|
||||
export * from './server.js';
|
||||
49
packages/server/src/experimental/tasks/interfaces.ts
Normal file
49
packages/server/src/experimental/tasks/interfaces.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Experimental task interfaces for MCP SDK.
|
||||
* WARNING: These APIs are experimental and may change without notice.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AnySchema,
|
||||
CallToolResult,
|
||||
CreateTaskRequestHandlerExtra,
|
||||
CreateTaskResult,
|
||||
GetTaskResult,
|
||||
Result,
|
||||
TaskRequestHandlerExtra,
|
||||
ZodRawShapeCompat
|
||||
} from '@modelcontextprotocol/core';
|
||||
|
||||
import type { BaseToolCallback } from '../../server/mcp.js';
|
||||
|
||||
// ============================================================================
|
||||
// Task Handler Types (for registerToolTask)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Handler for creating a task.
|
||||
* @experimental
|
||||
*/
|
||||
export type CreateTaskRequestHandler<
|
||||
SendResultT extends Result,
|
||||
Args extends undefined | ZodRawShapeCompat | AnySchema = undefined
|
||||
> = BaseToolCallback<SendResultT, CreateTaskRequestHandlerExtra, Args>;
|
||||
|
||||
/**
|
||||
* Handler for task operations (get, getResult).
|
||||
* @experimental
|
||||
*/
|
||||
export type TaskRequestHandler<
|
||||
SendResultT extends Result,
|
||||
Args extends undefined | ZodRawShapeCompat | AnySchema = undefined
|
||||
> = BaseToolCallback<SendResultT, TaskRequestHandlerExtra, Args>;
|
||||
|
||||
/**
|
||||
* Interface for task-based tool handlers.
|
||||
* @experimental
|
||||
*/
|
||||
export interface ToolTaskHandler<Args extends undefined | ZodRawShapeCompat | AnySchema = undefined> {
|
||||
createTask: CreateTaskRequestHandler<CreateTaskResult, Args>;
|
||||
getTask: TaskRequestHandler<GetTaskResult, Args>;
|
||||
getTaskResult: TaskRequestHandler<CallToolResult, Args>;
|
||||
}
|
||||
142
packages/server/src/experimental/tasks/mcp-server.ts
Normal file
142
packages/server/src/experimental/tasks/mcp-server.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* Experimental McpServer task features for MCP SDK.
|
||||
* WARNING: These APIs are experimental and may change without notice.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
|
||||
import type { AnySchema, TaskToolExecution, ToolAnnotations, ToolExecution, ZodRawShapeCompat } from '@modelcontextprotocol/core';
|
||||
|
||||
import type { AnyToolHandler, McpServer, RegisteredTool } from '../../server/mcp.js';
|
||||
import type { ToolTaskHandler } from './interfaces.js';
|
||||
|
||||
/**
|
||||
* Internal interface for accessing McpServer's private _createRegisteredTool method.
|
||||
* @internal
|
||||
*/
|
||||
interface McpServerInternal {
|
||||
_createRegisteredTool(
|
||||
name: string,
|
||||
title: string | undefined,
|
||||
description: string | undefined,
|
||||
inputSchema: ZodRawShapeCompat | AnySchema | undefined,
|
||||
outputSchema: ZodRawShapeCompat | AnySchema | undefined,
|
||||
annotations: ToolAnnotations | undefined,
|
||||
execution: ToolExecution | undefined,
|
||||
_meta: Record<string, unknown> | undefined,
|
||||
handler: AnyToolHandler<ZodRawShapeCompat | undefined>
|
||||
): RegisteredTool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Experimental task features for McpServer.
|
||||
*
|
||||
* Access via `server.experimental.tasks`:
|
||||
* ```typescript
|
||||
* server.experimental.tasks.registerToolTask('long-running', config, handler);
|
||||
* ```
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export class ExperimentalMcpServerTasks {
|
||||
constructor(private readonly _mcpServer: McpServer) {}
|
||||
|
||||
/**
|
||||
* Registers a task-based tool with a config object and handler.
|
||||
*
|
||||
* Task-based tools support long-running operations that can be polled for status
|
||||
* and results. The handler must implement `createTask`, `getTask`, and `getTaskResult`
|
||||
* methods.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* server.experimental.tasks.registerToolTask('long-computation', {
|
||||
* description: 'Performs a long computation',
|
||||
* inputSchema: { input: z.string() },
|
||||
* execution: { taskSupport: 'required' }
|
||||
* }, {
|
||||
* createTask: async (args, extra) => {
|
||||
* const task = await extra.taskStore.createTask({ ttl: 300000 });
|
||||
* startBackgroundWork(task.taskId, args);
|
||||
* return { task };
|
||||
* },
|
||||
* getTask: async (args, extra) => {
|
||||
* return extra.taskStore.getTask(extra.taskId);
|
||||
* },
|
||||
* getTaskResult: async (args, extra) => {
|
||||
* return extra.taskStore.getTaskResult(extra.taskId);
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @param name - The tool name
|
||||
* @param config - Tool configuration (description, schemas, etc.)
|
||||
* @param handler - Task handler with createTask, getTask, getTaskResult methods
|
||||
* @returns RegisteredTool for managing the tool's lifecycle
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
registerToolTask<OutputArgs extends undefined | ZodRawShapeCompat | AnySchema>(
|
||||
name: string,
|
||||
config: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
outputSchema?: OutputArgs;
|
||||
annotations?: ToolAnnotations;
|
||||
execution?: TaskToolExecution;
|
||||
_meta?: Record<string, unknown>;
|
||||
},
|
||||
handler: ToolTaskHandler<undefined>
|
||||
): RegisteredTool;
|
||||
|
||||
registerToolTask<InputArgs extends ZodRawShapeCompat | AnySchema, OutputArgs extends undefined | ZodRawShapeCompat | AnySchema>(
|
||||
name: string,
|
||||
config: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
inputSchema: InputArgs;
|
||||
outputSchema?: OutputArgs;
|
||||
annotations?: ToolAnnotations;
|
||||
execution?: TaskToolExecution;
|
||||
_meta?: Record<string, unknown>;
|
||||
},
|
||||
handler: ToolTaskHandler<InputArgs>
|
||||
): RegisteredTool;
|
||||
|
||||
registerToolTask<
|
||||
InputArgs extends undefined | ZodRawShapeCompat | AnySchema,
|
||||
OutputArgs extends undefined | ZodRawShapeCompat | AnySchema
|
||||
>(
|
||||
name: string,
|
||||
config: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
inputSchema?: InputArgs;
|
||||
outputSchema?: OutputArgs;
|
||||
annotations?: ToolAnnotations;
|
||||
execution?: TaskToolExecution;
|
||||
_meta?: Record<string, unknown>;
|
||||
},
|
||||
handler: ToolTaskHandler<InputArgs>
|
||||
): RegisteredTool {
|
||||
// Validate that taskSupport is not 'forbidden' for task-based tools
|
||||
const execution: ToolExecution = { taskSupport: 'required', ...config.execution };
|
||||
if (execution.taskSupport === 'forbidden') {
|
||||
throw new Error(`Cannot register task-based tool '${name}' with taskSupport 'forbidden'. Use registerTool() instead.`);
|
||||
}
|
||||
|
||||
// Access McpServer's internal _createRegisteredTool method
|
||||
const mcpServerInternal = this._mcpServer as unknown as McpServerInternal;
|
||||
return mcpServerInternal._createRegisteredTool(
|
||||
name,
|
||||
config.title,
|
||||
config.description,
|
||||
config.inputSchema,
|
||||
config.outputSchema,
|
||||
config.annotations,
|
||||
execution,
|
||||
config._meta,
|
||||
handler as AnyToolHandler<ZodRawShapeCompat | undefined>
|
||||
);
|
||||
}
|
||||
}
|
||||
141
packages/server/src/experimental/tasks/server.ts
Normal file
141
packages/server/src/experimental/tasks/server.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* Experimental server task features for MCP SDK.
|
||||
* WARNING: These APIs are experimental and may change without notice.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
|
||||
import type {
|
||||
AnySchema,
|
||||
CancelTaskResult,
|
||||
GetTaskResult,
|
||||
ListTasksResult,
|
||||
Notification,
|
||||
Request,
|
||||
RequestOptions,
|
||||
ResponseMessage,
|
||||
Result,
|
||||
SchemaOutput,
|
||||
ServerRequest
|
||||
} from '@modelcontextprotocol/core';
|
||||
|
||||
import type { Server } from '../../server/server.js';
|
||||
|
||||
/**
|
||||
* Experimental task features for low-level MCP servers.
|
||||
*
|
||||
* Access via `server.experimental.tasks`:
|
||||
* ```typescript
|
||||
* const stream = server.experimental.tasks.requestStream(request, schema, options);
|
||||
* ```
|
||||
*
|
||||
* For high-level server usage with task-based tools, use `McpServer.experimental.tasks` instead.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export class ExperimentalServerTasks<
|
||||
RequestT extends Request = Request,
|
||||
NotificationT extends Notification = Notification,
|
||||
ResultT extends Result = Result
|
||||
> {
|
||||
constructor(private readonly _server: Server<RequestT, NotificationT, ResultT>) {}
|
||||
|
||||
/**
|
||||
* Sends a request and returns an AsyncGenerator that yields response messages.
|
||||
* The generator is guaranteed to end with either a 'result' or 'error' message.
|
||||
*
|
||||
* This method provides streaming access to request processing, allowing you to
|
||||
* observe intermediate task status updates for task-augmented requests.
|
||||
*
|
||||
* @param request - The request to send
|
||||
* @param resultSchema - Zod schema for validating the result
|
||||
* @param options - Optional request options (timeout, signal, task creation params, etc.)
|
||||
* @returns AsyncGenerator that yields ResponseMessage objects
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
requestStream<T extends AnySchema>(
|
||||
request: ServerRequest | RequestT,
|
||||
resultSchema: T,
|
||||
options?: RequestOptions
|
||||
): AsyncGenerator<ResponseMessage<SchemaOutput<T>>, void, void> {
|
||||
// Delegate to the server's underlying Protocol method
|
||||
type ServerWithRequestStream = {
|
||||
requestStream<U extends AnySchema>(
|
||||
request: ServerRequest | RequestT,
|
||||
resultSchema: U,
|
||||
options?: RequestOptions
|
||||
): AsyncGenerator<ResponseMessage<SchemaOutput<U>>, void, void>;
|
||||
};
|
||||
return (this._server as unknown as ServerWithRequestStream).requestStream(request, resultSchema, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current status of a task.
|
||||
*
|
||||
* @param taskId - The task identifier
|
||||
* @param options - Optional request options
|
||||
* @returns The task status
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
async getTask(taskId: string, options?: RequestOptions): Promise<GetTaskResult> {
|
||||
type ServerWithGetTask = { getTask(params: { taskId: string }, options?: RequestOptions): Promise<GetTaskResult> };
|
||||
return (this._server as unknown as ServerWithGetTask).getTask({ taskId }, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the result of a completed task.
|
||||
*
|
||||
* @param taskId - The task identifier
|
||||
* @param resultSchema - Zod schema for validating the result
|
||||
* @param options - Optional request options
|
||||
* @returns The task result
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
async getTaskResult<T extends AnySchema>(taskId: string, resultSchema?: T, options?: RequestOptions): Promise<SchemaOutput<T>> {
|
||||
return (
|
||||
this._server as unknown as {
|
||||
getTaskResult: <U extends AnySchema>(
|
||||
params: { taskId: string },
|
||||
resultSchema?: U,
|
||||
options?: RequestOptions
|
||||
) => Promise<SchemaOutput<U>>;
|
||||
}
|
||||
).getTaskResult({ taskId }, resultSchema, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists tasks with optional pagination.
|
||||
*
|
||||
* @param cursor - Optional pagination cursor
|
||||
* @param options - Optional request options
|
||||
* @returns List of tasks with optional next cursor
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
async listTasks(cursor?: string, options?: RequestOptions): Promise<ListTasksResult> {
|
||||
return (
|
||||
this._server as unknown as {
|
||||
listTasks: (params?: { cursor?: string }, options?: RequestOptions) => Promise<ListTasksResult>;
|
||||
}
|
||||
).listTasks(cursor ? { cursor } : undefined, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a running task.
|
||||
*
|
||||
* @param taskId - The task identifier
|
||||
* @param options - Optional request options
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
async cancelTask(taskId: string, options?: RequestOptions): Promise<CancelTaskResult> {
|
||||
return (
|
||||
this._server as unknown as {
|
||||
cancelTask: (params: { taskId: string }, options?: RequestOptions) => Promise<CancelTaskResult>;
|
||||
}
|
||||
).cancelTask({ taskId }, options);
|
||||
}
|
||||
}
|
||||
18
packages/server/src/index.ts
Normal file
18
packages/server/src/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export * from './server/completable.js';
|
||||
export * from './server/express.js';
|
||||
export * from './server/inMemoryEventStore.js';
|
||||
export * from './server/mcp.js';
|
||||
export * from './server/server.js';
|
||||
export * from './server/sse.js';
|
||||
export * from './server/stdio.js';
|
||||
export * from './server/streamableHttp.js';
|
||||
export * from './server/webStandardStreamableHttp.js';
|
||||
|
||||
// auth exports
|
||||
export * from './server/auth/index.js';
|
||||
|
||||
// experimental exports
|
||||
export * from './experimental/index.js';
|
||||
|
||||
// re-export shared types
|
||||
export * from '@modelcontextprotocol/core';
|
||||
22
packages/server/src/server/auth/clients.ts
Normal file
22
packages/server/src/server/auth/clients.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { OAuthClientInformationFull } from '@modelcontextprotocol/core';
|
||||
|
||||
/**
|
||||
* Stores information about registered OAuth clients for this server.
|
||||
*/
|
||||
export interface OAuthRegisteredClientsStore {
|
||||
/**
|
||||
* Returns information about a registered client, based on its ID.
|
||||
*/
|
||||
getClient(clientId: string): OAuthClientInformationFull | undefined | Promise<OAuthClientInformationFull | undefined>;
|
||||
|
||||
/**
|
||||
* Registers a new client with the server. The client ID and secret will be automatically generated by the library. A modified version of the client information can be returned to reflect specific values enforced by the server.
|
||||
*
|
||||
* NOTE: Implementations should NOT delete expired client secrets in-place. Auth middleware provided by this library will automatically check the `client_secret_expires_at` field and reject requests with expired secrets. Any custom logic for authenticating clients should check the `client_secret_expires_at` field as well.
|
||||
*
|
||||
* If unimplemented, dynamic client registration is unsupported.
|
||||
*/
|
||||
registerClient?(
|
||||
client: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>
|
||||
): OAuthClientInformationFull | Promise<OAuthClientInformationFull>;
|
||||
}
|
||||
167
packages/server/src/server/auth/handlers/authorize.ts
Normal file
167
packages/server/src/server/auth/handlers/authorize.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { InvalidClientError, InvalidRequestError, OAuthError, ServerError, TooManyRequestsError } from '@modelcontextprotocol/core';
|
||||
import type { RequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
import type { Options as RateLimitOptions } from 'express-rate-limit';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
import { allowedMethods } from '../middleware/allowedMethods.js';
|
||||
import type { OAuthServerProvider } from '../provider.js';
|
||||
|
||||
export type AuthorizationHandlerOptions = {
|
||||
provider: OAuthServerProvider;
|
||||
/**
|
||||
* Rate limiting configuration for the authorization endpoint.
|
||||
* Set to false to disable rate limiting for this endpoint.
|
||||
*/
|
||||
rateLimit?: Partial<RateLimitOptions> | false;
|
||||
};
|
||||
|
||||
// Parameters that must be validated in order to issue redirects.
|
||||
const ClientAuthorizationParamsSchema = z.object({
|
||||
client_id: z.string(),
|
||||
redirect_uri: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(value => value === undefined || URL.canParse(value), { message: 'redirect_uri must be a valid URL' })
|
||||
});
|
||||
|
||||
// Parameters that must be validated for a successful authorization request. Failure can be reported to the redirect URI.
|
||||
const RequestAuthorizationParamsSchema = z.object({
|
||||
response_type: z.literal('code'),
|
||||
code_challenge: z.string(),
|
||||
code_challenge_method: z.literal('S256'),
|
||||
scope: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
resource: z.string().url().optional()
|
||||
});
|
||||
|
||||
export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler {
|
||||
// Create a router to apply middleware
|
||||
const router = express.Router();
|
||||
router.use(allowedMethods(['GET', 'POST']));
|
||||
router.use(express.urlencoded({ extended: false }));
|
||||
|
||||
// Apply rate limiting unless explicitly disabled
|
||||
if (rateLimitConfig !== false) {
|
||||
router.use(
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // 100 requests per windowMs
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: new TooManyRequestsError('You have exceeded the rate limit for authorization requests').toResponseObject(),
|
||||
...rateLimitConfig
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
router.all('/', async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
// In the authorization flow, errors are split into two categories:
|
||||
// 1. Pre-redirect errors (direct response with 400)
|
||||
// 2. Post-redirect errors (redirect with error parameters)
|
||||
|
||||
// Phase 1: Validate client_id and redirect_uri. Any errors here must be direct responses.
|
||||
let client_id, redirect_uri, client;
|
||||
try {
|
||||
const result = ClientAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query);
|
||||
if (!result.success) {
|
||||
throw new InvalidRequestError(result.error.message);
|
||||
}
|
||||
|
||||
client_id = result.data.client_id;
|
||||
redirect_uri = result.data.redirect_uri;
|
||||
|
||||
client = await provider.clientsStore.getClient(client_id);
|
||||
if (!client) {
|
||||
throw new InvalidClientError('Invalid client_id');
|
||||
}
|
||||
|
||||
if (redirect_uri !== undefined) {
|
||||
if (!client.redirect_uris.includes(redirect_uri)) {
|
||||
throw new InvalidRequestError('Unregistered redirect_uri');
|
||||
}
|
||||
} else if (client.redirect_uris.length === 1) {
|
||||
redirect_uri = client.redirect_uris[0];
|
||||
} else {
|
||||
throw new InvalidRequestError('redirect_uri must be specified when client has multiple registered URIs');
|
||||
}
|
||||
} catch (error) {
|
||||
// Pre-redirect errors - return direct response
|
||||
//
|
||||
// These don't need to be JSON encoded, as they'll be displayed in a user
|
||||
// agent, but OTOH they all represent exceptional situations (arguably,
|
||||
// "programmer error"), so presenting a nice HTML page doesn't help the
|
||||
// user anyway.
|
||||
if (error instanceof OAuthError) {
|
||||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
const serverError = new ServerError('Internal Server Error');
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: Validate other parameters. Any errors here should go into redirect responses.
|
||||
let state;
|
||||
try {
|
||||
// Parse and validate authorization parameters
|
||||
const parseResult = RequestAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query);
|
||||
if (!parseResult.success) {
|
||||
throw new InvalidRequestError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const { scope, code_challenge, resource } = parseResult.data;
|
||||
state = parseResult.data.state;
|
||||
|
||||
// Validate scopes
|
||||
let requestedScopes: string[] = [];
|
||||
if (scope !== undefined) {
|
||||
requestedScopes = scope.split(' ');
|
||||
}
|
||||
|
||||
// All validation passed, proceed with authorization
|
||||
await provider.authorize(
|
||||
client,
|
||||
{
|
||||
state,
|
||||
scopes: requestedScopes,
|
||||
redirectUri: redirect_uri!, // TODO: Someone to look at. Strict tsconfig showed this could be undefined, while the return type is string.
|
||||
codeChallenge: code_challenge,
|
||||
resource: resource ? new URL(resource) : undefined
|
||||
},
|
||||
res
|
||||
);
|
||||
} catch (error) {
|
||||
// Post-redirect errors - redirect with error parameters
|
||||
if (error instanceof OAuthError) {
|
||||
res.redirect(302, createErrorRedirect(redirect_uri!, error, state));
|
||||
} else {
|
||||
const serverError = new ServerError('Internal Server Error');
|
||||
res.redirect(302, createErrorRedirect(redirect_uri!, serverError, state));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create redirect URL with error parameters
|
||||
*/
|
||||
function createErrorRedirect(redirectUri: string, error: OAuthError, state?: string): string {
|
||||
const errorUrl = new URL(redirectUri);
|
||||
errorUrl.searchParams.set('error', error.errorCode);
|
||||
errorUrl.searchParams.set('error_description', error.message);
|
||||
if (error.errorUri) {
|
||||
errorUrl.searchParams.set('error_uri', error.errorUri);
|
||||
}
|
||||
if (state) {
|
||||
errorUrl.searchParams.set('state', state);
|
||||
}
|
||||
return errorUrl.href;
|
||||
}
|
||||
21
packages/server/src/server/auth/handlers/metadata.ts
Normal file
21
packages/server/src/server/auth/handlers/metadata.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/core';
|
||||
import cors from 'cors';
|
||||
import type { RequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
|
||||
import { allowedMethods } from '../middleware/allowedMethods.js';
|
||||
|
||||
export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler {
|
||||
// Nested router so we can configure middleware and restrict HTTP method
|
||||
const router = express.Router();
|
||||
|
||||
// Configure CORS to allow any origin, to make accessible to web-based MCP clients
|
||||
router.use(cors());
|
||||
|
||||
router.use(allowedMethods(['GET', 'OPTIONS']));
|
||||
router.get('/', (req, res) => {
|
||||
res.status(200).json(metadata);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
129
packages/server/src/server/auth/handlers/register.ts
Normal file
129
packages/server/src/server/auth/handlers/register.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import crypto from 'node:crypto';
|
||||
|
||||
import type { OAuthClientInformationFull } from '@modelcontextprotocol/core';
|
||||
import {
|
||||
InvalidClientMetadataError,
|
||||
OAuthClientMetadataSchema,
|
||||
OAuthError,
|
||||
ServerError,
|
||||
TooManyRequestsError
|
||||
} from '@modelcontextprotocol/core';
|
||||
import cors from 'cors';
|
||||
import type { RequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
import type { Options as RateLimitOptions } from 'express-rate-limit';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
|
||||
import type { OAuthRegisteredClientsStore } from '../clients.js';
|
||||
import { allowedMethods } from '../middleware/allowedMethods.js';
|
||||
|
||||
export type ClientRegistrationHandlerOptions = {
|
||||
/**
|
||||
* A store used to save information about dynamically registered OAuth clients.
|
||||
*/
|
||||
clientsStore: OAuthRegisteredClientsStore;
|
||||
|
||||
/**
|
||||
* The number of seconds after which to expire issued client secrets, or 0 to prevent expiration of client secrets (not recommended).
|
||||
*
|
||||
* If not set, defaults to 30 days.
|
||||
*/
|
||||
clientSecretExpirySeconds?: number;
|
||||
|
||||
/**
|
||||
* Rate limiting configuration for the client registration endpoint.
|
||||
* Set to false to disable rate limiting for this endpoint.
|
||||
* Registration endpoints are particularly sensitive to abuse and should be rate limited.
|
||||
*/
|
||||
rateLimit?: Partial<RateLimitOptions> | false;
|
||||
|
||||
/**
|
||||
* Whether to generate a client ID before calling the client registration endpoint.
|
||||
*
|
||||
* If not set, defaults to true.
|
||||
*/
|
||||
clientIdGeneration?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
||||
|
||||
export function clientRegistrationHandler({
|
||||
clientsStore,
|
||||
clientSecretExpirySeconds = DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS,
|
||||
rateLimit: rateLimitConfig,
|
||||
clientIdGeneration = true
|
||||
}: ClientRegistrationHandlerOptions): RequestHandler {
|
||||
if (!clientsStore.registerClient) {
|
||||
throw new Error('Client registration store does not support registering clients');
|
||||
}
|
||||
|
||||
// Nested router so we can configure middleware and restrict HTTP method
|
||||
const router = express.Router();
|
||||
|
||||
// Configure CORS to allow any origin, to make accessible to web-based MCP clients
|
||||
router.use(cors());
|
||||
|
||||
router.use(allowedMethods(['POST']));
|
||||
router.use(express.json());
|
||||
|
||||
// Apply rate limiting unless explicitly disabled - stricter limits for registration
|
||||
if (rateLimitConfig !== false) {
|
||||
router.use(
|
||||
rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 20, // 20 requests per hour - stricter as registration is sensitive
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: new TooManyRequestsError('You have exceeded the rate limit for client registration requests').toResponseObject(),
|
||||
...rateLimitConfig
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
try {
|
||||
const parseResult = OAuthClientMetadataSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new InvalidClientMetadataError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const clientMetadata = parseResult.data;
|
||||
const isPublicClient = clientMetadata.token_endpoint_auth_method === 'none';
|
||||
|
||||
// Generate client credentials
|
||||
const clientSecret = isPublicClient ? undefined : crypto.randomBytes(32).toString('hex');
|
||||
const clientIdIssuedAt = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Calculate client secret expiry time
|
||||
const clientsDoExpire = clientSecretExpirySeconds > 0;
|
||||
const secretExpiryTime = clientsDoExpire ? clientIdIssuedAt + clientSecretExpirySeconds : 0;
|
||||
const clientSecretExpiresAt = isPublicClient ? undefined : secretExpiryTime;
|
||||
|
||||
let clientInfo: Omit<OAuthClientInformationFull, 'client_id'> & { client_id?: string } = {
|
||||
...clientMetadata,
|
||||
client_secret: clientSecret,
|
||||
client_secret_expires_at: clientSecretExpiresAt
|
||||
};
|
||||
|
||||
if (clientIdGeneration) {
|
||||
clientInfo.client_id = crypto.randomUUID();
|
||||
clientInfo.client_id_issued_at = clientIdIssuedAt;
|
||||
}
|
||||
|
||||
clientInfo = await clientsStore.registerClient!(clientInfo);
|
||||
res.status(201).json(clientInfo);
|
||||
} catch (error) {
|
||||
if (error instanceof OAuthError) {
|
||||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
const serverError = new ServerError('Internal Server Error');
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
87
packages/server/src/server/auth/handlers/revoke.ts
Normal file
87
packages/server/src/server/auth/handlers/revoke.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import {
|
||||
InvalidRequestError,
|
||||
OAuthError,
|
||||
OAuthTokenRevocationRequestSchema,
|
||||
ServerError,
|
||||
TooManyRequestsError
|
||||
} from '@modelcontextprotocol/core';
|
||||
import cors from 'cors';
|
||||
import type { RequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
import type { Options as RateLimitOptions } from 'express-rate-limit';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
|
||||
import { allowedMethods } from '../middleware/allowedMethods.js';
|
||||
import { authenticateClient } from '../middleware/clientAuth.js';
|
||||
import type { OAuthServerProvider } from '../provider.js';
|
||||
|
||||
export type RevocationHandlerOptions = {
|
||||
provider: OAuthServerProvider;
|
||||
/**
|
||||
* Rate limiting configuration for the token revocation endpoint.
|
||||
* Set to false to disable rate limiting for this endpoint.
|
||||
*/
|
||||
rateLimit?: Partial<RateLimitOptions> | false;
|
||||
};
|
||||
|
||||
export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler {
|
||||
if (!provider.revokeToken) {
|
||||
throw new Error('Auth provider does not support revoking tokens');
|
||||
}
|
||||
|
||||
// Nested router so we can configure middleware and restrict HTTP method
|
||||
const router = express.Router();
|
||||
|
||||
// Configure CORS to allow any origin, to make accessible to web-based MCP clients
|
||||
router.use(cors());
|
||||
|
||||
router.use(allowedMethods(['POST']));
|
||||
router.use(express.urlencoded({ extended: false }));
|
||||
|
||||
// Apply rate limiting unless explicitly disabled
|
||||
if (rateLimitConfig !== false) {
|
||||
router.use(
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 50, // 50 requests per windowMs
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(),
|
||||
...rateLimitConfig
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticate and extract client details
|
||||
router.use(authenticateClient({ clientsStore: provider.clientsStore }));
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
try {
|
||||
const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new InvalidRequestError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const client = req.client;
|
||||
if (!client) {
|
||||
// This should never happen
|
||||
throw new ServerError('Internal Server Error');
|
||||
}
|
||||
|
||||
await provider.revokeToken!(client, parseResult.data);
|
||||
res.status(200).json({});
|
||||
} catch (error) {
|
||||
if (error instanceof OAuthError) {
|
||||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
const serverError = new ServerError('Internal Server Error');
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
158
packages/server/src/server/auth/handlers/token.ts
Normal file
158
packages/server/src/server/auth/handlers/token.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import {
|
||||
InvalidGrantError,
|
||||
InvalidRequestError,
|
||||
OAuthError,
|
||||
ServerError,
|
||||
TooManyRequestsError,
|
||||
UnsupportedGrantTypeError
|
||||
} from '@modelcontextprotocol/core';
|
||||
import cors from 'cors';
|
||||
import type { RequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
import type { Options as RateLimitOptions } from 'express-rate-limit';
|
||||
import { rateLimit } from 'express-rate-limit';
|
||||
import { verifyChallenge } from 'pkce-challenge';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
import { allowedMethods } from '../middleware/allowedMethods.js';
|
||||
import { authenticateClient } from '../middleware/clientAuth.js';
|
||||
import type { OAuthServerProvider } from '../provider.js';
|
||||
|
||||
export type TokenHandlerOptions = {
|
||||
provider: OAuthServerProvider;
|
||||
/**
|
||||
* Rate limiting configuration for the token endpoint.
|
||||
* Set to false to disable rate limiting for this endpoint.
|
||||
*/
|
||||
rateLimit?: Partial<RateLimitOptions> | false;
|
||||
};
|
||||
|
||||
const TokenRequestSchema = z.object({
|
||||
grant_type: z.string()
|
||||
});
|
||||
|
||||
const AuthorizationCodeGrantSchema = z.object({
|
||||
code: z.string(),
|
||||
code_verifier: z.string(),
|
||||
redirect_uri: z.string().optional(),
|
||||
resource: z.string().url().optional()
|
||||
});
|
||||
|
||||
const RefreshTokenGrantSchema = z.object({
|
||||
refresh_token: z.string(),
|
||||
scope: z.string().optional(),
|
||||
resource: z.string().url().optional()
|
||||
});
|
||||
|
||||
export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler {
|
||||
// Nested router so we can configure middleware and restrict HTTP method
|
||||
const router = express.Router();
|
||||
|
||||
// Configure CORS to allow any origin, to make accessible to web-based MCP clients
|
||||
router.use(cors());
|
||||
|
||||
router.use(allowedMethods(['POST']));
|
||||
router.use(express.urlencoded({ extended: false }));
|
||||
|
||||
// Apply rate limiting unless explicitly disabled
|
||||
if (rateLimitConfig !== false) {
|
||||
router.use(
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 50, // 50 requests per windowMs
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: new TooManyRequestsError('You have exceeded the rate limit for token requests').toResponseObject(),
|
||||
...rateLimitConfig
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticate and extract client details
|
||||
router.use(authenticateClient({ clientsStore: provider.clientsStore }));
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
try {
|
||||
const parseResult = TokenRequestSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new InvalidRequestError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const { grant_type } = parseResult.data;
|
||||
|
||||
const client = req.client;
|
||||
if (!client) {
|
||||
// This should never happen
|
||||
throw new ServerError('Internal Server Error');
|
||||
}
|
||||
|
||||
switch (grant_type) {
|
||||
case 'authorization_code': {
|
||||
const parseResult = AuthorizationCodeGrantSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new InvalidRequestError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const { code, code_verifier, redirect_uri, resource } = parseResult.data;
|
||||
|
||||
const skipLocalPkceValidation = provider.skipLocalPkceValidation;
|
||||
|
||||
// Perform local PKCE validation unless explicitly skipped
|
||||
// (e.g. to validate code_verifier in upstream server)
|
||||
if (!skipLocalPkceValidation) {
|
||||
const codeChallenge = await provider.challengeForAuthorizationCode(client, code);
|
||||
if (!(await verifyChallenge(code_verifier, codeChallenge))) {
|
||||
throw new InvalidGrantError('code_verifier does not match the challenge');
|
||||
}
|
||||
}
|
||||
|
||||
// Passes the code_verifier to the provider if PKCE validation didn't occur locally
|
||||
const tokens = await provider.exchangeAuthorizationCode(
|
||||
client,
|
||||
code,
|
||||
skipLocalPkceValidation ? code_verifier : undefined,
|
||||
redirect_uri,
|
||||
resource ? new URL(resource) : undefined
|
||||
);
|
||||
res.status(200).json(tokens);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'refresh_token': {
|
||||
const parseResult = RefreshTokenGrantSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new InvalidRequestError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const { refresh_token, scope, resource } = parseResult.data;
|
||||
|
||||
const scopes = scope?.split(' ');
|
||||
const tokens = await provider.exchangeRefreshToken(
|
||||
client,
|
||||
refresh_token,
|
||||
scopes,
|
||||
resource ? new URL(resource) : undefined
|
||||
);
|
||||
res.status(200).json(tokens);
|
||||
break;
|
||||
}
|
||||
// Additional auth methods will not be added on the server side of the SDK.
|
||||
case 'client_credentials':
|
||||
default:
|
||||
throw new UnsupportedGrantTypeError('The grant type is not supported by this authorization server.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof OAuthError) {
|
||||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
const serverError = new ServerError('Internal Server Error');
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
12
packages/server/src/server/auth/index.ts
Normal file
12
packages/server/src/server/auth/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export * from './clients.js';
|
||||
export * from './handlers/authorize.js';
|
||||
export * from './handlers/metadata.js';
|
||||
export * from './handlers/register.js';
|
||||
export * from './handlers/revoke.js';
|
||||
export * from './handlers/token.js';
|
||||
export * from './middleware/allowedMethods.js';
|
||||
export * from './middleware/bearerAuth.js';
|
||||
export * from './middleware/clientAuth.js';
|
||||
export * from './provider.js';
|
||||
export * from './providers/proxyProvider.js';
|
||||
export * from './router.js';
|
||||
20
packages/server/src/server/auth/middleware/allowedMethods.ts
Normal file
20
packages/server/src/server/auth/middleware/allowedMethods.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { MethodNotAllowedError } from '@modelcontextprotocol/core';
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
/**
|
||||
* Middleware to handle unsupported HTTP methods with a 405 Method Not Allowed response.
|
||||
*
|
||||
* @param allowedMethods Array of allowed HTTP methods for this endpoint (e.g., ['GET', 'POST'])
|
||||
* @returns Express middleware that returns a 405 error if method not in allowed list
|
||||
*/
|
||||
export function allowedMethods(allowedMethods: string[]): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
if (allowedMethods.includes(req.method)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const error = new MethodNotAllowedError(`The method ${req.method} is not allowed for this endpoint`);
|
||||
res.status(405).set('Allow', allowedMethods.join(', ')).json(error.toResponseObject());
|
||||
};
|
||||
}
|
||||
103
packages/server/src/server/auth/middleware/bearerAuth.ts
Normal file
103
packages/server/src/server/auth/middleware/bearerAuth.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import type { AuthInfo } from '@modelcontextprotocol/core';
|
||||
import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from '@modelcontextprotocol/core';
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
import type { OAuthTokenVerifier } from '../provider.js';
|
||||
|
||||
export type BearerAuthMiddlewareOptions = {
|
||||
/**
|
||||
* A provider used to verify tokens.
|
||||
*/
|
||||
verifier: OAuthTokenVerifier;
|
||||
|
||||
/**
|
||||
* Optional scopes that the token must have.
|
||||
*/
|
||||
requiredScopes?: string[];
|
||||
|
||||
/**
|
||||
* Optional resource metadata URL to include in WWW-Authenticate header.
|
||||
*/
|
||||
resourceMetadataUrl?: string;
|
||||
};
|
||||
|
||||
declare module 'express-serve-static-core' {
|
||||
interface Request {
|
||||
/**
|
||||
* Information about the validated access token, if the `requireBearerAuth` middleware was used.
|
||||
*/
|
||||
auth?: AuthInfo;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware that requires a valid Bearer token in the Authorization header.
|
||||
*
|
||||
* This will validate the token with the auth provider and add the resulting auth info to the request object.
|
||||
*
|
||||
* If resourceMetadataUrl is provided, it will be included in the WWW-Authenticate header
|
||||
* for 401 responses as per the OAuth 2.0 Protected Resource Metadata spec.
|
||||
*/
|
||||
export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) {
|
||||
throw new InvalidTokenError('Missing Authorization header');
|
||||
}
|
||||
|
||||
const [type, token] = authHeader.split(' ');
|
||||
if (type!.toLowerCase() !== 'bearer' || !token) {
|
||||
throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'");
|
||||
}
|
||||
|
||||
const authInfo = await verifier.verifyAccessToken(token);
|
||||
|
||||
// Check if token has the required scopes (if any)
|
||||
if (requiredScopes.length > 0) {
|
||||
const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope));
|
||||
|
||||
if (!hasAllScopes) {
|
||||
throw new InsufficientScopeError('Insufficient scope');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the token is set to expire or if it is expired
|
||||
if (typeof authInfo.expiresAt !== 'number' || isNaN(authInfo.expiresAt)) {
|
||||
throw new InvalidTokenError('Token has no expiration time');
|
||||
} else if (authInfo.expiresAt < Date.now() / 1000) {
|
||||
throw new InvalidTokenError('Token has expired');
|
||||
}
|
||||
|
||||
req.auth = authInfo;
|
||||
next();
|
||||
} catch (error) {
|
||||
// Build WWW-Authenticate header parts
|
||||
const buildWwwAuthHeader = (errorCode: string, message: string): string => {
|
||||
let header = `Bearer error="${errorCode}", error_description="${message}"`;
|
||||
if (requiredScopes.length > 0) {
|
||||
header += `, scope="${requiredScopes.join(' ')}"`;
|
||||
}
|
||||
if (resourceMetadataUrl) {
|
||||
header += `, resource_metadata="${resourceMetadataUrl}"`;
|
||||
}
|
||||
return header;
|
||||
};
|
||||
|
||||
if (error instanceof InvalidTokenError) {
|
||||
res.set('WWW-Authenticate', buildWwwAuthHeader(error.errorCode, error.message));
|
||||
res.status(401).json(error.toResponseObject());
|
||||
} else if (error instanceof InsufficientScopeError) {
|
||||
res.set('WWW-Authenticate', buildWwwAuthHeader(error.errorCode, error.message));
|
||||
res.status(403).json(error.toResponseObject());
|
||||
} else if (error instanceof ServerError) {
|
||||
res.status(500).json(error.toResponseObject());
|
||||
} else if (error instanceof OAuthError) {
|
||||
res.status(400).json(error.toResponseObject());
|
||||
} else {
|
||||
const serverError = new ServerError('Internal Server Error');
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
65
packages/server/src/server/auth/middleware/clientAuth.ts
Normal file
65
packages/server/src/server/auth/middleware/clientAuth.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { OAuthClientInformationFull } from '@modelcontextprotocol/core';
|
||||
import { InvalidClientError, InvalidRequestError, OAuthError, ServerError } from '@modelcontextprotocol/core';
|
||||
import type { RequestHandler } from 'express';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
import type { OAuthRegisteredClientsStore } from '../clients.js';
|
||||
|
||||
export type ClientAuthenticationMiddlewareOptions = {
|
||||
/**
|
||||
* A store used to read information about registered OAuth clients.
|
||||
*/
|
||||
clientsStore: OAuthRegisteredClientsStore;
|
||||
};
|
||||
|
||||
const ClientAuthenticatedRequestSchema = z.object({
|
||||
client_id: z.string(),
|
||||
client_secret: z.string().optional()
|
||||
});
|
||||
|
||||
declare module 'express-serve-static-core' {
|
||||
interface Request {
|
||||
/**
|
||||
* The authenticated client for this request, if the `authenticateClient` middleware was used.
|
||||
*/
|
||||
client?: OAuthClientInformationFull;
|
||||
}
|
||||
}
|
||||
|
||||
export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlewareOptions): RequestHandler {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const result = ClientAuthenticatedRequestSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
throw new InvalidRequestError(String(result.error));
|
||||
}
|
||||
const { client_id, client_secret } = result.data;
|
||||
const client = await clientsStore.getClient(client_id);
|
||||
if (!client) {
|
||||
throw new InvalidClientError('Invalid client_id');
|
||||
}
|
||||
if (client.client_secret) {
|
||||
if (!client_secret) {
|
||||
throw new InvalidClientError('Client secret is required');
|
||||
}
|
||||
if (client.client_secret !== client_secret) {
|
||||
throw new InvalidClientError('Invalid client_secret');
|
||||
}
|
||||
if (client.client_secret_expires_at && client.client_secret_expires_at < Math.floor(Date.now() / 1000)) {
|
||||
throw new InvalidClientError('Client secret has expired');
|
||||
}
|
||||
}
|
||||
|
||||
req.client = client;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof OAuthError) {
|
||||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
const serverError = new ServerError('Internal Server Error');
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
83
packages/server/src/server/auth/provider.ts
Normal file
83
packages/server/src/server/auth/provider.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { AuthInfo, OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import type { OAuthRegisteredClientsStore } from './clients.js';
|
||||
|
||||
export type AuthorizationParams = {
|
||||
state?: string;
|
||||
scopes?: string[];
|
||||
codeChallenge: string;
|
||||
redirectUri: string;
|
||||
resource?: URL;
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements an end-to-end OAuth server.
|
||||
*/
|
||||
export interface OAuthServerProvider {
|
||||
/**
|
||||
* A store used to read information about registered OAuth clients.
|
||||
*/
|
||||
get clientsStore(): OAuthRegisteredClientsStore;
|
||||
|
||||
/**
|
||||
* Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server.
|
||||
*
|
||||
* This server must eventually issue a redirect with an authorization response or an error response to the given redirect URI. Per OAuth 2.1:
|
||||
* - In the successful case, the redirect MUST include the `code` and `state` (if present) query parameters.
|
||||
* - In the error case, the redirect MUST include the `error` query parameter, and MAY include an optional `error_description` query parameter.
|
||||
*/
|
||||
authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void>;
|
||||
|
||||
/**
|
||||
* Returns the `codeChallenge` that was used when the indicated authorization began.
|
||||
*/
|
||||
challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Exchanges an authorization code for an access token.
|
||||
*/
|
||||
exchangeAuthorizationCode(
|
||||
client: OAuthClientInformationFull,
|
||||
authorizationCode: string,
|
||||
codeVerifier?: string,
|
||||
redirectUri?: string,
|
||||
resource?: URL
|
||||
): Promise<OAuthTokens>;
|
||||
|
||||
/**
|
||||
* Exchanges a refresh token for an access token.
|
||||
*/
|
||||
exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise<OAuthTokens>;
|
||||
|
||||
/**
|
||||
* Verifies an access token and returns information about it.
|
||||
*/
|
||||
verifyAccessToken(token: string): Promise<AuthInfo>;
|
||||
|
||||
/**
|
||||
* Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended).
|
||||
*
|
||||
* If the given token is invalid or already revoked, this method should do nothing.
|
||||
*/
|
||||
revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void>;
|
||||
|
||||
/**
|
||||
* Whether to skip local PKCE validation.
|
||||
*
|
||||
* If true, the server will not perform PKCE validation locally and will pass the code_verifier to the upstream server.
|
||||
*
|
||||
* NOTE: This should only be true if the upstream server is performing the actual PKCE validation.
|
||||
*/
|
||||
skipLocalPkceValidation?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim implementation useful for token verification
|
||||
*/
|
||||
export interface OAuthTokenVerifier {
|
||||
/**
|
||||
* Verifies an access token and returns information about it.
|
||||
*/
|
||||
verifyAccessToken(token: string): Promise<AuthInfo>;
|
||||
}
|
||||
231
packages/server/src/server/auth/providers/proxyProvider.ts
Normal file
231
packages/server/src/server/auth/providers/proxyProvider.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import type { AuthInfo, FetchLike, OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core';
|
||||
import { OAuthClientInformationFullSchema, OAuthTokensSchema, ServerError } from '@modelcontextprotocol/core';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import type { OAuthRegisteredClientsStore } from '../clients.js';
|
||||
import type { AuthorizationParams, OAuthServerProvider } from '../provider.js';
|
||||
|
||||
export type ProxyEndpoints = {
|
||||
authorizationUrl: string;
|
||||
tokenUrl: string;
|
||||
revocationUrl?: string;
|
||||
registrationUrl?: string;
|
||||
};
|
||||
|
||||
export type ProxyOptions = {
|
||||
/**
|
||||
* Individual endpoint URLs for proxying specific OAuth operations
|
||||
*/
|
||||
endpoints: ProxyEndpoints;
|
||||
|
||||
/**
|
||||
* Function to verify access tokens and return auth info
|
||||
*/
|
||||
verifyAccessToken: (token: string) => Promise<AuthInfo>;
|
||||
|
||||
/**
|
||||
* Function to fetch client information from the upstream server
|
||||
*/
|
||||
getClient: (clientId: string) => Promise<OAuthClientInformationFull | undefined>;
|
||||
|
||||
/**
|
||||
* Custom fetch implementation used for all network requests.
|
||||
*/
|
||||
fetch?: FetchLike;
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements an OAuth server that proxies requests to another OAuth server.
|
||||
*/
|
||||
export class ProxyOAuthServerProvider implements OAuthServerProvider {
|
||||
protected readonly _endpoints: ProxyEndpoints;
|
||||
protected readonly _verifyAccessToken: (token: string) => Promise<AuthInfo>;
|
||||
protected readonly _getClient: (clientId: string) => Promise<OAuthClientInformationFull | undefined>;
|
||||
protected readonly _fetch?: FetchLike;
|
||||
|
||||
skipLocalPkceValidation = true;
|
||||
|
||||
revokeToken?: (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => Promise<void>;
|
||||
|
||||
constructor(options: ProxyOptions) {
|
||||
this._endpoints = options.endpoints;
|
||||
this._verifyAccessToken = options.verifyAccessToken;
|
||||
this._getClient = options.getClient;
|
||||
this._fetch = options.fetch;
|
||||
if (options.endpoints?.revocationUrl) {
|
||||
this.revokeToken = async (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => {
|
||||
const revocationUrl = this._endpoints.revocationUrl;
|
||||
|
||||
if (!revocationUrl) {
|
||||
throw new Error('No revocation endpoint configured');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('token', request.token);
|
||||
params.set('client_id', client.client_id);
|
||||
if (client.client_secret) {
|
||||
params.set('client_secret', client.client_secret);
|
||||
}
|
||||
if (request.token_type_hint) {
|
||||
params.set('token_type_hint', request.token_type_hint);
|
||||
}
|
||||
|
||||
const response = await (this._fetch ?? fetch)(revocationUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: params.toString()
|
||||
});
|
||||
await response.body?.cancel();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ServerError(`Token revocation failed: ${response.status}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
get clientsStore(): OAuthRegisteredClientsStore {
|
||||
const registrationUrl = this._endpoints.registrationUrl;
|
||||
return {
|
||||
getClient: this._getClient,
|
||||
...(registrationUrl && {
|
||||
registerClient: async (client: OAuthClientInformationFull) => {
|
||||
const response = await (this._fetch ?? fetch)(registrationUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(client)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new ServerError(`Client registration failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return OAuthClientInformationFullSchema.parse(data);
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||
// Start with required OAuth parameters
|
||||
const targetUrl = new URL(this._endpoints.authorizationUrl);
|
||||
const searchParams = new URLSearchParams({
|
||||
client_id: client.client_id,
|
||||
response_type: 'code',
|
||||
redirect_uri: params.redirectUri,
|
||||
code_challenge: params.codeChallenge,
|
||||
code_challenge_method: 'S256'
|
||||
});
|
||||
|
||||
// Add optional standard OAuth parameters
|
||||
if (params.state) searchParams.set('state', params.state);
|
||||
if (params.scopes?.length) searchParams.set('scope', params.scopes.join(' '));
|
||||
if (params.resource) searchParams.set('resource', params.resource.href);
|
||||
|
||||
targetUrl.search = searchParams.toString();
|
||||
res.redirect(targetUrl.toString());
|
||||
}
|
||||
|
||||
async challengeForAuthorizationCode(_client: OAuthClientInformationFull, _authorizationCode: string): Promise<string> {
|
||||
// In a proxy setup, we don't store the code challenge ourselves
|
||||
// Instead, we proxy the token request and let the upstream server validate it
|
||||
return '';
|
||||
}
|
||||
|
||||
async exchangeAuthorizationCode(
|
||||
client: OAuthClientInformationFull,
|
||||
authorizationCode: string,
|
||||
codeVerifier?: string,
|
||||
redirectUri?: string,
|
||||
resource?: URL
|
||||
): Promise<OAuthTokens> {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: client.client_id,
|
||||
code: authorizationCode
|
||||
});
|
||||
|
||||
if (client.client_secret) {
|
||||
params.append('client_secret', client.client_secret);
|
||||
}
|
||||
|
||||
if (codeVerifier) {
|
||||
params.append('code_verifier', codeVerifier);
|
||||
}
|
||||
|
||||
if (redirectUri) {
|
||||
params.append('redirect_uri', redirectUri);
|
||||
}
|
||||
|
||||
if (resource) {
|
||||
params.append('resource', resource.href);
|
||||
}
|
||||
|
||||
const response = await (this._fetch ?? fetch)(this._endpoints.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: params.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new ServerError(`Token exchange failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return OAuthTokensSchema.parse(data);
|
||||
}
|
||||
|
||||
async exchangeRefreshToken(
|
||||
client: OAuthClientInformationFull,
|
||||
refreshToken: string,
|
||||
scopes?: string[],
|
||||
resource?: URL
|
||||
): Promise<OAuthTokens> {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: client.client_id,
|
||||
refresh_token: refreshToken
|
||||
});
|
||||
|
||||
if (client.client_secret) {
|
||||
params.set('client_secret', client.client_secret);
|
||||
}
|
||||
|
||||
if (scopes?.length) {
|
||||
params.set('scope', scopes.join(' '));
|
||||
}
|
||||
|
||||
if (resource) {
|
||||
params.set('resource', resource.href);
|
||||
}
|
||||
|
||||
const response = await (this._fetch ?? fetch)(this._endpoints.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: params.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new ServerError(`Token refresh failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return OAuthTokensSchema.parse(data);
|
||||
}
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
return this._verifyAccessToken(token);
|
||||
}
|
||||
}
|
||||
246
packages/server/src/server/auth/router.ts
Normal file
246
packages/server/src/server/auth/router.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/core';
|
||||
import type { RequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
|
||||
import type { AuthorizationHandlerOptions } from './handlers/authorize.js';
|
||||
import { authorizationHandler } from './handlers/authorize.js';
|
||||
import { metadataHandler } from './handlers/metadata.js';
|
||||
import type { ClientRegistrationHandlerOptions } from './handlers/register.js';
|
||||
import { clientRegistrationHandler } from './handlers/register.js';
|
||||
import type { RevocationHandlerOptions } from './handlers/revoke.js';
|
||||
import { revocationHandler } from './handlers/revoke.js';
|
||||
import type { TokenHandlerOptions } from './handlers/token.js';
|
||||
import { tokenHandler } from './handlers/token.js';
|
||||
import type { OAuthServerProvider } from './provider.js';
|
||||
|
||||
// Check for dev mode flag that allows HTTP issuer URLs (for development/testing only)
|
||||
const allowInsecureIssuerUrl =
|
||||
process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === 'true' || process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === '1';
|
||||
if (allowInsecureIssuerUrl) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL is enabled - HTTP issuer URLs are allowed. Do not use in production.');
|
||||
}
|
||||
|
||||
export type AuthRouterOptions = {
|
||||
/**
|
||||
* A provider implementing the actual authorization logic for this router.
|
||||
*/
|
||||
provider: OAuthServerProvider;
|
||||
|
||||
/**
|
||||
* The authorization server's issuer identifier, which is a URL that uses the "https" scheme and has no query or fragment components.
|
||||
*/
|
||||
issuerUrl: URL;
|
||||
|
||||
/**
|
||||
* The base URL of the authorization server to use for the metadata endpoints.
|
||||
*
|
||||
* If not provided, the issuer URL will be used as the base URL.
|
||||
*/
|
||||
baseUrl?: URL;
|
||||
|
||||
/**
|
||||
* An optional URL of a page containing human-readable information that developers might want or need to know when using the authorization server.
|
||||
*/
|
||||
serviceDocumentationUrl?: URL;
|
||||
|
||||
/**
|
||||
* An optional list of scopes supported by this authorization server
|
||||
*/
|
||||
scopesSupported?: string[];
|
||||
|
||||
/**
|
||||
* The resource name to be displayed in protected resource metadata
|
||||
*/
|
||||
resourceName?: string;
|
||||
|
||||
/**
|
||||
* The URL of the protected resource (RS) whose metadata we advertise.
|
||||
* If not provided, falls back to `baseUrl` and then to `issuerUrl` (AS=RS).
|
||||
*/
|
||||
resourceServerUrl?: URL;
|
||||
|
||||
// Individual options per route
|
||||
authorizationOptions?: Omit<AuthorizationHandlerOptions, 'provider'>;
|
||||
clientRegistrationOptions?: Omit<ClientRegistrationHandlerOptions, 'clientsStore'>;
|
||||
revocationOptions?: Omit<RevocationHandlerOptions, 'provider'>;
|
||||
tokenOptions?: Omit<TokenHandlerOptions, 'provider'>;
|
||||
};
|
||||
|
||||
const checkIssuerUrl = (issuer: URL): void => {
|
||||
// Technically RFC 8414 does not permit a localhost HTTPS exemption, but this will be necessary for ease of testing
|
||||
if (issuer.protocol !== 'https:' && issuer.hostname !== 'localhost' && issuer.hostname !== '127.0.0.1' && !allowInsecureIssuerUrl) {
|
||||
throw new Error('Issuer URL must be HTTPS');
|
||||
}
|
||||
if (issuer.hash) {
|
||||
throw new Error(`Issuer URL must not have a fragment: ${issuer}`);
|
||||
}
|
||||
if (issuer.search) {
|
||||
throw new Error(`Issuer URL must not have a query string: ${issuer}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const createOAuthMetadata = (options: {
|
||||
provider: OAuthServerProvider;
|
||||
issuerUrl: URL;
|
||||
baseUrl?: URL;
|
||||
serviceDocumentationUrl?: URL;
|
||||
scopesSupported?: string[];
|
||||
}): OAuthMetadata => {
|
||||
const issuer = options.issuerUrl;
|
||||
const baseUrl = options.baseUrl;
|
||||
|
||||
checkIssuerUrl(issuer);
|
||||
|
||||
const authorization_endpoint = '/authorize';
|
||||
const token_endpoint = '/token';
|
||||
const registration_endpoint = options.provider.clientsStore.registerClient ? '/register' : undefined;
|
||||
const revocation_endpoint = options.provider.revokeToken ? '/revoke' : undefined;
|
||||
|
||||
const metadata: OAuthMetadata = {
|
||||
issuer: issuer.href,
|
||||
service_documentation: options.serviceDocumentationUrl?.href,
|
||||
|
||||
authorization_endpoint: new URL(authorization_endpoint, baseUrl || issuer).href,
|
||||
response_types_supported: ['code'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
|
||||
token_endpoint: new URL(token_endpoint, baseUrl || issuer).href,
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
|
||||
scopes_supported: options.scopesSupported,
|
||||
|
||||
revocation_endpoint: revocation_endpoint ? new URL(revocation_endpoint, baseUrl || issuer).href : undefined,
|
||||
revocation_endpoint_auth_methods_supported: revocation_endpoint ? ['client_secret_post'] : undefined,
|
||||
|
||||
registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined
|
||||
};
|
||||
|
||||
return metadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Installs standard MCP authorization server endpoints, including dynamic client registration and token revocation (if supported).
|
||||
* Also advertises standard authorization server metadata, for easier discovery of supported configurations by clients.
|
||||
* Note: if your MCP server is only a resource server and not an authorization server, use mcpAuthMetadataRouter instead.
|
||||
*
|
||||
* By default, rate limiting is applied to all endpoints to prevent abuse.
|
||||
*
|
||||
* This router MUST be installed at the application root, like so:
|
||||
*
|
||||
* const app = express();
|
||||
* app.use(mcpAuthRouter(...));
|
||||
*/
|
||||
export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler {
|
||||
const oauthMetadata = createOAuthMetadata(options);
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(
|
||||
new URL(oauthMetadata.authorization_endpoint).pathname,
|
||||
authorizationHandler({ provider: options.provider, ...options.authorizationOptions })
|
||||
);
|
||||
|
||||
router.use(new URL(oauthMetadata.token_endpoint).pathname, tokenHandler({ provider: options.provider, ...options.tokenOptions }));
|
||||
|
||||
router.use(
|
||||
mcpAuthMetadataRouter({
|
||||
oauthMetadata,
|
||||
// Prefer explicit RS; otherwise fall back to AS baseUrl, then to issuer (back-compat)
|
||||
resourceServerUrl: options.resourceServerUrl ?? options.baseUrl ?? new URL(oauthMetadata.issuer),
|
||||
serviceDocumentationUrl: options.serviceDocumentationUrl,
|
||||
scopesSupported: options.scopesSupported,
|
||||
resourceName: options.resourceName
|
||||
})
|
||||
);
|
||||
|
||||
if (oauthMetadata.registration_endpoint) {
|
||||
router.use(
|
||||
new URL(oauthMetadata.registration_endpoint).pathname,
|
||||
clientRegistrationHandler({
|
||||
clientsStore: options.provider.clientsStore,
|
||||
...options.clientRegistrationOptions
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (oauthMetadata.revocation_endpoint) {
|
||||
router.use(
|
||||
new URL(oauthMetadata.revocation_endpoint).pathname,
|
||||
revocationHandler({ provider: options.provider, ...options.revocationOptions })
|
||||
);
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export type AuthMetadataOptions = {
|
||||
/**
|
||||
* OAuth Metadata as would be returned from the authorization server
|
||||
* this MCP server relies on
|
||||
*/
|
||||
oauthMetadata: OAuthMetadata;
|
||||
|
||||
/**
|
||||
* The url of the MCP server, for use in protected resource metadata
|
||||
*/
|
||||
resourceServerUrl: URL;
|
||||
|
||||
/**
|
||||
* The url for documentation for the MCP server
|
||||
*/
|
||||
serviceDocumentationUrl?: URL;
|
||||
|
||||
/**
|
||||
* An optional list of scopes supported by this MCP server
|
||||
*/
|
||||
scopesSupported?: string[];
|
||||
|
||||
/**
|
||||
* An optional resource name to display in resource metadata
|
||||
*/
|
||||
resourceName?: string;
|
||||
};
|
||||
|
||||
export function mcpAuthMetadataRouter(options: AuthMetadataOptions): express.Router {
|
||||
checkIssuerUrl(new URL(options.oauthMetadata.issuer));
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const protectedResourceMetadata: OAuthProtectedResourceMetadata = {
|
||||
resource: options.resourceServerUrl.href,
|
||||
|
||||
authorization_servers: [options.oauthMetadata.issuer],
|
||||
|
||||
scopes_supported: options.scopesSupported,
|
||||
resource_name: options.resourceName,
|
||||
resource_documentation: options.serviceDocumentationUrl?.href
|
||||
};
|
||||
|
||||
// Serve PRM at the path-specific URL per RFC 9728
|
||||
const rsPath = new URL(options.resourceServerUrl.href).pathname;
|
||||
router.use(`/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`, metadataHandler(protectedResourceMetadata));
|
||||
|
||||
// Always add this for OAuth Authorization Server metadata per RFC 8414
|
||||
router.use('/.well-known/oauth-authorization-server', metadataHandler(options.oauthMetadata));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to construct the OAuth 2.0 Protected Resource Metadata URL
|
||||
* from a given server URL. This replaces the path with the standard metadata endpoint.
|
||||
*
|
||||
* @param serverUrl - The base URL of the protected resource server
|
||||
* @returns The URL for the OAuth protected resource metadata endpoint
|
||||
*
|
||||
* @example
|
||||
* getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp'))
|
||||
* // Returns: 'https://api.example.com/.well-known/oauth-protected-resource/mcp'
|
||||
*/
|
||||
export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string {
|
||||
const u = new URL(serverUrl.href);
|
||||
const rsPath = u.pathname && u.pathname !== '/' ? u.pathname : '';
|
||||
return new URL(`/.well-known/oauth-protected-resource${rsPath}`, u).href;
|
||||
}
|
||||
67
packages/server/src/server/completable.ts
Normal file
67
packages/server/src/server/completable.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type { AnySchema, SchemaInput } from '@modelcontextprotocol/core';
|
||||
|
||||
export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable');
|
||||
|
||||
export type CompleteCallback<T extends AnySchema = AnySchema> = (
|
||||
value: SchemaInput<T>,
|
||||
context?: {
|
||||
arguments?: Record<string, string>;
|
||||
}
|
||||
) => SchemaInput<T>[] | Promise<SchemaInput<T>[]>;
|
||||
|
||||
export type CompletableMeta<T extends AnySchema = AnySchema> = {
|
||||
complete: CompleteCallback<T>;
|
||||
};
|
||||
|
||||
export type CompletableSchema<T extends AnySchema> = T & {
|
||||
[COMPLETABLE_SYMBOL]: CompletableMeta<T>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps a Zod type to provide autocompletion capabilities. Useful for, e.g., prompt arguments in MCP.
|
||||
* Works with both Zod v3 and v4 schemas.
|
||||
*/
|
||||
export function completable<T extends AnySchema>(schema: T, complete: CompleteCallback<T>): CompletableSchema<T> {
|
||||
Object.defineProperty(schema as object, COMPLETABLE_SYMBOL, {
|
||||
value: { complete } as CompletableMeta<T>,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
configurable: false
|
||||
});
|
||||
return schema as CompletableSchema<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a schema is completable (has completion metadata).
|
||||
*/
|
||||
export function isCompletable(schema: unknown): schema is CompletableSchema<AnySchema> {
|
||||
return !!schema && typeof schema === 'object' && COMPLETABLE_SYMBOL in (schema as object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the completer callback from a completable schema, if it exists.
|
||||
*/
|
||||
export function getCompleter<T extends AnySchema>(schema: T): CompleteCallback<T> | undefined {
|
||||
const meta = (schema as unknown as { [COMPLETABLE_SYMBOL]?: CompletableMeta<T> })[COMPLETABLE_SYMBOL];
|
||||
return meta?.complete as CompleteCallback<T> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps a completable schema to get the underlying schema.
|
||||
* For backward compatibility with code that called `.unwrap()`.
|
||||
*/
|
||||
export function unwrapCompletable<T extends AnySchema>(schema: CompletableSchema<T>): T {
|
||||
return schema;
|
||||
}
|
||||
|
||||
// Legacy exports for backward compatibility
|
||||
// These types are deprecated but kept for existing code
|
||||
export enum McpZodTypeKind {
|
||||
Completable = 'McpCompletable'
|
||||
}
|
||||
|
||||
export interface CompletableDef<T extends AnySchema = AnySchema> {
|
||||
type: T;
|
||||
complete: CompleteCallback<T>;
|
||||
typeName: McpZodTypeKind.Completable;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue