Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,6 @@ jobs:
;;
esac
echo "${secret_env_key}=$MOCK_API_TOKEN" >> "$OVERRIDES_FILE"
if [ "$service_key" = "CLOUDFLARE" ]; then
echo "CLOUDFLARE_ACCOUNT_ID=cf_account_mock_123" >> "$OVERRIDES_FILE"
fi

mock_summary_line="- ${service}: ${mock_url} (\`${mock_worker_name}\`)"
mock_comment_summary_line="- ${service}: [${mock_url}/__mocks](${mock_url}/__mocks?token=${MOCK_API_TOKEN}) (\`${mock_worker_name}\`)"
Expand Down Expand Up @@ -573,16 +570,14 @@ jobs:

- name: ℹ️ Skip GitHub preview environment delete
if: >-
always() &&
env.PREVIEW_ENVIRONMENT_GITHUB_TOKEN == ''
always() && env.PREVIEW_ENVIRONMENT_GITHUB_TOKEN == ''
run: >
echo "Skipping GitHub preview environment delete;
PREVIEW_ENVIRONMENT_GITHUB_TOKEN is not configured."

- name: 🏷️ Delete GitHub preview environment
if: >-
always() &&
env.PREVIEW_ENVIRONMENT_GITHUB_TOKEN != ''
always() && env.PREVIEW_ENVIRONMENT_GITHUB_TOKEN != ''
uses: actions/github-script@v8.0.0
env:
EVENT_NAME: ${{ github.event_name }}
Expand Down
6 changes: 6 additions & 0 deletions docs/contributing/adding-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ To merge extra domains later (e.g. plugins), the seam is:
`buildCapabilityRegistry([...builtinDomains, ...extraDomains])` with real
`Capability` handlers (typical Workers model: snapshot at deploy).

**Remote connectors:** at runtime, `getCapabilityRegistryForContext` also merges
domains synthesized from outbound WebSocket connectors (see
[`architecture/remote-connectors.md`](./architecture/remote-connectors.md)).
Those domains are driven by MCP **`remoteConnectors`** / **`homeConnectorId`**
rather than by editing `builtinDomains` in-repo.

`defineCapability()` in
`packages/worker/src/mcp/capabilities/define-capability.ts` is still what
normalizes Zod → JSON Schema and wraps handlers with logging; domain helpers
Expand Down
4 changes: 4 additions & 0 deletions docs/contributing/architecture/home-connector.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
The local `packages/home-connector` process is the bridge between Kody's
Cloudflare Worker and devices that are only reachable on the local network.

It is a **remote connector** with `kind: home`. The wire protocol, URL shapes,
and secret configuration for **any** outbound connector are documented in
[Remote connectors](./remote-connectors.md).

## Current adapters

The connector currently exposes three local-device families:
Expand Down
2 changes: 2 additions & 0 deletions docs/contributing/architecture/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ is trying to become.
Objects.
- [Home Connector](./home-connector.md): local device adapters, Samsung token
persistence, and connector-specific discovery/runtime behavior.
- [Remote connectors](./remote-connectors.md): generic outbound WebSocket
protocol, URLs, secrets, and MCP caller context for any `kind` / instance.
- [Local Agent Bridge Direction](./local-agent-bridge.md): proposed direction
for securely reaching local-network systems through an outbound agent
connection.
Expand Down
140 changes: 140 additions & 0 deletions docs/contributing/architecture/remote-connectors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Remote connectors

A **remote connector** is any service that opens an **outbound WebSocket** to
the Kody Worker and exposes **MCP-style tools** (`tools/list`, `tools/call`)
over that socket. The Worker’s `HomeConnectorSession` Durable Object (binding
name `HOME_CONNECTOR_SESSION`) holds one live session per **session key** and
proxies HTTP `fetch` from Worker code to JSON-RPC on the socket.

The first shipped connector is **`packages/home-connector`** (`kind: home`).
Additional kinds use the same protocol and routing pattern described below.

## URLs and session keys

- **Home (legacy URL, still supported):**
`wss://<worker-origin>/home/connectors/<instanceId>`
Session key = `<instanceId>` (unchanged from historical behavior).

