#!/usr/bin/env php */ public array $requests = []; /** @var array */ private array $responses = []; public function queueJson(array $payload, int $status = 200): void { $encoded = json_encode($payload); if ($encoded === false) { throw new RuntimeException('Could not encode fixture JSON.'); } $this->responses[] = HttpFactory::makeResponse($status, 'application/json', $encoded); } public function queueBinary(string $content, string $contentType = 'application/octet-stream', int $status = 200): void { $this->responses[] = HttpFactory::makeResponse($status, $contentType, $content); } public function request(Request $request): Response { $this->requests[] = [ 'method' => $request->getMethod(), 'path' => $request->getPath(), 'content_type' => $request->getContentType(), 'content' => $request->getContent(), ]; return array_shift($this->responses) ?? HttpFactory::makeResponse(200, 'application/json', '{}'); } public function getApi(string $name): Api { throw new RuntimeException('Unexpected vendor API call for ' . $name); } public function startImpersonateUser(string $username): void {} public function stopImpersonateUser(): void {} public function requestGet(string $path): bool { return false; } public function requestPost(string $path, string $body): bool { return false; } public function requestPut(string $path, string $body): bool { return false; } public function requestDelete(string $path): bool { return false; } public function getLastResponseStatusCode(): int { return 0; } public function getLastResponseContentType(): string { return ''; } public function getLastResponseBody(): string { return ''; } } final class RedmineStructureTest { private int $assertions = 0; public function run(): void { $this->testCreateIssuePreservesStructureFields(); $this->testUpdateIssuePreservesParentAndUploads(); $this->testMcpCreateIssueAcceptsFlatIssueFields(); $this->testMcpUpdateIssueAcceptsFlatIssueFields(); $this->testMcpFindProjectRecommendsExactIdentifier(); $this->testMcpFindProjectRecommendsExactName(); $this->testMcpFindProjectLeavesAmbiguousMatchesUnrecommended(); $this->testMcpSearchSanitizesNoisyTextFields(); $this->testMcpSearchCanDisableTextSanitization(); $this->testCreateRelationDefaultsToRelatesAndRequiresTarget(); $this->testAttachmentUploadSupportsPathAndBase64(); $this->testAttachmentUploadAcceptsPdfDataUrl(); $this->testAttachmentUploadAcceptsFileEnvelope(); $this->testDownloadPathValidationRejectsUnsafePaths(); $this->testDownloadAttachmentWritesSafePathAndLimitsBase64(); $this->testMcpToolListExposesStructureToolsWithoutIssueDelete(); fwrite(STDOUT, "OK {$this->assertions} assertions\n"); } private function testCreateIssuePreservesStructureFields(): void { $http = new RecordingClient(); $http->queueJson(['issue' => ['id' => 123]], 201); $client = new RedmineClient($http); $result = $client->createIssue([ 'project_id' => 'fud-nohelpdesk', 'subject' => 'Child issue', 'parent_issue_id' => 99, 'category_id' => 4, 'uploads' => [ ['token' => 'tok-1', 'filename' => 'note.txt', 'content_type' => 'text/plain'], ], ]); $request = $http->requests[0]; $payload = $this->json($request['content']); $this->assertSame('POST', $request['method'], 'create issue uses POST'); $this->assertSame('/issues.json', $request['path'], 'create issue uses raw JSON endpoint'); $this->assertSame(99, $payload['issue']['parent_issue_id'], 'create issue preserves parent_issue_id'); $this->assertSame(4, $payload['issue']['category_id'], 'create issue preserves category_id'); $this->assertSame('tok-1', $payload['issue']['uploads'][0]['token'], 'create issue preserves upload tokens'); $this->assertSame(123, $result['id'], 'create issue unwraps issue response'); } private function testUpdateIssuePreservesParentAndUploads(): void { $http = new RecordingClient(); $http->queueJson([], 204); $client = new RedmineClient($http); $ok = $client->updateIssue(123, [ 'parent_id' => 99, 'category_id' => 4, 'uploads' => [ ['token' => 'tok-2', 'filename' => 'followup.txt'], ], ]); $request = $http->requests[0]; $payload = $this->json($request['content']); $this->assertSame(true, $ok, 'update issue returns true on 204'); $this->assertSame('PUT', $request['method'], 'update issue uses PUT'); $this->assertSame('/issues/123.json', $request['path'], 'update issue uses raw JSON endpoint'); $this->assertSame(99, $payload['issue']['parent_id'], 'update issue preserves parent_id'); $this->assertSame('tok-2', $payload['issue']['uploads'][0]['token'], 'update issue preserves upload tokens'); } private function testMcpCreateIssueAcceptsFlatIssueFields(): void { $http = new RecordingClient(); $http->queueJson(['issue' => ['id' => 321]], 201); $dispatcher = new McpDispatcher(new RedmineClient($http)); $response = $dispatcher->handleMessage([ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => [ 'name' => 'redmine_create_issue', 'arguments' => [ 'project_id' => 'quality-tracker', 'subject' => 'Front warehouse deadbolt key gets stuck in lock', 'description' => "Problem: The deadbolt on the front door is sticking.\n\n~HermesBot", ], ], ]); if (!is_array($response) || isset($response['error'])) { throw new RuntimeException('Expected flat create issue call to succeed: ' . json_encode($response)); } $payload = $this->json($http->requests[0]['content']); $this->assertSame('quality-tracker', $payload['issue']['project_id'], 'flat MCP create preserves project_id'); $this->assertSame('Front warehouse deadbolt key gets stuck in lock', $payload['issue']['subject'], 'flat MCP create preserves subject'); $this->assertStringContains('~HermesBot', $payload['issue']['description'], 'flat MCP create preserves multiline description'); } private function testMcpUpdateIssueAcceptsFlatIssueFields(): void { $http = new RecordingClient(); $http->queueJson([], 204); $dispatcher = new McpDispatcher(new RedmineClient($http)); $response = $dispatcher->handleMessage([ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => [ 'name' => 'redmine_update_issue', 'arguments' => [ 'issue_id' => 321, 'notes' => 'Locksmith has been scheduled.', 'status_id' => 2, 'options' => ['send_helpdesk_email' => false], ], ], ]); if (!is_array($response) || isset($response['error'])) { throw new RuntimeException('Expected flat update issue call to succeed: ' . json_encode($response)); } $payload = $this->json($http->requests[0]['content']); $this->assertSame('/issues/321.json', $http->requests[0]['path'], 'flat MCP update uses requested issue id'); $this->assertSame('Locksmith has been scheduled.', $payload['issue']['notes'], 'flat MCP update preserves notes'); $this->assertSame(2, $payload['issue']['status_id'], 'flat MCP update preserves status_id'); $this->assertSame(false, isset($payload['issue']['options']), 'flat MCP update does not forward options as issue field'); } private function testMcpFindProjectRecommendsExactIdentifier(): void { $http = new RecordingClient(); $http->queueJson(['projects' => $this->projectFixtures()]); $dispatcher = new McpDispatcher(new RedmineClient($http)); $result = $this->callToolJson($dispatcher, 'redmine_find_project', ['query' => 'quality-tracker']); $this->assertSame('quality-tracker', $result['recommended_project_id'], 'exact identifier produces recommendation'); $this->assertSame('quality-tracker', $result['matches'][0]['identifier'], 'exact identifier match is first'); $this->assertSame('exact_identifier', $result['matches'][0]['match_reason'], 'exact identifier reason is reported'); $this->assertSame('quality-tracker', $result['matches'][0]['project_id_to_use'], 'identifier is preferred project_id_to_use'); } private function testMcpFindProjectRecommendsExactName(): void { $http = new RecordingClient(); $http->queueJson(['projects' => $this->projectFixtures()]); $dispatcher = new McpDispatcher(new RedmineClient($http)); $result = $this->callToolJson($dispatcher, 'redmine_find_project', ['query' => 'Quality Tracker']); $this->assertSame('quality-tracker', $result['recommended_project_id'], 'exact project name produces recommendation'); $this->assertSame('Quality Tracker', $result['matches'][0]['name'], 'exact name match is first'); $this->assertSame('exact_name', $result['matches'][0]['match_reason'], 'exact name reason is reported'); } private function testMcpFindProjectLeavesAmbiguousMatchesUnrecommended(): void { $http = new RecordingClient(); $http->queueJson(['projects' => $this->projectFixtures()]); $dispatcher = new McpDispatcher(new RedmineClient($http)); $result = $this->callToolJson($dispatcher, 'redmine_find_project', ['query' => 'quality']); $this->assertSame(null, $result['recommended_project_id'], 'ambiguous project query has no recommendation'); $this->assertSame(2, count($result['matches']), 'ambiguous project query returns both matches'); $this->assertSame('quality-tracker', $result['matches'][0]['identifier'], 'first ambiguous match is ranked deterministically'); $this->assertSame('quality-archive', $result['matches'][1]['identifier'], 'second ambiguous match is returned'); } private function testMcpSearchSanitizesNoisyTextFields(): void { $http = new RecordingClient(); $http->queueJson([ 'results' => [[ 'title' => 'Ticket result', 'description' => "Caf\u{00E9}\u{200B} issue\x07 !!!!!!!!!!\n\n\n\nDone", 'notes' => "Agent\u{FEFF} note\x1F........", ]], ]); $dispatcher = new McpDispatcher(new RedmineClient($http)); $result = $this->callToolJson($dispatcher, 'redmine_search', ['query' => 'ticket']); $description = (string) $result['results'][0]['description']; $notes = (string) $result['results'][0]['notes']; $this->assertStringContains('Café issue', $description, 'sanitizer preserves readable unicode content'); $this->assertNotStringContains("\x07", $description, 'sanitizer removes control characters from description'); $this->assertNotStringContains("\u{200B}", $description, 'sanitizer removes zero-width characters from description'); $this->assertNotStringContains('!!!!!!!!!!', $description, 'sanitizer caps excessive repeated punctuation in description'); $this->assertNotStringContains("\n\n\n\n", $description, 'sanitizer caps excessive blank lines in description'); $this->assertNotStringContains("\x1F", $notes, 'sanitizer removes control characters from notes'); $this->assertNotStringContains('.........', $notes, 'sanitizer caps excessive repeated punctuation in notes'); } private function testMcpSearchCanDisableTextSanitization(): void { $http = new RecordingClient(); $http->queueJson([ 'results' => [[ 'description' => "Raw\u{200B} text\x07 !!!!!!!!!!", ]], ]); $dispatcher = new McpDispatcher(new RedmineClient($http), null, false); $result = $this->callToolJson($dispatcher, 'redmine_search', ['query' => 'ticket']); $description = (string) $result['results'][0]['description']; $this->assertStringContains("\u{200B}", $description, 'sanitization toggle off keeps zero-width characters untouched'); $this->assertStringContains("\x07", $description, 'sanitization toggle off keeps control characters untouched'); $this->assertStringContains('!!!!!!!!!!', $description, 'sanitization toggle off keeps repeated punctuation untouched'); } private function testCreateRelationDefaultsToRelatesAndRequiresTarget(): void { $http = new RecordingClient(); $http->queueJson(['relation' => ['id' => 55]], 201); $client = new RedmineClient($http); $result = $client->createIssueRelation(10, ['issue_to_id' => 11]); $request = $http->requests[0]; $payload = $this->json($request['content']); $this->assertSame('/issues/10/relations.json', $request['path'], 'relation create uses issue relations endpoint'); $this->assertSame(11, $payload['relation']['issue_to_id'], 'relation create sends issue_to_id'); $this->assertSame('relates', $payload['relation']['relation_type'], 'relation create defaults to relates'); $this->assertSame(55, $result['id'], 'relation create unwraps relation response'); $this->assertThrows( static fn() => $client->createIssueRelation(10, []), 'issue_to_id', 'relation create requires issue_to_id' ); } private function testAttachmentUploadSupportsPathAndBase64(): void { $path = sys_get_temp_dir() . '/redmcp-upload-test.txt'; file_put_contents($path, 'from path'); $http = new RecordingClient(); $http->queueJson(['upload' => ['token' => 'path-token']], 201); $http->queueJson(['upload' => ['token' => 'base64-token']], 201); $client = new RedmineClient($http); $pathResult = $client->uploadAttachment(['path' => $path, 'content_type' => 'text/plain']); $base64Result = $client->uploadAttachment([ 'base64_content' => base64_encode('from base64'), 'filename' => 'base64.txt', 'content_type' => 'text/plain', ]); $this->assertSame('path-token', $pathResult['token'], 'path upload unwraps token'); $this->assertStringContains('filename=redmcp-upload-test.txt', $http->requests[0]['path'], 'path upload uses basename as filename'); $this->assertSame('from path', $http->requests[0]['content'], 'path upload sends file bytes'); $this->assertSame('application/octet-stream', $http->requests[0]['content_type'], 'path upload sends bytes as Redmine upload stream'); $this->assertSame('text/plain', $pathResult['content_type'], 'path upload preserves desired attachment content type metadata'); $this->assertSame('base64-token', $base64Result['token'], 'base64 upload unwraps token'); $this->assertStringContains('filename=base64.txt', $http->requests[1]['path'], 'base64 upload uses provided filename'); $this->assertSame('from base64', $http->requests[1]['content'], 'base64 upload sends decoded bytes'); } private function testAttachmentUploadAcceptsPdfDataUrl(): void { $http = new RecordingClient(); $http->queueJson(['upload' => ['token' => 'pdf-token']], 201); $client = new RedmineClient($http); $result = $client->uploadAttachment([ 'data_url' => 'data:application/pdf;base64,' . base64_encode('%PDF-1.4 raw pdf bytes'), ]); $this->assertSame('pdf-token', $result['token'], 'PDF data URL upload unwraps token'); $this->assertStringContains('filename=attachment.pdf', $http->requests[0]['path'], 'PDF data URL derives a useful filename'); $this->assertSame('%PDF-1.4 raw pdf bytes', $http->requests[0]['content'], 'PDF data URL upload sends decoded bytes'); $this->assertSame('application/octet-stream', $http->requests[0]['content_type'], 'PDF data URL upload sends bytes as Redmine upload stream'); $this->assertSame('application/pdf', $result['content_type'], 'PDF data URL preserves PDF content type metadata'); } private function testAttachmentUploadAcceptsFileEnvelope(): void { $http = new RecordingClient(); $http->queueJson(['upload' => ['token' => 'file-token']], 201); $client = new RedmineClient($http); $result = $client->uploadAttachment([ 'file' => [ 'name' => 'quote.pdf', 'mime_type' => 'application/pdf', 'data' => base64_encode('%PDF-1.7 envelope bytes'), ], ]); $this->assertSame('file-token', $result['token'], 'file envelope upload unwraps token'); $this->assertStringContains('filename=quote.pdf', $http->requests[0]['path'], 'file envelope uses provided name as filename'); $this->assertSame('%PDF-1.7 envelope bytes', $http->requests[0]['content'], 'file envelope sends decoded bytes'); $this->assertSame('application/pdf', $result['content_type'], 'file envelope preserves MIME type metadata'); } private function testDownloadPathValidationRejectsUnsafePaths(): void { $client = new RedmineClient(new RecordingClient()); $this->assertThrows( static fn() => $client->downloadAttachment(77, '/etc/redmcp-forbidden.txt'), 'under /tmp or the repository tree', 'download rejects paths outside safe roots' ); } private function testDownloadAttachmentWritesSafePathAndLimitsBase64(): void { $destination = sys_get_temp_dir() . '/redmcp-download-test.txt'; if (file_exists($destination)) { unlink($destination); } $http = new RecordingClient(); $http->queueBinary('downloaded attachment', 'text/plain'); $client = new RedmineClient($http); $result = $client->downloadAttachment(77, $destination, true, 4); $this->assertSame('/attachments/download/77', $http->requests[0]['path'], 'download uses Redmine attachment download endpoint'); $this->assertSame('downloaded attachment', (string) file_get_contents($destination), 'download writes attachment bytes'); $this->assertSame(21, $result['bytes'], 'download reports byte length'); $this->assertSame(true, $result['base64_omitted'], 'download omits oversized base64 content'); } private function testMcpToolListExposesStructureToolsWithoutIssueDelete(): void { $dispatcher = new McpDispatcher(new RedmineClient(new RecordingClient())); $response = $dispatcher->handleMessage([ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list', ]); if (!is_array($response)) { throw new RuntimeException('Expected tools/list response.'); } $names = array_map( static fn(array $tool): string => (string) $tool['name'], $response['result']['tools'] ); foreach ([ 'redmine_list_issue_relations', 'redmine_find_project', 'redmine_get_issue_relation', 'redmine_create_issue_relation', 'redmine_remove_issue_relation', 'redmine_list_issue_children', 'redmine_set_issue_parent', 'redmine_clear_issue_parent', 'redmine_list_project_issue_categories', 'redmine_get_issue_category', 'redmine_create_issue_category', 'redmine_update_issue_category', 'redmine_get_attachment', 'redmine_upload_attachment', 'redmine_download_attachment', 'redmine_update_attachment', ] as $expectedTool) { $this->assertContains($expectedTool, $names, $expectedTool . ' is listed'); } $this->assertNotContains('redmine_delete_issue', $names, 'issue delete tool is not listed'); $uploadTool = null; foreach ($response['result']['tools'] as $tool) { if (($tool['name'] ?? '') === 'redmine_upload_attachment') { $uploadTool = $tool; break; } } if (!is_array($uploadTool)) { throw new RuntimeException('Expected redmine_upload_attachment tool schema.'); } $uploadProperties = array_keys($uploadTool['inputSchema']['properties']); $this->assertContains('data_url', $uploadProperties, 'upload tool advertises data_url input'); $this->assertContains('file', $uploadProperties, 'upload tool advertises file envelope input'); $createIssueTool = null; foreach ($response['result']['tools'] as $tool) { if (($tool['name'] ?? '') === 'redmine_create_issue') { $createIssueTool = $tool; break; } } if (!is_array($createIssueTool)) { throw new RuntimeException('Expected redmine_create_issue tool schema.'); } $projectDescription = (string) $createIssueTool['inputSchema']['properties']['project_id']['description']; $this->assertStringContains('redmine_find_project', $projectDescription, 'project_id schema points agents to project resolver'); } /** * @return array> */ private function projectFixtures(): array { return [ ['id' => 78, 'identifier' => 'quality-tracker', 'name' => 'Quality Tracker'], ['id' => 79, 'identifier' => 'quality-archive', 'name' => 'Quality Archive'], ['id' => 80, 'identifier' => 'warehouse', 'name' => 'Warehouse Operations'], ]; } /** * @param array $arguments * @return array */ private function callToolJson(McpDispatcher $dispatcher, string $name, array $arguments): array { $response = $dispatcher->handleMessage([ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => [ 'name' => $name, 'arguments' => $arguments, ], ]); if (!is_array($response) || isset($response['error'])) { throw new RuntimeException('Expected MCP tool call to succeed: ' . json_encode($response)); } $content = $response['result']['content'][0]['text'] ?? null; if (!is_string($content)) { throw new RuntimeException('Expected MCP tool text content.'); } return $this->json($content); } /** * @return array */ private function json(string $content): array { $decoded = json_decode($content, true); if (!is_array($decoded)) { throw new RuntimeException('Invalid JSON: ' . $content); } return $decoded; } /** * @param mixed $expected * @param mixed $actual */ private function assertSame($expected, $actual, string $message): void { $this->assertions++; if ($expected === $actual) { return; } fwrite(STDERR, "FAIL: {$message}\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true) . "\n"); exit(1); } private function assertStringContains(string $needle, string $haystack, string $message): void { $this->assertions++; if (strpos($haystack, $needle) !== false) { return; } fwrite(STDERR, "FAIL: {$message}\nNeedle: {$needle}\nHaystack: {$haystack}\n"); exit(1); } private function assertNotStringContains(string $needle, string $haystack, string $message): void { $this->assertions++; if (strpos($haystack, $needle) === false) { return; } fwrite(STDERR, "FAIL: {$message}\nUnexpected needle: {$needle}\nHaystack: {$haystack}\n"); exit(1); } /** * @param array $haystack */ private function assertContains(string $needle, array $haystack, string $message): void { $this->assertions++; if (in_array($needle, $haystack, true)) { return; } fwrite(STDERR, "FAIL: {$message}\nMissing: {$needle}\n"); exit(1); } /** * @param array $haystack */ private function assertNotContains(string $needle, array $haystack, string $message): void { $this->assertions++; if (!in_array($needle, $haystack, true)) { return; } fwrite(STDERR, "FAIL: {$message}\nUnexpected: {$needle}\n"); exit(1); } private function assertThrows(callable $callback, string $expectedMessagePart, string $message): void { $this->assertions++; try { $callback(); } catch (Throwable $exception) { if (strpos($exception->getMessage(), $expectedMessagePart) !== false) { return; } fwrite(STDERR, "FAIL: {$message}\nExpected exception containing: {$expectedMessagePart}\nActual: {$exception->getMessage()}\n"); exit(1); } fwrite(STDERR, "FAIL: {$message}\nExpected exception was not thrown.\n"); exit(1); } } (new RedmineStructureTest())->run();