Plan: MCP over HTTP and the Python bridge on a production server
Status: implemented — 2026-08-22.
Problem
The Docker image (ghcr.io/nodetool-ai/nodetool), fly.toml, and
docker-compose.yml set NODETOOL_ENV=production. Two surfaces turn off in
that mode:
- The
/mcpstreamable-HTTP mount (packages/websocket/src/server.ts, theif (!isProduction)block around theapp.all("/mcp", …)route). An MCP-aware agent cannot reach a self-hosted server over HTTP. - The Python bridge
(
packages/runtime/src/python-stdio-bridge.ts,_assertCanConnectandhasPython). Python nodes are unavailable.
A self-hoster asked for an opt-in flag for each, modeled on
NODETOOL_ENABLE_EXTENSION_BRIDGE=1
(packages/websocket/src/plugins/websocket.ts). Their setup: one shared
instance behind a VPN, humans on the web UI, an agent on MCP with a bearer
token.
Findings
The Python flag already exists. NODETOOL_ALLOW_PYTHON_BRIDGE_IN_PRODUCTION=1
lifts the production refusal in python-stdio-bridge.ts and is documented in
docs/configuration.md. Two things are missing around it:
- The boot log in
server.tssays “Python bridge disabled in production” and does not name the flag, so an operator cannot find it from the log. - The Docker image ships no Python worker (
nodetool-core). The flag alone does not make Python nodes work in the image.
The MCP mount cannot be a bare flag flip. The mount binds every session to
agentToolsScope: { userId: "1", source: "local-dev-http" }. In production
that is an anonymous, full-access surface. Two more facts:
McpServerOptions.agentToolsScope.sourcealready has an unused"http-session"member (packages/websocket/src/mcp-server.ts), reserved for exactly this: a mount that binds the authenticated user.- The auth hook’s static-asset exemption in
server.tsskips auth for anyGEToutside/api,/ws,/v1,/trpcwhen a static app is served.GET /mcpis the SSE stream. It would bypass auth.
Decision
- Add
NODETOOL_ENABLE_MCP=1. The mount registers in production only with the flag, and always binds the user the auth hook resolved — never"1". - Add no Python flag. Fix the log line and the docs so the existing flag is discoverable, and document the worker-image requirement.
Steps
1. NODETOOL_ENABLE_MCP — packages/websocket/src/server.ts
- Define
mcpHttpEnabled = !isProduction || process.env["NODETOOL_ENABLE_MCP"] === "1", next to the other production gates. - Register the
/mcproute whenmcpHttpEnabled. When disabled in production, log one line that names the flag, the same way the extension bridge does. - Build the scope per request:
req.userId ? { userId: req.userId, source: isProduction ? "http-session" : "local-dev-http" } : undefined. A missing scope already returns the 401scopeRefusalfrommcp-server.tsat initialize. - Add
/mcpto the prefix list the static-asset exemption in the auth hook does not cover.
2. Bind a session to its owner — packages/websocket/src/mcp-server.ts
- Keep
sessionOwners: Map<sessionId, userId>next tosessionTransports. Record the owner inonsessioninitialized; delete it wherever the transport is evicted. - On POST, GET, and DELETE with a session id: when the recorded owner
differs from the request’s scope user, answer
404 Session not found. A 404 rather than a 403, so the response does not confirm the id exists. - Tests in
packages/websocket/tests/mcp-server.test.ts:- the same user resumes a session;
- a different user gets 404;
- initialize with no scope gets 401 (already covered — keep it).
3. Python bridge diagnostic — packages/websocket/src/server.ts
Replace the production branch of the “Python nodes will not be available” log with two cases:
- flag unset: name
NODETOOL_ALLOW_PYTHON_BRIDGE_IN_PRODUCTION=1; - flag set and no interpreter: “Python not found”, the same line the non-production branch prints.
No change to python-stdio-bridge.ts.
4. Docs
docs/configuration.md: a table row forNODETOOL_ENABLE_MCP, mirroring theNODETOOL_ENABLE_EXTENSION_BRIDGErow. State that the mount inherits the server’s auth mode and binds the authenticated user.docs/self-hosted-deployment.md: a section “MCP over HTTP and Python nodes” covering:- both flags;
- how an agent authenticates — a Supabase bearer token when
SUPABASE_URLis set;NODETOOL_TRUST_LOCAL_NETWORKS=<vpn cidr>in local mode; or a delegated token minted throughNODETOOL_INTEGRATION_TOKEN; - that the image ships no Python worker: derive an image that installs
nodetool-core, and setNODETOOL_PYTHONto that interpreter.
docker-compose.yml: commented examples of both flags in theenvironmentblock.
5. Verification
npm run test --workspace=packages/websocket,npm run typecheck,npm run lint.- Prove the gate can fail. Boot the image with
NODETOOL_ENV=production NODETOOL_ENABLE_MCP=1and:POST /mcpinitialize without a token → 401;- with a token → a
mcp-session-id; GET /mcpwith that session id and no token → 401. This is the check for the static-asset exemption fix in step 1.4;POST /mcpwith the session id and a second user’s token → 404.
- Boot without the flag and confirm
/mcpanswers 404 and the log names the flag.
Out of scope
- The tRPC
mcpConfigrouter stays disabled in production. It edits MCP client config files on the server’s filesystem, which has no meaning on a shared host. /ws/downloadand the other local-only websocket routes keep their production gates.
Size
About 80 lines of code, 40 of tests, 60 of docs. One PR.