Expand redMCP safe issue operations and HTTP handling
This commit is contained in:
+342
-14
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user