{"id":50492062,"url":"https://github.com/zh/redmine_mcp_py","last_synced_at":"2026-06-02T03:31:00.964Z","repository":{"id":358780738,"uuid":"1242994117","full_name":"zh/redmine_mcp_py","owner":"zh","description":"Python FastMCP HTTP server wrapping the Redmine REST API as MCP tools.","archived":false,"fork":false,"pushed_at":"2026-05-19T02:50:25.000Z","size":49,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-19T04:48:03.218Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/zh.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-19T00:38:32.000Z","updated_at":"2026-05-19T02:50:29.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/zh/redmine_mcp_py","commit_stats":null,"previous_names":["zh/redmine_mcp_py"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/zh/redmine_mcp_py","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zh%2Fredmine_mcp_py","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zh%2Fredmine_mcp_py/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zh%2Fredmine_mcp_py/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zh%2Fredmine_mcp_py/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/zh","download_url":"https://codeload.github.com/zh/redmine_mcp_py/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zh%2Fredmine_mcp_py/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33805340,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-02T02:00:07.132Z","response_time":109,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-02T03:30:59.630Z","updated_at":"2026-06-02T03:31:00.954Z","avatar_url":"https://github.com/zh.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"---\ntitle: Redmine MCP\nemoji: 🔧\ncolorFrom: blue\ncolorTo: indigo\nsdk: docker\napp_port: 7860\npinned: false\n---\n\n# Redmine MCP OAuth Server\n\nA FastMCP HTTP server that exposes 53 Redmine REST endpoints as\nModel-Context-Protocol tools, behind an OAuth 2.1 Authorization Code\nflow with mandatory PKCE.\n\nThe user logs in with their **own Redmine URL + API key** — the server\nholds no second account. The API key is validated against\n`/users/current.json`, then mapped to a bearer token used by every\nsubsequent MCP tool call.\n\n**Current version: 1.7** ([`CHANGELOG.md`](./CHANGELOG.md))\n\n## Contents\n\n- [Features](#features)\n- [Quick start (local Docker)](#quick-start-local-docker)\n- [Configuration](#configuration)\n- [Deployment](#deployment)\n  - [Docker Compose](#docker-compose-local-or-self-hosted)\n  - [Hugging Face Spaces](#hugging-face-spaces)\n  - [Render](#render)\n  - [Fly.io, Cloud Run, Railway, plain VPS](#flyio-cloud-run-railway-plain-vps)\n- [OAuth flow](#oauth-flow)\n- [Adding the server to an MCP client](#adding-the-server-to-an-mcp-client)\n- [Tools implemented](#tools-implemented)\n- [Architecture](#architecture)\n- [Security](#security)\n- [Testing](#testing)\n- [Roadmap](#roadmap)\n- [License](#license)\n\n## Features\n\n- **OAuth 2.1** authorization-code flow with mandatory **PKCE S256**.\n- **RFC 7591 Dynamic Client Registration** so MCP clients can self-register.\n- **RFC 8252 loopback redirect URIs** auto-allowed (works with every\n  desktop MCP client out of the box).\n- **Refresh tokens with rotation** (RFC 6749 §6) — single-use, rolling\n  30-day TTL.\n- **RFC 7009 revocation** at `POST /oauth/revoke`.\n- **CSRF-protected login form** with `itsdangerous`-signed cookies.\n- **SSRF defense** on the user-supplied Redmine URL — DNS-resolved IPs\n  in private / loopback / link-local / multicast / reserved space are\n  refused. `httpx.AsyncClient(follow_redirects=False)` closes 302\n  bypasses.\n- **Per-IP rate limiting** on `/auth/login` (5/min + 20/hour),\n  `/oauth/token` (10/min), and `/oauth/register` (5/min).\n- **Pluggable token store** — in-memory by default; opt-in Redis\n  backend with **Fernet at-rest encryption** of API keys.\n- **Structured JSON audit log** to stdout, with per-request IDs.\n- **Sanitized upstream errors** — Redmine's raw response bodies (version\n  banners, plugin names, occasional stack traces) never reach the MCP\n  client. Just `Permission denied.` / `Not found.` /\n  `Upstream Redmine error.` / parsed 422 validation errors.\n- **Confirm gate** on every destructive tool (`delete_*` requires\n  `confirm=True`).\n- **Health \u0026 version endpoints** (`/healthz`, `/readyz`, `/version`).\n- **Portable Docker image** — runs unchanged on Hugging Face Spaces\n  (port 7860), Render, Fly.io, Cloud Run, and self-hosted Docker.\n\n## Quick start (local Docker)\n\n```bash\ngit clone \u003crepo\u003e \u0026\u0026 cd redmine_mcp_py\ncp .env.example .env\n\n# 1. Generate the secret that signs CSRF cookies\npython3 -c 'import secrets; print(secrets.token_urlsafe(32))'\n# Paste into .env as REDMINE_MCP_SECRET=...\n\n# 2. (Optional) generate a Fernet key if you want Redis persistence later\npython3 -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'\n\n# 3. Run it\ndocker compose up --build\n```\n\nOpen `http://localhost:8000/healthz` — should return `{\"status\": \"ok\"}`.\nThe MCP endpoint is `http://localhost:8000/mcp`.\n\n## Configuration\n\nAll knobs are env-var-driven. Defaults are safe for local dev and\nproduction alike, except where noted.\n\n### Required\n\n| Variable | Notes |\n|---|---|\n| `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. |\n\n### Recommended for production\n\n| Variable | Default | Notes |\n|---|---|---|\n| `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. |\n| `REDMINE_MCP_TRUST_PROXY` | `false` | Set `true` when behind a known reverse proxy (HF Spaces, Render, Caddy, nginx). Required for correct rate-limit attribution. |\n| `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. |\n| `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())'`. |\n\n### Token lifetimes (seconds)\n\n| Variable | Default | Notes |\n|---|---|---|\n| `REDMINE_MCP_TOKEN_TTL_SECONDS` | `86400` | Access-token TTL. |\n| `REDMINE_MCP_REFRESH_TTL_SECONDS` | `2592000` (30d) | Refresh-token TTL. Rolls on each use. |\n| `REDMINE_MCP_CODE_TTL_SECONDS` | `300` | Authorization-code TTL. |\n| `REDMINE_MCP_CLIENT_TTL_SECONDS` | `2592000` (30d) | DCR client record TTL (Redis only). |\n\n### Rate limits (per IP)\n\n| Variable | Default |\n|---|---|\n| `REDMINE_MCP_RATE_LOGIN_PER_MIN` | `5` |\n| `REDMINE_MCP_RATE_LOGIN_PER_HOUR` | `20` |\n| `REDMINE_MCP_RATE_TOKEN_PER_MIN` | `10` |\n| `REDMINE_MCP_RATE_REGISTER_PER_MIN` | `5` |\n\n### Other knobs\n\n| Variable | Default | Notes |\n|---|---|---|\n| `REDMINE_MCP_TIMEOUT_SECONDS` | `30` | Per-call timeout for outbound Redmine HTTP. |\n| `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. |\n| `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`). |\n| `PORT` | `7860` | Listen port. HF Spaces uses 7860; Render/Fly/Cloud Run inject their own. |\n\n## Deployment\n\n### Docker Compose (local or self-hosted)\n\n`docker-compose.yml` ships with sensible local-dev defaults. Two\nprofiles:\n\n```bash\n# In-memory store (default profile, ephemeral)\ndocker compose up --build\n\n# Redis-backed store (sessions survive restart)\ndocker compose --profile redis up --build\n```\n\nThe Redis profile starts a sibling `redis:7-alpine` with a persistent\nvolume and a healthcheck. Set the corresponding env vars first:\n\n```env\nREDMINE_MCP_REDIS_URL=redis://redis:6379/0\nREDMINE_MCP_FERNET_KEY=\u003cpaste-Fernet.generate_key()-output\u003e\n```\n\n### Hugging Face Spaces\n\nThe `README.md` frontmatter (top of this file) is HF metadata; HF reads\n`sdk: docker` and `app_port: 7860` and runs the bundled `Dockerfile`.\n\n1. Push the repo to a Space with `sdk: docker`.\n2. In *Settings → Variables and secrets*, set:\n   - `REDMINE_MCP_SECRET` (required — generate fresh)\n   - `REDMINE_MCP_TRUST_PROXY=true` (HF sits behind a proxy)\n   - `REDMINE_MCP_ALLOWED_HOSTS=\u003cyour-redmine-host\u003e` (recommended)\n3. Restart the Space.\n\n⚠ **HF Spaces free tier sleeps after 15 min idle** — cold start wipes\nthe in-memory token store, forcing users to reconnect. For always-on\nbehavior either upgrade to a paid tier or enable Redis (point\n`REDMINE_MCP_REDIS_URL` at an external Redis like Upstash, since HF\ndoesn't provide one).\n\n### Render\n\nThe repo includes [`render.yaml`](./render.yaml) for one-click\nBlueprint deploys.\n\n1. Push to GitHub/GitLab.\n2. Render dashboard → **New +** → **Blueprint** → connect the repo →\n   **Apply**.\n3. In the service's *Environment* tab, fill in the two `sync: false`\n   values:\n   - `REDMINE_MCP_ALLOWED_HOSTS` (recommended — CSV of permitted\n     Redmine hostnames)\n   - `REDMINE_MCP_ALLOWED_REDIRECTS` (leave empty for desktop MCP\n     clients)\n4. First build takes ~3 min. You get `https://redmine-mcp-XXXX.onrender.com`.\n\nThe blueprint defaults to `plan: free`, which **sleeps after 15 min\nidle** like HF. Change to `plan: starter` ($7/mo) for always-on, or\nkeep free and add Render's *Key Value* (Redis-compatible) addon for\npersistence across cold starts.\n\n### Fly.io, Cloud Run, Railway, plain VPS\n\nThe Dockerfile listens on `${PORT:-7860}` and runs as UID 1000 with a\nbaked-in `HEALTHCHECK`. It works unchanged on any platform that builds\na Dockerfile.\n\nMinimum env vars: `REDMINE_MCP_SECRET`, `REDMINE_MCP_TRUST_PROXY=true`\n(when behind a proxy), and ideally `REDMINE_MCP_ALLOWED_HOSTS`.\n\nFor self-hosted behind nginx / Caddy / Traefik, terminate TLS at the\nproxy and forward to the container's `${PORT}`. Add\n`proxy_set_header X-Forwarded-For $remote_addr;` (nginx) or equivalent\nso rate-limit attribution works.\n\n## OAuth flow\n\n```\nUser clicks \"Connect\" in Claude Desktop / Coworks / custom MCP client\n  ↓\nGET  /oauth/register              (DCR — client registers its redirect_uri)\n  ↓\nGET  /auth/authorize?response_type=code\n                    \u0026client_id=\u003cdcr-issued\u003e\n                    \u0026redirect_uri=http://127.0.0.1:\u003cport\u003e/callback\n                    \u0026code_challenge=\u003cS256(verifier)\u003e\n                    \u0026code_challenge_method=S256\n                    \u0026state=\u003ccsrf-state\u003e\n  → server returns the HTML login form + a CSRF cookie\n  ↓\nPOST /auth/login                  (user submits Redmine URL + API key)\n  → server validates creds against /users/current.json on that URL\n  → server mints a single-use auth code, 302s to redirect_uri?code=...\u0026state=...\n  ↓\nPOST /oauth/token                 (PKCE verification, code exchange)\n  → server returns {access_token, refresh_token, token_type, expires_in}\n  ↓\nEvery MCP tool call carries the access_token as Bearer\n  → looked up in the token store → Redmine API key extracted → upstream call\n  ↓\nPOST /oauth/token (grant_type=refresh_token) once the access token expires\n  → old refresh_token consumed atomically (GETDEL in Redis), new pair issued\n  ↓\nPOST /oauth/revoke (optional, on logout)\n```\n\nThe user only ever supplies their **own Redmine credentials** — no\nsecond account is required on the MCP server side.\n\n## Adding the server to an MCP client\n\n### Claude Desktop, Claude Code, Coworks, …\n\nSettings → Connectors → **Add custom connector** → URL =\n`https://your-server/mcp`.\n\nOAuth Client ID and Secret can be left blank — the server is a public\nOAuth client (`token_endpoint_auth_method: none`). The real credential\nis the user's Redmine API key, captured during `/auth/login`.\n\n### From the Anthropic API or other MCP SDKs\n\nUse the standard MCP `streamable-http` transport pointed at\n`https://your-server/mcp`. The SDK handles DCR + PKCE automatically.\n\n## Tools implemented\n\nAll 53 tools accept structured arguments and return raw Redmine JSON\n(or `{}` for `204 No Content` responses). Tools that mutate state\nrequire their respective Redmine role/permission. Destructive `delete_*`\ntools require `confirm=True`.\n\n| Category | Tools |\n|---|---|\n| **Projects** | `list_projects`, `get_project`, `create_project`, `update_project`, `delete_project` |\n| **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` |\n| **Users** | `list_users`, `get_user`, `create_user`, `update_user`, `delete_user` |\n| **Time entries** | `list_time_entries`, `get_time_entry`, `create_time_entry`, `update_time_entry`, `delete_time_entry` |\n| **Memberships** | `list_memberships`, `get_membership`, `create_membership`, `update_membership`, `delete_membership` |\n| **Groups** | `list_groups`, `get_group`, `create_group`, `update_group`, `delete_group` |\n| **Versions** | `list_versions`, `get_version`, `create_version`, `update_version`, `delete_version` |\n| **Custom fields** | `list_custom_fields` |\n| **Queries** | `list_queries` |\n\n`limit` parameters are clamped to 1..100 (Redmine's hard server-side\nmax). `create_issue_relation.relation_type`, `create_version.status`,\nand `create_version.sharing` use `typing.Literal` so the MCP tool\nschema surfaces the allowed values to the LLM.\n\n## Architecture\n\n```\n┌──────────────────────┐\n│  MCP client          │  Claude Desktop / Coworks / custom SDK\n│  (loopback callback) │\n└──────────┬───────────┘\n           │ HTTPS\n┌──────────▼─────────────────────────────────────────────────────────────┐\n│  FastAPI app (server.py)                                               │\n│  ┌──────────────────────────────────────────────────────────────────┐  │\n│  │  RequestIDMiddleware   →  contextvar request_id                   │  │\n│  │  SecurityHeadersMiddleware → HSTS, CSP, X-Frame-Options, …        │  │\n│  └──────────────────────────────────────────────────────────────────┘  │\n│  ┌─────────────────────────┐  ┌────────────────────────────────────┐   │\n│  │  auth/routes.py         │  │  53 MCP tools (FastMCP)            │   │\n│  │  /auth/authorize        │  │  list_issues, create_project, …    │   │\n│  │  /auth/login            │  │  call _redmine() → outbound httpx  │   │\n│  │  /oauth/token           │  │  with SSRF re-validation + audit   │   │\n│  │  /oauth/revoke          │  │                                    │   │\n│  │  /oauth/register        │  │                                    │   │\n│  └─────────────┬───────────┘  └────────────┬───────────────────────┘   │\n│                │                           │                           │\n│  ┌─────────────▼───────────────────────────▼───────────────────────┐   │\n│  │  auth/token_store.py — TokenStore Protocol                      │   │\n│  │  ┌─────────────────────┐    ┌────────────────────────────────┐  │   │\n│  │  │ InMemoryTokenStore  │ OR │ RedisTokenStore                │  │   │\n│  │  │ (default)           │    │ (Fernet-encrypted API keys)    │  │   │\n│  │  └─────────────────────┘    └────────────────────────────────┘  │   │\n│  └─────────────────────────────────────────────────────────────────┘   │\n│  ┌─────────────────────────────────────────────────────────────────┐   │\n│  │  audit.py — one-line JSON to stdout                             │   │\n│  │  login_ok / login_rejected / token_issued / token_refreshed /   │   │\n│  │  token_revoked / rate_limit_exceeded / redmine_call             │   │\n│  └─────────────────────────────────────────────────────────────────┘   │\n└────────────────────────────────────────────────────────────────────────┘\n                                            │\n                                            │ httpx, follow_redirects=False\n                                            ▼\n                              ┌───────────────────────────────┐\n                              │  User's Redmine instance      │\n                              │  X-Redmine-API-Key: \u003csecret\u003e  │\n                              └───────────────────────────────┘\n```\n\nKey invariants:\n\n- API keys are wrapped in `RedactedStr` (returns `'***'` from `repr`/\n  `str`); they only reach the outbound HTTP path via `.reveal()`.\n- Every outbound URL is re-validated against the SSRF block-list on\n  every call, not just at login.\n- The CSRF cookie is `HttpOnly`, `SameSite=Lax`, `Secure` (unless\n  `REDMINE_MCP_ALLOW_HTTP=true`), path-scoped to `/auth/`.\n- Audit records never contain API keys, query params, request bodies,\n  or response bodies — only method, path, status, latency, and the\n  operator's Redmine login.\n\n## Security\n\n[`SECURITY.md`](./SECURITY.md) documents the threat model, every\ndefect found during the Phase 0 review, the fix, and the regression\ntest that pins it.\n\nVulnerabilities should be reported privately to the maintainer rather\nthan as a public GitHub issue.\n\n## Testing\n\n```bash\npip install -r requirements.txt\npip install pytest pytest-asyncio respx\npytest\n```\n\nCurrently **60 tests** covering Phase 0 + Phase 1 (~2s on a laptop)\nplus 2 opt-in Redis tests, run with:\n\n```bash\ndocker run --rm -d -p 6379:6379 --name redis-test redis:7-alpine\nREDMINE_MCP_TEST_REDIS_URL=redis://localhost:6379/15 pytest\ndocker stop redis-test\n```\n\n## Roadmap\n\n[`TODO.md`](./TODO.md) tracks remaining work. Phase 0 (critical\nsecurity) and Phase 1 (high-priority hardening) are complete. Phase 2\n(reliability + code quality: modularization, shared httpx client,\nretries, CI, ruff/mypy) and Phase 3 (operability: Prometheus metrics,\npydantic-settings, multi-stage Dockerfile) are the next chunks.\n\n## License\n\n[MIT](./LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fzh%2Fredmine_mcp_py","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fzh%2Fredmine_mcp_py","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fzh%2Fredmine_mcp_py/lists"}