Skip to content

Replace MCP DTOs with a descriptor-backed dual-revision runtime - #287

Draft
galatanovidiu wants to merge 8 commits into
trunkfrom
try/descriptor-backed-dual-revision
Draft

Replace MCP DTOs with a descriptor-backed dual-revision runtime#287
galatanovidiu wants to merge 8 commits into
trunkfrom
try/descriptor-backed-dual-revision

Conversation

@galatanovidiu

Copy link
Copy Markdown
Contributor

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-25 and 2026-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

  • Converts stored components, handlers, public hooks, content blocks, and errors to revision-neutral arrays.
  • Selects one immutable schema catalog per request and narrows at revision-specific wire encoders.
  • Hydrates every protocol result before emission; output validation is inherent and always enabled.
  • Keeps registration-time deep validation behind its existing opt-in filter.
  • Drops only an invalid component from list results, with logging and _doing_it_wrong(), instead of disabling the server.

JSON identity and transport boundaries

  • Adds a shared identity-preserving JSON-RPC decoder for HTTP, STDIO, and custom transports.
  • Distinguishes JSON objects from lists for batches, tool arguments, empty maps, and nested values.
  • Keeps callback-facing parameters as associative arrays.
  • Treats JSON object key order as insignificant while retaining exact checks for content, escaping, number formatting, and {} versus [].

Exact dual-revision runtime

  • Supports exactly 2025-11-25 and 2026-07-28.
  • Keeps legacy initialize negotiation, sessions, and ping behavior.
  • Resolves modern requests independently from required header/body protocol metadata and does not touch legacy session state.
  • Applies per-era method tables, not-found policy, typed protocol errors, HTTP statuses, result metadata, and cache fields.
  • Allows STDIO to alternate revisions on one stream.

Stateless continuation

  • Allows only modern tools/call, resources/read, and prompts/get to continue.
  • Requires direct components to opt in with supports_input_required => true; existing, Ability-backed, prompt-builder, non-opted-in, and legacy callbacks remain one-round.
  • Seals callback state in a short-lived HMAC envelope bound to the authenticated user, server, method, component, and canonical origin request.
  • Validates retries and input_required results through their exact modern descriptors.
  • Gates embedded elicitation, sampling, and roots requests against the current request's client capabilities.
  • Rejects invalid state before dispatch and reruns permission checks for valid retries.

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 --check and git diff --cached --check — clean.
  • Production compatibility audit — no concrete DTO references, compatibility wrappers, facades, class_alias, dual-shape returns, or Adapter-owned DTO substitutes in includes/.

To reproduce locally:

  1. Run composer install.
  2. Start the test environment with npm run wp-env:test -- start.
  3. Run the commands above.

Changelog Entry

Changed - Replace version-specific protocol DTOs with descriptor-backed dual-revision encoding and add MCP 2026-07-28 continuation support.

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

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.26531% with 107 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.72%. Comparing base (23cb53e) to head (11ff8b7).

Files with missing lines Patch % Lines
...es/Infrastructure/Protocol/ContinuationManager.php 85.22% 30 Missing ⚠️
...ncludes/Transport/Infrastructure/RequestRouter.php 82.69% 18 Missing ⚠️
includes/Handlers/Prompts/PromptsHandler.php 79.68% 13 Missing ⚠️
...es/Transport/Infrastructure/HttpRequestHandler.php 90.65% 10 Missing ⚠️
...s/Infrastructure/ErrorHandling/McpErrorFactory.php 92.75% 5 Missing ⚠️
includes/Cli/StdioServerBridge.php 91.42% 3 Missing ⚠️
includes/Handlers/HandlerHelperTrait.php 76.92% 3 Missing ⚠️
...s/Infrastructure/Protocol/V20260728WireEncoder.php 96.73% 3 Missing ⚠️
includes/Core/McpProtocolContext.php 86.66% 2 Missing ⚠️
includes/Core/McpServer.php 91.30% 2 Missing ⚠️
... and 14 more
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     
Flag Coverage Δ
unit 89.72% <91.26%> (+1.54%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

1 participant