Add redMCP Streamable HTTP server
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace RedMCP;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class McpDispatcher
|
||||
{
|
||||
private RedmineClient $redmine;
|
||||
|
||||
public function __construct(RedmineClient $redmine)
|
||||
{
|
||||
$this->redmine = $redmine;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $message
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function handleMessage(array $message): ?array
|
||||
{
|
||||
$id = $message['id'] ?? null;
|
||||
if ($id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$method = (string) ($message['method'] ?? '');
|
||||
$params = is_array($message['params'] ?? null) ? $message['params'] : [];
|
||||
return ['jsonrpc' => '2.0', 'id' => $id, 'result' => $this->dispatch($method, $params)];
|
||||
} catch (Throwable $exception) {
|
||||
return [
|
||||
'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' => '2025-03-26',
|
||||
'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])));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace RedMCP;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class McpEnvironment
|
||||
{
|
||||
/**
|
||||
* @return array{redmine_url:string,redmine_api_key:string,mcp_server_token:?string}
|
||||
*/
|
||||
public static function load(string $envFile): array
|
||||
{
|
||||
$env = self::loadFile($envFile);
|
||||
$apiKey = getenv('REDMINE_API_KEY') ?: getenv('REDMNINE_API_KEY') ?: ($env['REDMINE_API_KEY'] ?? $env['REDMNINE_API_KEY'] ?? null);
|
||||
if (!is_string($apiKey) || trim($apiKey) === '') {
|
||||
throw new RuntimeException('REDMINE_API_KEY is required in the environment or redMCP/.env');
|
||||
}
|
||||
|
||||
return [
|
||||
'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)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private static function loadFile(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;
|
||||
}
|
||||
|
||||
private static function optionalString(mixed $value): ?string
|
||||
{
|
||||
if (!is_string($value) || trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace RedMCP;
|
||||
|
||||
final class McpHttpHandler
|
||||
{
|
||||
private McpDispatcher $dispatcher;
|
||||
private string $token;
|
||||
private string $path;
|
||||
|
||||
public function __construct(McpDispatcher $dispatcher, string $token, string $path = '/mcp')
|
||||
{
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->token = $token;
|
||||
$this->path = '/' . trim($path, '/');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) !== $this->path) {
|
||||
$this->sendJson(404, ['error' => 'not found']);
|
||||
return;
|
||||
}
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||
$this->sendJson(405, ['error' => 'method not allowed']);
|
||||
return;
|
||||
}
|
||||
if (!$this->authorized()) {
|
||||
header('WWW-Authenticate: Bearer');
|
||||
$this->sendJson(401, ['error' => 'unauthorized']);
|
||||
return;
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$decoded = json_decode(is_string($raw) ? $raw : '', true);
|
||||
if (!is_array($decoded)) {
|
||||
$this->sendJson(400, $this->errorResponse(null, -32700, 'Invalid JSON.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (array_is_list($decoded)) {
|
||||
$responses = [];
|
||||
foreach ($decoded as $message) {
|
||||
if (!is_array($message)) {
|
||||
$responses[] = $this->errorResponse(null, -32600, 'Invalid request.');
|
||||
continue;
|
||||
}
|
||||
$response = $this->dispatcher->handleMessage($message);
|
||||
if ($response !== null) {
|
||||
$responses[] = $response;
|
||||
}
|
||||
}
|
||||
if ($responses === []) {
|
||||
http_response_code(202);
|
||||
return;
|
||||
}
|
||||
$this->sendJson(200, $responses);
|
||||
return;
|
||||
}
|
||||
|
||||
$response = $this->dispatcher->handleMessage($decoded);
|
||||
if ($response === null) {
|
||||
http_response_code(202);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendJson(200, $response);
|
||||
}
|
||||
|
||||
private function authorized(): bool
|
||||
{
|
||||
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
if (!is_string($header) || !str_starts_with($header, 'Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($this->token, substr($header, 7));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $payload
|
||||
*/
|
||||
private function sendJson(int $status, $payload): void
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function errorResponse(mixed $id, int $code, string $message): array
|
||||
{
|
||||
return [
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => $id,
|
||||
'error' => [
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace RedMCP;
|
||||
|
||||
final class McpStdioServer
|
||||
{
|
||||
private McpDispatcher $dispatcher;
|
||||
|
||||
public function __construct(McpDispatcher $dispatcher)
|
||||
{
|
||||
$this->dispatcher = $dispatcher;
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
while (($message = $this->readMessage(STDIN)) !== null) {
|
||||
$response = $this->dispatcher->handleMessage($message);
|
||||
if ($response !== null) {
|
||||
$this->writeMessage($response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use RedMCP\McpDispatcher;
|
||||
use RedMCP\McpEnvironment;
|
||||
use RedMCP\McpHttpHandler;
|
||||
use RedMCP\RedmineClient;
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$env = McpEnvironment::load(__DIR__ . '/../.env');
|
||||
$token = $env['mcp_server_token'];
|
||||
if ($token === null) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'MCP_SERVER_TOKEN is required']);
|
||||
return;
|
||||
}
|
||||
|
||||
$handler = new McpHttpHandler(
|
||||
new McpDispatcher(RedmineClient::fromCredentials($env['redmine_url'], $env['redmine_api_key'])),
|
||||
$token,
|
||||
getenv('MCP_HTTP_PATH') ?: '/mcp'
|
||||
);
|
||||
$handler->handle();
|
||||
Reference in New Issue
Block a user