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
+34
View File
@@ -220,6 +220,22 @@ The stdio server supports both MCP framing styles used by clients in the wild:
Responses mirror the detected input framing mode for compatibility.
### JSON-RPC response normalization (2026-07-10)
When running under Hermes's current Python MCP client, JSON-RPC responses with
`result` as a top-level JSON array (for example `[]`) can trigger repeated
Pydantic validation errors in `mcp.client.stdio`, followed by reconnect loops.
To prevent that log spam and reconnect churn, redMCP now normalizes list-style
JSON-RPC results at the dispatcher boundary:
- associative arrays (objects) pass through unchanged
- list arrays are wrapped as `{ "items": [...] }`
- empty list results are wrapped as `{ "items": [] }`
This keeps response payloads object-shaped for broad client compatibility while
preserving the underlying data.
For local testing, run the Streamable HTTP server:
```sh
@@ -336,6 +352,24 @@ customer-visible mail require an explicit tool call such as
`redmine_send_helpdesk_response` or `redmine_update_issue` with
`send_helpdesk_email=true`.
All MCP tools also accept optional `impersonate_user` to run the call as a
specific Redmine login when the configured API account has permission to switch
users. When omitted, calls run as the configured API user.
```json
{
"name": "redmine_update_issue",
"arguments": {
"impersonate_user": "jason",
"issue_id": 39858,
"notes": "Replying as Jason via MCP"
}
}
```
This applies to the entire tool call, including multi-step actions such as
`redmine_update_issue` with `send_helpdesk_email=true`.
Run the local no-network query normalizer checks with:
```sh
+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));
+1 -1
View File
@@ -54,7 +54,7 @@ try {
assertContains('text/event-stream', $sse['headers'], 'SSE POST returns event stream content type', $assertions);
assertContains('X-Accel-Buffering: no', $sse['headers'], 'SSE POST disables proxy buffering', $assertions);
assertContains("event: message\n", $sse['body'], 'SSE POST emits a message event', $assertions);
assertContains('data: {"jsonrpc":"2.0","id":1,"result":[]}', $sse['body'], 'SSE POST emits JSON-RPC response data', $assertions);
assertContains('data: {"jsonrpc":"2.0","id":1,"result":{"items":[]}}', $sse['body'], 'SSE POST emits JSON-RPC response data', $assertions);
$json = httpRequest(
'POST',
+58 -2
View File
@@ -19,6 +19,9 @@ final class RecordingClient implements Client, HttpClient
/** @var array<int,array{method:string,path:string,content_type:string,content:string}> */
public array $requests = [];
/** @var array<int,string> */
public array $impersonationEvents = [];
/** @var array<int,Response> */
private array $responses = [];
@@ -54,8 +57,15 @@ final class RecordingClient implements Client, HttpClient
throw new RuntimeException('Unexpected vendor API call for ' . $name);
}
public function startImpersonateUser(string $username): void {}
public function stopImpersonateUser(): void {}
public function startImpersonateUser(string $username): void
{
$this->impersonationEvents[] = 'start:' . $username;
}
public function stopImpersonateUser(): void
{
$this->impersonationEvents[] = 'stop';
}
public function requestGet(string $path): bool { return false; }
public function requestPost(string $path, string $body): bool { return false; }
public function requestPut(string $path, string $body): bool { return false; }
@@ -82,6 +92,8 @@ final class RedmineStructureTest
$this->testMcpGetProjectShowsHelpfulErrorForAmbiguousHumanName();
$this->testMcpSearchSanitizesNoisyTextFields();
$this->testMcpSearchCanDisableTextSanitization();
$this->testMcpToolCallSupportsImpersonationAndOmitsArgumentFromPayload();
$this->testMcpToolCallStopsImpersonationOnFailure();
$this->testCreateRelationDefaultsToRelatesAndRequiresTarget();
$this->testAttachmentUploadSupportsPathAndBase64();
$this->testAttachmentUploadAcceptsPdfDataUrl();
@@ -347,6 +359,49 @@ final class RedmineStructureTest
);
}
private function testMcpToolCallSupportsImpersonationAndOmitsArgumentFromPayload(): void
{
$http = new RecordingClient();
$http->queueJson(['issue' => ['id' => 90210]], 201);
$dispatcher = new McpDispatcher(new RedmineClient($http));
$result = $this->callToolJson($dispatcher, 'redmine_create_issue', [
'impersonate_user' => 'jason',
'project_id' => 'quality-tracker',
'subject' => 'Impersonation create smoke',
'description' => 'created via MCP test',
]);
$payload = $this->json($http->requests[0]['content']);
$this->assertSame(90210, $result['id'], 'impersonated create issue returns created issue payload');
$this->assertSame(['start:jason', 'stop'], $http->impersonationEvents, 'impersonation wraps full tool call');
$this->assertSame(false, isset($payload['issue']['impersonate_user']), 'impersonate_user is not forwarded as issue field');
}
private function testMcpToolCallStopsImpersonationOnFailure(): void
{
$http = new RecordingClient();
$http->queueJson(['errors' => ['forbidden']], 500);
$dispatcher = new McpDispatcher(new RedmineClient($http));
$response = $dispatcher->handleMessage([
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => 'redmine_create_issue',
'arguments' => [
'impersonate_user' => 'jason',
'project_id' => 'quality-tracker',
'subject' => 'Will fail intentionally',
],
],
]);
$this->assertSame(true, is_array($response) && isset($response['error']), 'failed tool call returns MCP error response');
$this->assertSame(['start:jason', 'stop'], $http->impersonationEvents, 'impersonation is cleared when tool call fails');
}
private function testAttachmentUploadSupportsPathAndBase64(): void
{
$path = sys_get_temp_dir() . '/redmcp-upload-test.txt';
@@ -507,6 +562,7 @@ final class RedmineStructureTest
}
$projectDescription = (string) $createIssueTool['inputSchema']['properties']['project_id']['description'];
$this->assertStringContains('redmine_find_project', $projectDescription, 'project_id schema points agents to project resolver');
$this->assertSame(true, isset($createIssueTool['inputSchema']['properties']['impersonate_user']), 'tool schema advertises impersonate_user');
}
/**