diff --git a/README.md b/README.md index 09aa202..46ddd7d 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,11 @@ The redMCP wrapper now makes Helpdesk behavior explicit: - `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. + Streamable HTTP MCP server for network client testing, with PID/status/stop + helpers and optional debug JSONL logging. +- `redMCP/bin/generate-bearer-token.php` generates local MCP bearer tokens. +- `projects()` and `project()` expose Redmine's built-in `/projects.json` + project list/detail APIs. - `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 ba15918..3d999c5 100644 --- a/docs/redmineup_local_fork_changelog.md +++ b/docs/redmineup_local_fork_changelog.md @@ -32,7 +32,7 @@ environment. Before risky edits, archive the current plugin directories in - Run `validate_helpdesk_outbox_worker.py` after outbox or worker changes, then choose the external index target. -## 2026-04-25 - redMCP Native Search And Filtering +## 2026-04-25 - redMCP Native Search, Filtering, And MCP Operations - Touched areas: - `redMCP` @@ -40,22 +40,30 @@ environment. Before risky edits, archive the current plugin directories in - Make Redmine's existing issue filtering and built-in text search explicit before adding external search infrastructure. - Make redMCP runnable as an MCP server for live client testing. + - Make the network MCP server easier to debug and restart during local tests. - 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 `projects()`, `listProjects()`, and `project()` for Redmine's + `/projects.json` APIs. - 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, + - Added PID/status/stop handling to the HTTP server. + - Added optional full-argument JSONL debug logging via `--debug-log` or + `MCP_DEBUG_LOG`. + - Added `redMCP/bin/generate-bearer-token.php`. + - Both transports expose Redmine project reads, filtering/search, issue CRUD, Helpdesk-aware reads, and explicit Helpdesk response tools. - - Registered both MCP server commands as Composer `bin` entries. + - Registered all MCP helper 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. + - `php -l redMCP/bin/generate-bearer-token.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 @@ -64,6 +72,14 @@ environment. Before risky edits, archive the current plugin directories in `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`. + - HTTP PID helpers reported stopped/running states, rejected a duplicate + start, stopped the live process, detected a stale PID file, and started + with `--force`. + - Debug logging wrote JSONL records with full project-tool arguments and did + not include the bearer token, `Authorization`, or Redmine API key. + - Token generation passed default, `--bytes 48`, and `--env-line` modes. + - `redmine_list_projects` returned three projects from 117 total. + - `redmine_get_project` returned `fud-helpdesk` by identifier and by id 117. - The live MCP tool calls returned issue search results from seven total for `redMCP-smoke`. diff --git a/redMCP/README.md b/redMCP/README.md index 6926f63..5ca7ddf 100644 --- a/redMCP/README.md +++ b/redMCP/README.md @@ -99,6 +99,12 @@ For local network testing, run the Streamable HTTP server: MCP_SERVER_TOKEN=test-token redMCP/bin/redmcp-http-server.php --host 0.0.0.0 --port 8765 ``` +Generate a bearer token with: + +```sh +redMCP/bin/generate-bearer-token.php --env-line +``` + The network endpoint defaults to `/mcp` and requires: ```text @@ -115,6 +121,30 @@ curl -sS \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' ``` +HTTP server process helpers: + +```sh +redMCP/bin/redmcp-http-server.php --status +redMCP/bin/redmcp-http-server.php --stop +redMCP/bin/redmcp-http-server.php --pid-file /tmp/redmcp-http-server.pid --status +``` + +The default PID file is `/tmp/redmcp-http-server.pid`. A second server start +fails if the PID file points to a live process. Use `--force` only to replace a +stale PID file. + +Debug logging is disabled by default. To record full MCP params/tool arguments +as JSONL during local testing: + +```sh +MCP_SERVER_TOKEN=test-token redMCP/bin/redmcp-http-server.php \ + --debug-log /tmp/redmcp-mcp.log +``` + +Debug logs may include customer text, issue notes, search terms, email content, +and IDs. Authorization headers, bearer tokens, and Redmine API keys are not +logged. + Example stdio client configuration: ```json @@ -127,11 +157,11 @@ Example stdio client configuration: } ``` -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 -`send_helpdesk_email=true`. +Both transports expose tools for native Redmine project listing/detail, +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 `send_helpdesk_email=true`. ## Test instance diff --git a/redMCP/app/McpDebugLogger.php b/redMCP/app/McpDebugLogger.php new file mode 100644 index 0000000..2de58d5 --- /dev/null +++ b/redMCP/app/McpDebugLogger.php @@ -0,0 +1,43 @@ +path = $path; + } + + public function enabled(): bool + { + return $this->path !== null && $this->path !== ''; + } + + /** + * @param array $record + */ + public function log(array $record): void + { + if (!$this->enabled()) { + return; + } + + $record = ['timestamp' => gmdate('c')] + $record; + $encoded = json_encode($record, JSON_UNESCAPED_SLASHES); + if ($encoded === false) { + return; + } + + $dir = dirname((string) $this->path); + if ($dir !== '' && $dir !== '.' && !is_dir($dir)) { + mkdir($dir, 0775, true); + } + + file_put_contents((string) $this->path, $encoded . "\n", FILE_APPEND | LOCK_EX); + } +} diff --git a/redMCP/app/McpDispatcher.php b/redMCP/app/McpDispatcher.php index 01faa55..bd8a497 100644 --- a/redMCP/app/McpDispatcher.php +++ b/redMCP/app/McpDispatcher.php @@ -10,10 +10,12 @@ use Throwable; final class McpDispatcher { private RedmineClient $redmine; + private McpDebugLogger $logger; - public function __construct(RedmineClient $redmine) + public function __construct(RedmineClient $redmine, ?McpDebugLogger $logger = null) { $this->redmine = $redmine; + $this->logger = $logger ?? new McpDebugLogger(null); } /** @@ -21,18 +23,23 @@ final class McpDispatcher * * @return array|null */ - public function handleMessage(array $message): ?array + 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 { - $method = (string) ($message['method'] ?? ''); - $params = is_array($message['params'] ?? null) ? $message['params'] : []; - return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->dispatch($method, $params)]; + $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, @@ -84,6 +91,13 @@ final class McpDispatcher private function tools(): array { return [ + $this->tool('redmine_list_projects', 'List Redmine projects using native /projects.json.', [ + 'params' => ['type' => 'object', 'description' => 'Redmine project list params such as include, offset, and limit.'], + ]), + $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_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.'], ]), @@ -154,6 +168,12 @@ final class McpDispatcher $arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : []; switch ($name) { + case 'redmine_list_projects': + $result = $this->redmine->listProjects($this->objectArg($arguments, 'params')); + break; + case 'redmine_get_project': + $result = $this->redmine->project($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params')); + break; case 'redmine_list_issues': $result = $this->redmine->filterIssues($this->objectArg($arguments, 'filters')); break; @@ -242,6 +262,21 @@ final class McpDispatcher return (int) $arguments[$key]; } + /** + * @param array $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 $arguments * @param array $default @@ -256,4 +291,36 @@ final class McpDispatcher return array_values(array_filter(array_map('strval', $arguments[$key]))); } + + /** + * @param array $context + * @param array $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' => $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) ? $arguments : null; + } + if ($error !== null) { + $record['error'] = $error; + } + + $this->logger->log($record); + } } diff --git a/redMCP/app/McpEnvironment.php b/redMCP/app/McpEnvironment.php index d34ea47..e2f177f 100644 --- a/redMCP/app/McpEnvironment.php +++ b/redMCP/app/McpEnvironment.php @@ -9,7 +9,7 @@ use RuntimeException; final class McpEnvironment { /** - * @return array{redmine_url:string,redmine_api_key:string,mcp_server_token:?string} + * @return array{redmine_url:string,redmine_api_key:string,mcp_server_token:?string,mcp_debug_log:?string} */ public static function load(string $envFile): array { @@ -23,6 +23,7 @@ final class McpEnvironment 'redmine_url' => 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)), + 'mcp_debug_log' => self::optionalString(getenv('MCP_DEBUG_LOG') ?: ($env['MCP_DEBUG_LOG'] ?? null)), ]; } diff --git a/redMCP/app/McpHttpHandler.php b/redMCP/app/McpHttpHandler.php index eec18e8..c28cc43 100644 --- a/redMCP/app/McpHttpHandler.php +++ b/redMCP/app/McpHttpHandler.php @@ -47,7 +47,7 @@ final class McpHttpHandler $responses[] = $this->errorResponse(null, -32600, 'Invalid request.'); continue; } - $response = $this->dispatcher->handleMessage($message); + $response = $this->dispatcher->handleMessage($message, $this->logContext()); if ($response !== null) { $responses[] = $response; } @@ -60,7 +60,7 @@ final class McpHttpHandler return; } - $response = $this->dispatcher->handleMessage($decoded); + $response = $this->dispatcher->handleMessage($decoded, $this->logContext()); if ($response === null) { http_response_code(202); return; @@ -79,6 +79,17 @@ final class McpHttpHandler return hash_equals($this->token, substr($header, 7)); } + /** + * @return array + */ + private function logContext(): array + { + return [ + 'transport' => 'http', + 'client_ip' => $_SERVER['REMOTE_ADDR'] ?? null, + ]; + } + /** * @param mixed $payload */ diff --git a/redMCP/app/McpStdioServer.php b/redMCP/app/McpStdioServer.php index bc0223f..10cdee4 100644 --- a/redMCP/app/McpStdioServer.php +++ b/redMCP/app/McpStdioServer.php @@ -16,7 +16,7 @@ final class McpStdioServer public function run(): void { while (($message = $this->readMessage(STDIN)) !== null) { - $response = $this->dispatcher->handleMessage($message); + $response = $this->dispatcher->handleMessage($message, ['transport' => 'stdio']); if ($response !== null) { $this->writeMessage($response); } diff --git a/redMCP/app/RedmineClient.php b/redMCP/app/RedmineClient.php index 21fda60..d2ddb3c 100644 --- a/redMCP/app/RedmineClient.php +++ b/redMCP/app/RedmineClient.php @@ -105,6 +105,52 @@ final class RedmineClient return $this->search($query, ['issues' => '1'] + $params); } + /** + * List Redmine projects. + * + * @param array $params Standard Redmine project list params. + * + * @return array + */ + public function projects(array $params = []): array + { + return $this->listProjects($params); + } + + /** + * List Redmine projects. + * + * @param array $params Standard Redmine project list params. + * + * @return array + */ + public function listProjects(array $params = []): array + { + return $this->getJson('/projects', $params) ?? []; + } + + /** + * Fetch a Redmine project by numeric id or identifier. + * + * @param array $params Standard Redmine project show params. + * + * @return array + */ + public function project(int|string $projectId, array $params = []): array + { + $projectId = trim((string) $projectId); + if ($projectId === '') { + throw new RuntimeException('Fetching a project requires a project id or identifier.'); + } + + $response = $this->getJson('/projects/' . rawurlencode($projectId), $params); + if (!is_array($response)) { + throw new RuntimeException('Could not fetch project ' . $projectId . '.'); + } + + return $response['project'] ?? $response; + } + /** * Fetch a normal Redmine issue. * diff --git a/redMCP/app/mcp-http-router.php b/redMCP/app/mcp-http-router.php index a4823b5..a78d393 100644 --- a/redMCP/app/mcp-http-router.php +++ b/redMCP/app/mcp-http-router.php @@ -3,6 +3,7 @@ declare(strict_types=1); use RedMCP\McpDispatcher; +use RedMCP\McpDebugLogger; use RedMCP\McpEnvironment; use RedMCP\McpHttpHandler; use RedMCP\RedmineClient; @@ -19,7 +20,10 @@ if ($token === null) { } $handler = new McpHttpHandler( - new McpDispatcher(RedmineClient::fromCredentials($env['redmine_url'], $env['redmine_api_key'])), + new McpDispatcher( + RedmineClient::fromCredentials($env['redmine_url'], $env['redmine_api_key']), + new McpDebugLogger($env['mcp_debug_log']) + ), $token, getenv('MCP_HTTP_PATH') ?: '/mcp' ); diff --git a/redMCP/bin/generate-bearer-token.php b/redMCP/bin/generate-bearer-token.php new file mode 100755 index 0000000..d6194f2 --- /dev/null +++ b/redMCP/bin/generate-bearer-token.php @@ -0,0 +1,24 @@ +#!/usr/bin/env php + is required.\n"); +if ($debugLog !== null && $debugLog !== '') { + fwrite(STDERR, "Debug log: {$debugLog}\n"); +} -passthru(implode(' ', array_map('escapeshellarg', $command)), $exitCode); +$descriptorSpec = [ + 0 => STDIN, + 1 => STDOUT, + 2 => STDERR, +]; +$process = proc_open($command, $descriptorSpec, $pipes); +if (!is_resource($process)) { + fwrite(STDERR, "Could not start PHP built-in HTTP server.\n"); + exit(1); +} + +$status = proc_get_status($process); +$pid = (int) ($status['pid'] ?? 0); +if ($pid <= 0) { + proc_terminate($process); + fwrite(STDERR, "Could not determine HTTP server PID.\n"); + exit(1); +} + +$pidDir = dirname($pidFile); +if ($pidDir !== '' && $pidDir !== '.' && !is_dir($pidDir)) { + mkdir($pidDir, 0775, true); +} +file_put_contents($pidFile, (string) $pid); +fwrite(STDERR, "PID file: {$pidFile} ({$pid})\n"); + +$exitCode = proc_close($process); +if (is_file($pidFile) && trim((string) file_get_contents($pidFile)) === (string) $pid) { + unlink($pidFile); +} exit((int) $exitCode); + +function showStatus(string $pidFile): void +{ + if (!is_file($pidFile)) { + fwrite(STDOUT, "stopped: no PID file at {$pidFile}\n"); + return; + } + + $pid = (int) trim((string) file_get_contents($pidFile)); + if ($pid > 0 && pidAlive($pid)) { + fwrite(STDOUT, "running: PID {$pid} from {$pidFile}\n"); + return; + } + + fwrite(STDOUT, "stale: PID file {$pidFile} points to non-running PID {$pid}\n"); +} + +function stopServer(string $pidFile): void +{ + if (!is_file($pidFile)) { + fwrite(STDOUT, "stopped: no PID file at {$pidFile}\n"); + return; + } + + $pid = (int) trim((string) file_get_contents($pidFile)); + if ($pid <= 0 || !pidAlive($pid)) { + unlink($pidFile); + fwrite(STDOUT, "removed stale PID file {$pidFile}\n"); + return; + } + + if (!stopPid($pid)) { + fwrite(STDERR, "could not stop PID {$pid}\n"); + exit(1); + } + + $deadline = time() + 5; + while (pidAlive($pid) && time() < $deadline) { + usleep(100000); + } + if (pidAlive($pid)) { + fwrite(STDERR, "PID {$pid} did not stop within timeout\n"); + exit(1); + } + + if (is_file($pidFile)) { + unlink($pidFile); + } + fwrite(STDOUT, "stopped PID {$pid}\n"); +} + +function isLivePidFile(string $pidFile): bool +{ + if (!is_file($pidFile)) { + return false; + } + + $pid = (int) trim((string) file_get_contents($pidFile)); + return $pid > 0 && pidAlive($pid); +} + +function pidAlive(int $pid): bool +{ + if (function_exists('posix_kill')) { + return posix_kill($pid, 0); + } + + exec('kill -0 ' . escapeshellarg((string) $pid) . ' 2>/dev/null', $output, $exitCode); + return $exitCode === 0; +} + +function stopPid(int $pid): bool +{ + if (function_exists('posix_kill')) { + return posix_kill($pid, 15); + } + + exec('kill ' . escapeshellarg((string) $pid) . ' 2>/dev/null', $output, $exitCode); + return $exitCode === 0; +} diff --git a/redMCP/bin/redmcp-server.php b/redMCP/bin/redmcp-server.php index 1d623e3..eb3e721 100755 --- a/redMCP/bin/redmcp-server.php +++ b/redMCP/bin/redmcp-server.php @@ -4,6 +4,7 @@ declare(strict_types=1); use RedMCP\McpDispatcher; +use RedMCP\McpDebugLogger; use RedMCP\McpEnvironment; use RedMCP\McpStdioServer; use RedMCP\RedmineClient; @@ -12,7 +13,10 @@ require __DIR__ . '/../vendor/autoload.php'; $env = McpEnvironment::load(__DIR__ . '/../.env'); $server = new McpStdioServer( - new McpDispatcher(RedmineClient::fromCredentials($env['redmine_url'], $env['redmine_api_key'])) + new McpDispatcher( + RedmineClient::fromCredentials($env['redmine_url'], $env['redmine_api_key']), + new McpDebugLogger($env['mcp_debug_log']) + ) ); $server->run(); exit(0); diff --git a/redMCP/composer.json b/redMCP/composer.json index d62ff4b..a9b0430 100644 --- a/redMCP/composer.json +++ b/redMCP/composer.json @@ -9,7 +9,8 @@ }, "bin": [ "bin/redmcp-server.php", - "bin/redmcp-http-server.php" + "bin/redmcp-http-server.php", + "bin/generate-bearer-token.php" ], "require": { "kbsali/redmine-api": "^2.9"