Skip to content
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
90 changes: 90 additions & 0 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
};

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

/**
* Initializes this client with the given name and version information.
Expand All @@ -216,6 +259,10 @@ export class Client extends Protocol<ClientContext> {
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) {
Expand Down Expand Up @@ -514,6 +561,9 @@ export class Client extends Protocol<ClientContext> {
this._setupListChangedHandlers(this._pendingListChangedConfig);
this._pendingListChangedConfig = undefined;
}

// Start periodic ping if enabled
this._startPeriodicPing();
} catch (error) {
// Disconnect if initialization fails.
void this.close();
Expand Down Expand Up @@ -542,6 +592,46 @@ export class Client extends Protocol<ClientContext> {
return this._instructions;
}

/**
* Closes the connection and stops periodic ping if running.
*/
override async close(): Promise<void> {
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': {
Expand Down
247 changes: 247 additions & 0 deletions packages/client/test/client/ping.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {}
async close(): Promise<void> {
this.onclose?.();
}
async send(message: JSONRPCMessage, _options?: TransportSendOptions): Promise<void> {
// 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<typeof setInterval>;
}

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<unknown> })._requestWithSchema;
(client as unknown as { _requestWithSchema: (...args: unknown[]) => Promise<unknown> })._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<unknown> })
._requestWithSchema;
(customIntervalClient as unknown as { _requestWithSchema: (...args: unknown[]) => Promise<unknown> })._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<unknown> })._requestWithSchema;
(client as unknown as { _requestWithSchema: (...args: unknown[]) => Promise<unknown> })._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);
});
});
Loading