Skip to content

Add batched secret approval errors with deep links - #72

Merged
kentcdodds merged 8 commits into
mainfrom
cursor/secret-capability-error-flow-9280
Mar 28, 2026
Merged

kentcdodds merged 8 commits into
mainfrom
cursor/secret-capability-error-flow-9280

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Mar 28, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • include prefilled capability URLs when a secret is blocked by allowed-capabilities policy
  • aggregate missing secret approvals for capability and host checks into batch error details
  • surface capability query param in account secrets approval view and generated UI host approvals

Testing

  • npm run test -- packages/worker/src/mcp/capabilities/capability-search.workers.test.ts packages/worker/src/mcp/run-codemode-registry.node.test.ts packages/worker/src/mcp/executor.node.test.ts packages/worker/src/mcp/secrets/errors.node.test.ts
  • npm run test:mcp -- packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features
    • Prefill secret approval UI from a capability URL parameter and display the requested capability in the host-approval banner.
    • Support batch approval messages: errors can list multiple missing approvals and include direct approval links to act on them quickly.
  • Bug Fixes / UX
    • Refresh logic now respects capability changes; capability query params are cleared from the URL after approval.

@coderabbitai

coderabbitai Bot commented Mar 28, 2026 •

Copy link
Copy Markdown

Warning

Rate limit exceeded

@cursor[bot] has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 4 minutes and 28 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 4 minutes and 28 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: de6fff63-934b-444e-a5d9-11384ef0ecba

📥 Commits

Reviewing files that changed from the base of the PR and between 01d6025 and 6c35ac2.

📒 Files selected for processing (15)
  • packages/worker/client/mcp-apps/generated-ui-runtime-contract.ts
  • packages/worker/client/mcp-apps/generated-ui-widget-runtime.ts
  • packages/worker/client/routes/account-approval-shared.ts
  • packages/worker/client/routes/account-secrets.tsx
  • packages/worker/src/app/handlers/account-secrets.ts
  • packages/worker/src/mcp/executor.node.test.ts
  • packages/worker/src/mcp/executor.ts
  • packages/worker/src/mcp/fetch-gateway.ts
  • packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
  • packages/worker/src/mcp/run-codemode-registry.node.test.ts
  • packages/worker/src/mcp/run-codemode-registry.ts
  • packages/worker/src/mcp/secrets/capability-approval-url.ts
  • packages/worker/src/mcp/secrets/errors.node.test.ts
  • packages/worker/src/mcp/secrets/errors.ts
  • packages/worker/src/mcp/tools/execute.ts
📝 Walkthrough

Walkthrough

Adds capability-prefill plumbing (URL query → editor state → handler), constructs capability-specific approval URLs, introduces batch approval-denied message formats/parsers, and updates execution flow to aggregate and report batch host/capability approval requirements.

Changes

Cohort / File(s) Summary
Client-side capability prefill
packages/worker/client/routes/account-secrets.tsx
Parse capability query param, prefill/merge into editor allowedCapabilities, include capability in data-refresh key, and remove capability from URL after host approval. Display approval.requestedCapability in host approval banner.
Server-side handler plumbing
packages/worker/src/app/handlers/account-secrets.ts
Read capability from request URL via readRequestedCapability, add requestedCapability to SecretApprovalView, and thread it through payload/build/resolver.
Approval URL builder
packages/worker/src/mcp/secrets/capability-approval-url.ts
New exported buildSecretCapabilityApprovalUrl that composes a secret approval URL including capability query param using secret path and optional storageContext.
Batch error messages & parsers
packages/worker/src/mcp/secrets/errors.ts, packages/worker/src/mcp/secrets/errors.node.test.ts
Add batch message prefixes/types and exported types CapabilityApprovalEntry / HostApprovalEntry. Add batch creators/parsers and allow single-item capability messages to include optional approvalUrl. Tests added/updated to validate batch serialization/parsing and approvalUrl suffix.
Executor error shape & parsing
packages/worker/src/mcp/executor.ts, packages/worker/src/mcp/executor.node.test.ts
Extend ExecutionErrorDetails with host_approval_required_batch and secret_capability_access_required_batch shapes; detect batch messages before single-item parsing; add approvalUrl to single-item capability error details. Tests updated/added for batch recognition and suggested actions.
Host approval collection
packages/worker/src/mcp/fetch-gateway.ts
Refactor per-secret host allowance checks into collectHostApprovalEntries to gather all disallowed secret/host pairs concurrently and throw a single batch error via createHostSecretAccessDeniedBatchMessage.
Codemode error rewriting & approval aggregation
packages/worker/src/mcp/run-codemode-registry.ts, packages/worker/src/mcp/run-codemode-registry.node.test.ts
On capability-denied errors, collect all referenced secrets, resolve them, detect those missing the capability, build approval URLs, and rewrite the error into a capability-denied batch message when applicable. Tests updated to expect approval URL-containing messages.
UI shell & hosted HTML parsing
packages/worker/client/mcp-apps/generated-ui-shell.ts, packages/worker/src/app/saved-ui-hosted-html.ts
Add parseHostApprovalBatchMessage and prefer batch-parsed approval data in extractApprovalDetails. Treat batch messages as host-approval-required in fetch error handling and aggregate secret names/URLs for display.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Server as AccountSecrets<br/>Handler
    participant ApprovalView as SecretApprovalView

    Client->>Server: GET /secrets?capability=home_lutron
    Server->>Server: readRequestedCapability(url)
    Server->>ApprovalView: resolveSecretApprovalView(requestedCapability)
    ApprovalView-->>Server: SecretApprovalView { requestedCapability }
    Server-->>Client: HTML with approval banner
    Client->>Client: applyCapabilityPrefill(capability)
    Client->>Server: POST host approval
    Server-->>Client: Redirect to /secrets (capability cleared)
