Application
MCP Protocol Semantics
Decode one stateless MCP request into a framework-neutral semantic model, discover configured capabilities truthfully, and return a JSON-RPC result without selecting an HTTP route or security policy.
Ownership¶
Application\Mcp implements framework-neutral MCP 2026-07-28 semantics. It decodes a JSON-RPC request,
retains bounded per-request MCP metadata, validates the protocol version, and produces a semantic JSON-RPC
result or protocol error. It does not create a PSR response, choose an endpoint, authenticate a caller, resolve a
principal, or make an authorization decision.
The consumer supplies McpServerInfo and one or more explicit McpCapability implementations through
McpCapabilityRegistry. Each capability owns its methods, advertised metadata, outer-parameter validation, and
handling. The registry rejects missing identity, capabilities without owned methods, duplicate method ownership,
contradictory metadata, a standard method whose advertised capability family does not match, an advertised standard
family without its mandatory anchor method, non-JSON capability definitions, and invalid transport-mirror
declarations during composition.
The prompts.listChanged, resources.subscribe, resources.listChanged, and tools.listChanged flags describe
subscription-driven delivery behavior. TASK-00104 supplies neither the subscription contract nor its long-lived
delivery lifecycle, so this foundation rejects those flags when true. A later capability must introduce and prove
that contract before it may advertise the flags.
Composition¶
Construct McpResponder with a registry. server/discover is reserved to the responder. Every capability that
registers a dispatchable method must advertise at least one validated capability definition, so discovery cannot
hide a callable method. Its result advertises only the registry's configured capabilities, supportedVersions, and the consumer-supplied server identity under
_meta.io.modelcontextprotocol/serverInfo. Every successful semantic result likewise includes that configured
identity, preserving other _meta values while the responder owns and overrides its reserved server-identity key.
It uses the conservative cache defaults ttlMs: 0 and
cacheScope: private; consumers do not supply a cache or authorization policy to this foundation.
For any other registered method, the responder validates the outer MCP request once and dispatches exactly that
capability. Existing custom McpCapability implementations still use handle(McpRequest) unchanged. A capability
that needs the responder's actual central result metadata for pre-disclosure or pre-open serving budgets may also
implement McpMetadataAwareCapability::handleWithMetadata(McpRequest, array). Resource and Skill discovery use
this optional Application contract; other capabilities need not implement it. Metadata is passed per request, not
bound to a capability shared across responders, and contains neither a principal nor an authorization decision.
Direct handle() calls on Resources and Skills retain their previous metadata-free behavior; the responder alone
merges final metadata and overrides the reserved server identity key. Final encoded-result enforcement does not
replace the Resource/Skill pre-disclosure checks. The Tool execution binding is separate and unchanged.
The result is McpJsonResponse, an Arrayable semantic JSON-RPC response. toJson() encodes it;
errorCode() permits transport status mapping. dispatch(McpRequest) handles an already-decoded request and
propagates failures to the caller's diagnostic boundary. Unlike respond(string), it does not sanitize thrown
failures itself. McpRequestHandler uses this seam only after HTTP safeguards, and owns sanitization and diagnostics.
A capability rejects invalid outer parameters by throwing McpProtocolException with
McpProtocolError::invalidParams() and the request ID. The responder preserves that semantic rejection as the
JSON-RPC invalidParams error. Any other Throwable from a capability becomes the generic internalError, with
no exception details exposed. Direct semantic consumers must supply McpResponder(..., diagnostics: $diagnostics)
to record unexpected failures through their redaction-aware McpDiagnostics. The optional constructor argument
preserves existing compositions; omitting it supplies no logging. dispatch() never logs: its HTTP/caller boundary
owns the one diagnostic, preventing duplicate records.
Extension composition¶
Contract fight-common.behavior.mcp-extension-composition: The registry combines distinct identifiers under
extensions without recursively merging their settings. Identical settings for the same ID coalesce; conflicting
settings and duplicate method ownership still fail. Object member order is ignored at every depth, including
objects inside lists; list order, member presence and scalar types remain significant. Object and list values
remain distinct, including empty containers. Other capability families retain their prior equality rules.
The static io.modelcontextprotocol/skills extension requires skills/list, skills/get and readable Resources;
its settings may be empty or explicitly directoryRead: false. Directory/notification claims are not supported.
Skills methods without their extension declaration fail composition. The built-in McpSkillDiscovery additionally
requires its exact shared Resource capability to own resources/read, not another nominally readable handler.
This is bounded extension-ID composition, not a plugin framework.
Authorized Resource discovery¶
Application\Mcp\Resource supplies resources/list, an empty complete resources/templates/list, and
opt-in exact reads. McpResourceDiscovery remains the single method owner:
its original constructor defaults preserve discovery-only consumers; supplying readLimits enables
resources/read on the same provider objects and availability boundary. server/discover advertises resources
with subscribe: false and listChanged: false. Template resolution and subscriptions remain unsupported.
A discovery-only composition still cannot return content; explicitly opt in to deliver a readable endpoint.
Metadata and provider ownership¶
McpResourceProvider::resources(): iterable yields immutable McpResourceInfo values in strictly increasing
bytewise order of the exact URI. Use a lazy metadata query or an existing sorted catalog, not file reads or
hashing. Every call starts a fresh enumeration; provider output must remain stable within that enumeration.
Common merges multiple providers without materializing the corpus. Duplicate URIs (including concealed ones),
repeated provider instances, unsorted output, invalid descriptors and excessive work fail rather than select
whichever provider happened to register first. Composition does not enumerate providers; dynamic ownership
conflicts are detected on listing before any result is returned. No filesystem/network adapter or implicit URI
normalization, decoding, path lookup, directory scan, redirect or fetch is supplied.
Create metadata with McpResourceInfo::fromArray($fields, $limits). Required fields are an absolute RFC 3986
uri and string name. Optional title, description and mimeType remain protocol strings (including empty
strings); size is a non-negative safe integer byte count. Icons validate their supported HTTP(S)/Base64-image
URI and defined fields. Image data URIs require RFC 2397 URL characters or valid percent escapes before MIME
and Base64 decoding: a raw # is not image data; an escaped %23 may appear in a MIME token. Escaped MIME
special characters and quoted-string parameter values remain supported. Annotations validate audience, priority
and string last-modified values. _meta must be an object whose immediate keys follow MCP's optional dotted-prefix
and name grammar. Empty names and unknown reserved keys are legal; extension values remain open JSON, without
recursive metadata-key rules. Unknown top-level fields, invalid JSON/Unicode, excessive nesting (32 levels), invalid
identities and byte-limit violations are rejected, not silently repaired or truncated. Common validates representations, not
whether a supplied MIME label describes actual bytes; reads check matching labels, sizes and valid UTF-8 text,
not content-type sniffing.
Nested objects are immutable StrictJson values; toArray() retains those typed objects and lists. Use
StrictJson::fromObject([]) for an empty object, distinct from an empty list. uri() returns the exact input
identity; validateLimits() can check the descriptor against another serving composition's bounds. The capability
always rechecks its own bounds even if a provider constructed metadata with more generous limits.
use Fight\Common\Application\Mcp\Resource\McpResourceAvailability;
use Fight\Common\Application\Mcp\Resource\McpResourceDiscovery;
use Fight\Common\Application\Mcp\Resource\McpResourceInfo;
use Fight\Common\Application\Mcp\Resource\McpResourceLimits;
use Fight\Common\Application\Mcp\Resource\McpResourceProvider;
$documents = new class implements McpResourceProvider {
public function resources(): iterable
{
// A real provider can stream already ordered metadata rows, without reading document bytes.
yield McpResourceInfo::fromArray([
'uri' => 'context://revision-42/guide',
'name' => 'Guide',
'mimeType' => 'text/markdown',
]);
}
};
// Consumer-provided, request-scoped collaborator; it keeps identity and permissions privately.
/** @var McpResourceAvailability $visibility */
$limits = new McpResourceLimits(pageSize: 50);
$resources = new McpResourceDiscovery(
[$documents],
$visibility,
$dedicatedCursorSecret, // Stable across serving processes; at least 32 bytes, never an access token.
'planning-documents', // Distinct catalog/endpoint scope; not a principal.
$limits,
);
// Add $resources to the existing McpCapabilityRegistry and McpRequestHandler composition.
// Do not register a second owner for either Resource discovery method.
The discovery-only provider port intentionally has no content method. Implement McpReadableResourceProvider
on the same object to opt into exact lookup/opening without changing the original metadata-provider contract.
Authorization, pagination and caching¶
Contract fight-common.behavior.mcp-resource-discovery: Discovery reevaluates
McpResourceAvailability::isAvailable(McpResourceInfo): bool on every request, before pagination or disclosure.
Common never receives a principal, credentials or permission names. Authentication stays in the consumer's route
composition. Provider and availability failures use the existing generic internal-error/diagnostic boundary,
including a collaborator that attempts to throw a protocol error. They never become partial pages or Tool
isError results. Origin, standard/custom mirrors and invocation rejection retain the existing handler ordering;
no Resource provider is enumerated until dispatch. No capability cache bypasses availability.
Successful list/template results include resultType: complete, ttlMs: 0, and cacheScope: private by default.
Optional constructor overrides allow a non-negative safe-integer TTL and private/public scope. Public is an
explicit consumer assertion that this catalog is genuinely caller-independent; immutable bytes or known URIs do
not make it public. A client must not share private cached pages across authorization contexts, and freshness is
not permission. Template discovery returns resourceTemplates: [], never a continuation or a template engine.
Pages use deterministic bytewise URI ordering. Continuation is a fixed 48-byte opaque authenticated string bound to catalog scope, page size and the currently visible descriptor sequence. It contains no concealed URI, provider identity or hidden count. Only the available catalog is hashed; hidden changes do not alter successful cursor bytes. All metadata is streamed on each page to revalidate ownership/current visibility and authenticate continuation. Providers must not eagerly materialize content or the full corpus to meet this contract.
Omitted or empty cursor starts enumeration. Only absence of nextCursor marks completion. A malformed,
tampered, wrong-scope/key/page-size or stale cursor fails with safe -32602 invalid parameters; clients discard
saved pages and restart without human approval. A visible change may invalidate continuation, and cursors are
never authority. Identical visible catalogs may continue across separately composed requests with the same key
and scope. There is no connection-dependent permission or cross-request snapshot guarantee. Template discovery
accepts omitted/empty cursor only, since it never issues a continuation.
Finite discovery budgets¶
Contract fight-common.behavior.mcp-resource-discovery-bounds: McpResourceLimits has these defaults and
validated override ceilings. Invalid relationships fail at construction; excessive provider output fails safely
without a truncated successful descriptor/page.
| Setting | Default | Maximum |
|---|---|---|
maxUriBytes |
4,096 | 1,048,576 |
maxDescriptorBytes (JSON-encoded) |
16,384 | 16,777,216 |
pageSize |
100 | 1,000 |
maxDescriptors (per-request merged scan) |
10,000 | 1,000,000 |
maxProviders |
32 | 256 |
maxResultBytes (complete semantic result) |
1,048,576 | 67,108,864 |
All are positive. Descriptor budget must be at least URI budget + 32; scan budget must be at least page size;
result budget must be at least descriptor budget + 256. A multi-descriptor page can still exceed the result
budget: Common rejects it, never silently shortens it or returns false completion. Lower page size or raise the
validated budget. Encoded sizes include JSON escaping. The result bound includes resultType, cache hints,
continuation and centrally added server-identity metadata; configure enough room for all of them. Resource
results use McpResult::boundedComplete(), which retains and rechecks its budget when metadata is added.
Existing McpResult::complete() consumers remain unbounded and compatible. The outer JSON-RPC envelope is not
part of this result budget, nor is it a claim of bounded HTTP ingress (owned by the shared ingress slice).
The scan cap is a work budget, not a protocol limit on lifetime catalog size. Providers may yield incrementally; Common retains only one lookahead descriptor per provider plus the page. It may inspect up to one lookahead per provider beyond the merged scan cap to detect overflow. Consumer implementations remain responsible for their own query cost, memory and deadlines. Exceeding the configured scan cap is an internal configuration/provider failure, not a successful partial catalog. Descriptor depth and cursor encoding have fixed finite bounds, not tunable parser engines. The cursor key is 32–4,096 bytes and catalog scope is 1–128 bytes.
Resources verification boundary¶
Pinned official conformance source modelcontextprotocol/conformance at
7169291ec0b68eb370fddcd9947313ab0d5e4156 (0.2.0-alpha.11), using MCP 2026-07-28, supplies list, text/binary
read, missing-resource, wire-schema and cache-hint checks. TASK-00118 records passing Resource checks and the
missing-resource warning for deliberately omitted URI error data. The broader caching scenario still fails its
uncomposed Tools/Prompts checks; all Resource cache checks pass. Neither those failures nor the warning are hidden
behind a blanket conformance badge. Template expansion/subscriptions are excluded, not passed. Owned tests fill
harness gaps in current authorization, exact Unicode/CRLF/empty bytes, independent lookup, ownership conflicts,
encoded bounds, provider failures, pagination and cache isolation. The real guarded-handler journey includes
consumer authentication composition and rejection-before-opening proof. This is package evidence, not Skills,
combined Tool/Resource-link, production storage, Agent OS or Pi qualification.
Exact authorized Resource reads¶
Contract fight-common.behavior.mcp-resource-reads: Supply readLimits: new McpResourceReadLimits() to
McpResourceDiscovery. Every registered provider must then implement the optional McpReadableResourceProvider
subtype, or composition fails before I/O. Do not add a parallel Resource capability or a second provider registry.
Existing McpResourceProvider implementations and discovery-only constructor calls remain unchanged.
The readable provider supplies two additional methods:
find(string $uri): ?McpResourceInfoperforms an exact metadata-only lookup. Return null for an unowned identity. Each listed identity must be findable with consistent metadata, but additional owned identities may be omitted from discovery. A prior page, list membership or successful list call is never required for reading.open(McpResourceInfo $resource): McpResourceContentopens only the selected authorized item. ReturnMcpResourceContent::text($info, $freshPsrStream)or::binary(...). A fresh readable stream starts at byte zero; Common consumes and closes it. Supply metadata describing the same version as the bytes, with exactly matching URI, optional MIME and optional size. Lookup and enumeration must not open or hash content.
Common validates the bounded absolute URI, checks every registered provider for exact ownership, rejects
multiple owners or a mismatched returned URI, then reevaluates McpResourceAvailability before opening. Provider
order cannot authorize fallback. Reads never enumerate the listing or prefetch unrelated content. A caller with a
saved URI, descriptor, cursor or cached result has no new authority. Policy is evaluated anew on each read; any
business permission and current identity resolution remain consumer-owned.
URI identity is exact, case-sensitive input, not a path normalization instruction. Common never decodes URI
escapes into another identity, follows redirects, performs URL fetches, resolves dot segments or maps to arbitrary
filesystem paths. For example file:///safe/a, file:///safe/%61 and file:///safe/../a are distinct identities:
only an explicitly owned exact identity can open content. A provider that returns normalized /a metadata for an
encoded/traversal input fails before opening. This is registered-identity containment, not proof that a future
filesystem adapter is safe: consumers must use a closed exact catalog and independently enforce real-path/symlink,
network and storage containment. Do not implement find() as a broad URI-to-file/URL fallback.
use Fight\Common\Application\Mcp\Resource\McpReadableResourceProvider;
use Fight\Common\Application\Mcp\Resource\McpResourceContent;
use Fight\Common\Application\Mcp\Resource\McpResourceReadLimits;
use Psr\Http\Message\StreamFactoryInterface;
// Small fixed demonstration only; production providers open a bounded lazy stream from owned storage.
$documents = new class ($streamFactory) implements McpReadableResourceProvider {
private const URI = 'context://revision-42/guide';
private const TEXT = "# Guide 雪\r\n";
public function __construct(private readonly StreamFactoryInterface $streams) {}
public function resources(): iterable
{
yield $this->find(self::URI);
}
public function find(string $uri): ?McpResourceInfo
{
if ($uri !== self::URI) {
return null;
}
return McpResourceInfo::fromArray([
'uri' => self::URI, 'name' => 'Guide', 'mimeType' => 'text/markdown', 'size' => strlen(self::TEXT)
]);
}
public function open(McpResourceInfo $resource): McpResourceContent
{
return McpResourceContent::text($resource, $this->streams->createStream(self::TEXT));
}
};
$resources = new McpResourceDiscovery(
[$documents], $visibility, $dedicatedCursorSecret, 'planning-documents',
readLimits: new McpResourceReadLimits()
);
// Compose this one capability in the existing registry/guarded endpoint, not alongside the earlier $resources.
Send resources/read with params.uri and the existing mandatory request _meta; over HTTP Mcp-Name mirrors
the exact URI, alongside Mcp-Method: resources/read and MCP-Protocol-Version: 2026-07-28. Existing Origin,
authentication composition and invocation guards precede lookup/opening. Transport rejection does not become a
Resource or Tool result.
An authorized read returns one contents item, resultType: complete, the central server identity, and cache hints.
Text preserves the original UTF-8 bytes, Unicode and line endings. Binary is Base64 of the raw bytes. An empty file
returns one item with text: "" or blob: "", never an empty contents array for absence. Invalid UTF-8 text,
changed/mismatched URI/MIME/declared size or provider failure is a generic internal error with diagnostics at the
existing redaction-aware boundary; bytes are never normalized, rendered, executed or truncated into success.
Common cannot infer the true media type or prove a consumer's immutable-storage guarantee from a label or URI.
Invalid, unknown and unavailable targets share -32602 Invalid params. without URI data, concealed metadata or
permission reasons. The pinned missing-resource check's optional URI-data recommendation is intentionally not
followed because this contract forbids reflecting protected paths. Unexpected provider/policy/stream failures,
including collaborator-thrown protocol exceptions, cannot publish their private messages; they become -32603.
These are central Resource failures, never Tool isError envelopes.
Reads use the capability's existing ttlMs/cacheScope overrides and default to zero/private. Public caching is an
explicit consumer assertion that both metadata and content are caller-independent. Revision-looking URIs,
immutable bytes or freshness do not confer visibility. Common keeps no content or permission cache, always checks
current availability, and cannot erase bytes previously disclosed to a client. Clients/consumer caches must isolate
private results by authorization context. server/discover retains its independent zero/private defaults.
Finite read budgets and stream ownership¶
Contract fight-common.behavior.mcp-resource-read-bounds: McpResourceReadLimits defaults to 1,048,576 raw
bytes and 8,388,608 encoded result bytes, with respective ceilings of 8,388,608 and 67,108,864. Raw budget must be
positive; encoded budget must reserve at least 6 * maxContentBytes + 256. Six is the worst-case JSON text escape
expansion per raw byte and also covers Base64 (including escaped slashes). This conservative relationship rejects
an unsafe override at construction, even for a binary-only provider.
Before listing or opening in read-enabled composition, declared size must fit the raw limit, and the encoded
budget must additionally fit the actual JSON-encoded URI/MIME fields. Hidden descriptors obey these same rules.
The 256-byte reserve is only the constructor/descriptor minimum. Before advertising any readable descriptor or
opening content, the responder also supplies its actual server metadata to Resource dispatch. Common checks the
exact JSON-encoded URI/MIME, result/cache wrapper and central metadata plus 6 * maxContentBytes. An incompatible
combination fails safely before a successful listing or content I/O, even when the separate discovery page budget
would fit. Identity strings are measured after JSON escaping, not by raw length. No identity is retained between
requests or shared responders. The actual final encoded result is independently checked after central metadata
is added as defense in depth. Discovery-only composition remains unchanged and retains its separate
McpResourceLimits page budget.
Neither limit changes shared HTTP request reading; that belongs to the ingress slice.
Common requests chunks no larger than 8,192 bytes, up to the remaining raw budget plus one overflow byte.
It validates actual byte count even when size was omitted or lied about. A stream that exceeds the requested chunk,
returns no bytes without EOF, is unreadable/previously consumed, or throws fails closed. A valid zero-byte stream
succeeds. Common never calls unbounded getContents(), casts a stream to string, seeks it, retries or drops excess
bytes. Every returned stream is closed in finally, including metadata, read, UTF-8 and limit failures; a closing
failure is also an internal failure. consume() is an internal one-shot serving seam, not a direct authorization API.
Providers must return fresh streams rather than shared handles, not eagerly buffer unbounded content before returning, and clean up themselves if opening fails before ownership transfers. Consumers retain bounded query/I/O time, storage deadlines and immutable-version guarantees. Common bounds its own buffering (raw content plus finite encoded copies), not arbitrary work inside a supplied collaborator or a blocking stream implementation.
Immutable Skill files¶
Validated immutable Skill revisions can contribute their root instructions and every supporting file to the
same Resource provider composition. See Skill revisions as Resources for safe YAML parsing,
complete manifests, whole-Skill availability, lazy reads and limits. Resource-only compositions remain unchanged.
Explicitly add McpSkillDiscovery to serve complete Skills list/get
and advertise the static Skills extension through that same guarded endpoint. Skills construction inspects supplied
immutable entries but does not enumerate other Resource providers; current shared catalog validity is checked
on admitted Skills dispatch before disclosure. Consumer acquisition and upstream authentication remain consumer-owned.
Explicit Tool registration¶
Application\Mcp\Tool provides explicit registration, discovery and validated invocation. A consumer Tool implements
McpTool and declares exactly one #[McpToolInfo(name:, description:, inputSchema:, outputSchema:)] on its
handle(ApplicationData $input, McpProgressReporter $progress): McpToolOutput|McpInputRequired method.
Non-interactive Tools may declare the narrower McpToolOutput return; interactive Tools may request additional
input as described under Protected ordinary input. ApplicationData is
Application\Validation\Data\ApplicationData; the reporter lives directly in Application\Mcp.
Construct McpToolRegistry with the complete array of opted-in Tool objects. Registration examines only those
objects' handle() metadata. It never scans a container, discovers HTTP Actions or CQRS handlers, hydrates a
payload, or invokes the Tool. Missing/duplicate method metadata, non-Tool registrations, malformed metadata,
unsupported schema declarations, invalid input-schema mirror annotations, and duplicate canonical names fail
composition with DomainException.
Names are case-sensitive, 1–128 ASCII letters, digits, underscores, dots or hyphens. Descriptions must be nonblank
UTF-8. Input and output schemas are required by the Common convention even though MCP makes output schemas optional.
McpToolInfo exposes only name, description and isolated schema snapshots. McpToolRegistry::find(), definition()
and mirrorsFor() are unprivileged composition lookups, not authorization or invocation APIs. Invocation applies
the same request-scoped availability decision as discovery before inspecting arguments or executing a Tool. Existing business authorization
remains the consumer use case's responsibility on every entry path.
use Fight\Common\Application\Mcp\McpCapabilityRegistry;
use Fight\Common\Application\Mcp\McpResponder;
use Fight\Common\Application\Mcp\McpServerInfo;
use Fight\Common\Application\Mcp\Tool\McpToolDiscovery;
use Fight\Common\Application\Mcp\Tool\McpToolRegistry;
// Explicit consumer-owned Tool objects and request-scoped McpToolAvailability.
$tools = new McpToolRegistry([$findOrderTool, $listOrdersTool]);
$discovery = new McpToolDiscovery($tools, $availability, $cursorSigningKey);
$registry = new McpCapabilityRegistry(new McpServerInfo('Orders', '1.0.0'), [$discovery]);
$responder = new McpResponder($registry);
// The same registry can be supplied to the guarded PSR endpoint below.
The required cursor key is a consumer-generated secret of at least 32 bytes, stable across serving processes and requests. Keep it in consumer secret configuration; do not use an access token, user identifier, or source-code example key. Rotating it invalidates outstanding cursors. This composition needs no framework integration.
Available Tool discovery¶
McpToolDiscovery registers only tools/list and advertises tools: {listChanged: false}. An empty registry
is valid and returns an empty list. Tool calls, subscriptions and list-change notifications are not implemented
by this capability; tools/call requires the separate McpToolInvocation capability below. Conflicting Tool metadata from another capability fails
in the existing McpCapabilityRegistry, rather than permitting contradictory discovery.
McpToolAvailability::isAvailable(McpToolInfo) is mandatory. Its consumer implementation may privately retain
current authentication and policy collaborators but receives no principal, credential, headers, raw arguments,
or authorization reason from Common. For every request the registry asks availability about each registered
metadata definition, filters unavailable definitions, then sorts the visible sequence by bytewise canonical name.
Pagination operates only on that sequence. No availability result is cached in Common.
The default page size is 100; an explicit size must be 1–1000. A small/final page omits nextCursor. Continuations
are opaque, authenticated tokens bound to the current visible definitions and page size. They contain no Tool name,
concealed definition, or unfiltered count. Changing hidden registrations alone does not change the token. Changing
visible metadata or availability invalidates an affected continuation; the client restarts without a cursor.
Every continuation reevaluates current availability. Tampered, out-of-range, wrong-key, or malformed cursors return
the same -32602 invalid-params error with no definitions or diagnostic detail. Tokens are not credentials and
provide no authorization. Unknown outer parameters, including client-supplied page sizes, reject.
Lists default to ttlMs: 0 and cacheScope: private. Constructor overrides require a nonnegative JavaScript-safe
integer TTL and exactly private or public. Selecting public caching is an explicit consumer assertion that the
catalog is safe to share across authorization contexts; Common cannot prove that policy. Positive TTL also means
the consumer accepts cached discovery being stale. These overrides do not alter server/discover cache defaults.
Availability failures propagate to the existing guarded endpoint's diagnostics/sanitization boundary; they never
become a partially returned list or an exposed policy reason.
Schema declarations and semantic output¶
Registration validates a bounded declaration profile; invocation evaluates that same profile against arguments
and successful structured output. Neither is a full JSON Schema implementation.
Both schemas are PHP arrays representing JSON objects. The input root must contain type: object; output schemas
may describe any JSON value, including scalar, array or null output. An empty output declaration means {}.
Without $schema, the dialect is JSON Schema 2020-12. If supplied, it must be exactly
https://json-schema.org/draft/2020-12/schema. This implementation currently supports:
type: one JSON type or a nonempty unique type list; nested schemas may also be Booleans or objects.properties,$defs: maps of schemas;items,additionalProperties,not: one schema.allOf,anyOf,oneOf: nonempty arrays of schemas;required: unique string arrays.enum: nonempty arrays of JSON values;const,default: JSON values;examples: arrays.title,description: strings;readOnly,writeOnly,deprecated,uniqueItems: Booleans.minLength,maxLength,minItems,maxItems,minProperties,maxProperties: nonnegative PHP integers.minimum,maximum,exclusiveMinimum,exclusiveMaximum: finite numbers;multipleOf: positive numbers.x-mcp-header: a nonempty ASCII alphanumeric/hyphen suffix starting with an alphanumeric character. Input-schema annotations are compiled into per-Tool mirrors during registration, with the reachability/type rules below.
Other keywords and dialects fail closed. In particular, references, formats, patterns and conditional schemas are
not currently supported. No remote schema is fetched. This profile is deliberately narrower than MCP permits;
it is not a claim of full JSON Schema conformance. $defs declarations are validated, but references are not enabled.
At runtime, annotations (default, examples, descriptions, read/write flags and header names) do not transform or
validate data. Assertions apply only to their JSON type; strings count Unicode code points, objects compare without
property-order significance, lists retain order, and numeric equality does not coerce strings or Booleans.
Numeric bounds and multipleOf use exact decimal arithmetic over PHP's JSON-encoded numeric values, without epsilon
rounding or optional math extensions. This cannot recover precision already lost when PHP decoded a JSON number.
PHP arrays at properties/$defs map positions may use [] for {}. The Boolean schema true permits any value;
programmatic declarations can use StrictJson::fromObject() for an empty schema object. Lists, objects,
numeric/Boolean/null values and annotations retain their JSON meaning.
Schemas, request metadata and output use Domain\Value\Basic\StrictJson, accepting only plain JSON values
and existing immutable StrictJson nodes. Tool schemas, arguments and output are bounded to depth 64:
the schema/output root is depth zero and each member or element adds one. Discovery's definition, catalog and
protocol wrappers do not consume a schema's depth budget. Malformed Unicode, nonfinite numbers, resources,
arbitrary objects/serializers and cycles reject. Object keys beginning with U+0000 also reject at construction
with DomainException, because PHP's object-mode JSON decoder cannot represent them; empty keys and keys with
non-leading U+0000 remain supported. This representation limit applies at every nesting level, including schema
annotation values. Construction snapshots mutable input, and accessors retain immutable typed object nodes preserving JSON
object/list distinctions. No consumer serializer executes.
This is data-shape safety, not automatic redaction: consumers must explicitly project public-safe schemas and data.
McpToolInfo::inputSchema() and outputSchema() return Fight\Common\Domain\Value\Basic\StrictJson values.
Attribute declarations remain PHP arrays. Accessors and toArray() share immutable schema values, not mutable
decoder objects. Use get('properties')->get('id')->get('type') for object navigation, properties() for a property
map, and toString() or direct JSON serialization for wire output. Discovery JSON and cursor hashing are unchanged.
McpToolOutput::structured($publicData) carries one complete JSON value. structuredContent() returns immutable
StrictJson, whose toString() matches text(), including zero-fraction numbers such as 1.0. It represents any
JSON root: object, list, string, number, Boolean or null. toData() returns an immutable StrictJson node for an
object, a PHP list with typed object children for an array, or the scalar itself. The factory validates plain data,
not arbitrary serializer objects. It creates no JSON-RPC response, error envelope, HTTP response, or partial result.
The caller owns safe projection; McpToolInvoker enforces output-schema conformance before emitting success.
Combined Tool, Resource and Skill journey¶
A consumer can compose McpToolDiscovery and McpToolInvocation over one McpToolRegistry,
McpResourceDiscovery over explicit document providers plus one McpSkillResources, and
McpSkillDiscovery over that same readable Resource discovery and Skill provider. Register all four
capabilities in one McpCapabilityRegistry and give it to the existing guarded McpRequestHandler.
Supply the route, authentication middleware, McpOriginPolicy, McpInvocationGuard, diagnostics, cursor secrets,
current request-scoped Tool/Resource/whole-Skill availability, and appropriate finite limits. The handler
validates Origin, negotiation, request bytes/depth, mirrors and guard before dispatch; upstream authentication,
server buffering, timeouts and authorization policy remain the consumer's responsibility. Distinct extension IDs
coexist; incompatible same-ID settings or duplicate methods fail composition. server/discover advertises
Tools, Resources and io.modelcontextprotocol/skills only when actually registered. Skills requires its same
readable Resource provider; registration never implies a route or permission grant.
For a query-backed Tool that returns a short public summary and a registered document revision, construct
McpResourceInfo::fromArray(['uri' => $exactUri, 'name' => $publicName, ...]) and return
McpToolOutput::structuredWithResourceLinks((object) ['summary' => $publicSummary], $resourceInfo).
The complete result has a schema-checked structuredContent value, matching text and a standard
resource_link content item with validated Resource metadata. Existing structured() calls remain text-only;
links are not injected into the output schema or automatically looked up, authorized, or read. The consumer
must select public-safe fields and register the exact URI with its own immutable provider. A client may follow
that link through resources/read without listing it; Common checks current Resource availability independently
of Tool availability. Denied and unknown reads have the same safe protocol error, not a Tool isError result.
The same client can page through skills/list, directly skills/get a complete entry outside the current page,
then request only its root SKILL.md and selected supporting resources/read files. Exact manifest sizes/digests
refer to original bytes, including CRLF, Unicode and binary files; serving template/script bytes does not run
or activate them. Recheck whole-Skill and general Resource availability on every read, including saved and
historical URIs. Cursors, link possession, authentication, immutable bytes, private cache hints (ttlMs: 0,
cacheScope: private by default) and previously returned manifests are not read grants. Resource and Skill
metadata/raw/encoded budgets must fit the configured revision, including JSON/Base64 and central result metadata;
invalid overrides or inconsistent providers fail rather than emitting a partial success. Do not treat the
Tool output as subject to the Resource content budget; it retains its existing schema/StrictJson bounds.
tests/Functional/McpCombinedJourneyTest.php exercises this single guarded handler with real query dispatch,
linked document reads and lazy structured Skill files. This package fixture is not an installed Agent OS/Pi
client, OAuth issuer, production catalog, directory RPC, execution sandbox or deployed proxy qualification.
This pre-release revision replaces the earlier MCP JsonObject/raw-object accessors with StrictJson. Request decoding,
client capability/info accessors, nested validated Tool arguments, discovery definitions, mirror traversal and semantic
structured output now use the same typed object representation; HTTP JSON remains compatible. Missing Tool arguments
become StrictJson::fromObject(). McpRequestMetadata::fromObject() takes StrictJson, not a generic PHP object.
The Domain type owns bounded JSON safety; MCP retains protocol/schema policy, including the original 512-level
protocol codec limit separately from the 64-level Tool-data bound. Legacy JsonObject, JSend and cache
sentinels are unchanged. See StrictJson for construction, navigation and equality. The
final output class derives compatible text from the identical snapshot, so a Tool cannot supply a separate unsafe
or contradictory text representation. Shape checks cannot determine whether an otherwise valid scalar is secret.
McpProgressReporter is only the stable report(float $progress, ?float $total, ?string $message) and
isCancelled(): bool port required by the Tool signature. Its implementation contract calls for finite nonnegative,
monotonic progress, an optional total not below progress, public-safe status text, and no emission after cancellation.
It carries no Tool result content. NullMcpProgressReporter validates monotonic finite progress but emits nothing and
never reports cancellation. Invocation supplies a fresh instance per Tool call by default. The request-scoped
semantic execution below supplies live progress without broadening this interface. The opt-in HTTP composition
below supplies SSE and observed-disconnect cancellation; rollback is never implied.
Validated Tool invocation¶
Compose McpToolInvocation(new McpToolInvoker($tools, $availability, ...)) alongside McpToolDiscovery in the same
capability registry. Both advertise tools: {listChanged: false}; discovery remains the required anchor method.
Use the same request-scoped availability implementation for both. The invoker is a semantic boundary, not a bus,
authorizer or transaction owner.
use Fight\Common\Application\Mcp\Tool\McpToolFailureMap;
use Fight\Common\Application\Mcp\Tool\McpToolInvocation;
use Fight\Common\Application\Mcp\Tool\McpToolInvoker;
$invocation = new McpToolInvocation(new McpToolInvoker(
$tools,
$availability,
failures: new McpToolFailureMap([
OrderAlreadyClosed::class => 'The order is already closed.'
])
));
$registry = new McpCapabilityRegistry($serverInfo, [$discovery, $invocation]);
$responder = new McpResponder($registry, diagnostics: $diagnostics);
Initial tools/call accepts name and optional arguments; protected interactive retries additionally carry
requestState and inputResponses as described below. Omitted arguments mean {}; present non-object arguments
are a selected-Tool validation failure. Unknown outer parameters reject as invalid params. The default composition
also rejects a progress token: it must not silently accept progress without a bound execution and delivery path.
Invocation selects an explicitly registered Tool, asks availability about its immutable metadata, then checks the
arguments against the input schema. Unknown and unavailable Tools produce identical -32602, Unknown or
unavailable tool. errors without argument/rule validation, invocation, metadata enrichment or diagnostics.
For an available Tool, reflection reads method-level #[Validation]; absent attributes or empty rules mean no
additional rules. ValidationService receives exactly the arguments' top-level field array and produces the
ApplicationData passed to handle(). Nested objects/lists retain their types, and input is snapshotted without
executing serializers. Numeric top-level property names reject because PHP arrays cannot retain them as the string
keys required by ValidationService. Plain-JSON depth/Unicode/key restrictions above apply to arguments too.
Declare rules beside McpToolInfo, for example:
#[McpToolInfo(
name: 'orders.find',
description: 'Find one public order',
inputSchema: [
'type' => 'object',
'properties' => ['id' => ['type' => 'string', 'minLength' => 1]],
'required' => ['id'],
'additionalProperties' => false
],
outputSchema: [
'type' => 'object',
'properties' => ['id' => ['type' => 'string']],
'required' => ['id'],
'additionalProperties' => false
]
)]
#[Validation(rules: [['field' => 'id', 'label' => 'Order', 'rules' => 'required|not_blank']])]
public function handle(ApplicationData $input, McpProgressReporter $progress): McpToolOutput
{
$order = $this->queries->fetch(new GetOrder($input->get('id')));
return McpToolOutput::structured((object) ['id' => $order->id]);
}
Import the attributes/types from Application\Mcp\Tool, Application\Attribute\Validation,
Application\Mcp\McpProgressReporter, and Application\Validation\Data\ApplicationData. The consumer owns the
existing GetOrder Query and safe projection. For a mutation, explicitly construct the existing Command, call void
CommandBus::execute() once, then acknowledge only known data such as its caller-generated identifier. Do not
invent a command result, reflectively hydrate messages, or perform a read solely to manufacture a result.
Consumer handlers retain authorization, transaction, retry and idempotency policy; this layer adds none.
Schema checks and runtime rules are separate. The package fixtures prove shared required/string/length boundaries,
an additional runtime not_blank rejection, empty-rule pass-through, one query/write, and public projection. The
consumer must keep its own schema and rules consistent; registration does not infer one from the other.
| Outcome | Semantic result |
|---|---|
| Input schema, plain-JSON or runtime validation rejection after selection | Complete isError: true, fixed safe text, no input/labels reflected |
Exact failure class registered in McpToolFailureMap during Tool execution |
Complete isError: true, consumer-authored constant text |
| Unregistered failure, availability/metadata/validator failure, invalid successful output | Generic -32603, recorded once at the central boundary |
| Successful conformant output | Complete text plus identical structuredContent |
Failure mappings use exact classes, not inheritance or automatic Domain/Application classification. Messages must
be nonblank valid UTF-8, at most 4096 bytes, without control characters. They are deliberate public projections,
never exception messages. A mapped error omits structured success content and need not satisfy the success schema.
A Tool-thrown McpProtocolException cannot bypass this boundary: it is unexpected unless explicitly mapped to safe
Tool-error text. Output validation and metadata failures cannot be mistaken for mapped business failures.
Protected ordinary input¶
An ordinary additional-input interaction is an opt-in Tool result, not a confirmation, authentication mechanism,
transaction or durable workflow. Application\Mcp\Tool\Interaction\McpInteractiveTool extends McpTool with:
public function resume(
ApplicationData $input,
McpInputResponses $responses,
McpProgressReporter $progress
): McpToolOutput|McpInputRequired;
Approved pre-release API change: McpTool::handle() now returns McpToolOutput|McpInputRequired. Existing
non-interactive implementations retain their narrower McpToolOutput return and unchanged behavior. Callers of the
base interface must handle both variants; this is not signature-compatible with an assumption of complete output.
McpToolOutput itself remains final, immutable and complete-only. All new interaction types below belong to
Application\Mcp\Tool\Interaction unless otherwise qualified.
Declare requiresFormElicitation: true in #[McpToolInfo(...)] on an interactive Tool's handle(). Registration
rejects a mismatch between that flag and the interactive interface. Keep initial #[Validation] and all Tool
metadata on handle() only; do not duplicate them on resume(). Current availability is checked first, then the
current request must declare _meta.io.modelcontextprotocol/clientCapabilities.elicitation.form: {} before initial
schema/rule validation or handle(). Missing form support returns -32021 with safe
data.requiredCapabilities: {elicitation: {form: {}}} and invokes no Tool or bus. An empty elicitation: {}, a URL-only
capability, a prior request, server advertisement, or endpoint configuration does not substitute for form support.
Direct McpToolInvoker::invoke() has no request capability evidence and rejects interactive Tools. The registered
McpToolInvocation uses invokeRequest() with the current semantic request instead.
A Tool may complete normally, or return ordinary additional input without dispatching its mapped use case:
return McpInputRequired::fromRequests([
'details' => McpInputRequest::form(
message: 'Provide a public label',
schema: [
'type' => 'object',
'properties' => ['label' => ['type' => 'string', 'minLength' => 1]],
'required' => ['label']
],
rules: [['field' => 'label', 'label' => 'Label', 'rules' => 'not_blank']]
)
]);
Each response key is a nonempty, non-numeric UTF-8 string. A request contains at least one form. Messages must be
nonblank UTF-8. Forms must never solicit passwords, API keys, payment authorizations or other secrets. Consumers
own that content policy; shape validation is not secret detection. The selected invoker turns this value into
resultType: input_required, keyed inputRequests containing elicitation/create form requests, and opaque
requestState. The responder preserves this result kind and adds its normal server identity. Runtime rule labels
and declarations are inside encrypted state only, never inside the public elicitation schema.
State protection and composition¶
Compose the existing invoker with an explicit request-scoped interaction service:
use Fight\Common\Adapter\Mcp\Sodium\SodiumMcpStateProtector;
use Fight\Common\Application\Mcp\Tool\Interaction\McpToolInteraction;
$protector = new SodiumMcpStateProtector(
activeVersion: 2,
keys: [1 => $previousRandom32ByteKey, 2 => $activeRandom32ByteKey]
);
$invoker = new McpToolInvoker(
$tools,
$availability,
interaction: new McpToolInteraction($protector, $neutralCallerBinding)
);
The optional Sodium adapter requires ext-sodium and uses XChaCha20-Poly1305 AEAD with a fresh random 24-byte nonce,
a 16-byte authentication tag and domain-separated associated data including the version prefix. Tokens have the
form 1.<key-version>.<unpadded-base64url nonce-and-ciphertext>. Only format/key versions are readable; caller,
Tool, arguments, requested contracts/runtime rules, expiry and prior request ID remain encrypted. Base64 alone is
not state protection. Encryption and validation use a dedicated key ring, never Bearer/HMAC credentials or
NonceRepository. At most four keys are accepted, each exactly 32 random bytes and indexed by an integer version
from 1 through 999999999. Issue only with the active version; retain older keys only for the desired grace period.
Removing a key retires its tokens immediately. Unknown/retired versions, modified headers/nonces/ciphertexts/tags,
and noncanonical token encodings fail closed. Keep all keys outside source control, requests, logs and fixtures
except explicitly nonproduction test keys.
McpStateProtector::MAX_STATE_BYTES is 65536 bytes, enforced before token parsing/decryption in both the
Application boundary and Sodium adapter. The adapter also rejects an oversized projected token before encryption.
A custom McpStateProtector must preserve these confidentiality, size, key-rotation and failure guarantees; it is
not permission to substitute plaintext or integrity-only state. Oversized server-authored interaction output is an
unexpected internal error, not a truncated or partially protected token. There is no interaction table or in-memory
session: another instance with the same keys and caller binding can resume.
The consumer supplies a stable neutral caller-binding string (1–1024 UTF-8 bytes), not a principal, credential,
permission or raw request. Use separate key rings per endpoint audience, or explicitly qualify the binding with the
audience. Default TTL is 300 seconds; composition accepts 1–900 seconds. Workers must have synchronized clocks.
The state binds ordinary mode, tools/call, canonical Tool name, original request arguments, the original validated
ApplicationData, requested forms/rules/keys, caller, expiry, and issuing JSON-RPC ID. Objects are canonicalized by
bytewise sorted keys; lists retain order, object/list distinctions and scalar types (including 1 versus 1.0).
No serializer is run. State wrapper depth permits the existing 64-level Tool argument bound.
Retry ordering and outcomes¶
Retry tools/call with a fresh JSON-RPC ID, the same name and original arguments, echoed requestState, and
inputResponses: {details: {action: "accept", content: {label: "Approved label"}}}. Object key ordering may differ;
replacement values or types reject. An originally omitted argument object may remain omitted or be explicit {}.
- Bound and authenticate/open the token privately only far enough to locate its candidate Tool.
- Reevaluate that Tool's current availability. Unknown or revoked Tools return the identical unknown/unavailable
error before restored-state disclosure, binding details, response validation,
resume()or bus dispatch. - Require current form capability; check mode/method, caller, Tool/name, arguments, expiry and fresh request ID.
- Validate the complete keyed response map and every envelope/action before any accept-only content. Missing or extra keys, unknown envelope fields/actions and malformed envelopes reject. This deliberately uses the TASK's stricter exact-key/fail-closed policy rather than MCP's advisory ignore-extra/reprompt behavior.
- For
accept, require an object and validate it against the retained form schema and runtime rules with a freshValidationServiceper response.declineandcancelprohibitcontent, including explicit null, and skip accept-only validation. Restore original validated arguments without rerunning initial schema or#[Validation]. - Call
resume()once with restoredApplicationData, typedMcpInputResponses, and the existing reporter.$responses->get('details')->action()returnsaccept,declineorcancel;content()returns validatedApplicationDataonly for accept, otherwise null.toArray()returns the keyed typed map.
Ordinary decline/cancel reaches the Tool for its safe terminal decision; it is not a destructive confirmation.
resume() owns mapping an accepted response to the existing query/command and projecting public-safe output.
Complete output still passes the declared output schema and existing failure/metadata boundaries. Further
additional-input results are supported without reentering initial handle(); each new result binds its issuing ID.
Invalid state and invalid response content share generic -32602 Invalid params. with no private diagnostic text.
Unexpected Tool/output/protection failures are sanitized and diagnosed at the existing central boundary; exact
mapped consumer failures retain their authored public text.
Ordinary tokens are replayable until expiry or key retirement. A fresh ID does not make them single-use.
Destructive or otherwise replay-sensitive operations require the distinct atomic confirmation mode.
Do not use ordinary input as an authorization approval token. Consumers
retain current business authorization, idempotency and dispatch policy on every resume(). Availability is not a
substitute for authorization inside the consumer use case.
Atomic destructive confirmation¶
A consumer-designated replay-sensitive Tool uses the same McpInteractiveTool, static form-capability gate,
form schemas, validation, protected state, current-availability checks and resume() contract. It opts in by
returning McpInputRequired::confirmation($forms) instead of fromRequests($forms). Common never infers
which actions are destructive or what an affirmative form value means. The Tool must not dispatch its mutation
while requesting confirmation; it owns current business authorization and content policy when resumed.
// Inside the consumer's interactive Tool; the form content and destructive policy belong to that consumer.
return McpInputRequired::confirmation([
'approval' => McpInputRequest::form(
'Confirm removal of this fixture value',
[
'type' => 'object',
'properties' => ['confirm' => ['type' => 'boolean']],
'required' => ['confirm']
]
)
]);
// At the composition root; $confirmationStore implements McpConfirmationStore.
$interaction = new McpToolInteraction(
$protector,
$neutralCallerBinding,
confirmations: $confirmationStore
);
Confirmation state adds a dedicated cryptographically random 256-bit identity and confirmation mode to the
same encrypted bindings. It contains no authentication credential or HMAC request nonce. Before exposing the
input_required result, Common seals the bounded state and calls McpConfirmationStore::issue($id, $binding,
$expires). The store receives only a 64-character lowercase hexadecimal identity, the SHA-256 digest of the
complete canonical protected state, and its exclusive Unix-second expiry. No raw caller, original arguments,
forms or approval language cross this storage port. An unavailable store, collision or issue failure emits no
usable confirmation result and reaches the existing sanitized internal-error boundary. Each issue creates a new
identity; it never renews a consumed confirmation.
Atomic store contract¶
Consumers implement Application\Mcp\Tool\Interaction\McpConfirmationStore against shared durable storage:
issue()persists one unused identity before returning and must never overwrite an existing or consumed entry.consume()compares the identity, full-state binding and exact expiry, checks expiry against the current time, and durably transitions unused to consumed atomically. Only one contender can returnCONSUMED. An exclusive transaction/conditional update or equivalent atomic mechanism is required; separate get/delete operations are not.McpConfirmationOutcomedistinguishesCONSUMED,ABSENT,EXPIRED,MISMATCHED,ALREADY_CONSUMEDandCONTENDEDinternally. A consumer may reject lock contention instead of waiting. Preserve consumed identity until expiry; absent/expired records cannot be acquired. Consumers own retention, backend access controls, synchronized clocks and operational recovery, not Common.- Throw on infrastructure failure. An ambiguous acknowledgement must never restore potentially consumed state.
Common does not retry, release, renew, roll back or reinstate confirmations.
NonceRepository,MutableCache, a process-local flag, or a read-then-delete pair cannot substitute for this contract.
No production store adapter is supplied. The package's LockedConfirmationStore test fixture demonstrates
cross-process mutual exclusion over one file; it is not qualified for production, distributed filesystems, database
failover or crash recovery. Consumers must prove the same contract for their chosen storage technology.
Confirmation retry and terminal outcomes¶
Retry uses the same fresh-ID, original-arguments and keyed ElicitResult rules as ordinary input. Common first
checks current Tool availability before restored-state disclosure or response validation, then current form
capability and every protected binding. It validates all envelopes/keys/actions and accept-only schema/rule content
before store acquisition. An unavailable Tool remains indistinguishable from an unknown Tool and never touches
the store. Invalid responses do not consume the confirmation or a different identity.
After successful validation, exactly one atomic consume occurs:
- If every action is
accept, Common checks the current reporter for cancellation and then callsresume()once. Anacceptaction means the client supplied form content, not that Common approves the business action. For example, a schema-validconfirm: falsestill reaches the consumer, which must interpret it before dispatch. - If any keyed action is
declineorcancel, Common retires the whole confirmation and returns a complete text-only result,Confirmation not accepted.It calls neitherresume()nor any underlying bus. Refusals prohibit even nullcontent; they do not validate accept-only content. Mixed maps still validate the content of their accepted responses before retirement. - A reporter cancelled after acquisition but before
resume()prevents Tool/bus dispatch and returns a safe cancelled Tool result; an outer cancelled stream suppresses final delivery. Duringresume(), cancellation remains cooperative: the Tool must observe its reporter. Common never interrupts work or rolls back a command. - Consumption is permanent after success, mapped rejection, unexpected
Throwable, invalid final output, metadata/response failure, cancellation or disconnect. Any later attempt fails closed beforeresume()or command/query/event dispatch. This is at-most-once admission, not a transaction with the business mutation: a crash after consumption may leave the action unperformed, and a failed response may follow a committed action. Recovery and any newly issued confirmation are explicit consumer decisions, never automatic replay.
Store rejections (including contention) and consumption failures all expose only -32602 Invalid params.;
no store, caller, Tool, arguments or internal rejection reason is reflected. Existing exact Tool failure mappings,
central unexpected-failure diagnostics, command/query envelopes, HMAC nonces and Bearer composition are unchanged.
Ordinary input remains stateless and replayable even when a confirmation store is configured.
tests/Application/Mcp/Tool/Interaction/McpConfirmationTest.php proves protected state, ordering, rejection,
refusal, consumption after each outcome, cancellation before/during resume, and simulated stream closure through
the semantic responder. tests/Functional/McpConfirmationJourneyTest.php checks a concrete store contract and
releases two independent processes from one start barrier: exactly one complete retry, one safe loser, and one
command plus event side effect through the real routing bus/event dispatcher. This is package evidence, not a
consumer persistence certification, live HTTP cancellation receipt or business-authorization guarantee.
Elicitation schema profile and evidence¶
Forms require a root type: object, properties, optional declared required names, and optional exact JSON Schema
2020-12 $schema. Properties are non-numeric string names. Supported fields are strings with length bounds,
number/integer bounds, Booleans, string enums, titled single-select oneOf const/title choices, and string-array
multi-selects (items type/string-enum or items.anyOf const/title choices) with item-count bounds. Fields may have
string title/description and a conformant typed default. No defaults are inserted into responses. Only declared
content properties are accepted. Runtime rules use the existing field/label/rules string declarations and must
name declared form fields.
This is a bounded subset of MCP's restricted form vocabulary: format, legacy enumNames, nested objects,
arbitrary arrays, references, patterns and other keywords reject at declaration rather than being silently ignored.
Use explicit runtime rules for additional supported server-side checks. Client UI constraints never replace server
validation. This does not claim full JSON Schema or every optional MCP elicitation feature; URL mode, sampling,
roots and durable Tasks remain outside this form slice; concrete confirmation persistence remains consumer-owned.
Pinned authority: MCP commit
ab3a39c13bd23be691c2760e1c6c5c15a64582e1,
Tools,
MRTR, and
elicitation.
McpToolInteractionTest exercises real semantic responder round trips with query/command spies and a fresh retry
instance; McpInputRequestTest proves schema, envelope, typed response and runtime validation behavior;
SodiumMcpStateProtectorTest proves AEAD round trips, tampering, limits and rotation. These are package fixtures,
not external SDK certification, a consumer authorization receipt or framework-starter qualification.
Request-scoped progress and cancellation¶
McpToolInvoker::invoke($name, $arguments, $progress) accepts an optional McpProgressReporter. Omission preserves
its fresh NullMcpProgressReporter default and existing validation, availability, metadata and safe-output behavior.
The reporter interface is unchanged; progress never enters Tool arguments or CQRS payloads. Non-interactive Tools
return complete McpToolOutput; opted-in interactive Tools may instead request ordinary input as described above.
Neither result is a generator, callback, partial result or response object.
Internal Tool\McpToolExecution supplies ordered execution. Its invocation binding, including
McpResponder::dispatch()'s optional execution parameter and McpToolInvocation::withExecution(), remains a
package implementation seam, not a consumer transport API. The HTTP adapter binds an isolated invocation.
The default guarded endpoint remains direct JSON; enable progressive: true only with the live emitter below.
This preserves existing buffered consumers instead of silently accepting progress-token requests.
One execution binds the exact decoded tools/call request and is shared only with that request's invocation
capability. Its run() dispatches through the existing semantic responder; an absent token selects exactly the
no-op reporter, while a string or integer token selects the live reporter. Empty strings, zero and integer signs
are preserved without coercion. Present null, Boolean, array, object or floating-point tokens reject during decode;
a token misplaced in outer Tool parameters rejects before invocation. Tokens are not globally indexed: clients
own uniqueness, and even equal tokens on independent request objects share neither reporter nor cancellation state.
The live reporter accepts finite nonnegative work, strictly increasing after the first report (which may be zero).
Optional total must be finite and at least progress; optional message is UTF-8 public-safe human-readable status.
Each report delivers exactly {jsonrpc: "2.0", method: "notifications/progress", params: {progressToken, progress,
...optional total/message}} before the final semantic response. Notifications have no response ID or content field.
Structured result content cannot enter the typed status API. Text can still contain sensitive or result data:
Tools own safe status wording; Common cannot detect secrets or reinterpret arbitrary text as a partial result.
The no-op reporter retains its compatible nondecreasing validation and emits nothing.
Input schema/rule validation completes before Tool execution and progress; rejection is one complete isError
Tool result. Expected mapped business failure after progress likewise produces one complete safe Tool error.
Unclassified failures, output-schema violations, reporter violations and response-preparation failures produce
one generic JSON-RPC internal error while delivery is open. The execution is the central diagnostic boundary
around McpResponder::dispatch(): unexpected failures are recorded at most once, and diagnostic sink failures
cannot escape to the client. A Tool cannot turn a reporter violation into success by catching it or by mapping its
exception class, including malformed argument types. The internal live implementation checks those types inside
its retained-failure boundary; this does not broaden the stable reporter interface or the no-op reporter's contract.
Earlier progress never means partial success.
The outer adapter calls cancel() when it observes client closure. The live reporter then returns true from
isCancelled() and suppresses all later progress and final messages, including errors; diagnostics remain
server-side. Tools must check cancellation at business-safe points. Cancellation neither interrupts execution
nor reverses a dispatched command, and it emits no notifications/cancelled. No-token reporters remain no-op
and never report cancellation. Reuse of an execution or invocation rejects. Completion closes reporting before
attempting the final delivery, so late/reentrant reports and duplicate final attempts cannot escape.
These guarantees assume serialized, synchronous delivery for each request, including cooperative PHP Fiber interleaving. Each concurrent request needs a separate execution and any request-scoped metadata filters. Delivery must not run ahead of emission; the HTTP adapter pauses at each frame until the emitter flushes it, and owns byte writes, closure detection and buffering. Once final delivery begins it is never retried, since a failing sink may have already written it. A terminal sink failure is diagnosed at most once and propagates as infrastructure failure rather than attempting another response.
McpToolExecutionTest proves controlled ordering, equivalent query/mutation output, validation and late failure,
once-only completion, cancellation before/after command dispatch, Fiber interleaving and cross-request isolation.
It does not prove HTTP/SSE framing, real disconnect observation, threads, native framework emission or starter
support. McpStreamingJourneyTest separately proves package transport. Consumer adoption is independently
owned; TASK-00110's five-starter confirmation plan was explicitly closed without implementation.
Tool envelope metadata¶
Tool\Messaging\McpToolMetadataFilter optionally implements both existing CommandFilter and QueryFilter.
Share one request-scoped instance between the invoker's metadata: argument and the normal command/query pipelines.
It enriches envelopes only while a validated available Tool executes, preserving payload, message identity,
timestamp and existing unrelated metadata. Outside that scope it forwards the original message untouched.
QueryPipeline forwards the filtered envelope through its inner bus's dispatch() rather than recreating it.
The reserved baseline is fight.mcp/tool (canonical name) and fight.mcp/correlation (fresh random 128-bit identity,
shared across that invocation's envelopes). Neither JSON-RPC IDs nor raw protocol metadata enter the bus. An
optional McpToolAuditMetadata::fieldsFor(McpToolInfo) implementation may close over consumer-owned context and
project explicitly allowlisted namespaced scalar fields:
$metadata = new McpToolMetadataFilter($auditProjection, ['orders/actorId', 'orders/traceId']);
$commands->addFilter($metadata);
$queries->addFilter($metadata);
$invoker = new McpToolInvoker($tools, $availability, metadata: $metadata);
Field declarations reject unnamespaced names, the reserved fight.mcp/ namespace, and credential/password/secret/
token/authorization/permission/argument/header/payload names. Returned undeclared keys, arrays, objects, null,
nonfinite floats, malformed Unicode, control-bearing strings and strings over 256 bytes reject before Tool/bus
execution. The consumer must project genuinely non-secret values: no syntactic check can detect a credential
mislabelled as an actor ID. The hook receives no arguments, headers or principal from Common and conveys no policy.
Scopes clear on success and every failure; nested/concurrent reuse rejects. Use separate instances for concurrent
requests/fibers. No global state, payload changes, durable audit guarantee, or cross-request correlation is added.
Protocol behavior¶
The decoder requires a JSON-RPC 2.0 string-or-integer request ID, string method, object-shaped params, and object-shaped _meta with
the io.modelcontextprotocol/protocolVersion and
io.modelcontextprotocol/clientCapabilities fields. Client capabilities are an object; optional client information
is an implementation object with a name and version. A present progress token is likewise a string or integer;
numeric progress values are validated by the selected reporter. The decoder validates every metadata key's MCP grammar,
defined field shape, present trace-context format, and schema-permitted HTTP/HTTPS or image-data implementation
icon source before dispatch, while
preserving schema-permitted empty strings, an empty opaque metadata key, and open objects. Image data URI sources accept
RFC 2045 token-valued or correctly percent-escaped parameter values when their raw representation uses RFC 2396 URL
characters or valid percent escapes. The decoder validates decoded media subtype and parameter attribute tokens, plus
escaped RFC 2045 tspecial and quoted-string parameter-value forms, before their terminal Base64 marker; malformed raw
characters, token syntax, quoting, or percent escapes reject before dispatch. Discovery configuration and server identity must be JSON-safe
Unicode values during composition. It retains only protocol metadata needed by this shared layer: protocol version,
client capabilities and information, and progress token. It intentionally drops opaque extension metadata rather than
treating it as credentials or authority.
Malformed JSON, malformed JSON-RPC, invalid outer parameters, unsupported protocol version, and unknown methods produce centralized JSON-RPC protocol errors without selecting a capability. If a usable request ID is available, the error retains it; otherwise the semantic response has no ID. An unexpected capability failure produces the generic internal protocol error and exposes no exception detail.
Guarded HTTP¶
Adapter\Http\Mcp\McpRequestHandler implements PSR-15's RequestHandlerInterface. A consumer-owned route
passes a PSR-7 server request to it and receives a PSR-7 response created through PSR-17 factories. No HTTP framework
is required. Its constructor requires a registry, McpOriginPolicy, McpInvocationGuard, and McpResponseFactory;
the response factory requires McpDiagnostics. Missing policies fail composition. There is no default allow-all
Origin policy or invocation guard.
Enforcement and errors¶
The handler checks a supplied Origin before reading the body, on every method. Next it checks POST, Accept,
and JSON content type; reads and decodes within finite budgets; validates the complete mirror set; checks the supported version; asks
the invocation guard; and only then invokes a capability. Tool mirror selection performs only metadata lookup and
availability before custom-header comparison; it does not validate arguments or execute the Tool. Invocation
reevaluates current availability after the guard rather than caching an earlier authorization decision.
server/discover passes through the same safeguards.
| Outcome | HTTP status | Representation |
|---|---|---|
| Disallowed, malformed, null, or multiple Origin values | 403 | Empty body, no capability selection |
| Non-POST, including GET/DELETE | 405 | Empty body and Allow: POST |
Missing/incomplete/invalid Accept |
406 | Empty body |
| Missing/non-JSON/unsupported-charset content type | 415 | Empty body |
| Request body exceeds the byte budget | 413 | Empty body; no partial request ID or dispatch |
| Malformed or overly deep JSON / request / outer parameters | 400 | JSON-RPC -32700 / -32600 / -32602 |
| Missing, ambiguous, malformed, or mismatched mirror | 400 | JSON-RPC -32020, Header mismatch. |
| Unsupported version with agreeing mirrors | 400 | JSON-RPC -32022 with supported/requested versions |
| Invocation denied | 429 | Common-defined JSON-RPC 429, Invocation limit exceeded., no diagnostic data |
Unknown method, including unregistered legacy initialize |
404 | JSON-RPC -32601 |
| Unexpected throwable, including result encoding failure | 500 | JSON-RPC -32603, Internal error. |
| Successful semantic result | 200 | Direct application/json, never JSend |
JSON-RPC errors retain a usable decoded request ID, including zero or an empty string. Errors encountered before
an ID can be decoded omit it. Transport-only rejections have no JSON-RPC body. The 429 JSON-RPC code is an
explicit Common convention approved in TASK-00105, not a code assigned by MCP. It is outside JSON-RPC's reserved
range. No threshold, caller-key format, retry time, quota store, exemption, or authorization policy is implied.
POST must explicitly list both application/json and text/event-stream in Accept, with a nonzero weight;
wildcards alone are insufficient. The default endpoint selects direct JSON; opt-in progressive delivery selects
SSE for a guarded tools/call with a progress token. Request content type is application/json,
optionally with charset=utf-8. Common bounds its own request reading/decoding as described below. Consumer
ingress still owns upstream body-size limits, timeouts, TLS, trusted proxies and authentication.
Legacy session and Last-Event-ID headers are ignored and never echoed; no session, GET stream, or DELETE lifecycle
is created. Incoming notification dispatch remains later work. Optional
OAuth resource-server protection now composes outside this endpoint:
consumer validation and claims handoff precede capability selection, with distinct empty 400/401/403 responses.
Consumer-selected HMAC remains a separate compatible composition. Tool registration, discovery and
invocation use this endpoint; outgoing progress uses the additive SSE composition below.
Bounded request ingress¶
Contract fight-common.behavior.mcp-request-bounds: McpRequestLimits supplies validated immutable budgets
shared by McpRequestDecoder and the existing guarded handler. Defaults are 1,048,576 encoded bytes (1 MiB)
and JSON decoder depth 512. Optional integer overrides accept 1–67,108,864 bytes (64 MiB) and depth 1–512;
other values throw DomainException during composition. There is no unlimited setting. The byte count includes
the whole JSON-RPC envelope, whitespace and UTF-8 bytes, not characters, unescaped string sizes or PHP object size.
A body exactly at the byte limit is accepted if otherwise valid. Depth uses PHP json_decode semantics: at most
maxDepth - 1 nested containers, counting the root and empty arrays/objects. A normal request with empty client
capabilities needs depth 5; smaller valid settings intentionally cannot admit that request shape. The default
retains the prior 511-container ceiling. Capability-specific metadata/Tool limits remain independent.
use Fight\Common\Application\Mcp\McpRequestDecoder;
use Fight\Common\Application\Mcp\McpRequestLimits;
$decoder = new McpRequestDecoder(new McpRequestLimits(maxBytes: 2097152, maxDepth: 128));
// Supply decoder: $decoder to the existing McpRequestHandler constructor.
// Its limits() value is also used for bounded HTTP reading; there is no second HTTP budget to synchronize.
Existing new McpRequestDecoder(), decode(string) and all handler constructor/call shapes remain supported.
The optional decoder argument already available on McpResponder also accepts this configured instance.
Compatibility: the new byte rejection is an intentional behavioral restriction approved in TASK-00121,
not a claim that all previously accepted oversized requests remain accepted. Existing valid in-budget calls retain
their protocol behavior. Depth overrides may intentionally narrow acceptance; no new MCP error code is invented.
After Origin and negotiation checks, Common requests chunks of at most 8 KiB, never more than the remaining
budget plus one byte. It stops on the first excess byte without appending it, draining the body, parsing a valid
prefix or searching for an ID. It never uses unrestricted string conversion, getContents(), Content-Length or
stream-size metadata to establish acceptance. Missing, false or oversized declared lengths cannot bypass actual
byte accounting. It does not eagerly allocate the configured maximum. Seekable bodies are rewound to preserve
previous PSR string-conversion behavior; non-seekable bodies are read from their current position. Consumers must
supply the complete request body at that position. Common neither closes nor restores the caller-owned stream.
Excess HTTP input returns an empty 413. Malformed JSON and excessive parsing depth return 400 / -32700 without an ID. Stream exceptions (including rewind/EOF failures), an oversized read return, or an empty read before EOF fail through the central 500 / -32603 response and one redaction-aware diagnostic, with no ID or dispatch. An empty read establishing EOF is valid. No-progress streams must not require retries from this synchronous handler. A stream collaborator cannot inject a public protocol error or unvalidated request ID through an exception. After a successful bounded decode, normal metadata, mirror, protocol-version and invocation-guard checks still apply.
Direct decode(string) checks encoded length before JSON parsing and passes the configured depth to the
parser itself, before creating immutable JSON nodes. An oversized direct input raises the existing
McpProtocolException with -32600 invalid request and no ID; McpResponder::respond() renders that semantic error.
A caller has already allocated that string: this check does not retrospectively bound acquisition or allocation.
HTTP callers should use the guarded handler, not expose the semantic responder directly as an endpoint.
For recovery, reduce the request size/nesting or deliberately configure a supported larger budget based on measured consumer needs; do not retry unchanged excess input or silently truncate it. Stream failures need consumer I/O recovery, not weaker limits. Upstream reverse proxies, PHP/server input, PSR factories and authentication middleware (including any HMAC body hashing) may already have read/buffered content. Common cannot undo that work or constrain an arbitrary stream implementation's internal allocation, read-ahead, lying EOF or blocking I/O. Consumers retain ingress admission, timeouts, concurrency/memory budgets and authenticated complete-body composition. Invocation quotas do not replace byte/depth bounds; none of these mechanics grants authorization or changes authentication.
McpRequestBodyReaderTest and McpRequestDecoderTest prove consumption and parsing boundaries directly.
McpRequestBoundsJourneyTest exercises the real guarded handler through an authenticated consumer-owned Slim route,
including no-read early rejection, exactly-at-limit Unicode input, excess bodies, partial-read failure and unchanged
mirror/guard enforcement. These are package-owned scenarios, not a new blanket MCP conformance or upstream-I/O claim.
Origins, guards, and diagnostics¶
ExactMcpOriginPolicy canonicalizes configured HTTP/HTTPS origins with McpOrigin::fromString(): scheme/host case,
default port, and equivalent IPv6 spellings normalize to one exact scheme-host-port value. Paths (even /), queries,
fragments, credentials, wildcards, malformed hosts, and invalid ports fail configuration. A trailing DNS dot remains
a distinct host. Runtime malformed values, Origin: null, and multiple values reject before policy evaluation.
There is no suffix matching, DNS resolution, Host-header inference, or forwarded-header inference.
DenyAllMcpOriginPolicy explicitly selects native-only composition: every supplied Origin is denied, while an
absent Origin remains valid. Custom McpOriginPolicy::allows(string) implementations receive only a validated
canonical Origin. McpInvocationGuard::allows(string $method, ?string $name) receives only the mirrored method and
applicable standard name/URI, never a principal, credential, raw request, metadata, or arguments. A request-scoped
consumer implementation may privately retain its authenticated caller and policy services.
McpDiagnostics::record(Throwable) is called once for an unexpected endpoint failure. Consumers must supply a
redaction-aware implementation that does not throw; exception details and traces are not sent to the client.
Protocol/Origin/guard rejections are not unexpected diagnostic events. A broken diagnostic sink is contained so its
own exception cannot leak, but successful log delivery cannot be guaranteed when that consumer contract fails.
Infrastructure that cannot create a response at all remains a consumer runtime failure.
Complete transport mirrors¶
McpHeaderValidator owns the rules, independent of PSR header extraction. It matches field names case-insensitively,
including dynamic suffixes, and rejects multiple values or case-variant duplicate entries for recognized fields.
Adapters and ingress must preserve the separate field values; a proxy that irreversibly coalesces duplicate fields
cannot be repaired by the package. Values and JSON property names remain case-sensitive.
MCP-Protocol-VersionandMcp-Methodare required and match their decoded body fields.Mcp-Nameis required fortools/call,prompts/get, andresources/read, matchingparams.nameorparams.uri. An extraneous name on another method rejects rather than becoming routing authority.- Each registered
McpMirrorDeclarationsupplies a method, exact parameter property path, andx-mcp-headersuffix. For example,['arguments', 'account', 'region']andRegionmirror intoMcp-Param-Region. Only object properties are traversed, never array items. A null or absent path requires omission; a present non-null value requires a matching header. Case-insensitive declaration collisions are composition errors. - Strings, booleans (
true/false), and JavaScript-safe integers are supported. Integer comparison is exact and numerical, including equivalent decimal/exponent forms, without floating-point rounding of header values. Non-integral numbers, unsafe integers, arrays, and objects cannot be mirrored. - Non-ASCII, control-bearing, or edge-whitespace strings require the case-sensitive
=?base64?...?=UTF-8 sentinel. Literal sentinel-shaped strings must themselves be encoded. Decoding requires valid canonical Base64 and UTF-8; malformed encodings fail closed. Plain values permit only visible ASCII, internal spaces and horizontal tabs. - Unannotated body properties do not create mirrors. Unrecognized custom headers have no authority and are ignored; capability parameter/schema validation remains responsible for unknown or invalid body properties.
Request-selected Tool mirrors¶
McpToolRegistry compiles input-schema annotations at composition. Only chains of properties from the root may
contain mirrored properties, with explicit string/integer/Boolean types (optionally unioned with null). Root,
array-item, $defs, additional-property and combinator annotations reject. Case-insensitive header collisions
within one Tool reject; other Tools have independent declarations. Defaults/examples are data, not schema nodes.
McpToolInvocation implements McpRequestMirrors: the HTTP validator asks for the currently available named Tool's
declarations after standard mirrors agree, without inspecting arguments or invoking it. Unknown and unavailable
Tools reject identically before custom mirrors, preventing header requirements from exposing hidden registration.
The normal invoker rechecks current availability before argument validation; consumers must use a stable
request-scoped policy, and no stale selection is cached. The header validator merges these declarations with any
method-wide declarations and rejects cross-set collisions or wrong-method declarations. It never applies another
Tool's schema to the selected name. Standalone semantic callers do not handle HTTP headers; use the guarded endpoint
for HTTP rather than exposing respond() directly on a route.
The conformance authority is MCP 2026-07-28, inspected at upstream commit
ab3a39c13bd23be691c2760e1c6c5c15a64582e1:
transport,
error policy, and
schema.
McpHeaderValidatorTest, McpRequestHandlerTest, and McpEndpointJourneyTest retain executable success/rejection/
failure fixtures. The journey boots a consumer-owned Slim route with authentication middleware and verifies direct
JSON, discovery, denied Origin, complete mirrors, invocation denial, and sanitized diagnostics end to end.
Progressive HTTP delivery¶
Enable McpRequestHandler(..., progressive: true) only with Adapter\Http\Mcp\McpResponseEmitter or the
native translations below. A string/integer token on tools/call selects one lazy SSE response after Origin,
negotiation, decode, mirrors, version and invocation-guard validation. Zero and empty-string tokens are preserved.
No-token calls still execute immediately with a fresh no-op reporter and unchanged complete JSON; other methods
remain direct JSON. Consumers retain authentication, authorization and request-scope ownership.
The progressive response is HTTP 200 with Content-Type: text/event-stream,
Cache-Control: no-store, no-cache, must-revalidate, X-Accel-Buffering: no, and no Content-Length.
Each frame is data: <one JSON-RPC object>\n\n. Progress notifications precede exactly one final JSON-RPC
response. No event names, event IDs, retry hints, session, resumability or terminal sentinel is added.
A late selected-Tool failure remains a complete isError result. An unexpected late failure produces one
sanitized -32603 error and one diagnostic. HTTP status cannot change after headers. Pre-stream transport and
guard rejections retain their direct-JSON/empty-body status mappings.
The internal McpEventStream PSR body is single-use and non-seekable. A PHP Fiber pauses the synchronous Tool at
each frame until the emitter requests the next fragment. Reads never assemble a complete transcript ahead of
emission. Closing the body cancels only that request and resumes suspended Tool work, discarding later output;
it does not forcibly kill execution. Availability is reevaluated when invocation starts. Keep the response and
its collaborators inside the consumer-owned request/security scope; use separate collaborators for concurrent work.
McpResponseEmitter::emit() sends PSR headers and body through PHP's SAPI. prepare() checks streaming conditions
before native headers; emitBody() supplies the native send callback. Every read is written and flushed before
the next read. During streaming, the emitter enables ignore_user_abort(true), checks connection_aborted() before
each read, closes the body on exit and restores the prior abort setting. PHP typically observes disconnect on a
write; this is not immediate detection of remote/proxy closure. After observation no later progress/error/final
bytes are attempted. Tools check isCancelled() at safe points; already-dispatched commands are not reversed.
A Tool ignoring cancellation can still run to completion.
Runtime conditions and package evidence¶
McpStreamingJourneyTest uses PHP's built-in cli-server, output buffering 0, zlib compression 0, direct
loopback HTTP without proxies, and a raw socket client. Its six lanes execute framework-free/PSR, Symfony native
StreamedResponse, Laravel ResponseFactory::stream, Yii Router plus the shared emitter, CodeIgniter native send,
and Slim App plus the shared emitter. Each proves two separately read progress events before Tool completion,
success, expected/unexpected failure after progress, direct JSON, early disconnect/cooperative stop, buffered
emission rejection and legacy opt-out. Native APIs are executed, not merely inspected.
The emitter rejects an active output buffer, zlib compression, Content-Length or Content-Encoding on a progressive
response before Tool execution. It does not discard consumer buffers or change compression configuration. Disable
buffering, compression, response-body collectors and debug middleware for this route. Never cache, replay, pre-read
or log its body. String conversion and getContents() explicitly collect bytes and are not streaming support;
an ordinary PSR emitter or complete-body bridge does not establish live delivery.
No PHP-FPM, Apache, worker-server or reverse-proxy guarantee follows from this harness. X-Accel-Buffering: no
is not proof that every intermediary honors it. Consumers must qualify their actual SAPI, buffer stack, proxy,
timeouts, compression and disconnect propagation. Package conformance is not a booted installed-package
starter receipt. Under the approved TASK-00110 closeout, the accepted package evidence completes TICKET-00025
without waiting for five starter receipts or consumer adoption. Fight Agent OS owns its implementation and
runtime qualification and reports demonstrated Common defects upstream as bug-fix TASKs. This scoped evidence
amendment does not claim any consumer journey passed. No persistent package server, route, deployment
configuration or new framework dependency is introduced.
Consumer wiring¶
All examples leave path selection, authentication, authorization, request scope, and runtime emission to the consumer. They are composition examples, not package-owned routes or proof of five installed starter journeys. Actual runtime qualification belongs to the consuming application and does not require a Common confirmation TASK. Never reproduce only part of mirror validation in a framework Action.
Shared composition and framework-free PSR¶
use Fight\Common\Adapter\Http\Mcp\ExactMcpOriginPolicy;
use Fight\Common\Adapter\Http\Mcp\McpRequestHandler;
use Fight\Common\Adapter\Http\Mcp\McpResponseFactory;
use Fight\Common\Application\Mcp\McpCapabilityRegistry;
use Fight\Common\Application\Mcp\McpServerInfo;
use GuzzleHttp\Psr7\HttpFactory;
// Consumer supplies explicit capabilities, a request-scoped McpInvocationGuard,
// and a redaction-aware McpDiagnostics. Guzzle is an optional PSR-17 choice.
$factory = new HttpFactory();
$endpoint = new McpRequestHandler(
new McpCapabilityRegistry(new McpServerInfo('Consumer', '1.0.0'), $capabilities),
new ExactMcpOriginPolicy(['https://client.example']),
$guard,
new McpResponseFactory($factory, $factory, $diagnostics)
);
// Only after consumer authentication/authorization succeeds:
$response = $endpoint->handle($serverRequest);
// Default direct JSON can use the consumer's ordinary PSR emitter.
// For progressive: true, use the concrete emitter below.
For native-only access, explicitly replace ExactMcpOriginPolicy with DenyAllMcpOriginPolicy.
$guard is not an optional placeholder: its consumer implementation must decide whether this caller's invocation
is permitted by configured limits. Authentication remains outside that interface.
Slim and Yii¶
Both use PSR requests/responses directly. Use the shared composition above. Slim's consumer route can delegate all methods so the handler supplies its 405 contract; attach consumer authentication middleware outside it:
In Yii, bind the endpoint in consumer DI and use a route action that delegates the received PSR request:
Route::post('/chosen/mcp')->action(fn (ServerRequestInterface $request) => $endpoint->handle($request));
Import Yiisoft\Router\Route and Psr\Http\Message\ServerRequestInterface in the Yii composition. A POST-only
router must itself return 405/Allow for other methods, or explicitly route them to the handler. Consumer middleware
owns authentication and authorization in either framework.
For progressive: true, use the shared emitter at the consumer-owned send boundary:
use Fight\Common\Adapter\Http\Mcp\McpResponseEmitter;
// Framework-free, or Yii's consumer-owned emitter integration:
(new McpResponseEmitter())->emit($endpoint->handle($serverRequest));
// Slim: use App::handle(), not App::run() with its ordinary non-flushing emitter.
(new McpResponseEmitter())->emit($app->handle($serverRequest));
Yii's application runner needs a consumer-owned integration invoking this same emit() method. The package
harness proves Router plus emitter, not an unmodified runner. Neither Yii nor Slim needs a branded adapter copy.
Symfony and Laravel¶
Use the optional symfony/psr-http-message-bridge compatible with the consumer's framework and a PSR-17 factory.
Neither is silently installed by Common. Symfony's native Request and Laravel's Request subclass can use the same
bridge, preserving the full header-value arrays. The consumer Action/controller performs conversion only:
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
$psr = new PsrHttpFactory($factory, $factory, $factory, $factory);
$response = $endpoint->handle($psr->createRequest($nativeRequest));
return (new HttpFoundationFactory())->createResponse($response);
Construct $endpoint with explicit server identity/capabilities/policies/diagnostics as above in the consumer's
service configuration. Register a consumer-owned route and run its firewall/authentication/authorization before
this delegation. No framework-specific mirror checks are needed or permitted as a substitute.
The generic response bridge is for direct JSON only. With progressive: true, replace response conversion
with the Common native factory, which also handles ordinary JSON:
use Fight\Common\Adapter\Http\Symfony\McpResponseFactory as NativeMcpResponses;
return (new NativeMcpResponses())->fromResponse($endpoint->handle($psrRequest));
Laravel accepts the same native Symfony Response/StreamedResponse; no branded wrapper is needed. Alternatively,
after $emitter->prepare($response), Laravel's
response()->stream(fn () => $emitter->emitBody($response), $response->getStatusCode(), $response->getHeaders())
is the callback boundary exercised by the package fixture. Do not use eventStream() with its default event
naming and </stream> sentinel. Native middleware must not collect content, add compression, replace streaming
headers or outlive the request-scoped collaborators.
CodeIgniter¶
The consumer controller builds a PSR request without flattening repeated header values, delegates all validation,
and maps only the returned status/headers/body back to its native response. With the same $factory and $endpoint:
$psrRequest = $factory->createServerRequest($this->request->getMethod(), (string) $this->request->getUri());
foreach ($this->request->headers() as $field) {
foreach (is_array($field) ? $field : [$field] as $header) {
$psrRequest = $psrRequest->withAddedHeader($header->getName(), $header->getValue());
}
}
$psrRequest = $psrRequest->withBody($factory->createStream((string) $this->request->getBody()));
$psrResponse = $endpoint->handle($psrRequest);
$this->response->setStatusCode($psrResponse->getStatusCode());
foreach ($psrResponse->getHeaders() as $name => $values) {
$this->response->setHeader($name, $values);
}
return $this->response->setBody((string) $psrResponse->getBody());
Place the route, filters, identity resolution, and request-scoped policies in the consuming application. If its SAPI or bridge has already collapsed repeated request fields, reject ambiguous input at ingress instead of claiming that lost multiplicity was validated. The string-body conversion above is direct JSON only.
For progressive delivery replace that native conversion with:
use Fight\Common\Adapter\Http\CodeIgniter\McpResponse;
return new McpResponse($endpoint->handle($psrRequest));
This narrow native Response subclass retains the guarded PSR body until CodeIgniter calls sendHeaders() and
sendBody(). It emits once, using the shared emitter instead of CodeIgniter's stored-string body. Normal
CodeIgniter configuration/services must already be booted. Disable response caching, compression and toolbar
filters; finish the framework's output buffer before sending. Framework-generated body appendages are not part
of MCP output. Consumers retain routes, filters and kernel/runtime wiring. The native send-phase fixture does
not establish a booted installed-package CodeIgniter starter journey.