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
+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
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
+61 -3
View File
@@ -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);
}
}
+62
View File
@@ -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.
*