diff --git a/redMCP/README.md b/redMCP/README.md index 5dd348f..2a61f1e 100644 --- a/redMCP/README.md +++ b/redMCP/README.md @@ -104,6 +104,12 @@ MCP clients that do not know the exact Redmine project identifier should call `redmine_find_project` first. Redmine identifiers are often slug-like strings and are not always the same as the display name. +If a tool receives a `project_id` that looks like a human project name (for +example it contains spaces or uppercase text), redMCP now attempts a safe +lookup first. When one clear match exists it uses that identifier +automatically; when matches are ambiguous it returns a guidance error that +points to `redmine_find_project` and candidate slugs. + ```json { "name": "redmine_find_project", diff --git a/redMCP/app/McpDispatcher.php b/redMCP/app/McpDispatcher.php index 72ffafc..00c8d5d 100644 --- a/redMCP/app/McpDispatcher.php +++ b/redMCP/app/McpDispatcher.php @@ -378,10 +378,10 @@ final class McpDispatcher $result = $this->findProject($this->stringArg($arguments, 'query'), $this->intArg($arguments, 'limit', 10)); break; case 'redmine_get_project': - $result = $this->redmine->project($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params')); + $result = $this->redmine->project($this->resolvedProjectIdArg($arguments, 'project_id', 'redmine_get_project'), $this->objectArg($arguments, 'params')); break; case 'redmine_list_project_memberships': - $result = $this->redmine->projectMemberships($this->projectIdArg($arguments, 'project_id'), ListQueryNormalizer::listParams($arguments)); + $result = $this->redmine->projectMemberships($this->resolvedProjectIdArg($arguments, 'project_id', 'redmine_list_project_memberships'), ListQueryNormalizer::listParams($arguments)); break; case 'redmine_list_users': $result = $this->redmine->listUsers(ListQueryNormalizer::userParams($arguments)); @@ -390,13 +390,13 @@ final class McpDispatcher $result = $this->redmine->user($this->intArg($arguments, 'user_id'), $this->objectArg($arguments, 'params')); break; case 'redmine_list_issues': - $result = $this->redmine->filterIssues(ListQueryNormalizer::issueFilters($arguments)); + $result = $this->redmine->filterIssues(ListQueryNormalizer::issueFilters($this->resolvedProjectArgument($arguments, 'redmine_list_issues'))); break; case 'redmine_search': - $result = $this->redmine->search($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($arguments)); + $result = $this->redmine->search($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($this->resolvedProjectArgument($arguments, 'redmine_search'))); break; case 'redmine_search_issues': - $result = $this->redmine->searchIssues($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($arguments)); + $result = $this->redmine->searchIssues($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($this->resolvedProjectArgument($arguments, 'redmine_search_issues'))); break; case 'redmine_get_issue': $result = $this->redmine->issue($this->intArg($arguments, 'issue_id'), $this->stringListArg($arguments, 'include', ['journals', 'attachments'])); @@ -432,19 +432,19 @@ final class McpDispatcher ); break; case 'redmine_create_issue': - $result = $this->redmine->createIssue($this->issueFieldsArg($arguments)); + $result = $this->redmine->createIssue($this->issueFieldsArg($arguments, 'redmine_create_issue')); break; case 'redmine_update_issue': - $result = ['ok' => $this->redmine->updateIssue($this->intArg($arguments, 'issue_id'), $this->issueFieldsArg($arguments), $this->objectArg($arguments, 'options'))]; + $result = ['ok' => $this->redmine->updateIssue($this->intArg($arguments, 'issue_id'), $this->issueFieldsArg($arguments, 'redmine_update_issue'), $this->objectArg($arguments, 'options'))]; break; case 'redmine_list_project_issue_categories': - $result = $this->redmine->listProjectIssueCategories($this->projectIdArg($arguments, 'project_id')); + $result = $this->redmine->listProjectIssueCategories($this->resolvedProjectIdArg($arguments, 'project_id', 'redmine_list_project_issue_categories')); break; case 'redmine_get_issue_category': $result = $this->redmine->issueCategory($this->intArg($arguments, 'category_id')); break; case 'redmine_create_issue_category': - $result = $this->redmine->createIssueCategory($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'fields')); + $result = $this->redmine->createIssueCategory($this->resolvedProjectIdArg($arguments, 'project_id', 'redmine_create_issue_category'), $this->objectArg($arguments, 'fields')); break; case 'redmine_update_issue_category': $result = $this->redmine->updateIssueCategory($this->intArg($arguments, 'category_id'), $this->objectArg($arguments, 'fields')); @@ -508,7 +508,7 @@ final class McpDispatcher * * @return array */ - private function issueFieldsArg(array $arguments): array + private function issueFieldsArg(array $arguments, string $toolName = ''): array { $fields = $this->objectArg($arguments, 'fields'); foreach (self::ISSUE_FIELD_ARGUMENT_KEYS as $key) { @@ -517,9 +517,94 @@ final class McpDispatcher } } + if (array_key_exists('project_id', $fields) && (is_int($fields['project_id']) || is_string($fields['project_id']))) { + $fields['project_id'] = $this->resolveProjectIdValue($fields['project_id'], $toolName); + } + return $fields; } + /** + * @param array $arguments + * + * @return array + */ + private function resolvedProjectArgument(array $arguments, string $toolName): array + { + if (!array_key_exists('project_id', $arguments) || (!is_int($arguments['project_id']) && !is_string($arguments['project_id']))) { + return $arguments; + } + + $arguments['project_id'] = $this->resolveProjectIdValue($arguments['project_id'], $toolName); + + return $arguments; + } + + /** + * @param array $arguments + */ + private function resolvedProjectIdArg(array $arguments, string $key, string $toolName): int|string + { + return $this->resolveProjectIdValue($this->projectIdArg($arguments, $key), $toolName); + } + + private function resolveProjectIdValue(int|string $projectId, string $toolName): int|string + { + if (is_int($projectId)) { + return $projectId; + } + + $candidate = trim($projectId); + if ($candidate === '') { + throw new RuntimeException('project_id is required.'); + } + if (!$this->looksLikeHumanProjectName($candidate)) { + return $candidate; + } + + $resolution = $this->findProject($candidate, 5); + $recommended = $resolution['recommended_project_id'] ?? null; + if (is_int($recommended) || (is_string($recommended) && trim($recommended) !== '')) { + return $recommended; + } + + throw new RuntimeException($this->projectIdGuidanceMessage($candidate, $toolName, $resolution)); + } + + private function looksLikeHumanProjectName(string $projectId): bool + { + return preg_match('/\s/u', $projectId) === 1 || preg_match('/[A-Z]/', $projectId) === 1; + } + + /** + * @param array $resolution + */ + private function projectIdGuidanceMessage(string $projectId, string $toolName, array $resolution): string + { + $matches = is_array($resolution['matches'] ?? null) ? $resolution['matches'] : []; + $suggestions = []; + foreach (array_slice($matches, 0, 3) as $match) { + if (!is_array($match)) { + continue; + } + $identifier = trim((string) ($match['identifier'] ?? '')); + $name = trim((string) ($match['name'] ?? '')); + if ($identifier === '') { + continue; + } + $suggestions[] = $name !== '' ? ($identifier . ' (' . $name . ')') : $identifier; + } + + $message = $toolName . ' could not safely resolve project_id="' . $projectId . '". ' + . 'Redmine expects a project identifier slug (for example quality-tracker) or numeric id. ' + . 'Call redmine_find_project first and pass project_id_to_use.'; + if ($suggestions !== []) { + $message .= ' Possible matches: ' . implode(', ', $suggestions) . '.'; + } + + return $message; + } + /** * @return array */ diff --git a/redMCP/bin/test-redmine-structure.php b/redMCP/bin/test-redmine-structure.php index 370472a..ba1d691 100755 --- a/redMCP/bin/test-redmine-structure.php +++ b/redMCP/bin/test-redmine-structure.php @@ -78,6 +78,8 @@ final class RedmineStructureTest $this->testMcpFindProjectRecommendsExactIdentifier(); $this->testMcpFindProjectRecommendsExactName(); $this->testMcpFindProjectLeavesAmbiguousMatchesUnrecommended(); + $this->testMcpGetProjectResolvesHumanProjectNameToIdentifier(); + $this->testMcpGetProjectShowsHelpfulErrorForAmbiguousHumanName(); $this->testMcpSearchSanitizesNoisyTextFields(); $this->testMcpSearchCanDisableTextSanitization(); $this->testCreateRelationDefaultsToRelatesAndRequiresTarget(); @@ -241,6 +243,45 @@ final class RedmineStructureTest $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(); diff --git a/skills/redmine-communicator/references/redmcp-tools.md b/skills/redmine-communicator/references/redmcp-tools.md index f22a8a4..c4c56ff 100644 --- a/skills/redmine-communicator/references/redmcp-tools.md +++ b/skills/redmine-communicator/references/redmcp-tools.md @@ -47,6 +47,11 @@ HTTP endpoint defaults to `/mcp` and requires `Authorization: Bearer `. - `redmine_list_project_issue_categories`, `redmine_get_issue_category`. - `redmine_get_attachment`. +When a tool receives `project_id` values that look like human names (spaces or +uppercase), redMCP attempts to resolve to a slug automatically when there is one +clear match. For ambiguous names, it returns a guidance error and suggests using +`redmine_find_project`. + ## Write Tools - `redmine_create_issue`: create an issue.