Skip to main content
Glama
bhflm

plane-mcp

by bhflm
README.md
# plane-mcp

Local MCP server for a self-hosted Plane instance. Exposes tickets
(work items), cycles, modules, labels, and states as MCP tools, plus a
small set of mutation tools (label add/remove, state changes, ticket
create/update, module attach).

The Plane API key is read only from the `PLANE_API_KEY` environment
variable. It is never logged, never printed, and never included in tool
output — errors are redacted before being returned.

## Setup

```bash
npm install
npm run build

export PLANE_BASE_URL="https://your-plane-instance.example.com"
export PLANE_API_KEY="..."
export PLANE_DEFAULT_WORKSPACE="..."          # optional
export PLANE_DEFAULT_PROJECT_ID="..."         # optional
export PLANE_DEFAULT_PROJECT_IDENTIFIER="..." # optional
export PLANE_ENABLE_WRITES="false"            # optional; must be true for mutations
```

Copy `.env.example` to `.env` for reference (placeholders only — never
commit a real `.env`).

## Run

```bash
npm start
# or during development:
npm run dev
```

The server speaks MCP over stdio.

## MCP client config (stdio)

```json
{
  "mcpServers": {
    "plane": {
      "command": "node",
      "args": ["/absolute/path/to/plane-mcp/dist/src/index.js"],
      "env": {
        "PLANE_BASE_URL": "https://your-plane-instance.example.com",
        "PLANE_API_KEY": "${PLANE_API_KEY}",
        "PLANE_DEFAULT_WORKSPACE": "${PLANE_DEFAULT_WORKSPACE}",
        "PLANE_DEFAULT_PROJECT_ID": "${PLANE_DEFAULT_PROJECT_ID}",
        "PLANE_ENABLE_WRITES": "false"
      }
    }
  }
}
```

## Exposed tools

| Tool | Purpose |
|---|---|
| `plane_list_workspaces` | List accessible workspaces |
| `plane_list_projects` | List projects in a workspace |
| `plane_resolve_project` | Resolve a project identifier (e.g. `SDK`) to its UUID |
| `plane_search_tickets` | Search work items by query/key |
| `plane_get_ticket` | Fetch a full ticket by UUID or key (e.g. `SDK-250`) |
| `plane_list_cycles` | List cycles (sprints/milestones) |
| `plane_get_current_cycle` | Fetch the current active cycle |
| `plane_list_cycle_tickets` | List tickets in a cycle |
| `plane_list_current_cycle_tickets` | One-shot "what's in the current cycle"; link summaries by default |
| `plane_list_modules` | List modules (epics/milestones) |
| `plane_list_module_tickets` | List tickets in a module |
| `plane_list_labels` | List labels for a project |
| `plane_add_labels_to_ticket` | Add labels without clobbering existing ones |
| `plane_remove_labels_from_ticket` | Remove labels from a ticket |
| `plane_update_ticket` | Patch basic ticket fields |
| `plane_add_comment` | Add a comment to a ticket/work item |
| `plane_list_states` | List states/statuses |
| `plane_set_ticket_state` | Move a ticket to a state by name or id |
| `plane_create_ticket` | Create a ticket, optionally attach to cycle/module |
| `plane_add_ticket_to_module` | Attach an existing ticket to a module |
| `plane_get_ticket_children` | List child tickets by filtering on `parent` |

Tools return normalized, allowlisted summaries rather than raw Plane API
records. Mutating tools accept `dry_run: boolean` for previews. Real writes
are disabled unless `PLANE_ENABLE_WRITES=true` is set, and each mutating call
must also pass `confirm: true`; successful ticket updates are verified by
re-fetching the record afterward, while comment creation returns the created
comment from Plane.

`plane_add_comment` accepts either `comment_text` or `comment_html`.
`comment_text` is escaped and converted to simple paragraph HTML before it is
sent to Plane. Comments default to `access: "INTERNAL"` unless supplied.

## Testing

```bash
npm test
```

Unit tests mock `fetch` — no real Plane API key is required. Coverage
includes: auth header presence without leaking the key, redaction of
secrets from errors and result payloads, safe path construction, bounded
cursor pagination, schema limits, ticket-key normalization, and label-union
(non-clobbering) behavior.

## Smoke test

With real credentials exported (see Setup), you can drive the server
directly with the MCP inspector, or manually via any MCP-compatible
client pointed at `node dist/src/index.js`. A minimal manual check:

```bash
node --input-type=module -e "
import { PlaneClient } from './dist/src/planeClient.js';
const client = new PlaneClient({
  baseUrl: process.env.PLANE_BASE_URL,
  apiKey: process.env.PLANE_API_KEY,
  defaultWorkspace: process.env.PLANE_DEFAULT_WORKSPACE,
});
console.log(await client.paginate('/api/v1/workspaces/'));
"
```

## Known quirks (self-hosted Plane)

- `GET /api/v1/workspaces/` may 404 (no cross-workspace listing endpoint on
  some self-hosted deployments). Set `PLANE_DEFAULT_WORKSPACE` and use
  workspace-scoped tools directly instead of relying on
  `plane_list_workspaces`.
- Advanced search may return no results when `project_id` is included;
  the client retries workspace-wide and filters client-side.
- Cycle-issue and module-issue list endpoints may return link objects
  (`{ issue: <uuid> }`) rather than full ticket records. Current-cycle tools
  keep link summaries by default; pass `include_full_tickets: true` to join
  normalized ticket summaries.

## Security

- Credentials are read only from environment variables (`PLANE_API_KEY`,
  `PLANE_BASE_URL`).
- The API key is never printed, logged, or embedded in responses.
- Errors (HTTP failures, network failures, JSON parse failures) are
  passed through a redactor before being surfaced to the MCP client.
- Tool arguments used in Plane URL paths are validated and encoded before
  request construction.
- Tool outputs are allowlisted and redacted; raw Plane API records are not
  returned to MCP clients.
- Writes are off by default and require both `PLANE_ENABLE_WRITES=true` and
  `confirm: true` on each mutating call.
- `.env` is gitignored; only `.env.example` with placeholders is committed.