Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Example environment for self-hosting the Plane MCP Server over HTTP.
# Copy to .env and fill in real values. DO NOT commit your filled-in .env.

# --- Plane connection -------------------------------------------------------
# Base URL of your self-hosted Plane API (verify the path against your deployment).
PLANE_BASE_URL=https://your-plane-instance.example.com

# Optional: internal/server-to-server URL, takes precedence over PLANE_BASE_URL.
# PLANE_INTERNAL_BASE_URL=http://plane-api:8000

# --- Bind address/port ------------------------------------------------------
# PORT (injected by Cloud Run and similar) takes precedence over MCP_PORT.
MCP_HOST=0.0.0.0
MCP_PORT=8211

# Optional URL path prefix for all mounts (e.g. /plane -> /plane/mcp).
# MCP_PATH_PREFIX=

# --- OAuth provider (for the per-user OAuth mount /http/mcp) -----------------
# Required only when running OAuth against a Plane instance that exposes OAuth apps.
# PLANE_OAUTH_PROVIDER_CLIENT_ID=
# PLANE_OAUTH_PROVIDER_CLIENT_SECRET=
# PLANE_OAUTH_PROVIDER_BASE_URL=https://your-mcp-host.example.com

# --- Optional: persistent OAuth token storage -------------------------------
# REDIS_HOST=redis
# REDIS_PORT=6379
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ RUN uv pip install --system --no-cache .
# Expose port for HTTP transports (SSE, streamable-http, http)
EXPOSE 8211

# Set environment variables with defaults
ENV FASTMCP_PORT=8211
# Default bind port. Override with MCP_PORT, or with PORT (injected by Cloud Run
# and similar platforms, which takes precedence). MCP_HOST defaults to 0.0.0.0.
ENV MCP_PORT=8211

# Default to streamable-http transport, but allow override via command
# Users can override by passing different transport as CMD
Expand Down
55 changes: 54 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,59 @@ Connect to the hosted Plane MCP server using OAuth authentication via Server-Sen
**Note**: OAuth authentication will be handled automatically when connecting to the remote server. This transport is deprecated in favor of the HTTP transport.


### 5. Self-hosting the HTTP server

The sections above connect to Plane's **hosted** MCP service. You can also **run the HTTP server yourself** against a self-hosted Plane: it exposes the same OAuth (`/http/mcp`) and per-user PAT (`/http/api-key/mcp`) mounts, is configured entirely via environment variables, and is stateless and container/Cloud-Run-ready.

#### Run it

```bash
export PLANE_BASE_URL="https://your-plane-instance.example.com" # your self-hosted Plane
# For the OAuth mount, register a Plane OAuth app and set:
export PLANE_OAUTH_PROVIDER_CLIENT_ID="..."
export PLANE_OAUTH_PROVIDER_CLIENT_SECRET="..."
export PLANE_OAUTH_PROVIDER_BASE_URL="https://your-mcp-host.example.com"
export MCP_PORT="8211" # MCP_HOST defaults to 0.0.0.0

uvx plane-mcp-server http # or: python -m plane_mcp http
# OAuth MCP : http://localhost:8211/http/mcp
# PAT MCP : http://localhost:8211/http/api-key/mcp (Authorization: Bearer <PAT> + X-Workspace-slug)
# Health : http://localhost:8211/healthz
```

Per-user **PAT** access needs no OAuth app — clients call `/http/api-key/mcp` with `Authorization: Bearer <PAT>` and `X-Workspace-slug` headers.

#### Environment variables

| Variable | Default | Purpose |
|----------|---------|---------|
| `PLANE_BASE_URL` | `https://api.plane.so` | Base URL of your Plane API (point at your self-hosted instance). |
| `PLANE_INTERNAL_BASE_URL` | — | Optional server-to-server URL; takes precedence over `PLANE_BASE_URL`. |
| `MCP_HOST` | `0.0.0.0` | Bind address for HTTP mode. |
| `MCP_PORT` | `8211` | Bind port. Overridden by `PORT` when set (e.g. Cloud Run). |
| `MCP_PATH_PREFIX` | — | Optional URL path prefix for all mounts. |
| `PLANE_OAUTH_PROVIDER_CLIENT_ID` / `_SECRET` / `_BASE_URL` | — | OAuth app credentials for the `/http/mcp` mount. |
| `REDIS_HOST` / `REDIS_PORT` | — | Optional OAuth token storage (falls back to in-memory). |

