Skip to main content
Glama
README.md
# openproject-mcp

MCP (Model Context Protocol) server for **OpenProject** β€” gives AI agents
(ZCode, Claude Desktop, etc.) a limited set of operations over OpenProject
via the REST API v3:

- πŸ” **Search work packages** β€” by subject, ID, status, project, assignee
- πŸ“„ **Work package details** β€” all fields, description, optional comments and attachments
- πŸ’¬ **Add comments** to work packages
- πŸ“Ž **Upload files** (attachments) to work packages
- ⏱ **Log time** (time entries)
- πŸ“Š **Time report** for a period β€” grouped by project with a total
- πŸ—‚ **List projects** β€” all projects, parent's subprojects, or the full hierarchy tree
- πŸ‘€ **Search users** β€” by name or login (to obtain IDs)

[Русская вСрсия](README.ru.md)

Three transports are supported (selected by the `MCP_TRANSPORT` variable or the
`--transport` flag):

| Transport | Purpose |
|---|---|
| **stdio** | Local clients (ZCode, etc.): the server runs as a subprocess and talks over stdin/stdout. Default. |
| **streamable-http** | Modern MCP HTTP transport (endpoint `/mcp`). Recommended for Docker / a networked microservice. |
| **sse** | Legacy HTTP transport (`/sse` + `/messages/`). For old clients that do not support streamable-http. |

> Why a custom server when OpenProject 17.2 has a built-in MCP? The built-in
> one is Enterprise-only and **read-only**. This server works with any edition
> (including Community) and supports write operations: comments, files, time.

---

## Requirements

- **Python 3.10+** (tested on 3.13)
- Access to an OpenProject instance with the API enabled (Personal Access Token)
- Token permissions: view work packages/projects, **add work package notes** (comments),
  **log time**, add attachments (edit work package or add attachments)
- For HTTP/Docker β€” Docker (or any ASGI server; `uvicorn` is included as a dependency)

## Installation

### Option A β€” via `uv` (recommended, faster)

```bash
cd path\to\openproject-mcp
uv venv
uv pip install -e ".[http]"     # [http] is only needed for the HTTP transport
```

### Option B β€” via standard `pip`

```bash
cd path\to\openproject-mcp
python -m venv .venv
.venv\Scripts\activate
pip install -e ".[http]"        # for stdio, `pip install -e .` is enough
```

After installation both the `openproject-mcp` command and `python -m openproject_mcp` are available.

## Configuration

Variables are grouped by prefix to avoid confusion:

- **`op_`** β€” connection to OpenProject (where we talk to)
- **`mcp_`** β€” settings of the MCP service itself (how it works)

Copy the example and fill in your values:

```bash
copy .env.example .env          # Windows
cp .env.example .env            # Linux/macOS
```

### Environment variables

| Variable | Prefix | Required | Description |
|---|---|---|---|
| `OP_URL` | op | βœ… | Base URL of your OpenProject **without** a trailing `/`. Example: `https://openproject.example.com` |
| `OP_API_KEY` | op | βœ… | Personal API token. Created in profile settings β†’ **Access tokens**. Requires the administrator setting "Enable API tokens". |
| `MCP_TRANSPORT` | mcp | ❌ | `stdio` (default) \| `streamable-http` \| `sse` |
| `MCP_BIND` | mcp | ❌ | HTTP transport address as `host:port` (IPv6: `[address]:port`). Default `127.0.0.1:8000`. In Docker β€” `0.0.0.0:8000`. Ignored for stdio. |
| `MCP_AUTH_TOKEN` | mcp | ❌ | Optional Bearer token protecting the HTTP endpoint. Empty = no auth (trusted network / reverse proxy only). Clients send `Authorization: Bearer <value>`. |
| `MCP_ALLOWED_HOSTS` | mcp | ❌ | Comma-separated host list (DNS-rebinding protection). Suffix `:*` β€” any port. Example: `mcp.corp.local,mcp.corp.local:*`. **Empty = host protection disabled** (see the "Host allowlist" section). |
| `MCP_LOG_LEVEL` | mcp | ❌ | `DEBUG` / `INFO` / `WARNING` / `ERROR`. Default `INFO`. Logs go to stderr. |

Variables can also be set without a `.env` β€” directly in the client config or at container startup.

### Getting an API token

1. Sign in to OpenProject.
2. Profile icon (top right) β†’ **My account** β†’ **Access tokens**.
3. Click **+ API token**, give it a name (e.g. "MCP"), copy the value.
4. If the section is unavailable, an administrator must enable **Enable API tokens**
   in Administration β†’ … (or grant your account the right).

> The token is shown only once β€” save it right away.

---

