OpenProject CE MCP Server
An MCP server that lets Claude (or any MCP client) read and write an OpenProject Community instance through the API v3 — 91 typed tools, 4 report prompts and 3 resource templates.
Work packages — search by text, list with structured filters (assignee, dates, status, version, custom fields), read full detail with custom fields and children, create, update with optimistic locking, bulk preview/apply up to 50 edits, delete.
Collaboration — read a ticket's comment/journal thread, post/edit comments (internal comments on 16+), add/remove watchers, create/update/delete relations (blocks, follows, duplicates…), emoji reactions, personal reminders, and instance-defined custom actions.
Attachments & file links — list, upload, download to disk (with SHA-256, size caps, virus-scan awareness), delete, and list Nextcloud/OneDrive file links.
Projects — list/search projects, read one, create, update (status, visibility, parent, archive), copy, schedule deletion, poll background jobs, favorites, project phase definitions/records.
Saved queries — list, run a team's saved view with its own filters/grouping/sums, and save new reusable views.
Notifications — read the user's inbox, mark selected or all matching notifications read/unread.
Time tracking — list logged time with accurate totals, log time, correct entries, delete entries.
Versions & sprints — list/create/update/delete versions, list and read sprints.
People & memberships — find users/groups/placeholder users, read profiles, list memberships and roles; admin-gated membership create/update/delete.
Metadata — instance info and live connection check, valid ids per project (types, statuses, priorities, versions, categories, activities), work-package schema, permission listing, and
enable_tool_groupto restore profile-hidden groups.Module-backed groups (per README) — meetings and recurring meetings, news, documents, budgets, wiki, git/PR activity, and project reporting.
Prompts & resources —
weekly_report,daily_standup,triage_inbox,groom_backlogprompts rendered from live data; resources for a work package, a project, and attachment bytes.Safety & deployment — read-only mode, per-group disabling,
confirm=trueon destructive tools, TLS always verified, secret redaction, stdio or authenticated HTTP transport, version-adaptive probing for OpenProject 14 LTS–17.x.
Provides tools for interacting with OpenProject's API v3, enabling management of work packages, comments, relations, attachments, git/PR activity, projects, saved queries, notifications, time tracking, versions, people, memberships, meetings, news, documents, budgets, and reporting.
OpenProject MCP Server
An MCP (Model Context Protocol) server for the OpenProject API v3. It gives Claude and any other MCP client 91 tools covering work packages, comments and relations, attachments, git/PR activity, projects, saved queries, notifications, time tracking, versions, people and memberships, meetings, news, documents, budgets and reporting — plus 4 report/workflow prompts and 3 resource templates. Built on FastMCP 4.x and httpx (HTTP/2); speaks MCP 2026-07-28 and the earlier handshake versions.
This project is aimed at the OpenProject Community edition. OpenProject's Enterprise edition now ships with its own built-in MCP integration; this server brings the same capability to self-hosted Community instances. It runs fine against any edition — it only needs the public API v3.
Design principles, all enforced in code:
Structured everything. Every tool returns a typed model, so clients get an
outputSchemaand machine-readablestructuredContent, not prose. Errors come back as a JSON envelope with a stabletype, the upstreamhttp_status, amessageand ahintdescribing how to correct the call.Honest degradation. OpenProject instances differ by version, installed modules and permissions. Tools report what they could not see as in-band notes — a missing module yields an empty page with an explanation, never a fake success or a bare traceback.
Safe by default. A read-only mode, admin-gated membership writes, per-group tool disabling, a
confirm=trueguard on every destructive tool, TLS always verified, and credentials that never appear in logs.Version-adaptive. Targets OpenProject 14 LTS through 17.x; API differences are detected by a lazy, cached feature probe instead of assumptions (see Supported OpenProject versions).
What it looks like
Two exchanges against a fictional "Website Relaunch" project — every id, name and date below is invented. You describe the outcome; the model picks the tools, and what comes back to it is a typed object rather than prose to re-parse.
You What is still open in Website Relaunch and due by Friday? The checkout
bug is urgent — raise it and leave a note for whoever owns it.
→ list_work_packages(project="website-relaunch", status_scope="open",
due_before="2026-03-13", sort_by=[["due_date", "asc"]])
{ "items": [
{ "id": 4821, "subject": "Checkout fails on expired coupon codes",
"type": {"id": 7, "name": "Bug"}, "status": {"id": 7, "name": "In progress"},
"assignee": {"id": 118, "name": "Dana Okafor"}, "due_date": "2026-03-11" },
{ "id": 4830, "subject": "Move press-kit assets to the new CDN",
"type": {"id": 1, "name": "Task"}, "status": {"id": 1, "name": "New"},
"assignee": null, "due_date": "2026-03-13" }],
"pagination": {"total": 2, "page": 1, "page_size": 20, "has_more": false} }
→ update_work_package(id=4821, priority="High")
→ add_work_package_comment(id=4821, comment="Raised to High — it blocks the
launch checklist. Dana, can you take a look today?")
Assistant Two open items are due by Friday. #4821 is High now and the note is on
its thread; #4830 is due Friday and still has nobody on it.The rows above are abridged: each one also carries priority, project, start_date,
percentage_done and updated_at, and the envelope keeps groups, sums and notes slots
for the calls that fill them.
The prompt templates cut out the tool round trips: the server reads OpenProject itself and hands back a finished document rather than instructions to go and fetch one. It arrives prefaced with a note that the numbers are already computed and must not be recomputed or embellished.
You /weekly_report project=website-relaunch
# WEEKLY REPORT - AGILE SCRUM
_Generated from live OpenProject data_
...
## B. EXECUTIVE SUMMARY
**Progress against the sprint goal:** Not assessed — sprint goal and completion dates are not available
**Currently closed work updated in the window:**
1. #4802 - Ship the new pricing page
2. #4795 - Halve the hero image payload
...
### Data notes
- open/closed bucketing uses each status's isClosed flag from
GET /statuses, not status names; a status this instance renamed
or translated is still bucketed correctlyThat last block is the house style: a report says which of its numbers are partial, and a tool that could not read something returns the gap as a note instead of guessing. The whole surface is in Tools and Prompts and resources.
Related MCP server: MCP Toolbox for Databases
Requirements
Python >= 3.12
An OpenProject instance, version 14 LTS through 17.x (any edition; aimed at Community — Enterprise ships its own MCP integration)
An OpenProject API key: in OpenProject, go to My account → Access tokens and generate an API token
Installation
The distribution name is openproject-mcp-server. It installs two identical console scripts,
openproject-mcp-server and openproject-mcp; the long form is canonical (an unrelated PyPI
package also installs a bin named openproject-mcp).
Run one-shot with uv, no install step:
uvx openproject-mcp-serverOr install persistently:
uv tool install openproject-mcp-server
# or
pip install openproject-mcp-serverThe minimal configuration is two environment variables:
export OPENPROJECT_URL=https://openproject.example.com
export OPENPROJECT_API_KEY=your-api-keyValidate the configuration without starting the server:
openproject-mcp-server --check--check verifies the configuration and exits; it does not contact your instance. Once
connected through a client, call the get_instance_info tool for a live end-to-end check.
When configuration is missing or invalid, the server prints the specific problems to stderr
and exits with code 2 — never a traceback.
Claude Code
claude mcp add openproject \
--env OPENPROJECT_URL=https://openproject.example.com \
--env OPENPROJECT_API_KEY=your-api-key \
-- uvx openproject-mcp-serverClaude Desktop and other MCP clients
Add to claude_desktop_config.json (or your client's equivalent mcpServers config):
{
"mcpServers": {
"openproject": {
"command": "uvx",
"args": ["openproject-mcp-server"],
"env": {
"OPENPROJECT_URL": "https://openproject.example.com",
"OPENPROJECT_API_KEY": "your-api-key"
}
}
}
}From source
git clone https://github.com/kar-thik/openproject-mcp
cd openproject-mcp
uv sync
uv run openproject-mcp-serverUpdating or rotating your API token
When a token is regenerated, revoked or invalidated (OpenProject major upgrades can do this —
the symptom is every tool suddenly failing with authentication_failed / HTTP 401), generate
a fresh one in OpenProject under My account → Access tokens and update it wherever your
key lives:
Shell environment (easiest to rotate). The server reads
OPENPROJECT_API_KEYstraight from the OS environment, so you can export it globally — e.g. in~/.zshenv— and register the server with no--envflags at all:claude mcp add openproject -- uvx openproject-mcp-serverRotation is then: edit the export, open a fresh terminal, reconnect. The client config never contains a secret. (This does not work for GUI apps like Claude Desktop, which don't read your shell profile.)
Claude Code with
--env. The registration stores the key, so replace it:claude mcp remove openproject claude mcp add openproject \ --env OPENPROJECT_URL=https://openproject.example.com \ --env OPENPROJECT_API_KEY=new-key \ -- uvx openproject-mcp-serverThen reconnect via
/mcp(a running session keeps the old environment until it does).Claude Desktop and other JSON-configured clients. Edit the
OPENPROJECT_API_KEYvalue in the client config and restart the client..envfile. Edit the file and restart the server.
Recent OpenProject versions allow several API tokens in parallel, so you can rotate with zero downtime: create the new token, switch your clients over, then revoke the old one.
Configuration
Configuration is entirely environment-driven. The table below is the authoritative reference:
the server binds exactly these 26 names and no others. Bare, unprefixed names such as
READ_TIMEOUT or API_KEY are deliberately ignored (a stray variable in your shell cannot
change or break the server), as is any other unknown variable. A .env file in the server's
working directory is read with the same names; real environment variables take precedence.
From-source users can start from
.env.example.
Variable | Default | Purpose |
| — (required) | Instance root URL, e.g. |
| — (required*) | API key from My account → Access tokens. Sent as HTTP Basic |
| unset | OAuth bearer token, as an alternative to the API key. *One of the two credentials is required. |
| unset | Sent as the |
| system trust store | Path to a CA bundle (PEM) for instances behind a private CA. TLS is always verified; there is deliberately no off switch. |
|
| Serve read tools only: every write, destructive and admin tool is removed at startup. |
|
| Expose the three admin-gated membership write tools (hidden by default). |
| empty | Comma-separated group tags to remove whole tool groups at startup (see below). |
|
|
|
|
| Allow |
|
| Directory where |
| unset | When set, |
| unset | When set, |
|
| Size cap for attachment downloads, in MiB. |
|
| Size cap for attachment bytes returned inline by the |
|
| TTL in seconds for the metadata cache (statuses, types, priorities, schemas). |
|
|
|
|
|
|
|
| Log request/response bodies — only at DEBUG level, with credentials redacted. Development use only. |
|
| Reserved for OpenTelemetry tracing. Accepted but not yet wired to anything in this release; setting it produces no traces. |
|
| Bind address for |
|
| Port for |
| unset | Comma-separated bearer tokens accepted by |
|
| Seconds to wait for a TCP/TLS connection to OpenProject. |
|
| Seconds to wait for response data. |
|
| Seconds to wait while sending request data (uploads). |
|
| Seconds to wait for a free connection from the pool. |
|
| Connection pool size toward OpenProject. |
|
| Retry budget for idempotent requests. |
Secrets (OPENPROJECT_API_KEY, OPENPROJECT_OAUTH_TOKEN, OPENPROJECT_MCP_AUTH_TOKENS) are
held in memory as Pydantic SecretStr values and are never written to logs; the
Authorization header is redacted in every log record.
Limiting what the model can do
Four settings shrink the tool surface at startup. The tool list is then fixed for the lifetime
of the process, with one exception: enable_tool_group can bring back a group hidden by the
core profile, for the calling client (see below for what that means per protocol version).
OPENPROJECT_MCP_READ_ONLY=1serves only the 40 read tools.OPENPROJECT_MCP_ADMIN_TOOLS=1reveals the three membership write tools (create_membership,update_membership,delete_membership); they are hidden by default.OPENPROJECT_MCP_DISABLEdrops whole groups to cut prompt cost, e.g.OPENPROJECT_MCP_DISABLE=meetings,news. The valid group tags are:work_packages,wp_collaboration,attachments,git_activity,projects,queries,notifications,time_entries,versions,people,metadata,meetings,wiki,documents,budgets,news,reporting— the same tags that head each section of the tool catalog below — plusmeetings_recurring, a sub-tag ofmeetingsthat drops only the six recurring-meeting tools (meetingsdrops them too).OPENPROJECT_MCP_PROFILEdefaults tocore(since 0.4.0), which hides the module-backed groups —meetings,news,documents,wiki,budgets,git_activityandreporting, plus the two reporting prompts — so a session starts with a smaller tool list. Set it tofullto list every group from the start. When the model needs one of them it callsenable_tool_group, which restores that group and tells the client to re-read its tool list (if yours never does, setfull). Who sees the restored group depends on the client's protocol version, and the result'sscopefield names it:session— clients on theinitializehandshake (MCP 2025-11-25 and earlier): that MCP session only. Over HTTP a session ends after 30 minutes idle.token— MCP 2026-07-28 clients over HTTP, which have no session: every connection presenting the same bearer token. Give each user their own token inOPENPROJECT_MCP_AUTH_TOKENSif they should not share a tool list. WithOPENPROJECT_MCP_INSECUREnothing identifies the client, so the call is refused; runfullthere (the server warns at startup).process— MCP 2026-07-28 clients over stdio: the whole server process, which serves one client anyway.
Enabled groups are forgotten after 24 hours idle.
enable_tool_groupnever liftsREAD_ONLY, the admin gate orOPENPROJECT_MCP_DISABLE; it lives in themetadatagroup, so disabling that group removes it.
Independent of all four, every destructive tool (the 13 permanent deletes) requires an
explicit confirm=true argument before it acts.
Transports
stdio (default) — what Claude Code, Claude Desktop and most local MCP clients use. Logs go to stderr; stdout carries only the protocol.
Streamable HTTP —
openproject-mcp-server --transport http. Binds127.0.0.1:8000by default; override with--host/--portorOPENPROJECT_MCP_HTTP_HOST/OPENPROJECT_MCP_HTTP_PORT. The HTTP transport refuses to start without authentication configured: setOPENPROJECT_MCP_AUTH_TOKENSto a comma-separated list of bearer tokens, or — for local development only — setOPENPROJECT_MCP_INSECURE=1.With tokens configured, every HTTP request to the MCP endpoint must carry an
Authorization: Bearer <token>header whose token matches one of the configured values (compared in constant time); requests with a missing, malformed or unknown token are rejected with a 401 and aWWW-Authenticateheader. Multiple tokens are supported — for example one per client, so each can be revoked independently. Tokens must be ASCII, and the endpoint applies no rate limiting, so use long random values — for examplepython -c "import secrets; print(secrets.token_urlsafe(32))".OPENPROJECT_MCP_INSECURE=1remains a development-only escape hatch that leaves the endpoint open without authentication.The server does not terminate TLS, so tokens would otherwise cross the network in plaintext: keep the default
127.0.0.1bind, and if you must expose the HTTP transport beyond localhost, put it behind a TLS-terminating reverse proxy (for example nginx or Caddy).One upstream identity. Bearer tokens decide who may reach the endpoint, not who they are in OpenProject: the server holds a single
OPENPROJECT_API_KEY(or OAuth token), and every HTTP client acts as that OpenProject user and shares its metadata cache. Deploy it for clients you would trust with that account, and issue a dedicated, least-privileged OpenProject user for it.File tools touch the server's disk.
upload_attachment,create_work_package (attachment_paths)anddownload_attachmentread and write paths on the machine running the server. Over HTTP they are off until you confine them:upload_attachmentandattachment_pathsneedOPENPROJECT_MCP_UPLOAD_ROOT,download_attachmentneedsOPENPROJECT_MCP_DOWNLOAD_ROOT. Until then the tool is hidden (andattachment_pathsis refused), and the server names the missing variable in a startup warning.
Tools
91 tools: 42 read, 46 write and 3 admin-gated writes. The admin tools stay hidden unless
OPENPROJECT_MCP_ADMIN_TOOLS=1; the 13 destructive tools additionally require confirm=true
on every call. Each section heading names the group tag accepted by
OPENPROJECT_MCP_DISABLE.
Work packages (work_packages)
Tool | Kind | What it does |
| Read | Find work packages by text when you do not know their ids. |
| Read | List work packages with structured filters — the workhorse read tool. |
| Read | Read one work package in full: description, dates, custom fields, parent and progress. |
| Write | Create a work package, validated through OpenProject's own form endpoint first. |
| Write | Change any writable field of a work package, with optimistic locking. |
| Write | Preview or apply up to 50 updates with per-item diffs, validation and conflict results. |
| Write (destructive) | Permanently delete a work package and everything attached to it. |
Batch workflow: call bulk_update_work_packages(updates=[{"id": 4821, "changes": {"target_versions": [3, 4]}}]) to preview. Review the returned changes,
then repeat with dry_run=false and each item's returned lock_version included
in updates. Any preflight error prevents the whole batch from writing. A later
conflict can produce partial success; inspect every item and never replay successful
updates. unknown means the write may have committed before the response was lost.
Comments, relations, watchers, reminders (wp_collaboration)
Tool | Kind | What it does |
| Read | Read the comment thread and change history of a work package. |
| Write | Post a comment on a work package. |
| Write | Rewrite the text of an existing work-package comment. |
| Write | Subscribe a user to a work package's notifications. |
| Write | Unsubscribe a user from a work package's notifications. |
| Write | Link two work packages (blocks, follows, duplicates, relates, ...). |
| Write | Change an existing relation's type, lag or description. |
| Write (destructive) | Remove the link between two work packages. |
| Write | React to a work-package comment with an emoji, or take the reaction back. |
| Write | Set, change or clear your personal reminder on a work package. |
| Read | List your own upcoming work-package reminders. |
| Write | Run an instance-defined one-click action on a work package. |
Attachments and file links (attachments)
Tool | Kind | What it does |
| Read | List the files attached to one container. |
| Read | Download an attachment's bytes to a file on the machine running this server. |
| Write | Attach a local file to a work package, wiki page, meeting, document, budget or comment. |
| Write (destructive) | Permanently delete one attached file from OpenProject. |
| Read | List the external-storage files (Nextcloud, OneDrive/SharePoint) linked to a work package. |
Git and pull requests (git_activity)
Tool | Kind | What it does |
| Read | Show the code behind a work package: commits, pull/merge requests and CI status. |
| Read | Read one linked GitHub pull request in full, including its CI check runs. |
Projects (projects)
Tool | Kind | What it does |
| Read | List projects, filtered server-side, one page at a time. |
| Read | Read one project in full. |
| Write | Create a project, validated through OpenProject's own form endpoint first. |
| Write | Change a project's name, description, visibility, parent, status or archived state. |
| Write (destructive) | Schedule the permanent deletion of a project and everything inside it. |
| Write | Copy a project — its settings, and optionally its work packages — into a new one. |
| Read | Check whether a background job (a project copy, a scheduled deletion) has finished. |
| Write | Add or remove a project from the authenticated user's favorites (OpenProject 17+). |
| Read | List the instance-wide phase catalog (Initiating, Planning, ...) with start/finish gates (OpenProject 16.1+). |
| Read | Read one project's phase record; ids come from a work package's |
Saved queries (queries)
Tool | Kind | What it does |
| Read | List the saved work-package views (queries) this user can open. |
| Read | Run a saved view and get its work packages as they are right now. |
| Write | Save a filter set as a reusable OpenProject view the whole team can open. |
Notifications (notifications)
Tool | Kind | What it does |
| Read | Read the authenticated user's OpenProject inbox. |
| Write | Mark specific notifications read (or unread) in one bulk request. |
| Write | Mark everything matching the filters as read — the whole inbox by default. |
Time tracking (time_entries)
Tool | Kind | What it does |
| Read | List logged time, filtered server-side, with an optional accurate total. |
| Write | Book time against a work package or a project. |
| Write | Correct an existing time entry. |
| Write (destructive) | Permanently delete a logged time entry. |
Versions and sprints (versions)
Tool | Kind | What it does |
| Read | List versions (releases, milestones, sprints) you can assign work packages to. |
| Read | List sprints (Scrum timeboxes) you can plan work packages into. |
| Read | Read one sprint in full: dates, workspace and lifecycle status. |
| Write | Create a version (release, milestone or sprint) inside a project. |
| Write | Change a version's name, dates, description, status or sharing. |
| Write (destructive) | Permanently delete a version. |
People and memberships (people)
Tool | Kind | What it does |
| Read | Find users, groups and placeholder users, and get their ids. |
| Read | Read one user's profile: name, login, email, admin flag and status. |
| Read | List who has access to which project, and with which roles. |
| Admin write | Grant a principal one or more roles in a project. |
| Admin write | Replace the roles of an existing membership. |
| Admin write (destructive) | Revoke a principal's access to a project. |
| Read | List the roles this instance defines, with their ids. |
The three membership write tools require OPENPROJECT_MCP_ADMIN_TOOLS=1 (and an OpenProject
account with the Manage members permission).
Instance metadata and schemas (metadata)
Tool | Kind | What it does |
| Read | Check the OpenProject connection and report what this instance supports. |
| Read | List the ids and names (types, statuses, priorities, ...) that are actually valid on this instance. |
| Read | Show which fields a work package of this type accepts in this project. |
| Read | List what the authenticated user is allowed to do, globally or in one project. |
| Read | Re-enable a tool group hidden by |
Meetings (meetings)
The six recurring-meeting tools also carry the meetings_recurring tag, so
OPENPROJECT_MCP_DISABLE=meetings_recurring drops just them (they need OpenProject 17.4+).
Tool | Kind | What it does |
| Read | List meetings: what is coming up, what already ran. |
| Read | Read one meeting in full: participants, the agenda, and any recorded outcomes. |
| Write | Schedule a meeting in a project and optionally invite participants. |
| Write | Change a meeting's title, time, place or invite list — or move its lifecycle state. |
| Write (destructive) | Permanently delete a meeting, together with its agenda and recorded outcomes. |
| Write | Add one item to a meeting's agenda, optionally pinned to a work package. |
| Write | Edit one agenda item: retitle it, rewrite its notes, retime, reorder or re-link it. |
| Write (destructive) | Permanently delete one agenda item, with any outcomes recorded against it. |
| Write | Record an outcome — a decision, a note, a follow-up ticket — against an agenda item. |
| Write | Correct a recorded outcome's kind, text or linked work package. |
| Write (destructive) | Permanently delete a recorded outcome from a running meeting's minutes. |
| Read | List recurring meeting series — the repetition rules, not the individual meetings. |
| Read | Read one recurring series in full: the schedule plus its next occurrences. |
| Write | Create a recurring meeting series: a schedule plus a template the occurrences copy. |
| Write (destructive) | Permanently delete a recurring series: template, schedule, and every occurrence. |
| Write | Materialize one occurrence of a series as a real meeting, copied from the template. |
| Write (destructive) | Cancel one occurrence of a series — skip a slot without touching the schedule. |
Wiki (wiki)
Tool | Kind | What it does |
| Read | Read a wiki page's identity and project — not its content (API v3 does not expose page bodies). |
Documents (documents)
Tool | Kind | What it does |
| Read | List the documents visible to you, across every project. |
| Read | Read one document with its full description text. |
Budgets (budgets)
Tool | Kind | What it does |
| Read | List a project's budgets — their ids and names, which is all API v3 exposes. |
News (news)
Tool | Kind | What it does |
| Read | List project news — the announcements a team publishes on its project overview. |
| Read | Read one news entry in full, including the markdown body. |
| Write | Publish a news announcement in a project. |
| Write | Correct or rewrite a published news entry. |
| Write (destructive) | Permanently delete a news entry. |
Reporting (reporting)
Tool | Kind | What it does |
| Read | Aggregate a status report for one project and date window: exact counts, sampled rows with a drill-down call, and a state per section. |
Prompts and resources
Report data uses closed_updated for currently closed work updated in the requested
window. This replaces the former closed field in v0.3.0. It does not establish when
work was completed; sprint health remains unassessed without supporting data.
Four prompt templates render live OpenProject data into ready-to-use briefings:
weekly_report — a weekly status report for one project: currently closed and updated / in progress / planned, hours and impediments.
daily_standup — today's standup for one project: yesterday's movement, what is due today, and what is blocked.
triage_inbox — groups your unread notifications by reason and suggests actions.
groom_backlog — sweeps a project's open backlog for unassigned, stale and unestimated work.
Three resource templates expose OpenProject objects at stable URIs:
openproject://work_package/{id}— one work package as JSON.openproject://project/{identifier}— one project as JSON (identifier slug or numeric id).openproject://attachment/{id}— the attachment's bytes with the detected MIME type, up toOPENPROJECT_MCP_MAX_INLINE_MB.
Clients that support argument completion get suggestions while typing: project
identifiers for the project argument of weekly_report, daily_standup and
groom_backlog and for the project resource, and the available locales for
weekly_report.
Supported OpenProject versions
The server targets OpenProject 14 LTS through 17.x. Instead of assuming one API dialect, it probes the instance lazily on first need and caches the result for an hour. The version-dependent surfaces:
Target versions: work-package details expose
target_versionson every supported instance. Create/update accept a list of version ids ([]clears); the schema selectstargetVersionson 17.8+ or a single legacyversion. The oldversionargument remains a single-value alias; do not pass both. On reads it is null for multiple assignments.Internal (private) comments need OpenProject >= 16. Older servers silently ignore the internal flag, so below 16 the server refuses with a clear error rather than posting a comment publicly that you asked to keep internal.
Emoji reactions (
toggle_comment_reaction) need OpenProject >= 16; detected from the version, tolerant of a 404 at call time.Project favorites (
set_project_favorite) need OpenProject >= 17; same tolerance.Time-entry filters: the work-package filter is probed (
entityId, falling back to the pre-15.xworkPackagespelling).Permission contexts (
list_permissions): the context prefix is probed (p{id}, falling back to thew{id}spelling introduced in 17.2).Meetings time filter: OpenProject 17.6 changed the wire dialect;
list_meetingsdiscovers which spelling the instance accepts and caches it.Story points need the Backlogs module and a type Backlogs treats as a story. 17.x refuses
story_pointsotherwise; 14.x accepts the write and silently drops the value, so the result carries a note saying it was not saved (bulk previews warn before writing).
Features that depend on optional instance modules (meetings, news, documents, budgets, wiki, backlogs, GitHub/GitLab integration, external storages) degrade honestly when the module is absent: list tools return an empty page with an in-band note naming the missing module, and detail tools return a structured error explaining both possible readings of the 404.
Security
The API key and OAuth token are held as
SecretStrand never logged;Authorizationand cookie headers are redacted from every log record. Request/response bodies are only logged at DEBUG level withOPENPROJECT_MCP_LOG_BODIES=1, for development.The
Authorizationheader is stripped whenever a redirect leaves the OpenProject origin — attachment downloads redirect to presigned object-storage URLs, and the credential must not travel there.TLS is always verified. Private CAs are supported via
OPENPROJECT_MCP_CA_BUNDLE; an insecure-TLS switch deliberately does not exist.Attachment downloads respect OpenProject's virus scanner: quarantined files are never fetched and produce a structured
attachment_quarantinederror. Downloads are capped byOPENPROJECT_MCP_MAX_DOWNLOAD_MB, stored under sanitized file names (directory separators and traversal sequences are neutralized), and reported with a SHA-256 of the bytes.Destructive tools require
confirm=trueand are annotated so clients can ask the user first.The HTTP transport refuses to start without
OPENPROJECT_MCP_AUTH_TOKENSconfigured and verifies theAuthorization: Bearerheader on every request — missing or invalid tokens are rejected with a 401, and token comparison is constant-time. The server does not terminate TLS: keep the default127.0.0.1bind, and put a TLS-terminating reverse proxy in front if the port must be reachable from anywhere else (see Transports). All HTTP clients share the server's one OpenProject identity; confine the file tools withOPENPROJECT_MCP_UPLOAD_ROOT/OPENPROJECT_MCP_DOWNLOAD_ROOT.Downloads are created exclusively (never overwriting an existing file, even under concurrent downloads of the same name), and a failed or cancelled transfer removes only the file it created.
Troubleshooting
Symptom | What it means and what to do |
Server exits with "cannot start, configuration is incomplete" | Run |
401 authentication_failed | The API key is wrong, revoked, or belongs to a blocked account. Generate a fresh token under My account → Access tokens. |
403 permission_denied | The account is authenticated but lacks a role permission for that project, or the relevant module is disabled for it. |
Empty list plus a note about a missing module | That OpenProject module (meetings, news, documents, ...) is not installed or not enabled in the project — the empty result is the honest answer, not an error. |
TLS certificate errors | Your instance uses a private CA: point |
| Set |
401 from the MCP HTTP endpoint itself | The request's |
Proxies | Standard |
When reporting an issue, please include your OpenProject version, the output of
openproject-mcp-server --version, and the output of openproject-mcp-server --check.
Development
git clone https://github.com/kar-thik/openproject-mcp
cd openproject-mcp
uv sync --group dev
uv run pytest # entire suite runs offline (respx-mocked HTTP), no instance needed
uv run ruff check .
uv run ruff format --check .
uv run pyright # strict modeThe default suite stays offline. Opt-in compatibility checks run against fresh official OpenProject 14.6, 15.5, 16.6 and 17.8 containers in the Compatibility workflow (including both single- and multiple-version modes on 17.8). To run one locally with Docker running:
uv run python scripts/live_smoke.py --image openproject/openproject:17.8.0The harness seeds a private project, temporary users, custom fields and versions; it tests
through MCP, then removes the container and its volumes. Existing instances and credentials
are not used. Direct pytest runs skip these tests unless --live-openproject is supplied
with a disposable fixture file. The
full technical specification lives in
SPEC.md.
Releasing is documented in CONTRIBUTING.md.
License and trademark
MIT — see LICENSE.
This is a community project. It is not affiliated with, endorsed by, or supported by OpenProject GmbH. "OpenProject" is a trademark of OpenProject GmbH and is used here only to indicate interoperability.
Available Tools
59 toolsadd_work_package_commentAdd work package commentA
Post a comment on a work package.
Use this to reply in a ticket's thread, record a decision, or leave a
handover note. Returns the created journal entry (activity id, author,
markdown text, internal flag, timestamps) — the same shape
list_work_package_comments returns, so the id can be reused.
Pitfalls. Every call creates a new comment; it is not idempotent, so do
not retry blindly after a timeout — read the thread first.
internal=true is refused on OpenProject below 16.0 because those
versions accept the flag and publish the comment anyway; upgrade or post
publicly, deliberately. notify=false suppresses notifications only,
the comment is still visible to everyone who can see the work package.
Cross-references: read the thread with list_work_package_comments;
change fields (status, assignee, dates) with update_work_package
rather than describing the change in prose; attach a file with
upload_attachment.
Inputs: id (required), comment (required), notify, internal. Returns: id, kind, author, comment, +8 more
add_work_package_watcherAdd work package watcherAIdempotent
Subscribe a user to a work package's notifications.
Use this when someone should be kept in the loop on a ticket without being assigned to it. Watchers receive OpenProject's notifications for comments and changes. Returns the watcher (user id and name), the watch state after the call, and whether this call actually changed anything.
Pitfalls. The user must already be able to see the work package;
OpenProject answers 422 with a violation on user otherwise, and
adding someone other than yourself needs the add-work-package-watchers
permission (adding yourself only needs view access). Calling twice is
harmless: the second call reports changed: false. Watching is not
assignment — use update_work_package(assignee=...) for that.
Cross-references: get_work_package(include=['watchers']) lists who
already watches; remove_work_package_watcher is the reverse;
list_work_package_comments shows what watchers are being notified
about.
Inputs: user_id (required), work_package_id (required). Returns: user, changed, message, watching, +1 more
bulk_update_work_packagesPreview or apply work package updatesA
Preview or apply up to 50 work-package updates, each with its own changes.
First call with dry_run=true (default). Review each before/after diff and validation error, then submit the same updates with dry_run=false and each preview's lock_version. Omitted fields remain untouched; null clears clearable fields; target_versions=[] clears version assignments. Status/type/priority accept names or ids, as in update_work_package.
All items are validated before any update. If any preflight fails, nothing is applied; correct those items or submit a valid subset. Execution is sequential and NOT atomic: concurrent edits can still conflict after validation. Each item reports its result; partial_failure is true when some apply and others fail. Item notes flag values OpenProject will ignore or ignored, such as story_points without Backlogs. An unknown outcome means a connection/server failure may have happened after committing: re-read that item before retrying. Writes are never automatically retried or rolled back. Never rerun successful items. Notifications use the same notify flag for the whole batch.
Inputs: updates (required), notify, dry_run. Returns: items, notes, total, applied, +2 more
copy_projectCopy projectA
Copy a project — its settings, and optionally its work packages — into a new one.
Use it to spin a new engagement or release off a template project, which is the only
way to reproduce a project's members, versions, categories and enabled modules in one
call. The request goes through POST /projects/{id}/copy/form first, so a name that
derives a taken identifier comes back as violations naming the attribute instead of
a failed background job.
Copying is ASYNCHRONOUS: OpenProject queues a job and answers immediately. This tool
therefore returns {scheduled: true, job_id, status, message, notes} and NEVER
claims the copy exists — a large project takes minutes. Poll
get_job_status(job_id=...) until status is 'success' (it then reports the new
project) or 'failure'.
Pitfalls: only include_work_packages and notify are exposed; every other copy
flag (members, versions, wiki, boards, file links) keeps this instance's own default,
which the form fills in — so the copy can contain more than the two parameters
suggest. A 403 means the account may not copy this project (it needs the 'copy project'
permission on the template plus the right to create projects). Work packages come
across with their relations, but time entries and comment histories do not.
Cross-references: get_job_status follows the job to completion; list_projects
or get_project confirms the result; create_project makes an empty project
instead; update_project renames the copy afterwards.
Inputs: new_name (required), id_or_identifier (required), notify, include_work_packages. Returns: notes, job_id, source, status, +4 more
create_projectCreate projectA
Create a project, validated through OpenProject's own form endpoint first.
Use it for a new workspace or, with parent_id, for a subproject of an existing
one. The call runs POST /projects/form before committing, so a taken identifier,
an unusable parent or a bad status comes back as violations naming the attribute
instead of an opaque failure — and the identifier OpenProject derives from the name
is used verbatim on the commit.
Returns the created project: {id, identifier, name, active, public, parent, status_code, description, status_explanation, created_at, updated_at}. Keep the
id — every other tool's project_id accepts it, as does the identifier.
Pitfalls: creating projects usually requires the 'create project' permission or
admin rights, so a 403 here is about the account, not the payload. The new project
starts with the instance's default modules and types enabled — check
get_project_metadata(project_id=...) before creating work packages in it.
Members are not copied from the parent; add them with create_membership
(admin-gated: hidden unless the server sets OPENPROJECT_MCP_ADMIN_TOOLS=1).
Cross-references: list_projects finds the parent id; update_project changes
any of these fields afterwards; get_project_metadata lists the types, versions
and categories valid inside the result.
Inputs: name (required), public, parent_id, identifier, +2 more. Returns: id, name, active, parent, +8 more
create_versionCreate versionA
Create a version (release, milestone or sprint) inside a project.
Use it to open a new sprint or plan a release before assigning work packages to it.
The call goes through POST /versions/form first, so a duplicate name or an
impossible date range comes back as violations naming the attribute instead of an
opaque rejection.
Returns the created version {id, name, project, status, start_date, end_date, description, sharing, source, created_at, updated_at}. The id is what
update_work_package(version=...) and update_version consume.
Pitfalls: end_date is the version's finish date and is written to the API's
endDate field — passing a date here always lands (an older client dropped it
silently). Creating versions needs the 'manage versions' permission in the project,
so a 403 is about the account, not the payload. Versions are per project: sharing is
the only way another project sees this one.
Cross-references: list_versions for what already exists (and for the ids);
update_version to change dates or close it later; list_projects for the
project id.
Inputs: name (required), project_id (required), status, sharing, +3 more. Returns: id, name, source, status, +7 more
create_work_packageCreate work packageA
Create a work package, validated through OpenProject's own form endpoint first.
Use it for new tasks, bugs, subtasks (parent_id) and milestones (date). The form
pre-flight surfaces an invalid status, a missing required custom field or a disallowed
type as structured violations with the allowed values, before anything is written.
Returns the created work package in full detail, including its new id, lock_version
and resolved custom fields.
Pitfalls: type, status and priority take names or ids; versions, assignees and
parents need numeric ids. Custom fields must exist on the schema — check
get_work_package_schema when unsure.
To change it afterwards use update_work_package; to attach a file to an existing work
package use upload_attachment.
Inputs: type (required), project (required), subject (required), date, +17 more. Returns: id, date, type, notes, +27 more
create_work_package_relationCreate work package relationA
Link two work packages (blocks, follows, duplicates, relates, ...).
Use this to record a dependency the schedule or the reader needs to know about: "ship the client layer follows design sign-off", "this duplicates #4321". Returns the created relation with its id, type, reverse_type, both work packages, lag and description.
Pitfalls. OpenProject stores one canonical direction per pair, so the
passive spellings are rewritten on save: creating precedes from A to
B comes back as B follows A, with from_work_package and
to_work_package swapped. That is the same fact, not an error — read
type and reverse_type from the result rather than assuming what
you sent. Only one relation may
exist between two work packages: a second one answers 409 conflict, and
changing it means update_work_package_relation on the existing id. A
relation that would close a scheduling cycle is rejected with a
validation error. Creating a follows relation can move dates, since
OpenProject reschedules the successor.
Cross-references: get_work_package(include=['relations']) lists what
a work package is already linked to and produces relation ids;
update_work_package_relation edits one;
delete_work_package_relation removes it; parent/child hierarchy goes
through update_work_package(parent_id=...).
Inputs: type (required), to_id (required), from_id (required), lag, +1 more. Returns: id, lag, type, description, +3 more
delete_attachmentDelete attachmentADestructive
Permanently delete one attached file from OpenProject.
Use it only on explicit user instruction, for example to remove a file uploaded to the wrong work package or a superseded document. The attachment's metadata is read first so the result names the file and the container it was attached to, and so an unknown id fails before anything is removed.
Returns the attachment id, the file name, its container and a confirmation message.
Pitfalls: this deletes the file itself, not a link to it — every work package, wiki page or comment that embedded it loses the image or download. Deleting needs edit permission on the container (or authorship for a file that has no container yet), so a 403 can follow a successful read. A 404 means the id is unknown or already deleted; a second call on the same id answers 404 rather than succeeding. Removing a file does not remove the comment or work package that referenced it.
Related: list_attachments shows the ids and file names of everything a container holds; upload_attachment adds a replacement; download_attachment saves a copy first if the bytes are still wanted.
Inputs: attachment_id (required), confirm. Returns: id, deleted, message, container, +1 more
delete_projectDelete projectADestructive
Schedule the permanent deletion of a project and everything inside it.
Use only on an explicit, specific instruction. Deletion CASCADES: every subproject,
work package, comment, attachment, time entry, version, wiki page and membership of
this project goes with it, and OpenProject offers no API-side undo. If the goal is
only to get the project out of the way, update_project(active=false) archives it
instead — reversible, and it preserves the data.
Deletion is ASYNCHRONOUS upstream: OpenProject accepts the request and runs it as a
background job. This tool therefore returns {scheduled: true, job_id, message},
never a claim that the project is already gone — for a large project the data
disappears over minutes and get_project may still answer during that window.
Pitfalls: deleting normally requires admin rights (403 otherwise). A 404 means the
id or identifier is wrong, or the project was already deleted. Because the work runs
in the background, a later failure inside the job is not visible here; confirm with
get_project (it should eventually 404) rather than assuming success.
Cross-references: get_project to check what you are about to destroy;
list_projects(parent_id=...) to see the subprojects that would go with it;
update_project(active=false) for the reversible alternative.
Inputs: id_or_identifier (required), confirm. Returns: id, job_id, message, scheduled
delete_time_entryDelete time entryADestructive
Permanently delete a logged time entry.
Use only on explicit user instruction, and only for genuinely wrong entries. Deletion removes the booked hours from every cost report and from the work package's aggregated spent time, with no API-side undo.
Returns a small confirmation object once OpenProject accepts the deletion.
Pitfalls. If the entry is merely on the wrong day, has the wrong
duration or the wrong activity, update_time_entry is the better
answer — it keeps the audit trail. Deleting someone else's entry needs
an administrative permission and otherwise fails with
permission_denied. Entries inside a closed cost-reporting period
cannot be deleted.
Cross-references: find the id with list_time_entries; correct
instead of deleting with update_time_entry.
Inputs: time_entry_id (required), confirm. Returns: id, deleted, message
delete_versionDelete versionADestructive
Permanently delete a version.
Use it only for a version created by mistake. For a finished release or sprint,
update_version(status='closed') is almost always the right answer: it keeps the
history and stops new assignments.
Returns a small confirmation once OpenProject accepts the deletion. Work packages are NOT deleted — they simply lose their version — but that only happens on instances that allow the deletion at all.
Pitfalls: OpenProject refuses (422) to delete a version that work packages still reference; the error hint explains how to find them. Deleting a shared version affects every project that used it. The 'manage versions' permission is required, so a 403 is about the account.
Cross-references: list_versions for the id; update_version(status='closed')
for the reversible alternative; list_work_packages to find what still points at
the version before removing it.
Inputs: version_id (required), confirm. Returns: id, deleted, message
delete_work_packageDelete work packageADestructive
Permanently delete a work package and everything attached to it.
Use only on explicit user instruction. Deletion removes the work package with its comments, attachments, time entries and relations, and OpenProject offers no API-side undo.
Returns a small confirmation object once OpenProject accepts the deletion.
Pitfalls: children are not deleted with the parent, so check
get_work_package(id, include=['children']) first and decide what happens to them. If you
only want the work package out of the way, update_work_package(id, status=<a closed status>) is almost always the better answer — get_project_metadata lists which statuses
this instance treats as closed.
Inputs: id (required), confirm. Returns: id, deleted, message
delete_work_package_relationDelete work package relationADestructive
Remove the link between two work packages.
Use it when a dependency no longer holds. Neither work package is touched, only the relation between them. Returns a small confirmation object.
Pitfalls. Deleting a follows relation drops the scheduling
constraint, so OpenProject may reschedule the work package that was
waiting. There is no undo; recreating the relation with
create_work_package_relation is the only way back. A second delete
of the same id answers 404. Parent/child hierarchy is not a relation —
clear it with update_work_package(parent_id=null).
Cross-references: get_work_package(include=['relations']) shows what
would be removed; update_work_package_relation changes a relation
instead of removing it.
Inputs: relation_id (required), confirm. Returns: id, deleted, message
download_attachmentDownload attachmentARead-only
Download an attachment's bytes to a file on the machine running this server.
Use it once list_attachments (or get_work_package(include=['attachments'])) has given you an attachment_id. Metadata is read first, then the bytes are streamed to disk in chunks with progress notifications, so a large file neither stalls the call nor buffers in memory.
Returns path, file_name, size_bytes, content_type and the SHA-256 of the bytes (use it to verify or de-duplicate). With return_image=true an image of at most 1 MB comes back as an inline image block as well.
Pitfalls: the file is written on the server's machine, which is the user's machine only in a local (stdio) deployment — tell the user the returned path rather than assuming they can see it. Quarantined attachments fail with attachment_quarantined and are never fetched. An attachment whose virus scan is unfinished answers 401 for everyone except its uploader. Transfers above OPENPROJECT_MCP_MAX_DOWNLOAD_MB (default 100) are refused up front and aborted mid-stream, leaving no partial file. A name collision in the target directory saves as 'name (2).ext' and says so in notes.
Related: list_attachments produces attachment_id; upload_attachment is the reverse direction.
Inputs: attachment_id (required), save_dir, return_image. Returns: path, notes, sha256, file_name, +2 more
edit_work_package_commentEdit work package commentAIdempotent
Rewrite the text of an existing work-package comment.
Use this to fix a typo, correct a wrong statement, or extend a note you
just posted. Returns the updated journal entry (activity id, author,
markdown text, internal flag, timestamps) in the same shape
list_work_package_comments returns.
Pitfalls. Only comment entries are editable: the journal also holds field-change entries ("Status changed from New to In progress"), which OpenProject records automatically and refuses to alter — this tool rejects those locally, before any write. Editing needs the edit-work-package-comments permission (or edit-own for your own comments); a 403 means the account may read the thread but not rewrite it. The edit replaces the text entirely and OpenProject keeps no API-visible history of the previous version, so do not use it to "undo" — post a correcting comment when the record matters. Editing does not notify anyone.
Cross-references: read the thread and get activity ids with
list_work_package_comments; post a new comment with
add_work_package_comment; change fields (status, assignee, dates)
with update_work_package instead of describing them in prose.
Inputs: comment (required), activity_id (required). Returns: id, kind, author, comment, +8 more
enable_tool_groupEnable tool groupARead-onlyIdempotent
Re-enable a tool group hidden by OPENPROJECT_MCP_PROFILE=core.
Use it when the user needs meetings, wiki, news, documents, budgets, git activity or reporting and those tools are not in your list. It never calls OpenProject.
Returns {group, title, status, tools_enabled, notes, scope}; status is
'enabled' or 'already_visible'. Re-read your tool list before calling the new tools.
Pitfalls: it only lifts the core profile. It cannot override read-only mode (write
tools stay hidden, see notes), the admin gate, or groups the operator removed with
OPENPROJECT_MCP_DISABLE, which fail with invalid_input. scope says who else sees
the group: this session, everyone on the same bearer token, or this stdio process.
Cross-references: get_instance_info shows which modules this instance supports.
Inputs: group (required). Returns: group, notes, scope, title, +2 more
execute_custom_actionExecute custom actionA
Run an instance-defined one-click action on a work package.
Custom actions are shortcuts an administrator configured — "Accept and
assign to me", "Reject", "Move to review" — that apply several field
changes at once, sometimes under conditions (role, status, project).
Use one when get_work_package(include=['custom_actions']) offers it,
instead of reproducing its effects field by field. Returns the updated
work package row (subject, type, status, priority, assignee, project,
dates, percentage_done, updated_at).
Pitfalls. The action decides what changes; this tool cannot influence it,
and OpenProject does not report which fields it touched — compare the
returned row with what you read before, or call get_work_package
again for the full detail (including the new lock_version for your
next update). Availability is per work package: an action listed on one
ticket may 403 on another because its conditions no longer hold, and a
422 usually means the resulting work package would be invalid (a
required field the action leaves empty). Writes are never retried
automatically — a conflict comes back with the fresh lock_version so
you can re-read and decide.
Cross-references: get_work_package(include=['custom_actions'])
produces the ids and says which are available right now;
update_work_package is the explicit alternative when you know exactly
which fields to set; list_work_package_comments shows what the action
recorded in the journal.
Inputs: work_package_id (required), custom_action_id (required), lock_version. Returns: id, type, status, project, +8 more
get_instance_infoGet instance infoARead-onlyIdempotent
Check the OpenProject connection and report what this instance supports.
Call this first when anything fails in an unexplained way, when the user asks "am I connected / who am I", or before using a version-gated parameter. It is the server's connection test: it authenticates on every call rather than answering from cache.
Returns the core version and instance name, the attachment size ceiling
(maximum_attachment_file_size_bytes), the page sizes the instance offers, the
authenticated user {id, name, login, admin}, and features — the probe result
telling you whether internal comments, emoji reactions and project favorites exist
here, and which time-entry filter spelling this version uses.
Pitfalls: features describes the server version, not this user's permissions —
a supported feature can still 403. A failure here is the actionable one: 401 means
the API key is wrong or revoked, a network error means the URL, DNS, proxy or TLS
trust is wrong; both come back with a hint naming the environment variable to fix.
For per-project ids (types, statuses, priorities, versions, categories, activities)
use get_project_metadata; for what the current user may do use list_permissions.
Inputs: no named parameters. Returns: api_url, features, core_version, current_user, +3 more
get_job_statusGet background job statusARead-onlyIdempotent
Check whether a background job (a project copy, a scheduled deletion) has finished.
OpenProject runs copies, deletions and exports asynchronously and hands back a job id.
This is the only way to learn what happened to one: call it after copy_project or
delete_project and wait for a terminal state before reporting an outcome to the
user.
Returns {id, status, finished, successful, message, project, result_url, notes}.
status is 'in_queue' or 'in_process' while the job runs and 'success', 'failure',
'error' or 'cancelled' once it is over; finished and successful are derived
from it, and successful stays null while the job runs rather than defaulting to
false. A finished copy reports the new project in project and the URL it lives at
in result_url.
Pitfalls: a 200 does not mean the job worked — read status. Polling is on you:
wait a few seconds between calls rather than looping tightly. OpenProject drops job
statuses after a while, so a 404 can mean 'long finished' as easily as 'wrong id';
confirm with get_project or list_projects. When a job fails, message is
what OpenProject recorded — there is no API to retry it, so the underlying tool has to
be called again deliberately.
Cross-references: copy_project and delete_project produce the job_id;
get_project / list_projects verify what the job actually did.
Inputs: job_id (required). Returns: id, notes, status, message, +4 more
get_projectGet projectARead-onlyIdempotent
Read one project in full.
Use it after list_projects when you need the description, the status explanation
or the parent of a specific project — or to verify that an id or identifier a user
gave you actually resolves.
Returns {id, identifier, name, active, public, parent, status_code, workspace_type, description, status_explanation, created_at, updated_at}. Rich
text comes back as markdown raw; html is dropped.
Pitfalls: status_code is one of on_track, at_risk, off_track, not_started,
finished, discontinued — an empty status_code means the project has no status
set, not "on track". A 404 here means the id/identifier is wrong or the project is
archived and invisible to this user; the error hint says which spelling to try next.
For the ids valid inside this project (types, versions, categories, time-entry
activities) call get_project_metadata(project_id=...); for its work packages call
list_work_packages(project=...).
Inputs: id_or_identifier (required). Returns: id, name, active, parent, +8 more
get_project_metadataGet project metadataARead-onlyIdempotent
List the ids and names that are actually valid on this instance.
This is the one-call answer to "what do I pass for type / status / priority / version / category / activity". Call it before any create or update, before filtering by ids, and whenever a write fails with an allowed-values error. Nothing here is hardcoded — priority ids and activity ids differ per instance.
Without project_id returns the global types, statuses, priorities and
roles. With project_id the types list narrows to the ones enabled in that
project and versions, categories and time_entry_activities are filled in.
Every row is {id, name} plus its flags: statuses[].is_closed is the
authoritative done marker (never classify by status name — it is localized),
types[].is_milestone tells you the type takes a single date, and
priorities[].is_default / time_entry_activities[].is_default say what you get
by omitting the field.
Pitfalls: results are cached (default 300 s) — pass refresh=true after an admin
change. Time-entry activities are read from the time-entry form, so if the time
tracking module is off or you lack permission the list comes back empty with a note
in notes rather than an error (check notes).
For the writable fields and custom fields of one project+type combination use
get_work_package_schema; for project ids themselves use list_projects.
Inputs: refresh, project_id. Returns: notes, roles, types, statuses, +5 more
get_project_phaseGet project phaseARead-onlyIdempotent
Read one project's phase record: name, active flag and its definition.
Use it after get_work_package surfaced a project_phase reference and you
need to know which project and definition that phase belongs to, or whether it is
still active.
Returns {id, name, active, definition, project, created_at, updated_at}.
Pitfalls: the API has no phases index — ids only come from work packages'
project_phase references. Phase dates are not exposed by the API (the notes
say so), so "which projects are in this phase now" goes through
list_projects(in_phase=...) instead. A 404 covers a wrong id, a phase invisible
to this user (view_project_phases), and instances that predate project phases
(16.1).
Cross-references: list_project_phase_definitions for the instance-wide catalog
and gates; list_projects(in_phase=...) for date-based phase queries.
Inputs: phase_id (required). Returns: id, name, notes, active, +4 more
get_sprintGet sprintARead-onlyIdempotent
Read one sprint in full: dates, workspace and lifecycle status.
Use it once list_sprints has given you an id and the row is not
enough — this adds created_at/updated_at to the same shape.
Pitfalls: sprint ids and version ids are different id spaces — a
version id from list_versions does not resolve here.
Cross-references: list_sprints for the id; list_work_packages
with sprint_ids for the work planned in this sprint.
Inputs: sprint_id (required). Returns: id, name, status, workspace, +5 more
get_userGet user detailARead-onlyIdempotent
Read one user's profile: name, login, email, admin flag and status.
Use it after search_principals when you need more than a name — to
confirm an account is active before assigning work, to check whether
somebody is an instance administrator, or to learn which account the
server itself is acting as (id_or_me='me').
Returns {id, name, login, email, admin, status, language, created_at, updated_at}. The avatar URL is deliberately dropped: it costs tokens
and cannot be rendered here.
Pitfalls. email, login and admin are visibility-dependent — a null
means the authenticated account may not see that field, never that the
value is empty. admin=true says nothing about project permissions; use
list_permissions for what the current user may actually do. A group or
placeholder-user id returns 404 from this endpoint; those principals only
appear in search_principals.
Cross-references: search_principals to find the id;
list_memberships(principal_id=...) for the projects and roles this
person holds; get_instance_info also reports the current user.
Inputs: id_or_me (required). Returns: id, name, admin, email, +5 more
get_work_packageGet work package detailARead-onlyIdempotent
Read one work package in full: description, dates, custom fields, parent and progress.
This is the tool to call once a search or list has given you an id, and the only way to
read a work package's description text. The lock_version in the result is what
update_work_package needs for a safe concurrent edit.
Returns every core field, custom_fields in the canonical
[{key, name, type, value, value_ids}] shape (only fields that have a value), an
available map saying whether this work package exposes dev links, meetings or file
links, and any requested includes.
Pitfalls: includes are capped at 20 — a truncated children list means you should call
list_work_packages(parent_id=…) for the rest, which more_via spells out verbatim. A
sub-resource that 403s or 404s (module off, no permission) degrades into a notes entry
instead of failing the whole read.
For the comment thread use list_work_package_comments; for attachment bytes use
download_attachment; for linked PRs and commits use get_work_package_git_activity.
Inputs: id (required), include. Returns: id, date, type, notes, +32 more
get_work_package_schemaGet work package schemaARead-onlyIdempotent
Show which fields a work package of this type accepts in this project.
Call it before create_work_package/update_work_package when you need the
required fields, when you want a custom field's key or its allowed options, or after
a 422 that named a field you do not recognise.
Returns required_fields (writable keys you must supply), fields — every core
attribute with {key, name, type, required, writable, has_default, allowed_values}
— and custom_fields with {key, name, type, required, writable, options}.
allowed_values/options are {id, name} lists for status, category, version
and list/user custom fields.
Pitfalls: key is the wire spelling (startDate, customField12) — that is
what raw_filters and custom_fields writes use, though writes also accept the
display name. A field with writable: false is computed by OpenProject; sending it
is an error, not a no-op. allowed_values is null when the API only offers a lookup
URL (assignee, project) — resolve those with search_principals or list_projects
instead. Long option lists are capped at 50 with a marker in notes.
Ids for both parameters come from get_project_metadata; to read the values
actually set on one work package use get_work_package.
Inputs: type_id (required), project_id (required), refresh. Returns: notes, fields, type_id, project_id, +2 more
list_attachmentsList attachmentsARead-onlyIdempotent
List the files attached to one container.
Containers are work packages, wiki pages, meetings, documents, budgets and comments. Use this to discover attachment ids before calling download_attachment, or to check what a work package already carries. The upstream collection is not paginated, so it is fetched in full: the envelope always reports has_more=false and a total equal to the row count.
Returns the standard list envelope; each row has id, file_name, size_bytes, content_type, description, author, created_at and status. status is the virus-scan state — 'uploaded' and 'scanned' are downloadable, 'quarantined' files are not, and anything else is still being scanned and is readable only by its uploader.
Pitfalls: container_id identifies the container, not the file. A 404 means the container does not exist or the module providing it (meetings, budgets, documents) is not enabled on this instance. Forum posts are a valid API container but have no discovery path here.
Related: download_attachment fetches the bytes for one row, upload_attachment adds a file to the same containers, and get_work_package(include=['attachments']) returns these rows inline for a single work package.
Inputs: container_id (required), container_type (required). Returns: sums, items, notes, groups, +1 more
list_file_linksList file linksARead-onlyIdempotent
List the external-storage files (Nextcloud, OneDrive/SharePoint) linked to a work package.
File links are OpenProject's other kind of file: instead of living inside OpenProject like an attachment, the document stays in a connected storage and the work package points at it. Use this to answer "which documents belong to this ticket" — and pair it with list_attachments, because the two lists are disjoint and neither implies the other.
Returns the standard list envelope, fetched in full (has_more is always false). Each row carries file_name, the storage it lives on, the file's origin_id inside that storage, mime_type, the creator and — the useful part — open_url and download_url. Those are absolute OpenProject URLs that redirect to the storage once OpenProject has resolved the link, so hand them to the user: they need the user's own OpenProject login, this server cannot fetch the bytes, and download_attachment does not work on them.
Pitfalls: this needs the storages module and a storage connected to the project. When it is missing (404) or this account may not read the links (403) the call still succeeds with an EMPTY list and a note explaining which — read notes before saying a ticket has no documents. An empty list is never proof either: an account lacking the 'view file links' permission gets an empty 200 rather than a 403, which is exactly what that note says. permission carries the storage's own wording — 'View allowed' means the URLs will work, 'View not allowed', 'Not found' and 'Error' mean they will not, and null means the storage said nothing. Creating and deleting file links, and browsing the remote storage, are out of scope for this server — do them in the OpenProject UI.
Related: list_attachments covers files stored inside OpenProject, download_attachment fetches those bytes, and get_work_package gives the ticket the links belong to.
Inputs: work_package_id (required). Returns: sums, items, notes, groups, +1 more
list_membershipsList project membershipsARead-onlyIdempotent
List who has access to which project, and with which roles.
Use it before granting or revoking access ("does she already have a role here?"), to audit a project's member list, or to see which projects a principal can reach. Called with no arguments it pages through every membership the authenticated account may see, which on a large instance is a lot — filter.
Returns the standard list envelope: items of {id, project, principal {id,name,type}, roles[], created_at, updated_at} plus
pagination{total,page,page_size,has_more}.
Pitfalls. The id in each row is the membership id — the handle for
update_membership and delete_membership — not the principal id and
not the project id; mixing them up revokes the wrong access. A person
can also reach a project through a group membership, so an empty
result for principal_id does not prove they have no access. Memberships
say who may act, not what they may do: roles carry the permissions.
Cross-references: list_roles for role ids and their permissions;
create_membership / update_membership / delete_membership to change
access (admin-gated: hidden unless the server sets
OPENPROJECT_MCP_ADMIN_TOOLS=1); search_principals for principal ids;
list_permissions for what the current user may do.
Inputs: page, page_size, project_id, principal_id. Returns: sums, items, notes, groups, +1 more
list_notificationsList notificationsARead-onlyIdempotent
Read the authenticated user's OpenProject inbox.
Use this to answer "what needs my attention?", "was I mentioned anywhere?" or "what changed on the things I watch?" — it is the only tool that sees notifications, and it always reports the inbox of the token owner, never another user's.
Returns the standard list envelope: items of {id, reason, read, created_at, actor, project, resource} plus pagination with
total/page/page_size/has_more. resource is the thing
the notification is about — {id, type, title}, usually
type='WorkPackage', so resource.id feeds straight into
get_work_package or list_work_package_comments.
Pitfalls. Several changes to the same work package are aggregated into
one notification, so the count is not a count of events. Reading a
notification here does not mark it read — that is
mark_notifications. unread_only=false can return a very long
history; keep a page size that fits your reply. A notification whose
project the token owner has lost access to disappears from the inbox
entirely.
Cross-references: mark specific rows read with mark_notifications;
clear the whole (optionally filtered) inbox with
mark_all_notifications_read; open the underlying ticket with
get_work_package(resource.id) and its discussion with
list_work_package_comments(resource.id).
Inputs: page, reason, page_size, project_id, +1 more. Returns: sums, items, notes, groups, +1 more
list_permissionsList permissionsARead-onlyIdempotent
List what the authenticated user is allowed to do, globally or in one project.
Use it before attempting a write that might 403, to explain to a user why an action failed, or to pick between tools ("can I add a member here, or should I ask an admin?"). It reads the real capabilities API for the current user — it does not return a user profile and it never guesses from the admin flag.
Returns the standard list envelope whose items are one row per context —
{id, context, project, actions} with actions such as
work_packages/create — plus principal (the user asked about),
capability_count, and check when permission was given.
CAVEAT, straight from the API: OpenProject exposes only a SUBSET of its permissions as capabilities. An action missing from this list is not proof that the user lacks the permission — it may simply not be modelled. Treat a hit as reliable and a miss as "unknown, try it and read the 403".
Pitfalls: the capabilities API has no "me" value, so the numeric id of the
authenticated user is resolved first (from the cached users/me) — you cannot ask
about another user with this tool. Results are capped at 500 capabilities with a note
in notes when the cap is hit; scope with project_id to stay well under it.
Capabilities are about permission only: a permitted action can still fail validation.
Cross-references: get_instance_info reports who this server is authenticated as
and what the instance version supports; list_memberships and list_roles show
where the permissions come from; get_project_metadata lists the ids a permitted
action needs.
Inputs: permission, project_id. Returns: sums, check, items, notes, +4 more
list_project_phase_definitionsList project phase definitionsARead-onlyIdempotent
List the instance-global phase definitions — the vocabulary of the project life cycle.
Use it to learn which phases (Initiating, Planning, …) and gates this instance
defines, and to get the definition id or name that list_projects(in_phase=...)
accepts.
Returns the standard list envelope of {id, name, start_gate, start_gate_name, finish_gate, finish_gate_name} rows. Gates are the checkpoints a phase can begin
or end with; a definition without gates has both flags false.
Pitfalls: definitions are the instance-wide catalog, not any project's actual
phases — a project may deactivate phases or set no dates. Phase dates are not
exposed by the API at all. Requires OpenProject 16.1+ and the
view_project_phases permission in at least one project; older instances 404
(the error hint says so).
Cross-references: list_projects(in_phase=...) to find the projects a phase
covers today; get_project_phase for one project's phase record (its id comes
from a work package's project_phase).
Inputs: page, page_size. Returns: sums, items, notes, groups, +1 more
list_projectsList projectsARead-onlyIdempotent
List projects, filtered server-side, one page at a time.
Use this to turn a project name into the id or identifier that every other tool
consumes, to enumerate the sub-projects of a parent, or to review which projects are
off track. It is the id-producing path for every project_id parameter in this
server.
Returns the standard list envelope: items of
{id, identifier, name, active, public, parent, status_code, workspace_type} plus
pagination with total/page/page_size/has_more. Nothing is
truncated silently — page explicitly until has_more is false.
Pitfalls: search matches name and identifier only (not descriptions);
parent_id returns direct children, so a deep hierarchy needs one call per level;
status_code is a code such as on_track, never a translated label. On
OpenProject 17.x this listing deliberately mixes plain projects with programs and
portfolios — workspace_type says which each row is. in_phase tests phase
dates ("which projects are in Executing today"), so projects whose phases carry
no dates never match it.
For a single project's description and status explanation use get_project. For
the types, versions, categories and time-entry activities valid inside a project use
get_project_metadata. To list a project's work packages use
list_work_packages(project=...).
Inputs: page, active, search, sort_by, +6 more. Returns: sums, items, notes, groups, +1 more
list_queriesList saved queriesARead-onlyIdempotent
List the saved work-package views (queries) this user can open.
Use it to discover what a team already tracks — "Sprint board", "My open bugs",
"Overdue in Platform" — before hand-building filters: running someone's saved view
with run_query reproduces exactly what they see in the UI, including their
grouping and sums.
Returns the standard list envelope: rows of {id, name, project, public, starred, updated_at} plus pagination. project is null for a global query (saved outside
any project); public false means the query is private to its owner, and only the
owner's queries are visible to this account.
Pitfalls. Query ids are instance-wide, not per project — never guess one, take it from here. This lists definitions only; it never runs them, so nothing here says how many work packages a query returns.
Cross-references: run one with run_query(query_id=…); build an ad-hoc query
instead with list_work_packages; project ids come from list_projects.
Inputs: page, page_size, project_id. Returns: sums, items, notes, groups, +1 more
list_remindersList remindersARead-onlyIdempotent
List your own upcoming work-package reminders.
Use this to answer "what have I asked to be reminded about", to check
whether a reminder is already set before creating another one, or to find
the work packages you deferred. Returns the standard list envelope:
items of {id, remind_at, note, work_package, creator} plus
pagination and notes.
Pitfalls. Reminders are personal — this only ever shows the ones the
authenticated account created, never a colleague's, and there is no way
to list someone else's. It only shows reminders that are still
upcoming: once one has fired (or was completed) OpenProject drops it
from this collection, so an empty result does not mean nothing was ever
scheduled. The work package each reminder points at is in
work_package; a reminder is not a work package and its id is not one.
Cross-references: set_work_package_reminder creates, moves or deletes
one; list_notifications shows what OpenProject has actually notified
you about, including fired reminders; get_work_package opens the
ticket a reminder points at.
Inputs: no named parameters. Returns: sums, items, notes, groups, +1 more
list_rolesList rolesARead-onlyIdempotent
List the roles this instance defines, with their ids.
This is the id-producing tool for create_membership.role_ids and
update_membership.role_ids (both admin-gated: hidden unless the server
sets OPENPROJECT_MCP_ADMIN_TOOLS=1) — role names are never accepted
there. Roles are instance-wide definitions ('Member', 'Reader', 'Project
admin'); a membership binds one principal to one project with a set of
them.
Returns the standard list envelope with has_more: false: the role list
is small and fetched in full. Each item is {id, name}, plus
permissions when include_permissions=true.
Pitfalls. Role names are configurable per instance, so do not assume
'Member' exists — read the list. Some roles are not assignable to a
project membership (global and work-package roles live in the same
collection); the membership form rejects those with the assignable set
listed. Not every OpenProject version exposes permission arrays on this
endpoint: when include_permissions=true returns none, notes says so
rather than pretending the roles grant nothing.
Cross-references: create_membership / update_membership consume these
ids; list_memberships shows which roles are in use; list_permissions
answers what the current user may do, which is the more useful question
when a call just failed with 403.
Inputs: include_permissions. Returns: sums, items, notes, groups, +1 more
list_sprintsList sprintsARead-onlyIdempotent
List sprints (Scrum timeboxes) you can plan work packages into.
Use it to turn "Sprint 11" into the sprint id that
create_work_package/update_work_package consume via their
sprint parameter, or to review a project's sprint plan with its
in_planning/active/completed states.
Returns the standard list envelope: items of {id, name, description, start_date, finish_date, workspace, status} plus
pagination and notes. A project-scoped listing is fetched in
full, so has_more is false; the instance-wide listing is paginated.
Pitfalls: sprints are not versions (17.3+): finish_date is the wire finishDate
and status is the sprint lifecycle. Assign work with
update_work_package(sprint=...); the API has no sprint write endpoint.
Cross-references: get_sprint for one sprint in full;
list_versions for releases and milestones; list_work_packages
with sprint_ids to see what is IN a sprint.
Inputs: page, page_size, project_id. Returns: sums, items, notes, groups, +1 more
list_time_entriesList time entriesARead-onlyIdempotent
List logged time, filtered server-side, with an optional accurate total.
Answers "how much time went into this ticket?" or "how much did the team spend on
project X in June?". Filters combine with AND, so project_id + user='me' + a date
range is one call.
Returns the standard list envelope: items of {id, hours, spent_on, comment, user, activity, work_package, project} plus pagination.
hours is a float (1.5 = 1h30), never an ISO duration. With
sum_hours=true, the envelope also carries sums.total_hours over
all matches and a groups bucket per activity with its own
count/sums.total_hours — both over the whole filtered set, so
never add pages up yourself.
Pitfalls. Without view-all-time-entries you see only your own entries, so a small
total may mean "not allowed to see" rather than "nobody booked time". work_package_id
scopes to that one work package — child work packages are not included; a
parent's roll-up needs a query per child. Summing stops at 2000 entries, noted in
notes; narrow the range or project rather than trusting the number.
Cross-references: book time with log_time; correct with update_time_entry,
remove with delete_time_entry; activity ids/names from get_project_metadata;
the work package from get_work_package.
Inputs: page, user, to_date, fetch_all, +6 more. Returns: sums, items, notes, groups, +1 more
list_versionsList versionsARead-onlyIdempotent
List versions (releases, milestones, sprints) you can assign work packages to.
Use it to turn "Sprint 12" or "release 2.1" into the version id that
create_work_package/update_work_package need, to see which versions are still
open, or to review a release plan's dates. With project_id it answers "what can I
target in THIS project", which includes versions shared down from parent projects.
Returns the standard list envelope: items of {id, name, project, status, start_date, end_date, description, sharing, source} plus pagination and
notes. A project-scoped listing is fetched in full, so has_more is false.
Pitfalls: project is the project that DEFINES the version, which for a shared
version is not the project you asked about — assigning still works. status is
open/locked/closed and OpenProject refuses to put work packages into a closed
version. The instance-wide listing is capped at one page of 100; if more exist,
pagination.has_more is true and notes says so — narrow with project_id
rather than assuming you saw everything. include_sprints depends on the backlogs
module: where it is not installed the versions still come back and notes explains
the absence, so read notes before telling a user a project has no sprints.
Cross-references: create_version adds one, update_version moves its dates or
closes it, delete_version removes it; get_project_metadata(project_id=...)
returns the same versions alongside types and categories; to see what is IN a version
use list_work_packages with a version filter. For standalone sprints (17.3+)
use list_sprints/get_sprint.
Inputs: project_id, include_sprints. Returns: sums, items, notes, groups, +1 more
list_work_package_commentsList work package commentsARead-onlyIdempotent
Read the comment thread and change history of a work package.
Use this whenever the question is "what did people say about this
ticket" or "what changed on it": it returns the full activity journal —
comment entries (author, markdown text, internal flag, timestamps) and
field-change entries whose details are parsed into
{field, from, to} (for example
{"field": "Status", "from": "New", "to": "In progress"}).
Returns the standard list envelope: items plus
pagination{total,page,page_size,has_more} and notes.
Pitfalls. OpenProject's activities endpoint is unpaginated — this
tool fetches the entire journal on every call and pages it here, so
page/page_size cost the same upstream but keep the reply small.
Entries are ordered oldest first, so ask for the last page to see the
latest discussion. Comment text is cut at max_comment_chars and
marked truncated: true; pass that entry's id back as
activity_id to read it in full.
Cross-references: post a comment with add_work_package_comment; the
work package itself (description, custom fields, watchers) comes from
get_work_package; files referenced in a comment are listed by
list_attachments.
Inputs: id (required), page, page_size, activity_id, +1 more. Returns: sums, items, notes, groups, +1 more
list_work_packagesList work packagesARead-onlyIdempotent
List work packages with structured filters — the workhorse read tool.
Handles "assigned to me", "overdue" via parameters, not separate
tools: overdue → due_before=<today>; unassigned → assignee=['none']; nearly done →
percentage_done_min=80; subtasks of a ticket → parent_id=<id>.
Returns the standard list envelope: rows plus pagination, plus groups when
group_by is set and sums when show_sums is set — both computed server-side over
the whole filtered set, independent of paging; never re-add them from one page's rows.
Pitfalls: returns open work packages only unless status_scope or status_ids is
given (status_ids overrides status_scope), so say so when reporting counts. Status,
type, priority and version ids come from get_project_metadata; user ids (assignee,
author, responsible, watcher) from search_principals — never guess either. Date
filters take YYYY-MM-DD, both bounds inclusive.
For text lookups use search_work_packages; for one work package's description, custom
fields and children use get_work_package.
Inputs: page, query, author, project, +29 more. Returns: sums, items, notes, groups, +1 more
log_timeLog timeA
Book time against a work package or a project.
Use it when the user says "log 2 hours on #1234" or "book half a day to project X". The call is validated through OpenProject's own form endpoint first, so an activity this project does not allow, a missing permission or a closed cost-reporting period comes back as a typed error listing what would be accepted — nothing half-written is left behind.
Returns the created entry: {id, hours, spent_on, comment, user, activity, work_package, project, created_at, updated_at, lock_version}. hours comes back as a float.
Pitfalls. This is not idempotent — calling it twice books the time
twice, so never blind-retry after a timeout; list the day's entries
first. The time is always booked for the token owner; you cannot log
time on someone else's behalf through this tool. Logging time does not
change the work package's status, estimate or progress — those are
separate fields, and on instances that derive progress from work the
percentage is read-only anyway. The work package's spent_hours
reflects the new entry on the next read.
Cross-references: the activities and ids this project accepts come from
get_project_metadata(project_id=...); review what is already booked
with list_time_entries; fix a mistake with update_time_entry or
delete_time_entry.
Inputs: hours (required), spent_on (required), comment, activity, +2 more. Returns: id, user, hours, comment, +7 more
mark_all_notifications_readMark all notifications readAIdempotent
Mark everything matching the filters as read — the whole inbox by default.
Use it for "clear my notifications" or "I have dealt with everything in
project X". Called with no arguments it marks every unread notification
of the token owner as read, across all projects; reason and
project_id narrow that blast radius, they do not create a preview.
Check what is about to disappear with
list_notifications(unread_only=true, ...) using the same filters
first — there is no undo beyond re-marking individual ids unread.
Returns {marked, read, message}, where marked is how many unread
notifications matched the filters at the moment of the call (counted
immediately before the bulk update, so a notification arriving during
the call may be counted differently than it was marked).
This tool only ever marks read. There is deliberately no
"mark everything unread" twin: that is a mistake with no upside, and the
reverse direction stays available per-id through
mark_notifications(ids=[...], read=false).
Cross-references: preview or page the inbox with list_notifications;
mark a handful of rows with mark_notifications.
Inputs: reason, project_id. Returns: ids, read, marked, message
mark_notificationsMark notifications read or unreadAIdempotent
Mark specific notifications read (or unread) in one bulk request.
Use it after you have actually handled what a notification was about, so
the user's inbox reflects reality. It is the id-consuming counterpart of
list_notifications: pass the row id values from that tool.
Returns {marked, read, ids, message}. OpenProject answers the bulk
endpoint with 204 No Content, so marked is the number of ids the
call covered — ids that were already in the requested state, or that
belong to someone else's inbox, are simply not changed.
Pitfalls. Marking is idempotent, so a retry after a timeout is safe. Pass
the notification id, not resource.id — the work package id
underneath is a different number entirely. Unknown ids do not fail the
call, so do not treat success as proof that every id existed.
Cross-references: get the ids from list_notifications; to clear an
entire (optionally filtered) inbox in one go use
mark_all_notifications_read.
Inputs: ids (required), read. Returns: ids, read, marked, message
remove_work_package_watcherRemove work package watcherAIdempotent
Unsubscribe a user from a work package's notifications.
Use this to stop notifying someone who no longer needs the updates. Returns the user, the watch state after the call (always not watching) and a confirmation message.
Pitfalls. OpenProject answers the same 204 whether or not the user was
watching, so changed comes back null — do not report "removed" as
proof that they were subscribed. Removing another user needs the
delete-work-package-watchers permission; removing yourself only needs
view access. A 404 means the user id is unknown (or the work package
is), not that they were not watching. This does not unassign anyone and
does not remove them from the project.
Cross-references: get_work_package(include=['watchers']) shows who
watches today; add_work_package_watcher is the reverse.
Inputs: user_id (required), work_package_id (required). Returns: user, changed, message, watching, +1 more
run_queryRun a saved queryARead-onlyIdempotent
Run a saved view and get its work packages — the fastest way to answer with a team's own definition of "the sprint" or "our bugs".
OpenProject queries run on read: this returns the rows as they are right now, in the
stored order and grouping. The result is the standard list envelope — items of
compact work-package rows, pagination, plus groups when the query groups and
sums when it asks for totals — with one addition: query carries the stored
definition (name, project, readable filters, group_by, sort_by), so the rows
can be interpreted without a second call.
Pitfalls. groups and sums are computed server-side across the entire result set,
not the page in front of you — never re-add them from items. Omitting page_size
keeps the query's own page size, which may be much larger than 20. override_filters
replaces the stored filters instead of narrowing them, and never edits the saved
query. A 422 means the filter set is invalid for this query's context (a
project-scoped filter on a global query, an unknown custom field); violations names
the attribute.
Cross-references: find query ids with list_queries; equivalent ad-hoc filtering
lives in list_work_packages; open a single row with get_work_package.
Inputs: query_id (required), page, page_size, override_filters. Returns: sums, items, notes, query, +2 more
save_querySave a queryA
Save a filter set as a reusable OpenProject view.
Use it to keep a filter combination — "Overdue in Platform", "My open bugs" — instead
of rebuilding it each session: the view shows up in the OpenProject UI too, and
run_query reproduces it exactly. Prove filters with list_work_packages first.
Validated via POST /queries/form first, so an invalid filter name, unsupported
operator, or project-scoped filter on a global view comes back as violations naming
the attribute — nothing is saved. Returns the stored definition: {id, name, project, public, starred, filters (as readable sentences), group_by, sort_by, display_sums, updated_at, notes}. Keep the id — run_query takes it.
Pitfalls. If OpenProject keeps fewer filters than were sent, notes says so — read
filters rather than assuming a match. Custom-field filters (customField12) are sent
as plain values, since a list-typed one can't be told apart from a text one without
asking the instance; a failing one means nothing was saved — save that view in the UI
instead. Editing and deleting saved views isn't offered here: use the OpenProject UI.
Cross-references: list_queries lists what exists (with ids); run_query(query_id=...)
runs this view; list_work_packages validates filters first; list_projects supplies
project_id.
Inputs: name (required), filters (required), star, public, +3 more. Returns: id, name, notes, public, +7 more
search_principalsSearch users, groups and placeholder usersARead-onlyIdempotent
Find users, groups and placeholder users, and get their ids.
This is the id-producing tool for every principal parameter in this
server: assignee/responsible on work packages, watcher ids, the
user_id of a time entry, and principal_id for create_membership
(admin-gated: hidden unless the server sets
OPENPROJECT_MCP_ADMIN_TOOLS=1). Names are never accepted where an id
is wanted — resolve here first, and never guess a numeric id.
Use it to answer "who is Grace Hopper's account", "which groups exist",
"who is a member of the demo project". Returns the standard list
envelope: items of {id, name, type, email?, login?, status?} plus
pagination{total,page,page_size,has_more} and notes.
Pitfalls. email, login and status are only returned for user
principals the authenticated account may see — a null email means "not
visible to you", not "no email". Group principals can hold memberships
and be assigned work, so filter by type when you specifically need a
person. Matching is substring-based, so a short query matches broadly;
prefer the full name or the login. member_of_project filters by
membership, not by whether the person ever touched the project.
Cross-references: get_user for one user's full detail;
list_memberships for who holds which roles in a project;
create_membership to grant access; list_roles for the role ids that
grant needs.
Inputs: page, type, query, status, +2 more. Returns: sums, items, notes, groups, +1 more
search_work_packagesSearch work packagesARead-onlyIdempotent
Find work packages by text when you do not know their ids.
Use this first whenever a user names a ticket instead of numbering it, then feed the
returned id into get_work_package, update_work_package or list_work_packages.
Returns the standard list envelope: compact rows (id, subject, type, status, priority,
assignee, project, dates, progress) plus pagination with total/page/page_size/has_more.
Pitfalls: search filters, it does not rank, so a broad query returns a lot — narrow it
with project_id, or switch to list_work_packages when you want structured filters
(assignee, due date, version) rather than text. Attachment-content matching in 'fulltext'
mode depends on instance database configuration and is reported honestly in notes.
For structured filtering use list_work_packages; for one work package's full detail
(description, custom fields, children) use get_work_package.
Inputs: query (required), mode, page, page_size, +2 more. Returns: sums, items, notes, groups, +1 more
set_project_favoriteSet project favoriteAIdempotent
Add or remove a project from the authenticated user's favorites (OpenProject 17+).
Favorites are per user, not per project: this changes what the account behind OPENPROJECT_API_KEY sees starred on its own overview page, and nothing about the project itself or about anybody else's view. Use it when the user asks to pin, star or favorite a project they work in.
Returns {id, favorite, message} — favorite is the state now in effect. The call
is idempotent: favoriting an already-favorited project succeeds again.
Pitfalls: this endpoint only exists from OpenProject 17. When the instance reports a version older than that the call is REFUSED before anything is sent, with the detected version in the message, because there is no downgrade that would achieve the same thing — favorite the project in the web UI instead. When it reports no version at all the request IS sent, since an unreported version says nothing about the endpoint. A 404 is ambiguous on purpose in the hint: it means either the project does not exist for this account or the endpoint is missing. This is not project 'status' and not a work-package watcher.
Cross-references: list_projects(favorites_only=true) lists the current favorites
(and works on older instances too); get_project resolves an identifier first;
get_instance_info reports the detected OpenProject version.
Inputs: favorite (required), id_or_identifier (required). Returns: id, message, favorite
set_work_package_reminderSet work package reminderAIdempotent
Set, change or clear your personal reminder on a work package.
Use this when something should resurface later: "remind me about this on
Monday", "ping me an hour before the release". Reminders are private —
only you see yours, and only you are notified. The tool upserts: it looks
for your active reminder on the work package and creates one if there is
none, updates it if there is. Returns action
(created/updated/deleted/unchanged) and the resulting reminder.
Passing remind_at=null deletes the reminder. That is the
documented way to clear it rather than a destructive operation — nothing
but your own pending notification is removed, the work package and its
history are untouched — so this tool does not ask for confirm. Set a
new time to get it back.
Pitfalls. OpenProject allows exactly one active reminder per work package
per person, so a second "create" becomes an update of the first — there
is no way to stack two. remind_at must carry a timezone; a bare
'2026-08-03T09:00' is refused rather than guessed at. A reminder in the
past is rejected by the instance. Once a reminder has fired it disappears
from the API, so a later call creates a fresh one rather than reviving
it. Reminders are personal: you cannot set one for a colleague — add them
as a watcher or mention them in a comment instead.
Cross-references: list_reminders shows everything you have pending;
add_work_package_watcher notifies someone about changes rather than
at a chosen time; add_work_package_comment with an @-mention is how
you get another person's attention.
Inputs: work_package_id (required), note, remind_at. Returns: action, message, reminder, work_package_id
toggle_comment_reactionToggle comment reactionA
React to a work-package comment with an emoji, or take your reaction back.
Use this for the lightweight acknowledgement a comment does not deserve:
👍 on a decision, 👀 to say you are looking at it, 🎉 when something
shipped. Returns the comment's full reaction state afterwards — every
emoji on it with the people who picked it — plus reacted, which says
whether you are now among them.
Pitfalls. This is a toggle, not an add: calling it twice with the
same reaction leaves the comment exactly as it started, so it is not safe
to retry blindly after a timeout — read reacted from the result
instead. Reactions belong to the authenticated account; you cannot react
on someone else's behalf and cannot remove their reaction. Only comment
entries can be reacted to — a field-change journal entry ("Status changed
from New to In progress") is refused by OpenProject with a 400. The
feature needs OpenProject 16.0 or newer; an instance known to be older
is refused up front with a version hint rather than pretending to have
reacted, and one whose version is not readable answers 404 instead.
Cross-references: list_work_package_comments reads the thread and
produces activity ids; add_work_package_comment says something in
words when an emoji is not enough; get_instance_info reports the
detected OpenProject version.
Inputs: reaction (required), activity_id (required). Returns: notes, message, reacted, reaction, +2 more
update_projectUpdate projectA
Change a project's name, description, visibility, parent, status or archived state.
Use it to record a status change with its explanation ("at_risk because the vendor
slipped"), to rename or re-parent a project, to publish it, or to archive it with
active=false. The change is validated through POST /projects/{id}/form first,
so rejected values come back as violations naming the attribute.
Only the parameters you pass are sent — omitted fields are never rewritten, so two
agents editing different fields do not clobber each other. Projects carry no
lockVersion upstream, so there is no version to echo and no lock parameter here.
Returns the updated project in the same shape as get_project.
Pitfalls: description and status_explanation REPLACE the stored text rather
than appending to it. active=false archives, which is not deletion but does hide
the project and freeze its work packages. Changing identifier is deliberately not
offered — it breaks every existing link to the project.
Cross-references: get_project to read the current values first; delete_project
to remove a project for good; list_projects(active=false) to find archived ones.
Inputs: id_or_identifier (required), name, active, public, +4 more. Returns: id, name, active, parent, +8 more
update_time_entryUpdate time entryA
Correct an existing time entry.
Use it for the everyday fixes: wrong duration, wrong day, wrong activity, a comment that needs to say what actually happened. Only the parameters you pass are written; everything else is left exactly as it is.
Returns the updated entry in the same shape log_time returns.
Pitfalls. What can be moved between entries is limited: the work package
and the project a time entry belongs to are not editable here —
delete the entry and log it again where it belongs. Some instances
report a lock_version for time entries and some do not; this tool
reads the entry first and only echoes a lock version when one exists, so
a concurrent edit surfaces as a conflict error with the fresh state
rather than silently overwriting a colleague's correction. Editing time
inside a closed cost-reporting period is refused by OpenProject with a
validation error.
Cross-references: find the id with list_time_entries; remove the
entry entirely with delete_time_entry; the valid activity names come
from get_project_metadata.
Inputs: time_entry_id (required), hours, comment, activity, +1 more. Returns: id, user, hours, comment, +7 more
update_versionUpdate versionA
Change a version's name, dates, description, status or sharing.
Use it to move a sprint's dates, to close a finished release (status='closed'),
or to widen sharing so a subproject can use the version. The change is validated
through POST /versions/{id}/form first, so rejected values come back as
violations naming the attribute.
Only the parameters you pass are sent, so concurrent edits to other fields survive.
Versions carry no lockVersion upstream, so there is nothing to echo and no lock
parameter here — a 409 would mean the resource itself changed, not a stale version.
Returns the updated version in the same shape as create_version.
Pitfalls: end_date writes the API's endDate — it lands, unlike in the old
server. description REPLACES the stored text. Closing a version does not move or
unassign its work packages; they keep pointing at it. The defining project cannot be
changed — create a new version instead.
Cross-references: list_versions for ids and current values; delete_version
when the version must really disappear; update_work_package(version=...) to move
individual work packages between versions.
Inputs: version_id (required), name, status, sharing, +3 more. Returns: id, name, source, status, +7 more
update_work_packageUpdate work packageA
Change any writable field of a work package, with optimistic locking done properly.
Use it to assign or unassign, move a status forward, re-schedule, re-parent, set progress, or write custom fields — one tool instead of many.
Returns the updated work package in full detail, including the new lock_version to use
for a follow-up edit.
Pitfalls: omitted parameters are left alone, while passing null clears a field
(assignee, responsible, version, sprint, parent, dates, description). A 409 error means
somebody else changed the work package first — the error carries the fresh lock_version
and the conflicting fields, so re-read and retry deliberately.
Ids come from get_work_package / list_work_packages; status, priority, type and
version values come from get_project_metadata; sprint ids come from list_sprints.
Inputs: id (required), date, type, notify, +18 more. Returns: id, date, type, notes, +27 more
update_work_package_relationUpdate work package relationAIdempotent
Change an existing relation's type, lag or description.
Use it to widen the gap between a predecessor and its successor, to correct a link that was created with the wrong type, or to explain why two work packages are connected. Returns the updated relation.
Pitfalls. At least one of type, lag or description must be given.
Relations carry no lock version, so this is a plain overwrite with no
conflict detection — a concurrent edit is silently replaced; re-read the
relation if that matters. The two work packages cannot be changed here:
delete the relation and create a new one instead. Raising the lag on a
follows relation reschedules the successor, so dates can move.
Cross-references: create_work_package_relation makes one;
delete_work_package_relation removes it;
get_work_package(include=['relations']) lists the ids.
Inputs: relation_id (required), lag, type, description. Returns: id, lag, type, description, +3 more
upload_attachmentUpload attachmentA
Attach a local file to a work package, wiki page, meeting, document, budget or comment.
Use it when a file that already exists on the server's machine should be added to an existing container. The file's existence and its size against this instance's maximumAttachmentFileSize are checked locally first, so an oversized file fails instantly instead of after the transfer.
Returns the created attachment row (id, file_name, size_bytes, content_type, description, author, created_at, status) — the id feeds download_attachment.
Pitfalls: uploading to a container needs edit permission on that container, so to give a brand-new work package its files use create_work_package(attachment_paths=[...]) instead, which uploads the files unattached and claims them on create. Instances may restrict extensions; a rejected type comes back as validation_failed with the allowlist hint and nothing is stored. The stored name comes from file_name (or the path's basename), never from the multipart part.
Related: list_attachments shows what a container already holds; download_attachment is the reverse direction.
Inputs: file_path (required), container_id (required), container_type (required), file_name, +1 more. Returns: id, author, status, file_name, +4 more
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 59 tools
Most tools have clearly distinct resource/action targets, and the descriptions explicitly separate commonly confused pairs such as list_attachments vs list_file_links and list_work_packages vs search_work_packages. The large set still has several adjacent read paths (list/search/run_query/get for work packages, versions vs sprints, metadata vs schema) that an agent must track carefully, but the boundaries are generally documented.
Names consistently use snake_case with a verb_noun pattern across CRUD, bulk, comment, watcher, relation, notification, and meta tools. Compound resources such as work_package, time_entry, and project_phase are applied predictably, with no camelCase or mixed-verb chaos.
At 59 tools, the surface is far beyond the typical 3-15 range and is heavy for an agent to navigate, even though OpenProject is a broad domain. The tools are mostly meaningful and non-redundant, but the set would benefit from stronger grouping or consolidation.
Core workflows are well covered: project CRUD/copy, work-package CRUD/bulk/comments/watchers/relations, attachments, time entries, versions, sprints, notifications, reminders, queries, phases, and permissions. Remaining gaps are mostly intentional or gated (wiki/meetings/documents/budgets hidden behind enable_tool_group, saved-view edit/delete deferred to UI, membership writes admin-gated).
Maintenance
Related MCP Connectors
Related MCP Servers
- MIT
- AlicenseNot gradedqualityBmaintenanceOpen source MCP server specializing in easy, fast, and secure tools for Databases.16,857Apache 2.0
- AGPL 3.0
- AlicenseNot gradedqualityDmaintenanceProduction-grade MCP server for universal reverse engineering automation.85GPL 3.0