From f5ef89fb0f2e4f756b9efdba04448ea65066d924 Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:24:38 -0700 Subject: [PATCH 1/3] Updated documentation clarity for creating connectors and mcps --- docs/connectors.md | 121 +++++++++++++++++++++++---- docs/local-packages-to-images.md | 2 +- docs/mcp-servers.md | 136 +++++++++++++++++++++++++++++-- docs/mcp.md | 2 + docs/packaging.md | 50 ++++++++++++ docs/public-api.md | 2 + sample.env | 5 +- 7 files changed, 291 insertions(+), 27 deletions(-) diff --git a/docs/connectors.md b/docs/connectors.md index 282cb27..f3242d4 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -29,7 +29,7 @@ Each connector is a **top-level package** under `src/` (e.g. `node_wire_fhir_epi | `registration.py` | Optional: registers connector-specific exceptions with `ErrorMapper`. | | `exceptions.py` | Optional: custom exception types. | -At startup, call **`node_wire_runtime.connector_registry.auto_register()`**: it loads entry points in group `node_wire.connectors`, imports each connector's `logic` module (triggering `BaseConnector.__init_subclass__` and registration via `get_connector_registry()`), then imports optional `registration.py` for `ErrorMapper` side effects. +At startup, call **`node_wire_runtime.connector_registry.auto_register()`**: it loads entry points in group `node_wire.connectors`, imports each connector's `logic` module (triggering `BaseConnector.__init_subclass__`, which populates the registry returned by `get_connector_registry()`), then imports optional `registration.py` for `ErrorMapper` side effects. --- @@ -112,10 +112,12 @@ GoogleDriveOperationInput = RootModel[_GoogleDriveOperationUnion] class GoogleDriveOperationOutput(BaseModel): - raw: dict + raw: dict | list description: str ``` +Use `dict | list` for `raw` when vendor APIs return arrays (e.g. list endpoints); Pydantic validates either shape. Per-action output models can use typed fields instead of a shared envelope. + When a connector only has **one** action, the `action` field is still required — the runtime always validates through the discriminated union. ### Step 2 — Map operations to the SDK (`action_spec.py`) @@ -225,12 +227,15 @@ To use authentication, call **`await self.get_auth_headers()`** (inherited from ```python # logic.py usage +# Base URL: read from the connector's own config, an env var, or a module constant. +# There is no inherited _get_base_url() helper — connectors own their URL resolution. +BASE_URL = "https://api.example.com" # or: os.environ["MY_SERVICE_URL"] + async def read_resource(self, params: In, *, trace_id: str) -> Out: - base_url = self._get_base_url() headers = await self.get_auth_headers() # Fetched/cached by provider async with httpx.AsyncClient() as client: - resp = await client.get(f"{base_url}/resource", headers=headers) + resp = await client.get(f"{BASE_URL}/resource", headers=headers) resp.raise_for_status() ... ``` @@ -239,11 +244,23 @@ async def read_resource(self, params: In, *, trace_id: str) -> Out: Choose a provider in your **`connectors.yaml`** via the `auth:` block: -| Type | Description | -|------|-------------| -| **`none`** | (Default) No auth headers added. | -| **`static_token`** | Uses a fixed token from a secret (Bearer, Basic, or custom). Supports refresh. | -| **`oauth2`** | Full Client Credentials flow. Supports `private_key_jwt` (RS384) and `client_secret_post`. Handles caching and expiry automagically. | +| Type | Description | Example connector | +|------|-------------|-------------------| +| **`none`** | (Default) No auth headers added. | `http_generic` | +| **`static_token`** | Uses a fixed token from a secret (Bearer, Basic, or custom). Supports refresh. | `stripe`, `slack` | +| **`static_credentials`** | Username + password pair (e.g. SMTP relay). | `smtp` | +| **`service_account`** | Google-style service account JSON + scopes. | `google_drive` | +| **`oauth2`** | Token exchange (`private_key_jwt`, `refresh_token`, `client_secret_post`, etc.). Handles caching and expiry. | `fhir_epic`, `salesforce` | + +#### `static_token` field reference + +| Field | Required | Default | Notes | +|-------|----------|---------|-------| +| `secret_key` | Yes | — | Env var name holding the raw token value (`EnvSecretProvider` tries the key as-is, then uppercased). | +| `header_name` | No | `Authorization` | HTTP header the token is injected into. | +| `prefix` | No | `Bearer ` (with trailing space) | String prepended to the token value. Set `prefix: ""` for APIs that expect the raw token (e.g. Stripe). Set `prefix: "token "` for older GitHub PAT style. | + +So `slack` (no `header_name`/`prefix`) produces `Authorization: Bearer `, and `stripe` (with `prefix: ""`) produces `Authorization: `. ### Configuration (`connectors.yaml`) @@ -264,14 +281,31 @@ connectors: enabled: true auth: provider: static_token - secret_key: STRIPE_API_KEY + secret_key: stripe_api_key + header_name: Authorization + prefix: "" # Stripe expects raw key; env var is STRIPE_API_KEY + + smtp: + enabled: true + auth: + provider: static_credentials + username_secret: SMTP_USERNAME + password_secret: SMTP_PASSWORD + + google_drive: + enabled: true + auth: + provider: service_account + sa_json_secret: GOOGLE_DRIVE_SA_JSON + scopes: + - https://www.googleapis.com/auth/drive ``` --- Key points: - **`connector_id`** — unique string; used for routing, config, and registry lookup. -- **`output_model`** — the Pydantic class returned by every action (Drive uses one shared envelope with `raw` + `description`). +- **`output_model`** — the Pydantic class returned by every action. Shared envelopes often use `raw: dict | list` for list-heavy vendor APIs; per-action models can use typed fields instead (see SMS example below). - **`error_map`** — maps exception types to `(ErrorCategory, error_code)`. Entries are registered with `ErrorMapper` automatically at class definition time. - **`build_client()`** — override to create the Google API client. `get_client()` caches the result in `self._client`. - **`action_specs`** — each key becomes a manifest action (e.g. `files.list`). Do **not** also add a manual `@nw_action` with the same name. @@ -295,7 +329,38 @@ connectors: ### Step 5 — Auto-registration (nothing extra needed) -`BaseConnector.__init_subclass__` registers your class (exposed via `get_connector_registry()`) as soon as `logic.py` is imported. **`node_wire_runtime.connector_registry.auto_register()`** performs those imports at startup. **No manual factory branch is required.** +`BaseConnector.__init_subclass__` registers your class in the global registry as soon as `logic.py` is imported. **`node_wire_runtime.connector_registry.auto_register()`** performs those imports at startup. **No manual factory branch is required.** + +### Connector registry API + +`get_connector_registry()` is defined in `base_connector.py` and exported from the top-level `node_wire_runtime` package — it is **not** in `node_wire_runtime.connector_registry`. Use it to read the connector-id → class map after `auto_register()` has imported your `logic` module: + +```python +from node_wire_runtime import get_connector_registry +from node_wire_runtime.connector_registry import auto_register + +auto_register() # requires NW_ALLOWED_CONNECTORS +registry = get_connector_registry() # Dict[str, Type[BaseConnector]] +connector_cls = registry["google_drive"] +``` + +For the full run pipeline (YAML config, instantiation, protocol routing), use **`ConnectorFactory`** (see [Calling a connector directly](#calling-a-connector-directly-in-process)). + +### Optional: `registration.py` for ErrorMapper + +When exceptions are raised outside the connector class (or shared across modules), register them in `registration.py` instead of inline `error_map`: + +```python +# src/node_wire_/registration.py +from node_wire_runtime import ErrorCategory, ErrorMapper + +from .exceptions import MyAuthError, MyRateLimitError + +ErrorMapper.register(MyAuthError, ErrorCategory.AUTH, code="MY_AUTH_ERROR") +ErrorMapper.register(MyRateLimitError, ErrorCategory.RETRYABLE, code="MY_RATE_LIMIT") +``` + +`auto_register()` imports `registration.py` after `logic.py`, so these registrations run at startup. Alternatively, use inline **`error_map`** on the connector class (Google Drive example above). --- @@ -342,12 +407,17 @@ class SmsConnector(BaseConnector): ## Calling a connector directly (in-process) -Use `connector.run(dict)` for the full pipeline (validation, policy, retries, error mapping): +Use `connector.run(dict)` for the full pipeline (validation, policy, retries, error mapping). + +Set **`NW_ALLOWED_CONNECTORS`** to a comma-separated list of entry-point names (e.g. `google_drive`) before calling `auto_register()` — without it, `auto_register()` loads nothing (fail-closed). ```python +import os + from node_wire_runtime.connector_registry import auto_register from bindings.factory import ConnectorFactory +os.environ["NW_ALLOWED_CONNECTORS"] = "google_drive" auto_register() factory = ConnectorFactory() factory.load() @@ -451,7 +521,7 @@ Published **`input_schema` omits the `action` property** (manifest contract v2+) ### Manifest -`build_manifest(connectors)` is the single source of truth for both bindings (by default it strips `action` from each entry’s `input_schema`). It returns one entry per `@sdk_action`: +`build_manifest(connectors)` (from `node_wire_runtime.manifest`) is the single source of truth for both bindings (by default it strips `action` from each entry’s `input_schema`). It returns one entry per `@sdk_action`: ```python [ @@ -480,7 +550,6 @@ Published **`input_schema` omits the `action` property** (manifest contract v2+) | `stripe` | `charge` | | `salesforce` | `create_lead`, `read_lead`, `update_lead`, `delete_lead`, `create_contact`, `read_contact`, `update_contact`, `delete_contact` | | `google_drive` | `files.list`, `files.upload`, … (see `action_specs`) | - | `fhir_epic` | `read_patient`, `search_patients`, `search_encounter`, `create_document_reference`, `search_document_reference` | | `fhir_cerner` | Same family as Epic with Cerner-specific schemas | | `slack` | `post_message`, `send_direct_message`, `upload_file` | @@ -491,13 +560,29 @@ MCP tool names: **`.`** (e.g. `fhir_epic.read_patient`). S ## Adding a new connector (checklist) -1. Create the package directory `src/node_wire_/` with `schema.py` (Pydantic input/output models) and register the entry point under `[project.entry-points."node_wire.connectors"]`. +### Runtime (dev) + +1. Create the package directory `src/node_wire_/` with `schema.py` (Pydantic input/output models) and register the entry point under `[project.entry-points."node_wire.connectors"]` in the root `pyproject.toml`. 2. In `logic.py`: subclass `BaseConnector`, set `connector_id` and `output_model`, then add `@nw_action` methods or wire `action_specs`. 3. **Authentication**: Delegate all header construction to **`self.get_auth_headers()`**. Do not hardcode secret lookups or IdP handshakes and ensure sensitive fields are removed from your `input_schema`. 4. For SDK-style connectors, add an `action_spec.py` (or similar) with `SdkActionSpec` entries and use **`execute_spec_in_thread`** when the vendor client is blocking. -5. Optionally add `error_map` and/or `registration.py` for custom exception handling. +5. Optionally add `error_map` and/or `registration.py` for custom exception handling (see [registration.py example](#optional-registrationpy-for-errormapper) below). 6. Add the connector to **`config/connectors.yaml`** with `enabled: true`, the desired `exposed_via` protocols, and an **`auth:`** block. -7. That's it — `auto_register()` handles the rest. No factory branch required. +7. **Environment template:** Add required secrets and connector-specific vars to [`sample.env`](../sample.env) (referenced by [configuration.md](configuration.md) and [installation.md](installation.md)). Use commented placeholders with the env var names your connector reads via `SecretProvider`. +8. `auto_register()` handles runtime registration — **no factory branch required**. + +### Publishable PyPI package (when shipping on PyPI) + +9. Add `packages/connectors//pyproject.toml` and `setup.py`; register the entry point. +10. Add the package path to **`scripts/build-packages.sh`** (`ALL_PACKAGES`) and CI allowlists (`.github/workflows/publish.yml`, `github-release.yml`, `security-pr.yml`). +11. Update the inventory table in **[packaging.md](packaging.md)**. + +### Standalone MCP server (optional — dedicated Docker/ToolHive image) + +12. Add `src/agents/_mcp.py`, a `[project.scripts]` entry in root `pyproject.toml`, `docker//Dockerfile`, and entries in **`scripts/build-mcp-images.sh`** and **`docker-compose.mcp.yml`**. +13. Add a row to the naming table in **[mcp-servers.md](mcp-servers.md)**. + +For full file lists see [packaging.md — Adding a new publishable connector](packaging.md#adding-a-new-publishable-connector). --- diff --git a/docs/local-packages-to-images.md b/docs/local-packages-to-images.md index faa3e13..ab33aba 100644 --- a/docs/local-packages-to-images.md +++ b/docs/local-packages-to-images.md @@ -14,7 +14,7 @@ The Dockerfiles in this repo install local wheel artifacts from `packages/**/dis ## Prerequisites -- Python 3.12 available in your shell +- Python 3.11+ available in your shell - Docker installed and running - Build tooling installed: diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 55d716c..70c315f 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -65,6 +65,106 @@ flowchart TD | Salesforce | `python -m agents.salesforce_mcp` | `nw-salesforce` | `nw-salesforce` | All manifest actions for `salesforce` (e.g., `salesforce.create_lead`) | | Slack | `python -m agents.slack_mcp` | `nw-slack` | `nw-slack` | All manifest actions for `slack` (e.g. `slack.post_message`) | +### Adding a row for a new connector + +When you add a standalone MCP server for a connector, add a row to the table above and update these files: + +| Field | Convention | +|-------|--------------| +| Python entrypoint | `python -m agents._mcp` (or `uv run nw-` from root `pyproject.toml` `[project.scripts]`) | +| Docker image | `nw-` | +| ToolHive name | Same as Docker image tag | +| MCP tools | `.` for each manifest action | + +Files to update: `src/agents/_mcp.py`, root `pyproject.toml` `[project.scripts]`, `docker//Dockerfile`, `scripts/build-mcp-images.sh`, `docker-compose.mcp.yml`, this table, and [local-packages-to-images.md](local-packages-to-images.md). See [packaging.md — Adding a new publishable connector](packaging.md#adding-a-new-publishable-connector) for the full checklist. + +#### Per-connector MCP entrypoint template (`src/agents/_mcp.py`) + +Every per-connector MCP file follows the same pattern — create `src/agents/_mcp.py`: + +```python +# src/agents/_mcp.py +"""MCP Server — connector only. Usage: python -m agents._mcp""" +from __future__ import annotations + +import logging +import os + +from dotenv import load_dotenv + +load_dotenv() +load_dotenv(os.path.join(os.path.dirname(__file__), ".env")) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("agents._mcp") + + +def main() -> None: + from bindings.mcp_server.server import McpServer + + logger.info("Starting nw- MCP server (stdio, manifest-driven)") + McpServer( + server_name="nw-", + connector_ids=[""], + ).run_stdio() + + +if __name__ == "__main__": + main() +``` + +`McpServer` is the shared manifest-driven server from `bindings.mcp_server.server`. The `connector_ids` list filters the full connector registry to only the connectors this image should expose. `run_stdio()` starts the MCP stdio transport; call `run_streamable_http()` instead for the HTTP transport (reads `NW_MCP_HOST`/`NW_MCP_PORT`/`NW_MCP_PATH`). + +Then register the script in root `pyproject.toml`: + +```toml +[project.scripts] +nw- = "agents._mcp:main" +``` + +#### Dockerfile template (`docker//Dockerfile`) + +All per-connector Docker images follow the same structure. The key requirements are: copy `src/` and `config/` from the repo root, install pre-built wheels from `/wheels/`, pin `NW_ALLOWED_CONNECTORS` to the single connector ID, and run as a non-root user. + +```dockerfile +FROM python:3.12-slim@sha256:3d5ed973e45820f5ba5e46bd065bd88b3a504ff0724d85980dcd05eab361fcf4 + +LABEL org.opencontainers.image.title="nw-" \ + org.opencontainers.image.description="Node Wire — MCP server" \ + org.opencontainers.image.source="https://github.com/AOT-Technologies/node-wire" + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY src/ ./src/ +COPY config/ ./config/ +COPY packages/runtime/dist/*.whl /wheels/ +COPY packages/connectors//dist/*.whl /wheels/ + +ENV PYTHONPATH=/app/src \ + NW_ALLOWED_CONNECTORS= + +RUN pip install --no-cache-dir --find-links=/wheels \ + node-wire-runtime node-wire- "mcp>=1.6.0" \ + && rm -rf /wheels + +RUN groupadd --system --gid 1000 app \ + && useradd --system --uid 1000 --gid app --home /app app \ + && chown -R app:app /app + +USER app + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD \ + python -c "from agents._mcp import main; assert callable(main); print('ok')" || exit 1 + +CMD ["python", "-m", "agents._mcp"] +``` + +> **Note:** The `COPY packages/connectors//dist/*.whl` line requires the connector's publishable wheel (Tier 2) to be built first via `bash scripts/build-packages.sh packages/connectors/`. Build wheels before building Docker images. + The unified server (`python -m agents.mcp_entrypoint`) exposes **every** connector enabled for MCP in `config/connectors.yaml` (e.g. `http_generic.request`, `stripe.charge`, `stripe.create_payment_intent`, `stripe.create_subscription`, `stripe.cancel_subscription`, `stripe.issue_refund`, plus the rows above). @@ -82,6 +182,21 @@ The unified server (`python -m agents.mcp_entrypoint`) exposes **every** connect --- +## Command reference + +| Goal | Command | +|------|---------| +| Combined MCP server (all MCP-enabled connectors) | `uv run python -m agents.mcp_entrypoint` | +| Single-connector MCP | `uv run nw-` or `python -m agents._mcp` | +| REST API | `uv run node-wire` (default `MODE=API`) | +| gRPC | `MODE=GRPC uv run node-wire` | + +For MCP transport configuration (`NW_MCP_TRANSPORT`, `streamable-http`, Inspector), see **[mcp.md](mcp.md)** (canonical MCP transport doc). For REST/gRPC setup see **[installation.md](installation.md)**. + +> **Note:** `uv run node-wire` and `python -m bindings_entrypoint` start the **REST API** by default (`MODE=API`). They do **not** start an MCP transport server. `MODE=MCP` on `bindings_entrypoint` is a POC stub only — use `agents.mcp_entrypoint` for real MCP. + +--- + ## Shifting between transport modes Node Wire now supports two ways to expose tools to AI agents. By default, it uses `stdio`, but you can easily shift to the native `streamable-http` mode for web-native deployments. @@ -103,7 +218,13 @@ You can switch modes and ports instantly using environment variables. No code ch No extra variables are needed. This is the mode expected by local stdio clients and ToolHive-style stdio wrapping. ```bash +# Using uv +uv run python -m agents.mcp_entrypoint + +# Using python python -m agents.mcp_entrypoint + +# Per-connector: uv run nw-slack ``` PowerShell: @@ -111,10 +232,10 @@ PowerShell: ```powershell $env:NW_MCP_TRANSPORT="stdio" # Using uv -uv run node-wire +uv run python -m agents.mcp_entrypoint # Using python -python -m bindings_entrypoint +python -m agents.mcp_entrypoint ``` #### 2. Shifting to native HTTP mode (Port 8081) @@ -127,10 +248,10 @@ $env:NW_MCP_HOST="127.0.0.1" $env:NW_MCP_PORT="8081" $env:NW_MCP_PATH="/mcp" # Using uv -uv run node-wire +uv run python -m agents.mcp_entrypoint # Using python -python -m bindings_entrypoint +python -m agents.mcp_entrypoint ``` **Bash (Linux/macOS):** @@ -140,10 +261,10 @@ export NW_MCP_HOST="127.0.0.1" export NW_MCP_PORT="8081" export NW_MCP_PATH="/mcp" # Using uv -uv run node-wire +uv run python -m agents.mcp_entrypoint # Using python -python -m bindings_entrypoint +python -m agents.mcp_entrypoint ``` The native HTTP endpoint will be: @@ -633,7 +754,10 @@ python -m agents.toolhive --local --patient-id 12724066 --recipient-email you@ex | `fhir_epic connector not configured` | Missing Epic env vars | Ensure all `EPIC_*` variables are set and non-empty | | `fhir_cerner connector not configured` | Missing Cerner env vars | Ensure all `CERNER_*` variables are set and non-empty | | Docker build fails with `COPY src/ not found` | Wrong build context | Always run `docker build` from the **repository root**, not from `docker//` | +| Docker build fails with `COPY packages/connectors/.../dist/*.whl` | Missing wheel | Run `bash scripts/build-packages.sh packages/connectors/` to build the wheel first (Tier 2 must exist before Docker image) | | Image healthcheck fails | Import error at startup | Run `docker logs ` to see the Python traceback; usually a missing env var | +| Log shows many `Connector enabled in configuration but not registered; skipping instantiation` warnings | Expected behaviour | Per-connector images set `NW_ALLOWED_CONNECTORS` to one ID; all other connectors in `config/connectors.yaml` produce this warning and are correctly skipped. Not an error. | +| `MCP server initialized \| manifest_contract=5` — what does the number mean? | Version indicator | `manifest_contract` is the MCP input-schema contract version (currently 5), **not** the number of tools. Use `tools/list` to count exposed tools. | ## Rollout verification checklist diff --git a/docs/mcp.md b/docs/mcp.md index 731275b..d9c9be9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6,6 +6,8 @@ Node Wire integrates with the Model Context Protocol to allow AI agents (like Claude or custom LLM orchestrators) to discover and use connectors as tools. +For **per-connector Docker images and ToolHive registration**, see [mcp-servers.md](mcp-servers.md). + For **outbound OAuth** when connecting to remote authorized MCP servers over HTTP, see [mcp-client-oauth.md](mcp-client-oauth.md). ## Transport Modes diff --git a/docs/packaging.md b/docs/packaging.md index d5486fc..73c0aad 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -26,6 +26,56 @@ Node Wire ships as **nine independent PyPI packages** (the runtime plus eight co Each connector's `pyproject.toml` lives at `packages/connectors//pyproject.toml`; the runtime's is at `packages/runtime/pyproject.toml`. +**Source of truth:** Keep this table in sync with `ALL_PACKAGES` in [`scripts/build-packages.sh`](../scripts/build-packages.sh). MCP Docker images are a **separate, smaller set** (seven connectors today) — see [Docker demo images](#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](connectors.md)), 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_/` | `schema.py`, `logic.py`, optional `registration.py`, `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`](../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//pyproject.toml` | Publishable package metadata, version, entry point | +| `packages/connectors//setup.py` | Cython/build glue (copy pattern from an existing connector) | +| [`scripts/build-packages.sh`](../scripts/build-packages.sh) | Add path to `ALL_PACKAGES` | +| [`.github/workflows/publish.yml`](../.github/workflows/publish.yml) | Add to `allowed` set | +| [`.github/workflows/github-release.yml`](../.github/workflows/github-release.yml) | Add to `package_paths` | +| [`.github/workflows/security-pr.yml`](../.github/workflows/security-pr.yml) | Add to matrix `package_path` | +| This doc — [Package inventory](#package-inventory) | Add row; update package count | +| Root + all package `pyproject.toml` | Version bump on release | +| `CHANGELOG.md` | Release section | + +### 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//dist/`; that directory does not exist until you run `bash scripts/build-packages.sh packages/connectors/`. + +Use when you need a dedicated Docker/ToolHive image for a single connector (not required for the combined `agents.mcp_entrypoint` server). For the entrypoint code template and Dockerfile template see [mcp-servers.md — Adding a row for a new connector](mcp-servers.md#adding-a-row-for-a-new-connector). + +| File | Purpose | +|---|---| +| `src/agents/_mcp.py` | Per-connector MCP agent entrypoint | +| Root `pyproject.toml` | `[project.scripts]` e.g. `nw-` | +| `docker//Dockerfile` | Demo MCP image | +| [`scripts/build-mcp-images.sh`](../scripts/build-mcp-images.sh) | `docker build` block | +| [`docker-compose.mcp.yml`](../docker-compose.mcp.yml) | Service + `NW_ALLOWED_CONNECTORS` | +| [mcp-servers.md](mcp-servers.md) | Naming conventions table row | +| [local-packages-to-images.md](local-packages-to-images.md) | Wheel → image mapping | + --- ## Python package build lifecycle diff --git a/docs/public-api.md b/docs/public-api.md index 7354ebd..219ef8a 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -55,6 +55,8 @@ Connector authors depend on these stable modules: Connectors register via the `node_wire.connectors` entry-point group. +**Bootstrap (not in `__all__`):** `node_wire_runtime.connector_registry.auto_register()` loads entry points at process startup (requires `NW_ALLOWED_CONNECTORS`). In-process usage typically goes through `bindings.factory.ConnectorFactory` after `auto_register()`, not direct registry access. + ## Wire contracts - **REST** — routes and request/response schemas served by the API binding diff --git a/sample.env b/sample.env index a09c535..e9cefd0 100644 --- a/sample.env +++ b/sample.env @@ -34,7 +34,7 @@ SMTP_PASSWORD=your-gmail-app-password STRIPE_API_KEY=sk_test_your_key_here # Slack -NW_SLACK_API_BASE_URL =https://slack.com/api +NW_SLACK_API_BASE_URL=https://slack.com/api NW_SLACK_SKIP_RESOLVE=true # Bot Token from https://api.slack.com/apps (Bot Token Scopes: chat:write, files:write, im:write) SLACK_BOT_TOKEN=xoxb-your-token-here @@ -82,7 +82,7 @@ NW_STREAM_BUFFER_MS=0 # NW_MCP_TRANSPORT: Selects the communication layer. # - stdio: (Default) Required for ToolHive proxying and Claude Desktop. # - streamable-http: Native HTTP/SSE transport for direct web integration. -NW_MCP_TRANSPORT=streamable-http +NW_MCP_TRANSPORT=stdio # NW_MCP_HOST defaults to 127.0.0.1 in code; set 0.0.0.0 only when exposing beyond localhost. NW_MCP_HOST=127.0.0.1 NW_MCP_PATH=/mcp @@ -144,6 +144,7 @@ NW_MCP_SCOPE_POLICY_DEFAULT=deny # REST auth for Playground demo (disable for local UI testing) # NW_REST_AUTH_DISABLED=true +# NW_REST_API_KEY=replace-with-strong-random-value NW_REST_LOAD_DOTENV=true # REST API key scopes (same format as NW_MCP_API_KEY_SCOPES). Empty = no scopes unless JWT carries scopes. # NW_REST_API_KEY_SCOPES=["mcp:smtp.send_email"] From 7103c66ddcdf6f4b0c127235ff297d3782a83238 Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:35:47 -0700 Subject: [PATCH 2/3] Updated documentation for connector creation --- docs/connectors.md | 17 ++-- docs/local-packages-to-images.md | 2 +- docs/mcp-servers.md | 2 +- docs/packaging.md | 128 ++++++++++++++++++++++++++++--- 4 files changed, 128 insertions(+), 21 deletions(-) diff --git a/docs/connectors.md b/docs/connectors.md index f3242d4..b9ca836 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -23,6 +23,7 @@ Each connector is a **top-level package** under `src/` (e.g. `node_wire_fhir_epi | File | Role | |------|------| +| `__init__.py` | Required empty file — marks the directory as a Python package. | | `schema.py` | Pydantic input/output models. Each input model has an `action: Literal[...]` discriminator field (often combined into a discriminated union). | | `logic.py` | Connector class: `BaseConnector` subclass — either explicit `@nw_action` methods, or **`action_specs`** plus an optional `_execute_action_spec` override for SDK dispatch. | | `action_spec.py` (optional) | Declarative `SdkActionSpec` entries mapping validated models to vendor SDK calls (see Google Drive). | @@ -258,7 +259,7 @@ Choose a provider in your **`connectors.yaml`** via the `auth:` block: |-------|----------|---------|-------| | `secret_key` | Yes | — | Env var name holding the raw token value (`EnvSecretProvider` tries the key as-is, then uppercased). | | `header_name` | No | `Authorization` | HTTP header the token is injected into. | -| `prefix` | No | `Bearer ` (with trailing space) | String prepended to the token value. Set `prefix: ""` for APIs that expect the raw token (e.g. Stripe). Set `prefix: "token "` for older GitHub PAT style. | +| `prefix` | No | `Bearer ` (with trailing space) | String prepended to the token value. Set `prefix: ""` for APIs that expect the raw token (e.g. Stripe). Set `prefix: "token "` for APIs that require the `token` scheme (check your vendor's auth docs). | So `slack` (no `header_name`/`prefix`) produces `Authorization: Bearer `, and `stripe` (with `prefix: ""`) produces `Authorization: `. @@ -562,25 +563,25 @@ MCP tool names: **`.`** (e.g. `fhir_epic.read_patient`). S ### Runtime (dev) -1. Create the package directory `src/node_wire_/` with `schema.py` (Pydantic input/output models) and register the entry point under `[project.entry-points."node_wire.connectors"]` in the root `pyproject.toml`. -2. In `logic.py`: subclass `BaseConnector`, set `connector_id` and `output_model`, then add `@nw_action` methods or wire `action_specs`. +1. Create the package directory `src/node_wire_/`. The directory **must contain `__init__.py`** (empty is fine) to be importable as a Python package. Add `schema.py` with Pydantic input/output models and register the entry point under `[project.entry-points."node_wire.connectors"]` in the root `pyproject.toml`. +2. In `logic.py`: subclass `BaseConnector`, set `connector_id` and `output_model`, then add `@nw_action` methods or wire `action_specs`. If your connector makes outbound HTTP calls (e.g. using `httpx`), declare that library as a dependency in the connector's `packages/connectors//pyproject.toml`. 3. **Authentication**: Delegate all header construction to **`self.get_auth_headers()`**. Do not hardcode secret lookups or IdP handshakes and ensure sensitive fields are removed from your `input_schema`. 4. For SDK-style connectors, add an `action_spec.py` (or similar) with `SdkActionSpec` entries and use **`execute_spec_in_thread`** when the vendor client is blocking. 5. Optionally add `error_map` and/or `registration.py` for custom exception handling (see [registration.py example](#optional-registrationpy-for-errormapper) below). 6. Add the connector to **`config/connectors.yaml`** with `enabled: true`, the desired `exposed_via` protocols, and an **`auth:`** block. -7. **Environment template:** Add required secrets and connector-specific vars to [`sample.env`](../sample.env) (referenced by [configuration.md](configuration.md) and [installation.md](installation.md)). Use commented placeholders with the env var names your connector reads via `SecretProvider`. +7. **Environment template:** Add required secrets and connector-specific vars to [`sample.env`](../sample.env) (referenced by [configuration.md](configuration.md) and [installation.md](installation.md)). Use commented placeholders with the env var names your connector reads via `SecretProvider`. Also add the new connector's entry-point name to the `NW_ALLOWED_CONNECTORS` line so the template stays current. 8. `auto_register()` handles runtime registration — **no factory branch required**. ### Publishable PyPI package (when shipping on PyPI) -9. Add `packages/connectors//pyproject.toml` and `setup.py`; register the entry point. -10. Add the package path to **`scripts/build-packages.sh`** (`ALL_PACKAGES`) and CI allowlists (`.github/workflows/publish.yml`, `github-release.yml`, `security-pr.yml`). +9. Create `packages/connectors//pyproject.toml` and `packages/connectors//setup.py`. See [packaging.md — Tier 2 templates](packaging.md#tier-2-templates) for copy-paste starting points for both files. +10. Add the package path to **`scripts/build-packages.sh`** (`ALL_PACKAGES`) and to the three CI workflow allowlists — see [packaging.md — CI allowlist updates](packaging.md#ci-allowlist-updates) for the exact lines to add in each file. 11. Update the inventory table in **[packaging.md](packaging.md)**. ### Standalone MCP server (optional — dedicated Docker/ToolHive image) -12. Add `src/agents/_mcp.py`, a `[project.scripts]` entry in root `pyproject.toml`, `docker//Dockerfile`, and entries in **`scripts/build-mcp-images.sh`** and **`docker-compose.mcp.yml`**. -13. Add a row to the naming table in **[mcp-servers.md](mcp-servers.md)**. +12. Add `src/agents/_mcp.py`, a `[project.scripts]` entry in root `pyproject.toml`, `docker//Dockerfile`, and entries in **`scripts/build-mcp-images.sh`**, **`docker-compose.mcp.yml`**, and **[local-packages-to-images.md](local-packages-to-images.md)** (wheel → image mapping table). +13. Add a row to the naming table in **[mcp-servers.md](mcp-servers.md)** and update the architecture diagram in that file to include the new connector. For full file lists see [packaging.md — Adding a new publishable connector](packaging.md#adding-a-new-publishable-connector). diff --git a/docs/local-packages-to-images.md b/docs/local-packages-to-images.md index ab33aba..32871fe 100644 --- a/docs/local-packages-to-images.md +++ b/docs/local-packages-to-images.md @@ -101,7 +101,7 @@ docker build -f docker/smtp/Dockerfile -t nw-smtp:local . ## Wheel requirements by image -Each Dockerfile expects specific wheel files to exist in `dist/`: +Each Dockerfile expects specific wheel files to exist in `dist/`. Keep this table in sync with the Dockerfiles in `docker/` — add a row here whenever you add a Tier 3 standalone MCP image (see [connectors.md — Step 12](connectors.md#adding-a-new-connector-checklist)). | Image | Required wheels | |---|---| diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 70c315f..913a320 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -76,7 +76,7 @@ When you add a standalone MCP server for a connector, add a row to the table abo | ToolHive name | Same as Docker image tag | | MCP tools | `.` for each manifest action | -Files to update: `src/agents/_mcp.py`, root `pyproject.toml` `[project.scripts]`, `docker//Dockerfile`, `scripts/build-mcp-images.sh`, `docker-compose.mcp.yml`, this table, and [local-packages-to-images.md](local-packages-to-images.md). See [packaging.md — Adding a new publishable connector](packaging.md#adding-a-new-publishable-connector) for the full checklist. +Files to update: `src/agents/_mcp.py`, root `pyproject.toml` `[project.scripts]`, `docker//Dockerfile`, `scripts/build-mcp-images.sh`, `docker-compose.mcp.yml`, this table (naming conventions + architecture diagram), and [local-packages-to-images.md](local-packages-to-images.md) (wheel requirements table). See [packaging.md — Adding a new publishable connector](packaging.md#adding-a-new-publishable-connector) for the full checklist. #### Per-connector MCP entrypoint template (`src/agents/_mcp.py`) diff --git a/docs/packaging.md b/docs/packaging.md index 73c0aad..b078542 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -6,7 +6,7 @@ SPDX-License-Identifier: Apache-2.0 # Packaging & Publishing -Node Wire ships as **nine independent PyPI packages** (the runtime plus eight connectors) built from a single monorepo. All wheels are binary-only (Cython-compiled `.so`/`.pyd` files) — no `.py` source is included in any published wheel. +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. --- @@ -26,7 +26,7 @@ Node Wire ships as **nine independent PyPI packages** (the runtime plus eight co Each connector's `pyproject.toml` lives at `packages/connectors//pyproject.toml`; the runtime's is at `packages/runtime/pyproject.toml`. -**Source of truth:** Keep this table in sync with `ALL_PACKAGES` in [`scripts/build-packages.sh`](../scripts/build-packages.sh). MCP Docker images are a **separate, smaller set** (seven connectors today) — see [Docker demo images](#docker-demo-images). `http_generic` is publishable on PyPI but does not have a standalone MCP container image. +**Source of truth:** Keep this table in sync with `ALL_PACKAGES` in [`scripts/build-packages.sh`](../scripts/build-packages.sh). MCP Docker images are a **separate subset** — see [Docker demo images](#docker-demo-images). `http_generic` is publishable on PyPI but does not have a standalone MCP container image. --- @@ -51,15 +51,121 @@ After implementing the connector runtime (see [connectors.md](connectors.md)), u | File | Purpose | |---|---| | `packages/connectors//pyproject.toml` | Publishable package metadata, version, entry point | -| `packages/connectors//setup.py` | Cython/build glue (copy pattern from an existing connector) | +| `packages/connectors//setup.py` | Cython/build glue — see [Tier 2 templates](#tier-2-templates) below | | [`scripts/build-packages.sh`](../scripts/build-packages.sh) | Add path to `ALL_PACKAGES` | -| [`.github/workflows/publish.yml`](../.github/workflows/publish.yml) | Add to `allowed` set | -| [`.github/workflows/github-release.yml`](../.github/workflows/github-release.yml) | Add to `package_paths` | -| [`.github/workflows/security-pr.yml`](../.github/workflows/security-pr.yml) | Add to matrix `package_path` | -| This doc — [Package inventory](#package-inventory) | Add row; update package count | +| [`.github/workflows/publish.yml`](../.github/workflows/publish.yml) | Add to `allowed` set — see [CI allowlist updates](#ci-allowlist-updates) below | +| [`.github/workflows/github-release.yml`](../.github/workflows/github-release.yml) | Add to `package_paths` list — see [CI allowlist updates](#ci-allowlist-updates) below | +| [`.github/workflows/security-pr.yml`](../.github/workflows/security-pr.yml) | Add to matrix `package_path` — see [CI allowlist updates](#ci-allowlist-updates) below | +| This doc — [Package inventory](#package-inventory) | Add row | | Root + all package `pyproject.toml` | Version bump on release | | `CHANGELOG.md` | Release section | +### Tier 2 templates + +#### `pyproject.toml` template + +```toml +# packages/connectors//pyproject.toml +[project] +name = "node-wire-" +version = "1.0.0" +description = "Node Wire connector — " +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"] + = "node_wire_.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_*"] +``` + +#### `setup.py` template + +```python +# packages/connectors//setup.py +import glob +import os +from Cython.Build import cythonize +from setuptools import setup +from setuptools.command.build_py import build_py as _BuildPy + + +class NoPyBuild(_BuildPy): + def find_package_modules(self, package, package_dir): + return [] + + +src_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../src/node_wire_")) +py_files = glob.glob(os.path.join(src_root, "**", "*.py"), recursive=True) + +setup( + cmdclass={"build_py": NoPyBuild}, + ext_modules=cythonize(py_files, compiler_directives={"language_level": "3"}, build_dir="build"), +) +``` + +Replace `` with the connector's snake_case name (e.g. `my_service`) and `` 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` — `allowed` set + +Inside the `validate` step, add your package path to the `allowed` Python set: + +```python +# .github/workflows/publish.yml (inside the inline Python script) +allowed = { + "packages/runtime", + "packages/connectors/http_generic", + "packages/connectors/stripe", + # ... existing entries ... + "packages/connectors/", # ← add this line +} +``` + +#### `.github/workflows/github-release.yml` — `package_paths` list + +Inside the release-manifest step, add your path to the `package_paths` Python list: + +```python +# .github/workflows/github-release.yml (inside the inline Python script) +package_paths = [ + "packages/runtime", + "packages/connectors/http_generic", + # ... existing entries ... + "packages/connectors/", # ← add this line +] +``` + +#### `.github/workflows/security-pr.yml` — matrix `package_path` + +Add a new YAML list item under `jobs..strategy.matrix.package_path`: + +```yaml +# .github/workflows/security-pr.yml +matrix: + package_path: + - packages/runtime + - packages/connectors/http_generic + # ... existing entries ... + - packages/connectors/ # ← 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//dist/`; that directory does not exist until you run `bash scripts/build-packages.sh packages/connectors/`. @@ -88,7 +194,7 @@ Prerequisites: `pip install build cython wheel` (and a usable `python` on the ho bash scripts/build-packages.sh ``` -Default mode builds each of the **nine** known package paths (see inventory above): `python -m build --wheel` on the **host**, then again inside **Docker** (`python:3.12-slim`) so you get Linux-tagged wheels suitable for containers. **Docker must be installed and the daemon running.** After each package, the script scans every produced wheel and fails if any `.py` file appears inside the archive. +Default mode builds each package path listed in `ALL_PACKAGES` in the script (see the [Package inventory](#package-inventory) for the current set): `python -m build --wheel` on the **host**, then again inside **Docker** (`python:3.12-slim`) so you get Linux-tagged wheels suitable for containers. **Docker must be installed and the daemon running.** After each package, the script scans every produced wheel and fails if any `.py` file appears inside the archive. ### Artifact layout and safe command usage @@ -228,7 +334,7 @@ manual step per package, bound to that tag. ### Step 1 — Prepare the release -1. Bump version in the root `pyproject.toml` and all nine package `pyproject.toml` files. +1. Bump version in the root `pyproject.toml` and all connector package `pyproject.toml` files (one per entry in `ALL_PACKAGES` in `scripts/build-packages.sh`). 2. Add a dated `CHANGELOG.md` section and release link for the target version. 3. Merge to `main` and confirm required CI checks are green. @@ -249,13 +355,13 @@ 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 nine publishable package paths. +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 3 — Publish packages to PyPI After the GitHub Release exists, dispatch `.github/workflows/publish.yml` **once per -package** (nine times for a full release). +package** (once per entry in the `allowed` set in that workflow). **Required inputs:** From 0624b8dcd89d26cfeb1b18824545adea2dc9ebba Mon Sep 17 00:00:00 2001 From: vinaayakh-aot <61819385+vinaayakh-aot@users.noreply.github.com> Date: Fri, 3 Jul 2026 03:44:03 -0700 Subject: [PATCH 3/3] Updated docs --- docs/connectors.md | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/connectors.md b/docs/connectors.md index b9ca836..c53f554 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -226,21 +226,40 @@ Node Wire provides a shared **`AuthProvider`** abstraction (`src/node_wire_runti To use authentication, call **`await self.get_auth_headers()`** (inherited from `BaseConnector`). This returns a dictionary of headers (e.g. `{"Authorization": "Bearer "}`) injected by the configured provider. +There are two patterns depending on how your connector talks to the vendor: + +**HTTP connectors** (direct REST calls via `httpx`) — create a short-lived client inside each `@nw_action` method. Do **not** override `build_client()`: + ```python -# logic.py usage +# logic.py — HTTP connector pattern (e.g. Slack, FHIR, GitHub) # Base URL: read from the connector's own config, an env var, or a module constant. # There is no inherited _get_base_url() helper — connectors own their URL resolution. BASE_URL = "https://api.example.com" # or: os.environ["MY_SERVICE_URL"] +@nw_action("read_resource") async def read_resource(self, params: In, *, trace_id: str) -> Out: headers = await self.get_auth_headers() # Fetched/cached by provider - async with httpx.AsyncClient() as client: resp = await client.get(f"{BASE_URL}/resource", headers=headers) resp.raise_for_status() ... ``` +**SDK connectors** (vendor Python SDK with a long-lived client object) — override `build_client()` so `get_client()` can cache the result across calls. Auth is handled inside `build_client()`, not via `get_auth_headers()`: + +```python +# logic.py — SDK connector pattern (e.g. Google Drive) +def build_client(self) -> Any: + # Read credential from secret provider and build the vendor client once. + raw_sa = self.secret_provider.get_secret("MY_SA_JSON") + creds = ... + return vendor_sdk.build("v1", credentials=creds) + +async def _execute_action_spec(self, action_name, params, *, trace_id, log_extra=None): + client = self.get_client() # cached; calls build_client() on first use + ... +``` + ### Supported Provider Types Choose a provider in your **`connectors.yaml`** via the `auth:` block: @@ -564,7 +583,7 @@ MCP tool names: **`.`** (e.g. `fhir_epic.read_patient`). S ### Runtime (dev) 1. Create the package directory `src/node_wire_/`. The directory **must contain `__init__.py`** (empty is fine) to be importable as a Python package. Add `schema.py` with Pydantic input/output models and register the entry point under `[project.entry-points."node_wire.connectors"]` in the root `pyproject.toml`. -2. In `logic.py`: subclass `BaseConnector`, set `connector_id` and `output_model`, then add `@nw_action` methods or wire `action_specs`. If your connector makes outbound HTTP calls (e.g. using `httpx`), declare that library as a dependency in the connector's `packages/connectors//pyproject.toml`. +2. In `logic.py`: subclass `BaseConnector`, set `connector_id` and `output_model`, then add `@nw_action` methods or wire `action_specs`. If your connector makes outbound HTTP calls (e.g. using `httpx`), declare that library as a dependency in the connector's `packages/connectors//pyproject.toml`. For HTTP-based connectors use an inline `async with httpx.AsyncClient() as client:` inside each `@nw_action` method (see [Using Auth in a Connector](#using-auth-in-a-connector)); only override `build_client()` / `get_client()` when wrapping a vendor SDK that requires a long-lived client object (e.g. `google_drive`). 3. **Authentication**: Delegate all header construction to **`self.get_auth_headers()`**. Do not hardcode secret lookups or IdP handshakes and ensure sensitive fields are removed from your `input_schema`. 4. For SDK-style connectors, add an `action_spec.py` (or similar) with `SdkActionSpec` entries and use **`execute_spec_in_thread`** when the vendor client is blocking. 5. Optionally add `error_map` and/or `registration.py` for custom exception handling (see [registration.py example](#optional-registrationpy-for-errormapper) below). @@ -580,6 +599,8 @@ MCP tool names: **`.`** (e.g. `fhir_epic.read_patient`). S ### Standalone MCP server (optional — dedicated Docker/ToolHive image) +> **Prerequisite:** Complete Steps 9–11 (Tier 2) first. The Dockerfile copies pre-built `.whl` files from `packages/connectors//dist/`; that directory does not exist until you run `bash scripts/build-packages.sh packages/connectors/`. + 12. Add `src/agents/_mcp.py`, a `[project.scripts]` entry in root `pyproject.toml`, `docker//Dockerfile`, and entries in **`scripts/build-mcp-images.sh`**, **`docker-compose.mcp.yml`**, and **[local-packages-to-images.md](local-packages-to-images.md)** (wheel → image mapping table). 13. Add a row to the naming table in **[mcp-servers.md](mcp-servers.md)** and update the architecture diagram in that file to include the new connector.