Loading
sequenceDiagram
    participant Codemode as Codemode Execution
    participant Registry as runCodemodeWithRegistry
    participant Storage as Secret Storage
    participant Executor as Executor

    Codemode->>Registry: runCodemodeWithRegistry(code)
    Registry->>Storage: resolve secret placeholders
    Storage-->>Registry: error (capability denied)
    Registry->>Registry: rewriteCapabilitySecretError(error)
    Registry->>Storage: resolve all referenced secrets
    Storage-->>Registry: secret objects
    Registry->>Registry: buildSecretCapabilityApprovalUrl for each missing
    Registry->>Executor: throw batch denial message
    Executor->>Executor: parseCapabilityAccessRequiredBatchMessage
    Executor-->>Client: ExecutionErrorDetails { kind: batch, missingApprovals[] }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

Poem

🐰 I hopped through query strings bright,

threaded capabilities into the light,
Batch of links to open doors,
Secrets granted, fewer chores,
Tiny paws, approval night ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main change: adding batched secret approval errors with deep links. This aligns with the core functionality across all modified files—aggregating multiple missing approvals and including approval URLs.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/secret-capability-error-flow-9280

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@kentcdodds
kentcdodds marked this pull request as ready for review March 28, 2026 00:47
@github-actions

github-actions Bot commented Mar 28, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-72.kentcdodds.workers.dev

Worker: kody-pr-72
D1: kody-pr-72-db
KV: kody-pr-72-oauth-kv

Mocks:

