Replace MCP DTOs with a descriptor-backed dual-revision runtime - #287
Draft
galatanovidiu wants to merge 8 commits into
Draft
Replace MCP DTOs with a descriptor-backed dual-revision runtime#287galatanovidiu wants to merge 8 commits into
galatanovidiu wants to merge 8 commits into
Conversation
Locks the exact JSON bytes, HTTP statuses, and session-header behavior
the adapter emits today across 37 fixtures: initialize and the session
lifecycle, ping, tools/list and seven tools/call variants, resources
list/templates/read, prompts list/get, batch and notification envelopes,
JSON-RPC error shapes, and STDIO exchanges. Later commits that replace
the schema layer must keep these fixtures byte-identical, so wire
compatibility becomes a mechanical check instead of a judgment call.
Fixtures normalize only the session UUID ({{SESSION_ID}}); regenerate
with WIRE_FIXTURES=update and review the diff. The harness applies
rest_post_dispatch after rest_do_request() the same way
WP_REST_Server::serve_request() does, because the adapter attaches the
Mcp-Session-Id header on that hook and rest_do_request() alone never
fires it.
First slice of the DTO-to-array neutralization. Every ContentBlockHelper factory keeps its name and parameters but returns the block's array form instead of a schema DTO, and annotations are accepted as plain arrays. Each factory still builds the matching DTO internally, so DTO validation runs unchanged; result DTOs rehydrate the arrays during response assembly, keeping wire output byte-identical (locked by the wire baseline fixtures). BREAKING CHANGE: ContentBlockHelper factories return arrays, not ContentBlockInterface DTOs, and annotations parameters take arrays instead of Annotations instances.
Domain components (McpTool, McpResource, McpPrompt) still validate their
data through the php-mcp-schema DTOs at registration time, but now store
and expose only the serialized wire-shape array. get_protocol_dto(), the
registry and McpServer getters, the tools/resources/prompts list filter
payloads, and McpPromptBuilderInterface::build() all return arrays
instead of DTO instances; the DTO-typed validator entry points are
replaced by array equivalents.
Tool::toArray() marks an empty properties map with a stdClass instance
so JSON encoding emits {}. SchemaTransformer::make_tool_schemas_hydratable()
folds that marker back to an empty array so stored tool data can be
re-hydrated by Tool::fromArray() at the serialization boundary, which
regenerates the object form and keeps the emitted bytes unchanged.
…al arrays Handlers (initialize, tools, resources, prompts, ping) and McpErrorFactory still build the php-mcp-schema result and error DTOs internally, so DTO validation keeps running, but every public method now returns the DTO's serialized wire-shape array: success results in result shape, protocol errors as full JSON-RPC error envelopes (id omitted when null, error data omitted when null). The mcp_adapter_initialize_response filter payload is the initialize result array instead of an InitializeResult instance. RequestRouter consumes these arrays directly and splits outcomes on the top-level error key, which no wire-shape result type carries. Its defensive invalid-response-type branch is removed: with array return types declared on every handler, a misbehaving handler or initialize filter raises a TypeError that the existing catch converts to the same -32603 internal error. HttpRequestHandler and HttpSessionValidator use the factory arrays as-is, and get_http_status_for_error() accepts only the array form.
Replaces the concrete DTO tree with the descriptor-backed php-mcp-schema
package. Nothing in includes/ references a DTO any more: responses are
built as revision-neutral arrays and turned into wire bytes by one
encoder at the boundary.
WireEncoder holds a catalog narrowed to a concrete revision and exposes
one method per payload, named through the catalog's generated accessor.
A payload name is a method rather than a string, so it is reachable from
the call site and an unknown payload fails static analysis rather than a
request. McpProtocolContext selects that catalog once per revision and
memoises it, which is where era selection will go when a second revision
becomes negotiable.
Hydration validates as it goes, so validation is no longer something a
response can skip. The mcp_adapter_validation_enabled filter keeps its
existing meaning, opt-in deep validation at registration, and does not
gate encoding: gating it would need a second, unvalidated serialization
path that hand-rolls object-versus-list identity, which belongs to the
schema package.
A component that fails to encode is dropped from its list and the rest
of the list is returned, so one malformed tool cannot cost a site every
other tool. Each omission is logged and raises _doing_it_wrong. Single
component operations still fail only themselves. The JSON-RPC transport
envelopes stay off the encoder: the specification requires a null id for
a parse error, which the protocol's own error type cannot express, and
an error path that can fail to encode is worse than an unvalidated one.
Annotations are the one field callers supply wholesale. The old DTOs
discarded an unknown or mistyped key silently on the way to the wire;
the schema rejects it, and a rejection would cost the component its
place in the list. They are dropped at registration instead, with
_doing_it_wrong, which keeps the emitted bytes identical to before.
Tests pin the two annotation types and the JSON-RPC constants across
both revisions, since one whitelist serves both.
Tool schemas keep advertising an empty properties map. The deleted DTOs
added it on serialization; SchemaTransformer now guarantees it, so a
tool that takes no arguments still emits "properties":{}.
Nine wire fixtures are re-recorded. Every changed fixture is deep-equal
to its predecessor after a recursive key sort: object key order is not
part of the wire contract, and the descriptor's field order differs from
the deleted DTOs' property order. No fixture changed in content.
BREAKING CHANGE: get_protocol_dto(), the four initialize/list hook
payloads, McpPromptBuilderInterface::build(), ContentBlockHelper and
McpErrorFactory now deal in revision-neutral arrays rather than DTOs.
Unknown annotation keys are dropped at registration instead of reaching
component construction.
Decode request bodies without collapsing JSON objects into lists while deriving the associative arrays consumed by existing callbacks. Keep non-object tool results in text content, carry session metadata out of band, and preserve schema objects through serialization.
Select an exact descriptor-backed encoder per request while retaining the 2025-11-25 initialize and session lifecycle. Add stateless discovery, required header and metadata agreement, revision-specific method gates and errors, and per-line STDIO era selection.\n\nLock the modern HTTP and STDIO behavior with byte-level fixtures while preserving legacy success bytes.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## trunk #287 +/- ##
============================================
+ Coverage 88.17% 89.72% +1.54%
- Complexity 1259 1574 +315
============================================
Files 54 60 +6
Lines 4120 4732 +612
============================================
+ Hits 3633 4246 +613
+ Misses 487 486 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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.
What?
See WordPress/php-mcp-schema#14.
This replaces the Adapter's concrete, revision-specific DTO surface with revision-neutral arrays validated and serialized by descriptor-backed schema catalogs. It then uses that boundary to support both MCP
2025-11-25and2026-07-28, including stateless multi-round tool, resource, and prompt execution.This is an intentional breaking change for PHP integrations that consumed DTO-typed public payloads. Prompt-builder results, initialize/list filter payloads, component getters, content blocks, and error-factory results now use arrays. No DTO compatibility facade or dual-shape return is retained.
Why?
The previous DTO graph coupled Adapter execution code to one protocol revision and duplicated exact wire-shape ownership that belongs in
php-mcp-schema. That made a second revision require parallel codecs and made JSON object/list identity easy to lose at transport and serialization boundaries.The new split keeps exact revision schemas, hydration, validation, and JSON identity in
php-mcp-schema. The Adapter retains protocol negotiation, sessions and headers, execution classification, MRTR policy, observability, and WordPress Ability normalization.How?
Revision-neutral execution and descriptor-backed output
_doing_it_wrong(), instead of disabling the server.JSON identity and transport boundaries
{}versus[].Exact dual-revision runtime
2025-11-25and2026-07-28.Stateless continuation
tools/call,resources/read, andprompts/getto continue.supports_input_required => true; existing, Ability-backed, prompt-builder, non-opted-in, and legacy callbacks remain one-round.input_requiredresults through their exact modern descriptors.Wire evidence
The PR adds an exact HTTP/STDIO fixture harness and covers both revisions, transport/version errors, JSON identity cases, mixed-era STDIO, continuation round trips for all supported methods, capability failures, tampered/cross-request state, and legacy isolation.
Dependency and remaining work
This PR depends on WordPress/php-mcp-schema#14 and pins schema commit
285d8fc0174336a626102b194dfe6a2563041967. At publication time that schema PR is still draft and its Generator check is failing; both must be resolved before this Adapter PR is ready.The planned A8 closure work is intentionally not represented as complete yet: MCP Inspector E2E for both revisions over REST and STDIO, continuation observability verification, final documentation/changelog updates, and the final two-repository gate sweep remain outstanding.
Use of AI Tools
AI assistance: Yes
Tool(s): OpenAI Codex
Model(s): GPT-5
Used for: implementation, tests, wire fixtures, documentation, verification, and this PR description.
Testing Instructions
Final Adapter candidate checks performed:
npm run test:php— 1,110 tests, 4,504 assertions, one pre-existing skip.npm run test:php -- --filter 'ContinuationManagerTest|V20260728WireEncoderTest|RequestRouterTest|ToolsHandlerCallTest|ResourcesHandlerReadTest|PromptsHandlerTest|WireBaselineTest|StdioServerBridgeTest|HttpRequestHandlerTest'— 197 tests, 1,004 assertions.npm run lint:php— clean.npm run lint:php:stan— no errors at repository level 8.composer validate --strict— valid.git diff --checkandgit diff --cached --check— clean.class_alias, dual-shape returns, or Adapter-owned DTO substitutes inincludes/.To reproduce locally:
composer install.npm run wp-env:test -- start.Changelog Entry