├── .github └── workflows │ ├── feature.yaml │ └── main.yaml ├── .gitignore ├── LICENSE ├── README.md ├── eslint.config.ts ├── jsr.json ├── package.json ├── pnpm-lock.yaml ├── src ├── InMemoryEventStore.ts ├── StdioClientTransport.ts ├── bin │ └── mcp-proxy.ts ├── index.ts ├── proxyServer.ts ├── simple-stdio-proxy-server.ts ├── simple-stdio-server.ts ├── startHTTPServer.test.ts ├── startHTTPServer.ts ├── startStdioServer.test.ts ├── startStdioServer.ts └── tapTransport.ts └── tsconfig.json /.github/workflows/feature.yaml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | types: 7 | - opened 8 | - synchronize 9 | - reopened 10 | - ready_for_review 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | name: Test 15 | strategy: 16 | fail-fast: true 17 | matrix: 18 | node: 19 | - 22 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | - uses: pnpm/action-setup@v4 26 | with: 27 | version: 9 28 | - name: Setup NodeJS ${{ matrix.node }} 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: ${{ matrix.node }} 32 | cache: "pnpm" 33 | cache-dependency-path: "**/pnpm-lock.yaml" 34 | - name: Install dependencies 35 | run: pnpm install 36 | - name: Run tests 37 | run: pnpm test 38 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | test: 8 | environment: release 9 | name: Test 10 | strategy: 11 | fail-fast: true 12 | matrix: 13 | node: 14 | - 22 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: write 18 | id-token: write 19 | steps: 20 | - name: setup repository 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | - uses: pnpm/action-setup@v4 25 | with: 26 | version: 9 27 | - name: setup node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | cache: "pnpm" 31 | node-version: ${{ matrix.node }} 32 | - name: Setup NodeJS ${{ matrix.node }} 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: ${{ matrix.node }} 36 | cache: "pnpm" 37 | cache-dependency-path: "**/pnpm-lock.yaml" 38 | - name: Install dependencies 39 | run: pnpm install 40 | - name: Run tests 41 | run: pnpm test 42 | - name: Build 43 | run: pnpm build 44 | - name: Release 45 | run: pnpm semantic-release 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2024, Frank Fiegel 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 17 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 22 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 23 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCP Proxy 2 | 3 | A TypeScript streamable HTTP and SSE proxy for [MCP](https://modelcontextprotocol.io/) servers that use `stdio` transport. 4 | 5 | > [!NOTE] 6 | > CORS is enabled by default. 7 | 8 | > [!NOTE] 9 | > For a Python implementation, see [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy). 10 | 11 | > [!NOTE] 12 | > MCP Proxy is what [FastMCP](https://github.com/punkpeye/fastmcp) uses to enable streamable HTTP and SSE. 13 | 14 | ## Installation 15 | 16 | ```bash 17 | npm install mcp-proxy 18 | ``` 19 | 20 | ## Quickstart 21 | 22 | ### Command-line 23 | 24 | ```bash 25 | npx mcp-proxy --port 8080 --shell tsx server.js 26 | ``` 27 | 28 | This starts a server and `stdio` server (`tsx server.js`). The server listens on port 8080 and `/mcp` (streamable HTTP) and `/sse` (SSE) endpoints, and forwards messages to the `stdio` server. 29 | 30 | options: 31 | 32 | - `--server`: Set to `sse` or `stream` to only enable the respective transport (default: both) 33 | - `--endpoint`: If `server` is set to `sse` or `stream`, this option sets the endpoint path (default: `/sse` or `/mcp`) 34 | - `--sseEndpoint`: Set the SSE endpoint path (default: `/sse`). Overrides `--endpoint` if `server` is set to `sse`. 35 | - `--streamEndpoint`: Set the streamable HTTP endpoint path (default: `/mcp`). Overrides `--endpoint` if `server` is set to `stream`. 36 | - `--port`: Specify the port to listen on (default: 8080) 37 | - `--debug`: Enable debug logging 38 | - `--shell`: Spawn the server via the user's shell 39 | 40 | > [!NOTE] 41 | > Any arguments starting with `-` after `` are parsed as `mcp-proxy` 42 | > options. Insert `--` before such arguments to pass them to the wrapped 43 | > command. For example: 44 | > 45 | > ```bash 46 | > npx mcp-proxy --port 8080 --shell npx -- -y some-package 47 | > ``` 48 | 49 | ### Node.js SDK 50 | 51 | The Node.js SDK provides several utilities that are used to create a proxy. 52 | 53 | #### `proxyServer` 54 | 55 | Sets up a proxy between a server and a client. 56 | 57 | ```ts 58 | const transport = new StdioClientTransport(); 59 | const client = new Client(); 60 | 61 | const server = new Server(serverVersion, { 62 | capabilities: {}, 63 | }); 64 | 65 | proxyServer({ 66 | server, 67 | client, 68 | capabilities: {}, 69 | }); 70 | ``` 71 | 72 | In this example, the server will proxy all requests to the client and vice versa. 73 | 74 | #### `startHTTPServer` 75 | 76 | Starts a proxy that listens on a `port`, and sends messages to the attached server via `StreamableHTTPServerTransport` and `SSEServerTransport`. 77 | 78 | ```ts 79 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 80 | import { startHTTPServer } from "mcp-proxy"; 81 | 82 | const { close } = await startHTTPServer({ 83 | createServer: async () => { 84 | return new Server(); 85 | }, 86 | eventStore: new InMemoryEventStore(), 87 | port: 8080, 88 | }); 89 | 90 | close(); 91 | ``` 92 | 93 | #### `startStdioServer` 94 | 95 | Starts a proxy that listens on a `stdio`, and sends messages to the attached `sse` or `streamable` server. 96 | 97 | ```ts 98 | import { ServerType, startStdioServer } from "./startStdioServer.js"; 99 | 100 | await startStdioServer({ 101 | serverType: ServerType.SSE, 102 | url: "http://127.0.0.1:8080/sse", 103 | }); 104 | ``` 105 | 106 | #### `tapTransport` 107 | 108 | Taps into a transport and logs events. 109 | 110 | ```ts 111 | import { tapTransport } from "mcp-proxy"; 112 | 113 | const transport = tapTransport(new StdioClientTransport(), (event) => { 114 | console.log(event); 115 | }); 116 | ``` 117 | -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import eslintConfigPrettier from "eslint-config-prettier/flat"; 3 | import perfectionist from "eslint-plugin-perfectionist"; 4 | import tseslint from "typescript-eslint"; 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | tseslint.configs.recommended, 9 | perfectionist.configs["recommended-alphabetical"], 10 | eslintConfigPrettier, 11 | { 12 | ignores: [ 13 | "**/*.js", 14 | "**/*.d.ts", 15 | "dist/**", 16 | "node_modules/**", 17 | "pnpm-lock.yaml", 18 | ], 19 | }, 20 | ); 21 | -------------------------------------------------------------------------------- /jsr.json: -------------------------------------------------------------------------------- 1 | { 2 | "exports": "./src/index.ts", 3 | "include": ["src/index.ts", "src/bin/mcp-proxy.ts"], 4 | "license": "MIT", 5 | "name": "@punkpeye/mcp-proxy", 6 | "version": "1.0.0" 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mcp-proxy", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "scripts": { 6 | "build": "tsup", 7 | "test": "vitest run && tsc && eslint . && jsr publish --dry-run --allow-dirty", 8 | "format": "eslint --fix ." 9 | }, 10 | "bin": { 11 | "mcp-proxy": "dist/bin/mcp-proxy.js" 12 | }, 13 | "keywords": [ 14 | "MCP", 15 | "SSE", 16 | "proxy" 17 | ], 18 | "type": "module", 19 | "author": "Frank Fiegel ", 20 | "license": "MIT", 21 | "description": "A TypeScript SSE proxy for MCP servers that use stdio transport.", 22 | "module": "dist/index.js", 23 | "types": "dist/index.d.ts", 24 | "dependencies": { 25 | "@modelcontextprotocol/sdk": "^1.11.4", 26 | "eventsource": "^4.0.0", 27 | "yargs": "^17.7.2" 28 | }, 29 | "repository": { 30 | "url": "https://github.com/punkpeye/mcp-proxy" 31 | }, 32 | "homepage": "https://glama.ai/mcp", 33 | "release": { 34 | "branches": [ 35 | "main" 36 | ], 37 | "plugins": [ 38 | "@semantic-release/commit-analyzer", 39 | "@semantic-release/release-notes-generator", 40 | "@semantic-release/npm", 41 | "@semantic-release/github", 42 | "@sebbo2002/semantic-release-jsr" 43 | ] 44 | }, 45 | "devDependencies": { 46 | "@eslint/js": "^9.27.0", 47 | "@sebbo2002/semantic-release-jsr": "^3.0.0", 48 | "@tsconfig/node22": "^22.0.2", 49 | "@types/express": "^5.0.2", 50 | "@types/node": "^22.15.21", 51 | "@types/yargs": "^17.0.33", 52 | "eslint": "^9.27.0", 53 | "eslint-config-prettier": "^10.1.5", 54 | "eslint-plugin-perfectionist": "^4.13.0", 55 | "express": "^5.0.1", 56 | "get-port-please": "^3.1.2", 57 | "jiti": "^2.4.2", 58 | "jsr": "^0.13.4", 59 | "prettier": "^3.5.3", 60 | "semantic-release": "^24.2.4", 61 | "tsup": "^8.5.0", 62 | "tsx": "^4.19.3", 63 | "typescript": "^5.8.3", 64 | "typescript-eslint": "^8.32.1", 65 | "vitest": "^3.1.4" 66 | }, 67 | "tsup": { 68 | "entry": [ 69 | "src/index.ts", 70 | "src/bin/mcp-proxy.ts" 71 | ], 72 | "format": [ 73 | "esm" 74 | ], 75 | "dts": true, 76 | "splitting": true, 77 | "sourcemap": true, 78 | "clean": true 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/InMemoryEventStore.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a copy of the InMemoryEventStore from the typescript-sdk 3 | * https://github.com/modelcontextprotocol/typescript-sdk/blob/main/src/inMemoryEventStore.ts 4 | */ 5 | 6 | import type { EventStore } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; 7 | import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; 8 | 9 | /** 10 | * Simple in-memory implementation of the EventStore interface for resumability 11 | * This is primarily intended for examples and testing, not for production use 12 | * where a persistent storage solution would be more appropriate. 13 | */ 14 | export class InMemoryEventStore implements EventStore { 15 | private events: Map = 16 | new Map(); 17 | 18 | /** 19 | * Replays events that occurred after a specific event ID 20 | * Implements EventStore.replayEventsAfter 21 | */ 22 | async replayEventsAfter( 23 | lastEventId: string, 24 | { 25 | send, 26 | }: { send: (eventId: string, message: JSONRPCMessage) => Promise }, 27 | ): Promise { 28 | if (!lastEventId || !this.events.has(lastEventId)) { 29 | return ""; 30 | } 31 | 32 | // Extract the stream ID from the event ID 33 | const streamId = this.getStreamIdFromEventId(lastEventId); 34 | if (!streamId) { 35 | return ""; 36 | } 37 | 38 | let foundLastEvent = false; 39 | 40 | // Sort events by eventId for chronological ordering 41 | const sortedEvents = [...this.events.entries()].sort((a, b) => 42 | a[0].localeCompare(b[0]), 43 | ); 44 | 45 | for (const [ 46 | eventId, 47 | { message, streamId: eventStreamId }, 48 | ] of sortedEvents) { 49 | // Only include events from the same stream 50 | if (eventStreamId !== streamId) { 51 | continue; 52 | } 53 | 54 | // Start sending events after we find the lastEventId 55 | if (eventId === lastEventId) { 56 | foundLastEvent = true; 57 | continue; 58 | } 59 | 60 | if (foundLastEvent) { 61 | await send(eventId, message); 62 | } 63 | } 64 | return streamId; 65 | } 66 | 67 | /** 68 | * Stores an event with a generated event ID 69 | * Implements EventStore.storeEvent 70 | */ 71 | async storeEvent(streamId: string, message: JSONRPCMessage): Promise { 72 | const eventId = this.generateEventId(streamId); 73 | this.events.set(eventId, { message, streamId }); 74 | return eventId; 75 | } 76 | 77 | /** 78 | * Generates a unique event ID for a given stream ID 79 | */ 80 | private generateEventId(streamId: string): string { 81 | return `${streamId}_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; 82 | } 83 | 84 | /** 85 | * Extracts the stream ID from an event ID 86 | */ 87 | private getStreamIdFromEventId(eventId: string): string { 88 | const parts = eventId.split("_"); 89 | return parts.length > 0 ? parts[0] : ""; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/StdioClientTransport.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Forked from https://github.com/modelcontextprotocol/typescript-sdk/blob/66e1508162d37c0b83b0637ebcd7f07946e3d210/src/client/stdio.ts#L90 3 | */ 4 | 5 | import { 6 | ReadBuffer, 7 | serializeMessage, 8 | } from "@modelcontextprotocol/sdk/shared/stdio.js"; 9 | import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; 10 | import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; 11 | import { ChildProcess, IOType, spawn } from "node:child_process"; 12 | import { Stream } from "node:stream"; 13 | 14 | export type StdioServerParameters = { 15 | /** 16 | * Command line arguments to pass to the executable. 17 | */ 18 | args?: string[]; 19 | 20 | /** 21 | * The executable to run to start the server. 22 | */ 23 | command: string; 24 | 25 | /** 26 | * The working directory to use when spawning the process. 27 | * 28 | * If not specified, the current working directory will be inherited. 29 | */ 30 | cwd?: string; 31 | 32 | /** 33 | * The environment to use when spawning the process. 34 | * 35 | * If not specified, the result of getDefaultEnvironment() will be used. 36 | */ 37 | env: Record; 38 | 39 | /** 40 | * A function to call when an event occurs. 41 | */ 42 | onEvent?: (event: TransportEvent) => void; 43 | 44 | /** 45 | * When true, spawn the child process using the user's shell. 46 | */ 47 | shell?: boolean; 48 | 49 | /** 50 | * How to handle stderr of the child process. This matches the semantics of Node's `child_process.spawn`. 51 | * 52 | * The default is "inherit", meaning messages to stderr will be printed to the parent process's stderr. 53 | */ 54 | stderr?: IOType | number | Stream; 55 | }; 56 | 57 | type TransportEvent = 58 | | { 59 | chunk: string; 60 | type: "data"; 61 | } 62 | | { 63 | error: Error; 64 | type: "error"; 65 | } 66 | | { 67 | message: JSONRPCMessage; 68 | type: "message"; 69 | } 70 | | { 71 | type: "close"; 72 | }; 73 | 74 | /** 75 | * Client transport for stdio: this will connect to a server by spawning a process and communicating with it over stdin/stdout. 76 | * 77 | * This transport is only available in Node.js environments. 78 | */ 79 | export class StdioClientTransport implements Transport { 80 | onclose?: () => void; 81 | onerror?: (error: Error) => void; 82 | onmessage?: (message: JSONRPCMessage) => void; 83 | /** 84 | * The stderr stream of the child process, if `StdioServerParameters.stderr` was set to "pipe" or "overlapped". 85 | * 86 | * This is only available after the process has been started. 87 | */ 88 | get stderr(): null | Stream { 89 | return this.process?.stderr ?? null; 90 | } 91 | private abortController: AbortController = new AbortController(); 92 | 93 | private onEvent?: (event: TransportEvent) => void; 94 | private process?: ChildProcess; 95 | private readBuffer: ReadBuffer = new ReadBuffer(); 96 | 97 | private serverParams: StdioServerParameters; 98 | 99 | constructor(server: StdioServerParameters) { 100 | this.serverParams = server; 101 | this.onEvent = server.onEvent; 102 | } 103 | 104 | async close(): Promise { 105 | this.onEvent?.({ 106 | type: "close", 107 | }); 108 | 109 | this.abortController.abort(); 110 | this.process = undefined; 111 | this.readBuffer.clear(); 112 | } 113 | 114 | send(message: JSONRPCMessage): Promise { 115 | return new Promise((resolve) => { 116 | if (!this.process?.stdin) { 117 | throw new Error("Not connected"); 118 | } 119 | 120 | const json = serializeMessage(message); 121 | if (this.process.stdin.write(json)) { 122 | resolve(); 123 | } else { 124 | this.process.stdin.once("drain", resolve); 125 | } 126 | }); 127 | } 128 | 129 | /** 130 | * Starts the server process and prepares to communicate with it. 131 | */ 132 | async start(): Promise { 133 | if (this.process) { 134 | throw new Error( 135 | "StdioClientTransport already started! If using Client class, note that connect() calls start() automatically.", 136 | ); 137 | } 138 | 139 | return new Promise((resolve, reject) => { 140 | this.process = spawn( 141 | this.serverParams.command, 142 | this.serverParams.args ?? [], 143 | { 144 | cwd: this.serverParams.cwd, 145 | env: this.serverParams.env, 146 | shell: this.serverParams.shell ?? false, 147 | signal: this.abortController.signal, 148 | stdio: ["pipe", "pipe", this.serverParams.stderr ?? "inherit"], 149 | }, 150 | ); 151 | 152 | this.process.on("error", (error) => { 153 | if (error.name === "AbortError") { 154 | // Expected when close() is called. 155 | this.onclose?.(); 156 | return; 157 | } 158 | 159 | reject(error); 160 | this.onerror?.(error); 161 | }); 162 | 163 | this.process.on("spawn", () => { 164 | resolve(); 165 | }); 166 | 167 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 168 | this.process.on("close", (_code) => { 169 | this.onEvent?.({ 170 | type: "close", 171 | }); 172 | 173 | this.process = undefined; 174 | this.onclose?.(); 175 | }); 176 | 177 | this.process.stdin?.on("error", (error) => { 178 | this.onEvent?.({ 179 | error, 180 | type: "error", 181 | }); 182 | 183 | this.onerror?.(error); 184 | }); 185 | 186 | this.process.stdout?.on("data", (chunk) => { 187 | this.onEvent?.({ 188 | chunk: chunk.toString(), 189 | type: "data", 190 | }); 191 | 192 | this.readBuffer.append(chunk); 193 | this.processReadBuffer(); 194 | }); 195 | 196 | this.process.stdout?.on("error", (error) => { 197 | this.onEvent?.({ 198 | error, 199 | type: "error", 200 | }); 201 | 202 | this.onerror?.(error); 203 | }); 204 | }); 205 | } 206 | 207 | private processReadBuffer() { 208 | while (true) { 209 | try { 210 | const message = this.readBuffer.readMessage(); 211 | 212 | if (message === null) { 213 | break; 214 | } 215 | 216 | this.onEvent?.({ 217 | message, 218 | type: "message", 219 | }); 220 | 221 | this.onmessage?.(message); 222 | } catch (error) { 223 | this.onEvent?.({ 224 | error: error as Error, 225 | type: "error", 226 | }); 227 | 228 | this.onerror?.(error as Error); 229 | } 230 | } 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/bin/mcp-proxy.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 4 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 5 | import { EventSource } from "eventsource"; 6 | import { setTimeout } from "node:timers"; 7 | import util from "node:util"; 8 | import yargs from "yargs"; 9 | import { hideBin } from "yargs/helpers"; 10 | 11 | import { InMemoryEventStore } from "../InMemoryEventStore.js"; 12 | import { proxyServer } from "../proxyServer.js"; 13 | import { startHTTPServer } from "../startHTTPServer.js"; 14 | import { StdioClientTransport } from "../StdioClientTransport.js"; 15 | 16 | util.inspect.defaultOptions.depth = 8; 17 | 18 | if (!("EventSource" in global)) { 19 | // @ts-expect-error - figure out how to use --experimental-eventsource with vitest 20 | global.EventSource = EventSource; 21 | } 22 | 23 | const argv = await yargs(hideBin(process.argv)) 24 | .scriptName("mcp-proxy") 25 | .command("$0 [args...]", "Run a command with MCP arguments") 26 | .positional("command", { 27 | demandOption: true, 28 | describe: "The command to run", 29 | type: "string", 30 | }) 31 | .positional("args", { 32 | array: true, 33 | describe: "The arguments to pass to the command", 34 | type: "string", 35 | }) 36 | .env("MCP_PROXY") 37 | .options({ 38 | debug: { 39 | default: false, 40 | describe: "Enable debug logging", 41 | type: "boolean", 42 | }, 43 | endpoint: { 44 | describe: "The endpoint to listen on", 45 | type: "string", 46 | }, 47 | port: { 48 | default: 8080, 49 | describe: "The port to listen on", 50 | type: "number", 51 | }, 52 | server: { 53 | choices: ["sse", "stream"], 54 | describe: "The server type to use (sse or stream). By default, both are enabled", 55 | type: "string", 56 | }, 57 | shell: { 58 | default: false, 59 | describe: "Spawn the server via the user's shell", 60 | type: "boolean", 61 | }, 62 | sseEndpoint: { 63 | default: "/sse", 64 | describe: "The SSE endpoint to listen on", 65 | type: "string", 66 | }, 67 | streamEndpoint: { 68 | default: "/mcp", 69 | describe: "The stream endpoint to listen on", 70 | type: "string", 71 | }, 72 | }) 73 | .help() 74 | .parseAsync(); 75 | 76 | const connect = async (client: Client) => { 77 | const transport = new StdioClientTransport({ 78 | args: argv.args, 79 | command: argv.command, 80 | env: process.env as Record, 81 | onEvent: (event) => { 82 | if (argv.debug) { 83 | console.debug("transport event", event); 84 | } 85 | }, 86 | shell: argv.shell, 87 | stderr: "pipe", 88 | }); 89 | 90 | await client.connect(transport); 91 | }; 92 | 93 | const proxy = async () => { 94 | const client = new Client( 95 | { 96 | name: "mcp-proxy", 97 | version: "1.0.0", 98 | }, 99 | { 100 | capabilities: {}, 101 | }, 102 | ); 103 | 104 | await connect(client); 105 | 106 | const serverVersion = client.getServerVersion() as { 107 | name: string; 108 | version: string; 109 | }; 110 | 111 | const serverCapabilities = client.getServerCapabilities() as { 112 | capabilities: Record; 113 | }; 114 | 115 | console.info("starting server on port %d", argv.port); 116 | 117 | const createServer = async () => { 118 | const server = new Server(serverVersion, { 119 | capabilities: serverCapabilities, 120 | }); 121 | 122 | proxyServer({ 123 | client, 124 | server, 125 | serverCapabilities, 126 | }); 127 | 128 | return server; 129 | }; 130 | 131 | await startHTTPServer({ 132 | createServer, 133 | eventStore: new InMemoryEventStore(), 134 | port: argv.port, 135 | sseEndpoint: argv.server && argv.server !== "sse" ? null : (argv.sseEndpoint ?? argv.endpoint), 136 | streamEndpoint: argv.server && argv.server !== "stream" ? null : (argv.streamEndpoint ?? argv.endpoint), 137 | }); 138 | }; 139 | 140 | const main = async () => { 141 | process.on("SIGINT", () => { 142 | console.info("SIGINT received, shutting down"); 143 | 144 | setTimeout(() => { 145 | process.exit(0); 146 | }, 1000); 147 | }); 148 | 149 | try { 150 | await proxy(); 151 | } catch (error) { 152 | console.error("could not start the proxy", error); 153 | 154 | setTimeout(() => { 155 | process.exit(1); 156 | }, 1000); 157 | } 158 | }; 159 | 160 | await main(); 161 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { InMemoryEventStore } from "./InMemoryEventStore.js"; 2 | export { proxyServer } from "./proxyServer.js"; 3 | export { startHTTPServer } from "./startHTTPServer.js"; 4 | export { ServerType, startStdioServer } from "./startStdioServer.js"; 5 | export { tapTransport } from "./tapTransport.js"; 6 | -------------------------------------------------------------------------------- /src/proxyServer.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 2 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 3 | import { 4 | CallToolRequestSchema, 5 | CompleteRequestSchema, 6 | GetPromptRequestSchema, 7 | ListPromptsRequestSchema, 8 | ListResourcesRequestSchema, 9 | ListResourceTemplatesRequestSchema, 10 | ListToolsRequestSchema, 11 | LoggingMessageNotificationSchema, 12 | ReadResourceRequestSchema, 13 | ResourceUpdatedNotificationSchema, 14 | ServerCapabilities, 15 | SubscribeRequestSchema, 16 | UnsubscribeRequestSchema, 17 | } from "@modelcontextprotocol/sdk/types.js"; 18 | 19 | export const proxyServer = async ({ 20 | client, 21 | server, 22 | serverCapabilities, 23 | }: { 24 | client: Client; 25 | server: Server; 26 | serverCapabilities: ServerCapabilities; 27 | }): Promise => { 28 | if (serverCapabilities?.logging) { 29 | server.setNotificationHandler( 30 | LoggingMessageNotificationSchema, 31 | async (args) => { 32 | return client.notification(args); 33 | }, 34 | ); 35 | client.setNotificationHandler( 36 | LoggingMessageNotificationSchema, 37 | async (args) => { 38 | return server.notification(args); 39 | }, 40 | ); 41 | } 42 | 43 | if (serverCapabilities?.prompts) { 44 | server.setRequestHandler(GetPromptRequestSchema, async (args) => { 45 | return client.getPrompt(args.params); 46 | }); 47 | 48 | server.setRequestHandler(ListPromptsRequestSchema, async (args) => { 49 | return client.listPrompts(args.params); 50 | }); 51 | } 52 | 53 | if (serverCapabilities?.resources) { 54 | server.setRequestHandler(ListResourcesRequestSchema, async (args) => { 55 | return client.listResources(args.params); 56 | }); 57 | 58 | server.setRequestHandler( 59 | ListResourceTemplatesRequestSchema, 60 | async (args) => { 61 | return client.listResourceTemplates(args.params); 62 | }, 63 | ); 64 | 65 | server.setRequestHandler(ReadResourceRequestSchema, async (args) => { 66 | return client.readResource(args.params); 67 | }); 68 | 69 | if (serverCapabilities?.resources.subscribe) { 70 | server.setNotificationHandler( 71 | ResourceUpdatedNotificationSchema, 72 | async (args) => { 73 | return client.notification(args); 74 | }, 75 | ); 76 | 77 | server.setRequestHandler(SubscribeRequestSchema, async (args) => { 78 | return client.subscribeResource(args.params); 79 | }); 80 | 81 | server.setRequestHandler(UnsubscribeRequestSchema, async (args) => { 82 | return client.unsubscribeResource(args.params); 83 | }); 84 | } 85 | } 86 | 87 | if (serverCapabilities?.tools) { 88 | server.setRequestHandler(CallToolRequestSchema, async (args) => { 89 | return client.callTool(args.params); 90 | }); 91 | 92 | server.setRequestHandler(ListToolsRequestSchema, async (args) => { 93 | return client.listTools(args.params); 94 | }); 95 | } 96 | 97 | server.setRequestHandler(CompleteRequestSchema, async (args) => { 98 | return client.complete(args.params); 99 | }); 100 | }; 101 | -------------------------------------------------------------------------------- /src/simple-stdio-proxy-server.ts: -------------------------------------------------------------------------------- 1 | import { startStdioServer } from "./startStdioServer.js"; 2 | 3 | await startStdioServer(JSON.parse(process.argv[2])); 4 | -------------------------------------------------------------------------------- /src/simple-stdio-server.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3 | import { 4 | ListResourcesRequestSchema, 5 | ListResourceTemplatesRequestSchema, 6 | ReadResourceRequestSchema, 7 | SubscribeRequestSchema, 8 | UnsubscribeRequestSchema, 9 | } from "@modelcontextprotocol/sdk/types.js"; 10 | 11 | const server = new Server( 12 | { 13 | name: "example-server", 14 | version: "1.0.0", 15 | }, 16 | { 17 | capabilities: { 18 | resources: { subscribe: true }, 19 | }, 20 | }, 21 | ); 22 | 23 | server.setRequestHandler(ListResourcesRequestSchema, async () => { 24 | return { 25 | resources: [ 26 | { 27 | name: "Example Resource", 28 | uri: "file:///example.txt", 29 | }, 30 | ], 31 | }; 32 | }); 33 | 34 | server.setRequestHandler(ReadResourceRequestSchema, async (request) => { 35 | if (request.params.uri === "file:///example.txt") { 36 | return { 37 | contents: [ 38 | { 39 | mimeType: "text/plain", 40 | text: "This is the content of the example resource.", 41 | uri: "file:///example.txt", 42 | }, 43 | ], 44 | }; 45 | } else { 46 | throw new Error("Resource not found"); 47 | } 48 | }); 49 | 50 | server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { 51 | return { 52 | resourceTemplates: [ 53 | { 54 | description: "Specify the filename to retrieve", 55 | name: "Example resource template", 56 | uriTemplate: `file://{filename}`, 57 | }, 58 | ], 59 | }; 60 | }); 61 | 62 | server.setRequestHandler(SubscribeRequestSchema, async () => { 63 | return {}; 64 | }); 65 | 66 | server.setRequestHandler(UnsubscribeRequestSchema, async () => { 67 | return {}; 68 | }); 69 | 70 | const transport = new StdioServerTransport(); 71 | 72 | await server.connect(transport); 73 | -------------------------------------------------------------------------------- /src/startHTTPServer.test.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 2 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; 3 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; 4 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; 5 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 6 | import { EventSource } from "eventsource"; 7 | import { getRandomPort } from "get-port-please"; 8 | import { setTimeout as delay } from "node:timers/promises"; 9 | import { expect, it, vi } from "vitest"; 10 | 11 | import { proxyServer } from "./proxyServer.js"; 12 | import { startHTTPServer } from "./startHTTPServer.js"; 13 | 14 | if (!("EventSource" in global)) { 15 | // @ts-expect-error - figure out how to use --experimental-eventsource with vitest 16 | global.EventSource = EventSource; 17 | } 18 | 19 | it("proxies messages between HTTP stream and stdio servers", async () => { 20 | const stdioTransport = new StdioClientTransport({ 21 | args: ["src/simple-stdio-server.ts"], 22 | command: "tsx", 23 | }); 24 | 25 | const stdioClient = new Client( 26 | { 27 | name: "mcp-proxy", 28 | version: "1.0.0", 29 | }, 30 | { 31 | capabilities: {}, 32 | }, 33 | ); 34 | 35 | await stdioClient.connect(stdioTransport); 36 | 37 | const serverVersion = stdioClient.getServerVersion() as { 38 | name: string; 39 | version: string; 40 | }; 41 | 42 | const serverCapabilities = stdioClient.getServerCapabilities() as { 43 | capabilities: Record; 44 | }; 45 | 46 | const port = await getRandomPort(); 47 | 48 | const onConnect = vi.fn().mockResolvedValue(undefined); 49 | const onClose = vi.fn().mockResolvedValue(undefined); 50 | 51 | await startHTTPServer({ 52 | createServer: async () => { 53 | const mcpServer = new Server(serverVersion, { 54 | capabilities: serverCapabilities, 55 | }); 56 | 57 | await proxyServer({ 58 | client: stdioClient, 59 | server: mcpServer, 60 | serverCapabilities, 61 | }); 62 | 63 | return mcpServer; 64 | }, 65 | onClose, 66 | onConnect, 67 | port, 68 | }); 69 | 70 | const streamClient = new Client( 71 | { 72 | name: "stream-client", 73 | version: "1.0.0", 74 | }, 75 | { 76 | capabilities: {}, 77 | }, 78 | ); 79 | 80 | const transport = new StreamableHTTPClientTransport( 81 | new URL(`http://localhost:${port}/mcp`), 82 | ); 83 | 84 | await streamClient.connect(transport); 85 | 86 | const result = await streamClient.listResources(); 87 | expect(result).toEqual({ 88 | resources: [ 89 | { 90 | name: "Example Resource", 91 | uri: "file:///example.txt", 92 | }, 93 | ], 94 | }); 95 | 96 | expect( 97 | await streamClient.readResource({ uri: result.resources[0].uri }, {}), 98 | ).toEqual({ 99 | contents: [ 100 | { 101 | mimeType: "text/plain", 102 | text: "This is the content of the example resource.", 103 | uri: "file:///example.txt", 104 | }, 105 | ], 106 | }); 107 | expect(await streamClient.subscribeResource({ uri: "xyz" })).toEqual({}); 108 | expect(await streamClient.unsubscribeResource({ uri: "xyz" })).toEqual({}); 109 | expect(await streamClient.listResourceTemplates()).toEqual({ 110 | resourceTemplates: [ 111 | { 112 | description: "Specify the filename to retrieve", 113 | name: "Example resource template", 114 | uriTemplate: `file://{filename}`, 115 | }, 116 | ], 117 | }); 118 | 119 | expect(onConnect).toHaveBeenCalled(); 120 | expect(onClose).not.toHaveBeenCalled(); 121 | 122 | // the transport no requires the function terminateSession to be called but the client does not implement it 123 | // so we need to call it manually 124 | await transport.terminateSession(); 125 | await streamClient.close(); 126 | 127 | await delay(1000); 128 | 129 | expect(onClose).toHaveBeenCalled(); 130 | }); 131 | 132 | it("proxies messages between SSE and stdio servers", async () => { 133 | const stdioTransport = new StdioClientTransport({ 134 | args: ["src/simple-stdio-server.ts"], 135 | command: "tsx", 136 | }); 137 | 138 | const stdioClient = new Client( 139 | { 140 | name: "mcp-proxy", 141 | version: "1.0.0", 142 | }, 143 | { 144 | capabilities: {}, 145 | }, 146 | ); 147 | 148 | await stdioClient.connect(stdioTransport); 149 | 150 | const serverVersion = stdioClient.getServerVersion() as { 151 | name: string; 152 | version: string; 153 | }; 154 | 155 | const serverCapabilities = stdioClient.getServerCapabilities() as { 156 | capabilities: Record; 157 | }; 158 | 159 | const port = await getRandomPort(); 160 | 161 | const onConnect = vi.fn(); 162 | const onClose = vi.fn(); 163 | 164 | await startHTTPServer({ 165 | createServer: async () => { 166 | const mcpServer = new Server(serverVersion, { 167 | capabilities: serverCapabilities, 168 | }); 169 | 170 | await proxyServer({ 171 | client: stdioClient, 172 | server: mcpServer, 173 | serverCapabilities, 174 | }); 175 | 176 | return mcpServer; 177 | }, 178 | onClose, 179 | onConnect, 180 | port, 181 | }); 182 | 183 | const sseClient = new Client( 184 | { 185 | name: "sse-client", 186 | version: "1.0.0", 187 | }, 188 | { 189 | capabilities: {}, 190 | }, 191 | ); 192 | 193 | const transport = new SSEClientTransport( 194 | new URL(`http://localhost:${port}/sse`), 195 | ); 196 | 197 | await sseClient.connect(transport); 198 | 199 | const result = await sseClient.listResources(); 200 | expect(result).toEqual({ 201 | resources: [ 202 | { 203 | name: "Example Resource", 204 | uri: "file:///example.txt", 205 | }, 206 | ], 207 | }); 208 | 209 | expect( 210 | await sseClient.readResource({ uri: result.resources[0].uri }, {}), 211 | ).toEqual({ 212 | contents: [ 213 | { 214 | mimeType: "text/plain", 215 | text: "This is the content of the example resource.", 216 | uri: "file:///example.txt", 217 | }, 218 | ], 219 | }); 220 | expect(await sseClient.subscribeResource({ uri: "xyz" })).toEqual({}); 221 | expect(await sseClient.unsubscribeResource({ uri: "xyz" })).toEqual({}); 222 | expect(await sseClient.listResourceTemplates()).toEqual({ 223 | resourceTemplates: [ 224 | { 225 | description: "Specify the filename to retrieve", 226 | name: "Example resource template", 227 | uriTemplate: `file://{filename}`, 228 | }, 229 | ], 230 | }); 231 | 232 | expect(onConnect).toHaveBeenCalled(); 233 | expect(onClose).not.toHaveBeenCalled(); 234 | 235 | await sseClient.close(); 236 | 237 | await delay(100); 238 | 239 | expect(onClose).toHaveBeenCalled(); 240 | }); 241 | -------------------------------------------------------------------------------- /src/startHTTPServer.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 2 | import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; 3 | import { 4 | EventStore, 5 | StreamableHTTPServerTransport, 6 | } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; 7 | import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; 8 | import http from "http"; 9 | import { randomUUID } from "node:crypto"; 10 | 11 | import { InMemoryEventStore } from "./InMemoryEventStore.js"; 12 | 13 | export type SSEServer = { 14 | close: () => Promise; 15 | }; 16 | 17 | type ServerLike = { 18 | close: Server["close"]; 19 | connect: Server["connect"]; 20 | }; 21 | 22 | const getBody = (request: http.IncomingMessage) => { 23 | return new Promise((resolve) => { 24 | const bodyParts: Buffer[] = []; 25 | let body: string; 26 | request 27 | .on("data", (chunk) => { 28 | bodyParts.push(chunk); 29 | }) 30 | .on("end", () => { 31 | body = Buffer.concat(bodyParts).toString(); 32 | try { 33 | resolve(JSON.parse(body)); 34 | } catch(error) { 35 | console.error("Error parsing body:", error); 36 | resolve(null); 37 | } 38 | }); 39 | }); 40 | }; 41 | 42 | const handleStreamRequest = async ({ 43 | activeTransports, 44 | createServer, 45 | endpoint, 46 | eventStore, 47 | onClose, 48 | onConnect, 49 | req, 50 | res, 51 | }: { 52 | activeTransports: Record< 53 | string, 54 | { server: T; transport: StreamableHTTPServerTransport } 55 | >; 56 | createServer: (request: http.IncomingMessage) => Promise; 57 | endpoint: string; 58 | eventStore?: EventStore; 59 | onClose?: (server: T) => Promise; 60 | onConnect?: (server: T) => Promise; 61 | req: http.IncomingMessage; 62 | res: http.ServerResponse; 63 | }) => { 64 | if ( 65 | req.method === "POST" && 66 | new URL(req.url!, "http://localhost").pathname === endpoint 67 | ) { 68 | try { 69 | const sessionId = Array.isArray(req.headers["mcp-session-id"]) 70 | ? req.headers["mcp-session-id"][0] 71 | : req.headers["mcp-session-id"]; 72 | 73 | let transport: StreamableHTTPServerTransport; 74 | 75 | let server: T; 76 | 77 | const body = await getBody(req); 78 | 79 | if (sessionId && activeTransports[sessionId]) { 80 | transport = activeTransports[sessionId].transport; 81 | server = activeTransports[sessionId].server; 82 | } else if (!sessionId && isInitializeRequest(body)) { 83 | // Create a new transport for the session 84 | transport = new StreamableHTTPServerTransport({ 85 | eventStore: eventStore || new InMemoryEventStore(), 86 | onsessioninitialized: (_sessionId) => { 87 | // add only when the id Sesison id is generated 88 | activeTransports[_sessionId] = { 89 | server, 90 | transport, 91 | }; 92 | }, 93 | sessionIdGenerator: randomUUID, 94 | }); 95 | 96 | // Handle the server close event 97 | transport.onclose = async () => { 98 | const sid = transport.sessionId; 99 | if (sid && activeTransports[sid]) { 100 | if (onClose) { 101 | await onClose(server); 102 | } 103 | 104 | try { 105 | await server.close(); 106 | } catch (error) { 107 | console.error("Error closing server:", error); 108 | } 109 | 110 | delete activeTransports[sid]; 111 | } 112 | }; 113 | 114 | try { 115 | server = await createServer(req); 116 | } catch (error) { 117 | if (error instanceof Response) { 118 | res.writeHead(error.status).end(error.statusText); 119 | 120 | return true; 121 | } 122 | 123 | res.writeHead(500).end("Error creating server"); 124 | 125 | return true; 126 | } 127 | 128 | server.connect(transport); 129 | 130 | if (onConnect) { 131 | await onConnect(server); 132 | } 133 | 134 | await transport.handleRequest(req, res, body); 135 | 136 | return true; 137 | } else { 138 | // Error if the server is not created but the request is not an initialize request 139 | res.setHeader("Content-Type", "application/json"); 140 | 141 | res.writeHead(400).end( 142 | JSON.stringify({ 143 | error: { 144 | code: -32000, 145 | message: "Bad Request: No valid session ID provided", 146 | }, 147 | id: null, 148 | jsonrpc: "2.0", 149 | }), 150 | ); 151 | 152 | return true; 153 | } 154 | 155 | // Handle the request if the server is already created 156 | await transport.handleRequest(req, res, body); 157 | 158 | return true; 159 | } catch (error) { 160 | console.error("Error handling request:", error); 161 | 162 | res.setHeader("Content-Type", "application/json"); 163 | 164 | res.writeHead(500).end( 165 | JSON.stringify({ 166 | error: { code: -32603, message: "Internal Server Error" }, 167 | id: null, 168 | jsonrpc: "2.0", 169 | }), 170 | ); 171 | } 172 | return true; 173 | } 174 | 175 | if ( 176 | req.method === "GET" && 177 | new URL(req.url!, "http://localhost").pathname === endpoint 178 | ) { 179 | const sessionId = req.headers["mcp-session-id"] as string | undefined; 180 | const activeTransport: 181 | | { 182 | server: T; 183 | transport: StreamableHTTPServerTransport; 184 | } 185 | | undefined = sessionId ? activeTransports[sessionId] : undefined; 186 | 187 | if (!sessionId) { 188 | res.writeHead(400).end("No sessionId"); 189 | 190 | return true; 191 | } 192 | 193 | if (!activeTransport) { 194 | res.writeHead(400).end("No active transport"); 195 | 196 | return true; 197 | } 198 | 199 | const lastEventId = req.headers["last-event-id"] as string | undefined; 200 | 201 | if (lastEventId) { 202 | console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); 203 | } else { 204 | console.log(`Establishing new SSE stream for session ${sessionId}`); 205 | } 206 | 207 | await activeTransport.transport.handleRequest(req, res); 208 | 209 | return true; 210 | } 211 | 212 | if ( 213 | req.method === "DELETE" && 214 | new URL(req.url!, "http://localhost").pathname === endpoint 215 | ) { 216 | console.log("received delete request"); 217 | 218 | const sessionId = req.headers["mcp-session-id"] as string | undefined; 219 | 220 | if (!sessionId) { 221 | res.writeHead(400).end("Invalid or missing sessionId"); 222 | 223 | return true; 224 | } 225 | 226 | console.log("received delete request for session", sessionId); 227 | 228 | const activeTransport = activeTransports[sessionId]; 229 | 230 | if (!activeTransport) { 231 | res.writeHead(400).end("No active transport"); 232 | return true; 233 | } 234 | 235 | try { 236 | await activeTransport.transport.handleRequest(req, res); 237 | 238 | if (onClose) { 239 | await onClose(activeTransport.server); 240 | } 241 | } catch (error) { 242 | console.error("Error handling delete request:", error); 243 | 244 | res.writeHead(500).end("Error handling delete request"); 245 | } 246 | 247 | return true; 248 | } 249 | 250 | return false; 251 | }; 252 | 253 | const handleSSERequest = async ({ 254 | activeTransports, 255 | createServer, 256 | endpoint, 257 | onClose, 258 | onConnect, 259 | req, 260 | res, 261 | }: { 262 | activeTransports: Record; 263 | createServer: (request: http.IncomingMessage) => Promise; 264 | endpoint: string; 265 | onClose?: (server: T) => Promise; 266 | onConnect?: (server: T) => Promise; 267 | req: http.IncomingMessage; 268 | res: http.ServerResponse; 269 | }) => { 270 | if ( 271 | req.method === "GET" && 272 | new URL(req.url!, "http://localhost").pathname === endpoint 273 | ) { 274 | const transport = new SSEServerTransport("/messages", res); 275 | 276 | let server: T; 277 | 278 | try { 279 | server = await createServer(req); 280 | } catch (error) { 281 | if (error instanceof Response) { 282 | res.writeHead(error.status).end(error.statusText); 283 | 284 | return true; 285 | } 286 | 287 | res.writeHead(500).end("Error creating server"); 288 | 289 | return true; 290 | } 291 | 292 | activeTransports[transport.sessionId] = transport; 293 | 294 | let closed = false; 295 | 296 | res.on("close", async () => { 297 | closed = true; 298 | 299 | try { 300 | await server.close(); 301 | } catch (error) { 302 | console.error("Error closing server:", error); 303 | } 304 | 305 | delete activeTransports[transport.sessionId]; 306 | 307 | await onClose?.(server); 308 | }); 309 | 310 | try { 311 | await server.connect(transport); 312 | 313 | await transport.send({ 314 | jsonrpc: "2.0", 315 | method: "sse/connection", 316 | params: { message: "SSE Connection established" }, 317 | }); 318 | 319 | if (onConnect) { 320 | await onConnect(server); 321 | } 322 | } catch (error) { 323 | if (!closed) { 324 | console.error("Error connecting to server:", error); 325 | 326 | res.writeHead(500).end("Error connecting to server"); 327 | } 328 | } 329 | 330 | return true; 331 | } 332 | 333 | if (req.method === "POST" && req.url?.startsWith("/messages")) { 334 | const sessionId = new URL(req.url, "https://example.com").searchParams.get( 335 | "sessionId", 336 | ); 337 | 338 | if (!sessionId) { 339 | res.writeHead(400).end("No sessionId"); 340 | 341 | return true; 342 | } 343 | 344 | const activeTransport: SSEServerTransport | undefined = 345 | activeTransports[sessionId]; 346 | 347 | if (!activeTransport) { 348 | res.writeHead(400).end("No active transport"); 349 | 350 | return true; 351 | } 352 | 353 | await activeTransport.handlePostMessage(req, res); 354 | 355 | return true; 356 | } 357 | 358 | return false; 359 | }; 360 | 361 | export const startHTTPServer = async ({ 362 | createServer, 363 | eventStore, 364 | onClose, 365 | onConnect, 366 | onUnhandledRequest, 367 | port, 368 | sseEndpoint, 369 | streamEndpoint, 370 | }: { 371 | createServer: (request: http.IncomingMessage) => Promise; 372 | eventStore?: EventStore; 373 | onClose?: (server: T) => Promise; 374 | onConnect?: (server: T) => Promise; 375 | onUnhandledRequest?: ( 376 | req: http.IncomingMessage, 377 | res: http.ServerResponse, 378 | ) => Promise; 379 | port: number; 380 | sseEndpoint?: null | string; 381 | streamEndpoint?: null | string; 382 | }): Promise => { 383 | const activeSSETransports: Record = {}; 384 | 385 | const activeStreamTransports: Record< 386 | string, 387 | { 388 | server: T; 389 | transport: StreamableHTTPServerTransport; 390 | } 391 | > = {}; 392 | 393 | /** 394 | * @author https://dev.classmethod.jp/articles/mcp-sse/ 395 | */ 396 | const httpServer = http.createServer(async (req, res) => { 397 | if (req.headers.origin) { 398 | try { 399 | const origin = new URL(req.headers.origin); 400 | 401 | res.setHeader("Access-Control-Allow-Origin", origin.origin); 402 | res.setHeader("Access-Control-Allow-Credentials", "true"); 403 | res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); 404 | res.setHeader("Access-Control-Allow-Headers", "*"); 405 | res.setHeader("Access-Control-Expose-Headers", "mcp-session-id"); 406 | } catch (error) { 407 | console.error("Error parsing origin:", error); 408 | } 409 | } 410 | 411 | if (req.method === "OPTIONS") { 412 | res.writeHead(204); 413 | res.end(); 414 | return; 415 | } 416 | 417 | if (req.method === "GET" && req.url === `/ping`) { 418 | res.writeHead(200).end("pong"); 419 | return; 420 | } 421 | 422 | if ( 423 | sseEndpoint !== null && 424 | await handleSSERequest({ 425 | activeTransports: activeSSETransports, 426 | createServer, 427 | endpoint: sseEndpoint ?? "/sse", 428 | onClose, 429 | onConnect, 430 | req, 431 | res, 432 | }) 433 | ) { 434 | return; 435 | } 436 | 437 | if ( 438 | streamEndpoint !== null && 439 | await handleStreamRequest({ 440 | activeTransports: activeStreamTransports, 441 | createServer, 442 | endpoint: streamEndpoint ?? "/mcp", 443 | eventStore, 444 | onClose, 445 | onConnect, 446 | req, 447 | res, 448 | }) 449 | ) { 450 | return; 451 | } 452 | 453 | if (onUnhandledRequest) { 454 | await onUnhandledRequest(req, res); 455 | } else { 456 | res.writeHead(404).end(); 457 | } 458 | }); 459 | 460 | await new Promise((resolve) => { 461 | httpServer.listen(port, "::", () => { 462 | resolve(undefined); 463 | }); 464 | }); 465 | 466 | return { 467 | close: async () => { 468 | for (const transport of Object.values(activeSSETransports)) { 469 | await transport.close(); 470 | } 471 | 472 | for (const transport of Object.values(activeStreamTransports)) { 473 | await transport.transport.close(); 474 | } 475 | 476 | return new Promise((resolve, reject) => { 477 | httpServer.close((error) => { 478 | if (error) { 479 | reject(error); 480 | 481 | return; 482 | } 483 | 484 | resolve(); 485 | }); 486 | }); 487 | }, 488 | }; 489 | }; 490 | -------------------------------------------------------------------------------- /src/startStdioServer.test.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 2 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; 3 | import { 4 | CallToolResultSchema, 5 | LoggingMessageNotificationSchema, 6 | } from "@modelcontextprotocol/sdk/types.js"; 7 | import { EventSource } from "eventsource"; 8 | import { ChildProcess, fork } from "node:child_process"; 9 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; 10 | 11 | import { ServerType } from "./startStdioServer.js"; 12 | 13 | if (!("EventSource" in global)) { 14 | // @ts-expect-error - figure out how to use --experimental-eventsource with vitest 15 | global.EventSource = EventSource; 16 | } 17 | 18 | describe("startStdioServer.test.ts", () => { 19 | let proc: ChildProcess; 20 | 21 | beforeEach(async () => { 22 | const serverPath = require.resolve( 23 | "@modelcontextprotocol/sdk/examples/server/sseAndStreamableHttpCompatibleServer.js", 24 | ); 25 | proc = fork(serverPath, [], { 26 | stdio: "pipe", 27 | }); 28 | await new Promise((resolve) => { 29 | proc.stdout?.on("data", (data) => { 30 | console.log(data.toString()); 31 | data 32 | .toString() 33 | .includes("Backwards compatible MCP server listening on port"); 34 | resolve(null); 35 | }); 36 | }); 37 | }); 38 | 39 | afterEach(async () => { 40 | proc.kill(); 41 | }); 42 | 43 | it("proxies messages between stdio and sse servers", async () => { 44 | const stdioTransport = new StdioClientTransport({ 45 | args: [ 46 | "src/simple-stdio-proxy-server.ts", 47 | JSON.stringify({ 48 | serverType: ServerType.SSE, 49 | url: "http://127.0.0.1:3000/sse", 50 | }), 51 | ], 52 | command: "tsx", 53 | }); 54 | 55 | const stdioClient = new Client( 56 | { 57 | name: "mcp-proxy", 58 | version: "1.0.0", 59 | }, 60 | { 61 | capabilities: {}, 62 | }, 63 | ); 64 | 65 | let notificationCount = 0; 66 | 67 | stdioClient.setNotificationHandler( 68 | LoggingMessageNotificationSchema, 69 | (notification) => { 70 | console.log( 71 | `Notification: ${notification.params.level} - ${notification.params.data}`, 72 | ); 73 | notificationCount++; 74 | }, 75 | ); 76 | 77 | await stdioClient.connect(stdioTransport); 78 | 79 | const result = await stdioClient.listTools(); 80 | 81 | expect(result).toEqual({ 82 | tools: [ 83 | { 84 | description: 85 | "Starts sending periodic notifications for testing resumability", 86 | inputSchema: { 87 | $schema: "http://json-schema.org/draft-07/schema#", 88 | additionalProperties: false, 89 | properties: { 90 | count: { 91 | default: 50, 92 | description: "Number of notifications to send (0 for 100)", 93 | type: "number", 94 | }, 95 | interval: { 96 | default: 100, 97 | description: "Interval in milliseconds between notifications", 98 | type: "number", 99 | }, 100 | }, 101 | type: "object", 102 | }, 103 | name: "start-notification-stream", 104 | }, 105 | ], 106 | }); 107 | const request = { 108 | method: "tools/call", 109 | params: { 110 | arguments: { 111 | count: 2, // Send 5 notifications 112 | interval: 1000, // 1 second between notifications 113 | }, 114 | name: "start-notification-stream", 115 | }, 116 | }; 117 | const notificationResult = await stdioClient.request( 118 | request, 119 | CallToolResultSchema, 120 | ); 121 | 122 | expect(notificationResult).toEqual({ 123 | content: [ 124 | { 125 | text: "Started sending periodic notifications every 1000ms", 126 | type: "text", 127 | }, 128 | ], 129 | }); 130 | 131 | expect(notificationCount).toEqual(2); 132 | 133 | await stdioClient.close(); 134 | }); 135 | 136 | it("proxies messages between stdio and stream able servers", async () => { 137 | const stdioTransport = new StdioClientTransport({ 138 | args: [ 139 | "src/simple-stdio-proxy-server.ts", 140 | JSON.stringify({ 141 | serverType: ServerType.HTTPStream, 142 | url: "http://127.0.0.1:3000/mcp", 143 | }), 144 | ], 145 | command: "tsx", 146 | }); 147 | 148 | const stdioClient = new Client( 149 | { 150 | name: "mcp-proxy", 151 | version: "1.0.0", 152 | }, 153 | { 154 | capabilities: {}, 155 | }, 156 | ); 157 | 158 | let notificationCount = 0; 159 | 160 | stdioClient.setNotificationHandler( 161 | LoggingMessageNotificationSchema, 162 | (notification) => { 163 | console.log( 164 | `Notification: ${notification.params.level} - ${notification.params.data}`, 165 | ); 166 | notificationCount++; 167 | }, 168 | ); 169 | 170 | await stdioClient.connect(stdioTransport); 171 | 172 | const result = await stdioClient.listTools(); 173 | 174 | expect(result).toEqual({ 175 | tools: [ 176 | { 177 | description: 178 | "Starts sending periodic notifications for testing resumability", 179 | inputSchema: { 180 | $schema: "http://json-schema.org/draft-07/schema#", 181 | additionalProperties: false, 182 | properties: { 183 | count: { 184 | default: 50, 185 | description: "Number of notifications to send (0 for 100)", 186 | type: "number", 187 | }, 188 | interval: { 189 | default: 100, 190 | description: "Interval in milliseconds between notifications", 191 | type: "number", 192 | }, 193 | }, 194 | type: "object", 195 | }, 196 | name: "start-notification-stream", 197 | }, 198 | ], 199 | }); 200 | const request = { 201 | method: "tools/call", 202 | params: { 203 | arguments: { 204 | count: 2, // Send 5 notifications 205 | interval: 1000, // 1 second between notifications 206 | }, 207 | name: "start-notification-stream", 208 | }, 209 | }; 210 | const notificationResult = await stdioClient.request( 211 | request, 212 | CallToolResultSchema, 213 | ); 214 | 215 | expect(notificationResult).toEqual({ 216 | content: [ 217 | { 218 | text: "Started sending periodic notifications every 1000ms", 219 | type: "text", 220 | }, 221 | ], 222 | }); 223 | 224 | expect(notificationCount).toEqual(2); 225 | 226 | await stdioClient.close(); 227 | }); 228 | }); 229 | -------------------------------------------------------------------------------- /src/startStdioServer.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 2 | import { SSEClientTransportOptions } from "@modelcontextprotocol/sdk/client/sse.js"; 3 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; 4 | import { StreamableHTTPClientTransportOptions } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; 5 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; 6 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 7 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 8 | 9 | import { proxyServer } from "./proxyServer.js"; 10 | 11 | export enum ServerType { 12 | HTTPStream = "HTTPStream", 13 | SSE = "SSE", 14 | } 15 | 16 | export const startStdioServer = async ({ 17 | initStdioServer, 18 | initStreamClient, 19 | serverType, 20 | transportOptions = {}, 21 | url, 22 | }: { 23 | initStdioServer?: () => Promise; 24 | initStreamClient?: () => Promise; 25 | serverType: ServerType; 26 | transportOptions?: 27 | | SSEClientTransportOptions 28 | | StreamableHTTPClientTransportOptions; 29 | url: string; 30 | }): Promise => { 31 | let transport: SSEClientTransport | StreamableHTTPClientTransport; 32 | switch (serverType) { 33 | case ServerType.SSE: 34 | transport = new SSEClientTransport(new URL(url), transportOptions); 35 | break; 36 | default: 37 | transport = new StreamableHTTPClientTransport( 38 | new URL(url), 39 | transportOptions, 40 | ); 41 | } 42 | const streamClient = initStreamClient 43 | ? await initStreamClient() 44 | : new Client( 45 | { 46 | name: "mcp-proxy", 47 | version: "1.0.0", 48 | }, 49 | { 50 | capabilities: {}, 51 | }, 52 | ); 53 | await streamClient.connect(transport); 54 | 55 | const serverVersion = streamClient.getServerVersion() as { 56 | name: string; 57 | version: string; 58 | }; 59 | 60 | const serverCapabilities = streamClient.getServerCapabilities() as { 61 | capabilities: Record; 62 | }; 63 | 64 | const stdioServer = initStdioServer 65 | ? await initStdioServer() 66 | : new Server(serverVersion, { 67 | capabilities: serverCapabilities, 68 | }); 69 | 70 | const stdioTransport = new StdioServerTransport(); 71 | 72 | await stdioServer.connect(stdioTransport); 73 | 74 | await proxyServer({ 75 | client: streamClient, 76 | server: stdioServer, 77 | serverCapabilities, 78 | }); 79 | 80 | return stdioServer; 81 | }; 82 | -------------------------------------------------------------------------------- /src/tapTransport.ts: -------------------------------------------------------------------------------- 1 | import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; 2 | import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; 3 | 4 | type TransportEvent = 5 | | { 6 | error: Error; 7 | type: "onerror"; 8 | } 9 | | { 10 | message: JSONRPCMessage; 11 | type: "onmessage"; 12 | } 13 | | { 14 | message: JSONRPCMessage; 15 | type: "send"; 16 | } 17 | | { 18 | type: "close"; 19 | } 20 | | { 21 | type: "onclose"; 22 | } 23 | | { 24 | type: "start"; 25 | }; 26 | 27 | export const tapTransport = ( 28 | transport: Transport, 29 | eventHandler: (event: TransportEvent) => void, 30 | ): Transport => { 31 | const originalClose = transport.close.bind(transport); 32 | const originalOnClose = transport.onclose?.bind(transport); 33 | const originalOnError = transport.onerror?.bind(transport); 34 | const originalOnMessage = transport.onmessage?.bind(transport); 35 | const originalSend = transport.send.bind(transport); 36 | const originalStart = transport.start.bind(transport); 37 | 38 | transport.close = async () => { 39 | eventHandler({ 40 | type: "close", 41 | }); 42 | 43 | return originalClose?.(); 44 | }; 45 | 46 | transport.onclose = async () => { 47 | eventHandler({ 48 | type: "onclose", 49 | }); 50 | 51 | return originalOnClose?.(); 52 | }; 53 | 54 | transport.onerror = async (error: Error) => { 55 | eventHandler({ 56 | error, 57 | type: "onerror", 58 | }); 59 | 60 | return originalOnError?.(error); 61 | }; 62 | 63 | transport.onmessage = async (message: JSONRPCMessage) => { 64 | eventHandler({ 65 | message, 66 | type: "onmessage", 67 | }); 68 | 69 | return originalOnMessage?.(message); 70 | }; 71 | 72 | transport.send = async (message: JSONRPCMessage) => { 73 | eventHandler({ 74 | message, 75 | type: "send", 76 | }); 77 | 78 | return originalSend?.(message); 79 | }; 80 | 81 | transport.start = async () => { 82 | eventHandler({ 83 | type: "start", 84 | }); 85 | 86 | return originalStart?.(); 87 | }; 88 | 89 | return transport; 90 | }; 91 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node22/tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "noUnusedLocals": true, 6 | "noUnusedParameters": true 7 | } 8 | } 9 | --------------------------------------------------------------------------------