434 lines
19 KiB
PHP
434 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace RedMCP;
|
|
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
final class McpDispatcher
|
|
{
|
|
private RedmineClient $redmine;
|
|
private McpDebugLogger $logger;
|
|
|
|
public function __construct(RedmineClient $redmine, ?McpDebugLogger $logger = null)
|
|
{
|
|
$this->redmine = $redmine;
|
|
$this->logger = $logger ?? new McpDebugLogger(null);
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $message
|
|
*
|
|
* @return array<string,mixed>|null
|
|
*/
|
|
public function handleMessage(array $message, array $context = []): ?array
|
|
{
|
|
$id = $message['id'] ?? null;
|
|
if ($id === null) {
|
|
return null;
|
|
}
|
|
|
|
$started = microtime(true);
|
|
$method = (string) ($message['method'] ?? '');
|
|
$params = is_array($message['params'] ?? null) ? $message['params'] : [];
|
|
|
|
try {
|
|
$result = $this->dispatch($method, $params);
|
|
$this->logCall($context, $method, $params, true, $started);
|
|
return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result];
|
|
} catch (Throwable $exception) {
|
|
$this->logCall($context, $method, $params, false, $started, $exception->getMessage());
|
|
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_projects', 'List Redmine projects using native /projects.json.', [
|
|
'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_get_project', 'Fetch one Redmine project by id or identifier.', [
|
|
'project_id' => ['type' => ['string', 'integer'], 'description' => 'Redmine numeric project id or identifier.'],
|
|
'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.'],
|
|
'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 membership list params; overrides friendly fields on conflict.'],
|
|
], ['project_id']),
|
|
$this->tool('redmine_list_users', 'List Redmine users using native /users.json.', [
|
|
'status' => ['description' => 'User status such as active, registered, locked, all, or a Redmine status id.'],
|
|
'name' => ['type' => 'string', 'description' => 'Filter users by name.'],
|
|
'group_id' => ['type' => ['string', 'integer'], 'description' => 'Filter users by group id.'],
|
|
'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 user list params; overrides friendly fields on conflict.'],
|
|
]),
|
|
$this->tool('redmine_get_user', 'Fetch one Redmine user by id.', [
|
|
'user_id' => ['type' => 'integer'],
|
|
'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']],
|
|
'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']],
|
|
'assigned_to_id' => ['type' => ['string', 'integer']],
|
|
'author_id' => ['type' => ['string', 'integer']],
|
|
'priority_id' => ['type' => ['string', 'integer']],
|
|
'category_id' => ['type' => ['string', 'integer']],
|
|
'query_id' => ['type' => ['string', 'integer']],
|
|
'created' => ['description' => 'Friendly created_on date filter such as today, last 7 days, since 2026-04-01, or {from,to}.'],
|
|
'updated' => ['description' => 'Friendly updated_on date filter such as today, last 7 days, since 2026-04-01, or {from,to}.'],
|
|
'due' => ['description' => 'Friendly due_date filter such as today, last 7 days, since 2026-04-01, or {from,to}.'],
|
|
'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' => 'Raw Redmine issue list filters; overrides friendly fields on conflict.'],
|
|
]),
|
|
$this->tool('redmine_search', 'Search Redmine using native /search.json.', [
|
|
'query' => ['type' => 'string'],
|
|
'project_id' => ['type' => ['string', 'integer']],
|
|
'scope' => ['type' => 'string'],
|
|
'all_words' => ['type' => ['boolean', 'string', 'integer']],
|
|
'titles_only' => ['type' => ['boolean', 'string', 'integer']],
|
|
'open_issues' => ['type' => ['boolean', 'string', '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, created_newest, or a Redmine sort string.'],
|
|
'params' => ['type' => 'object', 'description' => 'Raw Redmine search params; overrides friendly fields on conflict.'],
|
|
], ['query']),
|
|
$this->tool('redmine_search_issues', 'Search only issues using native /search.json.', [
|
|
'query' => ['type' => 'string'],
|
|
'project_id' => ['type' => ['string', 'integer']],
|
|
'scope' => ['type' => 'string'],
|
|
'all_words' => ['type' => ['boolean', 'string', 'integer']],
|
|
'titles_only' => ['type' => ['boolean', 'string', 'integer']],
|
|
'open_issues' => ['type' => ['boolean', 'string', '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, created_newest, or a Redmine sort string.'],
|
|
'params' => ['type' => 'object', 'description' => 'Raw Redmine search params; overrides friendly fields on conflict.'],
|
|
], ['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_projects':
|
|
$result = $this->redmine->listProjects(ListQueryNormalizer::listParams($arguments));
|
|
break;
|
|
case 'redmine_get_project':
|
|
$result = $this->redmine->project($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params'));
|
|
break;
|
|
case 'redmine_list_project_memberships':
|
|
$result = $this->redmine->projectMemberships($this->projectIdArg($arguments, 'project_id'), ListQueryNormalizer::listParams($arguments));
|
|
break;
|
|
case 'redmine_list_users':
|
|
$result = $this->redmine->listUsers(ListQueryNormalizer::userParams($arguments));
|
|
break;
|
|
case 'redmine_get_user':
|
|
$result = $this->redmine->user($this->intArg($arguments, 'user_id'), $this->objectArg($arguments, 'params'));
|
|
break;
|
|
case 'redmine_list_issues':
|
|
$result = $this->redmine->filterIssues(ListQueryNormalizer::issueFilters($arguments));
|
|
break;
|
|
case 'redmine_search':
|
|
$result = $this->redmine->search($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($arguments));
|
|
break;
|
|
case 'redmine_search_issues':
|
|
$result = $this->redmine->searchIssues($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($arguments));
|
|
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($this->redactSensitive($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
|
|
*/
|
|
private function projectIdArg(array $arguments, string $key): int|string
|
|
{
|
|
if (!isset($arguments[$key])) {
|
|
throw new RuntimeException($key . ' is required.');
|
|
}
|
|
if (is_int($arguments[$key])) {
|
|
return $arguments[$key];
|
|
}
|
|
|
|
return $this->stringArg($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])));
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $context
|
|
* @param array<string,mixed> $params
|
|
*/
|
|
private function logCall(
|
|
array $context,
|
|
string $method,
|
|
array $params,
|
|
bool $ok,
|
|
float $started,
|
|
?string $error = null
|
|
): void {
|
|
$record = [
|
|
'transport' => $context['transport'] ?? 'unknown',
|
|
'client_ip' => $context['client_ip'] ?? null,
|
|
'method' => $method,
|
|
'params' => $this->redactSensitive($params),
|
|
'ok' => $ok,
|
|
'duration_ms' => (int) round((microtime(true) - $started) * 1000),
|
|
];
|
|
if (isset($params['name'])) {
|
|
$record['tool_name'] = $params['name'];
|
|
$arguments = $params['arguments'] ?? null;
|
|
$record['arguments'] = is_array($arguments) ? $this->redactSensitive($arguments) : null;
|
|
}
|
|
if ($error !== null) {
|
|
$record['error'] = $error;
|
|
}
|
|
|
|
$this->logger->log($record);
|
|
}
|
|
|
|
/**
|
|
* @param mixed $value
|
|
*
|
|
* @return mixed
|
|
*/
|
|
private function redactSensitive($value)
|
|
{
|
|
if (!is_array($value)) {
|
|
return $value;
|
|
}
|
|
|
|
$redacted = [];
|
|
foreach ($value as $key => $item) {
|
|
if (is_string($key) && $this->isSensitiveKey($key)) {
|
|
$redacted[$key] = '[redacted]';
|
|
continue;
|
|
}
|
|
|
|
$redacted[$key] = $this->redactSensitive($item);
|
|
}
|
|
|
|
return $redacted;
|
|
}
|
|
|
|
private function isSensitiveKey(string $key): bool
|
|
{
|
|
$normalized = strtolower(str_replace(['-', '_'], '', $key));
|
|
|
|
return in_array($normalized, [
|
|
'apikey',
|
|
'authorization',
|
|
'bearertoken',
|
|
'password',
|
|
'secret',
|
|
'token',
|
|
], true);
|
|
}
|
|
}
|