Skip to main content
Glama
mieweb

redmine-mcp-proxy

by mieweb
README.md
# redmine-mcp-proxy

A **Streamable HTTP → stdio bridge** that turns a stdio [Model Context
Protocol](https://modelcontextprotocol.io) Redmine server into a multi-user,
network-reachable MCP endpoint — with **per-user impersonation** baked in.

Point your AI platform (Ozwell, Claude, etc.) at a single URL:

```
https://rm.os.mieweb.org/mcp
```

…and every logged-in user transparently acts as **themselves** in Redmine — no
shared service account, no per-user API keys, no prompt-injectable identity.

---

## Why this exists

MCP Redmine servers speak **stdio** (one process, one identity). That's great
for a single desktop user, but it doesn't work when:

- Many people share one AI assistant, and each must act as **their own**
  Redmine user (correct "assigned to me", correct author, correct permissions).
- The MCP client is a hosted web platform that can only reach an **HTTP URL**,
  not a local subprocess.

This proxy solves both: it exposes **Streamable HTTP** on `/mcp`, and for every
session it spawns a **dedicated backend subprocess** locked to the requesting
user's identity.

---

## How the Proxy works

```mermaid
sequenceDiagram
    participant AI as AI Platform (Ozwell / Claude)
    participant Proxy as redmine-mcp-proxy (HTTP :80/mcp)
    participant Sub as redmine-mcp-rs subprocess (stdio)
    participant RM as Redmine REST API

    AI->>Proxy: POST /mcp (initialize)<br/>x-ozwell-user-email: rgara@mieweb.com
    Note over Proxy: resolveUser() extracts identity
    Proxy->>Sub: spawn(redmine-mcp-rs)<br/>REDMINE_ON_BEHALF_OF=rgara@mieweb.com<br/>REDMINE_LOCK_ON_BEHALF_OF=1
    Proxy-->>AI: mcp-session-id: <uuid>
    AI->>Proxy: POST /mcp (tools/call)<br/>mcp-session-id: <uuid>
    Proxy->>Sub: forward JSON-RPC (stdin)
    Sub->>RM: GET /issues.json<br/>X-Redmine-Switch-User: rgara
    RM-->>Sub: issues visible to rgara
    Sub-->>Proxy: JSON-RPC result (stdout)
    Proxy-->>AI: Streamable HTTP response
```

Step by step:

1. **Identity resolution.** On the first request of a session, `resolveUser(req)`
   determines *who* the caller is, in priority order:

   | Priority | Source                          | Sent by                       |
   | -------- | ------------------------------- | ----------------------------- |
   | 1        | `x-redmine-user` header         | Explicit override             |
   | 2        | `x-ozwell-user-email` header    | Ozwell AI platform (auto)     |
   | 3        | `x-ozwell-user-name` header     | Ozwell AI platform (auto)     |
   | 4        | `?user=` query parameter        | Manual / testing              |
   | 5        | Basic-auth username             | Clients that send credentials |

   No identity → **HTTP 401**. Nobody gets an anonymous or admin session by
   accident.

2. **Per-session subprocess.** A new session spawns one backend MCP process
   (the Rust [`redmine-mcp-rs`](https://github.com/mieweb/redmine-mcp-rs)
   binary) with the environment locked to that user:

   ```js
   env: {
     REDMINE_URL,
     REDMINE_API_KEY,                 // admin key, server-side only
     REDMINE_ON_BEHALF_OF: onBehalfOf, // the resolved user
     REDMINE_LOCK_ON_BEHALF_OF: "1",   // AI cannot override this
   }
   ```

   `REDMINE_LOCK_ON_BEHALF_OF=1` is the security keystone: even if the model is
   tricked ("act as the admin", "switch to user X"), the backend ignores any
   per-call identity and stays pinned to the header-derived user.

3. **Bidirectional bridge.** The proxy wires the two transports together:
   `StreamableHTTPServerTransport` (facing the AI) ⇄ `StdioClientTransport`
   (facing the subprocess). Messages are forwarded verbatim in both directions,
   so tool names and arguments pass through unchanged.

4. **Session tracking.** Each session is keyed by the `mcp-session-id` header.
   Subsequent requests reuse the same subprocess; closing either transport tears
   down the pair and frees the process.

5. **Impersonation on the wire.** The backend translates
   `REDMINE_ON_BEHALF_OF` into Redmine's `X-Redmine-Switch-User` header on every
   REST call, so Redmine enforces that user's real permissions and ownership.

---

## Configuration

Create `/opt/redmine-mcp-proxy/.env` (chmod `600` — it holds the admin key):

```sh
PORT=80
REDMINE_URL=https://pm.mieweb.com/
REDMINE_API_KEY=<redmine-admin-api-key>
```

| Variable          | Required | Description                                        |
| ----------------- | -------- | -------------------------------------------------- |
| `PORT`            | no       | Listen port (default `80`)                         |
| `REDMINE_URL`     | yes      | Redmine base URL                                   |
| `REDMINE_API_KEY` | yes      | **Admin** API key (needed for impersonation)       |

The path to the backend binary is set in `server.mjs` (the `command:` field of
the `StdioClientTransport`). Swap it for `node index.js` to use the Node
[`redmine-mcp`](https://github.com/mieweb/redmine-mcp) server instead of the
Rust one.

---

## Run

```sh
npm install
node server.mjs
```

### As a systemd service

`/etc/systemd/system/redmine-mcp-proxy.service`:

```ini
[Unit]
Description=Redmine MCP Streamable HTTP proxy
After=network.target

[Service]
Type=simple
User=pralambomanarivo
EnvironmentFile=/opt/redmine-mcp-proxy/.env
ExecStart=/usr/bin/node /opt/redmine-mcp-proxy/server.mjs
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target
```

```sh
sudo systemctl enable --now redmine-mcp-proxy
```

> Binding to port 80 as a non-root user requires
> `sudo setcap cap_net_bind_service=+ep "$(command -v node)"`.

---

## Endpoints

| Method     | Path      | Purpose                                             |
| ---------- | --------- | --------------------------------------------------- |
| `GET`      | `/health` | `{ status, sessions, users }` liveness + who's on   |
| `POST/GET` | `/mcp`    | Streamable HTTP MCP endpoint                        |
| `DELETE`   | `/mcp`    | Terminate a session (`mcp-session-id` header)       |

```sh
curl -s https://rm.os.mieweb.org/health
# {"status":"ok","sessions":1,"users":["rgara@mieweb.com"]}
```

---

## Security model

- **Admin key never leaves the server.** The AI platform only sends a user
  identity header; the key lives in `.env` and is injected into the subprocess.
- **Locked impersonation.** `REDMINE_LOCK_ON_BEHALF_OF=1` makes identity
  header-driven and prompt-injection-proof.
- **Fail-closed.** No resolvable identity → `401`, never an anonymous/admin
  fallback.
- **Least privilege on the wire.** Redmine evaluates every request against the
  impersonated user's actual permissions.

---

## Related

- [`redmine-mcp-rs`](https://github.com/mieweb/redmine-mcp-rs) — the Rust MCP
  backend this proxy spawns (single static binary, fast startup).
- [`redmine-mcp`](https://github.com/mieweb/redmine-mcp) — the original Node MCP
  server (fully compatible backend).

Maintenance

ActivitySlowing
ResponsivenessNo issues