From 05c1a4bc97da5ac183ab7a94b3d6d4accff46834 Mon Sep 17 00:00:00 2001 From: Jason Thistlethwaite Date: Sat, 25 Apr 2026 02:23:48 +0000 Subject: [PATCH] Add redMCP Streamable HTTP server --- README.md | 5 +- docs/redmineup_local_fork_changelog.md | 22 ++- redMCP/README.md | 30 ++- redMCP/app/McpDispatcher.php | 259 +++++++++++++++++++++++++ redMCP/app/McpEnvironment.php | 59 ++++++ redMCP/app/McpHttpHandler.php | 106 ++++++++++ redMCP/app/McpStdioServer.php | 82 ++++++++ redMCP/app/mcp-http-router.php | 26 +++ redMCP/bin/redmcp-http-server.php | 43 ++++ redMCP/bin/redmcp-server.php | 11 +- redMCP/composer.json | 3 +- 11 files changed, 631 insertions(+), 15 deletions(-) create mode 100644 redMCP/app/McpDispatcher.php create mode 100644 redMCP/app/McpEnvironment.php create mode 100644 redMCP/app/McpHttpHandler.php create mode 100644 redMCP/app/McpStdioServer.php create mode 100644 redMCP/app/mcp-http-router.php create mode 100755 redMCP/bin/redmcp-http-server.php diff --git a/README.md b/README.md index dc13ac1..09aa202 100644 --- a/README.md +++ b/README.md @@ -308,8 +308,9 @@ If Mailpit moves, pass the host that Redmine can reach: The redMCP wrapper now makes Helpdesk behavior explicit: -- `redMCP/bin/redmcp-server.php` runs as a stdio MCP server for live client - testing. +- `redMCP/bin/redmcp-server.php` runs as a stdio MCP server. +- `redMCP/bin/redmcp-http-server.php` runs as a bearer-token-protected + Streamable HTTP MCP server for network client testing. - `issues()` and `filterIssues()` expose Redmine's built-in `/issues.json` issue filters. - `search()` and `searchIssues()` expose Redmine's built-in `/search.json` diff --git a/docs/redmineup_local_fork_changelog.md b/docs/redmineup_local_fork_changelog.md index 4eb1ac4..ba15918 100644 --- a/docs/redmineup_local_fork_changelog.md +++ b/docs/redmineup_local_fork_changelog.md @@ -39,25 +39,33 @@ environment. Before risky edits, archive the current plugin directories in - Purpose: - Make Redmine's existing issue filtering and built-in text search explicit before adding external search infrastructure. - - Make redMCP runnable as a stdio MCP server for live client testing. + - Make redMCP runnable as an MCP server for live client testing. - Behavior changed: - Added `filterIssues()` as a named alias for Redmine's `/issues.json` filtering. - Added `search()` for Redmine's built-in `/search.json` endpoint. - Added `searchIssues()` for issue-only Redmine text search. - - Added `redMCP/bin/redmcp-server.php`, a dependency-light stdio MCP server - that exposes Redmine filtering/search, issue CRUD, Helpdesk-aware reads, and - explicit Helpdesk response tools. - - Registered the MCP server as a Composer `bin` entry. + - Added a shared MCP dispatcher and transport-specific server wrappers. + - Added `redMCP/bin/redmcp-server.php` for stdio MCP clients. + - Added `redMCP/bin/redmcp-http-server.php` for bearer-token-protected + Streamable HTTP network clients on `/mcp`. + - Both transports expose Redmine filtering/search, issue CRUD, + Helpdesk-aware reads, and explicit Helpdesk response tools. + - Registered both MCP server commands as Composer `bin` entries. - LAN test result: - `php -l redMCP/app/RedmineClient.php` passed. - `php -l redMCP/bin/redmcp-server.php` passed. + - `php -l redMCP/bin/redmcp-http-server.php` passed. - `composer validate --working-dir=redMCP` passed; Composer emitted PHP 8.5 deprecation notices from system Composer dependencies. - Live stdio MCP framing test passed for `initialize`, `tools/list`, and `tools/call` using `redmine_search_issues` against `fud-helpdesk`. - - The live MCP tool call returned two issue search results from seven total - for `redMCP-smoke`. + - Live Streamable HTTP test passed for authenticated `initialize`, + `tools/list`, and `tools/call` using `redmine_search_issues`. + - `redmcp-http-server.php` refused to start without `MCP_SERVER_TOKEN`. + - Unauthenticated `/mcp` returned `401`; wrong path returned `404`. + - The live MCP tool calls returned issue search results from seven total for + `redMCP-smoke`. ## 2026-04-25 - Test Helpdesk Credential Sanitization diff --git a/redMCP/README.md b/redMCP/README.md index 16f44eb..6926f63 100644 --- a/redMCP/README.md +++ b/redMCP/README.md @@ -86,14 +86,36 @@ caller deliberately wants the customer to receive mail. ## MCP server -`redMCP` can also run as a stdio MCP server. It reads Redmine credentials from -environment variables or `redMCP/.env`: +`redMCP` can run as either a stdio MCP server or a network MCP server. It reads +Redmine credentials from environment variables or `redMCP/.env`. ```sh redMCP/bin/redmcp-server.php ``` -Example client configuration: +For local network 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 +``` + +The network endpoint defaults to `/mcp` and requires: + +```text +Authorization: Bearer +``` + +Example Streamable HTTP request: + +```sh +curl -sS \ + -H 'Authorization: Bearer test-token' \ + -H 'Content-Type: application/json' \ + http://127.0.0.1:8765/mcp \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +``` + +Example stdio client configuration: ```json { @@ -105,7 +127,7 @@ Example client configuration: } ``` -The server exposes tools for native Redmine filtering/search, issue CRUD, +Both transports expose tools for native Redmine 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 diff --git a/redMCP/app/McpDispatcher.php b/redMCP/app/McpDispatcher.php new file mode 100644 index 0000000..01faa55 --- /dev/null +++ b/redMCP/app/McpDispatcher.php @@ -0,0 +1,259 @@ +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]))); + } +} diff --git a/redMCP/app/McpEnvironment.php b/redMCP/app/McpEnvironment.php new file mode 100644 index 0000000..d34ea47 --- /dev/null +++ b/redMCP/app/McpEnvironment.php @@ -0,0 +1,59 @@ + rtrim((string) (getenv('REDMINE_URL') ?: ($env['REDMINE_URL'] ?? 'http://192.168.50.170')), '/'), + 'redmine_api_key' => $apiKey, + 'mcp_server_token' => self::optionalString(getenv('MCP_SERVER_TOKEN') ?: ($env['MCP_SERVER_TOKEN'] ?? null)), + ]; + } + + /** + * @return array + */ + private static function loadFile(string $path): array + { + if (!is_file($path)) { + return []; + } + + $values = []; + foreach (file($path, FILE_IGNORE_NEW_LINES) ?: [] as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) { + continue; + } + [$key, $value] = explode('=', $line, 2); + $values[trim($key)] = trim(trim($value), "\"'"); + } + + return $values; + } + + private static function optionalString(mixed $value): ?string + { + if (!is_string($value) || trim($value) === '') { + return null; + } + + return $value; + } +} diff --git a/redMCP/app/McpHttpHandler.php b/redMCP/app/McpHttpHandler.php new file mode 100644 index 0000000..eec18e8 --- /dev/null +++ b/redMCP/app/McpHttpHandler.php @@ -0,0 +1,106 @@ +dispatcher = $dispatcher; + $this->token = $token; + $this->path = '/' . trim($path, '/'); + } + + public function handle(): void + { + if (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) !== $this->path) { + $this->sendJson(404, ['error' => 'not found']); + return; + } + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + $this->sendJson(405, ['error' => 'method not allowed']); + return; + } + if (!$this->authorized()) { + header('WWW-Authenticate: Bearer'); + $this->sendJson(401, ['error' => 'unauthorized']); + return; + } + + $raw = file_get_contents('php://input'); + $decoded = json_decode(is_string($raw) ? $raw : '', true); + if (!is_array($decoded)) { + $this->sendJson(400, $this->errorResponse(null, -32700, 'Invalid JSON.')); + return; + } + + if (array_is_list($decoded)) { + $responses = []; + foreach ($decoded as $message) { + if (!is_array($message)) { + $responses[] = $this->errorResponse(null, -32600, 'Invalid request.'); + continue; + } + $response = $this->dispatcher->handleMessage($message); + if ($response !== null) { + $responses[] = $response; + } + } + if ($responses === []) { + http_response_code(202); + return; + } + $this->sendJson(200, $responses); + return; + } + + $response = $this->dispatcher->handleMessage($decoded); + if ($response === null) { + http_response_code(202); + return; + } + + $this->sendJson(200, $response); + } + + private function authorized(): bool + { + $header = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; + if (!is_string($header) || !str_starts_with($header, 'Bearer ')) { + return false; + } + + return hash_equals($this->token, substr($header, 7)); + } + + /** + * @param mixed $payload + */ + private function sendJson(int $status, $payload): void + { + http_response_code($status); + header('Content-Type: application/json'); + echo json_encode($payload, JSON_UNESCAPED_SLASHES); + } + + /** + * @return array + */ + private function errorResponse(mixed $id, int $code, string $message): array + { + return [ + 'jsonrpc' => '2.0', + 'id' => $id, + 'error' => [ + 'code' => $code, + 'message' => $message, + ], + ]; + } +} diff --git a/redMCP/app/McpStdioServer.php b/redMCP/app/McpStdioServer.php new file mode 100644 index 0000000..bc0223f --- /dev/null +++ b/redMCP/app/McpStdioServer.php @@ -0,0 +1,82 @@ +dispatcher = $dispatcher; + } + + public function run(): void + { + while (($message = $this->readMessage(STDIN)) !== null) { + $response = $this->dispatcher->handleMessage($message); + if ($response !== null) { + $this->writeMessage($response); + } + } + } + + /** + * @param resource $stream + * + * @return array|null + */ + private function readMessage($stream): ?array + { + $headers = []; + while (($line = fgets($stream)) !== false) { + $line = rtrim($line, "\r\n"); + if ($line === '') { + break; + } + if (!str_contains($line, ':')) { + $decoded = json_decode($line, true); + return is_array($decoded) ? $decoded : null; + } + [$name, $value] = explode(':', $line, 2); + $headers[strtolower(trim($name))] = trim($value); + } + + if ($line === false && $headers === []) { + return null; + } + + $length = isset($headers['content-length']) ? (int) $headers['content-length'] : 0; + if ($length <= 0) { + return null; + } + + $body = ''; + while (strlen($body) < $length && !feof($stream)) { + $chunk = fread($stream, $length - strlen($body)); + if ($chunk === false || $chunk === '') { + break; + } + $body .= $chunk; + } + + $decoded = json_decode($body, true); + return is_array($decoded) ? $decoded : null; + } + + /** + * @param array $message + */ + private function writeMessage(array $message): void + { + $body = json_encode($message, JSON_UNESCAPED_SLASHES); + if ($body === false) { + return; + } + + fwrite(STDOUT, 'Content-Length: ' . strlen($body) . "\r\n\r\n" . $body); + fflush(STDOUT); + } +} diff --git a/redMCP/app/mcp-http-router.php b/redMCP/app/mcp-http-router.php new file mode 100644 index 0000000..a4823b5 --- /dev/null +++ b/redMCP/app/mcp-http-router.php @@ -0,0 +1,26 @@ + 'MCP_SERVER_TOKEN is required']); + return; +} + +$handler = new McpHttpHandler( + new McpDispatcher(RedmineClient::fromCredentials($env['redmine_url'], $env['redmine_api_key'])), + $token, + getenv('MCP_HTTP_PATH') ?: '/mcp' +); +$handler->handle(); diff --git a/redMCP/bin/redmcp-http-server.php b/redMCP/bin/redmcp-http-server.php new file mode 100755 index 0000000..eb7c4c6 --- /dev/null +++ b/redMCP/bin/redmcp-http-server.php @@ -0,0 +1,43 @@ +#!/usr/bin/env php +getMessage() . PHP_EOL); + exit(1); +} + +putenv('MCP_HTTP_PATH=' . $path); +$router = __DIR__ . '/../app/mcp-http-router.php'; +$command = [ + PHP_BINARY, + '-S', + $host . ':' . $port, + $router, +]; + +fwrite(STDERR, "redMCP HTTP server listening on http://{$host}:{$port}{$path}\n"); +fwrite(STDERR, "Authorization: Bearer is required.\n"); + +passthru(implode(' ', array_map('escapeshellarg', $command)), $exitCode); +exit((int) $exitCode); diff --git a/redMCP/bin/redmcp-server.php b/redMCP/bin/redmcp-server.php index 22a89d8..1d623e3 100755 --- a/redMCP/bin/redmcp-server.php +++ b/redMCP/bin/redmcp-server.php @@ -3,11 +3,20 @@ declare(strict_types=1); +use RedMCP\McpDispatcher; +use RedMCP\McpEnvironment; +use RedMCP\McpStdioServer; use RedMCP\RedmineClient; require __DIR__ . '/../vendor/autoload.php'; -main(); +$env = McpEnvironment::load(__DIR__ . '/../.env'); +$server = new McpStdioServer( + new McpDispatcher(RedmineClient::fromCredentials($env['redmine_url'], $env['redmine_api_key'])) +); +$server->run(); +exit(0); +__halt_compiler(); function main(): void { diff --git a/redMCP/composer.json b/redMCP/composer.json index cd70c53..d62ff4b 100644 --- a/redMCP/composer.json +++ b/redMCP/composer.json @@ -8,7 +8,8 @@ } }, "bin": [ - "bin/redmcp-server.php" + "bin/redmcp-server.php", + "bin/redmcp-http-server.php" ], "require": { "kbsali/redmine-api": "^2.9"