redmine = $redmine; $this->logger = $logger ?? new McpDebugLogger(null); $this->sanitizeToolText = $sanitizeToolText; } /** * @param array $message * * @return array|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 $params * * @return array */ 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> */ 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'], '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'], '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 $properties * @param array $required * * @return array */ 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 $params * * @return array */ 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->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params')); break; case 'redmine_list_project_memberships': $result = $this->redmine->projectMemberships($this->projectIdArg($arguments, 'project_id'), 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($arguments)); break; case 'redmine_search': $result = $this->redmine->search($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($arguments)); break; case 'redmine_search_issues': $result = $this->redmine->searchIssues($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($arguments)); 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)); break; case 'redmine_update_issue': $result = ['ok' => $this->redmine->updateIssue($this->intArg($arguments, 'issue_id'), $this->issueFieldsArg($arguments), $this->objectArg($arguments, 'options'))]; break; case 'redmine_list_project_issue_categories': $result = $this->redmine->listProjectIssueCategories($this->projectIdArg($arguments, 'project_id')); 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->projectIdArg($arguments, 'project_id'), $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 $arguments * * @return array */ private function objectArg(array $arguments, string $key): array { return is_array($arguments[$key] ?? null) ? $arguments[$key] : []; } /** * @param array $arguments * * @return array */ private function issueFieldsArg(array $arguments): array { $fields = $this->objectArg($arguments, 'fields'); foreach (self::ISSUE_FIELD_ARGUMENT_KEYS as $key) { if (array_key_exists($key, $arguments)) { $fields[$key] = $arguments[$key]; } } return $fields; } /** * @return array */ 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 $project * * @return array|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 $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 $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 $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 $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 $arguments * @param array $default * * @return array */ 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 $context * @param array $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; } }