Skip to content

nw-mcp-builder

Advanced — most users want the nw CLI

nw-mcp-builder is the standalone entry point and exposes the full flag surface. For the normal path (spec → connector → wheels → MCP host → image) use nw gen-all, which calls this tool for you. Read on when you need a flag nw does not pass through, or are debugging this stage on its own.

Self-contained tool inside the node-wire repo that turns a node-wire connector into a standalone MCP server project.

It does not depend on any separate external mcp-builder project. Everything needed to generate connector-mode MCP hosts lives in this folder.

To create a connector from OpenAPI/Swagger first, use nw-connector-builder (it can hand off to this tool automatically unless you pass --no-mcp), or the full pipeline via nw gen-all.

You can also run MCP generation as a subcommand of the connector builder (same flags), or via the orchestrator CLI:

uv run --directory nw-connector-builder nw-connector-builder mcp -c <connector_id>
# equivalent happy-path stages:
uv run nw gen-mcp --connector-id <connector_id>

The standalone nw-mcp-builder entry point remains supported.


Platform and ToolHive (read this first)

Wheels for runtime and connectors are Cython / platform-specific. What you build must match how you run the host.

Goal Wheel platform How to build wheels
ToolHive local MCP (Docker image from out/<name>-mcp) Linux (linux_* / manylinux) for the same CPU architecture as the image — *_aarch64 on Apple Silicon / ARM hosts, *_x86_64 on Intel/AMD — Python 3.13 (matches python:3.13-slim in the generated Dockerfile) From the node-wire repo root, use scripts/build-packages.sh (see below); nw gen-all does this for you and picks your machine's architecture
Local run / MCP Inspector / ToolHive remote MCP on the same OS as your machine Host OS (e.g. Windows → *win_amd64*) Built automatically by uv run nw-mcp-builder -c <connector_id>

Linux wheels for ToolHive Docker (local MCP server)

From the node-wire repository root (requires Docker; uses python:3.13-slim for the Linux build):

# Generic — runtime + one connector
bash scripts/build-packages.sh packages/runtime packages/connectors/<connector_id>

# Example — google_drive
bash scripts/build-packages.sh packages/runtime packages/connectors/google_drive

Wheels land in:

  • packages/runtime/dist/
  • packages/connectors/<connector_id>/dist/

Then generate (or regenerate) the host without rebuilding host-OS wheels, so the Linux artifacts stay selected:

cd nw-mcp-builder
uv sync
uv run nw-mcp-builder -c <connector_id> --skip-build-wheels --force-output

# Example
uv run nw-mcp-builder -c google_drive --skip-build-wheels --force-output

Host-OS wheels (nw-mcp-builder without a prior Linux build)

uv run nw-mcp-builder -c <connector_id>

builds wheels for whatever OS/Python you are on (Windows → win_amd64, Linux → linux, etc.) and copies the newest .whl into out/<name>-mcp/wheels/. Those host wheels are fine for:

  • running the generated host locally (uv run python -m …)
  • MCP Inspector
  • ToolHive remote MCP servers when the remote runtime matches that OS

They are not suitable for the generated Linux Docker image used as a ToolHive local MCP server.


What this folder is

Path Purpose
src/nw_mcp_builder/ Python package: CLI, fixture writer, project generator
fixtures/ Connector-mode scope YAML files (<connector_id>_nw.yaml)
out/ Generated MCP host projects (one folder per connector)

Generated hosts are thin wrappers around node-wire’s McpServer. Auth, telemetry, and connector logic stay in node-wire — the host only wires wheels, config, and env.


What it does (end to end)

For a connector id like google_drive or salesforce, nw-mcp-builder:

  1. Validates the connector exists under packages/connectors/<id> and src/node_wire_<id>/logic.py
  2. Builds wheels (unless --skip-build-wheels):
  3. packages/runtime/dist/node_wire_runtime-*.whl
  4. packages/bindings/dist/node_wire_bindings-*.whl
  5. packages/connectors/<id>/dist/node_wire_<id>-*.whl
  6. Platform matches the machine running the CLI (see Platform and ToolHive)
  7. Ensures a scope fixture at fixtures/<id>_nw.yaml
  8. Auto-generated from @nw_action / @sdk_action / SdkActionSpec in connector source
  9. Skips overwrite if the file already exists (use --force-fixture to regenerate)
  10. Generates out/<server-name>-mcp/ containing:
  11. Copied wheels under wheels/ (runtime, bindings, connector). The host imports everything from these installed wheels; no node-wire source tree is vendored into it
  12. config/connectors.yaml from the monorepo
  13. Thin __main__.py that runs McpServer(connector_ids=[...])
  14. pyproject.toml, README.md, Dockerfile, .env.example

Example mapping:

connector_id Generated folder Python module
google_drive out/google-drive-nw-mcp/ google_drive_nw_mcp
salesforce out/salesforce-nw-mcp/ salesforce_nw_mcp
fhir_epic out/fhir-epic-nw-mcp/ fhir_epic_nw_mcp

Server name in the fixture is always {connector_id with _ → -}-nw (e.g. google-drive-nw).


Requirements

  • uv — package manager and runner
  • Python 3.13+ for nw-mcp-builder itself
  • Python version matching wheels for generated hosts — the generated host must run the same Python minor version the wheels were built for. The repo standardises on 3.13 (the generated Dockerfile uses python:3.13-slim). If your host wheels were built by a different interpreter, pin it explicitly:
uv sync --python 3.13
  • Docker — required for Linux wheels via scripts/build-packages.sh and for ToolHive local MCP images
  • Connector secrets from node-wire sample.env / config/connectors.yaml (copied into generated .env.example)

Quick start

1. Install the tool

cd nw-mcp-builder

From the node-wire repo root:

uv run --directory nw-mcp-builder nw-mcp-builder --help

2. Generate an MCP host

# Full run: build host-OS wheels + use/create fixture + generate project
# For ToolHive testing, use the second command if you already have Linux wheel files.
# This command builds platform-dependent wheel files
uv run nw-mcp-builder -c <connector_id>
# Example
uv run nw-mcp-builder -c google_drive

# Reuse existing wheels (required after Linux build-packages.sh for ToolHive Docker)
uv run nw-mcp-builder -c <connector_id> --skip-build-wheels
# Example
uv run nw-mcp-builder -c salesforce --skip-build-wheels

# Replace an existing generated project
uv run nw-mcp-builder -c <connector_id> --force-output
# Example
uv run nw-mcp-builder -c fhir_epic --force-output

# Regenerate fixture from connector source
uv run nw-mcp-builder -c <connector_id> --force-fixture
# Example
uv run nw-mcp-builder -c slack --force-fixture

3. Run the generated host

cd out/<name>-mcp
# Example
cd out/google-drive-nw-mcp
cp .env.example .env    # optional locally; or inject secrets via env (ToolHive)
uv sync --python 3.13   # must match the ABI your wheels were built for
uv run python -m <module_name>
# Example
uv run python -m google_drive_nw_mcp

HTTP MCP (default) listens on port 8081. For stdio (e.g. Cursor / MCP Inspector):

NW_MCP_TRANSPORT=stdio uv run python -m <module_name>
# Example
NW_MCP_TRANSPORT=stdio uv run python -m google_drive_nw_mcp

ToolHive local MCP (Docker image from the generated host)

If you will run this as a local MCP server in ToolHive, build the Docker image from the generated project (after placing Linux wheels — see Platform and ToolHive):

cd nw-mcp-builder/out/<name>-mcp
# Example
cd nw-mcp-builder/out/google-drive-nw-mcp
docker build -t <image-name>:latest .
# Example
docker build -t google-drive-nw-mcp:latest .

Point ToolHive at that image (e.g. google-drive-nw-mcp:latest).

Secrets / env — pick one (or combine: process env wins; project .env only fills unset keys):

  1. ToolHive Secrets — set connector credentials directly (e.g. GOOGLE_DRIVE_SA_JSON, GOOGLE_DRIVE_FOLDER_ID).
  2. Volume-mounted .env — copy from .env.example (or values from node-wire sample.env), fill secrets, then mount the file into the container.

Recommended ToolHive environment variables:

Name Value
NW_MCP_TRANSPORT streamable-http
NW_MCP_HOST 0.0.0.0
NW_MCP_PORT Same as ToolHive FASTMCP_PORT / MCP_PORT (e.g. if those are 33622, set NW_MCP_PORT=33622)
NW_MCP_PATH /mcp
NW_MCP_AUTH_DISABLED true

Also set NW_ALLOWED_CONNECTORS=<connector_id> if not already baked into the image (generated Dockerfiles usually set it).

Volume example (optional if all secrets are in ToolHive Secrets):

Host path {repo}\node-wire\nw-mcp-builder\out\<name>-mcp\.env (use the file picker)
Container path /app/.env
Mode Read-only

Example host path: G:\SPACE\node-wire\nw-mcp-builder\out\google-drive-nw-mcp\.env


CLI reference

uv run nw-mcp-builder -c <connector_id> [options]
Option Default Description
-c, --connector-id — (required) node-wire connector id (google_drive, salesforce, fhir_epic, …)
--node-wire-root Parent of nw-mcp-builder/ node-wire monorepo root
-o, --output-dir nw-mcp-builder/out Where generated hosts are written
--fixtures-dir nw-mcp-builder/fixtures Where *_nw.yaml fixtures live
--skip-build-wheels off Use wheels already in packages/*/dist
--force-fixture off Overwrite fixtures/<id>_nw.yaml from connector source
--force-output off Delete and regenerate out/<server>-mcp/ if it exists
--python — Python for wheel builds (sets UV_PYTHON)
-v, --verbose off Debug logging

Help:

uv run nw-mcp-builder --help
uv run nw-mcp-builder -c <connector_id> -v   # verbose logging during generation

Supported connectors

Any connector with this layout in the node-wire repo:

packages/connectors/<connector_id>/pyproject.toml
src/node_wire_<connector_id>/logic.py

Currently in this monorepo:

  • google_drive
  • salesforce
  • fhir_epic
  • fhir_cerner
  • slack
  • stripe
  • smtp
  • http_generic

Fixtures (fixtures/)

Scope YAML files describe the MCP server metadata for connector mode. They are not used to codegen HTTP clients — node-wire dispatches tools at runtime from the connector wheel.

  • Auto-generated — scans logic.py (and sibling .py files) for action names when the fixture is missing or --force-fixture is set
  • Hand-maintained — files like fhir_epic_nw.yaml can include richer tool descriptions; existing fixtures are kept unless you pass --force-fixture

Minimal shape:

version: "1"
server:
  name: salesforce-nw
  description: ...
runtime:
  type: node_wire
  connector_id: salesforce
spec:
  source: node-wire/src/node_wire_salesforce
  format: openapi3
  base_url: https://unused.invalid
groups:
  - name: connector
    tools: [...]
auth:
  type: none

Generated project layout

Each out/<name>-mcp/ folder is a deployable MCP host:

out/google-drive-nw-mcp/
  pyproject.toml          # deps from local wheels
  Dockerfile
  README.md
  .env.example            # NW + connector secret env names
  .dockerignore           # whitelist: wheels/, config/connectors.yaml, src/
  wheels/                 # runtime + bindings + connector .whl
  config/connectors.yaml
  src/google_drive_nw_mcp/
    __init__.py
    __main__.py           # McpServer entrypoint

Environment variables (generated host)

Every generated thin host prefers process environment (ToolHive secrets, Docker -e, K8s). If out/<name>-mcp/.env exists, it fills unset keys only (override=False). It does not load the node-wire monorepo or cwd .env. The bindings' own MCP/REST dotenv merge is disabled via NW_REST_LOAD_DOTENV=false. A missing project .env is OK when secrets/env are already injected.

Set these in .env (start from .env.example):

Variable Default Meaning
NW_MCP_TRANSPORT streamable-http stdio or streamable-http
NW_MCP_PORT 8081 HTTP port when using streamable-http
NW_MCP_HOST loopback (see Configuration) Bind address for streamable-http. Set 0.0.0.0 inside a container, otherwise the published port answers with Connection reset by peer
NW_ALLOWED_CONNECTORS <connector_id> Forced by the thin host
NW_MCP_AUTH_DISABLED true Local dev / MCP Inspector
NW_MCP_SCOPE_POLICY_DEFAULT allow Local dev tool access
NW_CONFIG_PATH config/connectors.yaml Connector registry config
NW_MULTITENANCY_ENABLED false When true, load NW_TENANTS_PATH and expose nw_* tenant/config tools
NW_TENANTS_PATH config/tenants.yaml Runtime tenant/config + secret store
NW_MCP_TENANT_PIN_LOCKED false When true, reject nw_select_tenant
NW_MCP_ALLOWED_TENANTS (unset) Comma-separated allowlist for list/select

Connector-specific secrets (any connector) are listed in .env.example when auto-detected from config/connectors.yaml and sample.env. Provide them via process env (ToolHive/Docker) or copy into the generated project .env for unset keys — do not rely on the monorepo .env.

Note: MCP Inspector “Bearer token” is for MCP server auth, not upstream API credentials. Put connector secrets in process env or the generated project’s .env.


Docker

From a generated project (use Linux wheels for this path — see Platform and ToolHive):

From the node-wire repo root (nw docker-build --connector-id <id> runs the same build and tags <id-dashed>-nw-mcp:latest — see nw CLI):

cd nw-mcp-builder/out/<name>-mcp
docker build -t <image-name> .
cp .env.example .env   # fill connector secrets; keeps NW_MCP_AUTH_DISABLED / NW_MCP_SCOPE_POLICY_DEFAULT for local use
# Secrets at runtime only — never COPY .env into the image.
# NW_MCP_HOST=0.0.0.0 is required: the server binds loopback by default, which is unreachable through -p.
docker run --rm --env-file .env -e NW_MCP_HOST=0.0.0.0 -p 8081:8081 <image-name>

# Example (generated by `nw gen-all --connector-id petstore_review`)
cd nw-mcp-builder/out/petstore-review-nw-mcp
cp .env.example .env
docker run --rm --env-file .env -e NW_MCP_HOST=0.0.0.0 -p 8081:8081 petstore-review-nw-mcp:latest

The startup log must say Uvicorn running on http://0.0.0.0:8081. If it says 127.0.0.1, NW_MCP_HOST was not set and requests from the host get Connection reset by peer.

Verify the running container

From a second terminal, open an MCP session and list the tools. Streamable HTTP needs both Accept types, and every request after initialize must echo the Mcp-Session-Id response header:

URL=http://localhost:8081/mcp
H=(-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream')

SID=$(curl -s -D - -o /dev/null "$URL" "${H[@]}" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \
  | awk 'tolower($1)=="mcp-session-id:" {print $2}' | tr -d '\r')
echo "session: $SID"

curl -s "$URL" "${H[@]}" -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' -o /dev/null -w '%{http_code}\n'   # 202

curl -s "$URL" "${H[@]}" -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | grep -o '"name":"<connector_id>_[^"]*"'

Replace <connector_id> with your id (e.g. petstore_review). Works in bash and zsh. A healthy host prints a session id, 202, then one line per tool (20 for the Swagger Petstore v2 spec). An empty session id means the port is not reachable — check NW_MCP_HOST. A session but no tool names means NW_MCP_SCOPE_POLICY_DEFAULT=allow is missing (see below). A plain curl http://localhost:8081/mcp returns 406 Not Acceptable; that only proves the port answers.

The generated Dockerfile is multi-stage and digest-pinned (python:3.13-slim@sha256:…): wheels install in a deps stage (BuildKit pip cache), then /usr/local is copied into the runtime stage with app sources last for layer caching. It runs as non-root USER app with a read-only application tree, and copies only wheels (node-wire-runtime, node-wire-bindings, connector), config/connectors.yaml, and the thin host package (src/<module>/, copied to /app/src and put on PYTHONPATH=/app/src; the entrypoint is python -m <module>). No node-wire source tree is vendored: runtime, bindings and connector come only from the installed wheels. .dockerignore is a whitelist so .env, tenant YAML, and keys never enter the build context. MCP auth is not disabled in the image, and the scope policy defaults fail-closed (deny) there too — unlike local uv run, which sets NW_MCP_AUTH_DISABLED=true and NW_MCP_SCOPE_POLICY_DEFAULT=allow automatically for Inspector convenience. A container started without both set accepts connections but tools/list comes back empty. Set NW_MCP_AUTH_DISABLED=true / NW_MCP_SCOPE_POLICY_DEFAULT=allow at run time for local Inspector/ToolHive use (the generated .env.example already contains both).

--env-file injects process environment. Do not bind-mount .env into the container filesystem.

MCP SDK: Pin mcp>=1.6.0,<2. MCP SDK 2.x breaks @server.list_tools() on the current server binding.


Troubleshooting

Problem What to try
No node-wire-runtime wheel in .../dist Run without --skip-build-wheels, or bash scripts/build-packages.sh packages/runtime
Docker / ToolHive image cannot install .whl Ensure Linux (*linux*) wheels are in dist/ and regenerate with --skip-build-wheels (Windows win_amd64 wheels will not install in python:3.13-slim)
Output project already exists Pass --force-output
No module named node_wire_runtime.policies Rebuild runtime wheel (nw gen-whl --runtime) — policies must be a package with __init__.py
No module named bindings / No node-wire-bindings wheel Build bindings: nw gen-whl --bindings
uv sync / import errors on generated host Pin --python to the ABI your .whl files were built for (cp313 for the standard 3.13 build)
Empty or wrong tools in fixture --force-fixture to rescan logic.py
503 / auth errors from MCP server Ensure NW_MCP_AUTH_DISABLED=true (env or project .env) for local use
Connector secrets missing in Docker/ToolHive Set secrets via docker run -e / --env-file or the orchestrator. Do not COPY or bind-mount .env into the image.
Connector action fails at runtime Fill connector secrets via env or project .env; check config/connectors.yaml
curl: (56) Recv failure: Connection reset by peer against a running container; log shows Uvicorn running on http://127.0.0.1:… Add -e NW_MCP_HOST=0.0.0.0 to docker run (see Docker)
Wrong listen port in ToolHive Set NW_MCP_PORT to the same value as ToolHive FASTMCP_PORT / MCP_PORT
AttributeError: 'Server' object has no attribute 'list_tools' Pin mcp>=1.6.0,<2 in the image Dockerfile and rebuild
Config 'X' is not defined for connector 'Y' (MT) Use a config name that exists on every connector you call, or add it in tenants.yaml / REST

Multi-tenancy (MCP)

A generated host is a thin wrapper around the same McpServer, so it has the same multi-tenancy: set NW_MULTITENANCY_ENABLED=true and provide NW_TENANTS_PATH (see the environment table). Tenant resolution per transport, the nw_* tenant/config tools and the ToolHive recipe are in Tenancy. After upgrading node-wire, regenerate and rebuild the host so its bundled bindings wheel picks up the current tools.


Package layout (source)

nw-mcp-builder/
  pyproject.toml
  README.md
  fixtures/                 # *_nw.yaml scope files
  out/                      # generated MCP hosts (gitignored content typical)
  src/nw_mcp_builder/
    cli.py                  # entrypoint: nw-mcp-builder
    from_connector.py       # wheels → fixture → pipeline → .env.example
    pipeline.py             # run_connector_pipeline
    generate/
      connector_project.py  # emit out/<server>-mcp/
    schema/
      models.py             # MCPScope validation for fixtures

Dependencies: pydantic, pyyaml only (no mcp-builder dependency).

Unit tests live under tests/nw_mcp_builder/ (run from the node-wire repo root):

uv run pytest tests/nw_mcp_builder -v --no-cov

Relationship to mcp-builder

The connector-mode logic originated in stacklok's mcp-builder (mcp-builder from-connector). nw-mcp-builder is a minimal copy that lives inside node-wire, so it has no dependency on that project. It generates a host for a connector that already exists. To generate the connector from an OpenAPI spec, use nw-connector-builder or nw gen-all. For a stacklok-generated server with AI-curated tools, use nw gen-stacklok, which vendors stacklok's generator separately.