a7d23cd79a
Auto-resolve project_id values that look like human names to canonical project identifiers when there is a clear match. Return actionable guidance with candidate slugs when ambiguous, and cover the behavior with structure tests and docs updates.
648 lines
28 KiB
PHP
Executable File
648 lines
28 KiB
PHP
Executable File
#!/usr/bin/env php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use RedMCP\RedmineClient;
|
|
use RedMCP\McpDispatcher;
|
|
use Redmine\Api;
|
|
use Redmine\Client\Client;
|
|
use Redmine\Http\HttpClient;
|
|
use Redmine\Http\HttpFactory;
|
|
use Redmine\Http\Request;
|
|
use Redmine\Http\Response;
|
|
|
|
require __DIR__ . '/../vendor/autoload.php';
|
|
|
|
final class RecordingClient implements Client, HttpClient
|
|
{
|
|
/** @var array<int,array{method:string,path:string,content_type:string,content:string}> */
|
|
public array $requests = [];
|
|
|
|
/** @var array<int,Response> */
|
|
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->testMcpGetProjectResolvesHumanProjectNameToIdentifier();
|
|
$this->testMcpGetProjectShowsHelpfulErrorForAmbiguousHumanName();
|
|
$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 testMcpGetProjectResolvesHumanProjectNameToIdentifier(): void
|
|
{
|
|
$http = new RecordingClient();
|
|
$http->queueJson(['projects' => $this->projectFixtures()]);
|
|
$http->queueJson(['project' => ['id' => 78, 'identifier' => 'quality-tracker', 'name' => 'Quality Tracker']]);
|
|
$dispatcher = new McpDispatcher(new RedmineClient($http));
|
|
|
|
$result = $this->callToolJson($dispatcher, 'redmine_get_project', ['project_id' => 'Quality Tracker']);
|
|
|
|
$this->assertSame(78, $result['id'], 'human project name resolves to expected project');
|
|
$this->assertSame('/projects/quality-tracker.json', $http->requests[1]['path'], 'resolved project lookup uses project identifier slug');
|
|
}
|
|
|
|
private function testMcpGetProjectShowsHelpfulErrorForAmbiguousHumanName(): void
|
|
{
|
|
$http = new RecordingClient();
|
|
$http->queueJson(['projects' => $this->projectFixtures()]);
|
|
$dispatcher = new McpDispatcher(new RedmineClient($http));
|
|
|
|
$response = $dispatcher->handleMessage([
|
|
'jsonrpc' => '2.0',
|
|
'id' => 1,
|
|
'method' => 'tools/call',
|
|
'params' => [
|
|
'name' => 'redmine_get_project',
|
|
'arguments' => [
|
|
'project_id' => 'Quality',
|
|
],
|
|
],
|
|
]);
|
|
|
|
if (!is_array($response) || !isset($response['error']) || !is_array($response['error'])) {
|
|
throw new RuntimeException('Expected ambiguous project name to produce an MCP error.');
|
|
}
|
|
$message = (string) ($response['error']['message'] ?? '');
|
|
$this->assertStringContains('redmine_find_project', $message, 'ambiguous project error points to resolver tool');
|
|
$this->assertStringContains('quality-tracker', $message, 'ambiguous project error provides possible identifier matches');
|
|
}
|
|
|
|
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<int,array<string,mixed>>
|
|
*/
|
|
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<string,mixed> $arguments
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<string,mixed>
|
|
*/
|
|
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<int,string> $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<int,string> $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();
|