- **Generic:**
`wss://<worker-origin>/connectors/<kind>/<instanceId>`
Session key = `<kind>:<instanceId>` when `kind` is not `home` (lowercase
compared after trim).

The Worker sets header **`X-Kody-Connector-Session-Key`** on requests forwarded
into the Durable Object. The connector’s **`connector.hello`** must declare a
**`connectorKind`** and **`connectorId`** (instance id) that match the session
key implied by the WebSocket URL; otherwise the session closes with a mismatch
error.

## WebSocket message protocol

All messages are **JSON objects** with a **`type`** field.

### Client → Worker (connector)

1. **`connector.hello`** (required first logical message after open)
- **`type`:** `"connector.hello"`
- **`connectorId`:** string — instance id (for example `default`,
`living-room`).
- **`sharedSecret`:** string — must match Worker configuration (see
[Environment variables](../environment-variables.md#remote-connector-secrets)).
- **`connectorKind`:** string (optional but **required for generic
`/connectors/...` URLs**). Omit or set to `"home"` for the home connector.
Lowercase values are normalized.

2. **`connector.heartbeat`**
- **`type`:** `"connector.heartbeat"`
- Keeps `lastSeenAt` fresh in the session DO.

3. **`connector.jsonrpc`**
- **`type`:** `"connector.jsonrpc"`
- **`message`:** a single JSON-RPC 2.0 object (request or response).

### Worker → Client (connector process)

- **`server.ping`** — Worker may send this; connector should stay connected.
- **`server.ack`** — Successful hello; includes **`connectorId`** echo.
- **`server.error`** — Human-readable **`message`**; connection may close.

## JSON-RPC on the socket

The Worker sends MCP-style requests over the WebSocket wrapped in
`connector.jsonrpc`:

- **`tools/list`** — Return `{ tools: [...] }` where each tool has at least
**`name`**, and typically **`description`**, **`inputSchema`**, optional
**`title`**, **`outputSchema`**, **`annotations`** (same shape as MCP tools).

- **`tools/call`** — Params: `{ name: string, arguments?: object }`. Return a
normal MCP **`CallToolResult`**-compatible payload (content, structured
content, `isError`, etc.).

If the Worker forwards **`notifications/tools/list_changed`**, the connector
should re-list tools when it supports dynamic registration. Separately, the
reference implementation in `packages/home-connector` **proactively** sends
`notifications/tools/list_changed` **to** the Worker right after
**`server.ack`** so the session performs an initial tool snapshot refresh.

## HTTP helper endpoints (same origin)

The same Durable Object serves snapshot and RPC helpers on paths **under the
connector URL** (for example `/snapshot`, `/rpc/tools-list`). External connector
authors normally only need the **WebSocket**; Worker-internal code uses these
for bridging.

## Worker-side attachment (MCP caller context)

For capabilities to be synthesized from a connector, the MCP session must list
that connector:

- **`remoteConnectors`:** optional array of `{ kind, instanceId }`. When present
(including empty), it fully defines the set of remote connectors for that
session.
- **`homeConnectorId`:** when `remoteConnectors` is omitted, a non-null value
maps to `{ kind: "home", instanceId: homeConnectorId }`.

Source: `packages/shared/src/chat.ts`,
`packages/shared/src/remote-connectors.ts`.

## Capability naming (search / execute)

- Single **`home`** connector with instance id **`default`:** synthesized
capabilities stay on the builtin **`home`** domain with names like
**`home_<tool>`** (legacy stability).

- Any other combination (multiple home instances, non-`home` kinds): the Worker
uses distinct **domain ids** (for example `remote:<kind>:<instance>`) and
**prefixed capability names** so nothing collides in `search` / `execute`.

## Compatibility checklist

1. **Outbound WebSocket** to the correct path for your **`kind`** and
**`instanceId`**.
2. **Hello first** with matching **`connectorKind`** + **`connectorId`** and a
**valid `sharedSecret`** for that `kind:instanceId` pair.
3. Implement **`tools/list`** and **`tools/call`** on the socket via
**`connector.jsonrpc`** envelopes.
4. **Heartbeats** if the service stays connected for a long time.
5. **Operator config:** Worker `REMOTE_CONNECTOR_SECRETS` and/or
`HOME_CONNECTOR_SHARED_SECRET` for `home`; MCP clients must pass
**`remoteConnectors`** / **`homeConnectorId`** so the registry merges your
domain.

## Reference implementation

- Protocol types and parsing: `packages/worker/src/home/types.ts`,
`packages/worker/src/home/utils.ts`
- Session Durable Object: `packages/worker/src/home/session.ts`
- Ingress and session key:
`packages/worker/src/remote-connector/connector-session-key.ts`
- Home connector WebSocket client:
`packages/home-connector/src/transport/worker-connector.ts`

## Related docs

- [Home Connector](./home-connector.md) — the shipped `home` implementation
(Roku, Lutron, Samsung TV, Sonos).
- [Request lifecycle](./request-lifecycle.md) — where connector routes sit in
the Worker.
- [Environment variables](../environment-variables.md#remote-connector-secrets)
— secrets and optional JSON map.
21 changes: 15 additions & 6 deletions docs/contributing/architecture/request-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,16 @@ Requests are handled in this order:
- `/.well-known/oauth-protected-resource/mcp`
5. MCP endpoint:
- `/mcp` (requires OAuth bearer token)
6. Home connector session endpoint:
- `/home/connectors/:connectorId...` (internal-only Worker route that proxies
websocket upgrades and JSON-RPC helper requests to the
`HomeConnectorSession` Durable Object)
6. Remote connector session endpoints (internal-only Worker routes that proxy
WebSocket upgrades and JSON-RPC helper requests to the `HomeConnectorSession`
Durable Object):
- `/home/connectors/:connectorId...` — legacy **`home`** connector URL
(session key equals `connectorId`)
- `/connectors/:kind/:instanceId...` — generic **`kind`** + instance (session
key `kind:instanceId` when `kind` is not `home`)

See [Remote connectors](./remote-connectors.md).

7. Internal chat agent endpoint:
- `/chat-agent/:threadId...` (requires the app session cookie and routes to
the per-thread chat Agent instance)
Expand Down Expand Up @@ -104,8 +110,11 @@ The home automation flow adds two more Durable Objects:
home connector tools when needed.

The chat agent still attaches to the main compact MCP server (`kody`), but it
also attaches to `home` and the runtime capability registry synthesizes a `home`
domain for `search` / `execute` from the connected home connector tool surface.
also attaches to `home` and the runtime capability registry **merges**
synthesized domains from **remote connectors** listed in MCP caller context
(`remoteConnectors` or legacy `homeConnectorId`). A single **`home`** +
**`default`** instance keeps the builtin `home` domain name; other combinations
use distinct domain ids. See [Remote connectors](./remote-connectors.md).

Shared options are built in `packages/worker/src/sentry-options.ts`: **release**
comes from `APP_COMMIT_SHA` when set (deploy workflows pass it as a var), and
Expand Down
20 changes: 20 additions & 0 deletions docs/contributing/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,26 @@ Optional Worker secret/var (see `packages/worker/src/env-schema.ts` and
`packages/home-connector` service when it opens the outbound WebSocket session
to the worker. When unset, the worker rejects home connector registration and
the internal home MCP bridge cannot route `home` capabilities.

### Remote connector secrets (Worker)

See `packages/worker/src/env-schema.ts` and
`packages/worker/src/remote-connector/resolve-remote-connector-secret.ts`.

- `REMOTE_CONNECTOR_SECRETS` — optional Worker **secret** (JSON string) whose
value is a JSON object mapping **`"kind:instanceId"`** keys (trimmed, kind
lowercased) to **shared secret strings** for **`connector.hello`**. When a key
is present, it overrides per-connector lookup before any kind-specific
fallback. At Worker boot, invalid JSON or malformed keys fail env validation
with a clear error. At runtime, if the value is a plain string in a test
harness, malformed JSON is logged and ignored for map lookup only.
- For **`kind: home`**, if a key is missing in the map, the worker still falls
back to **`HOME_CONNECTOR_SHARED_SECRET`**. Non-`home` kinds have **no**
legacy fallback; they must appear in the map (or hello is rejected).

Authoring guide for outbound WebSocket services:
[`architecture/remote-connectors.md`](./architecture/remote-connectors.md).

- `HOME_CONNECTOR_*` — when you start the full local stack with `npm run dev`,
any `HOME_CONNECTOR_`-prefixed variable is forwarded to the child connector
process with the prefix removed. For example, `HOME_CONNECTOR_MOCKS=false`
Expand Down
7 changes: 5 additions & 2 deletions docs/use/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,8 @@ not signed in, user-scoped results are empty.

## Home automation

If home-related tools appear missing, check home connector status with
**`meta_get_home_connector_status`** when that capability is available.
If home-related tools appear missing, check connector status with
**`meta_list_remote_connector_status`** (all attached remote connectors) or
**`meta_get_home_connector_status`** (first **`home`** connector only) when
those capabilities are available. For protocol and URL requirements, see
[Remote connectors](../contributing/architecture/remote-connectors.md).
1 change: 1 addition & 0 deletions packages/home-connector/src/transport/worker-connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ export function createWorkerConnector(input: {
)
const hello: HomeConnectorHelloMessage = {
type: 'connector.hello',
connectorKind: 'home',
connectorId: input.config.homeConnectorId,
sharedSecret: input.config.sharedSecret!,
}
Expand Down
31 changes: 31 additions & 0 deletions packages/shared/src/chat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import {
array,
createSchema,
fail,
nullable,
number,
object,
Expand All @@ -7,6 +10,28 @@ import {
type InferOutput,
} from 'remix/data-schema'

const remoteConnectorKindFieldSchema = createSchema<unknown, string>(
(value, context) => {
if (typeof value !== 'string') return fail('Expected string', context.path)
const trimmed = value.trim().toLowerCase()
if (!trimmed) {
return fail('remote connector kind must not be empty', context.path)
}
return { value: trimmed }
},
)

const remoteConnectorInstanceIdFieldSchema = createSchema<unknown, string>(
(value, context) => {
if (typeof value !== 'string') return fail('Expected string', context.path)
const trimmed = value.trim()
if (!trimmed) {
return fail('remote connector instanceId must not be empty', context.path)
}
return { value: trimmed }
},
)

export const aiModeValues = ['mock', 'remote'] as const
export type AiMode = (typeof aiModeValues)[number]

Expand All @@ -21,10 +46,16 @@ export const mcpStorageContextSchema = object({
appId: optional(nullable(string())),
})

const remoteConnectorRefSchema = object({
kind: remoteConnectorKindFieldSchema,
instanceId: remoteConnectorInstanceIdFieldSchema,
})

export const mcpCallerContextSchema = object({
baseUrl: string(),
user: optional(nullable(mcpUserContextSchema)),
homeConnectorId: optional(nullable(string())),
remoteConnectors: optional(nullable(array(remoteConnectorRefSchema))),
storageContext: optional(nullable(mcpStorageContextSchema)),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

Expand Down
43 changes: 43 additions & 0 deletions packages/shared/src/remote-connectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { type InferOutput } from 'remix/data-schema'
import { type mcpCallerContextSchema } from './chat.ts'

type McpCallerContext = InferOutput<typeof mcpCallerContextSchema>

export type RemoteConnectorRef = {
kind: string
instanceId: string
}

function normalizeKind(kind: string): string {
return kind.trim().toLowerCase()
}

function normalizeInstanceId(instanceId: string): string {
return instanceId.trim()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Effective remote connectors for MCP execution, in order.
* When `remoteConnectors` is set (including empty), it wins.
* Otherwise `homeConnectorId` maps to `{ kind: "home", instanceId }`.
*/
export function normalizeRemoteConnectorRefs(
context: Pick<McpCallerContext, 'homeConnectorId' | 'remoteConnectors'>,
): Array<RemoteConnectorRef> {
if (
context.remoteConnectors !== undefined &&
context.remoteConnectors !== null
) {
return context.remoteConnectors
.map((ref) => ({
kind: normalizeKind(ref.kind),
instanceId: normalizeInstanceId(ref.instanceId),
}))
.filter((ref) => ref.kind.length > 0 && ref.instanceId.length > 0)
}
const hid = context.homeConnectorId?.trim()
if (!hid) {
return []
}
return [{ kind: 'home', instanceId: hid }]
}
Loading
Loading