Repository navigation
feat: multi-user HTTP mode behind a gateway (for v0.3.0-sysblok.2) - #3
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.jsonstays0.3.0(the release workflow requires the tag to start withv<package.json version>-sysblok.), so there are no plugin-pin or identity changes. Nothing has been tagged or published.What changed
Transport.
MCP_TRANSPORT=httpor--http. A statelessStreamableHTTPServerTransportwith a freshServerand transport per request, no session ids, and JSON responses. UsesHOST(default0.0.0.0) andPORT(default3000).POST /mcpis the MCP endpoint.GET /healthzreturns 200 and never touches Planka.Per-request key (
src/request-context.ts).X-Api-Keyis read case-insensitively (Node lowercases header names) and kept inAsyncLocalStoragefor the life of the request.PlankaClient.authOptions()takes the key from there. TheplankaClientimport 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.PlankaAuthErrorinstead of falling back.Startup refusal.
getHttpConfig()throws ifPLANKA_API_KEY,PLANKA_AGENT_EMAILorPLANKA_AGENT_PASSWORDis set to a non-empty value.PLANKA_BASE_URLis still required.Refactor. Server construction moved from
src/index.tstosrc/server.ts(createMcpServer()) so both transports build the same server. The handler code itself is unchanged.Packaging.
EXPOSE 3000and aHEALTHCHECK(dist/healthcheck.js). It probes/healthzin HTTP mode and exits 0 in stdio mode, so stdio containers never show as unhealthy.PLANKA_API_KEYset.Docs. A README section "Multi-user HTTP mode (behind a gateway)" covering:
docker runexamples.Plus a CHANGELOG entry.
README.es.mdwas not translated.Audit of module-level mutable state
boardNamesCachesrc/operations/cards.ts<sha256(key)>:<boardId>(never the raw key). In stdio mode the key is one sharedprocessslot, 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/tokenExpiresAtPlankaClient(singleton)authOptions()returns the request key or throws, and the 401 retry depends onthis.token, which staysnull.configPlankaClient(singleton)baseUrl.users/megetCurrentUser()cachedPolicy/cachedEnvsrc/config/policy.tsPLANKA_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/FALSYServer/ transportsrc/http.tsKey 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.createMcpServer()scrubs every text block of every tool result as a last line of defence.planka_whoaminow reportsauthMode: "apiKey"in HTTP mode. Before, it called the env-onlygetConfig()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'sdata.url), so a link attachment would receive the caller'sX-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 thePLANKA_BASE_URLorigin:/attachments/<id>/download/…URL on another origin (Planka's publicBASE_URLwhile we talk to an internal address) is re-rooted ontoPLANKA_BASE_URL.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 /mcpreturn 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.node:http,node:async_hooks,node:crypto(for the SHA-256 fingerprint) andnode:fs(healthcheck) are added.fetchImpldefault is now(i, init) => globalThis.fetch(i, init)instead of capturingfetchat 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 perX-Api-Key, with random latency so requests genuinely interleave.planka_whoamicalls alternating between two keys. Each sees only its own user, and every Planka call carried exactly one caller's key, with noAuthorizationor cookie header.initialize,tools/listandtools/call, with zero Planka calls. An empty key counts as missing, and the header is matched case-insensitively.startHttpServer().PLANKA_BASE_URLis still required.console.error. A key Planka rejects is never echoed back./healthzreturns 200 without touching Planka, other paths get 404,/mcpwith GET or DELETE gets 405, oversized bodies get 413, and malformed JSON gets a -32700 parse error.I also broke the code on purpose to check the tests catch it. Each mutation made at least one test fail:
Manual verification (local)
I ran the built
dist/index.jsagainst a mock Planka that answers per key, and then the Docker image.node dist/index.js(HTTP mode):GET /healthzreturns{"status":"ok"}with a 200.initializewith a key returns 200 with serverInfo and instructions, andtools/listreturns 41 tools.planka_whoamiwith Alice's key returnsAlice apiKey; with Bob's key,Bob apiKey.initialize,tools/listandtools/callwithout a key each return 401 with{"jsonrpc":"2.0","error":{"code":-32001,"message":"Unauthorized: missing X-Api-Key header"},"id":null}./fooreturns 404 andGET /mcpreturns 405.PLANKA_API_KEYset, the server exits 1 with "HTTP mode refuses to start with PLANKA_API_KEY set …".tools/liststill lists the tools, and SIGTERM in HTTP mode exits cleanly.docker buildplus runs):healthy.initializewith a key returns 200; without one, 401.PLANKA_API_KEYset, the container refuses to start.docker run -istill answerstools/list, and the healthcheck exits 0 in a stdio container.Before release
v0.3.0-sysblok.2. The README examples already reference that tag, so they will 404 until it is pushed.🤖 Generated with Claude Code