diff --git a/README.md b/README.md index d093b28..dc13ac1 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,12 @@ 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. +- `issues()` and `filterIssues()` expose Redmine's built-in `/issues.json` + issue filters. +- `search()` and `searchIssues()` expose Redmine's built-in `/search.json` + text search. - `issueWithHelpdesk()` composes normal issue data with Helpdesk ticket/message metadata. - `updateIssue()` is safe by default and does not send customer email. diff --git a/docs/redmineup_local_fork_changelog.md b/docs/redmineup_local_fork_changelog.md index 6c432d2..4eb1ac4 100644 --- a/docs/redmineup_local_fork_changelog.md +++ b/docs/redmineup_local_fork_changelog.md @@ -32,6 +32,33 @@ 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 + +- Touched areas: + - `redMCP` +- 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. +- 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. +- LAN test result: + - `php -l redMCP/app/RedmineClient.php` passed. + - `php -l redMCP/bin/redmcp-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`. + ## 2026-04-25 - Test Helpdesk Credential Sanitization - Touched areas: diff --git a/redMCP/README.md b/redMCP/README.md index 678314f..16f44eb 100644 --- a/redMCP/README.md +++ b/redMCP/README.md @@ -35,6 +35,7 @@ Basic issue CRUD is exposed on the same wrapper: ```php $issues = $client->issues(['project_id' => 'customer-service', 'status_id' => 'open', 'limit' => 10]); +$filtered = $client->filterIssues(['query_id' => 12, 'limit' => 25]); $issue = $client->issue(39858); $created = $client->createIssue([ @@ -47,6 +48,23 @@ $client->updateIssue((int) $created['id'], ['notes' => 'Follow-up note']); $client->deleteIssue((int) $created['id']); ``` +Native Redmine search is exposed separately from issue filtering. Use +`filterIssues()` or `issues()` when you already know the structured filters. +Use `search()` or `searchIssues()` when you want Redmine's built-in text search: + +```php +$results = $client->search('power supply', [ + 'all_words' => '1', + 'limit' => 10, +]); + +$issueResults = $client->searchIssues('power supply', [ + 'project_id' => 'customer-service', + 'open_issues' => '1', + 'limit' => 10, +]); +``` + `updateIssue()` is intentionally safe by default: on Helpdesk-backed issues, a normal Redmine note does **not** send an email to the customer. To send through the Helpdesk plugin, opt in explicitly: @@ -66,6 +84,33 @@ Use the default non-email update for internal notes, status/category/assignee changes, and automation cleanup. Use the Helpdesk email path only when the 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`: + +```sh +redMCP/bin/redmcp-server.php +``` + +Example client configuration: + +```json +{ + "mcpServers": { + "redmcp": { + "command": "/home/iadnah/redmine/redMCP/bin/redmcp-server.php" + } + } +} +``` + +The server exposes 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`. + ## Test instance A working test copy of Redmine is available on the LAN at `192.168.50.170`. diff --git a/redMCP/app/RedmineClient.php b/redMCP/app/RedmineClient.php index e5e0b83..21fda60 100644 --- a/redMCP/app/RedmineClient.php +++ b/redMCP/app/RedmineClient.php @@ -55,6 +55,56 @@ final class RedmineClient return $response; } + /** + * Alias for listIssues() that makes Redmine's built-in issue filters + * explicit at call sites. + * + * Useful filters include project_id, tracker_id, status_id, + * assigned_to_id, author_id, category_id, fixed_version_id, query_id, + * created_on, updated_on, sort, offset, and limit. + * + * @param array $filters Standard Redmine issue list filters. + * + * @return array + */ + public function filterIssues(array $filters = []): array + { + return $this->listIssues($filters); + } + + /** + * Search Redmine using the built-in /search.json endpoint. + * + * Typical params include project_id, all_words, titles_only, scope, + * open_issues, issues, projects, news, documents, changesets, wiki_pages, + * messages, offset, and limit. + * + * @param array $params Standard Redmine search params. + * + * @return array + */ + public function search(string $query, array $params = []): array + { + $query = trim($query); + if ($query === '') { + throw new RuntimeException('Searching Redmine requires a non-empty query.'); + } + + return $this->getJson('/search', ['q' => $query] + $params) ?? []; + } + + /** + * Search only issues using Redmine's built-in /search.json endpoint. + * + * @param array $params Additional Redmine search params. + * + * @return array + */ + public function searchIssues(string $query, array $params = []): array + { + return $this->search($query, ['issues' => '1'] + $params); + } + /** * Fetch a normal Redmine issue. * diff --git a/redMCP/bin/redmcp-server.php b/redMCP/bin/redmcp-server.php new file mode 100755 index 0000000..22a89d8 --- /dev/null +++ b/redMCP/bin/redmcp-server.php @@ -0,0 +1,361 @@ +#!/usr/bin/env php +run(); +} + +/** + * @return array + */ +function loadEnv(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; +} + +final class RedmcpStdioServer +{ + private RedmineClient $redmine; + + public function __construct(RedmineClient $redmine) + { + $this->redmine = $redmine; + } + + public function run(): void + { + while (($message = $this->readMessage(STDIN)) !== null) { + $this->handleMessage($message); + } + } + + /** + * @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 handleMessage(array $message): void + { + $id = $message['id'] ?? null; + $method = (string) ($message['method'] ?? ''); + + if ($id === null) { + return; + } + + try { + $result = $this->dispatch($method, is_array($message['params'] ?? null) ? $message['params'] : []); + $this->writeMessage(['jsonrpc' => '2.0', 'id' => $id, 'result' => $result]); + } catch (Throwable $exception) { + $this->writeMessage([ + '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' => '2024-11-05', + '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]))); + } + + /** + * @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/composer.json b/redMCP/composer.json index 401223e..cd70c53 100644 --- a/redMCP/composer.json +++ b/redMCP/composer.json @@ -7,6 +7,9 @@ "RedMCP\\": "app/" } }, + "bin": [ + "bin/redmcp-server.php" + ], "require": { "kbsali/redmine-api": "^2.9" }