Compare commits

..

5 Commits

Author SHA1 Message Date
Jason Thistlethwaite bd26c8894f Add production rollout tooling and semantic index ops docs
Capture the production plugin rollout workflow and Qdrant validation steps so operations stay repeatable. Also harden redMCP stdio/schema compatibility to keep diverse MCP clients and validators working.
2026-05-06 22:18:02 -04:00
Jason Thistlethwaite 1f4c3d35ef Convert markdown links to repo-relative paths
Replace absolute local filesystem markdown links with repository-relative targets and drop local :line suffixes so links resolve consistently across environments.
2026-05-06 05:06:47 -04:00
Jason Thistlethwaite 38e06da3a6 Update cleanup notes with latest redMCP progress
Record the recent handoff and redMCP commits, refresh intentionally untracked file notes, and capture the latest redMCP lint/test validation commands and results.
2026-05-06 05:02:14 -04:00
Jason Thistlethwaite a7d23cd79a Resolve human project names in MCP project_id args
Auto-resolve project_id values that look like human names to canonical project identifiers when there is a clear match. Return actionable guidance with candidate slugs when ambiguous, and cover the behavior with structure tests and docs updates.
2026-05-06 05:00:45 -04:00
Jason Thistlethwaite 22c8e915e9 Sanitize noisy MCP text fields by default
Clean control and invisible junk from tool result text fields to reduce token waste while preserving readable Unicode. Add an MCP_TEXT_SANITIZATION toggle and regression tests for enabled and disabled behavior.
2026-05-06 02:31:25 -04:00
18 changed files with 1165 additions and 68 deletions
+11 -11
View File
@@ -77,25 +77,25 @@ embedding calls.
Top-level docs: Top-level docs:
- [README.md](/home/iadnah/redmine/README.md:1) - [README.md](README.md)
- [docs/event_outbox_spec.md](/home/iadnah/redmine/docs/event_outbox_spec.md:1) - [docs/event_outbox_spec.md](docs/event_outbox_spec.md)
- [docs/redmineup_local_fork_changelog.md](/home/iadnah/redmine/docs/redmineup_local_fork_changelog.md:1) - [docs/redmineup_local_fork_changelog.md](docs/redmineup_local_fork_changelog.md)
- [docs/pre_existing_issues.md](/home/iadnah/redmine/docs/pre_existing_issues.md:1) - [docs/pre_existing_issues.md](docs/pre_existing_issues.md)
Main scripts: Main scripts:
- [redmine_contacts.py](/home/iadnah/redmine/redmine_contacts.py:1) - [redmine_contacts.py](redmine_contacts.py)
- [redmine_helpdesk_search.py](/home/iadnah/redmine/redmine_helpdesk_search.py:1) - [redmine_helpdesk_search.py](redmine_helpdesk_search.py)
- [redmine_outbox_worker.py](/home/iadnah/redmine/redmine_outbox_worker.py:1) - [redmine_outbox_worker.py](redmine_outbox_worker.py)
Local Redmine copy: Local Redmine copy:
- [redmine-copy](/home/iadnah/redmine/redmine-copy) - [redmine-copy](redmine-copy)
Important local plugin paths: Important local plugin paths:
- [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)
- [redmine-copy/plugins/redmine_contacts_helpdesk](/home/iadnah/redmine/redmine-copy/plugins/redmine_contacts_helpdesk) - [redmine-copy/plugins/redmine_contacts_helpdesk](redmine-copy/plugins/redmine_contacts_helpdesk)
## What Has Already Been Done ## What Has Already Been Done
@@ -231,7 +231,7 @@ Existing rollback archives:
Read: 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: Especially remember:
+56 -8
View File
@@ -1,4 +1,4 @@
## Cleanup Notes ~ May 4, 2026 ## Cleanup Notes ~ May 6, 2026
This repository currently mixes multiple partially finished workstreams. The This repository currently mixes multiple partially finished workstreams. The
goal is to recover to a clean, reviewable git state with focused commits so 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] Inventory all modified and untracked files.
- [x] Identify likely project groupings for clean commits. - [x] Identify likely project groupings for clean commits.
- [x] Confirm `LOCAL_CHANGELOG.md` aligns with Helpdesk API patch files. - [x] Confirm `LOCAL_CHANGELOG.md` aligns with Helpdesk API patch files.
- [ ] Stage and commit Helpdesk API patch as a focused unit. - [x] Stage and commit Helpdesk API patch as a focused unit.
- [ ] Stage and commit post-import automation as a focused unit. - [x] Stage and commit post-import automation as a focused unit.
- [ ] Stage and commit semantic index files as a focused unit. - [x] Stage and commit semantic index files as a focused unit.
- [ ] Stage and commit redMCP feature updates as a focused unit. - [x] Stage and commit redMCP feature updates as a focused unit.
- [ ] Stage and commit redmine-communicator skill files (optional split). - [x] Stage and commit redmine-communicator skill files (optional split).
- [ ] Run targeted syntax/tests for each committed unit. - [x] Run targeted syntax/tests for each committed unit.
- [ ] Confirm final worktree state and note any intentionally uncommitted files. - [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 ## Notes to keep in mind
+30 -30
View File
@@ -37,7 +37,7 @@ The old RedmineUP plugin stack is effectively local legacy code now:
Tracked local plugin source lives under: 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 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 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: 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 That subproject contains the PHP wrapper that composes normal Redmine issue API
responses with local Helpdesk metadata. Its dependencies are managed by Composer; 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: 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: It currently supports:
@@ -122,14 +122,14 @@ It currently supports:
A small plugin was created at: 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`. It records local database events into `event_outbox_events`.
Known-good archive: 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) - [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](/home/iadnah/redmine/dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.MANIFEST.md:1) - [manifest](dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.MANIFEST.md)
Tested event types on the LAN copy: 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`: We made targeted changes to the local fork of `redmine_contacts_helpdesk`:
- added a read-only JSON controller: - 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: - added routes for:
- ticket by issue - ticket by issue
- issues by contact - issues by contact
@@ -174,24 +174,24 @@ successfully.
Before touching the RedmineUP plugin forks, rollback archives were created: 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-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](/home/iadnah/redmine/dist/redmine_contacts_helpdesk-3.0.9-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: Manifests:
- [contacts manifest](/home/iadnah/redmine/dist/redmine_contacts-4.1.2-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](/home/iadnah/redmine/dist/redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.MANIFEST.md:1) - [helpdesk manifest](dist/redmine_contacts_helpdesk-3.0.9-local-before-helpdesk-search-20260421T215548Z.MANIFEST.md)
Change tracking docs: Change tracking docs:
- [docs/redmineup_local_fork_changelog.md](/home/iadnah/redmine/docs/redmineup_local_fork_changelog.md:1) - [docs/redmineup_local_fork_changelog.md](docs/redmineup_local_fork_changelog.md)
- [redmine-copy/plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md](/home/iadnah/redmine/redmine-copy/plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md:1) - [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 ### 5. Read-Only Helpdesk Export/Search CLI
We also built: We also built:
- [redmine_helpdesk_search.py](/home/iadnah/redmine/redmine_helpdesk_search.py:1) - [redmine_helpdesk_search.py](redmine_helpdesk_search.py)
Purpose: Purpose:
@@ -216,7 +216,7 @@ intentionally stopped short of treating CLI speed optimization as the main goal.
The first external worker prototype is: 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 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 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: 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 ### 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 active projects to use the local Mailpit test mailbox for Helpdesk settings
with: 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: 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: 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: 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 Run the Helpdesk outbox worker validation when changing outbox hooks, worker
enrichment, or Helpdesk/redMCP behavior: enrichment, or Helpdesk/redMCP behavior:
@@ -276,7 +276,7 @@ enrichment, or Helpdesk/redMCP behavior:
That test is documented in: 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: Preview the affected projects and settings:
@@ -382,7 +382,7 @@ We discovered old plugin issues while working:
These are logged in: 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. 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: Project docs:
- [docs/event_outbox_spec.md](/home/iadnah/redmine/docs/event_outbox_spec.md:1) - [docs/event_outbox_spec.md](docs/event_outbox_spec.md)
- [docs/redmineup_local_fork_changelog.md](/home/iadnah/redmine/docs/redmineup_local_fork_changelog.md:1) - [docs/redmineup_local_fork_changelog.md](docs/redmineup_local_fork_changelog.md)
- [docs/helpdesk_smoke_test.md](/home/iadnah/redmine/docs/helpdesk_smoke_test.md:1) - [docs/helpdesk_smoke_test.md](docs/helpdesk_smoke_test.md)
- [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)
- [docs/pre_existing_issues.md](/home/iadnah/redmine/docs/pre_existing_issues.md:1) - [docs/pre_existing_issues.md](docs/pre_existing_issues.md)
Tooling: Tooling:
- [redmine_contacts.py](/home/iadnah/redmine/redmine_contacts.py:1) - [redmine_contacts.py](redmine_contacts.py)
- [redmine_helpdesk_search.py](/home/iadnah/redmine/redmine_helpdesk_search.py:1) - [redmine_helpdesk_search.py](redmine_helpdesk_search.py)
Local plugin work: Local plugin work:
- [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)
- [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_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb](redmine-copy/plugins/redmine_contacts_helpdesk/app/controllers/helpdesk_search_controller.rb)
## Current Recommended Next Steps ## Current Recommended Next Steps
+449
View File
@@ -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 "$@"
+25
View File
@@ -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, - Run `validate_helpdesk_outbox_worker.py` after outbox or worker changes,
then choose the external index target. 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 ## 2026-04-25 - redMCP Native Search, Filtering, And MCP Operations
- Touched areas: - Touched areas:
+18 -1
View File
@@ -53,6 +53,23 @@ docker run -p 6333:6333 -p 6334:6334 \
Before destructive maintenance, create a Qdrant snapshot or preserve the Docker Before destructive maintenance, create a Qdrant snapshot or preserve the Docker
volume. 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 ## Environment
For a production-style install, use: For a production-style install, use:
@@ -68,7 +85,7 @@ target host:
```sh ```sh
OPENAI_API_KEY= OPENAI_API_KEY=
QDRANT_URL=http://qdrant-host:6333 QDRANT_URL=http://10.11.0.105:6333
QDRANT_API_KEY= QDRANT_API_KEY=
QDRANT_COLLECTION=redmine_semantic_sample QDRANT_COLLECTION=redmine_semantic_sample
REDMINE_URL=http://redmine-host REDMINE_URL=http://redmine-host
+22
View File
@@ -38,6 +38,28 @@ SEMANTIC_INDEX_OVERLAP_MINUTES=15
Keep `OPENAI_API_KEY`, `QDRANT_URL`, `REDMINE_URL`, and `REDMINE_API_KEY` in the Keep `OPENAI_API_KEY`, `QDRANT_URL`, `REDMINE_URL`, and `REDMINE_API_KEY` in the
existing `.env` workflow or in the service manager environment. 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, For production-style deployment, use `/opt/semantic-index` for code,
`/etc/semantic-index.env` for service environment, `/var/lib/semantic-index` `/etc/semantic-index.env` for service environment, `/var/lib/semantic-index`
for refresh state, and `/var/log/semantic-index` for refresh logs. Systemd for refresh state, and `/var/log/semantic-index` for refresh logs. Systemd
+1
View File
@@ -1,2 +1,3 @@
REDMINE_URL=http://192.168.50.170 REDMINE_URL=http://192.168.50.170
REDMINE_API_KEY= REDMINE_API_KEY=
MCP_TEXT_SANITIZATION=true
+18
View File
@@ -104,6 +104,12 @@ MCP clients that do not know the exact Redmine project identifier should call
`redmine_find_project` first. Redmine identifiers are often slug-like strings `redmine_find_project` first. Redmine identifiers are often slug-like strings
and are not always the same as the display name. 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 ```json
{ {
"name": "redmine_find_project", "name": "redmine_find_project",
@@ -207,6 +213,13 @@ Redmine credentials from environment variables or `redMCP/.env`.
redMCP/bin/redmcp-server.php 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: For local testing, run the Streamable HTTP server:
```sh ```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 logged. MCP tool output also redacts credential fields returned by Redmine, such
as `api_key`. 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: Example stdio client configuration:
```json ```json
+169 -14
View File
@@ -37,11 +37,13 @@ final class McpDispatcher
private RedmineClient $redmine; private RedmineClient $redmine;
private McpDebugLogger $logger; 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->redmine = $redmine;
$this->logger = $logger ?? new McpDebugLogger(null); $this->logger = $logger ?? new McpDebugLogger(null);
$this->sanitizeToolText = $sanitizeToolText;
} }
/** /**
@@ -252,7 +254,7 @@ final class McpDispatcher
'due_date' => ['type' => 'string'], 'due_date' => ['type' => 'string'],
'start_date' => ['type' => 'string'], 'start_date' => ['type' => 'string'],
'custom_fields' => ['type' => 'array', 'items' => ['type' => 'object']], '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']], 'is_private' => ['type' => ['boolean', 'string', 'integer']],
'estimated_hours' => ['type' => ['number', 'string', 'integer']], 'estimated_hours' => ['type' => ['number', 'string', 'integer']],
'done_ratio' => ['type' => ['integer', 'string']], 'done_ratio' => ['type' => ['integer', 'string']],
@@ -277,7 +279,7 @@ final class McpDispatcher
'start_date' => ['type' => 'string'], 'start_date' => ['type' => 'string'],
'private_notes' => ['type' => ['boolean', 'string', 'integer']], 'private_notes' => ['type' => ['boolean', 'string', 'integer']],
'custom_fields' => ['type' => 'array', 'items' => ['type' => 'object']], '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']], 'is_private' => ['type' => ['boolean', 'string', 'integer']],
'estimated_hours' => ['type' => ['number', 'string', 'integer']], 'estimated_hours' => ['type' => ['number', 'string', 'integer']],
'done_ratio' => ['type' => ['integer', 'string']], 'done_ratio' => ['type' => ['integer', 'string']],
@@ -376,10 +378,10 @@ final class McpDispatcher
$result = $this->findProject($this->stringArg($arguments, 'query'), $this->intArg($arguments, 'limit', 10)); $result = $this->findProject($this->stringArg($arguments, 'query'), $this->intArg($arguments, 'limit', 10));
break; break;
case 'redmine_get_project': 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; break;
case 'redmine_list_project_memberships': 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; break;
case 'redmine_list_users': case 'redmine_list_users':
$result = $this->redmine->listUsers(ListQueryNormalizer::userParams($arguments)); $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')); $result = $this->redmine->user($this->intArg($arguments, 'user_id'), $this->objectArg($arguments, 'params'));
break; break;
case 'redmine_list_issues': case 'redmine_list_issues':
$result = $this->redmine->filterIssues(ListQueryNormalizer::issueFilters($arguments)); $result = $this->redmine->filterIssues(ListQueryNormalizer::issueFilters($this->resolvedProjectArgument($arguments, 'redmine_list_issues')));
break; break;
case 'redmine_search': 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; break;
case 'redmine_search_issues': 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; break;
case 'redmine_get_issue': case 'redmine_get_issue':
$result = $this->redmine->issue($this->intArg($arguments, 'issue_id'), $this->stringListArg($arguments, 'include', ['journals', 'attachments'])); $result = $this->redmine->issue($this->intArg($arguments, 'issue_id'), $this->stringListArg($arguments, 'include', ['journals', 'attachments']));
@@ -430,19 +432,19 @@ final class McpDispatcher
); );
break; break;
case 'redmine_create_issue': case 'redmine_create_issue':
$result = $this->redmine->createIssue($this->issueFieldsArg($arguments)); $result = $this->redmine->createIssue($this->issueFieldsArg($arguments, 'redmine_create_issue'));
break; break;
case 'redmine_update_issue': 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; break;
case 'redmine_list_project_issue_categories': 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; break;
case 'redmine_get_issue_category': case 'redmine_get_issue_category':
$result = $this->redmine->issueCategory($this->intArg($arguments, 'category_id')); $result = $this->redmine->issueCategory($this->intArg($arguments, 'category_id'));
break; break;
case 'redmine_create_issue_category': 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; break;
case 'redmine_update_issue_category': case 'redmine_update_issue_category':
$result = $this->redmine->updateIssueCategory($this->intArg($arguments, 'category_id'), $this->objectArg($arguments, 'fields')); $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); 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) { if ($encoded === false) {
throw new RuntimeException('Could not encode tool result.'); throw new RuntimeException('Could not encode tool result.');
} }
@@ -501,7 +508,7 @@ final class McpDispatcher
* *
* @return array<string,mixed> * @return array<string,mixed>
*/ */
private function issueFieldsArg(array $arguments): array private function issueFieldsArg(array $arguments, string $toolName = ''): array
{ {
$fields = $this->objectArg($arguments, 'fields'); $fields = $this->objectArg($arguments, 'fields');
foreach (self::ISSUE_FIELD_ARGUMENT_KEYS as $key) { 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; 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> * @return array<string,mixed>
*/ */
@@ -758,4 +850,67 @@ final class McpDispatcher
'token', 'token',
], true); ], true);
} }
/**
* @param mixed $value
*
* @return mixed
*/
private function sanitizeToolResult($value, string $key = '')
{
if (is_string($value)) {
if (!$this->shouldSanitizeTextKey($key)) {
return $value;
}
return $this->sanitizeText($value);
}
if (!is_array($value)) {
return $value;
}
$sanitized = [];
foreach ($value as $childKey => $childValue) {
$sanitized[$childKey] = $this->sanitizeToolResult(
$childValue,
is_string($childKey) ? $childKey : ''
);
}
return $sanitized;
}
private function shouldSanitizeTextKey(string $key): bool
{
$normalized = strtolower(trim($key));
if ($normalized === '') {
return false;
}
return in_array($normalized, [
'description',
'notes',
'content',
'body',
'text',
'message',
'message_body',
'message_text',
'plain_text',
'plain_body',
'html_body',
], true);
}
private function sanitizeText(string $value): string
{
$value = str_replace(["\r\n", "\r"], "\n", $value);
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? $value;
$value = preg_replace('/\p{Cf}+/u', '', $value) ?? $value;
$value = preg_replace('/[^\S\n]{3,}/u', ' ', $value) ?? $value;
$value = preg_replace('/\n{4,}/u', "\n\n\n", $value) ?? $value;
$value = preg_replace('/([[:punct:]])\1{7,}/u', '$1$1$1$1$1$1', $value) ?? $value;
return $value;
}
} }
+23 -1
View File
@@ -9,7 +9,7 @@ use RuntimeException;
final class McpEnvironment 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 public static function load(string $envFile): array
{ {
@@ -24,6 +24,7 @@ final class McpEnvironment
'redmine_api_key' => $apiKey, 'redmine_api_key' => $apiKey,
'mcp_server_token' => self::optionalString(getenv('MCP_SERVER_TOKEN') ?: ($env['MCP_SERVER_TOKEN'] ?? null)), '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_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; 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;
}
} }
+9 -1
View File
@@ -7,6 +7,7 @@ namespace RedMCP;
final class McpStdioServer final class McpStdioServer
{ {
private McpDispatcher $dispatcher; private McpDispatcher $dispatcher;
private string $wireMode = 'content-length';
public function __construct(McpDispatcher $dispatcher) public function __construct(McpDispatcher $dispatcher)
{ {
@@ -36,7 +37,8 @@ final class McpStdioServer
if ($line === '') { if ($line === '') {
break; break;
} }
if (!str_contains($line, ':')) { if (!preg_match('/^[A-Za-z0-9-]+\s*:/', $line)) {
$this->wireMode = 'line';
$decoded = json_decode($line, true); $decoded = json_decode($line, true);
return is_array($decoded) ? $decoded : null; return is_array($decoded) ? $decoded : null;
} }
@@ -76,6 +78,12 @@ final class McpStdioServer
return; return;
} }
if ($this->wireMode === 'line') {
fwrite(STDOUT, $body . "\n");
fflush(STDOUT);
return;
}
fwrite(STDOUT, 'Content-Length: ' . strlen($body) . "\r\n\r\n" . $body); fwrite(STDOUT, 'Content-Length: ' . strlen($body) . "\r\n\r\n" . $body);
fflush(STDOUT); fflush(STDOUT);
} }
+2 -1
View File
@@ -22,7 +22,8 @@ if ($token === null) {
$handler = new McpHttpHandler( $handler = new McpHttpHandler(
new McpDispatcher( new McpDispatcher(
RedmineClient::fromCredentials($env['redmine_url'], $env['redmine_api_key']), 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, $token,
getenv('MCP_HTTP_PATH') ?: '/mcp' getenv('MCP_HTTP_PATH') ?: '/mcp'
+2 -1
View File
@@ -15,7 +15,8 @@ $env = McpEnvironment::load(__DIR__ . '/../.env');
$server = new McpStdioServer( $server = new McpStdioServer(
new McpDispatcher( new McpDispatcher(
RedmineClient::fromCredentials($env['redmine_url'], $env['redmine_api_key']), 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(); $server->run();
+97
View File
@@ -78,6 +78,10 @@ final class RedmineStructureTest
$this->testMcpFindProjectRecommendsExactIdentifier(); $this->testMcpFindProjectRecommendsExactIdentifier();
$this->testMcpFindProjectRecommendsExactName(); $this->testMcpFindProjectRecommendsExactName();
$this->testMcpFindProjectLeavesAmbiguousMatchesUnrecommended(); $this->testMcpFindProjectLeavesAmbiguousMatchesUnrecommended();
$this->testMcpGetProjectResolvesHumanProjectNameToIdentifier();
$this->testMcpGetProjectShowsHelpfulErrorForAmbiguousHumanName();
$this->testMcpSearchSanitizesNoisyTextFields();
$this->testMcpSearchCanDisableTextSanitization();
$this->testCreateRelationDefaultsToRelatesAndRequiresTarget(); $this->testCreateRelationDefaultsToRelatesAndRequiresTarget();
$this->testAttachmentUploadSupportsPathAndBase64(); $this->testAttachmentUploadSupportsPathAndBase64();
$this->testAttachmentUploadAcceptsPdfDataUrl(); $this->testAttachmentUploadAcceptsPdfDataUrl();
@@ -239,6 +243,88 @@ final class RedmineStructureTest
$this->assertSame('quality-archive', $result['matches'][1]['identifier'], 'second ambiguous match is returned'); $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 private function testCreateRelationDefaultsToRelatesAndRequiresTarget(): void
{ {
$http = new RecordingClient(); $http = new RecordingClient();
@@ -500,6 +586,17 @@ final class RedmineStructureTest
exit(1); 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 * @param array<int,string> $haystack
*/ */
+19
View File
@@ -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 ```text
REDMINE_URL=http://redmine.example.test REDMINE_URL=http://redmine.example.test
REDMINE_API_KEY=... REDMINE_API_KEY=...
MCP_TEXT_SANITIZATION=true
``` ```
For Streamable HTTP MCP: 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_list_project_issue_categories`, `redmine_get_issue_category`.
- `redmine_get_attachment`. - `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 ## Write Tools
- `redmine_create_issue`: create an issue. - `redmine_create_issue`: create an issue.
+208
View File
@@ -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())