Skip to content

Commit cc84c2c

Browse files
committed
feat: mcp proxy keep alive (ping) mechanism
1 parent 7eecc9c commit cc84c2c

File tree

3 files changed

+85
-5
lines changed

3 files changed

+85
-5
lines changed

src/lib/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,11 @@ export interface OAuthCallbackServerOptions {
3333
/** Event emitter to signal when auth code is received */
3434
events: EventEmitter
3535
}
36+
37+
/*
38+
* Configuration for the ping mechanism
39+
*/
40+
export interface PingConfig {
41+
enabled: boolean
42+
interval: number
43+
}

src/lib/utils.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
44
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
55
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
66
import { OAuthClientInformationFull, OAuthClientInformationFullSchema } from '@modelcontextprotocol/sdk/shared/auth.js'
7-
import { OAuthCallbackServerOptions } from './types'
7+
import { OAuthCallbackServerOptions, PingConfig } from './types'
88
import { getConfigFilePath, readJsonFile } from './mcp-auth-config'
99
import express from 'express'
1010
import net from 'net'
@@ -14,6 +14,7 @@ import fs from 'fs/promises'
1414
// Connection constants
1515
export const REASON_AUTH_NEEDED = 'authentication-needed'
1616
export const REASON_TRANSPORT_FALLBACK = 'falling-back-to-alternate-transport'
17+
export const PING_INTERVAL_DEFAULT = 30000
1718

1819
// Transport strategy types
1920
export type TransportStrategy = 'sse-only' | 'http-only' | 'sse-first' | 'http-first'
@@ -91,6 +92,44 @@ export type AuthInitializer = () => Promise<{
9192
skipBrowserAuth: boolean
9293
}>
9394

95+
/**
96+
* Sets up periodic ping to keep the connection alive
97+
* @param transport The transport to ping
98+
* @param config Ping configuration
99+
* @returns A cleanup function to stop pinging
100+
*/
101+
export function setupPing(transport: Transport, config: PingConfig): () => void {
102+
if (!config.enabled) {
103+
return () => {}
104+
}
105+
106+
let pingTimeout: NodeJS.Timeout | null = null
107+
let lastPingId = 0
108+
109+
const interval = config.interval * 1000 // convert ms to s
110+
const pingInterval = setInterval(async () => {
111+
const pingId = ++lastPingId
112+
try {
113+
// Docs: https://modelcontextprotocol.io/specification/2025-03-26/basic/utilities/ping
114+
await transport.send({
115+
jsonrpc: '2.0',
116+
id: `ping-${pingId}`,
117+
method: 'ping',
118+
})
119+
log(`Ping ${pingId} successful`)
120+
} catch (error) {
121+
log(`Ping ${pingId} failed:`, error)
122+
}
123+
}, interval)
124+
125+
return () => {
126+
if (pingTimeout) {
127+
clearTimeout(pingTimeout)
128+
}
129+
clearInterval(pingInterval)
130+
}
131+
}
132+
94133
/**
95134
* Creates and connects to a remote server with OAuth authentication
96135
* @param client The client to connect with
@@ -432,6 +471,21 @@ export async function parseCommandLineArgs(args: string[], usage: string) {
432471
i++
433472
}
434473

474+
// Parse ping configuration
475+
const keepAlive = args.includes('--keep-alive')
476+
const pingIntervalIndex = args.indexOf('--ping-interval')
477+
let pingInterval = PING_INTERVAL_DEFAULT
478+
if (pingIntervalIndex !== -1 && pingIntervalIndex < args.length - 1) {
479+
const intervalStr = args[pingIntervalIndex + 1]
480+
const interval = parseInt(intervalStr)
481+
if (!isNaN(interval) && interval > 0) {
482+
pingInterval = interval
483+
log(`Using ping interval: ${pingInterval} seconds`)
484+
} else {
485+
log(`Warning: Invalid ping interval "${args[pingIntervalIndex + 1]}". Using default: ${PING_INTERVAL_DEFAULT} seconds`)
486+
}
487+
}
488+
435489
const serverUrl = args[0]
436490
const specifiedPort = args[1] ? parseInt(args[1]) : undefined
437491
const allowHttp = args.includes('--allow-http')
@@ -505,7 +559,16 @@ export async function parseCommandLineArgs(args: string[], usage: string) {
505559
})
506560
}
507561

508-
return { serverUrl, callbackPort, headers, transportStrategy }
562+
return {
563+
serverUrl,
564+
callbackPort,
565+
headers,
566+
transportStrategy,
567+
pingConfig: {
568+
enabled: keepAlive,
569+
interval: pingInterval,
570+
},
571+
}
509572
}
510573

511574
/**

src/proxy.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ import {
1818
parseCommandLineArgs,
1919
setupSignalHandlers,
2020
getServerUrlHash,
21-
MCP_REMOTE_VERSION,
2221
TransportStrategy,
22+
setupPing,
2323
} from './lib/utils'
2424
import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider'
2525
import { createLazyAuthCoordinator } from './lib/coordination'
26+
import { PingConfig } from './lib/types'
2627

2728
/**
2829
* Main function to run the proxy
@@ -32,6 +33,7 @@ async function runProxy(
3233
callbackPort: number,
3334
headers: Record<string, string>,
3435
transportStrategy: TransportStrategy = 'http-first',
36+
pingConfig: PingConfig,
3537
) {
3638
// Set up event emitter for auth flow
3739
const events = new EventEmitter()
@@ -80,6 +82,9 @@ async function runProxy(
8082
// Connect to remote server with lazy authentication
8183
const remoteTransport = await connectToRemoteServer(null, serverUrl, authProvider, headers, authInitializer, transportStrategy)
8284

85+
// Set up ping mechanism for remote transport
86+
const stopPing = setupPing(remoteTransport, pingConfig)
87+
8388
// Set up bidirectional proxy between local and remote transports
8489
mcpProxy({
8590
transportToClient: localTransport,
@@ -89,11 +94,15 @@ async function runProxy(
8994
// Start the local STDIO server
9095
await localTransport.start()
9196
log('Local STDIO server running')
97+
if (pingConfig.enabled) {
98+
log(`Automatic ping enabled with ${pingConfig.interval} second interval`)
99+
}
92100
log(`Proxy established successfully between local STDIO and remote ${remoteTransport.constructor.name}`)
93101
log('Press Ctrl+C to exit')
94102

95103
// Setup cleanup handler
96104
const cleanup = async () => {
105+
stopPing()
97106
await remoteTransport.close()
98107
await localTransport.close()
99108
// 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
136145

137146
// Parse command-line arguments and run the proxy
138147
parseCommandLineArgs(process.argv.slice(2), 'Usage: npx tsx proxy.ts <https://server-url> [callback-port]')
139-
.then(({ serverUrl, callbackPort, headers, transportStrategy }) => {
140-
return runProxy(serverUrl, callbackPort, headers, transportStrategy)
148+
.then(({ serverUrl, callbackPort, headers, transportStrategy, pingConfig }) => {
149+
return runProxy(serverUrl, callbackPort, headers, transportStrategy, pingConfig)
141150
})
142151
.catch((error) => {
143152
log('Fatal error:', error)

0 commit comments

Comments
 (0)