## Running

### stdio (local client)

```bash
openproject-mcp                 # MCP_TRANSPORT=stdio (default)
```
The server starts and waits for client commands over stdin/stdout. Stop with Ctrl+C.

### streamable-http / sse (HTTP service)

```bash
openproject-mcp --transport streamable-http --bind 0.0.0.0:8000
# or via environment variables:
#   MCP_TRANSPORT=streamable-http MCP_BIND=0.0.0.0:8000 openproject-mcp
```

Health check:
```bash
curl http://127.0.0.1:8000/health        # β†’ {"status": "ok"}  (no auth required)
```

CLI arguments (override env; precedence: CLI > env > default):

| Argument | Description |
|---|---|
| `--transport {stdio,streamable-http,sse}` | Transport |
| `--bind HOST:PORT` | Address for HTTP (IPv6: `[address]:port`). Ignored for stdio. |
| `--log-level LEVEL` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |

---

## Docker

The microservice is built into a portable image and runs as an HTTP service
(`streamable-http` by default). Secrets (`OP_URL`, `OP_API_KEY`,
`MCP_AUTH_TOKEN`) are passed at runtime β€” **not baked into the image**.

> ⚠️ **Security.** The server opens write operations to OpenProject
> (comments, files, time). On any network except a fully isolated one,
> **set `MCP_AUTH_TOKEN`** or keep the service behind an authenticated reverse proxy.

### Build and run

```bash
# Build the image
docker build -t openproject-mcp .

# Run (secrets via -e / --env-file)
docker run --rm -p 8000:8000 \
  -e OP_URL=https://openproject.example.com \
  -e OP_API_KEY=your_api_token_here \
  -e MCP_AUTH_TOKEN=choose_a_secret \
  openproject-mcp
```

Health check:
```bash
curl http://localhost:8000/health                                    # 200
curl -H "Authorization: Bearer choose_a_secret" \
     -H "Content-Type: application/json" \
     -H "Accept: application/json, text/event-stream" \
     -X POST http://localhost:8000/mcp \
     -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}'
```

### docker compose

Easier via `docker-compose.yml` (reads `.env`):

```bash
cp .env.example .env           # fill in OP_URL / OP_API_KEY / MCP_AUTH_TOKEN
docker compose up --build      # build + start
docker compose logs -f         # logs
docker compose down            # stop
```

After startup the MCP endpoint is `http://localhost:8000/mcp`, health at `/health`.

### Container variables

The image sets the defaults `MCP_TRANSPORT=streamable-http` and `MCP_BIND=0.0.0.0:8000`
(overridable at runtime). The following are passed in explicitly:

- `OP_URL`, `OP_API_KEY` β€” connection to OpenProject;
- `MCP_AUTH_TOKEN` β€” endpoint protection (recommended);
- `MCP_ALLOWED_HOSTS` β€” see below.

### Host allowlist (DNS-rebinding protection)

By default the MCP SDK accepts HTTP requests **only to localhost** β€” in Docker
(even on `0.0.0.0`) or behind a reverse proxy this yields HTTP 421 on every request.
This server behaves in a **hybrid** way:

- If `MCP_ALLOWED_HOSTS` is set (e.g. `mcp.corp.local,mcp.corp.local:*`) β€”
  protection is enabled with that host list.
- **If empty β€” protection is disabled**; the server relies on Bearer auth
  (`MCP_AUTH_TOKEN`), a reverse proxy, or network isolation.

> If you see 421 "Invalid Host header" β€” either set `MCP_ALLOWED_HOSTS` with the
> hostname your clients connect to, or (for an internal network) leave it empty
> and protect the endpoint with `MCP_AUTH_TOKEN`.

---

## Client integration

### stdio (ZCode, Claude Desktop β€” local subprocess)

Add the configuration to your MCP client's settings file.

```json
{
  "mcpServers": {
    "openproject": {
      "command": "C:\\path\\to\\project\\.venv\\Scripts\\python.exe",
      "args": ["-m", "openproject_mcp"],
      "env": {
        "OP_URL": "https://openproject.example.com",
        "OP_API_KEY": "your_api_token_here",
        "MCP_TRANSPORT": "stdio",
        "MCP_LOG_LEVEL": "INFO"
      }
    }
  }
}
```

> Paths in JSON on Windows require a double backslash `\\` or forward slashes.
> If a `.env` exists in the working directory, the `env` block can be omitted,
> but explicit variables are more reliable (they do not depend on the working
> directory at launch).

**Alternative via console script** (if `openproject-mcp` is on your `PATH`):
```json
{
  "mcpServers": {
    "openproject": {
      "command": "C:\\path\\to\\project\\.venv\\Scripts\\openproject-mcp.exe",
      "args": [],
      "env": { "OP_URL": "https://openproject.example.com", "OP_API_KEY": "..." }
    }
  }
}
```

