A comprehensive guide to developing Model Context Protocol clients using the logiscape/mcp-sdk-php SDK.
- Introduction
- Getting Started
- Part 1: Connecting to Servers
- Part 2: Calling Tools
- Part 3: Using Prompts
- Part 4: Reading Resources
- Part 5: Configuring the HTTP Transport
- Part 6: Connecting to OAuth-Protected Servers from the CLI
- Part 7: OAuth in Web Hosting Environments
- Part 8: Handling Elicitation Requests
- Part 9: Notifications, Progress, and Logging
- Part 10: Resuming HTTP Sessions Across Web Requests
- Appendix A: Configuration Reference
- Appendix B: Connection Recipes
The Model Context Protocol (MCP) is an open standard that lets AI applications interact with external data and tools through a uniform interface. An MCP client is the side of that conversation that consumes a server's capabilities -- it discovers what tools, prompts, and resources are available and invokes them on the user's behalf.
The logiscape/mcp-sdk-php SDK implements both ends of the MCP specification (including the latest 2025-11-25 revision) for PHP 8.1+. On the client side it provides the Client and ClientSession classes, which together handle:
- Both transports the spec defines: stdio (subprocess servers) and Streamable HTTP (remote servers).
- The full initialization handshake, capability negotiation, and JSON-RPC plumbing.
- Server-initiated elicitation requests in form and URL modes (since
2025-06-18and2025-11-25respectively), with optional schema-default auto-fill (SEP-1034). - The complete OAuth 2.1 authorization-code flow with PKCE, including dynamic client registration (RFC 7591), the Client ID Metadata Document path (CIMD,
2025-11-25), token storage and refresh, and a redirect-based async flow that works on stateless PHP hosting. - Streamable HTTP features that matter on PHP web hosts: SSE response streams with
retry/Last-Event-IDreconnection (SEP-1699), opt-out of the standalone GET stream, and a session-resume API that lets a single MCP session survive across multiple PHP requests.
- A CLI tool that drives any MCP server (local stdio subprocess or remote HTTP) -- ideal for scripting, testing, or building developer tooling.
- A web application that connects browser users to MCP servers, including OAuth-protected ones, while running on traditional cPanel/Apache/PHP-FPM hosting.
- A PHP-based MCP host that wires user-supplied tools into your own LLM workflows.
This guide focuses on the client side of the SDK. For creating MCP servers see the Building MCP Servers in PHP guide.
- PHP 8.1 or higher
- Composer
ext-curlandext-json(typically enabled by default)ext-openssl(required forFileTokenStorageencryption and HTTPS)- For stdio transports:
proc_openenabled (almost always available on CLI; usually disabled on shared web hosts -- which is fine, you don't need stdio there) - For OAuth callback handling on the CLI:
ext-sockets(needed byLoopbackCallbackHandler)
composer require logiscape/mcp-sdk-phpThe simplest possible client connects to a server, lists its tools, and disconnects:
<?php
// client_basic.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
// Connect to a remote MCP server. The same Client also speaks stdio --
// just pass a command instead of an HTTP(S) URL.
$client = new Client();
$session = $client->connect('https://example.com/mcp-server.php');
// The handshake has already happened by the time connect() returns.
$initResult = $session->getInitializeResult();
echo "Connected to {$initResult->serverInfo->name} {$initResult->serverInfo->version}\n";
echo "Negotiated protocol version: {$initResult->protocolVersion}\n";
// List the tools the server exposes.
$tools = $session->listTools()->tools ?? [];
echo "Server exposes " . count($tools) . " tool(s):\n";
foreach ($tools as $tool) {
echo " - {$tool->name}: {$tool->description}\n";
}
$client->close();A few things to know about this minimal example:
new Client()creates the orchestrator. It detects whether the target is a stdio command or an HTTP(S) URL.connect()builds the transport, performs the JSON-RPCinitializehandshake, sends theinitializednotification, and returns a fully-initializedClientSession.- The returned
ClientSessionis what you call methods on:listTools(),callTool(),readResource(), etc. close()tears everything down cleanly, sending an HTTPDELETE(or terminating the subprocess) so the server can free its session.
To launch a local MCP server as a subprocess instead, pass the command and arguments directly to connect():
<?php
// client_basic_stdio.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
$client = new Client();
$session = $client->connect(
commandOrUrl: 'php',
args: ['/absolute/path/to/server.php']
);
$initResult = $session->getInitializeResult();
echo "Connected to {$initResult->serverInfo->name}\n";
$client->close();The SDK uses the URL scheme of commandOrUrl to decide which transport to use:
- Anything that parses as
http://orhttps://-> Streamable HTTP transport - Anything else -> Stdio transport, with
commandOrUrlas the command andargsas its argv
The Client::connect() method is overloaded for both transports. Its parameter list is shared between them, but the meaning of args and env changes depending on which one is used:
public function connect(
string $commandOrUrl,
array $args = [],
?array $env = null,
?float $readTimeout = null
): ClientSession;| Param | Stdio meaning | HTTP meaning |
|---|---|---|
$commandOrUrl |
Executable to launch (e.g. 'php', 'node') |
The MCP endpoint URL |
$args |
Arguments to the executable | HTTP headers (['Authorization' => 'Bearer ...']) |
$env |
Environment variables for the subprocess | HTTP transport options array (see Part 5) |
$readTimeout |
Per-request read timeout (seconds) | Per-request read timeout (seconds) |
That dual meaning is why you'll see HTTP examples that pass headers as $args and an options array as $env -- the parameter names match the stdio case, but the SDK reuses the slots.
The InitializeResult returned by getInitializeResult() is the ground truth for what the server can do. Every capability the server didn't advertise will be null on the capabilities object:
<?php
// client_capabilities.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
$client = new Client();
$session = $client->connect('https://example.com/mcp-server.php');
$caps = $session->getInitializeResult()->capabilities;
if ($caps->tools !== null) {
echo "Server supports tools (listChanged: " . var_export($caps->tools->listChanged ?? null, true) . ")\n";
}
if ($caps->prompts !== null) {
echo "Server supports prompts\n";
}
if ($caps->resources !== null) {
echo "Server supports resources";
if ($caps->resources->subscribe ?? false) {
echo " (with subscribe)";
}
echo "\n";
}
if ($caps->logging !== null) {
echo "Server can emit logging messages\n";
}
if ($caps->completions !== null) {
echo "Server supports argument completion\n";
}
$client->close();Always gate your calls on the advertised capability. Calling listTools() against a server that didn't advertise tools will result in a JSON-RPC error.
The MCP protocol has had several spec revisions, and many features (elicitation, structured content, URL elicitation, sampling-with-tools, CIMD, etc.) only exist in certain versions. The session knows which version it negotiated and can answer feature questions:
$session = $client->connect('https://example.com/mcp-server.php');
// Hard version string (e.g. "2025-11-25").
$version = $session->getNegotiatedProtocolVersion();
// Boolean checks for individual features.
if ($session->supportsFeature('elicitation')) {
// The negotiated protocol version defines elicitation/create.
}
if ($session->supportsFeature('url_elicitation')) {
// Negotiated 2025-11-25 or newer; URL-mode elicitation is defined.
}
if ($session->supportsFeature('structured_content')) {
// The negotiated version defines structuredContent on tool results.
}supportsFeature() answers a single question: "is this feature defined in the negotiated protocol version?" It looks the feature up in the version-to-minimum-version table in Mcp\Shared\Version and does not look at what either side actually advertised in capabilities. To check what the server actually said it supports (e.g. tools.listChanged, resources.subscribe), inspect the capabilities object returned by the handshake:
$caps = $session->getInitializeResult()->capabilities;
if ($caps->tools !== null && $caps->tools->listChanged) {
// The server promised to send notifications/tools/list_changed.
}The full feature list is in Mcp\Shared\Version -- e.g. sampling, elicitation, url_elicitation, structured_content, tool_output_schema, progress_message, cimd, sampling_with_tools, tasks.
Tools are the primary thing a client invokes. The pattern is always the same: list, decide, call.
<?php
// tools_list.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
$client = new Client();
$session = $client->connect('https://example.com/mcp-server.php');
$result = $session->listTools();
foreach ($result->tools as $tool) {
echo "- {$tool->name}\n";
if (isset($tool->description)) {
echo " {$tool->description}\n";
}
// The input schema describes what arguments the tool expects. Each
// property definition is forwarded as decoded JSON, which is typically
// an associative array (servers occasionally hand back stdClass), so
// handle both shapes when reading fields.
if (isset($tool->inputSchema, $tool->inputSchema->properties)) {
$required = $tool->inputSchema->required ?? [];
foreach ($tool->inputSchema->properties as $name => $prop) {
$req = in_array($name, $required, true) ? 'required' : 'optional';
$type = is_array($prop)
? ($prop['type'] ?? 'unknown')
: ($prop->type ?? 'unknown');
echo " - {$name} ({$type}, {$req})\n";
}
}
}
$client->close();Most MCP servers return their entire tool, prompt, or resource catalog in a single response, so the convenience methods on ClientSession (listTools(), listPrompts(), listResources()) are deliberately cursor-free. For servers that do paginate -- typically because the catalog is large enough that returning it in one shot would blow past a sensible response budget -- the response will arrive with a non-null nextCursor, and the SDK exposes pagination through the lower-level sendRequest() API on the session.
The pattern is the same for any paginated list method: build the typed List…Request with the cursor you want to send (or null to start from the beginning), call sendRequest(), and keep going while nextCursor is non-null:
<?php
// tools_list_paginated.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
use Mcp\Types\ListToolsRequest;
use Mcp\Types\ListToolsResult;
$client = new Client();
$session = $client->connect('https://example.com/mcp-server.php');
$cursor = null;
$allTools = [];
$pageCount = 0;
do {
/** @var ListToolsResult $page */
$page = $session->sendRequest(
new ListToolsRequest($cursor),
ListToolsResult::class,
);
$pageCount++;
foreach ($page->tools as $tool) {
$allTools[] = $tool;
}
$cursor = $page->nextCursor; // null on the final page
} while ($cursor !== null);
echo "Fetched " . count($allTools) . " tool(s) across {$pageCount} page(s)\n";
$client->close();sendRequest() is the same low-level call the convenience methods use under the hood; passing the typed request directly just gives you control over the cursor parameter that the wrappers don't expose. The same pattern works for ListPromptsRequest / ListPromptsResult, ListResourcesRequest / ListResourcesResult, and ListTemplatesRequest / ListResourceTemplatesResult. Treat the cursor as opaque -- it's a server-defined token, never something you construct yourself.
If you don't care about pagination (and most callers don't), listTools() and friends are still the right call: they fetch a single page and ignore nextCursor. Reach for the lower-level form only when you know the server paginates and you actually need every page.
<?php
// tools_call.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
use Mcp\Types\TextContent;
$client = new Client();
$session = $client->connect('https://example.com/mcp-server.php');
$result = $session->callTool('add_numbers', ['a' => 1, 'b' => 2]);
// CallToolResult always carries a `content` array.
foreach ($result->content as $block) {
if ($block instanceof TextContent) {
echo "Text: {$block->text}\n";
} else {
echo "Other content type: " . get_class($block) . "\n";
}
}
// Tool errors don't throw -- the server returns a normal result with isError=true
// so the model can self-correct. Check for it explicitly.
if ($result->isError ?? false) {
fwrite(STDERR, "Tool reported an error\n");
}
$client->close();Two important things about tool results:
- Content is a list of typed blocks. Use
instanceofto handle each variant (TextContent,ImageContent,AudioContent,EmbeddedResource, etc.). Don't assume a shape. isErroris a flag, not an exception. When a tool's callback throws on the server side, the SDK reports it as a normal result withisError: true. Genuine RPC failures (network, malformed JSON, unknown method) do throw on the client.
Servers that negotiated 2025-06-18 or newer can attach a machine-readable structuredContent field alongside the human-readable content blocks. The SDK exposes it on CallToolResult as a plain ?array -- whatever the server sent, forwarded as-is. Handle the null branch and don't assume keys exist:
$result = $session->callTool('analyze-url', ['url' => 'https://example.com/path?q=1']);
if ($result->structuredContent !== null) {
$data = $result->structuredContent;
echo "Host: " . ($data['host'] ?? '(missing)') . "\n";
echo "Path: " . ($data['path'] ?? '(missing)') . "\n";
} else {
// Fall back to parsing the text blocks.
foreach ($result->content as $block) {
echo $block->text ?? '';
}
}Prompts are server-supplied message templates. The user (or your application) picks one, supplies arguments, and gets back a list of messages to seed a conversation with.
<?php
// prompts_list.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
$client = new Client();
$session = $client->connect('https://example.com/mcp-server.php');
$result = $session->listPrompts();
foreach ($result->prompts as $prompt) {
echo "- {$prompt->name}\n";
if (isset($prompt->description)) {
echo " {$prompt->description}\n";
}
if (!empty($prompt->arguments)) {
foreach ($prompt->arguments as $arg) {
$req = ($arg->required ?? false) ? 'required' : 'optional';
echo " arg: {$arg->name} ({$req}): " . ($arg->description ?? '') . "\n";
}
}
}
$client->close();<?php
// prompts_get.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
use Mcp\Types\TextContent;
$client = new Client();
$session = $client->connect('https://example.com/mcp-server.php');
// Prompt arguments must be strings -- they come from a UI form, not from JSON Schema.
$result = $session->getPrompt('code-review', [
'language' => 'php',
'code' => "function add(\$a, \$b) { return \$a + \$b; }",
]);
echo "Description: " . ($result->description ?? '(none)') . "\n\n";
foreach ($result->messages as $message) {
echo "[{$message->role->value}]\n";
if ($message->content instanceof TextContent) {
echo $message->content->text . "\n\n";
} else {
echo "(non-text content: " . get_class($message->content) . ")\n\n";
}
}
$client->close();The returned GetPromptResult::$messages is a list of PromptMessage objects -- each has a role (Role::USER, Role::ASSISTANT) and a content block. Feed them into your LLM as the initial conversation.
If the server advertises completions, you can ask it to suggest values for a prompt or resource argument as the user types:
<?php
// prompts_complete.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
use Mcp\Types\PromptReference;
$client = new Client();
$session = $client->connect('https://example.com/mcp-server.php');
if ($session->getInitializeResult()->capabilities->completions === null) {
echo "Server does not support completions.\n";
$client->close();
exit;
}
$result = $session->complete(
new PromptReference('code-review'),
['name' => 'language', 'value' => 'p'] // user has typed "p" so far
);
foreach ($result->completion->values as $suggestion) {
echo "Suggestion: {$suggestion}\n";
}
$client->close();Use ResourceReference instead of PromptReference when completing the variables of a templated resource URI.
Resources are URI-addressed pieces of context the server makes available. They might be files, database records, configuration, or live system data -- anything the server wants the model (or your application) to be able to read.
<?php
// resources_list.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
$client = new Client();
$session = $client->connect('https://example.com/mcp-server.php');
$result = $session->listResources();
foreach ($result->resources as $resource) {
echo "- {$resource->name}\n";
echo " URI: {$resource->uri}\n";
if (isset($resource->mimeType)) {
echo " MIME: {$resource->mimeType}\n";
}
if (isset($resource->description)) {
echo " Desc: {$resource->description}\n";
}
}
$client->close();<?php
// resources_read.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
use Mcp\Types\TextResourceContents;
use Mcp\Types\BlobResourceContents;
$client = new Client();
$session = $client->connect('https://example.com/mcp-server.php');
$result = $session->readResource('config://app-settings');
// A single resource read can return multiple content items (e.g. a schema + sample data).
foreach ($result->contents as $content) {
echo "URI: {$content->uri}\n";
echo "MIME: " . ($content->mimeType ?? 'unknown') . "\n";
if ($content instanceof TextResourceContents) {
echo "Text:\n{$content->text}\n";
} elseif ($content instanceof BlobResourceContents) {
// blob is base64-encoded bytes
$bytes = base64_decode($content->blob);
echo "Binary data: " . strlen($bytes) . " bytes\n";
}
echo "---\n";
}
$client->close();Always handle both TextResourceContents and BlobResourceContents -- the same URI might return either depending on what the server's resource callback produced.
If the server advertised resources.subscribe, you can ask it to notify you when a resource changes. Whether you receive those notifications depends on the transport's ability to deliver server-initiated messages -- see Part 9 for how to register a notification handler.
<?php
// resources_subscribe.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
$client = new Client();
$session = $client->connect('https://example.com/mcp-server.php');
$caps = $session->getInitializeResult()->capabilities;
if (!($caps->resources->subscribe ?? false)) {
echo "Server does not support resource subscriptions.\n";
$client->close();
exit;
}
$session->subscribeResource('info://server-status');
// ... do work, receive notifications/resources/updated ...
$session->unsubscribeResource('info://server-status');
$client->close();The HTTP transport accepts a configuration array as the third argument to Client::connect() (the $env parameter). Every option has a sensible default; override only what you need.
<?php
// http_configured.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
$client = new Client();
$session = $client->connect(
commandOrUrl: 'https://example.com/mcp-server.php',
args: [
'Authorization' => 'Bearer my-static-token',
'X-My-App' => 'my-php-client/1.0',
],
env: [
'connectionTimeout' => 10.0, // seconds to establish the TCP connection
'readTimeout' => 30.0, // seconds to wait for each response
'verifyTls' => true, // turn off only for self-signed dev servers
'enableSse' => true, // accept text/event-stream responses (default: true)
'autoSse' => true, // open the standalone GET SSE stream (default: true)
],
);
echo "Connected: {$session->getInitializeResult()->serverInfo->name}\n";
$client->close();The MCP Streamable HTTP spec allows clients to open a long-lived GET against the endpoint that the server can use to push messages out-of-band of any active POST. The SDK opens this stream automatically after a successful handshake.
In short-lived web requests this background channel is more trouble than it's worth -- it cannot outlive the request. Pass autoSse => false to skip it:
$session = $client->connect(
'https://example.com/mcp-server.php',
[],
['autoSse' => false],
);Server -> client interleaving on the POST SSE response (used during a tool call that triggers elicitation, for example) still works whether or not autoSse is set -- it's a different mechanism.
For self-signed or internal certificates, point the SDK at a custom CA bundle:
$session = $client->connect(
'https://internal.example/mcp-server.php',
[],
[
'verifyTls' => true,
'caFile' => '/path/to/internal-ca.pem',
],
);verifyTls => false is also supported but should be reserved for local development -- it disables both peer and host verification.
When the server interrupts an SSE response with a graceful close, the client honors the retry field and reconnects with Last-Event-ID to resume the stream. Two knobs control the reconnect policy:
$session = $client->connect(
'https://example.com/mcp-server.php',
[],
[
'sseDefaultRetryDelay' => 1.0, // delay (s) when the server omits `retry`
'sseReconnectBudget' => 60.0, // total wall-clock budget (s) for reconnect attempts
],
);These defaults are sensible for most servers; tune them only if you're working with a server that has unusual reconnect semantics.
For anything not exposed directly, you can pass raw cURL options:
$session = $client->connect(
'https://example.com/mcp-server.php',
[],
[
'curlOptions' => [
CURLOPT_PROXY => 'http://corporate-proxy:8080',
CURLOPT_USERAGENT => 'my-php-mcp-client/2.0',
],
],
);These are merged into every cURL handle the transport creates.
The SDK's HTTP client speaks the modern Streamable HTTP transport: a single endpoint to which it POSTs JSON-RPC and from which it accepts plain JSON or SSE responses (plus the optional standalone GET stream described above). This is the transport the current spec defines.
It does not implement the deprecated HTTP+SSE dual-endpoint transport from the 2024-11-05 revision -- the older design where the client opened a separate long-lived GET /sse stream and POSTed messages to a second, distinct endpoint. The spec deprecated that transport in favor of Streamable HTTP, and this SDK targets only the modern form.
The practical consequence is narrow: this client cannot connect to a server that only exposes the legacy dual-endpoint transport. This is intentional and does not reduce Streamable HTTP coverage -- any server implementing the current transport works normally, and protocol-version negotiation still lets the client speak older protocol revisions (including 2024-11-05 message shapes) over the modern transport.
When an MCP server is protected with OAuth 2.1 (per the MCP spec), an unauthenticated request returns 401 Unauthorized with a WWW-Authenticate header that points the client at the protected resource metadata. The SDK uses that header to:
- Discover the protected resource metadata (RFC 9728)
- Discover the authorization server metadata (RFC 8414 / OIDC)
- Pick a client credential strategy: pre-registered, CIMD (
2025-11-25), or dynamic registration (RFC 7591) - Run the PKCE-protected authorization-code flow
- Exchange the code for tokens and store them
For CLI applications the SDK ships a LoopbackCallbackHandler that opens a temporary loopback HTTP server on 127.0.0.1, opens the user's browser to the authorization URL, and captures the code from the redirect. This is the right approach for any long-running PHP process: developer CLIs, daemons, automated test harnesses, etc.
The MCP authorization spec lists three client-identification paths in priority order: pre-registered credentials, Client ID Metadata Documents (CIMD) for 2025-11-25 servers, and Dynamic Client Registration (DCR) as a backwards-compatibility fallback. The minimal example below uses pre-registration because the credentials are stable across invocations -- ideal for a CLI that may be re-run many times against FileTokenStorage. CIMD and DCR are covered in the subsections that follow.
<?php
// oauth_cli.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
use Mcp\Client\Auth\OAuthConfiguration;
use Mcp\Client\Auth\Callback\LoopbackCallbackHandler;
use Mcp\Client\Auth\Registration\ClientCredentials;
use Mcp\Client\Auth\Token\FileTokenStorage;
$tokenStorage = new FileTokenStorage(
storagePath: __DIR__ . '/.oauth-tokens',
encryptionSecret: 'your-encryption-secret-at-least-32-chars',
);
$callbackHandler = new LoopbackCallbackHandler(
port: 0, // auto-pick a free port
timeout: 120, // seconds to wait for the user
openBrowser: true, // try to open the URL automatically
);
// Register an OAuth client with your authorization server out of band, then
// supply the issued client_id (and client_secret if it gave you one) here.
$credentials = new ClientCredentials(
clientId: 'my-mcp-cli',
clientSecret: 'super-secret-string',
tokenEndpointAuthMethod: ClientCredentials::AUTH_METHOD_AUTO,
);
$oauthConfig = new OAuthConfiguration(
clientCredentials: $credentials,
tokenStorage: $tokenStorage,
authCallback: $callbackHandler,
);
$client = new Client();
// First run: the SDK opens the user's browser to authorize, then stores tokens
// in $tokenStorage. Re-runs of this script reuse the persisted tokens (and
// auto-refresh them when they near expiry) so the browser does not reopen.
$session = $client->connect(
commandOrUrl: 'https://example.com/mcp-server.php',
args: [],
env: ['oauth' => $oauthConfig],
);
echo "Authenticated and connected to {$session->getInitializeResult()->serverInfo->name}\n";
// Do work...
foreach ($session->listTools()->tools as $tool) {
echo "- {$tool->name}\n";
}
$client->close();A few things to note:
AUTH_METHOD_AUTOis usually the right choice. It lets the SDK pickclient_secret_post,client_secret_basic, ornonebased on the authorization server's metadata. Override it explicitly only if you have a specific reason.- Always use
FileTokenStorageoutside of trivial scripts. The defaultMemoryTokenStorageonly persists tokens for the lifetime of the PHP process, so the next run would re-prompt the user. - Encrypt token files. Pass an encryption secret to
FileTokenStorageso a dropped backup or rogue cron job can't lift refresh tokens off disk. The cipher is AES-256-GCM with a SHA-256-derived key. - Auto-refresh is on by default. When
OAuthConfiguration::$autoRefreshistrue(the default) the SDK refreshes tokens withinrefreshBufferseconds (default 60) of expiry, transparently to your code. - DCR credentials live for the process, not the disk. If you swap
clientCredentialsout forenableDynamicRegistration: true, theclient_id/client_secretreturned by the authorization server are cached in memory inside the activeOAuthClientfor the remainder of that PHP process. They are not written toTokenStorageInterface. So a long-running daemon is fine, but a CLI that runs to completion and exits will lose those credentials -- and the next invocation's refresh attempt will fail because the stored refresh token is bound to a client the AS has already issued away from. Across invocation boundaries (CLI re-runs or stateless web requests), prefer pre-registration or CIMD. If you must use DCR there, captureclientId/clientSecretfrom theAuthorizationRequestyou stored during the redirect flow and feed them back intoOAuthConfiguration(clientCredentials: ...)on every subsequent run -- the bundledwebclient/reference implementation shows the full pattern.
On 2025-11-25 authorization servers that advertise client_id_metadata_document_supported, you can skip dynamic registration entirely by hosting a static client metadata JSON file and passing its URL as both the client ID and the discovery hint:
$oauthConfig = new OAuthConfiguration(
tokenStorage: $tokenStorage,
authCallback: $callbackHandler,
enableCimd: true,
cimdUrl: 'https://my-app.example.com/mcp-client-metadata.json',
);That JSON file should contain the same fields you would have submitted via DCR (redirect URIs, client name, scopes, etc.). When CIMD is supported by the AS, the URL itself acts as your client identifier.
This is the recommended path for stateless PHP web hosting. Because the CIMD URL is the client identifier, there is nothing per-process to register or persist -- token refresh on a fresh PHP request just works, with only the tokens themselves needing storage via TokenStorageInterface. The SDK's defaults (enableCimd: true, enableDynamicRegistration: true) try CIMD first when the AS supports it and only fall through to DCR otherwise; setting cimdUrl is what activates the CIMD path.
ClientIdMetadataDocument (in Mcp\Client\Auth\Registration) is a small helper that builds the JSON document for you to host:
use Mcp\Client\Auth\Registration\ClientIdMetadataDocument;
$doc = new ClientIdMetadataDocument(
clientIdUrl: 'https://my-app.example.com/mcp-client-metadata.json',
clientName: 'My MCP Client',
redirectUris: ['https://my-app.example.com/oauth/callback'],
);
file_put_contents('/var/www/public/mcp-client-metadata.json', $doc->toJson());The document must be served over HTTPS, publicly reachable, and the client_id field inside it must match the URL exactly.
The MCP spec made the move from "OAuth-on-the-MCP-server" to "OAuth via separate authorization server" between 2025-03-26 and 2025-06-18. If you need to talk to a 2025-03-26 server, opt into the legacy fallback, which derives the AS metadata from the MCP server's URL when RFC 9728 discovery isn't available:
$oauthConfig = new OAuthConfiguration(
tokenStorage: $tokenStorage,
authCallback: $callbackHandler,
enableLegacyOAuthFallback: true,
);Leave this at the default (false) for any server that targets 2025-06-18 or newer.
The CLI flow above relies on a long-running process that can spin up a loopback server and block waiting for the user. Neither of those things is true in a typical web request:
- A PHP request lasts seconds, not minutes.
- The authorization server redirects the user's browser, not your PHP process.
- The redirect comes back to a different PHP request entirely (usually a dedicated callback URL).
The SDK handles this with a two-phase async flow:
- Initiation phase. Your callback handler throws an
AuthorizationRedirectExceptionthat carries the authorization URL plus all the state needed to complete the flow later. Your application catches it, persists the state in$_SESSION, and redirects the browser. - Completion phase. When the browser hits your callback endpoint, you re-hydrate the persisted state into an
AuthorizationRequestand callOAuthClient::exchangeCodeForTokens()to swap the code for tokens. Then redirect the browser back to the page that started the flow, which retries the originalconnect()-- this time tokens are stored, so it succeeds silently.
The SDK's LoopbackCallbackHandler is CLI-only. For web you need a tiny handler that throws the redirect exception instead of trying to open a socket. The full reference implementation lives at webclient/lib/WebCallbackHandler.php; here is the same idea condensed for re-use in your own application:
<?php
// MyWebCallbackHandler.php
declare(strict_types=1);
use Mcp\Client\Auth\Callback\AuthorizationCallbackInterface;
use Mcp\Client\Auth\Exception\AuthorizationRedirectException;
final class MyWebCallbackHandler implements AuthorizationCallbackInterface
{
public function __construct(private readonly string $callbackUrl) {}
public function authorize(string $authUrl, string $state): string
{
// The real return value never happens -- we hand control back to
// the application via this exception. The application redirects the
// browser to $authUrl and resumes after the callback fires.
throw new AuthorizationRedirectException(
authorizationUrl: $authUrl,
state: $state,
redirectUri: $this->callbackUrl,
);
}
public function getRedirectUri(): string
{
return $this->callbackUrl;
}
}Pass that handler into the OAuthConfiguration exactly as you would LoopbackCallbackHandler.
<?php
// connect.php -- POST endpoint that opens the MCP connection
declare(strict_types=1);
session_start();
require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/MyWebCallbackHandler.php';
use Mcp\Client\Client;
use Mcp\Client\Auth\OAuthConfiguration;
use Mcp\Client\Auth\Exception\AuthorizationRedirectException;
use Mcp\Client\Auth\Token\FileTokenStorage;
$tokenStorage = new FileTokenStorage(
storagePath: __DIR__ . '/var/tokens/' . session_id(),
encryptionSecret: getenv('TOKEN_ENC_SECRET'),
);
$callbackUrl = 'https://my-app.example.com/oauth_callback.php';
// Stateless web hosting: use CIMD so the URL itself is the stable client_id.
// Nothing per-process to register or persist; only the tokens themselves are
// stored (in $tokenStorage above). See the CIMD section in Part 6 for how to
// build and host the metadata document. If your AS only supports DCR, see
// `webclient/lib/SessionStore.php` for the credential-persistence pattern.
$oauthConfig = new OAuthConfiguration(
tokenStorage: $tokenStorage,
authCallback: new MyWebCallbackHandler($callbackUrl),
cimdUrl: 'https://my-app.example.com/mcp-client-metadata.json',
);
$client = new Client();
try {
$session = $client->connect(
commandOrUrl: 'https://example.com/mcp-server.php',
args: [],
env: ['oauth' => $oauthConfig, 'autoSse' => false],
);
// Authenticated! Do whatever this endpoint needs and close. close() sends
// the HTTP DELETE that drops the server-side MCP session, so subsequent
// requests will reconnect from scratch (the persisted tokens make that
// silent). To keep the same MCP session alive across requests instead,
// snapshot transport state and call $client->detach() -- see Part 10.
$tools = $session->listTools()->tools;
$client->close();
echo json_encode(['status' => 'ok', 'toolCount' => count($tools)]);
} catch (AuthorizationRedirectException $e) {
// Stash the in-flight authorization request so the callback endpoint can
// pick it up and exchange the code for tokens.
$authReq = $e->getAuthorizationRequest();
$_SESSION['pending_oauth'][$e->state] = [
'authorizationRequest' => $authReq?->toArray(),
'serverUrl' => 'https://example.com/mcp-server.php',
];
// Redirect the user's browser to the authorization server.
header('Location: ' . $e->authorizationUrl);
exit;
}A few subtleties:
- The
stateparameter is the SDK-generated CSRF token. Use it as your storage key so the callback can find the right pending request. getAuthorizationRequest()returns a value object that contains thecode_verifier, redirect URI, token endpoint, and resolved client credentials. You must persist it -- it's needed for the token exchange in phase 2.- The exception may also be thrown later in the lifecycle (e.g. when a stored token has expired or lacks a required scope). The same handling applies wherever you catch it.
<?php
// oauth_callback.php -- the redirect_uri the AS calls back to
declare(strict_types=1);
session_start();
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Auth\AuthorizationRequest;
use Mcp\Client\Auth\OAuthClient;
use Mcp\Client\Auth\OAuthConfiguration;
use Mcp\Client\Auth\Registration\ClientCredentials;
use Mcp\Client\Auth\Token\FileTokenStorage;
$code = $_GET['code'] ?? null;
$state = $_GET['state'] ?? null;
if ($code === null || $state === null || !isset($_SESSION['pending_oauth'][$state])) {
http_response_code(400);
exit('Invalid OAuth callback');
}
$pending = $_SESSION['pending_oauth'][$state];
unset($_SESSION['pending_oauth'][$state]);
$authRequest = AuthorizationRequest::fromArray($pending['authorizationRequest']);
$tokenStorage = new FileTokenStorage(
storagePath: __DIR__ . '/var/tokens/' . session_id(),
encryptionSecret: getenv('TOKEN_ENC_SECRET'),
);
// Build a minimal config just for the code exchange.
$oauthConfig = new OAuthConfiguration(
clientCredentials: new ClientCredentials(
clientId: $authRequest->clientId,
clientSecret: $authRequest->clientSecret,
tokenEndpointAuthMethod: $authRequest->tokenEndpointAuthMethod,
),
tokenStorage: $tokenStorage,
);
$oauthClient = new OAuthClient($oauthConfig);
$oauthClient->exchangeCodeForTokens($authRequest, $code);
// Tokens are now stored against $authRequest->resourceUrl. Send the user back
// to the page that started the flow, which will retry connect() and succeed.
header('Location: /index.php?oauth=success');
exit;After the redirect, the next call to Client::connect() finds the access token in storage, attaches it as a Bearer header, and the request goes through without ever raising AuthorizationRedirectException again.
Heads up (CIMD on shared hosting): When CIMD is enabled the AS pulls your
cimdUrldocument directly. That URL must be publicly reachable from the AS, served over HTTPS, and never gated behind authentication.
Elicitation (introduced in 2025-06-18, extended with URL mode in 2025-11-25) is the protocol mechanism a server uses to ask the user -- via the client -- for additional information mid-tool-call. The client side of that conversation is your job: when the server sends elicitation/create, your handler runs, presents UI (or makes a decision), and returns the response.
The handler is registered on the Client before connect() is called, so the elicitation capability is advertised in the handshake:
<?php
// elicitation_basic.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
use Mcp\Types\ElicitationCreateRequest;
use Mcp\Types\ElicitationCreateResult;
$client = new Client();
$client->onElicit(static function (ElicitationCreateRequest $req): ElicitationCreateResult {
// For this demo, accept everything with empty content. The applyDefaults
// flag below will fill in any defaults the server's schema specified.
fwrite(STDERR, "Server asked: {$req->message}\n");
return new ElicitationCreateResult(action: 'accept', content: []);
}, applyDefaults: true);
$session = $client->connect('https://example.com/mcp-server.php');
// Any tool call that triggers elicitation will route through the handler above.
$result = $session->callTool('test_client_elicitation_defaults', []);
foreach ($result->content as $block) {
echo $block->text ?? '';
echo "\n";
}
$client->close();The three valid actions are:
'accept'-- the user provided a response;contentholds it.'decline'-- the user explicitly chose not to provide a response.'cancel'-- the user cancelled the whole interaction.
If your handler throws, the SDK catches it and sends back an internal-error response (-32603) so the server can recover gracefully.
Form mode is the common case: the server sends a JSON Schema describing the fields it wants, and the client renders a form. A real CLI handler might prompt the user; a web handler would render an HTML form. Here is a CLI handler that prompts on stdin:
<?php
// elicitation_form_cli.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
use Mcp\Types\ElicitationCreateRequest;
use Mcp\Types\ElicitationCreateResult;
$client = new Client();
$client->onElicit(static function (ElicitationCreateRequest $req): ElicitationCreateResult {
// URL-mode requests have no schema -- handle them separately (next section).
if ($req->mode === 'url') {
fwrite(STDERR, "Server asked us to handle a URL flow; declining for this demo.\n");
return new ElicitationCreateResult(action: 'decline');
}
fwrite(STDERR, "\n=== Server question ===\n{$req->message}\n");
$properties = $req->requestedSchema['properties'] ?? [];
$required = $req->requestedSchema['required'] ?? [];
$content = [];
foreach ($properties as $name => $prop) {
$title = $prop['title'] ?? $name;
$type = $prop['type'] ?? 'string';
$isReq = in_array($name, $required, true);
$hint = $isReq ? '*' : ' ';
// Show enum options if present.
if (!empty($prop['enum'])) {
$hint .= ' (' . implode('|', $prop['enum']) . ')';
}
fwrite(STDERR, "{$hint}{$title}: ");
$line = trim((string) fgets(STDIN));
if ($line === '' && !$isReq) {
continue; // skip optional fields the user left blank
}
// Coerce to the right PHP type.
$content[$name] = match ($type) {
'integer' => (int) $line,
'number' => (float) $line,
'boolean' => in_array(strtolower($line), ['1', 'true', 'yes', 'y'], true),
default => $line,
};
}
return new ElicitationCreateResult(action: 'accept', content: $content);
});
$session = $client->connect('https://example.com/mcp-server.php');
$result = $session->callTool('archive-project', ['projectId' => 'demo-1']);
foreach ($result->content as $block) {
echo ($block->text ?? '') . "\n";
}
$client->close();A few things to know about form-mode schemas:
- The schema is always a flat object whose properties are primitives, single-select enums, or multi-select enums expressed as
arrayof enumitems. No nested objects. - The keys in your
contentarray must match the schema'spropertieskeys. - If the user explicitly declines (
'decline') or cancels ('cancel'), setcontenttonull-- the server's tool can react accordingly.
When the schema's properties carry per-field default values, you can let the SDK fill them in automatically for you. Pass applyDefaults: true when registering the handler:
$client->onElicit(static function (ElicitationCreateRequest $req): ElicitationCreateResult {
// Render a form and collect what the user actually filled in. Anything
// they didn't fill in -- but that has a `default` in the schema -- will
// be auto-populated by the SDK before the response goes back to the server.
// For this minimal example we hard-code a partial submission; a real
// handler would build $submitted from a CLI prompt, web form, etc.
$submitted = ['name' => 'Jane Doe'];
return new ElicitationCreateResult(
action: 'accept',
content: $submitted,
);
}, applyDefaults: true);applyDefaults only kicks in on accept responses, never overwrites a value you supplied, and is silently advertised in the handshake's elicitation capability so the server knows it can omit defaults from its server-side validation.
URL-mode requests carry a URL the user must visit out-of-band -- typically to consent to an OAuth flow, enter a sensitive credential, or complete a payment. The client never sees what the user types. Per the MCP spec, the response action signals what the user decided about opening the URL, not whether the out-of-band interaction has finished:
- Show the URL to the user and gather explicit consent.
- If the user consents, open the URL (or instruct them to) and immediately respond with
'accept'. This tells the server "the user has consented; the out-of-band interaction has begun." It does not mean the OAuth/payment/credential flow has completed. - If the user refuses or dismisses, respond with
'decline'or'cancel'.
Completion of the out-of-band flow is communicated separately. The server may push a notifications/elicitation/complete notification carrying the original elicitationId once the interaction finishes, and may also surface a URLElicitationRequiredError (code -32042) on a subsequent tool call until the user has actually completed the flow.
URL mode must be opted into at handler-registration time so the SDK advertises the url sub-capability in the handshake. By default onElicit() only advertises form, and a spec-compliant server will not send URL-mode requests to a client that hasn't declared support. Pass supportsUrlMode: true to enable it:
$client->onElicit(static function (ElicitationCreateRequest $req): ElicitationCreateResult {
if ($req->mode === 'url') {
fwrite(STDERR, "\n=== Action required ===\n{$req->message}\n\n");
fwrite(STDERR, "Open this URL: {$req->url}\n");
fwrite(STDERR, "Press Enter to confirm you will open the URL (or type 'cancel'): ");
$line = trim((string) fgets(STDIN));
if (strtolower($line) === 'cancel') {
return new ElicitationCreateResult(action: 'cancel');
}
// 'accept' here signals user consent to begin the out-of-band flow,
// not that the OAuth/payment/credential entry is finished. The server
// tracks completion separately via notifications/elicitation/complete
// and/or a -32042 URLElicitationRequiredError on a subsequent call.
return new ElicitationCreateResult(action: 'accept');
}
// Fall through to the form-mode logic from the previous example...
return new ElicitationCreateResult(action: 'decline');
}, supportsUrlMode: true);In a web client, "open this URL" usually means embedding it as a button and waiting for the user to click back into the app. The pattern is identical to the OAuth redirect flow in Part 7.
Security: Per the MCP spec, clients MUST NOT auto-fetch or auto-open the URL. Always show the full URL to the user, gather explicit consent, and open it in a context that prevents the client or LLM from inspecting the page (a separate browser tab,
SFSafariViewController, etc.).
Notifications are one-way messages the server sends without expecting a response. The SDK's high-level methods (callTool(), listResources(), etc.) run a tiny receive loop that surfaces these to the handlers you've registered.
<?php
// notifications.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
use Mcp\Types\ServerNotification;
use Mcp\Types\LoggingMessageNotification;
use Mcp\Types\ProgressNotification;
use Mcp\Types\ToolListChangedNotification;
use Mcp\Types\ResourceUpdatedNotification;
$client = new Client();
$session = $client->connect('https://example.com/mcp-server.php');
$session->onNotification(static function (ServerNotification $wrapper): void {
$note = $wrapper->getNotification();
if ($note instanceof LoggingMessageNotification) {
$level = $note->params->level->value;
$logger = $note->params->logger ?? '(unknown)';
$data = $note->params->data;
fwrite(STDERR, "[server log][{$logger}][{$level}] " . json_encode($data) . "\n");
return;
}
if ($note instanceof ProgressNotification) {
$p = $note->params;
$pct = $p->total ? round($p->progress / $p->total * 100, 1) : null;
fwrite(STDERR, "Progress: {$p->progress}" .
($p->total !== null ? "/{$p->total}" : '') .
($pct !== null ? " ({$pct}%)" : '') .
($p->message !== null ? " {$p->message}" : '') . "\n");
return;
}
if ($note instanceof ToolListChangedNotification) {
fwrite(STDERR, "Tool list changed -- consider re-running listTools().\n");
return;
}
if ($note instanceof ResourceUpdatedNotification) {
fwrite(STDERR, "Resource updated: {$note->params->uri}\n");
return;
}
});
// Now do work; any notifications the server emits while we're talking to it
// will route through the handler above.
$session->callTool('long-running-operation', []);
$client->close();The full set of notifications the SDK can dispatch:
| Notification class | Server method |
|---|---|
ProgressNotification |
notifications/progress |
LoggingMessageNotification |
notifications/message |
ResourceListChangedNotification |
notifications/resources/list_changed |
ResourceUpdatedNotification |
notifications/resources/updated |
PromptListChangedNotification |
notifications/prompts/list_changed |
ToolListChangedNotification |
notifications/tools/list_changed |
CancelledNotification |
notifications/cancelled |
If the server advertised logging, you can ask it to send messages at or above a given severity:
use Mcp\Types\LoggingLevel;
$session->setLoggingLevel(LoggingLevel::INFO);Other levels: DEBUG, NOTICE, WARNING, ERROR, CRITICAL, ALERT, EMERGENCY.
To get progress notifications during a long tool call, attach a progressToken to the call's _meta. The SDK doesn't yet expose a high-level helper for this on callTool() -- the typical pattern is to set up the listener via onNotification() (above) and let the server emit progress on its own schedule.
For the opposite direction -- sending progress to the server while you're processing a server-initiated request -- use $session->sendProgressNotification(...).
MCP defines a ping request/response pair at the protocol level, separate from any application "ping" tool a server might expose. It's a no-argument health check: send ping, the peer is required to respond with an empty result, and a missing or slow response tells you the connection isn't healthy. Use it to verify a session is still live before kicking off expensive work, to keep an idle stdio subprocess from being killed by an inactivity reaper, or as a smoke test in your test suite.
<?php
// ping.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
$client = new Client();
$session = $client->connect('https://example.com/mcp-server.php');
$start = microtime(true);
$session->sendPing(); // returns Mcp\Types\EmptyResult
$rttMs = (microtime(true) - $start) * 1000;
printf("Server is alive (round trip: %.1f ms)\n", $rttMs);
$client->close();sendPing() returns an EmptyResult on success and throws Mcp\Shared\McpError if the server returns an error response or RuntimeException if the transport fails. There is nothing to inspect on the result -- the value of a ping is that it returned at all. The SDK auto-handles incoming ping requests on both sides of the connection, so you do not need to register a handler to respond to a server-initiated ping.
MCP cancellation is a cooperative notification. Either side can send a notifications/cancelled carrying the requestId of an in-flight request to signal "stop working on this." There is no acknowledgement, no guarantee the peer was able to abort cleanly, and no rollback -- it is a hint, not a contract. Per the spec, the receiver SHOULD stop processing the cancelled request, free its associated resources, and not send a response for it. The receiver MAY also ignore the cancel entirely if the request is unknown, has already completed, or cannot be cancelled. Late responses are tolerated as a race: if your cancel and the peer's in-flight response cross on the wire, the sender of the cancel SHOULD discard whichever response arrives.
The SDK ships the wire format and dispatch path; deciding when to cancel and what to do on receipt is application logic.
To cancel an outbound request, send a CancelledNotification referencing the same requestId the SDK assigned to the original send. The catch is that the high-level convenience methods (callTool(), listTools(), etc.) block until the response arrives, so issuing a cancel from the same PHP process means doing it from a separate code path -- typically a signal handler on a long-running stdio client, or a separate HTTP request that targets the same MCP session via resumeHttpSession() (see Part 10).
The web-style flow looks like this. The first request stashes the in-flight request ID alongside the snapshot needed to resume the session; a second request loads both back, builds the cancel notification, and fires it:
<?php
// page1.php -- starts a long-running tool call and persists the request ID
declare(strict_types=1);
session_start();
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
use Mcp\Client\Transport\StreamableHttpTransport;
$client = new Client();
$session = $client->connect(
commandOrUrl: 'https://example.com/mcp-server.php',
args: [],
env: ['autoSse' => false],
);
// Capture the request ID *before* sending. getNextRequestId() returns the
// integer the SDK will assign to the next sendRequest() call, which is
// exactly the value the cancel needs to reference.
$_SESSION['inflight_request_id'] = $session->getNextRequestId();
// Kick off the long-running call. (In a real app you'd run this in a way
// that doesn't block the request -- a queue worker, a fork, etc. The
// snapshot below assumes a separate worker is now driving the session.)
// $session->callTool('long-running-search', ['query' => 'widgets']);
// Snapshot the session so the cancel endpoint can resume into it.
$transport = $client->getTransport();
if ($transport instanceof StreamableHttpTransport) {
$_SESSION['mcp'] = [
'sessionManagerState' => $transport->getSessionManager()->toArray(),
'initResult' => json_encode(
$session->getInitializeResult(),
JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
),
'protocolVersion' => $session->getNegotiatedProtocolVersion(),
'nextRequestId' => $session->getNextRequestId(),
'serverUrl' => 'https://example.com/mcp-server.php',
];
}
$client->detach();<?php
// cancel.php -- a separate HTTP request that aborts the in-flight call
declare(strict_types=1);
session_start();
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
use Mcp\Types\CancelledNotification;
use Mcp\Types\RequestId;
if (!isset($_SESSION['mcp'], $_SESSION['inflight_request_id'])) {
http_response_code(400);
exit('No in-flight request to cancel');
}
$snap = $_SESSION['mcp'];
$client = new Client();
$session = $client->resumeHttpSession(
url: $snap['serverUrl'],
sessionManagerState: $snap['sessionManagerState'],
initResultData: json_decode($snap['initResult'], true, flags: JSON_THROW_ON_ERROR),
negotiatedProtocolVersion: $snap['protocolVersion'],
nextRequestId: (int) $snap['nextRequestId'],
headers: [],
httpOptions: ['autoSse' => false],
);
$session->sendNotification(new CancelledNotification(
requestId: new RequestId((int) $_SESSION['inflight_request_id']),
reason: 'User clicked cancel',
));
unset($_SESSION['inflight_request_id']);
$client->detach();sendNotification() returns immediately -- there is no response to wait for. Whether the in-flight request actually stops depends entirely on the server: a spec-compliant server that polls for cancellation will short-circuit and (per the spec SHOULD) suppress its response; a server that doesn't poll will run to completion as if no cancel had arrived and send a normal response anyway. Your code should be ready for either: discard whichever response comes back after the cancel was sent, since the response that does arrive is a tolerated race rather than the cancel succeeding or failing. The reason field is optional and propagated to the server for logging. For long-running stdio clients the same sendNotification() call works; the difference is just that you can capture the cancel signal from pcntl_signal rather than going through session-resume gymnastics.
A server can cancel a request it sent to you -- for example, a sampling/createMessage it issued during a long tool call -- by sending the same notification in the opposite direction. Register a handler with onNotification() and dispatch on the typed notification class:
$session->onNotification(static function (\Mcp\Types\ServerNotification $wrapper): void {
$note = $wrapper->getNotification();
if (!($note instanceof \Mcp\Types\CancelledNotification)) {
return; // Some other notification -- ignore here.
}
$cancelledRequestId = $note->requestId->getValue();
$reason = $note->reason ?? '(no reason given)';
fwrite(STDERR, "Server cancelled request #{$cancelledRequestId}: {$reason}\n");
// If you have a long-running handler keyed by request ID, set its
// cancellation flag here; otherwise just log and move on.
});The requestId is wrapped in a RequestId value object; call ->getValue() to read the numeric ID. Cancellation handlers should be tolerant: as the spec notes, the notification can arrive after the work has already finished, so a cancel for an unknown request ID is normal and should be silently ignored.
MCP roots let a client publish a list of file:// URIs that act as anchors for what the server is allowed to look at -- typically the open workspace folders in an editor, or the working directory of a CLI invocation. The server requests the list with roots/list whenever it needs to know the current scope, and the client emits notifications/roots/list_changed when those anchors change so a long-lived server doesn't have to poll.
Register a roots handler with Client::onListRoots() before connect(). That single call does two things: it advertises the roots capability in the initialization handshake (the MCP spec requires a client that supports roots to declare it, so a spec-compliant server only calls roots/list once it sees the capability), and it wires your handler to answer every incoming roots/list. Two things you still own:
- The roots store itself. The SDK doesn't track which roots you've published. Hold them in an array (or whatever your application uses for workspace state) and return them from the handler on every
roots/list. - Change notifications. Call
ClientSession::sendRootsListChanged()whenever the store changes so a long-lived server can refresh. By defaultonListRoots()advertisesroots: { listChanged: true }; passlistChanged: falseif your root set is static and you will never send the notification.
Putting it together:
<?php
// roots.php
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
use Mcp\Types\ListRootsResult;
use Mcp\Types\Root;
// Application-owned root store. Update this whenever the user opens or closes
// a workspace folder; the change notification below tells the server to refresh.
$roots = [
new Root(uri: 'file:///home/alice/projects/website', name: 'website'),
new Root(uri: 'file:///home/alice/projects/api', name: 'api'),
];
$client = new Client();
// Register the roots/list handler before connect() so the `roots` capability
// is advertised in the handshake. The handler returns a ListRootsResult built
// from the current store; capture $roots by reference (use (&$roots)) so later
// updates are reflected on the next roots/list.
$client->onListRoots(static function () use (&$roots): ListRootsResult {
return new ListRootsResult(roots: $roots);
});
$session = $client->connect('https://example.com/mcp-server.php');
// Push an update when the user adds or removes a workspace.
$roots[] = new Root(uri: 'file:///home/alice/projects/cli-tools', name: 'cli-tools');
$session->sendRootsListChanged();
$client->close();A few constraints worth knowing:
Root::$urimust be afile://URI.Root::validate()rejects any other scheme with anInvalidArgumentException. Note that the SDK does not validate the result you hand tosendResponse()on the way out, nor does theRootconstructor validate -- the check runs when the receiving peer parses theListRootsResultoff the wire (Root::fromArray()), so a non-file://URI is rejected there rather than thrown locally at construction time. There is no virtual-roots escape hatch -- everything published has to live under afile://scheme even if the underlying handler maps it to something exotic.Root::$nameis optional and is meant for display in client UI; servers should not parse it.sendRootsListChanged()is fire-and-forget. The server may issue a freshroots/listin response, or it may defer until it next needs the list -- both behaviors are spec-compliant.
Stateful MCP sessions work naturally over stdio (the subprocess holds the state) and over HTTP (the server holds the state via Mcp-Session-Id). The challenge for PHP web hosting is that each browser request creates a fresh PHP process. Without help, every page load would have to do a full handshake, re-authenticate, and forget anything the previous request learned.
The SDK solves this with a detach + resume pair:
Client::detach()-- close locally but keep the server's session alive (no HTTPDELETE). Snapshot everything resumable to$_SESSION.Client::resumeHttpSession(...)-- the next request rebuilds the transport from the snapshot and skips the handshake.
<?php
// page1.php -- first request: connect, do work, persist state
declare(strict_types=1);
session_start();
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
use Mcp\Client\Transport\StreamableHttpTransport;
$client = new Client();
$session = $client->connect(
commandOrUrl: 'https://example.com/mcp-server.php',
args: [],
env: ['autoSse' => false],
);
// Do work for this request.
$tools = $session->listTools()->tools;
echo "Got " . count($tools) . " tools\n";
// Snapshot everything we need to resume next time.
$transport = $client->getTransport();
if ($transport instanceof StreamableHttpTransport) {
$_SESSION['mcp'] = [
'sessionManagerState' => $transport->getSessionManager()->toArray(),
'initResult' => json_encode(
$session->getInitializeResult(),
JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
),
'protocolVersion' => $session->getNegotiatedProtocolVersion(),
'nextRequestId' => $session->getNextRequestId(),
'serverUrl' => 'https://example.com/mcp-server.php',
];
}
// detach() leaves the server-side session alive; close() would tear it down.
$client->detach();<?php
// page2.php -- subsequent request: rehydrate, do more work, persist again
declare(strict_types=1);
session_start();
require __DIR__ . '/vendor/autoload.php';
use Mcp\Client\Client;
use Mcp\Client\Transport\StreamableHttpTransport;
if (!isset($_SESSION['mcp'])) {
http_response_code(400);
exit('No live MCP session in this browser session');
}
$snap = $_SESSION['mcp'];
$client = new Client();
$session = $client->resumeHttpSession(
url: $snap['serverUrl'],
sessionManagerState: $snap['sessionManagerState'],
initResultData: json_decode($snap['initResult'], true, flags: JSON_THROW_ON_ERROR),
negotiatedProtocolVersion: $snap['protocolVersion'],
nextRequestId: (int) $snap['nextRequestId'],
headers: [],
httpOptions: ['autoSse' => false],
);
// No handshake happens -- the session is ready for operations immediately.
$result = $session->callTool('add_numbers', ['a' => 5, 'b' => 7]);
foreach ($result->content as $block) {
echo ($block->text ?? '') . "\n";
}
// Persist the updated state again before detaching.
$transport = $client->getTransport();
if ($transport instanceof StreamableHttpTransport) {
$_SESSION['mcp']['sessionManagerState'] = $transport->getSessionManager()->toArray();
$_SESSION['mcp']['nextRequestId'] = $session->getNextRequestId();
}
$client->detach();A few important rules:
- Snapshot after every operation. The
nextRequestIdcounter and theMcp-Session-Id/ last-event-ID insidesessionManagerStateadvance on every JSON-RPC round-trip. - Use
detach(), notclose(), between requests.close()sends aDELETEand drops the server session. autoSse => falseis the right call. A standalone GET stream cannot survive past the end of the PHP request anyway, and in some SAPIs PHP-FPM may try to fork it -- which inherits the session/log state in confusing ways.- OAuth tokens persist separately. They're stored in the
TokenStorageInterfaceyou configured (useFileTokenStoragekeyed by PHP session ID for true per-user isolation). The session resume API only handles the MCP-level state.
For a complete reference implementation -- including OAuth, elicitation capture, and a JavaScript front-end -- see webclient/ in the repository.
| Parameter | Stdio | HTTP |
|---|---|---|
commandOrUrl (string) |
Executable to run | MCP endpoint URL |
args (array) |
Arguments for the executable | HTTP headers (['Header' => 'value']) |
env (array, nullable) |
Subprocess env vars | HTTP options array (see below) |
readTimeout (float, nullable) |
Per-request read timeout (s) | Per-request read timeout (s) |
| Option | Type | Default | Description |
|---|---|---|---|
connectionTimeout |
float | 30.0 | Seconds to establish TCP connection |
readTimeout |
float | 60.0 | Seconds to wait for each response |
sseIdleTimeout |
float | 300.0 | Max idle seconds for an SSE stream |
enableSse |
bool | true | Accept text/event-stream responses |
autoSse |
bool | true | Open the standalone GET SSE stream after connect |
verifyTls |
bool | true | Verify TLS peer + host |
caFile |
?string | null | Custom CA bundle path |
curlOptions |
array | [] | Raw cURL options merged into every request |
oauth |
?OAuthConfiguration | null | OAuth config (see below) |
sseDefaultRetryDelay |
float | 1.0 | Reconnect delay when server omits retry (s) |
sseReconnectBudget |
float | 60.0 | Total wall-clock budget for reconnect attempts (s) |
| Parameter | Type | Default | Description |
|---|---|---|---|
clientCredentials |
?ClientCredentials | null | Pre-registered client (skip DCR) |
tokenStorage |
?TokenStorageInterface | MemoryTokenStorage | Where tokens are persisted |
authCallback |
?AuthorizationCallbackInterface | null | Handles the user authorization step |
enableCimd |
bool | true | Allow Client ID Metadata Document path |
enableDynamicRegistration |
bool | true | Allow RFC 7591 DCR fallback |
cimdUrl |
?string | null | Hosted client metadata JSON URL |
additionalScopes |
array | [] | Extra scopes to request |
timeout |
float | 30.0 | HTTP timeout for OAuth requests (s) |
autoRefresh |
bool | true | Refresh tokens nearing expiry |
refreshBuffer |
int | 60 | Seconds before expiry to trigger refresh |
redirectUri |
?string | null | Override the callback handler's redirect URI |
verifyTls |
bool | true | Verify TLS for OAuth HTTP calls |
authorizationServerUrl |
?string | null | Fallback AS URL when PRM discovery fails |
enableLegacyOAuthFallback |
bool | false | MCP 2025-03-26 backwards-compat fallback |
| Class | Use case |
|---|---|
LoopbackCallbackHandler |
CLI applications: spins up a loopback HTTP server |
HeadlessCallbackHandler |
Test harnesses: simulates the redirect without a browser |
| (custom) | Web hosting: throw AuthorizationRedirectException -- see Part 7 |
| Class | Persistence | Recommended for |
|---|---|---|
MemoryTokenStorage |
Per PHP process | Trivial scripts, tests |
FileTokenStorage |
Disk (AES-256-GCM optional) | CLI tools, web hosting |
| Method | Description |
|---|---|
getInitializeResult() |
Server info and capabilities (post-handshake) |
getNegotiatedProtocolVersion() |
The version both sides agreed on |
supportsFeature(string) |
Boolean check against the version + feature matrix |
listTools() |
tools/list |
callTool(string, ?array) |
tools/call |
listPrompts() |
prompts/list |
getPrompt(string, ?array) |
prompts/get |
listResources() |
resources/list |
readResource(string) |
resources/read |
subscribeResource(string) |
resources/subscribe |
unsubscribeResource(string) |
resources/unsubscribe |
complete(ref, array) |
completion/complete |
setLoggingLevel(LoggingLevel) |
logging/setLevel |
sendPing() |
ping |
sendProgressNotification(...) |
Send progress while handling a server-initiated request |
onListRoots(callable, bool) |
Advertise the roots capability and answer roots/list |
sendRootsListChanged() |
Notify the server that the roots list changed |
onNotification(callable) |
Register a server-notification handler |
onRequest(callable) |
Register a low-level server-request handler (advanced) |
getNextRequestId() |
Persist the request-ID counter for session resume |
| Method | Description |
|---|---|
connect(commandOrUrl, args, env, readTimeout) |
Open transport, run handshake, return session |
onElicit(callable, applyDefaults, supportsUrlMode) |
Register the elicitation handler -- call before connect(). supportsUrlMode: true opts the client into the url sub-capability (2025-11-25) in addition to form |
onListRoots(callable, listChanged) |
Register the roots/list handler -- call before connect(). Advertises the roots capability (listChanged: true by default) in the handshake |
close() |
Tear down session and transport (sends DELETE on HTTP) |
detach() |
Close locally; preserve the server-side HTTP session |
resumeHttpSession(...) |
Rebuild a session from a snapshot, skipping handshake |
getSession() |
The current ClientSession (or null) |
getTransport() |
The current transport (or null) |
$client = new Client();
$session = $client->connect('php', ['/path/to/server.php']);
// ... use $session ...
$client->close();$client = new Client();
$session = $client->connect('https://example.com/mcp-server.php');
// ... use $session ...
$client->close();$client = new Client();
$session = $client->connect(
'https://example.com/mcp-server.php',
[],
[
'oauth' => new OAuthConfiguration(
tokenStorage: new FileTokenStorage(__DIR__ . '/.tokens', 'enc-secret'),
authCallback: new LoopbackCallbackHandler(),
),
],
);
// ... use $session ...
$client->close();$client = new Client();
$session = $client->connect(
'https://example.com/mcp-server.php',
['Authorization' => 'Bearer my-static-token'],
);
// ... use $session ...
$client->close();- On every request,
session_start()and check whether$_SESSION['mcp']exists. - If not,
Client::connect()(catchingAuthorizationRedirectExceptionfor OAuth servers). - If yes,
Client::resumeHttpSession(...). - Do the request's work.
- Snapshot transport state back into
$_SESSION['mcp']. Client::detach().
The reference implementation is in webclient/ -- see webclient/lib/SessionStore.php for the full snapshot/resume/oauth-resume flow used to power the bundled web UI.
$client = new Client();
$client->onElicit(
static fn ($req) => new ElicitationCreateResult(action: 'accept', content: []),
applyDefaults: true,
supportsUrlMode: true, // omit (or pass false) to advertise form-only
);
$session = $client->connect('https://example.com/mcp-server.php');onElicit() must be called before connect() so the elicitation capability is included in the handshake. supportsUrlMode is opt-in: with the default (false) the SDK advertises form only, and spec-compliant servers will not send URL-mode requests.
This guide covers the logiscape/mcp-sdk-php SDK implementing the MCP specification 2025-11-25. For SDK source code and updates, visit github.com/logiscape/mcp-sdk-php.