diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..528af8b --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + +name: docs + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install docs dependencies + run: pip install mkdocs mkdocs-material + + - name: Build (PRs — validates site builds cleanly) + if: github.event_name == 'pull_request' + run: mkdocs build + + - name: Deploy to GitHub Pages + if: github.event_name == 'push' + run: mkdocs gh-deploy --force diff --git a/.gitignore b/.gitignore index 50368c5..eca270c 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,9 @@ coverage.xml bandit-report.json .venv*/ +# MkDocs generated site +/site/ + # debug launch.json diff --git a/docs/architecture.md b/docs/architecture.md index b67a6a7..babddd4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,8 @@ -# SPDX-FileCopyrightText: 2026 AOT Technologies -# -# SPDX-License-Identifier: Apache-2.0 + # Node Wire Architecture diff --git a/docs/compliance/hipaa-considerations.md b/docs/compliance/hipaa-considerations.md index 2443070..98a3a6d 100644 --- a/docs/compliance/hipaa-considerations.md +++ b/docs/compliance/hipaa-considerations.md @@ -1,6 +1,8 @@ -# SPDX-FileCopyrightText: 2026 AOT Technologies -# -# SPDX-License-Identifier: Apache-2.0 + # HIPAA Compliance Considerations diff --git a/docs/configuration.md b/docs/configuration.md index 752d454..11033b8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,6 +1,8 @@ -# SPDX-FileCopyrightText: 2026 AOT Technologies -# -# SPDX-License-Identifier: Apache-2.0 + # Configuration Guide diff --git a/docs/connectors.md b/docs/connectors.md index 282cb27..c53f554 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -23,13 +23,14 @@ 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). | | `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 +113,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`) @@ -223,27 +226,61 @@ 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: - 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() ... ``` +**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: -| 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 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: `. ### Configuration (`connectors.yaml`) @@ -264,14 +301,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 +349,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 +427,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 +541,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 +570,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 +580,31 @@ 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"]`. -2. In `logic.py`: subclass `BaseConnector`, set `connector_id` and `output_model`, then add `@nw_action` methods or wire `action_specs`. +### 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`. 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. +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`. 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. 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) + +> **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. + +For full file lists see [packaging.md — Adding a new publishable connector](packaging.md#adding-a-new-publishable-connector). --- diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..d55da99 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,109 @@ + + +# Contributing to Node Wire + +Thanks for your interest in contributing! This guide covers how to set up a +development environment, the quality checks we enforce, and the conventions for +submitting changes. By contributing you agree that your contributions are +licensed under the project's [Apache License 2.0](https://github.com/AOT-Technologies/node-wire/blob/main/LICENSE) and that you sign off +each commit under the +[Developer Certificate of Origin](#developer-certificate-of-origin-dco). + +## Prerequisites + +| Requirement | Version | Notes | +|---|---|---| +| Python | 3.11+ | Required to run the platform | +| `uv` | Latest | Recommended for dependency management and reproducible installs | +| Git | Any recent version | | +| Docker | Latest | Only needed for MCP server image builds | + +## Development Setup + +Install the project and all development dependencies from the committed +lockfile: + +```bash +git clone https://github.com/AOT-Technologies/node-wire.git +cd node-wire +uv sync --frozen --all-extras --dev +``` + +Install the pre-commit hooks so checks run automatically before each commit: + +```bash +pre-commit install +``` + +## Quality Checks + +All of the following run in CI on pull requests against `main`. Run them +locally before opening a PR: + +- **Lint:** `uv run ruff check .` +- **Auto-fix & format:** `uv run ruff check --fix . && uv run ruff format .` +- **Type-check:** `uv run mypy` +- **Security (SAST):** `uv run bandit -c pyproject.toml -r src` +- **Tests:** `uv run pytest` + +See [Code Quality](code-quality-compliance.md) and [Quality & Security Gates](quality-security-gates.md) for the full tooling reference. + +## Licensing & REUSE Compliance + +This project is [REUSE](https://reuse.software/) compliant. **Every new file +must carry an SPDX header.** For source and Markdown files use the project's +standard header, e.g. for Python: + +```python +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 +``` + +and for Markdown/HTML, an HTML comment with the same two SPDX tags. + +## Git Identity + +Configure your git identity before committing. Commits must use your real +name and a valid email so attribution is accurate: + +```bash +git config user.name "Your Name" +git config user.email "you@example.com" +``` + +## Developer Certificate of Origin (DCO) + +This project uses the [Developer Certificate of Origin](https://developercertificate.org/) +(DCO) instead of a CLA. Add a `Signed-off-by` trailer to **every commit**: + +```bash +git commit -s -m "Your commit message" +``` + +CI (`.github/workflows/dco.yml`) verifies that every commit in a pull request +carries a sign-off matching its author. If you forget: + +```bash +git rebase --signoff main +git push --force-with-lease +``` + +## Submitting Changes + +1. Fork the repository and create a feature branch from `main`. +2. Make your change, including tests and documentation updates where relevant. + Sign off every commit with `git commit -s`. +3. Ensure all quality checks above pass locally. +4. Open a pull request against `main` with a clear description and motivation. +5. A maintainer will review your PR. Address feedback by pushing additional + commits to your branch. + +## Reporting Bugs & Requesting Features + +Use the GitHub issue templates. For **security vulnerabilities**, do not open a +public issue — follow the [Security Policy](https://github.com/AOT-Technologies/node-wire/blob/main/SECURITY.md) instead. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..903fd54 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,73 @@ + + +# Node Wire + +[![CI](https://github.com/AOT-Technologies/node-wire/actions/workflows/pytest.yml/badge.svg)](https://github.com/AOT-Technologies/node-wire/actions/workflows/pytest.yml) +[![CodeQL](https://github.com/AOT-Technologies/node-wire/actions/workflows/codeql.yml/badge.svg)](https://github.com/AOT-Technologies/node-wire/actions/workflows/codeql.yml) +[![PyPI](https://img.shields.io/pypi/v/node-wire.svg)](https://pypi.org/project/node-wire/) +[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/AOT-Technologies/node-wire/blob/main/LICENSE) + +Node Wire is a three-layer Python platform that runs connector adapters (Google Drive, SMTP, Stripe, FHIR, Salesforce, Slack, and more) and exposes them over REST, gRPC, or MCP. It provides a consistent execution contract with built-in validation, resilience, and telemetry. + +## Quick Start + +```bash +git clone https://github.com/AOT-Technologies/node-wire.git +cd node-wire +uv sync --frozen --all-extras --dev +cp sample.env .env +MODE=API uv run node-wire +``` + +Open [http://localhost:8000/docs](http://localhost:8000/docs) for the Swagger UI. + +## Key Sections + +
+ +- **Getting Started** + + Set up your environment and configure connectors. + + [:octicons-arrow-right-24: Installation](installation.md) + +- **Architecture** + + Understand the three-layer design: Runtime, Connectors, and Bindings. + + [:octicons-arrow-right-24: Architecture](architecture.md) + +- **Connectors** + + Build or configure integrations with Google Drive, Salesforce, Slack, and more. + + [:octicons-arrow-right-24: Connectors Guide](connectors.md) + +- **MCP Integration** + + Deploy connectors as Model Context Protocol servers for AI agents. + + [:octicons-arrow-right-24: MCP Overview](mcp.md) + +
+ +## Available Connectors + +| Connector | Protocol | Doc | +|---|---|---| +| Google Drive | REST + OAuth | [Guide](google_drive_connector.md) | +| Salesforce | REST | [Guide](salesforce_connector.md) | +| Slack | Events API | [Guide](slack_connector.md) | +| SMTP | Email | [Connectors](connectors.md) | +| Stripe | REST | [Connectors](connectors.md) | +| FHIR Epic | SMART on FHIR | [Connectors](connectors.md) | +| FHIR Cerner | SMART on FHIR | [Connectors](connectors.md) | +| HTTP Generic | REST bridge | [Connectors](connectors.md) | + +## Contributing + +Contributions are welcome. See the [Contributing guide](contributing.md) for development setup, quality checks, and DCO requirements. diff --git a/docs/installation.md b/docs/installation.md index b9da6b4..9b94728 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,6 +1,8 @@ -# SPDX-FileCopyrightText: 2026 AOT Technologies -# -# SPDX-License-Identifier: Apache-2.0 + # Installation Guide diff --git a/docs/local-packages-to-images.md b/docs/local-packages-to-images.md index faa3e13..32871fe 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: @@ -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-client-oauth.md b/docs/mcp-client-oauth.md index 7ee709c..7345cf8 100644 --- a/docs/mcp-client-oauth.md +++ b/docs/mcp-client-oauth.md @@ -1,137 +1,139 @@ -# SPDX-FileCopyrightText: 2026 AOT Technologies -# -# SPDX-License-Identifier: Apache-2.0 - -# MCP Client OAuth (Outbound) - -Node Wire can act as an **OAuth 2.1 client** when connecting to **remote HTTP MCP servers** that require authorization per the MCP Authorization specification (2025-11-25). - -This is **separate** from: - -- **Inbound MCP server auth** (`NW_MCP_API_KEY`, `NW_MCP_JWT_SECRET`) — clients calling *node-wire’s* MCP server. -- **Connector OAuth** (`OAuth2AuthProvider`) — Epic/Cerner/Stripe credentials for connector actions. - -## Package - -Implementation lives in `src/node_wire_runtime/mcp_client/`: - -| Module | Purpose | -|--------|---------| -| `config.py` | Operator settings (Section 10) | -| `discovery.py` | RFC 9728 + RFC 8414 metadata discovery | -| `dcr.py` | RFC 7591 dynamic client registration | -| `oauth_flow.py` | Authorization code + PKCE + resource parameter | -| `redirect_listener.py` | Loopback redirect callback (desktop) | -| `storage.py` | Persisted DCR registrations per issuer | - -## Configuration - -```python -from node_wire_runtime.mcp_client import McpClientConfig, McpServerConfig, AuthConfig - -config = McpClientConfig( - server=McpServerConfig(url="https://mcp.example.com/mcp"), - auth=AuthConfig( - scopes="mcp:tools", - client=AuthClientConfig(id="", secret=""), # empty → use DCR when supported - ), -) -``` - -### Section 10 settings - -| Setting | Default | Purpose | -|---------|---------|---------| -| `server.url` | (required) | Canonical MCP server URL; RFC 8707 `resource` | -| `auth.discovery.cacheTtlSeconds` | `3600` | Discovery metadata cache TTL | -| `auth.dcr.enabled` | `true` | Attempt RFC 7591 when `registration_endpoint` exists | -| `auth.client.id` / `secret` | empty | Override when AS has no DCR | -| `auth.scopes` | empty | Requested scopes | -| `auth.production` | `false` | When `true`, require `https://` MCP server URL and `configured-url` HTTPS redirect; disables HTTP loopback | -| `auth.redirect.mode` | `loopback` | `loopback` or `configured-url` (defaults to `configured-url` when `auth.production=true`) | -| `auth.redirect.url` | `http://127.0.0.1:0/callback` | HTTPS callback URL for hosted / production mode | -| `auth.token.refreshLeadSeconds` | `60` | Proactive refresh lead time | -| `auth.token.store` | `os-keychain` | Token storage backend | - -## Discovery - -```python -from node_wire_runtime.mcp_client import discover, discovery_cache_for_config - -result = await discover(config, www_authenticate=challenge_header, cache=cache) -``` - -Triggered on first MCP request without a token or after `401` with `WWW-Authenticate` `resource_metadata`. - -## Authorization - -**Loopback (desktop):** - -```python -from node_wire_runtime.mcp_client import AuthorizationCodeFlow - -flow = AuthorizationCodeFlow(config) -tokens = await flow.run_loopback_authorization(open_browser=True) -``` - -**Configured URL (hosted / production):** - -```python -url = await flow.start_authorization(open_browser=True) -# User completes login; your app receives redirect at auth.redirect.url -tokens = await flow.complete_authorization_with_callback_url(callback_url) -``` - -### Production hardening (`auth.production=true`) - -Set `NW_MCP_OAUTH_PRODUCTION=true` (or `auth.production=True` in code) for server deployments: - -- `mcp.server.url` must use `https://` -- `auth.redirect.mode` must be `configured-url` -- `auth.redirect.url` must use `https://` (HTTP loopback is rejected) -- Re-authorization cannot use `run_loopback_authorization`; the host app must expose an HTTPS callback route or inject `TokenManager(reauthorize=...)` / `McpOAuthClient(reauthorize=...)` - -Example environment (see also `sample.env`): - -```env -NW_MCP_OAUTH_ENABLED=true -NW_MCP_OAUTH_PRODUCTION=true -NW_MCP_SERVER_URL=https://mcp.example.com/mcp -NW_MCP_OAUTH_REDIRECT_MODE=configured-url -NW_MCP_OAUTH_REDIRECT_URL=https://app.example.com/oauth/mcp/callback -``` - -Desktop / local development keeps the default (`NW_MCP_OAUTH_PRODUCTION` unset or `false`) with `auth.redirect.mode=loopback`. - -## STDIO - -STDIO MCP transports use environment credentials only; this OAuth client does not apply. - -## Token manager and HTTP client (Phases 4–5) - -```python -from node_wire_runtime.mcp_client import McpOAuthClient, create_http_mcp_client - -# Factory: NW_MCP_OAUTH_ENABLED=true, or legacy TOOLHIVE_MCP_BEARER_TOKEN for static auth -client = create_http_mcp_client("https://mcp.example.com/mcp") - -# Production: pass reauthorize when tokens must be renewed without loopback -# client = create_http_mcp_client("https://mcp.example.com/mcp", reauthorize=my_reauth_fn) - -tools = await client.list_tools() -``` - -Environment variables: see `sample.env` (`NW_MCP_OAUTH_*`). - -## ToolHive / playground integration (Phase 6) - -`agents.toolhive` and `playground/scenarios.py` call `create_http_mcp_client()` for HTTP MCP URLs: - -1. `TOOLHIVE_MCP_BEARER_TOKEN` / `TOOLHIVE_MCP_API_KEY` → legacy static Bearer (no OAuth). -2. `NW_MCP_OAUTH_ENABLED=true` → `McpOAuthClient` with discovery, DCR, PKCE, token refresh. - -## Internal demo script - -```bash -uv run python scripts/demo_mcp_oauth_mock.py -``` + + +# MCP Client OAuth (Outbound) + +Node Wire can act as an **OAuth 2.1 client** when connecting to **remote HTTP MCP servers** that require authorization per the MCP Authorization specification (2025-11-25). + +This is **separate** from: + +- **Inbound MCP server auth** (`NW_MCP_API_KEY`, `NW_MCP_JWT_SECRET`) — clients calling *node-wire’s* MCP server. +- **Connector OAuth** (`OAuth2AuthProvider`) — Epic/Cerner/Stripe credentials for connector actions. + +## Package + +Implementation lives in `src/node_wire_runtime/mcp_client/`: + +| Module | Purpose | +|--------|---------| +| `config.py` | Operator settings (Section 10) | +| `discovery.py` | RFC 9728 + RFC 8414 metadata discovery | +| `dcr.py` | RFC 7591 dynamic client registration | +| `oauth_flow.py` | Authorization code + PKCE + resource parameter | +| `redirect_listener.py` | Loopback redirect callback (desktop) | +| `storage.py` | Persisted DCR registrations per issuer | + +## Configuration + +```python +from node_wire_runtime.mcp_client import McpClientConfig, McpServerConfig, AuthConfig + +config = McpClientConfig( + server=McpServerConfig(url="https://mcp.example.com/mcp"), + auth=AuthConfig( + scopes="mcp:tools", + client=AuthClientConfig(id="", secret=""), # empty → use DCR when supported + ), +) +``` + +### Section 10 settings + +| Setting | Default | Purpose | +|---------|---------|---------| +| `server.url` | (required) | Canonical MCP server URL; RFC 8707 `resource` | +| `auth.discovery.cacheTtlSeconds` | `3600` | Discovery metadata cache TTL | +| `auth.dcr.enabled` | `true` | Attempt RFC 7591 when `registration_endpoint` exists | +| `auth.client.id` / `secret` | empty | Override when AS has no DCR | +| `auth.scopes` | empty | Requested scopes | +| `auth.production` | `false` | When `true`, require `https://` MCP server URL and `configured-url` HTTPS redirect; disables HTTP loopback | +| `auth.redirect.mode` | `loopback` | `loopback` or `configured-url` (defaults to `configured-url` when `auth.production=true`) | +| `auth.redirect.url` | `http://127.0.0.1:0/callback` | HTTPS callback URL for hosted / production mode | +| `auth.token.refreshLeadSeconds` | `60` | Proactive refresh lead time | +| `auth.token.store` | `os-keychain` | Token storage backend | + +## Discovery + +```python +from node_wire_runtime.mcp_client import discover, discovery_cache_for_config + +result = await discover(config, www_authenticate=challenge_header, cache=cache) +``` + +Triggered on first MCP request without a token or after `401` with `WWW-Authenticate` `resource_metadata`. + +## Authorization + +**Loopback (desktop):** + +```python +from node_wire_runtime.mcp_client import AuthorizationCodeFlow + +flow = AuthorizationCodeFlow(config) +tokens = await flow.run_loopback_authorization(open_browser=True) +``` + +**Configured URL (hosted / production):** + +```python +url = await flow.start_authorization(open_browser=True) +# User completes login; your app receives redirect at auth.redirect.url +tokens = await flow.complete_authorization_with_callback_url(callback_url) +``` + +### Production hardening (`auth.production=true`) + +Set `NW_MCP_OAUTH_PRODUCTION=true` (or `auth.production=True` in code) for server deployments: + +- `mcp.server.url` must use `https://` +- `auth.redirect.mode` must be `configured-url` +- `auth.redirect.url` must use `https://` (HTTP loopback is rejected) +- Re-authorization cannot use `run_loopback_authorization`; the host app must expose an HTTPS callback route or inject `TokenManager(reauthorize=...)` / `McpOAuthClient(reauthorize=...)` + +Example environment (see also `sample.env`): + +```env +NW_MCP_OAUTH_ENABLED=true +NW_MCP_OAUTH_PRODUCTION=true +NW_MCP_SERVER_URL=https://mcp.example.com/mcp +NW_MCP_OAUTH_REDIRECT_MODE=configured-url +NW_MCP_OAUTH_REDIRECT_URL=https://app.example.com/oauth/mcp/callback +``` + +Desktop / local development keeps the default (`NW_MCP_OAUTH_PRODUCTION` unset or `false`) with `auth.redirect.mode=loopback`. + +## STDIO + +STDIO MCP transports use environment credentials only; this OAuth client does not apply. + +## Token manager and HTTP client (Phases 4–5) + +```python +from node_wire_runtime.mcp_client import McpOAuthClient, create_http_mcp_client + +# Factory: NW_MCP_OAUTH_ENABLED=true, or legacy TOOLHIVE_MCP_BEARER_TOKEN for static auth +client = create_http_mcp_client("https://mcp.example.com/mcp") + +# Production: pass reauthorize when tokens must be renewed without loopback +# client = create_http_mcp_client("https://mcp.example.com/mcp", reauthorize=my_reauth_fn) + +tools = await client.list_tools() +``` + +Environment variables: see `sample.env` (`NW_MCP_OAUTH_*`). + +## ToolHive / playground integration (Phase 6) + +`agents.toolhive` and `playground/scenarios.py` call `create_http_mcp_client()` for HTTP MCP URLs: + +1. `TOOLHIVE_MCP_BEARER_TOKEN` / `TOOLHIVE_MCP_API_KEY` → legacy static Bearer (no OAuth). +2. `NW_MCP_OAUTH_ENABLED=true` → `McpOAuthClient` with discovery, DCR, PKCE, token refresh. + +## Internal demo script + +```bash +uv run python scripts/demo_mcp_oauth_mock.py +``` diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 55d716c..913a320 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 (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`) + +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..1ee0bdc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,11 +1,15 @@ -# SPDX-FileCopyrightText: 2026 AOT Technologies -# -# SPDX-License-Identifier: Apache-2.0 + # Model Context Protocol (MCP) in Node Wire 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..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,6 +26,162 @@ 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 subset** — 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 — 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 — 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/`. + +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 @@ -38,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 @@ -178,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. @@ -199,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:** diff --git a/docs/privacy.md b/docs/privacy.md index b39f399..6da8d35 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -1,6 +1,8 @@ -# SPDX-FileCopyrightText: 2026 AOT Technologies -# -# SPDX-License-Identifier: Apache-2.0 + # Privacy Policy and Compliance 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/docs/quality-security-gates.md b/docs/quality-security-gates.md index 8bb0dea..6cb2d06 100644 --- a/docs/quality-security-gates.md +++ b/docs/quality-security-gates.md @@ -1,6 +1,8 @@ -# SPDX-FileCopyrightText: 2026 AOT Technologies -# -# SPDX-License-Identifier: Apache-2.0 + # Quality and security gates diff --git a/docs/salesforce_connector.md b/docs/salesforce_connector.md index 56db076..b00c301 100644 --- a/docs/salesforce_connector.md +++ b/docs/salesforce_connector.md @@ -1,6 +1,8 @@ -# SPDX-FileCopyrightText: 2026 AOT Technologies -# -# SPDX-License-Identifier: Apache-2.0 + # Salesforce Connector (`src/node_wire_salesforce`) diff --git a/docs/slack_connector.md b/docs/slack_connector.md index de05f88..23e72fb 100644 --- a/docs/slack_connector.md +++ b/docs/slack_connector.md @@ -1,6 +1,8 @@ -# SPDX-FileCopyrightText: 2026 AOT Technologies -# -# SPDX-License-Identifier: Apache-2.0 + # Slack Connector diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..306a4cd --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,357 @@ +/* SPDX-FileCopyrightText: 2026 AOT Technologies + SPDX-License-Identifier: Apache-2.0 */ + +/* Node Wire — Brand Stylesheet + Matched to aot-technologies.com/products/node-wire + Background: #1C2027 (dark charcoal grey) + Surface: #242930 (card / panel charcoal) + Blue: #37c4f0 (sky blue — links, nav, code borders, h1) + Amber: #ecb32e (gold — h2 accents, tips, table headers) + Pink: #e01d5a (red-pink — h3 accents, warnings, danger) + Text: #E8EDF5 (near-white) +*/ + +/* ─── Primary color (text ON the primary-colored header/nav) ─ */ +[data-md-color-primary="custom"] { + --md-primary-fg-color: #37c4f0; + --md-primary-fg-color--light: #62d2f5; + --md-primary-fg-color--dark: #1fa8d8; + --md-primary-bg-color: #ffffff; /* text on dark header — MUST be light */ + --md-primary-bg-color--light: rgba(255, 255, 255, 0.72); +} + +/* ─── Accent ───────────────────────────────────────────────── */ +[data-md-color-accent="custom"] { + --md-accent-fg-color: #37c4f0; + --md-accent-fg-color--transparent: rgba(55, 196, 240, 0.12); + --md-accent-bg-color: #1C2027; + --md-accent-bg-color--light: rgba(28, 32, 39, 0.7); +} + +/* ─── Dark scheme (slate) ──────────────────────────────────── */ +[data-md-color-scheme="slate"] { + --md-default-bg-color: #1C2027; + --md-default-bg-color--light: rgba(28, 32, 39, 0.54); + --md-default-bg-color--lighter: rgba(28, 32, 39, 0.26); + --md-default-bg-color--lightest: rgba(28, 32, 39, 0.12); + + --md-default-fg-color: #E8EDF5; + --md-default-fg-color--light: rgba(232, 237, 245, 0.72); + --md-default-fg-color--lighter: rgba(232, 237, 245, 0.44); + --md-default-fg-color--lightest: rgba(232, 237, 245, 0.12); + + --md-typeset-a-color: #37c4f0; + + --md-code-bg-color: #242930; + --md-code-fg-color: #E8EDF5; + --md-code-hl-color: rgba(55, 196, 240, 0.15); + + --md-footer-bg-color: #13171C; + --md-footer-bg-color--dark: #0D1015; + --md-footer-fg-color: #8A9BAC; + --md-footer-fg-color--light: rgba(138, 155, 172, 0.7); + --md-footer-fg-color--lighter: rgba(138, 155, 172, 0.45); +} + +/* ─── Light scheme ─────────────────────────────────────────── */ +[data-md-color-scheme="default"] { + --md-default-bg-color: #F5F7FA; + --md-typeset-a-color: #1a9fc8; + --md-code-bg-color: #EAECF0; +} + +/* ─── Header ───────────────────────────────────────────────── */ +/* Slightly lighter than page bg so it reads as a distinct bar */ +.md-header { + background: #252A32 !important; + border-bottom: 1px solid rgba(55, 196, 240, 0.25); + box-shadow: 0 2px 20px rgba(0, 0, 0, 0.5); +} + +/* Force all header text/icons to white for contrast */ +.md-header, +.md-header .md-header__title, +.md-header .md-header__button, +.md-header .md-search__input { + color: #ffffff !important; +} + +.md-header__title { + font-weight: 700; + letter-spacing: 0.02em; +} + +/* Search bar in header */ +.md-header .md-search__input { + background: rgba(255, 255, 255, 0.1) !important; + border-radius: 6px; +} +.md-header .md-search__input::placeholder { + color: rgba(255, 255, 255, 0.55) !important; +} + +/* Header icons (search, color toggle, github) */ +.md-header .md-icon svg { + fill: rgba(255, 255, 255, 0.85); +} +.md-header .md-icon:hover svg { + fill: #37c4f0; +} + +/* ─── Nav tabs ─────────────────────────────────────────────── */ +.md-tabs { + background: #1C2027 !important; + border-bottom: 1px solid rgba(55, 196, 240, 0.12); +} + +.md-tabs__link { + color: rgba(232, 237, 245, 0.55) !important; + font-weight: 500; + transition: color 0.15s, box-shadow 0.15s; +} + +.md-tabs__link:hover { + color: #37c4f0 !important; +} + +/* Active tab — class is on the
  • , not the */ +.md-tabs__item--active .md-tabs__link { + color: #37c4f0 !important; + font-weight: 700 !important; +} + +/* ─── Sidebar ──────────────────────────────────────────────── */ +[data-md-color-scheme="slate"] .md-nav { + background: #1C2027; +} + +[data-md-color-scheme="slate"] .md-nav__title { + background: #242930; + color: #E8EDF5; +} + +.md-nav__link--active { + color: #37c4f0 !important; + font-weight: 600; +} + +[data-md-color-scheme="default"] .md-nav__link--active { + color: #1a9fc8 !important; +} + +.md-nav__item--active > .md-nav__link { + border-left: 3px solid #37c4f0; + padding-left: calc(0.6rem - 3px); +} + +.md-nav__link:hover { + color: #37c4f0 !important; +} + +/* ─── Content typography ───────────────────────────────────── */ + +/* h1: blue underline */ +.md-typeset h1 { + font-weight: 800; + letter-spacing: -0.025em; + border-bottom: 2px solid #37c4f0; + padding-bottom: 0.25em; + display: inline-block; +} + +[data-md-color-scheme="slate"] .md-typeset h1 { + color: #F0F4FA; +} + +/* h2: amber left-bar + amber color */ +.md-typeset h2 { + font-weight: 700; + padding-left: 0.7rem; + border-left: 3px solid #ecb32e; + color: #ecb32e; +} + +[data-md-color-scheme="default"] .md-typeset h2 { + color: #b07d00; + border-color: #ecb32e; +} + +/* h3: pink/red accent dot via pseudo-element */ +.md-typeset h3 { + font-weight: 600; + color: #e01d5a; +} + +[data-md-color-scheme="default"] .md-typeset h3 { + color: #b8003d; +} + +.md-typeset a { + transition: color 0.15s; +} + +.md-typeset a:hover { + color: #62d2f5 !important; +} + +/* ─── Search highlight: sky blue, not yellow ───────────────── */ +.md-typeset mark, +.md-search-result mark { + background-color: rgba(55, 196, 240, 0.22) !important; + color: inherit !important; + border-radius: 2px; + padding: 0 2px; +} + +[data-md-color-scheme="slate"] .md-search__input { + background: #242930; + color: #E8EDF5; +} + +/* ─── Code blocks ──────────────────────────────────────────── */ +.md-typeset .highlight { + border-radius: 8px; + border-left: 3px solid #37c4f0; +} + +[data-md-color-scheme="slate"] .md-typeset pre > code { + background: #242930; +} + +.md-clipboard { + color: rgba(232, 237, 245, 0.35); + transition: color 0.15s; +} +.md-clipboard:hover { + color: #37c4f0 !important; +} + +/* ─── Admonitions ──────────────────────────────────────────── */ +.md-typeset .admonition, +.md-typeset details { + border-radius: 8px; +} + +[data-md-color-scheme="slate"] .md-typeset .admonition, +[data-md-color-scheme="slate"] .md-typeset details { + background: #242930; +} + +/* note / info → blue (#37c4f0) */ +.md-typeset .admonition.note, +.md-typeset .admonition.info, +.md-typeset .admonition.abstract, +.md-typeset details.note, +.md-typeset details.info { + border-color: #37c4f0; +} +.md-typeset .admonition.note > .admonition-title, +.md-typeset .admonition.info > .admonition-title, +.md-typeset .admonition.abstract > .admonition-title, +.md-typeset details.note > summary, +.md-typeset details.info > summary { + background: rgba(55, 196, 240, 0.12); + color: #37c4f0; +} + +/* tip / hint / success → amber (#ecb32e) */ +.md-typeset .admonition.tip, +.md-typeset .admonition.hint, +.md-typeset .admonition.success, +.md-typeset details.tip, +.md-typeset details.hint { + border-color: #ecb32e; +} +.md-typeset .admonition.tip > .admonition-title, +.md-typeset .admonition.hint > .admonition-title, +.md-typeset .admonition.success > .admonition-title, +.md-typeset details.tip > summary, +.md-typeset details.hint > summary { + background: rgba(236, 179, 46, 0.12); + color: #ecb32e; +} + +/* warning / danger / failure → pink (#e01d5a) */ +.md-typeset .admonition.warning, +.md-typeset .admonition.danger, +.md-typeset .admonition.failure, +.md-typeset .admonition.bug, +.md-typeset details.warning, +.md-typeset details.danger { + border-color: #e01d5a; +} +.md-typeset .admonition.warning > .admonition-title, +.md-typeset .admonition.danger > .admonition-title, +.md-typeset .admonition.failure > .admonition-title, +.md-typeset .admonition.bug > .admonition-title, +.md-typeset details.warning > summary, +.md-typeset details.danger > summary { + background: rgba(224, 29, 90, 0.12); + color: #e01d5a; +} + +/* ─── Tables ───────────────────────────────────────────────── */ +[data-md-color-scheme="slate"] .md-typeset table:not([class]) { + background: #242930; +} + +[data-md-color-scheme="slate"] .md-typeset table:not([class]) th { + background: #2D3340; + color: #ecb32e; + border-bottom: 2px solid #ecb32e; +} + +[data-md-color-scheme="slate"] .md-typeset table:not([class]) td { + border-color: rgba(255, 255, 255, 0.07); +} + +.md-typeset table:not([class]) tr:hover { + background: rgba(55, 196, 240, 0.05); +} + +[data-md-color-scheme="default"] .md-typeset table:not([class]) th { + background: #1C2027; + color: #ecb32e; + border-bottom: 2px solid #ecb32e; +} + +/* ─── TOC ──────────────────────────────────────────────────── */ +.md-nav--secondary .md-nav__link--active { + color: #37c4f0 !important; +} + +[data-md-color-scheme="slate"] .md-nav--secondary .md-nav__link { + color: rgba(232, 237, 245, 0.60); +} + +/* ─── Footer ───────────────────────────────────────────────── */ +/* Three-color brand stripe on footer top edge */ +.md-footer { + border-top: 2px solid; + border-image: linear-gradient(90deg, #37c4f0 33%, #ecb32e 33% 66%, #e01d5a 66%) 1; +} + +[data-md-color-scheme="default"] .md-footer { + background: #1C2027; +} + +[data-md-color-scheme="default"] .md-footer-meta { + background: #13171C; +} + +/* ─── Back-to-top ──────────────────────────────────────────── */ +.md-top { + background: #242930; + color: #E8EDF5; + border-radius: 8px; + border: 1px solid rgba(55, 196, 240, 0.3); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + transition: background 0.2s, border-color 0.2s, box-shadow 0.2s, color 0.2s; +} + +.md-top:hover { + background: #37c4f0; + color: #13171C; + border-color: transparent; + box-shadow: 0 4px 24px rgba(55, 196, 240, 0.4); +} diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4693067..ce24918 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,6 +1,8 @@ -# SPDX-FileCopyrightText: 2026 AOT Technologies -# -# SPDX-License-Identifier: Apache-2.0 + # Troubleshooting Guide diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..ac13b5b --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,128 @@ +# SPDX-FileCopyrightText: 2026 AOT Technologies +# +# SPDX-License-Identifier: Apache-2.0 + +site_name: Node Wire +site_description: Enterprise Connectors for AI & Workflow Systems +site_url: https://aot-technologies.github.io/node-wire/ +repo_url: https://github.com/AOT-Technologies/node-wire +repo_name: AOT-Technologies/node-wire +docs_dir: docs + +theme: + name: material + palette: + - scheme: slate + primary: custom + accent: custom + toggle: + icon: material/weather-sunny + name: Switch to light mode + - scheme: default + primary: custom + accent: custom + toggle: + icon: material/weather-night + name: Switch to dark mode + font: + text: Inter + code: JetBrains Mono + features: + - navigation.tabs + - navigation.tabs.sticky + - navigation.sections + - navigation.expand + - navigation.path + - navigation.top + - navigation.footer + - toc.follow + - search.highlight + - search.suggest + - search.share + - content.code.copy + - content.code.annotate + - content.tabs.link + - header.autohide + icon: + repo: fontawesome/brands/github + logo: material/lan-connect + +extra_css: + - stylesheets/extra.css + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/AOT-Technologies/node-wire + name: Node Wire on GitHub + - icon: fontawesome/brands/linkedin + link: https://www.linkedin.com/company/aot-technologies + name: AOT Technologies on LinkedIn + generator: false + +plugins: + - search: + separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])' + +markdown_extensions: + - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - toc: + permalink: true + permalink_title: Anchor link to this section + toc_depth: 3 + - pymdownx.details + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.keys + - pymdownx.mark + - pymdownx.tasklist: + custom_checkbox: true + +nav: + - Home: index.md + - Getting Started: + - Installation: installation.md + - Configuration: configuration.md + - Architecture: architecture.md + - Connectors: + - Overview: connectors.md + - Google Drive: google_drive_connector.md + - Salesforce: salesforce_connector.md + - Slack: slack_connector.md + - MCP: + - Overview: mcp.md + - MCP Servers: mcp-servers.md + - Client OAuth: mcp-client-oauth.md + - Agent Scenario: toolhive_agent_scenario.md + - Development: + - Contributing: contributing.md + - Code Quality: code-quality-compliance.md + - Quality & Security Gates: quality-security-gates.md + - Versioning: versioning.md + - Packaging: packaging.md + - Public API: public-api.md + - Operations: + - Troubleshooting: troubleshooting.md + - Release & Rollback: release-rollback.md + - Local Images: local-packages-to-images.md + - Compliance: + - Privacy: privacy.md + - HIPAA Considerations: compliance/hipaa-considerations.md diff --git a/pyproject.toml b/pyproject.toml index d04a89e..77013f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ Homepage = "https://github.com/AOT-Technologies/node-wire" Repository = "https://github.com/AOT-Technologies/node-wire" Issues = "https://github.com/AOT-Technologies/node-wire/issues" -Documentation = "https://github.com/AOT-Technologies/node-wire/tree/main/docs" +Documentation = "https://aot-technologies.github.io/node-wire/" [project.scripts] node-wire = "bindings_entrypoint:main" @@ -96,6 +96,10 @@ requires = ["setuptools>=69.0.0", "wheel"] build-backend = "setuptools.build_meta" [dependency-groups] +docs = [ + "mkdocs>=1.6", + "mkdocs-material>=9.5", +] dev = [ "pytest>=9.0.2", "pytest-asyncio>=1.3.0", diff --git a/sample.env b/sample.env index 2552a59..389e5c9 100644 --- a/sample.env +++ b/sample.env @@ -37,7 +37,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 @@ -85,7 +85,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 @@ -147,6 +147,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"] diff --git a/uv.lock b/uv.lock index a493fad..d903399 100644 --- a/uv.lock +++ b/uv.lock @@ -281,6 +281,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, + { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, +] + [[package]] name = "bandit" version = "1.9.4" @@ -942,6 +964,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + [[package]] name = "google-ai-generativelanguage" version = "0.6.15" @@ -1788,6 +1822,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" }, ] +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -1908,6 +1951,84 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + [[package]] name = "msgpack" version = "1.2.1" @@ -2201,6 +2322,10 @@ dev = [ { name = "reuse" }, { name = "ruff" }, ] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, +] [package.metadata] requires-dist = [ @@ -2249,6 +2374,10 @@ dev = [ { name = "reuse", specifier = ">=5.0.0" }, { name = "ruff", specifier = ">=0.3.5" }, ] +docs = [ + { name = "mkdocs", specifier = ">=1.6" }, + { name = "mkdocs-material", specifier = ">=9.5" }, +] [[package]] name = "nodeenv" @@ -3033,6 +3162,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -3528,6 +3666,19 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pymdown-extensions" +version = "11.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -3755,6 +3906,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -4479,6 +4642,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "watchfiles" version = "1.2.0"