[v2] Decouple server from express and hono - http framework-agnostic MCP server (#1326)
This commit is contained in:
parent
5ce4b5ef08
commit
f495077e08
34 changed files with 8193 additions and 15757 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/server",
|
||||
"version": "2.0.0-alpha.0",
|
||||
"description": "Model Context Protocol implementation for TypeScript",
|
||||
"description": "Model Context Protocol implementation for TypeScript - Server package",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
"homepage": "https://modelcontextprotocol.io",
|
||||
|
|
@ -18,7 +18,8 @@
|
|||
"packageManager": "pnpm@10.24.0",
|
||||
"keywords": [
|
||||
"modelcontextprotocol",
|
||||
"mcp"
|
||||
"mcp",
|
||||
"server"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
|
|
@ -44,14 +45,6 @@
|
|||
"client": "tsx scripts/cli.ts client"
|
||||
},
|
||||
"dependencies": {
|
||||
"content-type": "catalog:runtimeServerOnly",
|
||||
"cors": "catalog:runtimeServerOnly",
|
||||
"@hono/node-server": "catalog:runtimeServerOnly",
|
||||
"hono": "catalog:runtimeServerOnly",
|
||||
"express": "catalog:runtimeServerOnly",
|
||||
"express-rate-limit": "catalog:runtimeServerOnly",
|
||||
"raw-body": "catalog:runtimeServerOnly",
|
||||
"pkce-challenge": "catalog:runtimeShared",
|
||||
"zod": "catalog:runtimeShared",
|
||||
"zod-to-json-schema": "catalog:runtimeShared"
|
||||
},
|
||||
|
|
@ -68,20 +61,15 @@
|
|||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/core": "workspace:^",
|
||||
"@modelcontextprotocol/tsconfig": "workspace:^",
|
||||
"@modelcontextprotocol/vitest-config": "workspace:^",
|
||||
"@modelcontextprotocol/eslint-config": "workspace:^",
|
||||
"@modelcontextprotocol/test-helpers": "workspace:^",
|
||||
"@cfworker/json-schema": "catalog:runtimeShared",
|
||||
"@eslint/js": "catalog:devTools",
|
||||
"@types/content-type": "catalog:devTools",
|
||||
"@types/cors": "catalog:devTools",
|
||||
"@modelcontextprotocol/core": "workspace:^",
|
||||
"@modelcontextprotocol/eslint-config": "workspace:^",
|
||||
"@modelcontextprotocol/test-helpers": "workspace:^",
|
||||
"@modelcontextprotocol/tsconfig": "workspace:^",
|
||||
"@modelcontextprotocol/vitest-config": "workspace:^",
|
||||
"@types/cross-spawn": "catalog:devTools",
|
||||
"@types/eventsource": "catalog:devTools",
|
||||
"@types/express": "catalog:devTools",
|
||||
"@types/express-serve-static-core": "catalog:devTools",
|
||||
"@types/supertest": "catalog:devTools",
|
||||
"@typescript/native-preview": "catalog:devTools",
|
||||
"eslint": "catalog:devTools",
|
||||
"eslint-config-prettier": "catalog:devTools",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
export * from './server/completable.js';
|
||||
export * from './server/express.js';
|
||||
export * from './server/mcp.js';
|
||||
export * from './server/middleware/hostHeaderValidation.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';
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
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>;
|
||||
}
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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';
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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());
|
||||
};
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
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>;
|
||||
}
|
||||
|
|
@ -1,231 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import type { Express } from 'express';
|
||||
import express from 'express';
|
||||
|
||||
import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js';
|
||||
|
||||
/**
|
||||
* Options for creating an MCP Express application.
|
||||
*/
|
||||
export interface CreateMcpExpressAppOptions {
|
||||
/**
|
||||
* The hostname to bind to. Defaults to '127.0.0.1'.
|
||||
* When set to '127.0.0.1', 'localhost', or '::1', DNS rebinding protection is automatically enabled.
|
||||
*/
|
||||
host?: string;
|
||||
|
||||
/**
|
||||
* List of allowed hostnames for DNS rebinding protection.
|
||||
* If provided, host header validation will be applied using this list.
|
||||
* For IPv6, provide addresses with brackets (e.g., '[::1]').
|
||||
*
|
||||
* This is useful when binding to '0.0.0.0' or '::' but still wanting
|
||||
* to restrict which hostnames are allowed.
|
||||
*/
|
||||
allowedHosts?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Express application pre-configured for MCP servers.
|
||||
*
|
||||
* When the host is '127.0.0.1', 'localhost', or '::1' (the default is '127.0.0.1'),
|
||||
* DNS rebinding protection middleware is automatically applied to protect against
|
||||
* DNS rebinding attacks on localhost servers.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @returns A configured Express application
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic usage - defaults to 127.0.0.1 with DNS rebinding protection
|
||||
* const app = createMcpExpressApp();
|
||||
*
|
||||
* // Custom host - DNS rebinding protection only applied for localhost hosts
|
||||
* const app = createMcpExpressApp({ host: '0.0.0.0' }); // No automatic DNS rebinding protection
|
||||
* const app = createMcpExpressApp({ host: 'localhost' }); // DNS rebinding protection enabled
|
||||
*
|
||||
* // Custom allowed hosts for non-localhost binding
|
||||
* const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local', 'localhost'] });
|
||||
* ```
|
||||
*/
|
||||
export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): Express {
|
||||
const { host = '127.0.0.1', allowedHosts } = options;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// If allowedHosts is explicitly provided, use that for validation
|
||||
if (allowedHosts) {
|
||||
app.use(hostHeaderValidation(allowedHosts));
|
||||
} else {
|
||||
// Apply DNS rebinding protection automatically for localhost hosts
|
||||
const localhostHosts = ['127.0.0.1', 'localhost', '::1'];
|
||||
if (localhostHosts.includes(host)) {
|
||||
app.use(localhostHostValidation());
|
||||
} else if (host === '0.0.0.0' || host === '::') {
|
||||
// Warn when binding to all interfaces without DNS rebinding protection
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Warning: Server is binding to ${host} without DNS rebinding protection. ` +
|
||||
'Consider using the allowedHosts option to restrict allowed hosts, ' +
|
||||
'or use authentication to protect your server.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
@ -1,79 +1,67 @@
|
|||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
export type HostHeaderValidationResult =
|
||||
| { ok: true; hostname: string }
|
||||
| {
|
||||
ok: false;
|
||||
errorCode: 'missing_host' | 'invalid_host_header' | 'invalid_host';
|
||||
message: string;
|
||||
hostHeader?: string;
|
||||
hostname?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Express middleware for DNS rebinding protection.
|
||||
* Validates Host header hostname (port-agnostic) against an allowed list.
|
||||
* Parse and validate a Host header against an allowlist of hostnames (port-agnostic).
|
||||
*
|
||||
* This is particularly important for servers without authorization or HTTPS,
|
||||
* such as localhost servers or development servers. DNS rebinding attacks can
|
||||
* bypass same-origin policy by manipulating DNS to point a domain to a
|
||||
* localhost address, allowing malicious websites to access your local server.
|
||||
*
|
||||
* @param allowedHostnames - List of allowed hostnames (without ports).
|
||||
* For IPv6, provide the address with brackets (e.g., '[::1]').
|
||||
* @returns Express middleware function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const middleware = hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']);
|
||||
* app.use(middleware);
|
||||
* ```
|
||||
* - Input host header may include a port (e.g. `localhost:3000`) or IPv6 brackets (e.g. `[::1]:3000`).
|
||||
* - Allowlist items should be hostnames only (no ports). For IPv6, include brackets (e.g. `[::1]`).
|
||||
*/
|
||||
export function hostHeaderValidation(allowedHostnames: string[]): RequestHandler {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const hostHeader = req.headers.host;
|
||||
if (!hostHeader) {
|
||||
res.status(403).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Missing Host header'
|
||||
},
|
||||
id: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
export function validateHostHeader(hostHeader: string | null | undefined, allowedHostnames: string[]): HostHeaderValidationResult {
|
||||
if (!hostHeader) {
|
||||
return { ok: false, errorCode: 'missing_host', message: 'Missing Host header' };
|
||||
}
|
||||
|
||||
// Use URL API to parse hostname (handles IPv4, IPv6, and regular hostnames)
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(`http://${hostHeader}`).hostname;
|
||||
} catch {
|
||||
res.status(403).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: `Invalid Host header: ${hostHeader}`
|
||||
},
|
||||
id: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Use URL API to parse hostname (handles IPv4, IPv6, and regular hostnames)
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = new URL(`http://${hostHeader}`).hostname;
|
||||
} catch {
|
||||
return { ok: false, errorCode: 'invalid_host_header', message: `Invalid Host header: ${hostHeader}`, hostHeader };
|
||||
}
|
||||
|
||||
if (!allowedHostnames.includes(hostname)) {
|
||||
res.status(403).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: `Invalid Host: ${hostname}`
|
||||
},
|
||||
id: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
if (!allowedHostnames.includes(hostname)) {
|
||||
return { ok: false, errorCode: 'invalid_host', message: `Invalid Host: ${hostname}`, hostHeader, hostname };
|
||||
}
|
||||
|
||||
return { ok: true, hostname };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience middleware for localhost DNS rebinding protection.
|
||||
* Allows only localhost, 127.0.0.1, and [::1] (IPv6 localhost) hostnames.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* app.use(localhostHostValidation());
|
||||
* ```
|
||||
* Convenience allowlist for localhost DNS rebinding protection.
|
||||
*/
|
||||
export function localhostHostValidation(): RequestHandler {
|
||||
return hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']);
|
||||
export function localhostAllowedHostnames(): string[] {
|
||||
return ['localhost', '127.0.0.1', '[::1]'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Web-standard Request helper for DNS rebinding protection.
|
||||
* @example
|
||||
* const result = validateHostHeader(req.headers.get('host'), ['localhost'])
|
||||
*/
|
||||
export function hostHeaderValidationResponse(req: Request, allowedHostnames: string[]): Response | undefined {
|
||||
const result = validateHostHeader(req.headers.get('host'), allowedHostnames);
|
||||
if (result.ok) return undefined;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: result.message
|
||||
},
|
||||
id: null
|
||||
}),
|
||||
{
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,220 +0,0 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestInfo, Transport } from '@modelcontextprotocol/core';
|
||||
import { JSONRPCMessageSchema } from '@modelcontextprotocol/core';
|
||||
import contentType from 'content-type';
|
||||
import getRawBody from 'raw-body';
|
||||
|
||||
const MAXIMUM_MESSAGE_SIZE = '4mb';
|
||||
|
||||
/**
|
||||
* Configuration options for SSEServerTransport.
|
||||
*/
|
||||
export interface SSEServerTransportOptions {
|
||||
/**
|
||||
* List of allowed host header values for DNS rebinding protection.
|
||||
* If not specified, host validation is disabled.
|
||||
* @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead,
|
||||
* or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default.
|
||||
*/
|
||||
allowedHosts?: string[];
|
||||
|
||||
/**
|
||||
* List of allowed origin header values for DNS rebinding protection.
|
||||
* If not specified, origin validation is disabled.
|
||||
* @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead,
|
||||
* or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default.
|
||||
*/
|
||||
allowedOrigins?: string[];
|
||||
|
||||
/**
|
||||
* Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured).
|
||||
* Default is false for backwards compatibility.
|
||||
* @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead,
|
||||
* or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default.
|
||||
*/
|
||||
enableDnsRebindingProtection?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests.
|
||||
*
|
||||
* This transport is only available in Node.js environments.
|
||||
* @deprecated SSEServerTransport is deprecated. Use StreamableHTTPServerTransport instead.
|
||||
*/
|
||||
export class SSEServerTransport implements Transport {
|
||||
private _sseResponse?: ServerResponse;
|
||||
private _sessionId: string;
|
||||
private _options: SSEServerTransportOptions;
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
|
||||
|
||||
/**
|
||||
* Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`.
|
||||
*/
|
||||
constructor(
|
||||
private _endpoint: string,
|
||||
private res: ServerResponse,
|
||||
options?: SSEServerTransportOptions
|
||||
) {
|
||||
this._sessionId = randomUUID();
|
||||
this._options = options || { enableDnsRebindingProtection: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates request headers for DNS rebinding protection.
|
||||
* @returns Error message if validation fails, undefined if validation passes.
|
||||
*/
|
||||
private validateRequestHeaders(req: IncomingMessage): string | undefined {
|
||||
// Skip validation if protection is not enabled
|
||||
if (!this._options.enableDnsRebindingProtection) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Validate Host header if allowedHosts is configured
|
||||
if (this._options.allowedHosts && this._options.allowedHosts.length > 0) {
|
||||
const hostHeader = req.headers.host;
|
||||
if (!hostHeader || !this._options.allowedHosts.includes(hostHeader)) {
|
||||
return `Invalid Host header: ${hostHeader}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Origin header if allowedOrigins is configured
|
||||
if (this._options.allowedOrigins && this._options.allowedOrigins.length > 0) {
|
||||
const originHeader = req.headers.origin;
|
||||
if (originHeader && !this._options.allowedOrigins.includes(originHeader)) {
|
||||
return `Invalid Origin header: ${originHeader}`;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the initial SSE connection request.
|
||||
*
|
||||
* This should be called when a GET request is made to establish the SSE stream.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this._sseResponse) {
|
||||
throw new Error('SSEServerTransport already started! If using Server class, note that connect() calls start() automatically.');
|
||||
}
|
||||
|
||||
this.res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive'
|
||||
});
|
||||
|
||||
// Send the endpoint event
|
||||
// Use a dummy base URL because this._endpoint is relative.
|
||||
// This allows using URL/URLSearchParams for robust parameter handling.
|
||||
const dummyBase = 'http://localhost'; // Any valid base works
|
||||
const endpointUrl = new URL(this._endpoint, dummyBase);
|
||||
endpointUrl.searchParams.set('sessionId', this._sessionId);
|
||||
|
||||
// Reconstruct the relative URL string (pathname + search + hash)
|
||||
const relativeUrlWithSession = endpointUrl.pathname + endpointUrl.search + endpointUrl.hash;
|
||||
|
||||
this.res.write(`event: endpoint\ndata: ${relativeUrlWithSession}\n\n`);
|
||||
|
||||
this._sseResponse = this.res;
|
||||
this.res.on('close', () => {
|
||||
this._sseResponse = undefined;
|
||||
this.onclose?.();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming POST messages.
|
||||
*
|
||||
* This should be called when a POST request is made to send a message to the server.
|
||||
*/
|
||||
async handlePostMessage(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise<void> {
|
||||
if (!this._sseResponse) {
|
||||
const message = 'SSE connection not established';
|
||||
res.writeHead(500).end(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// Validate request headers for DNS rebinding protection
|
||||
const validationError = this.validateRequestHeaders(req);
|
||||
if (validationError) {
|
||||
res.writeHead(403).end(validationError);
|
||||
this.onerror?.(new Error(validationError));
|
||||
return;
|
||||
}
|
||||
|
||||
const authInfo: AuthInfo | undefined = req.auth;
|
||||
const requestInfo: RequestInfo = { headers: req.headers };
|
||||
|
||||
let body: string | unknown;
|
||||
try {
|
||||
const ct = contentType.parse(req.headers['content-type'] ?? '');
|
||||
if (ct.type !== 'application/json') {
|
||||
throw new Error(`Unsupported content-type: ${ct.type}`);
|
||||
}
|
||||
|
||||
body =
|
||||
parsedBody ??
|
||||
(await getRawBody(req, {
|
||||
limit: MAXIMUM_MESSAGE_SIZE,
|
||||
encoding: ct.parameters.charset ?? 'utf-8'
|
||||
}));
|
||||
} catch (error) {
|
||||
res.writeHead(400).end(String(error));
|
||||
this.onerror?.(error as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { requestInfo, authInfo });
|
||||
} catch {
|
||||
res.writeHead(400).end(`Invalid message: ${body}`);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(202).end('Accepted');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST.
|
||||
*/
|
||||
async handleMessage(message: unknown, extra?: MessageExtraInfo): Promise<void> {
|
||||
let parsedMessage: JSONRPCMessage;
|
||||
try {
|
||||
parsedMessage = JSONRPCMessageSchema.parse(message);
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.onmessage?.(parsedMessage, extra);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this._sseResponse?.end();
|
||||
this._sseResponse = undefined;
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
if (!this._sseResponse) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
this._sseResponse.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the session ID for this transport.
|
||||
*
|
||||
* This can be used to route incoming POST requests.
|
||||
*/
|
||||
get sessionId(): string {
|
||||
return this._sessionId;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,999 +0,0 @@
|
|||
/**
|
||||
* Web Standards Streamable HTTP Server Transport
|
||||
*
|
||||
* This is the core transport implementation using Web Standard APIs (Request, Response, ReadableStream).
|
||||
* It can run on any runtime that supports Web Standards: Node.js 18+, Cloudflare Workers, Deno, Bun, etc.
|
||||
*
|
||||
* For Node.js Express/HTTP compatibility, use `StreamableHTTPServerTransport` which wraps this transport.
|
||||
*/
|
||||
|
||||
import { TextEncoder } from 'node:util';
|
||||
|
||||
import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, RequestInfo, Transport } from '@modelcontextprotocol/core';
|
||||
import {
|
||||
DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
|
||||
isInitializeRequest,
|
||||
isJSONRPCErrorResponse,
|
||||
isJSONRPCRequest,
|
||||
isJSONRPCResultResponse,
|
||||
JSONRPCMessageSchema,
|
||||
SUPPORTED_PROTOCOL_VERSIONS
|
||||
} from '@modelcontextprotocol/core';
|
||||
|
||||
export type StreamId = string;
|
||||
export type EventId = string;
|
||||
|
||||
/**
|
||||
* Interface for resumability support via event storage
|
||||
*/
|
||||
export interface EventStore {
|
||||
/**
|
||||
* Stores an event for later retrieval
|
||||
* @param streamId ID of the stream the event belongs to
|
||||
* @param message The JSON-RPC message to store
|
||||
* @returns The generated event ID for the stored event
|
||||
*/
|
||||
storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId>;
|
||||
|
||||
/**
|
||||
* Get the stream ID associated with a given event ID.
|
||||
* @param eventId The event ID to look up
|
||||
* @returns The stream ID, or undefined if not found
|
||||
*
|
||||
* Optional: If not provided, the SDK will use the streamId returned by
|
||||
* replayEventsAfter for stream mapping.
|
||||
*/
|
||||
getStreamIdForEventId?(eventId: EventId): Promise<StreamId | undefined>;
|
||||
|
||||
replayEventsAfter(
|
||||
lastEventId: EventId,
|
||||
{
|
||||
send
|
||||
}: {
|
||||
send: (eventId: EventId, message: JSONRPCMessage) => Promise<void>;
|
||||
}
|
||||
): Promise<StreamId>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal stream mapping for managing SSE connections
|
||||
*/
|
||||
interface StreamMapping {
|
||||
/** Stream controller for pushing SSE data - only used with ReadableStream approach */
|
||||
controller?: ReadableStreamDefaultController<Uint8Array>;
|
||||
/** Text encoder for SSE formatting */
|
||||
encoder?: TextEncoder;
|
||||
/** Promise resolver for JSON response mode */
|
||||
resolveJson?: (response: Response) => void;
|
||||
/** Cleanup function to close stream and remove mapping */
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for WebStandardStreamableHTTPServerTransport
|
||||
*/
|
||||
export interface WebStandardStreamableHTTPServerTransportOptions {
|
||||
/**
|
||||
* Function that generates a session ID for the transport.
|
||||
* The session ID SHOULD be globally unique and cryptographically secure (e.g., a securely generated UUID, a JWT, or a cryptographic hash)
|
||||
*
|
||||
* If not provided, session management is disabled (stateless mode).
|
||||
*/
|
||||
sessionIdGenerator?: () => string;
|
||||
|
||||
/**
|
||||
* A callback for session initialization events
|
||||
* This is called when the server initializes a new session.
|
||||
* Useful in cases when you need to register multiple mcp sessions
|
||||
* and need to keep track of them.
|
||||
* @param sessionId The generated session ID
|
||||
*/
|
||||
onsessioninitialized?: (sessionId: string) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* A callback for session close events
|
||||
* This is called when the server closes a session due to a DELETE request.
|
||||
* Useful in cases when you need to clean up resources associated with the session.
|
||||
* Note that this is different from the transport closing, if you are handling
|
||||
* HTTP requests from multiple nodes you might want to close each
|
||||
* WebStandardStreamableHTTPServerTransport after a request is completed while still keeping the
|
||||
* session open/running.
|
||||
* @param sessionId The session ID that was closed
|
||||
*/
|
||||
onsessionclosed?: (sessionId: string) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* If true, the server will return JSON responses instead of starting an SSE stream.
|
||||
* This can be useful for simple request/response scenarios without streaming.
|
||||
* Default is false (SSE streams are preferred).
|
||||
*/
|
||||
enableJsonResponse?: boolean;
|
||||
|
||||
/**
|
||||
* Event store for resumability support
|
||||
* If provided, resumability will be enabled, allowing clients to reconnect and resume messages
|
||||
*/
|
||||
eventStore?: EventStore;
|
||||
|
||||
/**
|
||||
* List of allowed host header values for DNS rebinding protection.
|
||||
* If not specified, host validation is disabled.
|
||||
* @deprecated Use external middleware for host validation instead.
|
||||
*/
|
||||
allowedHosts?: string[];
|
||||
|
||||
/**
|
||||
* List of allowed origin header values for DNS rebinding protection.
|
||||
* If not specified, origin validation is disabled.
|
||||
* @deprecated Use external middleware for origin validation instead.
|
||||
*/
|
||||
allowedOrigins?: string[];
|
||||
|
||||
/**
|
||||
* Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured).
|
||||
* Default is false for backwards compatibility.
|
||||
* @deprecated Use external middleware for DNS rebinding protection instead.
|
||||
*/
|
||||
enableDnsRebindingProtection?: boolean;
|
||||
|
||||
/**
|
||||
* Retry interval in milliseconds to suggest to clients in SSE retry field.
|
||||
* When set, the server will send a retry field in SSE priming events to control
|
||||
* client reconnection timing for polling behavior.
|
||||
*/
|
||||
retryInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for handling a request
|
||||
*/
|
||||
export interface HandleRequestOptions {
|
||||
/**
|
||||
* Pre-parsed request body. If provided, the transport will use this instead of parsing req.json().
|
||||
* Useful when using body-parser middleware that has already parsed the body.
|
||||
*/
|
||||
parsedBody?: unknown;
|
||||
|
||||
/**
|
||||
* Authentication info from middleware. If provided, will be passed to message handlers.
|
||||
*/
|
||||
authInfo?: AuthInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server transport for Web Standards Streamable HTTP: this implements the MCP Streamable HTTP transport specification
|
||||
* using Web Standard APIs (Request, Response, ReadableStream).
|
||||
*
|
||||
* This transport works on any runtime that supports Web Standards: Node.js 18+, Cloudflare Workers, Deno, Bun, etc.
|
||||
*
|
||||
* Usage example:
|
||||
*
|
||||
* ```typescript
|
||||
* // Stateful mode - server sets the session ID
|
||||
* const statefulTransport = new WebStandardStreamableHTTPServerTransport({
|
||||
* sessionIdGenerator: () => crypto.randomUUID(),
|
||||
* });
|
||||
*
|
||||
* // Stateless mode - explicitly set session ID to undefined
|
||||
* const statelessTransport = new WebStandardStreamableHTTPServerTransport({
|
||||
* sessionIdGenerator: undefined,
|
||||
* });
|
||||
*
|
||||
* // Hono.js usage
|
||||
* app.all('/mcp', async (c) => {
|
||||
* return transport.handleRequest(c.req.raw);
|
||||
* });
|
||||
*
|
||||
* // Cloudflare Workers usage
|
||||
* export default {
|
||||
* async fetch(request: Request): Promise<Response> {
|
||||
* return transport.handleRequest(request);
|
||||
* }
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* In stateful mode:
|
||||
* - Session ID is generated and included in response headers
|
||||
* - Session ID is always included in initialization responses
|
||||
* - Requests with invalid session IDs are rejected with 404 Not Found
|
||||
* - Non-initialization requests without a session ID are rejected with 400 Bad Request
|
||||
* - State is maintained in-memory (connections, message history)
|
||||
*
|
||||
* In stateless mode:
|
||||
* - No Session ID is included in any responses
|
||||
* - No session validation is performed
|
||||
*/
|
||||
export class WebStandardStreamableHTTPServerTransport implements Transport {
|
||||
// when sessionId is not set (undefined), it means the transport is in stateless mode
|
||||
private sessionIdGenerator: (() => string) | undefined;
|
||||
private _started: boolean = false;
|
||||
private _streamMapping: Map<string, StreamMapping> = new Map();
|
||||
private _requestToStreamMapping: Map<RequestId, string> = new Map();
|
||||
private _requestResponseMap: Map<RequestId, JSONRPCMessage> = new Map();
|
||||
private _initialized: boolean = false;
|
||||
private _enableJsonResponse: boolean = false;
|
||||
private _standaloneSseStreamId: string = '_GET_stream';
|
||||
private _eventStore?: EventStore;
|
||||
private _onsessioninitialized?: (sessionId: string) => void | Promise<void>;
|
||||
private _onsessionclosed?: (sessionId: string) => void | Promise<void>;
|
||||
private _allowedHosts?: string[];
|
||||
private _allowedOrigins?: string[];
|
||||
private _enableDnsRebindingProtection: boolean;
|
||||
private _retryInterval?: number;
|
||||
|
||||
sessionId?: string;
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
|
||||
|
||||
constructor(options: WebStandardStreamableHTTPServerTransportOptions = {}) {
|
||||
this.sessionIdGenerator = options.sessionIdGenerator;
|
||||
this._enableJsonResponse = options.enableJsonResponse ?? false;
|
||||
this._eventStore = options.eventStore;
|
||||
this._onsessioninitialized = options.onsessioninitialized;
|
||||
this._onsessionclosed = options.onsessionclosed;
|
||||
this._allowedHosts = options.allowedHosts;
|
||||
this._allowedOrigins = options.allowedOrigins;
|
||||
this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false;
|
||||
this._retryInterval = options.retryInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the transport. This is required by the Transport interface but is a no-op
|
||||
* for the Streamable HTTP transport as connections are managed per-request.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this._started) {
|
||||
throw new Error('Transport already started');
|
||||
}
|
||||
this._started = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a JSON error response
|
||||
*/
|
||||
private createJsonErrorResponse(
|
||||
status: number,
|
||||
code: number,
|
||||
message: string,
|
||||
options?: { headers?: Record<string, string>; data?: string }
|
||||
): Response {
|
||||
const error: { code: number; message: string; data?: string } = { code, message };
|
||||
if (options?.data !== undefined) {
|
||||
error.data = options.data;
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
error,
|
||||
id: null
|
||||
}),
|
||||
{
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates request headers for DNS rebinding protection.
|
||||
* @returns Error response if validation fails, undefined if validation passes.
|
||||
*/
|
||||
private validateRequestHeaders(req: Request): Response | undefined {
|
||||
// Skip validation if protection is not enabled
|
||||
if (!this._enableDnsRebindingProtection) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Validate Host header if allowedHosts is configured
|
||||
if (this._allowedHosts && this._allowedHosts.length > 0) {
|
||||
const hostHeader = req.headers.get('host');
|
||||
if (!hostHeader || !this._allowedHosts.includes(hostHeader)) {
|
||||
const error = `Invalid Host header: ${hostHeader}`;
|
||||
this.onerror?.(new Error(error));
|
||||
return this.createJsonErrorResponse(403, -32000, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Origin header if allowedOrigins is configured
|
||||
if (this._allowedOrigins && this._allowedOrigins.length > 0) {
|
||||
const originHeader = req.headers.get('origin');
|
||||
if (originHeader && !this._allowedOrigins.includes(originHeader)) {
|
||||
const error = `Invalid Origin header: ${originHeader}`;
|
||||
this.onerror?.(new Error(error));
|
||||
return this.createJsonErrorResponse(403, -32000, error);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an incoming HTTP request, whether GET, POST, or DELETE
|
||||
* Returns a Response object (Web Standard)
|
||||
*/
|
||||
async handleRequest(req: Request, options?: HandleRequestOptions): Promise<Response> {
|
||||
// Validate request headers for DNS rebinding protection
|
||||
const validationError = this.validateRequestHeaders(req);
|
||||
if (validationError) {
|
||||
return validationError;
|
||||
}
|
||||
|
||||
switch (req.method) {
|
||||
case 'POST':
|
||||
return this.handlePostRequest(req, options);
|
||||
case 'GET':
|
||||
return this.handleGetRequest(req);
|
||||
case 'DELETE':
|
||||
return this.handleDeleteRequest(req);
|
||||
default:
|
||||
return this.handleUnsupportedRequest();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a priming event to establish resumption capability.
|
||||
* Only sends if eventStore is configured (opt-in for resumability) and
|
||||
* the client's protocol version supports empty SSE data (>= 2025-11-25).
|
||||
*/
|
||||
private async writePrimingEvent(
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
encoder: TextEncoder,
|
||||
streamId: string,
|
||||
protocolVersion: string
|
||||
): Promise<void> {
|
||||
if (!this._eventStore) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Priming events have empty data which older clients cannot handle.
|
||||
// Only send priming events to clients with protocol version >= 2025-11-25
|
||||
// which includes the fix for handling empty SSE data.
|
||||
if (protocolVersion < '2025-11-25') {
|
||||
return;
|
||||
}
|
||||
|
||||
const primingEventId = await this._eventStore.storeEvent(streamId, {} as JSONRPCMessage);
|
||||
|
||||
let primingEvent = `id: ${primingEventId}\ndata: \n\n`;
|
||||
if (this._retryInterval !== undefined) {
|
||||
primingEvent = `id: ${primingEventId}\nretry: ${this._retryInterval}\ndata: \n\n`;
|
||||
}
|
||||
controller.enqueue(encoder.encode(primingEvent));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles GET requests for SSE stream
|
||||
*/
|
||||
private async handleGetRequest(req: Request): Promise<Response> {
|
||||
// The client MUST include an Accept header, listing text/event-stream as a supported content type.
|
||||
const acceptHeader = req.headers.get('accept');
|
||||
if (!acceptHeader?.includes('text/event-stream')) {
|
||||
return this.createJsonErrorResponse(406, -32000, 'Not Acceptable: Client must accept text/event-stream');
|
||||
}
|
||||
|
||||
// If an Mcp-Session-Id is returned by the server during initialization,
|
||||
// clients using the Streamable HTTP transport MUST include it
|
||||
// in the Mcp-Session-Id header on all of their subsequent HTTP requests.
|
||||
const sessionError = this.validateSession(req);
|
||||
if (sessionError) {
|
||||
return sessionError;
|
||||
}
|
||||
const protocolError = this.validateProtocolVersion(req);
|
||||
if (protocolError) {
|
||||
return protocolError;
|
||||
}
|
||||
|
||||
// Handle resumability: check for Last-Event-ID header
|
||||
if (this._eventStore) {
|
||||
const lastEventId = req.headers.get('last-event-id');
|
||||
if (lastEventId) {
|
||||
return this.replayEvents(lastEventId);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there's already an active standalone SSE stream for this session
|
||||
if (this._streamMapping.get(this._standaloneSseStreamId) !== undefined) {
|
||||
// Only one GET SSE stream is allowed per session
|
||||
return this.createJsonErrorResponse(409, -32000, 'Conflict: Only one SSE stream is allowed per session');
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let streamController: ReadableStreamDefaultController<Uint8Array>;
|
||||
|
||||
// Create a ReadableStream with a controller we can use to push SSE events
|
||||
const readable = new ReadableStream<Uint8Array>({
|
||||
start: controller => {
|
||||
streamController = controller;
|
||||
},
|
||||
cancel: () => {
|
||||
// Stream was cancelled by client
|
||||
this._streamMapping.delete(this._standaloneSseStreamId);
|
||||
}
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive'
|
||||
};
|
||||
|
||||
// After initialization, always include the session ID if we have one
|
||||
if (this.sessionId !== undefined) {
|
||||
headers['mcp-session-id'] = this.sessionId;
|
||||
}
|
||||
|
||||
// Store the stream mapping with the controller for pushing data
|
||||
this._streamMapping.set(this._standaloneSseStreamId, {
|
||||
controller: streamController!,
|
||||
encoder,
|
||||
cleanup: () => {
|
||||
this._streamMapping.delete(this._standaloneSseStreamId);
|
||||
try {
|
||||
streamController!.close();
|
||||
} catch {
|
||||
// Controller might already be closed
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(readable, { headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays events that would have been sent after the specified event ID
|
||||
* Only used when resumability is enabled
|
||||
*/
|
||||
private async replayEvents(lastEventId: string): Promise<Response> {
|
||||
if (!this._eventStore) {
|
||||
return this.createJsonErrorResponse(400, -32000, 'Event store not configured');
|
||||
}
|
||||
|
||||
try {
|
||||
// If getStreamIdForEventId is available, use it for conflict checking
|
||||
let streamId: string | undefined;
|
||||
if (this._eventStore.getStreamIdForEventId) {
|
||||
streamId = await this._eventStore.getStreamIdForEventId(lastEventId);
|
||||
|
||||
if (!streamId) {
|
||||
return this.createJsonErrorResponse(400, -32000, 'Invalid event ID format');
|
||||
}
|
||||
|
||||
// Check conflict with the SAME streamId we'll use for mapping
|
||||
if (this._streamMapping.get(streamId) !== undefined) {
|
||||
return this.createJsonErrorResponse(409, -32000, 'Conflict: Stream already has an active connection');
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive'
|
||||
};
|
||||
|
||||
if (this.sessionId !== undefined) {
|
||||
headers['mcp-session-id'] = this.sessionId;
|
||||
}
|
||||
|
||||
// Create a ReadableStream with controller for SSE
|
||||
const encoder = new TextEncoder();
|
||||
let streamController: ReadableStreamDefaultController<Uint8Array>;
|
||||
|
||||
const readable = new ReadableStream<Uint8Array>({
|
||||
start: controller => {
|
||||
streamController = controller;
|
||||
},
|
||||
cancel: () => {
|
||||
// Stream was cancelled by client
|
||||
// Cleanup will be handled by the mapping
|
||||
}
|
||||
});
|
||||
|
||||
// Replay events - returns the streamId for backwards compatibility
|
||||
const replayedStreamId = await this._eventStore.replayEventsAfter(lastEventId, {
|
||||
send: async (eventId: string, message: JSONRPCMessage) => {
|
||||
const success = this.writeSSEEvent(streamController!, encoder, message, eventId);
|
||||
if (!success) {
|
||||
this.onerror?.(new Error('Failed replay events'));
|
||||
try {
|
||||
streamController!.close();
|
||||
} catch {
|
||||
// Controller might already be closed
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._streamMapping.set(replayedStreamId, {
|
||||
controller: streamController!,
|
||||
encoder,
|
||||
cleanup: () => {
|
||||
this._streamMapping.delete(replayedStreamId);
|
||||
try {
|
||||
streamController!.close();
|
||||
} catch {
|
||||
// Controller might already be closed
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(readable, { headers });
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
return this.createJsonErrorResponse(500, -32000, 'Error replaying events');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an event to an SSE stream via controller with proper formatting
|
||||
*/
|
||||
private writeSSEEvent(
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
encoder: TextEncoder,
|
||||
message: JSONRPCMessage,
|
||||
eventId?: string
|
||||
): boolean {
|
||||
try {
|
||||
let eventData = `event: message\n`;
|
||||
// Include event ID if provided - this is important for resumability
|
||||
if (eventId) {
|
||||
eventData += `id: ${eventId}\n`;
|
||||
}
|
||||
eventData += `data: ${JSON.stringify(message)}\n\n`;
|
||||
controller.enqueue(encoder.encode(eventData));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles unsupported requests (PUT, PATCH, etc.)
|
||||
*/
|
||||
private handleUnsupportedRequest(): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Method not allowed.'
|
||||
},
|
||||
id: null
|
||||
}),
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: 'GET, POST, DELETE',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles POST requests containing JSON-RPC messages
|
||||
*/
|
||||
private async handlePostRequest(req: Request, options?: HandleRequestOptions): Promise<Response> {
|
||||
try {
|
||||
// Validate the Accept header
|
||||
const acceptHeader = req.headers.get('accept');
|
||||
// The client MUST include an Accept header, listing both application/json and text/event-stream as supported content types.
|
||||
if (!acceptHeader?.includes('application/json') || !acceptHeader.includes('text/event-stream')) {
|
||||
return this.createJsonErrorResponse(
|
||||
406,
|
||||
-32000,
|
||||
'Not Acceptable: Client must accept both application/json and text/event-stream'
|
||||
);
|
||||
}
|
||||
|
||||
const ct = req.headers.get('content-type');
|
||||
if (!ct || !ct.includes('application/json')) {
|
||||
return this.createJsonErrorResponse(415, -32000, 'Unsupported Media Type: Content-Type must be application/json');
|
||||
}
|
||||
|
||||
// Build request info from headers
|
||||
const requestInfo: RequestInfo = {
|
||||
headers: Object.fromEntries(req.headers.entries())
|
||||
};
|
||||
|
||||
let rawMessage;
|
||||
if (options?.parsedBody !== undefined) {
|
||||
rawMessage = options.parsedBody;
|
||||
} else {
|
||||
try {
|
||||
rawMessage = await req.json();
|
||||
} catch {
|
||||
return this.createJsonErrorResponse(400, -32700, 'Parse error: Invalid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
let messages: JSONRPCMessage[];
|
||||
|
||||
// handle batch and single messages
|
||||
try {
|
||||
if (Array.isArray(rawMessage)) {
|
||||
messages = rawMessage.map(msg => JSONRPCMessageSchema.parse(msg));
|
||||
} else {
|
||||
messages = [JSONRPCMessageSchema.parse(rawMessage)];
|
||||
}
|
||||
} catch {
|
||||
return this.createJsonErrorResponse(400, -32700, 'Parse error: Invalid JSON-RPC message');
|
||||
}
|
||||
|
||||
// Check if this is an initialization request
|
||||
// https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/
|
||||
const isInitializationRequest = messages.some(isInitializeRequest);
|
||||
if (isInitializationRequest) {
|
||||
// If it's a server with session management and the session ID is already set we should reject the request
|
||||
// to avoid re-initialization.
|
||||
if (this._initialized && this.sessionId !== undefined) {
|
||||
return this.createJsonErrorResponse(400, -32600, 'Invalid Request: Server already initialized');
|
||||
}
|
||||
if (messages.length > 1) {
|
||||
return this.createJsonErrorResponse(400, -32600, 'Invalid Request: Only one initialization request is allowed');
|
||||
}
|
||||
this.sessionId = this.sessionIdGenerator?.();
|
||||
this._initialized = true;
|
||||
|
||||
// If we have a session ID and an onsessioninitialized handler, call it immediately
|
||||
// This is needed in cases where the server needs to keep track of multiple sessions
|
||||
if (this.sessionId && this._onsessioninitialized) {
|
||||
await Promise.resolve(this._onsessioninitialized(this.sessionId));
|
||||
}
|
||||
}
|
||||
if (!isInitializationRequest) {
|
||||
// If an Mcp-Session-Id is returned by the server during initialization,
|
||||
// clients using the Streamable HTTP transport MUST include it
|
||||
// in the Mcp-Session-Id header on all of their subsequent HTTP requests.
|
||||
const sessionError = this.validateSession(req);
|
||||
if (sessionError) {
|
||||
return sessionError;
|
||||
}
|
||||
// Mcp-Protocol-Version header is required for all requests after initialization.
|
||||
const protocolError = this.validateProtocolVersion(req);
|
||||
if (protocolError) {
|
||||
return protocolError;
|
||||
}
|
||||
}
|
||||
|
||||
// check if it contains requests
|
||||
const hasRequests = messages.some(isJSONRPCRequest);
|
||||
|
||||
if (!hasRequests) {
|
||||
// if it only contains notifications or responses, return 202
|
||||
for (const message of messages) {
|
||||
this.onmessage?.(message, { authInfo: options?.authInfo, requestInfo });
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 202,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The default behavior is to use SSE streaming
|
||||
// but in some cases server will return JSON responses
|
||||
const streamId = crypto.randomUUID();
|
||||
|
||||
// Extract protocol version for priming event decision.
|
||||
// For initialize requests, get from request params.
|
||||
// For other requests, get from header (already validated).
|
||||
const initRequest = messages.find(m => isInitializeRequest(m));
|
||||
const clientProtocolVersion = initRequest
|
||||
? initRequest.params.protocolVersion
|
||||
: (req.headers.get('mcp-protocol-version') ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION);
|
||||
|
||||
if (this._enableJsonResponse) {
|
||||
// For JSON response mode, return a Promise that resolves when all responses are ready
|
||||
return new Promise<Response>(resolve => {
|
||||
this._streamMapping.set(streamId, {
|
||||
resolveJson: resolve,
|
||||
cleanup: () => {
|
||||
this._streamMapping.delete(streamId);
|
||||
}
|
||||
});
|
||||
|
||||
for (const message of messages) {
|
||||
if (isJSONRPCRequest(message)) {
|
||||
this._requestToStreamMapping.set(message.id, streamId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
this.onmessage?.(message, { authInfo: options?.authInfo, requestInfo });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// SSE streaming mode - use ReadableStream with controller for more reliable data pushing
|
||||
const encoder = new TextEncoder();
|
||||
let streamController: ReadableStreamDefaultController<Uint8Array>;
|
||||
|
||||
const readable = new ReadableStream<Uint8Array>({
|
||||
start: controller => {
|
||||
streamController = controller;
|
||||
},
|
||||
cancel: () => {
|
||||
// Stream was cancelled by client
|
||||
this._streamMapping.delete(streamId);
|
||||
}
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive'
|
||||
};
|
||||
|
||||
// After initialization, always include the session ID if we have one
|
||||
if (this.sessionId !== undefined) {
|
||||
headers['mcp-session-id'] = this.sessionId;
|
||||
}
|
||||
|
||||
// Store the response for this request to send messages back through this connection
|
||||
// We need to track by request ID to maintain the connection
|
||||
for (const message of messages) {
|
||||
if (isJSONRPCRequest(message)) {
|
||||
this._streamMapping.set(streamId, {
|
||||
controller: streamController!,
|
||||
encoder,
|
||||
cleanup: () => {
|
||||
this._streamMapping.delete(streamId);
|
||||
try {
|
||||
streamController!.close();
|
||||
} catch {
|
||||
// Controller might already be closed
|
||||
}
|
||||
}
|
||||
});
|
||||
this._requestToStreamMapping.set(message.id, streamId);
|
||||
}
|
||||
}
|
||||
|
||||
// Write priming event if event store is configured (after mapping is set up)
|
||||
await this.writePrimingEvent(streamController!, encoder, streamId, clientProtocolVersion);
|
||||
|
||||
// handle each message
|
||||
for (const message of messages) {
|
||||
// Build closeSSEStream callback for requests when eventStore is configured
|
||||
// AND client supports resumability (protocol version >= 2025-11-25).
|
||||
// Old clients can't resume if the stream is closed early because they
|
||||
// didn't receive a priming event with an event ID.
|
||||
let closeSSEStream: (() => void) | undefined;
|
||||
let closeStandaloneSSEStream: (() => void) | undefined;
|
||||
if (isJSONRPCRequest(message) && this._eventStore && clientProtocolVersion >= '2025-11-25') {
|
||||
closeSSEStream = () => {
|
||||
this.closeSSEStream(message.id);
|
||||
};
|
||||
closeStandaloneSSEStream = () => {
|
||||
this.closeStandaloneSSEStream();
|
||||
};
|
||||
}
|
||||
|
||||
this.onmessage?.(message, { authInfo: options?.authInfo, requestInfo, closeSSEStream, closeStandaloneSSEStream });
|
||||
}
|
||||
// The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses
|
||||
// This will be handled by the send() method when responses are ready
|
||||
|
||||
return new Response(readable, { status: 200, headers });
|
||||
} catch (error) {
|
||||
// return JSON-RPC formatted error
|
||||
this.onerror?.(error as Error);
|
||||
return this.createJsonErrorResponse(400, -32700, 'Parse error', { data: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles DELETE requests to terminate sessions
|
||||
*/
|
||||
private async handleDeleteRequest(req: Request): Promise<Response> {
|
||||
const sessionError = this.validateSession(req);
|
||||
if (sessionError) {
|
||||
return sessionError;
|
||||
}
|
||||
const protocolError = this.validateProtocolVersion(req);
|
||||
if (protocolError) {
|
||||
return protocolError;
|
||||
}
|
||||
|
||||
await Promise.resolve(this._onsessionclosed?.(this.sessionId!));
|
||||
await this.close();
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates session ID for non-initialization requests.
|
||||
* Returns Response error if invalid, undefined otherwise
|
||||
*/
|
||||
private validateSession(req: Request): Response | undefined {
|
||||
if (this.sessionIdGenerator === undefined) {
|
||||
// If the sessionIdGenerator ID is not set, the session management is disabled
|
||||
// and we don't need to validate the session ID
|
||||
return undefined;
|
||||
}
|
||||
if (!this._initialized) {
|
||||
// If the server has not been initialized yet, reject all requests
|
||||
return this.createJsonErrorResponse(400, -32000, 'Bad Request: Server not initialized');
|
||||
}
|
||||
|
||||
const sessionId = req.headers.get('mcp-session-id');
|
||||
|
||||
if (!sessionId) {
|
||||
// Non-initialization requests without a session ID should return 400 Bad Request
|
||||
return this.createJsonErrorResponse(400, -32000, 'Bad Request: Mcp-Session-Id header is required');
|
||||
}
|
||||
|
||||
if (sessionId !== this.sessionId) {
|
||||
// Reject requests with invalid session ID with 404 Not Found
|
||||
return this.createJsonErrorResponse(404, -32001, 'Session not found');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the MCP-Protocol-Version header on incoming requests.
|
||||
*
|
||||
* For initialization: Version negotiation handles unknown versions gracefully
|
||||
* (server responds with its supported version).
|
||||
*
|
||||
* For subsequent requests with MCP-Protocol-Version header:
|
||||
* - Accept if in supported list
|
||||
* - 400 if unsupported
|
||||
*
|
||||
* For HTTP requests without the MCP-Protocol-Version header:
|
||||
* - Accept and default to the version negotiated at initialization
|
||||
*/
|
||||
private validateProtocolVersion(req: Request): Response | undefined {
|
||||
const protocolVersion = req.headers.get('mcp-protocol-version');
|
||||
|
||||
if (protocolVersion !== null && !SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) {
|
||||
return this.createJsonErrorResponse(
|
||||
400,
|
||||
-32000,
|
||||
`Bad Request: Unsupported protocol version: ${protocolVersion} (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')})`
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Close all SSE connections
|
||||
this._streamMapping.forEach(({ cleanup }) => {
|
||||
cleanup();
|
||||
});
|
||||
this._streamMapping.clear();
|
||||
|
||||
// Clear any pending responses
|
||||
this._requestResponseMap.clear();
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close an SSE stream for a specific request, triggering client reconnection.
|
||||
* Use this to implement polling behavior during long-running operations -
|
||||
* client will reconnect after the retry interval specified in the priming event.
|
||||
*/
|
||||
closeSSEStream(requestId: RequestId): void {
|
||||
const streamId = this._requestToStreamMapping.get(requestId);
|
||||
if (!streamId) return;
|
||||
|
||||
const stream = this._streamMapping.get(streamId);
|
||||
if (stream) {
|
||||
stream.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the standalone GET SSE stream, triggering client reconnection.
|
||||
* Use this to implement polling behavior for server-initiated notifications.
|
||||
*/
|
||||
closeStandaloneSSEStream(): void {
|
||||
const stream = this._streamMapping.get(this._standaloneSseStreamId);
|
||||
if (stream) {
|
||||
stream.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise<void> {
|
||||
let requestId = options?.relatedRequestId;
|
||||
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
|
||||
// If the message is a response, use the request ID from the message
|
||||
requestId = message.id;
|
||||
}
|
||||
|
||||
// Check if this message should be sent on the standalone SSE stream (no request ID)
|
||||
// Ignore notifications from tools (which have relatedRequestId set)
|
||||
// Those will be sent via dedicated response SSE streams
|
||||
if (requestId === undefined) {
|
||||
// For standalone SSE streams, we can only send requests and notifications
|
||||
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
|
||||
throw new Error('Cannot send a response on a standalone SSE stream unless resuming a previous client request');
|
||||
}
|
||||
|
||||
// Generate and store event ID if event store is provided
|
||||
// Store even if stream is disconnected so events can be replayed on reconnect
|
||||
let eventId: string | undefined;
|
||||
if (this._eventStore) {
|
||||
// Stores the event and gets the generated event ID
|
||||
eventId = await this._eventStore.storeEvent(this._standaloneSseStreamId, message);
|
||||
}
|
||||
|
||||
const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId);
|
||||
if (standaloneSse === undefined) {
|
||||
// Stream is disconnected - event is stored for replay, nothing more to do
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the message to the standalone SSE stream
|
||||
if (standaloneSse.controller && standaloneSse.encoder) {
|
||||
this.writeSSEEvent(standaloneSse.controller, standaloneSse.encoder, message, eventId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the response for this request
|
||||
const streamId = this._requestToStreamMapping.get(requestId);
|
||||
if (!streamId) {
|
||||
throw new Error(`No connection established for request ID: ${String(requestId)}`);
|
||||
}
|
||||
|
||||
const stream = this._streamMapping.get(streamId);
|
||||
|
||||
if (!this._enableJsonResponse && stream?.controller && stream?.encoder) {
|
||||
// For SSE responses, generate event ID if event store is provided
|
||||
let eventId: string | undefined;
|
||||
|
||||
if (this._eventStore) {
|
||||
eventId = await this._eventStore.storeEvent(streamId, message);
|
||||
}
|
||||
// Write the event to the response stream
|
||||
this.writeSSEEvent(stream.controller, stream.encoder, message, eventId);
|
||||
}
|
||||
|
||||
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
|
||||
this._requestResponseMap.set(requestId, message);
|
||||
const relatedIds = Array.from(this._requestToStreamMapping.entries())
|
||||
.filter(([_, sid]) => sid === streamId)
|
||||
.map(([id]) => id);
|
||||
|
||||
// Check if we have responses for all requests using this connection
|
||||
const allResponsesReady = relatedIds.every(id => this._requestResponseMap.has(id));
|
||||
|
||||
if (allResponsesReady) {
|
||||
if (!stream) {
|
||||
throw new Error(`No connection established for request ID: ${String(requestId)}`);
|
||||
}
|
||||
if (this._enableJsonResponse && stream.resolveJson) {
|
||||
// All responses ready, send as JSON
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (this.sessionId !== undefined) {
|
||||
headers['mcp-session-id'] = this.sessionId;
|
||||
}
|
||||
|
||||
const responses = relatedIds.map(id => this._requestResponseMap.get(id)!);
|
||||
|
||||
if (responses.length === 1) {
|
||||
stream.resolveJson(new Response(JSON.stringify(responses[0]), { status: 200, headers }));
|
||||
} else {
|
||||
stream.resolveJson(new Response(JSON.stringify(responses), { status: 200, headers }));
|
||||
}
|
||||
} else {
|
||||
// End the SSE stream
|
||||
stream.cleanup();
|
||||
}
|
||||
// Clean up
|
||||
for (const id of relatedIds) {
|
||||
this._requestResponseMap.delete(id);
|
||||
this._requestToStreamMapping.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue