Skip to content
Merged
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
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@
"jangregor/phpstan-prophecy": "^2.1.11",
"justinrainbow/json-schema": "^6.5.2",
"laravel/framework": "^11.0 || ^12.0 || ^13.0",
"mcp/sdk": "^0.6 || ^0.7",
"mcp/sdk": "^0.8",
"orchestra/testbench": "^10.9 || ^11.0",
"phpspec/prophecy-phpunit": "^2.2",
"phpstan/extension-installer": "^1.1",
Expand Down Expand Up @@ -176,7 +176,7 @@
"symfony/intl": "^6.4 || ^7.0 || ^8.0",
"symfony/json-streamer": "^7.4 || ^8.0",
"symfony/maker-bundle": "^1.24",
"symfony/mcp-bundle": "^0.12",
"symfony/mcp-bundle": "^0.13",
"symfony/mercure-bundle": "^0.4.3|^0.5",
"symfony/messenger": "^6.4 || ^7.0 || ^8.0",
"symfony/object-mapper": "^7.4 || ^8.0",
Expand Down
20 changes: 19 additions & 1 deletion src/Laravel/ApiPlatformProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,11 @@
use ApiPlatform\Laravel\State\SwaggerUiProvider;
use ApiPlatform\Laravel\State\ValidateProvider;
use ApiPlatform\Mcp\Capability\Registry\Loader as McpLoader;
use ApiPlatform\Mcp\Capability\Registry\SecureRegistry;
use ApiPlatform\Mcp\JsonSchema\SchemaFactory as McpSchemaFactory;
use ApiPlatform\Mcp\Metadata\Operation\Factory\OperationMetadataFactory as McpOperationMetadataFactory;
use ApiPlatform\Mcp\Routing\IriConverter as McpIriConverter;
use ApiPlatform\Mcp\Security\PolicyAccessChecker;
use ApiPlatform\Mcp\Server\Handler;
use ApiPlatform\Mcp\State\StructuredContentProcessor;
use ApiPlatform\Metadata\IdentifiersExtractor;
Expand Down Expand Up @@ -180,6 +182,7 @@
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Mcp\Capability\Registry;
use Mcp\Capability\RegistryInterface;
use Mcp\Server;
use Mcp\Server\Builder;
use Mcp\Server\Session\InMemorySessionStore;
Expand Down Expand Up @@ -1173,6 +1176,21 @@ private function registerMcp(): void
});
$this->app->tag(McpLoader::class, 'mcp.loader');

$this->app->singleton(PolicyAccessChecker::class, static function (Application $app) {
return new PolicyAccessChecker(
$app->make(McpOperationMetadataFactory::class),
$app->make(ResourceAccessCheckerInterface::class)
);
});

$this->app->singleton(RegistryInterface::class, static function (Application $app) {
return new SecureRegistry(
$app->make(Registry::class),
$app->make(McpLoader::class),
$app->make(PolicyAccessChecker::class)
);
});

// TODO: add more stores?
$this->app->singleton('mcp.session.store', static function () {
return new InMemorySessionStore(3600);
Expand All @@ -1190,7 +1208,7 @@ private function registerMcp(): void
null // website_url todo
)
->setPaginationLimit(100)
->setRegistry($app->make(Registry::class))
->setRegistry($app->make(RegistryInterface::class))
->setSession($app->make('mcp.session.store'));

foreach ($app->tagged('mcp.loader') as $loader) {
Expand Down
139 changes: 139 additions & 0 deletions src/Laravel/Tests/McpPolicyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?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\Laravel\Tests;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Orchestra\Testbench\Concerns\WithWorkbench;
use Orchestra\Testbench\TestCase;
use Symfony\AI\McpBundle\McpBundle;
use Workbench\App\ApiResource\McpSecuredTools;

class McpPolicyTest extends TestCase
{
use RefreshDatabase;
use WithWorkbench;

protected function defineEnvironment($app): void
{
Gate::guessPolicyNamesUsing(static function (string $modelClass) {
return McpSecuredTools::class === $modelClass ?
McpSecuredToolsPolicy::class :
null;
});
}

private function isPsr17FactoryAvailable(): bool
{
try {
if (!class_exists('Http\Discovery\Psr17FactoryDiscovery')) {
return false;
}

\Http\Discovery\Psr17FactoryDiscovery::findServerRequestFactory();

return true;
} catch (\Throwable) {
return false;
}
}

private function initializeMcpSession(): string
{
$response = $this->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'initialize',
'params' => [
'protocolVersion' => '2024-11-05',
'clientInfo' => [
'name' => 'ApiPlatform Test Suite',
'version' => '1.0',
],
'capabilities' => [],
],
], [
'Accept' => 'application/json, text/event-stream',
'Content-Type' => 'application/json',
]);

$response->assertStatus(200);

return $response->headers->get('mcp-session-id');
}

/**
* @return list<string>
*/
private function listToolNames(): array
{
$sessionId = $this->initializeMcpSession();
$response = $this->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 2,
'method' => 'tools/list',
], [
'Accept' => 'application/json, text/event-stream',
'Content-Type' => 'application/json',
'mcp-session-id' => $sessionId,
]);

