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
47 changes: 23 additions & 24 deletions docs/contributing/architecture/remote-connectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,15 @@ calls to JSON-RPC on the socket.

## URLs and session keys

`wss://<worker-origin>/@<username>/connectors/<kind>/<instanceId>`
`wss://<worker-origin>/@<username>/connectors/<instanceId>`

Session key = JSON tuple `[userId, kind, instanceId]` where `kind` is lowercase
after trim.
Session key = JSON tuple `[userId, instanceId]` where `instanceId` is the
user-chosen connector name, lowercase 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.
**`connectorId`** (instance id) that matches the session key implied by the
WebSocket URL; otherwise the session closes with a mismatch error.

## WebSocket message protocol

Expand All @@ -31,8 +30,9 @@ All messages are **JSON objects** with a **`type`** field.
(`^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$`, for example `home` or
`living-room`). Connector names are globally unique per user.
- **`sharedSecret`:** string — must match an enabled shared secret saved for
the connector ref in D1.
- **`connectorKind`:** non-empty string. Lowercase values are normalized.
the connector in D1.
- **`connectorKind`:** ignored by Kody (may still be sent for compatibility
with connector-kit clients).

2. **`connector.heartbeat`**
- **`type`:** `"connector.heartbeat"`
Expand Down Expand Up @@ -84,23 +84,22 @@ External connector authors only need the **WebSocket**.
For capabilities to be synthesized from a connector, the MCP session must list
that connector:

- **`remoteConnectors`:** optional array of `{ kind, instanceId }`, where
`instanceId` is the explicit connector name. When present (including empty),
it fully defines the set of remote connectors for that session.
- **`remoteConnectors`:** optional array of `{ instanceId }`, where `instanceId`
is the explicit connector name. When present (including empty), it fully
defines the set of remote connectors for that session.

Regular authenticated MCP and chat sessions load this array from the user's
saved remote connector settings. Operators can manage those settings at
`/account/remote-connectors`:

- **`kind`** is protocol metadata, and **`instanceId`** is the user-chosen
connector name. Names are unique per user across all kinds because they key
`kody.remote[name]`.
- **`instanceId`** is the user-chosen connector name. Names are unique per user
and key `kody.remote[name]`.
- **`enabled`** controls whether the saved shared secret can authenticate
`connector.hello` for that ref.
- **`attached`** controls whether the ref is included in normal Kody MCP/chat
caller context.
`connector.hello` for that connector.
- **`attached`** controls whether the connector is included in normal Kody
MCP/chat caller context.
- **`sharedSecret`** is encrypted in D1 and authenticates only the user-scoped
connector URL for the saved ref.
connector URL for the saved name.

Source: `packages/shared/src/chat.ts`,
`packages/shared/src/remote-connectors.ts`, and
Expand All @@ -121,15 +120,15 @@ Source: `packages/shared/src/chat.ts`,
## Connector checklist

1. **Outbound WebSocket** to your connector URL:
`wss://<worker-origin>/@<username>/connectors/<kind>/<connectorName>`.
2. **Hello first** with matching **`connectorKind`** + **`connectorId`** and a
**valid `sharedSecret`** for that `kind:instanceId` pair.
`wss://<worker-origin>/@<username>/connectors/<connectorName>`.
2. **Hello first** with matching **`connectorId`** and a **valid
`sharedSecret`** for that connector name.
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:** save the connector ref and shared secret from
`/account/remote-connectors`; enabled + attached refs are loaded into normal
Kody sessions so the registry merges your domain.
5. **Operator config:** save the connector and shared secret from
`/account/remote-connectors`; enabled + attached connectors are loaded into
normal Kody sessions so the registry merges your domain.

## Reference implementation

Expand Down
12 changes: 0 additions & 12 deletions packages/shared/src/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,6 @@ 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)
Expand Down Expand Up @@ -59,7 +48,6 @@ export const mcpRepoContextSchema = object({
})

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

Expand Down
13 changes: 2 additions & 11 deletions packages/shared/src/remote-connectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,12 @@ import { buildUsernamePathPrefix } from './public-urls.ts'
type McpCallerContext = InferOutput<typeof mcpCallerContextSchema>

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

export const remoteConnectorNamePattern =
/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/

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

export function normalizeRemoteConnectorInstanceId(instanceId: string): string {
return instanceId.trim().toLowerCase()
}
Expand All @@ -28,20 +23,17 @@ export function isValidRemoteConnectorName(instanceId: string): boolean {

export function userScopedConnectorIngressPath(input: {
username: string
kind: string
instanceId: string
}) {
const kind = encodeURIComponent(normalizeRemoteConnectorKind(input.kind))
const instanceId = encodeURIComponent(
normalizeRemoteConnectorInstanceId(input.instanceId),
)
return `${buildUsernamePathPrefix(input.username)}/connectors/${kind}/${instanceId}`
return `${buildUsernamePathPrefix(input.username)}/connectors/${instanceId}`
}

export function userScopedConnectorWebSocketUrl(input: {
origin: string
username: string
kind: string
instanceId: string
}) {
const origin = input.origin.trim().replace(/\/+$/, '')
Expand All @@ -53,8 +45,7 @@ export function normalizeRemoteConnectorRefs(
): Array<RemoteConnectorRef> {
return (context.remoteConnectors ?? [])
.map((ref) => ({
kind: normalizeRemoteConnectorKind(ref.kind),
instanceId: normalizeRemoteConnectorInstanceId(ref.instanceId),
}))
.filter((ref) => ref.kind.length > 0 && ref.instanceId.length > 0)
.filter((ref) => ref.instanceId.length > 0)
}
49 changes: 7 additions & 42 deletions packages/worker/client/routes/account-remote-connectors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ import { userScopedConnectorWebSocketUrl } from '@kody-internal/shared/remote-co

type RemoteConnectorListItem = {
id: string
kind: string
instanceId: string
connectorUrl: string
enabled: boolean
Expand All @@ -60,7 +59,6 @@ type AccountRemoteConnectorsPayload = {

type EditorState = {
id: string | null
kind: string
instanceId: string
enabled: boolean
attached: boolean
Expand Down Expand Up @@ -100,7 +98,6 @@ export async function accountRemoteConnectorsRouteLoader(
function createEmptyEditorState(): EditorState {
return {
id: null,
kind: '',
instanceId: '',
enabled: true,
attached: true,
Expand All @@ -114,7 +111,6 @@ function createEditorStateFromConnector(
): EditorState {
return {
id: connector.id,
kind: connector.kind,
instanceId: connector.instanceId,
enabled: connector.enabled,
attached: connector.attached,
Expand All @@ -128,9 +124,9 @@ function formatTimestamp(value: string) {
}

function connectorLabel(
connector: Pick<RemoteConnectorListItem, 'kind' | 'instanceId'>,
connector: Pick<RemoteConnectorListItem, 'instanceId'>,
) {
return `${connector.kind}:${connector.instanceId}`
return connector.instanceId
}

function bytesToBase64Url(bytes: Uint8Array) {
Expand Down Expand Up @@ -409,7 +405,6 @@ export function AccountRemoteConnectorsRoute(handle: Handle) {
const enabled = formData.get('enabled') === 'on'
return {
...editorState,
kind: String(formData.get('kind') ?? '').trim(),
instanceId: String(formData.get('instanceId') ?? '').trim(),
sharedSecret: String(formData.get('sharedSecret') ?? ''),
enabled,
Expand All @@ -423,11 +418,6 @@ export function AccountRemoteConnectorsRoute(handle: Handle) {
if (saveState !== 'idle') return
const nextEditorState = form ? readEditorStateFromForm(form) : editorState
editorState = nextEditorState
if (!nextEditorState.kind.trim()) {
message = 'Connector kind is required.'
handle.update()
return
}
if (!nextEditorState.instanceId.trim()) {
message = 'Connector name is required.'
handle.update()
Expand Down Expand Up @@ -456,7 +446,6 @@ export function AccountRemoteConnectorsRoute(handle: Handle) {
body: JSON.stringify({
action: 'save',
id: nextEditorState.id,
kind: nextEditorState.kind,
instanceId: nextEditorState.instanceId,
enabled: nextEditorState.enabled,
attached: nextEditorState.attached,
Expand Down Expand Up @@ -565,11 +554,10 @@ export function AccountRemoteConnectorsRoute(handle: Handle) {

function getEditorConnectorUrl() {
if (!username || !connectorUrlOrigin) return null
if (!editorState.kind.trim() || !editorState.instanceId.trim()) return null
if (!editorState.instanceId.trim()) return null
return userScopedConnectorWebSocketUrl({
origin: connectorUrlOrigin,
username,
kind: editorState.kind,
instanceId: editorState.instanceId,
})
}
Expand Down Expand Up @@ -609,10 +597,9 @@ export function AccountRemoteConnectorsRoute(handle: Handle) {
}
const isMutating = saveState !== 'idle'
const isEditing = Boolean(editorState.id)
const selectedLabel =
editorState.kind && editorState.instanceId
? `${editorState.kind}:${editorState.instanceId}`
: 'New remote connector'
const selectedLabel = editorState.instanceId
? editorState.instanceId
: 'New remote connector'
const connectorUrl = getEditorConnectorUrl()

return (
Expand Down Expand Up @@ -747,27 +734,6 @@ export function AccountRemoteConnectorsRoute(handle: Handle) {
</p>
</div>

<label mix={css(fieldCss)}>
<span mix={css(fieldLabelCss)}>Kind</span>
<input
name="kind"
type="text"
value={editorState.kind}
placeholder="lights"
disabled={isMutating}
required
mix={[
on('input', (event) => {
editorState = {
...editorState,
kind: event.currentTarget.value,
}
handle.update()
}),
css(inputCss),
]}
/>
</label>
<label mix={css(fieldCss)}>
<span mix={css(fieldLabelCss)}>Connector name</span>
<input
Expand Down Expand Up @@ -820,8 +786,7 @@ export function AccountRemoteConnectorsRoute(handle: Handle) {
</div>
) : (
<p mix={css(descriptionCss)}>
Enter a kind and connector name to build the connector
WebSocket URL.
Enter a connector name to build the connector WebSocket URL.
</p>
)}
<p mix={css(descriptionCss)}>
Expand Down
47 changes: 47 additions & 0 deletions packages/worker/migrations/0050-drop-remote-connector-kind.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
DROP INDEX IF EXISTS idx_remote_connector_settings_ref_enabled;

CREATE TABLE remote_connector_settings_new (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
instance_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
attached INTEGER NOT NULL DEFAULT 1 CHECK (attached IN (0, 1)),
encrypted_shared_secret TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(user_id, instance_id)
);

INSERT INTO remote_connector_settings_new (
id,
user_id,
instance_id,
enabled,
attached,
encrypted_shared_secret,
created_at,
updated_at
)
SELECT
r.id,
r.user_id,
LOWER(r.instance_id),
r.enabled,
r.attached,
r.encrypted_shared_secret,
r.created_at,
r.updated_at
FROM remote_connector_settings AS r
INNER JOIN (
SELECT user_id, LOWER(instance_id) AS normalized_instance_id, MIN(rowid) AS min_rowid
FROM remote_connector_settings
GROUP BY user_id, LOWER(instance_id)
) AS winners
ON r.rowid = winners.min_rowid;
Comment thread
cursor[bot] marked this conversation as resolved.

DROP TABLE remote_connector_settings;

ALTER TABLE remote_connector_settings_new RENAME TO remote_connector_settings;

CREATE INDEX IF NOT EXISTS idx_remote_connector_settings_ref_enabled
ON remote_connector_settings(instance_id, enabled);
11 changes: 5 additions & 6 deletions packages/worker/src/app/account-deletion.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,11 @@ function createTestDb(initial: RowMap): {
}
if (
lower ===
'select kind, instance_id from remote_connector_settings where user_id = ?'
'select instance_id from remote_connector_settings where user_id = ?'
) {
results = (rows.remote_connector_settings ?? [])
.filter((row) => row['user_id'] === userId)
.map((row) => ({
kind: row['kind'],
instance_id: row['instance_id'],
}))
return { results: results as Array<T>, meta: { changes: 0 } }
Expand Down Expand Up @@ -445,8 +444,8 @@ test('deleteUserAccount cascades user-scoped rows for the requested user', async
value_buckets: [{ id: 'vb-1', user_id: userAaa }],
value_entries: [{ bucket_id: 'vb-1', name: 'v', user_id: 'unused' }],
remote_connector_settings: [
{ id: 'rc-1', user_id: userAaa, kind: 'home', instance_id: 'home' },
{ id: 'rc-2', user_id: userBbb, kind: 'home', instance_id: 'other' },
{ id: 'rc-1', user_id: userAaa, instance_id: 'home' },
{ id: 'rc-2', user_id: userBbb, instance_id: 'other' },
],
saved_packages: [
{
Expand Down Expand Up @@ -636,7 +635,7 @@ test('deleteUserAccount cascades user-scoped rows for the requested user', async
},
])
expect(rows.remote_connector_settings).toEqual([
{ id: 'rc-2', user_id: userBbb, kind: 'home', instance_id: 'other' },
{ id: 'rc-2', user_id: userBbb, instance_id: 'other' },
])
expect(rows.published_bundle_artifacts).toEqual([
{ id: 'pba-2', user_id: userBbb, kv_key: 'bundle-artifact:v1:src-2' },
Expand Down Expand Up @@ -716,7 +715,7 @@ test('deleteUserAccount cascades user-scoped rows for the requested user', async
})
expect(purgeRemoteConnectorMock).toHaveBeenCalledWith({
userId: userAaa,
kind: 'home',

instanceId: 'home',
})
expect(doFetchMock).toHaveBeenCalledTimes(3)
Expand Down
Loading
Loading