Skip to content

feat: multi-user HTTP mode behind a gateway (for v0.3.0-sysblok.2) - #3

Merged
alexeyqu merged 1 commit into
mainfrom
feat/http-mode
Nov 1, 2026
Merged

alexeyqu merged 1 commit into
mainfrom
feat/http-mode

Conversation

@alexeyqu

@alexeyqu alexeyqu commented Nov 1, 2026

Copy link
Copy Markdown
Member

Adds a multi-user Streamable HTTP mode so one container behind LiteLLM (auth_type: api_key) can serve every editor, each with their own Planka API key. stdio stays the default and is unchanged.

Version: intended to ship as v0.3.0-sysblok.2. package.json stays 0.3.0 (the release workflow requires the tag to start with v<package.json version>-sysblok.), so there are no plugin-pin or identity changes. Nothing has been tagged or published.

What changed

  • Transport. MCP_TRANSPORT=http or --http. A stateless StreamableHTTPServerTransport with a fresh Server and transport per request, no session ids, and JSON responses. Uses HOST (default 0.0.0.0) and PORT (default 3000).

    • POST /mcp is the MCP endpoint.
    • GET /healthz returns 200 and never touches Planka.
    • Any other path gets a 404.
    • The body limit is 1 MB (413 above that).
    • SIGTERM shuts down gracefully.
  • Per-request key (src/request-context.ts). X-Api-Key is read case-insensitively (Node lowercases header names) and kept in AsyncLocalStorage for the life of the request. PlankaClient.authOptions() takes the key from there. The plankaClient import surface is unchanged, and no operations module was touched apart from the cache fix below.

  • No key, no request. A request without a key (or with an empty one) gets a 401 and {"jsonrpc":"2.0","error":{"code":-32001,...},"id":null} before the body is read or any MCP code runs.

    • In HTTP mode the client has no environment config. Calling it outside a request throws PlankaAuthError instead of falling back.
    • The email/password token path is unreachable in HTTP mode.
  • Startup refusal. getHttpConfig() throws if PLANKA_API_KEY, PLANKA_AGENT_EMAIL or PLANKA_AGENT_PASSWORD is set to a non-empty value. PLANKA_BASE_URL is still required.

  • Refactor. Server construction moved from src/index.ts to src/server.ts (createMcpServer()) so both transports build the same server. The handler code itself is unchanged.

  • Packaging.

    • Same image, plus EXPOSE 3000 and a HEALTHCHECK (dist/healthcheck.js). It probes /healthz in HTTP mode and exits 0 in stdio mode, so stdio containers never show as unhealthy.
    • CI gets an HTTP smoke step: the container becomes healthy, a request without a key gets 401, a request with a key gets 200, and the container refuses to start with PLANKA_API_KEY set.
  • Docs. A README section "Multi-user HTTP mode (behind a gateway)" covering:

    • the threat model;
    • not exposing the port publicly;
    • how LiteLLM sends the key;
    • docker run examples.

    Plus a CHANGELOG entry. README.es.md was not translated.

Audit of module-level mutable state

State Where Shared across users? Outcome
boardNamesCache src/operations/cards.ts Yes, the known leak Fixed. Keyed by <sha256(key)>:<boardId> (never the raw key). In stdio mode the key is one shared process slot, which is the old behaviour. Also bounded to 1000 entries: expired entries are swept first, then the oldest is evicted, because a long-lived multi-user process would otherwise grow without limit.
token / tokenExpiresAt PlankaClient (singleton) Would be, but Unreachable in HTTP mode. The env credential is refused at startup, authOptions() returns the request key or throws, and the 401 retry depends on this.token, which stays null.
config PlankaClient (singleton) Shared, not per-user Safe. In HTTP mode it holds only baseUrl.
users/me getCurrentUser() — Safe. Not memoised; fetched per call with the request key.
cachedPolicy / cachedEnv src/config/policy.ts Shared Documented. Derived from env only and identical for everyone. The scoping variables (PLANKA_ALLOWED_BOARD_IDS, PLANKA_READ_ONLY, PLANKA_DEFAULT_BOARD_ID, …) therefore apply to every user of an instance. The README says so. Per-user limits belong in Planka memberships.
byUri, byName, WRITE_TOOL_NAMES, DEPRECATED_TOOL_NAMES, TRUTHY/FALSY resources, prompts, policy, tools — Safe. Built from constants at import time and never mutated.
MCP Server / transport src/http.ts — Safe. A new pair per request, closed on response close.

Key never logged or echoed

  • PlankaClient.responseError() scrubs every live credential out of Planka's error message. Covered by a test where Planka returns a 400 whose body includes the key.
  • In HTTP mode a 401 from Planka gets its own message, which does not mention env variables or the key.
  • createMcpServer() scrubs every text block of every tool result as a last line of defence.
  • Unexpected errors are logged as scrubbed strings, not raw objects. The HTTP layer never logs requests or headers.
  • planka_whoami now reports authMode: "apiKey" in HTTP mode. Before, it called the env-only getConfig() and fell back to "unknown".

Second leak found during the audit, also fixed