Comment thread packages/worker/src/mcp/run-codemode-registry.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/worker/client/routes/account-secrets.tsx`:
- Around line 798-803: The shared ApprovalView type is missing the
requestedCapability field referenced by account-secrets.tsx; update the
ApprovalView export (in account-approval-shared.ts) to include
requestedCapability: string | null so it matches the backend SecretApprovalView
and fixes TS2339 for approval.requestedCapability used in the
ApprovalView-consuming components.

In `@packages/worker/src/mcp/run-codemode-registry.ts`:
- Around line 195-215: collectSecretNamesFromCode currently strips scope and
parses only error messages; change it to parse real secret placeholders from
wrapped code by calling parseSecretPlaceholders(code) (not
parseSecretNamesFromMessage) and return a list of ReferencedSecret objects
(preserving name and scope) while still extracting bare names from errors with
parseSecretNamesFromMessage when needed; update downstream rewrite logic that
consumes collectSecretNamesFromCode (and any use of
normalizeSecretNameList/parseSecretNamesFromMessage) to accept and propagate
ReferencedSecret so multi-secret calls aren’t collapsed and scopes are preserved
for the resolver and approval URLs.
- Around line 151-159: The current post-execute path calls
rewriteCapabilitySecretError and can throw, causing runCodemodeWithRegistry to
propagate enrichment failures instead of returning the original ExecuteResult;
change this to best-effort by wrapping the call to rewriteCapabilitySecretError
(and any awaits like resolveSecret it triggers) in a try/catch inside the block
that follows executor.execute(wrapped, [provider]) and, on any thrown error,
log/ignore the enrichment error and return the original result object (the
variable result) unchanged; ensure you still return the enriched result when
rewriteCapabilitySecretError succeeds (i.e., batchError ? { ...result, error:
batchError } : result).
- Around line 106-112: Normalize callerContext.storageContext once into the
required StorageContext shape (ensuring sessionId and appId are present) and
reuse that normalized variable when calling buildSecretCapabilityApprovalUrl
instead of passing callerContext.storageContext directly; likewise, in
findMissingCapabilityApprovals normalize input.storageContext up-front and pass
that normalized value to both resolveSecret and buildSecretCapabilityApprovalUrl
so both receive a StorageContext | null with the required properties (update
usages around resolveSecret and buildSecretCapabilityApprovalUrl to reference
the normalized variable).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0d2add05-98fe-4192-9007-0e50f5586a06

📥 Commits

Reviewing files that changed from the base of the PR and between d4ba042 and 2cd22a0.

📒 Files selected for processing (10)
  • packages/worker/client/routes/account-secrets.tsx
  • packages/worker/src/app/handlers/account-secrets.ts
  • packages/worker/src/mcp/executor.node.test.ts
  • packages/worker/src/mcp/executor.ts
  • packages/worker/src/mcp/fetch-gateway.ts
  • packages/worker/src/mcp/run-codemode-registry.node.test.ts
  • packages/worker/src/mcp/run-codemode-registry.ts
  • packages/worker/src/mcp/secrets/capability-approval-url.ts
  • packages/worker/src/mcp/secrets/errors.node.test.ts
  • packages/worker/src/mcp/secrets/errors.ts

Comment thread packages/worker/client/routes/account-secrets.tsx
Comment thread packages/worker/src/mcp/run-codemode-registry.ts
Comment thread packages/worker/src/mcp/run-codemode-registry.ts Outdated
Comment on lines +195 to +215
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<string>) {
return Array.from(
new Set(names.map((name) => name.trim()).filter((name) => name.length > 0)),
).sort((left, right) => left.localeCompare(right))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Parse actual secret placeholders here and keep their scope.

wrapped code contains placeholders like {{secret:lutronUsername|scope=user}}, not Secret "lutronUsername", so fromCode is usually empty. A multi-secret capability call will still rewrite to a one-entry batch, and collapsing everything to bare names also strips the scope the downstream resolver needs for app/session approval URLs. Parse code with parseSecretPlaceholders() and carry ReferencedSecret through the rest of the rewrite path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/run-codemode-registry.ts` around lines 195 - 215,
collectSecretNamesFromCode currently strips scope and parses only error
messages; change it to parse real secret placeholders from wrapped code by
calling parseSecretPlaceholders(code) (not parseSecretNamesFromMessage) and
return a list of ReferencedSecret objects (preserving name and scope) while
still extracting bare names from errors with parseSecretNamesFromMessage when
needed; update downstream rewrite logic that consumes collectSecretNamesFromCode
(and any use of normalizeSecretNameList/parseSecretNamesFromMessage) to accept
and propagate ReferencedSecret so multi-secret calls aren’t collapsed and scopes
are preserved for the resolver and approval URLs.

Comment thread packages/worker/src/mcp/fetch-gateway.ts
Comment thread packages/worker/src/mcp/run-codemode-registry.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
packages/worker/src/mcp/run-codemode-registry.ts (1)

223-257: ⚠️ Potential issue | 🔴 Critical

Normalize storageContext to fix type errors at lines 237 and 248.

Static analysis flags type mismatches at lines 237 and 248. The parameter input.storageContext is typed as McpCallerContext['storageContext'] | null which has optional sessionId/appId, but both resolveSecret and buildSecretCapabilityApprovalUrl expect StorageContext | null with required properties.

🔧 Proposed fix - normalize once at the start of the function
 async function findMissingCapabilityApprovals(input: {
 	env: Env
 	userId: string
 	secretNames: Array<string>
 	capabilityName: string
 	storageContext: McpCallerContext['storageContext'] | null
 	baseUrl: string
 }) {
+	const normalizedStorageContext = input.storageContext
+		? {
+				sessionId: input.storageContext.sessionId ?? null,
+				appId: input.storageContext.appId ?? null,
+			}
+		: null
 	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)) {
 				return null
 			}
 			const approvalUrl = buildSecretCapabilityApprovalUrl({
 				baseUrl: input.baseUrl,
 				name,
 				scope: resolved.scope ?? 'user',
 				capabilityName: input.capabilityName,
-				storageContext: input.storageContext,
+				storageContext: normalizedStorageContext,
 			})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/run-codemode-registry.ts` around lines 223 - 257,
Normalize the optional storageContext at the start of
findMissingCapabilityApprovals so the downstream calls get a StorageContext |
null with required fields: create a local normalizedStorageContext (type
StorageContext | null) from input.storageContext by returning null when absent
and otherwise constructing an object that supplies the required appId/sessionId
(or sensible defaults) and use that normalizedStorageContext in the calls to
resolveSecret and buildSecretCapabilityApprovalUrl instead of
input.storageContext; update the two call sites that currently pass
input.storageContext (the resolveSecret call and the
buildSecretCapabilityApprovalUrl call) to use the normalized variable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/worker/src/mcp/run-codemode-registry.ts`:
- Around line 223-257: Normalize the optional storageContext at the start of
findMissingCapabilityApprovals so the downstream calls get a StorageContext |
null with required fields: create a local normalizedStorageContext (type
StorageContext | null) from input.storageContext by returning null when absent
and otherwise constructing an object that supplies the required appId/sessionId
(or sensible defaults) and use that normalizedStorageContext in the calls to
resolveSecret and buildSecretCapabilityApprovalUrl instead of
input.storageContext; update the two call sites that currently pass
input.storageContext (the resolveSecret call and the
buildSecretCapabilityApprovalUrl call) to use the normalized variable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2a341c40-db4d-4fbf-86d5-da8d6884f5a5

📥 Commits

Reviewing files that changed from the base of the PR and between 2cd22a0 and 7911f32.

📒 Files selected for processing (1)
  • packages/worker/src/mcp/run-codemode-registry.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ Duplicate comments (4)
packages/worker/src/mcp/run-codemode-registry.ts (4)

110-116: ⚠️ Potential issue | 🟠 Major

Normalize storageContext before passing to buildSecretCapabilityApprovalUrl.

The callerContext.storageContext has optional sessionId/appId properties (may be undefined), but buildSecretCapabilityApprovalUrl expects StorageContext | null where both are required (but nullable). Apply the same normalization pattern used at lines 99-104:

 const approvalUrl = buildSecretCapabilityApprovalUrl({
 	baseUrl: callerContext.baseUrl,
 	name: secret.name,
 	scope: resolved.scope ?? secret.scope ?? 'user',
 	capabilityName,
-	storageContext: callerContext.storageContext ?? null,
+	storageContext: callerContext.storageContext
+		? {
+				sessionId: callerContext.storageContext.sessionId ?? null,
+				appId: callerContext.storageContext.appId ?? null,
+			}
+		: null,
 })

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/run-codemode-registry.ts` around lines 110 - 116,
Normalize callerContext.storageContext before calling
buildSecretCapabilityApprovalUrl: create a local storageContext variable that,
if callerContext.storageContext is present, maps sessionId and appId to either
their values or null (i.e. { sessionId: storage.sessionId ?? null, appId:
storage.appId ?? null }), otherwise set storageContext to null; then pass that
normalized storageContext into buildSecretCapabilityApprovalUrl (referencing
buildSecretCapabilityApprovalUrl, callerContext.storageContext, and
approvalUrl).

203-214: ⚠️ Potential issue | 🟠 Major

Secret scope is discarded, causing incorrect resolution downstream.

collectSecretNamesFromCode returns Array<string> (bare names), but parseSecretPlaceholders provides ReferencedSecret[] including scope (e.g., {{secret:foo|scope=app}}). Discarding scope means findMissingCapabilityApprovals cannot pass scope to resolveSecret, potentially resolving the wrong secret or generating approval URLs for the wrong scope.

Consider returning ReferencedSecret[] and threading scope through to findMissingCapabilityApprovals.

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/run-codemode-registry.ts` around lines 203 - 214,
collectSecretNamesFromCode currently flattens secrets to bare strings and drops
scope info, which breaks downstream resolution; change
collectSecretNamesFromCode to return ReferencedSecret[] (preserving scope) by
combining outputs of parseSecretNamesFromMessage /
parseSecretNamesFromCapabilityCalls (or the parseSecretPlaceholders variant that
yields ReferencedSecret) instead of calling normalizeSecretNameList, and update
any callers such as findMissingCapabilityApprovals to accept and thread
ReferencedSecret objects into resolveSecret so scope is used when constructing
approval URLs and resolving secrets.

318-336: ⚠️ Potential issue | 🟠 Major

Normalize storageContext and pass scope to resolveSecret.

Two issues in this resolution logic:

  1. Type mismatch: input.storageContext has optional sessionId/appId but resolveSecret and buildSecretCapabilityApprovalUrl expect required-but-nullable properties (same issue as lines 110-116).

  2. Missing scope: resolveSecret is called without a scope parameter (compare to lines 94-98 where scope: secret.scope is passed). Without scope, the resolver may find a different secret than what the code actually references.

Both stem from losing ReferencedSecret information upstream. Once collectSecretNamesFromCode returns ReferencedSecret[] instead of string[], this function should accept and use those objects:

