Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/Mcp/Server/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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());
}

Expand All @@ -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());
}
}
Expand Down
66 changes: 66 additions & 0 deletions tests/Fixtures/TestBundle/ApiResource/McpExceptionTools.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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<string, mixed> $uriVariables
* @param array<string, mixed> $context
*/
public static function provideNotFound(Operation $operation, array $uriVariables = [], array $context = []): never
{
throw new NotFoundHttpException('Provider says this resource does not exist.');
}

/**
* @param array<string, mixed> $uriVariables
* @param array<string, mixed> $context
*/
public static function processNotFound(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): never
{
throw new NotFoundHttpException('Processor says this resource does not exist.');
}
}
131 changes: 131 additions & 0 deletions tests/Functional/McpExceptionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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<string, array{string, string}>
*/
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<string, mixed> $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,
],
],
]);
}
}
Loading