Files
redmine/redMCP/app/McpDispatcher.php
Jason Thistlethwaite bd26c8894f Add production rollout tooling and semantic index ops docs
Capture the production plugin rollout workflow and Qdrant validation steps so operations stay repeatable. Also harden redMCP stdio/schema compatibility to keep diverse MCP clients and validators working.
2026-05-06 22:18:02 -04:00

917 lines
42 KiB
PHP

<?php
declare(strict_types=1);
namespace RedMCP;
use RuntimeException;
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 ISSUE_FIELD_ARGUMENT_KEYS = [
'project_id',
'subject',
'description',
'tracker_id',
'status_id',
'priority_id',
'assigned_to_id',
'category_id',
'parent_issue_id',
'parent_id',
'uploads',
'due_date',
'start_date',
'notes',
'private_notes',
'custom_fields',
'watcher_user_ids',
'is_private',
'estimated_hours',
'done_ratio',
'fixed_version_id',
];
private RedmineClient $redmine;
private McpDebugLogger $logger;
private bool $sanitizeToolText;
public function __construct(RedmineClient $redmine, ?McpDebugLogger $logger = null, bool $sanitizeToolText = true)
{
$this->redmine = $redmine;
$this->logger = $logger ?? new McpDebugLogger(null);
$this->sanitizeToolText = $sanitizeToolText;
}
/**
* @param array<string,mixed> $message
*
* @return array<string,mixed>|null
*/
public function handleMessage(array $message, array $context = []): ?array
{
$id = $message['id'] ?? null;
if ($id === null) {
return null;
}
$started = microtime(true);
$method = (string) ($message['method'] ?? '');
$params = is_array($message['params'] ?? null) ? $message['params'] : [];
try {
$result = $this->dispatch($method, $params);
$this->logCall($context, $method, $params, true, $started);
return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result];
} catch (Throwable $exception) {
$this->logCall($context, $method, $params, false, $started, $exception->getMessage());
return [
'jsonrpc' => '2.0',
'id' => $id,
'error' => [
'code' => -32000,
'message' => $exception->getMessage(),
],
];
}
}
/**
* @param array<string,mixed> $params
*
* @return array<string,mixed>
*/
private function dispatch(string $method, array $params): array
{
switch ($method) {
case 'initialize':
return [
'protocolVersion' => '2025-03-26',
'capabilities' => [
'tools' => ['listChanged' => false],
],
'serverInfo' => [
'name' => 'redMCP',
'version' => '0.1.0',
],
];
case 'ping':
return [];
case 'tools/list':
return ['tools' => $this->tools()];
case 'tools/call':
return $this->callTool($params);
case 'resources/list':
return ['resources' => []];
case 'prompts/list':
return ['prompts' => []];
default:
throw new RuntimeException('Unsupported MCP method: ' . $method);
}
}
/**
* @return array<int,array<string,mixed>>
*/
private function tools(): array
{
return [
$this->tool('redmine_list_projects', 'List available Redmine projects using native /projects.json. Use redmine_find_project to resolve a human project name.', [
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
'page' => ['type' => 'integer', 'minimum' => 1],
'offset' => ['type' => 'integer', 'minimum' => 0],
'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'],
'params' => ['type' => 'object', 'description' => 'Raw Redmine project list params; overrides friendly fields on conflict.'],
]),
$this->tool('redmine_find_project', 'Find the Redmine project identifier to use from a human project name, identifier, or numeric id. Read-only; use before create/list tools when project_id is uncertain.', [
'query' => ['type' => 'string', 'description' => 'Human project name, project identifier, or numeric project id to resolve.'],
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 25, 'description' => 'Maximum ranked matches to return. Defaults to 10.'],
], ['query']),
$this->tool('redmine_get_project', 'Fetch one Redmine project by id or identifier.', [
'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
'params' => ['type' => 'object', 'description' => 'Optional Redmine project params such as include=trackers,issue_categories,enabled_modules.'],
], ['project_id']),
$this->tool('redmine_list_project_memberships', 'List users/groups and roles for a Redmine project.', [
'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
'page' => ['type' => 'integer', 'minimum' => 1],
'offset' => ['type' => 'integer', 'minimum' => 0],
'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'],
'params' => ['type' => 'object', 'description' => 'Raw Redmine membership list params; overrides friendly fields on conflict.'],
], ['project_id']),
$this->tool('redmine_list_users', 'List Redmine users using native /users.json.', [
'status' => ['description' => 'User status such as active, registered, locked, all, or a Redmine status id.'],
'name' => ['type' => 'string', 'description' => 'Filter users by name.'],
'group_id' => ['type' => ['string', 'integer'], 'description' => 'Filter users by group id.'],
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
'page' => ['type' => 'integer', 'minimum' => 1],
'offset' => ['type' => 'integer', 'minimum' => 0],
'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'],
'params' => ['type' => 'object', 'description' => 'Raw Redmine user list params; overrides friendly fields on conflict.'],
]),
$this->tool('redmine_get_user', 'Fetch one Redmine user by id.', [
'user_id' => ['type' => 'integer'],
'params' => ['type' => 'object', 'description' => 'Optional Redmine user params such as include=memberships,groups.'],
], ['user_id']),
$this->tool('redmine_list_issues', 'List Redmine issues using native /issues.json filters.', [
'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
'status' => ['description' => 'Issue status such as open, closed, all, or a Redmine status id.'],
'status_id' => ['description' => 'Raw Redmine status id or status token.'],
'tracker_id' => ['type' => ['string', 'integer']],
'assigned_to_id' => ['type' => ['string', 'integer']],
'author_id' => ['type' => ['string', 'integer']],
'priority_id' => ['type' => ['string', 'integer']],
'category_id' => ['type' => ['string', 'integer']],
'query_id' => ['type' => ['string', 'integer']],
'created' => ['description' => 'Friendly created_on date filter such as today, last 7 days, since 2026-04-01, or {from,to}.'],
'updated' => ['description' => 'Friendly updated_on date filter such as today, last 7 days, since 2026-04-01, or {from,to}.'],
'due' => ['description' => 'Friendly due_date filter such as today, last 7 days, since 2026-04-01, or {from,to}.'],
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
'page' => ['type' => 'integer', 'minimum' => 1],
'offset' => ['type' => 'integer', 'minimum' => 0],
'sort' => ['description' => 'Sort shortcut such as newest, oldest, priority, or a Redmine sort string.'],
'filters' => ['type' => 'object', 'description' => 'Raw Redmine issue list filters; overrides friendly fields on conflict.'],
]),
$this->tool('redmine_search', 'Search Redmine using native /search.json.', [
'query' => ['type' => 'string'],
'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
'scope' => ['type' => 'string'],
'all_words' => ['type' => ['boolean', 'string', 'integer']],
'titles_only' => ['type' => ['boolean', 'string', 'integer']],
'open_issues' => ['type' => ['boolean', 'string', 'integer']],
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
'page' => ['type' => 'integer', 'minimum' => 1],
'offset' => ['type' => 'integer', 'minimum' => 0],
'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'],
'params' => ['type' => 'object', 'description' => 'Raw Redmine search params; overrides friendly fields on conflict.'],
], ['query']),
$this->tool('redmine_search_issues', 'Search only issues using native /search.json.', [
'query' => ['type' => 'string'],
'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
'scope' => ['type' => 'string'],
'all_words' => ['type' => ['boolean', 'string', 'integer']],
'titles_only' => ['type' => ['boolean', 'string', 'integer']],
'open_issues' => ['type' => ['boolean', 'string', 'integer']],
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
'page' => ['type' => 'integer', 'minimum' => 1],
'offset' => ['type' => 'integer', 'minimum' => 0],
'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'],
'params' => ['type' => 'object', 'description' => 'Raw Redmine search params; overrides friendly fields on conflict.'],
], ['query']),
$this->tool('redmine_get_issue', 'Fetch one Redmine issue.', [
'issue_id' => ['type' => 'integer'],
'include' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Issue includes such as journals, attachments, children, relations, changesets.'],
], ['issue_id']),
$this->tool('redmine_list_issue_relations', 'List issue relations attached to one Redmine issue.', [
'issue_id' => ['type' => 'integer'],
], ['issue_id']),
$this->tool('redmine_get_issue_relation', 'Fetch one Redmine issue relation link by relation id.', [
'relation_id' => ['type' => 'integer'],
], ['relation_id']),
$this->tool('redmine_create_issue_relation', 'Create a Redmine issue relation link. Defaults relation_type to relates.', [
'issue_id' => ['type' => 'integer', 'description' => 'Source issue id.'],
'fields' => ['type' => 'object', 'description' => 'Relation fields including issue_to_id, optional relation_type, and optional delay.'],
], ['issue_id', 'fields']),
$this->tool('redmine_remove_issue_relation', 'Unlink one mistaken or explicitly unwanted issue relation. This removes only the relationship, not either issue.', [
'relation_id' => ['type' => 'integer'],
], ['relation_id']),
$this->tool('redmine_list_issue_children', 'List child issues whose parent_id is the given issue id.', [
'issue_id' => ['type' => 'integer'],
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
'page' => ['type' => 'integer', 'minimum' => 1],
'offset' => ['type' => 'integer', 'minimum' => 0],
'sort' => ['description' => 'Sort shortcut such as newest, oldest, priority, or a Redmine sort string.'],
'filters' => ['type' => 'object', 'description' => 'Additional raw Redmine issue list filters. parent_id is controlled by issue_id.'],
], ['issue_id']),
$this->tool('redmine_set_issue_parent', 'Set an issue parent/subtask link.', [
'issue_id' => ['type' => 'integer'],
'parent_issue_id' => ['type' => 'integer'],
], ['issue_id', 'parent_issue_id']),
$this->tool('redmine_clear_issue_parent', 'Clear an issue parent/subtask link without deleting either issue.', [
'issue_id' => ['type' => 'integer'],
], ['issue_id']),
$this->tool('redmine_issue_with_helpdesk', 'Fetch one issue plus Helpdesk ticket/message context when available.', [
'issue_id' => ['type' => 'integer'],
'message_limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 200],
'include' => ['type' => 'array', 'items' => ['type' => 'string']],
], ['issue_id']),
$this->tool('redmine_create_issue', 'Create a Redmine issue.', [
'fields' => ['type' => 'object', 'description' => 'Issue fields including project_id and subject.'],
'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION . ' Flat value is copied into fields.'],
'subject' => ['type' => 'string', 'description' => 'Flat issue subject; copied into fields.'],
'description' => ['type' => 'string', 'description' => 'Flat issue description; copied into fields.'],
'tracker_id' => ['type' => ['string', 'integer']],
'status_id' => ['type' => ['string', 'integer']],
'priority_id' => ['type' => ['string', 'integer']],
'assigned_to_id' => ['type' => ['string', 'integer']],
'category_id' => ['type' => ['string', 'integer']],
'parent_issue_id' => ['type' => ['string', 'integer']],
'parent_id' => ['type' => ['string', 'integer']],
'uploads' => ['type' => 'array', 'items' => ['type' => 'object']],
'due_date' => ['type' => 'string'],
'start_date' => ['type' => 'string'],
'custom_fields' => ['type' => 'array', 'items' => ['type' => 'object']],
'watcher_user_ids' => ['type' => 'array', 'items' => ['type' => ['integer', 'string']]],
'is_private' => ['type' => ['boolean', 'string', 'integer']],
'estimated_hours' => ['type' => ['number', 'string', 'integer']],
'done_ratio' => ['type' => ['integer', 'string']],
'fixed_version_id' => ['type' => ['string', 'integer']],
]),
$this->tool('redmine_update_issue', 'Update a Redmine issue. Helpdesk email is opt-in.', [
'issue_id' => ['type' => 'integer'],
'fields' => ['type' => 'object'],
'options' => ['type' => 'object', 'description' => 'Pass send_helpdesk_email=true only for customer-visible Helpdesk replies.'],
'subject' => ['type' => 'string', 'description' => 'Flat issue subject; copied into fields.'],
'description' => ['type' => 'string', 'description' => 'Flat issue description; copied into fields.'],
'notes' => ['type' => 'string', 'description' => 'Flat issue note; copied into fields.'],
'tracker_id' => ['type' => ['string', 'integer']],
'status_id' => ['type' => ['string', 'integer']],
'priority_id' => ['type' => ['string', 'integer']],
'assigned_to_id' => ['type' => ['string', 'integer']],
'category_id' => ['type' => ['string', 'integer']],
'parent_issue_id' => ['type' => ['string', 'integer']],
'parent_id' => ['type' => ['string', 'integer']],
'uploads' => ['type' => 'array', 'items' => ['type' => 'object']],
'due_date' => ['type' => 'string'],
'start_date' => ['type' => 'string'],
'private_notes' => ['type' => ['boolean', 'string', 'integer']],
'custom_fields' => ['type' => 'array', 'items' => ['type' => 'object']],
'watcher_user_ids' => ['type' => 'array', 'items' => ['type' => ['integer', 'string']]],
'is_private' => ['type' => ['boolean', 'string', 'integer']],
'estimated_hours' => ['type' => ['number', 'string', 'integer']],
'done_ratio' => ['type' => ['integer', 'string']],
'fixed_version_id' => ['type' => ['string', 'integer']],
], ['issue_id']),
$this->tool('redmine_list_project_issue_categories', 'List issue categories for a Redmine project.', [
'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
], ['project_id']),
$this->tool('redmine_get_issue_category', 'Fetch one Redmine issue category by id.', [
'category_id' => ['type' => 'integer'],
], ['category_id']),
$this->tool('redmine_create_issue_category', 'Create an issue category for a project. Category deletion is intentionally not exposed.', [
'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
'fields' => ['type' => 'object', 'description' => 'Category fields including name and optional assigned_to_id.'],
], ['project_id', 'fields']),
$this->tool('redmine_update_issue_category', 'Update an issue category. Category deletion is intentionally not exposed.', [
'category_id' => ['type' => 'integer'],
'fields' => ['type' => 'object'],
], ['category_id', 'fields']),
$this->tool('redmine_get_attachment', 'Fetch Redmine attachment metadata by id.', [
'attachment_id' => ['type' => 'integer'],
], ['attachment_id']),
$this->tool('redmine_upload_attachment', 'Upload a local path, base64 content, data URL, or file envelope to Redmine and return an upload token for issue create/update uploads. Use this for PDFs and other non-image files instead of image_url.', [
'path' => ['type' => 'string', 'description' => 'Readable local file path to upload.'],
'base64_content' => ['type' => 'string', 'description' => 'Base64-encoded attachment bytes.'],
'base64' => ['type' => 'string', 'description' => 'Alias for base64_content.'],
'data' => ['type' => 'string', 'description' => 'Alias for base64_content.'],
'blob' => ['type' => 'string', 'description' => 'Alias for base64_content.'],
'data_url' => ['type' => 'string', 'description' => 'Base64 data URL such as data:application/pdf;base64,....'],
'filename' => ['type' => 'string', 'description' => 'Required for plain base64_content; optional for path or data_url.'],
'name' => ['type' => 'string', 'description' => 'Alias for filename.'],
'content_type' => ['type' => 'string', 'description' => 'Attachment MIME type.'],
'mime_type' => ['type' => 'string', 'description' => 'Alias for content_type.'],
'mimeType' => ['type' => 'string', 'description' => 'Alias for content_type.'],
'media_type' => ['type' => 'string', 'description' => 'Alias for content_type.'],
'description' => ['type' => 'string'],
'file' => [
'type' => 'object',
'description' => 'File envelope with name/filename, mime_type/content_type, and data/base64_content/blob, or a path/data_url.',
'additionalProperties' => true,
],
]),
$this->tool('redmine_download_attachment', 'Download an attachment to an explicit safe local path under /tmp or this repository. Base64 response content is optional and size-limited.', [
'attachment_id' => ['type' => 'integer'],
'path' => ['type' => 'string', 'description' => 'Destination path under /tmp or the repository tree.'],
'include_base64' => ['type' => 'boolean'],
'max_base64_bytes' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 1048576],
], ['attachment_id', 'path']),
$this->tool('redmine_update_attachment', 'Update Redmine attachment metadata such as filename or description.', [
'attachment_id' => ['type' => 'integer'],
'fields' => ['type' => 'object'],
], ['attachment_id', 'fields']),
$this->tool('redmine_send_helpdesk_response', 'Send a customer-visible Helpdesk email response.', [
'issue_id' => ['type' => 'integer'],
'content' => ['type' => 'string'],
'options' => ['type' => 'object', 'description' => 'Optional to_address, cc_address, bcc_address, and status_id.'],
], ['issue_id', 'content']),
];
}
/**
* @param array<string,mixed> $properties
* @param array<int,string> $required
*
* @return array<string,mixed>
*/
private function tool(string $name, string $description, array $properties, array $required = []): array
{
return [
'name' => $name,
'description' => $description,
'inputSchema' => [
'type' => 'object',
'properties' => $properties,
'required' => $required,
'additionalProperties' => false,
],
];
}
/**
* @param array<string,mixed> $params
*
* @return array<string,mixed>
*/
private function callTool(array $params): array
{
$name = (string) ($params['name'] ?? '');
$arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : [];
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);
}
$prepared = $this->redactSensitive($result);
if ($this->sanitizeToolText) {
$prepared = $this->sanitizeToolResult($prepared);
}
$encoded = json_encode($prepared, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
throw new RuntimeException('Could not encode tool result.');
}
return [
'content' => [
[
'type' => 'text',
'text' => $encoded,
],
],
];
}
/**
* @param array<string,mixed> $arguments
*
* @return array<string,mixed>
*/
private function objectArg(array $arguments, string $key): array
{
return is_array($arguments[$key] ?? null) ? $arguments[$key] : [];
}
/**
* @param array<string,mixed> $arguments
*
* @return array<string,mixed>
*/
private function issueFieldsArg(array $arguments, string $toolName = ''): array
{
$fields = $this->objectArg($arguments, 'fields');
foreach (self::ISSUE_FIELD_ARGUMENT_KEYS as $key) {
if (array_key_exists($key, $arguments)) {
$fields[$key] = $arguments[$key];
}
}
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>
*/
private function findProject(string $query, int $limit): array
{
$limit = max(1, min(25, $limit));
$projectsResponse = $this->redmine->listProjects(['limit' => 100]);
$projects = is_array($projectsResponse['projects'] ?? null) ? $projectsResponse['projects'] : [];
$matches = [];
foreach ($projects as $project) {
if (!is_array($project)) {
continue;
}
$match = $this->projectMatch($project, $query);
if ($match !== null) {
$matches[] = $match;
}
}
usort($matches, static function (array $a, array $b): int {
$scoreCompare = ($b['score'] <=> $a['score']);
if ($scoreCompare !== 0) {
return $scoreCompare;
}
$idCompare = ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0));
if ($idCompare !== 0) {
return $idCompare;
}
return strcmp((string) $a['project_id_to_use'], (string) $b['project_id_to_use']);
});
$matches = array_slice($matches, 0, $limit);
$recommended = null;
if (count($matches) === 1 || (isset($matches[0], $matches[1]) && $matches[0]['score'] > $matches[1]['score'])) {
$recommended = $matches[0]['project_id_to_use'] ?? null;
}
return [
'query' => $query,
'recommended_project_id' => $recommended,
'matches' => $matches,
];
}
/**
* @param array<string,mixed> $project
*
* @return array<string,mixed>|null
*/
private function projectMatch(array $project, string $query): ?array
{
$normalizedQuery = $this->normalizeProjectText($query);
$id = $project['id'] ?? null;
$identifier = trim((string) ($project['identifier'] ?? ''));
$name = trim((string) ($project['name'] ?? ''));
$normalizedId = $id === null ? '' : $this->normalizeProjectText((string) $id);
$normalizedIdentifier = $this->normalizeProjectText($identifier);
$normalizedName = $this->normalizeProjectText($name);
$score = 0;
$reason = '';
if ($normalizedId !== '' && $normalizedQuery === $normalizedId) {
$score = 100;
$reason = 'exact_id';
} elseif ($normalizedIdentifier !== '' && $normalizedQuery === $normalizedIdentifier) {
$score = 100;
$reason = 'exact_identifier';
} elseif ($normalizedName !== '' && $normalizedQuery === $normalizedName) {
$score = 90;
$reason = 'exact_name';
} elseif ($normalizedIdentifier !== '' && str_starts_with($normalizedIdentifier, $normalizedQuery)) {
$score = 80;
$reason = 'identifier_prefix';
} elseif ($normalizedName !== '' && str_starts_with($normalizedName, $normalizedQuery)) {
$score = 70;
$reason = 'name_prefix';
} elseif ($normalizedIdentifier !== '' && str_contains($normalizedIdentifier, $normalizedQuery)) {
$score = 60;
$reason = 'identifier_contains';
} elseif ($normalizedName !== '' && str_contains($normalizedName, $normalizedQuery)) {
$score = 50;
$reason = 'name_contains';
} else {
return null;
}
return [
'id' => $id,
'identifier' => $identifier,
'name' => $name,
'score' => $score,
'match_reason' => $reason,
'project_id_to_use' => $identifier !== '' ? $identifier : $id,
];
}
private function normalizeProjectText(string $value): string
{
return strtolower(trim($value));
}
/**
* @param array<string,mixed> $arguments
*/
private function stringArg(array $arguments, string $key): string
{
$value = trim((string) ($arguments[$key] ?? ''));
if ($value === '') {
throw new RuntimeException($key . ' is required.');
}
return $value;
}
/**
* @param array<string,mixed> $arguments
*/
private function intArg(array $arguments, string $key, ?int $default = null): int
{
if (!isset($arguments[$key])) {
if ($default !== null) {
return $default;
}
throw new RuntimeException($key . ' is required.');
}
return (int) $arguments[$key];
}
/**
* @param array<string,mixed> $arguments
*/
private function boolArg(array $arguments, string $key, bool $default = false): bool
{
if (!isset($arguments[$key])) {
return $default;
}
if (is_bool($arguments[$key])) {
return $arguments[$key];
}
return in_array(strtolower((string) $arguments[$key]), ['1', 'true', 'yes', 'on'], true);
}
/**
* @param array<string,mixed> $arguments
*/
private function projectIdArg(array $arguments, string $key): int|string
{
if (!isset($arguments[$key])) {
throw new RuntimeException($key . ' is required.');
}
if (is_int($arguments[$key])) {
return $arguments[$key];
}
return $this->stringArg($arguments, $key);
}
/**
* @param array<string,mixed> $arguments
* @param array<int,string> $default
*
* @return array<int,string>
*/
private function stringListArg(array $arguments, string $key, array $default): array
{
if (!isset($arguments[$key]) || !is_array($arguments[$key])) {
return $default;
}
return array_values(array_filter(array_map('strval', $arguments[$key])));
}
/**
* @param array<string,mixed> $context
* @param array<string,mixed> $params
*/
private function logCall(
array $context,
string $method,
array $params,
bool $ok,
float $started,
?string $error = null
): void {
$record = [
'transport' => $context['transport'] ?? 'unknown',
'client_ip' => $context['client_ip'] ?? null,
'method' => $method,
'params' => $this->redactSensitive($params),
'ok' => $ok,
'duration_ms' => (int) round((microtime(true) - $started) * 1000),
];
if (isset($params['name'])) {
$record['tool_name'] = $params['name'];
$arguments = $params['arguments'] ?? null;
$record['arguments'] = is_array($arguments) ? $this->redactSensitive($arguments) : null;
}
if ($error !== null) {
$record['error'] = $error;
}
$this->logger->log($record);
}
/**
* @param mixed $value
*
* @return mixed
*/
private function redactSensitive($value)
{
if (!is_array($value)) {
return $value;
}
$redacted = [];
foreach ($value as $key => $item) {
if (is_string($key) && $this->isSensitiveKey($key)) {
$redacted[$key] = '[redacted]';
continue;
}
$redacted[$key] = $this->redactSensitive($item);
}
return $redacted;
}
private function isSensitiveKey(string $key): bool
{
$normalized = strtolower(str_replace(['-', '_'], '', $key));
return in_array($normalized, [
'apikey',
'authorization',
'bearertoken',
'password',
'secret',
'token',
], true);
}
/**
* @param mixed $value
*
* @return mixed
*/
private function sanitizeToolResult($value, string $key = '')
{
if (is_string($value)) {
if (!$this->shouldSanitizeTextKey($key)) {
return $value;
}
return $this->sanitizeText($value);
}
if (!is_array($value)) {
return $value;
}
$sanitized = [];
foreach ($value as $childKey => $childValue) {
$sanitized[$childKey] = $this->sanitizeToolResult(
$childValue,
is_string($childKey) ? $childKey : ''
);
}
return $sanitized;
}
private function shouldSanitizeTextKey(string $key): bool
{
$normalized = strtolower(trim($key));
if ($normalized === '') {
return false;
}
return in_array($normalized, [
'description',
'notes',
'content',
'body',
'text',
'message',
'message_body',
'message_text',
'plain_text',
'plain_body',
'html_body',
], true);
}
private function sanitizeText(string $value): string
{
$value = str_replace(["\r\n", "\r"], "\n", $value);
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? $value;
$value = preg_replace('/\p{Cf}+/u', '', $value) ?? $value;
$value = preg_replace('/[^\S\n]{3,}/u', ' ', $value) ?? $value;
$value = preg_replace('/\n{4,}/u', "\n\n\n", $value) ?? $value;
$value = preg_replace('/([[:punct:]])\1{7,}/u', '$1$1$1$1$1$1', $value) ?? $value;
return $value;
}
}