plane_pages_mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@plane_pages_mcplist all pages in workspace work"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
plane_pages_mcp
An MCP server for a Plane CE instance, exposing two things a coding agent can't otherwise reach cleanly:
Pages (DB + live converter) — Plane CE Pages are absent from the public REST API (upstream: makeplane/plane#8986). Reads go straight to Postgres; writes go through Plane's own internal
liveHTML→Yjs converter so the collaborative-editor state stays consistent.Work items (public REST API) — issues/projects via Plane's supported CE REST API (
x-api-key). The one exception is relations (blocks,relates_to, …), which CE omits from the API and which therefore go to Postgres like pages — so they need the DB subsystem too.
Both are multi-workspace: one instance may host several workspaces (work, family business, personal), and every tool is workspace-scoped so content never leaks across contexts.
Built with Python + FastMCP. Ships as one container to sit inside your Plane Docker Compose stack.
Version coupling. All schema/endpoint facts were verified against Plane CE v1.3.1. On any Plane upgrade, re-run
plane-pages-mcp verify(below) before trusting writes — a schema change can add a required column and the REST surface can shift.
Subsystems and capability degradation
The two subsystems are configured and enabled independently:
Subsystem | Needs | Transport |
pages |
| direct Postgres + |
work items |
| public REST API |
The server starts if at least one is configured. A missing PAT never breaks
pages; a missing DB never breaks work items. The enabled set is logged at
startup (capabilities: pages=… work_items=…) and reported by verify.
Related MCP server: plane-mcp-server
How pages work (the four-column problem)
A page's content lives in four columns on pages: description_html,
description_json, description_binary (the Yjs editor state — authoritative),
and description_stripped. Writing only HTML produces edits that are invisible
in the editor and get clobbered by the live server. So every write sets all
four together, deriving json + binary from the live service's
convert-document endpoint (the same converter Plane uses internally).
Work-item descriptions have the same multi-representation issue, but the REST
API handles it itself: we send description_html and Plane fills the rest.
The pages DB-write path is never reused for work items.
Multi-workspace rules
WORKSPACE_SLUG is a default, not a fixture. Every tool takes an optional
workspace argument. Resolution order: explicit argument → WORKSPACE_SLUG
env → error naming both options. Slug→UUID is cached on demand; an unknown
slug returns a clean error listing the slugs that do exist.
Reads/lists (
list_pages,search_pages,list_projects,list_work_items, …) fall back to the default whenworkspaceis omitted, and are strictly scoped — a search from one workspace never returns another's.read_page/get_work_itemtake a globally-unique id, so no workspace is needed;read_pagereturns the owning workspace slug so you can tell where it came from.create_pageandcreate_work_itemREQUIRE an explicitworkspace— no default fallback, so a page/issue can't silently land in the wrong workspace.update_page/update_work_iteminherit the target's workspace from its id; noworkspaceargument.
SERVICE_USER_ID is a single configured user used for every workspace
(pages are attributed to the operator). Plane also creates a per-workspace bot
user (bot_user_<workspace-uuid>@plane.so), but we deliberately use one
configured id — simpler, and attribution stays accurate.
Tools
Pages (DB) — enabled by DATABASE_URL
Tool | Purpose |
| List pages (newest first), scoped to |
| Case-insensitive name+body match within one workspace; ±120-char snippet. |
| Metadata + content (markdown/html) + owning workspace slug. |
| Create a page — |
| Replace or |
Work items (REST) — enabled by PLANE_BASE_URL + PLANE_API_KEY
Tool | Purpose |
| id, name, identifier, description. |
| Issues with sequence id ( |
| Full detail; |
|
|
| Partial update; only supplied fields are sent. |
| Discover valid state/label names. |
| Create a relation (needs the DB subsystem — relations aren't in the REST API). |
| Remove a relation. |
| id, name, dates, issue counts (modules also |
|
|
| By name or UUID; leaves issues intact. |
| Add work items (list of |
| Remove work items (non-members ignored). |
Cycles and modules are fully served by the public REST API (create + delete +
issue assignment via the cycle-issues/module-issues sub-resources), so these
tools are pure-REST — no DB. end_date maps to a module's target date; cycle/
module resolution accepts a name or UUID.
Work-item relations are absent from CE's public REST API (like pages), so
link_work_items / unlink_work_items write directly to Postgres and are gated
on DATABASE_URL (not on the REST subsystem). get_work_item includes a
relations list whenever the DB subsystem is enabled. relation_type is
directional — "item <relation_type> related_item" — and accepts: blocks,
blocked_by, relates_to, duplicate, start_before, start_after,
finish_before, finish_after, implements, implemented_by (Plane stores the
canonical direction; the inverse label is shown on the other item). Parent/child
hierarchy is separate — use parent on create/update for sub-work-items.
Content accepts markdown (default; CommonMark via markdown-it-py, with
tables + strikethrough — a bullet list may follow a paragraph line without a
blank line, matching GitHub) or raw HTML via format="html". Priorities:
urgent/high/medium/low/none. Unknown state/label/assignee/priority/
parent names return an error listing the valid options.
Configuration
Set via environment (see .env.example):
Var | Default | Notes |
| — | pages only; dedicated role, see below |
| — | pages only; single user that owns tool-created pages |
|
| pages only; confirm base path at deploy |
|
| pages only; builds the UI URL from |
| — | work items; instance site root, client appends |
| — | work items; PAT, sent as header |
| — | default workspace (not a fixture); may be unset |
| — | http auth; per-environment GitHub OAuth app |
| — | http auth; public origin, e.g. |
| — | http auth; comma-separated GitHub logins allowed (case-insensitive); empty is rejected |
| — | http auth; long secret, stable across restarts (≥12 chars) |
| — | http auth; OAuth state store, e.g. |
| — | http auth; encrypts OAuth state at rest (Fernet) |
|
| http auth; FastMCP access-token lifetime (7 days) |
|
|
|
|
| http bind |
|
| page content never logged above |
In http mode every http auth var above is required — the server fails fast
at startup if any is missing (a public OAuth server must not come up half-armed).
stdio mode ignores all of them and runs auth-free (on-box use via
docker exec). PLANE_BASE_URL (REST instance root) is unrelated to
LIVE_CONVERT_URL (internal pages converter) and to PUBLIC_BASE_URL (this
server's own public origin).
Authentication (http transport)
http mode is a public OAuth server: identity via GitHub OAuth (FastMCP's
GitHubProvider proxy) and authorization via an allowlist of GitHub logins.
This lets the server be added as a claude.ai custom connector and used across
claude.ai web, Claude Desktop, Claude mobile, Cowork, and Claude Code.
Identity ≠ authorization. GitHubProvider only proves who you are — without the allowlist, any GitHub account on Earth could complete the flow and reach the tools.
ALLOWED_GITHUB_LOGINSis the gate; it is applied to every tool list and call and fails closed. An empty allowlist is rejected at startup.
Register two GitHub OAuth apps
github.com → Settings → Developer settings → OAuth Apps → New. Register one per
environment; the callback URL must match <PUBLIC_BASE_URL>/auth/callback
exactly:
App | Authorization callback URL |
production |
|
development |
|
Copy each app's Client ID and Client Secret into GITHUB_CLIENT_ID /
GITHUB_CLIENT_SECRET for that environment. Minimal scope (read:user) is
enough — the server only needs your identity, not repo access.
Security notes
Consent screen stays on. FastMCP shows a consent screen before authorizing a client; it's part of the security model (protection against AS-in-the-middle), not friction — do not disable it.
The redirect-URI allowlist is defense-in-depth, not the gate. A Jan 2026 upstream issue showed DCR clients bypassing it in older releases (since hardened — hence the
fastmcp>=3.4.4pin). The identity allowlist is the gate.Keep FastMCP current. Auth correctness rides on the OAuth-proxy hardening; re-run the test suite after any FastMCP upgrade.
Persistence. FastMCP's Linux defaults (ephemeral signing key, disk store under a key-derived path) invalidate all tokens on restart. This server pins
JWT_SIGNING_KEYand stores OAuth state in Redis, encrypted at rest with a Fernet key derived fromSTORAGE_ENCRYPTION_KEY. Adocker restarttherefore resumes existing Claude connections without a re-login.Re-auth cadence. Access tokens live
TOKEN_EXPIRY_SECONDS(7 days default); clients refresh transparently. In practice mobile/desktop re-prompt roughly at that cadence, not per session./healthzis unauthenticated (for container health checks); it exposes nothing butok.
Rate limiting
The instance sets API_KEY_RATE_LIMIT (default 60/minute). A chained work-item
sequence (list projects → list states → resolve assignees → create) can brush
against it. A 429 surfaces as a clear rate-limit error naming the limit; the
server does not silently retry in a loop.
Database role (pages + work-item relations)
Create a dedicated, least-privilege role (do not run as the Plane superuser):
CREATE ROLE plane_pages LOGIN PASSWORD '…';
GRANT SELECT ON pages, project_pages, projects, workspaces TO plane_pages;
GRANT INSERT, UPDATE ON pages, project_pages TO plane_pages;
GRANT SELECT ON users TO plane_pages;
-- work-item relations (only if you use link_work_items / unlink_work_items):
GRANT SELECT ON issues TO plane_pages;
GRANT SELECT, INSERT, DELETE ON issue_relations TO plane_pages;
-- no other tablesTool paths read pages, project_pages, projects, workspaces (page owner is
the configured SERVICE_USER_ID, not resolved from users; work-item
user/assignee resolution happens over REST, never the DB). users is read only
by verify. Relations additionally read issues and read/write
issue_relations — DELETE is needed because unlink_work_items hard-deletes
the relation row. verify runs a SELECT against every one of these tables,
so a missing GRANT shows up as a clean [FAIL] SELECT grant on '<table>' (with
the exact GRANT to run) rather than a runtime error later.
Post-upgrade ritual: verify
plane-pages-mcp verifyReports each subsystem independently and exits non-zero on any failure in an enabled subsystem (a disabled one is skipped, never failed):
Pages — expected tables exist; the four description columns exist; every
NOT-NULL-without-default column on pages/project_pages is covered by the
INSERT builders (a new required column fails loudly here, not mid-write);
pages.deleted_at exists; the <p>ping</p> convert round-trip returns 200 with
a non-empty decoded binary; enumerates workspaces and confirms the default slug
service user.
Work items — for the default workspace, probes each REST endpoint and prints
its status code: projects/, then per first project issues/, states/,
labels/, members/, cycles/, modules/, and a single-issue GET. Anything
not returning 200 should be dropped from the tool set (and noted here) rather
than shipped broken. A 403 is annotated with a hint that the service user may
not be a member of that project (workspace-level calls can still return 200).
Auth (when PUBLIC_BASE_URL is set) — confirms ALLOWED_GITHUB_LOGINS is
non-empty; fetches PUBLIC_BASE_URL/.well-known/oauth-authorization-server and
the protected-resource metadata for /mcp (expects 200 JSON naming the
authorize/token endpoints and authorization servers); and round-trips a value
through the encrypted Redis store to confirm the backend is reachable and the
Fernet wrapper is active. Run it through the tunnel after deploy so the
discovery docs are checked exactly as an external client sees them.
verify never raises — a failed probe (bad grant, 403, connection refused,
timeout, unreachable discovery) is recorded as a [FAIL] and the run continues
to the summary line, exiting non-zero. It fails informatively against a wrong
LIVE_CONVERT_URL, a DB missing a column or grant, a bad/absent PAT, or an
unreachable/misconfigured OAuth endpoint.
Running
Local dev (stdio)
uv venv && uv pip install -e .
cp .env.example .env # edit it; from the host use localhost URLs
set -a; . .env; set +a
plane-pages-mcp verify
plane-pages-mcp serve # MCP_TRANSPORT=stdioTo exercise the OAuth flow locally, use the development GitHub app with
PUBLIC_BASE_URL=http://localhost:8300, MCP_TRANSPORT=http, and a reachable
REDIS_URL, then drive it with MCP Inspector or the fastmcp client. (claude.ai
cannot reach localhost, so the claude.ai/mobile/desktop connector tests run
against the production deployment.)
Register with Claude Code
stdio (local):
claude mcp add plane -- \
env MCP_TRANSPORT=stdio \
DATABASE_URL='postgresql://plane_pages:…@localhost:5432/plane' \
SERVICE_USER_ID='<uuid>' \
LIVE_CONVERT_URL='http://localhost:3300/live/convert-document/' \
PLANE_BASE_URL='http://localhost' \
PLANE_API_KEY='<pat>' \
WORKSPACE_SLUG=test \
plane-pages-mcp serveHTTP (GitHub OAuth — no header, no secret):
claude mcp add --transport http plane https://planemcp.<domain>/mcpClaude Code runs the OAuth flow itself (GitHub login → consent → connected). The
claude.ai custom connector is added the same way: paste only the URL
(https://planemcp.<domain>/mcp) — leave the Client ID / Secret fields blank —
then complete GitHub login. The same connector then works from Claude Desktop and
Claude mobile with no extra setup. Every identity must be in
ALLOWED_GITHUB_LOGINS or it is refused after login.
Deployment (Docker Compose)
Build and add this service to your Plane stack (same network as plane-db and
live; publish only the MCP port):
pages-mcp:
image: <registry>/plane_pages_mcp:${PAGES_MCP_RELEASE:-latest}
deploy: { replicas: 1, restart_policy: { condition: any } }
ports: ["8300:8300"]
environment:
DATABASE_URL: ${PAGES_MCP_DATABASE_URL}
LIVE_CONVERT_URL: http://live:3000/live/convert-document/
SERVICE_USER_ID: ${PAGES_MCP_SERVICE_USER_ID}
PLANE_BASE_URL: ${PAGES_MCP_PLANE_BASE_URL}
PLANE_API_KEY: ${PAGES_MCP_API_KEY}
WORKSPACE_SLUG: ${PAGES_MCP_WORKSPACE_SLUG}
PLANE_WEB_URL: ${PAGES_MCP_WEB_URL:-http://localhost}
# http auth (GitHub OAuth)
PUBLIC_BASE_URL: ${PAGES_MCP_PUBLIC_BASE_URL} # https://planemcp.<domain>
GITHUB_CLIENT_ID: ${PAGES_MCP_GITHUB_CLIENT_ID}
GITHUB_CLIENT_SECRET: ${PAGES_MCP_GITHUB_CLIENT_SECRET}
ALLOWED_GITHUB_LOGINS: ${PAGES_MCP_ALLOWED_GITHUB_LOGINS}
JWT_SIGNING_KEY: ${PAGES_MCP_JWT_SIGNING_KEY}
STORAGE_ENCRYPTION_KEY: ${PAGES_MCP_STORAGE_ENCRYPTION_KEY}
# Logical DB 1 isolates OAuth state from Plane's own Redis use (DB 0).
REDIS_URL: redis://plane-redis:6379/1
depends_on: [plane-db, live, plane-redis]JWT_SIGNING_KEY and STORAGE_ENCRYPTION_KEY are two independent secrets —
keep them so. They rotate for different reasons (a suspected leaked JWT vs.
leaked Redis data at rest) and must rotate independently; deriving one from the
other would let a single compromise expose both. No healthcheck is needed:
restart_policy: any already covers Valkey not being ready at boot (a one-restart
race), so Plane's own service definitions stay untouched.
External exposure is via the existing Cloudflare Tunnel to
https://planemcp.<domain> — public-hostname routing is host-level and already
forwards every path to :8300, so /auth/callback, /authorize, /token, and
the /.well-known/* documents all flow through the existing route untouched;
nothing to change there. The convert-document endpoint has no auth and must
remain reachable only inside the compose network. After cutover, rotate/burn the
old MCP_AUTH_TOKEN everywhere it was stored — it circulated in shells and configs.
Caveats & non-goals
Concurrency (last-writer-loses). If a page is open in a browser, the live server's in-memory Yjs doc wins on its next persist and can overwrite a tool write. No Yjs merging is attempted — write to pages that aren't being edited.
No page version history. Tool page-writes create no
PageLog/page_versionsentries. Cosmetic, but be aware.Non-goals: page delete/archive, nested pages (
parent), image/asset upload in content, workspace-global page creation (is_global), the internal session-auth app API; and for work items: delete, attachments, comments, intake. (Sub-work-items are supported viaparent, work-item relations vialink_work_items/unlink_work_items, and cycles/modules — CRUD + issue assignment — via the*_cycle/*_moduletools.)
Development
uv pip install -e '.[dev]'
pytest # conversions, row shaping, column coverage, config capabilities,
# REST helpers, work-item shaping (fake REST), auth middlewareIntegration testing runs against a real Plane instance using a throwaway project
and disposable pages/items (take a pg_dump first for pages).
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/jbresolin/plane_pages_mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server