├── .git-blame-ignore-revs ├── .npmrc ├── .gitattributes ├── .prettierignore ├── src ├── __mocks__ │ └── pkce-challenge.ts ├── server │ ├── auth │ │ ├── handlers │ │ │ ├── metadata.ts │ │ │ ├── revoke.ts │ │ │ ├── metadata.test.ts │ │ │ ├── register.ts │ │ │ ├── token.ts │ │ │ └── authorize.ts │ │ ├── middleware │ │ │ ├── allowedMethods.ts │ │ │ ├── allowedMethods.test.ts │ │ │ ├── clientAuth.ts │ │ │ ├── bearerAuth.ts │ │ │ └── clientAuth.test.ts │ │ ├── types.ts │ │ ├── clients.ts │ │ ├── provider.ts │ │ └── errors.ts │ ├── completable.test.ts │ ├── stdio.test.ts │ ├── completable.ts │ ├── stdio.ts │ └── sse.ts ├── integration-tests │ └── process-cleanup.test.ts ├── shared │ ├── metadataUtils.ts │ ├── stdio.ts │ ├── stdio.test.ts │ ├── auth-utils.ts │ ├── transport.ts │ ├── auth-utils.test.ts │ ├── auth.test.ts │ └── protocol-transport-handling.test.ts ├── validation │ ├── index.ts │ ├── types.ts │ ├── cfworker-provider.ts │ └── ajv-provider.ts ├── examples │ ├── server │ │ ├── toolWithSampleServer.ts │ │ ├── mcpServerOutputSchema.ts │ │ ├── standaloneSseWithGetStreamableHttp.ts │ │ ├── simpleStatelessStreamableHttp.ts │ │ ├── simpleSseServer.ts │ │ └── jsonResponseStreamableHttp.ts │ ├── shared │ │ └── inMemoryEventStore.ts │ └── client │ │ ├── multipleClientsParallel.ts │ │ ├── streamableHttpWithSseFallbackClient.ts │ │ └── parallelToolCallsClient.ts ├── client │ ├── stdio.test.ts │ ├── websocket.ts │ ├── cross-spawn.test.ts │ └── stdio.ts ├── inMemory.ts ├── inMemory.test.ts └── cli.ts ├── tsconfig.prod.json ├── tsconfig.cjs.json ├── .prettierrc.json ├── jest.config.js ├── .github ├── CODEOWNERS └── workflows │ └── main.yml ├── tsconfig.json ├── eslint.config.mjs ├── SECURITY.md ├── LICENSE ├── CLAUDE.md ├── CONTRIBUTING.md ├── .gitignore ├── package.json └── CODE_OF_CONDUCT.md /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry = "https://registry.npmjs.org/" 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | package-lock.json linguist-generated=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | dist 4 | coverage 5 | *-lock.* 6 | node_modules 7 | **/build 8 | **/dist 9 | .github/CODEOWNERS 10 | pnpm-lock.yaml -------------------------------------------------------------------------------- /src/__mocks__/pkce-challenge.ts: -------------------------------------------------------------------------------- 1 | export default function pkceChallenge() { 2 | return { 3 | code_verifier: 'test_verifier', 4 | code_challenge: 'test_challenge' 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist/esm" 5 | }, 6 | "exclude": ["**/*.test.ts", "src/__mocks__/**/*"] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "outDir": "./dist/cjs" 7 | }, 8 | "exclude": ["**/*.test.ts", "src/__mocks__/**/*"] 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "bracketSameLine": false, 10 | "proseWrap": "always", 11 | "arrowParens": "avoid", 12 | "overrides": [ 13 | { 14 | "files": "**/*.md", 15 | "options": { 16 | "printWidth": 280 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | import { createDefaultEsmPreset } from 'ts-jest'; 2 | 3 | const defaultEsmPreset = createDefaultEsmPreset(); 4 | 5 | /** @type {import('ts-jest').JestConfigWithTsJest} **/ 6 | export default { 7 | ...defaultEsmPreset, 8 | moduleNameMapper: { 9 | '^(\\.{1,2}/.*)\\.js$': '$1', 10 | '^pkce-challenge$': '/src/__mocks__/pkce-challenge.ts' 11 | }, 12 | transformIgnorePatterns: ['/node_modules/(?!eventsource)/'], 13 | testPathIgnorePatterns: ['/node_modules/', '/dist/'] 14 | }; 15 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # TypeScript SDK Code Owners 2 | 3 | # Default owners for everything in the repo 4 | * @modelcontextprotocol/typescript-sdk 5 | 6 | # Auth team owns all auth-related code 7 | /src/server/auth/ @modelcontextprotocol/typescript-sdk-auth 8 | /src/client/auth* @modelcontextprotocol/typescript-sdk-auth 9 | /src/shared/auth* @modelcontextprotocol/typescript-sdk-auth 10 | /src/examples/client/simpleOAuthClient.ts @modelcontextprotocol/typescript-sdk-auth 11 | /src/examples/server/demoInMemoryOAuthProvider.ts @modelcontextprotocol/typescript-sdk-auth -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "sourceMap": true, 9 | "outDir": "./dist", 10 | "strict": true, 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "skipLibCheck": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "pkce-challenge": ["node_modules/pkce-challenge/dist/index.node"] 19 | } 20 | }, 21 | "include": ["src/**/*"], 22 | "exclude": ["node_modules", "dist"] 23 | } 24 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | import eslintConfigPrettier from 'eslint-config-prettier/flat'; 6 | 7 | export default tseslint.config( 8 | eslint.configs.recommended, 9 | ...tseslint.configs.recommended, 10 | { 11 | linterOptions: { 12 | reportUnusedDisableDirectives: false 13 | }, 14 | rules: { 15 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }] 16 | } 17 | }, 18 | { 19 | files: ['src/client/**/*.ts', 'src/server/**/*.ts'], 20 | ignores: ['**/*.test.ts'], 21 | rules: { 22 | 'no-console': 'error' 23 | } 24 | }, 25 | eslintConfigPrettier 26 | ); 27 | -------------------------------------------------------------------------------- /src/server/auth/handlers/metadata.ts: -------------------------------------------------------------------------------- 1 | import express, { RequestHandler } from 'express'; 2 | import { OAuthMetadata, OAuthProtectedResourceMetadata } from '../../../shared/auth.js'; 3 | import cors from 'cors'; 4 | import { allowedMethods } from '../middleware/allowedMethods.js'; 5 | 6 | export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler { 7 | // Nested router so we can configure middleware and restrict HTTP method 8 | const router = express.Router(); 9 | 10 | // Configure CORS to allow any origin, to make accessible to web-based MCP clients 11 | router.use(cors()); 12 | 13 | router.use(allowedMethods(['GET', 'OPTIONS'])); 14 | router.get('/', (req, res) => { 15 | res.status(200).json(metadata); 16 | }); 17 | 18 | return router; 19 | } 20 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Thank you for helping us keep the SDKs and systems they interact with secure. 4 | 5 | ## Reporting Security Issues 6 | 7 | This SDK is maintained by [Anthropic](https://www.anthropic.com/) as part of the Model Context Protocol project. 8 | 9 | The security of our systems and user data is Anthropic’s top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities. 10 | 11 | Our security program is managed on HackerOne and we ask that any validated vulnerability in this functionality be reported through their [submission form](https://hackerone.com/anthropic-vdp/reports/new?type=team&report_type=vulnerability). 12 | 13 | ## Vulnerability Disclosure Program 14 | 15 | Our Vulnerability Program Guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic-vdp). 16 | -------------------------------------------------------------------------------- /src/server/auth/middleware/allowedMethods.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | import { MethodNotAllowedError } from '../errors.js'; 3 | 4 | /** 5 | * Middleware to handle unsupported HTTP methods with a 405 Method Not Allowed response. 6 | * 7 | * @param allowedMethods Array of allowed HTTP methods for this endpoint (e.g., ['GET', 'POST']) 8 | * @returns Express middleware that returns a 405 error if method not in allowed list 9 | */ 10 | export function allowedMethods(allowedMethods: string[]): RequestHandler { 11 | return (req, res, next) => { 12 | if (allowedMethods.includes(req.method)) { 13 | next(); 14 | return; 15 | } 16 | 17 | const error = new MethodNotAllowedError(`The method ${req.method} is not allowed for this endpoint`); 18 | res.status(405).set('Allow', allowedMethods.join(', ')).json(error.toResponseObject()); 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/integration-tests/process-cleanup.test.ts: -------------------------------------------------------------------------------- 1 | import { Server } from '../server/index.js'; 2 | import { StdioServerTransport } from '../server/stdio.js'; 3 | 4 | describe('Process cleanup', () => { 5 | jest.setTimeout(5000); // 5 second timeout 6 | 7 | it('should exit cleanly after closing transport', async () => { 8 | const server = new Server( 9 | { 10 | name: 'test-server', 11 | version: '1.0.0' 12 | }, 13 | { 14 | capabilities: {} 15 | } 16 | ); 17 | 18 | const transport = new StdioServerTransport(); 19 | await server.connect(transport); 20 | 21 | // Close the transport 22 | await transport.close(); 23 | 24 | // If we reach here without hanging, the test passes 25 | // The test runner will fail if the process hangs 26 | expect(true).toBe(true); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/server/auth/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Information about a validated access token, provided to request handlers. 3 | */ 4 | export interface AuthInfo { 5 | /** 6 | * The access token. 7 | */ 8 | token: string; 9 | 10 | /** 11 | * The client ID associated with this token. 12 | */ 13 | clientId: string; 14 | 15 | /** 16 | * Scopes associated with this token. 17 | */ 18 | scopes: string[]; 19 | 20 | /** 21 | * When the token expires (in seconds since epoch). 22 | */ 23 | expiresAt?: number; 24 | 25 | /** 26 | * The RFC 8707 resource server identifier for which this token is valid. 27 | * If set, this MUST match the MCP server's resource identifier (minus hash fragment). 28 | */ 29 | resource?: URL; 30 | 31 | /** 32 | * Additional data associated with the token. 33 | * This field should be used for any additional data that needs to be attached to the auth info. 34 | */ 35 | extra?: Record; 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Anthropic, PBC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/shared/metadataUtils.ts: -------------------------------------------------------------------------------- 1 | import { BaseMetadata } from '../types.js'; 2 | 3 | /** 4 | * Utilities for working with BaseMetadata objects. 5 | */ 6 | 7 | /** 8 | * Gets the display name for an object with BaseMetadata. 9 | * For tools, the precedence is: title → annotations.title → name 10 | * For other objects: title → name 11 | * This implements the spec requirement: "if no title is provided, name should be used for display purposes" 12 | */ 13 | export function getDisplayName(metadata: BaseMetadata): string { 14 | // First check for title (not undefined and not empty string) 15 | if (metadata.title !== undefined && metadata.title !== '') { 16 | return metadata.title; 17 | } 18 | 19 | // Then check for annotations.title (only present in Tool objects) 20 | if ('annotations' in metadata) { 21 | const metadataWithAnnotations = metadata as BaseMetadata & { annotations?: { title?: string } }; 22 | if (metadataWithAnnotations.annotations?.title) { 23 | return metadataWithAnnotations.annotations.title; 24 | } 25 | } 26 | 27 | // Finally fall back to name 28 | return metadata.name; 29 | } 30 | -------------------------------------------------------------------------------- /src/shared/stdio.ts: -------------------------------------------------------------------------------- 1 | import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; 2 | 3 | /** 4 | * Buffers a continuous stdio stream into discrete JSON-RPC messages. 5 | */ 6 | export class ReadBuffer { 7 | private _buffer?: Buffer; 8 | 9 | append(chunk: Buffer): void { 10 | this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk; 11 | } 12 | 13 | readMessage(): JSONRPCMessage | null { 14 | if (!this._buffer) { 15 | return null; 16 | } 17 | 18 | const index = this._buffer.indexOf('\n'); 19 | if (index === -1) { 20 | return null; 21 | } 22 | 23 | const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, ''); 24 | this._buffer = this._buffer.subarray(index + 1); 25 | return deserializeMessage(line); 26 | } 27 | 28 | clear(): void { 29 | this._buffer = undefined; 30 | } 31 | } 32 | 33 | export function deserializeMessage(line: string): JSONRPCMessage { 34 | return JSONRPCMessageSchema.parse(JSON.parse(line)); 35 | } 36 | 37 | export function serializeMessage(message: JSONRPCMessage): string { 38 | return JSON.stringify(message) + '\n'; 39 | } 40 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # MCP TypeScript SDK Guide 2 | 3 | ## Build & Test Commands 4 | 5 | ```sh 6 | npm run build # Build ESM and CJS versions 7 | npm run lint # Run ESLint 8 | npm test # Run all tests 9 | npx jest path/to/file.test.ts # Run specific test file 10 | npx jest -t "test name" # Run tests matching pattern 11 | ``` 12 | 13 | ## Code Style Guidelines 14 | 15 | - **TypeScript**: Strict type checking, ES modules, explicit return types 16 | - **Naming**: PascalCase for classes/types, camelCase for functions/variables 17 | - **Files**: Lowercase with hyphens, test files with `.test.ts` suffix 18 | - **Imports**: ES module style, include `.js` extension, group imports logically 19 | - **Error Handling**: Use TypeScript's strict mode, explicit error checking in tests 20 | - **Formatting**: 2-space indentation, semicolons required, single quotes preferred 21 | - **Testing**: Co-locate tests with source files, use descriptive test names 22 | - **Comments**: JSDoc for public APIs, inline comments for complex logic 23 | 24 | ## Project Structure 25 | 26 | - `/src`: Source code with client, server, and shared modules 27 | - Tests alongside source files with `.test.ts` suffix 28 | - Node.js >= 18 required 29 | -------------------------------------------------------------------------------- /src/shared/stdio.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONRPCMessage } from '../types.js'; 2 | import { ReadBuffer } from './stdio.js'; 3 | 4 | const testMessage: JSONRPCMessage = { 5 | jsonrpc: '2.0', 6 | method: 'foobar' 7 | }; 8 | 9 | test('should have no messages after initialization', () => { 10 | const readBuffer = new ReadBuffer(); 11 | expect(readBuffer.readMessage()).toBeNull(); 12 | }); 13 | 14 | test('should only yield a message after a newline', () => { 15 | const readBuffer = new ReadBuffer(); 16 | 17 | readBuffer.append(Buffer.from(JSON.stringify(testMessage))); 18 | expect(readBuffer.readMessage()).toBeNull(); 19 | 20 | readBuffer.append(Buffer.from('\n')); 21 | expect(readBuffer.readMessage()).toEqual(testMessage); 22 | expect(readBuffer.readMessage()).toBeNull(); 23 | }); 24 | 25 | test('should be reusable after clearing', () => { 26 | const readBuffer = new ReadBuffer(); 27 | 28 | readBuffer.append(Buffer.from('foobar')); 29 | readBuffer.clear(); 30 | expect(readBuffer.readMessage()).toBeNull(); 31 | 32 | readBuffer.append(Buffer.from(JSON.stringify(testMessage))); 33 | readBuffer.append(Buffer.from('\n')); 34 | expect(readBuffer.readMessage()).toEqual(testMessage); 35 | }); 36 | -------------------------------------------------------------------------------- /src/validation/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * JSON Schema validation 3 | * 4 | * This module provides configurable JSON Schema validation for the MCP SDK. 5 | * Choose a validator based on your runtime environment: 6 | * 7 | * - AjvJsonSchemaValidator: Best for Node.js (default, fastest) 8 | * Import from: @modelcontextprotocol/sdk/validation/ajv 9 | * Requires peer dependencies: ajv, ajv-formats 10 | * 11 | * - CfWorkerJsonSchemaValidator: Best for edge runtimes 12 | * Import from: @modelcontextprotocol/sdk/validation/cfworker 13 | * Requires peer dependency: @cfworker/json-schema 14 | * 15 | * @example 16 | * ```typescript 17 | * // For Node.js with AJV 18 | * import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; 19 | * const validator = new AjvJsonSchemaValidator(); 20 | * 21 | * // For Cloudflare Workers 22 | * import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/cfworker'; 23 | * const validator = new CfWorkerJsonSchemaValidator(); 24 | * ``` 25 | * 26 | * @module validation 27 | */ 28 | 29 | // Core types only - implementations are exported via separate entry points 30 | export type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './types.js'; 31 | -------------------------------------------------------------------------------- /src/server/auth/clients.ts: -------------------------------------------------------------------------------- 1 | import { OAuthClientInformationFull } from '../../shared/auth.js'; 2 | 3 | /** 4 | * Stores information about registered OAuth clients for this server. 5 | */ 6 | export interface OAuthRegisteredClientsStore { 7 | /** 8 | * Returns information about a registered client, based on its ID. 9 | */ 10 | getClient(clientId: string): OAuthClientInformationFull | undefined | Promise; 11 | 12 | /** 13 | * 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. 14 | * 15 | * 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. 16 | * 17 | * If unimplemented, dynamic client registration is unsupported. 18 | */ 19 | registerClient?( 20 | client: Omit 21 | ): OAuthClientInformationFull | Promise; 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | release: 7 | types: [published] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: 18 22 | cache: npm 23 | 24 | - run: npm ci 25 | - run: npm run build 26 | - run: npm test 27 | - run: npm run lint 28 | 29 | publish: 30 | runs-on: ubuntu-latest 31 | if: github.event_name == 'release' 32 | environment: release 33 | needs: build 34 | 35 | permissions: 36 | contents: read 37 | id-token: write 38 | 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: actions/setup-node@v4 42 | with: 43 | node-version: 18 44 | cache: npm 45 | registry-url: 'https://registry.npmjs.org' 46 | 47 | - run: npm ci 48 | 49 | - run: npm publish --provenance --access public 50 | env: 51 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 52 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to MCP TypeScript SDK 2 | 3 | We welcome contributions to the Model Context Protocol TypeScript SDK! This document outlines the process for contributing to the project. 4 | 5 | ## Getting Started 6 | 7 | 1. Fork the repository 8 | 2. Clone your fork: `git clone https://github.com/YOUR-USERNAME/typescript-sdk.git` 9 | 3. Install dependencies: `npm install` 10 | 4. Build the project: `npm run build` 11 | 5. Run tests: `npm test` 12 | 13 | ## Development Process 14 | 15 | 1. Create a new branch for your changes 16 | 2. Make your changes 17 | 3. Run `npm run lint` to ensure code style compliance 18 | 4. Run `npm test` to verify all tests pass 19 | 5. Submit a pull request 20 | 21 | ## Pull Request Guidelines 22 | 23 | - Follow the existing code style 24 | - Include tests for new functionality 25 | - Update documentation as needed 26 | - Keep changes focused and atomic 27 | - Provide a clear description of changes 28 | 29 | ## Running Examples 30 | 31 | - Start the server: `npm run server` 32 | - Run the client: `npm run client` 33 | 34 | ## Code of Conduct 35 | 36 | This project follows our [Code of Conduct](CODE_OF_CONDUCT.md). Please review it before contributing. 37 | 38 | ## Reporting Issues 39 | 40 | - Use the [GitHub issue tracker](https://github.com/modelcontextprotocol/typescript-sdk/issues) 41 | - Search existing issues before creating a new one 42 | - Provide clear reproduction steps 43 | 44 | ## Security Issues 45 | 46 | Please review our [Security Policy](SECURITY.md) for reporting security vulnerabilities. 47 | 48 | ## License 49 | 50 | By contributing, you agree that your contributions will be licensed under the MIT License. 51 | -------------------------------------------------------------------------------- /src/server/completable.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { completable } from './completable.js'; 3 | 4 | describe('completable', () => { 5 | it('preserves types and values of underlying schema', () => { 6 | const baseSchema = z.string(); 7 | const schema = completable(baseSchema, () => []); 8 | 9 | expect(schema.parse('test')).toBe('test'); 10 | expect(() => schema.parse(123)).toThrow(); 11 | }); 12 | 13 | it('provides access to completion function', async () => { 14 | const completions = ['foo', 'bar', 'baz']; 15 | const schema = completable(z.string(), () => completions); 16 | 17 | expect(await schema._def.complete('')).toEqual(completions); 18 | }); 19 | 20 | it('allows async completion functions', async () => { 21 | const completions = ['foo', 'bar', 'baz']; 22 | const schema = completable(z.string(), async () => completions); 23 | 24 | expect(await schema._def.complete('')).toEqual(completions); 25 | }); 26 | 27 | it('passes current value to completion function', async () => { 28 | const schema = completable(z.string(), value => [value + '!']); 29 | 30 | expect(await schema._def.complete('test')).toEqual(['test!']); 31 | }); 32 | 33 | it('works with number schemas', async () => { 34 | const schema = completable(z.number(), () => [1, 2, 3]); 35 | 36 | expect(schema.parse(1)).toBe(1); 37 | expect(await schema._def.complete(0)).toEqual([1, 2, 3]); 38 | }); 39 | 40 | it('preserves schema description', () => { 41 | const desc = 'test description'; 42 | const schema = completable(z.string().describe(desc), () => []); 43 | 44 | expect(schema.description).toBe(desc); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/examples/server/toolWithSampleServer.ts: -------------------------------------------------------------------------------- 1 | // Run with: npx tsx src/examples/server/toolWithSampleServer.ts 2 | 3 | import { McpServer } from '../../server/mcp.js'; 4 | import { StdioServerTransport } from '../../server/stdio.js'; 5 | import { z } from 'zod'; 6 | 7 | const mcpServer = new McpServer({ 8 | name: 'tools-with-sample-server', 9 | version: '1.0.0' 10 | }); 11 | 12 | // Tool that uses LLM sampling to summarize any text 13 | mcpServer.registerTool( 14 | 'summarize', 15 | { 16 | description: 'Summarize any text using an LLM', 17 | inputSchema: { 18 | text: z.string().describe('Text to summarize') 19 | } 20 | }, 21 | async ({ text }) => { 22 | // Call the LLM through MCP sampling 23 | const response = await mcpServer.server.createMessage({ 24 | messages: [ 25 | { 26 | role: 'user', 27 | content: { 28 | type: 'text', 29 | text: `Please summarize the following text concisely:\n\n${text}` 30 | } 31 | } 32 | ], 33 | maxTokens: 500 34 | }); 35 | 36 | return { 37 | content: [ 38 | { 39 | type: 'text', 40 | text: response.content.type === 'text' ? response.content.text : 'Unable to generate summary' 41 | } 42 | ] 43 | }; 44 | } 45 | ); 46 | 47 | async function main() { 48 | const transport = new StdioServerTransport(); 49 | await mcpServer.connect(transport); 50 | console.log('MCP server is running...'); 51 | } 52 | 53 | main().catch(error => { 54 | console.error('Server error:', error); 55 | process.exit(1); 56 | }); 57 | -------------------------------------------------------------------------------- /src/validation/types.ts: -------------------------------------------------------------------------------- 1 | import type { Schema } from '@cfworker/json-schema'; 2 | 3 | /** 4 | * Result of a JSON Schema validation operation 5 | */ 6 | export type JsonSchemaValidatorResult = 7 | | { valid: true; data: T; errorMessage: undefined } 8 | | { valid: false; data: undefined; errorMessage: string }; 9 | 10 | /** 11 | * A validator function that validates data against a JSON Schema 12 | */ 13 | export type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; 14 | 15 | /** 16 | * Provider interface for creating validators from JSON Schemas 17 | * 18 | * This is the main extension point for custom validator implementations. 19 | * Implementations should: 20 | * - Support JSON Schema Draft 2020-12 (or be compatible with it) 21 | * - Return validator functions that can be called multiple times 22 | * - Handle schema compilation/caching internally 23 | * - Provide clear error messages on validation failure 24 | * 25 | * @example 26 | * ```typescript 27 | * class MyValidatorProvider implements jsonSchemaValidator { 28 | * getValidator(schema: JsonSchemaType): JsonSchemaValidator { 29 | * // Compile/cache validator from schema 30 | * return (input: unknown) => { 31 | * // Validate input against schema 32 | * if (valid) { 33 | * return { valid: true, data: input as T, errorMessage: undefined }; 34 | * } else { 35 | * return { valid: false, data: undefined, errorMessage: 'Error details' }; 36 | * } 37 | * }; 38 | * } 39 | * } 40 | * ``` 41 | */ 42 | export interface jsonSchemaValidator { 43 | /** 44 | * Create a validator for the given JSON Schema 45 | * 46 | * @param schema - Standard JSON Schema object 47 | * @returns A validator function that can be called multiple times 48 | */ 49 | getValidator(schema: JsonSchemaType): JsonSchemaValidator; 50 | } 51 | 52 | export type JsonSchemaType = Schema; 53 | -------------------------------------------------------------------------------- /src/client/stdio.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONRPCMessage } from '../types.js'; 2 | import { StdioClientTransport, StdioServerParameters } from './stdio.js'; 3 | 4 | // Configure default server parameters based on OS 5 | // Uses 'more' command for Windows and 'tee' command for Unix/Linux 6 | const getDefaultServerParameters = (): StdioServerParameters => { 7 | if (process.platform === 'win32') { 8 | return { command: 'more' }; 9 | } 10 | return { command: '/usr/bin/tee' }; 11 | }; 12 | 13 | const serverParameters = getDefaultServerParameters(); 14 | 15 | test('should start then close cleanly', async () => { 16 | const client = new StdioClientTransport(serverParameters); 17 | client.onerror = error => { 18 | throw error; 19 | }; 20 | 21 | let didClose = false; 22 | client.onclose = () => { 23 | didClose = true; 24 | }; 25 | 26 | await client.start(); 27 | expect(didClose).toBeFalsy(); 28 | await client.close(); 29 | expect(didClose).toBeTruthy(); 30 | }); 31 | 32 | test('should read messages', async () => { 33 | const client = new StdioClientTransport(serverParameters); 34 | client.onerror = error => { 35 | throw error; 36 | }; 37 | 38 | const messages: JSONRPCMessage[] = [ 39 | { 40 | jsonrpc: '2.0', 41 | id: 1, 42 | method: 'ping' 43 | }, 44 | { 45 | jsonrpc: '2.0', 46 | method: 'notifications/initialized' 47 | } 48 | ]; 49 | 50 | const readMessages: JSONRPCMessage[] = []; 51 | const finished = new Promise(resolve => { 52 | client.onmessage = message => { 53 | readMessages.push(message); 54 | 55 | if (JSON.stringify(message) === JSON.stringify(messages[1])) { 56 | resolve(); 57 | } 58 | }; 59 | }); 60 | 61 | await client.start(); 62 | await client.send(messages[0]); 63 | await client.send(messages[1]); 64 | await finished; 65 | expect(readMessages).toEqual(messages); 66 | 67 | await client.close(); 68 | }); 69 | 70 | test('should return child process pid', async () => { 71 | const client = new StdioClientTransport(serverParameters); 72 | 73 | await client.start(); 74 | expect(client.pid).not.toBeNull(); 75 | await client.close(); 76 | expect(client.pid).toBeNull(); 77 | }); 78 | -------------------------------------------------------------------------------- /src/client/websocket.ts: -------------------------------------------------------------------------------- 1 | import { Transport } from '../shared/transport.js'; 2 | import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; 3 | 4 | const SUBPROTOCOL = 'mcp'; 5 | 6 | /** 7 | * Client transport for WebSocket: this will connect to a server over the WebSocket protocol. 8 | */ 9 | export class WebSocketClientTransport implements Transport { 10 | private _socket?: WebSocket; 11 | private _url: URL; 12 | 13 | onclose?: () => void; 14 | onerror?: (error: Error) => void; 15 | onmessage?: (message: JSONRPCMessage) => void; 16 | 17 | constructor(url: URL) { 18 | this._url = url; 19 | } 20 | 21 | start(): Promise { 22 | if (this._socket) { 23 | throw new Error( 24 | 'WebSocketClientTransport already started! If using Client class, note that connect() calls start() automatically.' 25 | ); 26 | } 27 | 28 | return new Promise((resolve, reject) => { 29 | this._socket = new WebSocket(this._url, SUBPROTOCOL); 30 | 31 | this._socket.onerror = event => { 32 | const error = 'error' in event ? (event.error as Error) : new Error(`WebSocket error: ${JSON.stringify(event)}`); 33 | reject(error); 34 | this.onerror?.(error); 35 | }; 36 | 37 | this._socket.onopen = () => { 38 | resolve(); 39 | }; 40 | 41 | this._socket.onclose = () => { 42 | this.onclose?.(); 43 | }; 44 | 45 | this._socket.onmessage = (event: MessageEvent) => { 46 | let message: JSONRPCMessage; 47 | try { 48 | message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); 49 | } catch (error) { 50 | this.onerror?.(error as Error); 51 | return; 52 | } 53 | 54 | this.onmessage?.(message); 55 | }; 56 | }); 57 | } 58 | 59 | async close(): Promise { 60 | this._socket?.close(); 61 | } 62 | 63 | send(message: JSONRPCMessage): Promise { 64 | return new Promise((resolve, reject) => { 65 | if (!this._socket) { 66 | reject(new Error('Not connected')); 67 | return; 68 | } 69 | 70 | this._socket?.send(JSON.stringify(message)); 71 | resolve(); 72 | }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/inMemory.ts: -------------------------------------------------------------------------------- 1 | import { Transport } from './shared/transport.js'; 2 | import { JSONRPCMessage, RequestId } from './types.js'; 3 | import { AuthInfo } from './server/auth/types.js'; 4 | 5 | interface QueuedMessage { 6 | message: JSONRPCMessage; 7 | extra?: { authInfo?: AuthInfo }; 8 | } 9 | 10 | /** 11 | * In-memory transport for creating clients and servers that talk to each other within the same process. 12 | */ 13 | export class InMemoryTransport implements Transport { 14 | private _otherTransport?: InMemoryTransport; 15 | private _messageQueue: QueuedMessage[] = []; 16 | 17 | onclose?: () => void; 18 | onerror?: (error: Error) => void; 19 | onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; 20 | sessionId?: string; 21 | 22 | /** 23 | * Creates a pair of linked in-memory transports that can communicate with each other. One should be passed to a Client and one to a Server. 24 | */ 25 | static createLinkedPair(): [InMemoryTransport, InMemoryTransport] { 26 | const clientTransport = new InMemoryTransport(); 27 | const serverTransport = new InMemoryTransport(); 28 | clientTransport._otherTransport = serverTransport; 29 | serverTransport._otherTransport = clientTransport; 30 | return [clientTransport, serverTransport]; 31 | } 32 | 33 | async start(): Promise { 34 | // Process any messages that were queued before start was called 35 | while (this._messageQueue.length > 0) { 36 | const queuedMessage = this._messageQueue.shift()!; 37 | this.onmessage?.(queuedMessage.message, queuedMessage.extra); 38 | } 39 | } 40 | 41 | async close(): Promise { 42 | const other = this._otherTransport; 43 | this._otherTransport = undefined; 44 | await other?.close(); 45 | this.onclose?.(); 46 | } 47 | 48 | /** 49 | * Sends a message with optional auth info. 50 | * This is useful for testing authentication scenarios. 51 | */ 52 | async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId; authInfo?: AuthInfo }): Promise { 53 | if (!this._otherTransport) { 54 | throw new Error('Not connected'); 55 | } 56 | 57 | if (this._otherTransport.onmessage) { 58 | this._otherTransport.onmessage(message, { authInfo: options?.authInfo }); 59 | } else { 60 | this._otherTransport._messageQueue.push({ message, extra: { authInfo: options?.authInfo } }); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/shared/auth-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utilities for handling OAuth resource URIs. 3 | */ 4 | 5 | /** 6 | * Converts a server URL to a resource URL by removing the fragment. 7 | * RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component". 8 | * Keeps everything else unchanged (scheme, domain, port, path, query). 9 | */ 10 | export function resourceUrlFromServerUrl(url: URL | string): URL { 11 | const resourceURL = typeof url === 'string' ? new URL(url) : new URL(url.href); 12 | resourceURL.hash = ''; // Remove fragment 13 | return resourceURL; 14 | } 15 | 16 | /** 17 | * Checks if a requested resource URL matches a configured resource URL. 18 | * A requested resource matches if it has the same scheme, domain, port, 19 | * and its path starts with the configured resource's path. 20 | * 21 | * @param requestedResource The resource URL being requested 22 | * @param configuredResource The resource URL that has been configured 23 | * @returns true if the requested resource matches the configured resource, false otherwise 24 | */ 25 | export function checkResourceAllowed({ 26 | requestedResource, 27 | configuredResource 28 | }: { 29 | requestedResource: URL | string; 30 | configuredResource: URL | string; 31 | }): boolean { 32 | const requested = typeof requestedResource === 'string' ? new URL(requestedResource) : new URL(requestedResource.href); 33 | const configured = typeof configuredResource === 'string' ? new URL(configuredResource) : new URL(configuredResource.href); 34 | 35 | // Compare the origin (scheme, domain, and port) 36 | if (requested.origin !== configured.origin) { 37 | return false; 38 | } 39 | 40 | // Handle cases like requested=/foo and configured=/foo/ 41 | if (requested.pathname.length < configured.pathname.length) { 42 | return false; 43 | } 44 | 45 | // Check if the requested path starts with the configured path 46 | // Ensure both paths end with / for proper comparison 47 | // This ensures that if we have paths like "/api" and "/api/users", 48 | // we properly detect that "/api/users" is a subpath of "/api" 49 | // By adding a trailing slash if missing, we avoid false positives 50 | // where paths like "/api123" would incorrectly match "/api" 51 | const requestedPath = requested.pathname.endsWith('/') ? requested.pathname : requested.pathname + '/'; 52 | const configuredPath = configured.pathname.endsWith('/') ? configured.pathname : configured.pathname + '/'; 53 | 54 | return requestedPath.startsWith(configuredPath); 55 | } 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Output of 'npm run fetch:spec-types' 73 | spec.types.ts 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variable files 79 | .env 80 | .env.development.local 81 | .env.test.local 82 | .env.production.local 83 | .env.local 84 | 85 | # parcel-bundler cache (https://parceljs.org/) 86 | .cache 87 | .parcel-cache 88 | 89 | # Next.js build output 90 | .next 91 | out 92 | 93 | # Nuxt.js build / generate output 94 | .nuxt 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and not Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # vuepress v2.x temp and cache directory 106 | .temp 107 | .cache 108 | 109 | # Docusaurus cache and generated files 110 | .docusaurus 111 | 112 | # Serverless directories 113 | .serverless/ 114 | 115 | # FuseBox cache 116 | .fusebox/ 117 | 118 | # DynamoDB Local files 119 | .dynamodb/ 120 | 121 | # TernJS port file 122 | .tern-port 123 | 124 | # Stores VSCode versions used for testing VSCode extensions 125 | .vscode-test 126 | 127 | # yarn v2 128 | .yarn/cache 129 | .yarn/unplugged 130 | .yarn/build-state.yml 131 | .yarn/install-state.gz 132 | .pnp.* 133 | 134 | .DS_Store 135 | dist/ 136 | -------------------------------------------------------------------------------- /src/examples/server/mcpServerOutputSchema.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Example MCP server using the high-level McpServer API with outputSchema 4 | * This demonstrates how to easily create tools with structured output 5 | */ 6 | 7 | import { McpServer } from '../../server/mcp.js'; 8 | import { StdioServerTransport } from '../../server/stdio.js'; 9 | import { z } from 'zod'; 10 | 11 | const server = new McpServer({ 12 | name: 'mcp-output-schema-high-level-example', 13 | version: '1.0.0' 14 | }); 15 | 16 | // Define a tool with structured output - Weather data 17 | server.registerTool( 18 | 'get_weather', 19 | { 20 | description: 'Get weather information for a city', 21 | inputSchema: { 22 | city: z.string().describe('City name'), 23 | country: z.string().describe('Country code (e.g., US, UK)') 24 | }, 25 | outputSchema: { 26 | temperature: z.object({ 27 | celsius: z.number(), 28 | fahrenheit: z.number() 29 | }), 30 | conditions: z.enum(['sunny', 'cloudy', 'rainy', 'stormy', 'snowy']), 31 | humidity: z.number().min(0).max(100), 32 | wind: z.object({ 33 | speed_kmh: z.number(), 34 | direction: z.string() 35 | }) 36 | } 37 | }, 38 | async ({ city, country }) => { 39 | // Parameters are available but not used in this example 40 | void city; 41 | void country; 42 | // Simulate weather API call 43 | const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; 44 | const conditions = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'][Math.floor(Math.random() * 5)]; 45 | 46 | const structuredContent = { 47 | temperature: { 48 | celsius: temp_c, 49 | fahrenheit: Math.round(((temp_c * 9) / 5 + 32) * 10) / 10 50 | }, 51 | conditions, 52 | humidity: Math.round(Math.random() * 100), 53 | wind: { 54 | speed_kmh: Math.round(Math.random() * 50), 55 | direction: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)] 56 | } 57 | }; 58 | 59 | return { 60 | content: [ 61 | { 62 | type: 'text', 63 | text: JSON.stringify(structuredContent, null, 2) 64 | } 65 | ], 66 | structuredContent 67 | }; 68 | } 69 | ); 70 | 71 | async function main() { 72 | const transport = new StdioServerTransport(); 73 | await server.connect(transport); 74 | console.error('High-level Output Schema Example Server running on stdio'); 75 | } 76 | 77 | main().catch(error => { 78 | console.error('Server error:', error); 79 | process.exit(1); 80 | }); 81 | -------------------------------------------------------------------------------- /src/examples/shared/inMemoryEventStore.ts: -------------------------------------------------------------------------------- 1 | import { JSONRPCMessage } from '../../types.js'; 2 | import { EventStore } from '../../server/streamableHttp.js'; 3 | 4 | /** 5 | * Simple in-memory implementation of the EventStore interface for resumability 6 | * This is primarily intended for examples and testing, not for production use 7 | * where a persistent storage solution would be more appropriate. 8 | */ 9 | export class InMemoryEventStore implements EventStore { 10 | private events: Map = new Map(); 11 | 12 | /** 13 | * Generates a unique event ID for a given stream ID 14 | */ 15 | private generateEventId(streamId: string): string { 16 | return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; 17 | } 18 | 19 | /** 20 | * Extracts the stream ID from an event ID 21 | */ 22 | private getStreamIdFromEventId(eventId: string): string { 23 | const parts = eventId.split('_'); 24 | return parts.length > 0 ? parts[0] : ''; 25 | } 26 | 27 | /** 28 | * Stores an event with a generated event ID 29 | * Implements EventStore.storeEvent 30 | */ 31 | async storeEvent(streamId: string, message: JSONRPCMessage): Promise { 32 | const eventId = this.generateEventId(streamId); 33 | this.events.set(eventId, { streamId, message }); 34 | return eventId; 35 | } 36 | 37 | /** 38 | * Replays events that occurred after a specific event ID 39 | * Implements EventStore.replayEventsAfter 40 | */ 41 | async replayEventsAfter( 42 | lastEventId: string, 43 | { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise } 44 | ): Promise { 45 | if (!lastEventId || !this.events.has(lastEventId)) { 46 | return ''; 47 | } 48 | 49 | // Extract the stream ID from the event ID 50 | const streamId = this.getStreamIdFromEventId(lastEventId); 51 | if (!streamId) { 52 | return ''; 53 | } 54 | 55 | let foundLastEvent = false; 56 | 57 | // Sort events by eventId for chronological ordering 58 | const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); 59 | 60 | for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { 61 | // Only include events from the same stream 62 | if (eventStreamId !== streamId) { 63 | continue; 64 | } 65 | 66 | // Start sending events after we find the lastEventId 67 | if (eventId === lastEventId) { 68 | foundLastEvent = true; 69 | continue; 70 | } 71 | 72 | if (foundLastEvent) { 73 | await send(eventId, message); 74 | } 75 | } 76 | return streamId; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/server/auth/middleware/allowedMethods.test.ts: -------------------------------------------------------------------------------- 1 | import { allowedMethods } from './allowedMethods.js'; 2 | import express, { Request, Response } from 'express'; 3 | import request from 'supertest'; 4 | 5 | describe('allowedMethods', () => { 6 | let app: express.Express; 7 | 8 | beforeEach(() => { 9 | app = express(); 10 | 11 | // Set up a test router with a GET handler and 405 middleware 12 | const router = express.Router(); 13 | 14 | router.get('/test', (req, res) => { 15 | res.status(200).send('GET success'); 16 | }); 17 | 18 | // Add method not allowed middleware for all other methods 19 | router.all('/test', allowedMethods(['GET'])); 20 | 21 | app.use(router); 22 | }); 23 | 24 | test('allows specified HTTP method', async () => { 25 | const response = await request(app).get('/test'); 26 | expect(response.status).toBe(200); 27 | expect(response.text).toBe('GET success'); 28 | }); 29 | 30 | test('returns 405 for unspecified HTTP methods', async () => { 31 | const methods = ['post', 'put', 'delete', 'patch']; 32 | 33 | for (const method of methods) { 34 | // @ts-expect-error - dynamic method call 35 | const response = await request(app)[method]('/test'); 36 | expect(response.status).toBe(405); 37 | expect(response.body).toEqual({ 38 | error: 'method_not_allowed', 39 | error_description: `The method ${method.toUpperCase()} is not allowed for this endpoint` 40 | }); 41 | } 42 | }); 43 | 44 | test('includes Allow header with specified methods', async () => { 45 | const response = await request(app).post('/test'); 46 | expect(response.headers.allow).toBe('GET'); 47 | }); 48 | 49 | test('works with multiple allowed methods', async () => { 50 | const multiMethodApp = express(); 51 | const router = express.Router(); 52 | 53 | router.get('/multi', (req: Request, res: Response) => { 54 | res.status(200).send('GET'); 55 | }); 56 | router.post('/multi', (req: Request, res: Response) => { 57 | res.status(200).send('POST'); 58 | }); 59 | router.all('/multi', allowedMethods(['GET', 'POST'])); 60 | 61 | multiMethodApp.use(router); 62 | 63 | // Allowed methods should work 64 | const getResponse = await request(multiMethodApp).get('/multi'); 65 | expect(getResponse.status).toBe(200); 66 | 67 | const postResponse = await request(multiMethodApp).post('/multi'); 68 | expect(postResponse.status).toBe(200); 69 | 70 | // Unallowed methods should return 405 71 | const putResponse = await request(multiMethodApp).put('/multi'); 72 | expect(putResponse.status).toBe(405); 73 | expect(putResponse.headers.allow).toBe('GET, POST'); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/validation/cfworker-provider.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Cloudflare Worker-compatible JSON Schema validator provider 3 | * 4 | * This provider uses @cfworker/json-schema for validation without code generation, 5 | * making it compatible with edge runtimes like Cloudflare Workers that restrict 6 | * eval and new Function. 7 | * 8 | */ 9 | 10 | import { type Schema, Validator } from '@cfworker/json-schema'; 11 | import type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './types.js'; 12 | 13 | /** 14 | * JSON Schema draft version supported by @cfworker/json-schema 15 | */ 16 | export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; 17 | 18 | /** 19 | * 20 | * @example 21 | * ```typescript 22 | * // Use with default configuration (2020-12, shortcircuit) 23 | * const validator = new CfWorkerJsonSchemaValidator(); 24 | * 25 | * // Use with custom configuration 26 | * const validator = new CfWorkerJsonSchemaValidator({ 27 | * draft: '2020-12', 28 | * shortcircuit: false // Report all errors 29 | * }); 30 | * ``` 31 | */ 32 | export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { 33 | private shortcircuit: boolean; 34 | private draft: CfWorkerSchemaDraft; 35 | 36 | /** 37 | * Create a validator 38 | * 39 | * @param options - Configuration options 40 | * @param options.shortcircuit - If true, stop validation after first error (default: true) 41 | * @param options.draft - JSON Schema draft version to use (default: '2020-12') 42 | */ 43 | constructor(options?: { shortcircuit?: boolean; draft?: CfWorkerSchemaDraft }) { 44 | this.shortcircuit = options?.shortcircuit ?? true; 45 | this.draft = options?.draft ?? '2020-12'; 46 | } 47 | 48 | /** 49 | * Create a validator for the given JSON Schema 50 | * 51 | * Unlike AJV, this validator is not cached internally 52 | * 53 | * @param schema - Standard JSON Schema object 54 | * @returns A validator function that validates input data 55 | */ 56 | getValidator(schema: JsonSchemaType): JsonSchemaValidator { 57 | const cfSchema = schema as unknown as Schema; 58 | const validator = new Validator(cfSchema, this.draft, this.shortcircuit); 59 | 60 | return (input: unknown): JsonSchemaValidatorResult => { 61 | const result = validator.validate(input); 62 | 63 | if (result.valid) { 64 | return { 65 | valid: true, 66 | data: input as T, 67 | errorMessage: undefined 68 | }; 69 | } else { 70 | return { 71 | valid: false, 72 | data: undefined, 73 | errorMessage: result.errors.map(err => `${err.instanceLocation}: ${err.error}`).join('; ') 74 | }; 75 | } 76 | }; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/server/auth/middleware/clientAuth.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { RequestHandler } from 'express'; 3 | import { OAuthRegisteredClientsStore } from '../clients.js'; 4 | import { OAuthClientInformationFull } from '../../../shared/auth.js'; 5 | import { InvalidRequestError, InvalidClientError, ServerError, OAuthError } from '../errors.js'; 6 | 7 | export type ClientAuthenticationMiddlewareOptions = { 8 | /** 9 | * A store used to read information about registered OAuth clients. 10 | */ 11 | clientsStore: OAuthRegisteredClientsStore; 12 | }; 13 | 14 | const ClientAuthenticatedRequestSchema = z.object({ 15 | client_id: z.string(), 16 | client_secret: z.string().optional() 17 | }); 18 | 19 | declare module 'express-serve-static-core' { 20 | interface Request { 21 | /** 22 | * The authenticated client for this request, if the `authenticateClient` middleware was used. 23 | */ 24 | client?: OAuthClientInformationFull; 25 | } 26 | } 27 | 28 | export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlewareOptions): RequestHandler { 29 | return async (req, res, next) => { 30 | try { 31 | const result = ClientAuthenticatedRequestSchema.safeParse(req.body); 32 | if (!result.success) { 33 | throw new InvalidRequestError(String(result.error)); 34 | } 35 | 36 | const { client_id, client_secret } = result.data; 37 | const client = await clientsStore.getClient(client_id); 38 | if (!client) { 39 | throw new InvalidClientError('Invalid client_id'); 40 | } 41 | 42 | // If client has a secret, validate it 43 | if (client.client_secret) { 44 | // Check if client_secret is required but not provided 45 | if (!client_secret) { 46 | throw new InvalidClientError('Client secret is required'); 47 | } 48 | 49 | // Check if client_secret matches 50 | if (client.client_secret !== client_secret) { 51 | throw new InvalidClientError('Invalid client_secret'); 52 | } 53 | 54 | // Check if client_secret has expired 55 | if (client.client_secret_expires_at && client.client_secret_expires_at < Math.floor(Date.now() / 1000)) { 56 | throw new InvalidClientError('Client secret has expired'); 57 | } 58 | } 59 | 60 | req.client = client; 61 | next(); 62 | } catch (error) { 63 | if (error instanceof OAuthError) { 64 | const status = error instanceof ServerError ? 500 : 400; 65 | res.status(status).json(error.toResponseObject()); 66 | } else { 67 | const serverError = new ServerError('Internal Server Error'); 68 | res.status(500).json(serverError.toResponseObject()); 69 | } 70 | } 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /src/server/stdio.test.ts: -------------------------------------------------------------------------------- 1 | import { Readable, Writable } from 'node:stream'; 2 | import { ReadBuffer, serializeMessage } from '../shared/stdio.js'; 3 | import { JSONRPCMessage } from '../types.js'; 4 | import { StdioServerTransport } from './stdio.js'; 5 | 6 | let input: Readable; 7 | let outputBuffer: ReadBuffer; 8 | let output: Writable; 9 | 10 | beforeEach(() => { 11 | input = new Readable({ 12 | // We'll use input.push() instead. 13 | read: () => {} 14 | }); 15 | 16 | outputBuffer = new ReadBuffer(); 17 | output = new Writable({ 18 | write(chunk, encoding, callback) { 19 | outputBuffer.append(chunk); 20 | callback(); 21 | } 22 | }); 23 | }); 24 | 25 | test('should start then close cleanly', async () => { 26 | const server = new StdioServerTransport(input, output); 27 | server.onerror = error => { 28 | throw error; 29 | }; 30 | 31 | let didClose = false; 32 | server.onclose = () => { 33 | didClose = true; 34 | }; 35 | 36 | await server.start(); 37 | expect(didClose).toBeFalsy(); 38 | await server.close(); 39 | expect(didClose).toBeTruthy(); 40 | }); 41 | 42 | test('should not read until started', async () => { 43 | const server = new StdioServerTransport(input, output); 44 | server.onerror = error => { 45 | throw error; 46 | }; 47 | 48 | let didRead = false; 49 | const readMessage = new Promise(resolve => { 50 | server.onmessage = message => { 51 | didRead = true; 52 | resolve(message); 53 | }; 54 | }); 55 | 56 | const message: JSONRPCMessage = { 57 | jsonrpc: '2.0', 58 | id: 1, 59 | method: 'ping' 60 | }; 61 | input.push(serializeMessage(message)); 62 | 63 | expect(didRead).toBeFalsy(); 64 | await server.start(); 65 | expect(await readMessage).toEqual(message); 66 | }); 67 | 68 | test('should read multiple messages', async () => { 69 | const server = new StdioServerTransport(input, output); 70 | server.onerror = error => { 71 | throw error; 72 | }; 73 | 74 | const messages: JSONRPCMessage[] = [ 75 | { 76 | jsonrpc: '2.0', 77 | id: 1, 78 | method: 'ping' 79 | }, 80 | { 81 | jsonrpc: '2.0', 82 | method: 'notifications/initialized' 83 | } 84 | ]; 85 | 86 | const readMessages: JSONRPCMessage[] = []; 87 | const finished = new Promise(resolve => { 88 | server.onmessage = message => { 89 | readMessages.push(message); 90 | if (JSON.stringify(message) === JSON.stringify(messages[1])) { 91 | resolve(); 92 | } 93 | }; 94 | }); 95 | 96 | input.push(serializeMessage(messages[0])); 97 | input.push(serializeMessage(messages[1])); 98 | 99 | await server.start(); 100 | await finished; 101 | expect(readMessages).toEqual(messages); 102 | }); 103 | -------------------------------------------------------------------------------- /src/server/completable.ts: -------------------------------------------------------------------------------- 1 | import { ZodTypeAny, ZodTypeDef, ZodType, ParseInput, ParseReturnType, RawCreateParams, ZodErrorMap, ProcessedCreateParams } from 'zod'; 2 | 3 | export enum McpZodTypeKind { 4 | Completable = 'McpCompletable' 5 | } 6 | 7 | export type CompleteCallback = ( 8 | value: T['_input'], 9 | context?: { 10 | arguments?: Record; 11 | } 12 | ) => T['_input'][] | Promise; 13 | 14 | export interface CompletableDef extends ZodTypeDef { 15 | type: T; 16 | complete: CompleteCallback; 17 | typeName: McpZodTypeKind.Completable; 18 | } 19 | 20 | export class Completable extends ZodType, T['_input']> { 21 | _parse(input: ParseInput): ParseReturnType { 22 | const { ctx } = this._processInputParams(input); 23 | const data = ctx.data; 24 | return this._def.type._parse({ 25 | data, 26 | path: ctx.path, 27 | parent: ctx 28 | }); 29 | } 30 | 31 | unwrap() { 32 | return this._def.type; 33 | } 34 | 35 | static create = ( 36 | type: T, 37 | params: RawCreateParams & { 38 | complete: CompleteCallback; 39 | } 40 | ): Completable => { 41 | return new Completable({ 42 | type, 43 | typeName: McpZodTypeKind.Completable, 44 | complete: params.complete, 45 | ...processCreateParams(params) 46 | }); 47 | }; 48 | } 49 | 50 | /** 51 | * Wraps a Zod type to provide autocompletion capabilities. Useful for, e.g., prompt arguments in MCP. 52 | */ 53 | export function completable(schema: T, complete: CompleteCallback): Completable { 54 | return Completable.create(schema, { ...schema._def, complete }); 55 | } 56 | 57 | // Not sure why this isn't exported from Zod: 58 | // https://github.com/colinhacks/zod/blob/f7ad26147ba291cb3fb257545972a8e00e767470/src/types.ts#L130 59 | function processCreateParams(params: RawCreateParams): ProcessedCreateParams { 60 | if (!params) return {}; 61 | const { errorMap, invalid_type_error, required_error, description } = params; 62 | if (errorMap && (invalid_type_error || required_error)) { 63 | throw new Error(`Can't use "invalid_type_error" or "required_error" in conjunction with custom error map.`); 64 | } 65 | if (errorMap) return { errorMap: errorMap, description }; 66 | const customMap: ZodErrorMap = (iss, ctx) => { 67 | const { message } = params; 68 | 69 | if (iss.code === 'invalid_enum_value') { 70 | return { message: message ?? ctx.defaultError }; 71 | } 72 | if (typeof ctx.data === 'undefined') { 73 | return { message: message ?? required_error ?? ctx.defaultError }; 74 | } 75 | if (iss.code !== 'invalid_type') return { message: ctx.defaultError }; 76 | return { message: message ?? invalid_type_error ?? ctx.defaultError }; 77 | }; 78 | return { errorMap: customMap, description }; 79 | } 80 | -------------------------------------------------------------------------------- /src/shared/transport.ts: -------------------------------------------------------------------------------- 1 | import { JSONRPCMessage, MessageExtraInfo, RequestId } from '../types.js'; 2 | 3 | export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; 4 | 5 | /** 6 | * Options for sending a JSON-RPC message. 7 | */ 8 | export type TransportSendOptions = { 9 | /** 10 | * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. 11 | */ 12 | relatedRequestId?: RequestId; 13 | 14 | /** 15 | * The resumption token used to continue long-running requests that were interrupted. 16 | * 17 | * This allows clients to reconnect and continue from where they left off, if supported by the transport. 18 | */ 19 | resumptionToken?: string; 20 | 21 | /** 22 | * A callback that is invoked when the resumption token changes, if supported by the transport. 23 | * 24 | * This allows clients to persist the latest token for potential reconnection. 25 | */ 26 | onresumptiontoken?: (token: string) => void; 27 | }; 28 | /** 29 | * Describes the minimal contract for a MCP transport that a client or server can communicate over. 30 | */ 31 | export interface Transport { 32 | /** 33 | * Starts processing messages on the transport, including any connection steps that might need to be taken. 34 | * 35 | * This method should only be called after callbacks are installed, or else messages may be lost. 36 | * 37 | * NOTE: This method should not be called explicitly when using Client, Server, or Protocol classes, as they will implicitly call start(). 38 | */ 39 | start(): Promise; 40 | 41 | /** 42 | * Sends a JSON-RPC message (request or response). 43 | * 44 | * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. 45 | */ 46 | send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; 47 | 48 | /** 49 | * Closes the connection. 50 | */ 51 | close(): Promise; 52 | 53 | /** 54 | * Callback for when the connection is closed for any reason. 55 | * 56 | * This should be invoked when close() is called as well. 57 | */ 58 | onclose?: () => void; 59 | 60 | /** 61 | * Callback for when an error occurs. 62 | * 63 | * Note that errors are not necessarily fatal; they are used for reporting any kind of exceptional condition out of band. 64 | */ 65 | onerror?: (error: Error) => void; 66 | 67 | /** 68 | * Callback for when a message (request or response) is received over the connection. 69 | * 70 | * Includes the requestInfo and authInfo if the transport is authenticated. 71 | * 72 | * The requestInfo can be used to get the original request information (headers, etc.) 73 | */ 74 | onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; 75 | 76 | /** 77 | * The session ID generated for this connection. 78 | */ 79 | sessionId?: string; 80 | 81 | /** 82 | * Sets the protocol version used for the connection (called when the initialize response is received). 83 | */ 84 | setProtocolVersion?: (version: string) => void; 85 | } 86 | -------------------------------------------------------------------------------- /src/server/stdio.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import { Readable, Writable } from 'node:stream'; 3 | import { ReadBuffer, serializeMessage } from '../shared/stdio.js'; 4 | import { JSONRPCMessage } from '../types.js'; 5 | import { Transport } from '../shared/transport.js'; 6 | 7 | /** 8 | * Server transport for stdio: this communicates with a MCP client by reading from the current process' stdin and writing to stdout. 9 | * 10 | * This transport is only available in Node.js environments. 11 | */ 12 | export class StdioServerTransport implements Transport { 13 | private _readBuffer: ReadBuffer = new ReadBuffer(); 14 | private _started = false; 15 | 16 | constructor( 17 | private _stdin: Readable = process.stdin, 18 | private _stdout: Writable = process.stdout 19 | ) {} 20 | 21 | onclose?: () => void; 22 | onerror?: (error: Error) => void; 23 | onmessage?: (message: JSONRPCMessage) => void; 24 | 25 | // Arrow functions to bind `this` properly, while maintaining function identity. 26 | _ondata = (chunk: Buffer) => { 27 | this._readBuffer.append(chunk); 28 | this.processReadBuffer(); 29 | }; 30 | _onerror = (error: Error) => { 31 | this.onerror?.(error); 32 | }; 33 | 34 | /** 35 | * Starts listening for messages on stdin. 36 | */ 37 | async start(): Promise { 38 | if (this._started) { 39 | throw new Error( 40 | 'StdioServerTransport already started! If using Server class, note that connect() calls start() automatically.' 41 | ); 42 | } 43 | 44 | this._started = true; 45 | this._stdin.on('data', this._ondata); 46 | this._stdin.on('error', this._onerror); 47 | } 48 | 49 | private processReadBuffer() { 50 | while (true) { 51 | try { 52 | const message = this._readBuffer.readMessage(); 53 | if (message === null) { 54 | break; 55 | } 56 | 57 | this.onmessage?.(message); 58 | } catch (error) { 59 | this.onerror?.(error as Error); 60 | } 61 | } 62 | } 63 | 64 | async close(): Promise { 65 | // Remove our event listeners first 66 | this._stdin.off('data', this._ondata); 67 | this._stdin.off('error', this._onerror); 68 | 69 | // Check if we were the only data listener 70 | const remainingDataListeners = this._stdin.listenerCount('data'); 71 | if (remainingDataListeners === 0) { 72 | // Only pause stdin if we were the only listener 73 | // This prevents interfering with other parts of the application that might be using stdin 74 | this._stdin.pause(); 75 | } 76 | 77 | // Clear the buffer and notify closure 78 | this._readBuffer.clear(); 79 | this.onclose?.(); 80 | } 81 | 82 | send(message: JSONRPCMessage): Promise { 83 | return new Promise(resolve => { 84 | const json = serializeMessage(message); 85 | if (this._stdout.write(json)) { 86 | resolve(); 87 | } else { 88 | this._stdout.once('drain', resolve); 89 | } 90 | }); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/server/auth/handlers/revoke.ts: -------------------------------------------------------------------------------- 1 | import { OAuthServerProvider } from '../provider.js'; 2 | import express, { RequestHandler } from 'express'; 3 | import cors from 'cors'; 4 | import { authenticateClient } from '../middleware/clientAuth.js'; 5 | import { OAuthTokenRevocationRequestSchema } from '../../../shared/auth.js'; 6 | import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; 7 | import { allowedMethods } from '../middleware/allowedMethods.js'; 8 | import { InvalidRequestError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js'; 9 | 10 | export type RevocationHandlerOptions = { 11 | provider: OAuthServerProvider; 12 | /** 13 | * Rate limiting configuration for the token revocation endpoint. 14 | * Set to false to disable rate limiting for this endpoint. 15 | */ 16 | rateLimit?: Partial | false; 17 | }; 18 | 19 | export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler { 20 | if (!provider.revokeToken) { 21 | throw new Error('Auth provider does not support revoking tokens'); 22 | } 23 | 24 | // Nested router so we can configure middleware and restrict HTTP method 25 | const router = express.Router(); 26 | 27 | // Configure CORS to allow any origin, to make accessible to web-based MCP clients 28 | router.use(cors()); 29 | 30 | router.use(allowedMethods(['POST'])); 31 | router.use(express.urlencoded({ extended: false })); 32 | 33 | // Apply rate limiting unless explicitly disabled 34 | if (rateLimitConfig !== false) { 35 | router.use( 36 | rateLimit({ 37 | windowMs: 15 * 60 * 1000, // 15 minutes 38 | max: 50, // 50 requests per windowMs 39 | standardHeaders: true, 40 | legacyHeaders: false, 41 | message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(), 42 | ...rateLimitConfig 43 | }) 44 | ); 45 | } 46 | 47 | // Authenticate and extract client details 48 | router.use(authenticateClient({ clientsStore: provider.clientsStore })); 49 | 50 | router.post('/', async (req, res) => { 51 | res.setHeader('Cache-Control', 'no-store'); 52 | 53 | try { 54 | const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body); 55 | if (!parseResult.success) { 56 | throw new InvalidRequestError(parseResult.error.message); 57 | } 58 | 59 | const client = req.client; 60 | if (!client) { 61 | // This should never happen 62 | throw new ServerError('Internal Server Error'); 63 | } 64 | 65 | await provider.revokeToken!(client, parseResult.data); 66 | res.status(200).json({}); 67 | } catch (error) { 68 | if (error instanceof OAuthError) { 69 | const status = error instanceof ServerError ? 500 : 400; 70 | res.status(status).json(error.toResponseObject()); 71 | } else { 72 | const serverError = new ServerError('Internal Server Error'); 73 | res.status(500).json(serverError.toResponseObject()); 74 | } 75 | } 76 | }); 77 | 78 | return router; 79 | } 80 | -------------------------------------------------------------------------------- /src/server/auth/provider.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express'; 2 | import { OAuthRegisteredClientsStore } from './clients.js'; 3 | import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../shared/auth.js'; 4 | import { AuthInfo } from './types.js'; 5 | 6 | export type AuthorizationParams = { 7 | state?: string; 8 | scopes?: string[]; 9 | codeChallenge: string; 10 | redirectUri: string; 11 | resource?: URL; 12 | }; 13 | 14 | /** 15 | * Implements an end-to-end OAuth server. 16 | */ 17 | export interface OAuthServerProvider { 18 | /** 19 | * A store used to read information about registered OAuth clients. 20 | */ 21 | get clientsStore(): OAuthRegisteredClientsStore; 22 | 23 | /** 24 | * Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server. 25 | * 26 | * This server must eventually issue a redirect with an authorization response or an error response to the given redirect URI. Per OAuth 2.1: 27 | * - In the successful case, the redirect MUST include the `code` and `state` (if present) query parameters. 28 | * - In the error case, the redirect MUST include the `error` query parameter, and MAY include an optional `error_description` query parameter. 29 | */ 30 | authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise; 31 | 32 | /** 33 | * Returns the `codeChallenge` that was used when the indicated authorization began. 34 | */ 35 | challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise; 36 | 37 | /** 38 | * Exchanges an authorization code for an access token. 39 | */ 40 | exchangeAuthorizationCode( 41 | client: OAuthClientInformationFull, 42 | authorizationCode: string, 43 | codeVerifier?: string, 44 | redirectUri?: string, 45 | resource?: URL 46 | ): Promise; 47 | 48 | /** 49 | * Exchanges a refresh token for an access token. 50 | */ 51 | exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; 52 | 53 | /** 54 | * Verifies an access token and returns information about it. 55 | */ 56 | verifyAccessToken(token: string): Promise; 57 | 58 | /** 59 | * Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended). 60 | * 61 | * If the given token is invalid or already revoked, this method should do nothing. 62 | */ 63 | revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise; 64 | 65 | /** 66 | * Whether to skip local PKCE validation. 67 | * 68 | * If true, the server will not perform PKCE validation locally and will pass the code_verifier to the upstream server. 69 | * 70 | * NOTE: This should only be true if the upstream server is performing the actual PKCE validation. 71 | */ 72 | skipLocalPkceValidation?: boolean; 73 | } 74 | 75 | /** 76 | * Slim implementation useful for token verification 77 | */ 78 | export interface OAuthTokenVerifier { 79 | /** 80 | * Verifies an access token and returns information about it. 81 | */ 82 | verifyAccessToken(token: string): Promise; 83 | } 84 | -------------------------------------------------------------------------------- /src/validation/ajv-provider.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * AJV-based JSON Schema validator provider 3 | */ 4 | 5 | import { Ajv } from 'ajv'; 6 | import _addFormats from 'ajv-formats'; 7 | import type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './types.js'; 8 | 9 | function createDefaultAjvInstance(): Ajv { 10 | const ajv = new Ajv({ 11 | strict: false, 12 | validateFormats: true, 13 | validateSchema: false, 14 | allErrors: true 15 | }); 16 | 17 | const addFormats = _addFormats as unknown as typeof _addFormats.default; 18 | addFormats(ajv); 19 | 20 | return ajv; 21 | } 22 | 23 | /** 24 | * @example 25 | * ```typescript 26 | * // Use with default AJV instance (recommended) 27 | * import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; 28 | * const validator = new AjvJsonSchemaValidator(); 29 | * 30 | * // Use with custom AJV instance 31 | * import { Ajv } from 'ajv'; 32 | * const ajv = new Ajv({ strict: true, allErrors: true }); 33 | * const validator = new AjvJsonSchemaValidator(ajv); 34 | * ``` 35 | */ 36 | export class AjvJsonSchemaValidator implements jsonSchemaValidator { 37 | private _ajv: Ajv; 38 | 39 | /** 40 | * Create an AJV validator 41 | * 42 | * @param ajv - Optional pre-configured AJV instance. If not provided, a default instance will be created. 43 | * 44 | * @example 45 | * ```typescript 46 | * // Use default configuration (recommended for most cases) 47 | * import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; 48 | * const validator = new AjvJsonSchemaValidator(); 49 | * 50 | * // Or provide custom AJV instance for advanced configuration 51 | * import { Ajv } from 'ajv'; 52 | * import addFormats from 'ajv-formats'; 53 | * 54 | * const ajv = new Ajv({ validateFormats: true }); 55 | * addFormats(ajv); 56 | * const validator = new AjvJsonSchemaValidator(ajv); 57 | * ``` 58 | */ 59 | constructor(ajv?: Ajv) { 60 | this._ajv = ajv ?? createDefaultAjvInstance(); 61 | } 62 | 63 | /** 64 | * Create a validator for the given JSON Schema 65 | * 66 | * The validator is compiled once and can be reused multiple times. 67 | * If the schema has an $id, it will be cached by AJV automatically. 68 | * 69 | * @param schema - Standard JSON Schema object 70 | * @returns A validator function that validates input data 71 | */ 72 | getValidator(schema: JsonSchemaType): JsonSchemaValidator { 73 | // Check if schema has $id and is already compiled/cached 74 | const ajvValidator = 75 | '$id' in schema && typeof schema.$id === 'string' 76 | ? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema)) 77 | : this._ajv.compile(schema); 78 | 79 | return (input: unknown): JsonSchemaValidatorResult => { 80 | const valid = ajvValidator(input); 81 | 82 | if (valid) { 83 | return { 84 | valid: true, 85 | data: input as T, 86 | errorMessage: undefined 87 | }; 88 | } else { 89 | return { 90 | valid: false, 91 | data: undefined, 92 | errorMessage: this._ajv.errorsText(ajvValidator.errors) 93 | }; 94 | } 95 | }; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/server/auth/handlers/metadata.test.ts: -------------------------------------------------------------------------------- 1 | import { metadataHandler } from './metadata.js'; 2 | import { OAuthMetadata } from '../../../shared/auth.js'; 3 | import express from 'express'; 4 | import supertest from 'supertest'; 5 | 6 | describe('Metadata Handler', () => { 7 | const exampleMetadata: OAuthMetadata = { 8 | issuer: 'https://auth.example.com', 9 | authorization_endpoint: 'https://auth.example.com/authorize', 10 | token_endpoint: 'https://auth.example.com/token', 11 | registration_endpoint: 'https://auth.example.com/register', 12 | revocation_endpoint: 'https://auth.example.com/revoke', 13 | scopes_supported: ['profile', 'email'], 14 | response_types_supported: ['code'], 15 | grant_types_supported: ['authorization_code', 'refresh_token'], 16 | token_endpoint_auth_methods_supported: ['client_secret_basic'], 17 | code_challenge_methods_supported: ['S256'] 18 | }; 19 | 20 | let app: express.Express; 21 | 22 | beforeEach(() => { 23 | // Setup express app with metadata handler 24 | app = express(); 25 | app.use('/.well-known/oauth-authorization-server', metadataHandler(exampleMetadata)); 26 | }); 27 | 28 | it('requires GET method', async () => { 29 | const response = await supertest(app).post('/.well-known/oauth-authorization-server').send({}); 30 | 31 | expect(response.status).toBe(405); 32 | expect(response.headers.allow).toBe('GET, OPTIONS'); 33 | expect(response.body).toEqual({ 34 | error: 'method_not_allowed', 35 | error_description: 'The method POST is not allowed for this endpoint' 36 | }); 37 | }); 38 | 39 | it('returns the metadata object', async () => { 40 | const response = await supertest(app).get('/.well-known/oauth-authorization-server'); 41 | 42 | expect(response.status).toBe(200); 43 | expect(response.body).toEqual(exampleMetadata); 44 | }); 45 | 46 | it('includes CORS headers in response', async () => { 47 | const response = await supertest(app).get('/.well-known/oauth-authorization-server').set('Origin', 'https://example.com'); 48 | 49 | expect(response.header['access-control-allow-origin']).toBe('*'); 50 | }); 51 | 52 | it('supports OPTIONS preflight requests', async () => { 53 | const response = await supertest(app) 54 | .options('/.well-known/oauth-authorization-server') 55 | .set('Origin', 'https://example.com') 56 | .set('Access-Control-Request-Method', 'GET'); 57 | 58 | expect(response.status).toBe(204); 59 | expect(response.header['access-control-allow-origin']).toBe('*'); 60 | }); 61 | 62 | it('works with minimal metadata', async () => { 63 | // Setup a new express app with minimal metadata 64 | const minimalApp = express(); 65 | const minimalMetadata: OAuthMetadata = { 66 | issuer: 'https://auth.example.com', 67 | authorization_endpoint: 'https://auth.example.com/authorize', 68 | token_endpoint: 'https://auth.example.com/token', 69 | response_types_supported: ['code'] 70 | }; 71 | minimalApp.use('/.well-known/oauth-authorization-server', metadataHandler(minimalMetadata)); 72 | 73 | const response = await supertest(minimalApp).get('/.well-known/oauth-authorization-server'); 74 | 75 | expect(response.status).toBe(200); 76 | expect(response.body).toEqual(minimalMetadata); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/inMemory.test.ts: -------------------------------------------------------------------------------- 1 | import { InMemoryTransport } from './inMemory.js'; 2 | import { JSONRPCMessage } from './types.js'; 3 | import { AuthInfo } from './server/auth/types.js'; 4 | 5 | describe('InMemoryTransport', () => { 6 | let clientTransport: InMemoryTransport; 7 | let serverTransport: InMemoryTransport; 8 | 9 | beforeEach(() => { 10 | [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); 11 | }); 12 | 13 | test('should create linked pair', () => { 14 | expect(clientTransport).toBeDefined(); 15 | expect(serverTransport).toBeDefined(); 16 | }); 17 | 18 | test('should start without error', async () => { 19 | await expect(clientTransport.start()).resolves.not.toThrow(); 20 | await expect(serverTransport.start()).resolves.not.toThrow(); 21 | }); 22 | 23 | test('should send message from client to server', async () => { 24 | const message: JSONRPCMessage = { 25 | jsonrpc: '2.0', 26 | method: 'test', 27 | id: 1 28 | }; 29 | 30 | let receivedMessage: JSONRPCMessage | undefined; 31 | serverTransport.onmessage = msg => { 32 | receivedMessage = msg; 33 | }; 34 | 35 | await clientTransport.send(message); 36 | expect(receivedMessage).toEqual(message); 37 | }); 38 | 39 | test('should send message with auth info from client to server', async () => { 40 | const message: JSONRPCMessage = { 41 | jsonrpc: '2.0', 42 | method: 'test', 43 | id: 1 44 | }; 45 | 46 | const authInfo: AuthInfo = { 47 | token: 'test-token', 48 | clientId: 'test-client', 49 | scopes: ['read', 'write'], 50 | expiresAt: Date.now() / 1000 + 3600 51 | }; 52 | 53 | let receivedMessage: JSONRPCMessage | undefined; 54 | let receivedAuthInfo: AuthInfo | undefined; 55 | serverTransport.onmessage = (msg, extra) => { 56 | receivedMessage = msg; 57 | receivedAuthInfo = extra?.authInfo; 58 | }; 59 | 60 | await clientTransport.send(message, { authInfo }); 61 | expect(receivedMessage).toEqual(message); 62 | expect(receivedAuthInfo).toEqual(authInfo); 63 | }); 64 | 65 | test('should send message from server to client', async () => { 66 | const message: JSONRPCMessage = { 67 | jsonrpc: '2.0', 68 | method: 'test', 69 | id: 1 70 | }; 71 | 72 | let receivedMessage: JSONRPCMessage | undefined; 73 | clientTransport.onmessage = msg => { 74 | receivedMessage = msg; 75 | }; 76 | 77 | await serverTransport.send(message); 78 | expect(receivedMessage).toEqual(message); 79 | }); 80 | 81 | test('should handle close', async () => { 82 | let clientClosed = false; 83 | let serverClosed = false; 84 | 85 | clientTransport.onclose = () => { 86 | clientClosed = true; 87 | }; 88 | 89 | serverTransport.onclose = () => { 90 | serverClosed = true; 91 | }; 92 | 93 | await clientTransport.close(); 94 | expect(clientClosed).toBe(true); 95 | expect(serverClosed).toBe(true); 96 | }); 97 | 98 | test('should throw error when sending after close', async () => { 99 | await clientTransport.close(); 100 | await expect(clientTransport.send({ jsonrpc: '2.0', method: 'test', id: 1 })).rejects.toThrow('Not connected'); 101 | }); 102 | 103 | test('should queue messages sent before start', async () => { 104 | const message: JSONRPCMessage = { 105 | jsonrpc: '2.0', 106 | method: 'test', 107 | id: 1 108 | }; 109 | 110 | let receivedMessage: JSONRPCMessage | undefined; 111 | serverTransport.onmessage = msg => { 112 | receivedMessage = msg; 113 | }; 114 | 115 | await clientTransport.send(message); 116 | await serverTransport.start(); 117 | expect(receivedMessage).toEqual(message); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /src/server/auth/middleware/bearerAuth.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from '../errors.js'; 3 | import { OAuthTokenVerifier } from '../provider.js'; 4 | import { AuthInfo } from '../types.js'; 5 | 6 | export type BearerAuthMiddlewareOptions = { 7 | /** 8 | * A provider used to verify tokens. 9 | */ 10 | verifier: OAuthTokenVerifier; 11 | 12 | /** 13 | * Optional scopes that the token must have. 14 | */ 15 | requiredScopes?: string[]; 16 | 17 | /** 18 | * Optional resource metadata URL to include in WWW-Authenticate header. 19 | */ 20 | resourceMetadataUrl?: string; 21 | }; 22 | 23 | declare module 'express-serve-static-core' { 24 | interface Request { 25 | /** 26 | * Information about the validated access token, if the `requireBearerAuth` middleware was used. 27 | */ 28 | auth?: AuthInfo; 29 | } 30 | } 31 | 32 | /** 33 | * Middleware that requires a valid Bearer token in the Authorization header. 34 | * 35 | * This will validate the token with the auth provider and add the resulting auth info to the request object. 36 | * 37 | * If resourceMetadataUrl is provided, it will be included in the WWW-Authenticate header 38 | * for 401 responses as per the OAuth 2.0 Protected Resource Metadata spec. 39 | */ 40 | export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler { 41 | return async (req, res, next) => { 42 | try { 43 | const authHeader = req.headers.authorization; 44 | if (!authHeader) { 45 | throw new InvalidTokenError('Missing Authorization header'); 46 | } 47 | 48 | const [type, token] = authHeader.split(' '); 49 | if (type.toLowerCase() !== 'bearer' || !token) { 50 | throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'"); 51 | } 52 | 53 | const authInfo = await verifier.verifyAccessToken(token); 54 | 55 | // Check if token has the required scopes (if any) 56 | if (requiredScopes.length > 0) { 57 | const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope)); 58 | 59 | if (!hasAllScopes) { 60 | throw new InsufficientScopeError('Insufficient scope'); 61 | } 62 | } 63 | 64 | // Check if the token is set to expire or if it is expired 65 | if (typeof authInfo.expiresAt !== 'number' || isNaN(authInfo.expiresAt)) { 66 | throw new InvalidTokenError('Token has no expiration time'); 67 | } else if (authInfo.expiresAt < Date.now() / 1000) { 68 | throw new InvalidTokenError('Token has expired'); 69 | } 70 | 71 | req.auth = authInfo; 72 | next(); 73 | } catch (error) { 74 | if (error instanceof InvalidTokenError) { 75 | const wwwAuthValue = resourceMetadataUrl 76 | ? `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"` 77 | : `Bearer error="${error.errorCode}", error_description="${error.message}"`; 78 | res.set('WWW-Authenticate', wwwAuthValue); 79 | res.status(401).json(error.toResponseObject()); 80 | } else if (error instanceof InsufficientScopeError) { 81 | const wwwAuthValue = resourceMetadataUrl 82 | ? `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"` 83 | : `Bearer error="${error.errorCode}", error_description="${error.message}"`; 84 | res.set('WWW-Authenticate', wwwAuthValue); 85 | res.status(403).json(error.toResponseObject()); 86 | } else if (error instanceof ServerError) { 87 | res.status(500).json(error.toResponseObject()); 88 | } else if (error instanceof OAuthError) { 89 | res.status(400).json(error.toResponseObject()); 90 | } else { 91 | const serverError = new ServerError('Internal Server Error'); 92 | res.status(500).json(serverError.toResponseObject()); 93 | } 94 | } 95 | }; 96 | } 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@modelcontextprotocol/sdk", 3 | "version": "1.21.0", 4 | "description": "Model Context Protocol implementation for TypeScript", 5 | "license": "MIT", 6 | "author": "Anthropic, PBC (https://anthropic.com)", 7 | "homepage": "https://modelcontextprotocol.io", 8 | "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", 9 | "type": "module", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" 13 | }, 14 | "engines": { 15 | "node": ">=18" 16 | }, 17 | "keywords": [ 18 | "modelcontextprotocol", 19 | "mcp" 20 | ], 21 | "exports": { 22 | ".": { 23 | "import": "./dist/esm/index.js", 24 | "require": "./dist/cjs/index.js" 25 | }, 26 | "./client": { 27 | "import": "./dist/esm/client/index.js", 28 | "require": "./dist/cjs/client/index.js" 29 | }, 30 | "./server": { 31 | "import": "./dist/esm/server/index.js", 32 | "require": "./dist/cjs/server/index.js" 33 | }, 34 | "./validation": { 35 | "import": "./dist/esm/validation/index.js", 36 | "require": "./dist/cjs/validation/index.js" 37 | }, 38 | "./validation/ajv": { 39 | "import": "./dist/esm/validation/ajv-provider.js", 40 | "require": "./dist/cjs/validation/ajv-provider.js" 41 | }, 42 | "./validation/cfworker": { 43 | "import": "./dist/esm/validation/cfworker-provider.js", 44 | "require": "./dist/cjs/validation/cfworker-provider.js" 45 | }, 46 | "./*": { 47 | "import": "./dist/esm/*", 48 | "require": "./dist/cjs/*" 49 | } 50 | }, 51 | "typesVersions": { 52 | "*": { 53 | "*": [ 54 | "./dist/esm/*" 55 | ] 56 | } 57 | }, 58 | "files": [ 59 | "dist" 60 | ], 61 | "scripts": { 62 | "fetch:spec-types": "curl -o spec.types.ts https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/heads/main/schema/draft/schema.ts", 63 | "build": "npm run build:esm && npm run build:cjs", 64 | "build:esm": "mkdir -p dist/esm && echo '{\"type\": \"module\"}' > dist/esm/package.json && tsc -p tsconfig.prod.json", 65 | "build:esm:w": "npm run build:esm -- -w", 66 | "build:cjs": "mkdir -p dist/cjs && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json && tsc -p tsconfig.cjs.json", 67 | "build:cjs:w": "npm run build:cjs -- -w", 68 | "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", 69 | "prepack": "npm run build:esm && npm run build:cjs", 70 | "lint": "eslint src/ && prettier --check .", 71 | "lint:fix": "eslint src/ --fix && prettier --write .", 72 | "test": "npm run fetch:spec-types && jest", 73 | "start": "npm run server", 74 | "server": "tsx watch --clear-screen=false src/cli.ts server", 75 | "client": "tsx src/cli.ts client" 76 | }, 77 | "dependencies": { 78 | "ajv": "^8.17.1", 79 | "ajv-formats": "^3.0.1", 80 | "content-type": "^1.0.5", 81 | "cors": "^2.8.5", 82 | "cross-spawn": "^7.0.5", 83 | "eventsource": "^3.0.2", 84 | "eventsource-parser": "^3.0.0", 85 | "express": "^5.0.1", 86 | "express-rate-limit": "^7.5.0", 87 | "pkce-challenge": "^5.0.0", 88 | "raw-body": "^3.0.0", 89 | "zod": "^3.23.8", 90 | "zod-to-json-schema": "^3.24.1" 91 | }, 92 | "peerDependencies": { 93 | "@cfworker/json-schema": "^4.1.1" 94 | }, 95 | "peerDependenciesMeta": { 96 | "@cfworker/json-schema": { 97 | "optional": true 98 | } 99 | }, 100 | "devDependencies": { 101 | "@cfworker/json-schema": "^4.1.1", 102 | "@eslint/js": "^9.8.0", 103 | "@jest-mock/express": "^3.0.0", 104 | "@types/content-type": "^1.1.8", 105 | "@types/cors": "^2.8.17", 106 | "@types/cross-spawn": "^6.0.6", 107 | "@types/eslint__js": "^8.42.3", 108 | "@types/eventsource": "^1.1.15", 109 | "@types/express": "^5.0.0", 110 | "@types/jest": "^29.5.12", 111 | "@types/node": "^22.0.2", 112 | "@types/supertest": "^6.0.2", 113 | "@types/ws": "^8.5.12", 114 | "eslint": "^9.8.0", 115 | "eslint-config-prettier": "^10.1.8", 116 | "jest": "^29.7.0", 117 | "prettier": "3.6.2", 118 | "supertest": "^7.0.0", 119 | "ts-jest": "^29.2.4", 120 | "tsx": "^4.16.5", 121 | "typescript": "^5.5.4", 122 | "typescript-eslint": "^8.0.0", 123 | "ws": "^8.18.0" 124 | }, 125 | "resolutions": { 126 | "strip-ansi": "6.0.1" 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/shared/auth-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { resourceUrlFromServerUrl, checkResourceAllowed } from './auth-utils.js'; 2 | 3 | describe('auth-utils', () => { 4 | describe('resourceUrlFromServerUrl', () => { 5 | it('should remove fragments', () => { 6 | expect(resourceUrlFromServerUrl(new URL('https://example.com/path#fragment')).href).toBe('https://example.com/path'); 7 | expect(resourceUrlFromServerUrl(new URL('https://example.com#fragment')).href).toBe('https://example.com/'); 8 | expect(resourceUrlFromServerUrl(new URL('https://example.com/path?query=1#fragment')).href).toBe( 9 | 'https://example.com/path?query=1' 10 | ); 11 | }); 12 | 13 | it('should return URL unchanged if no fragment', () => { 14 | expect(resourceUrlFromServerUrl(new URL('https://example.com')).href).toBe('https://example.com/'); 15 | expect(resourceUrlFromServerUrl(new URL('https://example.com/path')).href).toBe('https://example.com/path'); 16 | expect(resourceUrlFromServerUrl(new URL('https://example.com/path?query=1')).href).toBe('https://example.com/path?query=1'); 17 | }); 18 | 19 | it('should keep everything else unchanged', () => { 20 | // Case sensitivity preserved 21 | expect(resourceUrlFromServerUrl(new URL('https://EXAMPLE.COM/PATH')).href).toBe('https://example.com/PATH'); 22 | // Ports preserved 23 | expect(resourceUrlFromServerUrl(new URL('https://example.com:443/path')).href).toBe('https://example.com/path'); 24 | expect(resourceUrlFromServerUrl(new URL('https://example.com:8080/path')).href).toBe('https://example.com:8080/path'); 25 | // Query parameters preserved 26 | expect(resourceUrlFromServerUrl(new URL('https://example.com?foo=bar&baz=qux')).href).toBe( 27 | 'https://example.com/?foo=bar&baz=qux' 28 | ); 29 | // Trailing slashes preserved 30 | expect(resourceUrlFromServerUrl(new URL('https://example.com/')).href).toBe('https://example.com/'); 31 | expect(resourceUrlFromServerUrl(new URL('https://example.com/path/')).href).toBe('https://example.com/path/'); 32 | }); 33 | }); 34 | 35 | describe('resourceMatches', () => { 36 | it('should match identical URLs', () => { 37 | expect( 38 | checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.com/path' }) 39 | ).toBe(true); 40 | expect(checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/' })).toBe( 41 | true 42 | ); 43 | }); 44 | 45 | it('should not match URLs with different paths', () => { 46 | expect( 47 | checkResourceAllowed({ requestedResource: 'https://example.com/path1', configuredResource: 'https://example.com/path2' }) 48 | ).toBe(false); 49 | expect( 50 | checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/path' }) 51 | ).toBe(false); 52 | }); 53 | 54 | it('should not match URLs with different domains', () => { 55 | expect( 56 | checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.org/path' }) 57 | ).toBe(false); 58 | }); 59 | 60 | it('should not match URLs with different ports', () => { 61 | expect( 62 | checkResourceAllowed({ requestedResource: 'https://example.com:8080/path', configuredResource: 'https://example.com/path' }) 63 | ).toBe(false); 64 | }); 65 | 66 | it('should not match URLs where one path is a sub-path of another', () => { 67 | expect( 68 | checkResourceAllowed({ requestedResource: 'https://example.com/mcpxxxx', configuredResource: 'https://example.com/mcp' }) 69 | ).toBe(false); 70 | expect( 71 | checkResourceAllowed({ 72 | requestedResource: 'https://example.com/folder', 73 | configuredResource: 'https://example.com/folder/subfolder' 74 | }) 75 | ).toBe(false); 76 | expect( 77 | checkResourceAllowed({ requestedResource: 'https://example.com/api/v1', configuredResource: 'https://example.com/api' }) 78 | ).toBe(true); 79 | }); 80 | 81 | it('should handle trailing slashes vs no trailing slashes', () => { 82 | expect( 83 | checkResourceAllowed({ requestedResource: 'https://example.com/mcp/', configuredResource: 'https://example.com/mcp' }) 84 | ).toBe(true); 85 | expect( 86 | checkResourceAllowed({ requestedResource: 'https://example.com/folder', configuredResource: 'https://example.com/folder/' }) 87 | ).toBe(false); 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /src/client/cross-spawn.test.ts: -------------------------------------------------------------------------------- 1 | import { StdioClientTransport, getDefaultEnvironment } from './stdio.js'; 2 | import spawn from 'cross-spawn'; 3 | import { JSONRPCMessage } from '../types.js'; 4 | import { ChildProcess } from 'node:child_process'; 5 | 6 | // mock cross-spawn 7 | jest.mock('cross-spawn'); 8 | const mockSpawn = spawn as jest.MockedFunction; 9 | 10 | describe('StdioClientTransport using cross-spawn', () => { 11 | beforeEach(() => { 12 | // mock cross-spawn's return value 13 | mockSpawn.mockImplementation(() => { 14 | const mockProcess: { 15 | on: jest.Mock; 16 | stdin?: { on: jest.Mock; write: jest.Mock }; 17 | stdout?: { on: jest.Mock }; 18 | stderr?: null; 19 | } = { 20 | on: jest.fn((event: string, callback: () => void) => { 21 | if (event === 'spawn') { 22 | callback(); 23 | } 24 | return mockProcess; 25 | }), 26 | stdin: { 27 | on: jest.fn(), 28 | write: jest.fn().mockReturnValue(true) 29 | }, 30 | stdout: { 31 | on: jest.fn() 32 | }, 33 | stderr: null 34 | }; 35 | return mockProcess as unknown as ChildProcess; 36 | }); 37 | }); 38 | 39 | afterEach(() => { 40 | jest.clearAllMocks(); 41 | }); 42 | 43 | test('should call cross-spawn correctly', async () => { 44 | const transport = new StdioClientTransport({ 45 | command: 'test-command', 46 | args: ['arg1', 'arg2'] 47 | }); 48 | 49 | await transport.start(); 50 | 51 | // verify spawn is called correctly 52 | expect(mockSpawn).toHaveBeenCalledWith( 53 | 'test-command', 54 | ['arg1', 'arg2'], 55 | expect.objectContaining({ 56 | shell: false 57 | }) 58 | ); 59 | }); 60 | 61 | test('should pass environment variables correctly', async () => { 62 | const customEnv = { TEST_VAR: 'test-value' }; 63 | const transport = new StdioClientTransport({ 64 | command: 'test-command', 65 | env: customEnv 66 | }); 67 | 68 | await transport.start(); 69 | 70 | // verify environment variables are merged correctly 71 | expect(mockSpawn).toHaveBeenCalledWith( 72 | 'test-command', 73 | [], 74 | expect.objectContaining({ 75 | env: { 76 | ...getDefaultEnvironment(), 77 | ...customEnv 78 | } 79 | }) 80 | ); 81 | }); 82 | 83 | test('should use default environment when env is undefined', async () => { 84 | const transport = new StdioClientTransport({ 85 | command: 'test-command', 86 | env: undefined 87 | }); 88 | 89 | await transport.start(); 90 | 91 | // verify default environment is used 92 | expect(mockSpawn).toHaveBeenCalledWith( 93 | 'test-command', 94 | [], 95 | expect.objectContaining({ 96 | env: getDefaultEnvironment() 97 | }) 98 | ); 99 | }); 100 | 101 | test('should send messages correctly', async () => { 102 | const transport = new StdioClientTransport({ 103 | command: 'test-command' 104 | }); 105 | 106 | // get the mock process object 107 | const mockProcess: { 108 | on: jest.Mock; 109 | stdin: { 110 | on: jest.Mock; 111 | write: jest.Mock; 112 | once: jest.Mock; 113 | }; 114 | stdout: { 115 | on: jest.Mock; 116 | }; 117 | stderr: null; 118 | } = { 119 | on: jest.fn((event: string, callback: () => void) => { 120 | if (event === 'spawn') { 121 | callback(); 122 | } 123 | return mockProcess; 124 | }), 125 | stdin: { 126 | on: jest.fn(), 127 | write: jest.fn().mockReturnValue(true), 128 | once: jest.fn() 129 | }, 130 | stdout: { 131 | on: jest.fn() 132 | }, 133 | stderr: null 134 | }; 135 | 136 | mockSpawn.mockReturnValue(mockProcess as unknown as ChildProcess); 137 | 138 | await transport.start(); 139 | 140 | // 关键修复:确保 jsonrpc 是字面量 "2.0" 141 | const message: JSONRPCMessage = { 142 | jsonrpc: '2.0', 143 | id: 'test-id', 144 | method: 'test-method' 145 | }; 146 | 147 | await transport.send(message); 148 | 149 | // verify message is sent correctly 150 | expect(mockProcess.stdin.write).toHaveBeenCalled(); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /src/examples/server/standaloneSseWithGetStreamableHttp.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | import { randomUUID } from 'node:crypto'; 3 | import { McpServer } from '../../server/mcp.js'; 4 | import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; 5 | import { isInitializeRequest, ReadResourceResult } from '../../types.js'; 6 | 7 | // Create an MCP server with implementation details 8 | const server = new McpServer({ 9 | name: 'resource-list-changed-notification-server', 10 | version: '1.0.0' 11 | }); 12 | 13 | // Store transports by session ID to send notifications 14 | const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; 15 | 16 | const addResource = (name: string, content: string) => { 17 | const uri = `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`; 18 | server.resource( 19 | name, 20 | uri, 21 | { mimeType: 'text/plain', description: `Dynamic resource: ${name}` }, 22 | async (): Promise => { 23 | return { 24 | contents: [{ uri, text: content }] 25 | }; 26 | } 27 | ); 28 | }; 29 | 30 | addResource('example-resource', 'Initial content for example-resource'); 31 | 32 | const resourceChangeInterval = setInterval(() => { 33 | const name = randomUUID(); 34 | addResource(name, `Content for ${name}`); 35 | }, 5000); // Change resources every 5 seconds for testing 36 | 37 | const app = express(); 38 | app.use(express.json()); 39 | 40 | app.post('/mcp', async (req: Request, res: Response) => { 41 | console.log('Received MCP request:', req.body); 42 | try { 43 | // Check for existing session ID 44 | const sessionId = req.headers['mcp-session-id'] as string | undefined; 45 | let transport: StreamableHTTPServerTransport; 46 | 47 | if (sessionId && transports[sessionId]) { 48 | // Reuse existing transport 49 | transport = transports[sessionId]; 50 | } else if (!sessionId && isInitializeRequest(req.body)) { 51 | // New initialization request 52 | transport = new StreamableHTTPServerTransport({ 53 | sessionIdGenerator: () => randomUUID(), 54 | onsessioninitialized: sessionId => { 55 | // Store the transport by session ID when session is initialized 56 | // This avoids race conditions where requests might come in before the session is stored 57 | console.log(`Session initialized with ID: ${sessionId}`); 58 | transports[sessionId] = transport; 59 | } 60 | }); 61 | 62 | // Connect the transport to the MCP server 63 | await server.connect(transport); 64 | 65 | // Handle the request - the onsessioninitialized callback will store the transport 66 | await transport.handleRequest(req, res, req.body); 67 | return; // Already handled 68 | } else { 69 | // Invalid request - no session ID or not initialization request 70 | res.status(400).json({ 71 | jsonrpc: '2.0', 72 | error: { 73 | code: -32000, 74 | message: 'Bad Request: No valid session ID provided' 75 | }, 76 | id: null 77 | }); 78 | return; 79 | } 80 | 81 | // Handle the request with existing transport 82 | await transport.handleRequest(req, res, req.body); 83 | } catch (error) { 84 | console.error('Error handling MCP request:', error); 85 | if (!res.headersSent) { 86 | res.status(500).json({ 87 | jsonrpc: '2.0', 88 | error: { 89 | code: -32603, 90 | message: 'Internal server error' 91 | }, 92 | id: null 93 | }); 94 | } 95 | } 96 | }); 97 | 98 | // Handle GET requests for SSE streams (now using built-in support from StreamableHTTP) 99 | app.get('/mcp', async (req: Request, res: Response) => { 100 | const sessionId = req.headers['mcp-session-id'] as string | undefined; 101 | if (!sessionId || !transports[sessionId]) { 102 | res.status(400).send('Invalid or missing session ID'); 103 | return; 104 | } 105 | 106 | console.log(`Establishing SSE stream for session ${sessionId}`); 107 | const transport = transports[sessionId]; 108 | await transport.handleRequest(req, res); 109 | }); 110 | 111 | // Start the server 112 | const PORT = 3000; 113 | app.listen(PORT, error => { 114 | if (error) { 115 | console.error('Failed to start server:', error); 116 | process.exit(1); 117 | } 118 | console.log(`Server listening on port ${PORT}`); 119 | }); 120 | 121 | // Handle server shutdown 122 | process.on('SIGINT', async () => { 123 | console.log('Shutting down server...'); 124 | clearInterval(resourceChangeInterval); 125 | await server.close(); 126 | process.exit(0); 127 | }); 128 | -------------------------------------------------------------------------------- /src/shared/auth.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from '@jest/globals'; 2 | import { 3 | SafeUrlSchema, 4 | OAuthMetadataSchema, 5 | OpenIdProviderMetadataSchema, 6 | OAuthClientMetadataSchema, 7 | OptionalSafeUrlSchema 8 | } from './auth.js'; 9 | 10 | describe('SafeUrlSchema', () => { 11 | it('accepts valid HTTPS URLs', () => { 12 | expect(SafeUrlSchema.parse('https://example.com')).toBe('https://example.com'); 13 | expect(SafeUrlSchema.parse('https://auth.example.com/oauth/authorize')).toBe('https://auth.example.com/oauth/authorize'); 14 | }); 15 | 16 | it('accepts valid HTTP URLs', () => { 17 | expect(SafeUrlSchema.parse('http://localhost:3000')).toBe('http://localhost:3000'); 18 | }); 19 | 20 | it('rejects javascript: scheme URLs', () => { 21 | expect(() => SafeUrlSchema.parse('javascript:alert(1)')).toThrow('URL cannot use javascript:, data:, or vbscript: scheme'); 22 | expect(() => SafeUrlSchema.parse('JAVASCRIPT:alert(1)')).toThrow('URL cannot use javascript:, data:, or vbscript: scheme'); 23 | }); 24 | 25 | it('rejects invalid URLs', () => { 26 | expect(() => SafeUrlSchema.parse('not-a-url')).toThrow(); 27 | expect(() => SafeUrlSchema.parse('')).toThrow(); 28 | }); 29 | 30 | it('works with safeParse', () => { 31 | expect(() => SafeUrlSchema.safeParse('not-a-url')).not.toThrow(); 32 | }); 33 | }); 34 | 35 | describe('OptionalSafeUrlSchema', () => { 36 | it('accepts empty string and transforms it to undefined', () => { 37 | expect(OptionalSafeUrlSchema.parse('')).toBe(undefined); 38 | }); 39 | }); 40 | 41 | describe('OAuthMetadataSchema', () => { 42 | it('validates complete OAuth metadata', () => { 43 | const metadata = { 44 | issuer: 'https://auth.example.com', 45 | authorization_endpoint: 'https://auth.example.com/oauth/authorize', 46 | token_endpoint: 'https://auth.example.com/oauth/token', 47 | response_types_supported: ['code'], 48 | scopes_supported: ['read', 'write'] 49 | }; 50 | 51 | expect(() => OAuthMetadataSchema.parse(metadata)).not.toThrow(); 52 | }); 53 | 54 | it('rejects metadata with javascript: URLs', () => { 55 | const metadata = { 56 | issuer: 'https://auth.example.com', 57 | authorization_endpoint: 'javascript:alert(1)', 58 | token_endpoint: 'https://auth.example.com/oauth/token', 59 | response_types_supported: ['code'] 60 | }; 61 | 62 | expect(() => OAuthMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme'); 63 | }); 64 | 65 | it('requires mandatory fields', () => { 66 | const incompleteMetadata = { 67 | issuer: 'https://auth.example.com' 68 | }; 69 | 70 | expect(() => OAuthMetadataSchema.parse(incompleteMetadata)).toThrow(); 71 | }); 72 | }); 73 | 74 | describe('OpenIdProviderMetadataSchema', () => { 75 | it('validates complete OpenID Provider metadata', () => { 76 | const metadata = { 77 | issuer: 'https://auth.example.com', 78 | authorization_endpoint: 'https://auth.example.com/oauth/authorize', 79 | token_endpoint: 'https://auth.example.com/oauth/token', 80 | jwks_uri: 'https://auth.example.com/.well-known/jwks.json', 81 | response_types_supported: ['code'], 82 | subject_types_supported: ['public'], 83 | id_token_signing_alg_values_supported: ['RS256'] 84 | }; 85 | 86 | expect(() => OpenIdProviderMetadataSchema.parse(metadata)).not.toThrow(); 87 | }); 88 | 89 | it('rejects metadata with javascript: in jwks_uri', () => { 90 | const metadata = { 91 | issuer: 'https://auth.example.com', 92 | authorization_endpoint: 'https://auth.example.com/oauth/authorize', 93 | token_endpoint: 'https://auth.example.com/oauth/token', 94 | jwks_uri: 'javascript:alert(1)', 95 | response_types_supported: ['code'], 96 | subject_types_supported: ['public'], 97 | id_token_signing_alg_values_supported: ['RS256'] 98 | }; 99 | 100 | expect(() => OpenIdProviderMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme'); 101 | }); 102 | }); 103 | 104 | describe('OAuthClientMetadataSchema', () => { 105 | it('validates client metadata with safe URLs', () => { 106 | const metadata = { 107 | redirect_uris: ['https://app.example.com/callback'], 108 | client_name: 'Test App', 109 | client_uri: 'https://app.example.com' 110 | }; 111 | 112 | expect(() => OAuthClientMetadataSchema.parse(metadata)).not.toThrow(); 113 | }); 114 | 115 | it('rejects client metadata with javascript: redirect URIs', () => { 116 | const metadata = { 117 | redirect_uris: ['javascript:alert(1)'], 118 | client_name: 'Test App' 119 | }; 120 | 121 | expect(() => OAuthClientMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme'); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | (global as any).WebSocket = WebSocket; 5 | 6 | import express from 'express'; 7 | import { Client } from './client/index.js'; 8 | import { SSEClientTransport } from './client/sse.js'; 9 | import { StdioClientTransport } from './client/stdio.js'; 10 | import { WebSocketClientTransport } from './client/websocket.js'; 11 | import { Server } from './server/index.js'; 12 | import { SSEServerTransport } from './server/sse.js'; 13 | import { StdioServerTransport } from './server/stdio.js'; 14 | import { ListResourcesResultSchema } from './types.js'; 15 | 16 | async function runClient(url_or_command: string, args: string[]) { 17 | const client = new Client( 18 | { 19 | name: 'mcp-typescript test client', 20 | version: '0.1.0' 21 | }, 22 | { 23 | capabilities: { 24 | sampling: {} 25 | } 26 | } 27 | ); 28 | 29 | let clientTransport; 30 | 31 | let url: URL | undefined = undefined; 32 | try { 33 | url = new URL(url_or_command); 34 | } catch { 35 | // Ignore 36 | } 37 | 38 | if (url?.protocol === 'http:' || url?.protocol === 'https:') { 39 | clientTransport = new SSEClientTransport(new URL(url_or_command)); 40 | } else if (url?.protocol === 'ws:' || url?.protocol === 'wss:') { 41 | clientTransport = new WebSocketClientTransport(new URL(url_or_command)); 42 | } else { 43 | clientTransport = new StdioClientTransport({ 44 | command: url_or_command, 45 | args 46 | }); 47 | } 48 | 49 | console.log('Connected to server.'); 50 | 51 | await client.connect(clientTransport); 52 | console.log('Initialized.'); 53 | 54 | await client.request({ method: 'resources/list' }, ListResourcesResultSchema); 55 | 56 | await client.close(); 57 | console.log('Closed.'); 58 | } 59 | 60 | async function runServer(port: number | null) { 61 | if (port !== null) { 62 | const app = express(); 63 | 64 | let servers: Server[] = []; 65 | 66 | app.get('/sse', async (req, res) => { 67 | console.log('Got new SSE connection'); 68 | 69 | const transport = new SSEServerTransport('/message', res); 70 | const server = new Server( 71 | { 72 | name: 'mcp-typescript test server', 73 | version: '0.1.0' 74 | }, 75 | { 76 | capabilities: {} 77 | } 78 | ); 79 | 80 | servers.push(server); 81 | 82 | server.onclose = () => { 83 | console.log('SSE connection closed'); 84 | servers = servers.filter(s => s !== server); 85 | }; 86 | 87 | await server.connect(transport); 88 | }); 89 | 90 | app.post('/message', async (req, res) => { 91 | console.log('Received message'); 92 | 93 | const sessionId = req.query.sessionId as string; 94 | const transport = servers.map(s => s.transport as SSEServerTransport).find(t => t.sessionId === sessionId); 95 | if (!transport) { 96 | res.status(404).send('Session not found'); 97 | return; 98 | } 99 | 100 | await transport.handlePostMessage(req, res); 101 | }); 102 | 103 | app.listen(port, error => { 104 | if (error) { 105 | console.error('Failed to start server:', error); 106 | process.exit(1); 107 | } 108 | console.log(`Server running on http://localhost:${port}/sse`); 109 | }); 110 | } else { 111 | const server = new Server( 112 | { 113 | name: 'mcp-typescript test server', 114 | version: '0.1.0' 115 | }, 116 | { 117 | capabilities: { 118 | prompts: {}, 119 | resources: {}, 120 | tools: {}, 121 | logging: {} 122 | } 123 | } 124 | ); 125 | 126 | const transport = new StdioServerTransport(); 127 | await server.connect(transport); 128 | 129 | console.log('Server running on stdio'); 130 | } 131 | } 132 | 133 | const args = process.argv.slice(2); 134 | const command = args[0]; 135 | switch (command) { 136 | case 'client': 137 | if (args.length < 2) { 138 | console.error('Usage: client [args...]'); 139 | process.exit(1); 140 | } 141 | 142 | runClient(args[1], args.slice(2)).catch(error => { 143 | console.error(error); 144 | process.exit(1); 145 | }); 146 | 147 | break; 148 | 149 | case 'server': { 150 | const port = args[1] ? parseInt(args[1]) : null; 151 | runServer(port).catch(error => { 152 | console.error(error); 153 | process.exit(1); 154 | }); 155 | 156 | break; 157 | } 158 | 159 | default: 160 | console.error('Unrecognized command:', command); 161 | } 162 | -------------------------------------------------------------------------------- /src/server/auth/handlers/register.ts: -------------------------------------------------------------------------------- 1 | import express, { RequestHandler } from 'express'; 2 | import { OAuthClientInformationFull, OAuthClientMetadataSchema } from '../../../shared/auth.js'; 3 | import crypto from 'node:crypto'; 4 | import cors from 'cors'; 5 | import { OAuthRegisteredClientsStore } from '../clients.js'; 6 | import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; 7 | import { allowedMethods } from '../middleware/allowedMethods.js'; 8 | import { InvalidClientMetadataError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js'; 9 | 10 | export type ClientRegistrationHandlerOptions = { 11 | /** 12 | * A store used to save information about dynamically registered OAuth clients. 13 | */ 14 | clientsStore: OAuthRegisteredClientsStore; 15 | 16 | /** 17 | * The number of seconds after which to expire issued client secrets, or 0 to prevent expiration of client secrets (not recommended). 18 | * 19 | * If not set, defaults to 30 days. 20 | */ 21 | clientSecretExpirySeconds?: number; 22 | 23 | /** 24 | * Rate limiting configuration for the client registration endpoint. 25 | * Set to false to disable rate limiting for this endpoint. 26 | * Registration endpoints are particularly sensitive to abuse and should be rate limited. 27 | */ 28 | rateLimit?: Partial | false; 29 | 30 | /** 31 | * Whether to generate a client ID before calling the client registration endpoint. 32 | * 33 | * If not set, defaults to true. 34 | */ 35 | clientIdGeneration?: boolean; 36 | }; 37 | 38 | const DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days 39 | 40 | export function clientRegistrationHandler({ 41 | clientsStore, 42 | clientSecretExpirySeconds = DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS, 43 | rateLimit: rateLimitConfig, 44 | clientIdGeneration = true 45 | }: ClientRegistrationHandlerOptions): RequestHandler { 46 | if (!clientsStore.registerClient) { 47 | throw new Error('Client registration store does not support registering clients'); 48 | } 49 | 50 | // Nested router so we can configure middleware and restrict HTTP method 51 | const router = express.Router(); 52 | 53 | // Configure CORS to allow any origin, to make accessible to web-based MCP clients 54 | router.use(cors()); 55 | 56 | router.use(allowedMethods(['POST'])); 57 | router.use(express.json()); 58 | 59 | // Apply rate limiting unless explicitly disabled - stricter limits for registration 60 | if (rateLimitConfig !== false) { 61 | router.use( 62 | rateLimit({ 63 | windowMs: 60 * 60 * 1000, // 1 hour 64 | max: 20, // 20 requests per hour - stricter as registration is sensitive 65 | standardHeaders: true, 66 | legacyHeaders: false, 67 | message: new TooManyRequestsError('You have exceeded the rate limit for client registration requests').toResponseObject(), 68 | ...rateLimitConfig 69 | }) 70 | ); 71 | } 72 | 73 | router.post('/', async (req, res) => { 74 | res.setHeader('Cache-Control', 'no-store'); 75 | 76 | try { 77 | const parseResult = OAuthClientMetadataSchema.safeParse(req.body); 78 | if (!parseResult.success) { 79 | throw new InvalidClientMetadataError(parseResult.error.message); 80 | } 81 | 82 | const clientMetadata = parseResult.data; 83 | const isPublicClient = clientMetadata.token_endpoint_auth_method === 'none'; 84 | 85 | // Generate client credentials 86 | const clientSecret = isPublicClient ? undefined : crypto.randomBytes(32).toString('hex'); 87 | const clientIdIssuedAt = Math.floor(Date.now() / 1000); 88 | 89 | // Calculate client secret expiry time 90 | const clientsDoExpire = clientSecretExpirySeconds > 0; 91 | const secretExpiryTime = clientsDoExpire ? clientIdIssuedAt + clientSecretExpirySeconds : 0; 92 | const clientSecretExpiresAt = isPublicClient ? undefined : secretExpiryTime; 93 | 94 | let clientInfo: Omit & { client_id?: string } = { 95 | ...clientMetadata, 96 | client_secret: clientSecret, 97 | client_secret_expires_at: clientSecretExpiresAt 98 | }; 99 | 100 | if (clientIdGeneration) { 101 | clientInfo.client_id = crypto.randomUUID(); 102 | clientInfo.client_id_issued_at = clientIdIssuedAt; 103 | } 104 | 105 | clientInfo = await clientsStore.registerClient!(clientInfo); 106 | res.status(201).json(clientInfo); 107 | } catch (error) { 108 | if (error instanceof OAuthError) { 109 | const status = error instanceof ServerError ? 500 : 400; 110 | res.status(status).json(error.toResponseObject()); 111 | } else { 112 | const serverError = new ServerError('Internal Server Error'); 113 | res.status(500).json(serverError.toResponseObject()); 114 | } 115 | } 116 | }); 117 | 118 | return router; 119 | } 120 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, 6 | education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 7 | 8 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 9 | 10 | ## Our Standards 11 | 12 | Examples of behavior that contributes to a positive environment for our community include: 13 | 14 | - Demonstrating empathy and kindness toward other people 15 | - Being respectful of differing opinions, viewpoints, and experiences 16 | - Giving and gracefully accepting constructive feedback 17 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 18 | - Focusing on what is best not just for us as individuals, but for the overall community 19 | 20 | Examples of unacceptable behavior include: 21 | 22 | - The use of sexualized language or imagery, and sexual attention or advances of any kind 23 | - Trolling, insulting or derogatory comments, and personal or political attacks 24 | - Public or private harassment 25 | - Publishing others' private information, such as a physical or email address, without their explicit permission 26 | - Other conduct which could reasonably be considered inappropriate in a professional setting 27 | 28 | ## Enforcement Responsibilities 29 | 30 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 31 | 32 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 33 | 34 | ## Scope 35 | 36 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, 37 | or acting as an appointed representative at an online or offline event. 38 | 39 | ## Enforcement 40 | 41 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at . All complaints will be reviewed and investigated promptly and fairly. 42 | 43 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 44 | 45 | ## Enforcement Guidelines 46 | 47 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 48 | 49 | ### 1. Correction 50 | 51 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 52 | 53 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 54 | 55 | ### 2. Warning 56 | 57 | **Community Impact**: A violation through a single incident or series of actions. 58 | 59 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as 60 | well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is 67 | allowed during this period. Violating these terms may lead to a permanent ban. 68 | 69 | ### 4. Permanent Ban 70 | 71 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 72 | 73 | **Consequence**: A permanent ban from any sort of public interaction within the community. 74 | 75 | ## Attribution 76 | 77 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at . 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at . Translations are available at . 84 | -------------------------------------------------------------------------------- /src/server/auth/middleware/clientAuth.test.ts: -------------------------------------------------------------------------------- 1 | import { authenticateClient, ClientAuthenticationMiddlewareOptions } from './clientAuth.js'; 2 | import { OAuthRegisteredClientsStore } from '../clients.js'; 3 | import { OAuthClientInformationFull } from '../../../shared/auth.js'; 4 | import express from 'express'; 5 | import supertest from 'supertest'; 6 | 7 | describe('clientAuth middleware', () => { 8 | // Mock client store 9 | const mockClientStore: OAuthRegisteredClientsStore = { 10 | async getClient(clientId: string): Promise { 11 | if (clientId === 'valid-client') { 12 | return { 13 | client_id: 'valid-client', 14 | client_secret: 'valid-secret', 15 | redirect_uris: ['https://example.com/callback'] 16 | }; 17 | } else if (clientId === 'expired-client') { 18 | // Client with no secret 19 | return { 20 | client_id: 'expired-client', 21 | redirect_uris: ['https://example.com/callback'] 22 | }; 23 | } else if (clientId === 'client-with-expired-secret') { 24 | // Client with an expired secret 25 | return { 26 | client_id: 'client-with-expired-secret', 27 | client_secret: 'expired-secret', 28 | client_secret_expires_at: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago 29 | redirect_uris: ['https://example.com/callback'] 30 | }; 31 | } 32 | return undefined; 33 | } 34 | }; 35 | 36 | // Setup Express app with middleware 37 | let app: express.Express; 38 | let options: ClientAuthenticationMiddlewareOptions; 39 | 40 | beforeEach(() => { 41 | app = express(); 42 | app.use(express.json()); 43 | 44 | options = { 45 | clientsStore: mockClientStore 46 | }; 47 | 48 | // Setup route with client auth 49 | app.post('/protected', authenticateClient(options), (req, res) => { 50 | res.status(200).json({ success: true, client: req.client }); 51 | }); 52 | }); 53 | 54 | it('authenticates valid client credentials', async () => { 55 | const response = await supertest(app).post('/protected').send({ 56 | client_id: 'valid-client', 57 | client_secret: 'valid-secret' 58 | }); 59 | 60 | expect(response.status).toBe(200); 61 | expect(response.body.success).toBe(true); 62 | expect(response.body.client.client_id).toBe('valid-client'); 63 | }); 64 | 65 | it('rejects invalid client_id', async () => { 66 | const response = await supertest(app).post('/protected').send({ 67 | client_id: 'non-existent-client', 68 | client_secret: 'some-secret' 69 | }); 70 | 71 | expect(response.status).toBe(400); 72 | expect(response.body.error).toBe('invalid_client'); 73 | expect(response.body.error_description).toBe('Invalid client_id'); 74 | }); 75 | 76 | it('rejects invalid client_secret', async () => { 77 | const response = await supertest(app).post('/protected').send({ 78 | client_id: 'valid-client', 79 | client_secret: 'wrong-secret' 80 | }); 81 | 82 | expect(response.status).toBe(400); 83 | expect(response.body.error).toBe('invalid_client'); 84 | expect(response.body.error_description).toBe('Invalid client_secret'); 85 | }); 86 | 87 | it('rejects missing client_id', async () => { 88 | const response = await supertest(app).post('/protected').send({ 89 | client_secret: 'valid-secret' 90 | }); 91 | 92 | expect(response.status).toBe(400); 93 | expect(response.body.error).toBe('invalid_request'); 94 | }); 95 | 96 | it('allows missing client_secret if client has none', async () => { 97 | const response = await supertest(app).post('/protected').send({ 98 | client_id: 'expired-client' 99 | }); 100 | 101 | // Since the client has no secret, this should pass without providing one 102 | expect(response.status).toBe(200); 103 | }); 104 | 105 | it('rejects request when client secret has expired', async () => { 106 | const response = await supertest(app).post('/protected').send({ 107 | client_id: 'client-with-expired-secret', 108 | client_secret: 'expired-secret' 109 | }); 110 | 111 | expect(response.status).toBe(400); 112 | expect(response.body.error).toBe('invalid_client'); 113 | expect(response.body.error_description).toBe('Client secret has expired'); 114 | }); 115 | 116 | it('handles malformed request body', async () => { 117 | const response = await supertest(app).post('/protected').send('not-json-format'); 118 | 119 | expect(response.status).toBe(400); 120 | }); 121 | 122 | // Testing request with extra fields to ensure they're ignored 123 | it('ignores extra fields in request', async () => { 124 | const response = await supertest(app).post('/protected').send({ 125 | client_id: 'valid-client', 126 | client_secret: 'valid-secret', 127 | extra_field: 'should be ignored' 128 | }); 129 | 130 | expect(response.status).toBe(200); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /src/examples/client/multipleClientsParallel.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../../client/index.js'; 2 | import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; 3 | import { CallToolRequest, CallToolResultSchema, LoggingMessageNotificationSchema, CallToolResult } from '../../types.js'; 4 | 5 | /** 6 | * Multiple Clients MCP Example 7 | * 8 | * This client demonstrates how to: 9 | * 1. Create multiple MCP clients in parallel 10 | * 2. Each client calls a single tool 11 | * 3. Track notifications from each client independently 12 | */ 13 | 14 | // Command line args processing 15 | const args = process.argv.slice(2); 16 | const serverUrl = args[0] || 'http://localhost:3000/mcp'; 17 | 18 | interface ClientConfig { 19 | id: string; 20 | name: string; 21 | toolName: string; 22 | toolArguments: Record; 23 | } 24 | 25 | async function createAndRunClient(config: ClientConfig): Promise<{ id: string; result: CallToolResult }> { 26 | console.log(`[${config.id}] Creating client: ${config.name}`); 27 | 28 | const client = new Client({ 29 | name: config.name, 30 | version: '1.0.0' 31 | }); 32 | 33 | const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); 34 | 35 | // Set up client-specific error handler 36 | client.onerror = error => { 37 | console.error(`[${config.id}] Client error:`, error); 38 | }; 39 | 40 | // Set up client-specific notification handler 41 | client.setNotificationHandler(LoggingMessageNotificationSchema, notification => { 42 | console.log(`[${config.id}] Notification: ${notification.params.data}`); 43 | }); 44 | 45 | try { 46 | // Connect to the server 47 | await client.connect(transport); 48 | console.log(`[${config.id}] Connected to MCP server`); 49 | 50 | // Call the specified tool 51 | console.log(`[${config.id}] Calling tool: ${config.toolName}`); 52 | const toolRequest: CallToolRequest = { 53 | method: 'tools/call', 54 | params: { 55 | name: config.toolName, 56 | arguments: { 57 | ...config.toolArguments, 58 | // Add client ID to arguments for identification in notifications 59 | caller: config.id 60 | } 61 | } 62 | }; 63 | 64 | const result = await client.request(toolRequest, CallToolResultSchema); 65 | console.log(`[${config.id}] Tool call completed`); 66 | 67 | // Keep the connection open for a bit to receive notifications 68 | await new Promise(resolve => setTimeout(resolve, 5000)); 69 | 70 | // Disconnect 71 | await transport.close(); 72 | console.log(`[${config.id}] Disconnected from MCP server`); 73 | 74 | return { id: config.id, result }; 75 | } catch (error) { 76 | console.error(`[${config.id}] Error:`, error); 77 | throw error; 78 | } 79 | } 80 | 81 | async function main(): Promise { 82 | console.log('MCP Multiple Clients Example'); 83 | console.log('============================'); 84 | console.log(`Server URL: ${serverUrl}`); 85 | console.log(''); 86 | 87 | try { 88 | // Define client configurations 89 | const clientConfigs: ClientConfig[] = [ 90 | { 91 | id: 'client1', 92 | name: 'basic-client-1', 93 | toolName: 'start-notification-stream', 94 | toolArguments: { 95 | interval: 3, // 1 second between notifications 96 | count: 5 // Send 5 notifications 97 | } 98 | }, 99 | { 100 | id: 'client2', 101 | name: 'basic-client-2', 102 | toolName: 'start-notification-stream', 103 | toolArguments: { 104 | interval: 2, // 2 seconds between notifications 105 | count: 3 // Send 3 notifications 106 | } 107 | }, 108 | { 109 | id: 'client3', 110 | name: 'basic-client-3', 111 | toolName: 'start-notification-stream', 112 | toolArguments: { 113 | interval: 1, // 0.5 second between notifications 114 | count: 8 // Send 8 notifications 115 | } 116 | } 117 | ]; 118 | 119 | // Start all clients in parallel 120 | console.log(`Starting ${clientConfigs.length} clients in parallel...`); 121 | console.log(''); 122 | 123 | const clientPromises = clientConfigs.map(config => createAndRunClient(config)); 124 | const results = await Promise.all(clientPromises); 125 | 126 | // Display results from all clients 127 | console.log('\n=== Final Results ==='); 128 | results.forEach(({ id, result }) => { 129 | console.log(`\n[${id}] Tool result:`); 130 | if (Array.isArray(result.content)) { 131 | result.content.forEach((item: { type: string; text?: string }) => { 132 | if (item.type === 'text' && item.text) { 133 | console.log(` ${item.text}`); 134 | } else { 135 | console.log(` ${item.type} content:`, item); 136 | } 137 | }); 138 | } else { 139 | console.log(` Unexpected result format:`, result); 140 | } 141 | }); 142 | 143 | console.log('\n=== All clients completed successfully ==='); 144 | } catch (error) { 145 | console.error('Error running multiple clients:', error); 146 | process.exit(1); 147 | } 148 | } 149 | 150 | // Start the example 151 | main().catch((error: unknown) => { 152 | console.error('Error running MCP multiple clients example:', error); 153 | process.exit(1); 154 | }); 155 | -------------------------------------------------------------------------------- /src/examples/server/simpleStatelessStreamableHttp.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | import { McpServer } from '../../server/mcp.js'; 3 | import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; 4 | import { z } from 'zod'; 5 | import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js'; 6 | import cors from 'cors'; 7 | 8 | const getServer = () => { 9 | // Create an MCP server with implementation details 10 | const server = new McpServer( 11 | { 12 | name: 'stateless-streamable-http-server', 13 | version: '1.0.0' 14 | }, 15 | { capabilities: { logging: {} } } 16 | ); 17 | 18 | // Register a simple prompt 19 | server.prompt( 20 | 'greeting-template', 21 | 'A simple greeting prompt template', 22 | { 23 | name: z.string().describe('Name to include in greeting') 24 | }, 25 | async ({ name }): Promise => { 26 | return { 27 | messages: [ 28 | { 29 | role: 'user', 30 | content: { 31 | type: 'text', 32 | text: `Please greet ${name} in a friendly manner.` 33 | } 34 | } 35 | ] 36 | }; 37 | } 38 | ); 39 | 40 | // Register a tool specifically for testing resumability 41 | server.tool( 42 | 'start-notification-stream', 43 | 'Starts sending periodic notifications for testing resumability', 44 | { 45 | interval: z.number().describe('Interval in milliseconds between notifications').default(100), 46 | count: z.number().describe('Number of notifications to send (0 for 100)').default(10) 47 | }, 48 | async ({ interval, count }, extra): Promise => { 49 | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 50 | let counter = 0; 51 | 52 | while (count === 0 || counter < count) { 53 | counter++; 54 | try { 55 | await server.sendLoggingMessage( 56 | { 57 | level: 'info', 58 | data: `Periodic notification #${counter} at ${new Date().toISOString()}` 59 | }, 60 | extra.sessionId 61 | ); 62 | } catch (error) { 63 | console.error('Error sending notification:', error); 64 | } 65 | // Wait for the specified interval 66 | await sleep(interval); 67 | } 68 | 69 | return { 70 | content: [ 71 | { 72 | type: 'text', 73 | text: `Started sending periodic notifications every ${interval}ms` 74 | } 75 | ] 76 | }; 77 | } 78 | ); 79 | 80 | // Create a simple resource at a fixed URI 81 | server.resource( 82 | 'greeting-resource', 83 | 'https://example.com/greetings/default', 84 | { mimeType: 'text/plain' }, 85 | async (): Promise => { 86 | return { 87 | contents: [ 88 | { 89 | uri: 'https://example.com/greetings/default', 90 | text: 'Hello, world!' 91 | } 92 | ] 93 | }; 94 | } 95 | ); 96 | return server; 97 | }; 98 | 99 | const app = express(); 100 | app.use(express.json()); 101 | 102 | // Configure CORS to expose Mcp-Session-Id header for browser-based clients 103 | app.use( 104 | cors({ 105 | origin: '*', // Allow all origins - adjust as needed for production 106 | exposedHeaders: ['Mcp-Session-Id'] 107 | }) 108 | ); 109 | 110 | app.post('/mcp', async (req: Request, res: Response) => { 111 | const server = getServer(); 112 | try { 113 | const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ 114 | sessionIdGenerator: undefined 115 | }); 116 | await server.connect(transport); 117 | await transport.handleRequest(req, res, req.body); 118 | res.on('close', () => { 119 | console.log('Request closed'); 120 | transport.close(); 121 | server.close(); 122 | }); 123 | } catch (error) { 124 | console.error('Error handling MCP request:', error); 125 | if (!res.headersSent) { 126 | res.status(500).json({ 127 | jsonrpc: '2.0', 128 | error: { 129 | code: -32603, 130 | message: 'Internal server error' 131 | }, 132 | id: null 133 | }); 134 | } 135 | } 136 | }); 137 | 138 | app.get('/mcp', async (req: Request, res: Response) => { 139 | console.log('Received GET MCP request'); 140 | res.writeHead(405).end( 141 | JSON.stringify({ 142 | jsonrpc: '2.0', 143 | error: { 144 | code: -32000, 145 | message: 'Method not allowed.' 146 | }, 147 | id: null 148 | }) 149 | ); 150 | }); 151 | 152 | app.delete('/mcp', async (req: Request, res: Response) => { 153 | console.log('Received DELETE MCP request'); 154 | res.writeHead(405).end( 155 | JSON.stringify({ 156 | jsonrpc: '2.0', 157 | error: { 158 | code: -32000, 159 | message: 'Method not allowed.' 160 | }, 161 | id: null 162 | }) 163 | ); 164 | }); 165 | 166 | // Start the server 167 | const PORT = 3000; 168 | app.listen(PORT, error => { 169 | if (error) { 170 | console.error('Failed to start server:', error); 171 | process.exit(1); 172 | } 173 | console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`); 174 | }); 175 | 176 | // Handle server shutdown 177 | process.on('SIGINT', async () => { 178 | console.log('Shutting down server...'); 179 | process.exit(0); 180 | }); 181 | -------------------------------------------------------------------------------- /src/examples/server/simpleSseServer.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | import { McpServer } from '../../server/mcp.js'; 3 | import { SSEServerTransport } from '../../server/sse.js'; 4 | import { z } from 'zod'; 5 | import { CallToolResult } from '../../types.js'; 6 | 7 | /** 8 | * This example server demonstrates the deprecated HTTP+SSE transport 9 | * (protocol version 2024-11-05). It mainly used for testing backward compatible clients. 10 | * 11 | * The server exposes two endpoints: 12 | * - /mcp: For establishing the SSE stream (GET) 13 | * - /messages: For receiving client messages (POST) 14 | * 15 | */ 16 | 17 | // Create an MCP server instance 18 | const getServer = () => { 19 | const server = new McpServer( 20 | { 21 | name: 'simple-sse-server', 22 | version: '1.0.0' 23 | }, 24 | { capabilities: { logging: {} } } 25 | ); 26 | 27 | server.tool( 28 | 'start-notification-stream', 29 | 'Starts sending periodic notifications', 30 | { 31 | interval: z.number().describe('Interval in milliseconds between notifications').default(1000), 32 | count: z.number().describe('Number of notifications to send').default(10) 33 | }, 34 | async ({ interval, count }, extra): Promise => { 35 | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 36 | let counter = 0; 37 | 38 | // Send the initial notification 39 | await server.sendLoggingMessage( 40 | { 41 | level: 'info', 42 | data: `Starting notification stream with ${count} messages every ${interval}ms` 43 | }, 44 | extra.sessionId 45 | ); 46 | 47 | // Send periodic notifications 48 | while (counter < count) { 49 | counter++; 50 | await sleep(interval); 51 | 52 | try { 53 | await server.sendLoggingMessage( 54 | { 55 | level: 'info', 56 | data: `Notification #${counter} at ${new Date().toISOString()}` 57 | }, 58 | extra.sessionId 59 | ); 60 | } catch (error) { 61 | console.error('Error sending notification:', error); 62 | } 63 | } 64 | 65 | return { 66 | content: [ 67 | { 68 | type: 'text', 69 | text: `Completed sending ${count} notifications every ${interval}ms` 70 | } 71 | ] 72 | }; 73 | } 74 | ); 75 | return server; 76 | }; 77 | 78 | const app = express(); 79 | app.use(express.json()); 80 | 81 | // Store transports by session ID 82 | const transports: Record = {}; 83 | 84 | // SSE endpoint for establishing the stream 85 | app.get('/mcp', async (req: Request, res: Response) => { 86 | console.log('Received GET request to /sse (establishing SSE stream)'); 87 | 88 | try { 89 | // Create a new SSE transport for the client 90 | // The endpoint for POST messages is '/messages' 91 | const transport = new SSEServerTransport('/messages', res); 92 | 93 | // Store the transport by session ID 94 | const sessionId = transport.sessionId; 95 | transports[sessionId] = transport; 96 | 97 | // Set up onclose handler to clean up transport when closed 98 | transport.onclose = () => { 99 | console.log(`SSE transport closed for session ${sessionId}`); 100 | delete transports[sessionId]; 101 | }; 102 | 103 | // Connect the transport to the MCP server 104 | const server = getServer(); 105 | await server.connect(transport); 106 | 107 | console.log(`Established SSE stream with session ID: ${sessionId}`); 108 | } catch (error) { 109 | console.error('Error establishing SSE stream:', error); 110 | if (!res.headersSent) { 111 | res.status(500).send('Error establishing SSE stream'); 112 | } 113 | } 114 | }); 115 | 116 | // Messages endpoint for receiving client JSON-RPC requests 117 | app.post('/messages', async (req: Request, res: Response) => { 118 | console.log('Received POST request to /messages'); 119 | 120 | // Extract session ID from URL query parameter 121 | // In the SSE protocol, this is added by the client based on the endpoint event 122 | const sessionId = req.query.sessionId as string | undefined; 123 | 124 | if (!sessionId) { 125 | console.error('No session ID provided in request URL'); 126 | res.status(400).send('Missing sessionId parameter'); 127 | return; 128 | } 129 | 130 | const transport = transports[sessionId]; 131 | if (!transport) { 132 | console.error(`No active transport found for session ID: ${sessionId}`); 133 | res.status(404).send('Session not found'); 134 | return; 135 | } 136 | 137 | try { 138 | // Handle the POST message with the transport 139 | await transport.handlePostMessage(req, res, req.body); 140 | } catch (error) { 141 | console.error('Error handling request:', error); 142 | if (!res.headersSent) { 143 | res.status(500).send('Error handling request'); 144 | } 145 | } 146 | }); 147 | 148 | // Start the server 149 | const PORT = 3000; 150 | app.listen(PORT, error => { 151 | if (error) { 152 | console.error('Failed to start server:', error); 153 | process.exit(1); 154 | } 155 | console.log(`Simple SSE Server (deprecated protocol version 2024-11-05) listening on port ${PORT}`); 156 | }); 157 | 158 | // Handle server shutdown 159 | process.on('SIGINT', async () => { 160 | console.log('Shutting down server...'); 161 | 162 | // Close all active transports to properly clean up resources 163 | for (const sessionId in transports) { 164 | try { 165 | console.log(`Closing transport for session ${sessionId}`); 166 | await transports[sessionId].close(); 167 | delete transports[sessionId]; 168 | } catch (error) { 169 | console.error(`Error closing transport for session ${sessionId}:`, error); 170 | } 171 | } 172 | console.log('Server shutdown complete'); 173 | process.exit(0); 174 | }); 175 | -------------------------------------------------------------------------------- /src/server/auth/handlers/token.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import express, { RequestHandler } from 'express'; 3 | import { OAuthServerProvider } from '../provider.js'; 4 | import cors from 'cors'; 5 | import { verifyChallenge } from 'pkce-challenge'; 6 | import { authenticateClient } from '../middleware/clientAuth.js'; 7 | import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; 8 | import { allowedMethods } from '../middleware/allowedMethods.js'; 9 | import { 10 | InvalidRequestError, 11 | InvalidGrantError, 12 | UnsupportedGrantTypeError, 13 | ServerError, 14 | TooManyRequestsError, 15 | OAuthError 16 | } from '../errors.js'; 17 | 18 | export type TokenHandlerOptions = { 19 | provider: OAuthServerProvider; 20 | /** 21 | * Rate limiting configuration for the token endpoint. 22 | * Set to false to disable rate limiting for this endpoint. 23 | */ 24 | rateLimit?: Partial | false; 25 | }; 26 | 27 | const TokenRequestSchema = z.object({ 28 | grant_type: z.string() 29 | }); 30 | 31 | const AuthorizationCodeGrantSchema = z.object({ 32 | code: z.string(), 33 | code_verifier: z.string(), 34 | redirect_uri: z.string().optional(), 35 | resource: z.string().url().optional() 36 | }); 37 | 38 | const RefreshTokenGrantSchema = z.object({ 39 | refresh_token: z.string(), 40 | scope: z.string().optional(), 41 | resource: z.string().url().optional() 42 | }); 43 | 44 | export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler { 45 | // Nested router so we can configure middleware and restrict HTTP method 46 | const router = express.Router(); 47 | 48 | // Configure CORS to allow any origin, to make accessible to web-based MCP clients 49 | router.use(cors()); 50 | 51 | router.use(allowedMethods(['POST'])); 52 | router.use(express.urlencoded({ extended: false })); 53 | 54 | // Apply rate limiting unless explicitly disabled 55 | if (rateLimitConfig !== false) { 56 | router.use( 57 | rateLimit({ 58 | windowMs: 15 * 60 * 1000, // 15 minutes 59 | max: 50, // 50 requests per windowMs 60 | standardHeaders: true, 61 | legacyHeaders: false, 62 | message: new TooManyRequestsError('You have exceeded the rate limit for token requests').toResponseObject(), 63 | ...rateLimitConfig 64 | }) 65 | ); 66 | } 67 | 68 | // Authenticate and extract client details 69 | router.use(authenticateClient({ clientsStore: provider.clientsStore })); 70 | 71 | router.post('/', async (req, res) => { 72 | res.setHeader('Cache-Control', 'no-store'); 73 | 74 | try { 75 | const parseResult = TokenRequestSchema.safeParse(req.body); 76 | if (!parseResult.success) { 77 | throw new InvalidRequestError(parseResult.error.message); 78 | } 79 | 80 | const { grant_type } = parseResult.data; 81 | 82 | const client = req.client; 83 | if (!client) { 84 | // This should never happen 85 | throw new ServerError('Internal Server Error'); 86 | } 87 | 88 | switch (grant_type) { 89 | case 'authorization_code': { 90 | const parseResult = AuthorizationCodeGrantSchema.safeParse(req.body); 91 | if (!parseResult.success) { 92 | throw new InvalidRequestError(parseResult.error.message); 93 | } 94 | 95 | const { code, code_verifier, redirect_uri, resource } = parseResult.data; 96 | 97 | const skipLocalPkceValidation = provider.skipLocalPkceValidation; 98 | 99 | // Perform local PKCE validation unless explicitly skipped 100 | // (e.g. to validate code_verifier in upstream server) 101 | if (!skipLocalPkceValidation) { 102 | const codeChallenge = await provider.challengeForAuthorizationCode(client, code); 103 | if (!(await verifyChallenge(code_verifier, codeChallenge))) { 104 | throw new InvalidGrantError('code_verifier does not match the challenge'); 105 | } 106 | } 107 | 108 | // Passes the code_verifier to the provider if PKCE validation didn't occur locally 109 | const tokens = await provider.exchangeAuthorizationCode( 110 | client, 111 | code, 112 | skipLocalPkceValidation ? code_verifier : undefined, 113 | redirect_uri, 114 | resource ? new URL(resource) : undefined 115 | ); 116 | res.status(200).json(tokens); 117 | break; 118 | } 119 | 120 | case 'refresh_token': { 121 | const parseResult = RefreshTokenGrantSchema.safeParse(req.body); 122 | if (!parseResult.success) { 123 | throw new InvalidRequestError(parseResult.error.message); 124 | } 125 | 126 | const { refresh_token, scope, resource } = parseResult.data; 127 | 128 | const scopes = scope?.split(' '); 129 | const tokens = await provider.exchangeRefreshToken( 130 | client, 131 | refresh_token, 132 | scopes, 133 | resource ? new URL(resource) : undefined 134 | ); 135 | res.status(200).json(tokens); 136 | break; 137 | } 138 | 139 | // Not supported right now 140 | //case "client_credentials": 141 | 142 | default: 143 | throw new UnsupportedGrantTypeError('The grant type is not supported by this authorization server.'); 144 | } 145 | } catch (error) { 146 | if (error instanceof OAuthError) { 147 | const status = error instanceof ServerError ? 500 : 400; 148 | res.status(status).json(error.toResponseObject()); 149 | } else { 150 | const serverError = new ServerError('Internal Server Error'); 151 | res.status(500).json(serverError.toResponseObject()); 152 | } 153 | } 154 | }); 155 | 156 | return router; 157 | } 158 | -------------------------------------------------------------------------------- /src/examples/server/jsonResponseStreamableHttp.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | import { randomUUID } from 'node:crypto'; 3 | import { McpServer } from '../../server/mcp.js'; 4 | import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; 5 | import { z } from 'zod'; 6 | import { CallToolResult, isInitializeRequest } from '../../types.js'; 7 | import cors from 'cors'; 8 | 9 | // Create an MCP server with implementation details 10 | const getServer = () => { 11 | const server = new McpServer( 12 | { 13 | name: 'json-response-streamable-http-server', 14 | version: '1.0.0' 15 | }, 16 | { 17 | capabilities: { 18 | logging: {} 19 | } 20 | } 21 | ); 22 | 23 | // Register a simple tool that returns a greeting 24 | server.tool( 25 | 'greet', 26 | 'A simple greeting tool', 27 | { 28 | name: z.string().describe('Name to greet') 29 | }, 30 | async ({ name }): Promise => { 31 | return { 32 | content: [ 33 | { 34 | type: 'text', 35 | text: `Hello, ${name}!` 36 | } 37 | ] 38 | }; 39 | } 40 | ); 41 | 42 | // Register a tool that sends multiple greetings with notifications 43 | server.tool( 44 | 'multi-greet', 45 | 'A tool that sends different greetings with delays between them', 46 | { 47 | name: z.string().describe('Name to greet') 48 | }, 49 | async ({ name }, extra): Promise => { 50 | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 51 | 52 | await server.sendLoggingMessage( 53 | { 54 | level: 'debug', 55 | data: `Starting multi-greet for ${name}` 56 | }, 57 | extra.sessionId 58 | ); 59 | 60 | await sleep(1000); // Wait 1 second before first greeting 61 | 62 | await server.sendLoggingMessage( 63 | { 64 | level: 'info', 65 | data: `Sending first greeting to ${name}` 66 | }, 67 | extra.sessionId 68 | ); 69 | 70 | await sleep(1000); // Wait another second before second greeting 71 | 72 | await server.sendLoggingMessage( 73 | { 74 | level: 'info', 75 | data: `Sending second greeting to ${name}` 76 | }, 77 | extra.sessionId 78 | ); 79 | 80 | return { 81 | content: [ 82 | { 83 | type: 'text', 84 | text: `Good morning, ${name}!` 85 | } 86 | ] 87 | }; 88 | } 89 | ); 90 | return server; 91 | }; 92 | 93 | const app = express(); 94 | app.use(express.json()); 95 | 96 | // Configure CORS to expose Mcp-Session-Id header for browser-based clients 97 | app.use( 98 | cors({ 99 | origin: '*', // Allow all origins - adjust as needed for production 100 | exposedHeaders: ['Mcp-Session-Id'] 101 | }) 102 | ); 103 | 104 | // Map to store transports by session ID 105 | const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; 106 | 107 | app.post('/mcp', async (req: Request, res: Response) => { 108 | console.log('Received MCP request:', req.body); 109 | try { 110 | // Check for existing session ID 111 | const sessionId = req.headers['mcp-session-id'] as string | undefined; 112 | let transport: StreamableHTTPServerTransport; 113 | 114 | if (sessionId && transports[sessionId]) { 115 | // Reuse existing transport 116 | transport = transports[sessionId]; 117 | } else if (!sessionId && isInitializeRequest(req.body)) { 118 | // New initialization request - use JSON response mode 119 | transport = new StreamableHTTPServerTransport({ 120 | sessionIdGenerator: () => randomUUID(), 121 | enableJsonResponse: true, // Enable JSON response mode 122 | onsessioninitialized: sessionId => { 123 | // Store the transport by session ID when session is initialized 124 | // This avoids race conditions where requests might come in before the session is stored 125 | console.log(`Session initialized with ID: ${sessionId}`); 126 | transports[sessionId] = transport; 127 | } 128 | }); 129 | 130 | // Connect the transport to the MCP server BEFORE handling the request 131 | const server = getServer(); 132 | await server.connect(transport); 133 | await transport.handleRequest(req, res, req.body); 134 | return; // Already handled 135 | } else { 136 | // Invalid request - no session ID or not initialization request 137 | res.status(400).json({ 138 | jsonrpc: '2.0', 139 | error: { 140 | code: -32000, 141 | message: 'Bad Request: No valid session ID provided' 142 | }, 143 | id: null 144 | }); 145 | return; 146 | } 147 | 148 | // Handle the request with existing transport - no need to reconnect 149 | await transport.handleRequest(req, res, req.body); 150 | } catch (error) { 151 | console.error('Error handling MCP request:', error); 152 | if (!res.headersSent) { 153 | res.status(500).json({ 154 | jsonrpc: '2.0', 155 | error: { 156 | code: -32603, 157 | message: 'Internal server error' 158 | }, 159 | id: null 160 | }); 161 | } 162 | } 163 | }); 164 | 165 | // Handle GET requests for SSE streams according to spec 166 | app.get('/mcp', async (req: Request, res: Response) => { 167 | // Since this is a very simple example, we don't support GET requests for this server 168 | // The spec requires returning 405 Method Not Allowed in this case 169 | res.status(405).set('Allow', 'POST').send('Method Not Allowed'); 170 | }); 171 | 172 | // Start the server 173 | const PORT = 3000; 174 | app.listen(PORT, error => { 175 | if (error) { 176 | console.error('Failed to start server:', error); 177 | process.exit(1); 178 | } 179 | console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); 180 | }); 181 | 182 | // Handle server shutdown 183 | process.on('SIGINT', async () => { 184 | console.log('Shutting down server...'); 185 | process.exit(0); 186 | }); 187 | -------------------------------------------------------------------------------- /src/shared/protocol-transport-handling.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test, beforeEach } from '@jest/globals'; 2 | import { Protocol } from './protocol.js'; 3 | import { Transport } from './transport.js'; 4 | import { Request, Notification, Result, JSONRPCMessage } from '../types.js'; 5 | import { z } from 'zod'; 6 | 7 | // Mock Transport class 8 | class MockTransport implements Transport { 9 | id: string; 10 | onclose?: () => void; 11 | onerror?: (error: Error) => void; 12 | onmessage?: (message: unknown) => void; 13 | sentMessages: JSONRPCMessage[] = []; 14 | 15 | constructor(id: string) { 16 | this.id = id; 17 | } 18 | 19 | async start(): Promise {} 20 | 21 | async close(): Promise { 22 | this.onclose?.(); 23 | } 24 | 25 | async send(message: JSONRPCMessage): Promise { 26 | this.sentMessages.push(message); 27 | } 28 | } 29 | 30 | describe('Protocol transport handling bug', () => { 31 | let protocol: Protocol; 32 | let transportA: MockTransport; 33 | let transportB: MockTransport; 34 | 35 | beforeEach(() => { 36 | protocol = new (class extends Protocol { 37 | protected assertCapabilityForMethod(): void {} 38 | protected assertNotificationCapability(): void {} 39 | protected assertRequestHandlerCapability(): void {} 40 | })(); 41 | 42 | transportA = new MockTransport('A'); 43 | transportB = new MockTransport('B'); 44 | }); 45 | 46 | test('should send response to the correct transport when multiple clients are connected', async () => { 47 | // Set up a request handler that simulates processing time 48 | let resolveHandler: (value: Result) => void; 49 | const handlerPromise = new Promise(resolve => { 50 | resolveHandler = resolve; 51 | }); 52 | 53 | const TestRequestSchema = z.object({ 54 | method: z.literal('test/method'), 55 | params: z 56 | .object({ 57 | from: z.string() 58 | }) 59 | .optional() 60 | }); 61 | 62 | protocol.setRequestHandler(TestRequestSchema, async request => { 63 | console.log(`Processing request from ${request.params?.from}`); 64 | return handlerPromise; 65 | }); 66 | 67 | // Client A connects and sends a request 68 | await protocol.connect(transportA); 69 | 70 | const requestFromA = { 71 | jsonrpc: '2.0' as const, 72 | method: 'test/method', 73 | params: { from: 'clientA' }, 74 | id: 1 75 | }; 76 | 77 | // Simulate client A sending a request 78 | transportA.onmessage?.(requestFromA); 79 | 80 | // While A's request is being processed, client B connects 81 | // This overwrites the transport reference in the protocol 82 | await protocol.connect(transportB); 83 | 84 | const requestFromB = { 85 | jsonrpc: '2.0' as const, 86 | method: 'test/method', 87 | params: { from: 'clientB' }, 88 | id: 2 89 | }; 90 | 91 | // Client B sends its own request 92 | transportB.onmessage?.(requestFromB); 93 | 94 | // Now complete A's request 95 | resolveHandler!({ data: 'responseForA' } as Result); 96 | 97 | // Wait for async operations to complete 98 | await new Promise(resolve => setTimeout(resolve, 10)); 99 | 100 | // Check where the responses went 101 | console.log('Transport A received:', transportA.sentMessages); 102 | console.log('Transport B received:', transportB.sentMessages); 103 | 104 | // FIXED: Each transport now receives its own response 105 | 106 | // Transport A should receive response for request ID 1 107 | expect(transportA.sentMessages.length).toBe(1); 108 | expect(transportA.sentMessages[0]).toMatchObject({ 109 | jsonrpc: '2.0', 110 | id: 1, 111 | result: { data: 'responseForA' } 112 | }); 113 | 114 | // Transport B should only receive its own response (when implemented) 115 | expect(transportB.sentMessages.length).toBe(1); 116 | expect(transportB.sentMessages[0]).toMatchObject({ 117 | jsonrpc: '2.0', 118 | id: 2, 119 | result: { data: 'responseForA' } // Same handler result in this test 120 | }); 121 | }); 122 | 123 | test('demonstrates the timing issue with multiple rapid connections', async () => { 124 | const delays: number[] = []; 125 | const results: { transport: string; response: JSONRPCMessage[] }[] = []; 126 | 127 | const DelayedRequestSchema = z.object({ 128 | method: z.literal('test/delayed'), 129 | params: z 130 | .object({ 131 | delay: z.number(), 132 | client: z.string() 133 | }) 134 | .optional() 135 | }); 136 | 137 | // Set up handler with variable delay 138 | protocol.setRequestHandler(DelayedRequestSchema, async (request, extra) => { 139 | const delay = request.params?.delay || 0; 140 | delays.push(delay); 141 | 142 | await new Promise(resolve => setTimeout(resolve, delay)); 143 | 144 | return { 145 | processedBy: `handler-${extra.requestId}`, 146 | delay: delay 147 | } as Result; 148 | }); 149 | 150 | // Rapid succession of connections and requests 151 | await protocol.connect(transportA); 152 | transportA.onmessage?.({ 153 | jsonrpc: '2.0' as const, 154 | method: 'test/delayed', 155 | params: { delay: 50, client: 'A' }, 156 | id: 1 157 | }); 158 | 159 | // Connect B while A is processing 160 | setTimeout(async () => { 161 | await protocol.connect(transportB); 162 | transportB.onmessage?.({ 163 | jsonrpc: '2.0' as const, 164 | method: 'test/delayed', 165 | params: { delay: 10, client: 'B' }, 166 | id: 2 167 | }); 168 | }, 10); 169 | 170 | // Wait for all processing 171 | await new Promise(resolve => setTimeout(resolve, 100)); 172 | 173 | // Collect results 174 | if (transportA.sentMessages.length > 0) { 175 | results.push({ transport: 'A', response: transportA.sentMessages }); 176 | } 177 | if (transportB.sentMessages.length > 0) { 178 | results.push({ transport: 'B', response: transportB.sentMessages }); 179 | } 180 | 181 | console.log('Timing test results:', results); 182 | 183 | // FIXED: Each transport receives its own responses 184 | expect(transportA.sentMessages.length).toBe(1); 185 | expect(transportB.sentMessages.length).toBe(1); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /src/server/auth/errors.ts: -------------------------------------------------------------------------------- 1 | import { OAuthErrorResponse } from '../../shared/auth.js'; 2 | 3 | /** 4 | * Base class for all OAuth errors 5 | */ 6 | export class OAuthError extends Error { 7 | static errorCode: string; 8 | 9 | constructor( 10 | message: string, 11 | public readonly errorUri?: string 12 | ) { 13 | super(message); 14 | this.name = this.constructor.name; 15 | } 16 | 17 | /** 18 | * Converts the error to a standard OAuth error response object 19 | */ 20 | toResponseObject(): OAuthErrorResponse { 21 | const response: OAuthErrorResponse = { 22 | error: this.errorCode, 23 | error_description: this.message 24 | }; 25 | 26 | if (this.errorUri) { 27 | response.error_uri = this.errorUri; 28 | } 29 | 30 | return response; 31 | } 32 | 33 | get errorCode(): string { 34 | return (this.constructor as typeof OAuthError).errorCode; 35 | } 36 | } 37 | 38 | /** 39 | * Invalid request error - The request is missing a required parameter, 40 | * includes an invalid parameter value, includes a parameter more than once, 41 | * or is otherwise malformed. 42 | */ 43 | export class InvalidRequestError extends OAuthError { 44 | static errorCode = 'invalid_request'; 45 | } 46 | 47 | /** 48 | * Invalid client error - Client authentication failed (e.g., unknown client, no client 49 | * authentication included, or unsupported authentication method). 50 | */ 51 | export class InvalidClientError extends OAuthError { 52 | static errorCode = 'invalid_client'; 53 | } 54 | 55 | /** 56 | * Invalid grant error - The provided authorization grant or refresh token is 57 | * invalid, expired, revoked, does not match the redirection URI used in the 58 | * authorization request, or was issued to another client. 59 | */ 60 | export class InvalidGrantError extends OAuthError { 61 | static errorCode = 'invalid_grant'; 62 | } 63 | 64 | /** 65 | * Unauthorized client error - The authenticated client is not authorized to use 66 | * this authorization grant type. 67 | */ 68 | export class UnauthorizedClientError extends OAuthError { 69 | static errorCode = 'unauthorized_client'; 70 | } 71 | 72 | /** 73 | * Unsupported grant type error - The authorization grant type is not supported 74 | * by the authorization server. 75 | */ 76 | export class UnsupportedGrantTypeError extends OAuthError { 77 | static errorCode = 'unsupported_grant_type'; 78 | } 79 | 80 | /** 81 | * Invalid scope error - The requested scope is invalid, unknown, malformed, or 82 | * exceeds the scope granted by the resource owner. 83 | */ 84 | export class InvalidScopeError extends OAuthError { 85 | static errorCode = 'invalid_scope'; 86 | } 87 | 88 | /** 89 | * Access denied error - The resource owner or authorization server denied the request. 90 | */ 91 | export class AccessDeniedError extends OAuthError { 92 | static errorCode = 'access_denied'; 93 | } 94 | 95 | /** 96 | * Server error - The authorization server encountered an unexpected condition 97 | * that prevented it from fulfilling the request. 98 | */ 99 | export class ServerError extends OAuthError { 100 | static errorCode = 'server_error'; 101 | } 102 | 103 | /** 104 | * Temporarily unavailable error - The authorization server is currently unable to 105 | * handle the request due to a temporary overloading or maintenance of the server. 106 | */ 107 | export class TemporarilyUnavailableError extends OAuthError { 108 | static errorCode = 'temporarily_unavailable'; 109 | } 110 | 111 | /** 112 | * Unsupported response type error - The authorization server does not support 113 | * obtaining an authorization code using this method. 114 | */ 115 | export class UnsupportedResponseTypeError extends OAuthError { 116 | static errorCode = 'unsupported_response_type'; 117 | } 118 | 119 | /** 120 | * Unsupported token type error - The authorization server does not support 121 | * the requested token type. 122 | */ 123 | export class UnsupportedTokenTypeError extends OAuthError { 124 | static errorCode = 'unsupported_token_type'; 125 | } 126 | 127 | /** 128 | * Invalid token error - The access token provided is expired, revoked, malformed, 129 | * or invalid for other reasons. 130 | */ 131 | export class InvalidTokenError extends OAuthError { 132 | static errorCode = 'invalid_token'; 133 | } 134 | 135 | /** 136 | * Method not allowed error - The HTTP method used is not allowed for this endpoint. 137 | * (Custom, non-standard error) 138 | */ 139 | export class MethodNotAllowedError extends OAuthError { 140 | static errorCode = 'method_not_allowed'; 141 | } 142 | 143 | /** 144 | * Too many requests error - Rate limit exceeded. 145 | * (Custom, non-standard error based on RFC 6585) 146 | */ 147 | export class TooManyRequestsError extends OAuthError { 148 | static errorCode = 'too_many_requests'; 149 | } 150 | 151 | /** 152 | * Invalid client metadata error - The client metadata is invalid. 153 | * (Custom error for dynamic client registration - RFC 7591) 154 | */ 155 | export class InvalidClientMetadataError extends OAuthError { 156 | static errorCode = 'invalid_client_metadata'; 157 | } 158 | 159 | /** 160 | * Insufficient scope error - The request requires higher privileges than provided by the access token. 161 | */ 162 | export class InsufficientScopeError extends OAuthError { 163 | static errorCode = 'insufficient_scope'; 164 | } 165 | 166 | /** 167 | * A utility class for defining one-off error codes 168 | */ 169 | export class CustomOAuthError extends OAuthError { 170 | constructor( 171 | private readonly customErrorCode: string, 172 | message: string, 173 | errorUri?: string 174 | ) { 175 | super(message, errorUri); 176 | } 177 | 178 | get errorCode(): string { 179 | return this.customErrorCode; 180 | } 181 | } 182 | 183 | /** 184 | * A full list of all OAuthErrors, enabling parsing from error responses 185 | */ 186 | export const OAUTH_ERRORS = { 187 | [InvalidRequestError.errorCode]: InvalidRequestError, 188 | [InvalidClientError.errorCode]: InvalidClientError, 189 | [InvalidGrantError.errorCode]: InvalidGrantError, 190 | [UnauthorizedClientError.errorCode]: UnauthorizedClientError, 191 | [UnsupportedGrantTypeError.errorCode]: UnsupportedGrantTypeError, 192 | [InvalidScopeError.errorCode]: InvalidScopeError, 193 | [AccessDeniedError.errorCode]: AccessDeniedError, 194 | [ServerError.errorCode]: ServerError, 195 | [TemporarilyUnavailableError.errorCode]: TemporarilyUnavailableError, 196 | [UnsupportedResponseTypeError.errorCode]: UnsupportedResponseTypeError, 197 | [UnsupportedTokenTypeError.errorCode]: UnsupportedTokenTypeError, 198 | [InvalidTokenError.errorCode]: InvalidTokenError, 199 | [MethodNotAllowedError.errorCode]: MethodNotAllowedError, 200 | [TooManyRequestsError.errorCode]: TooManyRequestsError, 201 | [InvalidClientMetadataError.errorCode]: InvalidClientMetadataError, 202 | [InsufficientScopeError.errorCode]: InsufficientScopeError 203 | } as const; 204 | -------------------------------------------------------------------------------- /src/examples/client/streamableHttpWithSseFallbackClient.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../../client/index.js'; 2 | import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; 3 | import { SSEClientTransport } from '../../client/sse.js'; 4 | import { 5 | ListToolsRequest, 6 | ListToolsResultSchema, 7 | CallToolRequest, 8 | CallToolResultSchema, 9 | LoggingMessageNotificationSchema 10 | } from '../../types.js'; 11 | 12 | /** 13 | * Simplified Backwards Compatible MCP Client 14 | * 15 | * This client demonstrates backward compatibility with both: 16 | * 1. Modern servers using Streamable HTTP transport (protocol version 2025-03-26) 17 | * 2. Older servers using HTTP+SSE transport (protocol version 2024-11-05) 18 | * 19 | * Following the MCP specification for backwards compatibility: 20 | * - Attempts to POST an initialize request to the server URL first (modern transport) 21 | * - If that fails with 4xx status, falls back to GET request for SSE stream (older transport) 22 | */ 23 | 24 | // Command line args processing 25 | const args = process.argv.slice(2); 26 | const serverUrl = args[0] || 'http://localhost:3000/mcp'; 27 | 28 | async function main(): Promise { 29 | console.log('MCP Backwards Compatible Client'); 30 | console.log('==============================='); 31 | console.log(`Connecting to server at: ${serverUrl}`); 32 | 33 | let client: Client; 34 | let transport: StreamableHTTPClientTransport | SSEClientTransport; 35 | 36 | try { 37 | // Try connecting with automatic transport detection 38 | const connection = await connectWithBackwardsCompatibility(serverUrl); 39 | client = connection.client; 40 | transport = connection.transport; 41 | 42 | // Set up notification handler 43 | client.setNotificationHandler(LoggingMessageNotificationSchema, notification => { 44 | console.log(`Notification: ${notification.params.level} - ${notification.params.data}`); 45 | }); 46 | 47 | // DEMO WORKFLOW: 48 | // 1. List available tools 49 | console.log('\n=== Listing Available Tools ==='); 50 | await listTools(client); 51 | 52 | // 2. Call the notification tool 53 | console.log('\n=== Starting Notification Stream ==='); 54 | await startNotificationTool(client); 55 | 56 | // 3. Wait for all notifications (5 seconds) 57 | console.log('\n=== Waiting for all notifications ==='); 58 | await new Promise(resolve => setTimeout(resolve, 5000)); 59 | 60 | // 4. Disconnect 61 | console.log('\n=== Disconnecting ==='); 62 | await transport.close(); 63 | console.log('Disconnected from MCP server'); 64 | } catch (error) { 65 | console.error('Error running client:', error); 66 | process.exit(1); 67 | } 68 | } 69 | 70 | /** 71 | * Connect to an MCP server with backwards compatibility 72 | * Following the spec for client backward compatibility 73 | */ 74 | async function connectWithBackwardsCompatibility(url: string): Promise<{ 75 | client: Client; 76 | transport: StreamableHTTPClientTransport | SSEClientTransport; 77 | transportType: 'streamable-http' | 'sse'; 78 | }> { 79 | console.log('1. Trying Streamable HTTP transport first...'); 80 | 81 | // Step 1: Try Streamable HTTP transport first 82 | const client = new Client({ 83 | name: 'backwards-compatible-client', 84 | version: '1.0.0' 85 | }); 86 | 87 | client.onerror = error => { 88 | console.error('Client error:', error); 89 | }; 90 | const baseUrl = new URL(url); 91 | 92 | try { 93 | // Create modern transport 94 | const streamableTransport = new StreamableHTTPClientTransport(baseUrl); 95 | await client.connect(streamableTransport); 96 | 97 | console.log('Successfully connected using modern Streamable HTTP transport.'); 98 | return { 99 | client, 100 | transport: streamableTransport, 101 | transportType: 'streamable-http' 102 | }; 103 | } catch (error) { 104 | // Step 2: If transport fails, try the older SSE transport 105 | console.log(`StreamableHttp transport connection failed: ${error}`); 106 | console.log('2. Falling back to deprecated HTTP+SSE transport...'); 107 | 108 | try { 109 | // Create SSE transport pointing to /sse endpoint 110 | const sseTransport = new SSEClientTransport(baseUrl); 111 | const sseClient = new Client({ 112 | name: 'backwards-compatible-client', 113 | version: '1.0.0' 114 | }); 115 | await sseClient.connect(sseTransport); 116 | 117 | console.log('Successfully connected using deprecated HTTP+SSE transport.'); 118 | return { 119 | client: sseClient, 120 | transport: sseTransport, 121 | transportType: 'sse' 122 | }; 123 | } catch (sseError) { 124 | console.error(`Failed to connect with either transport method:\n1. Streamable HTTP error: ${error}\n2. SSE error: ${sseError}`); 125 | throw new Error('Could not connect to server with any available transport'); 126 | } 127 | } 128 | } 129 | 130 | /** 131 | * List available tools on the server 132 | */ 133 | async function listTools(client: Client): Promise { 134 | try { 135 | const toolsRequest: ListToolsRequest = { 136 | method: 'tools/list', 137 | params: {} 138 | }; 139 | const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); 140 | 141 | console.log('Available tools:'); 142 | if (toolsResult.tools.length === 0) { 143 | console.log(' No tools available'); 144 | } else { 145 | for (const tool of toolsResult.tools) { 146 | console.log(` - ${tool.name}: ${tool.description}`); 147 | } 148 | } 149 | } catch (error) { 150 | console.log(`Tools not supported by this server: ${error}`); 151 | } 152 | } 153 | 154 | /** 155 | * Start a notification stream by calling the notification tool 156 | */ 157 | async function startNotificationTool(client: Client): Promise { 158 | try { 159 | // Call the notification tool using reasonable defaults 160 | const request: CallToolRequest = { 161 | method: 'tools/call', 162 | params: { 163 | name: 'start-notification-stream', 164 | arguments: { 165 | interval: 1000, // 1 second between notifications 166 | count: 5 // Send 5 notifications 167 | } 168 | } 169 | }; 170 | 171 | console.log('Calling notification tool...'); 172 | const result = await client.request(request, CallToolResultSchema); 173 | 174 | console.log('Tool result:'); 175 | result.content.forEach(item => { 176 | if (item.type === 'text') { 177 | console.log(` ${item.text}`); 178 | } else { 179 | console.log(` ${item.type} content:`, item); 180 | } 181 | }); 182 | } catch (error) { 183 | console.log(`Error calling notification tool: ${error}`); 184 | } 185 | } 186 | 187 | // Start the client 188 | main().catch((error: unknown) => { 189 | console.error('Error running MCP client:', error); 190 | process.exit(1); 191 | }); 192 | -------------------------------------------------------------------------------- /src/examples/client/parallelToolCallsClient.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../../client/index.js'; 2 | import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; 3 | import { 4 | ListToolsRequest, 5 | ListToolsResultSchema, 6 | CallToolResultSchema, 7 | LoggingMessageNotificationSchema, 8 | CallToolResult 9 | } from '../../types.js'; 10 | 11 | /** 12 | * Parallel Tool Calls MCP Client 13 | * 14 | * This client demonstrates how to: 15 | * 1. Start multiple tool calls in parallel 16 | * 2. Track notifications from each tool call using a caller parameter 17 | */ 18 | 19 | // Command line args processing 20 | const args = process.argv.slice(2); 21 | const serverUrl = args[0] || 'http://localhost:3000/mcp'; 22 | 23 | async function main(): Promise { 24 | console.log('MCP Parallel Tool Calls Client'); 25 | console.log('=============================='); 26 | console.log(`Connecting to server at: ${serverUrl}`); 27 | 28 | let client: Client; 29 | let transport: StreamableHTTPClientTransport; 30 | 31 | try { 32 | // Create client with streamable HTTP transport 33 | client = new Client({ 34 | name: 'parallel-tool-calls-client', 35 | version: '1.0.0' 36 | }); 37 | 38 | client.onerror = error => { 39 | console.error('Client error:', error); 40 | }; 41 | 42 | // Connect to the server 43 | transport = new StreamableHTTPClientTransport(new URL(serverUrl)); 44 | await client.connect(transport); 45 | console.log('Successfully connected to MCP server'); 46 | 47 | // Set up notification handler with caller identification 48 | client.setNotificationHandler(LoggingMessageNotificationSchema, notification => { 49 | console.log(`Notification: ${notification.params.data}`); 50 | }); 51 | 52 | console.log('List tools'); 53 | const toolsRequest = await listTools(client); 54 | console.log('Tools: ', toolsRequest); 55 | 56 | // 2. Start multiple notification tools in parallel 57 | console.log('\n=== Starting Multiple Notification Streams in Parallel ==='); 58 | const toolResults = await startParallelNotificationTools(client); 59 | 60 | // Log the results from each tool call 61 | for (const [caller, result] of Object.entries(toolResults)) { 62 | console.log(`\n=== Tool result for ${caller} ===`); 63 | result.content.forEach((item: { type: string; text?: string }) => { 64 | if (item.type === 'text') { 65 | console.log(` ${item.text}`); 66 | } else { 67 | console.log(` ${item.type} content:`, item); 68 | } 69 | }); 70 | } 71 | 72 | // 3. Wait for all notifications (10 seconds) 73 | console.log('\n=== Waiting for all notifications ==='); 74 | await new Promise(resolve => setTimeout(resolve, 10000)); 75 | 76 | // 4. Disconnect 77 | console.log('\n=== Disconnecting ==='); 78 | await transport.close(); 79 | console.log('Disconnected from MCP server'); 80 | } catch (error) { 81 | console.error('Error running client:', error); 82 | process.exit(1); 83 | } 84 | } 85 | 86 | /** 87 | * List available tools on the server 88 | */ 89 | async function listTools(client: Client): Promise { 90 | try { 91 | const toolsRequest: ListToolsRequest = { 92 | method: 'tools/list', 93 | params: {} 94 | }; 95 | const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); 96 | 97 | console.log('Available tools:'); 98 | if (toolsResult.tools.length === 0) { 99 | console.log(' No tools available'); 100 | } else { 101 | for (const tool of toolsResult.tools) { 102 | console.log(` - ${tool.name}: ${tool.description}`); 103 | } 104 | } 105 | } catch (error) { 106 | console.log(`Tools not supported by this server: ${error}`); 107 | } 108 | } 109 | 110 | /** 111 | * Start multiple notification tools in parallel with different configurations 112 | * Each tool call includes a caller parameter to identify its notifications 113 | */ 114 | async function startParallelNotificationTools(client: Client): Promise> { 115 | try { 116 | // Define multiple tool calls with different configurations 117 | const toolCalls = [ 118 | { 119 | caller: 'fast-notifier', 120 | request: { 121 | method: 'tools/call', 122 | params: { 123 | name: 'start-notification-stream', 124 | arguments: { 125 | interval: 2, // 0.5 second between notifications 126 | count: 10, // Send 10 notifications 127 | caller: 'fast-notifier' // Identify this tool call 128 | } 129 | } 130 | } 131 | }, 132 | { 133 | caller: 'slow-notifier', 134 | request: { 135 | method: 'tools/call', 136 | params: { 137 | name: 'start-notification-stream', 138 | arguments: { 139 | interval: 5, // 2 seconds between notifications 140 | count: 5, // Send 5 notifications 141 | caller: 'slow-notifier' // Identify this tool call 142 | } 143 | } 144 | } 145 | }, 146 | { 147 | caller: 'burst-notifier', 148 | request: { 149 | method: 'tools/call', 150 | params: { 151 | name: 'start-notification-stream', 152 | arguments: { 153 | interval: 1, // 0.1 second between notifications 154 | count: 3, // Send just 3 notifications 155 | caller: 'burst-notifier' // Identify this tool call 156 | } 157 | } 158 | } 159 | } 160 | ]; 161 | 162 | console.log(`Starting ${toolCalls.length} notification tools in parallel...`); 163 | 164 | // Start all tool calls in parallel 165 | const toolPromises = toolCalls.map(({ caller, request }) => { 166 | console.log(`Starting tool call for ${caller}...`); 167 | return client 168 | .request(request, CallToolResultSchema) 169 | .then(result => ({ caller, result })) 170 | .catch(error => { 171 | console.error(`Error in tool call for ${caller}:`, error); 172 | throw error; 173 | }); 174 | }); 175 | 176 | // Wait for all tool calls to complete 177 | const results = await Promise.all(toolPromises); 178 | 179 | // Organize results by caller 180 | const resultsByTool: Record = {}; 181 | results.forEach(({ caller, result }) => { 182 | resultsByTool[caller] = result; 183 | }); 184 | 185 | return resultsByTool; 186 | } catch (error) { 187 | console.error(`Error starting parallel notification tools:`, error); 188 | throw error; 189 | } 190 | } 191 | 192 | // Start the client 193 | main().catch((error: unknown) => { 194 | console.error('Error running MCP client:', error); 195 | process.exit(1); 196 | }); 197 | -------------------------------------------------------------------------------- /src/server/auth/handlers/authorize.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | import { z } from 'zod'; 3 | import express from 'express'; 4 | import { OAuthServerProvider } from '../provider.js'; 5 | import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; 6 | import { allowedMethods } from '../middleware/allowedMethods.js'; 7 | import { InvalidRequestError, InvalidClientError, InvalidScopeError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js'; 8 | 9 | export type AuthorizationHandlerOptions = { 10 | provider: OAuthServerProvider; 11 | /** 12 | * Rate limiting configuration for the authorization endpoint. 13 | * Set to false to disable rate limiting for this endpoint. 14 | */ 15 | rateLimit?: Partial | false; 16 | }; 17 | 18 | // Parameters that must be validated in order to issue redirects. 19 | const ClientAuthorizationParamsSchema = z.object({ 20 | client_id: z.string(), 21 | redirect_uri: z 22 | .string() 23 | .optional() 24 | .refine(value => value === undefined || URL.canParse(value), { message: 'redirect_uri must be a valid URL' }) 25 | }); 26 | 27 | // Parameters that must be validated for a successful authorization request. Failure can be reported to the redirect URI. 28 | const RequestAuthorizationParamsSchema = z.object({ 29 | response_type: z.literal('code'), 30 | code_challenge: z.string(), 31 | code_challenge_method: z.literal('S256'), 32 | scope: z.string().optional(), 33 | state: z.string().optional(), 34 | resource: z.string().url().optional() 35 | }); 36 | 37 | export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler { 38 | // Create a router to apply middleware 39 | const router = express.Router(); 40 | router.use(allowedMethods(['GET', 'POST'])); 41 | router.use(express.urlencoded({ extended: false })); 42 | 43 | // Apply rate limiting unless explicitly disabled 44 | if (rateLimitConfig !== false) { 45 | router.use( 46 | rateLimit({ 47 | windowMs: 15 * 60 * 1000, // 15 minutes 48 | max: 100, // 100 requests per windowMs 49 | standardHeaders: true, 50 | legacyHeaders: false, 51 | message: new TooManyRequestsError('You have exceeded the rate limit for authorization requests').toResponseObject(), 52 | ...rateLimitConfig 53 | }) 54 | ); 55 | } 56 | 57 | router.all('/', async (req, res) => { 58 | res.setHeader('Cache-Control', 'no-store'); 59 | 60 | // In the authorization flow, errors are split into two categories: 61 | // 1. Pre-redirect errors (direct response with 400) 62 | // 2. Post-redirect errors (redirect with error parameters) 63 | 64 | // Phase 1: Validate client_id and redirect_uri. Any errors here must be direct responses. 65 | let client_id, redirect_uri, client; 66 | try { 67 | const result = ClientAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query); 68 | if (!result.success) { 69 | throw new InvalidRequestError(result.error.message); 70 | } 71 | 72 | client_id = result.data.client_id; 73 | redirect_uri = result.data.redirect_uri; 74 | 75 | client = await provider.clientsStore.getClient(client_id); 76 | if (!client) { 77 | throw new InvalidClientError('Invalid client_id'); 78 | } 79 | 80 | if (redirect_uri !== undefined) { 81 | if (!client.redirect_uris.includes(redirect_uri)) { 82 | throw new InvalidRequestError('Unregistered redirect_uri'); 83 | } 84 | } else if (client.redirect_uris.length === 1) { 85 | redirect_uri = client.redirect_uris[0]; 86 | } else { 87 | throw new InvalidRequestError('redirect_uri must be specified when client has multiple registered URIs'); 88 | } 89 | } catch (error) { 90 | // Pre-redirect errors - return direct response 91 | // 92 | // These don't need to be JSON encoded, as they'll be displayed in a user 93 | // agent, but OTOH they all represent exceptional situations (arguably, 94 | // "programmer error"), so presenting a nice HTML page doesn't help the 95 | // user anyway. 96 | if (error instanceof OAuthError) { 97 | const status = error instanceof ServerError ? 500 : 400; 98 | res.status(status).json(error.toResponseObject()); 99 | } else { 100 | const serverError = new ServerError('Internal Server Error'); 101 | res.status(500).json(serverError.toResponseObject()); 102 | } 103 | 104 | return; 105 | } 106 | 107 | // Phase 2: Validate other parameters. Any errors here should go into redirect responses. 108 | let state; 109 | try { 110 | // Parse and validate authorization parameters 111 | const parseResult = RequestAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query); 112 | if (!parseResult.success) { 113 | throw new InvalidRequestError(parseResult.error.message); 114 | } 115 | 116 | const { scope, code_challenge, resource } = parseResult.data; 117 | state = parseResult.data.state; 118 | 119 | // Validate scopes 120 | let requestedScopes: string[] = []; 121 | if (scope !== undefined) { 122 | requestedScopes = scope.split(' '); 123 | const allowedScopes = new Set(client.scope?.split(' ')); 124 | 125 | // Check each requested scope against allowed scopes 126 | for (const scope of requestedScopes) { 127 | if (!allowedScopes.has(scope)) { 128 | throw new InvalidScopeError(`Client was not registered with scope ${scope}`); 129 | } 130 | } 131 | } 132 | 133 | // All validation passed, proceed with authorization 134 | await provider.authorize( 135 | client, 136 | { 137 | state, 138 | scopes: requestedScopes, 139 | redirectUri: redirect_uri, 140 | codeChallenge: code_challenge, 141 | resource: resource ? new URL(resource) : undefined 142 | }, 143 | res 144 | ); 145 | } catch (error) { 146 | // Post-redirect errors - redirect with error parameters 147 | if (error instanceof OAuthError) { 148 | res.redirect(302, createErrorRedirect(redirect_uri, error, state)); 149 | } else { 150 | const serverError = new ServerError('Internal Server Error'); 151 | res.redirect(302, createErrorRedirect(redirect_uri, serverError, state)); 152 | } 153 | } 154 | }); 155 | 156 | return router; 157 | } 158 | 159 | /** 160 | * Helper function to create redirect URL with error parameters 161 | */ 162 | function createErrorRedirect(redirectUri: string, error: OAuthError, state?: string): string { 163 | const errorUrl = new URL(redirectUri); 164 | errorUrl.searchParams.set('error', error.errorCode); 165 | errorUrl.searchParams.set('error_description', error.message); 166 | if (error.errorUri) { 167 | errorUrl.searchParams.set('error_uri', error.errorUri); 168 | } 169 | if (state) { 170 | errorUrl.searchParams.set('state', state); 171 | } 172 | return errorUrl.href; 173 | } 174 | -------------------------------------------------------------------------------- /src/client/stdio.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess, IOType } from 'node:child_process'; 2 | import spawn from 'cross-spawn'; 3 | import process from 'node:process'; 4 | import { Stream, PassThrough } from 'node:stream'; 5 | import { ReadBuffer, serializeMessage } from '../shared/stdio.js'; 6 | import { Transport } from '../shared/transport.js'; 7 | import { JSONRPCMessage } from '../types.js'; 8 | 9 | export type StdioServerParameters = { 10 | /** 11 | * The executable to run to start the server. 12 | */ 13 | command: string; 14 | 15 | /** 16 | * Command line arguments to pass to the executable. 17 | */ 18 | args?: string[]; 19 | 20 | /** 21 | * The environment to use when spawning the process. 22 | * 23 | * If not specified, the result of getDefaultEnvironment() will be used. 24 | */ 25 | env?: Record; 26 | 27 | /** 28 | * How to handle stderr of the child process. This matches the semantics of Node's `child_process.spawn`. 29 | * 30 | * The default is "inherit", meaning messages to stderr will be printed to the parent process's stderr. 31 | */ 32 | stderr?: IOType | Stream | number; 33 | 34 | /** 35 | * The working directory to use when spawning the process. 36 | * 37 | * If not specified, the current working directory will be inherited. 38 | */ 39 | cwd?: string; 40 | }; 41 | 42 | /** 43 | * Environment variables to inherit by default, if an environment is not explicitly given. 44 | */ 45 | export const DEFAULT_INHERITED_ENV_VARS = 46 | process.platform === 'win32' 47 | ? [ 48 | 'APPDATA', 49 | 'HOMEDRIVE', 50 | 'HOMEPATH', 51 | 'LOCALAPPDATA', 52 | 'PATH', 53 | 'PROCESSOR_ARCHITECTURE', 54 | 'SYSTEMDRIVE', 55 | 'SYSTEMROOT', 56 | 'TEMP', 57 | 'USERNAME', 58 | 'USERPROFILE', 59 | 'PROGRAMFILES' 60 | ] 61 | : /* list inspired by the default env inheritance of sudo */ 62 | ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER']; 63 | 64 | /** 65 | * Returns a default environment object including only environment variables deemed safe to inherit. 66 | */ 67 | export function getDefaultEnvironment(): Record { 68 | const env: Record = {}; 69 | 70 | for (const key of DEFAULT_INHERITED_ENV_VARS) { 71 | const value = process.env[key]; 72 | if (value === undefined) { 73 | continue; 74 | } 75 | 76 | if (value.startsWith('()')) { 77 | // Skip functions, which are a security risk. 78 | continue; 79 | } 80 | 81 | env[key] = value; 82 | } 83 | 84 | return env; 85 | } 86 | 87 | /** 88 | * Client transport for stdio: this will connect to a server by spawning a process and communicating with it over stdin/stdout. 89 | * 90 | * This transport is only available in Node.js environments. 91 | */ 92 | export class StdioClientTransport implements Transport { 93 | private _process?: ChildProcess; 94 | private _abortController: AbortController = new AbortController(); 95 | private _readBuffer: ReadBuffer = new ReadBuffer(); 96 | private _serverParams: StdioServerParameters; 97 | private _stderrStream: PassThrough | null = null; 98 | 99 | onclose?: () => void; 100 | onerror?: (error: Error) => void; 101 | onmessage?: (message: JSONRPCMessage) => void; 102 | 103 | constructor(server: StdioServerParameters) { 104 | this._serverParams = server; 105 | if (server.stderr === 'pipe' || server.stderr === 'overlapped') { 106 | this._stderrStream = new PassThrough(); 107 | } 108 | } 109 | 110 | /** 111 | * Starts the server process and prepares to communicate with it. 112 | */ 113 | async start(): Promise { 114 | if (this._process) { 115 | throw new Error( 116 | 'StdioClientTransport already started! If using Client class, note that connect() calls start() automatically.' 117 | ); 118 | } 119 | 120 | return new Promise((resolve, reject) => { 121 | this._process = spawn(this._serverParams.command, this._serverParams.args ?? [], { 122 | // merge default env with server env because mcp server needs some env vars 123 | env: { 124 | ...getDefaultEnvironment(), 125 | ...this._serverParams.env 126 | }, 127 | stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'], 128 | shell: false, 129 | signal: this._abortController.signal, 130 | windowsHide: process.platform === 'win32' && isElectron(), 131 | cwd: this._serverParams.cwd 132 | }); 133 | 134 | this._process.on('error', error => { 135 | if (error.name === 'AbortError') { 136 | // Expected when close() is called. 137 | this.onclose?.(); 138 | return; 139 | } 140 | 141 | reject(error); 142 | this.onerror?.(error); 143 | }); 144 | 145 | this._process.on('spawn', () => { 146 | resolve(); 147 | }); 148 | 149 | this._process.on('close', _code => { 150 | this._process = undefined; 151 | this.onclose?.(); 152 | }); 153 | 154 | this._process.stdin?.on('error', error => { 155 | this.onerror?.(error); 156 | }); 157 | 158 | this._process.stdout?.on('data', chunk => { 159 | this._readBuffer.append(chunk); 160 | this.processReadBuffer(); 161 | }); 162 | 163 | this._process.stdout?.on('error', error => { 164 | this.onerror?.(error); 165 | }); 166 | 167 | if (this._stderrStream && this._process.stderr) { 168 | this._process.stderr.pipe(this._stderrStream); 169 | } 170 | }); 171 | } 172 | 173 | /** 174 | * The stderr stream of the child process, if `StdioServerParameters.stderr` was set to "pipe" or "overlapped". 175 | * 176 | * If stderr piping was requested, a PassThrough stream is returned _immediately_, allowing callers to 177 | * attach listeners before the start method is invoked. This prevents loss of any early 178 | * error output emitted by the child process. 179 | */ 180 | get stderr(): Stream | null { 181 | if (this._stderrStream) { 182 | return this._stderrStream; 183 | } 184 | 185 | return this._process?.stderr ?? null; 186 | } 187 | 188 | /** 189 | * The child process pid spawned by this transport. 190 | * 191 | * This is only available after the transport has been started. 192 | */ 193 | get pid(): number | null { 194 | return this._process?.pid ?? null; 195 | } 196 | 197 | private processReadBuffer() { 198 | while (true) { 199 | try { 200 | const message = this._readBuffer.readMessage(); 201 | if (message === null) { 202 | break; 203 | } 204 | 205 | this.onmessage?.(message); 206 | } catch (error) { 207 | this.onerror?.(error as Error); 208 | } 209 | } 210 | } 211 | 212 | async close(): Promise { 213 | this._abortController.abort(); 214 | this._process = undefined; 215 | this._readBuffer.clear(); 216 | } 217 | 218 | send(message: JSONRPCMessage): Promise { 219 | return new Promise(resolve => { 220 | if (!this._process?.stdin) { 221 | throw new Error('Not connected'); 222 | } 223 | 224 | const json = serializeMessage(message); 225 | if (this._process.stdin.write(json)) { 226 | resolve(); 227 | } else { 228 | this._process.stdin.once('drain', resolve); 229 | } 230 | }); 231 | } 232 | } 233 | 234 | function isElectron() { 235 | return 'type' in process; 236 | } 237 | -------------------------------------------------------------------------------- /src/server/sse.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto'; 2 | import { IncomingMessage, ServerResponse } from 'node:http'; 3 | import { Transport } from '../shared/transport.js'; 4 | import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo, RequestInfo } from '../types.js'; 5 | import getRawBody from 'raw-body'; 6 | import contentType from 'content-type'; 7 | import { AuthInfo } from './auth/types.js'; 8 | import { URL } from 'url'; 9 | 10 | const MAXIMUM_MESSAGE_SIZE = '4mb'; 11 | 12 | /** 13 | * Configuration options for SSEServerTransport. 14 | */ 15 | export interface SSEServerTransportOptions { 16 | /** 17 | * List of allowed host header values for DNS rebinding protection. 18 | * If not specified, host validation is disabled. 19 | */ 20 | allowedHosts?: string[]; 21 | 22 | /** 23 | * List of allowed origin header values for DNS rebinding protection. 24 | * If not specified, origin validation is disabled. 25 | */ 26 | allowedOrigins?: string[]; 27 | 28 | /** 29 | * Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). 30 | * Default is false for backwards compatibility. 31 | */ 32 | enableDnsRebindingProtection?: boolean; 33 | } 34 | 35 | /** 36 | * Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests. 37 | * 38 | * This transport is only available in Node.js environments. 39 | */ 40 | export class SSEServerTransport implements Transport { 41 | private _sseResponse?: ServerResponse; 42 | private _sessionId: string; 43 | private _options: SSEServerTransportOptions; 44 | onclose?: () => void; 45 | onerror?: (error: Error) => void; 46 | onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; 47 | 48 | /** 49 | * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. 50 | */ 51 | constructor( 52 | private _endpoint: string, 53 | private res: ServerResponse, 54 | options?: SSEServerTransportOptions 55 | ) { 56 | this._sessionId = randomUUID(); 57 | this._options = options || { enableDnsRebindingProtection: false }; 58 | } 59 | 60 | /** 61 | * Validates request headers for DNS rebinding protection. 62 | * @returns Error message if validation fails, undefined if validation passes. 63 | */ 64 | private validateRequestHeaders(req: IncomingMessage): string | undefined { 65 | // Skip validation if protection is not enabled 66 | if (!this._options.enableDnsRebindingProtection) { 67 | return undefined; 68 | } 69 | 70 | // Validate Host header if allowedHosts is configured 71 | if (this._options.allowedHosts && this._options.allowedHosts.length > 0) { 72 | const hostHeader = req.headers.host; 73 | if (!hostHeader || !this._options.allowedHosts.includes(hostHeader)) { 74 | return `Invalid Host header: ${hostHeader}`; 75 | } 76 | } 77 | 78 | // Validate Origin header if allowedOrigins is configured 79 | if (this._options.allowedOrigins && this._options.allowedOrigins.length > 0) { 80 | const originHeader = req.headers.origin; 81 | if (!originHeader || !this._options.allowedOrigins.includes(originHeader)) { 82 | return `Invalid Origin header: ${originHeader}`; 83 | } 84 | } 85 | 86 | return undefined; 87 | } 88 | 89 | /** 90 | * Handles the initial SSE connection request. 91 | * 92 | * This should be called when a GET request is made to establish the SSE stream. 93 | */ 94 | async start(): Promise { 95 | if (this._sseResponse) { 96 | throw new Error('SSEServerTransport already started! If using Server class, note that connect() calls start() automatically.'); 97 | } 98 | 99 | this.res.writeHead(200, { 100 | 'Content-Type': 'text/event-stream', 101 | 'Cache-Control': 'no-cache, no-transform', 102 | Connection: 'keep-alive' 103 | }); 104 | 105 | // Send the endpoint event 106 | // Use a dummy base URL because this._endpoint is relative. 107 | // This allows using URL/URLSearchParams for robust parameter handling. 108 | const dummyBase = 'http://localhost'; // Any valid base works 109 | const endpointUrl = new URL(this._endpoint, dummyBase); 110 | endpointUrl.searchParams.set('sessionId', this._sessionId); 111 | 112 | // Reconstruct the relative URL string (pathname + search + hash) 113 | const relativeUrlWithSession = endpointUrl.pathname + endpointUrl.search + endpointUrl.hash; 114 | 115 | this.res.write(`event: endpoint\ndata: ${relativeUrlWithSession}\n\n`); 116 | 117 | this._sseResponse = this.res; 118 | this.res.on('close', () => { 119 | this._sseResponse = undefined; 120 | this.onclose?.(); 121 | }); 122 | } 123 | 124 | /** 125 | * Handles incoming POST messages. 126 | * 127 | * This should be called when a POST request is made to send a message to the server. 128 | */ 129 | async handlePostMessage(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise { 130 | if (!this._sseResponse) { 131 | const message = 'SSE connection not established'; 132 | res.writeHead(500).end(message); 133 | throw new Error(message); 134 | } 135 | 136 | // Validate request headers for DNS rebinding protection 137 | const validationError = this.validateRequestHeaders(req); 138 | if (validationError) { 139 | res.writeHead(403).end(validationError); 140 | this.onerror?.(new Error(validationError)); 141 | return; 142 | } 143 | 144 | const authInfo: AuthInfo | undefined = req.auth; 145 | const requestInfo: RequestInfo = { headers: req.headers }; 146 | 147 | let body: string | unknown; 148 | try { 149 | const ct = contentType.parse(req.headers['content-type'] ?? ''); 150 | if (ct.type !== 'application/json') { 151 | throw new Error(`Unsupported content-type: ${ct.type}`); 152 | } 153 | 154 | body = 155 | parsedBody ?? 156 | (await getRawBody(req, { 157 | limit: MAXIMUM_MESSAGE_SIZE, 158 | encoding: ct.parameters.charset ?? 'utf-8' 159 | })); 160 | } catch (error) { 161 | res.writeHead(400).end(String(error)); 162 | this.onerror?.(error as Error); 163 | return; 164 | } 165 | 166 | try { 167 | await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { requestInfo, authInfo }); 168 | } catch { 169 | res.writeHead(400).end(`Invalid message: ${body}`); 170 | return; 171 | } 172 | 173 | res.writeHead(202).end('Accepted'); 174 | } 175 | 176 | /** 177 | * 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. 178 | */ 179 | async handleMessage(message: unknown, extra?: MessageExtraInfo): Promise { 180 | let parsedMessage: JSONRPCMessage; 181 | try { 182 | parsedMessage = JSONRPCMessageSchema.parse(message); 183 | } catch (error) { 184 | this.onerror?.(error as Error); 185 | throw error; 186 | } 187 | 188 | this.onmessage?.(parsedMessage, extra); 189 | } 190 | 191 | async close(): Promise { 192 | this._sseResponse?.end(); 193 | this._sseResponse = undefined; 194 | this.onclose?.(); 195 | } 196 | 197 | async send(message: JSONRPCMessage): Promise { 198 | if (!this._sseResponse) { 199 | throw new Error('Not connected'); 200 | } 201 | 202 | this._sseResponse.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); 203 | } 204 | 205 | /** 206 | * Returns the session ID for this transport. 207 | * 208 | * This can be used to route incoming POST requests. 209 | */ 210 | get sessionId(): string { 211 | return this._sessionId; 212 | } 213 | } 214 | --------------------------------------------------------------------------------