Redmine MCP OAuth Server
title: Redmine MCP emoji: ๐ง colorFrom: blue colorTo: indigo sdk: docker app_port: 7860 pinned: false
Redmine MCP OAuth Server
A FastMCP HTTP server that exposes 53 Redmine REST endpoints as Model-Context-Protocol tools, behind an OAuth 2.1 Authorization Code flow with mandatory PKCE.
The user logs in with their own Redmine URL + API key โ the server
holds no second account. The API key is validated against
/users/current.json, then mapped to a bearer token used by every
subsequent MCP tool call.
Current version: 1.7 (CHANGELOG.md)
Contents
Features
OAuth 2.1 authorization-code flow with mandatory PKCE S256.
RFC 7591 Dynamic Client Registration so MCP clients can self-register.
RFC 8252 loopback redirect URIs auto-allowed (works with every desktop MCP client out of the box).
Refresh tokens with rotation (RFC 6749 ยง6) โ single-use, rolling 30-day TTL.
RFC 7009 revocation at
POST /oauth/revoke.CSRF-protected login form with
itsdangerous-signed cookies.SSRF defense on the user-supplied Redmine URL โ DNS-resolved IPs in private / loopback / link-local / multicast / reserved space are refused.
httpx.AsyncClient(follow_redirects=False)closes 302 bypasses.Per-IP rate limiting on
/auth/login(5/min + 20/hour),/oauth/token(10/min), and/oauth/register(5/min).Pluggable token store โ in-memory by default; opt-in Redis backend with Fernet at-rest encryption of API keys.
Structured JSON audit log to stdout, with per-request IDs.
Sanitized upstream errors โ Redmine's raw response bodies (version banners, plugin names, occasional stack traces) never reach the MCP client. Just
Permission denied./Not found./Upstream Redmine error./ parsed 422 validation errors.Confirm gate on every destructive tool (
delete_*requiresconfirm=True).Health & version endpoints (
/healthz,/readyz,/version).Portable Docker image โ runs unchanged on Hugging Face Spaces (port 7860), Render, Fly.io, Cloud Run, and self-hosted Docker.
Quick start (local Docker)
git clone <repo> && cd redmine_mcp_py
cp .env.example .env
# 1. Generate the secret that signs CSRF cookies
python3 -c 'import secrets; print(secrets.token_urlsafe(32))'
# Paste into .env as REDMINE_MCP_SECRET=...
# 2. (Optional) generate a Fernet key if you want Redis persistence later
python3 -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'
# 3. Run it
docker compose up --buildOpen http://localhost:8000/healthz โ should return {"status": "ok"}.
The MCP endpoint is http://localhost:8000/mcp.
Configuration
All knobs are env-var-driven. Defaults are safe for local dev and production alike, except where noted.
Required
Variable | Notes |
| 32+ random bytes; signs CSRF cookies. Generate with |
Recommended for production
Variable | Default | Notes |
| (empty) | CSV of Redmine hostnames the server is allowed to call. Empty = any public hostname (private/loopback IPs still blocked). The strongest SSRF defense. |
|
| Set |
| (empty) | e.g. |
| (empty) | Required when |
Token lifetimes (seconds)
Variable | Default | Notes |
|
| Access-token TTL. |
|
| Refresh-token TTL. Rolls on each use. |
|
| Authorization-code TTL. |
|
| DCR client record TTL (Redis only). |
Rate limits (per IP)
Variable | Default |
|
|
|
|
|
|
|
|
Other knobs
Variable | Default | Notes |
|
| Per-call timeout for outbound Redmine HTTP. |
| (empty) | CSV of OAuth |
|
| Dev only. Permits |
|
| Listen port. HF Spaces uses 7860; Render/Fly/Cloud Run inject their own. |
Deployment
Docker Compose (local or self-hosted)
docker-compose.yml ships with sensible local-dev defaults. Two
profiles:
# In-memory store (default profile, ephemeral)
docker compose up --build
# Redis-backed store (sessions survive restart)
docker compose --profile redis up --buildThe Redis profile starts a sibling redis:7-alpine with a persistent
volume and a healthcheck. Set the corresponding env vars first:
REDMINE_MCP_REDIS_URL=redis://redis:6379/0
REDMINE_MCP_FERNET_KEY=<paste-Fernet.generate_key()-output>Hugging Face Spaces
The README.md frontmatter (top of this file) is HF metadata; HF reads
sdk: docker and app_port: 7860 and runs the bundled Dockerfile.
Push the repo to a Space with
sdk: docker.In Settings โ Variables and secrets, set:
REDMINE_MCP_SECRET(required โ generate fresh)REDMINE_MCP_TRUST_PROXY=true(HF sits behind a proxy)REDMINE_MCP_ALLOWED_HOSTS=<your-redmine-host>(recommended)
Restart the Space.
โ HF Spaces free tier sleeps after 15 min idle โ cold start wipes
the in-memory token store, forcing users to reconnect. For always-on
behavior either upgrade to a paid tier or enable Redis (point
REDMINE_MCP_REDIS_URL at an external Redis like Upstash, since HF
doesn't provide one).
Render
The repo includes render.yaml for one-click
Blueprint deploys.
Push to GitHub/GitLab.
Render dashboard โ New + โ Blueprint โ connect the repo โ Apply.
In the service's Environment tab, fill in the two
sync: falsevalues:REDMINE_MCP_ALLOWED_HOSTS(recommended โ CSV of permitted Redmine hostnames)REDMINE_MCP_ALLOWED_REDIRECTS(leave empty for desktop MCP clients)
First build takes ~3 min. You get
https://redmine-mcp-XXXX.onrender.com.
The blueprint defaults to plan: free, which sleeps after 15 min
idle like HF. Change to plan: starter ($7/mo) for always-on, or
keep free and add Render's Key Value (Redis-compatible) addon for
persistence across cold starts.
Fly.io, Cloud Run, Railway, plain VPS
The Dockerfile listens on ${PORT:-7860} and runs as UID 1000 with a
baked-in HEALTHCHECK. It works unchanged on any platform that builds
a Dockerfile.
Minimum env vars: REDMINE_MCP_SECRET, REDMINE_MCP_TRUST_PROXY=true
(when behind a proxy), and ideally REDMINE_MCP_ALLOWED_HOSTS.
For self-hosted behind nginx / Caddy / Traefik, terminate TLS at the
proxy and forward to the container's ${PORT}. Add
proxy_set_header X-Forwarded-For $remote_addr; (nginx) or equivalent
so rate-limit attribution works.
OAuth flow
User clicks "Connect" in Claude Desktop / Coworks / custom MCP client
โ
GET /oauth/register (DCR โ client registers its redirect_uri)
โ
GET /auth/authorize?response_type=code
&client_id=<dcr-issued>
&redirect_uri=http://127.0.0.1:<port>/callback
&code_challenge=<S256(verifier)>
&code_challenge_method=S256
&state=<csrf-state>
โ server returns the HTML login form + a CSRF cookie
โ
POST /auth/login (user submits Redmine URL + API key)
โ server validates creds against /users/current.json on that URL
โ server mints a single-use auth code, 302s to redirect_uri?code=...&state=...
โ
POST /oauth/token (PKCE verification, code exchange)
โ server returns {access_token, refresh_token, token_type, expires_in}
โ
Every MCP tool call carries the access_token as Bearer
โ looked up in the token store โ Redmine API key extracted โ upstream call
โ
POST /oauth/token (grant_type=refresh_token) once the access token expires
โ old refresh_token consumed atomically (GETDEL in Redis), new pair issued
โ
POST /oauth/revoke (optional, on logout)The user only ever supplies their own Redmine credentials โ no second account is required on the MCP server side.
Adding the server to an MCP client
Claude Desktop, Claude Code, Coworks, โฆ
Settings โ Connectors โ Add custom connector โ URL =
https://your-server/mcp.
OAuth Client ID and Secret can be left blank โ the server is a public
OAuth client (token_endpoint_auth_method: none). The real credential
is the user's Redmine API key, captured during /auth/login.
From the Anthropic API or other MCP SDKs
Use the standard MCP streamable-http transport pointed at
https://your-server/mcp. The SDK handles DCR + PKCE automatically.
Tools implemented
All 53 tools accept structured arguments and return raw Redmine JSON
(or {} for 204 No Content responses). Tools that mutate state
require their respective Redmine role/permission. Destructive delete_*
tools require confirm=True.
Category | Tools |
Projects |
|
Issues |
|
Users |
|
Time entries |
|
Memberships |
|
Groups |
|
Versions |
|
Custom fields |
|
Queries |
|
limit parameters are clamped to 1..100 (Redmine's hard server-side
max). create_issue_relation.relation_type, create_version.status,
and create_version.sharing use typing.Literal so the MCP tool
schema surfaces the allowed values to the LLM.
Architecture
โโโโโโโโโโโโโโโโโโโโโโโโ
โ MCP client โ Claude Desktop / Coworks / custom SDK
โ (loopback callback) โ
โโโโโโโโโโโโฌโโโโโโโโโโโโ
โ HTTPS
โโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ FastAPI app (server.py) โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ RequestIDMiddleware โ contextvar request_id โ โ
โ โ SecurityHeadersMiddleware โ HSTS, CSP, X-Frame-Options, โฆ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ auth/routes.py โ โ 53 MCP tools (FastMCP) โ โ
โ โ /auth/authorize โ โ list_issues, create_project, โฆ โ โ
โ โ /auth/login โ โ call _redmine() โ outbound httpx โ โ
โ โ /oauth/token โ โ with SSRF re-validation + audit โ โ
โ โ /oauth/revoke โ โ โ โ
โ โ /oauth/register โ โ โ โ
โ โโโโโโโโโโโโโโโฌโโโโโโโโโโโโ โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ โ
โ โโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ auth/token_store.py โ TokenStore Protocol โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ InMemoryTokenStore โ OR โ RedisTokenStore โ โ โ
โ โ โ (default) โ โ (Fernet-encrypted API keys) โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ audit.py โ one-line JSON to stdout โ โ
โ โ login_ok / login_rejected / token_issued / token_refreshed / โ โ
โ โ token_revoked / rate_limit_exceeded / redmine_call โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โ httpx, follow_redirects=False
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ User's Redmine instance โ
โ X-Redmine-API-Key: <secret> โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโKey invariants:
API keys are wrapped in
RedactedStr(returns'***'fromrepr/str); they only reach the outbound HTTP path via.reveal().Every outbound URL is re-validated against the SSRF block-list on every call, not just at login.
The CSRF cookie is
HttpOnly,SameSite=Lax,Secure(unlessREDMINE_MCP_ALLOW_HTTP=true), path-scoped to/auth/.Audit records never contain API keys, query params, request bodies, or response bodies โ only method, path, status, latency, and the operator's Redmine login.
Security
SECURITY.md documents the threat model, every
defect found during the Phase 0 review, the fix, and the regression
test that pins it.
Vulnerabilities should be reported privately to the maintainer rather than as a public GitHub issue.
Testing
pip install -r requirements.txt
pip install pytest pytest-asyncio respx
pytestCurrently 60 tests covering Phase 0 + Phase 1 (~2s on a laptop) plus 2 opt-in Redis tests, run with:
docker run --rm -d -p 6379:6379 --name redis-test redis:7-alpine
REDMINE_MCP_TEST_REDIS_URL=redis://localhost:6379/15 pytest
docker stop redis-testRoadmap
TODO.md tracks remaining work. Phase 0 (critical
security) and Phase 1 (high-priority hardening) are complete. Phase 2
(reliability + code quality: modularization, shared httpx client,
retries, CI, ruff/mypy) and Phase 3 (operability: Prometheus metrics,
pydantic-settings, multi-stage Dockerfile) are the next chunks.