Add redMCP stdio MCP server
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
Executable
+361
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user