* [Client] Implement setMaxRetries connection retries
* [Client] Reject a negative retry count in Configuration
* [Client] Use a positive init timeout in the retry test
* [Client] Drop redundant docblock note on setMaxRetries
* [Client] Clear the initialized flag on a failed connection
* [Client] Trim comments to the non-obvious ones
* [Client] Cover retries and timeout cleanup with integration tests
The retry fixture counts the processes it was started as, which is what
separates a real retry from a second call into the same server. The timeout
fixture outlives the client's patience and then goes idle, so the next
request runs on a connection the client already gave up on once.
* [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.
* feat(client): add complete MCP client SDK with STDIO and HTTP transports
Add Client, Builder, ClientProtocol, ClientSession, and transport
implementations for communicating with MCP servers. Supports tools, resources, prompts, and real-time progress/logging notifications.
* refactor(examples): reorganize examples into server/ and client/ folders
* feat: add SamplingRequestHandler for handling server sampling requests
* feat: add setLoggingLevel method to control server logging verbosity
* refactor: make progress token dependent on request ID
* docs: Update server IP in usage instructions and add notes on sampling server requirements.
* feat: add complete() method for completion/complete requests
* refactor: properly parse and type initialize result
* refactor: standardize method names
* fix: revert changes to server related components
* fix: revert changes to server related components
* refactor: Restructure client transports and handlers into dedicated namespaces.
* fix: cs and php-stan errors fixes(partial)
* feat: Refactor request handlers to return `Response` or `Error` objects directly, adding a `SamplingException` and error logging for sampling requests.
* feat: Replace `ClientTransportInterface` with `TransportInterface` and move response result deserialization to client methods.
* refactor: remove redundant timeout check for intialize on transports
The processFiber that executes per tick while fiber is suspended already handles timeouts while waiting
* feat: broaden symfony/http-client dependency to support Symfony 5.4 to 8.0
* Update examples/client/README.md
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
* Update examples/client/README.md
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>
* refactor: remove outgoing message queue from session and simplify transport send method to directly dispatch messages.
* refactor: Replace ClientSession with ClientState for clearer intent on runtime state management
* refactor: rename connectAndInitialize to connect for consistency
* chore: cs fix
* chore: use SamplingCallbackInterface instead of a closure for sampling request handler
---------
Co-authored-by: Christopher Hertel <mail@christopher-hertel.de>