diff --git a/redMCP/README.md b/redMCP/README.md index 628cb94..b843afa 100644 --- a/redMCP/README.md +++ b/redMCP/README.md @@ -45,7 +45,6 @@ $created = $client->createIssue([ ]); $client->updateIssue((int) $created['id'], ['notes' => 'Follow-up note']); -$client->deleteIssue((int) $created['id']); ``` Native Redmine search is exposed separately from issue filtering. Use @@ -101,6 +100,33 @@ $users = $client->users(['status' => 1, 'limit' => 25]); $user = $client->user(1, ['include' => 'memberships,groups']); ``` +MCP clients that do not know the exact Redmine project identifier should call +`redmine_find_project` first. Redmine identifiers are often slug-like strings +and are not always the same as the display name. + +```json +{ + "name": "redmine_find_project", + "arguments": { + "query": "Quality Tracker" + } +} +``` + +Use `matches[0].project_id_to_use` or `recommended_project_id` when it is +non-null in later calls: + +```json +{ + "name": "redmine_create_issue", + "arguments": { + "project_id": "quality-tracker", + "subject": "Front warehouse deadbolt key gets stuck in lock", + "description": "Problem: The deadbolt on the front door is sticking.\n\n~HermesBot" + } +} +``` + `updateIssue()` is intentionally safe by default: on Helpdesk-backed issues, a normal Redmine note does **not** send an email to the customer. To send through the Helpdesk plugin, opt in explicitly: @@ -120,6 +146,58 @@ Use the default non-email update for internal notes, status/category/assignee changes, and automation cleanup. Use the Helpdesk email path only when the caller deliberately wants the customer to receive mail. +Issue structure operations are exposed explicitly. Issue create/update preserve +Redmine structure fields such as `parent_issue_id`, `parent_id`, +`category_id`, and `uploads`, so callers can create subtasks, categorize issues, +and attach previously uploaded files without falling through the bundled API +client's sanitized XML helpers. + +```php +$upload = $client->uploadAttachment([ + 'path' => '/tmp/redmine-note.txt', + 'content_type' => 'text/plain', +]); + +$pdfUpload = $client->uploadAttachment([ + 'data_url' => 'data:application/pdf;base64,...', +]); + +$fileEnvelopeUpload = $client->uploadAttachment([ + 'file' => [ + 'name' => 'quote.pdf', + 'mime_type' => 'application/pdf', + 'data' => 'JVBERi0xLjQK...', + ], +]); + +$parent = $client->createIssue([ + 'project_id' => 'fud-nohelpdesk', + 'subject' => 'Parent example', +]); + +$child = $client->createIssue([ + 'project_id' => 'fud-nohelpdesk', + 'subject' => 'Child example', + 'parent_issue_id' => (int) $parent['id'], + 'uploads' => [$upload], +]); + +$client->createIssueRelation((int) $parent['id'], [ + 'issue_to_id' => (int) $child['id'], +]); +``` + +The MCP server exposes explicit tools for issue relations, children/parents, +project issue categories, and attachments. It intentionally does not expose +tools for deleting issues, projects, users, categories, or attachments. The only +removal tool is `redmine_remove_issue_relation`, which unlinks the relationship +only and does not delete either issue. + +For MCP attachment uploads, prefer `redmine_upload_attachment` with `path`, +`base64_content`, `data_url`, or a `file` envelope. PDFs and other non-image +files should be passed as file/data URL inputs such as +`data:application/pdf;base64,...`, not as `image_url`. + ## MCP server `redMCP` can run as either a stdio MCP server or a network MCP server. It reads @@ -129,12 +207,16 @@ Redmine credentials from environment variables or `redMCP/.env`. redMCP/bin/redmcp-server.php ``` -For local network testing, run the Streamable HTTP server: +For local testing, run the Streamable HTTP server: ```sh -MCP_SERVER_TOKEN=test-token redMCP/bin/redmcp-http-server.php --host 0.0.0.0 --port 8765 +MCP_SERVER_TOKEN=test-token redMCP/bin/redmcp-http-server.php --port 8765 ``` +For LAN testing, pass `--host 0.0.0.0` deliberately. Browser-origin requests +from non-localhost origins require `MCP_ALLOWED_ORIGINS` as a comma-separated +allowlist. + Generate a bearer token with: ```sh @@ -153,10 +235,44 @@ Example Streamable HTTP request: curl -sS \ -H 'Authorization: Bearer test-token' \ -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ http://127.0.0.1:8765/mcp \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' ``` +When the request `Accept` header includes `text/event-stream`, redMCP returns a +short SSE response with one `message` event per JSON-RPC response. Clients that +send only `Accept: application/json` receive the traditional JSON response. +`GET /mcp` returns `405 Method Not Allowed` with `Allow: POST`; redMCP does not +currently expose standalone server-to-client notification streams. + +Issue create and update tools accept either canonical nested `fields` or common +issue fields at the top level. These two create calls are equivalent: + +```json +{ + "name": "redmine_create_issue", + "arguments": { + "fields": { + "project_id": "quality-tracker", + "subject": "Front warehouse deadbolt key gets stuck in lock", + "description": "Problem: The deadbolt on the front door is sticking.\n\n~HermesBot" + } + } +} +``` + +```json +{ + "name": "redmine_create_issue", + "arguments": { + "project_id": "quality-tracker", + "subject": "Front warehouse deadbolt key gets stuck in lock", + "description": "Problem: The deadbolt on the front door is sticking.\n\n~HermesBot" + } +} +``` + HTTP server process helpers: ```sh @@ -195,15 +311,19 @@ Example stdio client configuration: ``` Both transports expose tools for native Redmine project listing/detail, project -memberships, users, filtering/search, issue CRUD, Helpdesk-aware issue reads, -and explicit Helpdesk email responses. Tools that can send customer-visible mail -require an explicit tool call such as `redmine_send_helpdesk_response` or -`redmine_update_issue` with `send_helpdesk_email=true`. +memberships, users, filtering/search, issue create/update, issue relations, +subtasks/parents, project issue categories, attachments, Helpdesk-aware issue +reads, and explicit Helpdesk email responses. Tools that can send +customer-visible mail require an explicit tool call such as +`redmine_send_helpdesk_response` or `redmine_update_issue` with +`send_helpdesk_email=true`. Run the local no-network query normalizer checks with: ```sh php redMCP/bin/test-query-normalizer.php +php redMCP/bin/test-redmine-structure.php +php redMCP/bin/test-mcp-http-handler.php ``` ## Test instance diff --git a/redMCP/app/McpDispatcher.php b/redMCP/app/McpDispatcher.php index b7c5ea8..982da4a 100644 --- a/redMCP/app/McpDispatcher.php +++ b/redMCP/app/McpDispatcher.php @@ -9,6 +9,32 @@ 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; @@ -91,19 +117,23 @@ final class McpDispatcher private function tools(): array { return [ - $this->tool('redmine_list_projects', 'List Redmine projects using native /projects.json.', [ + $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' => 'Redmine numeric project 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' => 'Redmine numeric project id or identifier.'], + '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], @@ -125,7 +155,7 @@ final class McpDispatcher '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']], + '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']], @@ -145,7 +175,7 @@ final class McpDispatcher ]), $this->tool('redmine_search', 'Search Redmine using native /search.json.', [ 'query' => ['type' => 'string'], - 'project_id' => ['type' => ['string', 'integer']], + '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']], @@ -158,7 +188,7 @@ final class McpDispatcher ], ['query']), $this->tool('redmine_search_issues', 'Search only issues using native /search.json.', [ 'query' => ['type' => 'string'], - 'project_id' => ['type' => ['string', 'integer']], + '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']], @@ -173,6 +203,34 @@ final class McpDispatcher '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], @@ -180,15 +238,98 @@ final class McpDispatcher ], ['issue_id']), $this->tool('redmine_create_issue', 'Create a Redmine issue.', [ 'fields' => ['type' => 'object', 'description' => 'Issue fields including project_id and subject.'], - ], ['fields']), + '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.'], - ], ['issue_id', 'fields']), - $this->tool('redmine_delete_issue', 'Delete a Redmine issue.', [ - 'issue_id' => ['type' => 'integer'], + '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'], @@ -231,6 +372,9 @@ final class McpDispatcher 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; @@ -255,6 +399,29 @@ final class McpDispatcher 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'), @@ -263,13 +430,39 @@ final class McpDispatcher ); break; case 'redmine_create_issue': - $result = $this->redmine->createIssue($this->objectArg($arguments, 'fields')); + $result = $this->redmine->createIssue($this->issueFieldsArg($arguments)); break; case 'redmine_update_issue': - $result = ['ok' => $this->redmine->updateIssue($this->intArg($arguments, 'issue_id'), $this->objectArg($arguments, 'fields'), $this->objectArg($arguments, 'options'))]; + $result = ['ok' => $this->redmine->updateIssue($this->intArg($arguments, 'issue_id'), $this->issueFieldsArg($arguments), $this->objectArg($arguments, 'options'))]; break; - case 'redmine_delete_issue': - $result = ['ok' => $this->redmine->deleteIssue($this->intArg($arguments, 'issue_id'))]; + 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')); @@ -303,6 +496,126 @@ final class McpDispatcher 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 */ @@ -331,6 +644,21 @@ final class McpDispatcher 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 */ diff --git a/redMCP/app/McpHttpHandler.php b/redMCP/app/McpHttpHandler.php index c28cc43..9e1ccca 100644 --- a/redMCP/app/McpHttpHandler.php +++ b/redMCP/app/McpHttpHandler.php @@ -23,8 +23,8 @@ final class McpHttpHandler $this->sendJson(404, ['error' => 'not found']); return; } - if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { - $this->sendJson(405, ['error' => 'method not allowed']); + if (!$this->originAllowed()) { + $this->sendJson(403, ['error' => 'origin not allowed']); return; } if (!$this->authorized()) { @@ -32,6 +32,16 @@ final class McpHttpHandler $this->sendJson(401, ['error' => 'unauthorized']); return; } + if (($_SERVER['REQUEST_METHOD'] ?? '') === 'GET') { + header('Allow: POST'); + $this->sendJson(405, ['error' => 'method not allowed']); + return; + } + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + header('Allow: POST'); + $this->sendJson(405, ['error' => 'method not allowed']); + return; + } $raw = file_get_contents('php://input'); $decoded = json_decode(is_string($raw) ? $raw : '', true); @@ -56,6 +66,10 @@ final class McpHttpHandler http_response_code(202); return; } + if ($this->acceptsEventStream()) { + $this->sendEventStream($responses); + return; + } $this->sendJson(200, $responses); return; } @@ -66,6 +80,10 @@ final class McpHttpHandler return; } + if ($this->acceptsEventStream()) { + $this->sendEventStream([$response]); + return; + } $this->sendJson(200, $response); } @@ -79,6 +97,43 @@ final class McpHttpHandler return hash_equals($this->token, substr($header, 7)); } + private function originAllowed(): bool + { + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + if (!is_string($origin) || trim($origin) === '') { + return true; + } + + $origin = trim($origin); + foreach ($this->allowedOrigins() as $allowedOrigin) { + if (hash_equals($allowedOrigin, $origin)) { + return true; + } + } + + $host = parse_url($origin, PHP_URL_HOST); + return is_string($host) && in_array(strtolower($host), ['localhost', '127.0.0.1', '::1'], true); + } + + /** + * @return array + */ + private function allowedOrigins(): array + { + $raw = getenv('MCP_ALLOWED_ORIGINS') ?: ''; + if (!is_string($raw) || trim($raw) === '') { + return []; + } + + return array_values(array_filter(array_map('trim', explode(',', $raw)))); + } + + private function acceptsEventStream(): bool + { + $accept = $_SERVER['HTTP_ACCEPT'] ?? ''; + return is_string($accept) && stripos($accept, 'text/event-stream') !== false; + } + /** * @return array */ @@ -100,6 +155,27 @@ final class McpHttpHandler echo json_encode($payload, JSON_UNESCAPED_SLASHES); } + /** + * @param array> $messages + */ + private function sendEventStream(array $messages): void + { + http_response_code(200); + header('Content-Type: text/event-stream'); + header('Cache-Control: no-cache'); + header('X-Accel-Buffering: no'); + + foreach ($messages as $message) { + $encoded = json_encode($message, JSON_UNESCAPED_SLASHES); + if ($encoded === false) { + continue; + } + echo "event: message\n"; + echo 'data: ' . $encoded . "\n\n"; + flush(); + } + } + /** * @return array */ diff --git a/redMCP/app/RedmineClient.php b/redMCP/app/RedmineClient.php index dd48daa..9ee001d 100644 --- a/redMCP/app/RedmineClient.php +++ b/redMCP/app/RedmineClient.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace RedMCP; +use Redmine\Client\Client; use Redmine\Client\NativeCurlClient; use Redmine\Http\HttpClient; use Redmine\Http\HttpFactory; @@ -14,9 +15,9 @@ use Throwable; final class RedmineClient { - private NativeCurlClient $client; + private Client $client; - public function __construct(NativeCurlClient $client) + public function __construct(Client $client) { $this->client = $client; } @@ -236,8 +237,8 @@ final class RedmineClient * Create a Redmine issue. * * Typical fields include project_id, subject, description, tracker_id, - * status_id, priority_id, assigned_to_id, category_id, due_date, and - * start_date. + * status_id, priority_id, assigned_to_id, category_id, parent_issue_id, + * parent_id, uploads, due_date, and start_date. * * @param array $fields * @@ -249,11 +250,9 @@ final class RedmineClient throw new RuntimeException('Creating an issue requires at least project_id and subject.'); } - $issueApi = $this->client->getApi('issue'); - $response = $issueApi->create($fields); - $this->assertLastApiResponseSucceeded($issueApi, 'create issue'); + $response = $this->postJson('/issues', ['issue' => $fields]); - return $this->xmlResponseToArray($response); + return is_array($response['issue'] ?? null) ? $response['issue'] : $response; } /** @@ -265,7 +264,8 @@ final class RedmineClient * sendHelpdeskIssueResponse() directly. * * Typical fields include notes, subject, status_id, priority_id, - * assigned_to_id, private_notes, due_date, and tracker_id. + * assigned_to_id, private_notes, parent_issue_id, parent_id, category_id, + * uploads, due_date, and tracker_id. * * @param array $fields */ @@ -294,13 +294,383 @@ final class RedmineClient return true; } - $issueApi = $this->client->getApi('issue'); - $issueApi->update($issueId, $fields); - $this->assertLastApiResponseSucceeded($issueApi, 'update issue #' . $issueId); + $this->putJson('/issues/' . rawurlencode((string) $issueId), ['issue' => $fields]); return true; } + /** + * @return array + */ + public function issueWithStructure(int $issueId): array + { + return $this->issue($issueId, ['journals', 'attachments', 'children', 'relations']); + } + + /** + * @return array + */ + public function listIssueChildren(int $issueId, array $params = []): array + { + return $this->listIssues(['parent_id' => $issueId] + $params); + } + + public function setIssueParent(int $issueId, int $parentIssueId): bool + { + if ($parentIssueId <= 0) { + throw new RuntimeException('Setting an issue parent requires a positive parent_issue_id.'); + } + + return $this->updateIssue($issueId, ['parent_issue_id' => $parentIssueId]); + } + + public function clearIssueParent(int $issueId): bool + { + return $this->updateIssue($issueId, ['parent_issue_id' => null]); + } + + /** + * @return array + */ + public function listIssueRelations(int $issueId): array + { + return $this->getJson('/issues/' . rawurlencode((string) $issueId) . '/relations') ?? []; + } + + /** + * @return array + */ + public function issueRelation(int $relationId): array + { + if ($relationId <= 0) { + throw new RuntimeException('Fetching an issue relation requires a positive relation id.'); + } + + $response = $this->getJson('/relations/' . rawurlencode((string) $relationId)); + if (!is_array($response)) { + throw new RuntimeException('Could not fetch issue relation #' . $relationId . '.'); + } + + return $response['relation'] ?? $response; + } + + /** + * @param array $fields + * + * @return array + */ + public function createIssueRelation(int $issueId, array $fields): array + { + if (!isset($fields['issue_to_id']) || (int) $fields['issue_to_id'] <= 0) { + throw new RuntimeException('Creating an issue relation requires a positive issue_to_id.'); + } + + $fields += ['relation_type' => 'relates']; + $response = $this->postJson('/issues/' . rawurlencode((string) $issueId) . '/relations', ['relation' => $fields]); + + return is_array($response['relation'] ?? null) ? $response['relation'] : $response; + } + + public function removeIssueRelation(int $relationId): bool + { + if ($relationId <= 0) { + throw new RuntimeException('Removing an issue relation requires a positive relation id.'); + } + + $this->deleteJson('/relations/' . rawurlencode((string) $relationId)); + + return true; + } + + /** + * @return array + */ + public function listProjectIssueCategories(int|string $projectId): array + { + $projectId = trim((string) $projectId); + if ($projectId === '') { + throw new RuntimeException('Listing issue categories requires a project id or identifier.'); + } + + return $this->getJson('/projects/' . rawurlencode($projectId) . '/issue_categories') ?? []; + } + + /** + * @return array + */ + public function issueCategory(int $categoryId): array + { + if ($categoryId <= 0) { + throw new RuntimeException('Fetching an issue category requires a positive category id.'); + } + + $response = $this->getJson('/issue_categories/' . rawurlencode((string) $categoryId)); + if (!is_array($response)) { + throw new RuntimeException('Could not fetch issue category #' . $categoryId . '.'); + } + + return $response['issue_category'] ?? $response; + } + + /** + * @param array $fields + * + * @return array + */ + public function createIssueCategory(int|string $projectId, array $fields): array + { + $projectId = trim((string) $projectId); + if ($projectId === '') { + throw new RuntimeException('Creating an issue category requires a project id or identifier.'); + } + if (!isset($fields['name']) || trim((string) $fields['name']) === '') { + throw new RuntimeException('Creating an issue category requires a non-empty name.'); + } + + $response = $this->postJson('/projects/' . rawurlencode($projectId) . '/issue_categories', ['issue_category' => $fields]); + + return is_array($response['issue_category'] ?? null) ? $response['issue_category'] : $response; + } + + /** + * @param array $fields + * + * @return array + */ + public function updateIssueCategory(int $categoryId, array $fields): array + { + if ($fields === []) { + throw new RuntimeException('Updating an issue category requires at least one field.'); + } + + $response = $this->putJson('/issue_categories/' . rawurlencode((string) $categoryId), ['issue_category' => $fields]); + + return is_array($response['issue_category'] ?? null) ? $response['issue_category'] : $response; + } + + /** + * @return array + */ + public function attachment(int $attachmentId): array + { + if ($attachmentId <= 0) { + throw new RuntimeException('Fetching an attachment requires a positive attachment id.'); + } + + $response = $this->getJson('/attachments/' . rawurlencode((string) $attachmentId)); + if (!is_array($response)) { + throw new RuntimeException('Could not fetch attachment #' . $attachmentId . '.'); + } + + return $response['attachment'] ?? $response; + } + + /** + * @param array $source + * + * @return array + */ + public function uploadAttachment(array $source): array + { + $source = $this->normalizeAttachmentUploadSource($source); + $filename = isset($source['filename']) ? trim((string) $source['filename']) : ''; + $contentType = trim((string) ($source['content_type'] ?? 'application/octet-stream')); + if ($contentType === '') { + $contentType = 'application/octet-stream'; + } + + if (isset($source['path'])) { + $path = (string) $source['path']; + if (!is_file($path) || !is_readable($path)) { + throw new RuntimeException('Uploading an attachment from path requires a readable local file.'); + } + $bytes = file_get_contents($path); + if ($bytes === false) { + throw new RuntimeException('Could not read attachment file: ' . $path); + } + if ($filename === '') { + $filename = basename($path); + } + } elseif (isset($source['base64_content'])) { + if ($filename === '') { + throw new RuntimeException('Uploading base64 attachment content requires filename.'); + } + $decoded = base64_decode((string) $source['base64_content'], true); + if ($decoded === false) { + throw new RuntimeException('Attachment base64_content is not valid base64.'); + } + $bytes = $decoded; + } else { + throw new RuntimeException('Uploading an attachment requires either path or base64_content.'); + } + + if ($filename === '') { + throw new RuntimeException('Uploading an attachment requires filename.'); + } + + $response = $this->rawRequest( + 'POST', + PathSerializer::create('/uploads.json', ['filename' => $filename])->getPath(), + 'application/octet-stream', + $bytes + ); + $status = $response->getStatusCode(); + if ($status >= 400) { + throw new RuntimeException('Redmine upload failed with HTTP ' . $status . ': ' . $response->getContent()); + } + + $decoded = $this->decodeJsonResponse($response, 'Redmine upload'); + $upload = is_array($decoded['upload'] ?? null) ? $decoded['upload'] : $decoded; + if (isset($source['description'])) { + $upload['description'] = (string) $source['description']; + } + $upload += [ + 'filename' => $filename, + 'content_type' => $contentType, + ]; + + return $upload; + } + + /** + * @param array $source + * + * @return array + */ + private function normalizeAttachmentUploadSource(array $source): array + { + if (isset($source['file']) && is_array($source['file'])) { + $file = $source['file']; + unset($source['file']); + foreach ([ + 'path' => 'path', + 'filename' => 'filename', + 'name' => 'filename', + 'content_type' => 'content_type', + 'mime_type' => 'content_type', + 'mimeType' => 'content_type', + 'media_type' => 'content_type', + 'description' => 'description', + 'base64_content' => 'base64_content', + 'base64' => 'base64_content', + 'data' => 'base64_content', + 'blob' => 'base64_content', + 'data_url' => 'data_url', + 'url' => 'data_url', + ] as $from => $to) { + if (!isset($source[$to]) && isset($file[$from])) { + $source[$to] = $file[$from]; + } + } + } + + foreach ([ + 'name' => 'filename', + 'mime_type' => 'content_type', + 'mimeType' => 'content_type', + 'media_type' => 'content_type', + 'base64' => 'base64_content', + 'data' => 'base64_content', + 'blob' => 'base64_content', + ] as $from => $to) { + if (!isset($source[$to]) && isset($source[$from])) { + $source[$to] = $source[$from]; + } + } + + if (isset($source['data_url']) || (isset($source['base64_content']) && str_starts_with((string) $source['base64_content'], 'data:'))) { + $dataUrl = (string) ($source['data_url'] ?? $source['base64_content']); + $parsed = $this->parseAttachmentDataUrl($dataUrl); + $source['base64_content'] = $parsed['base64_content']; + if (!isset($source['content_type'])) { + $source['content_type'] = $parsed['content_type']; + } + if (!isset($source['filename']) || trim((string) $source['filename']) === '') { + $source['filename'] = $this->defaultAttachmentFilename((string) $source['content_type']); + } + } + + return $source; + } + + /** + * @return array{content_type:string,base64_content:string} + */ + private function parseAttachmentDataUrl(string $dataUrl): array + { + if (!preg_match('/^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s', $dataUrl, $matches)) { + throw new RuntimeException('Attachment data_url must be a base64 data URL.'); + } + + $contentType = trim($matches[1] !== '' ? $matches[1] : 'application/octet-stream'); + + return [ + 'content_type' => $contentType !== '' ? $contentType : 'application/octet-stream', + 'base64_content' => $matches[2], + ]; + } + + private function defaultAttachmentFilename(string $contentType): string + { + return match (strtolower($contentType)) { + 'application/pdf' => 'attachment.pdf', + 'text/plain' => 'attachment.txt', + 'text/csv' => 'attachment.csv', + 'application/json' => 'attachment.json', + 'image/jpeg' => 'attachment.jpg', + 'image/png' => 'attachment.png', + 'image/gif' => 'attachment.gif', + default => 'attachment.bin', + }; + } + + /** + * @return array + */ + public function updateAttachment(int $attachmentId, array $fields): array + { + if ($fields === []) { + throw new RuntimeException('Updating an attachment requires at least one field.'); + } + + $response = $this->putJson('/attachments/' . rawurlencode((string) $attachmentId), ['attachment' => $fields]); + + return is_array($response['attachment'] ?? null) ? $response['attachment'] : $response; + } + + /** + * @return array + */ + public function downloadAttachment(int $attachmentId, string $destinationPath, bool $includeBase64 = false, int $maxBase64Bytes = 262144): array + { + $destinationPath = $this->safeDownloadPath($destinationPath); + $response = $this->rawRequest('GET', '/attachments/download/' . rawurlencode((string) $attachmentId)); + $status = $response->getStatusCode(); + if ($status >= 400) { + throw new RuntimeException('Redmine attachment download failed with HTTP ' . $status . ': ' . $response->getContent()); + } + + $bytes = $response->getContent(); + if (file_put_contents($destinationPath, $bytes) === false) { + throw new RuntimeException('Could not write attachment download to ' . $destinationPath . '.'); + } + + $result = [ + 'attachment_id' => $attachmentId, + 'path' => $destinationPath, + 'bytes' => strlen($bytes), + 'content_type' => $response->getContentType(), + ]; + if ($includeBase64 && strlen($bytes) <= $maxBase64Bytes) { + $result['base64_content'] = base64_encode($bytes); + } elseif ($includeBase64) { + $result['base64_omitted'] = true; + $result['base64_limit_bytes'] = $maxBase64Bytes; + } + + return $result; + } + /** * Send a Helpdesk email response for an existing Helpdesk-backed issue. * @@ -498,22 +868,67 @@ final class RedmineClient */ private function postJson(string $path, array $payload): array { - if (!$this->client instanceof HttpClient) { - throw new RuntimeException('Configured Redmine client cannot issue raw HTTP requests.'); - } - $requestPath = $this->buildPath($path, []); $encoded = json_encode($payload); if ($encoded === false) { throw new RuntimeException('Could not encode Redmine POST payload.'); } - $response = $this->client->request(HttpFactory::makeJsonRequest('POST', $requestPath, $encoded)); + $response = $this->rawRequest('POST', $requestPath, 'application/json', $encoded); $status = $response->getStatusCode(); if ($status >= 400) { throw new RuntimeException('Redmine POST ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent()); } + return $this->decodeJsonResponse($response, 'Redmine POST ' . $requestPath); + } + + /** + * @param array $payload + * + * @return array + */ + private function putJson(string $path, array $payload): array + { + $requestPath = $this->buildPath($path, []); + $encoded = json_encode($payload); + if ($encoded === false) { + throw new RuntimeException('Could not encode Redmine PUT payload.'); + } + + $response = $this->rawRequest('PUT', $requestPath, 'application/json', $encoded); + $status = $response->getStatusCode(); + if ($status >= 400) { + throw new RuntimeException('Redmine PUT ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent()); + } + + return $this->decodeJsonResponse($response, 'Redmine PUT ' . $requestPath); + } + + private function deleteJson(string $path): void + { + $requestPath = $this->buildPath($path, []); + $response = $this->rawRequest('DELETE', $requestPath); + $status = $response->getStatusCode(); + if ($status >= 400) { + throw new RuntimeException('Redmine DELETE ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent()); + } + } + + private function rawRequest(string $method, string $path, string $contentType = '', string $content = ''): \Redmine\Http\Response + { + if (!$this->client instanceof HttpClient) { + throw new RuntimeException('Configured Redmine client cannot issue raw HTTP requests.'); + } + + return $this->client->request(HttpFactory::makeRequest($method, $path, $contentType, $content)); + } + + /** + * @return array + */ + private function decodeJsonResponse(\Redmine\Http\Response $response, string $action): array + { $body = $response->getContent(); if ($body === '') { return []; @@ -522,11 +937,11 @@ final class RedmineClient try { $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); } catch (Throwable $exception) { - throw new RuntimeException('Redmine POST ' . $requestPath . ' returned invalid JSON.', 0, $exception); + throw new RuntimeException($action . ' returned invalid JSON.', 0, $exception); } if (!is_array($decoded)) { - throw new RuntimeException('Redmine POST ' . $requestPath . ' returned invalid JSON.'); + throw new RuntimeException($action . ' returned invalid JSON.'); } return $decoded; @@ -540,6 +955,31 @@ final class RedmineClient return PathSerializer::create($path . '.json', $params)->getPath(); } + private function safeDownloadPath(string $path): string + { + $path = trim($path); + if ($path === '' || str_contains($path, "\0")) { + throw new RuntimeException('Attachment download requires a safe local destination path.'); + } + + $directory = dirname($path); + $realDirectory = realpath($directory); + if ($realDirectory === false || !is_dir($realDirectory)) { + throw new RuntimeException('Attachment download destination directory does not exist: ' . $directory); + } + + $resolved = $realDirectory . DIRECTORY_SEPARATOR . basename($path); + $repoRoot = realpath(dirname(__DIR__, 2)); + $tmpRoot = realpath(sys_get_temp_dir()); + foreach (array_filter([$repoRoot, $tmpRoot]) as $allowedRoot) { + if ($resolved === $allowedRoot || str_starts_with($resolved, $allowedRoot . DIRECTORY_SEPARATOR)) { + return $resolved; + } + } + + throw new RuntimeException('Attachment downloads must write under /tmp or the repository tree.'); + } + /** * @param mixed $api */ diff --git a/redMCP/bin/redmcp-http-server.php b/redMCP/bin/redmcp-http-server.php index 7bbac70..01fc5ba 100755 --- a/redMCP/bin/redmcp-http-server.php +++ b/redMCP/bin/redmcp-http-server.php @@ -11,12 +11,12 @@ $options = getopt('', ['host:', 'port:', 'path:', 'pid-file:', 'debug-log:', 'st if (isset($options['help'])) { fwrite( STDOUT, - "Usage: redmcp-http-server.php [--host 0.0.0.0] [--port 8765] [--path /mcp] [--pid-file /tmp/redmcp-http-server.pid] [--debug-log /tmp/redmcp-mcp.log] [--status|--stop] [--force]\n" + "Usage: redmcp-http-server.php [--host 127.0.0.1] [--port 8765] [--path /mcp] [--pid-file /tmp/redmcp-http-server.pid] [--debug-log /tmp/redmcp-mcp.log] [--status|--stop] [--force]\n" ); exit(0); } -$host = (string) ($options['host'] ?? '0.0.0.0'); +$host = (string) ($options['host'] ?? '127.0.0.1'); $port = (int) ($options['port'] ?? 8765); $path = (string) ($options['path'] ?? '/mcp'); $pidFile = (string) ($options['pid-file'] ?? '/tmp/redmcp-http-server.pid'); diff --git a/redMCP/bin/test-mcp-http-handler.php b/redMCP/bin/test-mcp-http-handler.php new file mode 100755 index 0000000..98fac01 --- /dev/null +++ b/redMCP/bin/test-mcp-http-handler.php @@ -0,0 +1,164 @@ +#!/usr/bin/env php +handle(); +PHP); +file_put_contents($router, str_replace('%AUTOLOAD%', addslashes($repoRoot . '/vendor/autoload.php'), (string) file_get_contents($router))); + +$port = 18765 + (getmypid() % 1000); +$command = [PHP_BINARY, '-S', '127.0.0.1:' . $port, $router]; +$process = proc_open($command, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $repoRoot); +if (!is_resource($process)) { + throw new RuntimeException('Could not start PHP built-in server.'); +} + +$assertions = 0; +try { + waitForServer($port); + + $sse = httpRequest( + 'POST', + 'http://127.0.0.1:' . $port . '/mcp', + [ + 'Authorization: Bearer test-token', + 'Content-Type: application/json', + 'Accept: application/json, text/event-stream', + ], + '{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}' + ); + assertContains('HTTP/1.1 200 OK', $sse['headers'], 'SSE POST returns 200', $assertions); + 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); + + $json = httpRequest( + 'POST', + 'http://127.0.0.1:' . $port . '/mcp', + [ + 'Authorization: Bearer test-token', + 'Content-Type: application/json', + 'Accept: application/json', + ], + '{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}' + ); + assertContains('application/json', $json['headers'], 'JSON POST preserves application/json content type', $assertions); + assertContains('"id":2', $json['body'], 'JSON POST emits JSON-RPC response body', $assertions); + + $get = httpRequest( + 'GET', + 'http://127.0.0.1:' . $port . '/mcp', + [ + 'Authorization: Bearer test-token', + 'Accept: text/event-stream', + ], + null + ); + assertContains('HTTP/1.1 405 Method Not Allowed', $get['headers'], 'GET returns method-not-allowed until standalone streams exist', $assertions); + assertContains('Allow: POST', $get['headers'], 'GET advertises supported method', $assertions); + + $origin = httpRequest( + 'POST', + 'http://127.0.0.1:' . $port . '/mcp', + [ + 'Authorization: Bearer test-token', + 'Content-Type: application/json', + 'Accept: application/json', + 'Origin: https://example.invalid', + ], + '{"jsonrpc":"2.0","id":3,"method":"ping","params":{}}' + ); + assertContains('HTTP/1.1 403 Forbidden', $origin['headers'], 'disallowed browser origin returns forbidden', $assertions); + + fwrite(STDOUT, "OK {$assertions} assertions\n"); +} finally { + proc_terminate($process); + proc_close($process); + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + @unlink($router); + @rmdir($tmpDir); +} + +function waitForServer(int $port): void +{ + $deadline = microtime(true) + 5; + while (microtime(true) < $deadline) { + $socket = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.1); + if (is_resource($socket)) { + fclose($socket); + return; + } + usleep(100000); + } + + throw new RuntimeException('Timed out waiting for test HTTP server.'); +} + +/** + * @param array $headers + * @return array{headers:string,body:string} + */ +function httpRequest(string $method, string $url, array $headers, ?string $body): array +{ + $curl = curl_init($url); + if ($curl === false) { + throw new RuntimeException('Could not initialize curl.'); + } + curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); + curl_setopt($curl, CURLOPT_HEADER, true); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + if ($body !== null) { + curl_setopt($curl, CURLOPT_POSTFIELDS, $body); + } + $raw = curl_exec($curl); + if (!is_string($raw)) { + throw new RuntimeException('curl failed: ' . curl_error($curl)); + } + $headerSize = (int) curl_getinfo($curl, CURLINFO_HEADER_SIZE); + curl_close($curl); + + return [ + 'headers' => substr($raw, 0, $headerSize), + 'body' => substr($raw, $headerSize), + ]; +} + +function assertContains(string $needle, string $haystack, string $message, int &$assertions): void +{ + $assertions++; + if (strpos($haystack, $needle) !== false) { + return; + } + + fwrite(STDERR, "FAIL: {$message}\nNeedle: {$needle}\nHaystack: {$haystack}\n"); + exit(1); +} diff --git a/redMCP/bin/test-redmine-structure.php b/redMCP/bin/test-redmine-structure.php new file mode 100755 index 0000000..1cd2ff5 --- /dev/null +++ b/redMCP/bin/test-redmine-structure.php @@ -0,0 +1,550 @@ +#!/usr/bin/env php + */ + public array $requests = []; + + /** @var array */ + private array $responses = []; + + public function queueJson(array $payload, int $status = 200): void + { + $encoded = json_encode($payload); + if ($encoded === false) { + throw new RuntimeException('Could not encode fixture JSON.'); + } + + $this->responses[] = HttpFactory::makeResponse($status, 'application/json', $encoded); + } + + public function queueBinary(string $content, string $contentType = 'application/octet-stream', int $status = 200): void + { + $this->responses[] = HttpFactory::makeResponse($status, $contentType, $content); + } + + public function request(Request $request): Response + { + $this->requests[] = [ + 'method' => $request->getMethod(), + 'path' => $request->getPath(), + 'content_type' => $request->getContentType(), + 'content' => $request->getContent(), + ]; + + return array_shift($this->responses) ?? HttpFactory::makeResponse(200, 'application/json', '{}'); + } + + public function getApi(string $name): Api + { + throw new RuntimeException('Unexpected vendor API call for ' . $name); + } + + public function startImpersonateUser(string $username): void {} + public function stopImpersonateUser(): void {} + 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; } + public function requestDelete(string $path): bool { return false; } + public function getLastResponseStatusCode(): int { return 0; } + public function getLastResponseContentType(): string { return ''; } + public function getLastResponseBody(): string { return ''; } +} + +final class RedmineStructureTest +{ + private int $assertions = 0; + + public function run(): void + { + $this->testCreateIssuePreservesStructureFields(); + $this->testUpdateIssuePreservesParentAndUploads(); + $this->testMcpCreateIssueAcceptsFlatIssueFields(); + $this->testMcpUpdateIssueAcceptsFlatIssueFields(); + $this->testMcpFindProjectRecommendsExactIdentifier(); + $this->testMcpFindProjectRecommendsExactName(); + $this->testMcpFindProjectLeavesAmbiguousMatchesUnrecommended(); + $this->testCreateRelationDefaultsToRelatesAndRequiresTarget(); + $this->testAttachmentUploadSupportsPathAndBase64(); + $this->testAttachmentUploadAcceptsPdfDataUrl(); + $this->testAttachmentUploadAcceptsFileEnvelope(); + $this->testDownloadPathValidationRejectsUnsafePaths(); + $this->testDownloadAttachmentWritesSafePathAndLimitsBase64(); + $this->testMcpToolListExposesStructureToolsWithoutIssueDelete(); + + fwrite(STDOUT, "OK {$this->assertions} assertions\n"); + } + + private function testCreateIssuePreservesStructureFields(): void + { + $http = new RecordingClient(); + $http->queueJson(['issue' => ['id' => 123]], 201); + $client = new RedmineClient($http); + + $result = $client->createIssue([ + 'project_id' => 'fud-nohelpdesk', + 'subject' => 'Child issue', + 'parent_issue_id' => 99, + 'category_id' => 4, + 'uploads' => [ + ['token' => 'tok-1', 'filename' => 'note.txt', 'content_type' => 'text/plain'], + ], + ]); + + $request = $http->requests[0]; + $payload = $this->json($request['content']); + $this->assertSame('POST', $request['method'], 'create issue uses POST'); + $this->assertSame('/issues.json', $request['path'], 'create issue uses raw JSON endpoint'); + $this->assertSame(99, $payload['issue']['parent_issue_id'], 'create issue preserves parent_issue_id'); + $this->assertSame(4, $payload['issue']['category_id'], 'create issue preserves category_id'); + $this->assertSame('tok-1', $payload['issue']['uploads'][0]['token'], 'create issue preserves upload tokens'); + $this->assertSame(123, $result['id'], 'create issue unwraps issue response'); + } + + private function testUpdateIssuePreservesParentAndUploads(): void + { + $http = new RecordingClient(); + $http->queueJson([], 204); + $client = new RedmineClient($http); + + $ok = $client->updateIssue(123, [ + 'parent_id' => 99, + 'category_id' => 4, + 'uploads' => [ + ['token' => 'tok-2', 'filename' => 'followup.txt'], + ], + ]); + + $request = $http->requests[0]; + $payload = $this->json($request['content']); + $this->assertSame(true, $ok, 'update issue returns true on 204'); + $this->assertSame('PUT', $request['method'], 'update issue uses PUT'); + $this->assertSame('/issues/123.json', $request['path'], 'update issue uses raw JSON endpoint'); + $this->assertSame(99, $payload['issue']['parent_id'], 'update issue preserves parent_id'); + $this->assertSame('tok-2', $payload['issue']['uploads'][0]['token'], 'update issue preserves upload tokens'); + } + + private function testMcpCreateIssueAcceptsFlatIssueFields(): void + { + $http = new RecordingClient(); + $http->queueJson(['issue' => ['id' => 321]], 201); + $dispatcher = new McpDispatcher(new RedmineClient($http)); + + $response = $dispatcher->handleMessage([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'redmine_create_issue', + 'arguments' => [ + 'project_id' => 'quality-tracker', + 'subject' => 'Front warehouse deadbolt key gets stuck in lock', + 'description' => "Problem: The deadbolt on the front door is sticking.\n\n~HermesBot", + ], + ], + ]); + + if (!is_array($response) || isset($response['error'])) { + throw new RuntimeException('Expected flat create issue call to succeed: ' . json_encode($response)); + } + $payload = $this->json($http->requests[0]['content']); + $this->assertSame('quality-tracker', $payload['issue']['project_id'], 'flat MCP create preserves project_id'); + $this->assertSame('Front warehouse deadbolt key gets stuck in lock', $payload['issue']['subject'], 'flat MCP create preserves subject'); + $this->assertStringContains('~HermesBot', $payload['issue']['description'], 'flat MCP create preserves multiline description'); + } + + private function testMcpUpdateIssueAcceptsFlatIssueFields(): void + { + $http = new RecordingClient(); + $http->queueJson([], 204); + $dispatcher = new McpDispatcher(new RedmineClient($http)); + + $response = $dispatcher->handleMessage([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'redmine_update_issue', + 'arguments' => [ + 'issue_id' => 321, + 'notes' => 'Locksmith has been scheduled.', + 'status_id' => 2, + 'options' => ['send_helpdesk_email' => false], + ], + ], + ]); + + if (!is_array($response) || isset($response['error'])) { + throw new RuntimeException('Expected flat update issue call to succeed: ' . json_encode($response)); + } + $payload = $this->json($http->requests[0]['content']); + $this->assertSame('/issues/321.json', $http->requests[0]['path'], 'flat MCP update uses requested issue id'); + $this->assertSame('Locksmith has been scheduled.', $payload['issue']['notes'], 'flat MCP update preserves notes'); + $this->assertSame(2, $payload['issue']['status_id'], 'flat MCP update preserves status_id'); + $this->assertSame(false, isset($payload['issue']['options']), 'flat MCP update does not forward options as issue field'); + } + + private function testMcpFindProjectRecommendsExactIdentifier(): void + { + $http = new RecordingClient(); + $http->queueJson(['projects' => $this->projectFixtures()]); + $dispatcher = new McpDispatcher(new RedmineClient($http)); + + $result = $this->callToolJson($dispatcher, 'redmine_find_project', ['query' => 'quality-tracker']); + + $this->assertSame('quality-tracker', $result['recommended_project_id'], 'exact identifier produces recommendation'); + $this->assertSame('quality-tracker', $result['matches'][0]['identifier'], 'exact identifier match is first'); + $this->assertSame('exact_identifier', $result['matches'][0]['match_reason'], 'exact identifier reason is reported'); + $this->assertSame('quality-tracker', $result['matches'][0]['project_id_to_use'], 'identifier is preferred project_id_to_use'); + } + + private function testMcpFindProjectRecommendsExactName(): void + { + $http = new RecordingClient(); + $http->queueJson(['projects' => $this->projectFixtures()]); + $dispatcher = new McpDispatcher(new RedmineClient($http)); + + $result = $this->callToolJson($dispatcher, 'redmine_find_project', ['query' => 'Quality Tracker']); + + $this->assertSame('quality-tracker', $result['recommended_project_id'], 'exact project name produces recommendation'); + $this->assertSame('Quality Tracker', $result['matches'][0]['name'], 'exact name match is first'); + $this->assertSame('exact_name', $result['matches'][0]['match_reason'], 'exact name reason is reported'); + } + + private function testMcpFindProjectLeavesAmbiguousMatchesUnrecommended(): void + { + $http = new RecordingClient(); + $http->queueJson(['projects' => $this->projectFixtures()]); + $dispatcher = new McpDispatcher(new RedmineClient($http)); + + $result = $this->callToolJson($dispatcher, 'redmine_find_project', ['query' => 'quality']); + + $this->assertSame(null, $result['recommended_project_id'], 'ambiguous project query has no recommendation'); + $this->assertSame(2, count($result['matches']), 'ambiguous project query returns both matches'); + $this->assertSame('quality-tracker', $result['matches'][0]['identifier'], 'first ambiguous match is ranked deterministically'); + $this->assertSame('quality-archive', $result['matches'][1]['identifier'], 'second ambiguous match is returned'); + } + + private function testCreateRelationDefaultsToRelatesAndRequiresTarget(): void + { + $http = new RecordingClient(); + $http->queueJson(['relation' => ['id' => 55]], 201); + $client = new RedmineClient($http); + + $result = $client->createIssueRelation(10, ['issue_to_id' => 11]); + $request = $http->requests[0]; + $payload = $this->json($request['content']); + + $this->assertSame('/issues/10/relations.json', $request['path'], 'relation create uses issue relations endpoint'); + $this->assertSame(11, $payload['relation']['issue_to_id'], 'relation create sends issue_to_id'); + $this->assertSame('relates', $payload['relation']['relation_type'], 'relation create defaults to relates'); + $this->assertSame(55, $result['id'], 'relation create unwraps relation response'); + + $this->assertThrows( + static fn() => $client->createIssueRelation(10, []), + 'issue_to_id', + 'relation create requires issue_to_id' + ); + } + + private function testAttachmentUploadSupportsPathAndBase64(): void + { + $path = sys_get_temp_dir() . '/redmcp-upload-test.txt'; + file_put_contents($path, 'from path'); + + $http = new RecordingClient(); + $http->queueJson(['upload' => ['token' => 'path-token']], 201); + $http->queueJson(['upload' => ['token' => 'base64-token']], 201); + $client = new RedmineClient($http); + + $pathResult = $client->uploadAttachment(['path' => $path, 'content_type' => 'text/plain']); + $base64Result = $client->uploadAttachment([ + 'base64_content' => base64_encode('from base64'), + 'filename' => 'base64.txt', + 'content_type' => 'text/plain', + ]); + + $this->assertSame('path-token', $pathResult['token'], 'path upload unwraps token'); + $this->assertStringContains('filename=redmcp-upload-test.txt', $http->requests[0]['path'], 'path upload uses basename as filename'); + $this->assertSame('from path', $http->requests[0]['content'], 'path upload sends file bytes'); + $this->assertSame('application/octet-stream', $http->requests[0]['content_type'], 'path upload sends bytes as Redmine upload stream'); + $this->assertSame('text/plain', $pathResult['content_type'], 'path upload preserves desired attachment content type metadata'); + $this->assertSame('base64-token', $base64Result['token'], 'base64 upload unwraps token'); + $this->assertStringContains('filename=base64.txt', $http->requests[1]['path'], 'base64 upload uses provided filename'); + $this->assertSame('from base64', $http->requests[1]['content'], 'base64 upload sends decoded bytes'); + } + + private function testAttachmentUploadAcceptsPdfDataUrl(): void + { + $http = new RecordingClient(); + $http->queueJson(['upload' => ['token' => 'pdf-token']], 201); + $client = new RedmineClient($http); + + $result = $client->uploadAttachment([ + 'data_url' => 'data:application/pdf;base64,' . base64_encode('%PDF-1.4 raw pdf bytes'), + ]); + + $this->assertSame('pdf-token', $result['token'], 'PDF data URL upload unwraps token'); + $this->assertStringContains('filename=attachment.pdf', $http->requests[0]['path'], 'PDF data URL derives a useful filename'); + $this->assertSame('%PDF-1.4 raw pdf bytes', $http->requests[0]['content'], 'PDF data URL upload sends decoded bytes'); + $this->assertSame('application/octet-stream', $http->requests[0]['content_type'], 'PDF data URL upload sends bytes as Redmine upload stream'); + $this->assertSame('application/pdf', $result['content_type'], 'PDF data URL preserves PDF content type metadata'); + } + + private function testAttachmentUploadAcceptsFileEnvelope(): void + { + $http = new RecordingClient(); + $http->queueJson(['upload' => ['token' => 'file-token']], 201); + $client = new RedmineClient($http); + + $result = $client->uploadAttachment([ + 'file' => [ + 'name' => 'quote.pdf', + 'mime_type' => 'application/pdf', + 'data' => base64_encode('%PDF-1.7 envelope bytes'), + ], + ]); + + $this->assertSame('file-token', $result['token'], 'file envelope upload unwraps token'); + $this->assertStringContains('filename=quote.pdf', $http->requests[0]['path'], 'file envelope uses provided name as filename'); + $this->assertSame('%PDF-1.7 envelope bytes', $http->requests[0]['content'], 'file envelope sends decoded bytes'); + $this->assertSame('application/pdf', $result['content_type'], 'file envelope preserves MIME type metadata'); + } + + private function testDownloadPathValidationRejectsUnsafePaths(): void + { + $client = new RedmineClient(new RecordingClient()); + + $this->assertThrows( + static fn() => $client->downloadAttachment(77, '/etc/redmcp-forbidden.txt'), + 'under /tmp or the repository tree', + 'download rejects paths outside safe roots' + ); + } + + private function testDownloadAttachmentWritesSafePathAndLimitsBase64(): void + { + $destination = sys_get_temp_dir() . '/redmcp-download-test.txt'; + if (file_exists($destination)) { + unlink($destination); + } + + $http = new RecordingClient(); + $http->queueBinary('downloaded attachment', 'text/plain'); + $client = new RedmineClient($http); + + $result = $client->downloadAttachment(77, $destination, true, 4); + + $this->assertSame('/attachments/download/77', $http->requests[0]['path'], 'download uses Redmine attachment download endpoint'); + $this->assertSame('downloaded attachment', (string) file_get_contents($destination), 'download writes attachment bytes'); + $this->assertSame(21, $result['bytes'], 'download reports byte length'); + $this->assertSame(true, $result['base64_omitted'], 'download omits oversized base64 content'); + } + + private function testMcpToolListExposesStructureToolsWithoutIssueDelete(): void + { + $dispatcher = new McpDispatcher(new RedmineClient(new RecordingClient())); + $response = $dispatcher->handleMessage([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + ]); + if (!is_array($response)) { + throw new RuntimeException('Expected tools/list response.'); + } + + $names = array_map( + static fn(array $tool): string => (string) $tool['name'], + $response['result']['tools'] + ); + + foreach ([ + 'redmine_list_issue_relations', + 'redmine_find_project', + 'redmine_get_issue_relation', + 'redmine_create_issue_relation', + 'redmine_remove_issue_relation', + 'redmine_list_issue_children', + 'redmine_set_issue_parent', + 'redmine_clear_issue_parent', + 'redmine_list_project_issue_categories', + 'redmine_get_issue_category', + 'redmine_create_issue_category', + 'redmine_update_issue_category', + 'redmine_get_attachment', + 'redmine_upload_attachment', + 'redmine_download_attachment', + 'redmine_update_attachment', + ] as $expectedTool) { + $this->assertContains($expectedTool, $names, $expectedTool . ' is listed'); + } + + $this->assertNotContains('redmine_delete_issue', $names, 'issue delete tool is not listed'); + + $uploadTool = null; + foreach ($response['result']['tools'] as $tool) { + if (($tool['name'] ?? '') === 'redmine_upload_attachment') { + $uploadTool = $tool; + break; + } + } + if (!is_array($uploadTool)) { + throw new RuntimeException('Expected redmine_upload_attachment tool schema.'); + } + $uploadProperties = array_keys($uploadTool['inputSchema']['properties']); + $this->assertContains('data_url', $uploadProperties, 'upload tool advertises data_url input'); + $this->assertContains('file', $uploadProperties, 'upload tool advertises file envelope input'); + + $createIssueTool = null; + foreach ($response['result']['tools'] as $tool) { + if (($tool['name'] ?? '') === 'redmine_create_issue') { + $createIssueTool = $tool; + break; + } + } + if (!is_array($createIssueTool)) { + throw new RuntimeException('Expected redmine_create_issue tool schema.'); + } + $projectDescription = (string) $createIssueTool['inputSchema']['properties']['project_id']['description']; + $this->assertStringContains('redmine_find_project', $projectDescription, 'project_id schema points agents to project resolver'); + } + + /** + * @return array> + */ + private function projectFixtures(): array + { + return [ + ['id' => 78, 'identifier' => 'quality-tracker', 'name' => 'Quality Tracker'], + ['id' => 79, 'identifier' => 'quality-archive', 'name' => 'Quality Archive'], + ['id' => 80, 'identifier' => 'warehouse', 'name' => 'Warehouse Operations'], + ]; + } + + /** + * @param array $arguments + * @return array + */ + private function callToolJson(McpDispatcher $dispatcher, string $name, array $arguments): array + { + $response = $dispatcher->handleMessage([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => $name, + 'arguments' => $arguments, + ], + ]); + if (!is_array($response) || isset($response['error'])) { + throw new RuntimeException('Expected MCP tool call to succeed: ' . json_encode($response)); + } + $content = $response['result']['content'][0]['text'] ?? null; + if (!is_string($content)) { + throw new RuntimeException('Expected MCP tool text content.'); + } + + return $this->json($content); + } + + /** + * @return array + */ + private function json(string $content): array + { + $decoded = json_decode($content, true); + if (!is_array($decoded)) { + throw new RuntimeException('Invalid JSON: ' . $content); + } + + return $decoded; + } + + /** + * @param mixed $expected + * @param mixed $actual + */ + private function assertSame($expected, $actual, string $message): void + { + $this->assertions++; + if ($expected === $actual) { + return; + } + + fwrite(STDERR, "FAIL: {$message}\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true) . "\n"); + exit(1); + } + + private function assertStringContains(string $needle, string $haystack, string $message): void + { + $this->assertions++; + if (strpos($haystack, $needle) !== false) { + return; + } + + fwrite(STDERR, "FAIL: {$message}\nNeedle: {$needle}\nHaystack: {$haystack}\n"); + exit(1); + } + + /** + * @param array $haystack + */ + private function assertContains(string $needle, array $haystack, string $message): void + { + $this->assertions++; + if (in_array($needle, $haystack, true)) { + return; + } + + fwrite(STDERR, "FAIL: {$message}\nMissing: {$needle}\n"); + exit(1); + } + + /** + * @param array $haystack + */ + private function assertNotContains(string $needle, array $haystack, string $message): void + { + $this->assertions++; + if (!in_array($needle, $haystack, true)) { + return; + } + + fwrite(STDERR, "FAIL: {$message}\nUnexpected: {$needle}\n"); + exit(1); + } + + private function assertThrows(callable $callback, string $expectedMessagePart, string $message): void + { + $this->assertions++; + try { + $callback(); + } catch (Throwable $exception) { + if (strpos($exception->getMessage(), $expectedMessagePart) !== false) { + return; + } + + fwrite(STDERR, "FAIL: {$message}\nExpected exception containing: {$expectedMessagePart}\nActual: {$exception->getMessage()}\n"); + exit(1); + } + + fwrite(STDERR, "FAIL: {$message}\nExpected exception was not thrown.\n"); + exit(1); + } +} + +(new RedmineStructureTest())->run(); diff --git a/redMCP/composer.json b/redMCP/composer.json index 22d7fa2..453e6ad 100644 --- a/redMCP/composer.json +++ b/redMCP/composer.json @@ -11,7 +11,9 @@ "bin/redmcp-server.php", "bin/redmcp-http-server.php", "bin/generate-bearer-token.php", - "bin/test-query-normalizer.php" + "bin/test-query-normalizer.php", + "bin/test-redmine-structure.php", + "bin/test-mcp-http-handler.php" ], "require": { "kbsali/redmine-api": "^2.9"