diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index edb08ee58..469b3a594 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -140,6 +140,26 @@ export function getSupportedElicitationModes(capabilities: ClientCapabilities['e return { supportsFormMode, supportsUrlMode }; } +/** + * Configuration options for periodic ping to monitor connection health. + * + * According to the MCP specification, implementations SHOULD periodically issue + * pings to detect connection health. + */ +export interface PingConfig { + /** + * Whether periodic pings are enabled. + * @default false + */ + enabled?: boolean; + + /** + * Interval between periodic pings in milliseconds. + * @default 30000 (30 seconds) + */ + intervalMs?: number; +} + export type ClientOptions = ProtocolOptions & { /** * Capabilities to advertise as being supported by this client. @@ -183,6 +203,27 @@ export type ClientOptions = ProtocolOptions & { * ``` */ listChanged?: ListChangedHandlers; + + /** + * Configure periodic ping to monitor connection health. + * + * When enabled, the client will automatically send ping requests at the + * specified interval after successfully connecting to the server. + * + * @example + * ```ts + * const client = new Client( + * { name: 'my-client', version: '1.0.0' }, + * { + * ping: { + * enabled: true, + * intervalMs: 60000 // Ping every 60 seconds + * } + * } + * ); + * ``` + */ + ping?: PingConfig; }; /** @@ -204,6 +245,8 @@ export class Client extends Protocol { private _listChangedDebounceTimers: Map> = new Map(); private _pendingListChangedConfig?: ListChangedHandlers; private _enforceStrictCapabilities: boolean; + private _pingConfig: PingConfig; + private _pingInterval?: ReturnType; /** * Initializes this client with the given name and version information. @@ -216,6 +259,10 @@ export class Client extends Protocol { this._capabilities = options?.capabilities ?? {}; this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; + this._pingConfig = { + enabled: options?.ping?.enabled ?? false, + intervalMs: options?.ping?.intervalMs ?? 30_000 + }; // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { @@ -514,6 +561,9 @@ export class Client extends Protocol { this._setupListChangedHandlers(this._pendingListChangedConfig); this._pendingListChangedConfig = undefined; } + + // Start periodic ping if enabled + this._startPeriodicPing(); } catch (error) { // Disconnect if initialization fails. void this.close(); @@ -542,6 +592,46 @@ export class Client extends Protocol { return this._instructions; } + /** + * Closes the connection and stops periodic ping if running. + */ + override async close(): Promise { + this._stopPeriodicPing(); + await super.close(); + } + + /** + * Starts periodic ping to monitor connection health. + * @internal + */ + private _startPeriodicPing(): void { + if (!this._pingConfig.enabled || this._pingInterval) { + return; + } + + this._pingInterval = setInterval(async () => { + try { + await this.ping(); + } catch (error) { + // Ping failed - connection may be unhealthy + // Emit error but don't stop the interval - let it retry + const errorMessage = error instanceof Error ? error.message : String(error); + this.onerror?.(new Error(`Periodic ping failed: ${errorMessage}`)); + } + }, this._pingConfig.intervalMs); + } + + /** + * Stops periodic ping. + * @internal + */ + private _stopPeriodicPing(): void { + if (this._pingInterval) { + clearInterval(this._pingInterval); + this._pingInterval = undefined; + } + } + protected assertCapabilityForMethod(method: RequestMethod): void { switch (method as ClientRequest['method']) { case 'logging/setLevel': { diff --git a/packages/client/test/client/ping.test.ts b/packages/client/test/client/ping.test.ts new file mode 100644 index 000000000..0523f98bd --- /dev/null +++ b/packages/client/test/client/ping.test.ts @@ -0,0 +1,247 @@ +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { Client } from '../../src/client/client.js'; +import type { Transport, TransportSendOptions } from '@modelcontextprotocol/core'; + +// Mock Transport class that responds to initialize requests +class MockTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: unknown) => void; + sessionId?: string; + + async start(): Promise {} + async close(): Promise { + this.onclose?.(); + } + async send(message: JSONRPCMessage, _options?: TransportSendOptions): Promise { + // Respond to initialize requests so connect() doesn't hang + if ('method' in message && message.method === 'initialize' && 'id' in message) { + // Use queueMicrotask to simulate async response + queueMicrotask(() => { + this.onmessage?.({ + jsonrpc: '2.0', + id: message.id as string | number, + result: { + protocolVersion: '2024-11-05', + capabilities: {}, + serverInfo: { name: 'test-server', version: '1.0.0' } + } + }); + }); + } + // Respond to ping requests + if ('method' in message && message.method === 'ping' && 'id' in message) { + queueMicrotask(() => { + this.onmessage?.({ + jsonrpc: '2.0', + id: message.id as string | number, + result: {} + }); + }); + } + } +} + +// Helper interface to access private members for testing +interface TestClient { + _pingConfig: { enabled: boolean; intervalMs: number }; + _pingInterval?: ReturnType; +} + +describe('Client periodic ping', () => { + let transport: MockTransport; + let client: Client; + let pingCalls: number; + + beforeEach(() => { + transport = new MockTransport(); + pingCalls = 0; + + // Override ping method to track calls + client = new Client( + { name: 'test-client', version: '1.0.0' }, + { + ping: { + enabled: true, + intervalMs: 100 + } + } + ); + + // Mock the internal _requestWithSchema to track ping calls + const originalRequest = (client as unknown as { _requestWithSchema: (...args: unknown[]) => Promise })._requestWithSchema; + (client as unknown as { _requestWithSchema: (...args: unknown[]) => Promise })._requestWithSchema = async ( + ...args: unknown[] + ) => { + const request = args[0] as { method: string }; + if (request?.method === 'ping') { + pingCalls++; + return {}; + } + return originalRequest.apply(client, args); + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should not start periodic ping when disabled', async () => { + const disabledClient = new Client( + { name: 'test-client', version: '1.0.0' }, + { + ping: { + enabled: false, + intervalMs: 100 + } + } + ); + + await disabledClient.connect(transport); + + // Wait longer than the ping interval + await new Promise(resolve => setTimeout(resolve, 150)); + + // Ping should not have been called + expect(pingCalls).toBe(0); + + await disabledClient.close(); + }); + + it('should start periodic ping when enabled', async () => { + await client.connect(transport); + + // Wait for at least one ping interval + await new Promise(resolve => setTimeout(resolve, 150)); + + // Ping should have been called at least once + expect(pingCalls).toBeGreaterThan(0); + + await client.close(); + }); + + it('should stop periodic ping on close', async () => { + await client.connect(transport); + + // Wait for a ping + await new Promise(resolve => setTimeout(resolve, 150)); + const callCountAfterFirst = pingCalls; + + // Close the client + await client.close(); + + // Wait longer than ping interval + await new Promise(resolve => setTimeout(resolve, 200)); + + // No additional pings should have been made + expect(pingCalls).toBe(callCountAfterFirst); + }); + + it('should use custom interval', async () => { + const customIntervalClient = new Client( + { name: 'test-client', version: '1.0.0' }, + { + ping: { + enabled: true, + intervalMs: 200 + } + } + ); + + let customPingCalls = 0; + const originalRequest = (customIntervalClient as unknown as { _requestWithSchema: (...args: unknown[]) => Promise }) + ._requestWithSchema; + (customIntervalClient as unknown as { _requestWithSchema: (...args: unknown[]) => Promise })._requestWithSchema = async ( + ...args: unknown[] + ) => { + const request = args[0] as { method: string }; + if (request?.method === 'ping') { + customPingCalls++; + return {}; + } + return originalRequest.apply(customIntervalClient, args); + }; + + await customIntervalClient.connect(transport); + + // Wait 100ms (less than interval) + await new Promise(resolve => setTimeout(resolve, 100)); + expect(customPingCalls).toBe(0); + + // Wait another 150ms (total 250ms, more than 200ms interval) + await new Promise(resolve => setTimeout(resolve, 150)); + expect(customPingCalls).toBeGreaterThan(0); + + await customIntervalClient.close(); + }); + + it('should handle ping errors gracefully', async () => { + const errors: Error[] = []; + client.onerror = (error: Error) => { + errors.push(error); + }; + + // Mock ping to fail + const originalRequest = (client as unknown as { _requestWithSchema: (...args: unknown[]) => Promise })._requestWithSchema; + (client as unknown as { _requestWithSchema: (...args: unknown[]) => Promise })._requestWithSchema = async ( + ...args: unknown[] + ) => { + const request = args[0] as { method: string }; + if (request?.method === 'ping') { + throw new Error('Ping failed'); + } + return originalRequest.apply(client, args); + }; + + await client.connect(transport); + + // Wait for ping to fail + await new Promise(resolve => setTimeout(resolve, 150)); + + // Should have recorded error + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]?.message).toContain('Periodic ping failed'); + }); +}); + +describe('Client periodic ping configuration', () => { + it('should use default values when ping config is not provided', () => { + const defaultClient = new Client({ name: 'test', version: '1.0.0' }); + const testClient = defaultClient as unknown as TestClient; + + expect(testClient._pingConfig.enabled).toBe(false); + expect(testClient._pingConfig.intervalMs).toBe(30000); + }); + + it('should use provided ping config values', () => { + const configuredClient = new Client( + { name: 'test', version: '1.0.0' }, + { + ping: { + enabled: true, + intervalMs: 60000 + } + } + ); + const testClient = configuredClient as unknown as TestClient; + + expect(testClient._pingConfig.enabled).toBe(true); + expect(testClient._pingConfig.intervalMs).toBe(60000); + }); + + it('should use partial ping config with defaults', () => { + const partialClient = new Client( + { name: 'test', version: '1.0.0' }, + { + ping: { + intervalMs: 45000 + } + } + ); + const testClient = partialClient as unknown as TestClient; + + expect(testClient._pingConfig.enabled).toBe(false); + expect(testClient._pingConfig.intervalMs).toBe(45000); + }); +});