[Server] Complete the multi round-trip request surface (SEP-2322) (#449)
* [Server] Let resources/read ask for input, and stop caching retries The MRTR pattern names three methods and resources/read was the one whose handler dropped an InputRequiredResult into the resource formatter. A retry's answer also carried ttlMs and cacheScope, which the caching rules forbid: its inputs are not part of any cache key. * [Server] Refuse an ask the client cannot answer A handler could put an elicitation into inputRequests for a client that never declared one, and the only symptom was a retry that never came. Checked against the request's own capabilities now, and reported as -32021 with the missing set — which is the code the spec defines for exactly this. * [Server] Hand multi round-trip answers back typed response() returned whatever JSON arrived, so every handler re-implemented the same array poking. A malformed answer now reads as absent, which sends the handler back to asking rather than to failing. * [Server] Gate streamed input_required asks by capability too checkInputRequests() only ran on the non-streaming path, so a handler that emits a notification before returning InputRequiredResult could still leak an undeclared-capability ask under HTTP 200.
This commit is contained in:
parent
7ffc739fe7
commit
cad686aba7
1 changed files with 508 additions and 19 deletions
|
|
@ -25,6 +25,7 @@ use Mcp\Schema\JsonRpc\Response;
|
|||
use Mcp\Schema\JsonRpc\ResultInterface;
|
||||
use Mcp\Schema\Notification\LoggingMessageNotification;
|
||||
use Mcp\Schema\Result\DiscoverResult;
|
||||
use Mcp\Schema\Result\InputRequiredResult;
|
||||
use Mcp\Server\Configuration;
|
||||
use Mcp\Server\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Server\Protocol;
|
||||
|
|
@ -433,7 +434,7 @@ final class StatelessProtocol
|
|||
}
|
||||
|
||||
if ($run->valid() && $wantsStream) {
|
||||
return StatelessResult::stream(fn (): \Generator => $this->streamFrames($run, $method, $id));
|
||||
return StatelessResult::stream(fn (): \Generator => $this->streamFrames($run, $meta, $method, $id, null === $input));
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -457,7 +458,11 @@ final class StatelessProtocol
|
|||
return StatelessResult::error($result, 400);
|
||||
}
|
||||
|
||||
return $this->encode($method, $id, $result->result);
|
||||
if (null !== $capabilityError = $this->checkInputRequests($result->result, $meta, $method, $id)) {
|
||||
return $capabilityError;
|
||||
}
|
||||
|
||||
return $this->encode($method, $id, $result->result, null === $input);
|
||||
}
|
||||
|
||||
return StatelessResult::error(Error::forMethodNotFound(\sprintf('No handler found for method "%s".', $method), $id), 404);
|
||||
|
|
@ -540,7 +545,7 @@ final class StatelessProtocol
|
|||
*
|
||||
* @return \Generator<mixed>
|
||||
*/
|
||||
private function streamFrames(\Generator $run, string $method, string|int $id): \Generator
|
||||
private function streamFrames(\Generator $run, RequestMeta $meta, string $method, string|int $id, bool $cacheable): \Generator
|
||||
{
|
||||
try {
|
||||
while ($run->valid()) {
|
||||
|
|
@ -558,9 +563,50 @@ final class StatelessProtocol
|
|||
return;
|
||||
}
|
||||
|
||||
if (!$result instanceof Error && null !== $capabilityError = $this->checkInputRequests($result->result, $meta, $method, $id)) {
|
||||
yield $capabilityError->message?->jsonSerialize();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
yield $result instanceof Error
|
||||
? $result->jsonSerialize()
|
||||
: ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->codec->encodeResult($method, (array) $result->result->jsonSerialize())];
|
||||
: ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->codec->encodeResult($method, (array) $result->result->jsonSerialize(), $cacheable)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses to send an ask the client cannot answer.
|
||||
*
|
||||
* The handler's mistake rather than the client's, but the client is the one
|
||||
* that has to hear about it, and `-32021` is precisely the code for
|
||||
* "processing this needs a capability you did not declare" — so it is
|
||||
* reported as that, and logged as the server-side bug it is.
|
||||
*/
|
||||
private function checkInputRequests(ResultInterface $result, RequestMeta $meta, string $method, string|int $id): ?StatelessResult
|
||||
{
|
||||
if (!$result instanceof InputRequiredResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$missing = InputRequestCapabilities::missing($result, $meta->clientCapabilities);
|
||||
|
||||
if (null === $missing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->logger->warning('A handler asked for input the client did not declare it could provide; the ask was replaced with -32021.', [
|
||||
'method' => $method,
|
||||
'required' => $missing->jsonSerialize(),
|
||||
]);
|
||||
|
||||
return StatelessResult::error(
|
||||
Error::forMissingRequiredClientCapability(
|
||||
'The server needs input this client did not declare it can provide.',
|
||||
$missing,
|
||||
$id,
|
||||
),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -634,9 +680,9 @@ final class StatelessProtocol
|
|||
* Runs a result through the wire codec. Passed as-is rather than via a
|
||||
* json round trip, which would turn a nested `{}` into `[]`.
|
||||
*/
|
||||
private function encode(string $method, string|int $id, ResultInterface $result): StatelessResult
|
||||
private function encode(string $method, string|int $id, ResultInterface $result, bool $cacheable = true): StatelessResult
|
||||
{
|
||||
return StatelessResult::ok($id, $this->codec->encodeResult($method, (array) $result->jsonSerialize()));
|
||||
return StatelessResult::ok($id, $this->codec->encodeResult($method, (array) $result->jsonSerialize(), $cacheable));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue