Skip to content

fix(mcp): answer a notification 202 on whichever transport served it (#155) - #156

Merged
ifahimreza merged 1 commit into
mainfrom
fix/155-notification-202
Aug 25, 2026
Merged

fix(mcp): answer a notification 202 on whichever transport served it (#155)#156
ifahimreza merged 1 commit into
mainfrom
fix/155-notification-202

Conversation

@ifahimreza

Copy link
Copy Markdown
Contributor

Closes #155

What

A JSON-RPC notification on Saddle's MCP route now comes back 202 Accepted with no body whichever transport served it.

Why

A tester on Codex reported:

The Apache SSL access log shows the MCP initialize request returning 200 316, followed by notifications/initialized returning 200 with an empty body… Codex consequently fails with EOF while parsing a value.

EOF while parsing a value is exactly what a strict client does when it tries to JSON-parse an empty body, so the body really is empty and the status really is 200.

It is not Saddle's own transport. That returns 202 with no body and has since #97/#98 (2026-08-16, so rc5 and later carry it). Verified against core: WP_REST_Server::serve_request() calls set_status( $code ) at class-wp-rest-server.php:477, before the rest_pre_serve_request at :516 that empties the body — so the 202 is on the wire.

It is the other path. saddle.php branches, and serve_empty_acknowledgement was hooked inside register_routes() — the else branch only:

if ( self::adapter_available() ) {
    add_action( 'mcp_adapter_init', array( 'Saddle_MCP', 'register_adapter_server' ) );
} else {
    add_action( 'rest_api_init', array( 'Saddle_MCP', 'register_routes' ) );
}

So when the official MCP Adapter plugin is installed, Saddle hands the whole request to it and none of Saddle's spec handling is in play. The answer to the one step that sits between "connected" and tools/list then comes from a plugin we neither ship nor version. Both zips exclude includes/lib/**, so adapter_available() in the field means precisely "the customer installed that plugin".

Saddle shouldn't depend on someone else's spec compliance for that step.

How

Two filters, both Saddle's own, neither touching the vendored library:

  • rest_post_dispatch forces 202 when the request targets Saddle's MCP route and the body really is a notification. It fires at :464, before the status is read at :477 — which is why correcting it there is what reaches the wire rather than just the response object.
  • serve_empty_acknowledgement moves out of register_routes() so it registers on both paths.

Both are safe on either transport because the adapter registers under Saddle's own namespace and route (create_server( …, self::REST_NAMESPACE, ltrim( self::ROUTE, '/' ), … )), so the existing owns_route() check already matches — and everything is scoped to that route.

Narrow twice over: our route only, and only a genuine notification — no id, a notifications/ method, and for a batch only when every member qualifies, because one real call in it expects a real response. An unparseable body is deliberately left alone: that's a 400 the transport already answered correctly, and guessing at it would hide the error.

Testing

  • composer test645 tests (was 637), 0 failures
  • composer lint · npm run lint:js — 0 errors
  • Verified red first. With the old filter chain the three behaviour tests failed naming the exact symptom (status not corrected from 200; body not emptied). The five "nothing else moves" tests passed before and after: a call with an id, a mixed batch, a notification-shaped body on someone else's route, an unparseable body, and a non-202 response never being emptied.
  • Core ordering confirmed by reading class-wp-rest-server.php, not assumed.

One note on the test file: set_up() re-registers the guards because they hook on rest_api_init, which fires once per process (rest_get_server() memoizes) while WP_UnitTestCase restores $wp_filter after every test — so without it only whichever test ran first would have them. Re-registering is idempotent.

Still worth confirming with the reporter

Which of the two transports they're on, and their Saddle version — their Connection details → Transport line answers it in one paste. The fix is correct either way, and it also covers the case where they're simply on a build older than #98.

CI note: main is red on Saddle_Skills_Test::test_the_playbook_adapts_step_two_to_a_classic_theme (#145, CI-only, green locally). This branch inherits it.

A tester on Codex could not connect: initialize came back 200, then
notifications/initialized came back 200 with an empty body, and the
client died with "EOF while parsing a value" — which is what a strict
client does when it tries to JSON-parse nothing.

It is not Saddle's own transport. That returns 202 with no body and has
since #97/#98, and core sets the status from the response object at
class-wp-rest-server.php:477, before the rest_pre_serve_request that
empties the body at :516 — so the 202 is on the wire. That path is fine.

It is the other one. saddle.php hands the whole request to the official
MCP Adapter plugin when it is installed, and serve_empty_acknowledgement
was hooked inside register_routes() — the else branch. So on the adapter
path none of Saddle's spec handling ran, and the answer to the one step
between "connected" and tools/list came from a plugin we neither ship nor
version. Both zips exclude includes/lib/**, so adapter_available() in the
field means exactly "the customer installed that plugin".

Saddle should not depend on someone else's spec compliance there. Two
filters, both ours, neither touching the vendored library:

- rest_post_dispatch forces 202 when the request targets Saddle's MCP
  route and the body really is a notification. It fires at :464, before
  the status is read at :477, which is why correcting it there is what
  reaches the wire rather than just the object.
- serve_empty_acknowledgement moves out of register_routes() so it is
  registered on both paths.

Safe on both because the adapter registers under Saddle's OWN namespace
and route, so owns_route() already matches either way, and everything is
scoped to that route.

Narrow twice over: our route only, and only a real notification — no id,
a notifications/ method, and for a batch only when EVERY member
qualifies, because one real call in it expects a real response. An
unparseable body is left alone; that is a 400 the transport already got
right, and guessing at it would hide the error.

Eight tests, five of which are the "nothing else moves" half: a call with
an id, a mixed batch, a notification-shaped body on someone else's route,
an unparseable body, and a non-202 response never being emptied. Verified
red first — the three behaviour tests failed naming the exact symptom.

The set_up() re-registration is not ceremony: the guards hook on
rest_api_init, which fires once per process because rest_get_server()
memoizes, while WP_UnitTestCase restores $wp_filter after every test — so
without it only the first test would have them.

645 tests (was 637), 0 lint errors.

Closes #155
@ifahimreza ifahimreza linked an issue Aug 25, 2026 that may be closed by this pull request
4 tasks
@ifahimreza
ifahimreza merged commit 9e92285 into main Aug 25, 2026
1 of 7 checks passed
@ifahimreza
ifahimreza deleted the fix/155-notification-202 branch August 25, 2026 07:53
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.

notifications/initialized can answer 200 instead of 202 on the adapter transport

1 participant