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
.mcp-cli
dist
.idea

View file

@ -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

View file

@ -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"
},

10
pnpm-lock.yaml generated
View file

@ -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

View file

@ -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<string, string>) {
async function runClient(serverUrl: string, callbackPort: number, headers: Record<string, string>, 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 <https://server-url> [callback-port]')
.then(({ serverUrl, callbackPort, headers }) => {
return runClient(serverUrl, callbackPort, headers)
parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts <https://server-url> [callback-port] [--streamableHttp]')
.then(({ serverUrl, callbackPort, headers, useStreamableHttp }) => {
return runClient(serverUrl, callbackPort, headers, useStreamableHttp)
})
.catch((error) => {
console.error('Fatal error:', error)

View file

@ -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<string, string>,
waitForAuthCode: () => Promise<string>,
skipBrowserAuth: boolean = false,
): Promise<SSEClientTransport> {
useStreamableHttp: boolean = false,
): Promise<StreamableHTTPClientTransport | SSEClientTransport> {
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<string, string> | undefined),
...headers,
...(tokens?.access_token ? { Authorization: `Bearer ${tokens.access_token}` } : {}),
Accept: "text/event-stream",
} as Record<string, string>,
})
);
},
};
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 }
}
/**

View file

@ -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<string, string>) {
async function runProxy(serverUrl: string, callbackPort: number, headers: Record<string, string>, 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 <https://server-url> [callback-port]')
.then(({ serverUrl, callbackPort, headers }) => {
return runProxy(serverUrl, callbackPort, headers)
parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts <https://server-url> [callback-port] [--streamableHttp]')
.then(({ serverUrl, callbackPort, headers, useStreamableHttp }) => {
return runProxy(serverUrl, callbackPort, headers, useStreamableHttp)
})
.catch((error) => {
log('Fatal error:', error)