Add redMCP user and membership tools

This commit is contained in:
Jason Thistlethwaite
2026-04-25 03:13:35 +00:00
parent d54319a5bb
commit a25361f5fc
5 changed files with 155 additions and 11 deletions
+2
View File
@@ -315,6 +315,8 @@ The redMCP wrapper now makes Helpdesk behavior explicit:
- `redMCP/bin/generate-bearer-token.php` generates local MCP bearer tokens. - `redMCP/bin/generate-bearer-token.php` generates local MCP bearer tokens.
- `projects()` and `project()` expose Redmine's built-in `/projects.json` - `projects()` and `project()` expose Redmine's built-in `/projects.json`
project list/detail APIs. 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` - `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`
+12 -2
View File
@@ -48,6 +48,8 @@ environment. Before risky edits, archive the current plugin directories in
- Added `searchIssues()` for issue-only Redmine text search. - Added `searchIssues()` for issue-only Redmine text search.
- Added `projects()`, `listProjects()`, and `project()` for Redmine's - Added `projects()`, `listProjects()`, and `project()` for Redmine's
`/projects.json` APIs. `/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 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
@@ -55,9 +57,11 @@ environment. Before risky edits, archive the current plugin directories in
- Added PID/status/stop handling to the HTTP server. - Added PID/status/stop handling to the HTTP server.
- Added optional full-argument JSONL debug logging via `--debug-log` or - Added optional full-argument JSONL debug logging via `--debug-log` or
`MCP_DEBUG_LOG`. `MCP_DEBUG_LOG`.
- Added recursive credential redaction for MCP tool output and debug logs.
- Added `redMCP/bin/generate-bearer-token.php`. - Added `redMCP/bin/generate-bearer-token.php`.
- Both transports expose Redmine project reads, filtering/search, issue CRUD, - Both transports expose Redmine project reads, users, project memberships,
Helpdesk-aware reads, and explicit Helpdesk response tools. filtering/search, issue CRUD, Helpdesk-aware reads, and explicit Helpdesk
response tools.
- Registered all MCP helper 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.
@@ -75,6 +79,12 @@ environment. Before risky edits, archive the current plugin directories in
- HTTP PID helpers reported stopped/running states, rejected a duplicate - HTTP PID helpers reported stopped/running states, rejected a duplicate
start, stopped the live process, detected a stale PID file, and started start, stopped the live process, detected a stale PID file, and started
with `--force`. 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 - Debug logging wrote JSONL records with full project-tool arguments and did
not include the bearer token, `Authorization`, or Redmine API key. not include the bearer token, `Authorization`, or Redmine API key.
- Token generation passed default, `--bytes 48`, and `--env-line` modes. - Token generation passed default, `--bytes 48`, and `--env-line` modes.
+18 -6
View File
@@ -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 `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:
@@ -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, Debug logs may include customer text, issue notes, search terms, email content,
and IDs. Authorization headers, bearer tokens, and Redmine API keys are not 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: Example stdio client configuration:
@@ -157,11 +169,11 @@ Example stdio client configuration:
} }
``` ```
Both transports expose tools for native Redmine project listing/detail, Both transports expose tools for native Redmine project listing/detail, project
filtering/search, issue CRUD, Helpdesk-aware issue reads, and explicit Helpdesk memberships, users, filtering/search, issue CRUD, Helpdesk-aware issue reads,
email responses. Tools that can send customer-visible mail require an explicit and explicit Helpdesk email responses. Tools that can send customer-visible mail
tool call such as `redmine_send_helpdesk_response` or `redmine_update_issue` require an explicit tool call such as `redmine_send_helpdesk_response` or
with `send_helpdesk_email=true`. `redmine_update_issue` with `send_helpdesk_email=true`.
## Test instance ## Test instance
+61 -3
View File
@@ -98,6 +98,17 @@ final class McpDispatcher
'project_id' => ['type' => ['string', 'integer'], 'description' => 'Redmine numeric project 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.'], 'params' => ['type' => 'object', 'description' => 'Optional Redmine project params such as include=trackers,issue_categories,enabled_modules.'],
], ['project_id']), ], ['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.', [ $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.'],
]), ]),
@@ -174,6 +185,15 @@ final class McpDispatcher
case 'redmine_get_project': case 'redmine_get_project':
$result = $this->redmine->project($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params')); $result = $this->redmine->project($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params'));
break; 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': case 'redmine_list_issues':
$result = $this->redmine->filterIssues($this->objectArg($arguments, 'filters')); $result = $this->redmine->filterIssues($this->objectArg($arguments, 'filters'));
break; break;
@@ -209,7 +229,7 @@ final class McpDispatcher
throw new RuntimeException('Unknown tool: ' . $name); 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) { if ($encoded === false) {
throw new RuntimeException('Could not encode tool result.'); throw new RuntimeException('Could not encode tool result.');
} }
@@ -308,14 +328,14 @@ final class McpDispatcher
'transport' => $context['transport'] ?? 'unknown', 'transport' => $context['transport'] ?? 'unknown',
'client_ip' => $context['client_ip'] ?? null, 'client_ip' => $context['client_ip'] ?? null,
'method' => $method, 'method' => $method,
'params' => $params, 'params' => $this->redactSensitive($params),
'ok' => $ok, 'ok' => $ok,
'duration_ms' => (int) round((microtime(true) - $started) * 1000), 'duration_ms' => (int) round((microtime(true) - $started) * 1000),
]; ];
if (isset($params['name'])) { if (isset($params['name'])) {
$record['tool_name'] = $params['name']; $record['tool_name'] = $params['name'];
$arguments = $params['arguments'] ?? null; $arguments = $params['arguments'] ?? null;
$record['arguments'] = is_array($arguments) ? $arguments : null; $record['arguments'] = is_array($arguments) ? $this->redactSensitive($arguments) : null;
} }
if ($error !== null) { if ($error !== null) {
$record['error'] = $error; $record['error'] = $error;
@@ -323,4 +343,42 @@ final class McpDispatcher
$this->logger->log($record); $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);
}
} }
+62
View File
@@ -151,6 +151,68 @@ final class RedmineClient
return $response['project'] ?? $response; 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. * Fetch a normal Redmine issue.
* *