redmine = $redmine; } /** * @param array $message * * @return array|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 $params * * @return array */ 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> */ 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 $properties * @param array $required * * @return array */ 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 $params * * @return array */ 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 $arguments * * @return array */ private function objectArg(array $arguments, string $key): array { return is_array($arguments[$key] ?? null) ? $arguments[$key] : []; } /** * @param array $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 $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 $arguments * @param array $default * * @return array */ 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]))); } }