Organized
This commit is contained in:
@@ -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
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user