Organized
This commit is contained in:
+121
-103
@@ -10,6 +10,11 @@ use Throwable;
|
||||
final class McpDispatcher
|
||||
{
|
||||
private const PROJECT_ID_DESCRIPTION = 'Redmine project identifier or numeric id. If unsure, call redmine_find_project first and use project_id_to_use.';
|
||||
private const IMPERSONATE_USER_ARG = 'impersonate_user';
|
||||
private const IMPERSONATE_USER_SCHEMA = [
|
||||
'type' => 'string',
|
||||
'description' => 'Optional Redmine login to impersonate for this tool call. Requires switch-user permission on the authenticated API account.',
|
||||
];
|
||||
|
||||
private const ISSUE_FIELD_ARGUMENT_KEYS = [
|
||||
'project_id',
|
||||
@@ -64,8 +69,9 @@ final class McpDispatcher
|
||||
|
||||
try {
|
||||
$result = $this->dispatch($method, $params);
|
||||
$normalizedResult = $this->normalizeJsonRpcResult($result);
|
||||
$this->logCall($context, $method, $params, true, $started);
|
||||
return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result];
|
||||
return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $normalizedResult];
|
||||
} catch (Throwable $exception) {
|
||||
$this->logCall($context, $method, $params, false, $started, $exception->getMessage());
|
||||
return [
|
||||
@@ -79,6 +85,27 @@ final class McpDispatcher
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $result
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function normalizeJsonRpcResult(array $result): array
|
||||
{
|
||||
// JSON-RPC 2.0 allows any JSON value, but the Python MCP client used by
|
||||
// Hermes currently validates response.result as an object/dict.
|
||||
// Normalize list-style results so stdio parsing does not fail.
|
||||
if ($result === []) {
|
||||
return ['items' => []];
|
||||
}
|
||||
|
||||
if (array_is_list($result)) {
|
||||
return ['items' => $result];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $params
|
||||
*
|
||||
@@ -348,6 +375,8 @@ final class McpDispatcher
|
||||
*/
|
||||
private function tool(string $name, string $description, array $properties, array $required = []): array
|
||||
{
|
||||
$properties[self::IMPERSONATE_USER_ARG] = self::IMPERSONATE_USER_SCHEMA;
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
@@ -369,109 +398,84 @@ final class McpDispatcher
|
||||
{
|
||||
$name = (string) ($params['name'] ?? '');
|
||||
$arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : [];
|
||||
$impersonateUser = $this->optionalStringArg($arguments, self::IMPERSONATE_USER_ARG);
|
||||
unset($arguments[self::IMPERSONATE_USER_ARG]);
|
||||
|
||||
switch ($name) {
|
||||
case 'redmine_list_projects':
|
||||
$result = $this->redmine->listProjects(ListQueryNormalizer::listParams($arguments));
|
||||
break;
|
||||
case 'redmine_find_project':
|
||||
$result = $this->findProject($this->stringArg($arguments, 'query'), $this->intArg($arguments, 'limit', 10));
|
||||
break;
|
||||
case 'redmine_get_project':
|
||||
$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->resolvedProjectIdArg($arguments, 'project_id', 'redmine_list_project_memberships'), ListQueryNormalizer::listParams($arguments));
|
||||
break;
|
||||
case 'redmine_list_users':
|
||||
$result = $this->redmine->listUsers(ListQueryNormalizer::userParams($arguments));
|
||||
break;
|
||||
case 'redmine_get_user':
|
||||
$result = $this->redmine->user($this->intArg($arguments, 'user_id'), $this->objectArg($arguments, 'params'));
|
||||
break;
|
||||
case 'redmine_list_issues':
|
||||
$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($this->resolvedProjectArgument($arguments, 'redmine_search')));
|
||||
break;
|
||||
case 'redmine_search_issues':
|
||||
$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']));
|
||||
break;
|
||||
case 'redmine_list_issue_relations':
|
||||
$result = $this->redmine->listIssueRelations($this->intArg($arguments, 'issue_id'));
|
||||
break;
|
||||
case 'redmine_get_issue_relation':
|
||||
$result = $this->redmine->issueRelation($this->intArg($arguments, 'relation_id'));
|
||||
break;
|
||||
case 'redmine_create_issue_relation':
|
||||
$result = $this->redmine->createIssueRelation($this->intArg($arguments, 'issue_id'), $this->objectArg($arguments, 'fields'));
|
||||
break;
|
||||
case 'redmine_remove_issue_relation':
|
||||
$result = ['ok' => $this->redmine->removeIssueRelation($this->intArg($arguments, 'relation_id'))];
|
||||
break;
|
||||
case 'redmine_list_issue_children':
|
||||
$filters = ListQueryNormalizer::issueFilters($arguments);
|
||||
unset($filters['parent_id']);
|
||||
$result = $this->redmine->listIssueChildren($this->intArg($arguments, 'issue_id'), $filters);
|
||||
break;
|
||||
case 'redmine_set_issue_parent':
|
||||
$result = ['ok' => $this->redmine->setIssueParent($this->intArg($arguments, 'issue_id'), $this->intArg($arguments, 'parent_issue_id'))];
|
||||
break;
|
||||
case 'redmine_clear_issue_parent':
|
||||
$result = ['ok' => $this->redmine->clearIssueParent($this->intArg($arguments, 'issue_id'))];
|
||||
break;
|
||||
case 'redmine_issue_with_helpdesk':
|
||||
$result = $this->redmine->issueWithHelpdesk(
|
||||
$this->intArg($arguments, 'issue_id'),
|
||||
$this->intArg($arguments, 'message_limit', 100),
|
||||
$this->stringListArg($arguments, 'include', ['journals', 'attachments'])
|
||||
);
|
||||
break;
|
||||
case 'redmine_create_issue':
|
||||
$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, 'redmine_update_issue'), $this->objectArg($arguments, 'options'))];
|
||||
break;
|
||||
case 'redmine_list_project_issue_categories':
|
||||
$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->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'));
|
||||
break;
|
||||
case 'redmine_get_attachment':
|
||||
$result = $this->redmine->attachment($this->intArg($arguments, 'attachment_id'));
|
||||
break;
|
||||
case 'redmine_upload_attachment':
|
||||
$result = $this->redmine->uploadAttachment($arguments);
|
||||
break;
|
||||
case 'redmine_download_attachment':
|
||||
$result = $this->redmine->downloadAttachment(
|
||||
$this->intArg($arguments, 'attachment_id'),
|
||||
$this->stringArg($arguments, 'path'),
|
||||
$this->boolArg($arguments, 'include_base64', false),
|
||||
$this->intArg($arguments, 'max_base64_bytes', 262144)
|
||||
);
|
||||
break;
|
||||
case 'redmine_update_attachment':
|
||||
$result = $this->redmine->updateAttachment($this->intArg($arguments, 'attachment_id'), $this->objectArg($arguments, 'fields'));
|
||||
break;
|
||||
case 'redmine_send_helpdesk_response':
|
||||
$result = $this->redmine->sendHelpdeskIssueResponse($this->intArg($arguments, 'issue_id'), $this->stringArg($arguments, 'content'), $this->objectArg($arguments, 'options'));
|
||||
break;
|
||||
default:
|
||||
throw new RuntimeException('Unknown tool: ' . $name);
|
||||
}
|
||||
$result = $this->redmine->withImpersonation($impersonateUser, function () use ($name, $arguments) {
|
||||
switch ($name) {
|
||||
case 'redmine_list_projects':
|
||||
return $this->redmine->listProjects(ListQueryNormalizer::listParams($arguments));
|
||||
case 'redmine_find_project':
|
||||
return $this->findProject($this->stringArg($arguments, 'query'), $this->intArg($arguments, 'limit', 10));
|
||||
case 'redmine_get_project':
|
||||
return $this->redmine->project($this->resolvedProjectIdArg($arguments, 'project_id', 'redmine_get_project'), $this->objectArg($arguments, 'params'));
|
||||
case 'redmine_list_project_memberships':
|
||||
return $this->redmine->projectMemberships($this->resolvedProjectIdArg($arguments, 'project_id', 'redmine_list_project_memberships'), ListQueryNormalizer::listParams($arguments));
|
||||
case 'redmine_list_users':
|
||||
return $this->redmine->listUsers(ListQueryNormalizer::userParams($arguments));
|
||||
case 'redmine_get_user':
|
||||
return $this->redmine->user($this->intArg($arguments, 'user_id'), $this->objectArg($arguments, 'params'));
|
||||
case 'redmine_list_issues':
|
||||
return $this->redmine->filterIssues(ListQueryNormalizer::issueFilters($this->resolvedProjectArgument($arguments, 'redmine_list_issues')));
|
||||
case 'redmine_search':
|
||||
return $this->redmine->search($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($this->resolvedProjectArgument($arguments, 'redmine_search')));
|
||||
case 'redmine_search_issues':
|
||||
return $this->redmine->searchIssues($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($this->resolvedProjectArgument($arguments, 'redmine_search_issues')));
|
||||
case 'redmine_get_issue':
|
||||
return $this->redmine->issue($this->intArg($arguments, 'issue_id'), $this->stringListArg($arguments, 'include', ['journals', 'attachments']));
|
||||
case 'redmine_list_issue_relations':
|
||||
return $this->redmine->listIssueRelations($this->intArg($arguments, 'issue_id'));
|
||||
case 'redmine_get_issue_relation':
|
||||
return $this->redmine->issueRelation($this->intArg($arguments, 'relation_id'));
|
||||
case 'redmine_create_issue_relation':
|
||||
return $this->redmine->createIssueRelation($this->intArg($arguments, 'issue_id'), $this->objectArg($arguments, 'fields'));
|
||||
case 'redmine_remove_issue_relation':
|
||||
return ['ok' => $this->redmine->removeIssueRelation($this->intArg($arguments, 'relation_id'))];
|
||||
case 'redmine_list_issue_children':
|
||||
$filters = ListQueryNormalizer::issueFilters($arguments);
|
||||
unset($filters['parent_id']);
|
||||
return $this->redmine->listIssueChildren($this->intArg($arguments, 'issue_id'), $filters);
|
||||
case 'redmine_set_issue_parent':
|
||||
return ['ok' => $this->redmine->setIssueParent($this->intArg($arguments, 'issue_id'), $this->intArg($arguments, 'parent_issue_id'))];
|
||||
case 'redmine_clear_issue_parent':
|
||||
return ['ok' => $this->redmine->clearIssueParent($this->intArg($arguments, 'issue_id'))];
|
||||
case 'redmine_issue_with_helpdesk':
|
||||
return $this->redmine->issueWithHelpdesk(
|
||||
$this->intArg($arguments, 'issue_id'),
|
||||
$this->intArg($arguments, 'message_limit', 100),
|
||||
$this->stringListArg($arguments, 'include', ['journals', 'attachments'])
|
||||
);
|
||||
case 'redmine_create_issue':
|
||||
return $this->redmine->createIssue($this->issueFieldsArg($arguments, 'redmine_create_issue'));
|
||||
case 'redmine_update_issue':
|
||||
return ['ok' => $this->redmine->updateIssue($this->intArg($arguments, 'issue_id'), $this->issueFieldsArg($arguments, 'redmine_update_issue'), $this->objectArg($arguments, 'options'))];
|
||||
case 'redmine_list_project_issue_categories':
|
||||
return $this->redmine->listProjectIssueCategories($this->resolvedProjectIdArg($arguments, 'project_id', 'redmine_list_project_issue_categories'));
|
||||
case 'redmine_get_issue_category':
|
||||
return $this->redmine->issueCategory($this->intArg($arguments, 'category_id'));
|
||||
case 'redmine_create_issue_category':
|
||||
return $this->redmine->createIssueCategory($this->resolvedProjectIdArg($arguments, 'project_id', 'redmine_create_issue_category'), $this->objectArg($arguments, 'fields'));
|
||||
case 'redmine_update_issue_category':
|
||||
return $this->redmine->updateIssueCategory($this->intArg($arguments, 'category_id'), $this->objectArg($arguments, 'fields'));
|
||||
case 'redmine_get_attachment':
|
||||
return $this->redmine->attachment($this->intArg($arguments, 'attachment_id'));
|
||||
case 'redmine_upload_attachment':
|
||||
return $this->redmine->uploadAttachment($arguments);
|
||||
case 'redmine_download_attachment':
|
||||
return $this->redmine->downloadAttachment(
|
||||
$this->intArg($arguments, 'attachment_id'),
|
||||
$this->stringArg($arguments, 'path'),
|
||||
$this->boolArg($arguments, 'include_base64', false),
|
||||
$this->intArg($arguments, 'max_base64_bytes', 262144)
|
||||
);
|
||||
case 'redmine_update_attachment':
|
||||
return $this->redmine->updateAttachment($this->intArg($arguments, 'attachment_id'), $this->objectArg($arguments, 'fields'));
|
||||
case 'redmine_send_helpdesk_response':
|
||||
return $this->redmine->sendHelpdeskIssueResponse($this->intArg($arguments, 'issue_id'), $this->stringArg($arguments, 'content'), $this->objectArg($arguments, 'options'));
|
||||
default:
|
||||
throw new RuntimeException('Unknown tool: ' . $name);
|
||||
}
|
||||
});
|
||||
|
||||
$prepared = $this->redactSensitive($result);
|
||||
if ($this->sanitizeToolText) {
|
||||
@@ -721,6 +725,20 @@ final class McpDispatcher
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $arguments
|
||||
*/
|
||||
private function optionalStringArg(array $arguments, string $key): ?string
|
||||
{
|
||||
if (!array_key_exists($key, $arguments)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim((string) $arguments[$key]);
|
||||
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $arguments
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user