Skip to content

Commit 9a31d31

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 e473c3c commit 9a31d31

38 files changed

Lines changed: 2922 additions & 25 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ All notable changes to `mcp/sdk` will be documented in this file.
2020
* Deprecate Roots, Sampling and Logging (SEP-2577, earliest removal `2027-07-28`); they keep working but trigger a deprecation notice.
2121
* [BC Break] Answer a not-found subject with `-32602` instead of `-32002` (SEP-2164): `resources/read` picks the code by revision (`-32602` from `2026-07-28` on), `prompts/get`, `completion/complete` and `tools/call` switch on every revision. Adds `ProtocolVersion::usesInvalidParamsForResourceNotFound()`.
2222
* [BC Break] A list-shaped tool result is only sent as `structuredContent` on `2026-07-28`+; older revisions keep the JSON-encoded value in `content`.
23+
* 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.
24+
* 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.
2325
* [BC Break] Add the extensions framework (SEP-2133) MCP Apps sits on: `ExtensionInterface::getId()` returns an `ExtensionIdentifier` value object, and the interface gains `getMessages()`/`getRequestHandlers()` (extend `AbstractExtension` to skip both). `MessageFactory::make()` takes an `$additional` message list; `RequestHandlerInterface`'s result template is covariant. `ServerExtensionInterface` is replaced by the side-agnostic `Schema\Extension\ExtensionInterface`.
2426
* Add client-side extension negotiation: `ClientGateway::supportsExtension()`, `Client\Builder::enableExtension()`, `ClientCapabilities::withExtensions()`.
2527
* Add sampling-with-tools: sampling requests can carry tools and tool-choice preferences, messages support tool-use/tool-result blocks, and clients advertise `sampling.context`/`sampling.tools` (`ClientGateway::supportsSamplingTools()`/`supportsSamplingContext()`). Requests violating the tool-flow rules are rejected with a JSON-RPC error.

docs/advanced/extensions.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,120 @@ public function getWeather(string $city, RequestContext $context): string
4848
}
4949
```
5050

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

53167
The [MCP Apps extension][ext-apps] lets servers expose interactive HTML UIs as
@@ -158,3 +272,4 @@ working minimal view is included in
158272
[`examples/server/mcp-apps/weather-app.html`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/mcp-apps/weather-app.html).
159273

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

src/Capability/Discovery/SchemaGenerator.php

Lines changed: 12 additions & 4 deletions
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 InjectableParameters} — an extension's, say — and
66+
* which 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

@@ -528,10 +534,12 @@ private function parseParametersInfo(\ReflectionMethod|\ReflectionFunction $refl
528534
foreach ($reflection->getParameters() as $rp) {
529535
$reflectionType = $rp->getType();
530536

531-
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()
532-
&& InjectableParameters::supports($reflectionType->getName())
533-
) {
534-
continue;
537+
if ($reflectionType instanceof \ReflectionNamedType && !$reflectionType->isBuiltin()) {
538+
$typeName = $reflectionType->getName();
539+
540+
if (InjectableParameters::supports($typeName) || \in_array($typeName, $this->injectedTypes, true)) {
541+
continue;
542+
}
535543
}
536544

537545
$paramName = $rp->getName();

src/Capability/Registry/ReferenceHandler.php

Lines changed: 16 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\Session\SessionInterface;
1718
use Psr\Container\ContainerInterface;
1819

@@ -21,8 +22,14 @@
2122
*/
2223
final class ReferenceHandler implements ReferenceHandlerInterface
2324
{
25+
/**
26+
* @param array<class-string, callable(SessionInterface, Request): object> $argumentProviders builders for further
27+
* injectable parameter types,
28+
* e.g. an extension's
29+
*/
2430
public function __construct(
2531
private readonly ?ContainerInterface $container = null,
32+
private readonly array $argumentProviders = [],
2633
) {
2734
}
2835

@@ -109,6 +116,15 @@ private function prepareArguments(\ReflectionFunctionAbstract $reflection, array
109116
$finalArgs[$paramPosition] = $injected;
110117
continue;
111118
}
119+
120+
// An extension may contribute further injectable types; the
121+
// core stays unaware of what they are.
122+
$typeName = $type->getName();
123+
124+
if (isset($this->argumentProviders[$typeName], $arguments['_session'], $arguments['_request'])) {
125+
$finalArgs[$paramPosition] = ($this->argumentProviders[$typeName])($arguments['_session'], $arguments['_request']);
126+
continue;
127+
}
112128
}
113129

114130
if (isset($arguments[$paramName])) {

src/Client.php

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

357+
/**
358+
* Sends any request and returns the raw response — the way to speak a
359+
* method the typed API does not cover, such as an extension's.
360+
*
361+
* @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress
362+
* Optional callback for progress updates
363+
*
364+
* @return Response<mixed>
365+
*
366+
* @throws RequestException|ConnectionException
367+
*/
368+
public function request(Request $request, ?callable $onProgress = null): Response
369+
{
370+
return $this->sendRequest($request, $onProgress);
371+
}
372+
357373
/**
358374
* Disconnect from the server.
359375
*/

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+
}

src/Schema/Enum/ResultType.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,7 @@ enum ResultType: string
2727

2828
/** The request needs more input before it can finish (MRTR). */
2929
case InputRequired = 'input_required';
30+
31+
/** The request became a task; the result is the handle to poll (Tasks extension). */
32+
case Task = 'task';
3033
}

0 commit comments

Comments
 (0)