The revision puts the method, the subject name and the mirrored arguments in
headers so an intermediary can route and cache without parsing the body. A
header that contradicts the body it travels with therefore has to be
refused, not ignored: StandardHeaderValidator compares each against the
request and answers -32020 on a mismatch.
Set with Builder::setHeaderValidator(); a transport with no header layer is
unaffected.
* [Schema][Server] Add the extensions framework SEP-2133 defines
enableExtension() took any string and advertised it, with no way for an
extension to add a method: MessageFactory could not decode one, so nothing
downstream ever saw it.
Identifiers are now checked against the _meta key naming rules through
ExtensionIdentifier - a prefix is mandatory, since an unprefixed name has no
owner, and the modelcontextprotocol/mcp second labels the specification
reserves are recognised. An extension implementing
MethodProvidingExtensionInterface contributes both its message classes and
the handlers serving them.
MessageFactory::make() takes an $additional list of message classes, and
RequestHandlerInterface's result template is covariant so a handler
declaring a concrete result satisfies a collection typed by the interface.
* Fix stale @see reference in MethodProvidingExtensionInterface docblock
* [Schema][Server][Client] Make ExtensionIdentifier a value object
ExtensionInterface::getId() now returns ExtensionIdentifier, which
validates the SEP-2133 naming rules at construction instead of the
callers checking it. getMessages()/getRequestHandlers() move into
ExtensionInterface itself; AbstractExtension gives them empty
defaults for extensions that only announce a capability.
SEP-2207 says a resource server must not advertise `offline_access` as a
required scope. Nothing in src/ writes it - `scopesSupported` is entirely
operator-supplied - so there was nothing to fix, but also nothing stopping a
future default from quietly starting to advertise one.
* Preserve request id in JSON-RPC error responses instead of fabricating id:""
When the server received input it could not turn into a valid message, the
error response carried a fabricated empty-string id that was never in the
request, breaking JSON-RPC client correlation.
The id default for the error factories was '' and that default reached the
wire unchanged: unrecoverable parse errors emitted id:"", and for invalid-
but-parseable messages MessageFactory discarded the decoded id so it also
fell back to "".
Now unrecoverable parse errors (-32700) return id: null per JSON-RPC 2.0,
and invalid-but-parseable requests (-32600) preserve the original request
id. Error::$id / getId() and the $id parameter of forParseError() /
forInvalidRequest() are widened to string|int|null, and the recoverable id
is threaded through InvalidInputMessageException.
Fixes#333
* Preserve nullable request id consistently across Error factories and fromArray
- Widen the remaining for*() factories (forMethodNotFound, forInvalidParams,
forInternalError, forServerError, forResourceNotFound) to string|int|null
defaulting to null, so no error path can fabricate id:"" again. The only
defaulting call site (ProtocolVersionMiddleware) now emits id:null instead
of id:"" for a pre-parse rejection, which is the spec-correct value.
- Accept a null id in Error::fromArray(): require the id key to be present
(JSON-RPC responses must carry it) but allow its value to be null, so a
spec-compliant id:null error response round-trips and incoming error
responses with id:null are accepted instead of rejected.
- Widen the @phpstan-type ErrorData id to string|int|null to match the ctor,
getId(), and jsonSerialize() shape.
* Document intentional id recovery guard in MessageFactory
* Cover id: 0 and string ids in recoverable-id preservation test
* Fixed client handling of null-id error responses
* [Schema] Omit the id of an error response that never had one
An error response whose id could not be read sent `"id": ""`, which claims
the peer issued a request with an empty-string id - a different statement
from "the id could not be read". JSON-RPC spells the latter as a null id,
and a receiver that cannot read the id is exactly the case the code covers.
[BC Break] Error::$id accepts null and getId() may return it. All the for*()
factories default to null, fromArray() accepts a missing or explicitly-null
id, and the member is omitted from the serialized form when absent.
* Guard null response id in Client/Server Protocol handlers
Also fix Error::jsonSerialize()'s phpdoc: data belongs inside error, not beside it.
* [Schema][Server] Close the 2025-11-25 schema gaps and add the 2026-07-28 surface
Ports the type definitions this SDK still misses outside of sampling tool use,
which #409 and #420 already cover. Every addition is optional and defaults to
current behaviour, so a connection negotiated on an older revision is
unaffected.
From 2025-11-25, elicitation gains modes. ElicitationMode splits `form` —
build a form from the requested schema — from `url`, which sends the user out
of band and returns only the accept/decline/cancel outcome. That is why
requestedSchema becomes optional and `url` appears beside it.
ClientCapabilities learns the matching sub-capabilities, where an
`elicitation` naming no mode declares form, the only shape that existed
before. Icon gains `theme` from the same revision, and Implementation gains
the `title` BaseMetadata has carried since 2025-06-18.
From 2026-07-28, schemas loosen where the revision loosens them: SEP-2106
drops the object-only restriction, so Tool::outputSchema may describe any JSON
value and CallToolResult::structuredContent follows.
The same revision defines three error codes (-32020 header mismatch, -32021
missing required client capability, -32022 unsupported protocol version).
ProtocolVersionMiddleware switches to the last of them, so a rejected version
carries the supported set as structured data the client can retry from rather
than only as prose.
* [Schema] Reject what the 2026-07-28 types cannot represent
`Implementation::title` reached the typed constructor unchecked, so malformed
wire data raised a TypeError instead of InvalidArgumentException.
`ToolUseContent::input` accepted a list and serialized it as a JSON array,
where the protocol requires an object. The empty array stays exempt: it is
also an empty map and still emits `{}`.
`ToolChoice` and `ElicitRequest` read their mode with isset(), which is false
for an explicit null, so `{"mode": null}` silently became the default instead
of being rejected. Both use array_key_exists() now, letting the existing type
check refuse null.
* [Client][Server] Advertise the implementation title through both builders
`Implementation::title` could be parsed but never sent: neither
`Client\Builder::setClientInfo()` nor `Server\Builder::setServerInfo()`
accepted one, so every SDK user emitted null.
Both gain a trailing optional `$title`. On the server it sits where the
Implementation constructor already puts it, so existing positional calls keep
their meaning; the client builder forwards it by name, leaving the icons and
websiteUrl slots defaulted.
* [Schema] Serialize every non-null structuredContent
The object-only hydration guard was never that: `!is_array()` admitted
`[1, 2, 3]` and `[]`, which serialize to JSON arrays, while rejecting the
scalars 2026-07-28 permits. The truthiness emission gate was backwards in the
same way — it dropped `[]`, `0`, `false` and `""`, yet emitted lists, strings
and an empty stdClass.
Hydration now accepts any JSON value, and `null` alone means absent, matching
`ToolResultContent` which already carries this field that way. Which values a
given revision permits is a question for version-aware serialization, which
results cannot answer yet.
* [Schema][Server] Make url elicitation reachable
`ElicitRequest::forUrl()` built a request no SDK user could send: the only
public gateway method always constructed form mode from an ElicitationSchema,
and `request()` is private.
`elicitUrl()` joins `elicit()`, and both funnel through one send path that
hydrates the result with the request's own mode. Without that, a url-mode
accept — contentless by design — threw, because ElicitResult requires content
whenever the action is accept. The result carries no discriminator of its own,
so the mode has to come from the request it answers.
`supportsElicitationUrl()` reports whether the client named the mode, reusing
the sub-capability reader the sampling checks already use.
* [Schema] Add CHANGELOG entry for the 2026-07-28 surface
* [Capability][Schema][Server] Carry the widened structuredContent end to end
Review follow-up on the SEP-2106 widening, which stopped at the type.
`ToolReference::extractStructuredContent()` returned `?array`, so a tool
declaring a scalar `outputSchema` had its result dropped and logged as
unsendable. It returns `mixed` now and keeps a scalar — but only from
2026-07-28 on, and only when the tool declared an outputSchema: without one
the value is already carried in `content`, and advertising it twice is not an
improvement.
`CallToolHandler` only warned about list-shaped `structuredContent` on a
self-built `CallToolResult`, so a scalar reached revisions that require an
object unremarked. The check is on the shape now, not on the list case alone.
`ElicitResult` retained a `content` the spec says is absent from url-mode
results, leaving a malformed response indistinguishable from a valid one.
`Tool::jsonSerialize()` emitted the empty root schema as `[]`, which is not a
schema at all. Sub-schemas already had this treatment.
* Address review feedback on resource_link
Three follow-ups from review of the resource_link work:
* PromptResultFormatter: add a regression test proving the optional
fields of a typed resource_link block (title, description, mimeType,
size, annotations, _meta) survive formatting. The delegation to
ResourceLink::fromArray() already landed while rebasing onto the
PromptResultFormatter refactor, but the existing test only supplied
uri/name and so could not catch a reintroduced field drop.
* ResourceLink::fromArray(): validate optional fields consistently, the
way the equivalent ResourceDefinition::fromArray() already does.
description, mimeType and size now raise InvalidArgumentException
instead of surfacing a TypeError (or silently coercing, for size's
(int) cast); annotations goes through Annotations::tryFromArray(),
which carries the is_array() guard that icons already had; icons uses
Icon::listFromArray() so a non-array entry is reported in context.
* ToolReference::extractStructuredContent(): never emit a list as
structuredContent. The Content guard added earlier fixed only one
instance of the real invariant - structuredContent must be a JSON
object, and a PHP list can never be one. Tool::fromArray() already
enforces the matching rule by rejecting an outputSchema whose type is
not "object", so this aligns the runtime path with it. The test
asserting the opposite for an array-typed outputSchema contradicted
the phpstan type and the fromArray() check introduced alongside it in
the same commit, so it is replaced by tests for the two list shapes.
* Apply the structuredContent object rule to object results too
`extractStructuredContent()` guards raw array results against being emitted
as a JSON array, but the object branch handed back whatever `json_decode()`
produced. A `JsonSerializable` returning a list or a scalar slipped straight
through, producing exactly the `structuredContent` the array guard exists to
prevent — and a return value that contradicts the method's own
`array<string, mixed>|null` signature.
Check the decoded value before returning it, and cover the object branch,
which had no tests at all.
* Document structured tool output
`outputSchema` and `structuredContent` were undocumented: the tool return
value docs covered only the `content` side, and the schema generation
section is about tool parameters.
Add a "Structured Output" subsection covering how to declare the schema,
which return values populate `structuredContent`, and why a list has to be
wrapped in a key to get structured output at all.
* Gate structuredContent on the negotiated protocol revision
SEP-2106 (revision 2026-07-28) widens structuredContent to any JSON value;
earlier revisions require an object. Resolve the revision per request and
apply the matching rule, and warn when a declared outputSchema yields none.
* Resolve the protocol revision on RequestContext
Keeps the `_meta`-then-session lookup in one place instead of per handler,
and exposes the negotiated revision to tool handlers.
* Document reading the negotiated revision from RequestContext
* Warn when a self-built CallToolResult carries an invalid structuredContent
Returning a CallToolResult opts out of the extraction rules, so the value is
still sent unchanged — but a JSON array is not valid before SEP-2106.
Sampling was the one client capability a tool could not check before asking.
`supportsRoots()` and `supportsElicitation()` both read the capabilities the
client advertised during the handshake; sampling is stored in that same session
data but had no accessor, so a tool had to issue the request and catch the
refusal.
* [Client][Docs][Schema][Server] Implement protocol version negotiation
The `initialize` handler never looked at the version the client asked for:
it always answered with the server's configured version (or the SDK default),
so a client offering a revision the server supports could still be told to
speak a different one. The spec requires echoing back a supported request and
only counter-offering when the requested version cannot be honoured.
Turns ProtocolVersion into the registry the other SDKs converged on
(typescript-sdk's protocolEras.ts, python-sdk's mcp_types/version.py):
`latestHandshake()`, `handshakeVersions()`, `modernVersions()` and ordered
comparison through `isAtLeast()`. Comparison goes through declaration order
rather than string collation, so an unrecognized peer string compares
conservatively instead of accidentally sorting above a known revision. The
one throw the enum can produce is the SDK's own LogicException, so a consumer
catching `Mcp\Exception\ExceptionInterface` does not miss it.
Adds 2026-07-28 as a known revision and splits the set into two eras:
handshake (2025-11-25 and earlier, negotiated via `initialize`) and modern
(2026-07-28+, per-request `_meta`). The lists are kept apart so a modern
revision can never leak into an `initialize` exchange, which has no
`initialize` at all. Serving the modern era is follow-up work; this only
teaches the SDK that the revision exists and must not be mis-negotiated.
Deliberately absent is any "newest revision in any era" accessor, which
would hand back the one revision the handshake must never offer.
Server: negotiates and stores the result on the session. A version pinned in
Configuration still pins the handshake to exactly that revision.
Client: validates the counter-offer and fails the handshake instead of
continuing on a revision it cannot speak; records the negotiated version on
the client state, readable through the new Client::getProtocolVersion().
The HTTP middleware now defaults to the handshake versions rather than every
declared case, so it stops accepting a modern version the server cannot yet
serve. When the header is absent it falls back to
ProtocolVersion::DEFAULT_NEGOTIATED_VERSION (2025-03-26), the revision that
introduced both Streamable HTTP and the header itself.
The negotiation behaviour is one provideNegotiationTable() data provider
whose rows are the documented table -- supported revision echoed back,
unknown or malformed counter-offered, modern counter-offered -- so the docs
and the suite cannot drift apart. The supported-revision rows iterate
handshakeVersions() rather than a literal list, so declaring a new handshake
revision extends the table automatically, and the "never answer with a modern
revision" assertion runs on every row.
Documented in server-builder.md (the enum registry, how the server answers a
handshake, what pinning changes), transports.md (the header check) and
client.md (the client's side), all linking the spec's versioning section.
* [Client][Docs][Schema][Server] Warn when the configured protocol version cannot be negotiated
* Log a warning instead of silently falling back when a modern revision is
configured on the client or pinned on the server builder.
* Document that setProtocolVersion() does not restrict the MCP-Protocol-Version
header check, which validates against a fixed set.
* Pin every revision's era in a test, so a new case declared past the modern
boundary fails instead of dropping out of the handshake set.
* Add CHANGELOG entry.
* [Client][Docs][Schema][Server] Rename version fallback constant and trim comments
* [Client][Server] Add Roots support
Wire up the MCP "roots" capability on both sides:
Client (php-sdk as client):
- RootsCallbackInterface + ListRootsRequestHandler answer server roots/list
requests, mirroring the Sampling handler.
- Client::sendRootsListChanged() emits notifications/roots/list_changed.
Server (php-sdk as server):
- ClientGateway::listRoots() requests the client's roots, and
supportsRoots() reports whether the client advertised the capability,
normalizing the stored capabilities to an array so an empty
capabilities object (\stdClass) doesn't cause a TypeError.
- Add ListRootsResult::fromArray() (used by listRoots()).
- ListRootsRequestHandler forwards RootsException messages to the server
(new Mcp\Exception\RootsException), mirroring SamplingRequestHandler.
- Client::sendRootsListChanged() throws when the client did not advertise
the roots.listChanged capability.
Examples, docs and tests:
- examples/README/docs advertise
`new ClientCapabilities(roots: true, rootsListChanged: true)` before
sending notifications/roots/list_changed.
- CHANGELOG entry for Roots (unreleased 0.7.0 section at the time).
- Unit tests for capability serialization/round-trip, sendRootsListChanged
gating, and RootsException message forwarding.
phpunit, php-cs-fixer and phpstan green.
* [Client][Server] Address review: schema validation, changelog section, connection guard, working example
Responds to chr-hertel's review of the Roots support:
- ListRootsResult::fromArray() rejects non-array root elements instead of
accepting malformed entries silently.
- CHANGELOG entry moved to the next unreleased section as the release
train advanced past it (0.7.0 -> 0.8.0).
- Client::sendRootsListChanged() went straight to
Protocol::sendNotification(), which sends via
`$this->transport?->send()` -- on a client that is not connected the
notification was silently dropped. It now throws a ConnectionException
like sendRequest() does.
- The stdio example only advertised the roots capability, so the
roots/list handler never actually ran. The client-communication demo
server gets an `inspect_workspace_roots` tool that calls
ClientGateway::supportsRoots()/listRoots(); the example calls it, so
the server actually issues roots/list and the client answers it.
Dropped the try/catch wrapper from the example.
- ClientTest's connected-client mock now goes through setState() so
isConnected() reflects a real handshake instead of a stubbed flag.
phpunit, php-cs-fixer and phpstan green.
* Never let a throwable escape Protocol::processInput()
* Validate payload types in the schema hydration methods
* Stop leaking handler exception messages to the client
The constructor guarded its unwritable-directory case with the global
`\RuntimeException`, the only throw in `src/` still outside
`Mcp\Exception\ExceptionInterface`. A consumer wrapping server setup in a
`catch (ExceptionInterface)` missed it and got an unhandled SPL exception on
a misconfigured session directory instead.
`Mcp\Exception\RuntimeException` extends `\RuntimeException`, so this is
additive: existing `catch (\RuntimeException)` blocks keep working.
The constructor had no test coverage at all, so this adds one for the throw
plus the directory-creation and write/read paths around it.
processInput() called fgets($this->input) with no length argument. PHP
fgets() reads until a newline or EOF, so a peer that streams bytes
without ever sending a newline forced the whole stream into a single
allocation and exhausted the process memory limit (GHSA-vm75-qjc7-xh6w).
Bound each read with fgets($this->input, $this->maxLineBytes) (default
4 MiB, configurable via the constructor). When a read fills the cap with
no trailing newline the line exceeds the limit: discard it and keep
discarding one bounded chunk per tick until its terminating newline,
rather than buffering it. The discard is stateful so the run loop keeps
servicing fibers and outgoing messages instead of blocking on a drain
loop, and the over-length line is logged once.
Reimplements tonghuaroot's advisory fix: makes the cap configurable
rather than a hardcoded constant, drains the over-length line without an
inner blocking loop (responsive run loop, single log line), and adds
regression tests.
Co-authored-by: tonghuaroot <23011166+tonghuaroot@users.noreply.github.com>
* [Server] Expose OAuth protected resource metadata as a reusable request handler
* docs: show handler DI wiring in Symfony/Laravel controller examples
* Focus PR on transport refactor: drop docs and example changes
* docs: add changelog entry for ProtectedResourceMetadataHandler
Builder::build() runs all loaders eagerly and snapshots capabilities from
the resulting registry. Both are computed once, when the server is built.
Under a persistent runtime (e.g. FrankenPHP worker mode) the server is
built a single time, so a loader whose data source is not yet ready at
that moment (cold cache, un-warmed metadata) leaves the registry empty for
the whole process — tools/list stays empty while tools/call still works.
Make deferred loading the default behavior of Registry itself: it takes a
loader and runs it on the first read (has*/get*), moving loading to
request time when the application is initialized. The load runs once; the
loaded flag is set only after a successful load, so a transient failure is
retried on the next read. A re-entrancy guard lets loaders that read the
registry during their own run (e.g. discovery's identity check) observe
the partial state instead of recursing. Registrations made before the
first read survive it.
Expose the switch on Builder via setLazyLoading() (default on) so callers
can opt back into eager loading at build time. In lazy mode capabilities
are advertised from the configured sources so the initialize handshake
does not force a load; in eager mode (or for a registry supplied via
setRegistry(), which is always loaded eagerly) they are read from the
loaded registry.
* Allow pre-built instance handlers in element registration
`HandlerResolver::resolve()` rejected `[$instance, 'methodName']` array
handlers even though the `Handler` type alias and the runtime invoker
already support them, so `Server::builder()->addTool(handler: [$obj, 'bar'])`
threw "Invalid array handler format" at build time.
Relax the array-shape guard to accept an object in slot 0 and normalize it
to its class name for reflection (the same idiom getHandlerDescription()
already uses). This unblocks dependency-injected handler objects that the
container-less `new $className()` fallback cannot build.
Fixes#369
* Use 0.6.1 changelog heading and test instance handler end-to-end
This is a bugfix with no new public API or BC break, so the changelog
entry belongs under 0.6.1 rather than a new 0.7.0 section.
Add an end-to-end test that registers a pre-built instance handler with a
constructor dependency the container-less new $className() fallback cannot
satisfy, then drives a real tools/call through CallToolHandler and asserts
the injected instance is the one invoked.
* Address review: reject Closure array handlers, fix abstract-method message
- HandlerResolver: extract the array-handler guard into a readable
$hasValidTarget check that accepts strings and objects but excludes
\Closure, so [$closure, 'method'] falls through to the format error
instead of the misleading "method not found in class Closure".
- Fix the abstract-method rejection message ("must be abstract" ->
"must not be abstract"), a pre-existing typo in the same branch.
- Tighten the CHANGELOG entry wording.
- Cover Closure rejection with a regression test.
* Move instance-handler entry under 0.7.0 changelog heading
Per review: registering pre-built instance handlers is a feature, not a
patch, so it belongs in a minor release heading.
* fix: harden JSON-RPC input parsing against malformed input and DoS
MessageFactory::create() detected batches via $input[0] === '{', which
mishandled valid-but-non-object JSON: a scalar ("5") or leading-whitespace
object silently returned [] (with a PHP warning) or could reach a foreach
over a non-array. It also had no bound on batch size, letting one small
request expand into arbitrarily many operations (amplification).
- Detect single-vs-batch from the decoded JSON type (object vs list array)
instead of the raw first byte; reject scalars and empty payloads as
InvalidInputMessageException rather than crashing or no-oping.
- Cap batch size (maxBatchSize, default 100) and reject oversized batches.
- Reject non-object batch elements as InvalidInputMessageException instead
of letting them hit a TypeError.
- Bound the StreamableHttpTransport POST body (maxBodyBytes, default 4 MiB),
returning 413, with an incremental read so unknown-size/chunked bodies
cannot exhaust memory.
* docs: document input-hardening limits in CHANGELOG and transports guide
Add CHANGELOG entries and docs/transports.md sections for the JSON-RPC
batch size cap (maxBatchSize) and the StreamableHttpTransport request
body size cap (maxBodyBytes), plus the type-based single-vs-batch
message detection.
A non-template resource resolves to a ResourceReference, which has no completionProviders property; only prompts and resource templates do. Read providers from those instead, returning an empty completion result when a resource has none.
* feat: add MCP Apps extension (io.modelcontextprotocol/ui) support
Adds support for the MCP Apps protocol extension, which lets servers
expose interactive HTML UIs as resources rendered by spec-conforming
clients (e.g. Goose) in sandboxed iframes.
- Generic extension plumbing: ServerExtensionInterface and
Builder::enableExtension(), with extensions advertised under
capabilities.extensions during the initialize handshake.
ClientCapabilities/ServerCapabilities gain an extensions field.
- Schema DTOs under Mcp\Schema\Extension\Apps: the McpApps marker,
UiToolMeta, ToolVisibility enum, UiResourceContentMeta,
UiResourceCsp, and UiResourcePermissions (with empty-object
presence markers per spec).
- A worked weather example under examples/server/mcp-apps/ with a
minimal spec-conforming view (ui/initialize handshake, postMessage
JSONRPCMessage objects, and ResizeObserver-driven size reporting).
- docs/extensions.md guide with pointers from index.md and
examples.md to the ext-apps repository for the TypeScript SDK and
richer view-side patterns.
- Unit and inspector snapshot test coverage.
Spec: https://github.com/modelcontextprotocol/ext-apps
* Type UiToolMeta visibility as ToolVisibility enum
Also fix UiResourceContentMeta::jsonSerialize() return phpdoc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address review nitpicks for MCP Apps extension
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Apply PR review feedback for MCP Apps extension
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add regression tests and CHANGELOG entry for capabilities extensions
- Pin the is_array() guards on extensions in ServerCapabilities and
ClientCapabilities::fromArray() with explicit non-array payload tests.
- Pin the isset()-rejects-null contract in UiResourcePermissions::fromArray().
- Surface the new extensions field on both capability classes in CHANGELOG.
- Inline the ?: idiom in Builder::build() to match the surrounding style.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* [Server] feat: allow runtime handlers
Adds Mcp\Server\Handler\RunTimeHandlerInterface so integrators
(e.g. configuration-driven element sources) can register handlers
whose arguments and execution are resolved at runtime rather than
via PHP reflection of a callable.
ReferenceHandler::handle() dispatches instanceof RunTimeHandler
ahead of the callable/array/string branches, calls filterArguments
to narrow the argument map, and forwards execute() a ClientGateway
built from the active session.
The Registry-layer surface (ElementReference and the four
*Reference subclasses, RegistryInterface, Registry::register*,
ArrayLoader's handler-description helper) is widened to accept the
new type. The Builder/ArrayLoader path keeps its narrow callable
union; exposing runtime handlers there requires reworking
HandlerResolver and is left for a follow-up.
* feat(server): add runtime handler metadata API
Extend RunTimeHandlerInterface with four nullable metadata
accessors (getInputSchema, getOutputSchema, getPromptArguments,
getCompletionProviders) so the loader can obtain element
metadata without reflection.
Add RunTimeHandlerTrait providing null defaults for all four
accessors so single-purpose handlers can override only the
methods relevant to their element kind.
* feat(server): expose runtime handlers via Builder
Widen Builder::addTool, addResource, addResourceTemplate, and
addPrompt to accept RunTimeHandlerInterface instances alongside
the existing closure/array/string handler forms.
Branch ArrayLoader::load() on instanceof RunTimeHandlerInterface
in each of the four registration loops so runtime handlers skip
the reflection-based HandlerResolver/HandlerSchema path and pull
metadata from the interface accessors instead. Require explicit
name and description for runtime handlers and raise
ConfigurationException when missing or when a runtime tool has
no input schema source.
Replace direct $data['handler'] use in error log context with
getHandlerDescription() to avoid serializing large runtime
handler object graphs into log records.
Document the runtime handler API in docs/server-builder.md with a
new Runtime Handlers subsection and add a cross-reference from
docs/mcp-elements.md.
* test(server): cover runtime handler Builder path
Add ArrayLoaderRunTimeHandlerTest covering:
- successful tool registration with handler-supplied input schema,
with kwarg input schema (precedence over the handler), and the
same precedence rule for output schema
- ConfigurationException for tools missing name, description, or
any input schema source
- successful resource registration via Builder kwargs and the
matching missing-name failure mode
- successful resource template registration with completion
providers sourced from the handler and the missing-description
failure mode
- successful prompt registration with arguments and completion
providers sourced from the handler, plus the missing-name
failure mode
- a trait-only fixture verifying the four metadata accessors
return null by default
Update existing ReferenceHandlerTest anonymous classes to use
RunTimeHandlerTrait so they satisfy the widened interface.
* refactor(server): split runtime handler interface
Splits RunTimeHandlerInterface into a minimal base contract (only
execute()) plus element-specific subtypes: RunTimeToolHandlerInterface,
RunTimePromptHandlerInterface, and
RunTimeResourceTemplateHandlerInterface. Each subtype declares only
the metadata accessors relevant to its element kind, so a tool
handler no longer has to no-op out prompt or template methods.
Drops the now-redundant RunTimeHandlerTrait and removes
filterArguments; execute() receives the full argument map directly.
Updates ArrayLoader, ReferenceHandler, and Builder type signatures
to use the per-element interfaces, and rewrites the Runtime
Handlers documentation accordingly.
* refactor(server): rename runtime handler interfaces to Runtime casing
Renames RunTimeHandlerInterface and its element-specific subtypes to
Runtime casing, adds a dedicated RuntimeResourceHandlerInterface so
addResource() rejects mismatched runtime handlers at the type level,
and strips _session and _request from arguments forwarded to runtime
handlers. Hardens ArrayLoader to re-throw ConfigurationException
without wrapping, and extracts inline test fixtures into
tests/Fixtures/Runtime/.
* chore: please the linting gods
* test(server): use Uuid::v4 for symfony/uid 5.4 compatibility
Uuid::v7() was added in symfony/uid 6.2 and breaks the Symfony 5.4
matrix job. Match the convention used elsewhere in the suite.
* refactor(server): split ArrayLoader registration
Extract per-element preparation into dedicated private prepare*
methods for tools, resources, templates, and prompts. Factor the
shared required-field assertion and reflected name/description
resolution into helpers.
* refactor(server): non-null runtime handler types
Drop nullable return types on RuntimeToolHandlerInterface::
getInputSchema(), RuntimePromptHandlerInterface::
getPromptArguments()/getCompletionProviders(), and
RuntimeResourceTemplateHandlerInterface::
getCompletionProviders() in favor of plain arrays.
ArrayLoader no longer needs the null-coalescing
fallbacks, and NullSchemaToolHandler plus its
ConfigurationException test become redundant.
* doc: update docs/server-builder.md
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
* feat: peer revie feedback
Introduce a new `Builder::add(...)` pattern for a more generic runtime
handler.
* refactor(server): apply PR 294 review feedback
Move per-interface handler dispatch from ReferenceHandler into
ExplicitElementLoader by wrapping each ElementHandlerInterface in a
Closure that satisfies the existing callable contract, revert the
*Reference / Registry handler-type widening PR 294 introduced, throw
Mcp\Exception\InvalidArgumentException (not \TypeError) on mismatched
Builder::add() pairings, and rename two classes for clarity:
* Mcp\Capability\Registry\Loader\ArrayLoader -> ReflectedElementLoader
* Mcp\Schema\Resource -> Mcp\Schema\ResourceDefinition (no alias)
ReferenceHandler::prepareArguments() now injects ClientGateway by type
and binds an `array $arguments` / `array $variables` closure parameter
from the leftover inbound arguments. This addresses Antoine's coupling
concern (ReferenceHandler no longer knows about the four handler
interfaces) and Chris's package-exception preference, and clears the
phpdoc reserved-word collision Antoine flagged on the resource schema
class. Existing tests are re-targeted to exercise the closure path
through ExplicitElementLoader / ReferenceHandler.
* doc: address PR 294 review feedback
Apply Chris's line-level suggestions on docs/server-builder.md
(rewrap to 120 chars, smoothed Drupal mcp module name-drop in the
explicit-element registration section, exception name reads
Mcp\Exception\InvalidArgumentException), rewrap docs/mcp-elements.md
to 120 chars, and add 0.6.0 CHANGELOG entries covering the
Builder::add() entry point, the new handler interfaces, the new
ExplicitElementLoader, plus three BC breaks (Resource ->
ResourceDefinition rename, ArrayLoader -> ReflectedElementLoader
rename, mismatched-pairing exception swap).
* refactor: address self-review feedback
* CHANGELOG: drop spurious [BC Break] entry for the exception
type Builder::add() raises (method is new in this PR).
* docs/server-builder: revert wrap-only edits to pre-existing
main content; keep 120ch wrapping only for new sections.
* ExplicitElementLoader: trim verbose class docblock.
* ReferenceHandler: rename SPLAT_PARAMETER_NAMES constant to
ARGUMENT_BAG_PARAMETERS.
* refactor: rename CallableHandler to Handler
* style: fix phpdoc param alignment
* refactor: inline $reference->handler access
* fix: maintainer review feedback
docs: mention ExplicitElementLoader in comment
docs: move Drupal reference to README list
docs: drop implementation-only changelog entry
docs: revert rewrap noise from mcp-elements.md
* style: fix phpdoc param alignment
---------
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
Co-authored-by: soyuka <soyuka@users.noreply.github.com>
* [Server] Add CORS, DNS rebinding and protocol version middleware
Introduce three PSR-15 middleware for `StreamableHttpTransport` exposed
through a public `StreamableHttpTransport::defaultMiddleware()` factory
composed automatically when no middleware is passed.
- `CorsMiddleware`: secure-by-default (no `Access-Control-Allow-Origin`),
configurable allowlist, reflects matching origin with `Vary: Origin`
to protect shared caches.
- `DnsRebindingProtectionMiddleware`: validates `Origin`/`Host` against
a hostname allowlist (localhost-only by default).
- `ProtocolVersionMiddleware`: rejects requests carrying an unsupported
`Mcp-Protocol-Version` header with `400 Bad Request`.
The transport no longer applies CORS via an `instanceof + array_unshift`
post-hook; the middleware parameter is nullable — `null` installs the
secure defaults, `[]` disables them, and users compose by spreading
`StreamableHttpTransport::defaultMiddleware()`. `SESSION_HEADER` and
`PROTOCOL_VERSION_HEADER` are promoted to public constants so middleware
can reuse them.
BC breaks:
- The `corsHeaders` constructor parameter is removed; the `middleware`
parameter shifts one position. Positional callers passing the old
`corsHeaders` argument must switch to named arguments or drop it.
- Default `Access-Control-Allow-Origin` is no longer `*`.
Addresses #260 (DNS rebinding), #277 (CORS extraction) and #306
(protocol version validation).
* [Server] Address review feedback on middleware defaults
- CorsMiddleware: emit Access-Control-Allow-Methods/Headers only on
preflight per CORS spec; tokenize Vary header to avoid substring
false-positives; add allowCredentials flag that emits
Access-Control-Allow-Credentials and throws when combined with
wildcard origin.
- DnsRebindingProtectionMiddleware: return plain-text 403 body
(HTTP-level rejection, not JSON-RPC); use parse_url(..., PHP_URL_HOST)
for origin parsing; drop redundant unbracketed '::1' from default
allowlist.
- ProtocolVersionMiddleware: use Error::forInvalidParams instead of
forInvalidRequest -- JSON-RPC -32602 fits header-value rejection
better.
- JsonRpcErrorResponse::create now accepts a JsonRpc\Error so callers
choose the error code.
- StreamableHttpTransport: emit a warning log when an explicit empty
middleware list is passed, so operators can spot accidental bypass
of the default security stack.
* [Server] Fix CI: switch warning-log tests to PHPUnit mocks
Replace the anonymous AbstractLogger fixture in
StreamableHttpTransportTest with `$this->createMock(LoggerInterface::class)`
so the test does not declare a `log()` signature at all. That sidesteps
both the psr/log v1 LSP incompatibility (untyped parameter) and the
phpstan `missingType.parameter` complaint in one move, and matches how
the rest of the suite (CallToolHandlerTest etc.) fakes loggers.
* Update src/Server/Transport/Http/Middleware/CorsMiddleware.php
Co-authored-by: Antoine Bluchet <soyuka@users.noreply.github.com>
---------
Co-authored-by: Antoine Bluchet <soyuka@users.noreply.github.com>
Make session GC probability configurable via gcProbability and gcDivisor
parameters, mirroring PHP's session.gc_probability/session.gc_divisor.
Exposed through both SessionManager constructor and Builder::setSession().
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat(schema): add top-level title to Tool
Mirror PR #278's Prompt change for Tool:
- Add optional ?string $title to Mcp\Schema\Tool between $name
and $inputSchema. fromArray() now reads $data['title'];
jsonSerialize() emits 'title' right after 'name'. The
@phpstan-type ToolData gains title?: string.
- Add ?string $title to the #[McpTool] attribute between $name
and $description.
- Note ToolAnnotations::$title as deprecated-for-display in
favor of Tool::$title per MCP spec 2025-06-18 (PHPDoc only,
field retained for BC).
Phase 1 of plan 01--tool-title-spec-compliance. Later phases
update Discoverer, Builder::addTool(), ArrayLoader, and the
conformance fixture to match the new signature.
* feat(server): thread title through discovery
- Discoverer::processMethod() now propagates McpTool::$title
into every discovered Tool via named arguments.
- Builder::addTool() accepts ?string $title between $name and
$description. [BC Break] positional callers passing the old
$description as 3rd positional argument must switch to named
arguments, matching the precedent set for addPrompt() in
0.5.0.
- tests/Conformance/server.php migrated to named-argument form
for every addTool() call.
Phase 2 of plan 01--tool-title-spec-compliance.
* feat(loader): forward title in ArrayLoader
ArrayLoader now reads 'title' from each tool config entry and
passes it into the Tool constructor, completing the wiring
from Builder::addTool() through array-driven registration.
The $tools @param array-shape typedef gains title: ?string
between name and description.
Phase 3 of plan 01--tool-title-spec-compliance.
* test(schema): cover Tool.title end-to-end
- New tests/Unit/Schema/ToolTest.php: constructor round-trip,
jsonSerialize key-order when title set vs null, fromArray
tolerance.
- New tests/Unit/Capability/Discovery/DiscovererToolTitleTest
plus a TitleFixture class verifying McpTool(title: ...)
propagates through discovery into the registered Tool.
- New tests/Unit/Capability/Registry/Loader/ArrayLoader...
test verifying array-driven title propagation.
- Extend McpToolTest for title named-arg and default-null.
- Extend BuilderTest with addTool(title: 't') propagation.
- Update tests/Unit/.../RegistryTest, CallToolHandlerTest,
ListToolsHandlerTest and examples/.../server.php
new Tool(...) call sites to pass title (named args) so
they match the new constructor signature.
- CHANGELOG 0.6.0 section: feature bullet + [BC Break]
note for addTool() signature, mirroring 0.5.0 Prompt
entries.
- docs/server-builder.md: addTool() row and prose now
list title? between name and description.
Phase 4 of plan 01--tool-title-spec-compliance. All 730
phpunit tests pass; remaining phpstan warnings are
pre-existing and unrelated to Tool.title.
* refactor(schema): make Tool.title non-BC-breaking
Move `title` to the end of the Tool, Builder::addTool, and McpTool
attribute constructors with a default of null. This preserves
positional-argument compatibility and removes the need for
`title: null` boilerplate at call sites. JSON serialization order
(name, title, inputSchema, ...) is unchanged.
Also tighten the new tests: parameterize ToolTest with data
providers, fold the Discoverer title fixture into the existing
DiscoverableToolHandler, and drop the reflection-heavy Builder
test that overlapped with ArrayLoaderToolTitleTest.
* fix: move title after name
---------
Co-authored-by: DDEV User <nobody@example.com>
* style: apply php-cs-fixer to test files
Addresses drift flagged by the default PHP-CS-Fixer config — mostly
adds missing `: void` return types on pre-existing test methods.
No behavioural changes.
* fix: resolve phpstan errors surfaced by qa
- Drop the obsolete `Method ::test*() has no return type specified`
ignore rule — every test method now declares a return type, so
the pattern never matches and phpstan reports it as unmatched.
- Remove `?? []` from two reads of `CallToolRequest::$arguments`;
the property is typed `array` (non-nullable), so the coalesce is
a no-op that phpstan flags.
---------
Co-authored-by: DDEV User <nobody@example.com>
* feat: relax OIDC discovery policy and add Dynamic Client Registration middleware (RFC 7591)
- Add LenientOidcDiscoveryMetadataPolicy for providers that omit
code_challenge_methods_supported (e.g. FusionAuth, Microsoft Entra ID)
- Keep StrictOidcDiscoveryMetadataPolicy RFC-aligned
- Add ClientRegistrationMiddleware handling POST /register and enriching
/.well-known/oauth-authorization-server with registration_endpoint
- Update Microsoft example to use built-in LenientOidcDiscoveryMetadataPolicy
* fix: move metadata policy note under server.php in Files section
Address review comment: the LenientOidcDiscoveryMetadataPolicy bullet
was a behavioral note, not a file entry. Inline it into the server.php
description to keep the Files list consistent.
* fix: address Copilot review comments on ClientRegistrationMiddleware
- Decode JSON with assoc=true for registrar data so nested objects
are associative arrays, not stdClass instances
- Add Cache-Control: no-store to all error responses (400), not just
the success response (201)
- Rewind response body stream before returning unmodified response
when metadata is not valid JSON, preventing empty body on read
* fix: harden ClientRegistrationMiddleware and improve test coverage
- Revert spurious .gitignore trailing newline
- Use JSON_THROW_ON_ERROR consistently in enrichAuthServerMetadata()
- Add comment clarifying the second json_decode in handleRegistration()
- Assert Cache-Control: no-store on registrar exception error response
- Add test for non-object JSON body in metadata enrichment path
- Extract createPlainTextHandler helper for non-JSON response tests
* fix: reject JSON array bodies in metadata enrichment
Treat JSON list arrays (e.g. [] or ["..."]) as invalid metadata in
enrichAuthServerMetadata() to prevent corrupting the response shape
by adding registration_endpoint to a non-object.
* fix: add RFC 7591 error codes, Content-Type validation, and docblock safety warning
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add setReferenceHandler to Builder
Allow consumers to provide a custom ReferenceHandlerInterface
implementation (e.g. a security-aware decorator) instead of
always using the default ReferenceHandler.
* test: add BuilderTest for setReferenceHandler
- Fluent API returns builder instance
- build() succeeds with and without custom handler
- Integration test verifies custom handler intercepts tool calls
* style: fix codestyle in BuilderTest
* style: use imports instead of inline FQCNs in BuilderTest
* docs: add changelog entry for setReferenceHandler
* Add Middleware handlers to StreamableHttpTransport
* OAuth Implementation based on middleware
* OAuth Implementation based on middleware
* Add Middleware handlers to StreamableHttpTransport
* OAuth Implementation based on middleware
* OAuth Implementation based on middleware
Signed-off-by: Volodymyr Panivko <sveneld300@gmail.com>
* Address PR review feedback for OAuth middleware
* Apply php-cs-fixer to satisfy QA
* Use Mcp exception classes in OAuth middleware
* Refactor OAuth HTTP stack and add Microsoft example policies
* Fix QA PHPStan findings in OAuth examples and auth middleware
* Require OIDC discovery in JWKS provider and refine OAuth proxy behavior
* Remove local development docker artifacts from VCS
* Revert .gitignore changes for local docker files
* Fix body retrieval in OAuthRequestMetaMiddleware for proper string conversion
Signed-off-by: Volodymyr Panivko <sveneld300@gmail.com>
* Address OAuth PR review feedback and align example configs
* Fix QA style issues and add authorization docs
* Fix PHPStan typing issues in OAuth middleware
* Remove duplicate Middleware namespace, consolidate under Http\Middleware
Remove old Mcp\Server\Transport\Middleware\ classes that were duplicated
in Mcp\Server\Transport\Http\Middleware\ and Http\OAuth\. Update docs
to reference the correct namespaces.
* Address PR review: replace Throwable catch with specific exceptions, add JSON_THROW_ON_ERROR
- JwtTokenValidator: replace catch(\Throwable) with explicit
\InvalidArgumentException|\UnexpectedValueException|\DomainException,
letting RuntimeException from JwksProvider propagate as infrastructure errors
- OAuthProxyMiddleware: add \JSON_THROW_ON_ERROR to json_encode for metadata response
- Update tests to expect RuntimeException for JWKS infrastructure failures
---------
Signed-off-by: Volodymyr Panivko <sveneld300@gmail.com>
Co-authored-by: Volodymyr Panivko <volodymyr.panivko@togethernetworks.com>
- Add ElicitAction enum (accept/decline/cancel)
- Add schema definitions for elicitation fields (string, number, boolean, enum)
- Add ElicitationSchema wrapper for requestedSchema
- Add ElicitRequest and ElicitResult for elicitation/create method
- Update ClientCapabilities to support elicitation capability
- Add elicit() method to ClientGateway
- Store client capabilities in session during initialization
- Add comprehensive unit tests
- Add elicitation example server with book_restaurant, confirm_action, and collect_feedback tools
* fix: standardize error handling across handlers with MCP specification compliance
* Update mcp-elements.md
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
* docs: clarify error handling behavior for all element types
* docs: remove unnecessary whitespace in mcp-elements.md
* fix: simplify exception signatures to accept simple messages
* refactor: use specific exceptions in examples for better error handling
* feat: throw exceptions from reference provider instead of returning null
---------
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
* feat(server): add bidirectional client communication support
* fix(server): use correct session ID string conversion method
* fix: clean up docblocks for request handler methods in Builder
* fix: merge artifacts