fix(mcp): answer a notification 202 on whichever transport served it (#155) - #156
Merged
Conversation
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
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
EOF while parsing a valueis 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()callsset_status( $code )atclass-wp-rest-server.php:477, before therest_pre_serve_requestat:516that empties the body — so the 202 is on the wire.It is the other path.
saddle.phpbranches, andserve_empty_acknowledgementwas hooked insideregister_routes()— theelsebranch only: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/listthen comes from a plugin we neither ship nor version. Both zips excludeincludes/lib/**, soadapter_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_dispatchforces 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_acknowledgementmoves out ofregister_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 existingowns_route()check already matches — and everything is scoped to that route.Narrow twice over: our route only, and only a genuine notification — no
id, anotifications/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 test— 645 tests (was 637), 0 failurescomposer lint·npm run lint:js— 0 errorsid, a mixed batch, a notification-shaped body on someone else's route, an unparseable body, and a non-202 response never being emptied.class-wp-rest-server.php, not assumed.One note on the test file:
set_up()re-registers the guards because they hook onrest_api_init, which fires once per process (rest_get_server()memoizes) whileWP_UnitTestCaserestores$wp_filterafter 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:
mainis red onSaddle_Skills_Test::test_the_playbook_adapts_step_two_to_a_classic_theme(#145, CI-only, green locally). This branch inherits it.