Partial fix for type normalization
+const normalizedStorageContext = input.storageContext
+	? {
+			sessionId: input.storageContext.sessionId ?? null,
+			appId: input.storageContext.appId ?? null,
+		}
+	: null
 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,
 		})
 		// ...
 		const approvalUrl = buildSecretCapabilityApprovalUrl({
 			baseUrl: input.baseUrl,
 			name,
 			scope: resolved.scope ?? 'user',
 			capabilityName: input.capabilityName,
-			storageContext: input.storageContext,
+			storageContext: normalizedStorageContext,
 		})

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/run-codemode-registry.ts` around lines 318 - 336, The
loop in run-codemode-registry must treat input.secretNames as ReferencedSecret[]
and normalize storageContext before calling resolvers: update the parameter type
where collectSecretNamesFromCode feeds into runCodemodeRegistry so secret
entries are objects with {name, scope, storageContext?}, pass the secret's scope
into resolveSecret (e.g., scope: secret.scope) and ensure storageContext is
converted to the required shape (provide sessionId/appId keys as null when
absent) before calling both resolveSecret and buildSecretCapabilityApprovalUrl;
adjust references to input.secretNames.map and the variables used inside the
async callback to use the ReferencedSecret fields rather than plain string
names.

155-163: ⚠️ Potential issue | 🟠 Major

Wrap error enrichment in try-catch to preserve best-effort behavior.

If rewriteCapabilitySecretError or its internal resolveSecret calls throw, runCodemodeWithRegistry will propagate that exception instead of returning the original ExecuteResult. Error enrichment should be best-effort:

Suggested fix
 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
+try {
+	const batchError = await rewriteCapabilitySecretError({
+		error: result.error,
+		code: wrapped,
+		env,
+		callerContext,
+	})
+	return batchError ? { ...result, error: batchError } : result
+} catch {
+	return result
+}

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/run-codemode-registry.ts` around lines 155 - 163, The
call that enriches errors with rewriteCapabilitySecretError inside
runCodemodeWithRegistry should be guarded so enrichment is best-effort: wrap the
await rewriteCapabilitySecretError({...}) call in a try-catch, and if it throws
simply ignore/log the enrichment failure and return the original ExecuteResult
(i.e., keep returning result if enrichment fails); reference the existing
symbols result, wrapped, env, callerContext, and rewriteCapabilitySecretError to
locate and modify the code.
🧹 Nitpick comments (1)
packages/worker/src/app/saved-ui-hosted-html.ts (1)

944-947: Avoid double parsing the batch host-approval payload.

Line 946 parses the batch once in the condition, then Line 948 triggers a second parse via extractApprovalDetails(...). Parse once and reuse for cleaner flow.

