An open API service indexing awesome lists of open source software.

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.

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)