A multi-tenant Model Context Protocol gateway for Plane. Each authenticated user can register multiple Plane workspaces / projects through a web UI and expose each one as its own MCP endpoint at /mcp/<slug>. MCP clients authenticate via OAuth 2.1 (Clerk as the upstream identity provider) and get a per-config tool surface backed by the user's Plane API key.
Built on Cloudflare Workers (Durable Objects + KV) with TanStack Start for the UI, @cloudflare/workers-oauth-provider for OAuth, and @clerk/backend for identity.
- Multi-tenant by URL slug — one account, many configs.
/mcp/work,/mcp/personal, etc. each carry their own Plane workspace, API key, and tool set. - ~109 Plane tools ported from the official
plane-mcp-server: work items, cycles, modules, epics, milestones, intake, labels, states, pages, comments, links, work logs, properties, types, members, features, and more. - Project pinning — optionally lock a config to a single Plane project. When pinned, the
project_idparameter is automatically removed from every tool schema and project-management tools (list_projects,create_project,delete_project) are hidden. This makes LLM interactions noticeably faster because the model never has to discover or pass a project id. - Live tool refresh — change a config in the UI and connected MCP sessions see
notifications/tools/list_changedwithin seconds. No reconnect needed. If a config is deleted mid-session the next request returns410 Gone. - OAuth 2.1 for MCP clients — Clerk handles user auth; the gateway mints MCP-side tokens that encrypt the user's Clerk identity. Compatible with
mcp-remote, MCP Inspector, Claude Desktop, Cursor, Windsurf, etc. - Test-from-UI — every config gets a "Test connection" button that pings the Plane API end-to-end (workspace + api key) and surfaces the actual error message on failure.
- Per-user isolation — KV keys are namespaced by Clerk user id (
plane:cfg:<userId>:<slug>); a config is only visible to its owner.
┌───────────────────┐
│ TanStack Start UI │ /app/configs
browser ──────cookie auth───── │ + Hono /api/* │ /api/configs/...
└─────────┬─────────┘
│ KV: plane:cfg:<userId>:<slug>
▼
┌──────────────┐ ┌─────────────────────────────────┐
│ MCP client │ ──OAuth─▶ @cloudflare/workers-oauth-provider │
│ (Claude etc) │ └─────────────┬───────────────────┘
└──────────────┘ │
▼
┌──────────────────────┐
/mcp/<slug> ───▶│ MyMCP Durable Object │ ──▶ Plane API
│ (per session) │ (per-config key)
└──────────────────────┘
Top-level dispatch in src/server.ts:
| Path pattern | Handler |
|---|---|
/(mcp|sse)/<slug>(/...) |
strip slug, set X-Plane-Config-Slug, forward to OAuthProvider |
/authorize, /callback, /register, /token, /.well-known/* |
OAuthProvider |
/api/... |
Hono app (src/api/index.ts), Clerk session auth |
| anything else | TanStack Start (UI) |
On each MCP request the Durable Object reloads the config from 2, compares updatedAt to the last-registered version, and tears down / re-registers Plane tools if it has changed. The MCP SDK emits notifications/tools/list_changed automatically. KV is eventually consistent across colos, so changes typically propagate within seconds (up to ~60 s worst case).
-
Install dependencies
bun install
-
Set up Clerk — see Configuring Clerk below. For local dev use
http://localhost:8788/callbackas the redirect URL. -
Configure environment
cp .env.example .env
Fill in:
CLERK_CLIENT_ID=... CLERK_CLIENT_SECRET=... CLERK_SECRET_KEY=sk_test_... CLERK_PUBLISHABLE_KEY=pk_test_... CLERK_FRONTEND_API=https://your-subdomain.clerk.accounts.dev COOKIE_ENCRYPTION_KEY=<openssl rand -hex 32>
-
Run the dev server
bun run dev
Open http://localhost:8788. Sign in, go to Plane configurations, click New config, and fill in:
- Slug — URL identifier (lowercase, hyphens; 2–63 chars). Your MCP endpoint becomes
/mcp/<slug>. - Display name — friendly label.
- Plane workspace slug — from your Plane workspace URL.
- Plane API key — from Plane → Settings → API tokens.
- Base URL (optional) — defaults to
https://api.plane.so. Set this for self-hosted Plane. - Pinned project (optional) — click Load projects, pick one to lock the config to a single project.
- Slug — URL identifier (lowercase, hyphens; 2–63 chars). Your MCP endpoint becomes
-
Test with MCP Inspector
bunx @modelcontextprotocol/inspector@latest
Connect to
http://localhost:8788/mcp/<your-slug>and complete the OAuth flow.
The gateway acts as an OAuth client to Clerk (Clerk is the upstream identity provider) and as an OAuth server to MCP clients. You only need to set up the upstream side — the MCP-facing OAuth server is handled by @cloudflare/workers-oauth-provider automatically.
-
Sign up / log in at the Clerk Dashboard and create an application.
-
In your application, go to Configure → OAuth applications and click Add OAuth application. Pick any name — this is internal.
-
Redirect URL — add the gateway's callback. The path is always
/callback:- Local dev:
http://localhost:8788/callback - Production:
https://<your-host>/callback
You can add multiple URLs to one OAuth application if you want a single Clerk app to serve both environments.
- Local dev:
-
Scopes — the gateway requires all of:
openidprofileemailoffline_access— needed so MCP clients can refresh tokens without re-prompting the user
-
Copy these values, you'll need them as worker secrets:
- Client ID and Client secret from the OAuth application you just created →
CLERK_CLIENT_ID,CLERK_CLIENT_SECRET - Secret key (
sk_…) and Publishable key (pk_…) from API keys →CLERK_SECRET_KEY,CLERK_PUBLISHABLE_KEY - Frontend API URL (e.g.
https://your-subdomain.clerk.accounts.dev) →CLERK_FRONTEND_API
- Client ID and Client secret from the OAuth application you just created →
- Add
https://<your-host>/callbackto the Clerk OAuth application's redirect URLs (see Configuring Clerk). - Create the KV namespace and copy the id into
wrangler.jsonc:wrangler kv namespace create OAUTH_KV
- Set secrets:
wrangler secret put CLERK_CLIENT_ID wrangler secret put CLERK_CLIENT_SECRET wrangler secret put CLERK_SECRET_KEY wrangler secret put CLERK_PUBLISHABLE_KEY wrangler secret put CLERK_FRONTEND_API wrangler secret put COOKIE_ENCRYPTION_KEY
- Deploy:
bun run deploy
Each config exposes both transports:
https://<host>/mcp/<slug>— Streamable HTTP (preferred)https://<host>/sse/<slug>— SSE (deprecated, still supported)
Use mcp-remote as a stdio adapter, since most desktop clients don't yet ship OAuth-capable remote transports:
{
"mcpServers": {
"plane-work": {
"command": "npx",
"args": ["mcp-remote", "https://<your-host>/mcp/work"]
}
}
}The first connection opens a browser for the Clerk OAuth flow; tokens are cached locally afterward.
Clients that speak OAuth 2.1 + Streamable HTTP can connect directly to https://<host>/mcp/<slug>. Discovery is published at /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource per RFC 9728.
A config is in one of two modes:
| Mode | project_id parameter |
list_projects / create_project / delete_project |
|---|---|---|
| All projects (default) | required on every tool that touches a project | available |
| Pinned to one project | removed from every tool schema | hidden |
Pinning is the right choice when an MCP session is scoped to one project — it eliminates a tool round-trip the LLM otherwise has to make to resolve the project id, and keeps the visible tool list focused.
You can toggle pinning at any time from the config's edit page. Existing MCP sessions pick up the change automatically on their next request.
Two kinds of tools:
-
Top-level tools — available to every authenticated user regardless of config. Register in
MyMCP.init()insrc/mcp/mcp-app.ts. -
Plane tools — per-config, registered per request. Add to
src/plane/tools/<resource>.ts:server.tool( "snake_case_name", "human-readable description", { ...projectIdField(ctx), other_param: z.string() }, async (input) => toolResult(() => resource.method(ctx.config, ctx.workspaceSlug, { project_id: requireProjectId(ctx, input), other_param: input.other_param, }), ), );
The HTTP call lives in
src/plane/resources/<resource>.ts. Wire new resources intosrc/plane/tools/index.ts.
See CLAUDE.md for the full architecture reference (Plane URL conventions, helper functions, common gotchas).
- Cloudflare Workers + Durable Objects + KV
@cloudflare/workers-oauth-provider— OAuth 2.1 server@clerk/backend+@clerk/tanstack-react-start— identity@modelcontextprotocol/sdk+agents— Durable MCP- TanStack Start + TanStack Router — UI
- Hono —
/api/* - shadcn/ui + Tailwind v4 — components
- Zod — schema validation
- Bun — package manager / runtime
- Each config's Plane API key is stored in KV under a user-scoped key. The plaintext key is never returned over the HTTP API — list/get/patch responses always show
••••<last4>. - OAuth state cookies are
__Host--prefixed, HTTP-only, one-time-use, and bound to a 10-minute KV-side TTL. - The OAuth metadata response is rewritten to
https://on non-local hosts to support tunneled deployments. WWW-Authenticateheaders on 401s includeresource_metadata=per RFC 9728.- Envelope-encrypting Plane API keys with
COOKIE_ENCRYPTION_KEYis a planned follow-up; for now treat the KV namespace as sensitive.
