Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f4c3d35ef | |||
| 38e06da3a6 | |||
| a7d23cd79a | |||
| 22c8e915e9 |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
REDMINE_URL=http://192.168.50.170
|
||||
REDMINE_API_KEY=
|
||||
MCP_TEXT_SANITIZATION=true
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user