$response->assertStatus(200);

return array_column($response->json('result.tools'), 'name');
}

public function testToolDeniedByPolicyIsNotListed(): void
{
if (!class_exists(McpBundle::class)) {
$this->markTestSkipped('MCP bundle is not installed');
}

if (!$this->isPsr17FactoryAvailable()) {
$this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)');
}

$this->assertNotContains('secured_denied_tool', $this->listToolNames());
}

public function testToolGrantedByPolicyIsListed(): void
{
if (!class_exists(McpBundle::class)) {
$this->markTestSkipped('MCP bundle is not installed');
}

if (!$this->isPsr17FactoryAvailable()) {
$this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)');
}

$this->assertContains('secured_granted_tool', $this->listToolNames());
}

public function testToolWhosePolicyNeedsTheModelStaysListed(): void
{
if (!class_exists(McpBundle::class)) {
$this->markTestSkipped('MCP bundle is not installed');
}

if (!$this->isPsr17FactoryAvailable()) {
$this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)');
}

// Gate::callPolicyMethod shifts off a string first argument ("this policy already knows
// what type of models it can authorize") and then calls $policy->view($user), so a policy
// method requiring a model instance throws instead of answering, see
// vendor/laravel/framework/src/Illuminate/Auth/Access/Gate.php:825-839
$this->assertContains('secured_model_tool', $this->listToolNames());
}
}
35 changes: 35 additions & 0 deletions src/Laravel/Tests/McpSecuredToolsPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?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\Laravel\Tests;

use Illuminate\Foundation\Auth\User;
use Workbench\App\ApiResource\McpSecuredTools;

class McpSecuredToolsPolicy
{
public function viewAny(?User $user): bool
{
return false;
}

public function create(?User $user): bool
{
return true;
}

public function view(?User $user, McpSecuredTools $resource): bool
{
return true;
}
}
2 changes: 1 addition & 1 deletion src/Laravel/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"phpstan/phpdoc-parser": "^1.29 || ^2.0",
"phpunit/phpunit": "^11.5 || ^12.2",
"symfony/http-client": "^7.4 || ^8.0",
"symfony/mcp-bundle": "^0.12",
"symfony/mcp-bundle": "^0.13",
"symfony/object-mapper": "^7.4 || ^8.0"
},
"autoload": {
Expand Down
60 changes: 60 additions & 0 deletions src/Laravel/workbench/app/ApiResource/McpSecuredTools.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?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 Workbench\App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\McpTool;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Result\CallToolResult;

#[ApiResource(
shortName: 'McpSecuredTools',
operations: [],
mcp: [
'secured_denied_tool' => new McpTool(
processor: [self::class, 'process'],
policy: 'viewAny',
),
'secured_model_tool' => new McpTool(
processor: [self::class, 'process'],
policy: 'view',
),
'secured_granted_tool' => new McpTool(
processor: [self::class, 'process'],
policy: 'create',
),
]
)]
class McpSecuredTools
{
public function __construct(
private ?string $text = null,
) {
}

public function getText(): ?string
{
return $this->text;
}

public function setText(?string $text): void
{
$this->text = $text;
}

public static function process(self $data): CallToolResult
{
return new CallToolResult([new TextContent('processed: '.$data->getText())]);
}
}
Loading
Loading