Organized

This commit is contained in:
Jason Thistlethwaite
2026-10-17 20:50:35 -04:00
parent bd26c8894f
commit 9ee9eb3ec6
130 changed files with 24172 additions and 140 deletions
+121 -103
View File
@@ -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
*/
+25
View File
@@ -22,6 +22,31 @@ final class RedmineClient
$this->client = $client;
}
/**
* Run a callback while impersonating an existing Redmine username.
*
* Redmine impersonation only applies when the authenticated API user has
* permission to switch users.
*
* @param callable $callback
*
* @return mixed
*/
public function withImpersonation(?string $username, callable $callback)
{
$normalized = is_string($username) ? trim($username) : '';
if ($normalized === '') {
return $callback();
}
$this->client->startImpersonateUser($normalized);
try {
return $callback();
} finally {
$this->client->stopImpersonateUser();
}
}
public static function fromCredentials(string $url, string $apiKeyOrUsername, ?string $password = null): self
{
return new self(new NativeCurlClient($url, $apiKeyOrUsername, $password));