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->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
+342 -14
View File
@@ -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<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
*/
@@ -331,6 +644,21 @@ final class McpDispatcher
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
*/
+78 -2
View File
@@ -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<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>
*/
@@ -100,6 +155,27 @@ final class McpHttpHandler
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>
*/
+459 -19
View File
@@ -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<string,mixed> $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<string,mixed> $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<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.
*
@@ -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<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();
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
*/
+2 -2
View File
@@ -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');
+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-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"