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",
|
||||
"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.*",
|
||||
|
||||
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",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "c696ed50e5c4bd8d3ea0af51a2a8a6c8",
|
||||
"content-hash": "b15a7808211e724ca29dd78602df3aab",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -2755,6 +2755,84 @@
|
||||
},
|
||||
"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",
|
||||
"version": "2.6.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
mcp:
|
||||
app: 'inventory'
|
||||
version: '%env(file:resolve:VERSION)%'
|
||||
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.
|
||||
|
||||
@@ -27,6 +27,12 @@ security:
|
||||
pattern: ^/api/session/profiles?$
|
||||
security: false
|
||||
|
||||
mcp:
|
||||
pattern: ^/_mcp
|
||||
stateless: true
|
||||
custom_authenticators:
|
||||
- App\Mcp\Security\McpHeaderAuthenticator
|
||||
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: false
|
||||
@@ -49,6 +55,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 }
|
||||
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }
|
||||
|
||||
@@ -34,6 +34,10 @@ services:
|
||||
tags:
|
||||
- { name: doctrine.event_subscriber }
|
||||
|
||||
App\Mcp\Security\McpHeaderAuthenticator:
|
||||
arguments:
|
||||
$mcpAuthLimiter: '@limiter.mcp_auth'
|
||||
|
||||
App\OpenApi\OpenApiDecorator:
|
||||
decorates: 'api_platform.openapi.factory'
|
||||
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