From 51a9eaeee01f7e94440a387db277157cd34db109 Mon Sep 17 00:00:00 2001 From: Travis Bonnet Date: Thu, 19 Mar 2026 13:44:15 -0500 Subject: [PATCH] feat(core): add opt-in periodic ping for connection health monitoring Implements the missing periodic ping functionality specified in issue #1000. Per the MCP specification, implementations SHOULD periodically issue pings to detect connection health, with configurable frequency. Changes: - Add `pingIntervalMs` option to `ProtocolOptions` (disabled by default) - Implement `startPeriodicPing()` and `stopPeriodicPing()` in Protocol - Client starts periodic ping after successful initialization - Server starts periodic ping after receiving initialized notification - Timer uses `unref()` so it does not prevent clean process exit - Ping failures are reported via `onerror` without stopping the timer - Timer is automatically cleaned up on close or unexpected disconnect Fixes #1000 Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/periodic-ping.md | 16 ++ packages/client/src/client/client.ts | 3 + packages/core/src/shared/protocol.ts | 67 +++++ .../core/test/shared/periodicPing.test.ts | 260 ++++++++++++++++++ packages/server/src/server/server.ts | 5 +- 5 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 .changeset/periodic-ping.md create mode 100644 packages/core/test/shared/periodicPing.test.ts diff --git a/.changeset/periodic-ping.md b/.changeset/periodic-ping.md new file mode 100644 index 000000000..b7c9e3ac7 --- /dev/null +++ b/.changeset/periodic-ping.md @@ -0,0 +1,16 @@ +--- +"@modelcontextprotocol/core": minor +"@modelcontextprotocol/client": minor +"@modelcontextprotocol/server": minor +--- + +feat: add opt-in periodic ping for connection health monitoring + +Adds a `pingIntervalMs` option to `ProtocolOptions` that enables automatic +periodic pings to verify the remote side is still responsive. Per the MCP +specification, implementations SHOULD periodically issue pings to detect +connection health, with configurable frequency. + +The feature is disabled by default. When enabled, pings begin after +initialization completes and stop automatically when the connection closes. +Failures are reported via the `onerror` callback without stopping the timer. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index edb08ee58..459d39e99 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -514,6 +514,9 @@ export class Client extends Protocol { this._setupListChangedHandlers(this._pendingListChangedConfig); this._pendingListChangedConfig = undefined; } + + // Start periodic ping after successful initialization + this.startPeriodicPing(); } catch (error) { // Disconnect if initialization fails. void this.close(); diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index b82731582..eb3d2e80d 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -43,6 +43,7 @@ import type { import { CancelTaskResultSchema, CreateTaskResultSchema, + EmptyResultSchema, getNotificationSchema, getRequestSchema, getResultSchema, @@ -119,6 +120,20 @@ export type ProtocolOptions = { * appropriately (e.g., by failing the task, dropping messages, etc.). */ maxTaskQueueSize?: number; + /** + * Interval (in milliseconds) between periodic ping requests sent to the remote side + * to verify connection health. If set, pings will begin after {@linkcode Protocol.connect | connect()} + * completes and stop automatically when the connection closes. + * + * Per the MCP specification, implementations SHOULD periodically issue pings to + * detect connection health, with configurable frequency. + * + * Disabled by default (no periodic pings). Typical values: 15000-60000 (15s-60s). + * + * Ping failures are reported via the {@linkcode Protocol.onerror | onerror} callback + * and do not stop the periodic timer. + */ + pingIntervalMs?: number; }; /** @@ -413,6 +428,9 @@ export abstract class Protocol { private _requestResolvers: Map void> = new Map(); + private _pingTimer?: ReturnType; + private _pingIntervalMs?: number; + protected _supportedProtocolVersions: string[]; /** @@ -441,6 +459,7 @@ export abstract class Protocol { constructor(private _options?: ProtocolOptions) { this._supportedProtocolVersions = _options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; + this._pingIntervalMs = _options?.pingIntervalMs; this.setNotificationHandler('notifications/cancelled', notification => { this._oncancel(notification); @@ -724,6 +743,8 @@ export abstract class Protocol { } private _onclose(): void { + this.stopPeriodicPing(); + const responseHandlers = this._responseHandlers; this._responseHandlers = new Map(); this._progressHandlers.clear(); @@ -992,10 +1013,56 @@ export abstract class Protocol { return this._transport; } + /** + * Starts sending periodic ping requests at the configured interval. + * Pings are used to verify that the remote side is still responsive. + * Failures are reported via the {@linkcode onerror} callback but do not + * stop the timer; pings continue until the connection is closed. + * + * This is called automatically at the end of {@linkcode connect} when + * `pingIntervalMs` is set. Subclasses that override `connect()` and + * perform additional initialization (e.g., the MCP handshake) may call + * this method after their initialization is complete instead. + * + * Has no effect if periodic ping is already running or if no interval + * is configured. + */ + protected startPeriodicPing(): void { + if (this._pingTimer || !this._pingIntervalMs) { + return; + } + + this._pingTimer = setInterval(async () => { + try { + await this._requestWithSchema({ method: 'ping' }, EmptyResultSchema, { + timeout: this._pingIntervalMs + }); + } catch (error) { + this._onerror(error instanceof Error ? error : new Error(`Periodic ping failed: ${String(error)}`)); + } + }, this._pingIntervalMs); + + // Allow the process to exit even if the timer is still running + if (typeof this._pingTimer === 'object' && 'unref' in this._pingTimer) { + this._pingTimer.unref(); + } + } + + /** + * Stops periodic ping requests. Called automatically when the connection closes. + */ + protected stopPeriodicPing(): void { + if (this._pingTimer) { + clearInterval(this._pingTimer); + this._pingTimer = undefined; + } + } + /** * Closes the connection. */ async close(): Promise { + this.stopPeriodicPing(); await this._transport?.close(); } diff --git a/packages/core/test/shared/periodicPing.test.ts b/packages/core/test/shared/periodicPing.test.ts new file mode 100644 index 000000000..eeabce3a7 --- /dev/null +++ b/packages/core/test/shared/periodicPing.test.ts @@ -0,0 +1,260 @@ +import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest'; + +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; +import type { Transport, TransportSendOptions } from '../../src/shared/transport.js'; +import type { JSONRPCMessage } from '../../src/types/types.js'; + +class MockTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: unknown) => void; + + async start(): Promise {} + async close(): Promise { + this.onclose?.(); + } + async send(_message: JSONRPCMessage, _options?: TransportSendOptions): Promise {} +} + +function createProtocol(options?: { pingIntervalMs?: number }) { + return new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } + protected assertTaskHandlerCapability(): void {} + // Expose protected methods for testing + public testStartPeriodicPing(): void { + this.startPeriodicPing(); + } + public testStopPeriodicPing(): void { + this.stopPeriodicPing(); + } + })(options); +} + +describe('Periodic Ping', () => { + let transport: MockTransport; + + beforeEach(() => { + vi.useFakeTimers(); + transport = new MockTransport(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('should not send periodic pings when pingIntervalMs is not set', async () => { + const protocol = createProtocol(); + const sendSpy = vi.spyOn(transport, 'send'); + + await protocol.connect(transport); + + // Advance time well past any reasonable interval + await vi.advanceTimersByTimeAsync(120_000); + + // No ping requests should have been sent (only no messages at all) + const pingMessages = sendSpy.mock.calls.filter(call => { + const msg = call[0] as { method?: string }; + return msg.method === 'ping'; + }); + expect(pingMessages).toHaveLength(0); + }); + + test('should send periodic pings when pingIntervalMs is set and startPeriodicPing is called', async () => { + const protocol = createProtocol({ pingIntervalMs: 10_000 }); + const sendSpy = vi.spyOn(transport, 'send'); + + await protocol.connect(transport); + + // Respond to each ping with a success result + sendSpy.mockImplementation(async (message: JSONRPCMessage) => { + const msg = message as { id?: number; method?: string }; + if (msg.method === 'ping' && msg.id !== undefined) { + // Simulate the server responding with a pong + transport.onmessage?.({ + jsonrpc: '2.0', + id: msg.id, + result: {} + }); + } + }); + + // Start periodic ping (in real usage, Client.connect() calls this after init) + protocol.testStartPeriodicPing(); + + // No ping yet (first fires after one interval) + const pingsBeforeAdvance = sendSpy.mock.calls.filter(call => { + const msg = call[0] as { method?: string }; + return msg.method === 'ping'; + }); + expect(pingsBeforeAdvance).toHaveLength(0); + + // Advance past one interval + await vi.advanceTimersByTimeAsync(10_000); + + const pingsAfterOne = sendSpy.mock.calls.filter(call => { + const msg = call[0] as { method?: string }; + return msg.method === 'ping'; + }); + expect(pingsAfterOne).toHaveLength(1); + + // Advance past another interval + await vi.advanceTimersByTimeAsync(10_000); + + const pingsAfterTwo = sendSpy.mock.calls.filter(call => { + const msg = call[0] as { method?: string }; + return msg.method === 'ping'; + }); + expect(pingsAfterTwo).toHaveLength(2); + }); + + test('should stop periodic pings on close', async () => { + const protocol = createProtocol({ pingIntervalMs: 5_000 }); + const sendSpy = vi.spyOn(transport, 'send'); + + await protocol.connect(transport); + + sendSpy.mockImplementation(async (message: JSONRPCMessage) => { + const msg = message as { id?: number; method?: string }; + if (msg.method === 'ping' && msg.id !== undefined) { + transport.onmessage?.({ + jsonrpc: '2.0', + id: msg.id, + result: {} + }); + } + }); + + protocol.testStartPeriodicPing(); + + // One ping fires + await vi.advanceTimersByTimeAsync(5_000); + const pingsBeforeClose = sendSpy.mock.calls.filter(call => { + const msg = call[0] as { method?: string }; + return msg.method === 'ping'; + }); + expect(pingsBeforeClose).toHaveLength(1); + + // Close the connection + await protocol.close(); + + // Advance more time; no new pings should be sent + sendSpy.mockClear(); + await vi.advanceTimersByTimeAsync(20_000); + + const pingsAfterClose = sendSpy.mock.calls.filter(call => { + const msg = call[0] as { method?: string }; + return msg.method === 'ping'; + }); + expect(pingsAfterClose).toHaveLength(0); + }); + + test('should report ping errors via onerror without stopping the timer', async () => { + const protocol = createProtocol({ pingIntervalMs: 5_000 }); + const errors: Error[] = []; + protocol.onerror = error => { + errors.push(error); + }; + + await protocol.connect(transport); + + // Make send reject to simulate a failed ping + const sendSpy = vi.spyOn(transport, 'send'); + sendSpy.mockImplementation(async (message: JSONRPCMessage) => { + const msg = message as { id?: number; method?: string }; + if (msg.method === 'ping' && msg.id !== undefined) { + transport.onmessage?.({ + jsonrpc: '2.0', + id: msg.id, + error: { + code: -32000, + message: 'Server error' + } + }); + } + }); + + protocol.testStartPeriodicPing(); + + // First ping fails + await vi.advanceTimersByTimeAsync(5_000); + expect(errors).toHaveLength(1); + + // Second ping also fails, proving the timer was not stopped + await vi.advanceTimersByTimeAsync(5_000); + expect(errors).toHaveLength(2); + }); + + test('should not start duplicate timers if startPeriodicPing is called multiple times', async () => { + const protocol = createProtocol({ pingIntervalMs: 5_000 }); + const sendSpy = vi.spyOn(transport, 'send'); + + await protocol.connect(transport); + + sendSpy.mockImplementation(async (message: JSONRPCMessage) => { + const msg = message as { id?: number; method?: string }; + if (msg.method === 'ping' && msg.id !== undefined) { + transport.onmessage?.({ + jsonrpc: '2.0', + id: msg.id, + result: {} + }); + } + }); + + // Call startPeriodicPing multiple times + protocol.testStartPeriodicPing(); + protocol.testStartPeriodicPing(); + protocol.testStartPeriodicPing(); + + await vi.advanceTimersByTimeAsync(5_000); + + // Should only have one ping, not three + const pings = sendSpy.mock.calls.filter(call => { + const msg = call[0] as { method?: string }; + return msg.method === 'ping'; + }); + expect(pings).toHaveLength(1); + }); + + test('should stop periodic pings when transport closes unexpectedly', async () => { + const protocol = createProtocol({ pingIntervalMs: 5_000 }); + const sendSpy = vi.spyOn(transport, 'send'); + + await protocol.connect(transport); + + sendSpy.mockImplementation(async (message: JSONRPCMessage) => { + const msg = message as { id?: number; method?: string }; + if (msg.method === 'ping' && msg.id !== undefined) { + transport.onmessage?.({ + jsonrpc: '2.0', + id: msg.id, + result: {} + }); + } + }); + + protocol.testStartPeriodicPing(); + + // One ping fires + await vi.advanceTimersByTimeAsync(5_000); + + // Simulate transport closing unexpectedly + transport.onclose?.(); + + sendSpy.mockClear(); + await vi.advanceTimersByTimeAsync(20_000); + + const pingsAfterTransportClose = sendSpy.mock.calls.filter(call => { + const msg = call[0] as { method?: string }; + return msg.method === 'ping'; + }); + expect(pingsAfterTransportClose).toHaveLength(0); + }); +}); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 00d3e6f52..b42545528 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -112,7 +112,10 @@ export class Server extends Protocol { this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this.setRequestHandler('initialize', request => this._oninitialize(request)); - this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); + this.setNotificationHandler('notifications/initialized', () => { + this.startPeriodicPing(); + this.oninitialized?.(); + }); if (this._capabilities.logging) { this._registerLoggingHandler();