♻️ Suggested refactor
-function extractApprovalDetails(message, fallbackSecretNames = []) {
+function extractApprovalDetails(message, fallbackSecretNames = [], preParsedBatch = null) {
 	const text = typeof message === 'string' ? message : String(message ?? '');
-	const batch = parseHostApprovalBatchMessage(text);
+	const batch = preParsedBatch ?? parseHostApprovalBatchMessage(text);
 	if (batch) {
 		return {
 			message: text,
@@
 	} catch (error) {
 		const message = error instanceof Error ? error.message : String(error);
+		const batch = parseHostApprovalBatchMessage(message);
 		if (
 			message.includes('not allowed for host') ||
-			parseHostApprovalBatchMessage(message)
+			batch
 		) {
-			const approval = extractApprovalDetails(message, fallbackSecretNames);
+			const approval = extractApprovalDetails(message, fallbackSecretNames, batch);
 			return {
 				ok: false,
 				kind: 'host_approval_required',
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/app/saved-ui-hosted-html.ts` around lines 944 - 947, The
code currently calls parseHostApprovalBatchMessage(message) in the if condition
and then calls extractApprovalDetails(...) which reparses the same payload;
update the flow to parse once into a variable (e.g., const batchPayload =
parseHostApprovalBatchMessage(message)) before the if, use that variable in the
conditional (check batchPayload truthiness) instead of re-parsing, and pass
batchPayload into extractApprovalDetails or otherwise reuse it where
extractApprovalDetails is invoked (ensure you still handle the case when parse
returns falsy). This will eliminate duplicate parsing calls for
parseHostApprovalBatchMessage and make extractApprovalDetails consume the
already-parsed payload.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/worker/src/mcp/run-codemode-registry.ts`:
- Around line 110-116: Normalize callerContext.storageContext before calling
buildSecretCapabilityApprovalUrl: create a local storageContext variable that,
if callerContext.storageContext is present, maps sessionId and appId to either
their values or null (i.e. { sessionId: storage.sessionId ?? null, appId:
storage.appId ?? null }), otherwise set storageContext to null; then pass that
normalized storageContext into buildSecretCapabilityApprovalUrl (referencing
buildSecretCapabilityApprovalUrl, callerContext.storageContext, and
approvalUrl).
- Around line 203-214: collectSecretNamesFromCode currently flattens secrets to
bare strings and drops scope info, which breaks downstream resolution; change
collectSecretNamesFromCode to return ReferencedSecret[] (preserving scope) by
combining outputs of parseSecretNamesFromMessage /
parseSecretNamesFromCapabilityCalls (or the parseSecretPlaceholders variant that
yields ReferencedSecret) instead of calling normalizeSecretNameList, and update
any callers such as findMissingCapabilityApprovals to accept and thread
ReferencedSecret objects into resolveSecret so scope is used when constructing
approval URLs and resolving secrets.
- Around line 318-336: The loop in run-codemode-registry must treat
input.secretNames as ReferencedSecret[] and normalize storageContext before
calling resolvers: update the parameter type where collectSecretNamesFromCode
feeds into runCodemodeRegistry so secret entries are objects with {name, scope,
storageContext?}, pass the secret's scope into resolveSecret (e.g., scope:
secret.scope) and ensure storageContext is converted to the required shape
(provide sessionId/appId keys as null when absent) before calling both
resolveSecret and buildSecretCapabilityApprovalUrl; adjust references to
input.secretNames.map and the variables used inside the async callback to use
the ReferencedSecret fields rather than plain string names.
- Around line 155-163: The call that enriches errors with
rewriteCapabilitySecretError inside runCodemodeWithRegistry should be guarded so
enrichment is best-effort: wrap the await rewriteCapabilitySecretError({...})
call in a try-catch, and if it throws simply ignore/log the enrichment failure
and return the original ExecuteResult (i.e., keep returning result if enrichment
fails); reference the existing symbols result, wrapped, env, callerContext, and
rewriteCapabilitySecretError to locate and modify the code.

---

Nitpick comments:
In `@packages/worker/src/app/saved-ui-hosted-html.ts`:
- Around line 944-947: The code currently calls
parseHostApprovalBatchMessage(message) in the if condition and then calls
extractApprovalDetails(...) which reparses the same payload; update the flow to
parse once into a variable (e.g., const batchPayload =
parseHostApprovalBatchMessage(message)) before the if, use that variable in the
conditional (check batchPayload truthiness) instead of re-parsing, and pass
batchPayload into extractApprovalDetails or otherwise reuse it where
extractApprovalDetails is invoked (ensure you still handle the case when parse
returns falsy). This will eliminate duplicate parsing calls for
parseHostApprovalBatchMessage and make extractApprovalDetails consume the
already-parsed payload.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a9d71d13-b50b-45ae-b215-e7d8341e81d7

📥 Commits

Reviewing files that changed from the base of the PR and between 7911f32 and 01d6025.

📒 Files selected for processing (3)
  • packages/worker/client/mcp-apps/generated-ui-shell.ts
  • packages/worker/src/app/saved-ui-hosted-html.ts
  • packages/worker/src/mcp/run-codemode-registry.ts

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Unhandled throw in rewrite turns returned error into exception
    • Wrapped the rewrite step in a try/catch to preserve the original ExecuteResult when secret rewrite logic throws.
Preview (81badff46d)
diff --git a/packages/worker/client/mcp-apps/generated-ui-shell.ts b/packages/worker/client/mcp-apps/generated-ui-shell.ts
--- a/packages/worker/client/mcp-apps/generated-ui-shell.ts
+++ b/packages/worker/client/mcp-apps/generated-ui-shell.ts
@@ -916,10 +916,53 @@
 	}
 	return collected;
 }
+function parseHostApprovalBatchMessage(message) {
+	if (typeof message !== 'string') return null;
+	const prefix = 'Secrets require host approval:';
+	if (!message.startsWith(prefix)) return null;
+	const raw = message.slice(prefix.length).trim();
+	if (!raw) return null;
+	try {
+		const parsed = JSON.parse(raw);
+		if (!Array.isArray(parsed)) return null;
+		const entries = [];
+		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;
+	}
+}
 function extractApprovalDetails(message, fallbackSecretNames = []) {
 	const text = typeof message === 'string' ? message : String(message ?? '');
+	const batch = parseHostApprovalBatchMessage(text);
+	if (batch) {
+		return {
+			message: text,
+			approvalUrl: batch[0]?.approvalUrl ?? null,
+			host: batch[0]?.host ?? null,
+			secretNames: normalizeSecretNameList(
+				batch.map((entry) => entry.secretName),
+			),
+		};
+	}
 	const secretNames = normalizeSecretNameList([
-		...Array.from(text.matchAll(/Secret "([^"]+)"/g)).map((match) => match[1]).filter(Boolean),
+		...Array.from(text.matchAll(/Secret "([^"]+)"/g))
+			.map((match) => match[1])
+			.filter(Boolean),
 		...fallbackSecretNames,
 	]);
 	const hostMatch = text.match(/host "([^"]+)"/);
@@ -1543,7 +1586,10 @@
 			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') ||
+				parseHostApprovalBatchMessage(message)
+			) {
 				const approval = extractApprovalDetails(message, fallbackSecretNames);
 				return {
 					ok: false,

diff --git a/packages/worker/client/routes/account-secrets.tsx b/packages/worker/client/routes/account-secrets.tsx
--- a/packages/worker/client/routes/account-secrets.tsx
+++ b/packages/worker/client/routes/account-secrets.tsx
@@ -222,6 +222,28 @@
 	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 @@
 	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 @@
 	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 @@
 				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 @@
 								<code>{approval.name}</code> from the{' '}
 								{getScopeLabel(approval.scope)} scope.
 							</p>
+							{approval.requestedCapability ? (
+								<p css={{ margin: 0, color: colors.textMuted }}>
+									Requested capability:{' '}
+									<code>{approval.requestedCapability}</code>
+								</p>
+							) : null}
 							<p css={{ margin: 0, color: colors.textMuted }}>
 								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
--- a/packages/worker/src/app/handlers/account-secrets.ts
+++ b/packages/worker/src/app/handlers/account-secrets.ts
@@ -56,6 +56,7 @@
 	name: string
 	scope: SecretScope
 	requestedHost: string
+	requestedCapability: string | null
 	currentAllowedHosts: Array<string>
 }
 
@@ -164,6 +165,7 @@
 	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 @@
 				userId: input.user.mcpUser.userId,
 				token: approvalToken,
 				requestedHost: requestedApprovalHost,
+				requestedCapability,
 			}).catch(() => null)
 		: null
 
@@ -272,6 +275,7 @@
 	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 @@
 		name: approval.name,
 		scope: approval.scope,
 		requestedHost: approval.requestedHost,
+		requestedCapability: input.requestedCapability,
 		currentAllowedHosts: secret.allowedHosts,
 	} satisfies SecretApprovalView
 }
