Skip to content

Repository files navigation

redmine-mcp

ci license: MIT python container

A stateless Model Context Protocol server for Redmine. Drop it in front of any Redmine instance and let Claude (or any MCP-aware LLM client) read and write issues, log time, browse the wiki, and search projects on behalf of the user, with each user's own API key.

  • Zero state: no database, no sessions, no shared secret. The server forwards each request to Redmine using the API key that came in with it.
  • One server, many users: the only thing the operator configures is the upstream REDMINE_URL. Each MCP client supplies its own Redmine API key as a Bearer token. Permissions are whatever Redmine says they are.
  • Coverage: issues (CRUD + notes), projects, memberships, users, time entries, wiki (PUT-upsert), attachments (upload + download), full-text search, statuses, trackers, categories, custom fields, enumerations.

Quick start

Local (uv)

uv sync
REDMINE_URL=https://redmine.example.com uv run redmine-mcp

The MCP endpoint is now at http://127.0.0.1:8080/mcp.

Container (GHCR)

docker run --rm -p 8080:8080 \
  -e REDMINE_URL=https://redmine.example.com \
  ghcr.io/kalvadtech/redmine-mcp:latest

The image is multi-stage Alpine, runs as a non-root user, and exposes 8080.

Wire it into your MCP client

Find your Redmine API key in My account > API access key. The key goes in the standard Authorization: Bearer header; both clients below talk to the same MCP endpoint over Streamable HTTP.

Claude Code

.mcp.json in your project root:

{
  "mcpServers": {
    "redmine": {
      "type": "http",
      "url": "http://127.0.0.1:8080/mcp",
      "headers": {
        "Authorization": "Bearer your-40-char-key"
      }
    }
  }
}

opencode

opencode.json in your project root (or your opencode config directory):

{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "redmine": {
      "type": "remote",
      "url": "http://127.0.0.1:8080/mcp",
      "enabled": true,
      "headers": {
        "Authorization": "Bearer {env:REDMINE_API_KEY}"
      }
    }
  }
}

opencode supports {env:VAR} interpolation in headers, so the API key stays out of the config file: REDMINE_API_KEY=... opencode.

The legacy X-Redmine-API-Key header is still accepted. If both headers are sent, the Bearer token wins.

Configuration

Variable Required Default Purpose
REDMINE_URL yes - Base URL of the Redmine instance (http or https).
MCP_ALLOWED_HOSTS no - Comma-separated list of Host headers this MCP server will accept (DNS-rebinding protection). Required when running behind a reverse proxy with a public hostname (Invalid Host header warnings otherwise). Bare entries also match host:*. Set to * to disable protection when the proxy already enforces hostnames. Empty keeps localhost-only defaults.

CLI flags on redmine-mcp:

--host        bind address (default 127.0.0.1)
--port        port (default 8080)
--log-level   uvicorn log level (default info)

The server fails fast on boot if REDMINE_URL is missing or not http(s).

Authentication model

The Redmine URL is not user-supplied; it is fixed per deployment via REDMINE_URL. This eliminates SSRF risk: clients cannot point the server at arbitrary hosts.

The only credential a client sends is its own Redmine API key, normally in the Authorization: Bearer <key> header (the legacy X-Redmine-API-Key header is accepted as a fallback). The server forwards it to Redmine as X-Redmine-API-Key (so it does not land in Redmine access logs as a query string), uses it to make one request, then discards it. There is no caching, no shared service account, no impersonation.

Requests without valid credentials get a 401 with a WWW-Authenticate: Bearer header and a JSON-RPC error body, so strict MCP clients can surface the auth requirement properly.

Tools

59 tools cover every resource family Redmine exposes through its REST API. List tools return {items, total_count, limit, offset} for easy paging.

Issues

  • list_issues (project, status, assignee, tracker, category, version, saved query, free-text query, sort, include)
  • get_issue (include: journals, attachments, relations, children, watchers)
  • create_issue (incl. uploads tokens, custom_fields, watcher_user_ids)
  • update_issue (incl. notes, private_notes)
  • add_issue_note (thin wrapper)
  • delete_issue

Issue relations

  • list_issue_relations, get_relation, create_issue_relation (relates / blocks / precedes / etc., with delay for precedes/follows), delete_relation

Journals (issue notes)

  • update_journal_note - edit an existing note's text or privacy. Use add_issue_note to add a new note.

Projects and versions

  • list_projects, get_project
  • list_versions, get_version, create_version, update_version, delete_version

Memberships

  • list_memberships, get_membership, add_project_member (user or group), update_membership, remove_membership

Users, my account, groups, roles

  • list_users, get_user (accepts the literal string "current")
  • get_my_account, update_my_account
  • list_groups, get_group, create_group, update_group, delete_group, add_user_to_group, remove_user_from_group
  • list_roles, get_role (with permissions)

Time entries

  • list_time_entries (date ranges, user, project, issue)
  • create_time_entry (issue or project, hours, activity, spent_on, comments)
  • update_time_entry, delete_time_entry

Wiki

  • list_wiki_pages, get_wiki_page (with version)
  • create_or_update_wiki_page (PUT-upsert)
  • delete_wiki_page

Attachments and files

  • get_attachment, download_attachment (capped at 25 MiB, base64 out)
  • upload_attachment (returns a token to attach via uploads on create_issue / update_issue)
  • list_files, upload_file (one-shot upload + attach to a project's Files area, with optional version_id and description)

News, queries, search and metadata

  • list_news (cross-project or per-project)
  • list_queries (saved queries; pass the id as query_id to list_issues)
  • search (full-text across issues, news, documents, wiki, etc.)
  • list_issue_statuses, list_trackers, list_issue_categories, list_custom_fields
  • list_issue_priorities, list_time_entry_activities, list_document_categories

Deployment

For a single Redmine instance, run one container per environment:

docker run -d --name redmine-mcp \
  --restart unless-stopped \
  -p 8080:8080 \
  -e REDMINE_URL=https://redmine.example.com \
  ghcr.io/kalvadtech/redmine-mcp:latest

Place it behind your usual reverse proxy and TLS termination. The MCP protocol is HTTP-only on the server side; users get TLS via your proxy.

For multiple Redmine instances, run one container per instance with its own REDMINE_URL and route by hostname / path at the proxy.

Development

uv sync
uv run pytest
uv run ruff check .
uv run mypy src

The test suite is fully offline and uses respx to mock the Redmine HTTP API. There are no integration tests against a live Redmine; bring-your-own.

Contributing

Pull requests are welcome. Please:

  • follow Conventional Commits,
  • keep changes terse and well-scoped (one logical change per commit),
  • add or update tests for any behaviour change,
  • run pytest, ruff, and mypy clean before opening the PR.

License

MIT (c) 2026 Kalvad.

About

Stateless MCP server for Redmine. Each user brings their own API key; no database, no shared secret.

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages