├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── eslint.config.mjs ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── __mocks__ │ └── pkce-challenge.ts ├── cli.ts ├── client │ ├── auth.test.ts │ ├── auth.ts │ ├── cross-spawn.test.ts │ ├── index.test.ts │ ├── index.ts │ ├── sse.test.ts │ ├── sse.ts │ ├── stdio.test.ts │ ├── stdio.ts │ ├── streamableHttp.test.ts │ ├── streamableHttp.ts │ └── websocket.ts ├── examples │ ├── README.md │ ├── client │ │ ├── multipleClientsParallel.ts │ │ ├── parallelToolCallsClient.ts │ │ ├── simpleStreamableHttp.ts │ │ └── streamableHttpWithSseFallbackClient.ts │ ├── server │ │ ├── jsonResponseStreamableHttp.ts │ │ ├── simpleSseServer.ts │ │ ├── simpleStatelessStreamableHttp.ts │ │ ├── simpleStreamableHttp.ts │ │ ├── sseAndStreamableHttpCompatibleServer.ts │ │ └── standaloneSseWithGetStreamableHttp.ts │ └── shared │ │ └── inMemoryEventStore.ts ├── inMemory.test.ts ├── inMemory.ts ├── integration-tests │ ├── process-cleanup.test.ts │ ├── stateManagementStreamableHttp.test.ts │ └── taskResumability.test.ts ├── server │ ├── auth │ │ ├── clients.ts │ │ ├── errors.ts │ │ ├── handlers │ │ │ ├── authorize.test.ts │ │ │ ├── authorize.ts │ │ │ ├── metadata.test.ts │ │ │ ├── metadata.ts │ │ │ ├── register.test.ts │ │ │ ├── register.ts │ │ │ ├── revoke.test.ts │ │ │ ├── revoke.ts │ │ │ ├── token.test.ts │ │ │ └── token.ts │ │ ├── middleware │ │ │ ├── allowedMethods.test.ts │ │ │ ├── allowedMethods.ts │ │ │ ├── bearerAuth.test.ts │ │ │ ├── bearerAuth.ts │ │ │ ├── clientAuth.test.ts │ │ │ └── clientAuth.ts │ │ ├── provider.ts │ │ ├── providers │ │ │ ├── proxyProvider.test.ts │ │ │ └── proxyProvider.ts │ │ ├── router.test.ts │ │ ├── router.ts │ │ └── types.ts │ ├── completable.test.ts │ ├── completable.ts │ ├── index.test.ts │ ├── index.ts │ ├── mcp.test.ts │ ├── mcp.ts │ ├── sse.test.ts │ ├── sse.ts │ ├── stdio.test.ts │ ├── stdio.ts │ ├── streamableHttp.test.ts │ └── streamableHttp.ts ├── shared │ ├── auth.ts │ ├── protocol.test.ts │ ├── protocol.ts │ ├── stdio.test.ts │ ├── stdio.ts │ ├── transport.ts │ ├── uriTemplate.test.ts │ └── uriTemplate.ts └── types.ts ├── tsconfig.cjs.json ├── tsconfig.json └── tsconfig.prod.json /.gitattributes: -------------------------------------------------------------------------------- 1 | package-lock.json linguist-generated=true 2 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | 93 | # Gatsby files 94 | .cache/ 95 | # Comment in the public line in if your project uses Gatsby and not Next.js 96 | # https://nextjs.org/blog/next-9-1#public-directory-support 97 | # public 98 | 99 | # vuepress build output 100 | .vuepress/dist 101 | 102 | # vuepress v2.x temp and cache directory 103 | .temp 104 | .cache 105 | 106 | # Docusaurus cache and generated files 107 | .docusaurus 108 | 109 | # Serverless directories 110 | .serverless/ 111 | 112 | # FuseBox cache 113 | .fusebox/ 114 | 115 | # DynamoDB Local files 116 | .dynamodb/ 117 | 118 | # TernJS port file 119 | .tern-port 120 | 121 | # Stores VSCode versions used for testing VSCode extensions 122 | .vscode-test 123 | 124 | # yarn v2 125 | .yarn/cache 126 | .yarn/unplugged 127 | .yarn/build-state.yml 128 | .yarn/install-state.gz 129 | .pnp.* 130 | 131 | .DS_Store 132 | dist/ 133 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry = "https://registry.npmjs.org/" 2 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # MCP TypeScript SDK Guide 2 | 3 | ## Build & Test Commands 4 | ``` 5 | npm run build # Build ESM and CJS versions 6 | npm run lint # Run ESLint 7 | npm test # Run all tests 8 | npx jest path/to/file.test.ts # Run specific test file 9 | npx jest -t "test name" # Run tests matching pattern 10 | ``` 11 | 12 | ## Code Style Guidelines 13 | - **TypeScript**: Strict type checking, ES modules, explicit return types 14 | - **Naming**: PascalCase for classes/types, camelCase for functions/variables 15 | - **Files**: Lowercase with hyphens, test files with `.test.ts` suffix 16 | - **Imports**: ES module style, include `.js` extension, group imports logically 17 | - **Error Handling**: Use TypeScript's strict mode, explicit error checking in tests 18 | - **Formatting**: 2-space indentation, semicolons required, single quotes preferred 19 | - **Testing**: Co-locate tests with source files, use descriptive test names 20 | - **Comments**: JSDoc for public APIs, inline comments for complex logic 21 | 22 | ## Project Structure 23 | - `/src`: Source code with client, server, and shared modules 24 | - Tests alongside source files with `.test.ts` suffix 25 | - Node.js >= 18 required -------------------------------------------------------------------------------- /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 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | mcp-coc@anthropic.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | Thank you for helping us keep the SDKs and systems they interact with secure. 3 | 4 | ## Reporting Security Issues 5 | 6 | This SDK is maintained by [Anthropic](https://www.anthropic.com/) as part of the Model Context Protocol project. 7 | 8 | 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. 9 | 10 | 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). 11 | 12 | ## Vulnerability Disclosure Program 13 | 14 | Our Vulnerability Program Guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic-vdp). 15 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | ...tseslint.configs.recommended, 9 | { 10 | linterOptions: { 11 | reportUnusedDisableDirectives: false, 12 | }, 13 | rules: { 14 | "@typescript-eslint/no-unused-vars": ["error", 15 | { "argsIgnorePattern": "^_" } 16 | ] 17 | } 18 | } 19 | ); 20 | -------------------------------------------------------------------------------- /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: [ 13 | "/node_modules/(?!eventsource)/" 14 | ], 15 | testPathIgnorePatterns: ["/node_modules/", "/dist/"], 16 | }; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@modelcontextprotocol/sdk", 3 | "version": "1.10.2", 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/*", 24 | "require": "./dist/cjs/*" 25 | } 26 | }, 27 | "typesVersions": { 28 | "*": { 29 | "*": [ 30 | "./dist/esm/*" 31 | ] 32 | } 33 | }, 34 | "files": [ 35 | "dist" 36 | ], 37 | "scripts": { 38 | "build": "npm run build:esm && npm run build:cjs", 39 | "build:esm": "tsc -p tsconfig.prod.json && echo '{\"type\": \"module\"}' > dist/esm/package.json", 40 | "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json", 41 | "prepack": "npm run build:esm && npm run build:cjs", 42 | "lint": "eslint src/", 43 | "test": "jest", 44 | "start": "npm run server", 45 | "server": "tsx watch --clear-screen=false src/cli.ts server", 46 | "client": "tsx src/cli.ts client" 47 | }, 48 | "dependencies": { 49 | "content-type": "^1.0.5", 50 | "cors": "^2.8.5", 51 | "cross-spawn": "^7.0.3", 52 | "eventsource": "^3.0.2", 53 | "express": "^5.0.1", 54 | "express-rate-limit": "^7.5.0", 55 | "pkce-challenge": "^5.0.0", 56 | "raw-body": "^3.0.0", 57 | "zod": "^3.23.8", 58 | "zod-to-json-schema": "^3.24.1" 59 | }, 60 | "devDependencies": { 61 | "@eslint/js": "^9.8.0", 62 | "@jest-mock/express": "^3.0.0", 63 | "@types/content-type": "^1.1.8", 64 | "@types/cors": "^2.8.17", 65 | "@types/cross-spawn": "^6.0.6", 66 | "@types/eslint__js": "^8.42.3", 67 | "@types/eventsource": "^1.1.15", 68 | "@types/express": "^5.0.0", 69 | "@types/jest": "^29.5.12", 70 | "@types/node": "^22.0.2", 71 | "@types/supertest": "^6.0.2", 72 | "@types/ws": "^8.5.12", 73 | "eslint": "^9.8.0", 74 | "jest": "^29.7.0", 75 | "supertest": "^7.0.0", 76 | "ts-jest": "^29.2.4", 77 | "tsx": "^4.16.5", 78 | "typescript": "^5.5.4", 79 | "typescript-eslint": "^8.0.0", 80 | "ws": "^8.18.0" 81 | }, 82 | "resolutions": { 83 | "strip-ansi": "6.0.1" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/__mocks__/pkce-challenge.ts: -------------------------------------------------------------------------------- 1 | export default function pkceChallenge() { 2 | return { 3 | code_verifier: "test_verifier", 4 | code_challenge: "test_challenge", 5 | }; 6 | } -------------------------------------------------------------------------------- /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 95 | .map((s) => s.transport as SSEServerTransport) 96 | .find((t) => t.sessionId === sessionId); 97 | if (!transport) { 98 | res.status(404).send("Session not found"); 99 | return; 100 | } 101 | 102 | await transport.handlePostMessage(req, res); 103 | }); 104 | 105 | app.listen(port, () => { 106 | console.log(`Server running on http://localhost:${port}/sse`); 107 | }); 108 | } else { 109 | const server = new Server( 110 | { 111 | name: "mcp-typescript test server", 112 | version: "0.1.0", 113 | }, 114 | { 115 | capabilities: { 116 | prompts: {}, 117 | resources: {}, 118 | tools: {}, 119 | logging: {}, 120 | }, 121 | }, 122 | ); 123 | 124 | const transport = new StdioServerTransport(); 125 | await server.connect(transport); 126 | 127 | console.log("Server running on stdio"); 128 | } 129 | } 130 | 131 | const args = process.argv.slice(2); 132 | const command = args[0]; 133 | switch (command) { 134 | case "client": 135 | if (args.length < 2) { 136 | console.error("Usage: client [args...]"); 137 | process.exit(1); 138 | } 139 | 140 | runClient(args[1], args.slice(2)).catch((error) => { 141 | console.error(error); 142 | process.exit(1); 143 | }); 144 | 145 | break; 146 | 147 | case "server": { 148 | const port = args[1] ? parseInt(args[1]) : null; 149 | runServer(port).catch((error) => { 150 | console.error(error); 151 | process.exit(1); 152 | }); 153 | 154 | break; 155 | } 156 | 157 | default: 158 | console.error("Unrecognized command:", command); 159 | } 160 | -------------------------------------------------------------------------------- /src/client/cross-spawn.test.ts: -------------------------------------------------------------------------------- 1 | import { StdioClientTransport } 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 passed correctly 71 | expect(mockSpawn).toHaveBeenCalledWith( 72 | "test-command", 73 | [], 74 | expect.objectContaining({ 75 | env: customEnv 76 | }) 77 | ); 78 | }); 79 | 80 | test("should send messages correctly", async () => { 81 | const transport = new StdioClientTransport({ 82 | command: "test-command" 83 | }); 84 | 85 | // get the mock process object 86 | const mockProcess: { 87 | on: jest.Mock; 88 | stdin: { 89 | on: jest.Mock; 90 | write: jest.Mock; 91 | once: jest.Mock; 92 | }; 93 | stdout: { 94 | on: jest.Mock; 95 | }; 96 | stderr: null; 97 | } = { 98 | on: jest.fn((event: string, callback: () => void) => { 99 | if (event === "spawn") { 100 | callback(); 101 | } 102 | return mockProcess; 103 | }), 104 | stdin: { 105 | on: jest.fn(), 106 | write: jest.fn().mockReturnValue(true), 107 | once: jest.fn() 108 | }, 109 | stdout: { 110 | on: jest.fn() 111 | }, 112 | stderr: null 113 | }; 114 | 115 | mockSpawn.mockReturnValue(mockProcess as unknown as ChildProcess); 116 | 117 | await transport.start(); 118 | 119 | // 关键修复:确保 jsonrpc 是字面量 "2.0" 120 | const message: JSONRPCMessage = { 121 | jsonrpc: "2.0", 122 | id: "test-id", 123 | method: "test-method" 124 | }; 125 | 126 | await transport.send(message); 127 | 128 | // verify message is sent correctly 129 | expect(mockProcess.stdin.write).toHaveBeenCalled(); 130 | }); 131 | }); -------------------------------------------------------------------------------- /src/client/sse.ts: -------------------------------------------------------------------------------- 1 | import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource"; 2 | import { Transport } from "../shared/transport.js"; 3 | import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; 4 | import { auth, AuthResult, OAuthClientProvider, UnauthorizedError } from "./auth.js"; 5 | 6 | export class SseError extends Error { 7 | constructor( 8 | public readonly code: number | undefined, 9 | message: string | undefined, 10 | public readonly event: ErrorEvent, 11 | ) { 12 | super(`SSE error: ${message}`); 13 | } 14 | } 15 | 16 | /** 17 | * Configuration options for the `SSEClientTransport`. 18 | */ 19 | export type SSEClientTransportOptions = { 20 | /** 21 | * An OAuth client provider to use for authentication. 22 | * 23 | * When an `authProvider` is specified and the SSE connection is started: 24 | * 1. The connection is attempted with any existing access token from the `authProvider`. 25 | * 2. If the access token has expired, the `authProvider` is used to refresh the token. 26 | * 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`. 27 | * 28 | * After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `SSEClientTransport.finishAuth` with the authorization code before retrying the connection. 29 | * 30 | * If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown. 31 | * 32 | * `UnauthorizedError` might also be thrown when sending any message over the SSE transport, indicating that the session has expired, and needs to be re-authed and reconnected. 33 | */ 34 | authProvider?: OAuthClientProvider; 35 | 36 | /** 37 | * Customizes the initial SSE request to the server (the request that begins the stream). 38 | * 39 | * NOTE: Setting this property will prevent an `Authorization` header from 40 | * being automatically attached to the SSE request, if an `authProvider` is 41 | * also given. This can be worked around by setting the `Authorization` header 42 | * manually. 43 | */ 44 | eventSourceInit?: EventSourceInit; 45 | 46 | /** 47 | * Customizes recurring POST requests to the server. 48 | */ 49 | requestInit?: RequestInit; 50 | }; 51 | 52 | /** 53 | * Client transport for SSE: this will connect to a server using Server-Sent Events for receiving 54 | * messages and make separate POST requests for sending messages. 55 | */ 56 | export class SSEClientTransport implements Transport { 57 | private _eventSource?: EventSource; 58 | private _endpoint?: URL; 59 | private _abortController?: AbortController; 60 | private _url: URL; 61 | private _eventSourceInit?: EventSourceInit; 62 | private _requestInit?: RequestInit; 63 | private _authProvider?: OAuthClientProvider; 64 | 65 | onclose?: () => void; 66 | onerror?: (error: Error) => void; 67 | onmessage?: (message: JSONRPCMessage) => void; 68 | 69 | constructor( 70 | url: URL, 71 | opts?: SSEClientTransportOptions, 72 | ) { 73 | this._url = url; 74 | this._eventSourceInit = opts?.eventSourceInit; 75 | this._requestInit = opts?.requestInit; 76 | this._authProvider = opts?.authProvider; 77 | } 78 | 79 | private async _authThenStart(): Promise { 80 | if (!this._authProvider) { 81 | throw new UnauthorizedError("No auth provider"); 82 | } 83 | 84 | let result: AuthResult; 85 | try { 86 | result = await auth(this._authProvider, { serverUrl: this._url }); 87 | } catch (error) { 88 | this.onerror?.(error as Error); 89 | throw error; 90 | } 91 | 92 | if (result !== "AUTHORIZED") { 93 | throw new UnauthorizedError(); 94 | } 95 | 96 | return await this._startOrAuth(); 97 | } 98 | 99 | private async _commonHeaders(): Promise { 100 | const headers: HeadersInit = {}; 101 | if (this._authProvider) { 102 | const tokens = await this._authProvider.tokens(); 103 | if (tokens) { 104 | headers["Authorization"] = `Bearer ${tokens.access_token}`; 105 | } 106 | } 107 | 108 | return headers; 109 | } 110 | 111 | private _startOrAuth(): Promise { 112 | return new Promise((resolve, reject) => { 113 | this._eventSource = new EventSource( 114 | this._url.href, 115 | this._eventSourceInit ?? { 116 | fetch: (url, init) => this._commonHeaders().then((headers) => fetch(url, { 117 | ...init, 118 | headers: { 119 | ...headers, 120 | Accept: "text/event-stream" 121 | } 122 | })), 123 | }, 124 | ); 125 | this._abortController = new AbortController(); 126 | 127 | this._eventSource.onerror = (event) => { 128 | if (event.code === 401 && this._authProvider) { 129 | this._authThenStart().then(resolve, reject); 130 | return; 131 | } 132 | 133 | const error = new SseError(event.code, event.message, event); 134 | reject(error); 135 | this.onerror?.(error); 136 | }; 137 | 138 | this._eventSource.onopen = () => { 139 | // The connection is open, but we need to wait for the endpoint to be received. 140 | }; 141 | 142 | this._eventSource.addEventListener("endpoint", (event: Event) => { 143 | const messageEvent = event as MessageEvent; 144 | 145 | try { 146 | this._endpoint = new URL(messageEvent.data, this._url); 147 | if (this._endpoint.origin !== this._url.origin) { 148 | throw new Error( 149 | `Endpoint origin does not match connection origin: ${this._endpoint.origin}`, 150 | ); 151 | } 152 | } catch (error) { 153 | reject(error); 154 | this.onerror?.(error as Error); 155 | 156 | void this.close(); 157 | return; 158 | } 159 | 160 | resolve(); 161 | }); 162 | 163 | this._eventSource.onmessage = (event: Event) => { 164 | const messageEvent = event as MessageEvent; 165 | let message: JSONRPCMessage; 166 | try { 167 | message = JSONRPCMessageSchema.parse(JSON.parse(messageEvent.data)); 168 | } catch (error) { 169 | this.onerror?.(error as Error); 170 | return; 171 | } 172 | 173 | this.onmessage?.(message); 174 | }; 175 | }); 176 | } 177 | 178 | async start() { 179 | if (this._eventSource) { 180 | throw new Error( 181 | "SSEClientTransport already started! If using Client class, note that connect() calls start() automatically.", 182 | ); 183 | } 184 | 185 | return await this._startOrAuth(); 186 | } 187 | 188 | /** 189 | * Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth. 190 | */ 191 | async finishAuth(authorizationCode: string): Promise { 192 | if (!this._authProvider) { 193 | throw new UnauthorizedError("No auth provider"); 194 | } 195 | 196 | const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode }); 197 | if (result !== "AUTHORIZED") { 198 | throw new UnauthorizedError("Failed to authorize"); 199 | } 200 | } 201 | 202 | async close(): Promise { 203 | this._abortController?.abort(); 204 | this._eventSource?.close(); 205 | this.onclose?.(); 206 | } 207 | 208 | async send(message: JSONRPCMessage): Promise { 209 | if (!this._endpoint) { 210 | throw new Error("Not connected"); 211 | } 212 | 213 | try { 214 | const commonHeaders = await this._commonHeaders(); 215 | const headers = new Headers({ ...commonHeaders, ...this._requestInit?.headers }); 216 | headers.set("content-type", "application/json"); 217 | const init = { 218 | ...this._requestInit, 219 | method: "POST", 220 | headers, 221 | body: JSON.stringify(message), 222 | signal: this._abortController?.signal, 223 | }; 224 | 225 | const response = await fetch(this._endpoint, init); 226 | if (!response.ok) { 227 | if (response.status === 401 && this._authProvider) { 228 | const result = await auth(this._authProvider, { serverUrl: this._url }); 229 | if (result !== "AUTHORIZED") { 230 | throw new UnauthorizedError(); 231 | } 232 | 233 | // Purposely _not_ awaited, so we don't call onerror twice 234 | return this.send(message); 235 | } 236 | 237 | const text = await response.text().catch(() => null); 238 | throw new Error( 239 | `Error POSTing to endpoint (HTTP ${response.status}): ${text}`, 240 | ); 241 | } 242 | } catch (error) { 243 | this.onerror?.(error as Error); 244 | throw error; 245 | } 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/client/stdio.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONRPCMessage } from "../types.js"; 2 | import { StdioClientTransport, StdioServerParameters } from "./stdio.js"; 3 | 4 | const serverParameters: StdioServerParameters = { 5 | command: "/usr/bin/tee", 6 | }; 7 | 8 | test("should start then close cleanly", async () => { 9 | const client = new StdioClientTransport(serverParameters); 10 | client.onerror = (error) => { 11 | throw error; 12 | }; 13 | 14 | let didClose = false; 15 | client.onclose = () => { 16 | didClose = true; 17 | }; 18 | 19 | await client.start(); 20 | expect(didClose).toBeFalsy(); 21 | await client.close(); 22 | expect(didClose).toBeTruthy(); 23 | }); 24 | 25 | test("should read messages", async () => { 26 | const client = new StdioClientTransport(serverParameters); 27 | client.onerror = (error) => { 28 | throw error; 29 | }; 30 | 31 | const messages: JSONRPCMessage[] = [ 32 | { 33 | jsonrpc: "2.0", 34 | id: 1, 35 | method: "ping", 36 | }, 37 | { 38 | jsonrpc: "2.0", 39 | method: "notifications/initialized", 40 | }, 41 | ]; 42 | 43 | const readMessages: JSONRPCMessage[] = []; 44 | const finished = new Promise((resolve) => { 45 | client.onmessage = (message) => { 46 | readMessages.push(message); 47 | 48 | if (JSON.stringify(message) === JSON.stringify(messages[1])) { 49 | resolve(); 50 | } 51 | }; 52 | }); 53 | 54 | await client.start(); 55 | await client.send(messages[0]); 56 | await client.send(messages[1]); 57 | await finished; 58 | expect(readMessages).toEqual(messages); 59 | 60 | await client.close(); 61 | }); 62 | -------------------------------------------------------------------------------- /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 } 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 | ] 60 | : /* list inspired by the default env inheritance of sudo */ 61 | ["HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]; 62 | 63 | /** 64 | * Returns a default environment object including only environment variables deemed safe to inherit. 65 | */ 66 | export function getDefaultEnvironment(): Record { 67 | const env: Record = {}; 68 | 69 | for (const key of DEFAULT_INHERITED_ENV_VARS) { 70 | const value = process.env[key]; 71 | if (value === undefined) { 72 | continue; 73 | } 74 | 75 | if (value.startsWith("()")) { 76 | // Skip functions, which are a security risk. 77 | continue; 78 | } 79 | 80 | env[key] = value; 81 | } 82 | 83 | return env; 84 | } 85 | 86 | /** 87 | * Client transport for stdio: this will connect to a server by spawning a process and communicating with it over stdin/stdout. 88 | * 89 | * This transport is only available in Node.js environments. 90 | */ 91 | export class StdioClientTransport implements Transport { 92 | private _process?: ChildProcess; 93 | private _abortController: AbortController = new AbortController(); 94 | private _readBuffer: ReadBuffer = new ReadBuffer(); 95 | private _serverParams: StdioServerParameters; 96 | 97 | onclose?: () => void; 98 | onerror?: (error: Error) => void; 99 | onmessage?: (message: JSONRPCMessage) => void; 100 | 101 | constructor(server: StdioServerParameters) { 102 | this._serverParams = server; 103 | } 104 | 105 | /** 106 | * Starts the server process and prepares to communicate with it. 107 | */ 108 | async start(): Promise { 109 | if (this._process) { 110 | throw new Error( 111 | "StdioClientTransport already started! If using Client class, note that connect() calls start() automatically." 112 | ); 113 | } 114 | 115 | return new Promise((resolve, reject) => { 116 | this._process = spawn( 117 | this._serverParams.command, 118 | this._serverParams.args ?? [], 119 | { 120 | env: this._serverParams.env ?? getDefaultEnvironment(), 121 | stdio: ["pipe", "pipe", this._serverParams.stderr ?? "inherit"], 122 | shell: false, 123 | signal: this._abortController.signal, 124 | windowsHide: process.platform === "win32" && isElectron(), 125 | cwd: this._serverParams.cwd, 126 | } 127 | ); 128 | 129 | this._process.on("error", (error) => { 130 | if (error.name === "AbortError") { 131 | // Expected when close() is called. 132 | this.onclose?.(); 133 | return; 134 | } 135 | 136 | reject(error); 137 | this.onerror?.(error); 138 | }); 139 | 140 | this._process.on("spawn", () => { 141 | resolve(); 142 | }); 143 | 144 | this._process.on("close", (_code) => { 145 | this._process = undefined; 146 | this.onclose?.(); 147 | }); 148 | 149 | this._process.stdin?.on("error", (error) => { 150 | this.onerror?.(error); 151 | }); 152 | 153 | this._process.stdout?.on("data", (chunk) => { 154 | this._readBuffer.append(chunk); 155 | this.processReadBuffer(); 156 | }); 157 | 158 | this._process.stdout?.on("error", (error) => { 159 | this.onerror?.(error); 160 | }); 161 | }); 162 | } 163 | 164 | /** 165 | * The stderr stream of the child process, if `StdioServerParameters.stderr` was set to "pipe" or "overlapped". 166 | * 167 | * This is only available after the process has been started. 168 | */ 169 | get stderr(): Stream | null { 170 | return this._process?.stderr ?? null; 171 | } 172 | 173 | private processReadBuffer() { 174 | while (true) { 175 | try { 176 | const message = this._readBuffer.readMessage(); 177 | if (message === null) { 178 | break; 179 | } 180 | 181 | this.onmessage?.(message); 182 | } catch (error) { 183 | this.onerror?.(error as Error); 184 | } 185 | } 186 | } 187 | 188 | async close(): Promise { 189 | this._abortController.abort(); 190 | this._process = undefined; 191 | this._readBuffer.clear(); 192 | } 193 | 194 | send(message: JSONRPCMessage): Promise { 195 | return new Promise((resolve) => { 196 | if (!this._process?.stdin) { 197 | throw new Error("Not connected"); 198 | } 199 | 200 | const json = serializeMessage(message); 201 | if (this._process.stdin.write(json)) { 202 | resolve(); 203 | } else { 204 | this._process.stdin.once("drain", resolve); 205 | } 206 | }); 207 | } 208 | } 209 | 210 | function isElectron() { 211 | return "type" in process; 212 | } 213 | -------------------------------------------------------------------------------- /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 = 33 | "error" in event 34 | ? (event.error as Error) 35 | : new Error(`WebSocket error: ${JSON.stringify(event)}`); 36 | reject(error); 37 | this.onerror?.(error); 38 | }; 39 | 40 | this._socket.onopen = () => { 41 | resolve(); 42 | }; 43 | 44 | this._socket.onclose = () => { 45 | this.onclose?.(); 46 | }; 47 | 48 | this._socket.onmessage = (event: MessageEvent) => { 49 | let message: JSONRPCMessage; 50 | try { 51 | message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); 52 | } catch (error) { 53 | this.onerror?.(error as Error); 54 | return; 55 | } 56 | 57 | this.onmessage?.(message); 58 | }; 59 | }); 60 | } 61 | 62 | async close(): Promise { 63 | this._socket?.close(); 64 | } 65 | 66 | send(message: JSONRPCMessage): Promise { 67 | return new Promise((resolve, reject) => { 68 | if (!this._socket) { 69 | reject(new Error("Not connected")); 70 | return; 71 | } 72 | 73 | this._socket?.send(JSON.stringify(message)); 74 | resolve(); 75 | }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/examples/client/multipleClientsParallel.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../../client/index.js'; 2 | import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; 3 | import { 4 | CallToolRequest, 5 | CallToolResultSchema, 6 | LoggingMessageNotificationSchema, 7 | CallToolResult, 8 | } from '../../types.js'; 9 | 10 | /** 11 | * Multiple Clients MCP Example 12 | * 13 | * This client demonstrates how to: 14 | * 1. Create multiple MCP clients in parallel 15 | * 2. Each client calls a single tool 16 | * 3. Track notifications from each client independently 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 | interface ClientConfig { 24 | id: string; 25 | name: string; 26 | toolName: string; 27 | toolArguments: Record; 28 | } 29 | 30 | async function createAndRunClient(config: ClientConfig): Promise<{ id: string; result: CallToolResult }> { 31 | console.log(`[${config.id}] Creating client: ${config.name}`); 32 | 33 | const client = new Client({ 34 | name: config.name, 35 | version: '1.0.0' 36 | }); 37 | 38 | const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); 39 | 40 | // Set up client-specific error handler 41 | client.onerror = (error) => { 42 | console.error(`[${config.id}] Client error:`, error); 43 | }; 44 | 45 | // Set up client-specific notification handler 46 | client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { 47 | console.log(`[${config.id}] Notification: ${notification.params.data}`); 48 | }); 49 | 50 | try { 51 | // Connect to the server 52 | await client.connect(transport); 53 | console.log(`[${config.id}] Connected to MCP server`); 54 | 55 | // Call the specified tool 56 | console.log(`[${config.id}] Calling tool: ${config.toolName}`); 57 | const toolRequest: CallToolRequest = { 58 | method: 'tools/call', 59 | params: { 60 | name: config.toolName, 61 | arguments: { 62 | ...config.toolArguments, 63 | // Add client ID to arguments for identification in notifications 64 | caller: config.id 65 | } 66 | } 67 | }; 68 | 69 | const result = await client.request(toolRequest, CallToolResultSchema); 70 | console.log(`[${config.id}] Tool call completed`); 71 | 72 | // Keep the connection open for a bit to receive notifications 73 | await new Promise(resolve => setTimeout(resolve, 5000)); 74 | 75 | // Disconnect 76 | await transport.close(); 77 | console.log(`[${config.id}] Disconnected from MCP server`); 78 | 79 | return { id: config.id, result }; 80 | } catch (error) { 81 | console.error(`[${config.id}] Error:`, error); 82 | throw error; 83 | } 84 | } 85 | 86 | async function main(): Promise { 87 | console.log('MCP Multiple Clients Example'); 88 | console.log('============================'); 89 | console.log(`Server URL: ${serverUrl}`); 90 | console.log(''); 91 | 92 | try { 93 | // Define client configurations 94 | const clientConfigs: ClientConfig[] = [ 95 | { 96 | id: 'client1', 97 | name: 'basic-client-1', 98 | toolName: 'start-notification-stream', 99 | toolArguments: { 100 | interval: 3, // 1 second between notifications 101 | count: 5 // Send 5 notifications 102 | } 103 | }, 104 | { 105 | id: 'client2', 106 | name: 'basic-client-2', 107 | toolName: 'start-notification-stream', 108 | toolArguments: { 109 | interval: 2, // 2 seconds between notifications 110 | count: 3 // Send 3 notifications 111 | } 112 | }, 113 | { 114 | id: 'client3', 115 | name: 'basic-client-3', 116 | toolName: 'start-notification-stream', 117 | toolArguments: { 118 | interval: 1, // 0.5 second between notifications 119 | count: 8 // Send 8 notifications 120 | } 121 | } 122 | ]; 123 | 124 | // Start all clients in parallel 125 | console.log(`Starting ${clientConfigs.length} clients in parallel...`); 126 | console.log(''); 127 | 128 | const clientPromises = clientConfigs.map(config => createAndRunClient(config)); 129 | const results = await Promise.all(clientPromises); 130 | 131 | // Display results from all clients 132 | console.log('\n=== Final Results ==='); 133 | results.forEach(({ id, result }) => { 134 | console.log(`\n[${id}] Tool result:`); 135 | if (Array.isArray(result.content)) { 136 | result.content.forEach((item: { type: string; text?: string }) => { 137 | if (item.type === 'text' && item.text) { 138 | console.log(` ${item.text}`); 139 | } else { 140 | console.log(` ${item.type} content:`, item); 141 | } 142 | }); 143 | } else { 144 | console.log(` Unexpected result format:`, result); 145 | } 146 | }); 147 | 148 | console.log('\n=== All clients completed successfully ==='); 149 | 150 | } catch (error) { 151 | console.error('Error running multiple clients:', error); 152 | process.exit(1); 153 | } 154 | } 155 | 156 | // Start the example 157 | main().catch((error: unknown) => { 158 | console.error('Error running MCP multiple clients example:', error); 159 | process.exit(1); 160 | }); -------------------------------------------------------------------------------- /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 | 57 | // 2. Start multiple notification tools in parallel 58 | console.log('\n=== Starting Multiple Notification Streams in Parallel ==='); 59 | const toolResults = await startParallelNotificationTools(client); 60 | 61 | // Log the results from each tool call 62 | for (const [caller, result] of Object.entries(toolResults)) { 63 | console.log(`\n=== Tool result for ${caller} ===`); 64 | result.content.forEach((item: { type: string; text?: string; }) => { 65 | if (item.type === 'text') { 66 | console.log(` ${item.text}`); 67 | } else { 68 | console.log(` ${item.type} content:`, item); 69 | } 70 | }); 71 | } 72 | 73 | // 3. Wait for all notifications (10 seconds) 74 | console.log('\n=== Waiting for all notifications ==='); 75 | await new Promise(resolve => setTimeout(resolve, 10000)); 76 | 77 | // 4. Disconnect 78 | console.log('\n=== Disconnecting ==='); 79 | await transport.close(); 80 | console.log('Disconnected from MCP server'); 81 | 82 | } catch (error) { 83 | console.error('Error running client:', error); 84 | process.exit(1); 85 | } 86 | } 87 | 88 | /** 89 | * List available tools on the server 90 | */ 91 | async function listTools(client: Client): Promise { 92 | try { 93 | const toolsRequest: ListToolsRequest = { 94 | method: 'tools/list', 95 | params: {} 96 | }; 97 | const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); 98 | 99 | console.log('Available tools:'); 100 | if (toolsResult.tools.length === 0) { 101 | console.log(' No tools available'); 102 | } else { 103 | for (const tool of toolsResult.tools) { 104 | console.log(` - ${tool.name}: ${tool.description}`); 105 | } 106 | } 107 | } catch (error) { 108 | console.log(`Tools not supported by this server: ${error}`); 109 | } 110 | } 111 | 112 | /** 113 | * Start multiple notification tools in parallel with different configurations 114 | * Each tool call includes a caller parameter to identify its notifications 115 | */ 116 | async function startParallelNotificationTools(client: Client): Promise> { 117 | try { 118 | // Define multiple tool calls with different configurations 119 | const toolCalls = [ 120 | { 121 | caller: 'fast-notifier', 122 | request: { 123 | method: 'tools/call', 124 | params: { 125 | name: 'start-notification-stream', 126 | arguments: { 127 | interval: 2, // 0.5 second between notifications 128 | count: 10, // Send 10 notifications 129 | caller: 'fast-notifier' // Identify this tool call 130 | } 131 | } 132 | } 133 | }, 134 | { 135 | caller: 'slow-notifier', 136 | request: { 137 | method: 'tools/call', 138 | params: { 139 | name: 'start-notification-stream', 140 | arguments: { 141 | interval: 5, // 2 seconds between notifications 142 | count: 5, // Send 5 notifications 143 | caller: 'slow-notifier' // Identify this tool call 144 | } 145 | } 146 | } 147 | }, 148 | { 149 | caller: 'burst-notifier', 150 | request: { 151 | method: 'tools/call', 152 | params: { 153 | name: 'start-notification-stream', 154 | arguments: { 155 | interval: 1, // 0.1 second between notifications 156 | count: 3, // Send just 3 notifications 157 | caller: 'burst-notifier' // Identify this tool call 158 | } 159 | } 160 | } 161 | } 162 | ]; 163 | 164 | console.log(`Starting ${toolCalls.length} notification tools in parallel...`); 165 | 166 | // Start all tool calls in parallel 167 | const toolPromises = toolCalls.map(({ caller, request }) => { 168 | console.log(`Starting tool call for ${caller}...`); 169 | return client.request(request, CallToolResultSchema) 170 | .then(result => ({ caller, result })) 171 | .catch(error => { 172 | console.error(`Error in tool call for ${caller}:`, error); 173 | throw error; 174 | }); 175 | }); 176 | 177 | // Wait for all tool calls to complete 178 | const results = await Promise.all(toolPromises); 179 | 180 | // Organize results by caller 181 | const resultsByTool: Record = {}; 182 | results.forEach(({ caller, result }) => { 183 | resultsByTool[caller] = result; 184 | }); 185 | 186 | return resultsByTool; 187 | } catch (error) { 188 | console.error(`Error starting parallel notification tools:`, error); 189 | throw error; 190 | } 191 | } 192 | 193 | // Start the client 194 | main().catch((error: unknown) => { 195 | console.error('Error running MCP client:', error); 196 | process.exit(1); 197 | }); -------------------------------------------------------------------------------- /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 | 65 | } catch (error) { 66 | console.error('Error running client:', error); 67 | process.exit(1); 68 | } 69 | } 70 | 71 | /** 72 | * Connect to an MCP server with backwards compatibility 73 | * Following the spec for client backward compatibility 74 | */ 75 | async function connectWithBackwardsCompatibility(url: string): Promise<{ 76 | client: Client, 77 | transport: StreamableHTTPClientTransport | SSEClientTransport, 78 | transportType: 'streamable-http' | 'sse' 79 | }> { 80 | console.log('1. Trying Streamable HTTP transport first...'); 81 | 82 | // Step 1: Try Streamable HTTP transport first 83 | const client = new Client({ 84 | name: 'backwards-compatible-client', 85 | version: '1.0.0' 86 | }); 87 | 88 | client.onerror = (error) => { 89 | console.error('Client error:', error); 90 | }; 91 | const baseUrl = new URL(url); 92 | 93 | try { 94 | // Create modern transport 95 | const streamableTransport = new StreamableHTTPClientTransport(baseUrl); 96 | await client.connect(streamableTransport); 97 | 98 | console.log('Successfully connected using modern Streamable HTTP transport.'); 99 | return { 100 | client, 101 | transport: streamableTransport, 102 | transportType: 'streamable-http' 103 | }; 104 | } catch (error) { 105 | // Step 2: If transport fails, try the older SSE transport 106 | console.log(`StreamableHttp transport connection failed: ${error}`); 107 | console.log('2. Falling back to deprecated HTTP+SSE transport...'); 108 | 109 | try { 110 | // Create SSE transport pointing to /sse endpoint 111 | const sseTransport = new SSEClientTransport(baseUrl); 112 | const sseClient = new Client({ 113 | name: 'backwards-compatible-client', 114 | version: '1.0.0' 115 | }); 116 | await sseClient.connect(sseTransport); 117 | 118 | console.log('Successfully connected using deprecated HTTP+SSE transport.'); 119 | return { 120 | client: sseClient, 121 | transport: sseTransport, 122 | transportType: 'sse' 123 | }; 124 | } catch (sseError) { 125 | console.error(`Failed to connect with either transport method:\n1. Streamable HTTP error: ${error}\n2. SSE error: ${sseError}`); 126 | throw new Error('Could not connect to server with any available transport'); 127 | } 128 | } 129 | } 130 | 131 | /** 132 | * List available tools on the server 133 | */ 134 | async function listTools(client: Client): Promise { 135 | try { 136 | const toolsRequest: ListToolsRequest = { 137 | method: 'tools/list', 138 | params: {} 139 | }; 140 | const toolsResult = await client.request(toolsRequest, ListToolsResultSchema); 141 | 142 | console.log('Available tools:'); 143 | if (toolsResult.tools.length === 0) { 144 | console.log(' No tools available'); 145 | } else { 146 | for (const tool of toolsResult.tools) { 147 | console.log(` - ${tool.name}: ${tool.description}`); 148 | } 149 | } 150 | } catch (error) { 151 | console.log(`Tools not supported by this server: ${error}`); 152 | } 153 | } 154 | 155 | /** 156 | * Start a notification stream by calling the notification tool 157 | */ 158 | async function startNotificationTool(client: Client): Promise { 159 | try { 160 | // Call the notification tool using reasonable defaults 161 | const request: CallToolRequest = { 162 | method: 'tools/call', 163 | params: { 164 | name: 'start-notification-stream', 165 | arguments: { 166 | interval: 1000, // 1 second between notifications 167 | count: 5 // Send 5 notifications 168 | } 169 | } 170 | }; 171 | 172 | console.log('Calling notification tool...'); 173 | const result = await client.request(request, CallToolResultSchema); 174 | 175 | console.log('Tool result:'); 176 | result.content.forEach(item => { 177 | if (item.type === 'text') { 178 | console.log(` ${item.text}`); 179 | } else { 180 | console.log(` ${item.type} content:`, item); 181 | } 182 | }); 183 | } catch (error) { 184 | console.log(`Error calling notification tool: ${error}`); 185 | } 186 | } 187 | 188 | // Start the client 189 | main().catch((error: unknown) => { 190 | console.error('Error running MCP client:', error); 191 | process.exit(1); 192 | }); -------------------------------------------------------------------------------- /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 | 8 | 9 | // Create an MCP server with implementation details 10 | const getServer = () => { 11 | const server = new McpServer({ 12 | name: 'json-response-streamable-http-server', 13 | version: '1.0.0', 14 | }, { 15 | capabilities: { 16 | logging: {}, 17 | } 18 | }); 19 | 20 | // Register a simple tool that returns a greeting 21 | server.tool( 22 | 'greet', 23 | 'A simple greeting tool', 24 | { 25 | name: z.string().describe('Name to greet'), 26 | }, 27 | async ({ name }): Promise => { 28 | return { 29 | content: [ 30 | { 31 | type: 'text', 32 | text: `Hello, ${name}!`, 33 | }, 34 | ], 35 | }; 36 | } 37 | ); 38 | 39 | // Register a tool that sends multiple greetings with notifications 40 | server.tool( 41 | 'multi-greet', 42 | 'A tool that sends different greetings with delays between them', 43 | { 44 | name: z.string().describe('Name to greet'), 45 | }, 46 | async ({ name }, { sendNotification }): Promise => { 47 | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 48 | 49 | await sendNotification({ 50 | method: "notifications/message", 51 | params: { level: "debug", data: `Starting multi-greet for ${name}` } 52 | }); 53 | 54 | await sleep(1000); // Wait 1 second before first greeting 55 | 56 | await sendNotification({ 57 | method: "notifications/message", 58 | params: { level: "info", data: `Sending first greeting to ${name}` } 59 | }); 60 | 61 | await sleep(1000); // Wait another second before second greeting 62 | 63 | await sendNotification({ 64 | method: "notifications/message", 65 | params: { level: "info", data: `Sending second greeting to ${name}` } 66 | }); 67 | 68 | return { 69 | content: [ 70 | { 71 | type: 'text', 72 | text: `Good morning, ${name}!`, 73 | } 74 | ], 75 | }; 76 | } 77 | ); 78 | return server; 79 | } 80 | 81 | const app = express(); 82 | app.use(express.json()); 83 | 84 | // Map to store transports by session ID 85 | const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; 86 | 87 | app.post('/mcp', async (req: Request, res: Response) => { 88 | console.log('Received MCP request:', req.body); 89 | try { 90 | // Check for existing session ID 91 | const sessionId = req.headers['mcp-session-id'] as string | undefined; 92 | let transport: StreamableHTTPServerTransport; 93 | 94 | if (sessionId && transports[sessionId]) { 95 | // Reuse existing transport 96 | transport = transports[sessionId]; 97 | } else if (!sessionId && isInitializeRequest(req.body)) { 98 | // New initialization request - use JSON response mode 99 | transport = new StreamableHTTPServerTransport({ 100 | sessionIdGenerator: () => randomUUID(), 101 | enableJsonResponse: true, // Enable JSON response mode 102 | onsessioninitialized: (sessionId) => { 103 | // Store the transport by session ID when session is initialized 104 | // This avoids race conditions where requests might come in before the session is stored 105 | console.log(`Session initialized with ID: ${sessionId}`); 106 | transports[sessionId] = transport; 107 | } 108 | }); 109 | 110 | // Connect the transport to the MCP server BEFORE handling the request 111 | const server = getServer(); 112 | await server.connect(transport); 113 | await transport.handleRequest(req, res, req.body); 114 | return; // Already handled 115 | } else { 116 | // Invalid request - no session ID or not initialization request 117 | res.status(400).json({ 118 | jsonrpc: '2.0', 119 | error: { 120 | code: -32000, 121 | message: 'Bad Request: No valid session ID provided', 122 | }, 123 | id: null, 124 | }); 125 | return; 126 | } 127 | 128 | // Handle the request with existing transport - no need to reconnect 129 | await transport.handleRequest(req, res, req.body); 130 | } catch (error) { 131 | console.error('Error handling MCP request:', error); 132 | if (!res.headersSent) { 133 | res.status(500).json({ 134 | jsonrpc: '2.0', 135 | error: { 136 | code: -32603, 137 | message: 'Internal server error', 138 | }, 139 | id: null, 140 | }); 141 | } 142 | } 143 | }); 144 | 145 | // Handle GET requests for SSE streams according to spec 146 | app.get('/mcp', async (req: Request, res: Response) => { 147 | // Since this is a very simple example, we don't support GET requests for this server 148 | // The spec requires returning 405 Method Not Allowed in this case 149 | res.status(405).set('Allow', 'POST').send('Method Not Allowed'); 150 | }); 151 | 152 | // Start the server 153 | const PORT = 3000; 154 | app.listen(PORT, () => { 155 | console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); 156 | }); 157 | 158 | // Handle server shutdown 159 | process.on('SIGINT', async () => { 160 | console.log('Shutting down server...'); 161 | process.exit(0); 162 | }); -------------------------------------------------------------------------------- /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 | name: 'simple-sse-server', 21 | version: '1.0.0', 22 | }, { capabilities: { logging: {} } }); 23 | 24 | server.tool( 25 | 'start-notification-stream', 26 | 'Starts sending periodic notifications', 27 | { 28 | interval: z.number().describe('Interval in milliseconds between notifications').default(1000), 29 | count: z.number().describe('Number of notifications to send').default(10), 30 | }, 31 | async ({ interval, count }, { sendNotification }): Promise => { 32 | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 33 | let counter = 0; 34 | 35 | // Send the initial notification 36 | await sendNotification({ 37 | method: "notifications/message", 38 | params: { 39 | level: "info", 40 | data: `Starting notification stream with ${count} messages every ${interval}ms` 41 | } 42 | }); 43 | 44 | // Send periodic notifications 45 | while (counter < count) { 46 | counter++; 47 | await sleep(interval); 48 | 49 | try { 50 | await sendNotification({ 51 | method: "notifications/message", 52 | params: { 53 | level: "info", 54 | data: `Notification #${counter} at ${new Date().toISOString()}` 55 | } 56 | }); 57 | } 58 | catch (error) { 59 | console.error("Error sending notification:", error); 60 | } 61 | } 62 | 63 | return { 64 | content: [ 65 | { 66 | type: 'text', 67 | text: `Completed sending ${count} notifications every ${interval}ms`, 68 | } 69 | ], 70 | }; 71 | } 72 | ); 73 | return server; 74 | }; 75 | 76 | const app = express(); 77 | app.use(express.json()); 78 | 79 | // Store transports by session ID 80 | const transports: Record = {}; 81 | 82 | // SSE endpoint for establishing the stream 83 | app.get('/mcp', async (req: Request, res: Response) => { 84 | console.log('Received GET request to /sse (establishing SSE stream)'); 85 | 86 | try { 87 | // Create a new SSE transport for the client 88 | // The endpoint for POST messages is '/messages' 89 | const transport = new SSEServerTransport('/messages', res); 90 | 91 | // Store the transport by session ID 92 | const sessionId = transport.sessionId; 93 | transports[sessionId] = transport; 94 | 95 | // Set up onclose handler to clean up transport when closed 96 | transport.onclose = () => { 97 | console.log(`SSE transport closed for session ${sessionId}`); 98 | delete transports[sessionId]; 99 | }; 100 | 101 | // Connect the transport to the MCP server 102 | const server = getServer(); 103 | await server.connect(transport); 104 | 105 | console.log(`Established SSE stream with session ID: ${sessionId}`); 106 | } catch (error) { 107 | console.error('Error establishing SSE stream:', error); 108 | if (!res.headersSent) { 109 | res.status(500).send('Error establishing SSE stream'); 110 | } 111 | } 112 | }); 113 | 114 | // Messages endpoint for receiving client JSON-RPC requests 115 | app.post('/messages', async (req: Request, res: Response) => { 116 | console.log('Received POST request to /messages'); 117 | 118 | // Extract session ID from URL query parameter 119 | // In the SSE protocol, this is added by the client based on the endpoint event 120 | const sessionId = req.query.sessionId as string | undefined; 121 | 122 | if (!sessionId) { 123 | console.error('No session ID provided in request URL'); 124 | res.status(400).send('Missing sessionId parameter'); 125 | return; 126 | } 127 | 128 | const transport = transports[sessionId]; 129 | if (!transport) { 130 | console.error(`No active transport found for session ID: ${sessionId}`); 131 | res.status(404).send('Session not found'); 132 | return; 133 | } 134 | 135 | try { 136 | // Handle the POST message with the transport 137 | await transport.handlePostMessage(req, res, req.body); 138 | } catch (error) { 139 | console.error('Error handling request:', error); 140 | if (!res.headersSent) { 141 | res.status(500).send('Error handling request'); 142 | } 143 | } 144 | }); 145 | 146 | // Start the server 147 | const PORT = 3000; 148 | app.listen(PORT, () => { 149 | console.log(`Simple SSE Server (deprecated protocol version 2024-11-05) listening on port ${PORT}`); 150 | }); 151 | 152 | // Handle server shutdown 153 | process.on('SIGINT', async () => { 154 | console.log('Shutting down server...'); 155 | 156 | // Close all active transports to properly clean up resources 157 | for (const sessionId in transports) { 158 | try { 159 | console.log(`Closing transport for session ${sessionId}`); 160 | await transports[sessionId].close(); 161 | delete transports[sessionId]; 162 | } catch (error) { 163 | console.error(`Error closing transport for session ${sessionId}:`, error); 164 | } 165 | } 166 | console.log('Server shutdown complete'); 167 | process.exit(0); 168 | }); -------------------------------------------------------------------------------- /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 | 7 | const getServer = () => { 8 | // Create an MCP server with implementation details 9 | const server = new McpServer({ 10 | name: 'stateless-streamable-http-server', 11 | version: '1.0.0', 12 | }, { capabilities: { logging: {} } }); 13 | 14 | // Register a simple prompt 15 | server.prompt( 16 | 'greeting-template', 17 | 'A simple greeting prompt template', 18 | { 19 | name: z.string().describe('Name to include in greeting'), 20 | }, 21 | async ({ name }): Promise => { 22 | return { 23 | messages: [ 24 | { 25 | role: 'user', 26 | content: { 27 | type: 'text', 28 | text: `Please greet ${name} in a friendly manner.`, 29 | }, 30 | }, 31 | ], 32 | }; 33 | } 34 | ); 35 | 36 | // Register a tool specifically for testing resumability 37 | server.tool( 38 | 'start-notification-stream', 39 | 'Starts sending periodic notifications for testing resumability', 40 | { 41 | interval: z.number().describe('Interval in milliseconds between notifications').default(100), 42 | count: z.number().describe('Number of notifications to send (0 for 100)').default(10), 43 | }, 44 | async ({ interval, count }, { sendNotification }): Promise => { 45 | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 46 | let counter = 0; 47 | 48 | while (count === 0 || counter < count) { 49 | counter++; 50 | try { 51 | await sendNotification({ 52 | method: "notifications/message", 53 | params: { 54 | level: "info", 55 | data: `Periodic notification #${counter} at ${new Date().toISOString()}` 56 | } 57 | }); 58 | } 59 | catch (error) { 60 | console.error("Error sending notification:", error); 61 | } 62 | // Wait for the specified interval 63 | await sleep(interval); 64 | } 65 | 66 | return { 67 | content: [ 68 | { 69 | type: 'text', 70 | text: `Started sending periodic notifications every ${interval}ms`, 71 | } 72 | ], 73 | }; 74 | } 75 | ); 76 | 77 | // Create a simple resource at a fixed URI 78 | server.resource( 79 | 'greeting-resource', 80 | 'https://example.com/greetings/default', 81 | { mimeType: 'text/plain' }, 82 | async (): Promise => { 83 | return { 84 | contents: [ 85 | { 86 | uri: 'https://example.com/greetings/default', 87 | text: 'Hello, world!', 88 | }, 89 | ], 90 | }; 91 | } 92 | ); 93 | return server; 94 | } 95 | 96 | const app = express(); 97 | app.use(express.json()); 98 | 99 | app.post('/mcp', async (req: Request, res: Response) => { 100 | const server = getServer(); 101 | try { 102 | const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ 103 | sessionIdGenerator: undefined, 104 | }); 105 | await server.connect(transport); 106 | await transport.handleRequest(req, res, req.body); 107 | res.on('close', () => { 108 | console.log('Request closed'); 109 | transport.close(); 110 | server.close(); 111 | }); 112 | } catch (error) { 113 | console.error('Error handling MCP request:', error); 114 | if (!res.headersSent) { 115 | res.status(500).json({ 116 | jsonrpc: '2.0', 117 | error: { 118 | code: -32603, 119 | message: 'Internal server error', 120 | }, 121 | id: null, 122 | }); 123 | } 124 | } 125 | }); 126 | 127 | app.get('/mcp', async (req: Request, res: Response) => { 128 | console.log('Received GET MCP request'); 129 | res.writeHead(405).end(JSON.stringify({ 130 | jsonrpc: "2.0", 131 | error: { 132 | code: -32000, 133 | message: "Method not allowed." 134 | }, 135 | id: null 136 | })); 137 | }); 138 | 139 | app.delete('/mcp', async (req: Request, res: Response) => { 140 | console.log('Received DELETE MCP request'); 141 | res.writeHead(405).end(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 | // Start the server 153 | const PORT = 3000; 154 | app.listen(PORT, () => { 155 | console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`); 156 | }); 157 | 158 | // Handle server shutdown 159 | process.on('SIGINT', async () => { 160 | console.log('Shutting down server...'); 161 | process.exit(0); 162 | }); -------------------------------------------------------------------------------- /src/examples/server/sseAndStreamableHttpCompatibleServer.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 { SSEServerTransport } from '../../server/sse.js'; 6 | import { z } from 'zod'; 7 | import { CallToolResult, isInitializeRequest } from '../../types.js'; 8 | import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; 9 | 10 | /** 11 | * This example server demonstrates backwards compatibility with both: 12 | * 1. The deprecated HTTP+SSE transport (protocol version 2024-11-05) 13 | * 2. The Streamable HTTP transport (protocol version 2025-03-26) 14 | * 15 | * It maintains a single MCP server instance but exposes two transport options: 16 | * - /mcp: The new Streamable HTTP endpoint (supports GET/POST/DELETE) 17 | * - /sse: The deprecated SSE endpoint for older clients (GET to establish stream) 18 | * - /messages: The deprecated POST endpoint for older clients (POST to send messages) 19 | */ 20 | 21 | const getServer = () => { 22 | const server = new McpServer({ 23 | name: 'backwards-compatible-server', 24 | version: '1.0.0', 25 | }, { capabilities: { logging: {} } }); 26 | 27 | // Register a simple tool that sends notifications over time 28 | server.tool( 29 | 'start-notification-stream', 30 | 'Starts sending periodic notifications for testing resumability', 31 | { 32 | interval: z.number().describe('Interval in milliseconds between notifications').default(100), 33 | count: z.number().describe('Number of notifications to send (0 for 100)').default(50), 34 | }, 35 | async ({ interval, count }, { sendNotification }): Promise => { 36 | const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); 37 | let counter = 0; 38 | 39 | while (count === 0 || counter < count) { 40 | counter++; 41 | try { 42 | await sendNotification({ 43 | method: "notifications/message", 44 | params: { 45 | level: "info", 46 | data: `Periodic notification #${counter} at ${new Date().toISOString()}` 47 | } 48 | }); 49 | } 50 | catch (error) { 51 | console.error("Error sending notification:", error); 52 | } 53 | // Wait for the specified interval 54 | await sleep(interval); 55 | } 56 | 57 | return { 58 | content: [ 59 | { 60 | type: 'text', 61 | text: `Started sending periodic notifications every ${interval}ms`, 62 | } 63 | ], 64 | }; 65 | } 66 | ); 67 | return server; 68 | }; 69 | 70 | // Create Express application 71 | const app = express(); 72 | app.use(express.json()); 73 | 74 | // Store transports by session ID 75 | const transports: Record = {}; 76 | 77 | //============================================================================= 78 | // STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-03-26) 79 | //============================================================================= 80 | 81 | // Handle all MCP Streamable HTTP requests (GET, POST, DELETE) on a single endpoint 82 | app.all('/mcp', async (req: Request, res: Response) => { 83 | console.log(`Received ${req.method} request to /mcp`); 84 | 85 | try { 86 | // Check for existing session ID 87 | const sessionId = req.headers['mcp-session-id'] as string | undefined; 88 | let transport: StreamableHTTPServerTransport; 89 | 90 | if (sessionId && transports[sessionId]) { 91 | // Check if the transport is of the correct type 92 | const existingTransport = transports[sessionId]; 93 | if (existingTransport instanceof StreamableHTTPServerTransport) { 94 | // Reuse existing transport 95 | transport = existingTransport; 96 | } else { 97 | // Transport exists but is not a StreamableHTTPServerTransport (could be SSEServerTransport) 98 | res.status(400).json({ 99 | jsonrpc: '2.0', 100 | error: { 101 | code: -32000, 102 | message: 'Bad Request: Session exists but uses a different transport protocol', 103 | }, 104 | id: null, 105 | }); 106 | return; 107 | } 108 | } else if (!sessionId && req.method === 'POST' && isInitializeRequest(req.body)) { 109 | const eventStore = new InMemoryEventStore(); 110 | transport = new StreamableHTTPServerTransport({ 111 | sessionIdGenerator: () => randomUUID(), 112 | eventStore, // Enable resumability 113 | onsessioninitialized: (sessionId) => { 114 | // Store the transport by session ID when session is initialized 115 | console.log(`StreamableHTTP session initialized with ID: ${sessionId}`); 116 | transports[sessionId] = transport; 117 | } 118 | }); 119 | 120 | // Set up onclose handler to clean up transport when closed 121 | transport.onclose = () => { 122 | const sid = transport.sessionId; 123 | if (sid && transports[sid]) { 124 | console.log(`Transport closed for session ${sid}, removing from transports map`); 125 | delete transports[sid]; 126 | } 127 | }; 128 | 129 | // Connect the transport to the MCP server 130 | const server = getServer(); 131 | await server.connect(transport); 132 | } else { 133 | // Invalid request - no session ID or not initialization request 134 | res.status(400).json({ 135 | jsonrpc: '2.0', 136 | error: { 137 | code: -32000, 138 | message: 'Bad Request: No valid session ID provided', 139 | }, 140 | id: null, 141 | }); 142 | return; 143 | } 144 | 145 | // Handle the request with the transport 146 | await transport.handleRequest(req, res, req.body); 147 | } catch (error) { 148 | console.error('Error handling MCP request:', error); 149 | if (!res.headersSent) { 150 | res.status(500).json({ 151 | jsonrpc: '2.0', 152 | error: { 153 | code: -32603, 154 | message: 'Internal server error', 155 | }, 156 | id: null, 157 | }); 158 | } 159 | } 160 | }); 161 | 162 | //============================================================================= 163 | // DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05) 164 | //============================================================================= 165 | 166 | app.get('/sse', async (req: Request, res: Response) => { 167 | console.log('Received GET request to /sse (deprecated SSE transport)'); 168 | const transport = new SSEServerTransport('/messages', res); 169 | transports[transport.sessionId] = transport; 170 | res.on("close", () => { 171 | delete transports[transport.sessionId]; 172 | }); 173 | const server = getServer(); 174 | await server.connect(transport); 175 | }); 176 | 177 | app.post("/messages", async (req: Request, res: Response) => { 178 | const sessionId = req.query.sessionId as string; 179 | let transport: SSEServerTransport; 180 | const existingTransport = transports[sessionId]; 181 | if (existingTransport instanceof SSEServerTransport) { 182 | // Reuse existing transport 183 | transport = existingTransport; 184 | } else { 185 | // Transport exists but is not a SSEServerTransport (could be StreamableHTTPServerTransport) 186 | res.status(400).json({ 187 | jsonrpc: '2.0', 188 | error: { 189 | code: -32000, 190 | message: 'Bad Request: Session exists but uses a different transport protocol', 191 | }, 192 | id: null, 193 | }); 194 | return; 195 | } 196 | if (transport) { 197 | await transport.handlePostMessage(req, res, req.body); 198 | } else { 199 | res.status(400).send('No transport found for sessionId'); 200 | } 201 | }); 202 | 203 | 204 | // Start the server 205 | const PORT = 3000; 206 | app.listen(PORT, () => { 207 | console.log(`Backwards compatible MCP server listening on port ${PORT}`); 208 | console.log(` 209 | ============================================== 210 | SUPPORTED TRANSPORT OPTIONS: 211 | 212 | 1. Streamable Http(Protocol version: 2025-03-26) 213 | Endpoint: /mcp 214 | Methods: GET, POST, DELETE 215 | Usage: 216 | - Initialize with POST to /mcp 217 | - Establish SSE stream with GET to /mcp 218 | - Send requests with POST to /mcp 219 | - Terminate session with DELETE to /mcp 220 | 221 | 2. Http + SSE (Protocol version: 2024-11-05) 222 | Endpoints: /sse (GET) and /messages (POST) 223 | Usage: 224 | - Establish SSE stream with GET to /sse 225 | - Send requests with POST to /messages?sessionId= 226 | ============================================== 227 | `); 228 | }); 229 | 230 | // Handle server shutdown 231 | process.on('SIGINT', async () => { 232 | console.log('Shutting down server...'); 233 | 234 | // Close all active transports to properly clean up resources 235 | for (const sessionId in transports) { 236 | try { 237 | console.log(`Closing transport for session ${sessionId}`); 238 | await transports[sessionId].close(); 239 | delete transports[sessionId]; 240 | } catch (error) { 241 | console.error(`Error closing transport for session ${sessionId}:`, error); 242 | } 243 | } 244 | console.log('Server shutdown complete'); 245 | process.exit(0); 246 | }); -------------------------------------------------------------------------------- /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 | 31 | addResource('example-resource', 'Initial content for example-resource'); 32 | 33 | const resourceChangeInterval = setInterval(() => { 34 | const name = randomUUID(); 35 | addResource(name, `Content for ${name}`); 36 | }, 5000); // Change resources every 5 seconds for testing 37 | 38 | const app = express(); 39 | app.use(express.json()); 40 | 41 | app.post('/mcp', async (req: Request, res: Response) => { 42 | console.log('Received MCP request:', req.body); 43 | try { 44 | // Check for existing session ID 45 | const sessionId = req.headers['mcp-session-id'] as string | undefined; 46 | let transport: StreamableHTTPServerTransport; 47 | 48 | if (sessionId && transports[sessionId]) { 49 | // Reuse existing transport 50 | transport = transports[sessionId]; 51 | } else if (!sessionId && isInitializeRequest(req.body)) { 52 | // New initialization request 53 | transport = new StreamableHTTPServerTransport({ 54 | sessionIdGenerator: () => randomUUID(), 55 | onsessioninitialized: (sessionId) => { 56 | // Store the transport by session ID when session is initialized 57 | // This avoids race conditions where requests might come in before the session is stored 58 | console.log(`Session initialized with ID: ${sessionId}`); 59 | transports[sessionId] = transport; 60 | } 61 | }); 62 | 63 | // Connect the transport to the MCP server 64 | await server.connect(transport); 65 | 66 | // Handle the request - the onsessioninitialized callback will store the transport 67 | await transport.handleRequest(req, res, req.body); 68 | return; // Already handled 69 | } else { 70 | // Invalid request - no session ID or not initialization request 71 | res.status(400).json({ 72 | jsonrpc: '2.0', 73 | error: { 74 | code: -32000, 75 | message: 'Bad Request: No valid session ID provided', 76 | }, 77 | id: null, 78 | }); 79 | return; 80 | } 81 | 82 | // Handle the request with existing transport 83 | await transport.handleRequest(req, res, req.body); 84 | } catch (error) { 85 | console.error('Error handling MCP request:', error); 86 | if (!res.headersSent) { 87 | res.status(500).json({ 88 | jsonrpc: '2.0', 89 | error: { 90 | code: -32603, 91 | message: 'Internal server error', 92 | }, 93 | id: null, 94 | }); 95 | } 96 | } 97 | }); 98 | 99 | // Handle GET requests for SSE streams (now using built-in support from StreamableHTTP) 100 | app.get('/mcp', async (req: Request, res: Response) => { 101 | const sessionId = req.headers['mcp-session-id'] as string | undefined; 102 | if (!sessionId || !transports[sessionId]) { 103 | res.status(400).send('Invalid or missing session ID'); 104 | return; 105 | } 106 | 107 | console.log(`Establishing SSE stream for session ${sessionId}`); 108 | const transport = transports[sessionId]; 109 | await transport.handleRequest(req, res); 110 | }); 111 | 112 | 113 | // Start the server 114 | const PORT = 3000; 115 | app.listen(PORT, () => { 116 | console.log(`Server listening on port ${PORT}`); 117 | }); 118 | 119 | // Handle server shutdown 120 | process.on('SIGINT', async () => { 121 | console.log('Shutting down server...'); 122 | clearInterval(resourceChangeInterval); 123 | await server.close(); 124 | process.exit(0); 125 | }); -------------------------------------------------------------------------------- /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(lastEventId: string, 42 | { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise } 43 | ): Promise { 44 | if (!lastEventId || !this.events.has(lastEventId)) { 45 | return ''; 46 | } 47 | 48 | // Extract the stream ID from the event ID 49 | const streamId = this.getStreamIdFromEventId(lastEventId); 50 | if (!streamId) { 51 | return ''; 52 | } 53 | 54 | let foundLastEvent = false; 55 | 56 | // Sort events by eventId for chronological ordering 57 | const sortedEvents = [...this.events.entries()].sort((a, b) => a[0].localeCompare(b[0])); 58 | 59 | for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { 60 | // Only include events from the same stream 61 | if (eventStreamId !== streamId) { 62 | continue; 63 | } 64 | 65 | // Start sending events after we find the lastEventId 66 | if (eventId === lastEventId) { 67 | foundLastEvent = true; 68 | continue; 69 | } 70 | 71 | if (foundLastEvent) { 72 | await send(eventId, message); 73 | } 74 | } 75 | return streamId; 76 | } 77 | } -------------------------------------------------------------------------------- /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( 101 | clientTransport.send({ jsonrpc: "2.0", method: "test", id: 1 }), 102 | ).rejects.toThrow("Not connected"); 103 | }); 104 | 105 | test("should queue messages sent before start", async () => { 106 | const message: JSONRPCMessage = { 107 | jsonrpc: "2.0", 108 | method: "test", 109 | id: 1, 110 | }; 111 | 112 | let receivedMessage: JSONRPCMessage | undefined; 113 | serverTransport.onmessage = (msg) => { 114 | receivedMessage = msg; 115 | }; 116 | 117 | await clientTransport.send(message); 118 | await serverTransport.start(); 119 | expect(receivedMessage).toEqual(message); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /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/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 | }); -------------------------------------------------------------------------------- /src/integration-tests/taskResumability.test.ts: -------------------------------------------------------------------------------- 1 | import { createServer, type Server } from 'node:http'; 2 | import { AddressInfo } from 'node:net'; 3 | import { randomUUID } from 'node:crypto'; 4 | import { Client } from '../client/index.js'; 5 | import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; 6 | import { McpServer } from '../server/mcp.js'; 7 | import { StreamableHTTPServerTransport } from '../server/streamableHttp.js'; 8 | import { CallToolResultSchema, LoggingMessageNotificationSchema } from '../types.js'; 9 | import { z } from 'zod'; 10 | import { InMemoryEventStore } from '../examples/shared/inMemoryEventStore.js'; 11 | 12 | 13 | 14 | describe('Transport resumability', () => { 15 | let server: Server; 16 | let mcpServer: McpServer; 17 | let serverTransport: StreamableHTTPServerTransport; 18 | let baseUrl: URL; 19 | let eventStore: InMemoryEventStore; 20 | 21 | beforeEach(async () => { 22 | // Create event store for resumability 23 | eventStore = new InMemoryEventStore(); 24 | 25 | // Create a simple MCP server 26 | mcpServer = new McpServer( 27 | { name: 'test-server', version: '1.0.0' }, 28 | { capabilities: { logging: {} } } 29 | ); 30 | 31 | // Add a simple notification tool that completes quickly 32 | mcpServer.tool( 33 | 'send-notification', 34 | 'Sends a single notification', 35 | { 36 | message: z.string().describe('Message to send').default('Test notification') 37 | }, 38 | async ({ message }, { sendNotification }) => { 39 | // Send notification immediately 40 | await sendNotification({ 41 | method: "notifications/message", 42 | params: { 43 | level: "info", 44 | data: message 45 | } 46 | }); 47 | 48 | return { 49 | content: [{ type: 'text', text: 'Notification sent' }] 50 | }; 51 | } 52 | ); 53 | 54 | // Add a long-running tool that sends multiple notifications 55 | mcpServer.tool( 56 | 'run-notifications', 57 | 'Sends multiple notifications over time', 58 | { 59 | count: z.number().describe('Number of notifications to send').default(10), 60 | interval: z.number().describe('Interval between notifications in ms').default(50) 61 | }, 62 | async ({ count, interval }, { sendNotification }) => { 63 | // Send notifications at specified intervals 64 | for (let i = 0; i < count; i++) { 65 | await sendNotification({ 66 | method: "notifications/message", 67 | params: { 68 | level: "info", 69 | data: `Notification ${i + 1} of ${count}` 70 | } 71 | }); 72 | 73 | // Wait for the specified interval before sending next notification 74 | if (i < count - 1) { 75 | await new Promise(resolve => setTimeout(resolve, interval)); 76 | } 77 | } 78 | 79 | return { 80 | content: [{ type: 'text', text: `Sent ${count} notifications` }] 81 | }; 82 | } 83 | ); 84 | 85 | // Create a transport with the event store 86 | serverTransport = new StreamableHTTPServerTransport({ 87 | sessionIdGenerator: () => randomUUID(), 88 | eventStore 89 | }); 90 | 91 | // Connect the transport to the MCP server 92 | await mcpServer.connect(serverTransport); 93 | 94 | // Create and start an HTTP server 95 | server = createServer(async (req, res) => { 96 | await serverTransport.handleRequest(req, res); 97 | }); 98 | 99 | // Start the server on a random port 100 | baseUrl = await new Promise((resolve) => { 101 | server.listen(0, '127.0.0.1', () => { 102 | const addr = server.address() as AddressInfo; 103 | resolve(new URL(`http://127.0.0.1:${addr.port}`)); 104 | }); 105 | }); 106 | }); 107 | 108 | afterEach(async () => { 109 | // Clean up resources 110 | await mcpServer.close().catch(() => { }); 111 | await serverTransport.close().catch(() => { }); 112 | server.close(); 113 | }); 114 | 115 | it('should store session ID when client connects', async () => { 116 | // Create and connect a client 117 | const client = new Client({ 118 | name: 'test-client', 119 | version: '1.0.0' 120 | }); 121 | 122 | const transport = new StreamableHTTPClientTransport(baseUrl); 123 | await client.connect(transport); 124 | 125 | // Verify session ID was generated 126 | expect(transport.sessionId).toBeDefined(); 127 | 128 | // Clean up 129 | await transport.close(); 130 | }); 131 | 132 | it('should have session ID functionality', async () => { 133 | // The ability to store a session ID when connecting 134 | const client = new Client({ 135 | name: 'test-client-reconnection', 136 | version: '1.0.0' 137 | }); 138 | 139 | const transport = new StreamableHTTPClientTransport(baseUrl); 140 | 141 | // Make sure the client can connect and get a session ID 142 | await client.connect(transport); 143 | expect(transport.sessionId).toBeDefined(); 144 | 145 | // Clean up 146 | await transport.close(); 147 | }); 148 | 149 | // This test demonstrates the capability to resume long-running tools 150 | // across client disconnection/reconnection 151 | it('should resume long-running notifications with lastEventId', async () => { 152 | // Create unique client ID for this test 153 | const clientId = 'test-client-long-running'; 154 | const notifications = []; 155 | let lastEventId: string | undefined; 156 | 157 | // Create first client 158 | const client1 = new Client({ 159 | id: clientId, 160 | name: 'test-client', 161 | version: '1.0.0' 162 | }); 163 | 164 | // Set up notification handler for first client 165 | client1.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { 166 | if (notification.method === 'notifications/message') { 167 | notifications.push(notification.params); 168 | } 169 | }); 170 | 171 | // Connect first client 172 | const transport1 = new StreamableHTTPClientTransport(baseUrl); 173 | await client1.connect(transport1); 174 | const sessionId = transport1.sessionId; 175 | expect(sessionId).toBeDefined(); 176 | 177 | // Start a long-running notification stream with tracking of lastEventId 178 | const onLastEventIdUpdate = jest.fn((eventId: string) => { 179 | lastEventId = eventId; 180 | }); 181 | expect(lastEventId).toBeUndefined(); 182 | // Start the notification tool with event tracking using request 183 | const toolPromise = client1.request({ 184 | method: 'tools/call', 185 | params: { 186 | name: 'run-notifications', 187 | arguments: { 188 | count: 3, 189 | interval: 10 190 | } 191 | } 192 | }, CallToolResultSchema, { 193 | resumptionToken: lastEventId, 194 | onresumptiontoken: onLastEventIdUpdate 195 | }); 196 | 197 | // Wait for some notifications to arrive (not all) - shorter wait time 198 | await new Promise(resolve => setTimeout(resolve, 20)); 199 | 200 | // Verify we received some notifications and lastEventId was updated 201 | expect(notifications.length).toBeGreaterThan(0); 202 | expect(notifications.length).toBeLessThan(4); 203 | expect(onLastEventIdUpdate).toHaveBeenCalled(); 204 | expect(lastEventId).toBeDefined(); 205 | 206 | 207 | // Disconnect first client without waiting for completion 208 | // When we close the connection, it will cause a ConnectionClosed error for 209 | // any in-progress requests, which is expected behavior 210 | await transport1.close(); 211 | // Save the promise so we can catch it after closing 212 | const catchPromise = toolPromise.catch(err => { 213 | // This error is expected - the connection was intentionally closed 214 | if (err?.code !== -32000) { // ConnectionClosed error code 215 | console.error("Unexpected error type during transport close:", err); 216 | } 217 | }); 218 | 219 | 220 | 221 | // Add a short delay to ensure clean disconnect before reconnecting 222 | await new Promise(resolve => setTimeout(resolve, 10)); 223 | 224 | // Wait for the rejection to be handled 225 | await catchPromise; 226 | 227 | 228 | // Create second client with same client ID 229 | const client2 = new Client({ 230 | id: clientId, 231 | name: 'test-client', 232 | version: '1.0.0' 233 | }); 234 | 235 | // Set up notification handler for second client 236 | client2.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { 237 | if (notification.method === 'notifications/message') { 238 | notifications.push(notification.params); 239 | } 240 | }); 241 | 242 | // Connect second client with same session ID 243 | const transport2 = new StreamableHTTPClientTransport(baseUrl, { 244 | sessionId 245 | }); 246 | await client2.connect(transport2); 247 | 248 | // Resume the notification stream using lastEventId 249 | // This is the key part - we're resuming the same long-running tool using lastEventId 250 | await client2.request({ 251 | method: 'tools/call', 252 | params: { 253 | name: 'run-notifications', 254 | arguments: { 255 | count: 1, 256 | interval: 5 257 | } 258 | } 259 | }, CallToolResultSchema, { 260 | resumptionToken: lastEventId, // Pass the lastEventId from the previous session 261 | onresumptiontoken: onLastEventIdUpdate 262 | }); 263 | 264 | // Verify we eventually received at leaset a few motifications 265 | expect(notifications.length).toBeGreaterThan(1); 266 | 267 | 268 | // Clean up 269 | await transport2.close(); 270 | 271 | }); 272 | }); -------------------------------------------------------------------------------- /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?(client: OAuthClientInformationFull): OAuthClientInformationFull | Promise; 20 | } -------------------------------------------------------------------------------- /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 | constructor( 8 | public readonly errorCode: string, 9 | message: string, 10 | public readonly errorUri?: string 11 | ) { 12 | super(message); 13 | this.name = this.constructor.name; 14 | } 15 | 16 | /** 17 | * Converts the error to a standard OAuth error response object 18 | */ 19 | toResponseObject(): OAuthErrorResponse { 20 | const response: OAuthErrorResponse = { 21 | error: this.errorCode, 22 | error_description: this.message 23 | }; 24 | 25 | if (this.errorUri) { 26 | response.error_uri = this.errorUri; 27 | } 28 | 29 | return response; 30 | } 31 | } 32 | 33 | /** 34 | * Invalid request error - The request is missing a required parameter, 35 | * includes an invalid parameter value, includes a parameter more than once, 36 | * or is otherwise malformed. 37 | */ 38 | export class InvalidRequestError extends OAuthError { 39 | constructor(message: string, errorUri?: string) { 40 | super("invalid_request", message, errorUri); 41 | } 42 | } 43 | 44 | /** 45 | * Invalid client error - Client authentication failed (e.g., unknown client, no client 46 | * authentication included, or unsupported authentication method). 47 | */ 48 | export class InvalidClientError extends OAuthError { 49 | constructor(message: string, errorUri?: string) { 50 | super("invalid_client", message, errorUri); 51 | } 52 | } 53 | 54 | /** 55 | * Invalid grant error - The provided authorization grant or refresh token is 56 | * invalid, expired, revoked, does not match the redirection URI used in the 57 | * authorization request, or was issued to another client. 58 | */ 59 | export class InvalidGrantError extends OAuthError { 60 | constructor(message: string, errorUri?: string) { 61 | super("invalid_grant", message, errorUri); 62 | } 63 | } 64 | 65 | /** 66 | * Unauthorized client error - The authenticated client is not authorized to use 67 | * this authorization grant type. 68 | */ 69 | export class UnauthorizedClientError extends OAuthError { 70 | constructor(message: string, errorUri?: string) { 71 | super("unauthorized_client", message, errorUri); 72 | } 73 | } 74 | 75 | /** 76 | * Unsupported grant type error - The authorization grant type is not supported 77 | * by the authorization server. 78 | */ 79 | export class UnsupportedGrantTypeError extends OAuthError { 80 | constructor(message: string, errorUri?: string) { 81 | super("unsupported_grant_type", message, errorUri); 82 | } 83 | } 84 | 85 | /** 86 | * Invalid scope error - The requested scope is invalid, unknown, malformed, or 87 | * exceeds the scope granted by the resource owner. 88 | */ 89 | export class InvalidScopeError extends OAuthError { 90 | constructor(message: string, errorUri?: string) { 91 | super("invalid_scope", message, errorUri); 92 | } 93 | } 94 | 95 | /** 96 | * Access denied error - The resource owner or authorization server denied the request. 97 | */ 98 | export class AccessDeniedError extends OAuthError { 99 | constructor(message: string, errorUri?: string) { 100 | super("access_denied", message, errorUri); 101 | } 102 | } 103 | 104 | /** 105 | * Server error - The authorization server encountered an unexpected condition 106 | * that prevented it from fulfilling the request. 107 | */ 108 | export class ServerError extends OAuthError { 109 | constructor(message: string, errorUri?: string) { 110 | super("server_error", message, errorUri); 111 | } 112 | } 113 | 114 | /** 115 | * Temporarily unavailable error - The authorization server is currently unable to 116 | * handle the request due to a temporary overloading or maintenance of the server. 117 | */ 118 | export class TemporarilyUnavailableError extends OAuthError { 119 | constructor(message: string, errorUri?: string) { 120 | super("temporarily_unavailable", message, errorUri); 121 | } 122 | } 123 | 124 | /** 125 | * Unsupported response type error - The authorization server does not support 126 | * obtaining an authorization code using this method. 127 | */ 128 | export class UnsupportedResponseTypeError extends OAuthError { 129 | constructor(message: string, errorUri?: string) { 130 | super("unsupported_response_type", message, errorUri); 131 | } 132 | } 133 | 134 | /** 135 | * Unsupported token type error - The authorization server does not support 136 | * the requested token type. 137 | */ 138 | export class UnsupportedTokenTypeError extends OAuthError { 139 | constructor(message: string, errorUri?: string) { 140 | super("unsupported_token_type", message, errorUri); 141 | } 142 | } 143 | 144 | /** 145 | * Invalid token error - The access token provided is expired, revoked, malformed, 146 | * or invalid for other reasons. 147 | */ 148 | export class InvalidTokenError extends OAuthError { 149 | constructor(message: string, errorUri?: string) { 150 | super("invalid_token", message, errorUri); 151 | } 152 | } 153 | 154 | /** 155 | * Method not allowed error - The HTTP method used is not allowed for this endpoint. 156 | * (Custom, non-standard error) 157 | */ 158 | export class MethodNotAllowedError extends OAuthError { 159 | constructor(message: string, errorUri?: string) { 160 | super("method_not_allowed", message, errorUri); 161 | } 162 | } 163 | 164 | /** 165 | * Too many requests error - Rate limit exceeded. 166 | * (Custom, non-standard error based on RFC 6585) 167 | */ 168 | export class TooManyRequestsError extends OAuthError { 169 | constructor(message: string, errorUri?: string) { 170 | super("too_many_requests", message, errorUri); 171 | } 172 | } 173 | 174 | /** 175 | * Invalid client metadata error - The client metadata is invalid. 176 | * (Custom error for dynamic client registration - RFC 7591) 177 | */ 178 | export class InvalidClientMetadataError extends OAuthError { 179 | constructor(message: string, errorUri?: string) { 180 | super("invalid_client_metadata", message, errorUri); 181 | } 182 | } 183 | 184 | /** 185 | * Insufficient scope error - The request requires higher privileges than provided by the access token. 186 | */ 187 | export class InsufficientScopeError extends OAuthError { 188 | constructor(message: string, errorUri?: string) { 189 | super("insufficient_scope", message, errorUri); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /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 { 8 | InvalidRequestError, 9 | InvalidClientError, 10 | InvalidScopeError, 11 | ServerError, 12 | TooManyRequestsError, 13 | OAuthError 14 | } from "../errors.js"; 15 | 16 | export type AuthorizationHandlerOptions = { 17 | provider: OAuthServerProvider; 18 | /** 19 | * Rate limiting configuration for the authorization endpoint. 20 | * Set to false to disable rate limiting for this endpoint. 21 | */ 22 | rateLimit?: Partial | false; 23 | }; 24 | 25 | // Parameters that must be validated in order to issue redirects. 26 | const ClientAuthorizationParamsSchema = z.object({ 27 | client_id: z.string(), 28 | redirect_uri: z.string().optional().refine((value) => value === undefined || URL.canParse(value), { message: "redirect_uri must be a valid URL" }), 29 | }); 30 | 31 | // Parameters that must be validated for a successful authorization request. Failure can be reported to the redirect URI. 32 | const RequestAuthorizationParamsSchema = z.object({ 33 | response_type: z.literal("code"), 34 | code_challenge: z.string(), 35 | code_challenge_method: z.literal("S256"), 36 | scope: z.string().optional(), 37 | state: z.string().optional(), 38 | }); 39 | 40 | export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler { 41 | // Create a router to apply middleware 42 | const router = express.Router(); 43 | router.use(allowedMethods(["GET", "POST"])); 44 | router.use(express.urlencoded({ extended: false })); 45 | 46 | // Apply rate limiting unless explicitly disabled 47 | if (rateLimitConfig !== false) { 48 | router.use(rateLimit({ 49 | windowMs: 15 * 60 * 1000, // 15 minutes 50 | max: 100, // 100 requests per windowMs 51 | standardHeaders: true, 52 | legacyHeaders: false, 53 | message: new TooManyRequestsError('You have exceeded the rate limit for authorization requests').toResponseObject(), 54 | ...rateLimitConfig 55 | })); 56 | } 57 | 58 | router.all("/", async (req, res) => { 59 | res.setHeader('Cache-Control', 'no-store'); 60 | 61 | // In the authorization flow, errors are split into two categories: 62 | // 1. Pre-redirect errors (direct response with 400) 63 | // 2. Post-redirect errors (redirect with error parameters) 64 | 65 | // Phase 1: Validate client_id and redirect_uri. Any errors here must be direct responses. 66 | let client_id, redirect_uri, client; 67 | try { 68 | const result = ClientAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query); 69 | if (!result.success) { 70 | throw new InvalidRequestError(result.error.message); 71 | } 72 | 73 | client_id = result.data.client_id; 74 | redirect_uri = result.data.redirect_uri; 75 | 76 | client = await provider.clientsStore.getClient(client_id); 77 | if (!client) { 78 | throw new InvalidClientError("Invalid client_id"); 79 | } 80 | 81 | if (redirect_uri !== undefined) { 82 | if (!client.redirect_uris.includes(redirect_uri)) { 83 | throw new InvalidRequestError("Unregistered redirect_uri"); 84 | } 85 | } else if (client.redirect_uris.length === 1) { 86 | redirect_uri = client.redirect_uris[0]; 87 | } else { 88 | throw new InvalidRequestError("redirect_uri must be specified when client has multiple registered URIs"); 89 | } 90 | } catch (error) { 91 | // Pre-redirect errors - return direct response 92 | // 93 | // These don't need to be JSON encoded, as they'll be displayed in a user 94 | // agent, but OTOH they all represent exceptional situations (arguably, 95 | // "programmer error"), so presenting a nice HTML page doesn't help the 96 | // user anyway. 97 | if (error instanceof OAuthError) { 98 | const status = error instanceof ServerError ? 500 : 400; 99 | res.status(status).json(error.toResponseObject()); 100 | } else { 101 | console.error("Unexpected error looking up client:", error); 102 | const serverError = new ServerError("Internal Server Error"); 103 | res.status(500).json(serverError.toResponseObject()); 104 | } 105 | 106 | return; 107 | } 108 | 109 | // Phase 2: Validate other parameters. Any errors here should go into redirect responses. 110 | let state; 111 | try { 112 | // Parse and validate authorization parameters 113 | const parseResult = RequestAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query); 114 | if (!parseResult.success) { 115 | throw new InvalidRequestError(parseResult.error.message); 116 | } 117 | 118 | const { scope, code_challenge } = parseResult.data; 119 | state = parseResult.data.state; 120 | 121 | // Validate scopes 122 | let requestedScopes: string[] = []; 123 | if (scope !== undefined) { 124 | requestedScopes = scope.split(" "); 125 | const allowedScopes = new Set(client.scope?.split(" ")); 126 | 127 | // Check each requested scope against allowed scopes 128 | for (const scope of requestedScopes) { 129 | if (!allowedScopes.has(scope)) { 130 | throw new InvalidScopeError(`Client was not registered with scope ${scope}`); 131 | } 132 | } 133 | } 134 | 135 | // All validation passed, proceed with authorization 136 | await provider.authorize(client, { 137 | state, 138 | scopes: requestedScopes, 139 | redirectUri: redirect_uri, 140 | codeChallenge: code_challenge, 141 | }, res); 142 | } catch (error) { 143 | // Post-redirect errors - redirect with error parameters 144 | if (error instanceof OAuthError) { 145 | res.redirect(302, createErrorRedirect(redirect_uri, error, state)); 146 | } else { 147 | console.error("Unexpected error during authorization:", error); 148 | const serverError = new ServerError("Internal Server Error"); 149 | res.redirect(302, createErrorRedirect(redirect_uri, serverError, state)); 150 | } 151 | } 152 | }); 153 | 154 | return router; 155 | } 156 | 157 | /** 158 | * Helper function to create redirect URL with error parameters 159 | */ 160 | function createErrorRedirect(redirectUri: string, error: OAuthError, state?: string): string { 161 | const errorUrl = new URL(redirectUri); 162 | errorUrl.searchParams.set("error", error.errorCode); 163 | errorUrl.searchParams.set("error_description", error.message); 164 | if (error.errorUri) { 165 | errorUrl.searchParams.set("error_uri", error.errorUri); 166 | } 167 | if (state) { 168 | errorUrl.searchParams.set("state", state); 169 | } 170 | return errorUrl.href; 171 | } -------------------------------------------------------------------------------- /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) 30 | .post('/.well-known/oauth-authorization-server') 31 | .send({}); 32 | 33 | expect(response.status).toBe(405); 34 | expect(response.headers.allow).toBe('GET'); 35 | expect(response.body).toEqual({ 36 | error: "method_not_allowed", 37 | error_description: "The method POST is not allowed for this endpoint" 38 | }); 39 | }); 40 | 41 | it('returns the metadata object', async () => { 42 | const response = await supertest(app) 43 | .get('/.well-known/oauth-authorization-server'); 44 | 45 | expect(response.status).toBe(200); 46 | expect(response.body).toEqual(exampleMetadata); 47 | }); 48 | 49 | it('includes CORS headers in response', async () => { 50 | const response = await supertest(app) 51 | .get('/.well-known/oauth-authorization-server') 52 | .set('Origin', 'https://example.com'); 53 | 54 | expect(response.header['access-control-allow-origin']).toBe('*'); 55 | }); 56 | 57 | it('supports OPTIONS preflight requests', async () => { 58 | const response = await supertest(app) 59 | .options('/.well-known/oauth-authorization-server') 60 | .set('Origin', 'https://example.com') 61 | .set('Access-Control-Request-Method', 'GET'); 62 | 63 | expect(response.status).toBe(204); 64 | expect(response.header['access-control-allow-origin']).toBe('*'); 65 | }); 66 | 67 | it('works with minimal metadata', async () => { 68 | // Setup a new express app with minimal metadata 69 | const minimalApp = express(); 70 | const minimalMetadata: OAuthMetadata = { 71 | issuer: 'https://auth.example.com', 72 | authorization_endpoint: 'https://auth.example.com/authorize', 73 | token_endpoint: 'https://auth.example.com/token', 74 | response_types_supported: ['code'] 75 | }; 76 | minimalApp.use('/.well-known/oauth-authorization-server', metadataHandler(minimalMetadata)); 77 | 78 | const response = await supertest(minimalApp) 79 | .get('/.well-known/oauth-authorization-server'); 80 | 81 | expect(response.status).toBe(200); 82 | expect(response.body).toEqual(minimalMetadata); 83 | }); 84 | }); -------------------------------------------------------------------------------- /src/server/auth/handlers/metadata.ts: -------------------------------------------------------------------------------- 1 | import express, { RequestHandler } from "express"; 2 | import { OAuthMetadata } from "../../../shared/auth.js"; 3 | import cors from 'cors'; 4 | import { allowedMethods } from "../middleware/allowedMethods.js"; 5 | 6 | export function metadataHandler(metadata: OAuthMetadata): 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'])); 14 | router.get("/", (req, res) => { 15 | res.status(200).json(metadata); 16 | }); 17 | 18 | return router; 19 | } -------------------------------------------------------------------------------- /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 { 9 | InvalidClientMetadataError, 10 | ServerError, 11 | TooManyRequestsError, 12 | OAuthError 13 | } from "../errors.js"; 14 | 15 | export type ClientRegistrationHandlerOptions = { 16 | /** 17 | * A store used to save information about dynamically registered OAuth clients. 18 | */ 19 | clientsStore: OAuthRegisteredClientsStore; 20 | 21 | /** 22 | * The number of seconds after which to expire issued client secrets, or 0 to prevent expiration of client secrets (not recommended). 23 | * 24 | * If not set, defaults to 30 days. 25 | */ 26 | clientSecretExpirySeconds?: number; 27 | 28 | /** 29 | * Rate limiting configuration for the client registration endpoint. 30 | * Set to false to disable rate limiting for this endpoint. 31 | * Registration endpoints are particularly sensitive to abuse and should be rate limited. 32 | */ 33 | rateLimit?: Partial | false; 34 | }; 35 | 36 | const DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days 37 | 38 | export function clientRegistrationHandler({ 39 | clientsStore, 40 | clientSecretExpirySeconds = DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS, 41 | rateLimit: rateLimitConfig 42 | }: ClientRegistrationHandlerOptions): RequestHandler { 43 | if (!clientsStore.registerClient) { 44 | throw new Error("Client registration store does not support registering clients"); 45 | } 46 | 47 | // Nested router so we can configure middleware and restrict HTTP method 48 | const router = express.Router(); 49 | 50 | // Configure CORS to allow any origin, to make accessible to web-based MCP clients 51 | router.use(cors()); 52 | 53 | router.use(allowedMethods(["POST"])); 54 | router.use(express.json()); 55 | 56 | // Apply rate limiting unless explicitly disabled - stricter limits for registration 57 | if (rateLimitConfig !== false) { 58 | router.use(rateLimit({ 59 | windowMs: 60 * 60 * 1000, // 1 hour 60 | max: 20, // 20 requests per hour - stricter as registration is sensitive 61 | standardHeaders: true, 62 | legacyHeaders: false, 63 | message: new TooManyRequestsError('You have exceeded the rate limit for client registration requests').toResponseObject(), 64 | ...rateLimitConfig 65 | })); 66 | } 67 | 68 | router.post("/", async (req, res) => { 69 | res.setHeader('Cache-Control', 'no-store'); 70 | 71 | try { 72 | const parseResult = OAuthClientMetadataSchema.safeParse(req.body); 73 | if (!parseResult.success) { 74 | throw new InvalidClientMetadataError(parseResult.error.message); 75 | } 76 | 77 | const clientMetadata = parseResult.data; 78 | const isPublicClient = clientMetadata.token_endpoint_auth_method === 'none' 79 | 80 | // Generate client credentials 81 | const clientId = crypto.randomUUID(); 82 | const clientSecret = isPublicClient 83 | ? undefined 84 | : crypto.randomBytes(32).toString('hex'); 85 | const clientIdIssuedAt = Math.floor(Date.now() / 1000); 86 | 87 | // Calculate client secret expiry time 88 | const clientsDoExpire = clientSecretExpirySeconds > 0 89 | const secretExpiryTime = clientsDoExpire ? clientIdIssuedAt + clientSecretExpirySeconds : 0 90 | const clientSecretExpiresAt = isPublicClient ? undefined : secretExpiryTime 91 | 92 | let clientInfo: OAuthClientInformationFull = { 93 | ...clientMetadata, 94 | client_id: clientId, 95 | client_secret: clientSecret, 96 | client_id_issued_at: clientIdIssuedAt, 97 | client_secret_expires_at: clientSecretExpiresAt, 98 | }; 99 | 100 | clientInfo = await clientsStore.registerClient!(clientInfo); 101 | res.status(201).json(clientInfo); 102 | } catch (error) { 103 | if (error instanceof OAuthError) { 104 | const status = error instanceof ServerError ? 500 : 400; 105 | res.status(status).json(error.toResponseObject()); 106 | } else { 107 | console.error("Unexpected error registering client:", error); 108 | const serverError = new ServerError("Internal Server Error"); 109 | res.status(500).json(serverError.toResponseObject()); 110 | } 111 | } 112 | }); 113 | 114 | return router; 115 | } -------------------------------------------------------------------------------- /src/server/auth/handlers/revoke.test.ts: -------------------------------------------------------------------------------- 1 | import { revocationHandler, RevocationHandlerOptions } from './revoke.js'; 2 | import { OAuthServerProvider, AuthorizationParams } from '../provider.js'; 3 | import { OAuthRegisteredClientsStore } from '../clients.js'; 4 | import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../../shared/auth.js'; 5 | import express, { Response } from 'express'; 6 | import supertest from 'supertest'; 7 | import { AuthInfo } from '../types.js'; 8 | import { InvalidTokenError } from '../errors.js'; 9 | 10 | describe('Revocation Handler', () => { 11 | // Mock client data 12 | const validClient: OAuthClientInformationFull = { 13 | client_id: 'valid-client', 14 | client_secret: 'valid-secret', 15 | redirect_uris: ['https://example.com/callback'] 16 | }; 17 | 18 | // Mock client store 19 | const mockClientStore: OAuthRegisteredClientsStore = { 20 | async getClient(clientId: string): Promise { 21 | if (clientId === 'valid-client') { 22 | return validClient; 23 | } 24 | return undefined; 25 | } 26 | }; 27 | 28 | // Mock provider with revocation capability 29 | const mockProviderWithRevocation: OAuthServerProvider = { 30 | clientsStore: mockClientStore, 31 | 32 | async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { 33 | res.redirect('https://example.com/callback?code=mock_auth_code'); 34 | }, 35 | 36 | async challengeForAuthorizationCode(): Promise { 37 | return 'mock_challenge'; 38 | }, 39 | 40 | async exchangeAuthorizationCode(): Promise { 41 | return { 42 | access_token: 'mock_access_token', 43 | token_type: 'bearer', 44 | expires_in: 3600, 45 | refresh_token: 'mock_refresh_token' 46 | }; 47 | }, 48 | 49 | async exchangeRefreshToken(): Promise { 50 | return { 51 | access_token: 'new_mock_access_token', 52 | token_type: 'bearer', 53 | expires_in: 3600, 54 | refresh_token: 'new_mock_refresh_token' 55 | }; 56 | }, 57 | 58 | async verifyAccessToken(token: string): Promise { 59 | if (token === 'valid_token') { 60 | return { 61 | token, 62 | clientId: 'valid-client', 63 | scopes: ['read', 'write'], 64 | expiresAt: Date.now() / 1000 + 3600 65 | }; 66 | } 67 | throw new InvalidTokenError('Token is invalid or expired'); 68 | }, 69 | 70 | async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { 71 | // Success - do nothing in mock 72 | } 73 | }; 74 | 75 | // Mock provider without revocation capability 76 | const mockProviderWithoutRevocation: OAuthServerProvider = { 77 | clientsStore: mockClientStore, 78 | 79 | async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { 80 | res.redirect('https://example.com/callback?code=mock_auth_code'); 81 | }, 82 | 83 | async challengeForAuthorizationCode(): Promise { 84 | return 'mock_challenge'; 85 | }, 86 | 87 | async exchangeAuthorizationCode(): Promise { 88 | return { 89 | access_token: 'mock_access_token', 90 | token_type: 'bearer', 91 | expires_in: 3600, 92 | refresh_token: 'mock_refresh_token' 93 | }; 94 | }, 95 | 96 | async exchangeRefreshToken(): Promise { 97 | return { 98 | access_token: 'new_mock_access_token', 99 | token_type: 'bearer', 100 | expires_in: 3600, 101 | refresh_token: 'new_mock_refresh_token' 102 | }; 103 | }, 104 | 105 | async verifyAccessToken(token: string): Promise { 106 | if (token === 'valid_token') { 107 | return { 108 | token, 109 | clientId: 'valid-client', 110 | scopes: ['read', 'write'], 111 | expiresAt: Date.now() / 1000 + 3600 112 | }; 113 | } 114 | throw new InvalidTokenError('Token is invalid or expired'); 115 | } 116 | // No revokeToken method 117 | }; 118 | 119 | describe('Handler creation', () => { 120 | it('throws error if provider does not support token revocation', () => { 121 | const options: RevocationHandlerOptions = { provider: mockProviderWithoutRevocation }; 122 | expect(() => revocationHandler(options)).toThrow('does not support revoking tokens'); 123 | }); 124 | 125 | it('creates handler if provider supports token revocation', () => { 126 | const options: RevocationHandlerOptions = { provider: mockProviderWithRevocation }; 127 | expect(() => revocationHandler(options)).not.toThrow(); 128 | }); 129 | }); 130 | 131 | describe('Request handling', () => { 132 | let app: express.Express; 133 | let spyRevokeToken: jest.SpyInstance; 134 | 135 | beforeEach(() => { 136 | // Setup express app with revocation handler 137 | app = express(); 138 | const options: RevocationHandlerOptions = { provider: mockProviderWithRevocation }; 139 | app.use('/revoke', revocationHandler(options)); 140 | 141 | // Spy on the revokeToken method 142 | spyRevokeToken = jest.spyOn(mockProviderWithRevocation, 'revokeToken'); 143 | }); 144 | 145 | afterEach(() => { 146 | spyRevokeToken.mockRestore(); 147 | }); 148 | 149 | it('requires POST method', async () => { 150 | const response = await supertest(app) 151 | .get('/revoke') 152 | .send({ 153 | client_id: 'valid-client', 154 | client_secret: 'valid-secret', 155 | token: 'token_to_revoke' 156 | }); 157 | 158 | expect(response.status).toBe(405); 159 | expect(response.headers.allow).toBe('POST'); 160 | expect(response.body).toEqual({ 161 | error: "method_not_allowed", 162 | error_description: "The method GET is not allowed for this endpoint" 163 | }); 164 | expect(spyRevokeToken).not.toHaveBeenCalled(); 165 | }); 166 | 167 | it('requires token parameter', async () => { 168 | const response = await supertest(app) 169 | .post('/revoke') 170 | .type('form') 171 | .send({ 172 | client_id: 'valid-client', 173 | client_secret: 'valid-secret' 174 | // Missing token 175 | }); 176 | 177 | expect(response.status).toBe(400); 178 | expect(response.body.error).toBe('invalid_request'); 179 | expect(spyRevokeToken).not.toHaveBeenCalled(); 180 | }); 181 | 182 | it('authenticates client before revoking token', async () => { 183 | const response = await supertest(app) 184 | .post('/revoke') 185 | .type('form') 186 | .send({ 187 | client_id: 'invalid-client', 188 | client_secret: 'wrong-secret', 189 | token: 'token_to_revoke' 190 | }); 191 | 192 | expect(response.status).toBe(400); 193 | expect(response.body.error).toBe('invalid_client'); 194 | expect(spyRevokeToken).not.toHaveBeenCalled(); 195 | }); 196 | 197 | it('successfully revokes token', async () => { 198 | const response = await supertest(app) 199 | .post('/revoke') 200 | .type('form') 201 | .send({ 202 | client_id: 'valid-client', 203 | client_secret: 'valid-secret', 204 | token: 'token_to_revoke' 205 | }); 206 | 207 | expect(response.status).toBe(200); 208 | expect(response.body).toEqual({}); // Empty response on success 209 | expect(spyRevokeToken).toHaveBeenCalledTimes(1); 210 | expect(spyRevokeToken).toHaveBeenCalledWith(validClient, { 211 | token: 'token_to_revoke' 212 | }); 213 | }); 214 | 215 | it('accepts optional token_type_hint', async () => { 216 | const response = await supertest(app) 217 | .post('/revoke') 218 | .type('form') 219 | .send({ 220 | client_id: 'valid-client', 221 | client_secret: 'valid-secret', 222 | token: 'token_to_revoke', 223 | token_type_hint: 'refresh_token' 224 | }); 225 | 226 | expect(response.status).toBe(200); 227 | expect(spyRevokeToken).toHaveBeenCalledWith(validClient, { 228 | token: 'token_to_revoke', 229 | token_type_hint: 'refresh_token' 230 | }); 231 | }); 232 | 233 | it('includes CORS headers in response', async () => { 234 | const response = await supertest(app) 235 | .post('/revoke') 236 | .type('form') 237 | .set('Origin', 'https://example.com') 238 | .send({ 239 | client_id: 'valid-client', 240 | client_secret: 'valid-secret', 241 | token: 'token_to_revoke' 242 | }); 243 | 244 | expect(response.header['access-control-allow-origin']).toBe('*'); 245 | }); 246 | }); 247 | }); -------------------------------------------------------------------------------- /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 { 9 | InvalidRequestError, 10 | ServerError, 11 | TooManyRequestsError, 12 | OAuthError 13 | } from "../errors.js"; 14 | 15 | export type RevocationHandlerOptions = { 16 | provider: OAuthServerProvider; 17 | /** 18 | * Rate limiting configuration for the token revocation endpoint. 19 | * Set to false to disable rate limiting for this endpoint. 20 | */ 21 | rateLimit?: Partial | false; 22 | }; 23 | 24 | export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler { 25 | if (!provider.revokeToken) { 26 | throw new Error("Auth provider does not support revoking tokens"); 27 | } 28 | 29 | // Nested router so we can configure middleware and restrict HTTP method 30 | const router = express.Router(); 31 | 32 | // Configure CORS to allow any origin, to make accessible to web-based MCP clients 33 | router.use(cors()); 34 | 35 | router.use(allowedMethods(["POST"])); 36 | router.use(express.urlencoded({ extended: false })); 37 | 38 | // Apply rate limiting unless explicitly disabled 39 | if (rateLimitConfig !== false) { 40 | router.use(rateLimit({ 41 | windowMs: 15 * 60 * 1000, // 15 minutes 42 | max: 50, // 50 requests per windowMs 43 | standardHeaders: true, 44 | legacyHeaders: false, 45 | message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(), 46 | ...rateLimitConfig 47 | })); 48 | } 49 | 50 | // Authenticate and extract client details 51 | router.use(authenticateClient({ clientsStore: provider.clientsStore })); 52 | 53 | router.post("/", async (req, res) => { 54 | res.setHeader('Cache-Control', 'no-store'); 55 | 56 | try { 57 | const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body); 58 | if (!parseResult.success) { 59 | throw new InvalidRequestError(parseResult.error.message); 60 | } 61 | 62 | const client = req.client; 63 | if (!client) { 64 | // This should never happen 65 | console.error("Missing client information after authentication"); 66 | throw new ServerError("Internal Server Error"); 67 | } 68 | 69 | await provider.revokeToken!(client, parseResult.data); 70 | res.status(200).json({}); 71 | } catch (error) { 72 | if (error instanceof OAuthError) { 73 | const status = error instanceof ServerError ? 500 : 400; 74 | res.status(status).json(error.toResponseObject()); 75 | } else { 76 | console.error("Unexpected error revoking token:", error); 77 | const serverError = new ServerError("Internal Server Error"); 78 | res.status(500).json(serverError.toResponseObject()); 79 | } 80 | } 81 | }); 82 | 83 | return router; 84 | } 85 | -------------------------------------------------------------------------------- /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 | }); 35 | 36 | const RefreshTokenGrantSchema = z.object({ 37 | refresh_token: z.string(), 38 | scope: z.string().optional(), 39 | }); 40 | 41 | export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler { 42 | // Nested router so we can configure middleware and restrict HTTP method 43 | const router = express.Router(); 44 | 45 | // Configure CORS to allow any origin, to make accessible to web-based MCP clients 46 | router.use(cors()); 47 | 48 | router.use(allowedMethods(["POST"])); 49 | router.use(express.urlencoded({ extended: false })); 50 | 51 | // Apply rate limiting unless explicitly disabled 52 | if (rateLimitConfig !== false) { 53 | router.use(rateLimit({ 54 | windowMs: 15 * 60 * 1000, // 15 minutes 55 | max: 50, // 50 requests per windowMs 56 | standardHeaders: true, 57 | legacyHeaders: false, 58 | message: new TooManyRequestsError('You have exceeded the rate limit for token requests').toResponseObject(), 59 | ...rateLimitConfig 60 | })); 61 | } 62 | 63 | // Authenticate and extract client details 64 | router.use(authenticateClient({ clientsStore: provider.clientsStore })); 65 | 66 | router.post("/", async (req, res) => { 67 | res.setHeader('Cache-Control', 'no-store'); 68 | 69 | try { 70 | const parseResult = TokenRequestSchema.safeParse(req.body); 71 | if (!parseResult.success) { 72 | throw new InvalidRequestError(parseResult.error.message); 73 | } 74 | 75 | const { grant_type } = parseResult.data; 76 | 77 | const client = req.client; 78 | if (!client) { 79 | // This should never happen 80 | console.error("Missing client information after authentication"); 81 | throw new ServerError("Internal Server Error"); 82 | } 83 | 84 | switch (grant_type) { 85 | case "authorization_code": { 86 | const parseResult = AuthorizationCodeGrantSchema.safeParse(req.body); 87 | if (!parseResult.success) { 88 | throw new InvalidRequestError(parseResult.error.message); 89 | } 90 | 91 | const { code, code_verifier } = parseResult.data; 92 | 93 | const skipLocalPkceValidation = provider.skipLocalPkceValidation; 94 | 95 | // Perform local PKCE validation unless explicitly skipped 96 | // (e.g. to validate code_verifier in upstream server) 97 | if (!skipLocalPkceValidation) { 98 | const codeChallenge = await provider.challengeForAuthorizationCode(client, code); 99 | if (!(await verifyChallenge(code_verifier, codeChallenge))) { 100 | throw new InvalidGrantError("code_verifier does not match the challenge"); 101 | } 102 | } 103 | 104 | // Passes the code_verifier to the provider if PKCE validation didn't occur locally 105 | const tokens = await provider.exchangeAuthorizationCode(client, code, skipLocalPkceValidation ? code_verifier : undefined); 106 | res.status(200).json(tokens); 107 | break; 108 | } 109 | 110 | case "refresh_token": { 111 | const parseResult = RefreshTokenGrantSchema.safeParse(req.body); 112 | if (!parseResult.success) { 113 | throw new InvalidRequestError(parseResult.error.message); 114 | } 115 | 116 | const { refresh_token, scope } = parseResult.data; 117 | 118 | const scopes = scope?.split(" "); 119 | const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes); 120 | res.status(200).json(tokens); 121 | break; 122 | } 123 | 124 | // Not supported right now 125 | //case "client_credentials": 126 | 127 | default: 128 | throw new UnsupportedGrantTypeError( 129 | "The grant type is not supported by this authorization server." 130 | ); 131 | } 132 | } catch (error) { 133 | if (error instanceof OAuthError) { 134 | const status = error instanceof ServerError ? 500 : 400; 135 | res.status(status).json(error.toResponseObject()); 136 | } else { 137 | console.error("Unexpected error exchanging token:", error); 138 | const serverError = new ServerError("Internal Server Error"); 139 | res.status(500).json(serverError.toResponseObject()); 140 | } 141 | } 142 | }); 143 | 144 | return router; 145 | } -------------------------------------------------------------------------------- /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 | }); -------------------------------------------------------------------------------- /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) 19 | .set('Allow', allowedMethods.join(', ')) 20 | .json(error.toResponseObject()); 21 | }; 22 | } -------------------------------------------------------------------------------- /src/server/auth/middleware/bearerAuth.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from "express"; 2 | import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; 3 | import { OAuthServerProvider } from "../provider.js"; 4 | import { AuthInfo } from "../types.js"; 5 | 6 | export type BearerAuthMiddlewareOptions = { 7 | /** 8 | * A provider used to verify tokens. 9 | */ 10 | provider: OAuthServerProvider; 11 | 12 | /** 13 | * Optional scopes that the token must have. 14 | */ 15 | requiredScopes?: string[]; 16 | }; 17 | 18 | declare module "express-serve-static-core" { 19 | interface Request { 20 | /** 21 | * Information about the validated access token, if the `requireBearerAuth` middleware was used. 22 | */ 23 | auth?: AuthInfo; 24 | } 25 | } 26 | 27 | /** 28 | * Middleware that requires a valid Bearer token in the Authorization header. 29 | * 30 | * This will validate the token with the auth provider and add the resulting auth info to the request object. 31 | */ 32 | export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthMiddlewareOptions): RequestHandler { 33 | return async (req, res, next) => { 34 | try { 35 | const authHeader = req.headers.authorization; 36 | if (!authHeader) { 37 | throw new InvalidTokenError("Missing Authorization header"); 38 | } 39 | 40 | const [type, token] = authHeader.split(' '); 41 | if (type.toLowerCase() !== 'bearer' || !token) { 42 | throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'"); 43 | } 44 | 45 | const authInfo = await provider.verifyAccessToken(token); 46 | 47 | // Check if token has the required scopes (if any) 48 | if (requiredScopes.length > 0) { 49 | const hasAllScopes = requiredScopes.every(scope => 50 | authInfo.scopes.includes(scope) 51 | ); 52 | 53 | if (!hasAllScopes) { 54 | throw new InsufficientScopeError("Insufficient scope"); 55 | } 56 | } 57 | 58 | // Check if the token is expired 59 | if (!!authInfo.expiresAt && authInfo.expiresAt < Date.now() / 1000) { 60 | throw new InvalidTokenError("Token has expired"); 61 | } 62 | 63 | req.auth = authInfo; 64 | next(); 65 | } catch (error) { 66 | if (error instanceof InvalidTokenError) { 67 | res.set("WWW-Authenticate", `Bearer error="${error.errorCode}", error_description="${error.message}"`); 68 | res.status(401).json(error.toResponseObject()); 69 | } else if (error instanceof InsufficientScopeError) { 70 | res.set("WWW-Authenticate", `Bearer error="${error.errorCode}", error_description="${error.message}"`); 71 | res.status(403).json(error.toResponseObject()); 72 | } else if (error instanceof ServerError) { 73 | res.status(500).json(error.toResponseObject()); 74 | } else if (error instanceof OAuthError) { 75 | res.status(400).json(error.toResponseObject()); 76 | } else { 77 | console.error("Unexpected error authenticating bearer token:", error); 78 | const serverError = new ServerError("Internal Server Error"); 79 | res.status(500).json(serverError.toResponseObject()); 80 | } 81 | } 82 | }; 83 | } -------------------------------------------------------------------------------- /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) 56 | .post('/protected') 57 | .send({ 58 | client_id: 'valid-client', 59 | client_secret: 'valid-secret' 60 | }); 61 | 62 | expect(response.status).toBe(200); 63 | expect(response.body.success).toBe(true); 64 | expect(response.body.client.client_id).toBe('valid-client'); 65 | }); 66 | 67 | it('rejects invalid client_id', async () => { 68 | const response = await supertest(app) 69 | .post('/protected') 70 | .send({ 71 | client_id: 'non-existent-client', 72 | client_secret: 'some-secret' 73 | }); 74 | 75 | expect(response.status).toBe(400); 76 | expect(response.body.error).toBe('invalid_client'); 77 | expect(response.body.error_description).toBe('Invalid client_id'); 78 | }); 79 | 80 | it('rejects invalid client_secret', async () => { 81 | const response = await supertest(app) 82 | .post('/protected') 83 | .send({ 84 | client_id: 'valid-client', 85 | client_secret: 'wrong-secret' 86 | }); 87 | 88 | expect(response.status).toBe(400); 89 | expect(response.body.error).toBe('invalid_client'); 90 | expect(response.body.error_description).toBe('Invalid client_secret'); 91 | }); 92 | 93 | it('rejects missing client_id', async () => { 94 | const response = await supertest(app) 95 | .post('/protected') 96 | .send({ 97 | client_secret: 'valid-secret' 98 | }); 99 | 100 | expect(response.status).toBe(400); 101 | expect(response.body.error).toBe('invalid_request'); 102 | }); 103 | 104 | it('allows missing client_secret if client has none', async () => { 105 | const response = await supertest(app) 106 | .post('/protected') 107 | .send({ 108 | client_id: 'expired-client' 109 | }); 110 | 111 | // Since the client has no secret, this should pass without providing one 112 | expect(response.status).toBe(200); 113 | }); 114 | 115 | it('rejects request when client secret has expired', async () => { 116 | const response = await supertest(app) 117 | .post('/protected') 118 | .send({ 119 | client_id: 'client-with-expired-secret', 120 | client_secret: 'expired-secret' 121 | }); 122 | 123 | expect(response.status).toBe(400); 124 | expect(response.body.error).toBe('invalid_client'); 125 | expect(response.body.error_description).toBe('Client secret has expired'); 126 | }); 127 | 128 | it('handles malformed request body', async () => { 129 | const response = await supertest(app) 130 | .post('/protected') 131 | .send('not-json-format'); 132 | 133 | expect(response.status).toBe(400); 134 | }); 135 | 136 | // Testing request with extra fields to ensure they're ignored 137 | it('ignores extra fields in request', async () => { 138 | const response = await supertest(app) 139 | .post('/protected') 140 | .send({ 141 | client_id: 'valid-client', 142 | client_secret: 'valid-secret', 143 | extra_field: 'should be ignored' 144 | }); 145 | 146 | expect(response.status).toBe(200); 147 | }); 148 | }); -------------------------------------------------------------------------------- /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 | console.error("Unexpected error authenticating client:", error); 68 | const serverError = new ServerError("Internal Server Error"); 69 | res.status(500).json(serverError.toResponseObject()); 70 | } 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /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 | }; 12 | 13 | /** 14 | * Implements an end-to-end OAuth server. 15 | */ 16 | export interface OAuthServerProvider { 17 | /** 18 | * A store used to read information about registered OAuth clients. 19 | */ 20 | get clientsStore(): OAuthRegisteredClientsStore; 21 | 22 | /** 23 | * Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server. 24 | * 25 | * This server must eventually issue a redirect with an authorization response or an error response to the given redirect URI. Per OAuth 2.1: 26 | * - In the successful case, the redirect MUST include the `code` and `state` (if present) query parameters. 27 | * - In the error case, the redirect MUST include the `error` query parameter, and MAY include an optional `error_description` query parameter. 28 | */ 29 | authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise; 30 | 31 | /** 32 | * Returns the `codeChallenge` that was used when the indicated authorization began. 33 | */ 34 | challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise; 35 | 36 | /** 37 | * Exchanges an authorization code for an access token. 38 | */ 39 | exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string): Promise; 40 | 41 | /** 42 | * Exchanges a refresh token for an access token. 43 | */ 44 | exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise; 45 | 46 | /** 47 | * Verifies an access token and returns information about it. 48 | */ 49 | verifyAccessToken(token: string): Promise; 50 | 51 | /** 52 | * Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended). 53 | * 54 | * If the given token is invalid or already revoked, this method should do nothing. 55 | */ 56 | revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise; 57 | 58 | /** 59 | * Whether to skip local PKCE validation. 60 | * 61 | * If true, the server will not perform PKCE validation locally and will pass the code_verifier to the upstream server. 62 | * 63 | * NOTE: This should only be true if the upstream server is performing the actual PKCE validation. 64 | */ 65 | skipLocalPkceValidation?: boolean; 66 | } -------------------------------------------------------------------------------- /src/server/auth/providers/proxyProvider.ts: -------------------------------------------------------------------------------- 1 | import { Response } from "express"; 2 | import { OAuthRegisteredClientsStore } from "../clients.js"; 3 | import { 4 | OAuthClientInformationFull, 5 | OAuthClientInformationFullSchema, 6 | OAuthTokenRevocationRequest, 7 | OAuthTokens, 8 | OAuthTokensSchema, 9 | } from "../../../shared/auth.js"; 10 | import { AuthInfo } from "../types.js"; 11 | import { AuthorizationParams, OAuthServerProvider } from "../provider.js"; 12 | import { ServerError } from "../errors.js"; 13 | 14 | export type ProxyEndpoints = { 15 | authorizationUrl: string; 16 | tokenUrl: string; 17 | revocationUrl?: string; 18 | registrationUrl?: string; 19 | }; 20 | 21 | export type ProxyOptions = { 22 | /** 23 | * Individual endpoint URLs for proxying specific OAuth operations 24 | */ 25 | endpoints: ProxyEndpoints; 26 | 27 | /** 28 | * Function to verify access tokens and return auth info 29 | */ 30 | verifyAccessToken: (token: string) => Promise; 31 | 32 | /** 33 | * Function to fetch client information from the upstream server 34 | */ 35 | getClient: (clientId: string) => Promise; 36 | 37 | }; 38 | 39 | /** 40 | * Implements an OAuth server that proxies requests to another OAuth server. 41 | */ 42 | export class ProxyOAuthServerProvider implements OAuthServerProvider { 43 | protected readonly _endpoints: ProxyEndpoints; 44 | protected readonly _verifyAccessToken: (token: string) => Promise; 45 | protected readonly _getClient: (clientId: string) => Promise; 46 | 47 | skipLocalPkceValidation = true; 48 | 49 | revokeToken?: ( 50 | client: OAuthClientInformationFull, 51 | request: OAuthTokenRevocationRequest 52 | ) => Promise; 53 | 54 | constructor(options: ProxyOptions) { 55 | this._endpoints = options.endpoints; 56 | this._verifyAccessToken = options.verifyAccessToken; 57 | this._getClient = options.getClient; 58 | if (options.endpoints?.revocationUrl) { 59 | this.revokeToken = async ( 60 | client: OAuthClientInformationFull, 61 | request: OAuthTokenRevocationRequest 62 | ) => { 63 | const revocationUrl = this._endpoints.revocationUrl; 64 | 65 | if (!revocationUrl) { 66 | throw new Error("No revocation endpoint configured"); 67 | } 68 | 69 | const params = new URLSearchParams(); 70 | params.set("token", request.token); 71 | params.set("client_id", client.client_id); 72 | if (client.client_secret) { 73 | params.set("client_secret", client.client_secret); 74 | } 75 | if (request.token_type_hint) { 76 | params.set("token_type_hint", request.token_type_hint); 77 | } 78 | 79 | const response = await fetch(revocationUrl, { 80 | method: "POST", 81 | headers: { 82 | "Content-Type": "application/x-www-form-urlencoded", 83 | }, 84 | body: params.toString(), 85 | }); 86 | 87 | if (!response.ok) { 88 | throw new ServerError(`Token revocation failed: ${response.status}`); 89 | } 90 | } 91 | } 92 | } 93 | 94 | get clientsStore(): OAuthRegisteredClientsStore { 95 | const registrationUrl = this._endpoints.registrationUrl; 96 | return { 97 | getClient: this._getClient, 98 | ...(registrationUrl && { 99 | registerClient: async (client: OAuthClientInformationFull) => { 100 | const response = await fetch(registrationUrl, { 101 | method: "POST", 102 | headers: { 103 | "Content-Type": "application/json", 104 | }, 105 | body: JSON.stringify(client), 106 | }); 107 | 108 | if (!response.ok) { 109 | throw new ServerError(`Client registration failed: ${response.status}`); 110 | } 111 | 112 | const data = await response.json(); 113 | return OAuthClientInformationFullSchema.parse(data); 114 | } 115 | }) 116 | } 117 | } 118 | 119 | async authorize( 120 | client: OAuthClientInformationFull, 121 | params: AuthorizationParams, 122 | res: Response 123 | ): Promise { 124 | // Start with required OAuth parameters 125 | const targetUrl = new URL(this._endpoints.authorizationUrl); 126 | const searchParams = new URLSearchParams({ 127 | client_id: client.client_id, 128 | response_type: "code", 129 | redirect_uri: params.redirectUri, 130 | code_challenge: params.codeChallenge, 131 | code_challenge_method: "S256" 132 | }); 133 | 134 | // Add optional standard OAuth parameters 135 | if (params.state) searchParams.set("state", params.state); 136 | if (params.scopes?.length) searchParams.set("scope", params.scopes.join(" ")); 137 | 138 | targetUrl.search = searchParams.toString(); 139 | res.redirect(targetUrl.toString()); 140 | } 141 | 142 | async challengeForAuthorizationCode( 143 | _client: OAuthClientInformationFull, 144 | _authorizationCode: string 145 | ): Promise { 146 | // In a proxy setup, we don't store the code challenge ourselves 147 | // Instead, we proxy the token request and let the upstream server validate it 148 | return ""; 149 | } 150 | 151 | async exchangeAuthorizationCode( 152 | client: OAuthClientInformationFull, 153 | authorizationCode: string, 154 | codeVerifier?: string 155 | ): Promise { 156 | const params = new URLSearchParams({ 157 | grant_type: "authorization_code", 158 | client_id: client.client_id, 159 | code: authorizationCode, 160 | }); 161 | 162 | if (client.client_secret) { 163 | params.append("client_secret", client.client_secret); 164 | } 165 | 166 | if (codeVerifier) { 167 | params.append("code_verifier", codeVerifier); 168 | } 169 | 170 | const response = await fetch(this._endpoints.tokenUrl, { 171 | method: "POST", 172 | headers: { 173 | "Content-Type": "application/x-www-form-urlencoded", 174 | }, 175 | body: params.toString(), 176 | }); 177 | 178 | 179 | if (!response.ok) { 180 | throw new ServerError(`Token exchange failed: ${response.status}`); 181 | } 182 | 183 | const data = await response.json(); 184 | return OAuthTokensSchema.parse(data); 185 | } 186 | 187 | async exchangeRefreshToken( 188 | client: OAuthClientInformationFull, 189 | refreshToken: string, 190 | scopes?: string[] 191 | ): Promise { 192 | 193 | const params = new URLSearchParams({ 194 | grant_type: "refresh_token", 195 | client_id: client.client_id, 196 | refresh_token: refreshToken, 197 | }); 198 | 199 | if (client.client_secret) { 200 | params.set("client_secret", client.client_secret); 201 | } 202 | 203 | if (scopes?.length) { 204 | params.set("scope", scopes.join(" ")); 205 | } 206 | 207 | const response = await fetch(this._endpoints.tokenUrl, { 208 | method: "POST", 209 | headers: { 210 | "Content-Type": "application/x-www-form-urlencoded", 211 | }, 212 | body: params.toString(), 213 | }); 214 | 215 | if (!response.ok) { 216 | throw new ServerError(`Token refresh failed: ${response.status}`); 217 | } 218 | 219 | const data = await response.json(); 220 | return OAuthTokensSchema.parse(data); 221 | } 222 | 223 | async verifyAccessToken(token: string): Promise { 224 | return this._verifyAccessToken(token); 225 | } 226 | } -------------------------------------------------------------------------------- /src/server/auth/router.ts: -------------------------------------------------------------------------------- 1 | import express, { RequestHandler } from "express"; 2 | import { clientRegistrationHandler, ClientRegistrationHandlerOptions } from "./handlers/register.js"; 3 | import { tokenHandler, TokenHandlerOptions } from "./handlers/token.js"; 4 | import { authorizationHandler, AuthorizationHandlerOptions } from "./handlers/authorize.js"; 5 | import { revocationHandler, RevocationHandlerOptions } from "./handlers/revoke.js"; 6 | import { metadataHandler } from "./handlers/metadata.js"; 7 | import { OAuthServerProvider } from "./provider.js"; 8 | 9 | export type AuthRouterOptions = { 10 | /** 11 | * A provider implementing the actual authorization logic for this router. 12 | */ 13 | provider: OAuthServerProvider; 14 | 15 | /** 16 | * The authorization server's issuer identifier, which is a URL that uses the "https" scheme and has no query or fragment components. 17 | */ 18 | issuerUrl: URL; 19 | 20 | /** 21 | * The base URL of the authorization server to use for the metadata endpoints. 22 | * 23 | * If not provided, the issuer URL will be used as the base URL. 24 | */ 25 | baseUrl?: URL; 26 | 27 | /** 28 | * An optional URL of a page containing human-readable information that developers might want or need to know when using the authorization server. 29 | */ 30 | serviceDocumentationUrl?: URL; 31 | 32 | // Individual options per route 33 | authorizationOptions?: Omit; 34 | clientRegistrationOptions?: Omit; 35 | revocationOptions?: Omit; 36 | tokenOptions?: Omit; 37 | }; 38 | 39 | /** 40 | * Installs standard MCP authorization endpoints, including dynamic client registration and token revocation (if supported). Also advertises standard authorization server metadata, for easier discovery of supported configurations by clients. 41 | * 42 | * By default, rate limiting is applied to all endpoints to prevent abuse. 43 | * 44 | * This router MUST be installed at the application root, like so: 45 | * 46 | * const app = express(); 47 | * app.use(mcpAuthRouter(...)); 48 | */ 49 | export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { 50 | const issuer = options.issuerUrl; 51 | const baseUrl = options.baseUrl; 52 | 53 | // Technically RFC 8414 does not permit a localhost HTTPS exemption, but this will be necessary for ease of testing 54 | if (issuer.protocol !== "https:" && issuer.hostname !== "localhost" && issuer.hostname !== "127.0.0.1") { 55 | throw new Error("Issuer URL must be HTTPS"); 56 | } 57 | if (issuer.hash) { 58 | throw new Error("Issuer URL must not have a fragment"); 59 | } 60 | if (issuer.search) { 61 | throw new Error("Issuer URL must not have a query string"); 62 | } 63 | 64 | const authorization_endpoint = "/authorize"; 65 | const token_endpoint = "/token"; 66 | const registration_endpoint = options.provider.clientsStore.registerClient ? "/register" : undefined; 67 | const revocation_endpoint = options.provider.revokeToken ? "/revoke" : undefined; 68 | 69 | const metadata = { 70 | issuer: issuer.href, 71 | service_documentation: options.serviceDocumentationUrl?.href, 72 | 73 | authorization_endpoint: new URL(authorization_endpoint, baseUrl || issuer).href, 74 | response_types_supported: ["code"], 75 | code_challenge_methods_supported: ["S256"], 76 | 77 | token_endpoint: new URL(token_endpoint, baseUrl || issuer).href, 78 | token_endpoint_auth_methods_supported: ["client_secret_post"], 79 | grant_types_supported: ["authorization_code", "refresh_token"], 80 | 81 | revocation_endpoint: revocation_endpoint ? new URL(revocation_endpoint, baseUrl || issuer).href : undefined, 82 | revocation_endpoint_auth_methods_supported: revocation_endpoint ? ["client_secret_post"] : undefined, 83 | 84 | registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined, 85 | }; 86 | 87 | const router = express.Router(); 88 | 89 | router.use( 90 | authorization_endpoint, 91 | authorizationHandler({ provider: options.provider, ...options.authorizationOptions }) 92 | ); 93 | 94 | router.use( 95 | token_endpoint, 96 | tokenHandler({ provider: options.provider, ...options.tokenOptions }) 97 | ); 98 | 99 | router.use("/.well-known/oauth-authorization-server", metadataHandler(metadata)); 100 | 101 | if (registration_endpoint) { 102 | router.use( 103 | registration_endpoint, 104 | clientRegistrationHandler({ 105 | clientsStore: options.provider.clientsStore, 106 | ...options, 107 | }) 108 | ); 109 | } 110 | 111 | if (revocation_endpoint) { 112 | router.use( 113 | revocation_endpoint, 114 | revocationHandler({ provider: options.provider, ...options.revocationOptions }) 115 | ); 116 | } 117 | 118 | return router; 119 | } -------------------------------------------------------------------------------- /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 | * Additional data associated with the token. 27 | * This field should be used for any additional data that needs to be attached to the auth info. 28 | */ 29 | extra?: Record; 30 | } -------------------------------------------------------------------------------- /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/server/completable.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ZodTypeAny, 3 | ZodTypeDef, 4 | ZodType, 5 | ParseInput, 6 | ParseReturnType, 7 | RawCreateParams, 8 | ZodErrorMap, 9 | ProcessedCreateParams, 10 | } from "zod"; 11 | 12 | export enum McpZodTypeKind { 13 | Completable = "McpCompletable", 14 | } 15 | 16 | export type CompleteCallback = ( 17 | value: T["_input"], 18 | ) => T["_input"][] | Promise; 19 | 20 | export interface CompletableDef 21 | extends ZodTypeDef { 22 | type: T; 23 | complete: CompleteCallback; 24 | typeName: McpZodTypeKind.Completable; 25 | } 26 | 27 | export class Completable extends ZodType< 28 | T["_output"], 29 | CompletableDef, 30 | T["_input"] 31 | > { 32 | _parse(input: ParseInput): ParseReturnType { 33 | const { ctx } = this._processInputParams(input); 34 | const data = ctx.data; 35 | return this._def.type._parse({ 36 | data, 37 | path: ctx.path, 38 | parent: ctx, 39 | }); 40 | } 41 | 42 | unwrap() { 43 | return this._def.type; 44 | } 45 | 46 | static create = ( 47 | type: T, 48 | params: RawCreateParams & { 49 | complete: CompleteCallback; 50 | }, 51 | ): Completable => { 52 | return new Completable({ 53 | type, 54 | typeName: McpZodTypeKind.Completable, 55 | complete: params.complete, 56 | ...processCreateParams(params), 57 | }); 58 | }; 59 | } 60 | 61 | /** 62 | * Wraps a Zod type to provide autocompletion capabilities. Useful for, e.g., prompt arguments in MCP. 63 | */ 64 | export function completable( 65 | schema: T, 66 | complete: CompleteCallback, 67 | ): Completable { 68 | return Completable.create(schema, { ...schema._def, complete }); 69 | } 70 | 71 | // Not sure why this isn't exported from Zod: 72 | // https://github.com/colinhacks/zod/blob/f7ad26147ba291cb3fb257545972a8e00e767470/src/types.ts#L130 73 | function processCreateParams(params: RawCreateParams): ProcessedCreateParams { 74 | if (!params) return {}; 75 | const { errorMap, invalid_type_error, required_error, description } = params; 76 | if (errorMap && (invalid_type_error || required_error)) { 77 | throw new Error( 78 | `Can't use "invalid_type_error" or "required_error" in conjunction with custom error map.`, 79 | ); 80 | } 81 | if (errorMap) return { errorMap: errorMap, description }; 82 | const customMap: ZodErrorMap = (iss, ctx) => { 83 | const { message } = params; 84 | 85 | if (iss.code === "invalid_enum_value") { 86 | return { message: message ?? ctx.defaultError }; 87 | } 88 | if (typeof ctx.data === "undefined") { 89 | return { message: message ?? required_error ?? ctx.defaultError }; 90 | } 91 | if (iss.code !== "invalid_type") return { message: ctx.defaultError }; 92 | return { message: message ?? invalid_type_error ?? ctx.defaultError }; 93 | }; 94 | return { errorMap: customMap, description }; 95 | } 96 | -------------------------------------------------------------------------------- /src/server/sse.test.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import { jest } from '@jest/globals'; 3 | import { SSEServerTransport } from './sse.js'; 4 | 5 | const createMockResponse = () => { 6 | const res = { 7 | writeHead: jest.fn(), 8 | write: jest.fn().mockReturnValue(true), 9 | on: jest.fn(), 10 | }; 11 | res.writeHead.mockReturnThis(); 12 | res.on.mockReturnThis(); 13 | 14 | return res as unknown as http.ServerResponse; 15 | }; 16 | 17 | describe('SSEServerTransport', () => { 18 | describe('start method', () => { 19 | it('should correctly append sessionId to a simple relative endpoint', async () => { 20 | const mockRes = createMockResponse(); 21 | const endpoint = '/messages'; 22 | const transport = new SSEServerTransport(endpoint, mockRes); 23 | const expectedSessionId = transport.sessionId; 24 | 25 | await transport.start(); 26 | 27 | expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); 28 | expect(mockRes.write).toHaveBeenCalledTimes(1); 29 | expect(mockRes.write).toHaveBeenCalledWith( 30 | `event: endpoint\ndata: /messages?sessionId=${expectedSessionId}\n\n` 31 | ); 32 | }); 33 | 34 | it('should correctly append sessionId to an endpoint with existing query parameters', async () => { 35 | const mockRes = createMockResponse(); 36 | const endpoint = '/messages?foo=bar&baz=qux'; 37 | const transport = new SSEServerTransport(endpoint, mockRes); 38 | const expectedSessionId = transport.sessionId; 39 | 40 | await transport.start(); 41 | 42 | expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); 43 | expect(mockRes.write).toHaveBeenCalledTimes(1); 44 | expect(mockRes.write).toHaveBeenCalledWith( 45 | `event: endpoint\ndata: /messages?foo=bar&baz=qux&sessionId=${expectedSessionId}\n\n` 46 | ); 47 | }); 48 | 49 | it('should correctly append sessionId to an endpoint with a hash fragment', async () => { 50 | const mockRes = createMockResponse(); 51 | const endpoint = '/messages#section1'; 52 | const transport = new SSEServerTransport(endpoint, mockRes); 53 | const expectedSessionId = transport.sessionId; 54 | 55 | await transport.start(); 56 | 57 | expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); 58 | expect(mockRes.write).toHaveBeenCalledTimes(1); 59 | expect(mockRes.write).toHaveBeenCalledWith( 60 | `event: endpoint\ndata: /messages?sessionId=${expectedSessionId}#section1\n\n` 61 | ); 62 | }); 63 | 64 | it('should correctly append sessionId to an endpoint with query parameters and a hash fragment', async () => { 65 | const mockRes = createMockResponse(); 66 | const endpoint = '/messages?key=value#section2'; 67 | const transport = new SSEServerTransport(endpoint, mockRes); 68 | const expectedSessionId = transport.sessionId; 69 | 70 | await transport.start(); 71 | 72 | expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); 73 | expect(mockRes.write).toHaveBeenCalledTimes(1); 74 | expect(mockRes.write).toHaveBeenCalledWith( 75 | `event: endpoint\ndata: /messages?key=value&sessionId=${expectedSessionId}#section2\n\n` 76 | ); 77 | }); 78 | 79 | it('should correctly handle the root path endpoint "/"', async () => { 80 | const mockRes = createMockResponse(); 81 | const endpoint = '/'; 82 | const transport = new SSEServerTransport(endpoint, mockRes); 83 | const expectedSessionId = transport.sessionId; 84 | 85 | await transport.start(); 86 | 87 | expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); 88 | expect(mockRes.write).toHaveBeenCalledTimes(1); 89 | expect(mockRes.write).toHaveBeenCalledWith( 90 | `event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n` 91 | ); 92 | }); 93 | 94 | it('should correctly handle an empty string endpoint ""', async () => { 95 | const mockRes = createMockResponse(); 96 | const endpoint = ''; 97 | const transport = new SSEServerTransport(endpoint, mockRes); 98 | const expectedSessionId = transport.sessionId; 99 | 100 | await transport.start(); 101 | 102 | expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); 103 | expect(mockRes.write).toHaveBeenCalledTimes(1); 104 | expect(mockRes.write).toHaveBeenCalledWith( 105 | `event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n` 106 | ); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /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 } 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 | * Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests. 14 | * 15 | * This transport is only available in Node.js environments. 16 | */ 17 | export class SSEServerTransport implements Transport { 18 | private _sseResponse?: ServerResponse; 19 | private _sessionId: string; 20 | 21 | onclose?: () => void; 22 | onerror?: (error: Error) => void; 23 | onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; 24 | 25 | /** 26 | * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. 27 | */ 28 | constructor( 29 | private _endpoint: string, 30 | private res: ServerResponse, 31 | ) { 32 | this._sessionId = randomUUID(); 33 | } 34 | 35 | /** 36 | * Handles the initial SSE connection request. 37 | * 38 | * This should be called when a GET request is made to establish the SSE stream. 39 | */ 40 | async start(): Promise { 41 | if (this._sseResponse) { 42 | throw new Error( 43 | "SSEServerTransport already started! If using Server class, note that connect() calls start() automatically.", 44 | ); 45 | } 46 | 47 | this.res.writeHead(200, { 48 | "Content-Type": "text/event-stream", 49 | "Cache-Control": "no-cache, no-transform", 50 | Connection: "keep-alive", 51 | }); 52 | 53 | // Send the endpoint event 54 | // Use a dummy base URL because this._endpoint is relative. 55 | // This allows using URL/URLSearchParams for robust parameter handling. 56 | const dummyBase = 'http://localhost'; // Any valid base works 57 | const endpointUrl = new URL(this._endpoint, dummyBase); 58 | endpointUrl.searchParams.set('sessionId', this._sessionId); 59 | 60 | // Reconstruct the relative URL string (pathname + search + hash) 61 | const relativeUrlWithSession = endpointUrl.pathname + endpointUrl.search + endpointUrl.hash; 62 | 63 | this.res.write( 64 | `event: endpoint\ndata: ${relativeUrlWithSession}\n\n`, 65 | ); 66 | 67 | this._sseResponse = this.res; 68 | this.res.on("close", () => { 69 | this._sseResponse = undefined; 70 | this.onclose?.(); 71 | }); 72 | } 73 | 74 | /** 75 | * Handles incoming POST messages. 76 | * 77 | * This should be called when a POST request is made to send a message to the server. 78 | */ 79 | async handlePostMessage( 80 | req: IncomingMessage & { auth?: AuthInfo }, 81 | res: ServerResponse, 82 | parsedBody?: unknown, 83 | ): Promise { 84 | if (!this._sseResponse) { 85 | const message = "SSE connection not established"; 86 | res.writeHead(500).end(message); 87 | throw new Error(message); 88 | } 89 | const authInfo: AuthInfo | undefined = req.auth; 90 | 91 | let body: string | unknown; 92 | try { 93 | const ct = contentType.parse(req.headers["content-type"] ?? ""); 94 | if (ct.type !== "application/json") { 95 | throw new Error(`Unsupported content-type: ${ct}`); 96 | } 97 | 98 | body = parsedBody ?? await getRawBody(req, { 99 | limit: MAXIMUM_MESSAGE_SIZE, 100 | encoding: ct.parameters.charset ?? "utf-8", 101 | }); 102 | } catch (error) { 103 | res.writeHead(400).end(String(error)); 104 | this.onerror?.(error as Error); 105 | return; 106 | } 107 | 108 | try { 109 | await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { authInfo }); 110 | } catch { 111 | res.writeHead(400).end(`Invalid message: ${body}`); 112 | return; 113 | } 114 | 115 | res.writeHead(202).end("Accepted"); 116 | } 117 | 118 | /** 119 | * 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. 120 | */ 121 | async handleMessage(message: unknown, extra?: { authInfo?: AuthInfo }): Promise { 122 | let parsedMessage: JSONRPCMessage; 123 | try { 124 | parsedMessage = JSONRPCMessageSchema.parse(message); 125 | } catch (error) { 126 | this.onerror?.(error as Error); 127 | throw error; 128 | } 129 | 130 | this.onmessage?.(parsedMessage, extra); 131 | } 132 | 133 | async close(): Promise { 134 | this._sseResponse?.end(); 135 | this._sseResponse = undefined; 136 | this.onclose?.(); 137 | } 138 | 139 | async send(message: JSONRPCMessage): Promise { 140 | if (!this._sseResponse) { 141 | throw new Error("Not connected"); 142 | } 143 | 144 | this._sseResponse.write( 145 | `event: message\ndata: ${JSON.stringify(message)}\n\n`, 146 | ); 147 | } 148 | 149 | /** 150 | * Returns the session ID for this transport. 151 | * 152 | * This can be used to route incoming POST requests. 153 | */ 154 | get sessionId(): string { 155 | return this._sessionId; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /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/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/shared/auth.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | /** 4 | * RFC 8414 OAuth 2.0 Authorization Server Metadata 5 | */ 6 | export const OAuthMetadataSchema = z 7 | .object({ 8 | issuer: z.string(), 9 | authorization_endpoint: z.string(), 10 | token_endpoint: z.string(), 11 | registration_endpoint: z.string().optional(), 12 | scopes_supported: z.array(z.string()).optional(), 13 | response_types_supported: z.array(z.string()), 14 | response_modes_supported: z.array(z.string()).optional(), 15 | grant_types_supported: z.array(z.string()).optional(), 16 | token_endpoint_auth_methods_supported: z.array(z.string()).optional(), 17 | token_endpoint_auth_signing_alg_values_supported: z 18 | .array(z.string()) 19 | .optional(), 20 | service_documentation: z.string().optional(), 21 | revocation_endpoint: z.string().optional(), 22 | revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(), 23 | revocation_endpoint_auth_signing_alg_values_supported: z 24 | .array(z.string()) 25 | .optional(), 26 | introspection_endpoint: z.string().optional(), 27 | introspection_endpoint_auth_methods_supported: z 28 | .array(z.string()) 29 | .optional(), 30 | introspection_endpoint_auth_signing_alg_values_supported: z 31 | .array(z.string()) 32 | .optional(), 33 | code_challenge_methods_supported: z.array(z.string()).optional(), 34 | }) 35 | .passthrough(); 36 | 37 | /** 38 | * OAuth 2.1 token response 39 | */ 40 | export const OAuthTokensSchema = z 41 | .object({ 42 | access_token: z.string(), 43 | token_type: z.string(), 44 | expires_in: z.number().optional(), 45 | scope: z.string().optional(), 46 | refresh_token: z.string().optional(), 47 | }) 48 | .strip(); 49 | 50 | /** 51 | * OAuth 2.1 error response 52 | */ 53 | export const OAuthErrorResponseSchema = z 54 | .object({ 55 | error: z.string(), 56 | error_description: z.string().optional(), 57 | error_uri: z.string().optional(), 58 | }); 59 | 60 | /** 61 | * RFC 7591 OAuth 2.0 Dynamic Client Registration metadata 62 | */ 63 | export const OAuthClientMetadataSchema = z.object({ 64 | redirect_uris: z.array(z.string()).refine((uris) => uris.every((uri) => URL.canParse(uri)), { message: "redirect_uris must contain valid URLs" }), 65 | token_endpoint_auth_method: z.string().optional(), 66 | grant_types: z.array(z.string()).optional(), 67 | response_types: z.array(z.string()).optional(), 68 | client_name: z.string().optional(), 69 | client_uri: z.string().optional(), 70 | logo_uri: z.string().optional(), 71 | scope: z.string().optional(), 72 | contacts: z.array(z.string()).optional(), 73 | tos_uri: z.string().optional(), 74 | policy_uri: z.string().optional(), 75 | jwks_uri: z.string().optional(), 76 | jwks: z.any().optional(), 77 | software_id: z.string().optional(), 78 | software_version: z.string().optional(), 79 | }).strip(); 80 | 81 | /** 82 | * RFC 7591 OAuth 2.0 Dynamic Client Registration client information 83 | */ 84 | export const OAuthClientInformationSchema = z.object({ 85 | client_id: z.string(), 86 | client_secret: z.string().optional(), 87 | client_id_issued_at: z.number().optional(), 88 | client_secret_expires_at: z.number().optional(), 89 | }).strip(); 90 | 91 | /** 92 | * RFC 7591 OAuth 2.0 Dynamic Client Registration full response (client information plus metadata) 93 | */ 94 | export const OAuthClientInformationFullSchema = OAuthClientMetadataSchema.merge(OAuthClientInformationSchema); 95 | 96 | /** 97 | * RFC 7591 OAuth 2.0 Dynamic Client Registration error response 98 | */ 99 | export const OAuthClientRegistrationErrorSchema = z.object({ 100 | error: z.string(), 101 | error_description: z.string().optional(), 102 | }).strip(); 103 | 104 | /** 105 | * RFC 7009 OAuth 2.0 Token Revocation request 106 | */ 107 | export const OAuthTokenRevocationRequestSchema = z.object({ 108 | token: z.string(), 109 | token_type_hint: z.string().optional(), 110 | }).strip(); 111 | 112 | export type OAuthMetadata = z.infer; 113 | export type OAuthTokens = z.infer; 114 | export type OAuthErrorResponse = z.infer; 115 | export type OAuthClientMetadata = z.infer; 116 | export type OAuthClientInformation = z.infer; 117 | export type OAuthClientInformationFull = z.infer; 118 | export type OAuthClientRegistrationError = z.infer; 119 | export type OAuthTokenRevocationRequest = z.infer; -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /src/shared/transport.ts: -------------------------------------------------------------------------------- 1 | import { AuthInfo } from "../server/auth/types.js"; 2 | import { JSONRPCMessage, RequestId } from "../types.js"; 3 | 4 | /** 5 | * Options for sending a JSON-RPC message. 6 | */ 7 | export type TransportSendOptions = { 8 | /** 9 | * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. 10 | */ 11 | relatedRequestId?: RequestId; 12 | 13 | /** 14 | * The resumption token used to continue long-running requests that were interrupted. 15 | * 16 | * This allows clients to reconnect and continue from where they left off, if supported by the transport. 17 | */ 18 | resumptionToken?: string; 19 | 20 | /** 21 | * A callback that is invoked when the resumption token changes, if supported by the transport. 22 | * 23 | * This allows clients to persist the latest token for potential reconnection. 24 | */ 25 | onresumptiontoken?: (token: string) => void; 26 | } 27 | /** 28 | * Describes the minimal contract for a MCP transport that a client or server can communicate over. 29 | */ 30 | export interface Transport { 31 | /** 32 | * Starts processing messages on the transport, including any connection steps that might need to be taken. 33 | * 34 | * This method should only be called after callbacks are installed, or else messages may be lost. 35 | * 36 | * NOTE: This method should not be called explicitly when using Client, Server, or Protocol classes, as they will implicitly call start(). 37 | */ 38 | start(): Promise; 39 | 40 | /** 41 | * Sends a JSON-RPC message (request or response). 42 | * 43 | * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. 44 | */ 45 | send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; 46 | 47 | /** 48 | * Closes the connection. 49 | */ 50 | close(): Promise; 51 | 52 | /** 53 | * Callback for when the connection is closed for any reason. 54 | * 55 | * This should be invoked when close() is called as well. 56 | */ 57 | onclose?: () => void; 58 | 59 | /** 60 | * Callback for when an error occurs. 61 | * 62 | * Note that errors are not necessarily fatal; they are used for reporting any kind of exceptional condition out of band. 63 | */ 64 | onerror?: (error: Error) => void; 65 | 66 | /** 67 | * Callback for when a message (request or response) is received over the connection. 68 | * 69 | * Includes the authInfo if the transport is authenticated. 70 | * 71 | */ 72 | onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; 73 | 74 | /** 75 | * The session ID generated for this connection. 76 | */ 77 | sessionId?: string; 78 | } 79 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist/esm" 5 | }, 6 | "exclude": ["**/*.test.ts", "src/__mocks__/**/*"] 7 | } 8 | --------------------------------------------------------------------------------