feat: support streamable http alongside sse

This commit is contained in:
Bar Hochman 2025-04-28 18:10:19 +03:00 committed by GitHub
parent 504aa26761
commit 1d1902208e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 87 additions and 48 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
node_modules node_modules
.mcp-cli .mcp-cli
dist dist
.idea

View file

@ -79,12 +79,22 @@ To bypass authentication, or to emit custom headers on all requests to your remo
```json ```json
"command": "npx", "command": "npx",
"args": [ "args": [
"-y" "-y",
"mcp-remote", "mcp-remote",
"https://remote.mcp.server/sse" "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: * To force `npx` to always check for an updated version of `mcp-remote`, add the `@latest` flag:
```json ```json

View file

@ -28,7 +28,7 @@
"check": "prettier --check . && tsc" "check": "prettier --check . && tsc"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.9.0", "@modelcontextprotocol/sdk": "^1.10.2",
"express": "^4.21.2", "express": "^4.21.2",
"open": "^10.1.0" "open": "^10.1.0"
}, },

10
pnpm-lock.yaml generated
View file

@ -9,8 +9,8 @@ importers:
.: .:
dependencies: dependencies:
'@modelcontextprotocol/sdk': '@modelcontextprotocol/sdk':
specifier: ^1.9.0 specifier: ^1.10.2
version: 1.9.0 version: 1.10.2
express: express:
specifier: ^4.21.2 specifier: ^4.21.2
version: 4.21.2 version: 4.21.2
@ -217,8 +217,8 @@ packages:
'@jridgewell/trace-mapping@0.3.25': '@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@modelcontextprotocol/sdk@1.9.0': '@modelcontextprotocol/sdk@1.10.2':
resolution: {integrity: sha512-Jq2EUCQpe0iyO5FGpzVYDNFR6oR53AIrwph9yWl7uSc7IWUMsrmpmSaTGra5hQNunXpM+9oit85p924jWuHzUA==} resolution: {integrity: sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
@ -1238,7 +1238,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
'@modelcontextprotocol/sdk@1.9.0': '@modelcontextprotocol/sdk@1.10.2':
dependencies: dependencies:
content-type: 1.0.5 content-type: 1.0.5
cors: 2.8.5 cors: 2.8.5

View file

@ -2,7 +2,7 @@
/** /**
* MCP Client with OAuth support * 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] * Run with: npx tsx client.ts https://example.remote/server [callback-port]
* *
@ -11,6 +11,7 @@
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { Client } from '@modelcontextprotocol/sdk/client/index.js' 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 { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
@ -21,7 +22,7 @@ import { coordinateAuth } from './lib/coordination'
/** /**
* Main function to run the client * Main function to run the client
*/ */
async function runClient(serverUrl: string, callbackPort: number, headers: Record<string, string>) { async function runClient(serverUrl: string, callbackPort: number, headers: Record<string, string>, useStreamableHttp: boolean = false) {
// Set up event emitter for auth flow // Set up event emitter for auth flow
const events = new EventEmitter() const events = new EventEmitter()
@ -60,7 +61,22 @@ async function runClient(serverUrl: string, callbackPort: number, headers: Recor
// Create the transport factory // Create the transport factory
const url = new URL(serverUrl) const url = new URL(serverUrl)
function initTransport() { 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 // Set up message and error handlers
transport.onmessage = (message) => { transport.onmessage = (message) => {
@ -155,9 +171,9 @@ async function runClient(serverUrl: string, callbackPort: number, headers: Recor
} }
// Parse command-line arguments and run the client // Parse command-line arguments and run the client
parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts <https://server-url> [callback-port]') parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts <https://server-url> [callback-port] [--streamableHttp]')
.then(({ serverUrl, callbackPort, headers }) => { .then(({ serverUrl, callbackPort, headers, useStreamableHttp }) => {
return runClient(serverUrl, callbackPort, headers) return runClient(serverUrl, callbackPort, headers, useStreamableHttp)
}) })
.catch((error) => { .catch((error) => {
console.error('Fatal error:', error) console.error('Fatal error:', error)

View file

@ -1,4 +1,5 @@
import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' 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 { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import { OAuthCallbackServerOptions } from './types' 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 serverUrl The URL of the remote server
* @param authProvider The OAuth client provider * @param authProvider The OAuth client provider
* @param headers Additional headers to send with the request * @param headers Additional headers to send with the request
* @param waitForAuthCode Function to wait for the auth code * @param waitForAuthCode Function to wait for the auth code
* @param skipBrowserAuth Whether to skip browser auth and use shared auth * @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( export async function connectToRemoteServer(
serverUrl: string, serverUrl: string,
@ -79,32 +81,27 @@ export async function connectToRemoteServer(
headers: Record<string, string>, headers: Record<string, string>,
waitForAuthCode: () => Promise<string>, waitForAuthCode: () => Promise<string>,
skipBrowserAuth: boolean = false, skipBrowserAuth: boolean = false,
): Promise<SSEClientTransport> { useStreamableHttp: boolean = false,
): Promise<StreamableHTTPClientTransport | SSEClientTransport> {
log(`[${pid}] Connecting to remote server: ${serverUrl}`) log(`[${pid}] Connecting to remote server: ${serverUrl}`)
const url = new URL(serverUrl) const url = new URL(serverUrl)
// Create transport with eventSourceInit to pass Authorization header if present // Create the appropriate transport (Streamable HTTP or SSE) based on the flag
const eventSourceInit = { const transport = useStreamableHttp
fetch: (url: string | URL, init?: RequestInit) => { ? new StreamableHTTPClientTransport(url, {
return Promise.resolve(authProvider?.tokens?.()).then((tokens) => authProvider,
fetch(url, { requestInit: { headers },
...init, reconnectionOptions: {
headers: { initialReconnectionDelay: 1000,
...(init?.headers as Record<string, string> | undefined), maxReconnectionDelay: 10000,
...headers, reconnectionDelayGrowFactor: 1.5,
...(tokens?.access_token ? { Authorization: `Bearer ${tokens.access_token}` } : {}), maxRetries: 10,
Accept: "text/event-stream", },
} as Record<string, string>, })
}) : new SSEClientTransport(url, {
); authProvider,
}, requestInit: { headers }
}; })
const transport = new SSEClientTransport(url, {
authProvider,
requestInit: { headers },
eventSourceInit,
})
try { try {
await transport.start() await transport.start()
@ -125,8 +122,22 @@ export async function connectToRemoteServer(
log('Completing authorization...') log('Completing authorization...')
await transport.finishAuth(code) await transport.finishAuth(code)
// Create a new transport after auth // Create a new transport (Streamable HTTP or SSE) after auth with the same type as before
const newTransport = new SSEClientTransport(url, { authProvider, requestInit: { headers } }) 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() await newTransport.start()
log('Connected to remote server after authentication') log('Connected to remote server after authentication')
return newTransport return newTransport
@ -300,6 +311,7 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number,
const serverUrl = args[0] const serverUrl = args[0]
const specifiedPort = args[1] ? parseInt(args[1]) : undefined const specifiedPort = args[1] ? parseInt(args[1]) : undefined
const allowHttp = args.includes('--allow-http') const allowHttp = args.includes('--allow-http')
const useStreamableHttp = args.includes('--streamableHttp')
if (!serverUrl) { if (!serverUrl) {
log(usage) log(usage)
@ -343,7 +355,7 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number,
}) })
} }
return { serverUrl, callbackPort, headers } return { serverUrl, callbackPort, headers, useStreamableHttp }
} }
/** /**

View file

@ -2,7 +2,7 @@
/** /**
* MCP Proxy with OAuth support * 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] * 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 * Main function to run the proxy
*/ */
async function runProxy(serverUrl: string, callbackPort: number, headers: Record<string, string>) { async function runProxy(serverUrl: string, callbackPort: number, headers: Record<string, string>, useStreamableHttp: boolean = false) {
// Set up event emitter for auth flow // Set up event emitter for auth flow
const events = new EventEmitter() const events = new EventEmitter()
@ -48,7 +48,7 @@ async function runProxy(serverUrl: string, callbackPort: number, headers: Record
try { try {
// Connect to remote server with authentication // 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 // Set up bidirectional proxy between local and remote transports
mcpProxy({ mcpProxy({
@ -59,7 +59,7 @@ async function runProxy(serverUrl: string, callbackPort: number, headers: Record
// Start the local STDIO server // Start the local STDIO server
await localTransport.start() await localTransport.start()
log('Local STDIO server running') 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') log('Press Ctrl+C to exit')
// Setup cleanup handler // 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 // Parse command-line arguments and run the proxy
parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts <https://server-url> [callback-port]') parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts <https://server-url> [callback-port] [--streamableHttp]')
.then(({ serverUrl, callbackPort, headers }) => { .then(({ serverUrl, callbackPort, headers, useStreamableHttp }) => {
return runProxy(serverUrl, callbackPort, headers) return runProxy(serverUrl, callbackPort, headers, useStreamableHttp)
}) })
.catch((error) => { .catch((error) => {
log('Fatal error:', error) log('Fatal error:', error)