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
90 changes: 90 additions & 0 deletions packages/worker/src/mcp/capabilities/registry.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import {
getCapabilityRegistryForContext,
getStaticRegistry,
} from '#mcp/capabilities/registry.ts'
import {
clearRemoteConnectorSnapshotCacheForTests,
remoteConnectorSnapshotTimeoutMs,
} from '#worker/remote-connector/snapshot-cache.ts'

test('getCapabilityRegistryForContext bypasses connector caches when no connectors are attached', async () => {
clearCapabilityRegistryCacheForTests()
Expand Down Expand Up @@ -128,3 +132,89 @@ test('the email domain no longer exposes self-service inbox or sender-identity c
expect(capabilityMap.email_message_list).toBeTruthy()
expect(capabilityMap.email_message_get).toBeTruthy()
})

test('getCapabilityRegistryForContext keeps healthy capabilities when a connector snapshot stalls', async () => {
vi.useFakeTimers()
clearCapabilityRegistryCacheForTests()
clearRemoteConnectorSnapshotCacheForTests()
const prepare = vi.fn(() => ({
bind() {
return this
},
async all() {
return { results: [], meta: { changes: 0 } }
},
async first() {
return null
},
async run() {
return { meta: { changes: 0 } }
},
}))
const env = {
REMOTE_CONNECTOR_SESSION: {
idFromName(name: string) {
return name
},
get(name: string) {
const [, instanceId] = JSON.parse(name) as [string, string]
return {
getSnapshot() {
if (instanceId === 'stalled') {
return new Promise(() => undefined)
}
return Promise.resolve({
connectorId: instanceId,
connectedAt: '2026-03-25T00:00:00.000Z',
lastSeenAt: '2026-03-25T00:00:01.000Z',
tools: [
{
name: 'roku_press_key',
description: 'Send a Roku ECP keypress.',
inputSchema: {
type: 'object',
properties: {
key: { type: 'string' },
},
required: ['key'],
},
},
],
})
},
}
},
},
APP_DB: {
prepare,
},
} as unknown as Env
const callerContext = createMcpCallerContext({
baseUrl: 'https://heykody.dev',
user: {
userId: 'user-1',
email: 'user-1@example.com',
displayName: 'user-1',
},
remoteConnectors: [{ instanceId: 'healthy' }, { instanceId: 'stalled' }],
})

try {
const registryPromise = getCapabilityRegistryForContext({
env,
callerContext,
})
await vi.advanceTimersByTimeAsync(remoteConnectorSnapshotTimeoutMs)
const registry = await registryPromise

expect(registry.capabilityMap.email_send).toBeTruthy()
expect(registry.capabilityMap['remote:healthy:roku_press_key']).toBeTruthy()
expect(
registry.capabilityMap['remote:stalled:roku_press_key'],
).toBeUndefined()
} finally {
clearCapabilityRegistryCacheForTests()
clearRemoteConnectorSnapshotCacheForTests()
vi.useRealTimers()
}
})
36 changes: 27 additions & 9 deletions packages/worker/src/mcp/capabilities/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,28 @@ async function loadMcpServerSnapshots(input: {
}
}

async function loadRemoteConnectorSnapshots(input: {
env: Env
userId: string
refs: ReadonlyArray<RemoteConnectorRef>
}): Promise<Array<RemoteConnectorSnapshot | null>> {
return await Promise.all(
input.refs.map(async (ref) => {
try {
return await getCachedRemoteConnectorSnapshot({
env: input.env,
userId: input.userId,
instanceId: ref.instanceId,
})
} catch {
// A stalled or unavailable connector must not prevent builtin or
// healthy dynamic capabilities from being discovered.
return null
}
}),
)
}

async function loadOpenApiBindingsForRegistry(input: {
env: Env
userId: string
Expand Down Expand Up @@ -262,15 +284,11 @@ export async function getCapabilityRegistryForContext(input: {
})
}
const [snapshots, mcpServerSnapshots] = await Promise.all([
Promise.all(
refs.map((ref) =>
getCachedRemoteConnectorSnapshot({
env: input.env,
userId,
instanceId: ref.instanceId,
}),
),
),
loadRemoteConnectorSnapshots({
env: input.env,
userId,
refs,
}),
loadMcpServerSnapshots({
env: input.env,
userId,
Expand Down
80 changes: 79 additions & 1 deletion packages/worker/src/remote-connector/snapshot-cache.node.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { expect, test } from 'vitest'
import { expect, test, vi } from 'vitest'
import { createRemoteConnectorMcpClient } from '#worker/remote-connector/client.ts'
import { getRemoteConnectorStatus } from '#worker/remote-connector/status.ts'
import {
clearRemoteConnectorSnapshotCacheForTests,
getCachedRemoteConnectorSnapshot,
RemoteConnectorSnapshotTimeoutError,
remoteConnectorSnapshotTimeoutMs,
} from '#worker/remote-connector/snapshot-cache.ts'

const runtimeTools = [
Expand Down Expand Up @@ -160,3 +163,78 @@ test('getCachedRemoteConnectorSnapshot does not share entries across users', asy

expect(getSnapshotCalls()).toBe(2)
})

test('a stalled snapshot times out, rejects concurrent callers, and is retried', async () => {
vi.useFakeTimers()
clearRemoteConnectorSnapshotCacheForTests()
let attempt = 0
const { env, getSnapshotCalls } = buildEnv(() => {
attempt += 1
if (attempt === 1) {
return new Promise(() => undefined)
}
return Promise.resolve({
connectorKind: 'roku',
connectorId: 'home',
connectedAt: '2026-03-25T00:00:00.000Z',
lastSeenAt: '2026-03-25T00:00:01.000Z',
tools: runtimeTools,
})
})
const request = {
env,
userId: 'user-1',
instanceId: 'home',
}

try {
const first = getCachedRemoteConnectorSnapshot(request)
const second = getCachedRemoteConnectorSnapshot(request)
const firstRejection = expect(first).rejects.toBeInstanceOf(
RemoteConnectorSnapshotTimeoutError,
)
const secondRejection = expect(second).rejects.toBeInstanceOf(
RemoteConnectorSnapshotTimeoutError,
)

expect(getSnapshotCalls()).toBe(1)
await vi.advanceTimersByTimeAsync(remoteConnectorSnapshotTimeoutMs)
await Promise.all([firstRejection, secondRejection])

await expect(
getCachedRemoteConnectorSnapshot(request),
).resolves.toMatchObject({
connectorId: 'home',
})
expect(getSnapshotCalls()).toBe(2)
} finally {
clearRemoteConnectorSnapshotCacheForTests()
vi.useRealTimers()
}
})

test('remote connector status reports a stalled snapshot as an error', async () => {
vi.useFakeTimers()
clearRemoteConnectorSnapshotCacheForTests()
const { env } = buildEnv(() => new Promise(() => undefined))

try {
const statusPromise = getRemoteConnectorStatus({
env,
userId: 'user-1',
ref: { instanceId: 'home' },
})
await vi.advanceTimersByTimeAsync(remoteConnectorSnapshotTimeoutMs)

await expect(statusPromise).resolves.toMatchObject({
state: 'error',
connectorId: 'home',
connected: false,
toolCount: 0,
error: expect.stringContaining('snapshot timed out'),
})
} finally {
clearRemoteConnectorSnapshotCacheForTests()
vi.useRealTimers()
}
})
38 changes: 37 additions & 1 deletion packages/worker/src/remote-connector/snapshot-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,39 @@ import { type RemoteConnectorSnapshot } from '#worker/remote-connector/types.ts'

export const remoteConnectorSnapshotCacheTtlMs = 30_000
export const remoteConnectorSnapshotCacheLimit = 100
export const remoteConnectorSnapshotTimeoutMs = 5_000

export class RemoteConnectorSnapshotTimeoutError extends Error {
constructor(instanceId: string) {
super(
`Remote connector "${instanceId}" snapshot timed out after ${remoteConnectorSnapshotTimeoutMs}ms.`,
)
this.name = 'RemoteConnectorSnapshotTimeoutError'
}
}

async function getSnapshotWithTimeout(input: {
instanceId: string
getSnapshot: () => Promise<RemoteConnectorSnapshot | null>
}) {
let timeoutId: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
input.getSnapshot(),
new Promise<never>((_resolve, reject) => {
timeoutId = setTimeout(
() =>
reject(new RemoteConnectorSnapshotTimeoutError(input.instanceId)),
remoteConnectorSnapshotTimeoutMs,
)
}),
])
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId)
}
}
}
Comment on lines +18 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the pending-cache implementation to confirm eviction/backoff behavior on rejection.
sed -n '1,56p' packages/worker/src/remote-connector/snapshot-cache.ts

Repository: kentcdodds/kody

Length of output: 1836


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== snapshot-cache usage =="
rg -n "getSnapshotWithTimeout|remoteConnectorSnapshotCache|createRemoteConnectorSnapshotCacheKey|RemoteConnectorSnapshotTimeoutError" packages/worker/src -S

echo
echo "== PromiseLruCache definition =="
fd -a "published-package-cache.ts|package-registry" packages/worker/src
rg -n "class PromiseLruCache|PromiseLruCache" packages/worker/src -S

echo
echo "== snapshot-cache tests =="
fd -a "snapshot-cache" packages/worker/src test packages
rg -n "RemoteConnectorSnapshotTimeoutError|snapshot cache|getSnapshotWithTimeout|PromiseLruCache" packages/worker/src test packages -S

Repository: kentcdodds/kody

Length of output: 9327


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== packages/worker/src/package-registry/published-package-cache.ts =="
wc -l packages/worker/src/package-registry/published-package-cache.ts
sed -n '1,220p' packages/worker/src/package-registry/published-package-cache.ts

echo
echo "== packages/worker/src/remote-connector/snapshot-cache.ts =="
sed -n '1,160p' packages/worker/src/remote-connector/snapshot-cache.ts

echo
echo "== packages/worker/src/remote-connector/snapshot-cache.node.test.ts (relevant section) =="
sed -n '1,280p' packages/worker/src/remote-connector/snapshot-cache.node.test.ts

Repository: kentcdodds/kody

Length of output: 11628


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('packages/worker/src/package-registry/published-package-cache.ts')
print(p.read_text())
PY

Repository: kentcdodds/kody

Length of output: 2336


Add backoff or cancellation for timed-out snapshot RPCs Timed-out entries are evicted, so a persistently stalled connector will start a fresh stub.getSnapshot() on each later lookup while the previous RPC can still remain open. Consider a short negative-cache/backoff window after timeout, or propagating cancellation into the DO call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/worker/src/remote-connector/snapshot-cache.ts` around lines 18 - 39,
Update getSnapshotWithTimeout and its callers to prevent repeated snapshot RPCs
after a timeout: either propagate cancellation to the in-flight getSnapshot
operation or add a short timeout-specific negative-cache/backoff window before
allowing another lookup. Preserve normal successful and null snapshot behavior,
and ensure timed-out connector requests cannot trigger an immediate fresh
stub.getSnapshot call.


function createRemoteConnectorSnapshotCache() {
return new PromiseLruCache<RemoteConnectorSnapshot | null>({
Expand Down Expand Up @@ -33,7 +66,10 @@ export function getCachedRemoteConnectorSnapshot(input: {
const stub = input.env.REMOTE_CONNECTOR_SESSION.get(
input.env.REMOTE_CONNECTOR_SESSION.idFromName(cacheKey),
)
const snapshot = await stub.getSnapshot()
const snapshot = await getSnapshotWithTimeout({
instanceId: input.instanceId,
getSnapshot: () => stub.getSnapshot(),
})
if (snapshot == null) {
// Do not retain disconnected results: a connector that comes online
// should be visible on the next lookup instead of after the TTL.
Expand Down
Loading