Skip to content

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 podTemplateSpec labels 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.