After saving the config, restart the client (or reconnect the MCP server).
The tools with the `op_*` prefix will appear in the tool list.

### streamable-http (remote microservice)

The client connects to the HTTP endpoint by URL and (if `MCP_AUTH_TOKEN` is set)
sends the authorization header. The exact format depends on the client; for ZCode
this is an MCP server section of type `http`/`url`:

```json
{
  "mcpServers": {
    "openproject": {
      "type": "http",
      "url": "http://mcp.corp.local:8000/mcp",
      "headers": {
        "Authorization": "Bearer choose_a_secret"
      }
    }
  }
}
```

> With `MCP_TRANSPORT=sse` the endpoints change to `/sse` (GET, stream) and
> `/messages/` (POST) β€” use your client's SSE mode.

---

## Tools

| Tool | Purpose | Key parameters |
|---|---|---|
| `op_check_connection` | Check URL + token | β€” |
| `op_search_work_packages` | Search work packages | `subject` (a string or a **list of synonyms**, searched in subject with an automatic description fallback), `status` (`open`/`closed`/`all`), `project_id`, `assignee_id`, `type_id`, `filters` (JSON), `page_size`, `offset`, `sort_by` |
| `op_get_work_package` | Details of one work package | `work_package_id`, `include_comments`, `include_attachments` |
| `op_list_projects` | List projects / subprojects / tree | `name` (a string or a **list of synonyms**, with a description fallback), `parent_id`, `direct_children_only`, `active`, `filters` (JSON), `page_size`, `offset`, `sort_by`, `as_tree` |
| `op_add_comment` | Comment on a work package | `work_package_id`, `comment`, `internal` |
| `op_add_attachment` | Upload a file | `work_package_id`, `file_path` (absolute path), `file_name` |
| `op_log_time` | Log time | `work_package_id`, `hours` (`1.5h`/`2h30m`/`90m`/`1:30`/`PT1H30M`), `activity_id`, `spent_on` (`YYYY-MM-DD`), `comment` |
| `op_list_time_entries` | Time report for a period | `user_id` (`'me'` or ID), `date_from`/`date_to` (`YYYY-MM-DD`), `project_id`, `activity_id`, `include_comments`, `page_size`, `offset`, `sort_by` |
| `op_list_users` | Search users | `query` (name or login), `filters` (JSON), `page_size`, `offset` |
| `op_list_time_entry_activities` | Time-entry activity reference | β€” |

> Tools are available over any transport β€” behavior is identical for stdio and HTTP.

### Usage examples

**Find open work packages in project #5 assigned to me:**
```
op_search_work_packages(project_id="5", status="open", assignee_id="me", page_size=10)
```

**Find a work package by subject (one term or synonyms):**
```
op_search_work_packages(subject="login")                         # one term
op_search_work_packages(subject=["bug", "defect", "issue"])      # synonyms β†’ OR, deduplicated
```
Search goes through the work package subject first; if nothing is found it
automatically falls back to the description. With no matches an empty list is
returned (not the whole backlog).

**Find a project by name:**
```
op_list_projects(name="demo")
op_list_projects(name=["demo", "test"])          # synonyms, description fallback
```

**All projects or the hierarchy tree:**
```
op_list_projects()                              # flat list of all projects
op_list_projects(as_tree=True)                  # tree: roots β†’ children β†’ ...
op_list_projects(active=True)                   # only active projects
```

**Subprojects of a specific project:**
```
op_list_projects(parent_id="1")                              # full subtree (any depth)
op_list_projects(parent_id="1", direct_children_only=True)   # only direct children
```

**Add a comment to work package #42:**
```
op_add_comment(work_package_id=42, comment="Verified, the bug reproduces", internal=True)
```

**Log 1.5 hours against work package #42:**
```
op_log_time(work_package_id=42, hours="1.5h", activity_id=1, comment="Debugging")
```

**Upload a file to work package #42:**
```
op_add_attachment(work_package_id=42, file_path="C:\\reports\\bug.png")
```

**Time report for a period (for me, for July):**
```
# "for July" β†’ date_from/date_to (the agent computes month boundaries itself)
op_list_time_entries(user_id="me", date_from="2026-07-01", date_to="2026-07-31")
# numbers only, no comments:
op_list_time_entries(user_id="me", date_from="2026-07-01", date_to="2026-07-31", include_comments=False)
# for a specific project:
op_list_time_entries(user_id="me", project_id="1", date_from="2026-07-01", date_to="2026-07-31")
```
Returns entries grouped by project, with per-project hour totals and a grand total.

**Find a user by name (to substitute the ID):**
```
op_list_users(query="Ivanov")
# then use the found id in op_list_time_entries(user_id="...")
```

---

## OpenProject version compatibility

The server is not tied to a version number and works with any OpenProject exposing
API v3. The only dialect-dependent point is the work-package link in a time entry:

- **OpenProject 14+** β†’ `_links.entity` (`/api/v3/work_packages/{id}`)
- **OpenProject ≀13** β†’ `_links.workPackage`

`op_log_time` automatically tries the modern `entity` field and, on a server
rejection (HTTP 422), retries with the legacy `workPackage` field. The successful
variant is cached, so subsequent writes avoid extra attempts. Search, comments and
attachments are identical across versions.

---

## Project structure

```
openproject-mcp/
β”œβ”€β”€ Dockerfile                 # microservice image (python:3.13-slim)
β”œβ”€β”€ docker-compose.yml         # local compose startup
β”œβ”€β”€ .dockerignore
β”œβ”€β”€ pyproject.toml             # hatchling; deps: mcp[cli], httpx, anyio; extra [http]: uvicorn[standard]
β”œβ”€β”€ .env.example               # config template (op_* / mcp_*)
β”œβ”€β”€ .env                       # real credentials (in .gitignore)
└── src/openproject_mcp/
    β”œβ”€β”€ __init__.py            # package version
    β”œβ”€β”€ __main__.py            # entry point: transport selection (stdio / http), CLI, stderr logging
    β”œβ”€β”€ config.py              # .env / environment variable loading, validation, security_settings()
    β”œβ”€β”€ client.py              # httpx client for API v3: auth, HAL errors, pagination
    β”œβ”€β”€ formatting.py          # HAL+JSON _links parsing, ISO8601 durations, filters
    β”œβ”€β”€ http_app.py            # HTTP app assembly: /health + optional Bearer auth
    └── server.py              # MCP tool registration (transport-independent)
```

## Troubleshooting

- **"Configuration error: OP_URL is not set"** β€” no `.env` in the working
  directory and the variables were not passed via `env`/`-e`.
- **HTTP 401 Unauthorized** β€” missing/incorrect `MCP_AUTH_TOKEN`. The client
  must send `Authorization: Bearer <value>`.
- **HTTP 421 "Invalid Host header"** β€” the host allowlist triggered. Either set
  `MCP_ALLOWED_HOSTS` with the hostname clients use, or leave it empty
  (protection is disabled) and secure with `MCP_AUTH_TOKEN`.
- **Connection fails in Docker** β€” check that `MCP_BIND=0.0.0.0:8000`
  (not `127.0.0.1`) and the port is published (`-p 8000:8000`).
- **"Port already in use"** β€” change the port in `MCP_BIND`/`--bind` and in the port mapping.
- **HTTP 401/403 from OpenProject** β€” wrong/expired token or missing permissions.
  Check the token and its rights (add work package notes, log time).
- **HTTP 404** β€” the work package/project was not found or you have no view permission.
- **Need diagnostics** β€” set `MCP_LOG_LEVEL=DEBUG`; logs go to stderr.

## Testing

```bash
pip install -r requirements-dev.txt
pytest tests/
```

The integration tests hit a live OpenProject server configured via `OP_URL` /
`OP_API_KEY` (or the repo's `.env`) and are skipped automatically when the
server is not reachable.

## License

MIT

TDQS

A4.3/5.0

Scored across 10 tools

Disambiguation5/5

Each tool targets a distinct resource and action: searching work packages, listing projects, retrieving a work package, adding comments/attachments, time tracking activities, logging time, listing time entries, searching users, and checking connection. No two tools have overlapping responsibilities; even the time-related tools are clearly separated between activity catalog, entry creation, and reporting.

Naming Consistency5/5

All tools share the 'op_' prefix and follow a consistent verb_noun pattern: search_work_packages, list_projects, get_work_package, add_comment, add_attachment, list_time_entry_activities, log_time, list_time_entries, list_users, check_connection. The naming is uniform and predictable, with no mixed conventions.

Tool Count5/5

With 10 tools, the server is well-scoped for OpenProject operations. It covers the core areas (projects, work packages, comments, attachments, time tracking, users, connection) without unnecessary bloat or thin coverage. Each tool earns its place.

Completeness3/5

The set covers searching/reading work packages, adding comments/attachments, and time tracking well, but lacks work package creation, update, deletion, and status/type management. This creates notable gaps for full workflow coverage, though the available operations form a coherent internal surface.

Maintenance

ActivitySlowing
ResponsivenessNo issues