Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ All notable changes to `mcp/sdk` will be documented in this file.
0.8.0
-----

* Add the Tasks extension (SEP-2663, `io.modelcontextprotocol/tasks`): a server hands back a durable handle instead of holding a connection open — `Mcp\Schema\Task` and `TaskStatus`, `ResultType::Task`, the flat `CreateTaskResult` / `TaskResult` wire shapes, the `tasks/get` / `tasks/update` / `tasks/cancel` surface, and `TaskStoreInterface` with `InMemoryTaskStore` and `Psr16TaskStore` (what PHP-FPM needs). Enable with `Builder::enableExtension(new TasksExtension($store))`; a handler declares a `TaskContext` parameter and creates a task through `TaskContext::create()` after `isSupported()` — a task for a client that did not declare the extension is refused with `-32021`. Advancing a task is the application's job. On the client, `enableExtension(new TasksExtension())` declares it and `Client\Task\TaskClient` speaks it.
* Let an extension reach handler code without the core knowing it: `ArgumentProvidingExtensionInterface` hands handlers objects of the extension's own, injected like a `RequestContext` and left out of the generated schemas; the tool, prompt and resource handlers pass any `ResultInterface` a handler returns through untouched; a `MissingRequiredClientCapabilityException` thrown from handler code is answered as `-32021`; `Client::request()` sends any request.
* [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them.
* Preserve the original request `id` on an invalid-but-parseable message (`-32600`) instead of answering it id-less: `InvalidInputMessageException` now carries the recoverable id via `getRequestId()`/`setRequestId()`, threaded from `MessageFactory` through to the error response.
* [BC Break] Add the extensions framework SEP-2133 defines, which MCP Apps sits on. `ExtensionInterface::getId()` now returns the new `Mcp\Schema\Extension\ExtensionIdentifier` value object instead of a string, which validates the identifier against the `_meta` key naming rules at construction time. `ExtensionInterface` also gains `getMessages()`/`getRequestHandlers()`, so an extension can contribute the message classes its methods decode into — without which its methods cannot be decoded at all — and the handlers serving them; extensions that only announce a capability can extend the new `Mcp\Schema\Extension\AbstractExtension` and skip both. `MessageFactory::make()` takes an `$additional` list of message classes, and `RequestHandlerInterface`'s result template is now covariant.
Expand Down
115 changes: 115 additions & 0 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,120 @@ public function getWeather(string $city, RequestContext $context): string
}
```

An extension that hands handlers an object of its own implements
`ArgumentProvidingExtensionInterface` on top: a handler declaring a parameter
of a provided type receives it for the request being served, the way it
receives a `RequestContext` — and it stays out of the generated input schemas.

## Tasks (`io.modelcontextprotocol/tasks`)

The [Tasks extension][ext-tasks] (SEP-2663) lets a server hand back a durable
handle instead of holding a connection open for a long-running request. The
client polls `tasks/get` until the task settles, answers anything it asks for
through `tasks/update`, and may `tasks/cancel` it.

```php
use Mcp\Server;
use Mcp\Server\Task\InMemoryTaskStore;
use Mcp\Server\Task\Psr16TaskStore;
use Mcp\Server\Task\TasksExtension;

$server = Server::builder()
->enableExtension(new TasksExtension(new InMemoryTaskStore()))
->build();
```

`InMemoryTaskStore` is right for stdio and any single-process runtime and drops
its oldest task past a configurable limit (1000 by default). Under PHP-FPM the
worker that creates a task is not the one polled for it, so use
`Psr16TaskStore` over a shared cache there — a filesystem adapter is enough.

Creating a task is the *server's* decision, made per request by returning a
`CreateTaskResult` from any tool, prompt or resource handler. The extension
hands handlers a `TaskContext` — declare the parameter and it arrives, like a
`RequestContext` does:

```php
use Mcp\Schema\Result\CreateTaskResult;
use Mcp\Server\Task\TaskContext;

static function (TaskContext $tasks) use ($queue): CreateTaskResult|string {
if (!$tasks->isSupported()) {
return runSynchronously(); // the client cannot poll, so answer now
}

$created = $tasks->create(ttlMs: 600_000, pollIntervalMs: 1000);
$queue->push($created->task->taskId); // a worker calls $store->save() as it progresses

return $created;
}
```

`create()` stores the task *before* returning it, so the first `tasks/get`
cannot arrive before the task exists. A client that did not declare the
extension during `initialize` cannot redeem a handle, so `create()` refuses
with `-32021` (missing required client capability) instead of handing one out —
the right answer for a handler whose task support is *required*, and what
`isSupported()` lets an optional one avoid.

The SDK owns storage and the `tasks/get` / `tasks/update` / `tasks/cancel`
surface; **advancing** a task is the application's job. A worker (or a
handler, through `TaskContext::getStore()`) saves the task with a new status
as it goes:

```php
use Mcp\Schema\Enum\TaskStatus;

