diff --git a/.gitignore b/.gitignore index 0cd204c..25eb6a4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules .mcp-cli dist +.idea diff --git a/README.md b/README.md index c7f9e73..e45e7a5 100644 --- a/README.md +++ b/README.md @@ -79,12 +79,22 @@ To bypass authentication, or to emit custom headers on all requests to your remo ```json "command": "npx", "args": [ - "-y" + "-y", "mcp-remote", "https://remote.mcp.server/sse" ] ``` +* To use Streamable HTTP instead of Server-Sent Events (SSE), add the `--streamableHttp` flag. This is recommended as SSE is deprecated: + +```json + "args": [ + "mcp-remote", + "https://remote.mcp.server/sse", + "--streamableHttp" + ] +``` + * To force `npx` to always check for an updated version of `mcp-remote`, add the `@latest` flag: ```json diff --git a/package.json b/package.json index 47cfa99..6eb6f3f 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "check": "prettier --check . && tsc" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.9.0", + "@modelcontextprotocol/sdk": "^1.10.2", "express": "^4.21.2", "open": "^10.1.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e550c3e..ad22d61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@modelcontextprotocol/sdk': - specifier: ^1.9.0 - version: 1.9.0 + specifier: ^1.10.2 + version: 1.10.2 express: specifier: ^4.21.2 version: 4.21.2 @@ -217,8 +217,8 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@modelcontextprotocol/sdk@1.9.0': - resolution: {integrity: sha512-Jq2EUCQpe0iyO5FGpzVYDNFR6oR53AIrwph9yWl7uSc7IWUMsrmpmSaTGra5hQNunXpM+9oit85p924jWuHzUA==} + '@modelcontextprotocol/sdk@1.10.2': + resolution: {integrity: sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==} engines: {node: '>=18'} '@pkgjs/parseargs@0.11.0': @@ -1238,7 +1238,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 - '@modelcontextprotocol/sdk@1.9.0': + '@modelcontextprotocol/sdk@1.10.2': dependencies: content-type: 1.0.5 cors: 2.8.5 diff --git a/src/client.ts b/src/client.ts index d620884..b9f2a01 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,7 +2,7 @@ /** * MCP Client with OAuth support - * A command-line client that connects to an MCP server using SSE with OAuth authentication. + * A command-line client that connects to an MCP server using StreamableHTTP with OAuth authentication. * * Run with: npx tsx client.ts https://example.remote/server [callback-port] * @@ -11,6 +11,7 @@ import { EventEmitter } from 'events' import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' @@ -21,7 +22,7 @@ import { coordinateAuth } from './lib/coordination' /** * Main function to run the client */ -async function runClient(serverUrl: string, callbackPort: number, headers: Record) { +async function runClient(serverUrl: string, callbackPort: number, headers: Record, useStreamableHttp: boolean = false) { // Set up event emitter for auth flow const events = new EventEmitter() @@ -60,7 +61,22 @@ async function runClient(serverUrl: string, callbackPort: number, headers: Recor // Create the transport factory const url = new URL(serverUrl) function initTransport() { - const transport = new SSEClientTransport(url, { authProvider, requestInit: { headers } }) + // Choose between Streamable HTTP or SSE transport based on flag + const transport = useStreamableHttp + ? new StreamableHTTPClientTransport(url, { + authProvider, + requestInit: { headers }, + reconnectionOptions: { + initialReconnectionDelay: 1000, + maxReconnectionDelay: 10000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 10, + }, + }) + : new SSEClientTransport(url, { + authProvider, + requestInit: { headers } + }) // Set up message and error handlers transport.onmessage = (message) => { @@ -155,9 +171,9 @@ async function runClient(serverUrl: string, callbackPort: number, headers: Recor } // Parse command-line arguments and run the client -parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts [callback-port]') - .then(({ serverUrl, callbackPort, headers }) => { - return runClient(serverUrl, callbackPort, headers) +parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts [callback-port] [--streamableHttp]') + .then(({ serverUrl, callbackPort, headers, useStreamableHttp }) => { + return runClient(serverUrl, callbackPort, headers, useStreamableHttp) }) .catch((error) => { console.error('Fatal error:', error) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 40c744d..d14ed59 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,4 +1,5 @@ import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { OAuthCallbackServerOptions } from './types' @@ -65,13 +66,14 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo } /** - * Creates and connects to a remote SSE server with OAuth authentication + * Creates and connects to a remote server with OAuth authentication * @param serverUrl The URL of the remote server * @param authProvider The OAuth client provider * @param headers Additional headers to send with the request * @param waitForAuthCode Function to wait for the auth code * @param skipBrowserAuth Whether to skip browser auth and use shared auth - * @returns The connected SSE client transport + * @param useStreamableHttp Whether to use Streamable HTTP transport instead of SSE + * @returns The connected client transport */ export async function connectToRemoteServer( serverUrl: string, @@ -79,32 +81,27 @@ export async function connectToRemoteServer( headers: Record, waitForAuthCode: () => Promise, skipBrowserAuth: boolean = false, -): Promise { + useStreamableHttp: boolean = false, +): Promise { log(`[${pid}] Connecting to remote server: ${serverUrl}`) const url = new URL(serverUrl) - // Create transport with eventSourceInit to pass Authorization header if present - const eventSourceInit = { - fetch: (url: string | URL, init?: RequestInit) => { - return Promise.resolve(authProvider?.tokens?.()).then((tokens) => - fetch(url, { - ...init, - headers: { - ...(init?.headers as Record | undefined), - ...headers, - ...(tokens?.access_token ? { Authorization: `Bearer ${tokens.access_token}` } : {}), - Accept: "text/event-stream", - } as Record, - }) - ); - }, - }; - - const transport = new SSEClientTransport(url, { - authProvider, - requestInit: { headers }, - eventSourceInit, - }) + // Create the appropriate transport (Streamable HTTP or SSE) based on the flag + const transport = useStreamableHttp + ? new StreamableHTTPClientTransport(url, { + authProvider, + requestInit: { headers }, + reconnectionOptions: { + initialReconnectionDelay: 1000, + maxReconnectionDelay: 10000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 10, + }, + }) + : new SSEClientTransport(url, { + authProvider, + requestInit: { headers } + }) try { await transport.start() @@ -125,8 +122,22 @@ export async function connectToRemoteServer( log('Completing authorization...') await transport.finishAuth(code) - // Create a new transport after auth - const newTransport = new SSEClientTransport(url, { authProvider, requestInit: { headers } }) + // Create a new transport (Streamable HTTP or SSE) after auth with the same type as before + const newTransport = useStreamableHttp + ? new StreamableHTTPClientTransport(url, { + authProvider, + requestInit: { headers }, + reconnectionOptions: { + initialReconnectionDelay: 1000, + maxReconnectionDelay: 10000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 10, + }, + }) + : new SSEClientTransport(url, { + authProvider, + requestInit: { headers } + }) await newTransport.start() log('Connected to remote server after authentication') return newTransport @@ -300,6 +311,7 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number, const serverUrl = args[0] const specifiedPort = args[1] ? parseInt(args[1]) : undefined const allowHttp = args.includes('--allow-http') + const useStreamableHttp = args.includes('--streamableHttp') if (!serverUrl) { log(usage) @@ -343,7 +355,7 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number, }) } - return { serverUrl, callbackPort, headers } + return { serverUrl, callbackPort, headers, useStreamableHttp } } /** diff --git a/src/proxy.ts b/src/proxy.ts index 9fd87d1..fb1d74e 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -2,7 +2,7 @@ /** * MCP Proxy with OAuth support - * A bidirectional proxy between a local STDIO MCP server and a remote SSE server with OAuth authentication. + * A bidirectional proxy between a local STDIO MCP server and a remote server with OAuth authentication. * * Run with: npx tsx proxy.ts https://example.remote/server [callback-port] * @@ -18,7 +18,7 @@ import { coordinateAuth } from './lib/coordination' /** * Main function to run the proxy */ -async function runProxy(serverUrl: string, callbackPort: number, headers: Record) { +async function runProxy(serverUrl: string, callbackPort: number, headers: Record, useStreamableHttp: boolean = false) { // Set up event emitter for auth flow const events = new EventEmitter() @@ -48,7 +48,7 @@ async function runProxy(serverUrl: string, callbackPort: number, headers: Record try { // Connect to remote server with authentication - const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, headers, waitForAuthCode, skipBrowserAuth) + const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, headers, waitForAuthCode, skipBrowserAuth, useStreamableHttp) // Set up bidirectional proxy between local and remote transports mcpProxy({ @@ -59,7 +59,7 @@ async function runProxy(serverUrl: string, callbackPort: number, headers: Record // Start the local STDIO server await localTransport.start() log('Local STDIO server running') - log('Proxy established successfully between local STDIO and remote SSE') + log('Proxy established successfully between local STDIO and remote server') log('Press Ctrl+C to exit') // Setup cleanup handler @@ -99,9 +99,9 @@ to the CA certificate file. If using claude_desktop_config.json, this might look } // Parse command-line arguments and run the proxy -parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts [callback-port]') - .then(({ serverUrl, callbackPort, headers }) => { - return runProxy(serverUrl, callbackPort, headers) +parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts [callback-port] [--streamableHttp]') + .then(({ serverUrl, callbackPort, headers, useStreamableHttp }) => { + return runProxy(serverUrl, callbackPort, headers, useStreamableHttp) }) .catch((error) => { log('Fatal error:', error)