From 5f8abd744012fc1200fa1b98e0d39845c956b072 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sat, 28 Mar 2026 00:44:01 +0000
Subject: [PATCH 1/8] Add batch secret approval details
Co-authored-by: Kent C. Dodds
---
.../worker/client/routes/account-secrets.tsx | 48 ++++++-
.../src/app/handlers/account-secrets.ts | 10 ++
packages/worker/src/mcp/executor.node.test.ts | 97 +++++++++++++-
packages/worker/src/mcp/executor.ts | 60 +++++++++
packages/worker/src/mcp/fetch-gateway.ts | 70 +++++++----
.../mcp/run-codemode-registry.node.test.ts | 2 +-
.../worker/src/mcp/run-codemode-registry.ts | 119 +++++++++++++++++-
.../mcp/secrets/capability-approval-url.ts | 21 ++++
.../src/mcp/secrets/errors.node.test.ts | 77 ++++++++++++
packages/worker/src/mcp/secrets/errors.ts | 92 +++++++++++++-
10 files changed, 566 insertions(+), 30 deletions(-)
create mode 100644 packages/worker/src/mcp/secrets/capability-approval-url.ts
diff --git a/packages/worker/client/routes/account-secrets.tsx b/packages/worker/client/routes/account-secrets.tsx
index d88e4be4d8..f34082026b 100644
--- a/packages/worker/client/routes/account-secrets.tsx
+++ b/packages/worker/client/routes/account-secrets.tsx
@@ -222,6 +222,28 @@ function buildSecretsHref(pathname: string, search = getCurrentSearch()) {
return `${pathname}${search}`
}
+function readCapabilityPrefill(href: string) {
+ const url = new URL(href, 'http://localhost')
+ const value = url.searchParams.get('capability')
+ return value?.trim() ? value.trim() : null
+}
+
+function applyCapabilityPrefill(state: EditorState, capability: string | null) {
+ if (!capability) return state
+ if (state.allowedCapabilities.some((entry) => entry.trim() === capability)) {
+ return state
+ }
+ const nextAllowedCapabilities =
+ state.allowedCapabilities.length === 1 &&
+ state.allowedCapabilities[0]?.trim() === ''
+ ? [capability]
+ : [...state.allowedCapabilities, capability]
+ return {
+ ...state,
+ allowedCapabilities: nextAllowedCapabilities,
+ }
+}
+
function buildSecretHref(secret: {
name: string
scope: SecretScope
@@ -258,7 +280,8 @@ function getDataRefreshKey(href: string) {
const url = new URL(href, 'http://localhost')
const request = url.searchParams.get('request') ?? ''
const requestedHost = url.searchParams.get('allowed-host') ?? ''
- return `${url.pathname}?request=${request}&allowed-host=${requestedHost}`
+ const requestedCapability = url.searchParams.get('capability') ?? ''
+ return `${url.pathname}?request=${request}&allowed-host=${requestedHost}&capability=${requestedCapability}`
}
function readFilterState(
@@ -359,15 +382,25 @@ export function AccountSecretsRoute(handle: Handle) {
function syncEditorState(selection: SelectionState) {
deleteSecretCheck.reset()
showSecretValue = false
+ const capabilityPrefill = readCapabilityPrefill(getCurrentHref())
if (selection.isCreating) {
- editorState = createEmptyEditorState(apps)
+ editorState = applyCapabilityPrefill(
+ createEmptyEditorState(apps),
+ capabilityPrefill,
+ )
return
}
if (selectedSecret) {
- editorState = createEditorStateFromSecret(selectedSecret)
+ editorState = applyCapabilityPrefill(
+ createEditorStateFromSecret(selectedSecret),
+ capabilityPrefill,
+ )
return
}
- editorState = createEmptyEditorState(apps)
+ editorState = applyCapabilityPrefill(
+ createEmptyEditorState(apps),
+ capabilityPrefill,
+ )
}
function applyPayload(
@@ -471,6 +504,7 @@ export function AccountSecretsRoute(handle: Handle) {
const nextUrl = new URL(nextHref, window.location.href)
nextUrl.searchParams.delete('request')
nextUrl.searchParams.delete('allowed-host')
+ nextUrl.searchParams.delete('capability')
navigate(`${nextUrl.pathname}${nextUrl.search}`)
lastLoadedDataKey = getDataRefreshKey(nextUrl.toString())
}
@@ -761,6 +795,12 @@ export function AccountSecretsRoute(handle: Handle) {
{approval.name} from the{' '}
{getScopeLabel(approval.scope)} scope.
+ {approval.requestedCapability ? (
+
+ Requested capability:{' '}
+ {approval.requestedCapability}
+
+ ) : null}
Current allowed hosts:{' '}
{approval.currentAllowedHosts.length > 0
diff --git a/packages/worker/src/app/handlers/account-secrets.ts b/packages/worker/src/app/handlers/account-secrets.ts
index e04e895b15..49d222cca5 100644
--- a/packages/worker/src/app/handlers/account-secrets.ts
+++ b/packages/worker/src/app/handlers/account-secrets.ts
@@ -56,6 +56,7 @@ type SecretApprovalView = {
name: string
scope: SecretScope
requestedHost: string
+ requestedCapability: string | null
currentAllowedHosts: Array
}
@@ -164,6 +165,7 @@ async function buildAccountSecretsPayload(input: {
const url = new URL(input.request.url)
const approvalToken = url.searchParams.get('request')
const requestedApprovalHost = readApprovalHost(url)
+ const requestedCapability = readRequestedCapability(url)
const savedApps =
input.savedApps ??
@@ -191,6 +193,7 @@ async function buildAccountSecretsPayload(input: {
userId: input.user.mcpUser.userId,
token: approvalToken,
requestedHost: requestedApprovalHost,
+ requestedCapability,
}).catch(() => null)
: null
@@ -272,6 +275,7 @@ async function resolveSecretApprovalView(input: {
userId: string
token: string
requestedHost: string | null
+ requestedCapability: string | null
}) {
const approval = await verifySecretHostApprovalToken(input.env, input.token)
if (approval.userId !== input.userId) {
@@ -300,6 +304,7 @@ async function resolveSecretApprovalView(input: {
name: approval.name,
scope: approval.scope,
requestedHost: approval.requestedHost,
+ requestedCapability: input.requestedCapability,
currentAllowedHosts: secret.allowedHosts,
} satisfies SecretApprovalView
}
@@ -632,6 +637,11 @@ function readApprovalHost(url: URL) {
return value?.trim() ? value.trim() : null
}
+function readRequestedCapability(url: URL) {
+ const value = url.searchParams.get('capability')
+ return value?.trim() ? value.trim() : null
+}
+
function readString(body: object, key: string) {
const value = (body as Record)[key]
return typeof value === 'string' && value.trim() ? value.trim() : null
diff --git a/packages/worker/src/mcp/executor.node.test.ts b/packages/worker/src/mcp/executor.node.test.ts
index 27eeead139..c5b74def77 100644
--- a/packages/worker/src/mcp/executor.node.test.ts
+++ b/packages/worker/src/mcp/executor.node.test.ts
@@ -1,6 +1,8 @@
import { expect, test } from 'vitest'
import {
+ createCapabilitySecretAccessDeniedBatchMessage,
createCapabilitySecretAccessDeniedMessage,
+ createHostSecretAccessDeniedBatchMessage,
createMissingSecretMessage,
} from '#mcp/secrets/errors.ts'
import { formatExecutionOutput, getExecutionErrorDetails } from './executor.ts'
@@ -10,17 +12,20 @@ test('getExecutionErrorDetails returns concrete guidance for capability access d
createCapabilitySecretAccessDeniedMessage(
'cloudflareToken',
'cloudflare_rest',
+ 'https://example.com/account/secrets/user/cloudflareToken?capability=cloudflare_rest',
),
)
expect(getExecutionErrorDetails(error)).toEqual({
kind: 'secret_capability_access_required',
message:
- 'Secret "cloudflareToken" is not allowed for capability "cloudflare_rest". If this capability should be able to use the secret, ask the user whether to add "cloudflare_rest" to the secret\'s allowed capabilities in the account secrets UI, then retry after they approve that policy change.',
+ 'Secret "cloudflareToken" is not allowed for capability "cloudflare_rest". If this capability should be able to use the secret, ask the user whether to add "cloudflare_rest" to the secret\'s allowed capabilities in the account secrets UI, then retry after they approve that policy change. Approval link: https://example.com/account/secrets/user/cloudflareToken?capability=cloudflare_rest',
nextStep:
"Ask the user whether this capability should be allowed to use the secret. If they approve, help them add this capability name to the secret's allowed capabilities in the account secrets UI, then retry.",
secretNames: ['cloudflareToken'],
capabilityName: 'cloudflare_rest',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/cloudflareToken?capability=cloudflare_rest',
suggestedAction: {
type: 'edit_secret_policy',
policyField: 'allowed_capabilities',
@@ -34,6 +39,7 @@ test('formatExecutionOutput includes capability access next step', () => {
createCapabilitySecretAccessDeniedMessage(
'cloudflareToken',
'cloudflare_rest',
+ 'https://example.com/account/secrets/user/cloudflareToken?capability=cloudflare_rest',
),
),
} as const
@@ -52,3 +58,92 @@ test('formatExecutionOutput keeps missing secret guidance intact', () => {
'Open a generated UI so the user can provide and save this secret',
)
})
+
+test('getExecutionErrorDetails returns batch capability approvals', () => {
+ const error = new Error(
+ createCapabilitySecretAccessDeniedBatchMessage([
+ {
+ secretName: 'lutronUsername',
+ capabilityName: 'home_lutron_set_credentials',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/lutronUsername?capability=home_lutron_set_credentials',
+ },
+ {
+ secretName: 'lutronPassword',
+ capabilityName: 'home_lutron_set_credentials',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/lutronPassword?capability=home_lutron_set_credentials',
+ },
+ ]),
+ )
+
+ expect(getExecutionErrorDetails(error)).toEqual({
+ kind: 'secret_capability_access_required_batch',
+ message:
+ 'Secrets require capability approval: [{"secretName":"lutronUsername","capabilityName":"home_lutron_set_credentials","approvalUrl":"https://example.com/account/secrets/user/lutronUsername?capability=home_lutron_set_credentials"},{"secretName":"lutronPassword","capabilityName":"home_lutron_set_credentials","approvalUrl":"https://example.com/account/secrets/user/lutronPassword?capability=home_lutron_set_credentials"}]',
+ nextStep:
+ 'Ask the user whether they want to approve these capabilities for the listed secrets in the account secrets UI, then retry after approval.',
+ missingApprovals: [
+ {
+ secretName: 'lutronUsername',
+ capabilityName: 'home_lutron_set_credentials',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/lutronUsername?capability=home_lutron_set_credentials',
+ },
+ {
+ secretName: 'lutronPassword',
+ capabilityName: 'home_lutron_set_credentials',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/lutronPassword?capability=home_lutron_set_credentials',
+ },
+ ],
+ suggestedAction: {
+ type: 'edit_secret_policy',
+ policyField: 'allowed_capabilities',
+ },
+ })
+})
+
+test('getExecutionErrorDetails returns batch host approvals', () => {
+ const error = new Error(
+ createHostSecretAccessDeniedBatchMessage([
+ {
+ secretName: 'cloudflareToken',
+ host: 'api.cloudflare.com',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com&request=token',
+ },
+ {
+ secretName: 'slackToken',
+ host: 'slack.com',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/slackToken?allowed-host=slack.com&request=token',
+ },
+ ]),
+ )
+
+ expect(getExecutionErrorDetails(error)).toEqual({
+ kind: 'host_approval_required_batch',
+ message:
+ 'Secrets require host approval: [{"secretName":"cloudflareToken","host":"api.cloudflare.com","approvalUrl":"https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com&request=token"},{"secretName":"slackToken","host":"slack.com","approvalUrl":"https://example.com/account/secrets/user/slackToken?allowed-host=slack.com&request=token"}]',
+ nextStep:
+ 'Ask the user whether they want to approve these hosts for the listed secrets in the account web UI, then retry after approval.',
+ missingApprovals: [
+ {
+ secretName: 'cloudflareToken',
+ host: 'api.cloudflare.com',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com&request=token',
+ },
+ {
+ secretName: 'slackToken',
+ host: 'slack.com',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/slackToken?allowed-host=slack.com&request=token',
+ },
+ ],
+ suggestedAction: {
+ type: 'approve_secret_host',
+ },
+ })
+})
diff --git a/packages/worker/src/mcp/executor.ts b/packages/worker/src/mcp/executor.ts
index 4499737c6d..9de1a9c60b 100644
--- a/packages/worker/src/mcp/executor.ts
+++ b/packages/worker/src/mcp/executor.ts
@@ -4,7 +4,9 @@ type WorkerLoopbackExports = Exclude
import { type FetchGatewayProps } from '#mcp/fetch-gateway.ts'
import {
isSecretAuthRequiredMessage,
+ parseCapabilityAccessRequiredBatchMessage,
parseCapabilityAccessRequiredMessage,
+ parseHostApprovalRequiredBatchMessage,
parseHostApprovalRequiredMessage,
parseMissingSecretMessage,
} from '#mcp/secrets/errors.ts'
@@ -45,12 +47,40 @@ export type ExecutionErrorDetails =
type: 'approve_secret_host'
}
}
+ | {
+ kind: 'host_approval_required_batch'
+ message: string
+ nextStep: string
+ missingApprovals: Array<{
+ secretName: string
+ host: string
+ approvalUrl: string
+ }>
+ suggestedAction: {
+ type: 'approve_secret_host'
+ }
+ }
| {
kind: 'secret_capability_access_required'
message: string
nextStep: string
secretNames: Array
capabilityName: string
+ approvalUrl: string | null
+ suggestedAction: {
+ type: 'edit_secret_policy'
+ policyField: 'allowed_capabilities'
+ }
+ }
+ | {
+ kind: 'secret_capability_access_required_batch'
+ message: string
+ nextStep: string
+ missingApprovals: Array<{
+ secretName: string
+ capabilityName: string
+ approvalUrl: string
+ }>
suggestedAction: {
type: 'edit_secret_policy'
policyField: 'allowed_capabilities'
@@ -96,6 +126,35 @@ export function getExecutionErrorDetails(
}
}
+ const hostApprovalBatch = parseHostApprovalRequiredBatchMessage(message)
+ if (hostApprovalBatch) {
+ return {
+ kind: 'host_approval_required_batch',
+ message,
+ nextStep:
+ 'Ask the user whether they want to approve these hosts for the listed secrets in the account web UI, then retry after approval.',
+ missingApprovals: hostApprovalBatch,
+ suggestedAction: {
+ type: 'approve_secret_host',
+ },
+ }
+ }
+
+ const capabilityAccessBatch = parseCapabilityAccessRequiredBatchMessage(message)
+ if (capabilityAccessBatch) {
+ return {
+ kind: 'secret_capability_access_required_batch',
+ message,
+ nextStep:
+ 'Ask the user whether they want to approve these capabilities for the listed secrets in the account secrets UI, then retry after approval.',
+ missingApprovals: capabilityAccessBatch,
+ suggestedAction: {
+ type: 'edit_secret_policy',
+ policyField: 'allowed_capabilities',
+ },
+ }
+ }
+
const capabilityAccessDetails = parseCapabilityAccessRequiredMessage(message)
if (capabilityAccessDetails) {
return {
@@ -105,6 +164,7 @@ export function getExecutionErrorDetails(
"Ask the user whether this capability should be allowed to use the secret. If they approve, help them add this capability name to the secret's allowed capabilities in the account secrets UI, then retry.",
secretNames: [capabilityAccessDetails.secretName],
capabilityName: capabilityAccessDetails.capabilityName,
+ approvalUrl: extractFirstUrl(message),
suggestedAction: {
type: 'edit_secret_policy',
policyField: 'allowed_capabilities',
diff --git a/packages/worker/src/mcp/fetch-gateway.ts b/packages/worker/src/mcp/fetch-gateway.ts
index 07f79e4a02..a7c1a2242b 100644
--- a/packages/worker/src/mcp/fetch-gateway.ts
+++ b/packages/worker/src/mcp/fetch-gateway.ts
@@ -10,6 +10,7 @@ import {
type ReferencedSecret,
} from '#mcp/secrets/placeholders.ts'
import {
+ createHostSecretAccessDeniedBatchMessage,
createMissingSecretMessage,
fetchSecretAuthRequiredMessage,
} from '#mcp/secrets/errors.ts'
@@ -85,27 +86,15 @@ export async function expandSecretPlaceholders(input: {
)
}
const normalizedHost = normalizeHost(requestedHost)
- for (const { referenced, resolved } of resolvedSecrets) {
- const allowedForHost = resolved.allowedHosts.includes(normalizedHost)
- if (allowedForHost) continue
- const approvalToken = await createSecretHostApprovalToken(input.env, {
- userId: input.props.userId!,
- name: referenced.name,
- scope: resolved.scope ?? referenced.scope ?? 'user',
- requestedHost,
- storageContext: input.props.storageContext,
- })
- const approvalUrl = buildSecretHostApprovalUrl({
- baseUrl: input.props.baseUrl,
- token: approvalToken,
- name: referenced.name,
- scope: resolved.scope ?? referenced.scope ?? 'user',
- requestedHost,
- storageContext: input.props.storageContext,
- })
- throw new Error(
- `Secret "${referenced.name}" is not allowed for host "${requestedHost}". If this request is expected, ask the user whether this host should be added to the secret's allowed hosts: ${approvalUrl}`,
- )
+ const missingApprovals = await collectHostApprovalEntries({
+ env: input.env,
+ props: input.props,
+ requestedHost,
+ normalizedHost,
+ resolvedSecrets,
+ })
+ if (missingApprovals.length > 0) {
+ throw new Error(createHostSecretAccessDeniedBatchMessage(missingApprovals))
}
}
for (const [key, value] of Array.from(headers.entries())) {
@@ -133,6 +122,45 @@ export async function expandSecretPlaceholders(input: {
})
}
+async function collectHostApprovalEntries(input: {
+ env: Pick
+ props: FetchGatewayProps
+ requestedHost: string
+ normalizedHost: string
+ resolvedSecrets: Array<{
+ referenced: ReferencedSecret
+ resolved: ResolvedSecret
+ }>
+}) {
+ const entries = await Promise.all(
+ input.resolvedSecrets.map(async ({ referenced, resolved }) => {
+ const allowedForHost = resolved.allowedHosts.includes(input.normalizedHost)
+ if (allowedForHost) return null
+ const approvalToken = await createSecretHostApprovalToken(input.env, {
+ userId: input.props.userId!,
+ name: referenced.name,
+ scope: resolved.scope ?? referenced.scope ?? 'user',
+ requestedHost: input.requestedHost,
+ storageContext: input.props.storageContext,
+ })
+ const approvalUrl = buildSecretHostApprovalUrl({
+ baseUrl: input.props.baseUrl,
+ token: approvalToken,
+ name: referenced.name,
+ scope: resolved.scope ?? referenced.scope ?? 'user',
+ requestedHost: input.requestedHost,
+ storageContext: input.props.storageContext,
+ })
+ return {
+ secretName: referenced.name,
+ host: input.requestedHost,
+ approvalUrl,
+ }
+ }),
+ )
+ return entries.filter((entry): entry is NonNullable => entry != null)
+}
+
function ensureFetchAllowed(props: FetchGatewayProps) {
if (!props.userId) {
throw new Error(fetchSecretAuthRequiredMessage)
diff --git a/packages/worker/src/mcp/run-codemode-registry.node.test.ts b/packages/worker/src/mcp/run-codemode-registry.node.test.ts
index 165e4ad58c..654670ffcc 100644
--- a/packages/worker/src/mcp/run-codemode-registry.node.test.ts
+++ b/packages/worker/src/mcp/run-codemode-registry.node.test.ts
@@ -167,7 +167,7 @@ test('buildCodemodeFns denies capability secret placeholders for disallowed capa
username: '{{secret:lutronUsername|scope=user}}',
}),
).rejects.toThrow(
- 'Secret "lutronUsername" is not allowed for capability "home_lutron_set_credentials"',
+ 'Secret "lutronUsername" is not allowed for capability "home_lutron_set_credentials". If this capability should be able to use the secret, ask the user whether to add "home_lutron_set_credentials" to the secret\'s allowed capabilities in the account secrets UI, then retry after they approve that policy change. Approval link: https://heykody.dev/account/secrets/user/lutronUsername?capability=home_lutron_set_credentials',
)
expect(resolveSecretSpy).toHaveBeenCalledWith(
expect.objectContaining({
diff --git a/packages/worker/src/mcp/run-codemode-registry.ts b/packages/worker/src/mcp/run-codemode-registry.ts
index 6de7aec390..c0e1c44d15 100644
--- a/packages/worker/src/mcp/run-codemode-registry.ts
+++ b/packages/worker/src/mcp/run-codemode-registry.ts
@@ -9,8 +9,10 @@ import { resolveCapabilityInputSecrets } from '#mcp/secrets/capability-inputs.ts
import {
capabilityInputSecretAuthRequiredMessage,
createCapabilitySecretAccessDeniedMessage,
+ createCapabilitySecretAccessDeniedBatchMessage,
createMissingSecretMessage,
} from '#mcp/secrets/errors.ts'
+import { buildSecretCapabilityApprovalUrl } from '#mcp/secrets/capability-approval-url.ts'
import { resolveSecret } from '#mcp/secrets/service.ts'
import { type ReferencedSecret } from '#mcp/secrets/placeholders.ts'
import { buildParameterizedSkillCode } from '#mcp/skills/skill-parameters.ts'
@@ -101,14 +103,26 @@ function createCapabilityInputSecretResolver(
throw new Error(createMissingSecretMessage(secret.name))
}
if (!resolved.allowedCapabilities.includes(capabilityName)) {
+ const approvalUrl = buildSecretCapabilityApprovalUrl({
+ baseUrl: callerContext.baseUrl,
+ name: secret.name,
+ scope: resolved.scope ?? secret.scope ?? 'user',
+ capabilityName,
+ storageContext: callerContext.storageContext ?? null,
+ })
throw new Error(
- createCapabilitySecretAccessDeniedMessage(secret.name, capabilityName),
+ createCapabilitySecretAccessDeniedMessage(
+ secret.name,
+ capabilityName,
+ approvalUrl,
+ ),
)
}
return resolved.value
}
}
+
export async function runCodemodeWithRegistry(
env: Env,
callerContext: McpCallerContext,
@@ -134,5 +148,106 @@ export async function runCodemodeWithRegistry(
params !== undefined
? await buildParameterizedSkillCode(code, params)
: code
- return executor.execute(wrapped, [provider])
+ const result = await executor.execute(wrapped, [provider])
+ if (!result.error) return result
+ const batchError = await rewriteCapabilitySecretError({
+ error: result.error,
+ code: wrapped,
+ env,
+ callerContext,
+ })
+ return batchError ? { ...result, error: batchError } : result
+}
+
+async function rewriteCapabilitySecretError(input: {
+ error: unknown
+ code: string
+ env: Env
+ callerContext: McpCallerContext
+}) {
+ const message =
+ input.error instanceof Error ? input.error.message : String(input.error)
+ const capabilityMatch = message.match(
+ /^Secret "([^"]+)" is not allowed for capability "([^"]+)"/,
+ )
+ if (!capabilityMatch?.[1] || !capabilityMatch?.[2]) return null
+ const capabilityName = capabilityMatch[2]
+ const userId = input.callerContext.user?.userId ?? null
+ if (!userId) return input.error instanceof Error ? input.error : new Error(message)
+ const secretNames = collectSecretNamesFromCode(input.error, input.code)
+ if (secretNames.length === 0) {
+ return input.error instanceof Error ? input.error : new Error(message)
+ }
+ const missing = await findMissingCapabilityApprovals({
+ env: input.env,
+ userId,
+ secretNames,
+ capabilityName,
+ storageContext: input.callerContext.storageContext ?? null,
+ baseUrl: input.callerContext.baseUrl,
+ })
+ if (missing.length === 0) {
+ return input.error instanceof Error ? input.error : new Error(message)
+ }
+ return new Error(createCapabilitySecretAccessDeniedBatchMessage(missing))
+}
+
+function collectSecretNamesFromCode(
+ error: unknown,
+ code: string | null,
+) {
+ const fromError =
+ error instanceof Error ? parseSecretNamesFromMessage(error.message) : []
+ const fromCode = code ? parseSecretNamesFromMessage(code) : []
+ return normalizeSecretNameList([...fromError, ...fromCode])
+}
+
+function parseSecretNamesFromMessage(message: string) {
+ const matches = Array.from(message.matchAll(/Secret "([^"]+)"/g))
+ return matches
+ .map((match) => match[1])
+ .filter((value): value is string => Boolean(value))
+}
+
+function normalizeSecretNameList(names: Array) {
+ return Array.from(
+ new Set(names.map((name) => name.trim()).filter((name) => name.length > 0)),
+ ).sort((left, right) => left.localeCompare(right))
+}
+
+async function findMissingCapabilityApprovals(input: {
+ env: Env
+ userId: string
+ secretNames: Array
+ capabilityName: string
+ storageContext: McpCallerContext['storageContext'] | null
+ baseUrl: string
+}) {
+ const entries = await Promise.all(
+ input.secretNames.map(async (name) => {
+ const resolved = await resolveSecret({
+ env: input.env,
+ userId: input.userId,
+ name,
+ storageContext: input.storageContext,
+ })
+ if (!resolved.found) return null
+ if (resolved.allowedCapabilities.includes(input.capabilityName)) {
+ return null
+ }
+ const approvalUrl = buildSecretCapabilityApprovalUrl({
+ baseUrl: input.baseUrl,
+ name,
+ scope: resolved.scope ?? 'user',
+ capabilityName: input.capabilityName,
+ storageContext: input.storageContext,
+ })
+ return {
+ secretName: name,
+ capabilityName: input.capabilityName,
+ approvalUrl,
+ }
+ }),
+ )
+ return entries.filter((entry): entry is NonNullable => entry != null)
}
diff --git a/packages/worker/src/mcp/secrets/capability-approval-url.ts b/packages/worker/src/mcp/secrets/capability-approval-url.ts
new file mode 100644
index 0000000000..986c8143a0
--- /dev/null
+++ b/packages/worker/src/mcp/secrets/capability-approval-url.ts
@@ -0,0 +1,21 @@
+import { buildAccountSecretPath } from '@kody-internal/shared/account-secret-route.ts'
+import { type StorageContext } from '#mcp/storage.ts'
+import { type SecretScope } from './types.ts'
+
+export function buildSecretCapabilityApprovalUrl(input: {
+ baseUrl: string
+ name: string
+ scope: SecretScope
+ capabilityName: string
+ storageContext: StorageContext | null
+}) {
+ const secretPath = buildAccountSecretPath({
+ name: input.name,
+ scope: input.scope,
+ appId: input.storageContext?.appId ?? null,
+ sessionId: input.storageContext?.sessionId ?? null,
+ })
+ const url = new URL(secretPath, input.baseUrl)
+ url.searchParams.set('capability', input.capabilityName)
+ return url.toString()
+}
diff --git a/packages/worker/src/mcp/secrets/errors.node.test.ts b/packages/worker/src/mcp/secrets/errors.node.test.ts
index ed6286ffe9..9fb1e466fc 100644
--- a/packages/worker/src/mcp/secrets/errors.node.test.ts
+++ b/packages/worker/src/mcp/secrets/errors.node.test.ts
@@ -2,10 +2,14 @@ import { expect, test } from 'vitest'
import {
capabilityInputSecretAuthRequiredMessage,
createCapabilitySecretAccessDeniedMessage,
+ createCapabilitySecretAccessDeniedBatchMessage,
+ createHostSecretAccessDeniedBatchMessage,
createMissingSecretMessage,
fetchSecretAuthRequiredMessage,
isSecretAuthRequiredMessage,
+ parseCapabilityAccessRequiredBatchMessage,
parseCapabilityAccessRequiredMessage,
+ parseHostApprovalRequiredBatchMessage,
parseHostApprovalRequiredMessage,
parseMissingSecretMessage,
} from './errors.ts'
@@ -38,10 +42,31 @@ test('parseHostApprovalRequiredMessage extracts secret name and host', () => {
expect(parseHostApprovalRequiredMessage('Host approval failed')).toBeNull()
})
+test('parseHostApprovalRequiredBatchMessage returns approval entries', () => {
+ const entries = [
+ {
+ secretName: 'cloudflareToken',
+ host: 'api.cloudflare.com',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com&request=token',
+ },
+ {
+ secretName: 'slackToken',
+ host: 'slack.com',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/slackToken?allowed-host=slack.com&request=token',
+ },
+ ]
+ const message = createHostSecretAccessDeniedBatchMessage(entries)
+ expect(parseHostApprovalRequiredBatchMessage(message)).toEqual(entries)
+ expect(parseHostApprovalRequiredBatchMessage('Host approval failed')).toBeNull()
+})
+
test('parseCapabilitySecretAccessDeniedMessage extracts secret and capability', () => {
const message = createCapabilitySecretAccessDeniedMessage(
'cloudflareToken',
'cloudflare_rest',
+ 'https://example.com/account/secrets/user/cloudflareToken?capability=cloudflare_rest',
)
expect(parseCapabilityAccessRequiredMessage(message)).toEqual({
secretName: 'cloudflareToken',
@@ -51,3 +76,55 @@ test('parseCapabilitySecretAccessDeniedMessage extracts secret and capability',
parseCapabilityAccessRequiredMessage('Capability approval failed'),
).toBeNull()
})
+
+test('parseCapabilityAccessRequiredBatchMessage returns approval entries', () => {
+ const entries = [
+ {
+ secretName: 'lutronUsername',
+ capabilityName: 'home_lutron_set_credentials',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/lutronUsername?capability=home_lutron_set_credentials',
+ },
+ {
+ secretName: 'lutronPassword',
+ capabilityName: 'home_lutron_set_credentials',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/lutronPassword?capability=home_lutron_set_credentials',
+ },
+ ]
+ const message = createCapabilitySecretAccessDeniedBatchMessage(entries)
+ expect(parseCapabilityAccessRequiredBatchMessage(message)).toEqual(entries)
+ expect(
+ parseCapabilityAccessRequiredBatchMessage(
+ createCapabilitySecretAccessDeniedBatchMessage([]),
+ ),
+ ).toBeNull()
+ expect(
+ parseCapabilityAccessRequiredBatchMessage('Capability approval failed'),
+ ).toBeNull()
+})
+
+test('parseHostApprovalRequiredBatchMessage returns approval entries', () => {
+ const entries = [
+ {
+ secretName: 'cloudflareToken',
+ host: 'api.cloudflare.com',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com&request=token',
+ },
+ {
+ secretName: 'githubToken',
+ host: 'api.github.com',
+ approvalUrl:
+ 'https://example.com/account/secrets/user/githubToken?allowed-host=api.github.com&request=token',
+ },
+ ]
+ const message = createHostSecretAccessDeniedBatchMessage(entries)
+ expect(parseHostApprovalRequiredBatchMessage(message)).toEqual(entries)
+ expect(
+ parseHostApprovalRequiredBatchMessage(
+ createHostSecretAccessDeniedBatchMessage([]),
+ ),
+ ).toBeNull()
+ expect(parseHostApprovalRequiredBatchMessage('Host approval failed')).toBeNull()
+})
diff --git a/packages/worker/src/mcp/secrets/errors.ts b/packages/worker/src/mcp/secrets/errors.ts
index 585a2adcb9..f21373afd2 100644
--- a/packages/worker/src/mcp/secrets/errors.ts
+++ b/packages/worker/src/mcp/secrets/errors.ts
@@ -2,6 +2,8 @@ const hostApprovalRequiredRegex =
/^Secret "([^"]+)" is not allowed for host "([^"]+)"/
const capabilityAccessRequiredRegex =
/^Secret "([^"]+)" is not allowed for capability "([^"]+)"/
+const capabilityBatchDeniedPrefix = 'Secrets require capability approval:'
+const hostBatchDeniedPrefix = 'Secrets require host approval:'
const missingSecretRegex = /^Secret "([^"]+)" was not found\.$/
export const fetchSecretAuthRequiredMessage =
@@ -22,8 +24,96 @@ export function createMissingSecretMessage(secretName: string) {
export function createCapabilitySecretAccessDeniedMessage(
secretName: string,
capabilityName: string,
+ approvalUrl?: string | null,
) {
- return `Secret "${secretName}" is not allowed for capability "${capabilityName}". If this capability should be able to use the secret, ask the user whether to add "${capabilityName}" to the secret's allowed capabilities in the account secrets UI, then retry after they approve that policy change.`
+ const approvalSuffix = approvalUrl
+ ? ` Approval link: ${approvalUrl}`
+ : ''
+ return `Secret "${secretName}" is not allowed for capability "${capabilityName}". If this capability should be able to use the secret, ask the user whether to add "${capabilityName}" to the secret's allowed capabilities in the account secrets UI, then retry after they approve that policy change.${approvalSuffix}`
+}
+
+export type CapabilityApprovalEntry = {
+ secretName: string
+ capabilityName: string
+ approvalUrl: string
+}
+
+export type HostApprovalEntry = {
+ secretName: string
+ host: string
+ approvalUrl: string
+}
+
+export function createCapabilitySecretAccessDeniedBatchMessage(
+ entries: Array,
+) {
+ const payload = JSON.stringify(entries)
+ return `${capabilityBatchDeniedPrefix} ${payload}`
+}
+
+export function createHostSecretAccessDeniedBatchMessage(
+ entries: Array,
+) {
+ const payload = JSON.stringify(entries)
+ return `${hostBatchDeniedPrefix} ${payload}`
+}
+
+export function parseCapabilityAccessRequiredBatchMessage(message: string) {
+ if (!message.startsWith(capabilityBatchDeniedPrefix)) return null
+ const raw = message.slice(capabilityBatchDeniedPrefix.length).trim()
+ if (!raw) return null
+ try {
+ const parsed = JSON.parse(raw)
+ if (!Array.isArray(parsed)) return null
+ const entries: Array = []
+ for (const entry of parsed) {
+ if (!entry || typeof entry !== 'object') continue
+ if (
+ typeof entry.secretName !== 'string' ||
+ typeof entry.capabilityName !== 'string' ||
+ typeof entry.approvalUrl !== 'string'
+ ) {
+ continue
+ }
+ entries.push({
+ secretName: entry.secretName,
+ capabilityName: entry.capabilityName,
+ approvalUrl: entry.approvalUrl,
+ })
+ }
+ return entries.length > 0 ? entries : null
+ } catch {
+ return null
+ }
+}
+
+export function parseHostApprovalRequiredBatchMessage(message: string) {
+ if (!message.startsWith(hostBatchDeniedPrefix)) return null
+ const raw = message.slice(hostBatchDeniedPrefix.length).trim()
+ if (!raw) return null
+ try {
+ const parsed = JSON.parse(raw)
+ if (!Array.isArray(parsed)) return null
+ const entries: Array = []
+ for (const entry of parsed) {
+ if (!entry || typeof entry !== 'object') continue
+ if (
+ typeof entry.secretName !== 'string' ||
+ typeof entry.host !== 'string' ||
+ typeof entry.approvalUrl !== 'string'
+ ) {
+ continue
+ }
+ entries.push({
+ secretName: entry.secretName,
+ host: entry.host,
+ approvalUrl: entry.approvalUrl,
+ })
+ }
+ return entries.length > 0 ? entries : null
+ } catch {
+ return null
+ }
}
export function parseMissingSecretMessage(message: string) {
From 4e19e328b3ebeecd4c3dbb770777590291a49a46 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sat, 28 Mar 2026 01:23:13 +0000
Subject: [PATCH 2/8] Fix post-rebase CI failures
Co-authored-by: Kent C. Dodds
---
.../mcp-apps/generated-ui-widget-runtime.ts | 48 ++++++++++++++++++-
.../client/routes/account-approval-shared.ts | 1 +
.../worker/src/mcp/mcp-server.mcp-e2e.test.ts | 39 ++++++++-------
.../worker/src/mcp/run-codemode-registry.ts | 35 +++++++-------
packages/worker/src/mcp/tools/execute.ts | 2 +-
5 files changed, 89 insertions(+), 36 deletions(-)
diff --git a/packages/worker/client/mcp-apps/generated-ui-widget-runtime.ts b/packages/worker/client/mcp-apps/generated-ui-widget-runtime.ts
index 7caa2483d0..4aec866003 100644
--- a/packages/worker/client/mcp-apps/generated-ui-widget-runtime.ts
+++ b/packages/worker/client/mcp-apps/generated-ui-widget-runtime.ts
@@ -374,6 +374,49 @@ function extractApprovalDetails(
fallbackSecretNames: Array = [],
): ApprovalDetails {
const text = typeof message === 'string' ? message : String(message ?? '')
+ const hostBatchPrefix = 'Secrets require host approval:'
+ if (text.startsWith(hostBatchPrefix)) {
+ const raw = text.slice(hostBatchPrefix.length).trim()
+ try {
+ const parsed = JSON.parse(raw)
+ if (Array.isArray(parsed)) {
+ const secretNames = normalizeSecretNameList([
+ ...parsed
+ .map((entry) =>
+ entry && typeof entry.secretName === 'string'
+ ? entry.secretName
+ : null,
+ )
+ .filter((entry): entry is string => Boolean(entry)),
+ ...fallbackSecretNames,
+ ])
+ const first = parsed.find(
+ (entry) =>
+ entry &&
+ typeof entry.approvalUrl === 'string' &&
+ typeof entry.host === 'string',
+ )
+ if (first) {
+ return {
+ message: text,
+ approvalUrl: first.approvalUrl,
+ host: first.host,
+ secretNames,
+ }
+ }
+ if (secretNames.length > 0) {
+ return {
+ message: text,
+ approvalUrl: null,
+ host: null,
+ secretNames,
+ }
+ }
+ }
+ } catch {
+ // Fall through to legacy parsing.
+ }
+ }
const secretNames = normalizeSecretNameList([
...Array.from(text.matchAll(/Secret "([^"]+)"/g))
.map((match) => match[1])
@@ -1493,7 +1536,10 @@ export function initializeGeneratedUiRuntime() {
return normalizeFetchWithSecretsResult(result)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
- if (message.includes('not allowed for host')) {
+ if (
+ message.includes('not allowed for host') ||
+ message.includes('Secrets require host approval:')
+ ) {
const approval = extractApprovalDetails(message, fallbackSecretNames)
return {
ok: false,
diff --git a/packages/worker/client/routes/account-approval-shared.ts b/packages/worker/client/routes/account-approval-shared.ts
index cafcd492a0..b8949ae829 100644
--- a/packages/worker/client/routes/account-approval-shared.ts
+++ b/packages/worker/client/routes/account-approval-shared.ts
@@ -7,6 +7,7 @@ export type ApprovalView = {
name: string
scope: ApprovalScope
requestedHost: string
+ requestedCapability: string | null
currentAllowedHosts: Array
}
diff --git a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
index a287b64cc2..71be60a8e8 100644
--- a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
+++ b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
@@ -1585,33 +1585,36 @@ test('generated ui sessions support secret storage, execute-time resolution, and
}
expect(blockedFetchExecutePayload.ok).toBe(false)
expect(blockedFetchExecutePayload.error).toContain(
- 'Secret "cloudflareToken" is not allowed for host "example.com"',
+ 'Secrets require host approval:',
)
- expect(blockedFetchExecutePayload.error).toContain(
- "ask the user whether this host should be added to the secret's allowed hosts",
+ const approvalUrl = (
+ blockedFetchExecutePayload.errorDetails as
+ | {
+ missingApprovals?: Array<{ approvalUrl?: string }>
+ }
+ | undefined
+ )?.missingApprovals?.[0]?.approvalUrl
+ expect(approvalUrl).toMatch(
+ /https?:\/\/\S*\/account\/secrets\/[^\s?]+\?[^)\s]*allowed-host=[^&\s)]+[^)\s]*/,
)
- const approvalMatch =
- blockedFetchExecutePayload.error?.match(
- /https?:\/\/\S*\/account\/secrets\/[^\s?]+\?[^)\s]*allowed-host=[^&\s)]+[^)\s]*/,
- ) ?? null
- expect(approvalMatch).not.toBeNull()
expect(blockedFetchExecutePayload.errorDetails).toEqual({
- kind: 'host_approval_required',
- message: expect.stringContaining(
- 'Secret "cloudflareToken" is not allowed for host "example.com"',
- ),
+ kind: 'host_approval_required_batch',
+ message: expect.stringContaining('Secrets require host approval:'),
nextStep:
- 'Ask the user whether they want to approve this host in the account web UI, then retry after approval.',
- approvalUrl: approvalMatch![0],
- host: 'example.com',
- secretNames: ['cloudflareToken'],
+ 'Ask the user whether they want to approve these hosts for the listed secrets in the account web UI, then retry after approval.',
+ missingApprovals: [
+ expect.objectContaining({
+ secretName: 'cloudflareToken',
+ host: 'example.com',
+ }),
+ ],
suggestedAction: {
type: 'approve_secret_host',
},
})
const appCookieHeader = await loginToApp(server.origin, database.user)
- const approvalResponse = await fetch(approvalMatch![0]!, {
+ const approvalResponse = await fetch(approvalUrl!, {
headers: {
Cookie: appCookieHeader,
},
@@ -1629,7 +1632,7 @@ test('generated ui sessions support secret storage, execute-time resolution, and
},
body: JSON.stringify({
action: 'approve',
- requestToken: new URL(approvalMatch![0]!).searchParams.get('request'),
+ requestToken: new URL(approvalUrl!).searchParams.get('request'),
}),
},
)
diff --git a/packages/worker/src/mcp/run-codemode-registry.ts b/packages/worker/src/mcp/run-codemode-registry.ts
index c0e1c44d15..a6d74b8f53 100644
--- a/packages/worker/src/mcp/run-codemode-registry.ts
+++ b/packages/worker/src/mcp/run-codemode-registry.ts
@@ -108,7 +108,7 @@ function createCapabilityInputSecretResolver(
name: secret.name,
scope: resolved.scope ?? secret.scope ?? 'user',
capabilityName,
- storageContext: callerContext.storageContext ?? null,
+ storageContext: callerContext.storageContext,
})
throw new Error(
createCapabilitySecretAccessDeniedMessage(
@@ -137,10 +137,12 @@ export async function runCodemodeWithRegistry(
gatewayProps: {
baseUrl: callerContext.baseUrl,
userId: callerContext.user?.userId ?? null,
- storageContext: {
- sessionId: callerContext.storageContext?.sessionId ?? null,
- appId: callerContext.storageContext?.appId ?? null,
- },
+ storageContext: callerContext.storageContext
+ ? {
+ sessionId: callerContext.storageContext.sessionId ?? null,
+ appId: callerContext.storageContext.appId ?? null,
+ }
+ : null,
},
})
const provider = await buildCodemodeProvider(env, callerContext)
@@ -150,13 +152,13 @@ export async function runCodemodeWithRegistry(
: code
const result = await executor.execute(wrapped, [provider])
if (!result.error) return result
- const batchError = await rewriteCapabilitySecretError({
+ const batchMessage = await rewriteCapabilitySecretError({
error: result.error,
code: wrapped,
env,
callerContext,
})
- return batchError ? { ...result, error: batchError } : result
+ return batchMessage ? { ...result, error: batchMessage } : result
}
async function rewriteCapabilitySecretError(input: {
@@ -173,11 +175,9 @@ async function rewriteCapabilitySecretError(input: {
if (!capabilityMatch?.[1] || !capabilityMatch?.[2]) return null
const capabilityName = capabilityMatch[2]
const userId = input.callerContext.user?.userId ?? null
- if (!userId) return input.error instanceof Error ? input.error : new Error(message)
+ if (!userId) return null
const secretNames = collectSecretNamesFromCode(input.error, input.code)
- if (secretNames.length === 0) {
- return input.error instanceof Error ? input.error : new Error(message)
- }
+ if (secretNames.length === 0) return null
const missing = await findMissingCapabilityApprovals({
env: input.env,
userId,
@@ -186,10 +186,8 @@ async function rewriteCapabilitySecretError(input: {
storageContext: input.callerContext.storageContext ?? null,
baseUrl: input.callerContext.baseUrl,
})
- if (missing.length === 0) {
- return input.error instanceof Error ? input.error : new Error(message)
- }
- return new Error(createCapabilitySecretAccessDeniedBatchMessage(missing))
+ if (missing.length === 0) return null
+ return createCapabilitySecretAccessDeniedBatchMessage(missing)
}
function collectSecretNamesFromCode(
@@ -240,7 +238,12 @@ async function findMissingCapabilityApprovals(input: {
name,
scope: resolved.scope ?? 'user',
capabilityName: input.capabilityName,
- storageContext: input.storageContext,
+ storageContext: input.storageContext
+ ? {
+ sessionId: input.storageContext.sessionId ?? null,
+ appId: input.storageContext.appId ?? null,
+ }
+ : null,
})
return {
secretName: name,
diff --git a/packages/worker/src/mcp/tools/execute.ts b/packages/worker/src/mcp/tools/execute.ts
index 62bcfe1995..213de8301f 100644
--- a/packages/worker/src/mcp/tools/execute.ts
+++ b/packages/worker/src/mcp/tools/execute.ts
@@ -155,7 +155,7 @@ export async function registerExecuteTool(agent: McpRegistrationAgent) {
},
],
structuredContent: {
- error: result.error,
+ error: errorMessage,
errorDetails,
logs: result.logs ?? [],
},
From 9a2d4ee3211ed7d892dff6e4ac0627fafcf774a5 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sat, 28 Mar 2026 05:09:17 +0000
Subject: [PATCH 3/8] Fix storage context typing for approvals
Co-authored-by: Kent C. Dodds
---
packages/worker/src/mcp/run-codemode-registry.ts | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/packages/worker/src/mcp/run-codemode-registry.ts b/packages/worker/src/mcp/run-codemode-registry.ts
index a6d74b8f53..6bb6ce92c7 100644
--- a/packages/worker/src/mcp/run-codemode-registry.ts
+++ b/packages/worker/src/mcp/run-codemode-registry.ts
@@ -108,7 +108,12 @@ function createCapabilityInputSecretResolver(
name: secret.name,
scope: resolved.scope ?? secret.scope ?? 'user',
capabilityName,
- storageContext: callerContext.storageContext,
+ storageContext: callerContext.storageContext
+ ? {
+ sessionId: callerContext.storageContext.sessionId ?? null,
+ appId: callerContext.storageContext.appId ?? null,
+ }
+ : null,
})
throw new Error(
createCapabilitySecretAccessDeniedMessage(
@@ -139,8 +144,8 @@ export async function runCodemodeWithRegistry(
userId: callerContext.user?.userId ?? null,
storageContext: callerContext.storageContext
? {
- sessionId: callerContext.storageContext.sessionId ?? null,
- appId: callerContext.storageContext.appId ?? null,
+ sessionId: callerContext.storageContext.sessionId ?? null,
+ appId: callerContext.storageContext.appId ?? null,
}
: null,
},
From 61ac19c63ca72e2033f37b13a2e1832c98f2e213 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sat, 28 Mar 2026 05:10:50 +0000
Subject: [PATCH 4/8] Normalize storage context for approval URLs
Co-authored-by: Kent C. Dodds
---
packages/worker/src/mcp/run-codemode-registry.ts | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/packages/worker/src/mcp/run-codemode-registry.ts b/packages/worker/src/mcp/run-codemode-registry.ts
index 6bb6ce92c7..e01d73bd72 100644
--- a/packages/worker/src/mcp/run-codemode-registry.ts
+++ b/packages/worker/src/mcp/run-codemode-registry.ts
@@ -108,12 +108,12 @@ function createCapabilityInputSecretResolver(
name: secret.name,
scope: resolved.scope ?? secret.scope ?? 'user',
capabilityName,
- storageContext: callerContext.storageContext
- ? {
- sessionId: callerContext.storageContext.sessionId ?? null,
- appId: callerContext.storageContext.appId ?? null,
- }
- : null,
+ storageContext: callerContext.storageContext
+ ? {
+ sessionId: callerContext.storageContext.sessionId ?? null,
+ appId: callerContext.storageContext.appId ?? null,
+ }
+ : null,
})
throw new Error(
createCapabilitySecretAccessDeniedMessage(
@@ -144,8 +144,8 @@ export async function runCodemodeWithRegistry(
userId: callerContext.user?.userId ?? null,
storageContext: callerContext.storageContext
? {
- sessionId: callerContext.storageContext.sessionId ?? null,
- appId: callerContext.storageContext.appId ?? null,
+ sessionId: callerContext.storageContext.sessionId ?? null,
+ appId: callerContext.storageContext.appId ?? null,
}
: null,
},
From c13c9fcb395748a2746448dd85030bd1ef80a8c2 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sat, 28 Mar 2026 05:11:58 +0000
Subject: [PATCH 5/8] Apply formatting updates
Co-authored-by: Kent C. Dodds
---
.../client/mcp-apps/generated-ui-runtime-contract.ts | 5 +----
.../client/mcp-apps/generated-ui-widget-runtime.ts | 5 ++++-
packages/worker/src/mcp/executor.ts | 3 ++-
packages/worker/src/mcp/fetch-gateway.ts | 12 +++++++++---
packages/worker/src/mcp/run-codemode-registry.ts | 10 ++++------
packages/worker/src/mcp/secrets/errors.node.test.ts | 8 ++++++--
packages/worker/src/mcp/secrets/errors.ts | 4 +---
7 files changed, 27 insertions(+), 20 deletions(-)
diff --git a/packages/worker/client/mcp-apps/generated-ui-runtime-contract.ts b/packages/worker/client/mcp-apps/generated-ui-runtime-contract.ts
index ed668e2c3d..d080b075aa 100644
--- a/packages/worker/client/mcp-apps/generated-ui-runtime-contract.ts
+++ b/packages/worker/client/mcp-apps/generated-ui-runtime-contract.ts
@@ -45,9 +45,7 @@ export type GeneratedUiRuntimeBootstrap = {
export const generatedUiRuntimeModuleSpecifier = '@kody/utils' as const
-export function buildGeneratedUiRuntimeImportMap(
- runtimeScriptHref: string,
-) {
+export function buildGeneratedUiRuntimeImportMap(runtimeScriptHref: string) {
const importMapJson = escapeInlineScriptSource(
JSON.stringify({
imports: {
@@ -70,4 +68,3 @@ window.params = window.__kodyAppParams;
`.trim()
}
-
diff --git a/packages/worker/client/mcp-apps/generated-ui-widget-runtime.ts b/packages/worker/client/mcp-apps/generated-ui-widget-runtime.ts
index 4aec866003..c2d3656466 100644
--- a/packages/worker/client/mcp-apps/generated-ui-widget-runtime.ts
+++ b/packages/worker/client/mcp-apps/generated-ui-widget-runtime.ts
@@ -231,7 +231,10 @@ export const kodyWidget = new Proxy(Object.create(null), {
return Reflect.ownKeys(getKodyWidget())
},
getOwnPropertyDescriptor(_target, property) {
- const descriptor = Reflect.getOwnPropertyDescriptor(getKodyWidget(), property)
+ const descriptor = Reflect.getOwnPropertyDescriptor(
+ getKodyWidget(),
+ property,
+ )
if (!descriptor) {
return undefined
}
diff --git a/packages/worker/src/mcp/executor.ts b/packages/worker/src/mcp/executor.ts
index 9de1a9c60b..565a6d88c3 100644
--- a/packages/worker/src/mcp/executor.ts
+++ b/packages/worker/src/mcp/executor.ts
@@ -140,7 +140,8 @@ export function getExecutionErrorDetails(
}
}
- const capabilityAccessBatch = parseCapabilityAccessRequiredBatchMessage(message)
+ const capabilityAccessBatch =
+ parseCapabilityAccessRequiredBatchMessage(message)
if (capabilityAccessBatch) {
return {
kind: 'secret_capability_access_required_batch',
diff --git a/packages/worker/src/mcp/fetch-gateway.ts b/packages/worker/src/mcp/fetch-gateway.ts
index a7c1a2242b..c11ecdce32 100644
--- a/packages/worker/src/mcp/fetch-gateway.ts
+++ b/packages/worker/src/mcp/fetch-gateway.ts
@@ -94,7 +94,9 @@ export async function expandSecretPlaceholders(input: {
resolvedSecrets,
})
if (missingApprovals.length > 0) {
- throw new Error(createHostSecretAccessDeniedBatchMessage(missingApprovals))
+ throw new Error(
+ createHostSecretAccessDeniedBatchMessage(missingApprovals),
+ )
}
}
for (const [key, value] of Array.from(headers.entries())) {
@@ -134,7 +136,9 @@ async function collectHostApprovalEntries(input: {
}) {
const entries = await Promise.all(
input.resolvedSecrets.map(async ({ referenced, resolved }) => {
- const allowedForHost = resolved.allowedHosts.includes(input.normalizedHost)
+ const allowedForHost = resolved.allowedHosts.includes(
+ input.normalizedHost,
+ )
if (allowedForHost) return null
const approvalToken = await createSecretHostApprovalToken(input.env, {
userId: input.props.userId!,
@@ -158,7 +162,9 @@ async function collectHostApprovalEntries(input: {
}
}),
)
- return entries.filter((entry): entry is NonNullable => entry != null)
+ return entries.filter(
+ (entry): entry is NonNullable => entry != null,
+ )
}
function ensureFetchAllowed(props: FetchGatewayProps) {
diff --git a/packages/worker/src/mcp/run-codemode-registry.ts b/packages/worker/src/mcp/run-codemode-registry.ts
index e01d73bd72..09bbf0bf79 100644
--- a/packages/worker/src/mcp/run-codemode-registry.ts
+++ b/packages/worker/src/mcp/run-codemode-registry.ts
@@ -127,7 +127,6 @@ function createCapabilityInputSecretResolver(
}
}
-
export async function runCodemodeWithRegistry(
env: Env,
callerContext: McpCallerContext,
@@ -195,10 +194,7 @@ async function rewriteCapabilitySecretError(input: {
return createCapabilitySecretAccessDeniedBatchMessage(missing)
}
-function collectSecretNamesFromCode(
- error: unknown,
- code: string | null,
-) {
+function collectSecretNamesFromCode(error: unknown, code: string | null) {
const fromError =
error instanceof Error ? parseSecretNamesFromMessage(error.message) : []
const fromCode = code ? parseSecretNamesFromMessage(code) : []
@@ -257,5 +253,7 @@ async function findMissingCapabilityApprovals(input: {
}
}),
)
- return entries.filter((entry): entry is NonNullable => entry != null)
+ return entries.filter(
+ (entry): entry is NonNullable => entry != null,
+ )
}
diff --git a/packages/worker/src/mcp/secrets/errors.node.test.ts b/packages/worker/src/mcp/secrets/errors.node.test.ts
index 9fb1e466fc..9f9099c7df 100644
--- a/packages/worker/src/mcp/secrets/errors.node.test.ts
+++ b/packages/worker/src/mcp/secrets/errors.node.test.ts
@@ -59,7 +59,9 @@ test('parseHostApprovalRequiredBatchMessage returns approval entries', () => {
]
const message = createHostSecretAccessDeniedBatchMessage(entries)
expect(parseHostApprovalRequiredBatchMessage(message)).toEqual(entries)
- expect(parseHostApprovalRequiredBatchMessage('Host approval failed')).toBeNull()
+ expect(
+ parseHostApprovalRequiredBatchMessage('Host approval failed'),
+ ).toBeNull()
})
test('parseCapabilitySecretAccessDeniedMessage extracts secret and capability', () => {
@@ -126,5 +128,7 @@ test('parseHostApprovalRequiredBatchMessage returns approval entries', () => {
createHostSecretAccessDeniedBatchMessage([]),
),
).toBeNull()
- expect(parseHostApprovalRequiredBatchMessage('Host approval failed')).toBeNull()
+ expect(
+ parseHostApprovalRequiredBatchMessage('Host approval failed'),
+ ).toBeNull()
})
diff --git a/packages/worker/src/mcp/secrets/errors.ts b/packages/worker/src/mcp/secrets/errors.ts
index f21373afd2..30ff06c05a 100644
--- a/packages/worker/src/mcp/secrets/errors.ts
+++ b/packages/worker/src/mcp/secrets/errors.ts
@@ -26,9 +26,7 @@ export function createCapabilitySecretAccessDeniedMessage(
capabilityName: string,
approvalUrl?: string | null,
) {
- const approvalSuffix = approvalUrl
- ? ` Approval link: ${approvalUrl}`
- : ''
+ const approvalSuffix = approvalUrl ? ` Approval link: ${approvalUrl}` : ''
return `Secret "${secretName}" is not allowed for capability "${capabilityName}". If this capability should be able to use the secret, ask the user whether to add "${capabilityName}" to the secret's allowed capabilities in the account secrets UI, then retry after they approve that policy change.${approvalSuffix}`
}
From ad1a616045281f85c4b117521def26fcfd36a743 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sat, 28 Mar 2026 05:13:02 +0000
Subject: [PATCH 6/8] Normalize storage context in approval lookup
Co-authored-by: Kent C. Dodds
---
packages/worker/src/mcp/run-codemode-registry.ts | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/packages/worker/src/mcp/run-codemode-registry.ts b/packages/worker/src/mcp/run-codemode-registry.ts
index 09bbf0bf79..aad3acd1f5 100644
--- a/packages/worker/src/mcp/run-codemode-registry.ts
+++ b/packages/worker/src/mcp/run-codemode-registry.ts
@@ -182,12 +182,18 @@ async function rewriteCapabilitySecretError(input: {
if (!userId) return null
const secretNames = collectSecretNamesFromCode(input.error, input.code)
if (secretNames.length === 0) return null
- const missing = await findMissingCapabilityApprovals({
+const normalizedStorageContext = callerContext.storageContext
+ ? {
+ sessionId: callerContext.storageContext.sessionId ?? null,
+ appId: callerContext.storageContext.appId ?? null,
+ }
+ : null
+const missing = await findMissingCapabilityApprovals({
env: input.env,
userId,
secretNames,
capabilityName,
- storageContext: input.callerContext.storageContext ?? null,
+ storageContext: normalizedStorageContext,
baseUrl: input.callerContext.baseUrl,
})
if (missing.length === 0) return null
From 759e3c312157dc533927a3b60b8ef87b51483ad5 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sat, 28 Mar 2026 05:13:43 +0000
Subject: [PATCH 7/8] Format storage context update
Co-authored-by: Kent C. Dodds
---
packages/worker/src/mcp/run-codemode-registry.ts | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/packages/worker/src/mcp/run-codemode-registry.ts b/packages/worker/src/mcp/run-codemode-registry.ts
index aad3acd1f5..c5a5e02ec7 100644
--- a/packages/worker/src/mcp/run-codemode-registry.ts
+++ b/packages/worker/src/mcp/run-codemode-registry.ts
@@ -182,18 +182,18 @@ async function rewriteCapabilitySecretError(input: {
if (!userId) return null
const secretNames = collectSecretNamesFromCode(input.error, input.code)
if (secretNames.length === 0) return null
-const normalizedStorageContext = callerContext.storageContext
- ? {
- sessionId: callerContext.storageContext.sessionId ?? null,
- appId: callerContext.storageContext.appId ?? null,
- }
- : null
-const missing = await findMissingCapabilityApprovals({
+ const normalizedStorageContext = callerContext.storageContext
+ ? {
+ sessionId: callerContext.storageContext.sessionId ?? null,
+ appId: callerContext.storageContext.appId ?? null,
+ }
+ : null
+ const missing = await findMissingCapabilityApprovals({
env: input.env,
userId,
secretNames,
capabilityName,
- storageContext: normalizedStorageContext,
+ storageContext: normalizedStorageContext,
baseUrl: input.callerContext.baseUrl,
})
if (missing.length === 0) return null
From 6c35ac2ddd01668c4acce8dfe0b7a972e9f8b106 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sat, 28 Mar 2026 05:16:28 +0000
Subject: [PATCH 8/8] Reuse storage context normalization
Co-authored-by: Kent C. Dodds
---
.../worker/src/mcp/run-codemode-registry.ts | 56 +++++++++----------
1 file changed, 25 insertions(+), 31 deletions(-)
diff --git a/packages/worker/src/mcp/run-codemode-registry.ts b/packages/worker/src/mcp/run-codemode-registry.ts
index c5a5e02ec7..8b11bfc0c3 100644
--- a/packages/worker/src/mcp/run-codemode-registry.ts
+++ b/packages/worker/src/mcp/run-codemode-registry.ts
@@ -87,17 +87,15 @@ function createCapabilityInputSecretResolver(
if (!userId) {
throw new Error(capabilityInputSecretAuthRequiredMessage)
}
+ const normalizedStorageContext = normalizeStorageContext(
+ callerContext.storageContext ?? null,
+ )
const resolved = await resolveSecret({
env,
userId,
name: secret.name,
scope: secret.scope,
- storageContext: callerContext.storageContext
- ? {
- sessionId: callerContext.storageContext.sessionId ?? null,
- appId: callerContext.storageContext.appId ?? null,
- }
- : null,
+ storageContext: normalizedStorageContext,
})
if (!resolved.found || typeof resolved.value !== 'string') {
throw new Error(createMissingSecretMessage(secret.name))
@@ -108,12 +106,7 @@ function createCapabilityInputSecretResolver(
name: secret.name,
scope: resolved.scope ?? secret.scope ?? 'user',
capabilityName,
- storageContext: callerContext.storageContext
- ? {
- sessionId: callerContext.storageContext.sessionId ?? null,
- appId: callerContext.storageContext.appId ?? null,
- }
- : null,
+ storageContext: normalizedStorageContext,
})
throw new Error(
createCapabilitySecretAccessDeniedMessage(
@@ -135,18 +128,16 @@ export async function runCodemodeWithRegistry(
executorExports?: typeof workerExports,
) {
const { createExecuteExecutor } = await import('#mcp/executor.ts')
+ const normalizedStorageContext = normalizeStorageContext(
+ callerContext.storageContext ?? null,
+ )
const executor = createExecuteExecutor({
env,
exports: executorExports ?? workerExports,
gatewayProps: {
baseUrl: callerContext.baseUrl,
userId: callerContext.user?.userId ?? null,
- storageContext: callerContext.storageContext
- ? {
- sessionId: callerContext.storageContext.sessionId ?? null,
- appId: callerContext.storageContext.appId ?? null,
- }
- : null,
+ storageContext: normalizedStorageContext,
},
})
const provider = await buildCodemodeProvider(env, callerContext)
@@ -182,12 +173,9 @@ async function rewriteCapabilitySecretError(input: {
if (!userId) return null
const secretNames = collectSecretNamesFromCode(input.error, input.code)
if (secretNames.length === 0) return null
- const normalizedStorageContext = callerContext.storageContext
- ? {
- sessionId: callerContext.storageContext.sessionId ?? null,
- appId: callerContext.storageContext.appId ?? null,
- }
- : null
+ const normalizedStorageContext = normalizeStorageContext(
+ input.callerContext.storageContext,
+ )
const missing = await findMissingCapabilityApprovals({
env: input.env,
userId,
@@ -220,6 +208,16 @@ function normalizeSecretNameList(names: Array) {
).sort((left, right) => left.localeCompare(right))
}
+function normalizeStorageContext(
+ storageContext: McpCallerContext['storageContext'] | null,
+) {
+ if (!storageContext) return null
+ return {
+ sessionId: storageContext.sessionId ?? null,
+ appId: storageContext.appId ?? null,
+ }
+}
+
async function findMissingCapabilityApprovals(input: {
env: Env
userId: string
@@ -228,13 +226,14 @@ async function findMissingCapabilityApprovals(input: {
storageContext: McpCallerContext['storageContext'] | null
baseUrl: string
}) {
+ const normalizedStorageContext = normalizeStorageContext(input.storageContext)
const entries = await Promise.all(
input.secretNames.map(async (name) => {
const resolved = await resolveSecret({
env: input.env,
userId: input.userId,
name,
- storageContext: input.storageContext,
+ storageContext: normalizedStorageContext,
})
if (!resolved.found) return null
if (resolved.allowedCapabilities.includes(input.capabilityName)) {
@@ -245,12 +244,7 @@ async function findMissingCapabilityApprovals(input: {
name,
scope: resolved.scope ?? 'user',
capabilityName: input.capabilityName,
- storageContext: input.storageContext
- ? {
- sessionId: input.storageContext.sessionId ?? null,
- appId: input.storageContext.appId ?? null,
- }
- : null,
+ storageContext: normalizedStorageContext,
})
return {
secretName: name,