diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..876f649 --- /dev/null +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile index 97e65f9..e4e0045 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 191fc42..55c268f 100644 --- a/README.md +++ b/README.md @@ -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 + 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 ` 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 \ + --set-env-vars PLANE_BASE_URL=https://your-plane-instance.example.com +``` + + ## Configuration ### Authentication @@ -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 diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..2626acf --- /dev/null +++ b/docker-compose.example.yml @@ -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 diff --git a/plane_mcp/__main__.py b/plane_mcp/__main__.py index 176b611..8dfa1b8 100644 --- a/plane_mcp/__main__.py +++ b/plane_mcp/__main__.py @@ -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 @@ -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.""" @@ -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") @@ -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, @@ -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, ) diff --git a/tests/test_self_hosted_http.py b/tests/test_self_hosted_http.py new file mode 100644 index 0000000..74642f1 --- /dev/null +++ b/tests/test_self_hosted_http.py @@ -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) + + +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"}