Skip to content

Commit 027d67d

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, `RequestContext::createTask()` / `supportsTasks()`, and the -32021 refusal for a task handed to a client that did not declare the extension. On the client, `enableExtension(new TasksExtension())` declares it, the call methods return a `CreateTaskResult` when the server answered with a task, and `getTask()` / `updateTask()` / `cancelTask()` drive it. Covered end to end by an integration test against a stdio fixture server. Extensions that add methods implement `MethodProvidingExtensionInterface`; `Builder::enableExtension()` registers their messages and handlers. Extension settings serialize as `{}` rather than `[]` when empty.
1 parent 6f58fe5 commit 027d67d

42 files changed

Lines changed: 2832 additions & 22 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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 can hand back a durable handle instead of holding a connection open. `Mcp\Schema\Task` and `TaskStatus`, `ResultType::Task`, the `CreateTaskResult` / `TaskResult` wire shapes (flat — the result *is* the task), the `tasks/get` / `tasks/update` / `tasks/cancel` surface, and `TaskStoreInterface` with `InMemoryTaskStore` and `Psr16TaskStore` implementations — the latter being what PHP-FPM needs, since the worker that creates a task is not the one polled for it. Enable with `Builder::enableExtension(new TasksExtension($store))`; handlers create a task through `RequestContext::createTask()` after checking `supportsTasks()`, and a task created for a client that did not declare the extension is refused with `-32021` rather than sent. Advancing a task is the application's job. On the client, `Client\Builder::enableExtension(new TasksExtension())` declares it, `Client::callTool()` / `getPrompt()` / `readResource()` now return a `CreateTaskResult` when the server answered with a task (a widening of their return types), and `Client::getTask()` / `updateTask()` / `cancelTask()` drive it. Extensions that add methods of their own implement the new `MethodProvidingExtensionInterface`, and `Builder::enableExtension()` registers their messages and handlers.
9+
* Serialize an extension's settings as `{}` rather than `[]` when it has none, in both `ServerCapabilities` and `ClientCapabilities`; an extension declaring support with no settings was advertised as an empty JSON array, which the schema does not allow.
810
* Add `ClientGateway::supportsExtension()` to check whether the client negotiated a protocol extension (e.g. `McpApps::EXTENSION_ID`) before offering UI-linked tools, plus `Client\Builder::enableExtension()` and `ClientCapabilities::withExtensions()` so hosts advertise extensions the same way servers do. [BC Break] `ServerExtensionInterface` is replaced by the side-agnostic `Mcp\Schema\Extension\ExtensionInterface`.
911
* Deprecate the Roots, Sampling and Logging features per SEP-2577 (protocol revision `2026-07-28`, earliest removal `2027-07-28`): the schema, client and server classes that make them up are marked `@deprecated` with the suggested migration (tool arguments or resource URIs instead of roots; an LLM provider's API instead of sampling; stderr or OpenTelemetry instead of logging), and exercising them — `ClientGateway::log()` / `sample()` / `listRoots()`, or registering a client-side `SamplingRequestHandler`, `ListRootsRequestHandler` or `LoggingNotificationHandler` — triggers a silenced `E_USER_DEPRECATED` via `symfony/deprecation-contracts`. Everything remains fully functional until removal.
1012
* Always emit `{}` for empty tool schemas: `Tool` recursively normalizes every empty sub-schema — `properties`, `items`, `additionalProperties`, `$defs`, combinators and the other draft-07 to 2020-12 schema keywords — in the constructor, for both `inputSchema` and `outputSchema`, so an object position is never serialized as `[]`.

docs/extensions.md

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

48+
An extension that adds RPC methods of its own implements
49+
`MethodProvidingExtensionInterface` on top: its message classes are registered
50+
with the `MessageFactory` and its request handlers with the server, so enabling
51+
it is all it takes for those methods to exist.
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:
78+
79+
```php
80+
use Mcp\Schema\Result\CreateTaskResult;
81+
use Mcp\Server\RequestContext;
82+
83+
static function (RequestContext $context) use ($queue): CreateTaskResult|string {
84+
if (!$context->supportsTasks()) {
85+
return runSynchronously(); // the client cannot poll, so answer now
86+
}
87+
88+
$created = $context->createTask(ttlMs: 600_000, pollIntervalMs: 1000);
89+
$queue->push($created->task->taskId); // a worker calls $store->save() as it progresses
90+
91+
return $created;
92+
}
93+
```
94+
95+
`createTask()` stores the task *before* returning it, so the first `tasks/get`
96+
cannot arrive before the task exists. A client that did not declare the
97+
extension during `initialize` is refused a task handle with `-32021`
98+
(missing required client capability) rather than being left holding a handle it
99+
cannot redeem — which is the right answer for a handler whose task support is
100+
*required*, and what `supportsTasks()` lets an optional one avoid.
101+
102+
The SDK owns storage and the `tasks/get` / `tasks/update` / `tasks/cancel`
103+
surface; **advancing** a task is the application's job. A worker (or a
104+
handler, through `RequestContext::getTaskStore()` or a `TaskStoreInterface`
105+
parameter) saves the task with a new status as it goes:
106+
107+
```php
108+
use Mcp\Schema\Enum\TaskStatus;
109+
110+
$store->save($task->with(TaskStatus::Completed, result: ['content' => [/* ... */]]));
111+
```
112+
113+
A task that needs the client's input parks itself as `input_required` with
114+
`inputRequests` (elicitation, sampling or roots requests keyed by name); the
115+
client answers through `tasks/update`, and a `TaskInputHandlerInterface` passed
116+
to `TasksExtension` decides what those answers mean for the task.
117+
118+
Status semantics worth getting right: a tool that ran and reported a problem is
119+
`completed` with `isError` on its result — `failed` is reserved for
120+
protocol-level errors, and carries the error inlined instead of a result.
121+
`Task` refuses to be constructed the other way round.
122+
123+
### On the client
124+
125+
A client declares the extension the same way, and then handles whichever
126+
result shape arrives — `callTool()`, `getPrompt()` and `readResource()` return
127+
a `CreateTaskResult` when the server chose to answer with a task:
128+
129+
```php
130+
use Mcp\Client;
131+
use Mcp\Schema\Result\CallToolResult;
132+
use Mcp\Schema\Result\CreateTaskResult;
133+
use Mcp\Server\Task\TasksExtension;
134+
135+
$client = Client::builder()
136+
->enableExtension(new TasksExtension())
137+
->build();
138+
139+
$result = $client->callTool('long_job');
140+
141+
if ($result instanceof CreateTaskResult) {
142+
do {
143+
usleep(1000 * ($result->task->pollIntervalMs ?? 1000));
144+
$task = $client->getTask($result->task->taskId);
145+
} while (!$task->status->isTerminal());
146+
147+
$result = CallToolResult::fromArray($task->result); // once completed
148+
}
149+
```
150+
151+
A task waiting as `input_required` lists its `inputRequests`; answer them with
152+
`updateTask($taskId, ['<key>' => $answer])`, keyed as the requests were, and
153+
`cancelTask($taskId)` asks the server to stop.
154+
48155
## MCP Apps (`io.modelcontextprotocol/ui`)
49156

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

