install('test@leantime.io', 'Test123456!', 'John', 'Smith', 'Smith & Co'); } #[Group('mcp')] public function mcpEndpointRequiresAuth(AcceptanceTester $I, Scenario $scenario) { $this->enableMcpPluginOrSkip($I, $scenario); $I->haveHttpHeader('Content-Type', 'application/json'); $I->sendPost(self::MCP_PATH, json_encode($this->initializeRequest('2025-06-18'), JSON_THROW_ON_ERROR)); $I->seeResponseCodeIs(401); } #[Group('mcp')] public function mcpHandshakeNegotiatesProtocolVersions(AcceptanceTester $I, Scenario $scenario) { $this->enableMcpPluginOrSkip($I, $scenario); $this->mintBearerToken($I); // Baseline: a revision from laravel/mcp v0.1.1's built-in list. $response = $this->mcp($I, $this->initializeRequest('2025-06-18')); Assert::assertSame('2025-06-18', $response['result']['protocolVersion'] ?? null, 'initialize failed: '.json_encode($response)); Assert::assertNotEmpty($response['result']['serverInfo']['name'] ?? null); // Regression gate for the Claude Code handshake: current clients request 2025-11-25. // v0.1.1 rejects unknown revisions instead of downgrading (spec says downgrade), so the // server class must keep newer revisions in $supportedProtocolVersion (plugins#60). $response = $this->mcp($I, $this->initializeRequest('2025-11-25')); Assert::assertArrayNotHasKey( 'error', $response, 'Server rejected protocol 2025-11-25 — Claude Code cannot connect. ' .'LeantimeMcpServer::$supportedProtocolVersion must include it: '.json_encode($response) ); } #[Group('mcp')] public function mcpListsAllRegisteredTools(AcceptanceTester $I, Scenario $scenario) { $this->enableMcpPluginOrSkip($I, $scenario); $this->mintBearerToken($I); // tools/list paginates (15 per page in v0.1.1) — walk every cursor. $toolNames = []; $cursor = null; $guard = 0; do { $params = $cursor === null ? new \stdClass : ['cursor' => $cursor]; $response = $this->mcp($I, [ 'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list', 'params' => $params, ]); Assert::assertArrayNotHasKey('error', $response, 'tools/list failed: '.json_encode($response)); foreach ($response['result']['tools'] ?? [] as $tool) { $toolNames[] = $tool['name']; } $cursor = $response['result']['nextCursor'] ?? null; } while ($cursor !== null && ++$guard < 20); Assert::assertGreaterThanOrEqual(56, count($toolNames), 'Expected the full tool catalog, got: '.implode(', ', $toolNames)); // One representative per domain — catches a whole domain falling out of the registry. foreach (['findTasks', 'getAllProjects', 'getAllGoals', 'getCalendar', 'getComments', 'logTime'] as $expected) { Assert::assertContains($expected, $toolNames, "Tool {$expected} missing from tools/list"); } } #[Group('mcp')] public function mcpToolCallLifecycle(AcceptanceTester $I, Scenario $scenario) { $this->enableMcpPluginOrSkip($I, $scenario); $this->mintBearerToken($I); // Create a task, read it back, patch it — the minimal write→read→write contract that // exercises DI-constructed tools, session auth context, and the Tickets service layer. $response = $this->callTool($I, 'addTask', [ 'headline' => 'MCP contract test task', 'projectId' => 1, ]); $text = $this->toolText($response); Assert::assertFalse($response['result']['isError'] ?? true, 'addTask errored: '.$text); Assert::assertSame(1, preg_match('/ID:?\s*(\d+)/', $text, $matches), 'addTask did not return an id: '.$text); $taskId = (int) $matches[1]; $response = $this->callTool($I, 'getTicket', ['id' => $taskId]); Assert::assertFalse($response['result']['isError'] ?? true, 'getTicket errored'); Assert::assertStringContainsString('MCP contract test task', $this->toolText($response)); $response = $this->callTool($I, 'editTask', [ 'id' => $taskId, 'params' => ['headline' => 'MCP contract test task (edited)'], ]); Assert::assertFalse($response['result']['isError'] ?? true, 'editTask errored: '.$this->toolText($response)); $response = $this->callTool($I, 'getTicket', ['id' => $taskId]); Assert::assertStringContainsString('(edited)', $this->toolText($response), 'edit did not persist'); } #[Group('mcp')] public function mcpRejectsUnknownTool(AcceptanceTester $I, Scenario $scenario) { $this->enableMcpPluginOrSkip($I, $scenario); $this->mintBearerToken($I); // laravel/mcp reports unknown tools as an isError tool result ("Tool not found"), // not a JSON-RPC error object. $response = $this->callTool($I, 'definitelyNotATool', []); Assert::assertTrue($response['result']['isError'] ?? false, 'Unknown tool should produce an error result: '.json_encode($response)); } /** * Enable the McpServer plugin for this test (row is rolled back by the Db module afterwards), * or skip when the plugin code is not present (public OSS checkout — app/Plugins is a private * submodule). The test runner shares the app container, so the folder check is authoritative. */ private function enableMcpPluginOrSkip(AcceptanceTester $I, Scenario $scenario): void { if (! is_dir(dirname(__DIR__, 3).'/app/Plugins/McpServer')) { $scenario->skip('McpServer plugin not present (app/Plugins submodule not checked out)'); } $I->haveInDatabase('zp_plugins', [ 'name' => 'leantime/mcpServer', 'enabled' => 1, 'description' => 'MCP Server (acceptance fixture)', 'version' => '1.0.0', 'installdate' => date('Y-m-d H:i:s'), 'foldername' => 'McpServer', 'homepage' => 'https://leantime.io', 'authors' => '[]', 'license' => '', 'format' => 'folder', ]); } /** * Mint a Bearer token directly in the DB — sha256 of an opaque string, exactly as * AccessTokenRepository::createToken persists it (same approach as BearerApiCest). */ private function mintBearerToken(AcceptanceTester $I): void { $userId = $I->grabFromDatabase('zp_user', 'id', ['username' => 'test@leantime.io']); Assert::assertNotEmpty($userId, 'Test user not found after install'); $token = bin2hex(random_bytes(20)); $I->haveInDatabase('zp_access_tokens', [ 'tokenable_type' => 'Leantime\\Domain\\Auth\\Services\\Auth', 'tokenable_id' => (int) $userId, 'name' => 'mcp-cest', 'token' => hash('sha256', $token), 'abilities' => json_encode(['*']), 'created_at' => date('Y-m-d H:i:s'), ]); $I->haveHttpHeader('Authorization', 'Bearer '.$token); } /** * POST a JSON-RPC payload to /mcp and return the decoded response. */ private function mcp(AcceptanceTester $I, array $payload): array { $I->haveHttpHeader('Content-Type', 'application/json'); $I->haveHttpHeader('Accept', 'application/json'); $I->sendPost(self::MCP_PATH, json_encode($payload, JSON_THROW_ON_ERROR)); $I->seeResponseCodeIs(200); $I->seeResponseIsJson(); return json_decode($I->grabResponse(), true, 512, JSON_THROW_ON_ERROR); } /** * Invoke an MCP tool via tools/call. */ private function callTool(AcceptanceTester $I, string $name, array $arguments): array { return $this->mcp($I, [ 'jsonrpc' => '2.0', 'id' => 3, 'method' => 'tools/call', 'params' => [ 'name' => $name, 'arguments' => $arguments === [] ? new \stdClass : $arguments, ], ]); } /** * Text content of a tools/call response ('' when the shape is unexpected). */ private function toolText(array $response): string { return $response['result']['content'][0]['text'] ?? ''; } /** * A spec-shaped initialize request for the given protocol revision. */ private function initializeRequest(string $protocolVersion): array { return [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => [ 'protocolVersion' => $protocolVersion, 'capabilities' => new \stdClass, 'clientInfo' => ['name' => 'mcp-cest', 'version' => '1.0'], ], ]; } }