Skip to content

Use a connector

Call a connector that already ships — in-process from Python or over REST — and control which tenants may use it through the runtime config store. Every connector works the same way; only the payload and the config / auth blocks change.

Actions, auth, secrets and setup guides for every shipped connector are in the connector catalog.

Writing your own connector? See Build a connector.

The following example uses http_generic for two tenants.

In-process (Python)

The following script entitles two tenants, runs an action, and shows the fail-closed cases:

import asyncio
import os

os.environ["NW_ALLOWED_CONNECTORS"] = "http_generic"
os.environ["NW_MCP_SCOPE_POLICY_DEFAULT"] = "allow"  # local only — see the Scope policy note after the output
os.environ["NW_HTTP_GENERIC_ALLOWED_HOSTS"] = "httpbin.org"

from bindings.factory import ConnectorFactory
from node_wire_runtime.config_store import ConfigNotFoundError
from node_wire_runtime.connector_registry import auto_register


async def main() -> None:
    auto_register()
    factory = ConnectorFactory()
    factory.load()  # reads config/connectors.yaml into the __default__ tenant
    store = factory.store

    # 1. Entitle two tenants. http_generic takes the whole request in the payload,
    #    so its config block is empty — the entry itself is what grants access.
    store.create("acme", "http_generic", {"name": "default", "config": {}})
    store.create("globex", "http_generic", {"name": "default", "config": {}})

    # 2. Resolve a tenant-pinned instance and run an action.
    acme = await factory.get("http_generic", tenant_id="acme")
    response = await acme.run(
        {"action": "request", "method": "GET", "url": "https://httpbin.org/get",
         "params": {"q": "node-wire"}}
    )
    print("acme:", response.success, response.data["status_code"])

    # 3. A tenant with no config is not entitled — the factory fails closed.
    try:
        await factory.get("http_generic", tenant_id="initech")
    except ConfigNotFoundError as exc:
        print("initech:", exc)

    # 4. An instance pinned to acme refuses to run for another tenant.
    mismatch = await acme.run(
        {"action": "request", "method": "GET", "url": "https://httpbin.org/get"},
        tenant_id="globex",
    )
    print("mismatch:", mismatch.success, mismatch.error_code)

    # 5. Revoke access at runtime: deleting the config drops the cached instance.
    store.delete("acme", "http_generic", "default")
    try:
        await factory.get("http_generic", tenant_id="acme")
    except ConfigNotFoundError as exc:
        print("acme after delete:", exc)


asyncio.run(main())

The output is similar to the following:

acme: True 200
initech: no config for tenant 'initech' / connector 'http_generic'
mismatch: False TENANT_MISMATCH
acme after delete: no config for tenant 'acme' / connector 'http_generic'

The following recording shows the script running, saved as tenants_demo.py, with log output hidden:

Terminal running the tenants_demo.py script: acme succeeds with status 200, initech has no config, the pinned instance refuses globex with TENANT_MISMATCH, and acme is refused after its config is deleted.

What each step relies on:

Step Behaviour
factory.load() Reads config/connectors.yaml (or NW_CONFIG_PATH) once and copies each enabled connector into the store as the __default__ tenant. Tenants you add later live only in the store.
store.create(tenant, connector, doc) Adds a named config. The first config for a (tenant, connector) pair becomes its default. A config existing is what entitles the tenant — there is no separate allow list.
factory.get(connector, tenant_id=…) Resolves the tenant's config (the default, or config_name=…), builds the instance with that config's secrets and auth, and caches it per (tenant, connector, config). Omitting tenant_id means __default__, never "the current request".
run(payload) Validation → policy → retries → error mapping. The instance is pinned to its tenant, so leave tenant_id off; a different one returns TENANT_MISMATCH without calling out.
store.update / store.delete Take effect on the next get() — the cached instance for that config is dropped at once.

The store is in-process memory. Anything you add with store.create() is gone on restart unless you persist it. The REST config API writes to NW_TENANTS_PATH for you.

Scope policy. NW_MCP_SCOPE_POLICY_DEFAULT=allow is only for local scripts. In a real host, pass scopes=("mcp:http_generic.request",) (or principal) to run(). See Security.

Connectors that read their config. For connectors such as stripe or fhir_epic, the same calls apply. Put per-tenant settings in the doc's config block and the credentials setup in its auth block (see Auth blocks). Per-tenant secret naming is in Tenancy — Tenant secrets. A tenant can hold several named configs (for example sandbox and live); select one with factory.get(..., config_name="live").

Over REST

The REST binding wraps the same store. Start the API with multi-tenancy on (NW_REST_AUTH_DISABLED is for local use only):

NW_MULTITENANCY_ENABLED=true NW_REST_AUTH_DISABLED=true \
NW_ALLOWED_CONNECTORS=http_generic NW_MCP_SCOPE_POLICY_DEFAULT=allow \
MODE=API uv run node-wire

The tenant always comes from the X-Tenant-ID header (or the caller's JWT tenant claim), never from the URL.

To entitle acme, create a config for it. This writes to NW_TENANTS_PATH (default config/tenants.yaml):

curl -X POST localhost:8000/v1/connectors/http_generic/configs \
    -H 'X-Tenant-ID: acme' -H 'Content-Type: application/json' \
    -d '{"name": "default", "config": {}}'

The response is 201 with {"name": "default", "default": true}. Next, call the connector as acme:

curl -X POST localhost:8000/connectors/http_generic/request \
    -H 'X-Tenant-ID: acme' -H 'Content-Type: application/json' \
    -d '{"method": "GET", "url": "https://httpbin.org/get"}'

The response is 200 with "success": true and the upstream status_code in data. A tenant with no config is refused:

curl -X POST localhost:8000/connectors/http_generic/request \
    -H 'X-Tenant-ID: initech' -H 'Content-Type: application/json' \
    -d '{"method": "GET", "url": "https://httpbin.org/get"}'

The response is 403.

The following recording shows the same calls against a local server, plus a health check:

Terminal running curl against the REST API: health returns ok, creating acme's config returns default true, the request as acme returns success true with status 200, and the request as initech returns 403.

Leaving out X-Tenant-ID returns 400 while multi-tenancy is on. Add "config_name": "<name>" to the request body to pick a named config. The rest of the config API is GET/PUT/DELETE /v1/connectors/{cid}/configs/{name} and PUT …/{name}/default. For MCP, the same store sits behind the nw_list_tenants / nw_select_tenant / nw_select_config tools. See Tenancy — MCP tools.


Doc When to read it
Connector reference ConnectorFactory / registry APIs, connectors.yaml schema, connector catalog
Connectors on REST, MCP and gRPC How actions map to REST routes, MCP tools and gRPC methods
Tenancy Tenant resolution, entitlement and tenant secrets
Build a connector Writing a new connector by hand