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];
+ }
+}