Resolve human project names in MCP project_id args

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.
This commit is contained in:
Jason Thistlethwaite
2026-05-06 05:00:45 -04:00
parent 22c8e915e9
commit a7d23cd79a
4 changed files with 147 additions and 10 deletions
+6
View File
@@ -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",
+95 -10
View File
@@ -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<string,mixed>
*/
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<string,mixed> $arguments
*
* @return array<string,mixed>
*/
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<string,mixed> $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<string,mixed> $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<string,mixed>
*/
+41
View File
@@ -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();