Skip to content

Keep alive (ping) mechanism for proxied connections #70

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
68 changes: 66 additions & 2 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 = 30 // seconds

// Transport strategy types
export type TransportStrategy = 'sse-only' | 'http-only' | 'sse-first' | 'http-first'
Expand Down Expand Up @@ -91,6 +92,45 @@ 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 s to ms
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)

log(`Automatic ping enabled with ${config.interval} second 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
Expand Down Expand Up @@ -432,6 +472,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')
Expand Down Expand Up @@ -505,7 +560,16 @@ export async function parseCommandLineArgs(args: string[], usage: string) {
})
}

return { serverUrl, callbackPort, headers, transportStrategy }
return {
serverUrl,
callbackPort,
headers,
transportStrategy,
pingConfig: {
enabled: keepAlive,
interval: pingInterval,
},
}
}

/**
Expand Down
12 changes: 9 additions & 3 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +33,7 @@ async function runProxy(
callbackPort: number,
headers: Record<string, string>,
transportStrategy: TransportStrategy = 'http-first',
pingConfig: PingConfig,
) {
// Set up event emitter for auth flow
const events = new EventEmitter()
Expand Down Expand Up @@ -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,
Expand All @@ -94,6 +99,7 @@ async function runProxy(

// Setup cleanup handler
const cleanup = async () => {
stopPing()
await remoteTransport.close()
await localTransport.close()
// Only close the server if it was initialized
Expand Down Expand Up @@ -136,8 +142,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 <https://server-url> [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)
Expand Down