Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/periodic-ping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': minor
---

Add configurable periodic ping to Client for connection health monitoring, as recommended by the MCP specification.
48 changes: 48 additions & 0 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,19 @@ export type ClientOptions = ProtocolOptions & {
* ```
*/
listChanged?: ListChangedHandlers;

/**
* Configuration for periodic ping to detect connection health.
*
* Per the MCP specification, implementations SHOULD periodically issue pings
* to detect connection health, and the frequency SHOULD be configurable.
*/
ping?: {
/** Ping interval in milliseconds. Defaults to 60000 (60 seconds). */
intervalMs?: number;
/** Whether to enable periodic pings. Defaults to false. */
enabled?: boolean;
};
};

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

/**
* Initializes this client with the given name and version information.
Expand All @@ -217,6 +232,10 @@ export class Client extends Protocol<ClientContext> {
this._capabilities = options?.capabilities ?? {};
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator();
this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false;
this._pingConfig = {
intervalMs: options?.ping?.intervalMs ?? 60_000,
enabled: options?.ping?.enabled ?? false
};

// Store list changed config for setup after connection (when we know server capabilities)
if (options?.listChanged) {
Expand Down Expand Up @@ -515,13 +534,42 @@ export class Client extends Protocol<ClientContext> {
this._setupListChangedHandlers(this._pendingListChangedConfig);
this._pendingListChangedConfig = undefined;
}

// Start periodic ping if configured
this._startPeriodicPing();
} catch (error) {
// Disconnect if initialization fails.
void this.close();
throw error;
}
}

private _startPeriodicPing(): void {
if (!this._pingConfig.enabled || this._pingInterval) {
return;
}

this._pingInterval = setInterval(async () => {
try {
await this.ping();
} catch (error) {
this.onerror?.(error as Error);
}
}, this._pingConfig.intervalMs);
}

private _stopPeriodicPing(): void {
if (this._pingInterval) {
clearInterval(this._pingInterval);
this._pingInterval = undefined;
}
}

override async close(): Promise<void> {
this._stopPeriodicPing();
await super.close();
}

/**
* After initialization has completed, this will be populated with the server's reported capabilities.
*/
Expand Down
85 changes: 85 additions & 0 deletions test/integration/test/client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4161,3 +4161,88 @@ describe('Client sampling validation with tools', () => {
expect(Array.isArray(result.content)).toBe(true);
});
});

/***
* Tests: Periodic Ping
*/
describe('periodic ping', () => {
test('should send periodic pings when enabled', async () => {
const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} });

const pingReceived = vi.fn();
server.setRequestHandler('ping', async () => {
pingReceived();
return {};
});

const client = new Client(
{ name: 'test client', version: '1.0' },
{
ping: { enabled: true, intervalMs: 100 }
}
);

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

// Wait for a few ping intervals
await new Promise(resolve => setTimeout(resolve, 350));

expect(pingReceived).toHaveBeenCalled();
expect(pingReceived.mock.calls.length).toBeGreaterThanOrEqual(2);

await client.close();
});

test('should not send pings when disabled (default)', async () => {
const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} });

const pingReceived = vi.fn();
server.setRequestHandler('ping', async () => {
pingReceived();
return {};
});

const client = new Client({ name: 'test client', version: '1.0' }, {});

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

await new Promise(resolve => setTimeout(resolve, 200));

expect(pingReceived).not.toHaveBeenCalled();

await client.close();
});

test('should stop pings on close', async () => {
const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} });

const pingReceived = vi.fn();
server.setRequestHandler('ping', async () => {
pingReceived();
return {};
});

const client = new Client(
{ name: 'test client', version: '1.0' },
{
ping: { enabled: true, intervalMs: 100 }
}
);

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);

// Wait for at least one ping
await new Promise(resolve => setTimeout(resolve, 150));
const countBeforeClose = pingReceived.mock.calls.length;
expect(countBeforeClose).toBeGreaterThanOrEqual(1);

await client.close();

// Wait and verify no more pings are sent
await new Promise(resolve => setTimeout(resolve, 300));
expect(pingReceived.mock.calls.length).toBe(countBeforeClose);
});
});
Loading