Add redMCP stdio MCP server

This commit is contained in:
Jason Thistlethwaite
2026-04-25 01:54:23 +00:00
parent 3c1d03bd7a
commit 3b6b4d6dba
6 changed files with 492 additions and 0 deletions
+6
View File
@@ -308,6 +308,12 @@ If Mailpit moves, pass the host that Redmine can reach:
The redMCP wrapper now makes Helpdesk behavior explicit: 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 - `issueWithHelpdesk()` composes normal issue data with Helpdesk ticket/message
metadata. metadata.
- `updateIssue()` is safe by default and does not send customer email. - `updateIssue()` is safe by default and does not send customer email.
+27
View File
@@ -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, - Run `validate_helpdesk_outbox_worker.py` after outbox or worker changes,
then choose the external index target. 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 ## 2026-04-25 - Test Helpdesk Credential Sanitization
- Touched areas: - Touched areas:
+45
View File
@@ -35,6 +35,7 @@ Basic issue CRUD is exposed on the same wrapper:
```php ```php
$issues = $client->issues(['project_id' => 'customer-service', 'status_id' => 'open', 'limit' => 10]); $issues = $client->issues(['project_id' => 'customer-service', 'status_id' => 'open', 'limit' => 10]);
$filtered = $client->filterIssues(['query_id' => 12, 'limit' => 25]);
$issue = $client->issue(39858); $issue = $client->issue(39858);
$created = $client->createIssue([ $created = $client->createIssue([
@@ -47,6 +48,23 @@ $client->updateIssue((int) $created['id'], ['notes' => 'Follow-up note']);
$client->deleteIssue((int) $created['id']); $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 `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 normal Redmine note does **not** send an email to the customer. To send through
the Helpdesk plugin, opt in explicitly: 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 changes, and automation cleanup. Use the Helpdesk email path only when the
caller deliberately wants the customer to receive mail. 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 ## Test instance
A working test copy of Redmine is available on the LAN at `192.168.50.170`. A working test copy of Redmine is available on the LAN at `192.168.50.170`.
+50
View File
@@ -55,6 +55,56 @@ final class RedmineClient
return $response; 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<string,mixed> $filters Standard Redmine issue list filters.
*
* @return array<string,mixed>
*/
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<string,mixed> $params Standard Redmine search params.
*
* @return array<string,mixed>
*/
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<string,mixed> $params Additional Redmine search params.
*
* @return array<string,mixed>
*/
public function searchIssues(string $query, array $params = []): array
{
return $this->search($query, ['issues' => '1'] + $params);
}
/** /**
* Fetch a normal Redmine issue. * Fetch a normal Redmine issue.
* *
+361
View File
@@ -0,0 +1,361 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use RedMCP\RedmineClient;
require __DIR__ . '/../vendor/autoload.php';
main();
function main(): void
{
$env = loadEnv(__DIR__ . '/../.env');
$url = getenv('REDMINE_URL') ?: ($env['REDMINE_URL'] ?? 'http://192.168.50.170');
$apiKey = getenv('REDMINE_API_KEY') ?: getenv('REDMNINE_API_KEY') ?: ($env['REDMINE_API_KEY'] ?? $env['REDMNINE_API_KEY'] ?? null);
if (!is_string($apiKey) || trim($apiKey) === '') {
fwrite(STDERR, "REDMINE_API_KEY is required in the environment or redMCP/.env\n");
exit(1);
}
$server = new RedmcpStdioServer(RedmineClient::fromCredentials(rtrim($url, '/'), $apiKey));
$server->run();
}
/**
* @return array<string,string>
*/
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<string,mixed>|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<string,mixed> $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<string,mixed> $params
*
* @return array<string,mixed>
*/
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<int,array<string,mixed>>
*/
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<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_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<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
* @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> $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);
}
}
+3
View File
@@ -7,6 +7,9 @@
"RedMCP\\": "app/" "RedMCP\\": "app/"
} }
}, },
"bin": [
"bin/redmcp-server.php"
],
"require": { "require": {
"kbsali/redmine-api": "^2.9" "kbsali/redmine-api": "^2.9"
} }