Improve redMCP server operations
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace RedMCP;
|
||||
|
||||
final class McpDebugLogger
|
||||
{
|
||||
private ?string $path;
|
||||
|
||||
public function __construct(?string $path)
|
||||
{
|
||||
$this->path = $path;
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->path !== null && $this->path !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $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);
|
||||
}
|
||||
}
|
||||
@@ -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<string,mixed>|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<string,mixed> $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<string,mixed> $arguments
|
||||
* @param array<int,string> $default
|
||||
@@ -256,4 +291,36 @@ final class McpDispatcher
|
||||
|
||||
return array_values(array_filter(array_map('strval', $arguments[$key])));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $context
|
||||
* @param array<string,mixed> $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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string,mixed>
|
||||
*/
|
||||
private function logContext(): array
|
||||
{
|
||||
return [
|
||||
'transport' => 'http',
|
||||
'client_ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $payload
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -105,6 +105,52 @@ final class RedmineClient
|
||||
return $this->search($query, ['issues' => '1'] + $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* List Redmine projects.
|
||||
*
|
||||
* @param array<string,mixed> $params Standard Redmine project list params.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function projects(array $params = []): array
|
||||
{
|
||||
return $this->listProjects($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* List Redmine projects.
|
||||
*
|
||||
* @param array<string,mixed> $params Standard Redmine project list params.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function listProjects(array $params = []): array
|
||||
{
|
||||
return $this->getJson('/projects', $params) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a Redmine project by numeric id or identifier.
|
||||
*
|
||||
* @param array<string,mixed> $params Standard Redmine project show params.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user