Improve redMCP server operations
This commit is contained in:
@@ -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-server.php` runs as a stdio MCP server.
|
||||||
- `redMCP/bin/redmcp-http-server.php` runs as a bearer-token-protected
|
- `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`
|
- `issues()` and `filterIssues()` expose Redmine's built-in `/issues.json`
|
||||||
issue filters.
|
issue filters.
|
||||||
- `search()` and `searchIssues()` expose Redmine's built-in `/search.json`
|
- `search()` and `searchIssues()` expose Redmine's built-in `/search.json`
|
||||||
|
|||||||
@@ -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,
|
- 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
|
## 2026-04-25 - redMCP Native Search, Filtering, And MCP Operations
|
||||||
|
|
||||||
- Touched areas:
|
- Touched areas:
|
||||||
- `redMCP`
|
- `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
|
- Make Redmine's existing issue filtering and built-in text search explicit
|
||||||
before adding external search infrastructure.
|
before adding external search infrastructure.
|
||||||
- Make redMCP runnable as an MCP server for live client testing.
|
- 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:
|
- Behavior changed:
|
||||||
- Added `filterIssues()` as a named alias for Redmine's `/issues.json`
|
- Added `filterIssues()` as a named alias for Redmine's `/issues.json`
|
||||||
filtering.
|
filtering.
|
||||||
- Added `search()` for Redmine's built-in `/search.json` endpoint.
|
- Added `search()` for Redmine's built-in `/search.json` endpoint.
|
||||||
- Added `searchIssues()` for issue-only Redmine text search.
|
- 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 a shared MCP dispatcher and transport-specific server wrappers.
|
||||||
- Added `redMCP/bin/redmcp-server.php` for stdio MCP clients.
|
- Added `redMCP/bin/redmcp-server.php` for stdio MCP clients.
|
||||||
- Added `redMCP/bin/redmcp-http-server.php` for bearer-token-protected
|
- Added `redMCP/bin/redmcp-http-server.php` for bearer-token-protected
|
||||||
Streamable HTTP network clients on `/mcp`.
|
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.
|
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:
|
- LAN test result:
|
||||||
- `php -l redMCP/app/RedmineClient.php` passed.
|
- `php -l redMCP/app/RedmineClient.php` passed.
|
||||||
- `php -l redMCP/bin/redmcp-server.php` passed.
|
- `php -l redMCP/bin/redmcp-server.php` passed.
|
||||||
- `php -l redMCP/bin/redmcp-http-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
|
- `composer validate --working-dir=redMCP` passed; Composer emitted PHP 8.5
|
||||||
deprecation notices from system Composer dependencies.
|
deprecation notices from system Composer dependencies.
|
||||||
- Live stdio MCP framing test passed for `initialize`, `tools/list`, and
|
- 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`.
|
`tools/list`, and `tools/call` using `redmine_search_issues`.
|
||||||
- `redmcp-http-server.php` refused to start without `MCP_SERVER_TOKEN`.
|
- `redmcp-http-server.php` refused to start without `MCP_SERVER_TOKEN`.
|
||||||
- Unauthenticated `/mcp` returned `401`; wrong path returned `404`.
|
- 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
|
- The live MCP tool calls returned issue search results from seven total for
|
||||||
`redMCP-smoke`.
|
`redMCP-smoke`.
|
||||||
|
|
||||||
|
|||||||
+35
-5
@@ -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
|
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:
|
The network endpoint defaults to `/mcp` and requires:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -115,6 +121,30 @@ curl -sS \
|
|||||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
-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:
|
Example stdio client configuration:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -127,11 +157,11 @@ Example stdio client configuration:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Both transports expose tools for native Redmine filtering/search, issue CRUD,
|
Both transports expose tools for native Redmine project listing/detail,
|
||||||
Helpdesk-aware issue reads, and explicit Helpdesk email responses. Tools that
|
filtering/search, issue CRUD, Helpdesk-aware issue reads, and explicit Helpdesk
|
||||||
can send customer-visible mail require an explicit tool call such as
|
email responses. Tools that can send customer-visible mail require an explicit
|
||||||
`redmine_send_helpdesk_response` or `redmine_update_issue` with
|
tool call such as `redmine_send_helpdesk_response` or `redmine_update_issue`
|
||||||
`send_helpdesk_email=true`.
|
with `send_helpdesk_email=true`.
|
||||||
|
|
||||||
## Test instance
|
## Test instance
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
final class McpDispatcher
|
||||||
{
|
{
|
||||||
private RedmineClient $redmine;
|
private RedmineClient $redmine;
|
||||||
|
private McpDebugLogger $logger;
|
||||||
|
|
||||||
public function __construct(RedmineClient $redmine)
|
public function __construct(RedmineClient $redmine, ?McpDebugLogger $logger = null)
|
||||||
{
|
{
|
||||||
$this->redmine = $redmine;
|
$this->redmine = $redmine;
|
||||||
|
$this->logger = $logger ?? new McpDebugLogger(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,18 +23,23 @@ final class McpDispatcher
|
|||||||
*
|
*
|
||||||
* @return array<string,mixed>|null
|
* @return array<string,mixed>|null
|
||||||
*/
|
*/
|
||||||
public function handleMessage(array $message): ?array
|
public function handleMessage(array $message, array $context = []): ?array
|
||||||
{
|
{
|
||||||
$id = $message['id'] ?? null;
|
$id = $message['id'] ?? null;
|
||||||
if ($id === null) {
|
if ($id === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$started = microtime(true);
|
||||||
|
$method = (string) ($message['method'] ?? '');
|
||||||
|
$params = is_array($message['params'] ?? null) ? $message['params'] : [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$method = (string) ($message['method'] ?? '');
|
$result = $this->dispatch($method, $params);
|
||||||
$params = is_array($message['params'] ?? null) ? $message['params'] : [];
|
$this->logCall($context, $method, $params, true, $started);
|
||||||
return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->dispatch($method, $params)];
|
return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $result];
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
|
$this->logCall($context, $method, $params, false, $started, $exception->getMessage());
|
||||||
return [
|
return [
|
||||||
'jsonrpc' => '2.0',
|
'jsonrpc' => '2.0',
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
@@ -84,6 +91,13 @@ final class McpDispatcher
|
|||||||
private function tools(): array
|
private function tools(): array
|
||||||
{
|
{
|
||||||
return [
|
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.', [
|
$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.'],
|
'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'] : [];
|
$arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : [];
|
||||||
|
|
||||||
switch ($name) {
|
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':
|
case 'redmine_list_issues':
|
||||||
$result = $this->redmine->filterIssues($this->objectArg($arguments, 'filters'));
|
$result = $this->redmine->filterIssues($this->objectArg($arguments, 'filters'));
|
||||||
break;
|
break;
|
||||||
@@ -242,6 +262,21 @@ final class McpDispatcher
|
|||||||
return (int) $arguments[$key];
|
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<string,mixed> $arguments
|
||||||
* @param array<int,string> $default
|
* @param array<int,string> $default
|
||||||
@@ -256,4 +291,36 @@ final class McpDispatcher
|
|||||||
|
|
||||||
return array_values(array_filter(array_map('strval', $arguments[$key])));
|
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
|
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
|
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_url' => rtrim((string) (getenv('REDMINE_URL') ?: ($env['REDMINE_URL'] ?? 'http://192.168.50.170')), '/'),
|
||||||
'redmine_api_key' => $apiKey,
|
'redmine_api_key' => $apiKey,
|
||||||
'mcp_server_token' => self::optionalString(getenv('MCP_SERVER_TOKEN') ?: ($env['MCP_SERVER_TOKEN'] ?? null)),
|
'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.');
|
$responses[] = $this->errorResponse(null, -32600, 'Invalid request.');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$response = $this->dispatcher->handleMessage($message);
|
$response = $this->dispatcher->handleMessage($message, $this->logContext());
|
||||||
if ($response !== null) {
|
if ($response !== null) {
|
||||||
$responses[] = $response;
|
$responses[] = $response;
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ final class McpHttpHandler
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = $this->dispatcher->handleMessage($decoded);
|
$response = $this->dispatcher->handleMessage($decoded, $this->logContext());
|
||||||
if ($response === null) {
|
if ($response === null) {
|
||||||
http_response_code(202);
|
http_response_code(202);
|
||||||
return;
|
return;
|
||||||
@@ -79,6 +79,17 @@ final class McpHttpHandler
|
|||||||
return hash_equals($this->token, substr($header, 7));
|
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
|
* @param mixed $payload
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ final class McpStdioServer
|
|||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
while (($message = $this->readMessage(STDIN)) !== null) {
|
while (($message = $this->readMessage(STDIN)) !== null) {
|
||||||
$response = $this->dispatcher->handleMessage($message);
|
$response = $this->dispatcher->handleMessage($message, ['transport' => 'stdio']);
|
||||||
if ($response !== null) {
|
if ($response !== null) {
|
||||||
$this->writeMessage($response);
|
$this->writeMessage($response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,52 @@ final class RedmineClient
|
|||||||
return $this->search($query, ['issues' => '1'] + $params);
|
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.
|
* Fetch a normal Redmine issue.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use RedMCP\McpDispatcher;
|
use RedMCP\McpDispatcher;
|
||||||
|
use RedMCP\McpDebugLogger;
|
||||||
use RedMCP\McpEnvironment;
|
use RedMCP\McpEnvironment;
|
||||||
use RedMCP\McpHttpHandler;
|
use RedMCP\McpHttpHandler;
|
||||||
use RedMCP\RedmineClient;
|
use RedMCP\RedmineClient;
|
||||||
@@ -19,7 +20,10 @@ if ($token === null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$handler = new McpHttpHandler(
|
$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,
|
$token,
|
||||||
getenv('MCP_HTTP_PATH') ?: '/mcp'
|
getenv('MCP_HTTP_PATH') ?: '/mcp'
|
||||||
);
|
);
|
||||||
|
|||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$options = getopt('', ['bytes:', 'env-line', 'help']);
|
||||||
|
if (isset($options['help'])) {
|
||||||
|
fwrite(STDOUT, "Usage: generate-bearer-token.php [--bytes 32] [--env-line]\n");
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes = isset($options['bytes']) ? (int) $options['bytes'] : 32;
|
||||||
|
if ($bytes < 16) {
|
||||||
|
fwrite(STDERR, "--bytes must be at least 16.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '=');
|
||||||
|
if (isset($options['env-line'])) {
|
||||||
|
fwrite(STDOUT, 'MCP_SERVER_TOKEN=' . $token . PHP_EOL);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, $token . PHP_EOL);
|
||||||
@@ -7,15 +7,45 @@ use RedMCP\McpEnvironment;
|
|||||||
|
|
||||||
require __DIR__ . '/../vendor/autoload.php';
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
$options = getopt('', ['host:', 'port:', 'path:', 'help']);
|
$options = getopt('', ['host:', 'port:', 'path:', 'pid-file:', 'debug-log:', 'status', 'stop', 'force', 'help']);
|
||||||
if (isset($options['help'])) {
|
if (isset($options['help'])) {
|
||||||
fwrite(STDOUT, "Usage: redmcp-http-server.php [--host 0.0.0.0] [--port 8765] [--path /mcp]\n");
|
fwrite(
|
||||||
|
STDOUT,
|
||||||
|
"Usage: redmcp-http-server.php [--host 0.0.0.0] [--port 8765] [--path /mcp] [--pid-file /tmp/redmcp-http-server.pid] [--debug-log /tmp/redmcp-mcp.log] [--status|--stop] [--force]\n"
|
||||||
|
);
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
$host = (string) ($options['host'] ?? '0.0.0.0');
|
$host = (string) ($options['host'] ?? '0.0.0.0');
|
||||||
$port = (int) ($options['port'] ?? 8765);
|
$port = (int) ($options['port'] ?? 8765);
|
||||||
$path = (string) ($options['path'] ?? '/mcp');
|
$path = (string) ($options['path'] ?? '/mcp');
|
||||||
|
$pidFile = (string) ($options['pid-file'] ?? '/tmp/redmcp-http-server.pid');
|
||||||
|
$debugLog = isset($options['debug-log']) ? (string) $options['debug-log'] : null;
|
||||||
|
$force = isset($options['force']);
|
||||||
|
|
||||||
|
if (isset($options['status'])) {
|
||||||
|
showStatus($pidFile);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($options['stop'])) {
|
||||||
|
stopServer($pidFile);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLivePidFile($pidFile)) {
|
||||||
|
fwrite(STDERR, "redMCP HTTP server already appears to be running with PID " . trim((string) file_get_contents($pidFile)) . ".\n");
|
||||||
|
fwrite(STDERR, "Use --stop first, or --force to replace a stale PID file.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_file($pidFile)) {
|
||||||
|
if (!$force) {
|
||||||
|
fwrite(STDERR, "Stale PID file exists at {$pidFile}. Use --force to remove it.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
unlink($pidFile);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$env = McpEnvironment::load(__DIR__ . '/../.env');
|
$env = McpEnvironment::load(__DIR__ . '/../.env');
|
||||||
@@ -28,6 +58,9 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
putenv('MCP_HTTP_PATH=' . $path);
|
putenv('MCP_HTTP_PATH=' . $path);
|
||||||
|
if ($debugLog !== null && $debugLog !== '') {
|
||||||
|
putenv('MCP_DEBUG_LOG=' . $debugLog);
|
||||||
|
}
|
||||||
$router = __DIR__ . '/../app/mcp-http-router.php';
|
$router = __DIR__ . '/../app/mcp-http-router.php';
|
||||||
$command = [
|
$command = [
|
||||||
PHP_BINARY,
|
PHP_BINARY,
|
||||||
@@ -38,6 +71,118 @@ $command = [
|
|||||||
|
|
||||||
fwrite(STDERR, "redMCP HTTP server listening on http://{$host}:{$port}{$path}\n");
|
fwrite(STDERR, "redMCP HTTP server listening on http://{$host}:{$port}{$path}\n");
|
||||||
fwrite(STDERR, "Authorization: Bearer <MCP_SERVER_TOKEN> is required.\n");
|
fwrite(STDERR, "Authorization: Bearer <MCP_SERVER_TOKEN> 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);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use RedMCP\McpDispatcher;
|
use RedMCP\McpDispatcher;
|
||||||
|
use RedMCP\McpDebugLogger;
|
||||||
use RedMCP\McpEnvironment;
|
use RedMCP\McpEnvironment;
|
||||||
use RedMCP\McpStdioServer;
|
use RedMCP\McpStdioServer;
|
||||||
use RedMCP\RedmineClient;
|
use RedMCP\RedmineClient;
|
||||||
@@ -12,7 +13,10 @@ require __DIR__ . '/../vendor/autoload.php';
|
|||||||
|
|
||||||
$env = McpEnvironment::load(__DIR__ . '/../.env');
|
$env = McpEnvironment::load(__DIR__ . '/../.env');
|
||||||
$server = new McpStdioServer(
|
$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();
|
$server->run();
|
||||||
exit(0);
|
exit(0);
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
},
|
},
|
||||||
"bin": [
|
"bin": [
|
||||||
"bin/redmcp-server.php",
|
"bin/redmcp-server.php",
|
||||||
"bin/redmcp-http-server.php"
|
"bin/redmcp-http-server.php",
|
||||||
|
"bin/generate-bearer-token.php"
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"kbsali/redmine-api": "^2.9"
|
"kbsali/redmine-api": "^2.9"
|
||||||
|
|||||||
Reference in New Issue
Block a user