157264
[ext-apps]: https://github.com/modelcontextprotocol/ext-apps
265+
[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/Registry/ReferenceHandler.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,20 @@
1616
use Mcp\Server\ClientGateway;
1717
use Mcp\Server\RequestContext;
1818
use Mcp\Server\Session\SessionInterface;
19+
use Mcp\Server\Task\TaskStoreInterface;
1920
use Psr\Container\ContainerInterface;
2021

2122
/**
2223
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
2324
*/
2425
final class ReferenceHandler implements ReferenceHandlerInterface
2526
{
27+
/**
28+
* @param ?TaskStoreInterface $taskStore the store handlers create tasks in, when the Tasks extension is enabled
29+
*/
2630
public function __construct(
2731
private readonly ?ContainerInterface $container = null,
32+
private readonly ?TaskStoreInterface $taskStore = null,
2833
) {
2934
}
3035

@@ -109,7 +114,12 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array
109114
$typeName = $type->getName();
110115

111116
if (RequestContext::class === $typeName && isset($arguments['_session'], $arguments['_request'])) {
112-
$finalArgs[$paramPosition] = new RequestContext($arguments['_session'], $arguments['_request']);
117+
$finalArgs[$paramPosition] = new RequestContext($arguments['_session'], $arguments['_request'], $this->taskStore);
118+
continue;
119+
}
120+
121+
if (TaskStoreInterface::class === $typeName && null !== $this->taskStore) {
122+
$finalArgs[$paramPosition] = $this->taskStore;
113123
continue;
114124
}
115125

src/Client.php

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,21 @@
3636
use Mcp\Schema\Request\PingRequest;
3737
use Mcp\Schema\Request\ReadResourceRequest;
3838
use Mcp\Schema\Request\SetLogLevelRequest;
39+
use Mcp\Schema\Request\TasksCancelRequest;
40+
use Mcp\Schema\Request\TasksGetRequest;
41+
use Mcp\Schema\Request\TasksUpdateRequest;
3942
use Mcp\Schema\ResourceReference;
4043
use Mcp\Schema\Result\CallToolResult;
4144
use Mcp\Schema\Result\CompletionCompleteResult;
45+
use Mcp\Schema\Result\CreateTaskResult;
4246
use Mcp\Schema\Result\GetPromptResult;
4347
use Mcp\Schema\Result\ListPromptsResult;
4448
use Mcp\Schema\Result\ListResourcesResult;
4549
use Mcp\Schema\Result\ListResourceTemplatesResult;
4650
use Mcp\Schema\Result\ListToolsResult;
4751
use Mcp\Schema\Result\ReadResourceResult;
52+
use Mcp\Schema\Result\TaskResult;
53+
use Mcp\Schema\Task;
4854
use Psr\Log\LoggerInterface;
4955
use Psr\Log\NullLogger;
5056

@@ -181,18 +187,24 @@ public function listTools(?string $cursor = null): ListToolsResult
181187
/**
182188
* Call a tool on the server.
183189
*
190+
* A server with the Tasks extension may answer with a {@see CreateTaskResult}
191+
* instead of the tool's output when the client declared the extension; poll
192+
* it with {@see self::getTask()}.
193+
*
184194
* @param string $name Tool name
185195
* @param array<string, mixed> $arguments Tool arguments
186196
* @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
187197
* Optional callback for progress updates
188198
*/
189-
public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult
199+
public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult|CreateTaskResult
190200
{
191201
$request = new CallToolRequest($name, $arguments);
192202

193203
$response = $this->sendRequest($request, $onProgress);
194204

195-
return CallToolResult::fromArray($response->result);
205+
return CreateTaskResult::describes($response->result)
206+
? CreateTaskResult::fromArray($response->result)
207+
: CallToolResult::fromArray($response->result);
196208
}
197209

198210
/**
@@ -222,17 +234,21 @@ public function listResourceTemplates(?string $cursor = null): ListResourceTempl
222234
/**
223235
* Read a resource by URI.
224236
*
237+
* May answer with a {@see CreateTaskResult}, see {@see self::callTool()}.
238+
*
225239
* @param string $uri The resource URI
226240
* @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
227241
* Optional callback for progress updates
228242
*/
229-
public function readResource(string $uri, ?callable $onProgress = null): ReadResourceResult
243+
public function readResource(string $uri, ?callable $onProgress = null): ReadResourceResult|CreateTaskResult
230244
{
231245
$request = new ReadResourceRequest($uri);
232246

233247
$response = $this->sendRequest($request, $onProgress);
234248

235-
return ReadResourceResult::fromArray($response->result);
249+
return CreateTaskResult::describes($response->result)
250+
? CreateTaskResult::fromArray($response->result)
251+
: ReadResourceResult::fromArray($response->result);
236252
}
237253

238254
/**
@@ -250,18 +266,55 @@ public function listPrompts(?string $cursor = null): ListPromptsResult
250266
/**
251267
* Get a prompt from the server.
252268
*
269+
* May answer with a {@see CreateTaskResult}, see {@see self::callTool()}.
270+
*
253271
* @param string $name Prompt name
254272
* @param array<string, string> $arguments Prompt arguments
255273
* @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
256274
* Optional callback for progress updates
257275
*/
258-
public function getPrompt(string $name, array $arguments = [], ?callable $onProgress = null): GetPromptResult
276+
public function getPrompt(string $name, array $arguments = [], ?callable $onProgress = null): GetPromptResult|CreateTaskResult
259277
{
260278
$request = new GetPromptRequest($name, $arguments);
261279

262280
$response = $this->sendRequest($request, $onProgress);
263281

264-
return GetPromptResult::fromArray($response->result);
282+
return CreateTaskResult::describes($response->result)
283+
? CreateTaskResult::fromArray($response->result)
284+
: GetPromptResult::fromArray($response->result);
285+
}
286+
287+
/**
288+
* The current state of a task (Tasks extension, `tasks/get`).
289+
*
290+
* Poll it at the task's `pollIntervalMs` until {@see Schema\Enum\TaskStatus::isTerminal()};
291+
* a completed task carries the original request's result, an
292+
* `input_required` one what it is waiting for, to answer with {@see self::updateTask()}.
293+
*/
294+
public function getTask(string $taskId): Task
295+
{
296+
$response = $this->sendRequest(new TasksGetRequest($taskId));
297+
298+
return TaskResult::fromArray($response->result)->task;
299+
}
300+
301+
/**
302+
* Answers what a task asked for (Tasks extension, `tasks/update`).
303+
*
304+
* @param array<string, mixed> $inputResponses keyed as the task's `inputRequests` were
305+
*/
306+
public function updateTask(string $taskId, array $inputResponses): void
307+
{
308+
$this->sendRequest(new TasksUpdateRequest($taskId, $inputResponses));
309+
}
310+
311+
/**
312+
* Asks the server to cancel a task (Tasks extension, `tasks/cancel`).
313+
* Cooperative: the task may still finish.
314+
*/
315+
public function cancelTask(string $taskId): void
316+
{
317+
$this->sendRequest(new TasksCancelRequest($taskId));
265318
}
266319

267320
/**

src/JsonRpc/MessageFactory.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,12 @@ public function __construct(
9595

9696
/**
9797
* Creates a new Factory instance with all the protocol's default messages.
98+
*
99+
* @param list<class-string<Request>|class-string<Notification>> $additional message classes an extension defines
98100
*/
99-
public static function make(int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE): self
101+
public static function make(int $maxBatchSize = self::DEFAULT_MAX_BATCH_SIZE, array $additional = []): self
100102
{
101-
return new self(self::REGISTERED_MESSAGES, $maxBatchSize);
103+
return new self([...self::REGISTERED_MESSAGES, ...$additional], $maxBatchSize);
102104
}
103105

104106
/**

src/Schema/ClientCapabilities.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,12 @@ public function jsonSerialize(): array|object
188188
}
189189

190190
if ($this->extensions) {
191-
$data['extensions'] = (object) $this->extensions;
191+
// Each entry is a settings *object*; an extension with no settings
192+
// declares `{}`, and an empty PHP array would serialize as `[]`.
193+
$data['extensions'] = (object) array_map(
194+
static fn (mixed $settings): mixed => \is_array($settings) ? (object) $settings : $settings,
195+
$this->extensions,
196+
);
192197
}
193198

194199
return $data ?: new \stdClass();

src/Schema/Enum/ResultType.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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\Schema\Enum;
13+
14+
/**
15+
* Tells the client how to read a result before it looks at the body.
16+
*
17+
* Required from 2026-07-28, where a request may come back finished or asking
18+
* for input. A client seeing no `resultType` MUST read it as
19+
* {@see self::Complete}.
20+
*
21+
* @author Christopher Hertel <mail@christopher-hertel.de>
22+
*/
23+
enum ResultType: string
24+
{
25+
/** The request finished; the result holds the final content. */
26+
case Complete = 'complete';
27+
28+
/** The request needs more input before it can finish (MRTR). */
29+
case InputRequired = 'input_required';
30+
31+
/** The request became a task; the result is the handle to poll (Tasks extension). */
32+
case Task = 'task';
33+
}

0 commit comments

Comments
 (0)