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
+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>
*/