Compare commits
6 Commits
42fc8318fa
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bd26c8894f | |||
| 1f4c3d35ef | |||
| 38e06da3a6 | |||
| a7d23cd79a | |||
| 22c8e915e9 | |||
| def9084981 |
@@ -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:
|
||||
|
||||
|
||||
+56
-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,61 @@ 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.
|
||||
- Production plugin rollout milestone completed:
|
||||
- `deploy_redmine_prod_patches.sh` was used to deploy plugin patches on
|
||||
production.
|
||||
- Upload bundle used: `dist/redmine-prod-plugin-rollout-20260506T210606Z.tar.gz`.
|
||||
- User-reported outcome: production deploy completed and appears to be
|
||||
working.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
This file contains mostly longer-horizon ideas for this project.
|
||||
|
||||
## redMCP
|
||||
|
||||
[ ] Basic search backed by Redmine's existing API, full text searching:
|
||||
[ ] Issues
|
||||
[ ] Contacts
|
||||
|
||||
[ ] Paged getting/fetching of issues matching simple criteria such as:
|
||||
[ ] Project
|
||||
[ ] Last updated or created
|
||||
[ ] Status
|
||||
|
||||
## Other / uncertain
|
||||
|
||||
[ ] Contact disambiguation with Gmail/Google
|
||||
[ ] Cross-reference contacts for a given project in Redmine with Google Contacts, then
|
||||
intelligently update them so they match the same information.
|
||||
|
||||
[ ] Contacts present in Redmine but missing from Gmail should be added to Gmail
|
||||
|
||||
## Redmine (plugins)
|
||||
|
||||
- [ ] /contacts/<contact_id>/tabs/helpdesk helpdesk tickets list improvements:
|
||||
- [ ] Show dates created and last updated
|
||||
- [ ] Simple search function
|
||||
- [ ] Paging / don't shit the bed for contacts who have hundreds of closed tickets
|
||||
- [ ] LLM summary showing the key things the person has mentioned recently
|
||||
|
||||
- [ ] Issue tagging - free-form (?) tagging for issues, where IDs are shared across projects
|
||||
|
||||
[ ] Watchers notification settings
|
||||
If there are mechanisms available, other than email, to notify people who are watches on
|
||||
a ticket, they should be able to configure some settings about that.
|
||||
|
||||
Examples might include:
|
||||
|
||||
- Webhook
|
||||
- Text message
|
||||
|
||||
[ ] Better send/initiate email support
|
||||
As it stands, the two ways to initiate email using the Helpdesk plugin are clunky.
|
||||
|
||||
Method 1: Find the contact under contacts, click to send them an email, and it makes the
|
||||
user fill out the from/reply-to address -- which defaults to the one they used
|
||||
to sign up with redmine. That's typically an issue for two different reasons.
|
||||
|
||||
a. It means that if the contact responds to it, the ticket probably won't show up
|
||||
in redmine. It will just go to the employee's inbox.
|
||||
|
||||
b. Doing this doesn't create a ticket/issue unless the contact responds, which can
|
||||
be a problem if we need records of who has been emailed and about what.
|
||||
|
||||
Method 2: The user can click "New Issue" inside of a project, select that it's a support
|
||||
ticket, and then they get an option asking about what kind of ticket. They have a
|
||||
confusing drop-down that defaults to not sending an email. It's just extra headache
|
||||
to teach and manage employees to deal with this.
|
||||
|
||||
Another issue with this is that when it does send an email, it doesn't put the header/footer
|
||||
from Helpdesk on it, and the user isn't exactly told that's the case.
|
||||
|
||||
A very simple "New Ticket -> Email" option would be very useful that just acts like people
|
||||
who use Gmail would expect it to act.
|
||||
|
||||
[ ] Helpdesk: better footers/signatures
|
||||
As it stands, email footers/signatures are project based and can't easily be customized on
|
||||
a per-user basis (not quite). It would be nice if they had support for macros that will add
|
||||
the employee's job title, contact info, etc. I think it already has this feature to some
|
||||
extent, but it's not very good.
|
||||
|
||||
[ ] Helpdesk: more direct spam / blocklist / filtering ability
|
||||
Helpdesk might not be the best place to implement this, but it seems like it's worth
|
||||
thinking about.
|
||||
|
||||
Here are common issues we run into (over 10 years of using this):
|
||||
|
||||
- We _do_ want some emails from @amazon.com, but not everything. Existing features do not
|
||||
really cover anything about this. Amazon.com is just an example.
|
||||
|
||||
|
||||
[ ] Rules engine for email -> ticket imports
|
||||
The rules provided by the existing plugin are not very useful. It would be much more useful
|
||||
if we were able to do things like this:
|
||||
|
||||
[ ] Based on some criteria, send a different auto response template to certain contacts
|
||||
|
||||
[ ] Import a ticket as normal, but don't send an auto-reply. Instead, pass the imported data
|
||||
to some other program which then decides whether or not to send an autoresponse, what it
|
||||
should include, etc. If a response is sent, the ticket should be updated to include it.
|
||||
|
||||
|
||||
[ ] Draft
|
||||
A very simple way to save a draft response to a ticket, so it does not get emailed to
|
||||
anybody. However, any legit/authorized user who can see/edit the ticket can see the
|
||||
draft and choose to edit or send it.
|
||||
|
||||
[ ] We should be able to search by whether or not tickets have drafts.
|
||||
This is for these reasons:
|
||||
[ ] We may want to present the user with a list of ticket drafts they need to
|
||||
approve/deny/edit before they will be sent.
|
||||
|
||||
[ ] We may have an agent write drafts, and in doing so, it should be able to focus
|
||||
on tickets that need a draft and don't have one already.
|
||||
|
||||
[ ] It's completely fine if this is implemented using something more like a custom
|
||||
field, but I suspect a plugin will be what we want.
|
||||
Executable
+449
@@ -0,0 +1,449 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
REPO_ROOT="$SCRIPT_DIR"
|
||||
REDMINE_ROOT="/usr/share/redmine"
|
||||
BACKUP_ROOT="/root/redmine-plugin-backups"
|
||||
|
||||
REDMINE_URL="${REDMINE_URL:-}"
|
||||
REDMINE_API_KEY="${REDMINE_API_KEY:-${REDMNINE_API_KEY:-}}"
|
||||
HELPDESK_ISSUE_ID="${HELPDESK_ISSUE_ID:-}"
|
||||
NON_HELPDESK_ISSUE_ID="${NON_HELPDESK_ISSUE_ID:-}"
|
||||
|
||||
SKIP_CONTACTS=0
|
||||
SKIP_HELPDESK=0
|
||||
SKIP_OUTBOX=0
|
||||
|
||||
ACTION="deploy"
|
||||
ROLLBACK_DIR=""
|
||||
APPLY=0
|
||||
APPLY_SET=0
|
||||
PRINT_CHANGE_MAP=0
|
||||
|
||||
PLUGINS=(redmine_contacts redmine_contacts_helpdesk redmine_event_outbox)
|
||||
SELECTED_PLUGINS=()
|
||||
BACKUP_DIR=""
|
||||
ROLLBACK_RUNNING=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./deploy_redmine_prod_patches.sh [options]
|
||||
|
||||
Defaults to dry-run deploy mode.
|
||||
|
||||
Modes:
|
||||
--apply Execute actions (default is dry-run)
|
||||
--dry-run Print actions only
|
||||
--rollback <backup_dir> Restore plugins from a prior backup directory
|
||||
|
||||
Core options:
|
||||
--repo-root <path> Local repo root (default: script directory)
|
||||
--redmine-root <path> Production Redmine root (default: /usr/share/redmine)
|
||||
--backup-root <path> Backup root for deploy mode (default: /root/redmine-plugin-backups)
|
||||
|
||||
Verification options:
|
||||
--redmine-url <url> Redmine base URL for API checks
|
||||
--api-key <key> Redmine API key for API checks
|
||||
--helpdesk-issue-id <id> Known Helpdesk issue id for include=helpdesk verification
|
||||
--non-helpdesk-issue-id <id> Known non-Helpdesk issue id for include=helpdesk verification
|
||||
|
||||
Selection options:
|
||||
--skip-contacts
|
||||
--skip-helpdesk
|
||||
--skip-outbox
|
||||
|
||||
Informational:
|
||||
--print-change-map Show patch groups and key files, then exit
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
./deploy_redmine_prod_patches.sh --dry-run
|
||||
./deploy_redmine_prod_patches.sh --apply --redmine-url https://redmine.example.com --api-key ... --helpdesk-issue-id 39779 --non-helpdesk-issue-id 12345
|
||||
./deploy_redmine_prod_patches.sh --rollback /root/redmine-plugin-backups/prod-plugin-rollout-20260506T120000Z
|
||||
EOF
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date -u +%H:%M:%S)" "$*"
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
print_change_map() {
|
||||
cat <<'EOF'
|
||||
Patch Groups -> Key Files
|
||||
|
||||
1) Helpdesk API include patch (semantic-index dependency)
|
||||
- plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb
|
||||
- plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb
|
||||
- plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb
|
||||
|
||||
2) Helpdesk search routes/controller in local helpdesk fork
|
||||
- plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb
|
||||
- plugins/redmine_contacts_helpdesk/config/routes.rb
|
||||
- plugins/redmine_contacts_helpdesk/init.rb
|
||||
|
||||
3) POP3 compatibility fix in contacts fork
|
||||
- plugins/redmine_contacts/lib/redmine_contacts/utils/check_mail.rb
|
||||
|
||||
4) Event outbox + Helpdesk-related hooks
|
||||
- plugins/redmine_event_outbox/db/migrate/001_create_event_outbox_events.rb
|
||||
- plugins/redmine_event_outbox/lib/redmine_event_outbox.rb
|
||||
- plugins/redmine_event_outbox/lib/redmine_event_outbox/patches/helpdesk_ticket_patch.rb
|
||||
- plugins/redmine_event_outbox/lib/redmine_event_outbox/patches/journal_message_patch.rb
|
||||
|
||||
Operational note:
|
||||
This script deploys full plugin directories to match the post-import workflow:
|
||||
- plugins/redmine_contacts/
|
||||
- plugins/redmine_contacts_helpdesk/
|
||||
- plugins/redmine_event_outbox/
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_string() {
|
||||
local out=""
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
out+=" $(printf '%q' "$arg")"
|
||||
done
|
||||
printf '%s' "${out# }"
|
||||
}
|
||||
|
||||
run_cmd() {
|
||||
log "+ $(cmd_string "$@")"
|
||||
if [[ "$APPLY" -eq 1 ]]; then
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
require_path() {
|
||||
local path="$1"
|
||||
[[ -e "$path" ]] || die "missing required path: $path"
|
||||
}
|
||||
|
||||
require_dir() {
|
||||
local path="$1"
|
||||
[[ -d "$path" ]] || die "missing required directory: $path"
|
||||
}
|
||||
|
||||
build_selected_plugins() {
|
||||
SELECTED_PLUGINS=()
|
||||
if [[ "$SKIP_CONTACTS" -eq 0 ]]; then
|
||||
SELECTED_PLUGINS+=("redmine_contacts")
|
||||
fi
|
||||
if [[ "$SKIP_HELPDESK" -eq 0 ]]; then
|
||||
SELECTED_PLUGINS+=("redmine_contacts_helpdesk")
|
||||
fi
|
||||
if [[ "$SKIP_OUTBOX" -eq 0 ]]; then
|
||||
SELECTED_PLUGINS+=("redmine_event_outbox")
|
||||
fi
|
||||
|
||||
if [[ "${#SELECTED_PLUGINS[@]}" -eq 0 ]]; then
|
||||
die "nothing selected; remove skip flags or pick at least one plugin"
|
||||
fi
|
||||
}
|
||||
|
||||
backup_plugin() {
|
||||
local plugin="$1"
|
||||
local src="$REDMINE_ROOT/plugins/$plugin"
|
||||
local dst="$BACKUP_DIR/$plugin"
|
||||
local absent_marker="$BACKUP_DIR/.${plugin}.absent"
|
||||
|
||||
if [[ -d "$src" ]]; then
|
||||
run_cmd mkdir -p "$dst"
|
||||
run_cmd rsync -a "$src/" "$dst/"
|
||||
else
|
||||
run_cmd mkdir -p "$BACKUP_DIR"
|
||||
run_cmd touch "$absent_marker"
|
||||
fi
|
||||
}
|
||||
|
||||
restore_plugin() {
|
||||
local plugin="$1"
|
||||
local src="$ROLLBACK_DIR/$plugin"
|
||||
local dst="$REDMINE_ROOT/plugins/$plugin"
|
||||
local absent_marker="$ROLLBACK_DIR/.${plugin}.absent"
|
||||
|
||||
if [[ -f "$absent_marker" ]]; then
|
||||
run_cmd rm -rf "$dst"
|
||||
return
|
||||
fi
|
||||
|
||||
require_dir "$src"
|
||||
run_cmd mkdir -p "$dst"
|
||||
run_cmd rsync -a --delete "$src/" "$dst/"
|
||||
}
|
||||
|
||||
deploy_plugin() {
|
||||
local plugin="$1"
|
||||
local src="$REPO_ROOT/plugins/$plugin"
|
||||
local dst="$REDMINE_ROOT/plugins/$plugin"
|
||||
|
||||
require_dir "$src"
|
||||
run_cmd mkdir -p "$dst"
|
||||
run_cmd rsync -a --delete "$src/" "$dst/"
|
||||
}
|
||||
|
||||
verify_contacts_syntax() {
|
||||
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts/lib/redmine_contacts/utils/check_mail.rb"
|
||||
}
|
||||
|
||||
verify_helpdesk_syntax() {
|
||||
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb"
|
||||
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb"
|
||||
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb"
|
||||
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_contacts_helpdesk/app/models/helpdesk_mailer.rb"
|
||||
}
|
||||
|
||||
verify_outbox_syntax() {
|
||||
run_cmd ruby -c "$REDMINE_ROOT/plugins/redmine_event_outbox/lib/redmine_event_outbox.rb"
|
||||
}
|
||||
|
||||
migrate_plugins() {
|
||||
run_cmd bash -lc "cd $(printf '%q' "$REDMINE_ROOT") && RAILS_ENV=production bundle exec rake redmine:plugins:migrate"
|
||||
}
|
||||
|
||||
restart_passenger() {
|
||||
run_cmd touch "$REDMINE_ROOT/tmp/restart.txt"
|
||||
}
|
||||
|
||||
verify_outbox_table() {
|
||||
if [[ "$SKIP_OUTBOX" -eq 1 ]]; then
|
||||
return
|
||||
fi
|
||||
run_cmd bash -lc "cd $(printf '%q' "$REDMINE_ROOT") && RAILS_ENV=production bundle exec ruby -e \"require './config/environment'; abort('missing event_outbox_events') unless ActiveRecord::Base.connection.table_exists?(:event_outbox_events); puts 'OK event_outbox_events'\""
|
||||
}
|
||||
|
||||
verify_helpdesk_api() {
|
||||
if [[ "$SKIP_HELPDESK" -eq 1 ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -z "$REDMINE_URL" || -z "$REDMINE_API_KEY" ]]; then
|
||||
log "Skipping API verification (set --redmine-url and --api-key to enable)"
|
||||
return
|
||||
fi
|
||||
|
||||
local tmp1 tmp2
|
||||
tmp1="$(mktemp)"
|
||||
tmp2="$(mktemp)"
|
||||
|
||||
if [[ -n "$HELPDESK_ISSUE_ID" ]]; then
|
||||
run_cmd curl -fsS -H "X-Redmine-API-Key: $REDMINE_API_KEY" "${REDMINE_URL%/}/issues/${HELPDESK_ISSUE_ID}.json?include=journals,helpdesk" -o "$tmp1"
|
||||
run_cmd grep -q '"helpdesk_ticket"' "$tmp1"
|
||||
else
|
||||
log "Skipping Helpdesk issue include check (set --helpdesk-issue-id)"
|
||||
fi
|
||||
|
||||
if [[ -n "$NON_HELPDESK_ISSUE_ID" ]]; then
|
||||
run_cmd curl -fsS -H "X-Redmine-API-Key: $REDMINE_API_KEY" "${REDMINE_URL%/}/issues/${NON_HELPDESK_ISSUE_ID}.json?include=helpdesk" -o "$tmp2"
|
||||
run_cmd grep -q '"issue"' "$tmp2"
|
||||
else
|
||||
log "Skipping non-Helpdesk include check (set --non-helpdesk-issue-id)"
|
||||
fi
|
||||
|
||||
if [[ "$APPLY" -eq 1 ]]; then
|
||||
rm -f "$tmp1" "$tmp2"
|
||||
else
|
||||
log "Temporary API response files (dry-run placeholders): $tmp1 $tmp2"
|
||||
fi
|
||||
}
|
||||
|
||||
rollback_selected_plugins() {
|
||||
local plugin
|
||||
for plugin in "${SELECTED_PLUGINS[@]}"; do
|
||||
log "Restoring plugin: $plugin"
|
||||
restore_plugin "$plugin"
|
||||
done
|
||||
restart_passenger
|
||||
}
|
||||
|
||||
on_error() {
|
||||
local line="$1"
|
||||
local rc="$2"
|
||||
if [[ "$ROLLBACK_RUNNING" -eq 1 ]]; then
|
||||
exit "$rc"
|
||||
fi
|
||||
log "Failure at line ${line} (exit ${rc})"
|
||||
if [[ "$ACTION" == "deploy" && "$APPLY" -eq 1 && -n "$BACKUP_DIR" ]]; then
|
||||
ROLLBACK_RUNNING=1
|
||||
ROLLBACK_DIR="$BACKUP_DIR"
|
||||
log "Attempting automatic rollback from: $ROLLBACK_DIR"
|
||||
rollback_selected_plugins || true
|
||||
log "Automatic rollback finished"
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--apply)
|
||||
APPLY=1
|
||||
APPLY_SET=1
|
||||
;;
|
||||
--dry-run)
|
||||
APPLY=0
|
||||
APPLY_SET=1
|
||||
;;
|
||||
--rollback)
|
||||
ACTION="rollback"
|
||||
shift
|
||||
[[ "$#" -gt 0 ]] || die "--rollback requires a backup directory"
|
||||
ROLLBACK_DIR="$1"
|
||||
;;
|
||||
--repo-root)
|
||||
shift
|
||||
[[ "$#" -gt 0 ]] || die "--repo-root requires a path"
|
||||
REPO_ROOT="$1"
|
||||
;;
|
||||
--redmine-root)
|
||||
shift
|
||||
[[ "$#" -gt 0 ]] || die "--redmine-root requires a path"
|
||||
REDMINE_ROOT="$1"
|
||||
;;
|
||||
--backup-root)
|
||||
shift
|
||||
[[ "$#" -gt 0 ]] || die "--backup-root requires a path"
|
||||
BACKUP_ROOT="$1"
|
||||
;;
|
||||
--redmine-url)
|
||||
shift
|
||||
[[ "$#" -gt 0 ]] || die "--redmine-url requires a value"
|
||||
REDMINE_URL="$1"
|
||||
;;
|
||||
--api-key)
|
||||
shift
|
||||
[[ "$#" -gt 0 ]] || die "--api-key requires a value"
|
||||
REDMINE_API_KEY="$1"
|
||||
;;
|
||||
--helpdesk-issue-id)
|
||||
shift
|
||||
[[ "$#" -gt 0 ]] || die "--helpdesk-issue-id requires a value"
|
||||
HELPDESK_ISSUE_ID="$1"
|
||||
;;
|
||||
--non-helpdesk-issue-id)
|
||||
shift
|
||||
[[ "$#" -gt 0 ]] || die "--non-helpdesk-issue-id requires a value"
|
||||
NON_HELPDESK_ISSUE_ID="$1"
|
||||
;;
|
||||
--skip-contacts)
|
||||
SKIP_CONTACTS=1
|
||||
;;
|
||||
--skip-helpdesk)
|
||||
SKIP_HELPDESK=1
|
||||
;;
|
||||
--skip-outbox)
|
||||
SKIP_OUTBOX=1
|
||||
;;
|
||||
--print-change-map)
|
||||
PRINT_CHANGE_MAP=1
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
if [[ "$PRINT_CHANGE_MAP" -eq 1 ]]; then
|
||||
print_change_map
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$ACTION" == "rollback" && "$APPLY_SET" -eq 0 ]]; then
|
||||
APPLY=1
|
||||
fi
|
||||
|
||||
if [[ "$APPLY" -eq 1 && "$EUID" -ne 0 ]]; then
|
||||
die "--apply and --rollback apply mode require root"
|
||||
fi
|
||||
|
||||
require_dir "$REDMINE_ROOT"
|
||||
require_dir "$REDMINE_ROOT/plugins"
|
||||
require_path "$REDMINE_ROOT/tmp"
|
||||
|
||||
build_selected_plugins
|
||||
trap 'on_error ${LINENO} $?' ERR
|
||||
|
||||
if [[ "$ACTION" == "rollback" ]]; then
|
||||
require_dir "$ROLLBACK_DIR"
|
||||
log "Mode: rollback ($([[ "$APPLY" -eq 1 ]] && echo apply || echo dry-run))"
|
||||
log "Redmine root: $REDMINE_ROOT"
|
||||
log "Backup source: $ROLLBACK_DIR"
|
||||
rollback_selected_plugins
|
||||
trap - ERR
|
||||
log "Rollback completed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
require_dir "$REPO_ROOT"
|
||||
require_dir "$REPO_ROOT/plugins"
|
||||
|
||||
BACKUP_DIR="$BACKUP_ROOT/prod-plugin-rollout-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
log "Mode: deploy ($([[ "$APPLY" -eq 1 ]] && echo apply || echo dry-run))"
|
||||
log "Repo root: $REPO_ROOT"
|
||||
log "Redmine root: $REDMINE_ROOT"
|
||||
log "Backup dir: $BACKUP_DIR"
|
||||
log "Selected plugins: ${SELECTED_PLUGINS[*]}"
|
||||
|
||||
run_cmd mkdir -p "$BACKUP_DIR"
|
||||
|
||||
local plugin
|
||||
for plugin in "${SELECTED_PLUGINS[@]}"; do
|
||||
log "Backing up plugin: $plugin"
|
||||
backup_plugin "$plugin"
|
||||
done
|
||||
|
||||
if [[ "$SKIP_CONTACTS" -eq 0 ]]; then
|
||||
log "Deploying plugin: redmine_contacts"
|
||||
deploy_plugin "redmine_contacts"
|
||||
log "Verifying syntax: redmine_contacts"
|
||||
verify_contacts_syntax
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_HELPDESK" -eq 0 ]]; then
|
||||
log "Deploying plugin: redmine_contacts_helpdesk"
|
||||
deploy_plugin "redmine_contacts_helpdesk"
|
||||
log "Verifying syntax: redmine_contacts_helpdesk"
|
||||
verify_helpdesk_syntax
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_OUTBOX" -eq 0 ]]; then
|
||||
log "Deploying plugin: redmine_event_outbox"
|
||||
deploy_plugin "redmine_event_outbox"
|
||||
log "Verifying syntax: redmine_event_outbox"
|
||||
verify_outbox_syntax
|
||||
fi
|
||||
|
||||
log "Running plugin migrations"
|
||||
migrate_plugins
|
||||
|
||||
log "Restarting Passenger"
|
||||
restart_passenger
|
||||
|
||||
log "Running runtime verifications"
|
||||
verify_outbox_table
|
||||
verify_helpdesk_api
|
||||
|
||||
trap - ERR
|
||||
log "Deploy completed successfully"
|
||||
log "Rollback command: $(cmd_string "$0" --rollback "$BACKUP_DIR")"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -32,6 +32,31 @@ environment. Before risky edits, archive the current plugin directories in
|
||||
- Run `validate_helpdesk_outbox_worker.py` after outbox or worker changes,
|
||||
then choose the external index target.
|
||||
|
||||
## 2026-05-06 - Production Plugin Rollout Via Unified Script
|
||||
|
||||
- Touched areas:
|
||||
- `plugins/redmine_contacts`
|
||||
- `plugins/redmine_contacts_helpdesk`
|
||||
- `plugins/redmine_event_outbox`
|
||||
- `deploy_redmine_prod_patches.sh`
|
||||
- Purpose:
|
||||
- Apply the same plugin payload shape used by test-host post-import automation
|
||||
to production in a controlled, repeatable sequence.
|
||||
- Keep rollback simple by backing up plugin directories before each apply run.
|
||||
- Behavior/deployment tooling:
|
||||
- Added and used `deploy_redmine_prod_patches.sh` with one-command dry-run,
|
||||
apply, and rollback modes.
|
||||
- Deployment package assembled as
|
||||
`dist/redmine-prod-plugin-rollout-20260506T210606Z.tar.gz` for upload and
|
||||
unpack on production.
|
||||
- Script-level checks include Ruby syntax checks per plugin, plugin migration,
|
||||
Passenger restart, and optional API verification for
|
||||
`include=journals,helpdesk` behavior.
|
||||
- Production result:
|
||||
- Production rollout completed and reported working after deploy.
|
||||
- This confirms the production host now has the local plugin fork updates in
|
||||
place through the scripted deployment path.
|
||||
|
||||
## 2026-04-25 - redMCP Native Search, Filtering, And MCP Operations
|
||||
|
||||
- Touched areas:
|
||||
|
||||
@@ -53,6 +53,23 @@ docker run -p 6333:6333 -p 6334:6334 \
|
||||
Before destructive maintenance, create a Qdrant snapshot or preserve the Docker
|
||||
volume.
|
||||
|
||||
## WireGuard Topology
|
||||
|
||||
Current WireGuard endpoints:
|
||||
|
||||
- production server: `10.11.0.100`
|
||||
- LAN server: `10.11.0.105`
|
||||
|
||||
When Qdrant is hosted on the LAN server, keep it reachable on the WireGuard
|
||||
address and point production semantic-index traffic at:
|
||||
|
||||
```sh
|
||||
QDRANT_URL=http://10.11.0.105:6333
|
||||
```
|
||||
|
||||
Do not bind production-hosted Qdrant to `10.11.0.105`; that address belongs to
|
||||
the LAN host.
|
||||
|
||||
## Environment
|
||||
|
||||
For a production-style install, use:
|
||||
@@ -68,7 +85,7 @@ target host:
|
||||
|
||||
```sh
|
||||
OPENAI_API_KEY=
|
||||
QDRANT_URL=http://qdrant-host:6333
|
||||
QDRANT_URL=http://10.11.0.105:6333
|
||||
QDRANT_API_KEY=
|
||||
QDRANT_COLLECTION=redmine_semantic_sample
|
||||
REDMINE_URL=http://redmine-host
|
||||
|
||||
@@ -38,6 +38,28 @@ SEMANTIC_INDEX_OVERLAP_MINUTES=15
|
||||
Keep `OPENAI_API_KEY`, `QDRANT_URL`, `REDMINE_URL`, and `REDMINE_API_KEY` in the
|
||||
existing `.env` workflow or in the service manager environment.
|
||||
|
||||
Current WireGuard addressing used by this environment:
|
||||
|
||||
- production server: `10.11.0.100`
|
||||
- LAN server: `10.11.0.105`
|
||||
|
||||
If Qdrant stays on the LAN server, set:
|
||||
|
||||
```sh
|
||||
QDRANT_URL=http://10.11.0.105:6333
|
||||
```
|
||||
|
||||
Keep Qdrant bound to the WireGuard/LAN path only and protect it with
|
||||
`QDRANT_API_KEY`.
|
||||
|
||||
From the production server, run `./validate_qdrant.py` to verify Qdrant liveness,
|
||||
readiness, auth, and a minimal create/upsert/read/delete round trip.
|
||||
|
||||
```sh
|
||||
QDRANT_API_KEY=... ./validate_qdrant.py
|
||||
./validate_qdrant.py --skip-write-test
|
||||
```
|
||||
|
||||
For production-style deployment, use `/opt/semantic-index` for code,
|
||||
`/etc/semantic-index.env` for service environment, `/var/lib/semantic-index`
|
||||
for refresh state, and `/var/log/semantic-index` for refresh logs. Systemd
|
||||
|
||||
@@ -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",
|
||||
@@ -207,6 +213,13 @@ Redmine credentials from environment variables or `redMCP/.env`.
|
||||
redMCP/bin/redmcp-server.php
|
||||
```
|
||||
|
||||
The stdio server supports both MCP framing styles used by clients in the wild:
|
||||
|
||||
- `Content-Length` framed JSON-RPC messages
|
||||
- line-delimited JSON messages (one JSON-RPC object per line)
|
||||
|
||||
Responses mirror the detected input framing mode for compatibility.
|
||||
|
||||
For local testing, run the Streamable HTTP server:
|
||||
|
||||
```sh
|
||||
@@ -298,6 +311,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
|
||||
|
||||
+169
-14
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,7 +254,7 @@ final class McpDispatcher
|
||||
'due_date' => ['type' => 'string'],
|
||||
'start_date' => ['type' => 'string'],
|
||||
'custom_fields' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
'watcher_user_ids' => ['type' => 'array'],
|
||||
'watcher_user_ids' => ['type' => 'array', 'items' => ['type' => ['integer', 'string']]],
|
||||
'is_private' => ['type' => ['boolean', 'string', 'integer']],
|
||||
'estimated_hours' => ['type' => ['number', 'string', 'integer']],
|
||||
'done_ratio' => ['type' => ['integer', 'string']],
|
||||
@@ -277,7 +279,7 @@ final class McpDispatcher
|
||||
'start_date' => ['type' => 'string'],
|
||||
'private_notes' => ['type' => ['boolean', 'string', 'integer']],
|
||||
'custom_fields' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||||
'watcher_user_ids' => ['type' => 'array'],
|
||||
'watcher_user_ids' => ['type' => 'array', 'items' => ['type' => ['integer', 'string']]],
|
||||
'is_private' => ['type' => ['boolean', 'string', 'integer']],
|
||||
'estimated_hours' => ['type' => ['number', 'string', 'integer']],
|
||||
'done_ratio' => ['type' => ['integer', 'string']],
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace RedMCP;
|
||||
final class McpStdioServer
|
||||
{
|
||||
private McpDispatcher $dispatcher;
|
||||
private string $wireMode = 'content-length';
|
||||
|
||||
public function __construct(McpDispatcher $dispatcher)
|
||||
{
|
||||
@@ -36,7 +37,8 @@ final class McpStdioServer
|
||||
if ($line === '') {
|
||||
break;
|
||||
}
|
||||
if (!str_contains($line, ':')) {
|
||||
if (!preg_match('/^[A-Za-z0-9-]+\s*:/', $line)) {
|
||||
$this->wireMode = 'line';
|
||||
$decoded = json_decode($line, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
@@ -76,6 +78,12 @@ final class McpStdioServer
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->wireMode === 'line') {
|
||||
fwrite(STDOUT, $body . "\n");
|
||||
fflush(STDOUT);
|
||||
return;
|
||||
}
|
||||
|
||||
fwrite(STDOUT, 'Content-Length: ' . strlen($body) . "\r\n\r\n" . $body);
|
||||
fflush(STDOUT);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
## Roadmap 2026-05-06
|
||||
|
||||
redMCP is now being used in production, even though the changes to redmine's plugins and the semantic search feature are not yet in production. Since beginning production use, we have learned that the way the MCP server may return certain information wastes valuable tokens and context space for connected agents. This problem doesn't really seem to be an issue with the MCP server itself, but has more to do the contents of tickets themselves.
|
||||
|
||||
The issue is, the "description" field of many redmine tickets may contain a lot of extra non-printing characters, unicode, or things of that nature which do not seve any real purpose, and which waste tokens and context.
|
||||
|
||||
## Goal
|
||||
|
||||
Develop a plan for trimming fetched data to minimize junk information without harming the meaning of the data. This should, in particular, strip needlessly repeating characters from fetched issues or notes.
|
||||
|
||||
## Other issues needing addressed
|
||||
|
||||
- [ ] MCP server response when client requests projects by human-name needs to be adjusted.
|
||||
Problem: a client may call list_issues and for the project_id, they may pass "Quality Tracker", which is the human-facing name for that project in redmine. That fails because redmine expects to receive either the numeric project id or the slug (like quality-tracker). This is a mistake that humans can easily make as well.
|
||||
|
||||
Proposed solutions:
|
||||
|
||||
- [ ] When a "project_id" is passed to commands like list_issues and it contains any spaces or uppercase characters, that likely means the client is triggering this bug. If the subsequent call to redmine results in an empty response or error message, it would be helpful if the MCP server returns an error message that explains this problem.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Executable
+208
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Simple Qdrant connectivity and write-path validator.
|
||||
|
||||
Run this from the production host to confirm Qdrant is reachable, auth works,
|
||||
and a minimal create/upsert/read/delete round trip succeeds.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
DEFAULT_URLS = ("http://127.0.0.1:6333", "http://10.11.0.105:6333")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate Qdrant connectivity and basic operations.")
|
||||
parser.add_argument(
|
||||
"--url",
|
||||
action="append",
|
||||
help=(
|
||||
"Qdrant base URL to test. Repeat for multiple endpoints. "
|
||||
"Defaults to QDRANT_URL if set, otherwise localhost and 10.11.0.105."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-key",
|
||||
default=os.getenv("QDRANT_API_KEY", ""),
|
||||
help="Qdrant API key. Defaults to QDRANT_API_KEY env var.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-write-test",
|
||||
action="store_true",
|
||||
help="Only verify read-only endpoints and auth.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=5.0,
|
||||
help="HTTP timeout in seconds (default: 5).",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
urls = normalized_urls(args.url)
|
||||
api_key = args.api_key.strip()
|
||||
failures = 0
|
||||
|
||||
for url in urls:
|
||||
print(f"\n== {url} ==")
|
||||
try:
|
||||
validate_endpoint(url, api_key=api_key, timeout=args.timeout, skip_write_test=args.skip_write_test)
|
||||
print(f"[OK] Endpoint validated: {url}")
|
||||
except ValidationError as exc:
|
||||
failures += 1
|
||||
print(f"[FAIL] {url}: {exc}")
|
||||
|
||||
print(f"\nSummary: {len(urls) - failures} OK, {failures} FAIL")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
def normalized_urls(values: Optional[List[str]]) -> List[str]:
|
||||
if values:
|
||||
return [v.rstrip("/") for v in values]
|
||||
env_url = os.getenv("QDRANT_URL", "").strip()
|
||||
if env_url:
|
||||
return [env_url.rstrip("/")]
|
||||
return [u.rstrip("/") for u in DEFAULT_URLS]
|
||||
|
||||
|
||||
def validate_endpoint(base_url: str, api_key: str, timeout: float, skip_write_test: bool) -> None:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["api-key"] = api_key
|
||||
|
||||
live_text = http_text("GET", f"{base_url}/livez", headers=headers, timeout=timeout)
|
||||
ensure_health_ok(live_text, "livez")
|
||||
print("[OK] /livez")
|
||||
|
||||
ready_text = http_text("GET", f"{base_url}/readyz", headers=headers, timeout=timeout)
|
||||
ensure_health_ok(ready_text, "readyz")
|
||||
print("[OK] /readyz")
|
||||
|
||||
collections = http_json("GET", f"{base_url}/collections", headers=headers, timeout=timeout)
|
||||
ensure_status_ok(collections, "collections")
|
||||
count = len(collections.get("result", {}).get("collections", []))
|
||||
print(f"[OK] /collections (count={count})")
|
||||
|
||||
if skip_write_test:
|
||||
print("[OK] Write-path test skipped")
|
||||
return
|
||||
|
||||
collection = temp_collection_name()
|
||||
created = False
|
||||
try:
|
||||
body = {"vectors": {"size": 4, "distance": "Cosine"}}
|
||||
create_result = http_json(
|
||||
"PUT",
|
||||
f"{base_url}/collections/{collection}",
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
body=body,
|
||||
)
|
||||
ensure_status_ok(create_result, "create collection")
|
||||
created = True
|
||||
print(f"[OK] Created temp collection: {collection}")
|
||||
|
||||
point = {"id": 1, "vector": [0.1, 0.2, 0.3, 0.4], "payload": {"check": "qdrant-smoke"}}
|
||||
upsert_result = http_json(
|
||||
"PUT",
|
||||
f"{base_url}/collections/{collection}/points?wait=true",
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
body={"points": [point]},
|
||||
)
|
||||
ensure_status_ok(upsert_result, "upsert point")
|
||||
print("[OK] Upserted test point")
|
||||
|
||||
fetch_result = http_json(
|
||||
"POST",
|
||||
f"{base_url}/collections/{collection}/points",
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
body={"ids": [1], "with_payload": True, "with_vector": True},
|
||||
)
|
||||
ensure_status_ok(fetch_result, "fetch point")
|
||||
points = fetch_result.get("result", [])
|
||||
if not points:
|
||||
raise ValidationError("fetch point returned empty result")
|
||||
if points[0].get("id") != 1:
|
||||
raise ValidationError(f"unexpected point id in fetch response: {points[0].get('id')!r}")
|
||||
print("[OK] Fetched test point")
|
||||
|
||||
finally:
|
||||
if created:
|
||||
try:
|
||||
delete_result = http_json(
|
||||
"DELETE",
|
||||
f"{base_url}/collections/{collection}?timeout=30",
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
ensure_status_ok(delete_result, "delete collection")
|
||||
print(f"[OK] Deleted temp collection: {collection}")
|
||||
except ValidationError as exc:
|
||||
print(f"[WARN] Could not delete temp collection {collection}: {exc}")
|
||||
|
||||
|
||||
def temp_collection_name() -> str:
|
||||
stamp = time.strftime("%Y%m%d%H%M%S")
|
||||
host = socket.gethostname().replace("_", "-").replace(".", "-")
|
||||
return f"qdrant_smoke_{host}_{stamp}_{os.getpid()}"
|
||||
|
||||
|
||||
def http_json(method: str, url: str, headers: Dict[str, str], timeout: float, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
request = urllib.request.Request(url=url, method=method, data=data, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
payload = response.read().decode("utf-8")
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise ValidationError(f"HTTP {exc.code} for {method} {url}: {detail.strip()}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise ValidationError(f"network error for {method} {url}: {exc.reason}") from exc
|
||||
|
||||
try:
|
||||
return json.loads(payload)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValidationError(f"non-JSON response for {method} {url}: {payload[:200]!r}") from exc
|
||||
|
||||
|
||||
def http_text(method: str, url: str, headers: Dict[str, str], timeout: float) -> str:
|
||||
request = urllib.request.Request(url=url, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return response.read().decode("utf-8", errors="replace").strip()
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise ValidationError(f"HTTP {exc.code} for {method} {url}: {detail.strip()}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise ValidationError(f"network error for {method} {url}: {exc.reason}") from exc
|
||||
|
||||
|
||||
def ensure_status_ok(payload: Dict[str, Any], context: str) -> None:
|
||||
if payload.get("status") != "ok":
|
||||
raise ValidationError(f"{context} returned non-ok payload: {payload}")
|
||||
|
||||
|
||||
def ensure_health_ok(payload_text: str, context: str) -> None:
|
||||
text = payload_text.lower()
|
||||
if "passed" in text or text == "ok" or "ready" in text:
|
||||
return
|
||||
raise ValidationError(f"{context} returned unexpected payload: {payload_text!r}")
|
||||
|
||||
|
||||
class ValidationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user