From 175bcad98a249673d39728fd45fe69287b3369a2 Mon Sep 17 00:00:00 2001 From: Alexis Lefebvre Date: Mon, 7 Sep 2026 14:51:48 +0200 Subject: [PATCH] fix(mcp): forward Symfony HTTP exception messages as JSON-RPC errors A state provider or processor signalling a caller-facing failure through HttpKernel's HTTP exception contract has its message forwarded to the client, the same as one using the metadata component's contract. Both qualify at the two conversion sites; every other throwable still reaches the SDK's generic handler, so unexpected exception text stays hidden. Signalling a missing resource relies on the HttpKernel contract, since the metadata component ships no equivalent for that status, and the SDK replaces the message of anything it handles itself with a fixed constant. Co-Authored-By: Claude Opus 5 (1M context) --- src/Mcp/Server/Handler.php | 14 +- .../ApiResource/McpExceptionTools.php | 66 +++++++++ tests/Functional/McpExceptionTest.php | 131 ++++++++++++++++++ 3 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/McpExceptionTools.php create mode 100644 tests/Functional/McpExceptionTest.php diff --git a/src/Mcp/Server/Handler.php b/src/Mcp/Server/Handler.php index e6ea7dab26..33b1f57cdd 100644 --- a/src/Mcp/Server/Handler.php +++ b/src/Mcp/Server/Handler.php @@ -31,6 +31,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; /** * @experimental @@ -133,12 +134,15 @@ public function handle(Request $request, SessionInterface $session): Response|Er } // The MCP transport has no HTTP response to carry a status code, so a caller-facing - // HttpExceptionInterface (e.g. access denied, validation) is converted into a JSON-RPC - // error carrying its message; anything else stays uncaught and reaches the SDK's own - // generic handler, which does not leak arbitrary exception messages to the client. + // HTTP exception (e.g. access denied, validation, not found) is converted into a JSON-RPC + // error carrying its message. Both API Platform's and Symfony's HttpExceptionInterface + // qualify: throwing Symfony's NotFoundHttpException from a state provider is a documented + // idiom, and API Platform has no 404 equivalent of its own. Anything else stays uncaught + // and reaches the SDK's own generic handler, which does not leak arbitrary exception + // messages to the client. try { $body = $this->provider->provide($operation, $uriVariables, $context); - } catch (HttpExceptionInterface $e) { + } catch (HttpExceptionInterface|SymfonyHttpExceptionInterface $e) { return Error::forInternalError($e->getMessage(), $request->getId()); } @@ -159,7 +163,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er try { return $this->processor->process($body, $operation, $uriVariables, $context); - } catch (HttpExceptionInterface $e) { + } catch (HttpExceptionInterface|SymfonyHttpExceptionInterface $e) { return Error::forInternalError($e->getMessage(), $request->getId()); } } diff --git a/tests/Fixtures/TestBundle/ApiResource/McpExceptionTools.php b/tests/Fixtures/TestBundle/ApiResource/McpExceptionTools.php new file mode 100644 index 0000000000..b00c06509b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/McpExceptionTools.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\Operation; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +#[ApiResource( + shortName: 'McpExceptionTools', + operations: [], + mcp: [ + 'symfony_not_found_provider_tool' => new McpTool( + provider: [self::class, 'provideNotFound'], + ), + 'symfony_not_found_processor_tool' => new McpTool( + processor: [self::class, 'processNotFound'], + ), + ] +)] +class McpExceptionTools +{ + public function __construct(private ?string $text = null) + { + } + + public function getText(): ?string + { + return $this->text; + } + + public function setText(?string $text): void + { + $this->text = $text; + } + + /** + * @param array $uriVariables + * @param array $context + */ + public static function provideNotFound(Operation $operation, array $uriVariables = [], array $context = []): never + { + throw new NotFoundHttpException('Provider says this resource does not exist.'); + } + + /** + * @param array $uriVariables + * @param array $context + */ + public static function processNotFound(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): never + { + throw new NotFoundHttpException('Processor says this resource does not exist.'); + } +} diff --git a/tests/Functional/McpExceptionTest.php b/tests/Functional/McpExceptionTest.php new file mode 100644 index 0000000000..d1707c0d47 --- /dev/null +++ b/tests/Functional/McpExceptionTest.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\McpExceptionTools; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\AI\McpBundle\McpBundle; + +/** + * A caller-facing HTTP exception thrown by a state provider or processor must reach the client as a + * JSON-RPC error carrying its own message, whether it implements API Platform's + * HttpExceptionInterface or Symfony's: without that, the SDK replaces the message with its generic + * "Internal server error." and the caller cannot tell a missing resource from a server fault. + */ +final class McpExceptionTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [McpExceptionTools::class]; + } + + /** + * @return iterable + */ + public static function symfonyHttpExceptionProvider(): iterable + { + yield 'provider' => ['symfony_not_found_provider_tool', 'Provider says this resource does not exist.']; + yield 'processor' => ['symfony_not_found_processor_tool', 'Processor says this resource does not exist.']; + } + + #[DataProvider('symfonyHttpExceptionProvider')] + public function testSymfonyHttpExceptionMessageReachesTheCaller(string $tool, string $expectedMessage): void + { + $this->skipUnlessMcpIsAvailable(); + + $client = self::createClient(); + $result = $this->callTool($client, $this->initializeMcpSession($client), $tool, ['text' => 'hello'])->toArray(false); + + self::assertArrayNotHasKey('result', $result, \sprintf('Tool "%s" returned a result instead of an error.', $tool)); + self::assertSame($expectedMessage, $result['error']['message'] ?? null); + } + + private function skipUnlessMcpIsAvailable(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if ($this->isMongoDB()) { + $this->markTestSkipped('MCP is not supported with MongoDB'); + } + + try { + if (!class_exists('Http\Discovery\Psr17FactoryDiscovery')) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + \Http\Discovery\Psr17FactoryDiscovery::findServerRequestFactory(); + } catch (\Throwable) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + } + + private function initializeMcpSession($client): string + { + $res = $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'clientInfo' => ['name' => 'ApiPlatform Test Suite', 'version' => '1.0'], + 'capabilities' => [], + ], + ], + ]); + self::assertResponseIsSuccessful(); + + return $res->getHeaders()['mcp-session-id'][0]; + } + + /** + * @param array $arguments + */ + private function callTool($client, string $sessionId, string $toolName, array $arguments = []) + { + return $client->request('POST', '/mcp', [ + 'headers' => [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ], + 'json' => [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => $toolName, + 'arguments' => $arguments, + ], + ], + ]); + } +}