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 7caa2483d0..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
}
@@ -374,6 +377,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 +1539,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/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..565a6d88c3 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,36 @@ 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 +165,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..c11ecdce32 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,26 +86,16 @@ 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,
- })
+ const missingApprovals = await collectHostApprovalEntries({
+ env: input.env,
+ props: input.props,
+ requestedHost,
+ normalizedHost,
+ resolvedSecrets,
+ })
+ if (missingApprovals.length > 0) {
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}`,
+ createHostSecretAccessDeniedBatchMessage(missingApprovals),
)
}
}
@@ -133,6 +124,49 @@ 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/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.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..8b11bfc0c3 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'
@@ -85,24 +87,33 @@ 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))
}
if (!resolved.allowedCapabilities.includes(capabilityName)) {
+ const approvalUrl = buildSecretCapabilityApprovalUrl({
+ baseUrl: callerContext.baseUrl,
+ name: secret.name,
+ scope: resolved.scope ?? secret.scope ?? 'user',
+ capabilityName,
+ storageContext: normalizedStorageContext,
+ })
throw new Error(
- createCapabilitySecretAccessDeniedMessage(secret.name, capabilityName),
+ createCapabilitySecretAccessDeniedMessage(
+ secret.name,
+ capabilityName,
+ approvalUrl,
+ ),
)
}
return resolved.value
@@ -117,16 +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: {
- sessionId: callerContext.storageContext?.sessionId ?? null,
- appId: callerContext.storageContext?.appId ?? null,
- },
+ storageContext: normalizedStorageContext,
},
})
const provider = await buildCodemodeProvider(env, callerContext)
@@ -134,5 +145,115 @@ 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 batchMessage = await rewriteCapabilitySecretError({
+ error: result.error,
+ code: wrapped,
+ env,
+ callerContext,
+ })
+ return batchMessage ? { ...result, error: batchMessage } : 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 null
+ const secretNames = collectSecretNamesFromCode(input.error, input.code)
+ if (secretNames.length === 0) return null
+ const normalizedStorageContext = normalizeStorageContext(
+ input.callerContext.storageContext,
+ )
+ const missing = await findMissingCapabilityApprovals({
+ env: input.env,
+ userId,
+ secretNames,
+ capabilityName,
+ storageContext: normalizedStorageContext,
+ baseUrl: input.callerContext.baseUrl,
+ })
+ if (missing.length === 0) return null
+ return 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))
+}
+
+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
+ secretNames: Array
+ capabilityName: string
+ 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: normalizedStorageContext,
+ })
+ 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: normalizedStorageContext,
+ })
+ 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..9f9099c7df 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,33 @@ 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 +78,57 @@ 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..30ff06c05a 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,94 @@ 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) {
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 ?? [],
},