$store->save($task->with(TaskStatus::Completed, result: ['content' => [/* ... */]]));
```

A task that needs the client's input parks itself as `input_required` with
`inputRequests` (elicitation, sampling or roots requests keyed by name); the
client answers through `tasks/update`, and a `TaskInputHandlerInterface` passed
to `TasksExtension` decides what those answers mean for the task.

Status semantics worth getting right: a tool that ran and reported a problem is
`completed` with `isError` on its result — `failed` is reserved for
protocol-level errors, and carries the error inlined instead of a result.
`Task` refuses to be constructed the other way round.

### On the client

A client declares the extension the same way, and then handles whichever
result shape arrives. `TaskClient` wraps a connected `Client`: its `callTool()`,
`getPrompt()` and `readResource()` return a `CreateTaskResult` when the server
chose to answer with a task, and `get()` / `update()` / `cancel()` drive it:

```php
use Mcp\Client;
use Mcp\Client\Task\TaskClient;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\Result\CreateTaskResult;
use Mcp\Server\Task\TasksExtension;

$client = Client::builder()
->enableExtension(new TasksExtension())
->build();
$client->connect($transport);

$tasks = new TaskClient($client);
$result = $tasks->callTool('long_job');

if ($result instanceof CreateTaskResult) {
do {
usleep(1000 * ($result->task->pollIntervalMs ?? 1000));
$task = $tasks->get($result->task->taskId);
} while (!$task->status->isTerminal());

$result = CallToolResult::fromArray($task->result); // once completed
}
```

A task waiting as `input_required` lists its `inputRequests`; answer them with
`update($taskId, ['<key>' => $answer])`, keyed as the requests were, and
`cancel($taskId)` asks the server to stop. The core `Client` itself stays
task-agnostic; `Client::request()` sends any request for code like this.

## MCP Apps (`io.modelcontextprotocol/ui`)

The [MCP Apps extension][ext-apps] lets servers expose interactive HTML UIs as
Expand Down Expand Up @@ -155,3 +269,4 @@ working minimal view is included in
[`examples/server/mcp-apps/weather-app.html`](../examples/server/mcp-apps/weather-app.html).

[ext-apps]: https://github.com/modelcontextprotocol/ext-apps
[ext-tasks]: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- [Client](client.md) — Client SDK for connecting to and communicating with MCP servers.
- [Transports](transports.md) — STDIO and HTTP transport implementations with guidance on choosing between them.
- [Server-Client Communication](server-client-communication.md) — Methods for servers to communicate back to clients: sampling, logging, progress, and notifications.
- [Protocol Extensions](extensions.md) — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources).
- [Protocol Extensions](extensions.md) — Opt-in protocol extensions announced during capability negotiation, including Tasks (durable handles for long-running requests) and MCP Apps (HTML UI resources).
- [Authorization](authorization.md) — OAuth and authorization setup for the HTTP transport.
- [Events](events.md) — Hooking into the server lifecycle with PSR-14 events.
- [Examples](examples.md) — Example projects demonstrating attribute-based discovery, dependency injection, HTTP transport, and more.
8 changes: 7 additions & 1 deletion src/Capability/Discovery/SchemaGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,14 @@
*/
final class SchemaGenerator implements SchemaGeneratorInterface
{
/**
* @param list<class-string> $injectedTypes parameter types the runtime injects rather than the caller supplies,
* on top of {@see RequestContext} — an extension's, say — and which
* therefore do not belong in a schema
*/
public function __construct(
private readonly DocBlockParser $docBlockParser,
private readonly array $injectedTypes = [],
) {
}

Expand Down Expand Up @@ -531,7 +537,7 @@ private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $refl
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
$typeName = $reflectionType->getName();

if (is_a($typeName, RequestContext::class, true)) {
if (is_a($typeName, RequestContext::class, true) || \in_array($typeName, $this->injectedTypes, true)) {
continue;
}
}
Expand Down
12 changes: 12 additions & 0 deletions src/Capability/Registry/ReferenceHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Mcp\Exception\InvalidArgumentException;
use Mcp\Exception\RegistryException;
use Mcp\Schema\JsonRpc\Request;
use Mcp\Server\ClientGateway;
use Mcp\Server\RequestContext;
use Mcp\Server\Session\SessionInterface;
Expand All @@ -23,8 +24,14 @@
*/
final class ReferenceHandler implements ReferenceHandlerInterface
{
/**
* @param array<class-string, callable(SessionInterface, Request): object> $argumentProviders builders for further
* injectable parameter types,
* e.g. an extension's
*/
public function __construct(
private readonly ?ContainerInterface $container = null,
private readonly array $argumentProviders = [],
) {
}

Expand Down Expand Up @@ -113,6 +120,11 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array
continue;
}

if (isset($this->argumentProviders[$typeName], $arguments['_session'], $arguments['_request'])) {
$finalArgs[$paramPosition] = ($this->argumentProviders[$typeName])($arguments['_session'], $arguments['_request']);
continue;
}

if (ClientGateway::class === $typeName && isset($arguments['_session'])) {
$finalArgs[$paramPosition] = new ClientGateway($arguments['_session']);
continue;
Expand Down
16 changes: 16 additions & 0 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,22 @@ private function sendRequest(Request $request, ?callable $onProgress = null): Re
return $response;
}

/**
* Sends any request and returns the raw response — the way to speak a
* method the typed API does not cover, such as an extension's.
*
* @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
* Optional callback for progress updates
*
* @return Response<mixed>
*
* @throws RequestException|ConnectionException
*/
public function request(Request $request, ?callable $onProgress = null): Response
{
return $this->sendRequest($request, $onProgress);
}

/**
* Disconnect from the server.
*/
Expand Down
122 changes: 122 additions & 0 deletions src/Client/Task/TaskClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Client\Task;

use Mcp\Client;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Request\GetPromptRequest;
use Mcp\Schema\Request\ReadResourceRequest;
use Mcp\Schema\Request\TasksCancelRequest;
use Mcp\Schema\Request\TasksGetRequest;
use Mcp\Schema\Request\TasksUpdateRequest;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\Result\CreateTaskResult;
use Mcp\Schema\Result\GetPromptResult;
use Mcp\Schema\Result\ReadResourceResult;
use Mcp\Schema\Result\TaskResult;
use Mcp\Schema\Task;

/**
* The client side of the Tasks extension (SEP-2663), on top of a connected
* {@see Client} that declared it.
*
* ```php
* $client = Client::builder()->enableExtension(new TasksExtension())->build();
* $client->connect($transport);
*
* $tasks = new TaskClient($client);
* $result = $tasks->callTool('long_job');
*
* if ($result instanceof CreateTaskResult) {
* do {
* usleep(1000 * ($result->task->pollIntervalMs ?? 1000));
* $task = $tasks->get($result->task->taskId);
* } while (!$task->status->isTerminal());
* }
* ```
*
* The core client's `callTool()`, `getPrompt()` and `readResource()` expect
* the answer itself; these variants accept a task handle in its place, which a
* server may send once the extension is declared.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
final class TaskClient
{
public function __construct(
private readonly Client $client,
) {
}

/**
* @param array<string, mixed> $arguments
* @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
*/
public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult|CreateTaskResult
{
$result = $this->client->request(new CallToolRequest($name, $arguments), $onProgress)->result;

return CreateTaskResult::describes($result) ? CreateTaskResult::fromArray($result) : CallToolResult::fromArray($result);
}

/**
* @param array<string, string> $arguments
* @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
*/
public function getPrompt(string $name, array $arguments = [], ?callable $onProgress = null): GetPromptResult|CreateTaskResult
{
$result = $this->client->request(new GetPromptRequest($name, $arguments), $onProgress)->result;

return CreateTaskResult::describes($result) ? CreateTaskResult::fromArray($result) : GetPromptResult::fromArray($result);
}

/**
* @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
*/
public function readResource(string $uri, ?callable $onProgress = null): ReadResourceResult|CreateTaskResult
{
$result = $this->client->request(new ReadResourceRequest($uri), $onProgress)->result;

return CreateTaskResult::describes($result) ? CreateTaskResult::fromArray($result) : ReadResourceResult::fromArray($result);
}

/**
* The current state of a task (`tasks/get`).
*
* Poll it at the task's `pollIntervalMs` until {@see \Mcp\Schema\Enum\TaskStatus::isTerminal()};
* a completed task carries the original request's result, an
* `input_required` one what it is waiting for, to answer with {@see self::update()}.
*/
public function get(string $taskId): Task
{
return TaskResult::fromArray($this->client->request(new TasksGetRequest($taskId))->result)->task;
}

/**
* Answers what a task asked for (`tasks/update`).
*
* @param array<string, mixed> $inputResponses keyed as the task's `inputRequests` were
*/
public function update(string $taskId, array $inputResponses): void
{
$this->client->request(new TasksUpdateRequest($taskId, $inputResponses));
}

/**
* Asks the server to cancel a task (`tasks/cancel`). Cooperative: the task
* may still finish.
*/
public function cancel(string $taskId): void
{
$this->client->request(new TasksCancelRequest($taskId));
}
}
42 changes: 42 additions & 0 deletions src/Exception/MissingRequiredClientCapabilityException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Mcp\Exception;

use Mcp\Schema\ClientCapabilities;
use Mcp\Schema\JsonRpc\Error;

/**
* Answering the request needs a client capability it never declared; the
* server answers `-32021` (missing required client capability).
*
* The capabilities travel as a {@see ClientCapabilities} object rather than a
* list of names, so the client can compare them against what it would send.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
class MissingRequiredClientCapabilityException extends \RuntimeException implements ExceptionInterface
{
public function __construct(
public readonly ClientCapabilities $requiredCapabilities,
string $message = 'Request requires a client capability that was not declared.',
) {
parent::__construct($message);
}

/**
* The error to answer the request with.
*/
public function toError(string|int $id): Error
{
return Error::forMissingRequiredClientCapability($this->getMessage(), $this->requiredCapabilities, $id);
}
}
Loading