From cc84c2ce105addd407c3e0a45d8b40e05856d1ba Mon Sep 17 00:00:00 2001 From: justin Date: Sat, 10 May 2025 12:32:55 -0400 Subject: [PATCH 1/3] feat: mcp proxy keep alive (ping) mechanism --- src/lib/types.ts | 8 ++++++ src/lib/utils.ts | 67 ++++++++++++++++++++++++++++++++++++++++++++++-- src/proxy.ts | 15 ++++++++--- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/lib/types.ts b/src/lib/types.ts index 723b93f..dbf824e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -33,3 +33,11 @@ export interface OAuthCallbackServerOptions { /** Event emitter to signal when auth code is received */ events: EventEmitter } + +/* + * Configuration for the ping mechanism + */ +export interface PingConfig { + enabled: boolean + interval: number +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a0a60dc..2feb5cf 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,7 +4,7 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { OAuthClientInformationFull, OAuthClientInformationFullSchema } from '@modelcontextprotocol/sdk/shared/auth.js' -import { OAuthCallbackServerOptions } from './types' +import { OAuthCallbackServerOptions, PingConfig } from './types' import { getConfigFilePath, readJsonFile } from './mcp-auth-config' import express from 'express' import net from 'net' @@ -14,6 +14,7 @@ import fs from 'fs/promises' // Connection constants export const REASON_AUTH_NEEDED = 'authentication-needed' export const REASON_TRANSPORT_FALLBACK = 'falling-back-to-alternate-transport' +export const PING_INTERVAL_DEFAULT = 30000 // Transport strategy types export type TransportStrategy = 'sse-only' | 'http-only' | 'sse-first' | 'http-first' @@ -91,6 +92,44 @@ export type AuthInitializer = () => Promise<{ skipBrowserAuth: boolean }> +/** + * Sets up periodic ping to keep the connection alive + * @param transport The transport to ping + * @param config Ping configuration + * @returns A cleanup function to stop pinging + */ +export function setupPing(transport: Transport, config: PingConfig): () => void { + if (!config.enabled) { + return () => {} + } + + let pingTimeout: NodeJS.Timeout | null = null + let lastPingId = 0 + + const interval = config.interval * 1000 // convert ms to s + const pingInterval = setInterval(async () => { + const pingId = ++lastPingId + try { + // Docs: https://modelcontextprotocol.io/specification/2025-03-26/basic/utilities/ping + await transport.send({ + jsonrpc: '2.0', + id: `ping-${pingId}`, + method: 'ping', + }) + log(`Ping ${pingId} successful`) + } catch (error) { + log(`Ping ${pingId} failed:`, error) + } + }, interval) + + return () => { + if (pingTimeout) { + clearTimeout(pingTimeout) + } + clearInterval(pingInterval) + } +} + /** * Creates and connects to a remote server with OAuth authentication * @param client The client to connect with @@ -432,6 +471,21 @@ export async function parseCommandLineArgs(args: string[], usage: string) { i++ } + // Parse ping configuration + const keepAlive = args.includes('--keep-alive') + const pingIntervalIndex = args.indexOf('--ping-interval') + let pingInterval = PING_INTERVAL_DEFAULT + if (pingIntervalIndex !== -1 && pingIntervalIndex < args.length - 1) { + const intervalStr = args[pingIntervalIndex + 1] + const interval = parseInt(intervalStr) + if (!isNaN(interval) && interval > 0) { + pingInterval = interval + log(`Using ping interval: ${pingInterval} seconds`) + } else { + log(`Warning: Invalid ping interval "${args[pingIntervalIndex + 1]}". Using default: ${PING_INTERVAL_DEFAULT} seconds`) + } + } + const serverUrl = args[0] const specifiedPort = args[1] ? parseInt(args[1]) : undefined const allowHttp = args.includes('--allow-http') @@ -505,7 +559,16 @@ export async function parseCommandLineArgs(args: string[], usage: string) { }) } - return { serverUrl, callbackPort, headers, transportStrategy } + return { + serverUrl, + callbackPort, + headers, + transportStrategy, + pingConfig: { + enabled: keepAlive, + interval: pingInterval, + }, + } } /** diff --git a/src/proxy.ts b/src/proxy.ts index 535bfe2..199da09 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -18,11 +18,12 @@ import { parseCommandLineArgs, setupSignalHandlers, getServerUrlHash, - MCP_REMOTE_VERSION, TransportStrategy, + setupPing, } from './lib/utils' import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider' import { createLazyAuthCoordinator } from './lib/coordination' +import { PingConfig } from './lib/types' /** * Main function to run the proxy @@ -32,6 +33,7 @@ async function runProxy( callbackPort: number, headers: Record, transportStrategy: TransportStrategy = 'http-first', + pingConfig: PingConfig, ) { // Set up event emitter for auth flow const events = new EventEmitter() @@ -80,6 +82,9 @@ async function runProxy( // Connect to remote server with lazy authentication const remoteTransport = await connectToRemoteServer(null, serverUrl, authProvider, headers, authInitializer, transportStrategy) + // Set up ping mechanism for remote transport + const stopPing = setupPing(remoteTransport, pingConfig) + // Set up bidirectional proxy between local and remote transports mcpProxy({ transportToClient: localTransport, @@ -89,11 +94,15 @@ async function runProxy( // Start the local STDIO server await localTransport.start() log('Local STDIO server running') + if (pingConfig.enabled) { + log(`Automatic ping enabled with ${pingConfig.interval} second interval`) + } log(`Proxy established successfully between local STDIO and remote ${remoteTransport.constructor.name}`) log('Press Ctrl+C to exit') // Setup cleanup handler const cleanup = async () => { + stopPing() await remoteTransport.close() await localTransport.close() // Only close the server if it was initialized @@ -136,8 +145,8 @@ 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), 'Usage: npx tsx proxy.ts [callback-port]') - .then(({ serverUrl, callbackPort, headers, transportStrategy }) => { - return runProxy(serverUrl, callbackPort, headers, transportStrategy) + .then(({ serverUrl, callbackPort, headers, transportStrategy, pingConfig }) => { + return runProxy(serverUrl, callbackPort, headers, transportStrategy, pingConfig) }) .catch((error) => { log('Fatal error:', error) From 9fc56bb7b7d2caf02b04680781b7374581e2b4ad Mon Sep 17 00:00:00 2001 From: justin Date: Sun, 11 May 2025 15:16:03 -0400 Subject: [PATCH 2/3] chore: add keep-alive flag to readme --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index bc0a4a7..267ebd2 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,16 @@ To bypass authentication, or to emit custom headers on all requests to your remo ] ``` +* If the remote server is automatically closing your connection while not actively being used (e.g., disconnects after 5 minutes of inactivity) you can add the `--keep-alive` flag to ping the server every 30 seconds. The interval can also be customized using `--ping-interval`. + +```json + "args": [ + "mcp-remote", + "https://remote.mcp.server/sse", + "--keep-alive" + ] +``` + * To allow HTTP connections in trusted private networks, add the `--allow-http` flag. Note: This should only be used in secure private networks where traffic cannot be intercepted. ```json From 9eba3a0d1dd86bf538c9c63c92da6b5ec2ced8e0 Mon Sep 17 00:00:00 2001 From: justin Date: Mon, 12 May 2025 19:47:22 -0400 Subject: [PATCH 3/3] fix: default ping interval in secs --- src/lib/utils.ts | 5 +++-- src/proxy.ts | 3 --- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2feb5cf..73d8b1f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -14,7 +14,7 @@ import fs from 'fs/promises' // Connection constants export const REASON_AUTH_NEEDED = 'authentication-needed' export const REASON_TRANSPORT_FALLBACK = 'falling-back-to-alternate-transport' -export const PING_INTERVAL_DEFAULT = 30000 +export const PING_INTERVAL_DEFAULT = 30 // seconds // Transport strategy types export type TransportStrategy = 'sse-only' | 'http-only' | 'sse-first' | 'http-first' @@ -106,7 +106,7 @@ export function setupPing(transport: Transport, config: PingConfig): () => void let pingTimeout: NodeJS.Timeout | null = null let lastPingId = 0 - const interval = config.interval * 1000 // convert ms to s + const interval = config.interval * 1000 // convert s to ms const pingInterval = setInterval(async () => { const pingId = ++lastPingId try { @@ -122,6 +122,7 @@ export function setupPing(transport: Transport, config: PingConfig): () => void } }, interval) + log(`Automatic ping enabled with ${config.interval} second interval`) return () => { if (pingTimeout) { clearTimeout(pingTimeout) diff --git a/src/proxy.ts b/src/proxy.ts index 199da09..f75023a 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -94,9 +94,6 @@ async function runProxy( // Start the local STDIO server await localTransport.start() log('Local STDIO server running') - if (pingConfig.enabled) { - log(`Automatic ping enabled with ${pingConfig.interval} second interval`) - } log(`Proxy established successfully between local STDIO and remote ${remoteTransport.constructor.name}`) log('Press Ctrl+C to exit')