plane-selfhost-mcp
by nicolasegpla
README.md
# plane-selfhost-mcp
Custom MCP server for **self-hosted Plane** instances that require `X-API-Key` authentication instead of `Authorization: Bearer`.
This project exists because the official Plane MCP flow did not work reliably against this self-hosted environment. This server talks directly to the Plane REST API, exposes a small focused MCP toolset, and is designed to be consumed from OpenCode.
## What this project does
- Connects to a self-hosted Plane workspace with `X-API-Key`
- Exposes Plane operations as MCP tools over stdio
- Supports project discovery, issue listing, issue creation, issue updates, parent-child hierarchy updates, comments, workflow state lookup, and project label management
- Includes experimental/diagnostic project page tools for Plane editions/builds that expose API-key-compatible Pages endpoints
- Works with OpenCode through a local MCP server entry such as `plane-selfhost`
## Why it was created
The official Plane MCP server uses `Authorization: Bearer <token>`. In this self-hosted setup that produced auth failures, while direct API calls with `X-API-Key` worked correctly.
So the right move was NOT to keep fighting the wrong auth model. The right move was to build a thin MCP wrapper around the API behavior that the real instance actually accepts.
## Quick start
### Requirements
- Python 3.10+
- A reachable self-hosted Plane instance
- A Plane API key with workspace access
- The Plane workspace slug
### Install
```bash
cd plane-selfhost-mcp
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```
### Configure environment
Preferred path: copy `.env.example` to a project-root `.env` file so OpenCode and local commands can share one source of truth.
```bash
cp .env.example .env
```
Then edit `.env` with your real values.
At startup the MCP loads `.env` automatically, then reads the real process environment on top of it. That means explicit environment variables still win when you need a one-off override.
If you prefer, you can still export the variables directly instead of using `.env`.
The server fails fast with a clear error if any required variable is missing from both sources.
### Run locally
#### stdio mode
```bash
source .venv/bin/activate
python -m plane_selfhost_mcp
```
#### Installed script
```bash
source .venv/bin/activate
plane-selfhost-mcp
```
## Makefile shortcuts
After installing the package, you can use the local Makefile instead of manually activating the virtual environment:
```bash
make help # list available targets
make install # install the package and dev dependencies in .venv
make run # run the MCP server (sources .env automatically if present)
make smoke-test # verify config loads (sources .env automatically if present)
make whoami # print the authenticated Plane user (sources .env automatically if present)
make list-projects # list workspace projects (sources .env automatically if present)
make list-states # list workflow states for a project (requires PROJECT_ID=...)
make create-test-issue # create a test issue in a project (requires PROJECT_ID=... TITLE=...)
make move-issue # move an issue to a target state (requires PROJECT_ID=... ISSUE_ID=... STATE_ID=...)
make comment # add an HTML/plain comment to an issue (requires PROJECT_ID=... ISSUE_ID=... COMMENT="...")
make test # run pytest
```
Free-form values such as `TITLE` and `COMMENT` are passed to the inline Python scripts through environment variables, so quotes and special characters do not break the command.
`make run`, `make smoke-test`, `make whoami`, `make list-projects`, `make list-states`, `make create-test-issue`, `make move-issue`, and `make comment` will source a `.env` file in the project root if one exists, so you do not need to export variables manually every time.
## OpenCode integration
Add this MCP server to your OpenCode config:
```jsonc
{
"mcp": {
"plane-selfhost": {
"type": "local",
"enabled": true,
"command": [
"/absolute/path/to/plane-selfhost-mcp/.venv/bin/python",
"-m",
"plane_selfhost_mcp"
]
}
}
}
```
Recommended setup for OpenCode: keep Plane credentials in the repo's `.env` file and let the MCP load them automatically at startup.
Use the MCP `env` block only when you intentionally want OpenCode to override the `.env` values for that process.
Replace `/absolute/path/to/plane-selfhost-mcp/.venv/bin/python` with the Python executable from your own local virtual environment.
After updating OpenCode config, restart OpenCode. MCP config is not hot-reloaded.
## Available tools
| Tool | What it does | Typical use |
| --- | --- | --- |
| `get_user_me` | Returns the authenticated Plane user | Verify auth/config works |
| `list_projects` | Lists projects in the configured workspace | Resolve a target project before issue work |
| `list_project_issues` | Lists issues for a project | Find an issue before updating it |
| `list_project_labels` | Lists labels for a project | Resolve label IDs or inspect available labels |
| `list_project_pages` | Experimentally lists pages for a project when the Plane edition/build exposes API-key-compatible Pages endpoints | Diagnose page endpoint availability or inspect pages on supported builds |
| `create_project_page` | Experimentally creates a page when the Plane edition/build exposes API-key-compatible Pages endpoints | Add rich project documentation only on supported builds |
| `update_project_page` | Experimentally updates page fields when the Plane edition/build exposes API-key-compatible Pages endpoints | Edit page metadata only on supported builds |
| `create_project_label` | Creates a label in a project | Add a new label before assigning it to issues |
| `create_issue` | Creates a new issue | Add a task/bug/story in Plane |
| `update_issue` | Updates issue fields | Move states, rename, reprioritize, assign, relabel |
| `set_issue_parent` | Sets an issue's parent in Plane's hierarchy | Create parent-child issue/work-item structure, not dependencies |
| `add_issue_comment` | Adds an HTML comment to an issue | Leave progress notes or audit comments |
| `list_states` | Lists workflow states for a project | Resolve initial/done state IDs before transitions |
## How the MCP should be used
This MCP is intentionally simple. The safest usage flow is:
1. Call `get_user_me` to prove the connection.
2. Call `list_projects` to resolve the target `project_id`.
3. If the action depends on workflow placement, call `list_states`.
4. For labels, call `list_project_labels` first or create them with `create_project_label`.
5. For pages, first confirm your Plane edition/build exposes API-key-compatible Pages endpoints; on Plane Community v1.3.1, these tools are expected to fail diagnostically.
6. Create or update issues and supported pages only after resolving IDs from live responses.
7. Treat responses as successful only when the MCP payload returns `ok: true`.
### Important operational rules
- Do **not** invent `project_id`, `issue_id`, or `state` values.
- Label assignment accepts existing label UUIDs or names, but names are resolved against the project's existing labels before the request is sent.
- Missing label names are rejected locally with a clear error. Issue tools do **not** auto-create labels.
- Use `create_project_label` explicitly when you want to create a new label.
- Use `description_html` and `comment_html` for rich text fields.
- Plane Community v1.3.1 exposes project pages only through web-app/session-auth routes under `/api/workspaces/{workspace_slug}/projects/{project_id}/pages/`. As tested, API-key-compatible Pages API routes are not available in Community.
- Page tools are experimental/diagnostic unless the target Plane edition/build exposes API-key-compatible Pages endpoints. On Community v1.3.1, they are expected to fail diagnostically rather than provide functional Pages support.
- Existing project, issue, label, and state tools continue to use public `/api/v1/workspaces/...` routes with `X-API-Key`.
- External Pages API access with API-key authentication appears to be Commercial/Cloud functionality, not Plane Community behavior. The MCP intentionally does not implement browser/session auth.
- Use `set_issue_parent` for parent-child hierarchy only. It calls Plane's work-items API with `parent`; it does not create dependency relations such as blocking or blocked-by.
- Self-hosted Plane in this setup expects `X-API-Key`, not bearer auth.
- If auth fails, debug credentials first; do not assume the API path is wrong.
## Shared OpenCode skill
This repository includes a shared `plane-mcp` skill artifact that another user can copy into their own OpenCode skills setup.
- Skill location in this repository: `skills/plane-mcp/SKILL.md`
The repository does **not** auto-register that skill for anyone. OpenCode will only use it after each user copies or registers it in their own local OpenCode configuration.
### Skill purpose
The `plane-mcp` skill exists so agents follow the correct operational contract every time instead of improvising.
It teaches the agent to:
- target the custom `plane-selfhost-mcp`
- verify config and auth first
- resolve IDs from live Plane responses
- use `list_states` before state transitions
- stop and surface errors when the MCP payload returns `ok: false`
### How another user can use it
1. Clone this repository and install the MCP package.
2. Configure the repository root `.env` file with real Plane credentials.
3. Add the MCP server entry shown in `OpenCode integration` to your own OpenCode config.
4. Copy `skills/plane-mcp/SKILL.md` into your own OpenCode skills directory, or register it from your own OpenCode setup.
5. Restart OpenCode after registering the skill.
The `.env`-first recommendation stays the same: keep credentials in the repository root `.env` file by default, and use the MCP `env` block only for intentional per-process overrides.
## Verification and smoke tests
### Config loader only
```bash
source .venv/bin/activate
python -c "from plane_selfhost_mcp.config import load_config; print(load_config())"
```
### Real API check
```bash
source .venv/bin/activate
python -c "
import asyncio
from plane_selfhost_mcp.config import load_config
from plane_selfhost_mcp.client import PlaneClient
async def main():
cfg = load_config()
client = PlaneClient(cfg)
try:
print('ME:', await client.get_user_me())
print('PROJECTS:', await client.list_projects())
finally:
await client.close()
asyncio.run(main())
"
```
Expected result:
- `get_user_me` returns the authenticated user
- `list_projects` returns workspace projects
## Label workflow
Plane issue mutations expect label UUIDs, but this MCP now resolves existing project label names for you before sending the API request.
Deterministic behavior:
- `create_issue` and `update_issue` accept `labels` as a list of existing label UUIDs and/or existing label names.
- Name resolution checks the target project's labels first.
- Exact name matches win first.
- If only one case-insensitive match exists, it is accepted.
- If a name does not exist, the MCP fails before sending the issue mutation.
- If case-insensitive matching is ambiguous, the MCP fails and tells you to use a UUID.
- Labels are only created through `create_project_label`.
Example flow:
1. Call `list_project_labels` for the target project.
2. If the label is missing, call `create_project_label`.
3. Call `create_issue` or `update_issue` with either the label UUID or the label name.
Live verification status:
- After restarting OpenCode so it reloaded the MCP config, we verified live MCP issue creation with labels against the self-hosted Plane instance.
- The successful path was: restart OpenCode -> resolve the project -> ensure the label already exists (or create it first) -> call `create_issue` with label names.
## Development
### Run tests
```bash
make test
```
Or, with the virtual environment activated:
```bash
pytest
```
### Package facts
| Topic | Value |
| --- | --- |
| Python | `>=3.10` |
| Entry point | `plane-selfhost-mcp = plane_selfhost_mcp.server:main` |
| Runtime deps | `httpx`, `mcp`, `pydantic` |
| Test deps | `pytest`, `pytest-asyncio`, `respx` |
## Project structure
```text
src/plane_selfhost_mcp/
├── client.py # Plane REST client
├── config.py # env loading and validation
├── server.py # MCP tool registration and stdio server
└── __main__.py # python -m entrypoint
tests/
└── test_client.py # HTTP client tests
```
## Troubleshooting
### Missing environment variables
If you see a runtime error about missing `PLANE_BASE_URL`, `PLANE_API_KEY`, or `PLANE_WORKSPACE_SLUG`, check the project-root `.env` file first, then check whether the MCP process is receiving any explicit env overrides.
### 401 Unauthorized
Plane received no credentials. Check whether `.env` exists in the project root, then check whether the MCP `env` block is actually being passed.
For project page tools specifically, a `401 Unauthorized` response can also mean the route exists but Plane rejected the current `X-API-Key` auth for pages. The self-hosted pages endpoint may require session, JWT, cookie auth, or an API token with pages support. This MCP intentionally does not support browser/session auth yet.
Current self-hosted `stable` finding: project pages are exposed through Plane's web-app/session-auth route family, not through the public API-key route family used by the rest of this MCP. Do not configure browser cookies or session tokens as a workaround unless Plane documents that auth flow as a stable automation contract.
### 403 Forbidden
The credential reached Plane but the API key is invalid or lacks access.
### Project not found
Verify the workspace slug and call `list_projects` before attempting issue operations.
### Project pages API returns 404
If `list_project_pages`, `create_project_page`, or `update_project_page` returns 404 while project and issue tools work, verify that the self-hosted Plane pages routes are available under `/api/workspaces/{workspace_slug}/projects/{project_id}/pages/` rather than the v1 prefix. Existing project, issue, label, and state tools still use `/api/v1/workspaces/...`. Also confirm the API key includes `projects.pages:read` or `projects.pages:write`, because Plane may mask missing page scopes as 404.
## License
MIT
TDQS
A3.5/5.0
Scored across 7 tools
Disambiguation5/5
Each tool has a clearly distinct purpose: creating, updating, listing issues, listing projects, states, adding comments, and getting the current user. No overlaps.
Naming Consistency5/5
All tools use snake_case with a consistent verb_noun pattern (e.g., list_projects, create_issue, update_issue). Even 'get_user_me' fits the pattern.
Tool Count5/5
Seven tools is well-scoped for a project management server, covering essential operations without being too sparse or excessive.
Completeness4/5
Covers CRUD for issues and listing of projects and states. Minor gaps: missing delete issue, single issue retrieval, and user management beyond current user.
Maintenance
ActivitySlowing
ResponsivenessNo issues