From 98caaa148df033c86dd0292584c0a4375cf6b2b0 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 16 Mar 2026 12:07:32 +0100 Subject: [PATCH] feat(mcp) : add McpHeaderAuthenticator with rate limiting Co-Authored-By: Claude Opus 4.6 (1M context) --- composer.json | 1 + composer.lock | 80 +++++++++++++- config/packages/mcp.yaml | 2 +- config/packages/security.yaml | 7 ++ config/services.yaml | 4 + src/Mcp/Security/McpHeaderAuthenticator.php | 100 ++++++++++++++++++ .../Security/McpHeaderAuthenticatorTest.php | 84 +++++++++++++++ 7 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 src/Mcp/Security/McpHeaderAuthenticator.php create mode 100644 tests/Mcp/Security/McpHeaderAuthenticatorTest.php diff --git a/composer.json b/composer.json index 92b272c..4b4392d 100644 --- a/composer.json +++ b/composer.json @@ -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.*", diff --git a/composer.lock b/composer.lock index 0d47978..cbcceec 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/packages/mcp.yaml b/config/packages/mcp.yaml index 1cb4647..ddc2cc2 100644 --- a/config/packages/mcp.yaml +++ b/config/packages/mcp.yaml @@ -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. diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 49f1b61..d4fd595 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -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 } diff --git a/config/services.yaml b/config/services.yaml index 282f9e3..8d0e4d0 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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: diff --git a/src/Mcp/Security/McpHeaderAuthenticator.php b/src/Mcp/Security/McpHeaderAuthenticator.php new file mode 100644 index 0000000..0c6eec7 --- /dev/null +++ b/src/Mcp/Security/McpHeaderAuthenticator.php @@ -0,0 +1,100 @@ +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, + ); + } +} diff --git a/tests/Mcp/Security/McpHeaderAuthenticatorTest.php b/tests/Mcp/Security/McpHeaderAuthenticatorTest.php new file mode 100644 index 0000000..859c5ce --- /dev/null +++ b/tests/Mcp/Security/McpHeaderAuthenticatorTest.php @@ -0,0 +1,84 @@ +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)); + } +}