Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 2 additions & 4 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -573,16 +573,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
141 changes: 141 additions & 0 deletions docs/contributing/architecture/remote-connectors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# 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.).

The connector should handle **`notifications/tools/list_changed`** from the
Worker by re-listing tools if it implements dynamic registration; the reference
implementation in `packages/home-connector` responds by sending a
`notifications/tools/list_changed` **to** the Worker after `server.ack` so the
session refreshes its tool snapshot.

## 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.
20 changes: 14 additions & 6 deletions docs/contributing/architecture/request-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,15 @@ 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 +109,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
18 changes: 18 additions & 0 deletions docs/contributing/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,24 @@ 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

Optional Worker var (see `packages/worker/src/env-schema.ts` and
`packages/worker/src/remote-connector/resolve-remote-connector-secret.ts`):

- `REMOTE_CONNECTOR_SECRETS` — JSON object mapping **`"kind:instanceId"`** (both
trimmed, kind lowercased) to a **shared secret string** used in
**`connector.hello`**. When set, these entries override per-connector secrets
before any kind-specific fallback. Invalid JSON is ignored and falls back to
legacy behavior.
- For **`kind: home`**, if a key is missing in `REMOTE_CONNECTOR_SECRETS`, the
worker still falls back to **`HOME_CONNECTOR_SHARED_SECRET`**. Non-`home`
kinds have **no** legacy fallback; they must appear in
`REMOTE_CONNECTOR_SECRETS` (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
8 changes: 8 additions & 0 deletions packages/shared/src/chat.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {
array,
nullable,
number,
object,
optional,
string,
type InferOutput,
} from 'remix/data-schema'
import { minLength } from 'remix/data-schema/checks'

export const aiModeValues = ['mock', 'remote'] as const
export type AiMode = (typeof aiModeValues)[number]
Expand All @@ -21,10 +23,16 @@ export const mcpStorageContextSchema = object({
appId: optional(nullable(string())),
})

const remoteConnectorRefSchema = object({
kind: string().pipe(minLength(1)),
instanceId: string().pipe(minLength(1)),
})

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.
Resolved
})

Expand Down
41 changes: 41 additions & 0 deletions packages/shared/src/remote-connectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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.
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),
}))
}
const hid = context.homeConnectorId?.trim()
if (!hid) {
return []
}
return [{ kind: 'home', instanceId: hid }]
}
6 changes: 3 additions & 3 deletions packages/worker/client/mcp-apps/kody-ui-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -833,8 +833,7 @@ async function executeGeneratedUiShellScript(
return
}
const shouldAwaitLoad =
scriptDescriptor.executionMode === 'module' ||
scriptDescriptor.src != null
scriptDescriptor.executionMode === 'module' || scriptDescriptor.src != null
if (!scriptDescriptor.src) {
script.textContent = scriptDescriptor.textContent
if (!shouldAwaitLoad) {
Expand Down Expand Up @@ -1147,7 +1146,8 @@ async function initializeShellHostDocument() {
}
}

const isStaleRender = (renderId: number) => renderId !== latestScheduledRenderId
const isStaleRender = (renderId: number) =>
renderId !== latestScheduledRenderId

async function renderEnvelope(
envelope: RenderEnvelope | null,
Expand Down
1 change: 1 addition & 0 deletions packages/worker/src/env-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export const EnvSchema = object({
CLOUDFLARE_API_BASE_URL: optionalUrlStringSchema,
CAPABILITY_REINDEX_SECRET: optionalNonEmptyStringSchema,
HOME_CONNECTOR_SHARED_SECRET: optionalNonEmptyStringSchema,
REMOTE_CONNECTOR_SECRETS: optionalNonEmptyStringSchema,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
})

export type AppEnv = InferOutput<typeof EnvSchema>
Loading
Loading