getBinary() sent credentials to any absolute URL. That URL comes from board data (an attachment's data.url), so a link attachment would receive the caller's X-Api-Key (plus the cookie). In multi-user mode, anyone who can edit a card could collect other users' keys this way. Credentialed downloads now go only to the PLANKA_BASE_URL origin:

  • A Planka /attachments/<id>/download/… URL on another origin (Planka's public BASE_URL while we talk to an internal address) is re-rooted onto PLANKA_BASE_URL.
  • Anything else is refused before a request goes out.

This also applies in stdio mode, which is the one behaviour change there. It only affects downloads to foreign hosts.

Deviations from the brief, with reasons

  • GET/DELETE /mcp return 405, not 404. The MCP spec asks for 405 when a server offers no SSE stream. The SDK client treats a 405 on its optional GET as "no stream", but treats a 404 as an error (an expired session). Every other path still gets a 404.
  • No new runtime dependencies. Only node:http, node:async_hooks, node:crypto (for the SHA-256 fingerprint) and node:fs (healthcheck) are added.
  • fetchImpl default is now (i, init) => globalThis.fetch(i, init) instead of capturing fetch at construction. Same behaviour, but the singleton can be tested with a stubbed global fetch.

Tests

New file: tests/unit/http-mode.test.ts, 26 tests. They run the real server, singleton client, tool handlers and SDK transport on an ephemeral port. The fetch stub answers per X-Api-Key, with random latency so requests genuinely interleave.

  • 24 interleaved concurrent planka_whoami calls alternating between two keys. Each sees only its own user, and every Planka call carried exactly one caller's key, with no Authorization or cookie header.
  • The board-names cache is not shared between keys: each key's first read misses, a key's own repeat read hits. Its keys are hashes, not raw keys. Also checked under concurrency.
  • Requests without a key get 401 for initialize, tools/list and tools/call, with zero Planka calls. An empty key counts as missing, and the header is matched case-insensitively.
  • Startup is refused for each of the three env credentials, including through startHttpServer(). PLANKA_BASE_URL is still required.
  • An error body that echoes the key is redacted in the tool result and in console.error. A key Planka rejects is never echoed back.
  • A foreign-origin download is refused with no request sent. A Planka attachment URL from another origin is re-rooted.
  • Routes: /healthz returns 200 without touching Planka, other paths get 404, /mcp with GET or DELETE gets 405, oversized bodies get 413, and malformed JSON gets a -32700 parse error.
  • stdio is unchanged: it is the default transport, it still authenticates with the env key, and it still requires env credentials.

I also broke the code on purpose to check the tests catch it. Each mutation made at least one test fail:

  • a single shared cache slot;
  • removing the redaction;
  • the client remembering the last request's key on the instance.
npm run build   # ok (check:identity + tsc)
npm test        # Test Files 29 passed (29) · Tests 325 passed (325)

Manual verification (local)

I ran the built dist/index.js against a mock Planka that answers per key, and then the Docker image.

  • node dist/index.js (HTTP mode):
    • GET /healthz returns {"status":"ok"} with a 200.
    • initialize with a key returns 200 with serverInfo and instructions, and tools/list returns 41 tools.
    • planka_whoami with Alice's key returns Alice apiKey; with Bob's key, Bob apiKey.
    • initialize, tools/list and tools/call without a key each return 401 with {"jsonrpc":"2.0","error":{"code":-32001,"message":"Unauthorized: missing X-Api-Key header"},"id":null}.
    • /foo returns 404 and GET /mcp returns 405.
    • With PLANKA_API_KEY set, the server exits 1 with "HTTP mode refuses to start with PLANKA_API_KEY set …".
    • stdio with tools/list still lists the tools, and SIGTERM in HTTP mode exits cleanly.
  • Docker image (docker build plus runs):
    • The HTTP container turns healthy. initialize with a key returns 200; without one, 401.
    • With PLANKA_API_KEY set, the container refuses to start.
    • stdio over docker run -i still answers tools/list, and the healthcheck exits 0 in a stdio container.
    • The new CI smoke step also passes when run locally against this image.

Before release

  • After merge, tag v0.3.0-sysblok.2. The README examples already reference that tag, so they will 404 until it is pushed.
  • The LiteLLM snippet in the README is a sketch. Please check the field names (and how per-user BYOK keys are stored) against our LiteLLM version.

🤖 Generated with Claude Code

MCP_TRANSPORT=http (or --http) serves stateless Streamable HTTP on POST /mcp.
Each request runs as the owner of its X-Api-Key, carried in
AsyncLocalStorage; a request without one gets a 401 before any tool runs, and
the server refuses to start with any environment credential set. stdio stays
the default and is unchanged.

- The board-names cache is partitioned by a SHA-256 hash of the key, and bounded.
- Credentials are scrubbed from Planka error text, tool results and logs.
- Credentialed downloads only go to the PLANKA_BASE_URL origin.
- The image gains EXPOSE 3000 and a HEALTHCHECK; CI smoke-tests HTTP mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alexeyqu
alexeyqu merged commit 3ea52f2 into main Nov 1, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant