ADR 0001 — stacklok MCP builder on node-wire¶
- Status: Accepted
- Date: 2026-09-26
Frozen decision record
This page records why the decisions were made, as they stood on the date above. It is not
updated when the code changes. For how nw gen-stacklok behaves today, read
stacklok MCP servers. A later change of direction is a new ADR
that points back here with Supersedes:.
Context¶
The goal was to build MCP servers with stacklok's mcp-builder, modified so every tool runs on the node-wire runtime and a node-wire connector instead of stacklok's generated HTTP client.
- stacklok's generator is the generator.
- node-wire's own generators (nw-connector-builder, nw-mcp-builder) and stacklok's code do not call each other. The connector package is the only thing they share.
- node-wire features such as multitenancy are kept.
Decisions¶
| Decision | Why |
|---|---|
One command, nw gen-stacklok, runs connector codegen → wheels → stacklok generation |
A single step was a hard requirement. The connector is built from the scope's own spec.source and spec.base_url. |
stacklok/mcp-builder and mcp-template-py are vendored, not fetched at build time. That covers generation, plus Phase 1 (the ai-scoping skill, its two agents, and analyze / validate). |
Simpler than fetching every run. There is no PyPI package: the PyPI name mcp-builder belongs to an unrelated project. |
stacklok's Phases 1–2 are reused, and nw gen-stacklok --path runs Phases 1–3 in one command. Phase 4 /ai-validation isn't vendored, because it reviews the httpx client node-wire replaces. |
Every stacklok step up to generation is reused; only the implementation moves to node-wire. |
Phase 1 is an interactive Claude Code session by default, with the skill's real questions and gates. --headless (claude -p, gates take the AI's recommendation) is opt-in, and is used when there's no terminal. |
Unattended gates were the largest deviation from stacklok's flow. |
Phase 2 always pauses for a human: continue, or stop and print the --scope resume command. There is no skip flag. |
Every scope must be reviewed by a human before it is built. |
Vendored files only gain hook calls into nw_stacklok/. The pristine copy is committed first. |
Keeps the diff against upstream small and reviewable. Moving to a new upstream commit stays a re-copy plus re-apply. |
| Auth follows ToolHive. ToolHive is the only source of upstream credentials; node-wire's own inbound auth is off in these servers. | This is stacklok's model: "the generated server performs no authentication". It avoids two sources of credentials, and avoids Authorization meaning two things. |
The credential relay lives in an optional layer, node-wire-toolhive, plus one additive bindings argument, ConnectorFactory(auth_provider_hook=). The runtime is unchanged. |
This had to be a layer, not a runtime change. The runtime's existing auth providers already format bearer, header and query credentials. |
| A relayed credential is read on every call and never cached | The factory keeps one connector per (tenant, config), so a cached credential would be sent for the next caller (spike S1). |
Headers are read from the MCP SDK's per-message request_ctx, not from middleware contextvars |
Under stateful streamable HTTP, the template's contextvar keeps a session's first request, so a refreshed token would be ignored. |
Multitenancy: one backend serves all tenants. Each tenant has its own ToolHive MCPRemoteProxy, whose headerForward sets X-Tenant-ID. |
ToolHive has no claim-to-header mapping, and its header injection is static per proxy. Its header-forward middleware uses Header.Set, which overrides a client-supplied header (verified at toolhive dfb0713). A NetworkPolicy admits only the proxies. |
Tenant choice is fixed by the proxy. Only nw_list_configs / nw_select_config are exposed. |
Per-session selection only picks a named config within the tenant. |
| Python 3.13 for the whole repo | stacklok requires it, and nw-cli calls the vendored generator in-process. Package versions were left unchanged at the time. |
| stacklok's image defaults (DHI Alpine) are kept. The wheel target is read from the image's base (cp313 musllinux for DHI Alpine), and packages with unchanged sources are reused | stacklok's defaults are kept deliberately. The DHI image has no Python headers, so the wheels come from cibuildwheel (build-packages.sh --cibw-linux). Reuse keeps reruns from recompiling the runtime every time. |
Not carried over¶
- Bindings-only features: tool-search mode, per-caller scope policy (ToolHive authorizes callers), REST and gRPC.
- node-wire token refresh and OAuth flows: ToolHive owns the token lifecycle.
- Hand-written connectors without an OpenAPI spec (smtp, ms_teams), which a scope cannot reference.
Consequences¶
These questions were unanswered when the decision was taken:
- The Kubernetes side (spike S2) needs a kind cluster with the ToolHive operator to confirm:
- whether the operator applies
podTemplateSpeclabels to proxy pods (the NetworkPolicy selects on them); - how remote-proxy auth delivers the upstream token to an in-cluster backend.
- Dropping Python 3.11/3.12 from published wheels needs a versioning decision (minor release or
2.0.0). - The template's stale per-session token is worth reporting upstream to stacklok.
Their current state is tracked in issues, not in this record.