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
+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.
*