#### Docker / Docker Compose

```bash
docker build -t plane-mcp-server .
docker run --rm -p 8211:8211 -e PLANE_BASE_URL="https://your-plane-instance.example.com" plane-mcp-server http
```

A ready-to-edit Compose file is provided as [`docker-compose.example.yml`](docker-compose.example.yml) and a sample environment as [`.env.example`](.env.example).

#### Cloud Run

The server is stateless and Cloud Run-ready: it **respects the injected `$PORT`** (which takes precedence over `MCP_PORT`) and exposes `GET /healthz` (returns `200 {"status":"ok"}`) for startup/liveness probes.

```bash
gcloud run deploy plane-mcp-server --source . --region <REGION> \
--set-env-vars PLANE_BASE_URL=https://your-plane-instance.example.com
```


## Configuration

### Authentication
Expand All @@ -127,7 +180,7 @@ export PLANE_API_KEY="your-api-key"
export PLANE_WORKSPACE_SLUG="your-workspace-slug"
```

**Note**: For remote HTTP transports (OAuth or PAT), authentication is handled via the connection method (OAuth flow or PAT headers) and does not require these environment variables.
**Note**: For the **hosted** remote HTTP transports (OAuth or PAT), authentication is handled via the connection method (OAuth flow or PAT headers) and does not require these environment variables. When **self-hosting the HTTP server** (section 5), configuration is env-driven — see the environment-variable table there.

### OAuth redirect URIs

Expand Down
28 changes: 28 additions & 0 deletions docker-compose.example.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Example: self-host the Plane MCP Server over HTTP against a self-hosted Plane.
# Usage:
# cp .env.example .env # then fill in PLANE_BASE_URL (+ PLANE_OAUTH_PROVIDER_* for OAuth)
# docker compose -f docker-compose.example.yml up --build
#
# The MCP endpoints are served on http://localhost:8211 (e.g. /http/mcp for OAuth,
# /http/api-key/mcp for per-user PAT headers). /healthz is the readiness probe.

services:
plane-mcp:
build: .
# Or use a published image instead of building:
# image: ghcr.io/makeplane/plane-mcp-server:latest
command: ["http"]
restart: unless-stopped
ports:
- "${MCP_PORT:-8211}:${MCP_PORT:-8211}"
env_file:
- .env
environment:
MCP_HOST: ${MCP_HOST:-0.0.0.0}
MCP_PORT: ${MCP_PORT:-8211}
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${MCP_PORT:-8211}/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
29 changes: 25 additions & 4 deletions plane_mcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
from fastmcp.server.dependencies import get_access_token
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from starlette.routing import Mount
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route

from plane_mcp.server import get_header_mcp, get_oauth_mcp, get_stdio_mcp

Expand Down Expand Up @@ -116,6 +118,23 @@ class ServerMode(Enum):
HTTP = "http"


def resolve_bind() -> tuple[str, int]:
"""Resolve the HTTP host/port to bind from the environment.

``MCP_HOST`` (default ``0.0.0.0``) and ``MCP_PORT`` (default ``8211``). Platforms
such as Cloud Run inject ``$PORT``; when set it takes precedence over ``MCP_PORT``
so the service always binds the platform-assigned port.
"""
host = os.getenv("MCP_HOST", "0.0.0.0")
port = int(os.getenv("PORT") or os.getenv("MCP_PORT", "8211"))
return host, port


async def healthz(request: Request) -> JSONResponse:
"""Liveness/readiness probe for container orchestration (Cloud Run, k8s)."""
return JSONResponse({"status": "ok"})


@asynccontextmanager
async def combined_lifespan(oauth_app, header_app, sse_app):
"""Combine lifespans from both OAuth and Header MCP apps."""
Expand Down Expand Up @@ -143,6 +162,7 @@ def main() -> None:
return

if server_mode == ServerMode.HTTP:
host, port = resolve_bind()
prefix = os.getenv("MCP_PATH_PREFIX") or ""

oauth_mcp = get_oauth_mcp(prefix + "/http")
Expand All @@ -160,6 +180,7 @@ def main() -> None:

app = Starlette(
routes=[
Route("/healthz", healthz),
# Well-known routes for OAuth and Header HTTP
*oauth_well_known,
*sse_well_known,
Expand Down Expand Up @@ -189,11 +210,11 @@ def main() -> None:
uv_handler.addFilter(UserContextFilter())
uv_logger.addHandler(uv_handler)

logger.info("Starting HTTP server at URLs: /mcp and /header/mcp")
logger.info("Starting HTTP server on %s:%s", host, port)
uvicorn.run(
app,
host="0.0.0.0",
port=8211,
host=host,
port=port,
log_level="info",
access_log=False,
)
Expand Down
100 changes: 100 additions & 0 deletions tests/test_self_hosted_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Tests for the self-hosted HTTP deployment path.

Covers env-driven host/port binding (incl. ``$PORT`` precedence), transport parsing,
``PLANE_BASE_URL`` propagation to the API client, the preserved per-user header-auth app,
and the ``/healthz`` probe. All network is avoided: the API client is only constructed
(never called) and MCP apps are exercised with Starlette's TestClient.
"""

from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient

from plane_mcp.__main__ import ServerMode, healthz, resolve_bind
from plane_mcp.client import get_plane_client_context
from plane_mcp.server import get_header_mcp

BIND_VARS = ("PORT", "MCP_HOST", "MCP_PORT")


class TestResolveBind:
"""resolve_bind() reads MCP_HOST/MCP_PORT, with PORT taking precedence."""

def _clean(self, monkeypatch):
for var in BIND_VARS:
monkeypatch.delenv(var, raising=False)

def test_defaults(self, monkeypatch):
self._clean(monkeypatch)
assert resolve_bind() == ("0.0.0.0", 8211)

def test_env_overrides(self, monkeypatch):
self._clean(monkeypatch)
monkeypatch.setenv("MCP_HOST", "127.0.0.1")
monkeypatch.setenv("MCP_PORT", "9000")
assert resolve_bind() == ("127.0.0.1", 9000)

def test_platform_port_takes_precedence_over_mcp_port(self, monkeypatch):
# Cloud Run and similar inject $PORT; it must win over MCP_PORT.
self._clean(monkeypatch)
monkeypatch.setenv("MCP_PORT", "9000")
monkeypatch.setenv("PORT", "8080")
assert resolve_bind() == ("0.0.0.0", 8080)


class TestTransportParsing:
def test_http_transport_resolves(self):
assert ServerMode("http") == ServerMode.HTTP
assert ServerMode("stdio") == ServerMode.STDIO


class TestPlaneBaseUrl:
"""PLANE_BASE_URL / PLANE_INTERNAL_BASE_URL flow into the constructed client."""

def test_plane_base_url_is_used(self, monkeypatch):
monkeypatch.delenv("PLANE_INTERNAL_BASE_URL", raising=False)
monkeypatch.setenv("PLANE_BASE_URL", "https://plane.example.com/api")
monkeypatch.setenv("PLANE_API_KEY", "dummy-key")
monkeypatch.setenv("PLANE_WORKSPACE_SLUG", "acme")

ctx = get_plane_client_context()

assert ctx.workspace_slug == "acme"
assert ctx.client.config.base_path.startswith("https://plane.example.com/api")

def test_internal_base_url_takes_precedence(self, monkeypatch):
monkeypatch.setenv("PLANE_BASE_URL", "https://public.example.com")
monkeypatch.setenv("PLANE_INTERNAL_BASE_URL", "http://plane-api:8000")
monkeypatch.setenv("PLANE_API_KEY", "dummy-key")
monkeypatch.setenv("PLANE_WORKSPACE_SLUG", "acme")

ctx = get_plane_client_context()

assert ctx.client.config.base_path.startswith("http://plane-api:8000")


class TestHeaderAuthPreserved:
"""The per-user header-auth (PAT passthrough) app stays gated."""

def test_header_app_rejects_request_without_workspace_slug(self):
app = get_header_mcp().http_app(stateless_http=True)
client = TestClient(app, raise_server_exceptions=False)
# Send a bearer token but omit X-Workspace-slug so the PAT-specific
# workspace-slug gate is exercised (not just generic unauthenticated handling).
response = client.post(
"/mcp",
headers={"Authorization": "Bearer dummy-pat"},
json={"jsonrpc": "2.0", "method": "initialize", "id": 1},
)
assert response.status_code in (401, 403)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


class TestHealthz:
"""The /healthz probe returns 200 with a JSON status body."""

def test_healthz_returns_ok(self):
app = Starlette(routes=[Route("/healthz", healthz)])
client = TestClient(app)
response = client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}