Skip to content

Commit c47f1c2

Browse files
committed
[Schema][Client][Server] Add the Tasks extension (SEP-2663)
`io.modelcontextprotocol/tasks`: `Task` and `TaskStatus`, `ResultType::Task`, the `CreateTaskResult` / `TaskResult` wire shapes, the `tasks/get` / `tasks/update` / `tasks/cancel` handlers behind `TasksExtension`, `TaskStoreInterface` with in-memory and PSR-16 stores, and a `TaskContext` handed to handlers for creating tasks — refused with -32021 for a client that did not declare the extension. The core stays extension-agnostic: `MethodProvidingExtensionInterface` and `ArgumentProvidingExtensionInterface` let an extension register its messages, handlers and injectable handler arguments; the core handlers pass any `ResultInterface` through; a `MissingRequiredClientCapabilityException` from handler code becomes -32021; `Client::request()` sends any request. On the client, `TaskClient` speaks the extension. Covered end to end by an integration test against a stdio fixture server. Extension settings serialize as `{}` rather than `[]` when empty.
1 parent 6071195 commit c47f1c2

40 files changed

Lines changed: 3001 additions & 11 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ All notable changes to `mcp/sdk` will be documented in this file.
55
0.8.0
66
-----
77

8+
* 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.
9+
* 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.
810
* [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.
911
* 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.
1012
* [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.

docs/extensions.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,120 @@ public function getWeather(string $city, RequestContext $context): string
4545
}
4646
```
4747

48+
An extension that hands handlers an object of its own implements
49+
`ArgumentProvidingExtensionInterface` on top: a handler declaring a parameter
50+
of a provided type receives it for the request being served, the way it
51+
receives a `RequestContext` — and it stays out of the generated input schemas.
52+
53+
## Tasks (`io.modelcontextprotocol/tasks`)
54+
55+
The [Tasks extension][ext-tasks] (SEP-2663) lets a server hand back a durable
56+
handle instead of holding a connection open for a long-running request. The
57+
client polls `tasks/get` until the task settles, answers anything it asks for
58+
through `tasks/update`, and may `tasks/cancel` it.
59+
60+
```php
61+
use Mcp\Server;
62+
use Mcp\Server\Task\InMemoryTaskStore;
63+
use Mcp\Server\Task\Psr16TaskStore;
64+
use Mcp\Server\Task\TasksExtension;
65+
66+
$server = Server::builder()
67+
->enableExtension(new TasksExtension(new InMemoryTaskStore()))
68+
->build();
69+
```
70+
71+
`InMemoryTaskStore` is right for stdio and any single-process runtime and drops
72+
its oldest task past a configurable limit (1000 by default). Under PHP-FPM the
73+
worker that creates a task is not the one polled for it, so use
74+
`Psr16TaskStore` over a shared cache there — a filesystem adapter is enough.
75+
76+
Creating a task is the *server's* decision, made per request by returning a
77+
`CreateTaskResult` from any tool, prompt or resource handler. The extension
78+
hands handlers a `TaskContext` — declare the parameter and it arrives, like a
79+
`RequestContext` does:
80+
81+
```php
82+
use Mcp\Schema\Result\CreateTaskResult;
83+
use Mcp\Server\Task\TaskContext;
84+
85+
static function (TaskContext $tasks) use ($queue): CreateTaskResult|string {
86+
if (!$tasks->isSupported()) {
87+
return runSynchronously(); // the client cannot poll, so answer now
88+
}
89+
90+
$created = $tasks->create(ttlMs: 600_000, pollIntervalMs: 1000);
91+
$queue->push($created->task->taskId); // a worker calls $store->save() as it progresses
92+
93+
return $created;
94+
}
95+
```
96+
97+
`create()` stores the task *before* returning it, so the first `tasks/get`
98+
cannot arrive before the task exists. A client that did not declare the
99+
extension during `initialize` cannot redeem a handle, so `create()` refuses
100+
with `-32021` (missing required client capability) instead of handing one out —
101+
the right answer for a handler whose task support is *required*, and what
102+
`isSupported()` lets an optional one avoid.
103+
104+
The SDK owns storage and the `tasks/get` / `tasks/update` / `tasks/cancel`
105+
surface; **advancing** a task is the application's job. A worker (or a
106+
handler, through `TaskContext::getStore()`) saves the task with a new status
107+
as it goes:
108+
109+
```php
110+
use Mcp\Schema\Enum\TaskStatus;
111+
112+
$store->save($task->with(TaskStatus::Completed, result: ['content' => [/* ... */]]));
113+
```
114+
115+
A task that needs the client's input parks itself as `input_required` with
116+
`inputRequests` (elicitation, sampling or roots requests keyed by name); the
117+
client answers through `tasks/update`, and a `TaskInputHandlerInterface` passed
118+
to `TasksExtension` decides what those answers mean for the task.
119+
120+
Status semantics worth getting right: a tool that ran and reported a problem is
121+
`completed` with `isError` on its result — `failed` is reserved for
122+
protocol-level errors, and carries the error inlined instead of a result.
123+
`Task` refuses to be constructed the other way round.
124+
125+
### On the client
126+
127+
A client declares the extension the same way, and then handles whichever
128+
result shape arrives. `TaskClient` wraps a connected `Client`: its `callTool()`,
129+
`getPrompt()` and `readResource()` return a `CreateTaskResult` when the server
130+
chose to answer with a task, and `get()` / `update()` / `cancel()` drive it:
131+
132+
```php
133+
use Mcp\Client;
134+
use Mcp\Client\Task\TaskClient;
135+
use Mcp\Schema\Result\CallToolResult;
136+
use Mcp\Schema\Result\CreateTaskResult;
137+
use Mcp\Server\Task\TasksExtension;
138+
139+
$client = Client::builder()
140+
->enableExtension(new TasksExtension())
141+
->build();
142+
$client->connect($transport);
143+
144+
$tasks = new TaskClient($client);
145+
$result = $tasks->callTool('long_job');
146+
147+
if ($result instanceof CreateTaskResult) {
148+
do {
149+
usleep(1000 * ($result->task->pollIntervalMs ?? 1000));
150+
$task = $tasks->get($result->task->taskId);
151+
} while (!$task->status->isTerminal());
152+
153+
$result = CallToolResult::fromArray($task->result); // once completed
154+
}
155+
```
156+
157+
A task waiting as `input_required` lists its `inputRequests`; answer them with
158+
`update($taskId, ['<key>' => $answer])`, keyed as the requests were, and
159+
`cancel($taskId)` asks the server to stop. The core `Client` itself stays
160+
task-agnostic; `Client::request()` sends any request for code like this.
161+
48162
## MCP Apps (`io.modelcontextprotocol/ui`)
49163

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

157271
[ext-apps]: https://github.com/modelcontextprotocol/ext-apps
272+
[ext-tasks]: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
- [Client](client.md) — Client SDK for connecting to and communicating with MCP servers.
66
- [Transports](transports.md) — STDIO and HTTP transport implementations with guidance on choosing between them.
77
- [Server-Client Communication](server-client-communication.md) — Methods for servers to communicate back to clients: sampling, logging, progress, and notifications.
8-
- [Protocol Extensions](extensions.md) — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources).
8+
- [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).
99
- [Authorization](authorization.md) — OAuth and authorization setup for the HTTP transport.
1010
- [Events](events.md) — Hooking into the server lifecycle with PSR-14 events.
1111
- [Examples](examples.md) — Example projects demonstrating attribute-based discovery, dependency injection, HTTP transport, and more.

src/Capability/Discovery/SchemaGenerator.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,14 @@
6060
*/
6161
final class SchemaGenerator implements SchemaGeneratorInterface
6262
{
63+
/**
64+
* @param list<class-string> $injectedTypes parameter types the runtime injects rather than the caller supplies,
65+
* on top of {@see RequestContext} — an extension's, say — and which
66+
* therefore do not belong in a schema
67+
*/
6368
public function __construct(
6469
private readonly DocBlockParser $docBlockParser,
70+
private readonly array $injectedTypes = [],
6571
) {
6672
}
6773

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

534-
if (is_a($typeName, RequestContext::class, true)) {
540+
if (is_a($typeName, RequestContext::class, true) || \in_array($typeName, $this->injectedTypes, true)) {
535541
continue;
536542
}
537543
}

src/Capability/Registry/ReferenceHandler.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Mcp\Exception\InvalidArgumentException;
1515
use Mcp\Exception\RegistryException;
16+
use Mcp\Schema\JsonRpc\Request;
1617
use Mcp\Server\ClientGateway;
1718
use Mcp\Server\RequestContext;
1819
use Mcp\Server\Session\SessionInterface;
@@ -23,8 +24,14 @@
2324
*/
2425
final class ReferenceHandler implements ReferenceHandlerInterface
2526
{
27+
/**
28+
* @param array<class-string, callable(SessionInterface, Request): object> $argumentProviders builders for further
29+
* injectable parameter types,
30+
* e.g. an extension's
31+
*/
2632
public function __construct(
2733
private readonly ?ContainerInterface $container = null,
34+
private readonly array $argumentProviders = [],
2835
) {
2936
}
3037

@@ -113,6 +120,11 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array
113120
continue;
114121
}
115122

123+
if (isset($this->argumentProviders[$typeName], $arguments['_session'], $arguments['_request'])) {
124+
$finalArgs[$paramPosition] = ($this->argumentProviders[$typeName])($arguments['_session'], $arguments['_request']);
125+
continue;
126+
}
127+
116128
if (ClientGateway::class === $typeName && isset($arguments['_session'])) {
117129
$finalArgs[$paramPosition] = new ClientGateway($arguments['_session']);
118130
continue;

src/Client.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,22 @@ private function sendRequest(Request $request, ?callable $onProgress = null): Re
336336
return $response;
337337
}
338338

339+
/**
340+
* Sends any request and returns the raw response — the way to speak a
341+
* method the typed API does not cover, such as an extension's.
342+
*
343+
* @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
344+
* Optional callback for progress updates
345+
*
346+
* @return Response<mixed>
347+
*
348+
* @throws RequestException|ConnectionException
349+
*/
350+
public function request(Request $request, ?callable $onProgress = null): Response
351+
{
352+
return $this->sendRequest($request, $onProgress);
353+
}
354+
339355
/**
340356
* Disconnect from the server.
341357
*/

src/Client/Task/TaskClient.php

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Client\Task;
13+
14+
use Mcp\Client;
15+
use Mcp\Schema\Request\CallToolRequest;
16+
use Mcp\Schema\Request\GetPromptRequest;
17+
use Mcp\Schema\Request\ReadResourceRequest;
18+
use Mcp\Schema\Request\TasksCancelRequest;
19+
use Mcp\Schema\Request\TasksGetRequest;
20+
use Mcp\Schema\Request\TasksUpdateRequest;
21+
use Mcp\Schema\Result\CallToolResult;
22+
use Mcp\Schema\Result\CreateTaskResult;
23+
use Mcp\Schema\Result\GetPromptResult;
24+
use Mcp\Schema\Result\ReadResourceResult;
25+
use Mcp\Schema\Result\TaskResult;
26+
use Mcp\Schema\Task;
27+
28+
/**
29+
* The client side of the Tasks extension (SEP-2663), on top of a connected
30+
* {@see Client} that declared it.
31+
*
32+
* ```php
33+
* $client = Client::builder()->enableExtension(new TasksExtension())->build();
34+
* $client->connect($transport);
35+
*
36+
* $tasks = new TaskClient($client);
37+
* $result = $tasks->callTool('long_job');
38+
*
39+
* if ($result instanceof CreateTaskResult) {
40+
* do {
41+
* usleep(1000 * ($result->task->pollIntervalMs ?? 1000));
42+
* $task = $tasks->get($result->task->taskId);
43+
* } while (!$task->status->isTerminal());
44+
* }
45+
* ```
46+
*
47+
* The core client's `callTool()`, `getPrompt()` and `readResource()` expect
48+
* the answer itself; these variants accept a task handle in its place, which a
49+
* server may send once the extension is declared.
50+
*
51+
* @author Christopher Hertel <mail@christopher-hertel.de>
52+
*/
53+
final class TaskClient
54+
{
55+
public function __construct(
56+
private readonly Client $client,
57+
) {
58+
}
59+
60+
/**
61+
* @param array<string, mixed> $arguments
62+
* @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
63+
*/
64+
public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult|CreateTaskResult
65+
{
66+
$result = $this->client->request(new CallToolRequest($name, $arguments), $onProgress)->result;
67+
68+
return CreateTaskResult::describes($result) ? CreateTaskResult::fromArray($result) : CallToolResult::fromArray($result);
69+
}
70+
71+
/**
72+
* @param array<string, string> $arguments
73+
* @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
74+
*/
75+
public function getPrompt(string $name, array $arguments = [], ?callable $onProgress = null): GetPromptResult|CreateTaskResult
76+
{
77+
$result = $this->client->request(new GetPromptRequest($name, $arguments), $onProgress)->result;
78+
79+
return CreateTaskResult::describes($result) ? CreateTaskResult::fromArray($result) : GetPromptResult::fromArray($result);
80+
}
81+
82+
/**
83+
* @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
84+
*/
85+
public function readResource(string $uri, ?callable $onProgress = null): ReadResourceResult|CreateTaskResult
86+
{
87+
$result = $this->client->request(new ReadResourceRequest($uri), $onProgress)->result;
88+
89+
return CreateTaskResult::describes($result) ? CreateTaskResult::fromArray($result) : ReadResourceResult::fromArray($result);
90+
}
91+
92+
/**
93+
* The current state of a task (`tasks/get`).
94+
*
95+
* Poll it at the task's `pollIntervalMs` until {@see \Mcp\Schema\Enum\TaskStatus::isTerminal()};
96+
* a completed task carries the original request's result, an
97+
* `input_required` one what it is waiting for, to answer with {@see self::update()}.
98+
*/
99+
public function get(string $taskId): Task
100+
{
101+
return TaskResult::fromArray($this->client->request(new TasksGetRequest($taskId))->result)->task;
102+
}
103+
104+
/**
105+
* Answers what a task asked for (`tasks/update`).
106+
*
107+
* @param array<string, mixed> $inputResponses keyed as the task's `inputRequests` were
108+
*/
109+
public function update(string $taskId, array $inputResponses): void
110+
{
111+
$this->client->request(new TasksUpdateRequest($taskId, $inputResponses));
112+
}
113+
114+
/**
115+
* Asks the server to cancel a task (`tasks/cancel`). Cooperative: the task
116+
* may still finish.
117+
*/
118+
public function cancel(string $taskId): void
119+
{
120+
$this->client->request(new TasksCancelRequest($taskId));
121+
}
122+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Exception;
13+
14+
use Mcp\Schema\ClientCapabilities;
15+
use Mcp\Schema\JsonRpc\Error;
16+
17+
/**
18+
* Answering the request needs a client capability it never declared; the
19+
* server answers `-32021` (missing required client capability).
20+
*
21+
* The capabilities travel as a {@see ClientCapabilities} object rather than a
22+
* list of names, so the client can compare them against what it would send.
23+
*
24+
* @author Christopher Hertel <mail@christopher-hertel.de>
25+
*/
26+
class MissingRequiredClientCapabilityException extends \RuntimeException implements ExceptionInterface
27+
{
28+
public function __construct(
29+
public readonly ClientCapabilities $requiredCapabilities,
30+
string $message = 'Request requires a client capability that was not declared.',
31+
) {
32+
parent::__construct($message);
33+
}
34+
35+
/**
36+
* The error to answer the request with.
37+
*/
38+
public function toError(string|int $id): Error
39+
{
40+
return Error::forMissingRequiredClientCapability($this->getMessage(), $this->requiredCapabilities, $id);
41+
}
42+
}

0 commit comments

Comments
 (0)