Files
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

352 lines
12 KiB
Markdown

This project is for creating a simple library and MCP server for handling Redmine, particularly for when Redmine is being used for customer service/support.
This is a private project for now, as it pertains to an installation of Redmine 3.4.4-stable used by LDR, which also uses some outdated plugins. Notably, the outdated pluggins in use are both from Redmine Up (contacts, helpdesk/crm).
This is about creating the basic tools that an agent would need in order to interact with and automate LDR's redmine communications. Some of the functionality will extend beyond Redmine.
## Notable issues to be aware of
Projects which have the modules "contacts" or "contacts_helpdesk" enabled have the Helpdesk plugins enabled. That means a few things:
The regular API call to fetch an issue will not necessarily return the helpdesk information. Instead, it may claim that the issue's author or journals were created by "anonymous". When getting these issues, we need to use a different way to handle it.
The project in /home/iadnah/redmine/ is related to this problem.
## Client shape
`RedMCP\RedmineClient` wraps the normal Redmine REST client and composes
Helpdesk context in application logic instead of modifying Redmine's core issue
API.
```php
$client = RedMCP\RedmineClient::fromCredentials('http://192.168.50.170', $apiKey);
$context = $client->issueWithHelpdesk(39858);
```
The returned array has:
- `issue`: the normal `/issues/:id.json` response
- `helpdesk.ticket`: metadata from `/helpdesk_search/issues/:id/ticket`, or
`null`
- `helpdesk.journal_messages`: metadata from
`/helpdesk_search/issues/:id/messages`
Basic issue CRUD is exposed on the same wrapper:
```php
$issues = $client->issues(['project_id' => 'customer-service', 'status_id' => 'open', 'limit' => 10]);
$filtered = $client->filterIssues(['query_id' => 12, 'limit' => 25]);
$issue = $client->issue(39858);
$created = $client->createIssue([
'project_id' => 78,
'subject' => 'Example issue from redMCP',
'description' => 'Created through the Redmine REST API wrapper.',
]);
$client->updateIssue((int) $created['id'], ['notes' => 'Follow-up note']);
```
Native Redmine search is exposed separately from issue filtering. Use
`filterIssues()` or `issues()` when you already know the structured filters.
Use `search()` or `searchIssues()` when you want Redmine's built-in text search:
```php
$results = $client->search('power supply', [
'all_words' => '1',
'limit' => 10,
]);
$issueResults = $client->searchIssues('power supply', [
'project_id' => 'customer-service',
'open_issues' => '1',
'limit' => 10,
]);
```
MCP list tools also accept friendly top-level query options so callers do not
need to know Redmine's raw parameter syntax. These are normalized into Redmine
params before the request is sent:
```json
{
"name": "redmine_list_issues",
"arguments": {
"project_id": "customer-service",
"status": "open",
"updated": "last 7 days",
"sort": "newest",
"limit": 25
}
}
```
Friendly paging uses `limit`, `page`, and `offset`; the default limit is 25 and
the maximum is 100. Sort shortcuts include `newest`, `recent`, `oldest`,
`created_newest`, `created_oldest`, and `priority`. Issue date filters accept
exact dates, ranges such as `2026-04-01..2026-04-25`, objects with `from`/`to`,
phrases such as `since 2026-04-01`, and common presets such as `today`,
`yesterday`, `last 7 days`, `this_week`, and `last_month`. Raw `filters` or
`params` remain available and override friendly fields on conflict.
Project and user discovery is read-only:
```php
$projects = $client->projects(['limit' => 25]);
$project = $client->project('fud-helpdesk', ['include' => 'trackers,enabled_modules']);
$members = $client->projectMemberships('fud-helpdesk');
$users = $client->users(['status' => 1, 'limit' => 25]);
$user = $client->user(1, ['include' => 'memberships,groups']);
```
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",
"arguments": {
"query": "Quality Tracker"
}
}
```
Use `matches[0].project_id_to_use` or `recommended_project_id` when it is
non-null in later calls:
```json
{
"name": "redmine_create_issue",
"arguments": {
"project_id": "quality-tracker",
"subject": "Front warehouse deadbolt key gets stuck in lock",
"description": "Problem: The deadbolt on the front door is sticking.\n\n~HermesBot"
}
}
```
`updateIssue()` is intentionally safe by default: on Helpdesk-backed issues, a
normal Redmine note does **not** send an email to the customer. To send through
the Helpdesk plugin, opt in explicitly:
```php
$client->updateIssue(39858, [
'notes' => 'Customer-visible response.',
], [
'send_helpdesk_email' => true,
]);
// Equivalent explicit form:
$client->sendHelpdeskIssueResponse(39858, 'Customer-visible response.');
```
Use the default non-email update for internal notes, status/category/assignee
changes, and automation cleanup. Use the Helpdesk email path only when the
caller deliberately wants the customer to receive mail.
Issue structure operations are exposed explicitly. Issue create/update preserve
Redmine structure fields such as `parent_issue_id`, `parent_id`,
`category_id`, and `uploads`, so callers can create subtasks, categorize issues,
and attach previously uploaded files without falling through the bundled API
client's sanitized XML helpers.
```php
$upload = $client->uploadAttachment([
'path' => '/tmp/redmine-note.txt',
'content_type' => 'text/plain',
]);
$pdfUpload = $client->uploadAttachment([
'data_url' => 'data:application/pdf;base64,...',
]);
$fileEnvelopeUpload = $client->uploadAttachment([
'file' => [
'name' => 'quote.pdf',
'mime_type' => 'application/pdf',
'data' => 'JVBERi0xLjQK...',
],
]);
$parent = $client->createIssue([
'project_id' => 'fud-nohelpdesk',
'subject' => 'Parent example',
]);
$child = $client->createIssue([
'project_id' => 'fud-nohelpdesk',
'subject' => 'Child example',
'parent_issue_id' => (int) $parent['id'],
'uploads' => [$upload],
]);
$client->createIssueRelation((int) $parent['id'], [
'issue_to_id' => (int) $child['id'],
]);
```
The MCP server exposes explicit tools for issue relations, children/parents,
project issue categories, and attachments. It intentionally does not expose
tools for deleting issues, projects, users, categories, or attachments. The only
removal tool is `redmine_remove_issue_relation`, which unlinks the relationship
only and does not delete either issue.
For MCP attachment uploads, prefer `redmine_upload_attachment` with `path`,
`base64_content`, `data_url`, or a `file` envelope. PDFs and other non-image
files should be passed as file/data URL inputs such as
`data:application/pdf;base64,...`, not as `image_url`.
## MCP server
`redMCP` can run as either a stdio MCP server or a network MCP server. It reads
Redmine credentials from environment variables or `redMCP/.env`.
```sh
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
MCP_SERVER_TOKEN=test-token redMCP/bin/redmcp-http-server.php --port 8765
```
For LAN testing, pass `--host 0.0.0.0` deliberately. Browser-origin requests
from non-localhost origins require `MCP_ALLOWED_ORIGINS` as a comma-separated
allowlist.
Generate a bearer token with:
```sh
redMCP/bin/generate-bearer-token.php --env-line
```
The network endpoint defaults to `/mcp` and requires:
```text
Authorization: Bearer <MCP_SERVER_TOKEN>
```
Example Streamable HTTP request:
```sh
curl -sS \
-H 'Authorization: Bearer test-token' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
http://127.0.0.1:8765/mcp \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```
When the request `Accept` header includes `text/event-stream`, redMCP returns a
short SSE response with one `message` event per JSON-RPC response. Clients that
send only `Accept: application/json` receive the traditional JSON response.
`GET /mcp` returns `405 Method Not Allowed` with `Allow: POST`; redMCP does not
currently expose standalone server-to-client notification streams.
Issue create and update tools accept either canonical nested `fields` or common
issue fields at the top level. These two create calls are equivalent:
```json
{
"name": "redmine_create_issue",
"arguments": {
"fields": {
"project_id": "quality-tracker",
"subject": "Front warehouse deadbolt key gets stuck in lock",
"description": "Problem: The deadbolt on the front door is sticking.\n\n~HermesBot"
}
}
}
```
```json
{
"name": "redmine_create_issue",
"arguments": {
"project_id": "quality-tracker",
"subject": "Front warehouse deadbolt key gets stuck in lock",
"description": "Problem: The deadbolt on the front door is sticking.\n\n~HermesBot"
}
}
```
HTTP server process helpers:
```sh
redMCP/bin/redmcp-http-server.php --status
redMCP/bin/redmcp-http-server.php --stop
redMCP/bin/redmcp-http-server.php --pid-file /tmp/redmcp-http-server.pid --status
```
The default PID file is `/tmp/redmcp-http-server.pid`. A second server start
fails if the PID file points to a live process. Use `--force` only to replace a
stale PID file.
Debug logging is disabled by default. To record full MCP params/tool arguments
as JSONL during local testing:
```sh
MCP_SERVER_TOKEN=test-token redMCP/bin/redmcp-http-server.php \
--debug-log /tmp/redmcp-mcp.log
```
Debug logs may include customer text, issue notes, search terms, email content,
and IDs. Authorization headers, bearer tokens, and Redmine API keys are not
logged. 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
{
"mcpServers": {
"redmcp": {
"command": "/home/iadnah/redmine/redMCP/bin/redmcp-server.php"
}
}
}
```
Both transports expose tools for native Redmine project listing/detail, project
memberships, users, filtering/search, issue create/update, issue relations,
subtasks/parents, project issue categories, attachments, Helpdesk-aware issue
reads, and explicit Helpdesk email responses. Tools that can send
customer-visible mail require an explicit tool call such as
`redmine_send_helpdesk_response` or `redmine_update_issue` with
`send_helpdesk_email=true`.
Run the local no-network query normalizer checks with:
```sh
php redMCP/bin/test-query-normalizer.php
php redMCP/bin/test-redmine-structure.php
php redMCP/bin/test-mcp-http-handler.php
```
## Test instance
A working test copy of Redmine is available on the LAN at `192.168.50.170`.
The related Redmine plugin forks, helper scripts, and operational docs live in
the parent repository.