Files
redmine/redMCP/bin/redmcp-server.php
T
Jason Thistlethwaite 22c8e915e9 Sanitize noisy MCP text fields by default
Clean control and invisible junk from tool result text fields to reduce token waste while preserving readable Unicode. Add an MCP_TEXT_SANITIZATION toggle and regression tests for enabled and disabled behavior.
2026-05-06 02:31:25 -04:00

376 lines
13 KiB
PHP
Executable File

#!/usr/bin/env php
<?php
declare(strict_types=1);
use RedMCP\McpDispatcher;
use RedMCP\McpDebugLogger;
use RedMCP\McpEnvironment;
use RedMCP\McpStdioServer;
use RedMCP\RedmineClient;
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 McpDebugLogger($env['mcp_debug_log']),
$env['mcp_text_sanitization']
)
);
$server->run();
exit(0);
__halt_compiler();
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);
}
}