Compare commits
3 Commits
43bec07bb8
...
37aa755819
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37aa755819 | ||
|
|
98caaa148d | ||
|
|
523eed927e |
@@ -14,6 +14,7 @@
|
||||
"doctrine/orm": "^3.6",
|
||||
"lexik/jwt-authentication-bundle": "^3.2",
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"symfony/asset": "8.0.*",
|
||||
@@ -22,8 +23,10 @@
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/mcp-bundle": "^0.6.0",
|
||||
"symfony/property-access": "8.0.*",
|
||||
"symfony/property-info": "8.0.*",
|
||||
"symfony/rate-limiter": "8.0.*",
|
||||
"symfony/runtime": "8.0.*",
|
||||
"symfony/security-bundle": "8.0.*",
|
||||
"symfony/serializer": "8.0.*",
|
||||
|
||||
1033
composer.lock
generated
1033
composer.lock
generated
File diff suppressed because it is too large
Load Diff
10
config/packages/http_discovery.yaml
Normal file
10
config/packages/http_discovery.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
|
||||
|
||||
http_discovery.psr17_factory:
|
||||
class: Http\Discovery\Psr17Factory
|
||||
20
config/packages/mcp.yaml.disabled
Normal file
20
config/packages/mcp.yaml.disabled
Normal file
@@ -0,0 +1,20 @@
|
||||
mcp:
|
||||
app: 'inventory'
|
||||
version: '1.0.0'
|
||||
description: 'Inventory MCP Server - Gestion inventaire industriel (machines, pièces, composants, produits)'
|
||||
instructions: |
|
||||
Serveur MCP pour gérer un inventaire industriel.
|
||||
Entités principales : Machine, Composant, Pièce, Produit, Site, Constructeur.
|
||||
Utilisez search_inventory pour chercher dans toutes les entités.
|
||||
Utilisez get_model_type pour comprendre la structure attendue avant de créer un composant ou une pièce.
|
||||
Consultez la resource inventory://schema/entities pour voir le schéma complet.
|
||||
Authentification requise : envoyez X-Profile-Id et X-Profile-Password dans les headers HTTP.
|
||||
client_transports:
|
||||
stdio: true
|
||||
http: true
|
||||
http:
|
||||
path: /_mcp
|
||||
session:
|
||||
store: file
|
||||
directory: '%kernel.cache_dir%/mcp-sessions'
|
||||
ttl: 3600
|
||||
6
config/packages/rate_limiter.yaml
Normal file
6
config/packages/rate_limiter.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
framework:
|
||||
rate_limiter:
|
||||
mcp_auth:
|
||||
policy: sliding_window
|
||||
limit: 5
|
||||
interval: '1 minute'
|
||||
@@ -27,6 +27,13 @@ security:
|
||||
pattern: ^/api/session/profiles?$
|
||||
security: false
|
||||
|
||||
# TODO: re-enable when symfony/ai-mcp-bundle is installed
|
||||
# mcp:
|
||||
# pattern: ^/_mcp
|
||||
# stateless: true
|
||||
# custom_authenticators:
|
||||
# - App\Mcp\Security\McpHeaderAuthenticator
|
||||
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: false
|
||||
@@ -49,6 +56,7 @@ security:
|
||||
- { path: ^/api/admin, roles: ROLE_ADMIN }
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
|
||||
# - { path: ^/_mcp, roles: ROLE_USER } # TODO: re-enable with MCP
|
||||
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }
|
||||
|
||||
@@ -18,6 +18,8 @@ services:
|
||||
# this creates a service per class whose id is the fully-qualified class name
|
||||
App\:
|
||||
resource: '../src/'
|
||||
exclude:
|
||||
- '../src/Mcp/'
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
@@ -34,6 +36,11 @@ services:
|
||||
tags:
|
||||
- { name: doctrine.event_subscriber }
|
||||
|
||||
# TODO: re-enable when symfony/ai-mcp-bundle is installed
|
||||
# App\Mcp\Security\McpHeaderAuthenticator:
|
||||
# arguments:
|
||||
# $mcpAuthLimiter: '@limiter.mcp_auth'
|
||||
|
||||
App\OpenApi\OpenApiDecorator:
|
||||
decorates: 'api_platform.openapi.factory'
|
||||
arguments:
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<testsuites>
|
||||
<testsuite name="Project Test Suite">
|
||||
<directory>tests</directory>
|
||||
<exclude>tests/Mcp</exclude>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
|
||||
100
src/Mcp/Security/McpHeaderAuthenticator.php
Normal file
100
src/Mcp/Security/McpHeaderAuthenticator.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Security;
|
||||
|
||||
use App\Entity\Profile;
|
||||
use App\Repository\ProfileRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||
|
||||
final class McpHeaderAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProfileRepository $profiles,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly RateLimiterFactory $mcpAuthLimiter,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
if (!$request->headers->has('X-Profile-Id') || !$request->headers->has('X-Profile-Password')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
$profileId = $request->headers->get('X-Profile-Id', '');
|
||||
$password = $request->headers->get('X-Profile-Password', '');
|
||||
|
||||
$limiter = $this->mcpAuthLimiter->create($request->getClientIp() ?? 'unknown');
|
||||
$limit = $limiter->consume(1);
|
||||
|
||||
if (!$limit->isAccepted()) {
|
||||
$this->logger->warning('MCP auth rate limited', ['ip' => $request->getClientIp()]);
|
||||
|
||||
throw new CustomUserMessageAuthenticationException('Rate limited: too many authentication attempts.');
|
||||
}
|
||||
|
||||
return new SelfValidatingPassport(
|
||||
new UserBadge($profileId, function (string $id) use ($password, $limiter, $request): Profile {
|
||||
$profile = $this->profiles->find($id);
|
||||
|
||||
if (!$profile || !$profile->isActive()) {
|
||||
$this->logger->warning('MCP auth failed: profile not found', ['profileId' => $id]);
|
||||
|
||||
throw new CustomUserMessageAuthenticationException('Authentication failed: invalid credentials.');
|
||||
}
|
||||
|
||||
if (!$this->passwordHasher->isPasswordValid($profile, $password)) {
|
||||
$this->logger->warning('MCP auth failed: invalid password', ['profileId' => $id]);
|
||||
|
||||
throw new CustomUserMessageAuthenticationException('Authentication failed: invalid credentials.');
|
||||
}
|
||||
|
||||
$limiter->reset();
|
||||
|
||||
$this->logger->info('MCP auth success', [
|
||||
'profileId' => $id,
|
||||
'roles' => $profile->getRoles(),
|
||||
'ip' => $request->getClientIp(),
|
||||
]);
|
||||
|
||||
return $profile;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||
{
|
||||
$statusCode = str_contains($exception->getMessageKey(), 'Rate limited')
|
||||
? Response::HTTP_TOO_MANY_REQUESTS
|
||||
: Response::HTTP_UNAUTHORIZED;
|
||||
|
||||
return new JsonResponse(
|
||||
['message' => $exception->getMessageKey()],
|
||||
$statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
15
symfony.lock
15
symfony.lock
@@ -94,6 +94,18 @@
|
||||
"config/packages/nelmio_cors.yaml"
|
||||
]
|
||||
},
|
||||
"php-http/discovery": {
|
||||
"version": "1.20",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.18",
|
||||
"ref": "f45b5dd173a27873ab19f5e3180b2f661c21de02"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/http_discovery.yaml"
|
||||
]
|
||||
},
|
||||
"phpunit/phpunit": {
|
||||
"version": "12.5",
|
||||
"recipe": {
|
||||
@@ -154,6 +166,9 @@
|
||||
".editorconfig"
|
||||
]
|
||||
},
|
||||
"symfony/mcp-bundle": {
|
||||
"version": "v0.6.0"
|
||||
},
|
||||
"symfony/property-info": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
|
||||
84
tests/Mcp/Security/McpHeaderAuthenticatorTest.php
Normal file
84
tests/Mcp/Security/McpHeaderAuthenticatorTest.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Security;
|
||||
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class McpHeaderAuthenticatorTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testMcpEndpointRejectsWithoutCredentials(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('POST', '/_mcp', [
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'body' => $this->mcpRequest(),
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testMcpEndpointRejectsInvalidPassword(): void
|
||||
{
|
||||
$profile = $this->createProfile(
|
||||
roles: ['ROLE_VIEWER'],
|
||||
password: 'correct-password',
|
||||
);
|
||||
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('POST', '/_mcp', [
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Profile-Id' => $profile->getId(),
|
||||
'X-Profile-Password' => 'wrong-password',
|
||||
],
|
||||
'body' => $this->mcpRequest(),
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testMcpEndpointAcceptsValidCredentials(): void
|
||||
{
|
||||
$profile = $this->createProfile(
|
||||
roles: ['ROLE_VIEWER'],
|
||||
password: 'valid-password',
|
||||
);
|
||||
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('POST', '/_mcp', [
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Profile-Id' => $profile->getId(),
|
||||
'X-Profile-Password' => 'valid-password',
|
||||
],
|
||||
'body' => $this->mcpRequest(),
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
private function mcpRequest(array $headers = [], array $body = []): string
|
||||
{
|
||||
$default = [
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => 'initialize',
|
||||
'params' => [
|
||||
'protocolVersion' => '2025-03-26',
|
||||
'capabilities' => new stdClass(),
|
||||
'clientInfo' => ['name' => 'test', 'version' => '1.0'],
|
||||
],
|
||||
'id' => 1,
|
||||
];
|
||||
|
||||
return json_encode(array_merge($default, $body));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user