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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -47,6 +47,11 @@ HTTP endpoint defaults to `/mcp` and requires `Authorization: Bearer <token>`.
|
||||
- `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.
|
||||
|
||||
Reference in New Issue
Block a user