feat(mcp) : add McpHeaderAuthenticator with rate limiting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
"doctrine/orm": "^3.6",
|
"doctrine/orm": "^3.6",
|
||||||
"lexik/jwt-authentication-bundle": "^3.2",
|
"lexik/jwt-authentication-bundle": "^3.2",
|
||||||
"nelmio/cors-bundle": "^2.6",
|
"nelmio/cors-bundle": "^2.6",
|
||||||
|
"nyholm/psr7": "^1.8",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6",
|
"phpdocumentor/reflection-docblock": "^5.6",
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
"phpstan/phpdoc-parser": "^2.3",
|
||||||
"symfony/asset": "8.0.*",
|
"symfony/asset": "8.0.*",
|
||||||
|
|||||||
80
composer.lock
generated
80
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "c696ed50e5c4bd8d3ea0af51a2a8a6c8",
|
"content-hash": "b15a7808211e724ca29dd78602df3aab",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -2755,6 +2755,84 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-10-23T06:57:22+00:00"
|
"time": "2025-10-23T06:57:22+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "nyholm/psr7",
|
||||||
|
"version": "1.8.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Nyholm/psr7.git",
|
||||||
|
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
|
||||||
|
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.2",
|
||||||
|
"psr/http-factory": "^1.0",
|
||||||
|
"psr/http-message": "^1.1 || ^2.0"
|
||||||
|
},
|
||||||
|
"provide": {
|
||||||
|
"php-http/message-factory-implementation": "1.0",
|
||||||
|
"psr/http-factory-implementation": "1.0",
|
||||||
|
"psr/http-message-implementation": "1.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"http-interop/http-factory-tests": "^0.9",
|
||||||
|
"php-http/message-factory": "^1.0",
|
||||||
|
"php-http/psr7-integration-tests": "^1.0",
|
||||||
|
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
|
||||||
|
"symfony/error-handler": "^4.4"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.8-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Nyholm\\Psr7\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Tobias Nyholm",
|
||||||
|
"email": "tobias.nyholm@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Martijn van der Ven",
|
||||||
|
"email": "martijn@vanderven.se"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A fast PHP7 implementation of PSR-7",
|
||||||
|
"homepage": "https://tnyholm.se",
|
||||||
|
"keywords": [
|
||||||
|
"psr-17",
|
||||||
|
"psr-7"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Nyholm/psr7/issues",
|
||||||
|
"source": "https://github.com/Nyholm/psr7/tree/1.8.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/Zegnat",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nyholm",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2024-09-09T07:06:30+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "opis/json-schema",
|
"name": "opis/json-schema",
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
mcp:
|
mcp:
|
||||||
app: 'inventory'
|
app: 'inventory'
|
||||||
version: '%env(file:resolve:VERSION)%'
|
version: '1.0.0'
|
||||||
description: 'Inventory MCP Server - Gestion inventaire industriel (machines, pièces, composants, produits)'
|
description: 'Inventory MCP Server - Gestion inventaire industriel (machines, pièces, composants, produits)'
|
||||||
instructions: |
|
instructions: |
|
||||||
Serveur MCP pour gérer un inventaire industriel.
|
Serveur MCP pour gérer un inventaire industriel.
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ security:
|
|||||||
pattern: ^/api/session/profiles?$
|
pattern: ^/api/session/profiles?$
|
||||||
security: false
|
security: false
|
||||||
|
|
||||||
|
mcp:
|
||||||
|
pattern: ^/_mcp
|
||||||
|
stateless: true
|
||||||
|
custom_authenticators:
|
||||||
|
- App\Mcp\Security\McpHeaderAuthenticator
|
||||||
|
|
||||||
api:
|
api:
|
||||||
pattern: ^/api
|
pattern: ^/api
|
||||||
stateless: false
|
stateless: false
|
||||||
@@ -49,6 +55,7 @@ security:
|
|||||||
- { path: ^/api/admin, roles: ROLE_ADMIN }
|
- { path: ^/api/admin, roles: ROLE_ADMIN }
|
||||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
|
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/_mcp, roles: ROLE_USER }
|
||||||
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }
|
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ services:
|
|||||||
tags:
|
tags:
|
||||||
- { name: doctrine.event_subscriber }
|
- { name: doctrine.event_subscriber }
|
||||||
|
|
||||||
|
App\Mcp\Security\McpHeaderAuthenticator:
|
||||||
|
arguments:
|
||||||
|
$mcpAuthLimiter: '@limiter.mcp_auth'
|
||||||
|
|
||||||
App\OpenApi\OpenApiDecorator:
|
App\OpenApi\OpenApiDecorator:
|
||||||
decorates: 'api_platform.openapi.factory'
|
decorates: 'api_platform.openapi.factory'
|
||||||
arguments:
|
arguments:
|
||||||
|
|||||||
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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