Skip to content
Pilotoo
v0.1

Custom gateway

A custom gateway is a process in an image you build that publishes many utilities (command-line tools, local HTTP services, stdio MCP servers) as one MCP server. An administrator registers it in Connect, and it becomes an ordinary provider named gw-<slug>: it is enabled per tenant, its tools appear in connections_search, users and organisations connect accounts to it, and every call is checked by the sharing policy and written to the audit log.

Connect reaches a gateway in one of two ways:

Transport Who starts the connection Use when
url Connect calls the gateway’s public URL The gateway runs somewhere Connect can reach, such as a server.
worker The gateway polls Connect The gateway runs on a machine behind NAT, such as your laptop, and has no public URL.

The contract is packages/chat-protocol/src/connect-gateway.ts. The gateway process that implements it is apps/connect-gateway.

Call the admin API with an administrator session or token. The slug becomes the provider id gw-<slug>; it takes a-z, 0-9 and -, starts with a letter, and is at most 31 characters.

Terminal window
curl -X POST "$CONNECT_URL/admin/connections/gateways" \
-H "Authorization: Bearer $CONNECT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "my-tools",
"name": "My tools",
"transport": "url",
"url": "https://gateway.example.com",
"token": "a-long-random-token-of-at-least-16-characters"
}'

token is the bearer token the gateway expects (GATEWAY_TOKEN on the gateway). Connect stores it sealed in the vault and never returns it. url must be https, except on localhost and 127.0.0.1.

Connect reads the gateway’s manifest and tools right away. If the gateway is not reachable yet, the registration still succeeds with no tools; fix the gateway and call refresh:

Terminal window
curl -X POST "$CONNECT_URL/admin/connections/gateways/gw-my-tools/refresh" \
-H "Authorization: Bearer $CONNECT_TOKEN"

Every request to the gateway carries Authorization: Bearer <token>. Connect calls GET <url>/manifest and POST <url>/mcp (streamable HTTP, stateless).

Terminal window
curl -X POST "$CONNECT_URL/admin/connections/gateways" \
-H "Authorization: Bearer $CONNECT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "slug": "home", "name": "Home tools", "transport": "worker" }'

The answer holds the worker token once:

{ "gateway": { "provider": "gw-home", "transport": "worker", "toolCount": 0 }, "workerToken": "gwk_..." }

Run the gateway image with the token:

Terminal window
docker run --rm \
-e GATEWAY_MODE=worker \
-e CONNECT_URL=https://connect.example.com \
-e CONNECT_WORKER_TOKEN=gwk_... \
my-gateway-image

The worker announces its manifest and tools with every poll, so the provider’s tool list follows the image without a refresh.

The manifest lists the environment variables each utility needs. Connect turns them into the fields of the provider’s credential:

Manifest variable Field in the connect form
secret: true (the default) A masked field.
secret: false A plain text field.
provided: true Optional: the gateway has a value of its own, and a value entered here replaces it for that account.
any other Required.

Each tool becomes gw-<slug>__<tool>. A tool whose MCP annotations say readOnlyHint: true has read access; every other tool has write access. Its JSON Schema is shown to the model as the gateway published it, and the gateway validates the arguments.

The administrator then enables the provider under Admin → Apps, and users or the organisation connect an account, as for any provider with several fields.

The credential values are sent with each call, never stored by the gateway: in the x-connect-env header as base64url JSON for a url gateway, and in the env of the job for a worker. They are not written to logs and are scrubbed from tool errors when the field is secret.

All routes need a role in CONNECTIONS_ADMIN_ROLES and act on the caller’s tenant. Every change is written to the audit log as config_changed.

Route Purpose
GET /admin/connections/gateways { gateways }: each with provider, transport, url, hasToken, manifest, toolCount, lastSeenAt.
POST /admin/connections/gateways Register. Body slug, name, description?, transport, and for url also url and token. Answers 201 with { gateway, workerToken? }. 409 when the slug is taken.
PUT /admin/connections/gateways/:provider Change name, description, url or token. The slug and the transport cannot change. An absent token keeps the stored one.
DELETE /admin/connections/gateways/:provider Remove. 204, or 409 while the provider is enabled.
POST /admin/connections/gateways/:provider/refresh url gateways: read the manifest and tools again.
POST /admin/connections/gateways/:provider/worker-token worker gateways: issue a new token, shown once.

A gateway belongs to the tenant that registered it. No other tenant sees its provider or can run its tools.

A worker authenticates with Authorization: Bearer <worker token> and nothing else: these routes take no user session. Each token belongs to one gateway of one tenant, and any other token is answered 401.

POST /gateway/worker/poll

request body
{
"manifest": { "name": "my-tools", "version": "1", "env": [{ "name": "TELEGRAM_API_ID", "secret": false, "provided": false }] },
"tools": [{ "name": "telegram_fetch", "description": "Read recent messages", "inputSchema": { "type": "object" }, "annotations": { "readOnlyHint": true } }],
"waitMs": 25000
}

Connect stores the manifest and tools, then holds the request for up to waitMs (at most 30 000, default 25 000). It answers:

  • 200 with one job when a tool call arrives.
  • 204 when none came.
200 response
{
"id": "5d0f...",
"tool": "telegram_fetch",
"arguments": { "chat": "general" },
"env": { "TELEGRAM_API_ID": "123456" },
"deadline": "2026-06-01T12:00:27.000Z"
}

Poll again straight away after either answer. Poll with several requests at once to run jobs in parallel; each job goes to one request.

POST /gateway/worker/jobs/:id/result

request body
{ "content": [{ "type": "text", "text": "{\"messages\":[]}" }], "isError": false }

This is the MCP tools/call result. Connect answers 204. It answers 404 when the job is unknown, belongs to another gateway, or is past its deadline: a result sent late is refused.

Situation The tool call fails with
No worker polled in the last 60 seconds The gateway worker is offline, immediately.
A job is not taken or not answered before its deadline (just under CONNECTIONS_TOOL_TIMEOUT_MS) The gateway worker did not answer in time.

Connect keeps the queue of a worker gateway in the memory of one process, so run one Connect instance for it.