@@ -632,6 +637,11 @@
 	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<string, unknown>)[key]
 	return typeof value === 'string' && value.trim() ? value.trim() : null

diff --git a/packages/worker/src/app/saved-ui-hosted-html.ts b/packages/worker/src/app/saved-ui-hosted-html.ts
--- a/packages/worker/src/app/saved-ui-hosted-html.ts
+++ b/packages/worker/src/app/saved-ui-hosted-html.ts
@@ -184,10 +184,53 @@
 	}
 	return collected;
 }
+function parseHostApprovalBatchMessage(message) {
+	if (typeof message !== 'string') return null;
+	const prefix = 'Secrets require host approval:';
+	if (!message.startsWith(prefix)) return null;
+	const raw = message.slice(prefix.length).trim();
+	if (!raw) return null;
+	try {
+		const parsed = JSON.parse(raw);
+		if (!Array.isArray(parsed)) return null;
+		const entries = [];
+		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;
+	}
+}
 function extractApprovalDetails(message, fallbackSecretNames = []) {
 	const text = typeof message === 'string' ? message : String(message ?? '');
+	const batch = parseHostApprovalBatchMessage(text);
+	if (batch) {
+		return {
+			message: text,
+			approvalUrl: batch[0]?.approvalUrl ?? null,
+			host: batch[0]?.host ?? null,
+			secretNames: normalizeSecretNameList(
+				batch.map((entry) => entry.secretName),
+			),
+		};
+	}
 	const secretNames = normalizeSecretNameList([
-		...Array.from(text.matchAll(/Secret "([^"]+)"/g)).map((match) => match[1]).filter(Boolean),
+		...Array.from(text.matchAll(/Secret "([^"]+)"/g))
+			.map((match) => match[1])
+			.filter(Boolean),
 		...fallbackSecretNames,
 	]);
 	const hostMatch = text.match(/host "([^"]+)"/);
@@ -898,7 +941,10 @@
 			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') ||
+				parseHostApprovalBatchMessage(message)
+			) {
 				const approval = extractApprovalDetails(message, fallbackSecretNames);
 				return {
 					ok: false,

diff --git a/packages/worker/src/mcp/executor.node.test.ts b/packages/worker/src/mcp/executor.node.test.ts
--- 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 @@
 		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 @@
 			createCapabilitySecretAccessDeniedMessage(
 				'cloudflareToken',
 				'cloudflare_rest',
+				'https://example.com/account/secrets/user/cloudflareToken?capability=cloudflare_rest',
 			),
 		),
 	} as const
@@ -52,3 +58,92 @@
 		'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
--- a/packages/worker/src/mcp/executor.ts
+++ b/packages/worker/src/mcp/executor.ts
@@ -4,7 +4,9 @@
 import { type FetchGatewayProps } from '#mcp/fetch-gateway.ts'
 import {
 	isSecretAuthRequiredMessage,
+	parseCapabilityAccessRequiredBatchMessage,
 	parseCapabilityAccessRequiredMessage,
+	parseHostApprovalRequiredBatchMessage,
 	parseHostApprovalRequiredMessage,
 	parseMissingSecretMessage,
 } from '#mcp/secrets/errors.ts'
@@ -46,17 +48,45 @@
 			}
 	  }
 	| {
+			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<string>
 			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'
+			}
+	  }
+	| {
 			kind: 'secret_required'
 			message: string
 			nextStep: string
@@ -96,6 +126,35 @@
 		}
 	}
 
+	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 @@
 				"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
--- a/packages/worker/src/mcp/fetch-gateway.ts
+++ b/packages/worker/src/mcp/fetch-gateway.ts
@@ -10,6 +10,7 @@
 	type ReferencedSecret,
 } from '#mcp/secrets/placeholders.ts'
 import {
+	createHostSecretAccessDeniedBatchMessage,
 	createMissingSecretMessage,
 	fetchSecretAuthRequiredMessage,
 } from '#mcp/secrets/errors.ts'
@@ -85,27 +86,15 @@
 			)
 		}
 		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 @@
 	})
 }
 
+async function collectHostApprovalEntries(input: {
+	env: Pick<Env, 'COOKIE_SECRET'>
+	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<typeof entry> => 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
--- 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 @@
 				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
--- a/packages/worker/src/mcp/run-codemode-registry.ts
+++ b/packages/worker/src/mcp/run-codemode-registry.ts
@@ -1,3 +1,4 @@
+import * as acorn from 'acorn'
 import {
 	resolveProvider,
 	type ResolvedProvider,
@@ -9,10 +10,15 @@
 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 {
+	parseSecretPlaceholders,
+	type ReferencedSecret,
+} from '#mcp/secrets/placeholders.ts'
 import { buildParameterizedSkillCode } from '#mcp/skills/skill-parameters.ts'
 import { getCapabilityRegistryForContext } from '#mcp/capabilities/registry.ts'
 
@@ -101,8 +107,19 @@
 			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
@@ -134,5 +151,202 @@
 		params !== undefined
 			? await buildParameterizedSkillCode(code, params)
 			: code
-	return executor.execute(wrapped, [provider])
+	const result = await executor.execute(wrapped, [provider])
+	if (!result.error) return result
+	let batchError: Error | null = null
+	try {
+		batchError = await rewriteCapabilitySecretError({
+			error: result.error,
+			code: wrapped,
+			env,
+			callerContext,
+		})
+	} catch {
+		return result
+	}
+	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,
+		capabilityName,
+	)
+	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,
+	capabilityName: string | null,
+) {
+	const fromError =
+		error instanceof Error ? parseSecretNamesFromMessage(error.message) : []
+	const fromCode = code
+		? parseSecretNamesFromCapabilityCalls(code, capabilityName)
+		: []
+	return normalizeSecretNameList([...fromError, ...fromCode])
+}
+
+function parseSecretNamesFromCapabilityCalls(
+	code: string,
+	capabilityName: string | null,
+) {
+	if (!capabilityName) {
+		return parseSecretPlaceholders(code).map((secret) => secret.name)
+	}
+	try {
+		const ast = acorn.parse(code, {
+			ecmaVersion: 'latest',
+			sourceType: 'module',
+		}) as acorn.Node
+		const collected = new Set<string>()
+		const walk = (node: unknown) => {
+			if (!node || typeof node !== 'object') return
+			const typed = node as Record<string, unknown>
+			if (typed.type === 'CallExpression') {
+				const callee = typed.callee as unknown
+				const matchedName = getCodemodeCapabilityName(callee)
+				if (matchedName === capabilityName) {
+					const args = (typed.arguments ?? []) as Array<unknown>
+					for (const arg of args) {
+						const start = (arg as { start?: number }).start
+						const end = (arg as { end?: number }).end
+						if (typeof start !== 'number' || typeof end !== 'number') continue
+						const snippet = code.slice(start, end)
+						for (const secret of parseSecretPlaceholders(snippet)) {
+							collected.add(secret.name)
+						}
+					}
+				}
+			}
+			for (const value of Object.values(typed)) {
+				if (Array.isArray(value)) {
+					for (const entry of value) walk(entry)
+					continue
+				}
+				if (value && typeof value === 'object') walk(value)
+			}
+		}
+		walk(ast)
+		return Array.from(collected)
+	} catch {
... diff truncated: showing 800 of 1136 lines

Comment thread packages/worker/src/mcp/run-codemode-registry.ts Outdated
@cursor
cursor Bot force-pushed the cursor/secret-capability-error-flow-9280 branch from 81badff to 973b561 Compare March 28, 2026 01:23
cursoragent and others added 3 commits March 28, 2026 05:08
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@cursor
cursor Bot force-pushed the cursor/secret-capability-error-flow-9280 branch from 973b561 to 9a2d4ee Compare March 28, 2026 05:09
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@cursor cursor Bot changed the title Add batch approval details for secret capability/host errors Add batched secret approval errors with deep links Mar 28, 2026
cursoragent and others added 4 commits March 28, 2026 05:11
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@kentcdodds
kentcdodds merged commit c604dbf into main Mar 28, 2026
9 checks passed
@kentcdodds
kentcdodds deleted the cursor/secret-capability-error-flow-9280 branch March 28, 2026 05:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants