feat: support streamable http alongside sse
This commit is contained in:
parent
504aa26761
commit
1d1902208e
7 changed files with 87 additions and 48 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
node_modules
|
||||
.mcp-cli
|
||||
dist
|
||||
.idea
|
||||
|
|
12
README.md
12
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
|
||||
|
|
|
@ -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
10
pnpm-lock.yaml
generated
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,31 +81,26 @@ 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, {
|
||||
// Create the appropriate transport (Streamable HTTP or SSE) based on the flag
|
||||
const transport = useStreamableHttp
|
||||
? new StreamableHTTPClientTransport(url, {
|
||||
authProvider,
|
||||
requestInit: { headers },
|
||||
eventSourceInit,
|
||||
reconnectionOptions: {
|
||||
initialReconnectionDelay: 1000,
|
||||
maxReconnectionDelay: 10000,
|
||||
reconnectionDelayGrowFactor: 1.5,
|
||||
maxRetries: 10,
|
||||
},
|
||||
})
|
||||
: new SSEClientTransport(url, {
|
||||
authProvider,
|
||||
requestInit: { headers }
|
||||
})
|
||||
|
||||
try {
|
||||
|
@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
14
src/proxy.ts
14
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<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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue