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

An MCP server over the [OpenProject APIv3](https://www.openproject.org/docs/api/) — work packages,
comments, time entries, documents, versions, categories, memberships, relations, watchers, saved
queries, users, and activities. Zero dependencies, stdio transport, one file.

## Why this exists

OpenProject ships its own MCP server as of **17.2**, but it is an **Enterprise add-on** (Professional,
Premium, Corporate) and is **read-only**: *"Right now OpenProject only offers read-only tools."* That
rules it out for the job this server does, which is creating and updating work packages.

The community alternatives were surveyed before writing this:

| Server | Stack | Stars | Comments | Time entries | Blocker |
|---|---|---|---|---|---|
| [AndyEverything](https://github.com/AndyEverything/openproject-mcp-server) | Python + uv | 72 | ❌ | ✅ | *"Do not use it productively"*; 5 delete tools |
| [brunofin](https://github.com/brunofin/openproject-mcp) | TypeScript | 1 | ✅ | ❌ | 4 commits total |
| [Tangaratta](https://github.com/Tangaratta-Engineering/openproject-mcp-server) | Python + FastMCP | 14 | ❌ | ❌ | SSE-only, fixed port |

None combined comments with time entries, and all three carry immaturity warnings. Hence a tightly
scoped tool set, with delete tools limited to relations — the one resource where the two linked
work packages can't be swapped out, so a full teardown is still the only way to redirect a
relation, even though its type/description/lag can now be changed in place.

## Tools

**Read** — `op_list_projects`, `op_list_work_packages`, `op_get_work_package`, `op_list_reference`
(types/statuses/priorities/categories/time_activities), `op_list_time_entries`, `op_list_users`,
`op_list_work_package_activities`, `op_list_documents`, `op_get_document`, `op_current_user`,
`op_list_versions`, `op_list_memberships`, `op_list_relations`, `op_list_queries`,
`op_list_work_package_attachments`

**Write** — `op_create_work_package`, `op_update_work_package`, `op_comment_work_package`,
`op_log_time`, `op_update_document`, `op_add_document_attachment`, `op_create_version`,
`op_update_version`, `op_create_relation`, `op_update_relation`, `op_delete_relation`,
`op_manage_watcher` (list/add/remove), `op_add_work_package_attachment`, `op_delete_attachment`

15 read tools, 14 write tools. `op_list_reference` and `op_manage_watcher` fold what used to be
5 lookup tools and 3 watcher tools into one action/kind-parameterized tool each — fewer entries on
every `tools/list` call, one schema to pick between instead of several near-identical ones.

Lists stay slim — id, subject, status, priority, and the like. `op_get_work_package` is the one that
is genuinely full: description, custom fields, version, category, `responsible`, the
`spentTime`/`estimatedTime`/`remainingTime` aggregates, and `createdAt`.

### Every writable field is writable here

Create and update both take the whole schema-writable set, not a subset: `priority`, `category` and
`version` by name; `estimatedTime` (**Work**) and `remainingTime` (**Remaining work**) as decimal
hours, an ISO duration, or `null` to clear; `percentageDone`; `assignee` and `responsible`
(**Accountable**); and `type`, which is settable on update, not only at creation. Names are resolved
against the instance, and an unknown one fails with the valid values listed — that error is the
fallback discovery path when you'd rather not make a separate call; `op_list_reference(kind:
"categories")` and `op_list_versions` (with `op_create_version`/`op_update_version`) cover the same
ground up front.

Work, Remaining work and % Complete are **one quantity in three views**: OpenProject derives the
third from the other two, so passing all three at once is refused up front rather than coming back as
a 422. Pass at most two.

`op_list_projects` and `op_list_work_packages` page via `pageSize` (capped at 200) and `offset`,
returning `total`/`offset`/`limit`; `op_list_time_entries` takes `pageSize` too. On
`op_list_work_packages`, `project: "all"` searches across every project even when
`OPENPROJECT_PROJECT_ID` is set.

`op_list_users` resolves assignable principals, and it does not need `/api/v3/users` — that endpoint
answers **403** for an ordinary API key, which used to make assignment unusable. It falls back
outwards from the project's `available_assignees` (which needs only project membership) to
`/principals` to `/users`, and reports in `source` which list answered. Because of that, `assignee`
and `responsible` accept a **display name or login** as well as an id; a name that matches nothing or
matches more than one principal fails with the candidates listed. Groups and placeholder users
resolve to their own collections, so assigning a group works.

`op_list_work_package_activities` reads comments back, the read-your-comments counterpart to
`op_comment_work_package` — named apart from `op_list_reference(kind: "time_activities")`
(time-tracking activity categories) on purpose, since the two used to share a name-collision-prone
prefix.

### Documents

`op_list_documents` lists the Documents-module documents, newest first, scoped to the default
project (or `project: "all"`); it filters by `search` (title) and sorts by
`sortBy` (`created_at`, `updated_at`, `id`) with `order`. `op_get_document` is the genuinely full
one: title, markdown description, project, dates, and the document's attachments (file name, size,
MIME type, upload status, author, download URL).

**APIv3 has no document create or delete** — `POST /api/v3/documents` answers 404 on every version,
and the official docs call the endpoint "only a stub". Documents are created in the UI; the MCP
side covers the rest of the lifecycle: `op_update_document` (title and/or markdown description —
there is no `lockVersion` on documents, so concurrent edits are last-write-wins) and
`op_add_document_attachment`, which uploads base64 file bytes as multipart/form-data. Uploads are
capped at the instance's own `maximumAttachmentFileSize` from `/api/v3/configuration` (5 MiB
fallback). Both tools need the `manage_documents` permission; reading needs `view_documents`.

### Attachments

Files can also be attached straight to a work package, without going through a Document.
`op_list_work_package_attachments`/`op_add_work_package_attachment` are the work-package-scoped
twins of `op_get_document`'s embedded attachments and `op_add_document_attachment` — same
base64-as-multipart upload, the same `maximumAttachmentFileSize` cap. `op_delete_attachment` is
shared by both: `DELETE /api/v3/attachments/{id}` is keyed by the attachment's own id regardless of
which container it was uploaded to, so one delete tool covers document and work-package attachments
alike. Uploading needs edit permission on the work package; deleting needs edit permission on the
container or authorship of the attachment.

The document API has two quirks the server absorbs. The `description` **`{"raw": …}` form
documented for every other resource corrupts document text on documents** — the stored value
becomes Ruby's `'{raw: "…"}'` literal, and on 17.3.1 ASCII-only bodies even 500 the response
(verified live; the update contract is unchanged through 17.7, whose OpenAPI spec still documents
the same broken form) — so the server sends the plain string the UI itself sends, which stores
cleanly on every version. And filters on the collection take numeric project ids only (never
identifiers), which is why `op_list_documents`' `project` argument is resolved internally, exactly
like the `time_entries` filter.

Writes default to `notify=false` — `op_log_time` doesn't even expose the flag — so bulk agent
activity does not email the whole project. `op_update_work_package` fetches `lockVersion` itself, so
a concurrent edit fails loudly instead of silently overwriting; `parent: null` on an update clears
the parent link. Arguments are validated up front: ids must be positive integers, dates must be
`YYYY-MM-DD`, and unknown statuses/activities/ids fail with messages that name the offending value.

### Versions, categories, memberships, relations, watchers, queries

`op_current_user` reports the API key's own account — useful for confirming which principal is
acting before a write. `op_list_versions` returns a project's sprints/milestones with status and
dates; `status` filters the result locally, since the collection takes no server-side filter for
it. `op_create_version`/`op_update_version` cover the rest of the lifecycle — `status` is a plain
`open`/`locked`/`closed` string, not a link, unlike a work package's status.
`op_list_reference(kind: "categories")` lists a project's categories.

`op_list_memberships` reports who has which role on a project; like `time_entries` and
`documents`, the collection's `project` filter takes only the numeric id, resolved internally.

`op_list_relations`/`op_create_relation`/`op_update_relation`/`op_delete_relation` manage the links
between work packages — blocks, precedes, relates, duplicates, and their inverses.
`op_create_relation` names the "to" work package and a `type`; `op_update_relation` and
`op_delete_relation` both take the relation's own id (from `op_list_relations`), not either work
package's id. `op_update_relation` can change `type`, `description`, and `lag` in place — mirroring
the API's own restriction, it cannot repoint a relation at a different pair of work packages, so
that still means delete and recreate.

`op_manage_watcher` (`action: "list"|"add"|"remove"`) manages who is notified on a work package.
`user` is required for `add`/`remove` and accepts a display name or login the same way
`assignee`/`responsible` do, resolved through the same project-scoped principal lookup.

`op_list_queries` lists saved views, optionally scoped to a project (`project: "all"` for every
project); `starred` is present only on instances that expose it.

## Configuration

Resolution order:

1. `OPENPROJECT_ENV_FILE` — path to a `.env` to read `OPENPROJECT_*` from. **Values in an explicitly
   named `.env` win over the ambient environment.** Deliberate: a stale exported
   `OPENPROJECT_API_KEY` silently shadowing a freshly rotated one in `.env` cost hours of debugging —
   the API reports it as `401 You did not provide the correct credentials`, indistinguishable from a
   bad key. Naming a file is a deliberate act; an inherited variable usually is not.
2. `OPENPROJECT_URL` (or `OPENPROJECT_BASE_URL`) + `OPENPROJECT_API_KEY` + `OPENPROJECT_PROJECT_ID`
   from the environment
3. `~/.config/openproject-mcp/config.json` — `{"url": "...", "apiKey": "...", "defaultProject": "..."}`
   (consulted only when a URL or key is still missing)

`OPENPROJECT_PROJECT_ID` sets the default project, so most calls need no `project` argument.

`OPENPROJECT_HOURS_DB` (optional) points the hours-ledger tap at a SQLite cache;
it defaults to `~/Projects/hours/hours.db`. An empty value disables the tap.

### The hours-ledger tap

This machine runs a second, unrelated tool — the *hours* tracker at `~/Projects/hours` — that
keeps the team sheet's side of the time ledger in SQLite (entry rows keyed by OpenProject work
package id, statuses draft/approved/pushed, plus a cached task row per package). It also writes
OpenProject time entries of its own when a tracked entry carries a task id, which makes it a
second writer alongside `op_log_time`.

So this server peeks at that cache — read-only, always best-effort — and reports the local side
where it matters:

- **`op_get_work_package`** returns `hoursLedger` next to `spentTime`: the sheet's own minutes
  for that task, split into draft/approved/pushed, plus the cached task subject/status when
  known.
- **`op_log_time`** returns `hoursLedger` alongside the created entry, so the sheet's side of
  the ledger is visible at the moment of the write.

`hoursLedger` is present only when the cache is installed and readable; absent otherwise. The
two ledgers describe the same work, so **they are never summed** — `spentTime` and
`sheet.totalMinutes` must be read as alternatives, not added. Nothing here blocks or dedupes a
write; the tap exists to inform the caller, and the server is fully functional with the hours
tool absent, disabled (`OPENPROJECT_HOURS_DB=""`), or unreachable. The cache is SQLite in WAL
mode, and the tap opens it read-only with short queries, so it is safe to run while the hours
CLI, MCP server, or collector daemon are writing. Requires Node ≥ 22.5 (built-in
`node:sqlite`); on older Node the tap stays off and nothing else changes.

When both MCP servers are registered in the same client, the hours server's `task_hours
{taskId, refresh}` tool is the fuller union view (OpenProject's `spentTime` plus the local
ledger in one call), and `op_list_time_entries` verifies time entries the hours push
write-through created.

Auth is HTTP Basic with the literal username `apikey` and the key as password. The key is never
written to stdout, stderr, or any tool result — and the URL must be **https** (plain http is
rejected unless the host is `localhost` or `127.0.0.1`), because the key must not travel in the
clear.

`OPENPROJECT_TIMEOUT_MS` sets the per-request timeout (default 30000). Transient failures (429, 502,
503, 504) are retried twice with backoff, honoring `Retry-After` — GETs only; writes are never
retried, so a timed-out create is not silently replayed.

`.env` and `config.json` should be `chmod 600` — the server warns on stderr when either is readable
by others. An unreadable or missing `OPENPROJECT_ENV_FILE` prints a stderr warning and falls through
to the remaining sources instead of failing silently.

## Install

Requires **Node.js ≥ 18**. The server is a single self-contained file with no dependencies — no
build step, nothing to compile. Install it in one line, then register it with your client below.

**macOS / Linux:**

```bash
curl -fsSL https://raw.githubusercontent.com/DDeluca06/openproject-mcp/master/scripts/install.sh | bash
```

**Windows (PowerShell):**

```powershell
irm https://raw.githubusercontent.com/DDeluca06/openproject-mcp/master/scripts/install.ps1 | iex
```

Both download `server.mjs` into `~/.local/bin` (Windows adds an `openproject-mcp.cmd` shim, since
shebangs don't work there) and print the next steps. If `~/.local/bin` isn't on your `PATH`
(macOS and Windows by default), the installer tells you how to add it:

```bash
export PATH="$HOME/.local/bin:$PATH"   # put this line in ~/.zshrc or ~/.bashrc
```

GUI-launched apps (VS Code, some terminals) inherit a minimal `PATH` — if your client can't find
`openproject-mcp`, use the absolute path the installer printed.

**Alternative (npm, all three platforms)** — installs the same one file with a proper bin entry:

```bash
npm install -g --allow-remote https://github.com/DDeluca06/openproject-mcp/tarball/master
```

`--allow-remote` is required on npm ≥ 12, which blocks GitHub tarballs by default; older npm
versions ignore the flag.

**From a clone** (development, or if you'd rather not pipe a script into a shell):

```bash
git clone https://github.com/DDeluca06/openproject-mcp.git
cd openproject-mcp
cp .env.example .env && chmod 600 .env   # then paste your API key into .env
node server.mjs                          # or: npm start
```

There is nothing to install — `npm install` fetches no dependencies. Point your client at the
absolute path to `server.mjs` instead of the `openproject-mcp` command.

Either way, verify the server starts: `openproject-mcp` should run and wait on stdin.

### Claude Code

```bash
claude mcp add openproject -s user \
  -e OPENPROJECT_URL=https://projects.example.com \
  -e OPENPROJECT_PROJECT_ID=your-project-identifier \
  -e OPENPROJECT_ENV_FILE=/path/to/.env \
  -- openproject-mcp
```

The snippet sets both `OPENPROJECT_URL` and `OPENPROJECT_ENV_FILE`; if the `.env` also holds
`OPENPROJECT_URL`, the `-e` value is ignored — deliberate, so a freshly rotated `.env` key wins.

### OpenCode

In `~/.config/opencode/opencode.json`, under the **top-level** `mcp` key — not nested under
`mcp.servers`, which fails schema validation and silently disables *every* server in the file:

```json
{
  "mcp": {
    "openproject": {
      "type": "local",
      "command": ["openproject-mcp"],
      "enabled": true,
      "environment": {
        "OPENPROJECT_URL": "https://projects.example.com",
        "OPENPROJECT_PROJECT_ID": "your-project-identifier",
        "OPENPROJECT_ENV_FILE": "/path/to/.env"
      }
    }
  }
}
```

Verify with `claude mcp get openproject` and `opencode mcp list`.

## Tests

```bash
npm test              # mock suite (no credentials needed) + live smoke test
npm test -- --writes  # smoke also creates a real work package, comment, and time entry
```

`npm test` runs `test/mock.test.mjs` (a deterministic fake APIv3 server, 151 checks, no
credentials) and then `test/smoke.mjs` against the live instance; the smoke test self-skips when
`OPENPROJECT_URL` is unset. `--writes` creates a real work package, comment, and time entry — the
work package is clearly marked as a smoke test, then permanently deleted through the APIv3 DELETE
endpoint after the run (its time entries go with it). Documents have no APIv3 create, so the write
smoke instead renames an existing document to a `SMOKE TEST …` title, immediately reverts title
and description, and uploads a tiny attachment that is then deleted through `op_delete_attachment`
— if the revert or delete is interrupted, the remaining mark is a `SMOKE TEST` title or a
`smoke-test-*.txt` attachment. If the API key lacks the delete permission the deletion is skipped
loudly (`DELETE 403`), and the work package is left closed for manual cleanup; pass `--keep` to
always leave the records in place.

## APIv3 quirks this server absorbs

Each of these was found by a failing call, not from the docs:

- A parentless work package still returns `parent: {href: null}` rather than omitting the link, so
  naive `parent.href.split()` throws.
- `time_entries` rejects a project **identifier** and demands the numeric id, unlike the
  `work_packages` endpoints which take either. Resolved and cached internally.
- The work-package filter on `time_entries` is **`entity`**, not `work_package` — the resource was
  generalised to attach to meetings too, and the old filter name no longer exists.
- `TimeEntriesActivity` has **no collection endpoint**. The server first asks
  `/api/v3/time_entries/schema` for `activity.allowedValues`; instances that omit it fall back to
  probing `/api/v3/time_entries/activities/{id}` in parallel batches of 12, stopping after the first
  fully-missed batch (capped at id 120). The set is cached for 30 minutes. That plural
  `/time_entries/activities/{id}` path is an undocumented alias — it works, but it is not in the API
  docs.
- `hours` is an ISO-8601 duration (`PT1H30M`; day components like `P1DT2H` parse too). `op_log_time`
  accepts decimal hours and converts; reads return both forms plus a decimal total. A duration that
  will not parse comes back as `hoursDecimal: null` with a `parseWarnings` count in
  `op_list_time_entries` — not silently as 0.
- `op_log_time`'s default `spentOn` is the **local** calendar date, not UTC — "today" in the
  server's timezone.
- Time entries are always attributed to the API key's own user. OpenProject makes that field
  read-only, so time cannot be logged on someone else's behalf.
- The Documents endpoint has no create (`POST /api/v3/documents` → 404) and no delete, and its
  own docs say "only a stub for now". `PATCH` writes title/description, attachments upload below.
- A document's `description` must be PATCHed as a **plain string**, not the `{"raw": …}` hash
  (see the Documents section) — on 17.3 the hash form stores Ruby's inspected literal and the
  response can arrive as a 500 with `text must be UTF-8 encoded` even though the write committed.
- Uploading a document attachment is multipart, but the `metadata` part must be a plain form-data
  string: sending it as a blob makes undici attach `filename="blob"`, Rack treats the part as a
  file upload instead of JSON, and the API answers 500 `no implicit conversion of
  ActiveSupport::HashWithIndifferentAccess into String`.
- The documents collection filter takes numeric project ids only — an identifier in
  `filters=[{"project":…}]` fails, resolved and cached internally like `time_entries`.

TDQS

A3.9/5.0

Scored across 18 tools

Disambiguation4/5

Most tools target clearly distinct resource/action pairs, and the descriptions remove most ambiguity. The only slight confusion is among op_list_time_entry_activities, op_list_time_entries, and op_list_activities, but their different subjects are clarified in the descriptions.

Naming Consistency5/5

All tools follow a consistent op_<verb>_<noun> snake_case pattern with predictable verbs like list, get, create, update, and comment. op_log_time and op_add_document_attachment are minor stylistic variations but still fit the overall pattern.

Tool Count4/5

At 18 tools, the server is slightly above the ideal 3-15 range but each tool covers a legitimate OpenProject action across projects, work packages, time tracking, and documents. The breadth is justified by the domain rather than being padded with redundant tools.

Completeness4/5

Core workflows are well covered: listing projects, CRUD-ish work package operations, commenting, time entry logging, reference data, and document read/update. Notable gaps like work-package deletion or time-entry update/delete exist, but they are secondary or documented API limitations, so agents can still complete main workflows.

Maintenance

ActivityMaintained
ResponsivenessNo issues