Files
redmine/redMCP/README.md
T
Jason Thistlethwaite 22c8e915e9 Sanitize noisy MCP text fields by default
Clean control and invisible junk from tool result text fields to reduce token waste while preserving readable Unicode. Add an MCP_TEXT_SANITIZATION toggle and regression tests for enabled and disabled behavior.
2026-05-06 02:31:25 -04:00

11 KiB

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.

$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:

$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:

$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:

{
  "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:

$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.

{
  "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:

{
  "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:

$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.

$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.

redMCP/bin/redmcp-server.php

For local testing, run the Streamable HTTP server:

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:

redMCP/bin/generate-bearer-token.php --env-line

The network endpoint defaults to /mcp and requires:

Authorization: Bearer <MCP_SERVER_TOKEN>

Example Streamable HTTP request:

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:

{
  "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"
    }
  }
}
{
  "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:

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:

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:

{
  "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:

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.