Files
redmine/redMCP/app/McpDispatcher.php
T
2026-04-25 02:23:48 +00:00

260 lines
9.7 KiB
PHP

<?php
declare(strict_types=1);
namespace RedMCP;
use RuntimeException;
use Throwable;
final class McpDispatcher
{
private RedmineClient $redmine;
public function __construct(RedmineClient $redmine)
{
$this->redmine = $redmine;
}
/**
* @param array<string,mixed> $message
*
* @return array<string,mixed>|null
*/
public function handleMessage(array $message): ?array
{
$id = $message['id'] ?? null;
if ($id === null) {
return null;
}
try {
$method = (string) ($message['method'] ?? '');
$params = is_array($message['params'] ?? null) ? $message['params'] : [];
return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->dispatch($method, $params)];
} catch (Throwable $exception) {
return [
'jsonrpc' => '2.0',
'id' => $id,
'error' => [
'code' => -32000,
'message' => $exception->getMessage(),
],
];
}
}
/**
* @param array<string,mixed> $params
*
* @return array<string,mixed>
*/
private function dispatch(string $method, array $params): array
{
switch ($method) {
case 'initialize':
return [
'protocolVersion' => '2025-03-26',
'capabilities' => [
'tools' => ['listChanged' => false],
],
'serverInfo' => [
'name' => 'redMCP',
'version' => '0.1.0',
],
];
case 'ping':
return [];
case 'tools/list':
return ['tools' => $this->tools()];
case 'tools/call':
return $this->callTool($params);
case 'resources/list':
return ['resources' => []];
case 'prompts/list':
return ['prompts' => []];
default:
throw new RuntimeException('Unsupported MCP method: ' . $method);
}
}
/**
* @return array<int,array<string,mixed>>
*/
private function tools(): array
{
return [
$this->tool('redmine_list_issues', 'List Redmine issues using native /issues.json filters.', [
'filters' => ['type' => 'object', 'description' => 'Redmine issue list filters such as project_id, status_id, query_id, sort, offset, and limit.'],
]),
$this->tool('redmine_search', 'Search Redmine using native /search.json.', [
'query' => ['type' => 'string'],
'params' => ['type' => 'object', 'description' => 'Redmine search params such as project_id, all_words, titles_only, offset, and limit.'],
], ['query']),
$this->tool('redmine_search_issues', 'Search only issues using native /search.json.', [
'query' => ['type' => 'string'],
'params' => ['type' => 'object', 'description' => 'Additional Redmine search params.'],
], ['query']),
$this->tool('redmine_get_issue', 'Fetch one Redmine issue.', [
'issue_id' => ['type' => 'integer'],
'include' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Issue includes such as journals, attachments, children, relations, changesets.'],
], ['issue_id']),
$this->tool('redmine_issue_with_helpdesk', 'Fetch one issue plus Helpdesk ticket/message context when available.', [
'issue_id' => ['type' => 'integer'],
'message_limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 200],
'include' => ['type' => 'array', 'items' => ['type' => 'string']],
], ['issue_id']),
$this->tool('redmine_create_issue', 'Create a Redmine issue.', [
'fields' => ['type' => 'object', 'description' => 'Issue fields including project_id and subject.'],
], ['fields']),
$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'],
], ['issue_id']),
$this->tool('redmine_send_helpdesk_response', 'Send a customer-visible Helpdesk email response.', [
'issue_id' => ['type' => 'integer'],
'content' => ['type' => 'string'],
'options' => ['type' => 'object', 'description' => 'Optional to_address, cc_address, bcc_address, and status_id.'],
], ['issue_id', 'content']),
];
}
/**
* @param array<string,mixed> $properties
* @param array<int,string> $required
*
* @return array<string,mixed>
*/
private function tool(string $name, string $description, array $properties, array $required = []): array
{
return [
'name' => $name,
'description' => $description,
'inputSchema' => [
'type' => 'object',
'properties' => $properties,
'required' => $required,
'additionalProperties' => false,
],
];
}
/**
* @param array<string,mixed> $params
*
* @return array<string,mixed>
*/
private function callTool(array $params): array
{
$name = (string) ($params['name'] ?? '');
$arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : [];
switch ($name) {
case 'redmine_list_issues':
$result = $this->redmine->filterIssues($this->objectArg($arguments, 'filters'));
break;
case 'redmine_search':
$result = $this->redmine->search($this->stringArg($arguments, 'query'), $this->objectArg($arguments, 'params'));
break;
case 'redmine_search_issues':
$result = $this->redmine->searchIssues($this->stringArg($arguments, 'query'), $this->objectArg($arguments, 'params'));
break;
case 'redmine_get_issue':
$result = $this->redmine->issue($this->intArg($arguments, 'issue_id'), $this->stringListArg($arguments, 'include', ['journals', 'attachments']));
break;
case 'redmine_issue_with_helpdesk':
$result = $this->redmine->issueWithHelpdesk(
$this->intArg($arguments, 'issue_id'),
$this->intArg($arguments, 'message_limit', 100),
$this->stringListArg($arguments, 'include', ['journals', 'attachments'])
);
break;
case 'redmine_create_issue':
$result = $this->redmine->createIssue($this->objectArg($arguments, 'fields'));
break;
case 'redmine_update_issue':
$result = ['ok' => $this->redmine->updateIssue($this->intArg($arguments, 'issue_id'), $this->objectArg($arguments, 'fields'), $this->objectArg($arguments, 'options'))];
break;
case 'redmine_delete_issue':
$result = ['ok' => $this->redmine->deleteIssue($this->intArg($arguments, 'issue_id'))];
break;
case 'redmine_send_helpdesk_response':
$result = $this->redmine->sendHelpdeskIssueResponse($this->intArg($arguments, 'issue_id'), $this->stringArg($arguments, 'content'), $this->objectArg($arguments, 'options'));
break;
default:
throw new RuntimeException('Unknown tool: ' . $name);
}
$encoded = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
throw new RuntimeException('Could not encode tool result.');
}
return [
'content' => [
[
'type' => 'text',
'text' => $encoded,
],
],
];
}
/**
* @param array<string,mixed> $arguments
*
* @return array<string,mixed>
*/
private function objectArg(array $arguments, string $key): array
{
return is_array($arguments[$key] ?? null) ? $arguments[$key] : [];
}
/**
* @param array<string,mixed> $arguments
*/
private function stringArg(array $arguments, string $key): string
{
$value = trim((string) ($arguments[$key] ?? ''));
if ($value === '') {
throw new RuntimeException($key . ' is required.');
}
return $value;
}
/**
* @param array<string,mixed> $arguments
*/
private function intArg(array $arguments, string $key, ?int $default = null): int
{
if (!isset($arguments[$key])) {
if ($default !== null) {
return $default;
}
throw new RuntimeException($key . ' is required.');
}
return (int) $arguments[$key];
}
/**
* @param array<string,mixed> $arguments
* @param array<int,string> $default
*
* @return array<int,string>
*/
private function stringListArg(array $arguments, string $key, array $default): array
{
if (!isset($arguments[$key]) || !is_array($arguments[$key])) {
return $default;
}
return array_values(array_filter(array_map('strval', $arguments[$key])));
}
}