From e335f4c24ca3fdb3d7a1146778d072bba944c24a Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 16 Mar 2026 14:18:09 +0100 Subject: [PATCH] feat(mcp) : add stdio auth, dashboard stats PoC tool, and helper trait - McpStdioAuthSubscriber for console transport auth via env vars - DashboardStatsTool as PoC (validates MCP protocol flow) - McpToolHelper trait for shared pagination/error utilities - Key learning: #[McpTool] must be on CLASS, not method for __invoke Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Mcp/Security/McpStdioAuthSubscriber.php | 71 ++++++++++++ src/Mcp/Tool/DashboardStatsTool.php | 50 +++++++++ src/Mcp/Tool/McpToolHelper.php | 58 ++++++++++ tests/Mcp/Tool/DashboardStatsToolTest.php | 115 ++++++++++++++++++++ 4 files changed, 294 insertions(+) create mode 100644 src/Mcp/Security/McpStdioAuthSubscriber.php create mode 100644 src/Mcp/Tool/DashboardStatsTool.php create mode 100644 src/Mcp/Tool/McpToolHelper.php create mode 100644 tests/Mcp/Tool/DashboardStatsToolTest.php diff --git a/src/Mcp/Security/McpStdioAuthSubscriber.php b/src/Mcp/Security/McpStdioAuthSubscriber.php new file mode 100644 index 0000000..9ae2591 --- /dev/null +++ b/src/Mcp/Security/McpStdioAuthSubscriber.php @@ -0,0 +1,71 @@ +getCommand(); + + if (!$command || !str_starts_with($command->getName() ?? '', 'mcp:')) { + return; + } + + $profileId = $_ENV['MCP_PROFILE_ID'] ?? ''; + $password = $_ENV['MCP_PROFILE_PASSWORD'] ?? ''; + + if ('' === $profileId || '' === $password) { + $this->logger->error('MCP stdio: missing MCP_PROFILE_ID or MCP_PROFILE_PASSWORD env vars'); + $event->disableCommand(); + $event->getOutput()->writeln('MCP auth: MCP_PROFILE_ID and MCP_PROFILE_PASSWORD env vars required'); + + return; + } + + $profile = $this->profiles->find($profileId); + + if (!$profile || !$profile->isActive()) { + $this->logger->error('MCP stdio: profile not found or inactive', ['profileId' => $profileId]); + $event->disableCommand(); + $event->getOutput()->writeln('MCP auth: invalid profile'); + + return; + } + + if (!$this->passwordHasher->isPasswordValid($profile, $password)) { + $this->logger->error('MCP stdio: invalid password', ['profileId' => $profileId]); + $event->disableCommand(); + $event->getOutput()->writeln('MCP auth: invalid password'); + + return; + } + + $token = new UsernamePasswordToken($profile, 'mcp', $profile->getRoles()); + $this->tokenStorage->setToken($token); + + $this->logger->info('MCP stdio auth success', [ + 'profileId' => $profileId, + 'roles' => $profile->getRoles(), + ]); + } +} diff --git a/src/Mcp/Tool/DashboardStatsTool.php b/src/Mcp/Tool/DashboardStatsTool.php new file mode 100644 index 0000000..f592fb8 --- /dev/null +++ b/src/Mcp/Tool/DashboardStatsTool.php @@ -0,0 +1,50 @@ +em->createQuery( + "SELECT COUNT(c.id) FROM App\\Entity\\Comment c WHERE c.status = 'open'" + )->getSingleScalarResult(); + + return [ + new TextContent( + text: json_encode([ + 'machines' => $this->machines->count([]), + 'pieces' => $this->pieces->count([]), + 'composants' => $this->composants->count([]), + 'products' => $this->products->count([]), + 'sites' => $this->sites->count([]), + 'unresolvedComments' => $unresolvedComments, + ], JSON_THROW_ON_ERROR) + ), + ]; + } +} diff --git a/src/Mcp/Tool/McpToolHelper.php b/src/Mcp/Tool/McpToolHelper.php new file mode 100644 index 0000000..96c3afd --- /dev/null +++ b/src/Mcp/Tool/McpToolHelper.php @@ -0,0 +1,58 @@ +isGranted($role)) { + throw new RuntimeException("Permission denied: {$role} required."); + } + } + + /** + * @return array{TextContent} + */ + private function jsonResponse(array $data): array + { + return [new TextContent(text: json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE))]; + } + + private function mcpError(string $category, string $message): never + { + throw new RuntimeException("{$category}: {$message}"); + } + + /** + * @return array{page: int, limit: int, offset: int} + */ + private function paginationParams(int $page = 1, int $limit = 30): array + { + $page = max(1, $page); + $limit = min(100, max(1, $limit)); + $offset = ($page - 1) * $limit; + + return ['page' => $page, 'limit' => $limit, 'offset' => $offset]; + } + + /** + * @return array{TextContent} + */ + private function paginatedResponse(array $items, int $total, int $page, int $limit): array + { + return $this->jsonResponse([ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'limit' => $limit, + 'pageCount' => (int) ceil($total / max(1, $limit)), + ]); + } +} diff --git a/tests/Mcp/Tool/DashboardStatsToolTest.php b/tests/Mcp/Tool/DashboardStatsToolTest.php new file mode 100644 index 0000000..bcc5b56 --- /dev/null +++ b/tests/Mcp/Tool/DashboardStatsToolTest.php @@ -0,0 +1,115 @@ +createSite(); + $this->createMachine(name: 'Machine Stats 1', site: $site); + $this->createMachine(name: 'Machine Stats 2', site: $site); + + $profile = $this->createProfile(roles: ['ROLE_VIEWER'], password: 'test123'); + $session = $this->initMcpSession($profile->getId(), 'test123'); + + $response = $session['client']->request('POST', '/_mcp', [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-Profile-Id' => $profile->getId(), + 'X-Profile-Password' => 'test123', + 'Mcp-Session-Id' => $session['sessionId'], + ], + 'body' => json_encode([ + 'jsonrpc' => '2.0', + 'method' => 'tools/call', + 'params' => [ + 'name' => 'get_dashboard_stats', + 'arguments' => new stdClass(), + ], + 'id' => 2, + ]), + ]); + + // First list tools to see what's registered + $listResponse = $session['client']->request('POST', '/_mcp', [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-Profile-Id' => $profile->getId(), + 'X-Profile-Password' => 'test123', + 'Mcp-Session-Id' => $session['sessionId'], + ], + 'body' => json_encode([ + 'jsonrpc' => '2.0', + 'method' => 'tools/list', + 'params' => new stdClass(), + 'id' => 3, + ]), + ]); + $toolsList = $listResponse->toArray(false); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertArrayHasKey('result', $data, 'Tools list: '.json_encode($toolsList).' | Call response: '.json_encode($data)); + + // Parse the text content from the MCP response + $content = $data['result']['content'][0]['text'] ?? ''; + $stats = json_decode($content, true); + $this->assertIsArray($stats); + $this->assertGreaterThanOrEqual(2, $stats['machines']); + $this->assertArrayHasKey('sites', $stats); + $this->assertArrayHasKey('unresolvedComments', $stats); + } + + private function initMcpSession(string $profileId, string $password): array + { + $client = static::createClient(); + + // Step 1: Initialize MCP session + $response = $client->request('POST', '/_mcp', [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-Profile-Id' => $profileId, + 'X-Profile-Password' => $password, + ], + 'body' => json_encode([ + 'jsonrpc' => '2.0', + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-03-26', + 'capabilities' => new stdClass(), + 'clientInfo' => ['name' => 'test', 'version' => '1.0'], + ], + 'id' => 1, + ]), + ]); + + $this->assertResponseIsSuccessful(); + $sessionId = $response->getHeaders()['mcp-session-id'][0] ?? ''; + $this->assertNotEmpty($sessionId, 'MCP session ID should be returned'); + + // Step 2: Send initialized notification + $client->request('POST', '/_mcp', [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-Profile-Id' => $profileId, + 'X-Profile-Password' => $password, + 'Mcp-Session-Id' => $sessionId, + ], + 'body' => json_encode([ + 'jsonrpc' => '2.0', + 'method' => 'notifications/initialized', + ]), + ]); + + return ['client' => $client, 'sessionId' => $sessionId]; + } +}