Expand redMCP safe issue operations and HTTP handling

This commit is contained in:
Jason Thistlethwaite
2026-05-04 09:50:11 -04:00
parent b305544f63
commit 4c931bae1a
8 changed files with 1725 additions and 45 deletions
+127 -7
View File
@@ -45,7 +45,6 @@ $created = $client->createIssue([
]); ]);
$client->updateIssue((int) $created['id'], ['notes' => 'Follow-up note']); $client->updateIssue((int) $created['id'], ['notes' => 'Follow-up note']);
$client->deleteIssue((int) $created['id']);
``` ```
Native Redmine search is exposed separately from issue filtering. Use 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']); $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 `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 normal Redmine note does **not** send an email to the customer. To send through
the Helpdesk plugin, opt in explicitly: 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 changes, and automation cleanup. Use the Helpdesk email path only when the
caller deliberately wants the customer to receive mail. 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 ## MCP server
`redMCP` can run as either a stdio MCP server or a network MCP server. It reads `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 redMCP/bin/redmcp-server.php
``` ```
For local network testing, run the Streamable HTTP server: For local testing, run the Streamable HTTP server:
```sh ```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: Generate a bearer token with:
```sh ```sh
@@ -153,10 +235,44 @@ Example Streamable HTTP request:
curl -sS \ curl -sS \
-H 'Authorization: Bearer test-token' \ -H 'Authorization: Bearer test-token' \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
http://127.0.0.1:8765/mcp \ http://127.0.0.1:8765/mcp \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' -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: HTTP server process helpers:
```sh ```sh
@@ -195,15 +311,19 @@ Example stdio client configuration:
``` ```
Both transports expose tools for native Redmine project listing/detail, project Both transports expose tools for native Redmine project listing/detail, project
memberships, users, filtering/search, issue CRUD, Helpdesk-aware issue reads, memberships, users, filtering/search, issue create/update, issue relations,
and explicit Helpdesk email responses. Tools that can send customer-visible mail subtasks/parents, project issue categories, attachments, Helpdesk-aware issue
require an explicit tool call such as `redmine_send_helpdesk_response` or reads, and explicit Helpdesk email responses. Tools that can send
`redmine_update_issue` with `send_helpdesk_email=true`. 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: Run the local no-network query normalizer checks with:
```sh ```sh
php redMCP/bin/test-query-normalizer.php php redMCP/bin/test-query-normalizer.php
php redMCP/bin/test-redmine-structure.php
php redMCP/bin/test-mcp-http-handler.php
``` ```
## Test instance ## Test instance
+342 -14
View File
@@ -9,6 +9,32 @@ use Throwable;
final class McpDispatcher 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 RedmineClient $redmine;
private McpDebugLogger $logger; private McpDebugLogger $logger;
@@ -91,19 +117,23 @@ final class McpDispatcher
private function tools(): array private function tools(): array
{ {
return [ 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], 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
'page' => ['type' => 'integer', 'minimum' => 1], 'page' => ['type' => 'integer', 'minimum' => 1],
'offset' => ['type' => 'integer', 'minimum' => 0], 'offset' => ['type' => 'integer', 'minimum' => 0],
'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'], '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.'], '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.', [ $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.'], 'params' => ['type' => 'object', 'description' => 'Optional Redmine project params such as include=trackers,issue_categories,enabled_modules.'],
], ['project_id']), ], ['project_id']),
$this->tool('redmine_list_project_memberships', 'List users/groups and roles for a Redmine project.', [ $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], 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100],
'page' => ['type' => 'integer', 'minimum' => 1], 'page' => ['type' => 'integer', 'minimum' => 1],
'offset' => ['type' => 'integer', 'minimum' => 0], '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.'], 'params' => ['type' => 'object', 'description' => 'Optional Redmine user params such as include=memberships,groups.'],
], ['user_id']), ], ['user_id']),
$this->tool('redmine_list_issues', 'List Redmine issues using native /issues.json filters.', [ $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' => ['description' => 'Issue status such as open, closed, all, or a Redmine status id.'],
'status_id' => ['description' => 'Raw Redmine status id or status token.'], 'status_id' => ['description' => 'Raw Redmine status id or status token.'],
'tracker_id' => ['type' => ['string', 'integer']], 'tracker_id' => ['type' => ['string', 'integer']],
@@ -145,7 +175,7 @@ final class McpDispatcher
]), ]),
$this->tool('redmine_search', 'Search Redmine using native /search.json.', [ $this->tool('redmine_search', 'Search Redmine using native /search.json.', [
'query' => ['type' => 'string'], 'query' => ['type' => 'string'],
'project_id' => ['type' => ['string', 'integer']], 'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
'scope' => ['type' => 'string'], 'scope' => ['type' => 'string'],
'all_words' => ['type' => ['boolean', 'string', 'integer']], 'all_words' => ['type' => ['boolean', 'string', 'integer']],
'titles_only' => ['type' => ['boolean', 'string', 'integer']], 'titles_only' => ['type' => ['boolean', 'string', 'integer']],
@@ -158,7 +188,7 @@ final class McpDispatcher
], ['query']), ], ['query']),
$this->tool('redmine_search_issues', 'Search only issues using native /search.json.', [ $this->tool('redmine_search_issues', 'Search only issues using native /search.json.', [
'query' => ['type' => 'string'], 'query' => ['type' => 'string'],
'project_id' => ['type' => ['string', 'integer']], 'project_id' => ['type' => ['string', 'integer'], 'description' => self::PROJECT_ID_DESCRIPTION],
'scope' => ['type' => 'string'], 'scope' => ['type' => 'string'],
'all_words' => ['type' => ['boolean', 'string', 'integer']], 'all_words' => ['type' => ['boolean', 'string', 'integer']],
'titles_only' => ['type' => ['boolean', 'string', 'integer']], 'titles_only' => ['type' => ['boolean', 'string', 'integer']],
@@ -173,6 +203,34 @@ final class McpDispatcher
'issue_id' => ['type' => 'integer'], 'issue_id' => ['type' => 'integer'],
'include' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Issue includes such as journals, attachments, children, relations, changesets.'], 'include' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Issue includes such as journals, attachments, children, relations, changesets.'],
], ['issue_id']), ], ['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.', [ $this->tool('redmine_issue_with_helpdesk', 'Fetch one issue plus Helpdesk ticket/message context when available.', [
'issue_id' => ['type' => 'integer'], 'issue_id' => ['type' => 'integer'],
'message_limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 200], 'message_limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 200],
@@ -180,15 +238,98 @@ final class McpDispatcher
], ['issue_id']), ], ['issue_id']),
$this->tool('redmine_create_issue', 'Create a Redmine issue.', [ $this->tool('redmine_create_issue', 'Create a Redmine issue.', [
'fields' => ['type' => 'object', 'description' => 'Issue fields including project_id and subject.'], '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.', [ $this->tool('redmine_update_issue', 'Update a Redmine issue. Helpdesk email is opt-in.', [
'issue_id' => ['type' => 'integer'], 'issue_id' => ['type' => 'integer'],
'fields' => ['type' => 'object'], 'fields' => ['type' => 'object'],
'options' => ['type' => 'object', 'description' => 'Pass send_helpdesk_email=true only for customer-visible Helpdesk replies.'], 'options' => ['type' => 'object', 'description' => 'Pass send_helpdesk_email=true only for customer-visible Helpdesk replies.'],
], ['issue_id', 'fields']), 'subject' => ['type' => 'string', 'description' => 'Flat issue subject; copied into fields.'],
$this->tool('redmine_delete_issue', 'Delete a Redmine issue.', [ 'description' => ['type' => 'string', 'description' => 'Flat issue description; copied into fields.'],
'issue_id' => ['type' => 'integer'], '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']), ], ['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.', [ $this->tool('redmine_send_helpdesk_response', 'Send a customer-visible Helpdesk email response.', [
'issue_id' => ['type' => 'integer'], 'issue_id' => ['type' => 'integer'],
'content' => ['type' => 'string'], 'content' => ['type' => 'string'],
@@ -231,6 +372,9 @@ final class McpDispatcher
case 'redmine_list_projects': case 'redmine_list_projects':
$result = $this->redmine->listProjects(ListQueryNormalizer::listParams($arguments)); $result = $this->redmine->listProjects(ListQueryNormalizer::listParams($arguments));
break; break;
case 'redmine_find_project':
$result = $this->findProject($this->stringArg($arguments, 'query'), $this->intArg($arguments, 'limit', 10));
break;
case 'redmine_get_project': case 'redmine_get_project':
$result = $this->redmine->project($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params')); $result = $this->redmine->project($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params'));
break; break;
@@ -255,6 +399,29 @@ final class McpDispatcher
case 'redmine_get_issue': case 'redmine_get_issue':
$result = $this->redmine->issue($this->intArg($arguments, 'issue_id'), $this->stringListArg($arguments, 'include', ['journals', 'attachments'])); $result = $this->redmine->issue($this->intArg($arguments, 'issue_id'), $this->stringListArg($arguments, 'include', ['journals', 'attachments']));
break; 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': case 'redmine_issue_with_helpdesk':
$result = $this->redmine->issueWithHelpdesk( $result = $this->redmine->issueWithHelpdesk(
$this->intArg($arguments, 'issue_id'), $this->intArg($arguments, 'issue_id'),
@@ -263,13 +430,39 @@ final class McpDispatcher
); );
break; break;
case 'redmine_create_issue': case 'redmine_create_issue':
$result = $this->redmine->createIssue($this->objectArg($arguments, 'fields')); $result = $this->redmine->createIssue($this->issueFieldsArg($arguments));
break; break;
case 'redmine_update_issue': 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; break;
case 'redmine_delete_issue': case 'redmine_list_project_issue_categories':
$result = ['ok' => $this->redmine->deleteIssue($this->intArg($arguments, 'issue_id'))]; $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; break;
case 'redmine_send_helpdesk_response': case 'redmine_send_helpdesk_response':
$result = $this->redmine->sendHelpdeskIssueResponse($this->intArg($arguments, 'issue_id'), $this->stringArg($arguments, 'content'), $this->objectArg($arguments, 'options')); $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] : []; return is_array($arguments[$key] ?? null) ? $arguments[$key] : [];
} }
/**
* @param array<string,mixed> $arguments
*
* @return array<string,mixed>
*/
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<string,mixed>
*/
private function findProject(string $query, int $limit): array
{
$limit = max(1, min(25, $limit));
$projectsResponse = $this->redmine->listProjects(['limit' => 100]);
$projects = is_array($projectsResponse['projects'] ?? null) ? $projectsResponse['projects'] : [];
$matches = [];
foreach ($projects as $project) {
if (!is_array($project)) {
continue;
}
$match = $this->projectMatch($project, $query);
if ($match !== null) {
$matches[] = $match;
}
}
usort($matches, static function (array $a, array $b): int {
$scoreCompare = ($b['score'] <=> $a['score']);
if ($scoreCompare !== 0) {
return $scoreCompare;
}
$idCompare = ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0));
if ($idCompare !== 0) {
return $idCompare;
}
return strcmp((string) $a['project_id_to_use'], (string) $b['project_id_to_use']);
});
$matches = array_slice($matches, 0, $limit);
$recommended = null;
if (count($matches) === 1 || (isset($matches[0], $matches[1]) && $matches[0]['score'] > $matches[1]['score'])) {
$recommended = $matches[0]['project_id_to_use'] ?? null;
}
return [
'query' => $query,
'recommended_project_id' => $recommended,
'matches' => $matches,
];
}
/**
* @param array<string,mixed> $project
*
* @return array<string,mixed>|null
*/
private function projectMatch(array $project, string $query): ?array
{
$normalizedQuery = $this->normalizeProjectText($query);
$id = $project['id'] ?? null;
$identifier = trim((string) ($project['identifier'] ?? ''));
$name = trim((string) ($project['name'] ?? ''));
$normalizedId = $id === null ? '' : $this->normalizeProjectText((string) $id);
$normalizedIdentifier = $this->normalizeProjectText($identifier);
$normalizedName = $this->normalizeProjectText($name);
$score = 0;
$reason = '';
if ($normalizedId !== '' && $normalizedQuery === $normalizedId) {
$score = 100;
$reason = 'exact_id';
} elseif ($normalizedIdentifier !== '' && $normalizedQuery === $normalizedIdentifier) {
$score = 100;
$reason = 'exact_identifier';
} elseif ($normalizedName !== '' && $normalizedQuery === $normalizedName) {
$score = 90;
$reason = 'exact_name';
} elseif ($normalizedIdentifier !== '' && str_starts_with($normalizedIdentifier, $normalizedQuery)) {
$score = 80;
$reason = 'identifier_prefix';
} elseif ($normalizedName !== '' && str_starts_with($normalizedName, $normalizedQuery)) {
$score = 70;
$reason = 'name_prefix';
} elseif ($normalizedIdentifier !== '' && str_contains($normalizedIdentifier, $normalizedQuery)) {
$score = 60;
$reason = 'identifier_contains';
} elseif ($normalizedName !== '' && str_contains($normalizedName, $normalizedQuery)) {
$score = 50;
$reason = 'name_contains';
} else {
return null;
}
return [
'id' => $id,
'identifier' => $identifier,
'name' => $name,
'score' => $score,
'match_reason' => $reason,
'project_id_to_use' => $identifier !== '' ? $identifier : $id,
];
}
private function normalizeProjectText(string $value): string
{
return strtolower(trim($value));
}
/** /**
* @param array<string,mixed> $arguments * @param array<string,mixed> $arguments
*/ */
@@ -331,6 +644,21 @@ final class McpDispatcher
return (int) $arguments[$key]; return (int) $arguments[$key];
} }
/**
* @param array<string,mixed> $arguments
*/
private function boolArg(array $arguments, string $key, bool $default = false): bool
{
if (!isset($arguments[$key])) {
return $default;
}
if (is_bool($arguments[$key])) {
return $arguments[$key];
}
return in_array(strtolower((string) $arguments[$key]), ['1', 'true', 'yes', 'on'], true);
}
/** /**
* @param array<string,mixed> $arguments * @param array<string,mixed> $arguments
*/ */
+78 -2
View File
@@ -23,8 +23,8 @@ final class McpHttpHandler
$this->sendJson(404, ['error' => 'not found']); $this->sendJson(404, ['error' => 'not found']);
return; return;
} }
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { if (!$this->originAllowed()) {
$this->sendJson(405, ['error' => 'method not allowed']); $this->sendJson(403, ['error' => 'origin not allowed']);
return; return;
} }
if (!$this->authorized()) { if (!$this->authorized()) {
@@ -32,6 +32,16 @@ final class McpHttpHandler
$this->sendJson(401, ['error' => 'unauthorized']); $this->sendJson(401, ['error' => 'unauthorized']);
return; 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'); $raw = file_get_contents('php://input');
$decoded = json_decode(is_string($raw) ? $raw : '', true); $decoded = json_decode(is_string($raw) ? $raw : '', true);
@@ -56,6 +66,10 @@ final class McpHttpHandler
http_response_code(202); http_response_code(202);
return; return;
} }
if ($this->acceptsEventStream()) {
$this->sendEventStream($responses);
return;
}
$this->sendJson(200, $responses); $this->sendJson(200, $responses);
return; return;
} }
@@ -66,6 +80,10 @@ final class McpHttpHandler
return; return;
} }
if ($this->acceptsEventStream()) {
$this->sendEventStream([$response]);
return;
}
$this->sendJson(200, $response); $this->sendJson(200, $response);
} }
@@ -79,6 +97,43 @@ final class McpHttpHandler
return hash_equals($this->token, substr($header, 7)); 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<int,string>
*/
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<string,mixed> * @return array<string,mixed>
*/ */
@@ -100,6 +155,27 @@ final class McpHttpHandler
echo json_encode($payload, JSON_UNESCAPED_SLASHES); echo json_encode($payload, JSON_UNESCAPED_SLASHES);
} }
/**
* @param array<int,array<string,mixed>> $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<string,mixed> * @return array<string,mixed>
*/ */
+459 -19
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace RedMCP; namespace RedMCP;
use Redmine\Client\Client;
use Redmine\Client\NativeCurlClient; use Redmine\Client\NativeCurlClient;
use Redmine\Http\HttpClient; use Redmine\Http\HttpClient;
use Redmine\Http\HttpFactory; use Redmine\Http\HttpFactory;
@@ -14,9 +15,9 @@ use Throwable;
final class RedmineClient final class RedmineClient
{ {
private NativeCurlClient $client; private Client $client;
public function __construct(NativeCurlClient $client) public function __construct(Client $client)
{ {
$this->client = $client; $this->client = $client;
} }
@@ -236,8 +237,8 @@ final class RedmineClient
* Create a Redmine issue. * Create a Redmine issue.
* *
* Typical fields include project_id, subject, description, tracker_id, * Typical fields include project_id, subject, description, tracker_id,
* status_id, priority_id, assigned_to_id, category_id, due_date, and * status_id, priority_id, assigned_to_id, category_id, parent_issue_id,
* start_date. * parent_id, uploads, due_date, and start_date.
* *
* @param array<string,mixed> $fields * @param array<string,mixed> $fields
* *
@@ -249,11 +250,9 @@ final class RedmineClient
throw new RuntimeException('Creating an issue requires at least project_id and subject.'); throw new RuntimeException('Creating an issue requires at least project_id and subject.');
} }
$issueApi = $this->client->getApi('issue'); $response = $this->postJson('/issues', ['issue' => $fields]);
$response = $issueApi->create($fields);
$this->assertLastApiResponseSucceeded($issueApi, 'create issue');
return $this->xmlResponseToArray($response); return is_array($response['issue'] ?? null) ? $response['issue'] : $response;
} }
/** /**
@@ -265,7 +264,8 @@ final class RedmineClient
* sendHelpdeskIssueResponse() directly. * sendHelpdeskIssueResponse() directly.
* *
* Typical fields include notes, subject, status_id, priority_id, * 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<string,mixed> $fields * @param array<string,mixed> $fields
*/ */
@@ -294,13 +294,383 @@ final class RedmineClient
return true; return true;
} }
$issueApi = $this->client->getApi('issue'); $this->putJson('/issues/' . rawurlencode((string) $issueId), ['issue' => $fields]);
$issueApi->update($issueId, $fields);
$this->assertLastApiResponseSucceeded($issueApi, 'update issue #' . $issueId);
return true; return true;
} }
/**
* @return array<string,mixed>
*/
public function issueWithStructure(int $issueId): array
{
return $this->issue($issueId, ['journals', 'attachments', 'children', 'relations']);
}
/**
* @return array<string,mixed>
*/
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<string,mixed>
*/
public function listIssueRelations(int $issueId): array
{
return $this->getJson('/issues/' . rawurlencode((string) $issueId) . '/relations') ?? [];
}
/**
* @return array<string,mixed>
*/
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<string,mixed> $fields
*
* @return array<string,mixed>
*/
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<string,mixed>
*/
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<string,mixed>
*/
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<string,mixed> $fields
*
* @return array<string,mixed>
*/
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<string,mixed> $fields
*
* @return array<string,mixed>
*/
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<string,mixed>
*/
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<string,mixed> $source
*
* @return array<string,mixed>
*/
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<string,mixed> $source
*
* @return array<string,mixed>
*/
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<string,mixed>
*/
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<string,mixed>
*/
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. * 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 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, []); $requestPath = $this->buildPath($path, []);
$encoded = json_encode($payload); $encoded = json_encode($payload);
if ($encoded === false) { if ($encoded === false) {
throw new RuntimeException('Could not encode Redmine POST payload.'); 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(); $status = $response->getStatusCode();
if ($status >= 400) { if ($status >= 400) {
throw new RuntimeException('Redmine POST ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent()); throw new RuntimeException('Redmine POST ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent());
} }
return $this->decodeJsonResponse($response, 'Redmine POST ' . $requestPath);
}
/**
* @param array<string,mixed> $payload
*
* @return array<string,mixed>
*/
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<string,mixed>
*/
private function decodeJsonResponse(\Redmine\Http\Response $response, string $action): array
{
$body = $response->getContent(); $body = $response->getContent();
if ($body === '') { if ($body === '') {
return []; return [];
@@ -522,11 +937,11 @@ final class RedmineClient
try { try {
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (Throwable $exception) { } 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)) { if (!is_array($decoded)) {
throw new RuntimeException('Redmine POST ' . $requestPath . ' returned invalid JSON.'); throw new RuntimeException($action . ' returned invalid JSON.');
} }
return $decoded; return $decoded;
@@ -540,6 +955,31 @@ final class RedmineClient
return PathSerializer::create($path . '.json', $params)->getPath(); 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 * @param mixed $api
*/ */
+2 -2
View File
@@ -11,12 +11,12 @@ $options = getopt('', ['host:', 'port:', 'path:', 'pid-file:', 'debug-log:', 'st
if (isset($options['help'])) { if (isset($options['help'])) {
fwrite( fwrite(
STDOUT, 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); exit(0);
} }
$host = (string) ($options['host'] ?? '0.0.0.0'); $host = (string) ($options['host'] ?? '127.0.0.1');
$port = (int) ($options['port'] ?? 8765); $port = (int) ($options['port'] ?? 8765);
$path = (string) ($options['path'] ?? '/mcp'); $path = (string) ($options['path'] ?? '/mcp');
$pidFile = (string) ($options['pid-file'] ?? '/tmp/redmcp-http-server.pid'); $pidFile = (string) ($options['pid-file'] ?? '/tmp/redmcp-http-server.pid');
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
$repoRoot = dirname(__DIR__);
$tmpDir = sys_get_temp_dir() . '/redmcp-http-test-' . getmypid();
if (!mkdir($tmpDir, 0775, true) && !is_dir($tmpDir)) {
throw new RuntimeException('Could not create temp test dir.');
}
$router = $tmpDir . '/router.php';
file_put_contents($router, <<<'PHP'
<?php
declare(strict_types=1);
use RedMCP\McpDispatcher;
use RedMCP\McpHttpHandler;
use RedMCP\RedmineClient;
require '%AUTOLOAD%';
$handler = new McpHttpHandler(
new McpDispatcher(RedmineClient::fromCredentials('http://127.0.0.1', 'test-key')),
'test-token',
'/mcp'
);
$handler->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<int,string> $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);
}
+550
View File
@@ -0,0 +1,550 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use RedMCP\RedmineClient;
use RedMCP\McpDispatcher;
use Redmine\Api;
use Redmine\Client\Client;
use Redmine\Http\HttpClient;
use Redmine\Http\HttpFactory;
use Redmine\Http\Request;
use Redmine\Http\Response;
require __DIR__ . '/../vendor/autoload.php';
final class RecordingClient implements Client, HttpClient
{
/** @var array<int,array{method:string,path:string,content_type:string,content:string}> */
public array $requests = [];
/** @var array<int,Response> */
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<int,array<string,mixed>>
*/
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<string,mixed> $arguments
* @return array<string,mixed>
*/
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<string,mixed>
*/
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<int,string> $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<int,string> $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();
+3 -1
View File
@@ -11,7 +11,9 @@
"bin/redmcp-server.php", "bin/redmcp-server.php",
"bin/redmcp-http-server.php", "bin/redmcp-http-server.php",
"bin/generate-bearer-token.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": { "require": {
"kbsali/redmine-api": "^2.9" "kbsali/redmine-api": "^2.9"