Compare commits

...

4 Commits

Author SHA1 Message Date
Jason Thistlethwaite 1f4c3d35ef Convert markdown links to repo-relative paths
Replace absolute local filesystem markdown links with repository-relative targets and drop local :line suffixes so links resolve consistently across environments.
2026-05-06 05:06:47 -04:00
Jason Thistlethwaite 38e06da3a6 Update cleanup notes with latest redMCP progress
Record the recent handoff and redMCP commits, refresh intentionally untracked file notes, and capture the latest redMCP lint/test validation commands and results.
2026-05-06 05:02:14 -04:00
Jason Thistlethwaite a7d23cd79a Resolve human project names in MCP project_id args
Auto-resolve project_id values that look like human names to canonical project identifiers when there is a clear match. Return actionable guidance with candidate slugs when ambiguous, and cover the behavior with structure tests and docs updates.
2026-05-06 05:00:45 -04:00
Jason Thistlethwaite 22c8e915e9 Sanitize noisy MCP text fields by default
Clean control and invisible junk from tool result text fields to reduce token waste while preserving readable Unicode. Add an MCP_TEXT_SANITIZATION toggle and regression tests for enabled and disabled behavior.
2026-05-06 02:31:25 -04:00
11 changed files with 400 additions and 64 deletions
+11 -11
View File
@@ -77,25 +77,25 @@ embedding calls.
Top-level docs:
- [README.md](/home/iadnah/redmine/README.md:1)
- [docs/event_outbox_spec.md](/home/iadnah/redmine/docs/event_outbox_spec.md:1)
- [docs/redmineup_local_fork_changelog.md](/home/iadnah/redmine/docs/redmineup_local_fork_changelog.md:1)
- [docs/pre_existing_issues.md](/home/iadnah/redmine/docs/pre_existing_issues.md:1)
- [README.md](README.md)
- [docs/event_outbox_spec.md](docs/event_outbox_spec.md)
- [docs/redmineup_local_fork_changelog.md](docs/redmineup_local_fork_changelog.md)
- [docs/pre_existing_issues.md](docs/pre_existing_issues.md)
Main scripts:
- [redmine_contacts.py](/home/iadnah/redmine/redmine_contacts.py:1)
- [redmine_helpdesk_search.py](/home/iadnah/redmine/redmine_helpdesk_search.py:1)
- [redmine_outbox_worker.py](/home/iadnah/redmine/redmine_outbox_worker.py:1)
- [redmine_contacts.py](redmine_contacts.py)
- [redmine_helpdesk_search.py](redmine_helpdesk_search.py)
- [redmine_outbox_worker.py](redmine_outbox_worker.py)
Local Redmine copy:
- [redmine-copy](/home/iadnah/redmine/redmine-copy)
- [redmine-copy](redmine-copy)
Important local plugin paths:
- [redmine-copy/plugins/redmine_event_outbox](/home/iadnah/redmine/redmine-copy/plugins/redmine_event_outbox)
- [redmine-copy/plugins/redmine_contacts_helpdesk](/home/iadnah/redmine/redmine-copy/plugins/redmine_contacts_helpdesk)
- [redmine-copy/plugins/redmine_event_outbox](redmine-copy/plugins/redmine_event_outbox)
- [redmine-copy/plugins/redmine_contacts_helpdesk](redmine-copy/plugins/redmine_contacts_helpdesk)
## What Has Already Been Done
@@ -231,7 +231,7 @@ Existing rollback archives:
Read:
- [docs/pre_existing_issues.md](/home/iadnah/redmine/docs/pre_existing_issues.md:1)
- [docs/pre_existing_issues.md](docs/pre_existing_issues.md)
Especially remember:
+50 -8
View File
@@ -1,4 +1,4 @@
## Cleanup Notes ~ May 4, 2026
## Cleanup Notes ~ May 6, 2026
This repository currently mixes multiple partially finished workstreams. The
goal is to recover to a clean, reviewable git state with focused commits so
@@ -30,13 +30,55 @@ The current dirty tree appears to contain these distinct units:
- [x] Inventory all modified and untracked files.
- [x] Identify likely project groupings for clean commits.
- [x] Confirm `LOCAL_CHANGELOG.md` aligns with Helpdesk API patch files.
- [ ] Stage and commit Helpdesk API patch as a focused unit.
- [ ] Stage and commit post-import automation as a focused unit.
- [ ] Stage and commit semantic index files as a focused unit.
- [ ] Stage and commit redMCP feature updates as a focused unit.
- [ ] Stage and commit redmine-communicator skill files (optional split).
- [ ] Run targeted syntax/tests for each committed unit.
- [ ] Confirm final worktree state and note any intentionally uncommitted files.
- [x] Stage and commit Helpdesk API patch as a focused unit.
- [x] Stage and commit post-import automation as a focused unit.
- [x] Stage and commit semantic index files as a focused unit.
- [x] Stage and commit redMCP feature updates as a focused unit.
- [x] Stage and commit redmine-communicator skill files (optional split).
- [x] Run targeted syntax/tests for each committed unit.
- [x] Confirm final worktree state and note any intentionally uncommitted files.
## Cleanup result
Committed units:
- `fba494d` Add Helpdesk issue API include serializer
- `faad708` Automate post-import refresh and validation workflow
- `b305544` Add semantic-index service, deployment assets, and tests
- `4c931ba` Expand redMCP safe issue operations and HTTP handling
- `42fc831` Add redmine-communicator skill docs and setup tooling
- `def9084` Handoff notes for next agent/workflow
- `22c8e91` Sanitize noisy MCP text fields by default
- `a7d23cd` Resolve human project names in MCP project_id args
Intentionally untracked local files:
- `redMCP/startProd.sh`
- `roadmap/`
Recent validation run for redMCP changes:
- `php -l app/McpDispatcher.php`
- `php -l app/McpEnvironment.php`
- `php -l app/mcp-http-router.php`
- `php -l bin/redmcp-server.php`
- `php -l bin/test-redmine-structure.php`
- `php bin/test-redmine-structure.php` (`OK 90 assertions`)
## Handoff notes for next session
- Gitea private repo is created and current history was pushed.
- Monorepo approach is acceptable; keep path-scoped commits and deployment-unit
boundaries.
- Production semantic-index target is a separate host from production Redmine.
- redMCP improvement focus is operational quality:
- useful error/access logging without console spam,
- easy background operation,
- simple install/remove/status workflow.
- A single fixed systemd service is not preferred for redMCP because multiple
concurrent identities/API keys may be needed. Prefer an instance model.
- If systemd is used for redMCP, implement a simple operator script with
`install`, `remove`, and `status` flows.
## Notes to keep in mind
+30 -30
View File
@@ -37,7 +37,7 @@ The old RedmineUP plugin stack is effectively local legacy code now:
Tracked local plugin source lives under:
- [plugins](/home/iadnah/redmine/plugins)
- [plugins](plugins)
The full `redmine-copy/` tree is an ignored working/reference copy of the legacy
install. Make local plugin changes in `plugins/` first, then deploy or copy them
@@ -45,7 +45,7 @@ into the test Redmine instance or `redmine-copy/` as needed.
The Redmine API/MCP wrapper project now lives in:
- [redMCP](/home/iadnah/redmine/redMCP)
- [redMCP](redMCP)
That subproject contains the PHP wrapper that composes normal Redmine issue API
responses with local Helpdesk metadata. Its dependencies are managed by Composer;
@@ -109,7 +109,7 @@ The old RedmineUP contacts plugin already exposes useful JSON routes such as:
That led to the first standalone helper:
- [redmine_contacts.py](/home/iadnah/redmine/redmine_contacts.py:1)
- [redmine_contacts.py](redmine_contacts.py)
It currently supports:
@@ -122,14 +122,14 @@ It currently supports:
A small plugin was created at:
- [redmine-copy/plugins/redmine_event_outbox](/home/iadnah/redmine/redmine-copy/plugins/redmine_event_outbox)
- [redmine-copy/plugins/redmine_event_outbox](redmine-copy/plugins/redmine_event_outbox)
It records local database events into `event_outbox_events`.
Known-good archive:
- [dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.tar.gz](/home/iadnah/redmine/dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.tar.gz)
- [manifest](/home/iadnah/redmine/dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.MANIFEST.md:1)
- [dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.tar.gz](dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.tar.gz)
- [manifest](dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.MANIFEST.md)
Tested event types on the LAN copy:
@@ -158,7 +158,7 @@ and worker-derived documents without marking rows processed.
We made targeted changes to the local fork of `redmine_contacts_helpdesk`:
- added a read-only JSON controller:
- [helpdesk_search_controller.rb](/home/iadnah/redmine/redmine-copy/plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb:1)
- [helpdesk_search_controller.rb](redmine-copy/plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb)
- added routes for:
- ticket by issue
- issues by contact
@@ -174,24 +174,24 @@ successfully.
Before touching the RedmineUP plugin forks, rollback archives were created:
- [redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.tar.gz](/home/iadnah/redmine/dist/redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.tar.gz)
- [redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.tar.gz](/home/iadnah/redmine/dist/redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.tar.gz)
- [redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.tar.gz](dist/redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.tar.gz)
- [redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.tar.gz](dist/redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.tar.gz)
Manifests:
- [contacts manifest](/home/iadnah/redmine/dist/redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.MANIFEST.md:1)
- [helpdesk manifest](/home/iadnah/redmine/dist/redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.MANIFEST.md:1)
- [contacts manifest](dist/redmine_contacts-4.1.2-local-before-helpdesk-search-20260421T215548Z.MANIFEST.md)
- [helpdesk manifest](dist/redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.MANIFEST.md)
Change tracking docs:
- [docs/redmineup_local_fork_changelog.md](/home/iadnah/redmine/docs/redmineup_local_fork_changelog.md:1)
- [redmine-copy/plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md](/home/iadnah/redmine/redmine-copy/plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md:1)
- [docs/redmineup_local_fork_changelog.md](docs/redmineup_local_fork_changelog.md)
- [redmine-copy/plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md](redmine-copy/plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md)
### 5. Read-Only Helpdesk Export/Search CLI
We also built:
- [redmine_helpdesk_search.py](/home/iadnah/redmine/redmine_helpdesk_search.py:1)
- [redmine_helpdesk_search.py](redmine_helpdesk_search.py)
Purpose:
@@ -216,7 +216,7 @@ intentionally stopped short of treating CLI speed optimization as the main goal.
The first external worker prototype is:
- [redmine_outbox_worker.py](/home/iadnah/redmine/redmine_outbox_worker.py:1)
- [redmine_outbox_worker.py](redmine_outbox_worker.py)
It runs outside Redmine and consumes `event_outbox_events` over SSH/MySQL. The
initial output target is deterministic local JSONL rather than a live search
@@ -237,7 +237,7 @@ Current behavior:
The worker processing policy is documented in:
- [docs/outbox_worker_policy.md](/home/iadnah/redmine/docs/outbox_worker_policy.md:1)
- [docs/outbox_worker_policy.md](docs/outbox_worker_policy.md)
### 7. Test Helpdesk Mail Reset
@@ -245,11 +245,11 @@ After importing a production database into the LAN test instance, reset all
active projects to use the local Mailpit test mailbox for Helpdesk settings
with:
- [reset_helpdesk_mail_settings.py](/home/iadnah/redmine/reset_helpdesk_mail_settings.py:1)
- [reset_helpdesk_mail_settings.py](reset_helpdesk_mail_settings.py)
The complete post-import workflow is documented in:
- [docs/test_instance_post_import.md](/home/iadnah/redmine/docs/test_instance_post_import.md:1)
- [docs/test_instance_post_import.md](docs/test_instance_post_import.md)
Use the read-only validator to check the test instance without changing it:
@@ -265,7 +265,7 @@ Run the Helpdesk/redMCP live smoke test after the post-import checks pass:
That test is documented in:
- [docs/helpdesk_smoke_test.md](/home/iadnah/redmine/docs/helpdesk_smoke_test.md:1)
- [docs/helpdesk_smoke_test.md](docs/helpdesk_smoke_test.md)
Run the Helpdesk outbox worker validation when changing outbox hooks, worker
enrichment, or Helpdesk/redMCP behavior:
@@ -276,7 +276,7 @@ enrichment, or Helpdesk/redMCP behavior:
That test is documented in:
- [docs/helpdesk_outbox_worker_validation.md](/home/iadnah/redmine/docs/helpdesk_outbox_worker_validation.md:1)
- [docs/helpdesk_outbox_worker_validation.md](docs/helpdesk_outbox_worker_validation.md)
Preview the affected projects and settings:
@@ -382,7 +382,7 @@ We discovered old plugin issues while working:
These are logged in:
- [docs/pre_existing_issues.md](/home/iadnah/redmine/docs/pre_existing_issues.md:1)
- [docs/pre_existing_issues.md](docs/pre_existing_issues.md)
They are important context but are not the primary search deliverable.
@@ -390,21 +390,21 @@ They are important context but are not the primary search deliverable.
Project docs:
- [docs/event_outbox_spec.md](/home/iadnah/redmine/docs/event_outbox_spec.md:1)
- [docs/redmineup_local_fork_changelog.md](/home/iadnah/redmine/docs/redmineup_local_fork_changelog.md:1)
- [docs/helpdesk_smoke_test.md](/home/iadnah/redmine/docs/helpdesk_smoke_test.md:1)
- [docs/test_instance_post_import.md](/home/iadnah/redmine/docs/test_instance_post_import.md:1)
- [docs/pre_existing_issues.md](/home/iadnah/redmine/docs/pre_existing_issues.md:1)
- [docs/event_outbox_spec.md](docs/event_outbox_spec.md)
- [docs/redmineup_local_fork_changelog.md](docs/redmineup_local_fork_changelog.md)
- [docs/helpdesk_smoke_test.md](docs/helpdesk_smoke_test.md)
- [docs/test_instance_post_import.md](docs/test_instance_post_import.md)
- [docs/pre_existing_issues.md](docs/pre_existing_issues.md)
Tooling:
- [redmine_contacts.py](/home/iadnah/redmine/redmine_contacts.py:1)
- [redmine_helpdesk_search.py](/home/iadnah/redmine/redmine_helpdesk_search.py:1)
- [redmine_contacts.py](redmine_contacts.py)
- [redmine_helpdesk_search.py](redmine_helpdesk_search.py)
Local plugin work:
- [redmine-copy/plugins/redmine_event_outbox](/home/iadnah/redmine/redmine-copy/plugins/redmine_event_outbox)
- [redmine-copy/plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb](/home/iadnah/redmine/redmine-copy/plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb:1)
- [redmine-copy/plugins/redmine_event_outbox](redmine-copy/plugins/redmine_event_outbox)
- [redmine-copy/plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb](redmine-copy/plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb)
## Current Recommended Next Steps
+1
View File
@@ -1,2 +1,3 @@
REDMINE_URL=http://192.168.50.170
REDMINE_API_KEY=
MCP_TEXT_SANITIZATION=true
+11
View File
@@ -104,6 +104,12 @@ MCP clients that do not know the exact Redmine project identifier should call
`redmine_find_project` first. Redmine identifiers are often slug-like strings
and are not always the same as the display name.
If a tool receives a `project_id` that looks like a human project name (for
example it contains spaces or uppercase text), redMCP now attempts a safe
lookup first. When one clear match exists it uses that identifier
automatically; when matches are ambiguous it returns a guidance error that
points to `redmine_find_project` and candidate slugs.
```json
{
"name": "redmine_find_project",
@@ -298,6 +304,11 @@ and IDs. Authorization headers, bearer tokens, and Redmine API keys are not
logged. MCP tool output also redacts credential fields returned by Redmine, such
as `api_key`.
Tool output text sanitization is enabled by default to reduce token waste from
invisible/control junk in fetched issue text. This cleanup preserves readable
Unicode and targets fields such as `description`, `notes`, `content`, and
message body text. Set `MCP_TEXT_SANITIZATION=false` to disable it.
Example stdio client configuration:
```json
+167 -12
View File
@@ -37,11 +37,13 @@ final class McpDispatcher
private RedmineClient $redmine;
private McpDebugLogger $logger;
private bool $sanitizeToolText;
public function __construct(RedmineClient $redmine, ?McpDebugLogger $logger = null)
public function __construct(RedmineClient $redmine, ?McpDebugLogger $logger = null, bool $sanitizeToolText = true)
{
$this->redmine = $redmine;
$this->logger = $logger ?? new McpDebugLogger(null);
$this->sanitizeToolText = $sanitizeToolText;
}
/**
@@ -376,10 +378,10 @@ final class McpDispatcher
$result = $this->findProject($this->stringArg($arguments, 'query'), $this->intArg($arguments, 'limit', 10));
break;
case 'redmine_get_project':
$result = $this->redmine->project($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params'));
$result = $this->redmine->project($this->resolvedProjectIdArg($arguments, 'project_id', 'redmine_get_project'), $this->objectArg($arguments, 'params'));
break;
case 'redmine_list_project_memberships':
$result = $this->redmine->projectMemberships($this->projectIdArg($arguments, 'project_id'), ListQueryNormalizer::listParams($arguments));
$result = $this->redmine->projectMemberships($this->resolvedProjectIdArg($arguments, 'project_id', 'redmine_list_project_memberships'), ListQueryNormalizer::listParams($arguments));
break;
case 'redmine_list_users':
$result = $this->redmine->listUsers(ListQueryNormalizer::userParams($arguments));
@@ -388,13 +390,13 @@ final class McpDispatcher
$result = $this->redmine->user($this->intArg($arguments, 'user_id'), $this->objectArg($arguments, 'params'));
break;
case 'redmine_list_issues':
$result = $this->redmine->filterIssues(ListQueryNormalizer::issueFilters($arguments));
$result = $this->redmine->filterIssues(ListQueryNormalizer::issueFilters($this->resolvedProjectArgument($arguments, 'redmine_list_issues')));
break;
case 'redmine_search':
$result = $this->redmine->search($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($arguments));
$result = $this->redmine->search($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($this->resolvedProjectArgument($arguments, 'redmine_search')));
break;
case 'redmine_search_issues':
$result = $this->redmine->searchIssues($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($arguments));
$result = $this->redmine->searchIssues($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($this->resolvedProjectArgument($arguments, 'redmine_search_issues')));
break;
case 'redmine_get_issue':
$result = $this->redmine->issue($this->intArg($arguments, 'issue_id'), $this->stringListArg($arguments, 'include', ['journals', 'attachments']));
@@ -430,19 +432,19 @@ final class McpDispatcher
);
break;
case 'redmine_create_issue':
$result = $this->redmine->createIssue($this->issueFieldsArg($arguments));
$result = $this->redmine->createIssue($this->issueFieldsArg($arguments, 'redmine_create_issue'));
break;
case 'redmine_update_issue':
$result = ['ok' => $this->redmine->updateIssue($this->intArg($arguments, 'issue_id'), $this->issueFieldsArg($arguments), $this->objectArg($arguments, 'options'))];
$result = ['ok' => $this->redmine->updateIssue($this->intArg($arguments, 'issue_id'), $this->issueFieldsArg($arguments, 'redmine_update_issue'), $this->objectArg($arguments, 'options'))];
break;
case 'redmine_list_project_issue_categories':
$result = $this->redmine->listProjectIssueCategories($this->projectIdArg($arguments, 'project_id'));
$result = $this->redmine->listProjectIssueCategories($this->resolvedProjectIdArg($arguments, 'project_id', 'redmine_list_project_issue_categories'));
break;
case 'redmine_get_issue_category':
$result = $this->redmine->issueCategory($this->intArg($arguments, 'category_id'));
break;
case 'redmine_create_issue_category':
$result = $this->redmine->createIssueCategory($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'fields'));
$result = $this->redmine->createIssueCategory($this->resolvedProjectIdArg($arguments, 'project_id', 'redmine_create_issue_category'), $this->objectArg($arguments, 'fields'));
break;
case 'redmine_update_issue_category':
$result = $this->redmine->updateIssueCategory($this->intArg($arguments, 'category_id'), $this->objectArg($arguments, 'fields'));
@@ -471,7 +473,12 @@ final class McpDispatcher
throw new RuntimeException('Unknown tool: ' . $name);
}
$encoded = json_encode($this->redactSensitive($result), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$prepared = $this->redactSensitive($result);
if ($this->sanitizeToolText) {
$prepared = $this->sanitizeToolResult($prepared);
}
$encoded = json_encode($prepared, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
throw new RuntimeException('Could not encode tool result.');
}
@@ -501,7 +508,7 @@ final class McpDispatcher
*
* @return array<string,mixed>
*/
private function issueFieldsArg(array $arguments): array
private function issueFieldsArg(array $arguments, string $toolName = ''): array
{
$fields = $this->objectArg($arguments, 'fields');
foreach (self::ISSUE_FIELD_ARGUMENT_KEYS as $key) {
@@ -510,9 +517,94 @@ final class McpDispatcher
}
}
if (array_key_exists('project_id', $fields) && (is_int($fields['project_id']) || is_string($fields['project_id']))) {
$fields['project_id'] = $this->resolveProjectIdValue($fields['project_id'], $toolName);
}
return $fields;
}
/**
* @param array<string,mixed> $arguments
*
* @return array<string,mixed>
*/
private function resolvedProjectArgument(array $arguments, string $toolName): array
{
if (!array_key_exists('project_id', $arguments) || (!is_int($arguments['project_id']) && !is_string($arguments['project_id']))) {
return $arguments;
}
$arguments['project_id'] = $this->resolveProjectIdValue($arguments['project_id'], $toolName);
return $arguments;
}
/**
* @param array<string,mixed> $arguments
*/
private function resolvedProjectIdArg(array $arguments, string $key, string $toolName): int|string
{
return $this->resolveProjectIdValue($this->projectIdArg($arguments, $key), $toolName);
}
private function resolveProjectIdValue(int|string $projectId, string $toolName): int|string
{
if (is_int($projectId)) {
return $projectId;
}
$candidate = trim($projectId);
if ($candidate === '') {
throw new RuntimeException('project_id is required.');
}
if (!$this->looksLikeHumanProjectName($candidate)) {
return $candidate;
}
$resolution = $this->findProject($candidate, 5);
$recommended = $resolution['recommended_project_id'] ?? null;
if (is_int($recommended) || (is_string($recommended) && trim($recommended) !== '')) {
return $recommended;
}
throw new RuntimeException($this->projectIdGuidanceMessage($candidate, $toolName, $resolution));
}
private function looksLikeHumanProjectName(string $projectId): bool
{
return preg_match('/\s/u', $projectId) === 1 || preg_match('/[A-Z]/', $projectId) === 1;
}
/**
* @param array<string,mixed> $resolution
*/
private function projectIdGuidanceMessage(string $projectId, string $toolName, array $resolution): string
{
$matches = is_array($resolution['matches'] ?? null) ? $resolution['matches'] : [];
$suggestions = [];
foreach (array_slice($matches, 0, 3) as $match) {
if (!is_array($match)) {
continue;
}
$identifier = trim((string) ($match['identifier'] ?? ''));
$name = trim((string) ($match['name'] ?? ''));
if ($identifier === '') {
continue;
}
$suggestions[] = $name !== '' ? ($identifier . ' (' . $name . ')') : $identifier;
}
$message = $toolName . ' could not safely resolve project_id="' . $projectId . '". '
. 'Redmine expects a project identifier slug (for example quality-tracker) or numeric id. '
. 'Call redmine_find_project first and pass project_id_to_use.';
if ($suggestions !== []) {
$message .= ' Possible matches: ' . implode(', ', $suggestions) . '.';
}
return $message;
}
/**
* @return array<string,mixed>
*/
@@ -758,4 +850,67 @@ final class McpDispatcher
'token',
], true);
}
/**
* @param mixed $value
*
* @return mixed
*/
private function sanitizeToolResult($value, string $key = '')
{
if (is_string($value)) {
if (!$this->shouldSanitizeTextKey($key)) {
return $value;
}
return $this->sanitizeText($value);
}
if (!is_array($value)) {
return $value;
}
$sanitized = [];
foreach ($value as $childKey => $childValue) {
$sanitized[$childKey] = $this->sanitizeToolResult(
$childValue,
is_string($childKey) ? $childKey : ''
);
}
return $sanitized;
}
private function shouldSanitizeTextKey(string $key): bool
{
$normalized = strtolower(trim($key));
if ($normalized === '') {
return false;
}
return in_array($normalized, [
'description',
'notes',
'content',
'body',
'text',
'message',
'message_body',
'message_text',
'plain_text',
'plain_body',
'html_body',
], true);
}
private function sanitizeText(string $value): string
{
$value = str_replace(["\r\n", "\r"], "\n", $value);
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? $value;
$value = preg_replace('/\p{Cf}+/u', '', $value) ?? $value;
$value = preg_replace('/[^\S\n]{3,}/u', ' ', $value) ?? $value;
$value = preg_replace('/\n{4,}/u', "\n\n\n", $value) ?? $value;
$value = preg_replace('/([[:punct:]])\1{7,}/u', '$1$1$1$1$1$1', $value) ?? $value;
return $value;
}
}
+23 -1
View File
@@ -9,7 +9,7 @@ use RuntimeException;
final class McpEnvironment
{
/**
* @return array{redmine_url:string,redmine_api_key:string,mcp_server_token:?string,mcp_debug_log:?string}
* @return array{redmine_url:string,redmine_api_key:string,mcp_server_token:?string,mcp_debug_log:?string,mcp_text_sanitization:bool}
*/
public static function load(string $envFile): array
{
@@ -24,6 +24,7 @@ final class McpEnvironment
'redmine_api_key' => $apiKey,
'mcp_server_token' => self::optionalString(getenv('MCP_SERVER_TOKEN') ?: ($env['MCP_SERVER_TOKEN'] ?? null)),
'mcp_debug_log' => self::optionalString(getenv('MCP_DEBUG_LOG') ?: ($env['MCP_DEBUG_LOG'] ?? null)),
'mcp_text_sanitization' => self::boolSetting(getenv('MCP_TEXT_SANITIZATION') ?: ($env['MCP_TEXT_SANITIZATION'] ?? null), true),
];
}
@@ -57,4 +58,25 @@ final class McpEnvironment
return $value;
}
private static function boolSetting(mixed $value, bool $default): bool
{
if (!is_string($value)) {
return $default;
}
$normalized = strtolower(trim($value));
if ($normalized === '') {
return $default;
}
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
return true;
}
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
return false;
}
return $default;
}
}
+2 -1
View File
@@ -22,7 +22,8 @@ if ($token === null) {
$handler = new McpHttpHandler(
new McpDispatcher(
RedmineClient::fromCredentials($env['redmine_url'], $env['redmine_api_key']),
new McpDebugLogger($env['mcp_debug_log'])
new McpDebugLogger($env['mcp_debug_log']),
$env['mcp_text_sanitization']
),
$token,
getenv('MCP_HTTP_PATH') ?: '/mcp'
+2 -1
View File
@@ -15,7 +15,8 @@ $env = McpEnvironment::load(__DIR__ . '/../.env');
$server = new McpStdioServer(
new McpDispatcher(
RedmineClient::fromCredentials($env['redmine_url'], $env['redmine_api_key']),
new McpDebugLogger($env['mcp_debug_log'])
new McpDebugLogger($env['mcp_debug_log']),
$env['mcp_text_sanitization']
)
);
$server->run();
+97
View File
@@ -78,6 +78,10 @@ final class RedmineStructureTest
$this->testMcpFindProjectRecommendsExactIdentifier();
$this->testMcpFindProjectRecommendsExactName();
$this->testMcpFindProjectLeavesAmbiguousMatchesUnrecommended();
$this->testMcpGetProjectResolvesHumanProjectNameToIdentifier();
$this->testMcpGetProjectShowsHelpfulErrorForAmbiguousHumanName();
$this->testMcpSearchSanitizesNoisyTextFields();
$this->testMcpSearchCanDisableTextSanitization();
$this->testCreateRelationDefaultsToRelatesAndRequiresTarget();
$this->testAttachmentUploadSupportsPathAndBase64();
$this->testAttachmentUploadAcceptsPdfDataUrl();
@@ -239,6 +243,88 @@ final class RedmineStructureTest
$this->assertSame('quality-archive', $result['matches'][1]['identifier'], 'second ambiguous match is returned');
}
private function testMcpGetProjectResolvesHumanProjectNameToIdentifier(): void
{
$http = new RecordingClient();
$http->queueJson(['projects' => $this->projectFixtures()]);
$http->queueJson(['project' => ['id' => 78, 'identifier' => 'quality-tracker', 'name' => 'Quality Tracker']]);
$dispatcher = new McpDispatcher(new RedmineClient($http));
$result = $this->callToolJson($dispatcher, 'redmine_get_project', ['project_id' => 'Quality Tracker']);
$this->assertSame(78, $result['id'], 'human project name resolves to expected project');
$this->assertSame('/projects/quality-tracker.json', $http->requests[1]['path'], 'resolved project lookup uses project identifier slug');
}
private function testMcpGetProjectShowsHelpfulErrorForAmbiguousHumanName(): void
{
$http = new RecordingClient();
$http->queueJson(['projects' => $this->projectFixtures()]);
$dispatcher = new McpDispatcher(new RedmineClient($http));
$response = $dispatcher->handleMessage([
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => [
'name' => 'redmine_get_project',
'arguments' => [
'project_id' => 'Quality',
],
],
]);
if (!is_array($response) || !isset($response['error']) || !is_array($response['error'])) {
throw new RuntimeException('Expected ambiguous project name to produce an MCP error.');
}
$message = (string) ($response['error']['message'] ?? '');
$this->assertStringContains('redmine_find_project', $message, 'ambiguous project error points to resolver tool');
$this->assertStringContains('quality-tracker', $message, 'ambiguous project error provides possible identifier matches');
}
private function testMcpSearchSanitizesNoisyTextFields(): void
{
$http = new RecordingClient();
$http->queueJson([
'results' => [[
'title' => 'Ticket result',
'description' => "Caf\u{00E9}\u{200B} issue\x07 !!!!!!!!!!\n\n\n\nDone",
'notes' => "Agent\u{FEFF} note\x1F........",
]],
]);
$dispatcher = new McpDispatcher(new RedmineClient($http));
$result = $this->callToolJson($dispatcher, 'redmine_search', ['query' => 'ticket']);
$description = (string) $result['results'][0]['description'];
$notes = (string) $result['results'][0]['notes'];
$this->assertStringContains('Café issue', $description, 'sanitizer preserves readable unicode content');
$this->assertNotStringContains("\x07", $description, 'sanitizer removes control characters from description');
$this->assertNotStringContains("\u{200B}", $description, 'sanitizer removes zero-width characters from description');
$this->assertNotStringContains('!!!!!!!!!!', $description, 'sanitizer caps excessive repeated punctuation in description');
$this->assertNotStringContains("\n\n\n\n", $description, 'sanitizer caps excessive blank lines in description');
$this->assertNotStringContains("\x1F", $notes, 'sanitizer removes control characters from notes');
$this->assertNotStringContains('.........', $notes, 'sanitizer caps excessive repeated punctuation in notes');
}
private function testMcpSearchCanDisableTextSanitization(): void
{
$http = new RecordingClient();
$http->queueJson([
'results' => [[
'description' => "Raw\u{200B} text\x07 !!!!!!!!!!",
]],
]);
$dispatcher = new McpDispatcher(new RedmineClient($http), null, false);
$result = $this->callToolJson($dispatcher, 'redmine_search', ['query' => 'ticket']);
$description = (string) $result['results'][0]['description'];
$this->assertStringContains("\u{200B}", $description, 'sanitization toggle off keeps zero-width characters untouched');
$this->assertStringContains("\x07", $description, 'sanitization toggle off keeps control characters untouched');
$this->assertStringContains('!!!!!!!!!!', $description, 'sanitization toggle off keeps repeated punctuation untouched');
}
private function testCreateRelationDefaultsToRelatesAndRequiresTarget(): void
{
$http = new RecordingClient();
@@ -500,6 +586,17 @@ final class RedmineStructureTest
exit(1);
}
private function assertNotStringContains(string $needle, string $haystack, string $message): void
{
$this->assertions++;
if (strpos($haystack, $needle) === false) {
return;
}
fwrite(STDERR, "FAIL: {$message}\nUnexpected needle: {$needle}\nHaystack: {$haystack}\n");
exit(1);
}
/**
* @param array<int,string> $haystack
*/
@@ -10,6 +10,7 @@ Required environment:
```text
REDMINE_URL=http://redmine.example.test
REDMINE_API_KEY=...
MCP_TEXT_SANITIZATION=true
```
For Streamable HTTP MCP:
@@ -46,6 +47,11 @@ HTTP endpoint defaults to `/mcp` and requires `Authorization: Bearer <token>`.
- `redmine_list_project_issue_categories`, `redmine_get_issue_category`.
- `redmine_get_attachment`.
When a tool receives `project_id` values that look like human names (spaces or
uppercase), redMCP attempts to resolve to a slug automatically when there is one
clear match. For ambiguous names, it returns a guidance error and suggests using
`redmine_find_project`.
## Write Tools
- `redmine_create_issue`: create an issue.