https://github.com/zh/redmine_mcp_py
Python FastMCP HTTP server wrapping the Redmine REST API as MCP tools.
https://github.com/zh/redmine_mcp_py
Last synced: about 2 months ago
JSON representation
Python FastMCP HTTP server wrapping the Redmine REST API as MCP tools.
- Host: GitHub
- URL: https://github.com/zh/redmine_mcp_py
- Owner: zh
- License: mit
- Created: 2026-05-19T00:38:32.000Z (2 months ago)
- Default Branch: main
- Last Pushed: 2026-05-19T02:50:25.000Z (2 months ago)
- Last Synced: 2026-05-19T04:48:03.218Z (2 months ago)
- Language: Python
- Size: 47.9 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
- Security: SECURITY.md
Awesome Lists containing this project
README
---
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`](./CHANGELOG.md))
## Contents
- [Features](#features)
- [Quick start (local Docker)](#quick-start-local-docker)
- [Configuration](#configuration)
- [Deployment](#deployment)
- [Docker Compose](#docker-compose-local-or-self-hosted)
- [Hugging Face Spaces](#hugging-face-spaces)
- [Render](#render)
- [Fly.io, Cloud Run, Railway, plain VPS](#flyio-cloud-run-railway-plain-vps)
- [OAuth flow](#oauth-flow)
- [Adding the server to an MCP client](#adding-the-server-to-an-mcp-client)
- [Tools implemented](#tools-implemented)
- [Architecture](#architecture)
- [Security](#security)
- [Testing](#testing)
- [Roadmap](#roadmap)
- [License](#license)
## 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_*` requires
`confirm=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)
```bash
git clone && 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 --build
```
Open `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 |
|---|---|
| `REDMINE_MCP_SECRET` | 32+ random bytes; signs CSRF cookies. Generate with `python -c 'import secrets; print(secrets.token_urlsafe(32))'`. **A fresh per-process value is auto-generated when unset**, which means restarts invalidate in-flight CSRF cookies โ fine for dev, set it explicitly in production. |
### Recommended for production
| Variable | Default | Notes |
|---|---|---|
| `REDMINE_MCP_ALLOWED_HOSTS` | (empty) | CSV of Redmine hostnames the server is allowed to call. Empty = any public hostname (private/loopback IPs still blocked). The strongest SSRF defense. |
| `REDMINE_MCP_TRUST_PROXY` | `false` | Set `true` when behind a known reverse proxy (HF Spaces, Render, Caddy, nginx). Required for correct rate-limit attribution. |
| `REDMINE_MCP_REDIS_URL` | (empty) | e.g. `redis://redis:6379/0`. Enables the Redis-backed token store so sessions, codes, and rate counters survive restarts. |
| `REDMINE_MCP_FERNET_KEY` | (empty) | Required when `REDMINE_MCP_REDIS_URL` is set. Encrypts API keys at rest. Generate with `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'`. |
### Token lifetimes (seconds)
| Variable | Default | Notes |
|---|---|---|
| `REDMINE_MCP_TOKEN_TTL_SECONDS` | `86400` | Access-token TTL. |
| `REDMINE_MCP_REFRESH_TTL_SECONDS` | `2592000` (30d) | Refresh-token TTL. Rolls on each use. |
| `REDMINE_MCP_CODE_TTL_SECONDS` | `300` | Authorization-code TTL. |
| `REDMINE_MCP_CLIENT_TTL_SECONDS` | `2592000` (30d) | DCR client record TTL (Redis only). |
### Rate limits (per IP)
| Variable | Default |
|---|---|
| `REDMINE_MCP_RATE_LOGIN_PER_MIN` | `5` |
| `REDMINE_MCP_RATE_LOGIN_PER_HOUR` | `20` |
| `REDMINE_MCP_RATE_TOKEN_PER_MIN` | `10` |
| `REDMINE_MCP_RATE_REGISTER_PER_MIN` | `5` |
### Other knobs
| Variable | Default | Notes |
|---|---|---|
| `REDMINE_MCP_TIMEOUT_SECONDS` | `30` | Per-call timeout for outbound Redmine HTTP. |
| `REDMINE_MCP_ALLOWED_REDIRECTS` | (empty) | CSV of OAuth `redirect_uri` values for non-loopback web MCP clients. Loopback URIs (`http://127.0.0.1:*`, `localhost:*`, `[::1]:*`) are auto-allowed per RFC 8252. |
| `REDMINE_MCP_ALLOW_HTTP` | `false` | **Dev only.** Permits `http://` Redmine URLs, bare IP hosts, and drops the `Secure` flag from the CSRF cookie (needed for plain `http://localhost`). |
| `PORT` | `7860` | 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:
```bash
# In-memory store (default profile, ephemeral)
docker compose up --build
# Redis-backed store (sessions survive restart)
docker compose --profile redis up --build
```
The Redis profile starts a sibling `redis:7-alpine` with a persistent
volume and a healthcheck. Set the corresponding env vars first:
```env
REDMINE_MCP_REDIS_URL=redis://redis:6379/0
REDMINE_MCP_FERNET_KEY=
```
### 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`.
1. Push the repo to a Space with `sdk: docker`.
2. 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=` (recommended)
3. 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`](./render.yaml) for one-click
Blueprint deploys.
1. Push to GitHub/GitLab.
2. Render dashboard โ **New +** โ **Blueprint** โ connect the repo โ
**Apply**.
3. In the service's *Environment* tab, fill in the two `sync: false`
values:
- `REDMINE_MCP_ALLOWED_HOSTS` (recommended โ CSV of permitted
Redmine hostnames)
- `REDMINE_MCP_ALLOWED_REDIRECTS` (leave empty for desktop MCP
clients)
4. 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=
&redirect_uri=http://127.0.0.1:/callback
&code_challenge=
&code_challenge_method=S256
&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** | `list_projects`, `get_project`, `create_project`, `update_project`, `delete_project` |
| **Issues** | `list_issues`, `get_issue`, `create_issue`, `update_issue`, `delete_issue`, `copy_issue`, `move_issue`, `add_issue_watcher`, `remove_issue_watcher`, `get_issue_relations`, `create_issue_relation`, `delete_issue_relation`, `get_issue_journals` |
| **Users** | `list_users`, `get_user`, `create_user`, `update_user`, `delete_user` |
| **Time entries** | `list_time_entries`, `get_time_entry`, `create_time_entry`, `update_time_entry`, `delete_time_entry` |
| **Memberships** | `list_memberships`, `get_membership`, `create_membership`, `update_membership`, `delete_membership` |
| **Groups** | `list_groups`, `get_group`, `create_group`, `update_group`, `delete_group` |
| **Versions** | `list_versions`, `get_version`, `create_version`, `update_version`, `delete_version` |
| **Custom fields** | `list_custom_fields` |
| **Queries** | `list_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: โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
```
Key invariants:
- API keys are wrapped in `RedactedStr` (returns `'***'` from `repr`/
`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` (unless
`REDMINE_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`](./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
```bash
pip install -r requirements.txt
pip install pytest pytest-asyncio respx
pytest
```
Currently **60 tests** covering Phase 0 + Phase 1 (~2s on a laptop)
plus 2 opt-in Redis tests, run with:
```bash
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-test
```
## Roadmap
[`TODO.md`](./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.
## License
[MIT](./LICENSE)