diff --git a/README.md b/README.md index 46ddd7d..563f13f 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/docs/redmineup_local_fork_changelog.md b/docs/redmineup_local_fork_changelog.md index 3d999c5..85adb38 100644 --- a/docs/redmineup_local_fork_changelog.md +++ b/docs/redmineup_local_fork_changelog.md @@ -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. diff --git a/redMCP/README.md b/redMCP/README.md index 5ca7ddf..941a61a 100644 --- a/redMCP/README.md +++ b/redMCP/README.md @@ -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 diff --git a/redMCP/app/McpDispatcher.php b/redMCP/app/McpDispatcher.php index bd8a497..8f2f3c4 100644 --- a/redMCP/app/McpDispatcher.php +++ b/redMCP/app/McpDispatcher.php @@ -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); + } } diff --git a/redMCP/app/RedmineClient.php b/redMCP/app/RedmineClient.php index d2ddb3c..dd48daa 100644 --- a/redMCP/app/RedmineClient.php +++ b/redMCP/app/RedmineClient.php @@ -151,6 +151,68 @@ final class RedmineClient return $response['project'] ?? $response; } + /** + * List Redmine users. + * + * @param array $params Standard Redmine user list params. + * + * @return array + */ + public function users(array $params = []): array + { + return $this->listUsers($params); + } + + /** + * List Redmine users. + * + * @param array $params Standard Redmine user list params. + * + * @return array + */ + public function listUsers(array $params = []): array + { + return $this->getJson('/users', $params) ?? []; + } + + /** + * Fetch one Redmine user. + * + * @param array $params Standard Redmine user show params. + * + * @return array + */ + 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 $params Standard Redmine membership list params. + * + * @return array + */ + 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. *