openproject-mcp
An MCP server that reads and writes OpenProject work packages, comments, time entries, and documents over APIv3.
Browse reference data — list projects, users/assignable principals, types, statuses, priorities, and time-entry activity categories.
Find work packages — list with free-text search,
open/closed/allstatus, and per-project orproject: "all"scope, or fetch one in full (custom fields, time aggregates, optional local hours ledger).Create and update work packages — subject, description, type, status, parent, dates, priority, category, version, assignee/responsible by name or id, and Work / Remaining work / % Complete (at most two of the three);
lockVersionis fetched automatically so concurrent edits fail loudly.Comment on work packages — add markdown comments, and list a work package's comments/activities.
Track time — log time against a work package (decimal hours or ISO-8601 duration, always attributed to the API key's own user) and list logged entries with ISO and decimal hours plus totals.
Manage documents — list (search/sort/paginate) and fetch documents with their attachments, and update title/description; APIv3 has no create or delete.
Upload attachments — attach base64 file bytes to a document, capped by the instance's attachment size limit.
Safe writes — email notifications default to
notify=false, and reads are non-destructive/idempotent.
Provides tools for interacting with the OpenProject API, enabling management of work packages, comments, time entries, users, and activities.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@openproject-mcp Create a work package titled 'Fix login button' in the web project."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
openproject-mcp
An MCP server over the OpenProject APIv3 — 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 |
Python + uv | 72 | ❌ | ✅ | "Do not use it productively"; 5 delete tools | |
TypeScript | 1 | ✅ | ❌ | 4 commits total | |
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.
Related MCP server: OpenProject MCP Server
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:
OPENPROJECT_ENV_FILE— path to a.envto readOPENPROJECT_*from. Values in an explicitly named.envwin over the ambient environment. Deliberate: a stale exportedOPENPROJECT_API_KEYsilently shadowing a freshly rotated one in.envcost hours of debugging — the API reports it as401 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.OPENPROJECT_URL(orOPENPROJECT_BASE_URL) +OPENPROJECT_API_KEY+OPENPROJECT_PROJECT_IDfrom the environment~/.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_packagereturnshoursLedgernext tospentTime: the sheet's own minutes for that task, split into draft/approved/pushed, plus the cached task subject/status when known.op_log_timereturnshoursLedgeralongside 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:
curl -fsSL https://raw.githubusercontent.com/DDeluca06/openproject-mcp/master/scripts/install.sh | bashWindows (PowerShell):
irm https://raw.githubusercontent.com/DDeluca06/openproject-mcp/master/scripts/install.ps1 | iexBoth 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:
export PATH="$HOME/.local/bin:$PATH" # put this line in ~/.zshrc or ~/.bashrcGUI-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:
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):
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 startThere 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
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-mcpThe 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:
{
"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
npm test # mock suite (no credentials needed) + live smoke test
npm test -- --writes # smoke also creates a real work package, comment, and time entrynpm 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 naiveparent.href.split()throws.time_entriesrejects a project identifier and demands the numeric id, unlike thework_packagesendpoints which take either. Resolved and cached internally.The work-package filter on
time_entriesisentity, notwork_package— the resource was generalised to attach to meetings too, and the old filter name no longer exists.TimeEntriesActivityhas no collection endpoint. The server first asks/api/v3/time_entries/schemaforactivity.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.hoursis an ISO-8601 duration (PT1H30M; day components likeP1DT2Hparse too).op_log_timeaccepts decimal hours and converts; reads return both forms plus a decimal total. A duration that will not parse comes back ashoursDecimal: nullwith aparseWarningscount inop_list_time_entries— not silently as 0.op_log_time's defaultspentOnis 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".PATCHwrites title/description, attachments upload below.A document's
descriptionmust 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 withtext must be UTF-8 encodedeven though the write committed.Uploading a document attachment is multipart, but the
metadatapart must be a plain form-data string: sending it as a blob makes undici attachfilename="blob", Rack treats the part as a file upload instead of JSON, and the API answers 500no 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 liketime_entries.
Available Tools
18 toolsop_add_document_attachmentA
Upload a file to a document: the API key needs the manage_documents permission in the document's project. content is the file bytes as base64. The upload is capped at the instance's attachment size limit, read from /api/v3/configuration.
Inputs: id (required), content (required), fileName (required), contentType.
op_comment_work_packageA
Add a markdown comment to a work package. Notifications are off unless notify=true.
Inputs: id (required), comment (required), notify.
op_create_work_packageA
Create a work package. Returns the created id. Notifications are off unless notify=true. Every change to a tracked project needs one of these before work starts.
Inputs: subject (required), type, notify, parent, +12 more.
op_get_documentARead-onlyIdempotent
Fetch one document in full detail: title, markdown description, project, dates, and its attachments (file name, size, type, status, download URL).
Inputs: id (required).
op_get_work_packageARead-onlyIdempotent
Fetch one work package in full detail, including custom fields, priority, version, and time aggregates. When the local hours ledger is installed, hoursLedger reports the sheet side of the task's time separately — never summed with spentTime.
Inputs: id (required).
op_list_activitiesBRead-onlyIdempotent
List comments and activities on a work package.
Inputs: id (required).
op_list_documentsARead-onlyIdempotent
List documents (the Documents module), newest first. A document is a titled container for attachments with a markdown description. APIv3 has no document create or delete (UI-only), so documents are read and updated only. project: "all" searches every project even when OPENPROJECT_PROJECT_ID is set.
Inputs: order, offset, search, sortBy, +2 more.
op_list_prioritiesARead-onlyIdempotent
List priority names, so a create or update can name one that exists.
Inputs: no named parameters.
op_list_projectsARead-onlyIdempotent
List the OpenProject projects visible to the API user, with their identifiers. Paginated via pageSize and offset.
Inputs: offset, pageSize.
op_list_statusesARead-onlyIdempotent
List every status name, so an update can name one that exists.
Inputs: no named parameters.
op_list_time_entriesARead-onlyIdempotent
List logged time. Hours are returned both as ISO-8601 duration and as a decimal, plus a total, so "how much time went into this" needs no arithmetic downstream.
Inputs: to, from, user, project, +2 more.
op_list_time_entry_activitiesARead-onlyIdempotent
List the time-entry activity categories (Management, Development, …) with their ids. Needed to log time under the right category.
Inputs: no named parameters.
op_list_typesBRead-onlyIdempotent
List the work package types available in a project, with their names.
Inputs: project.
op_list_usersARead-onlyIdempotent
List the principals that can own a work package, with their ids. Falls back from the project's assignable users to /principals to /users, so it works on a key that cannot read /api/v3/users. source reports which list answered.
Inputs: project.
op_list_work_packagesARead-onlyIdempotent
List work packages, newest-id last. Defaults to open items only in the default project. status must be open, closed, or all — "all" includes closed items. project: "all" searches across every project even when OPENPROJECT_PROJECT_ID is set.
Inputs: offset, search, status, project, +1 more.
op_log_timeA
Log time against a work package. Accepts decimal hours (1.5) or an ISO-8601 duration ("PT1H30M"). The entry is 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. When the local hours ledger is installed, the result includes hoursLedger (the sheet's side for this task) so double-logging across the two ledgers is visible at the moment of the write.
Inputs: hours (required), workPackage (required), comment, spentOn, +1 more.
op_update_documentAIdempotent
Update a document title and/or description. The description replaces the whole markdown body. Documents carry no lockVersion, so concurrent edits are last-write-wins — unlike work packages, the API offers no optimistic locking. The "description" {"raw": …} form documented for other resources corrupts the stored text on documents (verified on 17.3, contract unchanged through 17.7); a plain string is what the UI itself sends.
Inputs: id (required), title, description.
op_update_work_packageAIdempotent
Update a work package. lockVersion is fetched automatically, so concurrent edits fail loudly rather than silently overwriting. Notifications are off unless notify=true.
Inputs: id (required), type, notify, parent, +13 more.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 18 tools
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.
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.
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.
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
Related MCP Connectors
MCP server for MOCO business software: time tracking, projects, budgets, and invoicing via API
timesheet.io MCP server - manage timers, projects, tasks and reports
Remote MCP server to read and edit GanttFather projects: tasks, dates, dependencies, critical path.
Headless task management for bots, apps and AI agents. Remote MCP server, OAuth or access token.
Related MCP Servers
- AlicenseAqualityCmaintenanceWrite-capable MCP server for OpenProject API v3 with Community Edition support. Search, create, update, assign, prioritize, and comment on work packages.41MIT
- AlicenseNot gradedqualityDmaintenanceAn MCP server that connects Claude Desktop to your OpenProject instance, allowing you to manage projects, tasks, and time entries through natural language.MIT
- AlicenseBqualityDmaintenanceA local STDIO server that exposes OpenProject API v3 as MCP tools, allowing MCP clients like Codex CLI and GitHub Copilot CLI to reference projects and work packages.6MIT
- AlicenseAqualityDmaintenanceMCP server for OpenProject that enables AI agents to search and view work packages, add comments, upload attachments, log time, list projects, and search users via the REST API v3.10MIT