Skip to content

Answer server-to-client pings in the client - #541

Merged
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:respond_to_server_pings_in_the_client
Sep 4, 2026
Merged

Answer server-to-client pings in the client#541
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:respond_to_server_pings_in_the_client

Conversation

@koic

@koic koic commented Sep 3, 2026

Copy link
Copy Markdown
Member

Motivation and Context

The ping utility says the receiver "MUST respond promptly with an empty response", and a server may treat a missing answer as a stale connection and drop it. Both reference SDKs honor that on the client side (the TypeScript protocol layer installs an automatic pong, and the Python session answers PingRequest with an empty result), but this client answered a server ping with Method not found over Streamable HTTP and ignored it entirely over stdio, leaving long-lived legacy sessions vulnerable to a liveness-checking server.

Over HTTP, an unhandled ping is now answered with the empty result in dispatch_server_request. The default deliberately does not live in @server_request_handlers: that registry's any? opens the GET listening stream on connect, and a built-in pong must not change listener behavior - a server can only ping on a stream that is already open. A handler registered via on_server_request("ping") still wins.

Over stdio, the read loop now answers a ping frame inline and keeps waiting for its response. Frames are told apart by shape rather than id: one carrying method is a request or notification, never the awaited response, so a server-chosen ping id that collides with the awaited one is still answered - and a request frame can no longer be returned to the caller as if it were the response. Between requests nothing reads stdout, so a queued ping is answered when the next request starts reading, which is inherent to the single-threaded stdio client. Other server-to-client requests over stdio stay unsupported as documented.

The pong is best effort on both transports, in the ways the reference SDKs imply: a pong that cannot be written over a broken stdin is dropped instead of failing the unrelated request whose response the loop is reading, and a ping whose id is not a String or a Number - a shape the TypeScript and Python schemas reject outright - is not answered with a pong: over stdio it is skipped entirely, and over HTTP it falls through to Method not found, exactly as before this change.

The modern lifecycle removes ping altogether (SEP-2575), so this applies to handshake-lifecycle connections only.

How Has This Been Tested?

New transport tests: over HTTP, a ping delivered on the SSE stream results in a POSTed empty-result response with the ping's id, and a user-registered "ping" handler overrides the default; over stdio, a ping injected while a response is awaited is answered on stdin and the original response still returns. Both fail without the change (Method not found / no answer). Further tests cover the best-effort edges: a pong write failure over a closed stdin leaves the awaited response intact, and a malformed-id ping is skipped over stdio and answered with Method not found over HTTP. bundle exec rake (tests, RuboCop, and conformance baseline) passes.

Breaking Changes

None. A server ping that previously received Method not found (or nothing) now receives the empty result the specification requires.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

## Motivation and Context

The ping utility says the receiver "MUST respond promptly with an empty response",
and a server may treat a missing answer as a stale connection and drop it. Both reference SDKs honor
that on the client side (the TypeScript protocol layer installs an automatic pong,
and the Python session answers `PingRequest` with an empty result), but this client answered
a server ping with Method not found over Streamable HTTP and ignored it entirely over stdio,
leaving long-lived legacy sessions vulnerable to a liveness-checking server.

Over HTTP, an unhandled `ping` is now answered with the empty result in `dispatch_server_request`.
The default deliberately does not live in `@server_request_handlers`: that registry's `any?` opens
the GET listening stream on connect, and a built-in pong must not change listener behavior -
a server can only ping on a stream that is already open. A handler registered via
`on_server_request("ping")` still wins.

Over stdio, the read loop now answers a ping frame inline and keeps waiting for its response.
Frames are told apart by shape rather than id: one carrying `method` is a request or notification,
never the awaited response, so a server-chosen ping id that collides with the awaited one is still answered -
and a request frame can no longer be returned to the caller as if it were the response.
Between requests nothing reads stdout, so a queued ping is answered when the next request starts reading,
which is inherent to the single-threaded stdio client. Other server-to-client requests over stdio stay
unsupported as documented.

The pong is best effort on both transports, in the ways the reference SDKs imply: a pong that cannot be written
over a broken stdin is dropped instead of failing the unrelated request whose response the loop is reading,
and a ping whose id is not a String or a Number - a shape the TypeScript and Python schemas reject outright -
is not answered with a pong: over stdio it is skipped entirely, and over HTTP it falls through to Method not found,
exactly as before this change.

The modern lifecycle removes `ping` altogether (SEP-2575), so this applies to handshake-lifecycle connections only.

## How Has This Been Tested?

New transport tests: over HTTP, a ping delivered on the SSE stream results in a POSTed empty-result response with
the ping's id, and a user-registered `"ping"` handler overrides the default; over stdio, a ping injected while
a response is awaited is answered on stdin and the original response still returns. Both fail without the change
(Method not found / no answer). Further tests cover the best-effort edges: a pong write failure over
a closed stdin leaves the awaited response intact, and a malformed-id ping is skipped over stdio
and answered with Method not found over HTTP. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes.

## Breaking Changes

None. A server ping that previously received Method not found (or nothing) now receives the empty result
the specification requires.
@koic
koic merged commit 6d272df into modelcontextprotocol:main Sep 4, 2026
11 checks passed
@koic
koic deleted the respond_to_server_pings_in_the_client branch September 4, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants