Skip to content

Packaging & Publishing

Node Wire ships as multiple independent PyPI packages (the runtime plus one package per connector) built from a single monorepo. All wheels are binary-only (Cython-compiled .so/.pyd files) — no .py source is included in any published wheel.


Package inventory

PyPI name Source path Entry-point key
node-wire-runtime src/node_wire_runtime/ — (no entry point; this is the runtime)
node-wire-bindings (not published) src/bindings/ (MCP surface) — (factory / invoke / mcp_server for MCP images)
node-wire-fhir-cerner src/node_wire_fhir_cerner/ fhir_cerner
node-wire-fhir-epic src/node_wire_fhir_epic/ fhir_epic
node-wire-google-drive src/node_wire_google_drive/ google_drive
node-wire-http src/node_wire_http_generic/ http_generic
node-wire-salesforce src/node_wire_salesforce/ salesforce
node-wire-slack src/node_wire_slack/ slack
node-wire-smtp src/node_wire_smtp/ smtp
node-wire-stripe src/node_wire_stripe/ stripe

Each connector's pyproject.toml lives at packages/connectors/<name>/pyproject.toml; the runtime's is at packages/runtime/pyproject.toml; MCP bindings at packages/bindings/pyproject.toml.

Only the runtime and connectors ship to PyPI. node-wire-bindings is built as a local wheel (scripts/build-packages.sh, nw gen-whl --bindings) for MCP Docker images and is deliberately absent from the package lists in publish.yml, github-release.yml, and security-pr.yml. Do not add it to them.

Source of truth: Keep this table in sync with ALL_PACKAGES in scripts/build-packages.sh (which also builds the unpublished bindings wheel). MCP Docker images are a separate subset — see Docker demo images. http_generic is publishable on PyPI but does not have a standalone MCP container image.


Adding a new publishable connector

After implementing the connector runtime (see connectors.md, or generate a REST skeleton with nw-connector-builder), update these files to ship it on PyPI and optionally as a standalone MCP server.

Tier 1 — Runtime (dev, always required)

File / area Purpose
src/node_wire_<name>/ schema.py, logic.py (with optional error_map), action_spec.py, README.md
Root pyproject.toml [project.entry-points."node_wire.connectors"] for editable dev install
config/connectors.yaml enabled, exposed_via, auth:
sample.env Commented placeholders for connector secrets
Tests e.g. tests/test_connectors_basic.py, registry tests

auto_register() discovers the connector via the entry point — no factory branch required.

Tier 2 — Publishable PyPI package

File Purpose
packages/connectors/<name>/pyproject.toml Publishable package metadata, version, entry point
packages/connectors/<name>/setup.py Cython/build glue — see Tier 2 templates below
scripts/build-packages.sh Add path to ALL_PACKAGES
.github/workflows/publish.yml Add to the package dropdown and the allowed set — see CI allowlist updates below
.github/workflows/github-release.yml Add to package_paths list — see CI allowlist updates below
.github/workflows/security-pr.yml Add to matrix package_path — see CI allowlist updates below
This doc — Package inventory Add row
Root + all package pyproject.toml Version bump on release via ./scripts/bump-version.py (nw-mcp-builder excluded)
CHANGELOG.md Release section (scaffolded by the bump script; fill in notes before tagging)

Tier 2 templates

pyproject.toml template

# packages/connectors/<name>/pyproject.toml
[project]
name = "node-wire-<name>"
version = "1.0.0"
description = "Node Wire connector — <short description>"
requires-python = ">=3.11"
license = "Apache-2.0"
authors = [{ name = "AOT Technologies", email = "opensource@aot-technologies.com" }]

dependencies = [
    "node-wire-runtime>=1.0.0",
    # Add vendor SDK or HTTP client here, e.g.:
    # "httpx>=0.27.0,<0.28.0",
]

[project.entry-points."node_wire.connectors"]
<connector_id> = "node_wire_<name>.logic"

[build-system]
requires = ["setuptools>=69.0.0", "cython>=3.0", "wheel"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["../../../src"]
include = ["node_wire_<name>*"]

setup.py template

#
# SPDX-FileCopyrightText: 2026 AOT Technologies
# SPDX-License-Identifier: Apache-2.0
#
# packages/connectors/<name>/setup.py
import glob
import os

from Cython.Build import cythonize
from setuptools import setup
from setuptools.command.build_ext import build_ext as _BuildExt
from setuptools.command.build_py import build_py as _BuildPy


class NoPyBuild(_BuildPy):
    def find_package_modules(self, package, package_dir):
        return []


class ParallelBuildExt(_BuildExt):
    """Compile extension modules with one compiler job per core."""

    def finalize_options(self):
        super().finalize_options()
        if self.parallel is None:
            self.parallel = os.cpu_count() or 1


src_root = os.path.abspath(
    os.path.join(os.path.dirname(__file__), "../../../src/node_wire_<name>")
)
py_files = glob.glob(os.path.join(src_root, "**", "*.py"), recursive=True)

# Guarded so Cython's process pool can re-import this file on macOS and Windows.
if __name__ == "__main__":
    setup(
        cmdclass={"build_py": NoPyBuild, "build_ext": ParallelBuildExt},
        ext_modules=cythonize(
            py_files,
            nthreads=os.cpu_count() or 1,
            compiler_directives={"language_level": "3"},
            build_dir="build",
            annotate=False,
        ),
    )

Replace <name> with the connector's snake_case name (e.g. my_service) and <connector_id> with the entry-point key (same string used in config/connectors.yaml and NW_ALLOWED_CONNECTORS).

CI allowlist updates

Three workflow files each maintain a hardcoded list of publishable packages. Add one entry to each when shipping a new connector.

.github/workflows/publish.yml — package dropdown + allowed set

Two places, and they must agree. First, the dispatch dropdown under on.workflow_dispatch.inputs.package.options:

# .github/workflows/publish.yml
      package:
        type: choice
        options:
          - packages/runtime
          - packages/connectors/http_generic
          # ... existing entries ...
          - packages/connectors/<name>   # ← add this line

Then the server-side allowlist inside the validate step, which re-checks the value the dropdown supplied:

# .github/workflows/publish.yml  (inside the inline Python script)
allowed = {
    "packages/runtime",
    "packages/connectors/http_generic",
    "packages/connectors/stripe",
    # ... existing entries ...
    "packages/connectors/<name>",   # ← add this line
}

A path in the dropdown but missing from allowed fails the run at the validate step; a path in allowed but missing from the dropdown is simply unreachable.

.github/workflows/github-release.yml — package_paths list

Inside the release-manifest step, add your path to the package_paths Python list:

# .github/workflows/github-release.yml  (inside the inline Python script)
package_paths = [
    "packages/runtime",
    "packages/connectors/http_generic",
    # ... existing entries ...
    "packages/connectors/<name>",   # ← add this line
]

.github/workflows/security-pr.yml — matrix package_path

Add a new YAML list item under jobs.<job>.strategy.matrix.package_path:

# .github/workflows/security-pr.yml
matrix:
  package_path:
    - packages/runtime
    - packages/connectors/http_generic
    # ... existing entries ...
    - packages/connectors/<name>   # ← add this line

Tier 3 — Standalone MCP server (optional)

Prerequisite: Tier 2 (the PyPI wheel) must be completed first. The Dockerfile copies pre-built .whl files from packages/connectors/<name>/dist/; that directory does not exist until you run bash scripts/build-packages.sh packages/connectors/<name>.

Use when you need a dedicated Docker/ToolHive image for a single connector (not required for the combined agents.mcp_entrypoint server). For the wheel → image mapping see local-packages-to-images.md; for an existing connector's entrypoint/Dockerfile, use src/agents/<name>_mcp.py and docker/<name>/Dockerfile as templates.

File Purpose
src/agents/<name>_mcp.py Per-connector MCP agent entrypoint
Root pyproject.toml [project.scripts] e.g. nw-<kebab-name>
docker/<name>/Dockerfile Demo MCP image
scripts/build-mcp-images.sh docker build block
docker-compose.mcp.yml Service + NW_ALLOWED_CONNECTORS
local-packages-to-images.md Wheel → image mapping

Python package build lifecycle

Prerequisites: pip install build cython wheel (and a usable python on the host). Run bash scripts/build-packages.sh --help for usage.

Build all packages (default)

For a single connector (or runtime) from the orchestrator CLI, see nw gen-whl — it wraps this script with Linux-only as the default.

bash scripts/build-packages.sh

Default mode builds each package path listed in ALL_PACKAGES in the script (see the Package inventory for the current set): python -m build --wheel on the host, then again inside Docker so you get Linux-tagged wheels suitable for containers. Docker must be installed and the daemon running (unless you use --host-only). After each package, the script scans every produced wheel and fails if any .py file appears inside the archive.

Linux builds use a local-only image nw-wheel-builder:local from docker/wheel-builder/Dockerfile (build-essential + Cython toolchain). The script runs docker build once per invocation; Docker layer cache makes later runs fast when the Dockerfile is unchanged. This image is not published to Docker Hub or any registry.

Host-only or Linux-only

# Fast local iteration — host OS wheels only (no Docker)
bash scripts/build-packages.sh --host-only
bash scripts/build-packages.sh --host-only packages/connectors/stripe
# or: uv run nw gen-whl --connector-id stripe --host

# Container / ToolHive wheels only
bash scripts/build-packages.sh --linux-only
bash scripts/build-packages.sh --linux-only packages/runtime
# or: uv run nw gen-whl --connector-id stripe
#     uv run nw gen-whl --runtime

--host-only and --linux-only cannot be combined with each other or with --all.

Artifact layout and safe command usage

scripts/build-packages.sh writes wheels per package under packages/**/dist/ (there is no single repo-root dist/ output).

Before using wildcard wheel commands, clear old wheel artifacts so commands do not accidentally match stale versions:

rm -f packages/runtime/dist/*.whl
rm -f packages/connectors/stripe/dist/*.whl

Build a single package

bash scripts/build-packages.sh packages/connectors/stripe

Optional: broader wheels with cibuildwheel (--all)

For additional platform wheels from your current machine (whatever cibuildwheel can target there), install it and use the same script:

python -m pip install 'cibuildwheel==4.2.1'
bash scripts/build-packages.sh --all
bash scripts/build-packages.sh --all packages/runtime

Local --all builds CPython 3.11 and 3.12 (CIBW_BUILD=cp311-* cp312-*) and skips win32, 32-bit manylinux, and PyPy (CIBW_SKIP=*-win32 *-manylinux_i686 pp*) unless you override those variables. Publish CI (.github/workflows/publish.yml) builds the same interpreters with cibuildwheel==4.2.1, one job per platform and CPython version, so manylinux and musllinux each get their own skip list. A full Linux, macOS, and Windows set comes from that workflow.

Inspect wheel contents

After building, confirm no source leaks:

unzip -l packages/connectors/stripe/dist/node_wire_stripe-*.whl
# Must show .so/.pyd files only — no .py files

Install from wheels and verify entry points

# Install into an active (clean) virtual env
pip install \
  packages/runtime/dist/node_wire_runtime-*.whl \
  packages/connectors/stripe/dist/node_wire_stripe-*.whl

# Confirm entry points registered
python -c "
from importlib.metadata import entry_points
print(list(entry_points(group='node_wire.connectors')))
"

Verify connector loading

python -c "
from node_wire_runtime.connector_registry import auto_register
loaded = auto_register()
print('Loaded:', loaded)
"

Client consumption model

A downstream client installs only what it needs:

pip install node-wire-runtime node-wire-stripe node-wire-fhir-epic

At startup, auto_register() discovers all installed connectors via the node_wire.connectors entry-point group — no explicit import list required.

Runtime loading knobs

Fail-closed connector loading and related env vars are documented in configuration.md. Packaging clients must still set NW_ALLOWED_CONNECTORS and may set NW_CONNECTOR_MODULE_PREFIX (default node_wire_) when embedding wheels.


connectors.yaml and secrets

Minimal connectors.yaml

connectors:
  stripe:
    enabled: true
    exposed_via: ["mcp"]
  fhir_epic:
    enabled: false
    exposed_via: []

enabled gates whether the connector is instantiated. exposed_via controls which protocols (rest, grpc, mcp) surface it. A connector that is installed but enabled: false will not run.

See config/connectors.yaml for the full working example and src/node_wire_runtime/connectors.yaml.sample for a commented template with all supported fields.

For per-connector detail (operations, env vars, request/response shapes) see connectors.md and each connector's README.md under src/node_wire_<name>/.

Secret backends (NW_SECRET_BACKEND, aws_env, optional vault/azure/gcp extras) are documented in configuration.md — Secrets Management.


Release process (tag-first)

Stable releases are tag-driven: create and push the tag first, then publish each package from it. Betas need no tag — publish derives the pre-release version itself.

  • Stable (v1.2.0) — Create Release Tag → GitHub Release → publish with channel: release
  • Beta — publish with channel: beta from any branch or tag (no tag, no GitHub Release)

Both channels use the same .github/workflows/publish.yml; its channel input selects the behaviour, and the version always comes from the selected package's pyproject.toml rather than from a tag name. PyPI treats PEP 440 pre-releases as pre-releases: pip install node-wire-runtime stays on stable; pip install --pre node-wire-runtime or an exact pin (for example node-wire-runtime==1.2.0b1) installs a beta.

Create Release Tag (.github/workflows/create-tag.yml) accepts both MAJOR.MINOR.PATCH and PEP 440 pre-releases (aN / bN / rcN). It checks that the tag is free, package versions match, and CHANGELOG.md has the matching entry, then pushes v…. Stable releases use it with the version from the steps below; Beta PyPI publish does not use it at all.

Stable release

Step 1 — Prepare the release

  1. Bump lockstep package versions with ./scripts/bump-version.py X.Y.Z (root pyproject.toml, every packages/**/pyproject.toml, and connector node-wire-runtime>=… floors). This does not touch nw-mcp-builder/, which is versioned independently.
  2. Fill in the scaffolded CHANGELOG.md notes. Create Release Tag and GitHub Release both require a dated ## [X.Y.Z] - YYYY-MM-DD section and a footer link [X.Y.Z]: …/releases/tag/vX.Y.Z (the bump script scaffolds these when missing).
  3. Merge to main and confirm required CI checks are green.

Step 2 — Create the tag

Dispatch Create Release Tag in Actions with version set to 1.0.0 (no leading v). For stable releases, run this before dispatching "GitHub Release" below.

Manual fallback, if you must create the tag by hand (bypasses the validation above):

git tag -a v1.0.0 -m "Release 1.0.0"
git push origin v1.0.0

Step 3 — Create the GitHub Release

Dispatch GitHub Release in Actions with version set to 1.0.0 (no leading v). Stable versions only — the workflow rejects PEP 440 pre-releases.

Workflow: .github/workflows/github-release.yml — manual workflow_dispatch after the tag has been pushed.

The workflow:

  1. Validates all package versions match the tag.
  2. Verifies CHANGELOG.md has the matching section and release link.
  3. Generates sbom.json (release-level SBOM).
  4. Creates release-manifest.txt listing all publishable package paths (one per entry in github-release.yml's package_paths list).
  5. Creates the GitHub Release with changelog notes, SBOM, and manifest attached.

Step 4 — Publish packages to PyPI

After the GitHub Release exists, dispatch .github/workflows/publish.yml once per package (once per entry in the allowed set in that workflow). There is no version input — the version is read from the selected package's pyproject.toml, so dispatch from the release tag via the Use workflow from dropdown.

Required inputs:

Input Example Notes
channel release release publishes the declared version as-is; beta derives the next pre-release
package packages/connectors/stripe Dropdown backed by the workflow allowlist

Prerequisites checked before build (channel: release):

  • package is allowlisted.
  • Package pyproject.toml declares a stable X.Y.Z version.
  • That version is not already published on PyPI.
  • CHANGELOG.md contains the matching release section/link.
  • A GitHub Release exists for v<version>.

Pipeline steps:

  1. Matrix-build wheels on Ubuntu, macOS, and Windows via cibuildwheel (Python 3.11, 3.12)
  2. Post-build gate: verify zero .py files per wheel; record SHA256 checksums
  3. Merge artifacts; pip-audit --fail-on HIGH CVE gate
  4. Publish to PyPI via OIDC Trusted Publisher with Sigstore attestations

Note: The release-level SBOM is attached to the GitHub Release (step 3). Package publish produces PyPI Sigstore attestations per wheel; it does not generate a separate SBOM.

PyPI Trusted Publisher: The workflow file is kept as publish.yml and the workflow name as Publish Node Wire package so existing PyPI publisher configuration continues to work. Stable and beta both use this same publisher.

If a published release must be withdrawn or replaced, follow release-rollback.md (PyPI yank, corrective patch release, and GitHub tag/release handling).

Beta PyPI publish

Beta builds ship to the same PyPI projects as PEP 440 pre-releases. They never create a GitHub Release (github-release.yml rejects pre-release versions).

Leave pyproject.toml on the plain X.Y.Z version you are working towards — publish derives the pre-release suffix itself.

  1. Confirm the packages declare the target stable version (e.g. 1.2.0) and CI is green.
  2. Dispatch .github/workflows/publish.yml from that branch or tag, once per allowlisted package, with channel: beta. The workflow reads what is already on PyPI and publishes the next unused suffix — 1.2.0b1, then 1.2.0b2, and so on — stamping it into the wheel at build time. No changelog entry, git tag, or GitHub Release is required; pipeline steps are otherwise identical to stable.

Note: ./scripts/bump-version.py still accepts aN/bN/rcN, but publish rejects a pre-release project.version. Keep the source tree on X.Y.Z and let channel: beta derive the suffix.

Install a beta with:

pip install --pre node-wire-runtime
# or pin exactly:
pip install node-wire-runtime==1.2.0b1

CI publish flow (Trusted Publisher)

See Release process (tag-first) above for the full end-to-end flow (stable and beta). The package publish workflow is .github/workflows/publish.yml.


Docker demo images

The docker/*/Dockerfile images are demonstration templates for packaging a single connector as a standalone MCP server. They are not production orchestration artefacts.

Generated MCP host images expect Linux wheels built for Python 3.12 (python:3.12-slim in the generated Dockerfile). See mcp-servers.md and local-packages-to-images.md for the wheel → image walkthrough.

docker build -f docker/smtp/Dockerfile -t nw-smtp .
docker build -f docker/google-drive/Dockerfile -t nw-google-drive .
docker build -f docker/fhir-epic/Dockerfile -t nw-smartonfhir-epic .
docker build -f docker/fhir-cerner/Dockerfile -t nw-smartonfhir-cerner .
docker build -f docker/stripe/Dockerfile -t nw-stripe .
docker build -f docker/salesforce/Dockerfile -t nw-salesforce .
docker build -f docker/slack/Dockerfile -t nw-slack .

For compose config see docker-compose.mcp.yml; for ToolHive registration of these pre-built images see toolhive_agent_scenario.md.


Pre-PyPI local validation checklist

Run these gates before triggering the CI publish workflow (default build-packages.sh is enough; --all is optional for broader local wheels):

  • bash scripts/build-packages.sh exits 0
  • unzip -l packages/<pkg>/dist/*.whl shows no .py files
  • Install wheels into a clean venv; confirm entry points resolve
  • auto_register() loads expected connectors
  • pytest tests/test_connector_registry.py tests/test_connectors_basic.py passes
  • Wheel SHA256 checksums recorded and match expected values
  • package and channel inputs are correct before dispatching, and for channel: release the tag and GitHub Release already exist