diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c45581..afb0b2a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/extensions.md b/docs/extensions.md index aff06aba..7ebc69b5 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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, ['' => $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 @@ -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 diff --git a/docs/index.md b/docs/index.md index 91162290..acb13a7e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. diff --git a/src/Capability/Discovery/SchemaGenerator.php b/src/Capability/Discovery/SchemaGenerator.php index 673477db..13ebb38b 100644 --- a/src/Capability/Discovery/SchemaGenerator.php +++ b/src/Capability/Discovery/SchemaGenerator.php @@ -60,8 +60,14 @@ */ final class SchemaGenerator implements SchemaGeneratorInterface { + /** + * @param list $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 = [], ) { } @@ -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; } } diff --git a/src/Capability/Registry/ReferenceHandler.php b/src/Capability/Registry/ReferenceHandler.php index 99e58442..b9ada64c 100644 --- a/src/Capability/Registry/ReferenceHandler.php +++ b/src/Capability/Registry/ReferenceHandler.php @@ -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; @@ -23,8 +24,14 @@ */ final class ReferenceHandler implements ReferenceHandlerInterface { + /** + * @param array $argumentProviders builders for further + * injectable parameter types, + * e.g. an extension's + */ public function __construct( private readonly ?ContainerInterface $container = null, + private readonly array $argumentProviders = [], ) { } @@ -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; diff --git a/src/Client.php b/src/Client.php index ed5abc6f..96a6dfda 100644 --- a/src/Client.php +++ b/src/Client.php @@ -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 + * + * @throws RequestException|ConnectionException + */ + public function request(Request $request, ?callable $onProgress = null): Response + { + return $this->sendRequest($request, $onProgress); + } + /** * Disconnect from the server. */ diff --git a/src/Client/Task/TaskClient.php b/src/Client/Task/TaskClient.php new file mode 100644 index 00000000..d407072a --- /dev/null +++ b/src/Client/Task/TaskClient.php @@ -0,0 +1,122 @@ +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 + */ +final class TaskClient +{ + public function __construct( + private readonly Client $client, + ) { + } + + /** + * @param array $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 $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 $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)); + } +} diff --git a/src/Exception/MissingRequiredClientCapabilityException.php b/src/Exception/MissingRequiredClientCapabilityException.php new file mode 100644 index 00000000..945721a7 --- /dev/null +++ b/src/Exception/MissingRequiredClientCapabilityException.php @@ -0,0 +1,42 @@ + + */ +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); + } +} diff --git a/src/Schema/Enum/ResultType.php b/src/Schema/Enum/ResultType.php new file mode 100644 index 00000000..0a2e3714 --- /dev/null +++ b/src/Schema/Enum/ResultType.php @@ -0,0 +1,33 @@ + + */ +enum ResultType: string +{ + /** The request finished; the result holds the final content. */ + case Complete = 'complete'; + + /** The request needs more input before it can finish (MRTR). */ + case InputRequired = 'input_required'; + + /** The request became a task; the result is the handle to poll (Tasks extension). */ + case Task = 'task'; +} diff --git a/src/Schema/Enum/TaskStatus.php b/src/Schema/Enum/TaskStatus.php new file mode 100644 index 00000000..a2382b65 --- /dev/null +++ b/src/Schema/Enum/TaskStatus.php @@ -0,0 +1,52 @@ + + */ +enum TaskStatus: string +{ + /** The work is in progress. */ + case Working = 'working'; + + /** The server needs client input before it can continue. */ + case InputRequired = 'input_required'; + + /** The work finished; the result is what the original request would have returned. */ + case Completed = 'completed'; + + /** A protocol-level error stopped the work; the error is inlined. */ + case Failed = 'failed'; + + /** The work was cancelled. Cooperative: reaching it is never guaranteed. */ + case Cancelled = 'cancelled'; + + /** + * Whether the task can still change. Terminal states never do. + */ + public function isTerminal(): bool + { + return match ($this) { + self::Completed, self::Failed, self::Cancelled => true, + self::Working, self::InputRequired => false, + }; + } +} diff --git a/src/Schema/Extension/ArgumentProvidingExtensionInterface.php b/src/Schema/Extension/ArgumentProvidingExtensionInterface.php new file mode 100644 index 00000000..d3c9b595 --- /dev/null +++ b/src/Schema/Extension/ArgumentProvidingExtensionInterface.php @@ -0,0 +1,35 @@ + + */ +interface ArgumentProvidingExtensionInterface extends ExtensionInterface +{ + /** + * Builders for the types this extension injects, keyed by the type. + * + * @return array + */ + public function getArgumentProviders(): array; +} diff --git a/src/Schema/Request/TasksCancelRequest.php b/src/Schema/Request/TasksCancelRequest.php new file mode 100644 index 00000000..39d8616c --- /dev/null +++ b/src/Schema/Request/TasksCancelRequest.php @@ -0,0 +1,48 @@ + + */ +final class TasksCancelRequest extends Request +{ + public function __construct( + public readonly string $taskId, + ) { + } + + public static function getMethod(): string + { + return 'tasks/cancel'; + } + + protected static function fromParams(?array $params): static + { + if (!isset($params['taskId']) || !\is_string($params['taskId']) || '' === $params['taskId']) { + throw new InvalidArgumentException('Missing or invalid "taskId" parameter for tasks/cancel.'); + } + + return new self($params['taskId']); + } + + /** + * @return array{taskId: string} + */ + protected function getParams(): array + { + return ['taskId' => $this->taskId]; + } +} diff --git a/src/Schema/Request/TasksGetRequest.php b/src/Schema/Request/TasksGetRequest.php new file mode 100644 index 00000000..c2435305 --- /dev/null +++ b/src/Schema/Request/TasksGetRequest.php @@ -0,0 +1,48 @@ + + */ +final class TasksGetRequest extends Request +{ + public function __construct( + public readonly string $taskId, + ) { + } + + public static function getMethod(): string + { + return 'tasks/get'; + } + + protected static function fromParams(?array $params): static + { + if (!isset($params['taskId']) || !\is_string($params['taskId']) || '' === $params['taskId']) { + throw new InvalidArgumentException('Missing or invalid "taskId" parameter for tasks/get.'); + } + + return new self($params['taskId']); + } + + /** + * @return array{taskId: string} + */ + protected function getParams(): array + { + return ['taskId' => $this->taskId]; + } +} diff --git a/src/Schema/Request/TasksUpdateRequest.php b/src/Schema/Request/TasksUpdateRequest.php new file mode 100644 index 00000000..e9f717fe --- /dev/null +++ b/src/Schema/Request/TasksUpdateRequest.php @@ -0,0 +1,63 @@ + + */ +final class TasksUpdateRequest extends Request +{ + /** + * @param array $inputResponses client answers, keyed as the task's inputRequests were + */ + public function __construct( + public readonly string $taskId, + public readonly array $inputResponses = [], + ) { + } + + public static function getMethod(): string + { + return 'tasks/update'; + } + + protected static function fromParams(?array $params): static + { + if (!isset($params['taskId']) || !\is_string($params['taskId']) || '' === $params['taskId']) { + throw new InvalidArgumentException('Missing or invalid "taskId" parameter for tasks/update.'); + } + + $responses = $params['inputResponses'] ?? []; + + if (!\is_array($responses)) { + throw new InvalidArgumentException('Invalid "inputResponses" parameter for tasks/update.'); + } + + return new self($params['taskId'], $responses); + } + + /** + * @return array{taskId: string, inputResponses: array} + */ + protected function getParams(): array + { + return ['taskId' => $this->taskId, 'inputResponses' => $this->inputResponses]; + } +} diff --git a/src/Schema/Result/CreateTaskResult.php b/src/Schema/Result/CreateTaskResult.php new file mode 100644 index 00000000..4bb0eb5c --- /dev/null +++ b/src/Schema/Result/CreateTaskResult.php @@ -0,0 +1,73 @@ + + */ +class CreateTaskResult implements ResultInterface +{ + public function __construct( + public readonly Task $task, + ) { + } + + /** + * @param array $data + * + * @throws InvalidArgumentException when the data is not a task handle + */ + public static function fromArray(array $data): self + { + if (ResultType::Task->value !== ($data['resultType'] ?? null)) { + throw new InvalidArgumentException('Missing or invalid "resultType" in CreateTaskResult data.'); + } + + return new self(Task::fromArray($data)); + } + + /** + * Whether a result is a task handle rather than the answer itself. + * + * @param array $data + */ + public static function describes(array $data): bool + { + return ResultType::Task->value === ($data['resultType'] ?? null); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + // The envelope only: a task that was just created has no result, no + // error and nothing it is waiting for. + return ['resultType' => ResultType::Task->value, ...$this->task->toEnvelope()]; + } +} diff --git a/src/Schema/Result/TaskResult.php b/src/Schema/Result/TaskResult.php new file mode 100644 index 00000000..25468091 --- /dev/null +++ b/src/Schema/Result/TaskResult.php @@ -0,0 +1,49 @@ + + */ +class TaskResult implements ResultInterface +{ + public function __construct( + public readonly Task $task, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self(Task::fromArray($data)); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return $this->task->jsonSerialize(); + } +} diff --git a/src/Schema/Task.php b/src/Schema/Task.php new file mode 100644 index 00000000..5d341a55 --- /dev/null +++ b/src/Schema/Task.php @@ -0,0 +1,271 @@ + + */ +class Task implements \JsonSerializable +{ + /** + * @param string $taskId server-assigned, unique and durable + * @param ?string $statusMessage human-readable progress, e.g. "42 of 100 rows" + * @param ?int $ttlMs how long the task stays readable through `tasks/get`; null means no limit + * @param ?int $pollIntervalMs how long a client should wait between polls + * @param mixed $result what the original request would have returned, once completed + * @param ?Error $error a protocol-level failure, inlined when the status is `failed` + * @param array $inputRequests what the task is waiting for, when the status is `input_required` + */ + public function __construct( + public readonly string $taskId, + public readonly TaskStatus $status = TaskStatus::Working, + public readonly ?\DateTimeImmutable $createdAt = null, + public readonly ?\DateTimeImmutable $lastUpdatedAt = null, + public readonly ?int $ttlMs = null, + public readonly ?int $pollIntervalMs = null, + public readonly ?string $statusMessage = null, + public readonly mixed $result = null, + public readonly ?Error $error = null, + public readonly array $inputRequests = [], + ) { + if ('' === $this->taskId) { + throw new InvalidArgumentException('A task must have a non-empty "taskId".'); + } + + // Both are milliseconds, and both are guidance a client acts on — a + // fractional or negative one is not guidance, it is a bug on the wire. + if (null !== $this->ttlMs && $this->ttlMs <= 0) { + throw new InvalidArgumentException(\sprintf('A task "ttlMs" must be a positive number of milliseconds or null, got %d.', $this->ttlMs)); + } + + if (null !== $this->pollIntervalMs && $this->pollIntervalMs <= 0) { + throw new InvalidArgumentException(\sprintf('A task "pollIntervalMs" must be a positive number of milliseconds, got %d.', $this->pollIntervalMs)); + } + + if (TaskStatus::Failed === $this->status && null === $this->error) { + throw new InvalidArgumentException('A failed task must carry the error that stopped it.'); + } + + if (null !== $this->error && TaskStatus::Failed !== $this->status) { + throw new InvalidArgumentException('Only a failed task carries an "error"; a tool that ran and reported a problem is completed with "isError" on its result.'); + } + } + + /** + * Reads a task back from its wire shape — what {@see self::jsonSerialize()} + * produces, or a superset of it. + * + * @param array $data + * + * @throws InvalidArgumentException when the data does not describe a task + */ + public static function fromArray(array $data): self + { + if (!\is_string($data['taskId'] ?? null) || '' === $data['taskId']) { + throw new InvalidArgumentException('Missing or invalid "taskId" in Task data.'); + } + + $status = TaskStatus::tryFrom(\is_string($data['status'] ?? null) ? $data['status'] : ''); + if (null === $status) { + throw new InvalidArgumentException(\sprintf('Missing or invalid "status" in Task data for task "%s".', $data['taskId'])); + } + + $error = \is_array($data['error'] ?? null) && isset($data['error']['code'], $data['error']['message']) + ? new Error(null, (int) $data['error']['code'], (string) $data['error']['message'], $data['error']['data'] ?? null) + : null; + + return new self( + $data['taskId'], + $status, + \is_string($data['createdAt'] ?? null) ? new \DateTimeImmutable($data['createdAt']) : null, + \is_string($data['lastUpdatedAt'] ?? null) ? new \DateTimeImmutable($data['lastUpdatedAt']) : null, + \is_int($data['ttlMs'] ?? null) ? $data['ttlMs'] : null, + \is_int($data['pollIntervalMs'] ?? null) ? $data['pollIntervalMs'] : null, + \is_string($data['statusMessage'] ?? null) ? $data['statusMessage'] : null, + $data['result'] ?? null, + $error, + self::inputRequestsFrom($data['inputRequests'] ?? []), + ); + } + + /** + * The `inputRequests` map, from bare method/params pairs or full envelopes. + * + * @return array + */ + private static function inputRequestsFrom(mixed $raw): array + { + if (!\is_array($raw)) { + return []; + } + + $requests = []; + + foreach ($raw as $key => $envelope) { + if (!\is_array($envelope) || !\is_string($envelope['method'] ?? null)) { + continue; + } + + $class = match ($envelope['method']) { + ElicitRequest::getMethod() => ElicitRequest::class, + CreateSamplingMessageRequest::getMethod() => CreateSamplingMessageRequest::class, + ListRootsRequest::getMethod() => ListRootsRequest::class, + default => null, + }; + + if (null === $class) { + continue; + } + + // On the wire the pair has no id — the map key is what answers are + // keyed by — so one is supplied to satisfy the message parser. + $requests[(string) $key] = $class::fromArray($envelope + ['jsonrpc' => MessageInterface::JSONRPC_VERSION, 'id' => 0]); + } + + return $requests; + } + + /** + * A copy with a new status and whatever that status carries. + * + * @param array $inputRequests + */ + public function with( + TaskStatus $status, + mixed $result = null, + ?Error $error = null, + array $inputRequests = [], + ?string $statusMessage = null, + ?\DateTimeImmutable $now = null, + ): self { + return new self( + $this->taskId, + $status, + $this->createdAt, + $now ?? new \DateTimeImmutable(), + $this->ttlMs, + $this->pollIntervalMs, + $statusMessage ?? $this->statusMessage, + $result ?? $this->result, + $error ?? $this->error, + [] !== $inputRequests ? $inputRequests : $this->inputRequests, + ); + } + + /** + * Whether the task is still readable, given its TTL. + */ + public function isReadable(?\DateTimeImmutable $now = null): bool + { + if (null === $this->ttlMs || null === $this->createdAt) { + return true; + } + + $expiresAt = $this->createdAt->modify(\sprintf('+%d milliseconds', $this->ttlMs)); + + return ($now ?? new \DateTimeImmutable()) < $expiresAt; + } + + /** + * The task's own fields, without anything only a detailed view carries. + * + * @return array + */ + public function toEnvelope(): array + { + $data = [ + 'taskId' => $this->taskId, + 'status' => $this->status->value, + ]; + + if (null !== $this->createdAt) { + $data['createdAt'] = $this->createdAt->format(\DATE_ATOM); + } + + if (null !== $this->lastUpdatedAt) { + $data['lastUpdatedAt'] = $this->lastUpdatedAt->format(\DATE_ATOM); + } + + // Emitted even as null: absent would mean "the server said nothing + // about how long this lives", and null means "as long as you like". + $data['ttlMs'] = $this->ttlMs; + + if (null !== $this->pollIntervalMs) { + $data['pollIntervalMs'] = $this->pollIntervalMs; + } + + if (null !== $this->statusMessage) { + $data['statusMessage'] = $this->statusMessage; + } + + return $data; + } + + /** + * The envelope plus whatever the current status carries. + * + * @return array + */ + public function jsonSerialize(): array + { + $data = $this->toEnvelope(); + + if (TaskStatus::Completed === $this->status) { + $data['result'] = $this->result; + } + + if (null !== $this->error) { + $error = ['code' => $this->error->code, 'message' => $this->error->message]; + + if (null !== $this->error->data) { + $error['data'] = $this->error->data; + } + + $data['error'] = $error; + } + + if ([] !== $this->inputRequests) { + $requests = []; + foreach ($this->inputRequests as $key => $request) { + // Values are bare method/params pairs, not messages: the client + // keys answers by the map key. getParams() is protected, so the + // envelope is built with a throwaway id and then discarded. + $params = $request->withId(0)->jsonSerialize()['params'] ?? null; + + $requests[$key] = [ + 'method' => $request::getMethod(), + // An empty PHP array would encode as `[]`, not `{}`. + 'params' => [] === $params || null === $params ? new \stdClass() : $params, + ]; + } + $data['inputRequests'] = $requests; + } + + return $data; + } +} diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 431fe548..feb30c64 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -15,6 +15,8 @@ use Mcp\Capability\Discovery\CachedDiscoverer; use Mcp\Capability\Discovery\Discoverer; use Mcp\Capability\Discovery\DiscovererInterface; +use Mcp\Capability\Discovery\DocBlockParser; +use Mcp\Capability\Discovery\SchemaGenerator; use Mcp\Capability\Discovery\SchemaGeneratorInterface; use Mcp\Capability\Registry; use Mcp\Capability\Registry\Container; @@ -33,6 +35,7 @@ use Mcp\Schema\Annotations; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Extension\AbstractExtension; +use Mcp\Schema\Extension\ArgumentProvidingExtensionInterface; use Mcp\Schema\Extension\ExtensionInterface; use Mcp\Schema\Icon; use Mcp\Schema\Implementation; @@ -53,6 +56,7 @@ use Mcp\Server\Resource\SessionSubscriptionManager; use Mcp\Server\Resource\SubscriptionManagerInterface; use Mcp\Server\Session\InMemorySessionStore; +use Mcp\Server\Session\SessionInterface; use Mcp\Server\Session\SessionManager; use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Session\SessionStoreInterface; @@ -218,6 +222,9 @@ final class Builder /** @var list|class-string<\Mcp\Schema\JsonRpc\Notification>> */ private array $extensionMessages = []; + /** @var array */ + private array $extensionArgumentProviders = []; + /** * @var LoaderInterface[] */ @@ -302,6 +309,10 @@ public function enableExtension(ExtensionInterface ...$extensions): self $this->extensions[$id] = $extension->getCapabilities(); + if ($extension instanceof ArgumentProvidingExtensionInterface) { + $this->extensionArgumentProviders += $extension->getArgumentProviders(); + } + // Without this the method cannot be decoded at all, so nothing // downstream ever sees it. foreach ($extension->getMessages() as $message) { @@ -701,7 +712,7 @@ public function build(): Server $this->explicitResourceTemplates, $this->explicitPrompts, ), - new ReflectedElementLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), + new ReflectedElementLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator($logger)), ]; if (null !== $this->discoveryBasePath) { @@ -747,7 +758,7 @@ public function build(): Server $serverInfo = $this->serverInfo ?? new Implementation(); $configuration = new Configuration($serverInfo, $capabilities, $this->paginationLimit, $this->instructions, $this->protocolVersion); - $referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container); + $referenceHandler = $this->referenceHandler ?? new ReferenceHandler($container, $this->extensionArgumentProviders); $requestHandlers = array_merge($this->requestHandlers, [ new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger), @@ -824,9 +835,23 @@ private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLo ); } + /** + * The configured generator, or one that leaves the parameter types + * extensions inject out of the schemas — null when nothing needs that, + * so the loaders keep their own defaults. + */ + private function schemaGenerator(LoggerInterface $logger): ?SchemaGeneratorInterface + { + if (null !== $this->schemaGenerator || [] === $this->extensionArgumentProviders) { + return $this->schemaGenerator; + } + + return new SchemaGenerator(new DocBlockParser(logger: $logger), array_keys($this->extensionArgumentProviders)); + } + private function createDiscoverer(LoggerInterface $logger): DiscovererInterface { - $discoverer = new Discoverer($logger, null, $this->schemaGenerator); + $discoverer = new Discoverer($logger, null, $this->schemaGenerator($logger)); if (null !== $this->discoveryCache) { return new CachedDiscoverer($discoverer, $this->discoveryCache, $logger); diff --git a/src/Server/Handler/Request/CallToolHandler.php b/src/Server/Handler/Request/CallToolHandler.php index 3d43d0ac..5d832031 100644 --- a/src/Server/Handler/Request/CallToolHandler.php +++ b/src/Server/Handler/Request/CallToolHandler.php @@ -14,12 +14,14 @@ use Mcp\Capability\Discovery\SchemaValidator; use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; +use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; use Mcp\Server\RequestContext; @@ -28,7 +30,7 @@ use Psr\Log\NullLogger; /** - * @implements RequestHandlerInterface + * @implements RequestHandlerInterface * * @author Christopher Hertel * @author Tobias Nyholm @@ -52,7 +54,7 @@ public function supports(Request $request): bool } /** - * @return Response|Error + * @return Response|Error */ public function handle(Request $request, SessionInterface $session): Response|Error { @@ -98,6 +100,12 @@ public function handle(Request $request, SessionInterface $session): Response|Er try { $result = $this->referenceHandler->handle($reference, $arguments); + // A handler that built a whole result of another kind — an + // extension's, say — keeps what it decided; it is not tool output. + if ($result instanceof ResultInterface && !$result instanceof CallToolResult) { + return new Response($request->getId(), $result); + } + $protocolVersion = $context->getProtocolVersion(); $structuredContent = null; @@ -145,6 +153,8 @@ public function handle(Request $request, SessionInterface $session): Response|Er $errorContent = [new TextContent($e->getMessage())]; return new Response($request->getId(), CallToolResult::error($errorContent)); + } catch (MissingRequiredClientCapabilityException $e) { + return $e->toError($request->getId()); } catch (\Throwable $e) { $this->logger->error('Unhandled error during tool execution', [ 'name' => $toolName, diff --git a/src/Server/Handler/Request/GetPromptHandler.php b/src/Server/Handler/Request/GetPromptHandler.php index 745b9b86..b297827a 100644 --- a/src/Server/Handler/Request/GetPromptHandler.php +++ b/src/Server/Handler/Request/GetPromptHandler.php @@ -13,11 +13,13 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\RegistryInterface; +use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\Exception\PromptGetException; use Mcp\Exception\PromptNotFoundException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Result\GetPromptResult; use Mcp\Server\Session\SessionInterface; @@ -25,7 +27,7 @@ use Psr\Log\NullLogger; /** - * @implements RequestHandlerInterface + * @implements RequestHandlerInterface * * @author Tobias Nyholm */ @@ -44,7 +46,7 @@ public function supports(Request $request): bool } /** - * @return Response|Error + * @return Response|Error */ public function handle(Request $request, SessionInterface $session): Response|Error { @@ -61,6 +63,12 @@ public function handle(Request $request, SessionInterface $session): Response|Er $result = $this->referenceHandler->handle($reference, $arguments); + // A handler that built a whole result of another kind — an + // extension's, say — keeps what it decided; it is not prompt messages. + if ($result instanceof ResultInterface) { + return new Response($request->getId(), $result); + } + $formatted = $reference->formatResult($result); return new Response($request->getId(), new GetPromptResult($formatted)); @@ -72,6 +80,8 @@ public function handle(Request $request, SessionInterface $session): Response|Er $this->logger->error('Prompt not found', ['prompt_name' => $promptName, 'exception' => $e]); return Error::forResourceNotFound($e->getMessage(), $request->getId()); + } catch (MissingRequiredClientCapabilityException $e) { + return $e->toError($request->getId()); } catch (\Throwable $e) { $this->logger->error(\sprintf('Unexpected error while handling prompt "%s": "%s".', $promptName, $e->getMessage()), ['exception' => $e]); diff --git a/src/Server/Handler/Request/ReadResourceHandler.php b/src/Server/Handler/Request/ReadResourceHandler.php index a9551eff..24e8622a 100644 --- a/src/Server/Handler/Request/ReadResourceHandler.php +++ b/src/Server/Handler/Request/ReadResourceHandler.php @@ -14,11 +14,13 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ResourceTemplateReference; use Mcp\Capability\RegistryInterface; +use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\Exception\ResourceNotFoundException; use Mcp\Exception\ResourceReadException; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\Request\ReadResourceRequest; use Mcp\Schema\Result\ReadResourceResult; use Mcp\Server\Session\SessionInterface; @@ -26,7 +28,7 @@ use Psr\Log\NullLogger; /** - * @implements RequestHandlerInterface + * @implements RequestHandlerInterface * * @author Tobias Nyholm */ @@ -45,7 +47,7 @@ public function supports(Request $request): bool } /** - * @return Response|Error + * @return Response|Error */ public function handle(Request $request, SessionInterface $session): Response|Error { @@ -69,9 +71,23 @@ public function handle(Request $request, SessionInterface $session): Response|Er $arguments = array_merge($arguments, $variables); $result = $this->referenceHandler->handle($reference, $arguments); + + // A handler that built a whole result — its own ReadResourceResult, + // or another kind, an extension's say — keeps what it decided. + if ($result instanceof ResultInterface) { + return new Response($request->getId(), $result); + } + $formatted = $reference->formatResult($result, $uri, $reference->resourceTemplate->mimeType); } else { $result = $this->referenceHandler->handle($reference, $arguments); + + // A handler that built a whole result — its own ReadResourceResult, + // or another kind, an extension's say — keeps what it decided. + if ($result instanceof ResultInterface) { + return new Response($request->getId(), $result); + } + $formatted = $reference->formatResult($result, $uri, $reference->resource->mimeType); } @@ -84,6 +100,8 @@ public function handle(Request $request, SessionInterface $session): Response|Er $this->logger->error('Resource not found', ['uri' => $uri, 'exception' => $e]); return Error::forResourceNotFound($e->getMessage(), $request->getId()); + } catch (MissingRequiredClientCapabilityException $e) { + return $e->toError($request->getId()); } catch (\Throwable $e) { $this->logger->error(\sprintf('Unexpected error while reading resource "%s": "%s".', $uri, $e->getMessage()), ['exception' => $e]); diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index a6e9f1ab..90ca4b9c 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -16,6 +16,7 @@ use Mcp\Event\RequestEvent; use Mcp\Event\ResponseEvent; use Mcp\Exception\InvalidInputMessageException; +use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Notification; @@ -302,6 +303,12 @@ private function handleRequest(TransportInterface $transport, Request $request, } $this->sendResponse($transport, $finalResult, $session); + } catch (MissingRequiredClientCapabilityException $e) { + $error = $e->toError($request->getId()); + $errorEvent = $this->dispatchEvent(new ErrorEvent($error, $request, $session, $e)); + $error = $errorEvent->getError(); + + $this->sendResponse($transport, $error, $session); } catch (\InvalidArgumentException $e) { $this->logger->warning(\sprintf('Invalid argument: %s', $e->getMessage()), ['exception' => $e]); diff --git a/src/Server/Task/Handler/TasksCancelHandler.php b/src/Server/Task/Handler/TasksCancelHandler.php new file mode 100644 index 00000000..9607116b --- /dev/null +++ b/src/Server/Task/Handler/TasksCancelHandler.php @@ -0,0 +1,72 @@ + + * + * @author Christopher Hertel + */ +final class TasksCancelHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly TaskStoreInterface $store, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof TasksCancelRequest; + } + + public function handle(Request $request, SessionInterface $session): Response|Error + { + \assert($request instanceof TasksCancelRequest); + + // The task surface is gated on negotiation: to a client that never + // declared the extension these methods do not exist as *its* methods, + // and -32021 says which declaration is missing. + if (null !== $refusal = TaskCapabilityGuard::refuse($request, $session)) { + return $refusal; + } + + $task = $this->store->get($request->taskId); + + if (null === $task) { + return Error::forInvalidParams(\sprintf('Unknown task "%s".', $request->taskId), $request->getId(), ['taskId' => $request->taskId]); + } + + if (!$task->status->isTerminal()) { + $this->store->save($task->with(TaskStatus::Cancelled)); + } + + return new Response($request->getId(), new EmptyResult()); + } +} diff --git a/src/Server/Task/Handler/TasksGetHandler.php b/src/Server/Task/Handler/TasksGetHandler.php new file mode 100644 index 00000000..6a670113 --- /dev/null +++ b/src/Server/Task/Handler/TasksGetHandler.php @@ -0,0 +1,65 @@ + + * + * @author Christopher Hertel + */ +final class TasksGetHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly TaskStoreInterface $store, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof TasksGetRequest; + } + + public function handle(Request $request, SessionInterface $session): Response|Error + { + \assert($request instanceof TasksGetRequest); + + // The task surface is gated on negotiation: to a client that never + // declared the extension these methods do not exist as *its* methods, + // and -32021 says which declaration is missing. + if (null !== $refusal = TaskCapabilityGuard::refuse($request, $session)) { + return $refusal; + } + + $task = $this->store->get($request->taskId); + + if (null === $task) { + // An unknown id and a lapsed one are the same answer: the spec + // reserves -32602 for exactly this, and a client cannot act on the + // difference. + return Error::forInvalidParams(\sprintf('Unknown task "%s".', $request->taskId), $request->getId(), ['taskId' => $request->taskId]); + } + + return new Response($request->getId(), new TaskResult($task)); + } +} diff --git a/src/Server/Task/Handler/TasksUpdateHandler.php b/src/Server/Task/Handler/TasksUpdateHandler.php new file mode 100644 index 00000000..3b053f52 --- /dev/null +++ b/src/Server/Task/Handler/TasksUpdateHandler.php @@ -0,0 +1,80 @@ + + * + * @author Christopher Hertel + */ +final class TasksUpdateHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly TaskStoreInterface $store, + private readonly ?TaskInputHandlerInterface $inputHandler = null, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof TasksUpdateRequest; + } + + public function handle(Request $request, SessionInterface $session): Response|Error + { + \assert($request instanceof TasksUpdateRequest); + + // The task surface is gated on negotiation: to a client that never + // declared the extension these methods do not exist as *its* methods, + // and -32021 says which declaration is missing. + if (null !== $refusal = TaskCapabilityGuard::refuse($request, $session)) { + return $refusal; + } + + $task = $this->store->get($request->taskId); + + if (null === $task) { + return Error::forInvalidParams(\sprintf('Unknown task "%s".', $request->taskId), $request->getId(), ['taskId' => $request->taskId]); + } + + // Answers to something already finished, or never asked for, are + // ignored rather than refused: the spec says to drop responses for + // unknown or already-satisfied keys, and a terminal task is the same + // case one step further along. + if (TaskStatus::InputRequired === $task->status) { + $updated = $this->inputHandler?->receive($task, $request->inputResponses) + ?? $task->with(TaskStatus::Working, statusMessage: 'Input received.'); + + $this->store->save($updated); + } + + return new Response($request->getId(), new EmptyResult()); + } +} diff --git a/src/Server/Task/InMemoryTaskStore.php b/src/Server/Task/InMemoryTaskStore.php new file mode 100644 index 00000000..c9191526 --- /dev/null +++ b/src/Server/Task/InMemoryTaskStore.php @@ -0,0 +1,84 @@ + + */ +final class InMemoryTaskStore implements TaskStoreInterface +{ + /** @var array */ + private array $tasks = []; + + private readonly ClockInterface $clock; + + /** + * @param int $limit how many tasks to keep before dropping the oldest, so an unbounded + * run of task creation cannot exhaust memory + */ + public function __construct( + private readonly int $limit = 1000, + ?ClockInterface $clock = null, + ) { + if ($this->limit < 1) { + throw new InvalidArgumentException(\sprintf('A task store must hold at least one task, got %d.', $this->limit)); + } + + $this->clock = $clock ?? new NativeClock(); + } + + public function save(Task $task): void + { + // Re-inserted rather than updated in place, so the eviction order below + // reflects last use and a task being polled is not the one dropped. + unset($this->tasks[$task->taskId]); + $this->tasks[$task->taskId] = $task; + + while (\count($this->tasks) > $this->limit) { + array_shift($this->tasks); + } + } + + public function get(string $taskId): ?Task + { + $task = $this->tasks[$taskId] ?? null; + + if (null === $task) { + return null; + } + + if (!$task->isReadable($this->clock->now())) { + unset($this->tasks[$taskId]); + + return null; + } + + return $task; + } + + public function delete(string $taskId): void + { + unset($this->tasks[$taskId]); + } +} diff --git a/src/Server/Task/Psr16TaskStore.php b/src/Server/Task/Psr16TaskStore.php new file mode 100644 index 00000000..3b0bedf4 --- /dev/null +++ b/src/Server/Task/Psr16TaskStore.php @@ -0,0 +1,119 @@ + + */ +final class Psr16TaskStore implements TaskStoreInterface +{ + private readonly ClockInterface $clock; + + /** + * @param string $prefix namespace for this store's keys, so one cache can carry several + * @param int $defaultTtl seconds a task with no `ttlMs` of its own is kept + */ + public function __construct( + private readonly CacheInterface $cache, + private readonly string $prefix = 'mcp.tasks.', + private readonly int $defaultTtl = 3600, + private readonly LoggerInterface $logger = new NullLogger(), + ?ClockInterface $clock = null, + ) { + $this->clock = $clock ?? new NativeClock(); + } + + public function save(Task $task): void + { + $ttl = null !== $task->ttlMs + ? max(1, (int) ceil($task->ttlMs / 1000)) + : $this->defaultTtl; + + $this->cache->set($this->prefix.$task->taskId, json_encode(self::toArray($task), \JSON_THROW_ON_ERROR), $ttl); + } + + public function get(string $taskId): ?Task + { + $raw = $this->cache->get($this->prefix.$taskId); + + if (!\is_string($raw)) { + return null; + } + + try { + /** @var array $data */ + $data = json_decode($raw, true, flags: \JSON_THROW_ON_ERROR); + $task = Task::fromArray($data); + } catch (\Throwable $e) { + // Written by an older shape, or corrupted. Unreadable and expired + // look the same to a client, and neither is worth failing on. + $this->logger->warning('Dropped an unreadable task from the store.', ['taskId' => $taskId, 'exception' => $e]); + + return null; + } + + // The cache TTL is coarser than the task's own — it is whole seconds — + // so the task still has the last word on whether it is readable. + return $task->isReadable($this->clock->now()) ? $task : null; + } + + public function delete(string $taskId): void + { + $this->cache->delete($this->prefix.$taskId); + } + + /** + * @return array + */ + private static function toArray(Task $task): array + { + return [ + 'taskId' => $task->taskId, + 'status' => $task->status->value, + 'createdAt' => $task->createdAt?->format(\DATE_ATOM), + 'lastUpdatedAt' => $task->lastUpdatedAt?->format(\DATE_ATOM), + 'ttlMs' => $task->ttlMs, + 'pollIntervalMs' => $task->pollIntervalMs, + 'statusMessage' => $task->statusMessage, + 'result' => $task->result, + 'error' => null !== $task->error + ? ['code' => $task->error->code, 'message' => $task->error->message, 'data' => $task->error->data] + : null, + // Kept as the envelopes they go out as, so a parked task still + // knows what it is waiting for after a round trip through storage. + 'inputRequests' => array_map( + static fn (Request $request): array => $request->withId(0)->jsonSerialize(), + $task->inputRequests, + ), + ]; + } +} diff --git a/src/Server/Task/TaskCapabilityGuard.php b/src/Server/Task/TaskCapabilityGuard.php new file mode 100644 index 00000000..6f258557 --- /dev/null +++ b/src/Server/Task/TaskCapabilityGuard.php @@ -0,0 +1,61 @@ + + */ +final class TaskCapabilityGuard +{ + /** + * The refusal to return, or null when the client declared the extension. + */ + public static function refuse(Request $request, SessionInterface $session): ?Error + { + if (self::declared($session)) { + return null; + } + + return Error::forMissingRequiredClientCapability( + \sprintf('The "%s" methods need the extension the client did not declare.', TasksExtension::ID), + self::required(), + $request->getId(), + ); + } + + /** + * Whether the client declared the extension during `initialize`. + */ + public static function declared(SessionInterface $session): bool + { + $capabilities = (array) $session->get('client_capabilities', []); + $extensions = $capabilities['extensions'] ?? null; + + return TasksExtension::declaredBy(null === $extensions ? null : (array) $extensions); + } + + private static function required(): ClientCapabilities + { + return new ClientCapabilities(roots: false, extensions: [TasksExtension::ID => new \stdClass()]); + } +} diff --git a/src/Server/Task/TaskContext.php b/src/Server/Task/TaskContext.php new file mode 100644 index 00000000..22247de5 --- /dev/null +++ b/src/Server/Task/TaskContext.php @@ -0,0 +1,116 @@ +isSupported()) { + * return runSynchronously(); + * } + * + * $created = $tasks->create(pollIntervalMs: 1000); + * $queue->push($created->task->taskId); + * + * return $created; + * } + * ``` + * + * @author Christopher Hertel + */ +final class TaskContext +{ + public function __construct( + private readonly SessionInterface $session, + private readonly TaskStoreInterface $store, + ) { + } + + /** + * Whether this client declared the extension during `initialize`. + * + * A handler that can work either way must ask before creating a task: a + * client that did not declare it has no polling loop, and the specification + * says such a request falls through to running synchronously rather than + * failing. + */ + public function isSupported(): bool + { + return TaskCapabilityGuard::declared($this->session); + } + + /** + * Creates a task and stores it, returning the result to hand back. + * + * The handler's own work is not started here — that is the application's, + * and in PHP it usually means enqueueing something a worker picks up. What + * this guarantees is the part the specification cares about: the task is + * durably stored *before* its id reaches the client, so the first + * `tasks/get` cannot arrive before the task exists. + * + * @param ?int $ttlMs how long the task stays readable; null for no limit + * @param ?int $pollIntervalMs how long the client should wait between polls + * + * @throws MissingRequiredClientCapabilityException when the client did not declare the extension — it has no + * `tasks/get` loop, and a handle it will never poll is worse + * than a slow answer; the server answers `-32021` instead + */ + public function create( + ?string $taskId = null, + ?int $ttlMs = 3_600_000, + ?int $pollIntervalMs = 1000, + ?string $statusMessage = null, + ): CreateTaskResult { + if (!$this->isSupported()) { + throw new MissingRequiredClientCapabilityException(new ClientCapabilities(roots: false, extensions: [TasksExtension::ID => new \stdClass()]), \sprintf('This request is served as a task, which needs the "%s" extension the client did not declare.', TasksExtension::ID)); + } + + $now = new \DateTimeImmutable(); + + $task = new Task( + $taskId ?? Uuid::v4()->toRfc4122(), + TaskStatus::Working, + $now, + $now, + $ttlMs, + $pollIntervalMs, + $statusMessage, + ); + + $this->store->save($task); + + return new CreateTaskResult($task); + } + + /** + * The store this request is served with, for a handler that needs to + * advance a task it did not create here. + */ + public function getStore(): TaskStoreInterface + { + return $this->store; + } +} diff --git a/src/Server/Task/TaskInputHandlerInterface.php b/src/Server/Task/TaskInputHandlerInterface.php new file mode 100644 index 00000000..0171f4e0 --- /dev/null +++ b/src/Server/Task/TaskInputHandlerInterface.php @@ -0,0 +1,35 @@ + + */ +interface TaskInputHandlerInterface +{ + /** + * Applies $inputResponses to $task and returns its new state. + * + * Keys match the task's `inputRequests`. Anything unrecognized should be + * ignored rather than refused. + * + * @param array $inputResponses + */ + public function receive(Task $task, array $inputResponses): Task; +} diff --git a/src/Server/Task/TaskStoreInterface.php b/src/Server/Task/TaskStoreInterface.php new file mode 100644 index 00000000..b4b9adcd --- /dev/null +++ b/src/Server/Task/TaskStoreInterface.php @@ -0,0 +1,46 @@ + + */ +interface TaskStoreInterface +{ + /** + * Stores a task, overwriting any earlier state under the same id. + */ + public function save(Task $task): void; + + /** + * The task, or null when there is no such id — or when its TTL has lapsed, + * which a client cannot tell apart and does not need to. + */ + public function get(string $taskId): ?Task; + + /** + * Drops a task before its TTL, if the store supports it. + */ + public function delete(string $taskId): void; +} diff --git a/src/Server/Task/TasksExtension.php b/src/Server/Task/TasksExtension.php new file mode 100644 index 00000000..26b52c4e --- /dev/null +++ b/src/Server/Task/TasksExtension.php @@ -0,0 +1,115 @@ +enableExtension(new TasksExtension(new InMemoryTaskStore())); + * Client::builder()->enableExtension(new TasksExtension()); + * ``` + * + * Creating a task is the *server's* decision, made per request — a client opts + * in once by declaring the extension and then handles whichever result shape + * arrives. A handler creates one through the {@see TaskContext} it can declare + * as a parameter, and returns the {@see \Mcp\Schema\Result\CreateTaskResult}. + * + * @author Christopher Hertel + */ +final class TasksExtension implements ArgumentProvidingExtensionInterface +{ + public const ID = 'io.modelcontextprotocol/tasks'; + + /** + * @param ?TaskStoreInterface $store where a server keeps its tasks; a client declaring the extension needs none + */ + public function __construct( + private readonly ?TaskStoreInterface $store = null, + private readonly ?TaskInputHandlerInterface $inputHandler = null, + ) { + } + + public function getId(): ExtensionIdentifier + { + return new ExtensionIdentifier(self::ID); + } + + public function getCapabilities(): array + { + return []; + } + + public function getMessages(): array + { + return [ + TasksGetRequest::class, + TasksUpdateRequest::class, + TasksCancelRequest::class, + ]; + } + + public function getRequestHandlers(): iterable + { + $store = $this->getStore(); + + yield new TasksGetHandler($store); + yield new TasksUpdateHandler($store, $this->inputHandler); + yield new TasksCancelHandler($store); + } + + public function getArgumentProviders(): array + { + $store = $this->getStore(); + + return [ + TaskContext::class => static fn (SessionInterface $session, Request $request): TaskContext => new TaskContext($session, $store), + ]; + } + + /** + * @throws LogicException when enabled on a server without a store + */ + public function getStore(): TaskStoreInterface + { + return $this->store ?? throw new LogicException(\sprintf('The Tasks extension needs a store to serve tasks/*; enable it with new %s(new InMemoryTaskStore()) or another %s.', self::class, TaskStoreInterface::class)); + } + + /** + * Whether a client's declared capabilities include this extension. + * + * The gate on task creation: a client that never declared it has no + * `tasks/get` loop and would be left holding a handle it does not know how + * to redeem. + * + * @param ?array $extensions the `extensions` member of the request's clientCapabilities + */ + public static function declaredBy(?array $extensions): bool + { + return \array_key_exists(self::ID, $extensions ?? []); + } +} diff --git a/tests/Integration/Fixture/tasks.php b/tests/Integration/Fixture/tasks.php new file mode 100644 index 00000000..8c421067 --- /dev/null +++ b/tests/Integration/Fixture/tasks.php @@ -0,0 +1,152 @@ +> what each task becomes on its next polls */ + private array $plans = []; + + public function __construct(private readonly TaskStoreInterface $inner) + { + } + + /** + * @param list $states + */ + public function plan(string $taskId, array $states): void + { + $this->plans[$taskId] = $states; + } + + public function save(Task $task): void + { + $this->inner->save($task); + } + + public function get(string $taskId): ?Task + { + $task = $this->inner->get($taskId); + + if (null === $task || $task->status->isTerminal() || [] === ($this->plans[$taskId] ?? [])) { + return $task; + } + + $next = array_shift($this->plans[$taskId]); + $this->inner->save($next); + + return $next; + } + + public function delete(string $taskId): void + { + $this->inner->delete($taskId); + } + + public function receive(Task $task, array $inputResponses): Task + { + $name = $inputResponses['name']['content']['name'] ?? 'nobody'; + + return $task->with(TaskStatus::Completed, result: new CallToolResult([new TextContent('Hello, '.$name)])); + } +}; + +Server::builder() + ->setServerInfo('integration-server', '1.0.0') + ->enableExtension(new TasksExtension($store, $store)) + ->addTool( + static function (TaskContext $tasks) use ($store): CreateTaskResult|string { + if (!$tasks->isSupported()) { + return 'sync'; + } + + $created = $tasks->create(pollIntervalMs: 10, statusMessage: 'queued'); + $store->plan($created->task->taskId, [ + $created->task->with(TaskStatus::Working, statusMessage: 'halfway'), + $created->task->with(TaskStatus::Completed, result: new CallToolResult([new TextContent('done')])), + ]); + + return $created; + }, + name: 'long_job', + description: 'Runs as a task when the client can poll, synchronously otherwise.', + ) + ->addTool( + static fn (TaskContext $tasks): CreateTaskResult => $tasks->create(pollIntervalMs: 10), + name: 'task_only', + description: 'Always creates a task, even for a client that cannot poll.', + ) + ->addTool( + static function (TaskContext $tasks) use ($store): CreateTaskResult { + $created = $tasks->create(pollIntervalMs: 10); + $store->plan($created->task->taskId, [ + $created->task->with(TaskStatus::InputRequired, inputRequests: [ + 'name' => new ElicitRequest('What is your name?', new ElicitationSchema(['name' => new StringSchemaDefinition('Name')])), + ]), + ]); + + return $created; + }, + name: 'ask_job', + description: 'Parks for the client\'s name, then greets.', + ) + ->addTool( + static function (TaskContext $tasks) use ($store): CreateTaskResult { + $created = $tasks->create(pollIntervalMs: 10); + $store->plan($created->task->taskId, [$created->task->with(TaskStatus::Failed, error: Error::forInternalError('the worker died'))]); + + return $created; + }, + name: 'failing_job', + description: 'Fails at the protocol level.', + ) + ->addPrompt( + static function (TaskContext $tasks) use ($store): CreateTaskResult { + $created = $tasks->create(pollIntervalMs: 10); + $store->plan($created->task->taskId, [ + $created->task->with(TaskStatus::Completed, result: new GetPromptResult([new PromptMessage(Role::User, new TextContent('prompt done'))])), + ]); + + return $created; + }, + name: 'slow_prompt', + description: 'A prompt served as a task.', + ) + ->build() + ->run(new StdioTransport()); diff --git a/tests/Integration/TasksTest.php b/tests/Integration/TasksTest.php new file mode 100644 index 00000000..13fc3587 --- /dev/null +++ b/tests/Integration/TasksTest.php @@ -0,0 +1,207 @@ +connectDeclaring(); + + $result = $client->callTool('long_job'); + + $this->assertInstanceOf(CreateTaskResult::class, $result); + $this->assertSame(TaskStatus::Working, $result->task->status); + $this->assertSame('queued', $result->task->statusMessage); + $this->assertSame(10, $result->task->pollIntervalMs); + $this->assertNotNull($result->task->createdAt); + } + + #[TestDox('a task is polled to completion and carries the tool result')] + public function testTaskIsPolledToCompletion(): void + { + $client = $this->connectDeclaring(); + $created = $client->callTool('long_job'); + $this->assertInstanceOf(CreateTaskResult::class, $created); + + $polled = $client->get($created->task->taskId); + $this->assertSame(TaskStatus::Working, $polled->status); + $this->assertSame('halfway', $polled->statusMessage); + + $done = $this->settle($client, $created->task->taskId); + $this->assertSame(TaskStatus::Completed, $done->status); + $this->assertNull($done->error); + + $result = CallToolResult::fromArray($done->result); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('done', $result->content[0]->text); + } + + #[TestDox('a client that did not declare the extension gets the synchronous answer')] + public function testUndeclaredClientIsServedSynchronously(): void + { + $client = $this->connect('tasks'); + + $result = $client->callTool('long_job'); + + $this->assertInstanceOf(CallToolResult::class, $result); + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('sync', $result->content[0]->text); + } + + #[TestDox('a task-only tool refuses a client that cannot poll with -32021')] + public function testUndeclaredClientIsRefusedARequiredTask(): void + { + $client = $this->connect('tasks'); + + try { + $client->callTool('task_only'); + $this->fail('Expected the task to be refused.'); + } catch (RequestException $e) { + $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $e->getError()?->code); + $this->assertArrayHasKey(TasksExtension::ID, $e->getError()->data['requiredCapabilities']['extensions']); + } + } + + #[TestDox('the tasks/* methods themselves are refused to a client that did not declare the extension')] + public function testUndeclaredClientCannotPoll(): void + { + $client = new TaskClient($this->connect('tasks')); + + try { + $client->get('anything'); + $this->fail('Expected tasks/get to be refused.'); + } catch (RequestException $e) { + $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $e->getError()?->code); + } + } + + #[TestDox('a parked task exposes what it asks for, and an answer through tasks/update completes it')] + public function testInputRequiredRoundTrip(): void + { + $client = $this->connectDeclaring(); + $created = $client->callTool('ask_job'); + $this->assertInstanceOf(CreateTaskResult::class, $created); + + $parked = $client->get($created->task->taskId); + $this->assertSame(TaskStatus::InputRequired, $parked->status); + $this->assertArrayHasKey('name', $parked->inputRequests); + $this->assertInstanceOf(ElicitRequest::class, $parked->inputRequests['name']); + $this->assertSame('What is your name?', $parked->inputRequests['name']->message); + + $client->update($created->task->taskId, ['name' => ['action' => 'accept', 'content' => ['name' => 'Ada']]]); + + $done = $client->get($created->task->taskId); + $this->assertSame(TaskStatus::Completed, $done->status); + $this->assertSame('Hello, Ada', CallToolResult::fromArray($done->result)->content[0]->text ?? null); + } + + #[TestDox('a failed task carries the error inline and no result')] + public function testFailedTask(): void + { + $client = $this->connectDeclaring(); + $created = $client->callTool('failing_job'); + $this->assertInstanceOf(CreateTaskResult::class, $created); + + $failed = $this->settle($client, $created->task->taskId); + + $this->assertSame(TaskStatus::Failed, $failed->status); + $this->assertNull($failed->result); + $this->assertSame(Error::INTERNAL_ERROR, $failed->error?->code); + $this->assertSame('the worker died', $failed->error->message); + } + + #[TestDox('cancelling is cooperative and idempotent')] + public function testCancel(): void + { + $client = $this->connectDeclaring(); + $created = $client->callTool('task_only'); + $this->assertInstanceOf(CreateTaskResult::class, $created); + + $client->cancel($created->task->taskId); + $this->assertSame(TaskStatus::Cancelled, $client->get($created->task->taskId)->status); + + $client->cancel($created->task->taskId); + $this->assertSame(TaskStatus::Cancelled, $client->get($created->task->taskId)->status); + } + + #[TestDox('an unknown task is invalid params, not a missing method')] + public function testUnknownTask(): void + { + $client = $this->connectDeclaring(); + + try { + $client->get('no-such-task'); + $this->fail('Expected tasks/get to fail.'); + } catch (RequestException $e) { + $this->assertSame(Error::INVALID_PARAMS, $e->getError()?->code); + } + } + + #[TestDox('a prompt can be served as a task too')] + public function testPromptAsTask(): void + { + $client = $this->connectDeclaring(); + $created = $client->getPrompt('slow_prompt'); + $this->assertInstanceOf(CreateTaskResult::class, $created); + + $done = $this->settle($client, $created->task->taskId); + + $this->assertSame(TaskStatus::Completed, $done->status); + $prompt = GetPromptResult::fromArray($done->result); + $this->assertSame('prompt done', $prompt->messages[0]->content->text ?? null); + } + + private function connectDeclaring(): TaskClient + { + return new TaskClient($this->connect('tasks', $this->clientBuilder()->enableExtension(new TasksExtension()))); + } + + /** + * Polls until the task reaches a terminal status, honouring `pollIntervalMs`. + */ + private function settle(TaskClient $client, string $taskId): Task + { + for ($i = 0; $i < 20; ++$i) { + $task = $client->get($taskId); + + if ($task->status->isTerminal()) { + return $task; + } + + usleep(1000 * ($task->pollIntervalMs ?? 10)); + } + + $this->fail(\sprintf('Task "%s" did not settle.', $taskId)); + } +} diff --git a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php index dadca9f5..d782664e 100644 --- a/tests/Unit/Capability/Registry/ReferenceHandlerTest.php +++ b/tests/Unit/Capability/Registry/ReferenceHandlerTest.php @@ -14,6 +14,8 @@ use Mcp\Capability\Registry\ElementReference; use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\JsonRpc\Request; +use Mcp\Schema\Request\PingRequest; use Mcp\Server\ClientGateway; use Mcp\Server\Handler\ResourceHandlerInterface; use Mcp\Server\Handler\ToolHandlerInterface; @@ -131,6 +133,23 @@ public function testHandleStillReflectsOrdinaryClosuresAndDoesNotInjectArgumentB $this->assertSame('value', $captured); } + public function testHandleInjectsWhatAnArgumentProviderBuildsForTheRequest(): void + { + $session = $this->createMock(SessionInterface::class); + $request = PingRequest::fromArray(['jsonrpc' => '2.0', 'id' => 7, 'method' => 'ping']); + + $closure = static fn (\ArrayObject $provided, string $kept): string => $provided['request'].':'.$kept; + $reference = new ElementReference($closure); + + $handler = new ReferenceHandler(argumentProviders: [ + \ArrayObject::class => static fn (SessionInterface $s, Request $r): \ArrayObject => new \ArrayObject(['request' => $r::getMethod().'#'.$r->getId()]), + ]); + + $result = $handler->handle($reference, ['_session' => $session, '_request' => $request, 'kept' => 'value']); + + $this->assertSame('ping#7:value', $result); + } + public function testHandleThrowsForStringHandlerThatIsNeitherFunctionNorClass(): void { $session = $this->createMock(SessionInterface::class); diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 63bef736..e919d6b7 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -30,6 +30,9 @@ use Mcp\Server\Handler\Request\InitializeHandler; use Mcp\Server\Protocol; use Mcp\Server\Session\SessionInterface; +use Mcp\Server\Task\InMemoryTaskStore; +use Mcp\Server\Task\TaskContext; +use Mcp\Server\Task\TasksExtension; use Mcp\Tests\Unit\Server\Extension\ThingExtension; use Mcp\Tests\Unit\Server\Extension\ThingListHandler; use Mcp\Tests\Unit\Server\Extension\ThingListRequest; @@ -136,6 +139,29 @@ public function testEnableExtensionRejectsDuplicate(): void Server::builder()->enableExtension(new McpApps(), new McpApps()); } + #[TestDox('enableExtension() lets an extension hand its own object to handlers, and keeps it out of the schema')] + public function testEnableExtensionProvidesHandlerArguments(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->enableExtension(new TasksExtension(new InMemoryTaskStore())) + ->addTool(static fn (TaskContext $tasks): string => $tasks->isSupported() ? 'tasks' : 'no tasks', name: 'probe') + ->build(); + + $this->assertArrayHasKey(TasksExtension::ID, $this->extractServerCapabilities($server)->extensions ?? []); + $this->assertSame('no tasks', $this->callTool($server, 'probe')); + + $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); + foreach ((new \ReflectionClass($protocol))->getProperty('requestHandlers')->getValue($protocol) as $handler) { + if ($handler instanceof CallToolHandler) { + $registry = (new \ReflectionClass($handler))->getProperty('registry')->getValue($handler); + $schema = $registry->getTool('probe')->tool->inputSchema; + $this->assertArrayNotHasKey('required', $schema); + $this->assertSame([], (array) ($schema['properties'] ?? [])); + } + } + } + #[TestDox('enableExtension() extensions are merged into capabilities set via setCapabilities()')] public function testEnableExtensionMergesIntoCustomCapabilities(): void { diff --git a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php index 87a696be..0f780771 100644 --- a/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/CallToolHandlerTest.php @@ -14,13 +14,16 @@ use Mcp\Capability\Registry\ReferenceHandlerInterface; use Mcp\Capability\Registry\ToolReference; use Mcp\Capability\RegistryInterface; +use Mcp\Exception\MissingRequiredClientCapabilityException; use Mcp\Exception\ToolCallException; use Mcp\Exception\ToolNotFoundException; +use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\TextContent; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\EmptyResult; use Mcp\Schema\Tool; use Mcp\Server\Handler\Request\CallToolHandler; use Mcp\Server\Session\SessionInterface; @@ -92,6 +95,42 @@ public function testHandleSuccessfulToolCall(): void $this->assertEquals($expectedResult, $response->result); } + public function testAResultOfAnotherKindIsPassedThroughUntouched(): void + { + // What an extension's handler returns — a task handle, say — is a + // result in its own right, not tool output to be formatted. + $request = $this->createCallToolRequest('slow_tool', []); + $toolReference = $this->createToolReference('slow_tool', static fn () => null); + $foreign = new EmptyResult(); + + $this->registry->method('getTool')->willReturn($toolReference); + $this->referenceHandler->method('handle')->willReturn($foreign); + $toolReference->expects($this->never())->method('formatResult'); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($foreign, $response->result); + } + + public function testAMissingClientCapabilityIsAnswered32021(): void + { + $request = $this->createCallToolRequest('slow_tool', []); + $toolReference = $this->createToolReference('slow_tool', static fn () => null); + $required = new ClientCapabilities(extensions: ['io.example/thing' => new \stdClass()]); + + $this->registry->method('getTool')->willReturn($toolReference); + $this->referenceHandler->method('handle')->willThrowException(new MissingRequiredClientCapabilityException($required, 'Needs the thing.')); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $response->code); + $this->assertSame('Needs the thing.', $response->message); + $this->assertSame($required, $response->data['requiredCapabilities']); + $this->assertSame($request->getId(), $response->id); + } + public function testHandleToolCallWithEmptyArguments(): void { $request = $this->createCallToolRequest('simple_tool', []); @@ -506,6 +545,7 @@ public function testStructuredContentFollowsTheNegotiatedRevision(?string $negot $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); + $this->assertInstanceOf(CallToolResult::class, $response->result); $this->assertSame($expected, $response->result->structuredContent); } @@ -611,6 +651,7 @@ public function testDeclaredOutputSchemaWithoutStructuredContentIsLogged(): void $response = $this->handler->handle($request, $this->session); $this->assertInstanceOf(Response::class, $response); + $this->assertInstanceOf(CallToolResult::class, $response->result); $this->assertNull($response->result->structuredContent); } diff --git a/tests/Unit/Server/Task/TaskStoreTest.php b/tests/Unit/Server/Task/TaskStoreTest.php new file mode 100644 index 00000000..5c5f77b5 --- /dev/null +++ b/tests/Unit/Server/Task/TaskStoreTest.php @@ -0,0 +1,239 @@ + + */ + public static function stores(): iterable + { + yield 'in memory' => [new InMemoryTaskStore()]; + yield 'psr-16' => [new Psr16TaskStore(self::arrayCache())]; + } + + #[DataProvider('stores')] + #[TestDox('a task round-trips through the store')] + public function testRoundTrip(TaskStoreInterface $store): void + { + $store->save(new Task('t-1', TaskStatus::Working, new \DateTimeImmutable(), new \DateTimeImmutable(), 600_000, 250, 'halfway')); + + $task = $store->get('t-1'); + + $this->assertNotNull($task); + $this->assertSame('t-1', $task->taskId); + $this->assertSame(TaskStatus::Working, $task->status); + $this->assertSame(600_000, $task->ttlMs); + $this->assertSame(250, $task->pollIntervalMs); + $this->assertSame('halfway', $task->statusMessage); + } + + #[DataProvider('stores')] + #[TestDox('an unknown id reads as absent')] + public function testUnknownIdIsNull(TaskStoreInterface $store): void + { + $this->assertNull($store->get('nope')); + } + + #[DataProvider('stores')] + #[TestDox('a saved task overwrites the earlier state under the same id')] + public function testSaveOverwrites(TaskStoreInterface $store): void + { + $task = new Task('t-2', TaskStatus::Working, new \DateTimeImmutable(), new \DateTimeImmutable(), 600_000); + $store->save($task); + $store->save($task->with(TaskStatus::Cancelled)); + + $this->assertSame(TaskStatus::Cancelled, $store->get('t-2')?->status); + } + + #[DataProvider('stores')] + #[TestDox('a failed task keeps its error across the store')] + public function testErrorSurvives(TaskStoreInterface $store): void + { + $task = (new Task('t-3', TaskStatus::Working, new \DateTimeImmutable(), null, 600_000)) + ->with(TaskStatus::Failed, error: Error::forInternalError('boom')); + + $store->save($task); + + $read = $store->get('t-3'); + $this->assertNotNull($read); + $this->assertNotNull($read->error); + $this->assertSame(Error::INTERNAL_ERROR, $read->error->code); + $this->assertSame('boom', $read->error->message); + } + + #[DataProvider('stores')] + #[TestDox('a parked task still knows what it is waiting for after a round trip')] + public function testInputRequestsSurvive(TaskStoreInterface $store): void + { + $task = (new Task('t-4', TaskStatus::Working, new \DateTimeImmutable(), null, 600_000)) + ->with(TaskStatus::InputRequired, inputRequests: [ + 'who' => new ElicitRequest('Who?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])), + ]); + + $store->save($task); + + $read = $store->get('t-4'); + $this->assertNotNull($read); + $this->assertArrayHasKey('who', $read->inputRequests); + $this->assertInstanceOf(ElicitRequest::class, $read->inputRequests['who']); + $this->assertSame('Who?', $read->inputRequests['who']->message); + } + + #[DataProvider('stores')] + #[TestDox('a deleted task is gone')] + public function testDelete(TaskStoreInterface $store): void + { + $store->save(new Task('t-5')); + $store->delete('t-5'); + + $this->assertNull($store->get('t-5')); + } + + #[TestDox('a task past its TTL reads as absent')] + public function testTtlExpiry(): void + { + $created = new \DateTimeImmutable('2026-08-15T10:00:00+00:00'); + $clock = new class($created) implements ClockInterface { + public function __construct(public \DateTimeImmutable $now) + { + } + + public function now(): \DateTimeImmutable + { + return $this->now; + } + }; + + $store = new InMemoryTaskStore(clock: $clock); + $store->save(new Task('t-6', TaskStatus::Working, $created, $created, 1000)); + + $this->assertNotNull($store->get('t-6')); + + $clock->now = $created->modify('+2 seconds'); + $this->assertNull($store->get('t-6')); + } + + #[TestDox('the in-memory store drops the least recently used task past its limit')] + public function testInMemoryEviction(): void + { + $store = new InMemoryTaskStore(limit: 2); + + $store->save(new Task('a')); + $store->save(new Task('b')); + // Touching `a` makes `b` the least recently used. + $store->save(new Task('a', TaskStatus::Working)); + $store->save(new Task('c')); + + $this->assertNotNull($store->get('a')); + $this->assertNull($store->get('b')); + $this->assertNotNull($store->get('c')); + } + + #[TestDox('a store that can hold nothing is refused')] + public function testZeroLimitIsRefused(): void + { + $this->expectException(InvalidArgumentException::class); + + new InMemoryTaskStore(limit: 0); + } + + #[TestDox('an unreadable stored payload reads as absent rather than throwing')] + public function testCorruptPayloadIsDropped(): void + { + $cache = self::arrayCache(); + $cache->set('mcp.tasks.t-7', 'not json at all'); + + $this->assertNull((new Psr16TaskStore($cache))->get('t-7')); + } + + private static function arrayCache(): CacheInterface + { + return new class implements CacheInterface { + /** @var array */ + private array $values = []; + + public function get(string $key, mixed $default = null): mixed + { + return $this->values[$key] ?? $default; + } + + public function set(string $key, mixed $value, int|\DateInterval|null $ttl = null): bool + { + $this->values[$key] = $value; + + return true; + } + + public function delete(string $key): bool + { + unset($this->values[$key]); + + return true; + } + + public function clear(): bool + { + $this->values = []; + + return true; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + foreach ($keys as $key) { + yield $key => $this->get($key, $default); + } + } + + public function setMultiple(iterable $values, int|\DateInterval|null $ttl = null): bool + { + foreach ($values as $key => $value) { + $this->set((string) $key, $value, $ttl); + } + + return true; + } + + public function deleteMultiple(iterable $keys): bool + { + foreach ($keys as $key) { + $this->delete($key); + } + + return true; + } + + public function has(string $key): bool + { + return \array_key_exists($key, $this->values); + } + }; + } +} diff --git a/tests/Unit/Server/Task/TaskTest.php b/tests/Unit/Server/Task/TaskTest.php new file mode 100644 index 00000000..b2c0cd28 --- /dev/null +++ b/tests/Unit/Server/Task/TaskTest.php @@ -0,0 +1,173 @@ +jsonSerialize(); + + $this->assertSame('task', $data['resultType']); + $this->assertSame('task-1', $data['taskId']); + $this->assertSame('working', $data['status']); + $this->assertSame(600_000, $data['ttlMs']); + $this->assertSame(250, $data['pollIntervalMs']); + $this->assertArrayNotHasKey('task', $data); + + // A task that was just created has nothing to report yet. + $this->assertArrayNotHasKey('result', $data); + $this->assertArrayNotHasKey('error', $data); + $this->assertArrayNotHasKey('inputRequests', $data); + } + + #[TestDox('the wire fields carry their unit in the name')] + public function testLegacyWireNamesAreGone(): void + { + $data = (new CreateTaskResult(self::task()))->jsonSerialize(); + + $this->assertArrayNotHasKey('ttl', $data); + $this->assertArrayNotHasKey('pollInterval', $data); + } + + #[TestDox('timestamps are ISO-8601')] + public function testTimestampsAreIso8601(): void + { + $data = (new CreateTaskResult(self::task()))->jsonSerialize(); + + $this->assertSame('2026-08-15T10:00:00+00:00', $data['createdAt']); + $this->assertSame('2026-08-15T10:00:00+00:00', $data['lastUpdatedAt']); + } + + #[TestDox('an unlimited TTL is emitted as null, not omitted')] + public function testNullTtlIsEmitted(): void + { + $data = (new CreateTaskResult(new Task('task-1', ttlMs: null)))->jsonSerialize(); + + $this->assertArrayHasKey('ttlMs', $data); + $this->assertNull($data['ttlMs']); + } + + #[TestDox('a completed task inlines the result the request would have returned')] + public function testCompletedTaskInlinesItsResult(): void + { + $task = self::task()->with(TaskStatus::Completed, result: new CallToolResult([new TextContent('done')])); + + $data = (new TaskResult($task))->jsonSerialize(); + + $this->assertSame('completed', $data['status']); + $this->assertSame('done', $data['result']->content[0]->text); + } + + #[TestDox('a failed task inlines the error and carries no result')] + public function testFailedTaskInlinesItsError(): void + { + $task = self::task()->with(TaskStatus::Failed, error: Error::forInternalError('boom')); + + $data = (new TaskResult($task))->jsonSerialize(); + + $this->assertSame('failed', $data['status']); + $this->assertSame(Error::INTERNAL_ERROR, $data['error']['code']); + $this->assertSame('boom', $data['error']['message']); + $this->assertArrayNotHasKey('result', $data); + } + + #[TestDox('a tool that ran and reported a problem is completed, never failed')] + public function testToolErrorCannotBeSpelledAsFailed(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/isError/'); + + new Task('task-1', TaskStatus::Completed, error: Error::forInternalError('boom')); + } + + #[TestDox('a failed task without an error is refused')] + public function testFailedTaskNeedsAnError(): void + { + $this->expectException(InvalidArgumentException::class); + + new Task('task-1', TaskStatus::Failed); + } + + #[TestDox('a waiting task surfaces what it is waiting for')] + public function testInputRequiredTaskCarriesItsRequests(): void + { + $task = self::task()->with(TaskStatus::InputRequired, inputRequests: [ + 'who' => new ElicitRequest('Who?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])), + ]); + + $data = (new TaskResult($task))->jsonSerialize(); + + $this->assertSame('input_required', $data['status']); + $this->assertSame('elicitation/create', $data['inputRequests']['who']['method']); + } + + #[TestDox('terminal states are the ones that never change again')] + public function testTerminalStates(): void + { + $this->assertTrue(TaskStatus::Completed->isTerminal()); + $this->assertTrue(TaskStatus::Failed->isTerminal()); + $this->assertTrue(TaskStatus::Cancelled->isTerminal()); + $this->assertFalse(TaskStatus::Working->isTerminal()); + $this->assertFalse(TaskStatus::InputRequired->isTerminal()); + } + + #[TestDox('a task stays readable for its whole TTL, and not past it')] + public function testTtlBoundsReadability(): void + { + $task = self::task(); + + $this->assertTrue($task->isReadable(new \DateTimeImmutable('2026-08-15T10:09:59+00:00'))); + $this->assertFalse($task->isReadable(new \DateTimeImmutable('2026-08-15T10:10:01+00:00'))); + } + + #[TestDox('a task with no TTL is always readable')] + public function testNoTtlIsAlwaysReadable(): void + { + $this->assertTrue((new Task('task-1'))->isReadable(new \DateTimeImmutable('2099-01-01T00:00:00+00:00'))); + } + + #[TestDox('a fractional or negative duration is refused: it is not guidance a client can act on')] + public function testDurationsMustBePositive(): void + { + $this->expectException(InvalidArgumentException::class); + + new Task('task-1', ttlMs: -1); + } +} diff --git a/tests/Unit/Server/Task/TasksExtensionTest.php b/tests/Unit/Server/Task/TasksExtensionTest.php new file mode 100644 index 00000000..427947a7 --- /dev/null +++ b/tests/Unit/Server/Task/TasksExtensionTest.php @@ -0,0 +1,283 @@ +store = new InMemoryTaskStore(); + } + + public function testDeclaresItselfWithoutSettings(): void + { + $extension = new TasksExtension($this->store); + + $this->assertSame('io.modelcontextprotocol/tasks', (string) $extension->getId()); + $this->assertSame([], $extension->getCapabilities()); + $this->assertSame([TasksGetRequest::class, TasksUpdateRequest::class, TasksCancelRequest::class], $extension->getMessages()); + $this->assertSame($this->store, $extension->getStore()); + } + + public function testWithoutAStoreItOnlyDeclares(): void + { + $extension = new TasksExtension(); + + $this->assertSame([], $extension->getCapabilities()); + $this->expectException(LogicException::class); + $extension->getStore(); + } + + public function testProvidesATaskContextToHandlers(): void + { + $providers = (new TasksExtension($this->store))->getArgumentProviders(); + + $this->assertSame([TaskContext::class], array_keys($providers)); + $context = $providers[TaskContext::class]($this->session(), PingRequest::fromArray(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'ping'])); + $this->assertInstanceOf(TaskContext::class, $context); + $this->assertSame($this->store, $context->getStore()); + } + + public function testTaskContextStoresTheTaskBeforeHandingOutTheHandle(): void + { + $context = new TaskContext($this->session(), $this->store); + $this->assertTrue($context->isSupported()); + + $created = $context->create(ttlMs: 60_000, pollIntervalMs: 500, statusMessage: 'Queued.'); + + $this->assertSame(TaskStatus::Working, $created->task->status); + $this->assertSame(60_000, $created->task->ttlMs); + $this->assertSame(500, $created->task->pollIntervalMs); + $this->assertSame('Queued.', $created->task->statusMessage); + $this->assertSame($created->task, $this->store->get($created->task->taskId)); + } + + public function testTaskContextRefusesAHandleToAClientThatCannotRedeemIt(): void + { + $context = new TaskContext($this->session(declared: false), $this->store); + $this->assertFalse($context->isSupported()); + + try { + $context->create(); + $this->fail('Expected the task to be refused.'); + } catch (MissingRequiredClientCapabilityException $e) { + $this->assertArrayHasKey(TasksExtension::ID, $e->requiredCapabilities->extensions); + $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $e->toError(1)->code); + } + } + + public function testEachMethodHasAHandler(): void + { + $handlers = [...(new TasksExtension($this->store))->getRequestHandlers()]; + + $this->assertCount(3, $handlers); + foreach ([$this->get('t'), $this->update('t'), $this->cancel('t')] as $request) { + $this->assertCount(1, array_filter($handlers, static fn (RequestHandlerInterface $h): bool => $h->supports($request))); + } + } + + public function testDeclaredBy(): void + { + $this->assertTrue(TasksExtension::declaredBy(['io.modelcontextprotocol/tasks' => []])); + $this->assertTrue(TasksExtension::declaredBy(['io.modelcontextprotocol/tasks' => new \stdClass()])); + $this->assertFalse(TasksExtension::declaredBy(['io.modelcontextprotocol/ui' => []])); + $this->assertFalse(TasksExtension::declaredBy(null)); + } + + public function testGetReturnsTheTaskInDetail(): void + { + $this->store->save((new Task('t-1', createdAt: new \DateTimeImmutable('2026-01-01T00:00:00Z'))) + ->with(TaskStatus::Completed, result: ['content' => []], now: new \DateTimeImmutable('2026-01-01T00:00:01Z'))); + + $response = $this->handle($this->get('t-1'), $this->session()); + + $this->assertInstanceOf(Response::class, $response); + $this->assertInstanceOf(TaskResult::class, $response->result); + $this->assertSame([ + 'taskId' => 't-1', + 'status' => 'completed', + 'createdAt' => '2026-01-01T00:00:00+00:00', + 'lastUpdatedAt' => '2026-01-01T00:00:01+00:00', + 'ttlMs' => null, + 'result' => ['content' => []], + ], $response->result->jsonSerialize()); + } + + public function testUnknownTaskIsInvalidParams(): void + { + foreach ([$this->get('nope'), $this->update('nope'), $this->cancel('nope')] as $request) { + $response = $this->handle($request, $this->session()); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame(Error::INVALID_PARAMS, $response->code); + $this->assertSame(['taskId' => 'nope'], $response->data); + } + } + + public function testUndeclaredClientIsRefusedWithMissingCapability(): void + { + $this->store->save(new Task('t-1')); + + foreach ([$this->get('t-1'), $this->update('t-1'), $this->cancel('t-1')] as $request) { + $response = $this->handle($request, $this->session(declared: false)); + + $this->assertInstanceOf(Error::class, $response); + $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $response->code); + $this->assertInstanceOf(ClientCapabilities::class, $response->data['requiredCapabilities']); + $this->assertArrayHasKey(TasksExtension::ID, $response->data['requiredCapabilities']->extensions); + } + + $this->assertSame(TaskStatus::Working, $this->store->get('t-1')?->status); + } + + public function testCancelIsCooperativeAndIdempotent(): void + { + $this->store->save(new Task('t-1')); + + $response = $this->handle($this->cancel('t-1'), $this->session()); + $this->assertInstanceOf(Response::class, $response); + $this->assertInstanceOf(EmptyResult::class, $response->result); + $this->assertSame(TaskStatus::Cancelled, $this->store->get('t-1')?->status); + + // A second cancel, and a cancel of something finished, change nothing. + $this->handle($this->cancel('t-1'), $this->session()); + $this->assertSame(TaskStatus::Cancelled, $this->store->get('t-1')->status); + + $this->store->save((new Task('t-2'))->with(TaskStatus::Completed, result: 'done')); + $this->handle($this->cancel('t-2'), $this->session()); + $this->assertSame(TaskStatus::Completed, $this->store->get('t-2')?->status); + } + + public function testUpdateHandsTheAnswersToTheInputHandler(): void + { + $ask = new ElicitRequest('Who?', new ElicitationSchema(['name' => new StringSchemaDefinition('Name')])); + $this->store->save((new Task('t-1'))->with(TaskStatus::InputRequired, inputRequests: ['who' => $ask])); + + $inputHandler = new class implements TaskInputHandlerInterface { + /** @var array */ + public array $received = []; + + public function receive(Task $task, array $inputResponses): Task + { + $this->received = $inputResponses; + + return $task->with(TaskStatus::Completed, result: 'Hello, '.$inputResponses['who']['content']['name']); + } + }; + + $answers = ['who' => ['action' => 'accept', 'content' => ['name' => 'Ada']]]; + $response = $this->handle($this->update('t-1', $answers), $this->session(), $inputHandler); + + $this->assertInstanceOf(Response::class, $response); + $this->assertInstanceOf(EmptyResult::class, $response->result); + $this->assertSame($answers, $inputHandler->received); + $this->assertSame(TaskStatus::Completed, $this->store->get('t-1')?->status); + $this->assertSame('Hello, Ada', $this->store->get('t-1')->result); + } + + public function testUpdateWithoutAnInputHandlerResumesTheTask(): void + { + $ask = new ElicitRequest('Who?', new ElicitationSchema(['name' => new StringSchemaDefinition('Name')])); + $this->store->save((new Task('t-1'))->with(TaskStatus::InputRequired, inputRequests: ['who' => $ask])); + + $this->handle($this->update('t-1', ['who' => ['action' => 'decline']]), $this->session()); + + $this->assertSame(TaskStatus::Working, $this->store->get('t-1')?->status); + } + + public function testUpdateOfATaskNotWaitingIsIgnored(): void + { + $this->store->save(new Task('t-1')); + + $response = $this->handle($this->update('t-1', ['stale' => []]), $this->session()); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(TaskStatus::Working, $this->store->get('t-1')?->status); + } + + /** + * @return Response<\Mcp\Schema\JsonRpc\ResultInterface>|Error + */ + private function handle(Request $request, SessionInterface $session, ?TaskInputHandlerInterface $inputHandler = null): Response|Error + { + foreach ((new TasksExtension($this->store, $inputHandler))->getRequestHandlers() as $handler) { + if ($handler->supports($request)) { + return $handler->handle($request, $session); + } + } + + $this->fail(\sprintf('No handler for "%s".', $request::getMethod())); + } + + /** + * A session as `initialize` leaves it, with or without the extension declared. + */ + private function session(bool $declared = true): SessionInterface + { + $session = new Session(new InMemorySessionStore()); + $capabilities = new ClientCapabilities(extensions: $declared ? [TasksExtension::ID => new \stdClass()] : null); + $session->set('client_capabilities', $capabilities->jsonSerialize()); + + return $session; + } + + private function get(string $taskId): TasksGetRequest + { + return TasksGetRequest::fromArray(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tasks/get', 'params' => ['taskId' => $taskId]]); + } + + /** + * @param array $inputResponses + */ + private function update(string $taskId, array $inputResponses = []): TasksUpdateRequest + { + return TasksUpdateRequest::fromArray(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tasks/update', 'params' => ['taskId' => $taskId, 'inputResponses' => $inputResponses]]); + } + + private function cancel(string $taskId): TasksCancelRequest + { + return TasksCancelRequest::fromArray(['jsonrpc' => '2.0', 'id' => 3, 'method' => 'tasks/cancel', 'params' => ['taskId' => $taskId]]); + } +}