Skip to content

Commit ad869cf

Browse files
rootBr1an67
authored andcommitted
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.
1 parent 4a7cdf4 commit ad869cf

File tree

2 files changed

+304
-0
lines changed

2 files changed

+304
-0
lines changed

packages/client/src/client/client.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,26 @@ export function getSupportedElicitationModes(capabilities: ClientCapabilities['e
140140
return { supportsFormMode, supportsUrlMode };
141141
}
142142

143+
/**
144+
* Configuration options for periodic ping to monitor connection health.
145+
*
146+
* According to the MCP specification, implementations SHOULD periodically issue
147+
* pings to detect connection health.
148+
*/
149+
export interface PingConfig {
150+
/**
151+
* Whether periodic pings are enabled.
152+
* @default false
153+
*/
154+
enabled?: boolean;
155+
156+
/**
157+
* Interval between periodic pings in milliseconds.
158+
* @default 30000 (30 seconds)
159+
*/
160+
intervalMs?: number;
161+
}
162+
143163
export type ClientOptions = ProtocolOptions & {
144164
/**
145165
* Capabilities to advertise as being supported by this client.
@@ -183,6 +203,27 @@ export type ClientOptions = ProtocolOptions & {
183203
* ```
184204
*/
185205
listChanged?: ListChangedHandlers;
206+
207+
/**
208+
* Configure periodic ping to monitor connection health.
209+
*
210+
* When enabled, the client will automatically send ping requests at the
211+
* specified interval after successfully connecting to the server.
212+
*
213+
* @example
214+
* ```ts
215+
* const client = new Client(
216+
* { name: 'my-client', version: '1.0.0' },
217+
* {
218+
* ping: {
219+
* enabled: true,
220+
* intervalMs: 60000 // Ping every 60 seconds
221+
* }
222+
* }
223+
* );
224+
* ```
225+
*/
226+
ping?: PingConfig;
186227
};
187228

188229
/**
@@ -204,6 +245,8 @@ export class Client extends Protocol<ClientContext> {
204245
private _listChangedDebounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
205246
private _pendingListChangedConfig?: ListChangedHandlers;
206247
private _enforceStrictCapabilities: boolean;
248+
private _pingConfig: PingConfig;
249+
private _pingInterval?: ReturnType<typeof setInterval>;
207250

208251
/**
209252
* Initializes this client with the given name and version information.
@@ -216,6 +259,10 @@ export class Client extends Protocol<ClientContext> {
216259
this._capabilities = options?.capabilities ?? {};
217260
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator();
218261
this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false;
262+
this._pingConfig = {
263+
enabled: options?.ping?.enabled ?? false,
264+
intervalMs: options?.ping?.intervalMs ?? 30000,
265+
};
219266

220267
// Store list changed config for setup after connection (when we know server capabilities)
221268
if (options?.listChanged) {
@@ -514,6 +561,9 @@ export class Client extends Protocol<ClientContext> {
514561
this._setupListChangedHandlers(this._pendingListChangedConfig);
515562
this._pendingListChangedConfig = undefined;
516563
}
564+
565+
// Start periodic ping if enabled
566+
this._startPeriodicPing();
517567
} catch (error) {
518568
// Disconnect if initialization fails.
519569
void this.close();
@@ -542,6 +592,46 @@ export class Client extends Protocol<ClientContext> {
542592
return this._instructions;
543593
}
544594

595+
/**
596+
* Closes the connection and stops periodic ping if running.
597+
*/
598+
override async close(): Promise<void> {
599+
this._stopPeriodicPing();
600+
await super.close();
601+
}
602+
603+
/**
604+
* Starts periodic ping to monitor connection health.
605+
* @internal
606+
*/
607+
private _startPeriodicPing(): void {
608+
if (!this._pingConfig.enabled || this._pingInterval) {
609+
return;
610+
}
611+
612+
this._pingInterval = setInterval(async () => {
613+
try {
614+
await this.ping();
615+
} catch (error) {
616+
// Ping failed - connection may be unhealthy
617+
// Emit error but don't stop the interval - let it retry
618+
const errorMessage = error instanceof Error ? error.message : String(error);
619+
this.onerror?.(new Error(`Periodic ping failed: ${errorMessage}`));
620+
}
621+
}, this._pingConfig.intervalMs);
622+
}
623+
624+
/**
625+
* Stops periodic ping.
626+
* @internal
627+
*/
628+
private _stopPeriodicPing(): void {
629+
if (this._pingInterval) {
630+
clearInterval(this._pingInterval);
631+
this._pingInterval = undefined;
632+
}
633+
}
634+
545635
protected assertCapabilityForMethod(method: RequestMethod): void {
546636
switch (method as ClientRequest['method']) {
547637
case 'logging/setLevel': {
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import type { JSONRPCMessage } from '@modelcontextprotocol/core';
3+
import { Client } from '../../src/client/client.js';
4+
import type { Transport, TransportSendOptions } from '@modelcontextprotocol/core';
5+
6+
// Mock Transport class
7+
class MockTransport implements Transport {
8+
onclose?: () => void;
9+
onerror?: (error: Error) => void;
10+
onmessage?: (message: unknown) => void;
11+
sessionId?: string;
12+
13+
async start(): Promise<void> {}
14+
async close(): Promise<void> {
15+
this.onclose?.();
16+
}
17+
async send(_message: JSONRPCMessage, _options?: TransportSendOptions): Promise<void> {}
18+
}
19+
20+
// Helper interface to access private members for testing
21+
interface TestClient {
22+
_pingConfig: { enabled: boolean; intervalMs: number };
23+
_pingInterval?: ReturnType<typeof setInterval>;
24+
}
25+
26+
describe('Client periodic ping', () => {
27+
let transport: MockTransport;
28+
let client: Client;
29+
let pingCalls: number;
30+
31+
beforeEach(() => {
32+
transport = new MockTransport();
33+
pingCalls = 0;
34+
35+
// Override ping method to track calls
36+
client = new Client(
37+
{ name: 'test-client', version: '1.0.0' },
38+
{
39+
ping: {
40+
enabled: true,
41+
intervalMs: 100
42+
}
43+
}
44+
);
45+
46+
// Mock the internal _requestWithSchema to track ping calls
47+
const originalRequest = (client as unknown as { _requestWithSchema: (...args: unknown[]) => Promise<unknown> })._requestWithSchema;
48+
(client as unknown as { _requestWithSchema: (...args: unknown[]) => Promise<unknown> })._requestWithSchema = async (...args: unknown[]) => {
49+
const request = args[0] as { method: string };
50+
if (request?.method === 'ping') {
51+
pingCalls++;
52+
return {};
53+
}
54+
return originalRequest.apply(client, args);
55+
};
56+
});
57+
58+
afterEach(() => {
59+
vi.restoreAllMocks();
60+
});
61+
62+
it('should not start periodic ping when disabled', async () => {
63+
const disabledClient = new Client(
64+
{ name: 'test-client', version: '1.0.0' },
65+
{
66+
ping: {
67+
enabled: false,
68+
intervalMs: 100
69+
}
70+
}
71+
);
72+
73+
await disabledClient.connect(transport);
74+
75+
// Wait longer than the ping interval
76+
await new Promise(resolve => setTimeout(resolve, 150));
77+
78+
// Ping should not have been called
79+
expect(pingCalls).toBe(0);
80+
81+
await disabledClient.close();
82+
});
83+
84+
it('should start periodic ping when enabled', async () => {
85+
await client.connect(transport);
86+
87+
// Wait for at least one ping interval
88+
await new Promise(resolve => setTimeout(resolve, 150));
89+
90+
// Ping should have been called at least once
91+
expect(pingCalls).toBeGreaterThan(0);
92+
93+
await client.close();
94+
});
95+
96+
it('should stop periodic ping on close', async () => {
97+
await client.connect(transport);
98+
99+
// Wait for a ping
100+
await new Promise(resolve => setTimeout(resolve, 150));
101+
const callCountAfterFirst = pingCalls;
102+
103+
// Close the client
104+
await client.close();
105+
106+
// Wait longer than ping interval
107+
await new Promise(resolve => setTimeout(resolve, 200));
108+
109+
// No additional pings should have been made
110+
expect(pingCalls).toBe(callCountAfterFirst);
111+
});
112+
113+
it('should use custom interval', async () => {
114+
const customIntervalClient = new Client(
115+
{ name: 'test-client', version: '1.0.0' },
116+
{
117+
ping: {
118+
enabled: true,
119+
intervalMs: 200
120+
}
121+
}
122+
);
123+
124+
let customPingCalls = 0;
125+
const originalRequest = (customIntervalClient as unknown as { _requestWithSchema: (...args: unknown[]) => Promise<unknown> })._requestWithSchema;
126+
(customIntervalClient as unknown as { _requestWithSchema: (...args: unknown[]) => Promise<unknown> })._requestWithSchema = async (...args: unknown[]) => {
127+
const request = args[0] as { method: string };
128+
if (request?.method === 'ping') {
129+
customPingCalls++;
130+
return {};
131+
}
132+
return originalRequest.apply(customIntervalClient, args);
133+
};
134+
135+
await customIntervalClient.connect(transport);
136+
137+
// Wait 100ms (less than interval)
138+
await new Promise(resolve => setTimeout(resolve, 100));
139+
expect(customPingCalls).toBe(0);
140+
141+
// Wait another 150ms (total 250ms, more than 200ms interval)
142+
await new Promise(resolve => setTimeout(resolve, 150));
143+
expect(customPingCalls).toBeGreaterThan(0);
144+
145+
await customIntervalClient.close();
146+
});
147+
148+
it('should handle ping errors gracefully', async () => {
149+
const errors: Error[] = [];
150+
client.onerror = (error: Error) => {
151+
errors.push(error);
152+
};
153+
154+
// Mock ping to fail
155+
const originalRequest = (client as unknown as { _requestWithSchema: (...args: unknown[]) => Promise<unknown> })._requestWithSchema;
156+
(client as unknown as { _requestWithSchema: (...args: unknown[]) => Promise<unknown> })._requestWithSchema = async (...args: unknown[]) => {
157+
const request = args[0] as { method: string };
158+
if (request?.method === 'ping') {
159+
throw new Error('Ping failed');
160+
}
161+
return originalRequest.apply(client, args);
162+
};
163+
164+
await client.connect(transport);
165+
166+
// Wait for ping to fail
167+
await new Promise(resolve => setTimeout(resolve, 150));
168+
169+
// Should have recorded error
170+
expect(errors.length).toBeGreaterThan(0);
171+
expect(errors[0]?.message).toContain('Periodic ping failed');
172+
});
173+
});
174+
175+
describe('Client periodic ping configuration', () => {
176+
it('should use default values when ping config is not provided', () => {
177+
const defaultClient = new Client({ name: 'test', version: '1.0.0' });
178+
const testClient = defaultClient as unknown as TestClient;
179+
180+
expect(testClient._pingConfig.enabled).toBe(false);
181+
expect(testClient._pingConfig.intervalMs).toBe(30000);
182+
});
183+
184+
it('should use provided ping config values', () => {
185+
const configuredClient = new Client(
186+
{ name: 'test', version: '1.0.0' },
187+
{
188+
ping: {
189+
enabled: true,
190+
intervalMs: 60000
191+
}
192+
}
193+
);
194+
const testClient = configuredClient as unknown as TestClient;
195+
196+
expect(testClient._pingConfig.enabled).toBe(true);
197+
expect(testClient._pingConfig.intervalMs).toBe(60000);
198+
});
199+
200+
it('should use partial ping config with defaults', () => {
201+
const partialClient = new Client(
202+
{ name: 'test', version: '1.0.0' },
203+
{
204+
ping: {
205+
intervalMs: 45000
206+
}
207+
}
208+
);
209+
const testClient = partialClient as unknown as TestClient;
210+
211+
expect(testClient._pingConfig.enabled).toBe(false);
212+
expect(testClient._pingConfig.intervalMs).toBe(45000);
213+
});
214+
});

0 commit comments

Comments
 (0)