From ad869cf825ff411180de4a391e79b6a3e2298dbe Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 11:21:10 +0000 Subject: [PATCH 1/3] feat(client): add periodic ping support for connection health monitoring Add configurable periodic ping functionality to the Client class as specified in the MCP protocol. This allows automatic connection health monitoring by sending ping requests at a configurable interval. Changes: - Add PingConfig interface with enabled and intervalMs options - Add ping configuration option to ClientOptions - Start periodic ping after successful connection initialization - Stop periodic ping on client close - Emit errors via onerror callback when ping fails - Default to disabled (opt-in feature) - Default interval of 30 seconds when enabled The implementation follows the MCP specification recommendation that implementations SHOULD periodically issue pings to detect connection health. --- packages/client/src/client/client.ts | 90 ++++++++++ packages/client/test/client/ping.test.ts | 214 +++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 packages/client/test/client/ping.test.ts diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index edb08ee58..f11794f22 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 ?? 30000, + }; // 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..375889c44 --- /dev/null +++ b/packages/client/test/client/ping.test.ts @@ -0,0 +1,214 @@ +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 +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 {} +} + +// 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); + }); +}); From 8be3bdea2537f0fdffd99bd95a21d69d9cc51287 Mon Sep 17 00:00:00 2001 From: Br1an67 <932039080@qq.com> Date: Sun, 8 Mar 2026 16:41:38 +0000 Subject: [PATCH 2/3] fix(client): fix ping tests timing out due to MockTransport not responding The MockTransport class in ping.test.ts was not responding to initialize and ping requests, causing client.connect() to hang indefinitely and tests to timeout. Updated MockTransport.send() to properly respond to these requests with appropriate JSON-RPC responses. --- packages/client/test/client/ping.test.ts | 30 ++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/client/test/client/ping.test.ts b/packages/client/test/client/ping.test.ts index 375889c44..b45dedb40 100644 --- a/packages/client/test/client/ping.test.ts +++ b/packages/client/test/client/ping.test.ts @@ -3,7 +3,7 @@ import type { JSONRPCMessage } from '@modelcontextprotocol/core'; import { Client } from '../../src/client/client.js'; import type { Transport, TransportSendOptions } from '@modelcontextprotocol/core'; -// Mock Transport class +// Mock Transport class that responds to initialize requests class MockTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; @@ -14,7 +14,33 @@ class MockTransport implements Transport { async close(): Promise { this.onclose?.(); } - async send(_message: JSONRPCMessage, _options?: TransportSendOptions): Promise {} + 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 From 5d290c3c44b831549aaefa0fc390bf3baf73cec0 Mon Sep 17 00:00:00 2001 From: Br1an67 <932039080@qq.com> Date: Sun, 8 Mar 2026 19:05:07 +0000 Subject: [PATCH 3/3] fix(client): fix lint error - use numeric separator for intervalMs default Add underscore separator to 30000 -> 30_000 for better readability and to satisfy unicorn/numeric-separators-style lint rule. Also apply prettier formatting to ping.test.ts. Co-Authored-By: Claude --- packages/client/src/client/client.ts | 2 +- packages/client/test/client/ping.test.ts | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index f11794f22..469b3a594 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -261,7 +261,7 @@ export class Client extends Protocol { this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; this._pingConfig = { enabled: options?.ping?.enabled ?? false, - intervalMs: options?.ping?.intervalMs ?? 30000, + intervalMs: options?.ping?.intervalMs ?? 30_000 }; // Store list changed config for setup after connection (when we know server capabilities) diff --git a/packages/client/test/client/ping.test.ts b/packages/client/test/client/ping.test.ts index b45dedb40..0523f98bd 100644 --- a/packages/client/test/client/ping.test.ts +++ b/packages/client/test/client/ping.test.ts @@ -71,7 +71,9 @@ describe('Client periodic ping', () => { // 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[]) => { + (client as unknown as { _requestWithSchema: (...args: unknown[]) => Promise })._requestWithSchema = async ( + ...args: unknown[] + ) => { const request = args[0] as { method: string }; if (request?.method === 'ping') { pingCalls++; @@ -148,8 +150,11 @@ describe('Client periodic ping', () => { ); 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 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++; @@ -179,7 +184,9 @@ describe('Client periodic ping', () => { // 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[]) => { + (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');