Add redMCP user and membership tools
This commit is contained in:
+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