Add redMCP user and membership tools
This commit is contained in:
@@ -315,6 +315,8 @@ The redMCP wrapper now makes Helpdesk behavior explicit:
|
||||
- `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.
|
||||
- `users()`, `user()`, and `projectMemberships()` expose Redmine's built-in
|
||||
user and project membership APIs.
|
||||
- `issues()` and `filterIssues()` expose Redmine's built-in `/issues.json`
|
||||
issue filters.
|
||||
- `search()` and `searchIssues()` expose Redmine's built-in `/search.json`
|
||||
|
||||
@@ -48,6 +48,8 @@ environment. Before risky edits, archive the current plugin directories in
|
||||
- Added `searchIssues()` for issue-only Redmine text search.
|
||||
- Added `projects()`, `listProjects()`, and `project()` for Redmine's
|
||||
`/projects.json` APIs.
|
||||
- Added `users()`, `listUsers()`, `user()`, and `projectMemberships()` for
|
||||
Redmine's user and membership APIs.
|
||||
- Added a shared MCP dispatcher and transport-specific server wrappers.
|
||||
- Added `redMCP/bin/redmcp-server.php` for stdio MCP clients.
|
||||
- Added `redMCP/bin/redmcp-http-server.php` for bearer-token-protected
|
||||
@@ -55,9 +57,11 @@ environment. Before risky edits, archive the current plugin directories in
|
||||
- Added PID/status/stop handling to the HTTP server.
|
||||
- Added optional full-argument JSONL debug logging via `--debug-log` or
|
||||
`MCP_DEBUG_LOG`.
|
||||
- Added recursive credential redaction for MCP tool output and debug logs.
|
||||
- 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.
|
||||
- Both transports expose Redmine project reads, users, project memberships,
|
||||
filtering/search, issue CRUD, Helpdesk-aware reads, and explicit Helpdesk
|
||||
response tools.
|
||||
- Registered all MCP helper commands as Composer `bin` entries.
|
||||
- LAN test result:
|
||||
- `php -l redMCP/app/RedmineClient.php` passed.
|
||||
@@ -75,6 +79,12 @@ environment. Before risky edits, archive the current plugin directories in
|
||||
- HTTP PID helpers reported stopped/running states, rejected a duplicate
|
||||
start, stopped the live process, detected a stale PID file, and started
|
||||
with `--force`.
|
||||
- Live Streamable HTTP tests passed for `redmine_list_users`,
|
||||
`redmine_get_user`, and `redmine_list_project_memberships`.
|
||||
- `redmine_get_user` redacted the returned Redmine `api_key` field.
|
||||
- `redmine_list_project_memberships` returned direct and inherited
|
||||
memberships for `customer-service`; `fud-helpdesk` returned a valid empty
|
||||
membership list.
|
||||
- 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.
|
||||
|
||||
+18
-6
@@ -65,6 +65,17 @@ $issueResults = $client->searchIssues('power supply', [
|
||||
]);
|
||||
```
|
||||
|
||||
Project and user discovery is read-only:
|
||||
|
||||
```php
|
||||
$projects = $client->projects(['limit' => 25]);
|
||||
$project = $client->project('fud-helpdesk', ['include' => 'trackers,enabled_modules']);
|
||||
$members = $client->projectMemberships('fud-helpdesk');
|
||||
|
||||
$users = $client->users(['status' => 1, 'limit' => 25]);
|
||||
$user = $client->user(1, ['include' => 'memberships,groups']);
|
||||
```
|
||||
|
||||
`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
|
||||
the Helpdesk plugin, opt in explicitly:
|
||||
@@ -143,7 +154,8 @@ MCP_SERVER_TOKEN=test-token redMCP/bin/redmcp-http-server.php \
|
||||
|
||||
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.
|
||||
logged. MCP tool output also redacts credential fields returned by Redmine, such
|
||||
as `api_key`.
|
||||
|
||||
Example stdio client configuration:
|
||||
|
||||
@@ -157,11 +169,11 @@ Example stdio client configuration:
|
||||
}
|
||||
```
|
||||
|
||||
Both transports expose tools for native Redmine project listing/detail,
|
||||
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`.
|
||||
Both transports expose tools for native Redmine project listing/detail, project
|
||||
memberships, users, 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
|
||||
|
||||
|
||||
@@ -98,6 +98,17 @@ final class McpDispatcher
|
||||
'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_project_memberships', 'List users/groups and roles for a Redmine project.', [
|
||||
'project_id' => ['type' => ['string', 'integer'], 'description' => 'Redmine numeric project id or identifier.'],
|
||||
'params' => ['type' => 'object', 'description' => 'Redmine membership list params such as offset and limit.'],
|
||||
], ['project_id']),
|
||||
$this->tool('redmine_list_users', 'List Redmine users using native /users.json.', [
|
||||
'params' => ['type' => 'object', 'description' => 'Redmine user list params such as status, name, group_id, offset, and limit.'],
|
||||
]),
|
||||
$this->tool('redmine_get_user', 'Fetch one Redmine user by id.', [
|
||||
'user_id' => ['type' => 'integer'],
|
||||
'params' => ['type' => 'object', 'description' => 'Optional Redmine user params such as include=memberships,groups.'],
|
||||
], ['user_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.'],
|
||||
]),
|
||||
@@ -174,6 +185,15 @@ final class McpDispatcher
|
||||
case 'redmine_get_project':
|
||||
$result = $this->redmine->project($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params'));
|
||||
break;
|
||||
case 'redmine_list_project_memberships':
|
||||
$result = $this->redmine->projectMemberships($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params'));
|
||||
break;
|
||||
case 'redmine_list_users':
|
||||
$result = $this->redmine->listUsers($this->objectArg($arguments, 'params'));
|
||||
break;
|
||||
case 'redmine_get_user':
|
||||
$result = $this->redmine->user($this->intArg($arguments, 'user_id'), $this->objectArg($arguments, 'params'));
|
||||
break;
|
||||
case 'redmine_list_issues':
|
||||
$result = $this->redmine->filterIssues($this->objectArg($arguments, 'filters'));
|
||||
break;
|
||||
@@ -209,7 +229,7 @@ final class McpDispatcher
|
||||
throw new RuntimeException('Unknown tool: ' . $name);
|
||||
}
|
||||
|
||||
$encoded = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
$encoded = json_encode($this->redactSensitive($result), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($encoded === false) {
|
||||
throw new RuntimeException('Could not encode tool result.');
|
||||
}
|
||||
@@ -308,14 +328,14 @@ final class McpDispatcher
|
||||
'transport' => $context['transport'] ?? 'unknown',
|
||||
'client_ip' => $context['client_ip'] ?? null,
|
||||
'method' => $method,
|
||||
'params' => $params,
|
||||
'params' => $this->redactSensitive($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;
|
||||
$record['arguments'] = is_array($arguments) ? $this->redactSensitive($arguments) : null;
|
||||
}
|
||||
if ($error !== null) {
|
||||
$record['error'] = $error;
|
||||
@@ -323,4 +343,42 @@ final class McpDispatcher
|
||||
|
||||
$this->logger->log($record);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
private function redactSensitive($value)
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$redacted = [];
|
||||
foreach ($value as $key => $item) {
|
||||
if (is_string($key) && $this->isSensitiveKey($key)) {
|
||||
$redacted[$key] = '[redacted]';
|
||||
continue;
|
||||
}
|
||||
|
||||
$redacted[$key] = $this->redactSensitive($item);
|
||||
}
|
||||
|
||||
return $redacted;
|
||||
}
|
||||
|
||||
private function isSensitiveKey(string $key): bool
|
||||
{
|
||||
$normalized = strtolower(str_replace(['-', '_'], '', $key));
|
||||
|
||||
return in_array($normalized, [
|
||||
'apikey',
|
||||
'authorization',
|
||||
'bearertoken',
|
||||
'password',
|
||||
'secret',
|
||||
'token',
|
||||
], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,68 @@ final class RedmineClient
|
||||
return $response['project'] ?? $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* List Redmine users.
|
||||
*
|
||||
* @param array<string,mixed> $params Standard Redmine user list params.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function users(array $params = []): array
|
||||
{
|
||||
return $this->listUsers($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* List Redmine users.
|
||||
*
|
||||
* @param array<string,mixed> $params Standard Redmine user list params.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function listUsers(array $params = []): array
|
||||
{
|
||||
return $this->getJson('/users', $params) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one Redmine user.
|
||||
*
|
||||
* @param array<string,mixed> $params Standard Redmine user show params.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function user(int $userId, array $params = []): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
throw new RuntimeException('Fetching a user requires a positive user id.');
|
||||
}
|
||||
|
||||
$response = $this->getJson('/users/' . rawurlencode((string) $userId), $params);
|
||||
if (!is_array($response)) {
|
||||
throw new RuntimeException('Could not fetch user #' . $userId . '.');
|
||||
}
|
||||
|
||||
return $response['user'] ?? $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* List memberships for a Redmine project.
|
||||
*
|
||||
* @param array<string,mixed> $params Standard Redmine membership list params.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function projectMemberships(int|string $projectId, array $params = []): array
|
||||
{
|
||||
$projectId = trim((string) $projectId);
|
||||
if ($projectId === '') {
|
||||
throw new RuntimeException('Fetching project memberships requires a project id or identifier.');
|
||||
}
|
||||
|
||||
return $this->getJson('/projects/' . rawurlencode($projectId) . '/memberships', $params) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a normal Redmine issue.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user