Skip to content

Plane MCP for self-hosting, no OAuth, just header auth

Adityo Guni Waluyo

plane-mcp-server http mode always builds an OAuth app and crashes without env vars. A 16-line wrapper unlocks header auth with live token checks.

TL;DR

HTTP mode crashed at startup because it always built an OAuth app without env vars. A 16-line wrapper instead calls get_header_mcp() to run a stateless server on loopback 8211 that validates Authorization headers against Plane. This lets local AI agents securely use all 30 tools with no internet exposure or full OAuth setup.

The container log went red on the first deploy: the MCP process died instantly. I was running makeplane/plane-mcp-server version 0.3.3, the official server for wiring AI agents into Plane. The error was consistent: it tried to build an OAuth application while the OAuth provider environment variables were never set. This deployment is purely internal; it was never meant to become a hosted service.

My first guess missed. I assumed the self-hosted package's http mode would accept an API key option, similar to the hosted service which has a dedicated PAT endpoint with Authorization and X-Workspace-slug headers [5]. I looked for a flag to skip OAuth. Nothing there.

Reading the source made it obvious: the http mode is hardcoded to always build the OAuth app [5]. Empty env means a crash. The header-auth transport already exists in the code, one function call away through get_header_mcp() [7]. It just is not exposed as a CLI option.

So I wrote a 16-line Python wrapper, run_header.py, that calls that function directly. Not a single line of upstream code changed:

import uvicorn

from plane_mcp.server import get_header_mcp

mcp = get_header_mcp()
app = mcp.http_app(stateless_http=True)

uvicorn.run(app, host=HOST, port=8211, log_level="info")  # HOST: bind all interfaces inside the container

In compose, this service runs under the mcp profile and publishes its port to the host loopback only, on 8211. From inside the container it talks to Plane over the internal network, never through the internet. No egress, no secrets stored in the container; the tokens that actually grant access stay with the client.

I went stateless on purpose: every request stands on its own with no session to keep alive. The call pattern is bursty, so long-lived sessions would only add overhead.

One note if you want to copy this: when HTTP is not needed at all, the official README ships a stdio path through uvx that is far simpler, just pass the API key and workspace slug as environment variables [5]. I still chose HTTP because my client needs a URL that works from anywhere on the local network, and a compose profile makes this service easy to toggle separately from the main stack.

A resource server, not a token machine

Checking that this actually works is easy: send a POST to the initialize endpoint without a token, then repeat with a dummy one. Both return 401 invalid_token. That error is exactly what convinced me the setup was right: it means the server genuinely validates tokens against the live Plane instance instead of accepting anything.

This behavior is the spec working as designed. MCP defines authorization at the transport level, and a protected server acts as an OAuth 2.1 resource server [6]. Over HTTP such a server never signs anyone in and never issues tokens. It has one job: read the Authorization header on every request and decide whether the token is good [7]. How the verification happens is left to the implementer.

What all of this is for

The MCP server itself is no toy. It ships 30 tools covering 204 operations: reading and managing projects, work items, cycles, up to modules [5]. Once connected, a local AI agent can work directly on top of self-hosted Plane data with zero standard ports opened to the internet.

Two lessons stuck with me. For internal deployments, a thin TokenVerifier that checks against your app's existing API is enough; no full OAuth gate is needed just because an agent runs on your own network. And never assume the default mode is the only path. Sometimes the distance between a crash at startup and production is a 16-line wrapper.

Sources

  1. Official makeplane/plane-mcp-server repository
  2. MCP specification: authorization
  3. MCP Python SDK: authorization (resource server)

Related articles