Skip to content

feat: add connection-backed skill runtime - #35

Closed
kentcdodds wants to merge 11 commits into
mainfrom
dynamic-skills-oauth-runtime
Closed

kentcdodds wants to merge 11 commits into
mainfrom
dynamic-skills-oauth-runtime

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Mar 25, 2026 •

Copy link
Copy Markdown
Member

Summary

  • add a D1-backed provider connection runtime with draft setup, secure secret storage, OAuth flows, opaque handles, and provider request capabilities
  • extend MCP skills and search with connection bindings plus seeded builtin skill templates so provider access becomes D1-first instead of static-registry-first
  • add generated UI app sessions and direct worker action/secure-input endpoints so apps can submit secrets without routing them through model-visible tool arguments

Test plan

  • bun run format
  • bun run validate
  • bun run test:mcp

Made with Cursor


Note

High Risk
High risk because it adds new credential storage/encryption, OAuth callback handling, and cross-origin generated UI APIs (CORS + bearer/session headers), plus new D1 schema that affects persisted user data.

Overview
Adds a D1-backed provider connection runtime with draft setup/finalization, encrypted secret storage, OAuth initiation/callback handling, default-connection selection, and opaque short-lived connection handles for provider requests.

Introduces a new connections MCP capability domain (begin/list/resolve/set-default/disconnect/start-oauth/finalize plus authenticated provider_http_request/provider_graphql_request/provider_refresh_token), and extends skills/search to persist and surface connection_bindings and seed per-user builtin skill templates.

Upgrades the generated UI shell/tooling to create generated UI app sessions and support direct worker endpoints for invokeAction and submitSecureInput (with stricter origin/CORS checks), and tweaks Playwright docs/config defaults (port/base URL) plus a minor type-only import fix.

Written by Cursor Bugbot for commit 95362a9. This will update automatically on new commits. Configure here.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added provider connection management system with setup, OAuth flow, and secure finalization workflows.
    • Added support for authenticated HTTP and GraphQL requests via provider connections.
    • Skills now support connection bindings to provider credentials.
    • Enhanced generated UI with app sessions, new API endpoints, and additional widget methods (invokeAction, submitSecureInput).
    • Added automatic seeding of builtin skill templates for users.
    • Search results now include seeded skill templates.

@coderabbitai

coderabbitai Bot commented Mar 25, 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 0 minutes and 49 seconds before requesting another review.

⌛ 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: 8c7ba871-49b6-4a7b-bb7d-ff27c5cde44a

📥 Commits

Reviewing files that changed from the base of the PR and between 7331f81 and 95362a9.

📒 Files selected for processing (5)
  • packages/worker/client/mcp-apps/generated-ui-shell.ts
  • packages/worker/src/mcp/connections/auth-spec.ts
  • packages/worker/src/mcp/connections/connection-service.ts
  • packages/worker/src/mcp/generated-ui-api.ts
  • packages/worker/src/mcp/generated-ui-app-session.ts
📝 Walkthrough

Walkthrough

This PR introduces a comprehensive provider connections system enabling secure OAuth-based credential management. It adds database tables for connection drafts and persistent connections, a connection service handling setup/OAuth/finalization workflows, MCP capabilities for connection operations, encrypted secret storage, skill-to-connection bindings, generated UI app sessions, and corresponding API endpoints.

Changes

Cohort / File(s) Summary
Database Migrations
packages/worker/migrations/0008-provider-connections.sql, packages/worker/migrations/0009-skill-connection-bindings.sql
New tables for connection drafts/secrets and provider connections/secrets with foreign keys, indexes, and constraints; adds connection_bindings and template_key columns to mcp_skills with partial unique index on template.
Connection Core Types & Repositories
packages/worker/src/mcp/connections/connection-drafts-types.ts, packages/worker/src/mcp/connections/connection-drafts-repo.ts, packages/worker/src/mcp/connections/provider-connections-types.ts, packages/worker/src/mcp/connections/provider-connections-repo.ts
Type definitions and D1 repository functions for CRUD operations on connection drafts/secrets and provider connections/secrets, including partial updates, cascading deletes, and timestamp handling.
Connection Authentication & Crypto
packages/worker/src/mcp/connections/auth-spec.ts, packages/worker/src/mcp/connections/crypto.ts, packages/worker/src/mcp/connections/connection-handles.ts
Auth specification schemas (OAuth, API key, bearer, basic auth), AES-GCM encryption/decryption for JSON values, HMAC token signing/verification, and opaque connection handle token generation with TTL.
Connection Service & Provider Requests
packages/worker/src/mcp/connections/connection-service.ts, packages/worker/src/mcp/connections/provider-request.ts
End-to-end connection lifecycle: setup drafts, secret storage, OAuth flow initiation, finalization with verification; authenticated HTTP/GraphQL request execution with automatic token refresh for OAuth2; provider metadata derivation.
Connection OAuth & API Routing
packages/worker/src/mcp/connections/oauth-api.ts, packages/worker/src/index.ts, packages/worker/src/mcp/mcp-auth.ts
OAuth callback request routing and validation; new request handlers for OAuth callbacks and generated UI API; cooldown-based builtin skill template seeding per user; CORS enforcement for generated UI origins (\*.claudemcpcontent.com).
Connection MCP Capabilities
packages/worker/src/mcp/capabilities/connections/connections-begin-setup.ts, packages/worker/src/mcp/capabilities/connections/connections-finalize.ts, packages/worker/src/mcp/capabilities/connections/connections-list.ts, packages/worker/src/mcp/capabilities/connections/connections-resolve.ts, packages/worker/src/mcp/capabilities/connections/connections-set-default.ts, packages/worker/src/mcp/capabilities/connections/connections-disconnect.ts, packages/worker/src/mcp/capabilities/connections/connections-start-oauth.ts, packages/worker/src/mcp/capabilities/connections/domain.ts, packages/worker/src/mcp/capabilities/domain-metadata.ts
MCP capability definitions for initiating connection setup, finalizing connections, listing/resolving/setting-default/disconnecting connections, starting OAuth flow; new connections domain aggregating capabilities; updated domain metadata.
Provider Request Capabilities
packages/worker/src/mcp/capabilities/connections/provider-http-request.ts, packages/worker/src/mcp/capabilities/connections/provider-graphql-request.ts, packages/worker/src/mcp/capabilities/connections/provider-refresh-token.ts
MCP capabilities for executing authenticated HTTP/GraphQL requests and refreshing tokens against resolved provider connections using opaque handles.
Skill-to-Connection Integration
packages/worker/src/mcp/skills/skill-connections.ts, packages/worker/src/mcp/skills/mcp-skills-types.ts, packages/worker/src/mcp/skills/mcp-skills-repo.ts, packages/worker/src/mcp/skills/skill-mutation.ts, packages/worker/src/mcp/skills/skill-embed-and-flags.ts, packages/worker/src/mcp/capabilities/meta/meta-get-skill.ts, packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts, packages/worker/src/mcp/capabilities/meta/meta-update-skill.ts, packages/worker/src/mcp/capabilities/unified-search.ts
Schema and parsing for skill-to-provider connection bindings; extended skill persistence/retrieval/updating to include connection bindings and template keys; embedding text generation includes connection metadata; search results include binding details.
Builtin Skill Templates
packages/worker/src/mcp/skills/builtin-skill-templates.ts, packages/worker/src/mcp/capabilities/builtin-domains.ts
New function to seed builtin skill templates (GitHub REST/GraphQL, Cloudflare API, Cursor Cloud) per user on first MCP request; builtinDomains extended to include new connectionsDomain.
Generated UI App Sessions
packages/worker/src/mcp/generated-ui-app-session.ts, packages/worker/src/mcp/generated-ui-api.ts, packages/worker/client/mcp-apps/generated-ui-shell.ts
Session token creation/verification for generated UI with endpoint URLs; API handler for app-source retrieval, action invocation (code execution), and secure-input submission; iframe bridge runtime extended with session-aware action/secure-input methods and session-injected code execution.
Tools & Miscellaneous
packages/worker/src/mcp/tools/open-generated-ui.ts, packages/worker/src/mcp/tools/search.ts, packages/worker/src/mcp/index.ts
Updated generated UI tool to pass app session to client; search tool description updated to mention skill templates; MCP server instructions expanded to reference seeded templates.
Configuration & Documentation
playwright.config.ts, docs/agents/end-to-end-testing.md, packages/home-connector/app/handlers.ts
Playwright port updated from 8788 to 38888 to avoid Wrangler dev port collisions; documentation updated accordingly; handlers.ts import statement adjusted.

Sequence Diagram

sequenceDiagram
    participant User
    participant Client as Generated UI Client
    participant Server as Backend API
    participant Provider as OAuth Provider
    participant DB as Database

    rect rgba(100, 150, 200, 0.5)
    Note over User,DB: Connection Setup & OAuth Flow
    User->>Client: Request provider connection
    Client->>Server: POST /api/connections/begin-setup<br/>(provider, auth spec)
    Server->>DB: Insert connection draft<br/>(initial status based on strategy)
    Server-->>Client: setup_id, secret fields, instructions
    
    alt OAuth Strategy
        Client->>Server: POST /api/connections/start-oauth<br/>(setup_id)
        Server->>DB: Fetch draft + stored secrets
        Server->>Server: Generate OAuth state token<br/>+ PKCE challenge
        Server->>DB: Save OAuth state in draft
        Server-->>Client: authorize_url, setup_id
        
        Client->>Provider: Redirect to authorization
        User->>Provider: Approve authorization
        Provider->>Server: Callback: /api/connections/oauth/callback<br/>(code, state)
        Server->>Server: Verify signed state
        Server->>Provider: Exchange code for tokens
        Provider-->>Server: access_token, refresh_token
        Server->>DB: Store encrypted secrets for draft
        Server->>DB: Update draft status→authorized
        Server-->>Client: HTML success response
    else Non-OAuth Strategy
        Client->>Server: POST /api/connections/secrets<br/>(setup_id, secret fields)
        Server->>DB: Store encrypted secrets
        Server->>DB: Check if all required secrets present
        Server-->>Client: Stored secrets, missing secrets
    end
    end

    rect rgba(150, 100, 200, 0.5)
    Note over User,DB: Connection Finalization
    Client->>Server: POST /api/connections/finalize<br/>(setup_id)
    Server->>DB: Fetch draft + secrets
    Server->>Provider: Verify connection<br/>(via verification request)
    Provider-->>Server: account_id, scope_set
    Server->>DB: Insert provider_connection<br/>(with verified metadata)
    Server->>DB: Store encrypted secrets<br/>in provider_connection_secrets
    Server->>DB: Mark draft completed
    Server-->>Client: connection_id, account details, scopes
    end

    rect rgba(200, 150, 100, 0.5)
    Note over User,DB: Later: Use Connection for Requests
    Client->>Server: invoke_action({ code, params })<br/>with appSession
    Server->>Server: Resolve connection by handle<br/>(verify user ownership)
    Server->>DB: Fetch connection + encrypted secrets
    Server->>Server: Decrypt secrets<br/>Apply auth transport header
    Server->>Provider: Execute authenticated request<br/>(HTTP or GraphQL)
    Provider-->>Server: Response
    Server->>DB: Update connection<br/>last_used_at, token_expires_at
    Server-->>Client: result or error
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Replace calculator MCP app with generic generated UI shell #29: Directly modifies the generated UI shell client and MCP generated-ui APIs (packages/worker/client/mcp-apps/generated-ui-shell.ts, packages/worker/src/mcp/generated-ui-api.ts, and related app resource modules) alongside this PR's changes to the same files.
  • Add home connector MCP bridge and Roku package #31: Both PRs alter worker request routing (packages/worker/src/index.ts) and extend the MCP capability domain metadata (packages/worker/src/mcp/capabilities/domain-metadata.ts) to register new domains.
  • Add parameterized skills support #19: Both PRs modify the MCP skill surface (storage schema, repo, types, embed-text generation, meta capabilities, and unified search) to add and thread new skill metadata fields through persistence and retrieval layers.

Poem

🐰 A bunny hops through the OAuth flow,
With secrets encrypted nice and low,
Connections now bind to skills so true,
Provider requests—secure, tried and new!
From drafts to tokens, the dance complete,
Making provider partnerships neat! 🔐

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.74% 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
Title check ✅ Passed The PR title 'feat: add connection-backed skill runtime' clearly and concisely describes the main addition: a connection-backed runtime for skills that manages provider connections with OAuth and encryption.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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 dynamic-skills-oauth-runtime

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.

Comment thread packages/worker/client/mcp-apps/generated-ui-shell.ts Outdated
Comment thread packages/worker/src/mcp-auth.ts
Comment thread packages/worker/src/mcp/connections/provider-request.ts Outdated
Comment thread packages/worker/src/mcp/connections/provider-request.ts
Comment thread packages/worker/src/mcp-auth.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: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/worker/client/mcp-apps/generated-ui-shell.ts (1)

683-759: ⚠️ Potential issue | 🔴 Critical

Capture appSession per render instead of reading latestEnvelope after await.

renderEnvelope() updates latestEnvelope, awaits resolveSavedAppCode(), and then the document builders pull latestEnvelope?.appSession again. If another render arrives before the first fetch resolves, the older app can be mounted with the newer app’s session token/endpoints. That is both a stale-render bug and a cross-session leak.

Proposed fix
 let latestRenderData: RenderDataEnvelope | undefined
 let latestEnvelope: RenderEnvelope | null = null
+let renderVersion = 0

-function buildInlineModuleSource(code: string) {
+function buildInlineModuleSource(
+	code: string,
+	appSession: AppSessionEnvelope | null,
+) {
 	const safeCode = escapeInlineScriptSource(code)
-	const appSession = latestEnvelope?.appSession ?? null
 	return `
 ${buildChildBridgeRuntimeSource(appSession)}
 ${safeCode}
 	`.trim()
 }

-function buildHtmlDocumentFromFragment(code: string) {
+function buildHtmlDocumentFromFragment(
+	code: string,
+	appSession: AppSessionEnvelope | null,
+) {
 	const theme = coerceTheme(latestRenderData?.theme)
-	const appSession = latestEnvelope?.appSession ?? null
 	return `
 <!doctype html>
 ...
 	`.trim()
 }

-function setFrameSource(code: string, runtime: AppRuntime) {
+function setFrameSource(
+	code: string,
+	runtime: AppRuntime,
+	appSession: AppSessionEnvelope | null,
+) {
 	if (runtime === 'javascript') {
-		const inlineModuleSource = buildInlineModuleSource(code)
+		const inlineModuleSource = buildInlineModuleSource(code, appSession)
 		...
 	}
 	...
-			: buildHtmlDocumentFromFragment(code)
+			: buildHtmlDocumentFromFragment(code, appSession)
 	frameElement.srcdoc = htmlSource
 }

-async function resolveSavedAppCode(appId: string) {
-	const appSession = latestEnvelope?.appSession ?? null
+async function resolveSavedAppCode(
+	appId: string,
+	appSession: AppSessionEnvelope | null,
+) {
 	...
 }

 async function renderEnvelope(envelope: RenderEnvelope | null) {
 	latestEnvelope = envelope
+	const renderId = ++renderVersion
 	...
 	try {
-		const resolved = await resolveSavedAppCode(envelope.appId)
-		setFrameSource(resolved.code, resolved.runtime)
+		const appSession = envelope.appSession ?? null
+		const resolved = await resolveSavedAppCode(envelope.appId, appSession)
+		if (renderId !== renderVersion) return
+		setFrameSource(resolved.code, resolved.runtime, appSession)
 	} catch (error) {
 		...
 	}
 }

Also applies to: 761-788, 812-840

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

In `@packages/worker/client/mcp-apps/generated-ui-shell.ts` around lines 683 -
759, The builders currently read latestEnvelope?.appSession at render time
(after awaits), causing cross-session leaks; capture the current appSession
before any await in renderEnvelope and thread that captured value into the
document builders instead of accessing latestEnvelope inside
buildInlineModuleSource, buildHtmlDocumentFromFragment and setFrameSource (and
any other builders at lines ~761-788 and ~812-840). Change signatures to accept
an appSession parameter (e.g., buildInlineModuleSource(code, appSession),
buildHtmlDocumentFromFragment(code, appSession)) and use the passed appSession
when calling buildChildBridgeRuntimeSource or buildHeadInjection, and update
renderEnvelope to pass the captured appSession into
setFrameSource/buildHtmlDocumentFromFragment so each render uses its own session
token.
🧹 Nitpick comments (10)
packages/worker/src/mcp/capabilities/unified-search.ts (1)

135-137: Parse connection_bindings only when detail is requested.

Line 135 currently does parsing for every hit, including summary mode where the value is unused. This is a small hot-path optimization.

♻️ Proposed refactor
-	const parameters = parseSkillParameters(row.parameters)
-	const connectionBindings = parseSkillConnectionBindings(
-		row.connection_bindings,
-	)
+	const parameters = parseSkillParameters(row.parameters)
 	const base: SkillSearchHitSummary = {
 		type: 'skill',
 		skillId: row.id,
@@
 	}
 	if (detail) {
+		const connectionBindings = parseSkillConnectionBindings(
+			row.connection_bindings,
+		)
 		const hit: SkillSearchHitDetail = {
 			...base,
 			inferredCapabilities: inferred,
 			usesCapabilities: uses,
 			searchText: row.search_text,
 			parameters,
 			connectionBindings,
 		}
 		return hit
 	}

Also applies to: 154-162

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

In `@packages/worker/src/mcp/capabilities/unified-search.ts` around lines 135 -
137, Only parse row.connection_bindings when the caller requested detail:
replace the unconditional call to
parseSkillConnectionBindings(row.connection_bindings) with a conditional/lazy
parse that sets connectionBindings = detail ?
parseSkillConnectionBindings(row.connection_bindings) : undefined (or only
assign when detail is truthy) so summary-mode hits avoid the work; apply the
same change to the second occurrence around the other hit-processing block (the
code regions currently using connectionBindings and
parseSkillConnectionBindings) to ensure both hot-paths skip parsing when detail
is false.
packages/worker/src/mcp/generated-ui-app-session.ts (1)

40-53: Consider normalizing baseUrl to prevent double-slash issues.

If baseUrl already ends with a trailing slash, new URL('/api/...', appBaseUrl) handles it correctly. However, calling new URL(baseUrl) on line 40 without using it as the base directly in subsequent calls is redundant—you could simplify by using baseUrl directly as the second argument.

💡 Optional: Simplify URL construction
-	const appBaseUrl = new URL(baseUrl)
 	return {
 		sessionId,
 		token,
 		expiresAt: new Date(expiresAtMs).toISOString(),
 		endpoints: {
-			appSource: new URL('/api/generated-ui/app-source', appBaseUrl).toString(),
-			action: new URL('/api/generated-ui/actions', appBaseUrl).toString(),
-			secureInput: new URL(
-				'/api/generated-ui/secure-input',
-				appBaseUrl,
-			).toString(),
+			appSource: new URL('/api/generated-ui/app-source', baseUrl).toString(),
+			action: new URL('/api/generated-ui/actions', baseUrl).toString(),
+			secureInput: new URL('/api/generated-ui/secure-input', baseUrl).toString(),
 		},
 	} satisfies GeneratedUiAppSession
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/generated-ui-app-session.ts` around lines 40 - 53,
The code creates appBaseUrl via new URL(baseUrl) then builds endpoints with new
URL('/api/…', appBaseUrl), which is fine but redundant and can cause subtle
double-slash edge cases if baseUrl includes a trailing slash; update the
GeneratedUiAppSession builder to normalize baseUrl first (trim a trailing slash)
or construct each endpoint by passing baseUrl string as the base to new URL (use
baseUrl normalized) so appBaseUrl is not misused—adjust the logic around
appBaseUrl, baseUrl, and the endpoints keys (appSource, action, secureInput) to
ensure consistent URL construction and remove the unnecessary new URL(baseUrl)
where appropriate.
packages/worker/src/mcp/connections/connection-handles.ts (1)

14-31: Consider validating ttlMs to prevent negative or zero TTL values.

If a caller passes a negative or zero ttlMs, the handle would be immediately expired or have unexpected behavior. While unlikely in practice, defensive validation could prevent subtle bugs.

💡 Optional: Add TTL validation
 export async function createConnectionHandle(
 	env: Env,
 	input: {
 		connectionId: string
 		userId: string
 		providerKey: string
 		ttlMs?: number
 	},
 ) {
+	const ttl = input.ttlMs ?? defaultConnectionHandleTtlMs
+	if (ttl <= 0) {
+		throw new Error('ttlMs must be positive')
+	}
 	const now = Date.now()
 	return signToken(env, connectionHandlePurpose, {
 		connection_id: input.connectionId,
 		user_id: input.userId,
 		provider_key: input.providerKey,
 		iat: now,
-		exp: now + (input.ttlMs ?? defaultConnectionHandleTtlMs),
+		exp: now + ttl,
 	})
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/connections/connection-handles.ts` around lines 14 -
31, The createConnectionHandle function should validate the input.ttlMs to avoid
zero/negative TTLs: inside createConnectionHandle (which uses
defaultConnectionHandleTtlMs, connectionHandlePurpose, and signToken) check if
input.ttlMs is provided and is a positive number; if it's <= 0 or NaN, either
replace it with defaultConnectionHandleTtlMs or throw a clear error (choose
consistent behavior with the codebase), then compute exp using the validated TTL
and call signToken as before. Ensure you reference input.ttlMs when validating
and keep the iat/exp calculation using the validated ttl variable.
packages/worker/src/mcp/connections/auth-spec.ts (1)

137-144: getPrimarySecretFieldName returns hardcoded 'access_token' for OAuth strategies.

For OAuth flows, 'access_token' isn't in secret_fields (which contains client_id/client_secret for pre-registered, or is empty for dynamic). This seems intentional since OAuth access tokens are obtained dynamically, but consider adding a clarifying comment:

💡 Suggested documentation
 export function getPrimarySecretFieldName(spec: ConnectionAuthSpec) {
+	// OAuth strategies use dynamically-obtained access tokens, not user-provided secrets
 	if (spec.strategy === 'oauth2_pre_registered_client') {
 		return 'access_token'
 	}
 	if (spec.strategy === 'oauth2_dynamic_client') {
 		return 'access_token'
 	}
+	// Manual token strategies use the first declared secret field
 	return spec.secret_fields[0]?.name ?? null
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/connections/auth-spec.ts` around lines 137 - 144, The
getPrimarySecretFieldName function returns a hardcoded 'access_token' for OAuth
strategies, which is intentional because OAuth access tokens are obtained
dynamically and not listed in spec.secret_fields (which contains
client_id/client_secret for pre-registered or is empty for dynamic); update the
function by adding a brief clarifying comment above getPrimarySecretFieldName
explaining that for 'oauth2_pre_registered_client' and 'oauth2_dynamic_client'
the primary secret is the runtime access_token (not present in
spec.secret_fields) so returning 'access_token' is expected, and mention that
client credentials remain in spec.secret_fields for pre-registered clients to
avoid confusion.
packages/worker/src/mcp/skills/builtin-skill-templates.ts (1)

347-376: Embedding vector upsert failures are silently ignored.

If upsertSkillVector fails after a successful insert, the skill exists in D1 but won't be searchable via vector similarity. Consider logging or collecting these failures:

💡 Suggested improvement
 		if (!inserted) {
 			continue
 		}
-		await upsertSkillVector(env, {
-			skillId,
-			userId,
-			embedText: prep.embedText,
-		})
+		try {
+			await upsertSkillVector(env, {
+				skillId,
+				userId,
+				embedText: prep.embedText,
+			})
+		} catch (error) {
+			console.warn(`Failed to upsert vector for builtin skill ${template.templateKey}:`, error)
+		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/skills/builtin-skill-templates.ts` around lines 347 -
376, The loop that inserts new templates calls upsertSkillVector after
insertMcpSkillIfMissingTemplate but ignores failures, which can leave skills
unsearchable; wrap the upsertSkillVector call in a try/catch, log the error
(including skillId and template.templateKey) and either retry or push the failed
skillId into a retry/failure list for later processing; ensure the error logging
uses existing logger (or processLogger) and preserve the inserted skill when
retrying so you don't rollback DB insertion in
insertMcpSkillIfMissingTemplate/upsertSkillVector.
packages/worker/src/mcp/generated-ui-api.ts (2)

60-65: JSON parse failure produces a generic Zod error rather than a clear message.

When request.json() throws (malformed JSON), the code passes null to safeParse, which fails with a Zod type error rather than a clear "Invalid JSON" message. Consider distinguishing parse errors:

💡 Suggested improvement
-		const body = actionRequestSchema.safeParse(
-			await request.json().catch(() => null),
-		)
-		if (!body.success) {
-			return jsonResponse({ error: body.error.message }, 400)
-		}
+		let rawBody: unknown
+		try {
+			rawBody = await request.json()
+		} catch {
+			return jsonResponse({ error: 'Invalid JSON body.' }, 400)
+		}
+		const body = actionRequestSchema.safeParse(rawBody)
+		if (!body.success) {
+			return jsonResponse({ error: body.error.message }, 400)
+		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/generated-ui-api.ts` around lines 60 - 65, The
current flow calls actionRequestSchema.safeParse(await request.json().catch(()
=> null)), which converts a JSON parse failure into a Zod type error; change it
to explicitly catch JSON parse errors before schema validation: await
request.json() in a try/catch, if parsing throws return jsonResponse({ error:
"Invalid JSON" }, 400), otherwise call actionRequestSchema.safeParse(parsedBody)
and continue to return schema errors as before (referencing
actionRequestSchema.safeParse, request.json(), body variable and jsonResponse).

97-102: Same JSON parse error handling issue for secure-input endpoint.

Apply the same pattern here for clearer error messages on malformed JSON.

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

In `@packages/worker/src/mcp/generated-ui-api.ts` around lines 97 - 102, The
secure-input handler currently swallows JSON parse errors by using await
request.json().catch(() => null) before secureInputRequestSchema.safeParse;
change it to explicitly catch and surface the JSON parsing error from
request.json() (e.g., await request.json().catch(err => { throw err })) or
capture the error into a variable and, if present, return jsonResponse({ error:
err.message }, 400) so callers get a clear parse error; keep the
secureInputRequestSchema.safeParse usage for schema validation and still return
jsonResponse({ error: body.error.message }, 400) on validation failures.
packages/worker/src/mcp/connections/crypto.ts (2)

28-50: Inconsistent key derivation between AES and HMAC.

deriveAesKey uses SHA-256 to hash the purpose+secret material before importing, while deriveHmacKey uses the raw bytes directly. This inconsistency could be intentional (HMAC is designed for variable-length keys), but aligning the approaches would improve maintainability:

💡 Optional: align HMAC derivation with AES pattern
 async function deriveHmacKey(secret: string, purpose: string) {
+	const material = await crypto.subtle.digest(
+		'SHA-256',
+		textEncoder.encode(`kody:hmac:${purpose}:${secret}`),
+	)
 	return crypto.subtle.importKey(
 		'raw',
-		textEncoder.encode(`kody:hmac:${purpose}:${secret}`),
+		material,
 		{
 			name: 'HMAC',
 			hash: 'SHA-256',
 		},
 		false,
 		['sign', 'verify'],
 	)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/connections/crypto.ts` around lines 28 - 50,
deriveAesKey and deriveHmacKey derive keys differently: AES hashes the formatted
material with SHA-256 before import, while HMAC imports raw bytes. Make
deriveHmacKey mirror deriveAesKey by first computing
crypto.subtle.digest('SHA-256',
textEncoder.encode(`kody:hmac:${purpose}:${secret}`)) and then using that digest
as the 'raw' key material in crypto.subtle.importKey (preserving name: 'HMAC',
hash: 'SHA-256', extractable: false, usages: ['sign','verify']); ensure you
handle the digest ArrayBuffer the same way deriveAesKey does.

135-137: Add documentation to clarify the exp field expects milliseconds, not seconds.

All current callers of signToken correctly use milliseconds (verified: generated-ui-app-session.ts uses expiresAtMs, connection-handles.ts uses ttlMs / defaultConnectionHandleTtlMs, and connection-service.ts uses 10 * 60 * 1000). However, the exp field in the payload types (ConnectionHandlePayload, GeneratedUiAppSessionPayload) is only typed as number without unit documentation. Consider adding a JSDoc comment to the signToken function or the payload types to explicitly state that exp must be in milliseconds (consistent with Date.now()), preventing future callers from accidentally using seconds.

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

In `@packages/worker/src/mcp/connections/crypto.ts` around lines 135 - 137, The
expiration check uses Date.now() so the payload.exp must be in milliseconds; add
a clear JSDoc comment stating that exp is in milliseconds to prevent future
confusion: update the JSDoc for the signToken function and the payload type
declarations (ConnectionHandlePayload, GeneratedUiAppSessionPayload) to
explicitly say "exp is a UNIX timestamp in milliseconds (ms)"; also add an
inline JSDoc above the existing expiration check in crypto.ts (the block
throwing 'Signed token has expired.') to reinforce the expectation that
payload['exp'] is milliseconds.
packages/worker/src/mcp/tools/open-generated-ui.ts (1)

127-135: Consider whether session creation failures should fail the tool call.

If createGeneratedUiAppSession throws (e.g., due to a transient error), the entire tool call will fail. If app session is a non-essential enhancement (the tool can still render UI without it), consider wrapping in try/catch and falling back to null:

💡 Optional defensive pattern
 		const callerContext = agent.getCallerContext()
-		const appSession =
-			callerContext.user != null
-				? await createGeneratedUiAppSession(
-						agent.getEnv(),
-						callerContext.baseUrl,
-						callerContext.user,
-					)
-				: null
+		let appSession = null
+		if (callerContext.user != null) {
+			try {
+				appSession = await createGeneratedUiAppSession(
+					agent.getEnv(),
+					callerContext.baseUrl,
+					callerContext.user,
+				)
+			} catch (error) {
+				console.warn('Failed to create generated UI app session:', error)
+			}
+		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/tools/open-generated-ui.ts` around lines 127 - 135,
The call to createGeneratedUiAppSession inside the block that builds appSession
can throw and currently will fail the whole tool call; change it to try/catch
around createGeneratedUiAppSession (when callerContext.user != null) so that any
thrown error is caught, set appSession = null on failure, and optionally log the
error for debugging (use agent.getLogger()/agent.logger or the existing logger).
Keep the rest of the flow unchanged so the tool continues to render UI even if
session creation fails.
🤖 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/mcp-apps/generated-ui-shell.ts`:
- Around line 479-523: Both invokeActionWithSession and
submitSecureInputWithSession must treat non-2xx and malformed responses as
failures: after fetch, if response.ok is false, attempt to read a text/json
error message and return { ok: false, error: '...' } instead of trying to use
the body; if response.ok is true parse JSON but validate that payload is an
object and that payload.ok is explicitly present and boolean — if missing or
payload is not an object return { ok: false, error: '...invalid response' }.
Update invokeActionWithSession and submitSecureInputWithSession to perform these
checks and return a consistent {ok:false,...} shape for error cases so callers
(e.g. executeCode) won’t treat error bodies as success.

In `@packages/worker/src/mcp/connections/auth-spec.ts`:
- Around line 55-61: The enum in manualTokenLikeAuthSpecSchema includes
'api_key' but connectionAuthSpecSchema's discriminated union does not include an
api_key branch, causing inconsistent validation; either remove 'api_key' from
manualTokenLikeAuthSpecSchema (and the other identical enum occurrence) if it's
not supported, or add a corresponding discriminated union member to
connectionAuthSpecSchema that accepts the manual token-like shape for 'api_key'
(mirror the existing manual_token member) so the discriminator covers both
'manual_token' and 'api_key'; update both occurrences (the enum at
manualTokenLikeAuthSpecSchema and the duplicate at lines ~98-102) and ensure
secret_fields/request/verification shapes remain the same for the new member.

In `@packages/worker/src/mcp/connections/connection-drafts-repo.ts`:
- Around line 73-135: The current updateConnectionDraft and
updateConnectionDraftUnsafe functions perform a read-then-overwrite using
getConnectionDraftById/getConnectionDraftByIdUnsafe and resolveFieldUpdate,
which risks stomping concurrent updates (e.g., from storeDraftSecrets,
startConnectionOAuth or the OAuth callback); change both functions to build the
SQL UPDATE dynamically using only the keys present in the passed fields object
(only include status, state_json, error_message, expires_at if they exist on
fields), construct the corresponding parameter list and updated_at value, and
execute a single UPDATE WITHOUT reading the existing row (remove reliance on
resolveFieldUpdate and the initial getConnectionDraft* calls) so omitted columns
are left untouched and concurrent writes do not restore stale values.

In `@packages/worker/src/mcp/connections/connection-service.ts`:
- Around line 929-939: The helper getRequiredConnectionDraft should validate
that the returned draft from getConnectionDraftById is not expired and not in a
terminal/completed state before allowing reuse; update
getRequiredConnectionDraft to check draft.expires_at (compare to current time)
and draft.completed (or other terminal flags) and throw an Error like
"Connection draft expired or already finalized" when either condition holds so
downstream callers such as storeDraftSecrets, startConnectionOAuth, and
finalizeConnectionSetup cannot operate on stale or completed drafts.
- Around line 215-238: The finalizeConnectionSetup flow currently proceeds even
for OAuth drafts that have client credentials but no tokens; update
finalizeConnectionSetup to detect OAuth-style specs (use the parsed spec from
parseConnectionAuthSpec) and check secretMaterial for required token fields
(e.g., access_token or refresh_token) after loadDraftSecretMaterial and
getMissingSecretNames; if the spec indicates OAuth and no token is present,
abort finalization by throwing a clear Error or returning a pending state
instead of calling verifyProviderConnectionDraft, so OAuth drafts are only
finalized once authorization has completed.
- Around line 598-628: Wrap the token exchange and secret persistence sequence
(the calls to exchangeOAuthCodeForTokens, encryptJson, and
upsertConnectionDraftSecret) in a try/catch so any thrown error is caught; in
the catch, call updateConnectionDraftUnsafe for the draft (referencing draft.id
and updateConnectionDraftUnsafe) to persist a failure state by setting a
sensible status (e.g., 'error' or leaving 'awaiting_oauth_callback' per desired
semantics) and writing error_message to the error text (include
error.message/stack) and optionally state_json, then rethrow or return the error
page result—this ensures failures during
loadDraftSecretMaterial/exchangeOAuthCodeForTokens/encryptJson/upsertConnectionDraftSecret
are recorded on the draft before the handler exits.

In `@packages/worker/src/mcp/connections/oauth-api.ts`:
- Around line 8-17: The code currently calls handleConnectionOAuthCallback for
HEAD requests which can cause side effects; change the control flow so
handleConnectionOAuthCallback(request, env) is only invoked when request.method
=== 'GET' and not for 'HEAD'. Specifically, check for 'HEAD' immediately after
validating allowed methods and return an empty Response with the appropriate
status/headers without calling handleConnectionOAuthCallback, and move the
handleConnectionOAuthCallback call into the GET branch so side-effecting OAuth
callback logic runs only for GET.

In `@packages/worker/src/mcp/connections/provider-request.ts`:
- Around line 140-155: verifyProviderConnectionDraft currently returns the raw
response from performProviderHttpRequestWithSpec even when the provider returns
non-success statuses (e.g., 401/403/500), allowing downstream
finalizeConnectionSetup to persist invalid/active connections; update
verifyProviderConnectionDraft to inspect the returned response status (from
performProviderHttpRequestWithSpec) and treat any non-2xx status as a failure:
either throw a descriptive error or return a rejected result so callers can
abort setup, include the status and provider response body in the error message
for diagnostics, and keep references to ConnectionAuthSpec,
ProviderSecretMaterial, performProviderHttpRequestWithSpec,
verifyProviderConnectionDraft and finalizeConnectionSetup to locate the change.

In `@packages/worker/src/mcp/generated-ui-app-session.ts`:
- Around line 33-38: The token payload currently includes PII (user.email) when
calling signToken with generatedUiSessionPurpose; remove email from the
McpUserContext passed into signToken so only non-PII fields (e.g., userId and
displayName) are embedded (keep session_id, iat, exp as-is). Locate the call to
signToken and the object referenced by the user variable (McpUserContext) and
change the constructed payload to exclude email (or construct a new minimal user
object) and update any type annotations if necessary; if email is required in
future, switch to an encrypted token (JWE) instead.

In `@packages/worker/src/mcp/skills/builtin-skill-templates.ts`:
- Around line 248-263: The Cloudflare and Cursor template code currently calls
codemode.connections_resolve and codemode.provider_http_request directly and
will throw if no connection exists; update both templates to call
codemode.connections_resolve with allow_missing: true, check the returned
connection (or lack thereof) and return a user-facing setup guidance
object/response when missing, otherwise proceed to call
codemode.provider_http_request using connection.handle (mirror the GitHub REST
template's flow). Ensure you reference the existing calls to
codemode.connections_resolve and codemode.provider_http_request in the template
strings and implement the missing-connection early-return/guide path.
- Around line 189-201: The GraphQL template currently calls
codemode.connections_resolve(...) without allow_missing and will throw if no
GitHub connection exists; change the call in the async template handler to pass
allow_missing: true to match the REST template, then mirror the REST flow: check
the returned connection value and, if it's missing/falsy, return the same
connection-setup initiation path used by the REST template instead of proceeding
to codemode.provider_graphql_request; update the block that calls
provider_graphql_request to only run when a truthy connection.handle is present
and otherwise delegate to the existing connection-setup behavior.

---

Outside diff comments:
In `@packages/worker/client/mcp-apps/generated-ui-shell.ts`:
- Around line 683-759: The builders currently read latestEnvelope?.appSession at
render time (after awaits), causing cross-session leaks; capture the current
appSession before any await in renderEnvelope and thread that captured value
into the document builders instead of accessing latestEnvelope inside
buildInlineModuleSource, buildHtmlDocumentFromFragment and setFrameSource (and
any other builders at lines ~761-788 and ~812-840). Change signatures to accept
an appSession parameter (e.g., buildInlineModuleSource(code, appSession),
buildHtmlDocumentFromFragment(code, appSession)) and use the passed appSession
when calling buildChildBridgeRuntimeSource or buildHeadInjection, and update
renderEnvelope to pass the captured appSession into
setFrameSource/buildHtmlDocumentFromFragment so each render uses its own session
token.

---

Nitpick comments:
In `@packages/worker/src/mcp/capabilities/unified-search.ts`:
- Around line 135-137: Only parse row.connection_bindings when the caller
requested detail: replace the unconditional call to
parseSkillConnectionBindings(row.connection_bindings) with a conditional/lazy
parse that sets connectionBindings = detail ?
parseSkillConnectionBindings(row.connection_bindings) : undefined (or only
assign when detail is truthy) so summary-mode hits avoid the work; apply the
same change to the second occurrence around the other hit-processing block (the
code regions currently using connectionBindings and
parseSkillConnectionBindings) to ensure both hot-paths skip parsing when detail
is false.

In `@packages/worker/src/mcp/connections/auth-spec.ts`:
- Around line 137-144: The getPrimarySecretFieldName function returns a
hardcoded 'access_token' for OAuth strategies, which is intentional because
OAuth access tokens are obtained dynamically and not listed in
spec.secret_fields (which contains client_id/client_secret for pre-registered or
is empty for dynamic); update the function by adding a brief clarifying comment
above getPrimarySecretFieldName explaining that for
'oauth2_pre_registered_client' and 'oauth2_dynamic_client' the primary secret is
the runtime access_token (not present in spec.secret_fields) so returning
'access_token' is expected, and mention that client credentials remain in
spec.secret_fields for pre-registered clients to avoid confusion.

In `@packages/worker/src/mcp/connections/connection-handles.ts`:
- Around line 14-31: The createConnectionHandle function should validate the
input.ttlMs to avoid zero/negative TTLs: inside createConnectionHandle (which
uses defaultConnectionHandleTtlMs, connectionHandlePurpose, and signToken) check
if input.ttlMs is provided and is a positive number; if it's <= 0 or NaN, either
replace it with defaultConnectionHandleTtlMs or throw a clear error (choose
consistent behavior with the codebase), then compute exp using the validated TTL
and call signToken as before. Ensure you reference input.ttlMs when validating
and keep the iat/exp calculation using the validated ttl variable.

In `@packages/worker/src/mcp/connections/crypto.ts`:
- Around line 28-50: deriveAesKey and deriveHmacKey derive keys differently: AES
hashes the formatted material with SHA-256 before import, while HMAC imports raw
bytes. Make deriveHmacKey mirror deriveAesKey by first computing
crypto.subtle.digest('SHA-256',
textEncoder.encode(`kody:hmac:${purpose}:${secret}`)) and then using that digest
as the 'raw' key material in crypto.subtle.importKey (preserving name: 'HMAC',
hash: 'SHA-256', extractable: false, usages: ['sign','verify']); ensure you
handle the digest ArrayBuffer the same way deriveAesKey does.
- Around line 135-137: The expiration check uses Date.now() so the payload.exp
must be in milliseconds; add a clear JSDoc comment stating that exp is in
milliseconds to prevent future confusion: update the JSDoc for the signToken
function and the payload type declarations (ConnectionHandlePayload,
GeneratedUiAppSessionPayload) to explicitly say "exp is a UNIX timestamp in
milliseconds (ms)"; also add an inline JSDoc above the existing expiration check
in crypto.ts (the block throwing 'Signed token has expired.') to reinforce the
expectation that payload['exp'] is milliseconds.

In `@packages/worker/src/mcp/generated-ui-api.ts`:
- Around line 60-65: The current flow calls actionRequestSchema.safeParse(await
request.json().catch(() => null)), which converts a JSON parse failure into a
Zod type error; change it to explicitly catch JSON parse errors before schema
validation: await request.json() in a try/catch, if parsing throws return
jsonResponse({ error: "Invalid JSON" }, 400), otherwise call
actionRequestSchema.safeParse(parsedBody) and continue to return schema errors
as before (referencing actionRequestSchema.safeParse, request.json(), body
variable and jsonResponse).
- Around line 97-102: The secure-input handler currently swallows JSON parse
errors by using await request.json().catch(() => null) before
secureInputRequestSchema.safeParse; change it to explicitly catch and surface
the JSON parsing error from request.json() (e.g., await request.json().catch(err
=> { throw err })) or capture the error into a variable and, if present, return
jsonResponse({ error: err.message }, 400) so callers get a clear parse error;
keep the secureInputRequestSchema.safeParse usage for schema validation and
still return jsonResponse({ error: body.error.message }, 400) on validation
failures.

In `@packages/worker/src/mcp/generated-ui-app-session.ts`:
- Around line 40-53: The code creates appBaseUrl via new URL(baseUrl) then
builds endpoints with new URL('/api/…', appBaseUrl), which is fine but redundant
and can cause subtle double-slash edge cases if baseUrl includes a trailing
slash; update the GeneratedUiAppSession builder to normalize baseUrl first (trim
a trailing slash) or construct each endpoint by passing baseUrl string as the
base to new URL (use baseUrl normalized) so appBaseUrl is not misused—adjust the
logic around appBaseUrl, baseUrl, and the endpoints keys (appSource, action,
secureInput) to ensure consistent URL construction and remove the unnecessary
new URL(baseUrl) where appropriate.

In `@packages/worker/src/mcp/skills/builtin-skill-templates.ts`:
- Around line 347-376: The loop that inserts new templates calls
upsertSkillVector after insertMcpSkillIfMissingTemplate but ignores failures,
which can leave skills unsearchable; wrap the upsertSkillVector call in a
try/catch, log the error (including skillId and template.templateKey) and either
retry or push the failed skillId into a retry/failure list for later processing;
ensure the error logging uses existing logger (or processLogger) and preserve
the inserted skill when retrying so you don't rollback DB insertion in
insertMcpSkillIfMissingTemplate/upsertSkillVector.

In `@packages/worker/src/mcp/tools/open-generated-ui.ts`:
- Around line 127-135: The call to createGeneratedUiAppSession inside the block
that builds appSession can throw and currently will fail the whole tool call;
change it to try/catch around createGeneratedUiAppSession (when
callerContext.user != null) so that any thrown error is caught, set appSession =
null on failure, and optionally log the error for debugging (use
agent.getLogger()/agent.logger or the existing logger). Keep the rest of the
flow unchanged so the tool continues to render UI even if session creation
fails.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 41dba659-81eb-4cc2-a87b-26371ccb5e1b

📥 Commits

Reviewing files that changed from the base of the PR and between b715905 and 9698935.

📒 Files selected for processing (47)
  • docs/agents/end-to-end-testing.md
  • docs/agents/mcp-apps-starter-guide.md
  • packages/worker/client/mcp-apps/generated-ui-shell.ts
  • packages/worker/migrations/0008-provider-connections.sql
  • packages/worker/migrations/0009-skill-connection-bindings.sql
  • packages/worker/src/index.ts
  • packages/worker/src/mcp-auth.ts
  • packages/worker/src/mcp/capabilities/builtin-domains.ts
  • packages/worker/src/mcp/capabilities/connections/connections-begin-setup.ts
  • packages/worker/src/mcp/capabilities/connections/connections-disconnect.ts
  • packages/worker/src/mcp/capabilities/connections/connections-finalize.ts
  • packages/worker/src/mcp/capabilities/connections/connections-list.ts
  • packages/worker/src/mcp/capabilities/connections/connections-resolve.ts
  • packages/worker/src/mcp/capabilities/connections/connections-set-default.ts
  • packages/worker/src/mcp/capabilities/connections/connections-start-oauth.ts
  • packages/worker/src/mcp/capabilities/connections/domain.ts
  • packages/worker/src/mcp/capabilities/connections/provider-graphql-request.ts
  • packages/worker/src/mcp/capabilities/connections/provider-http-request.ts
  • packages/worker/src/mcp/capabilities/connections/provider-refresh-token.ts
  • packages/worker/src/mcp/capabilities/domain-metadata.ts
  • packages/worker/src/mcp/capabilities/meta/meta-get-skill.ts
  • packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts
  • packages/worker/src/mcp/capabilities/meta/meta-update-skill.ts
  • packages/worker/src/mcp/capabilities/unified-search.ts
  • packages/worker/src/mcp/connections/auth-spec.ts
  • packages/worker/src/mcp/connections/connection-drafts-repo.ts
  • packages/worker/src/mcp/connections/connection-drafts-types.ts
  • packages/worker/src/mcp/connections/connection-handles.ts
  • packages/worker/src/mcp/connections/connection-service.ts
  • packages/worker/src/mcp/connections/crypto.ts
  • packages/worker/src/mcp/connections/oauth-api.ts
  • packages/worker/src/mcp/connections/provider-connections-repo.ts
  • packages/worker/src/mcp/connections/provider-connections-types.ts
  • packages/worker/src/mcp/connections/provider-request.ts
  • packages/worker/src/mcp/generated-ui-api.ts
  • packages/worker/src/mcp/generated-ui-app-session.ts
  • packages/worker/src/mcp/index.ts
  • packages/worker/src/mcp/mcp-server-e2e.test.ts
  • packages/worker/src/mcp/skills/builtin-skill-templates.ts
  • packages/worker/src/mcp/skills/mcp-skills-repo.ts
  • packages/worker/src/mcp/skills/mcp-skills-types.ts
  • packages/worker/src/mcp/skills/skill-connections.ts
  • packages/worker/src/mcp/skills/skill-embed-and-flags.ts
  • packages/worker/src/mcp/skills/skill-mutation.ts
  • packages/worker/src/mcp/tools/open-generated-ui.ts
  • packages/worker/src/mcp/tools/search.ts
  • playwright.config.ts

Comment on lines +479 to +523
async function invokeActionWithSession(input) {
if (!appSession) return null;
const response = await fetch(appSession.endpoints.action, {
method: 'POST',
headers: {
...getAppSessionHeaders(),
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
const payload = await response.json().catch(() => null);
if (!payload || typeof payload !== 'object') {
return {
ok: false,
error: 'Generated UI action endpoint returned an invalid response.',
};
}
return payload;
}
async function submitSecureInputWithSession(input) {
if (!appSession) {
return {
ok: false,
error: 'Secure input is unavailable without a generated UI app session.',
};
}
const response = await fetch(appSession.endpoints.secureInput, {
method: 'POST',
headers: {
...getAppSessionHeaders(),
'content-type': 'application/json',
},
body: JSON.stringify({
setup_id: input.setupId,
fields: input.fields,
}),
});
const payload = await response.json().catch(() => null);
if (!payload || typeof payload !== 'object') {
return {
ok: false,
error: 'Secure input endpoint returned an invalid response.',
};
}
return payload;

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

Treat non-2xx or malformed session-endpoint responses as failures.

Both helpers currently return any JSON object, even when the endpoint replied with 401/500. executeCode() only throws on payload.ok === false, so a JSON error body without ok is treated as a successful null result.

Proposed fix
 async function invokeActionWithSession(input) {
 	if (!appSession) return null;
 	const response = await fetch(appSession.endpoints.action, {
 		method: 'POST',
 		headers: {
 			...getAppSessionHeaders(),
 			'content-type': 'application/json',
 		},
 		body: JSON.stringify(input),
 	});
 	const payload = await response.json().catch(() => null);
-	if (!payload || typeof payload !== 'object') {
+	if (
+		!payload ||
+		typeof payload !== 'object' ||
+		typeof payload.ok !== 'boolean'
+	) {
 		return {
 			ok: false,
 			error: 'Generated UI action endpoint returned an invalid response.',
 		};
 	}
+	if (!response.ok && payload.ok !== false) {
+		return {
+			ok: false,
+			error: 'Generated UI action failed.',
+		};
+	}
 	return payload;
 }

 async function submitSecureInputWithSession(input) {
 	...
 	const payload = await response.json().catch(() => null);
-	if (!payload || typeof payload !== 'object') {
+	if (
+		!payload ||
+		typeof payload !== 'object' ||
+		typeof payload.ok !== 'boolean'
+	) {
 		return {
 			ok: false,
 			error: 'Secure input endpoint returned an invalid response.',
 		};
 	}
+	if (!response.ok && payload.ok !== false) {
+		return {
+			ok: false,
+			error: 'Secure input submission failed.',
+		};
+	}
 	return payload;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function invokeActionWithSession(input) {
if (!appSession) return null;
const response = await fetch(appSession.endpoints.action, {
method: 'POST',
headers: {
...getAppSessionHeaders(),
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
const payload = await response.json().catch(() => null);
if (!payload || typeof payload !== 'object') {
return {
ok: false,
error: 'Generated UI action endpoint returned an invalid response.',
};
}
return payload;
}
async function submitSecureInputWithSession(input) {
if (!appSession) {
return {
ok: false,
error: 'Secure input is unavailable without a generated UI app session.',
};
}
const response = await fetch(appSession.endpoints.secureInput, {
method: 'POST',
headers: {
...getAppSessionHeaders(),
'content-type': 'application/json',
},
body: JSON.stringify({
setup_id: input.setupId,
fields: input.fields,
}),
});
const payload = await response.json().catch(() => null);
if (!payload || typeof payload !== 'object') {
return {
ok: false,
error: 'Secure input endpoint returned an invalid response.',
};
}
return payload;
async function invokeActionWithSession(input) {
if (!appSession) return null;
const response = await fetch(appSession.endpoints.action, {
method: 'POST',
headers: {
...getAppSessionHeaders(),
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
const payload = await response.json().catch(() => null);
if (
!payload ||
typeof payload !== 'object' ||
typeof payload.ok !== 'boolean'
) {
return {
ok: false,
error: 'Generated UI action endpoint returned an invalid response.',
};
}
if (!response.ok && payload.ok !== false) {
return {
ok: false,
error: 'Generated UI action failed.',
};
}
return payload;
}
async function submitSecureInputWithSession(input) {
if (!appSession) {
return {
ok: false,
error: 'Secure input is unavailable without a generated UI app session.',
};
}
const response = await fetch(appSession.endpoints.secureInput, {
method: 'POST',
headers: {
...getAppSessionHeaders(),
'content-type': 'application/json',
},
body: JSON.stringify({
setup_id: input.setupId,
fields: input.fields,
}),
});
const payload = await response.json().catch(() => null);
if (
!payload ||
typeof payload !== 'object' ||
typeof payload.ok !== 'boolean'
) {
return {
ok: false,
error: 'Secure input endpoint returned an invalid response.',
};
}
if (!response.ok && payload.ok !== false) {
return {
ok: false,
error: 'Secure input submission failed.',
};
}
return payload;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/generated-ui-shell.ts` around lines 479 -
523, Both invokeActionWithSession and submitSecureInputWithSession must treat
non-2xx and malformed responses as failures: after fetch, if response.ok is
false, attempt to read a text/json error message and return { ok: false, error:
'...' } instead of trying to use the body; if response.ok is true parse JSON but
validate that payload is an object and that payload.ok is explicitly present and
boolean — if missing or payload is not an object return { ok: false, error:
'...invalid response' }. Update invokeActionWithSession and
submitSecureInputWithSession to perform these checks and return a consistent
{ok:false,...} shape for error cases so callers (e.g. executeCode) won’t treat
error bodies as success.

Comment thread packages/worker/src/mcp/connections/auth-spec.ts Outdated
Comment on lines +73 to +135
export async function updateConnectionDraft(
db: D1Database,
draftId: string,
userId: string,
fields: {
status?: string
state_json?: string | null
error_message?: string | null
expires_at?: string
},
) {
const now = new Date().toISOString()
const existing = await getConnectionDraftById(db, userId, draftId)
if (!existing) return false
await db
.prepare(
`UPDATE connection_drafts
SET status = ?, state_json = ?, error_message = ?, expires_at = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
)
.bind(
resolveFieldUpdate(fields, 'status', existing.status),
resolveFieldUpdate(fields, 'state_json', existing.state_json),
resolveFieldUpdate(fields, 'error_message', existing.error_message),
resolveFieldUpdate(fields, 'expires_at', existing.expires_at),
now,
draftId,
userId,
)
.run()
return true
}

export async function updateConnectionDraftUnsafe(
db: D1Database,
draftId: string,
fields: {
status?: string
state_json?: string | null
error_message?: string | null
expires_at?: string
},
) {
const now = new Date().toISOString()
const existing = await getConnectionDraftByIdUnsafe(db, draftId)
if (!existing) return false
await db
.prepare(
`UPDATE connection_drafts
SET status = ?, state_json = ?, error_message = ?, expires_at = ?, updated_at = ?
WHERE id = ?`,
)
.bind(
resolveFieldUpdate(fields, 'status', existing.status),
resolveFieldUpdate(fields, 'state_json', existing.state_json),
resolveFieldUpdate(fields, 'error_message', existing.error_message),
resolveFieldUpdate(fields, 'expires_at', existing.expires_at),
now,
draftId,
)
.run()
return true
}

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

Avoid the read-then-overwrite update pattern here.

Both update helpers read a snapshot and then rewrite every mutable column. If storeDraftSecrets(), startConnectionOAuth(), and the OAuth callback touch the same draft concurrently, the later write can restore stale state_json, status, or error_message.

Build the SET clause from only the keys explicitly present in fields instead of hydrating omitted values from a stale row.

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

In `@packages/worker/src/mcp/connections/connection-drafts-repo.ts` around lines
73 - 135, The current updateConnectionDraft and updateConnectionDraftUnsafe
functions perform a read-then-overwrite using
getConnectionDraftById/getConnectionDraftByIdUnsafe and resolveFieldUpdate,
which risks stomping concurrent updates (e.g., from storeDraftSecrets,
startConnectionOAuth or the OAuth callback); change both functions to build the
SQL UPDATE dynamically using only the keys present in the passed fields object
(only include status, state_json, error_message, expires_at if they exist on
fields), construct the corresponding parameter list and updated_at value, and
execute a single UPDATE WITHOUT reading the existing row (remove reliance on
resolveFieldUpdate and the initial getConnectionDraft* calls) so omitted columns
are left untouched and concurrent writes do not restore stale values.

Comment on lines +215 to +238
export async function finalizeConnectionSetup(input: {
env: Env
userId: string
draftId: string
makeDefault?: boolean
}) {
const draft = await getRequiredConnectionDraft(
input.env,
input.userId,
input.draftId,
)
const spec = parseConnectionAuthSpec(draft.auth_spec_json)
const secretMaterial = await loadDraftSecretMaterial(input.env, draft.id)
const missingSecretNames = getMissingSecretNames(spec, secretMaterial)
if (missingSecretNames.length > 0) {
throw new Error(
`Connection draft is missing required secret fields: ${missingSecretNames.join(', ')}`,
)
}

const verification = await verifyProviderConnectionDraft({
spec,
secretMaterial,
})

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

Block OAuth finalization until authorization has completed.

For OAuth setups, getMissingSecretNames() only validates the stored client credentials. A draft can reach this block with no access token yet, and if the provider has no verification endpoint you will create an active connection that can never authenticate.

Proposed fix
 	const spec = parseConnectionAuthSpec(draft.auth_spec_json)
 	const secretMaterial = await loadDraftSecretMaterial(input.env, draft.id)
+	if (
+		(spec.strategy === 'oauth2_pre_registered_client' ||
+			spec.strategy === 'oauth2_dynamic_client') &&
+		(
+			draft.status !== 'authorized' ||
+			typeof secretMaterial['access_token'] !== 'string' ||
+			secretMaterial['access_token'].length === 0
+		)
+	) {
+		throw new Error(
+			'OAuth authorization must complete before finalizing this connection.',
+		)
+	}
 	const missingSecretNames = getMissingSecretNames(spec, secretMaterial)
 	if (missingSecretNames.length > 0) {
 		throw new Error(
 			`Connection draft is missing required secret fields: ${missingSecretNames.join(', ')}`,
 		)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/connections/connection-service.ts` around lines 215 -
238, The finalizeConnectionSetup flow currently proceeds even for OAuth drafts
that have client credentials but no tokens; update finalizeConnectionSetup to
detect OAuth-style specs (use the parsed spec from parseConnectionAuthSpec) and
check secretMaterial for required token fields (e.g., access_token or
refresh_token) after loadDraftSecretMaterial and getMissingSecretNames; if the
spec indicates OAuth and no token is present, abort finalization by throwing a
clear Error or returning a pending state instead of calling
verifyProviderConnectionDraft, so OAuth drafts are only finalized once
authorization has completed.

Comment on lines +598 to +628
const secretMaterial = await loadDraftSecretMaterial(env, draft.id)
const tokenResponse = await exchangeOAuthCodeForTokens({
env,
spec,
secretMaterial,
code,
redirectUri,
codeVerifier,
})

for (const [name, value] of Object.entries(tokenResponse.secretMaterial)) {
if (typeof value !== 'string') continue
const encryptedValue = await encryptJson(
env,
encryptedDraftSecretPurpose,
value,
)
await upsertConnectionDraftSecret(env.APP_DB, {
draft_id: draft.id,
secret_name: name,
encrypted_value: encryptedValue,
})
}
await updateConnectionDraftUnsafe(env.APP_DB, draft.id, {
status: 'authorized',
error_message: null,
state_json: JSON.stringify({
...(stateJson ?? {}),
authorized_at: new Date().toISOString(),
}),
})

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

Persist OAuth callback failures onto the draft.

If exchangeOAuthCodeForTokens() or one of the secret upserts throws, this handler exits with an unhandled error and leaves the draft stuck in awaiting_oauth_callback. Record the failure on the draft before returning the error page.

Proposed fix
 	const secretMaterial = await loadDraftSecretMaterial(env, draft.id)
-	const tokenResponse = await exchangeOAuthCodeForTokens({
-		env,
-		spec,
-		secretMaterial,
-		code,
-		redirectUri,
-		codeVerifier,
-	})
-
-	for (const [name, value] of Object.entries(tokenResponse.secretMaterial)) {
-		if (typeof value !== 'string') continue
-		const encryptedValue = await encryptJson(
-			env,
-			encryptedDraftSecretPurpose,
-			value,
-		)
-		await upsertConnectionDraftSecret(env.APP_DB, {
-			draft_id: draft.id,
-			secret_name: name,
-			encrypted_value: encryptedValue,
-		})
-	}
-	await updateConnectionDraftUnsafe(env.APP_DB, draft.id, {
-		status: 'authorized',
-		error_message: null,
-		state_json: JSON.stringify({
-			...(stateJson ?? {}),
-			authorized_at: new Date().toISOString(),
-		}),
-	})
-	return renderOAuthCallbackResponse(
-		200,
-		`${draft.display_name} authorization succeeded. Return to Kody and finish setup.`,
-	)
+	try {
+		const tokenResponse = await exchangeOAuthCodeForTokens({
+			env,
+			spec,
+			secretMaterial,
+			code,
+			redirectUri,
+			codeVerifier,
+		})
+
+		for (const [name, value] of Object.entries(tokenResponse.secretMaterial)) {
+			if (typeof value !== 'string') continue
+			const encryptedValue = await encryptJson(
+				env,
+				encryptedDraftSecretPurpose,
+				value,
+			)
+			await upsertConnectionDraftSecret(env.APP_DB, {
+				draft_id: draft.id,
+				secret_name: name,
+				encrypted_value: encryptedValue,
+			})
+		}
+		await updateConnectionDraftUnsafe(env.APP_DB, draft.id, {
+			status: 'authorized',
+			error_message: null,
+			state_json: JSON.stringify({
+				...(stateJson ?? {}),
+				authorized_at: new Date().toISOString(),
+			}),
+		})
+		return renderOAuthCallbackResponse(
+			200,
+			`${draft.display_name} authorization succeeded. Return to Kody and finish setup.`,
+		)
+	} catch (error) {
+		const message =
+			error instanceof Error ? error.message : 'OAuth token exchange failed.'
+		await updateConnectionDraftUnsafe(env.APP_DB, draft.id, {
+			status: 'error',
+			error_message: message,
+		})
+		return renderOAuthCallbackResponse(400, message)
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/connections/connection-service.ts` around lines 598 -
628, Wrap the token exchange and secret persistence sequence (the calls to
exchangeOAuthCodeForTokens, encryptJson, and upsertConnectionDraftSecret) in a
try/catch so any thrown error is caught; in the catch, call
updateConnectionDraftUnsafe for the draft (referencing draft.id and
updateConnectionDraftUnsafe) to persist a failure state by setting a sensible
status (e.g., 'error' or leaving 'awaiting_oauth_callback' per desired
semantics) and writing error_message to the error text (include
error.message/stack) and optionally state_json, then rethrow or return the error
page result—this ensures failures during
loadDraftSecretMaterial/exchangeOAuthCodeForTokens/encryptJson/upsertConnectionDraftSecret
are recorded on the draft before the handler exits.

Comment on lines +8 to +17
if (request.method !== 'GET' && request.method !== 'HEAD') {
return new Response('Method not allowed.', { status: 405 })
}
const response = await handleConnectionOAuthCallback(request, env)
if (request.method === 'HEAD') {
return new Response(null, {
status: response.status,
headers: response.headers,
})
}

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

Do not execute OAuth callback flow for HEAD requests.

Line 11 currently runs the full callback for HEAD, which can trigger side effects on a non-interactive probe and jeopardize the subsequent real GET callback.

🛡️ Proposed fix
 export async function handleConnectionOAuthRequest(request: Request, env: Env) {
-	if (request.method !== 'GET' && request.method !== 'HEAD') {
-		return new Response('Method not allowed.', { status: 405 })
+	if (request.method !== 'GET') {
+		return new Response('Method not allowed.', {
+			status: 405,
+			headers: { Allow: 'GET' },
+		})
 	}
 	const response = await handleConnectionOAuthCallback(request, env)
-	if (request.method === 'HEAD') {
-		return new Response(null, {
-			status: response.status,
-			headers: response.headers,
-		})
-	}
 	return response
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/connections/oauth-api.ts` around lines 8 - 17, The
code currently calls handleConnectionOAuthCallback for HEAD requests which can
cause side effects; change the control flow so
handleConnectionOAuthCallback(request, env) is only invoked when request.method
=== 'GET' and not for 'HEAD'. Specifically, check for 'HEAD' immediately after
validating allowed methods and return an empty Response with the appropriate
status/headers without calling handleConnectionOAuthCallback, and move the
handleConnectionOAuthCallback call into the GET branch so side-effecting OAuth
callback logic runs only for GET.

Comment on lines +140 to +155
export async function verifyProviderConnectionDraft(input: {
spec: ConnectionAuthSpec
secretMaterial: ProviderSecretMaterial
}) {
if (!input.spec.verification) return null
return performProviderHttpRequestWithSpec({
spec: input.spec,
secretMaterial: input.secretMaterial,
request: {
method: input.spec.verification.method,
path: input.spec.verification.path,
query: input.spec.verification.query,
body: input.spec.verification.body,
},
})
}

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

Fail draft verification on non-success statuses.

This helper currently returns the raw verification response even for 401/403/500. Downstream, finalizeConnectionSetup() can still persist an active connection after the provider rejected the credentials.

Proposed fix
 export async function verifyProviderConnectionDraft(input: {
 	spec: ConnectionAuthSpec
 	secretMaterial: ProviderSecretMaterial
 }) {
 	if (!input.spec.verification) return null
-	return performProviderHttpRequestWithSpec({
+	const response = await performProviderHttpRequestWithSpec({
 		spec: input.spec,
 		secretMaterial: input.secretMaterial,
 		request: {
 			method: input.spec.verification.method,
 			path: input.spec.verification.path,
 			query: input.spec.verification.query,
 			body: input.spec.verification.body,
 		},
 	})
+	if (response.status < 200 || response.status >= 300) {
+		throw new Error(`Provider verification failed (${response.status}).`)
+	}
+	return response
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function verifyProviderConnectionDraft(input: {
spec: ConnectionAuthSpec
secretMaterial: ProviderSecretMaterial
}) {
if (!input.spec.verification) return null
return performProviderHttpRequestWithSpec({
spec: input.spec,
secretMaterial: input.secretMaterial,
request: {
method: input.spec.verification.method,
path: input.spec.verification.path,
query: input.spec.verification.query,
body: input.spec.verification.body,
},
})
}
export async function verifyProviderConnectionDraft(input: {
spec: ConnectionAuthSpec
secretMaterial: ProviderSecretMaterial
}) {
if (!input.spec.verification) return null
const response = await performProviderHttpRequestWithSpec({
spec: input.spec,
secretMaterial: input.secretMaterial,
request: {
method: input.spec.verification.method,
path: input.spec.verification.path,
query: input.spec.verification.query,
body: input.spec.verification.body,
},
})
if (response.status < 200 || response.status >= 300) {
throw new Error(`Provider verification failed (${response.status}).`)
}
return response
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/connections/provider-request.ts` around lines 140 -
155, verifyProviderConnectionDraft currently returns the raw response from
performProviderHttpRequestWithSpec even when the provider returns non-success
statuses (e.g., 401/403/500), allowing downstream finalizeConnectionSetup to
persist invalid/active connections; update verifyProviderConnectionDraft to
inspect the returned response status (from performProviderHttpRequestWithSpec)
and treat any non-2xx status as a failure: either throw a descriptive error or
return a rejected result so callers can abort setup, include the status and
provider response body in the error message for diagnostics, and keep references
to ConnectionAuthSpec, ProviderSecretMaterial,
performProviderHttpRequestWithSpec, verifyProviderConnectionDraft and
finalizeConnectionSetup to locate the change.

Comment on lines +33 to +38
const token = await signToken(env, generatedUiSessionPurpose, {
session_id: sessionId,
user,
iat: now,
exp: expiresAtMs,
})

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check what fields McpUserContext contains

# Search for McpUserContext type definition
rg -n -A10 'type McpUserContext' packages/shared/
rg -n -A10 'interface McpUserContext' packages/shared/

# Also check the shared chat module
fd -e ts 'chat' packages/shared --exec cat {}

Repository: kentcdodds/kody

Length of output: 2515


Remove unnecessary PII from the signed token payload.

The McpUserContext object embedded in the token includes email, which is personally identifiable information. While the token is signed, it is not encrypted, making the payload readable to anyone who possesses the token. The userId and displayName alone should be sufficient for session identification; email should be removed from the token payload or the token should be encrypted (JWE) if email inclusion is necessary.

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

In `@packages/worker/src/mcp/generated-ui-app-session.ts` around lines 33 - 38,
The token payload currently includes PII (user.email) when calling signToken
with generatedUiSessionPurpose; remove email from the McpUserContext passed into
signToken so only non-PII fields (e.g., userId and displayName) are embedded
(keep session_id, iat, exp as-is). Locate the call to signToken and the object
referenced by the user variable (McpUserContext) and change the constructed
payload to exclude email (or construct a new minimal user object) and update any
type annotations if necessary; if email is required in future, switch to an
encrypted token (JWE) instead.

Comment on lines +189 to +201
code: `async (params) => {
const connection = await codemode.connections_resolve({
provider: 'github',
selection: { strategy: 'default' },
})

return await codemode.provider_graphql_request({
connection_handle: connection.handle,
query: params.query,
...(params.variables ? { variables: params.variables } : {}),
...(params.operationName ? { operationName: params.operationName } : {}),
})
}`,

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 | 🟡 Minor

GraphQL template will throw on missing connection, unlike REST template.

The GitHub REST template (lines 84-149) uses allow_missing: true and gracefully handles the missing connection case by initiating setup. The GraphQL template calls connections_resolve without allow_missing, so it will throw an error if no connection exists rather than offering to set one up.

If this is intentional (GraphQL is lower-level and assumes connection exists), consider documenting this in the template description. Otherwise, align the behavior:

💡 Suggested fix to add graceful fallback
 code: `async (params) => {
   const connection = await codemode.connections_resolve({
     provider: 'github',
     selection: { strategy: 'default' },
+    allow_missing: true,
   })
+
+  if (!connection.found) {
+    return {
+      ok: false,
+      next_action: 'connect_account',
+      provider: 'github',
+      message: 'No GitHub connection found. Please set up a GitHub connection first using the GitHub REST Request skill.',
+    }
+  }

   return await codemode.provider_graphql_request({
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/skills/builtin-skill-templates.ts` around lines 189 -
201, The GraphQL template currently calls codemode.connections_resolve(...)
without allow_missing and will throw if no GitHub connection exists; change the
call in the async template handler to pass allow_missing: true to match the REST
template, then mirror the REST flow: check the returned connection value and, if
it's missing/falsy, return the same connection-setup initiation path used by the
REST template instead of proceeding to codemode.provider_graphql_request; update
the block that calls provider_graphql_request to only run when a truthy
connection.handle is present and otherwise delegate to the existing
connection-setup behavior.

Comment on lines +248 to +263
code: `async (params) => {
const connection = await codemode.connections_resolve({
provider: 'cloudflare',
selection: { strategy: 'default' },
})

return await codemode.provider_http_request({
connection_handle: connection.handle,
request: {
method: params.method,
path: params.path,
...(params.query ? { query: params.query } : {}),
...(params.body !== undefined ? { body: params.body } : {}),
},
})
}`,

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 | 🟡 Minor

Cloudflare and Cursor templates also lack graceful missing-connection handling.

Same pattern as the GraphQL template - these will throw rather than guide the user through setup. Consider whether these templates should include allow_missing: true with setup flow similar to the GitHub REST template.

Also applies to: 309-324

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

In `@packages/worker/src/mcp/skills/builtin-skill-templates.ts` around lines 248 -
263, The Cloudflare and Cursor template code currently calls
codemode.connections_resolve and codemode.provider_http_request directly and
will throw if no connection exists; update both templates to call
codemode.connections_resolve with allow_missing: true, check the returned
connection (or lack thereof) and return a user-facing setup guidance
object/response when missing, otherwise proceed to call
codemode.provider_http_request using connection.handle (mirror the GitHub REST
template's flow). Ensure you reference the existing calls to
codemode.connections_resolve and codemode.provider_http_request in the template
strings and implement the missing-connection early-return/guide path.

Comment thread packages/worker/src/mcp/connections/auth-spec.ts
Comment thread packages/worker/src/mcp/generated-ui-api.ts
Comment thread packages/worker/src/mcp/connections/connection-service.ts
kentcdodds and others added 7 commits March 24, 2026 23:00
Move provider auth and capability discovery toward per-user D1-backed connections and skill templates so provider access can change without redeploys. Add generated UI app sessions so frontend actions and secret submission can reach host-owned worker endpoints without exposing sensitive input to codemode.

Made-with: Cursor
Normalize the new connection and generated UI runtime changes to the repository formatting rules so the follow-up validation and review diffs stay focused on behavior.

Made-with: Cursor
Use a dedicated default Playwright port so validation does not accidentally reuse unrelated local Wrangler workers and fail the app e2e suite with false negatives.

Made-with: Cursor
Make builtin skill template seeding idempotent under concurrent first-use requests and keep seeding failures from aborting unrelated MCP calls. Preserve explicit null clears in connection updates and tighten provider refresh/path guards so retries and host-side requests behave predictably.

Made-with: Cursor
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Keep the home connector handler aligned with the current lint autofix output so validate finishes with a clean working tree after the rebase.

Made-with: Cursor
@kentcdodds
kentcdodds force-pushed the dynamic-skills-oauth-runtime branch from a113509 to b646abe Compare March 25, 2026 05:03
@github-actions

Copy link
Copy Markdown
Contributor

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

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

Mocks:

Comment thread packages/worker/src/mcp/connections/connection-service.ts
Comment thread packages/worker/src/index.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

@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 2 potential issues.

There are 4 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Unhandled exceptions in secure-input API endpoint
    • Wrapped secure-input storage in a try/catch to return structured JSON errors instead of uncaught exceptions.
  • ✅ Fixed: Content-Type header set unconditionally on GET requests
    • Made the JSON content-type header conditional on actually sending a JSON body for HTTP requests.
Preview (b693cc8cc0)
diff --git a/docs/agents/end-to-end-testing.md b/docs/agents/end-to-end-testing.md
--- a/docs/agents/end-to-end-testing.md
+++ b/docs/agents/end-to-end-testing.md
@@ -37,9 +37,9 @@
 ## Server and routing
 
 - The test server is started via Playwright `webServer` using Wrangler.
-- The base URL defaults to `http://localhost:8788` for Playwright to avoid
-  colliding with the dev server. Override with `PLAYWRIGHT_BASE_URL` or
-  `PLAYWRIGHT_PORT`.
+- The base URL defaults to `http://localhost:38888` for Playwright to avoid
+  colliding with the common local Wrangler dev ports. Override with
+  `PLAYWRIGHT_BASE_URL` or `PLAYWRIGHT_PORT`.
 - Playwright sets `CLOUDFLARE_ENV=test`; Wrangler still loads
   `packages/worker/.env` values for local secrets.
 - Ensure the `env.test` section in `packages/worker/wrangler.jsonc` includes

diff --git a/packages/home-connector/app/handlers.ts b/packages/home-connector/app/handlers.ts
--- a/packages/home-connector/app/handlers.ts
+++ b/packages/home-connector/app/handlers.ts
@@ -2,7 +2,7 @@
 import { html } from 'remix/html-template'
 import { render } from './render.ts'
 import { RootLayout } from './root.tsx'
-import type { routes } from './routes.ts'
+import { type routes } from './routes.ts'
 import { type HomeConnectorState } from '../src/state.ts'
 import { type RokuDiscoveryDiagnostics } from '../src/adapters/roku/types.ts'
 import { scanRokuDevices } from '../src/adapters/roku/index.ts'

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
@@ -5,11 +5,23 @@
 type DisplayMode = 'inline' | 'fullscreen' | 'pip'
 type ThemeName = 'light' | 'dark'
 
+type AppSessionEnvelope = {
+	sessionId: string
+	token: string
+	expiresAt: string
+	endpoints: {
+		appSource: string
+		action: string
+		secureInput: string
+	}
+}
+
 type RenderEnvelope = {
 	mode: RenderMode
 	code?: string
 	appId?: string
 	runtime?: AppRuntime
+	appSession?: AppSessionEnvelope | null
 }
 
 type RenderDataEnvelope = {
@@ -32,6 +44,36 @@
 	return value === 'html' || value === 'javascript' ? value : undefined
 }
 
+function coerceAppSession(value: unknown): AppSessionEnvelope | null {
+	if (!isRecord(value)) return null
+	if (
+		typeof value.sessionId !== 'string' ||
+		typeof value.token !== 'string' ||
+		typeof value.expiresAt !== 'string'
+	) {
+		return null
+	}
+	const endpoints = isRecord(value.endpoints) ? value.endpoints : null
+	if (
+		!endpoints ||
+		typeof endpoints.appSource !== 'string' ||
+		typeof endpoints.action !== 'string' ||
+		typeof endpoints.secureInput !== 'string'
+	) {
+		return null
+	}
+	return {
+		sessionId: value.sessionId,
+		token: value.token,
+		expiresAt: value.expiresAt,
+		endpoints: {
+			appSource: endpoints.appSource,
+			action: endpoints.action,
+			secureInput: endpoints.secureInput,
+		},
+	}
+}
+
 function coerceDisplayMode(value: unknown): DisplayMode | null {
 	return value === 'inline' || value === 'fullscreen' || value === 'pip'
 		? value
@@ -65,9 +107,8 @@
 ) {
 	if (/<head\b[^>]*>/i.test(code)) {
 		const withTheme = theme
-			? code.replace(
-					/<html\b[^>]*>/i,
-					(match) => injectThemeAttributeIntoHtmlTag(match, theme),
+			? code.replace(/<html\b[^>]*>/i, (match) =>
+					injectThemeAttributeIntoHtmlTag(match, theme),
 				)
 			: code
 
@@ -118,7 +159,8 @@
 				: undefined
 	const appId = typeof value.appId === 'string' ? value.appId : undefined
 	const runtime = coerceRuntime(value.runtime) ?? (code ? 'html' : undefined)
-	return { mode: renderSource, code, appId, runtime }
+	const appSession = coerceAppSession(value.appSession)
+	return { mode: renderSource, code, appId, runtime, appSession }
 }
 
 function getEnvelopeFromRenderData(renderData: RenderDataEnvelope | undefined) {
@@ -142,6 +184,7 @@
 	const childMessagePrefix = 'kody-generated-ui:'
 
 	let latestRenderData: RenderDataEnvelope | undefined
+	let latestEnvelope: RenderEnvelope | null = null
 
 	function getBaseHref() {
 		try {
@@ -151,7 +194,7 @@
 		}
 	}
 
-	function escapeInlineModuleSource(code: string) {
+	function escapeInlineScriptSource(code: string) {
 		return code.replace(/<\/script/gi, '<\\/script')
 	}
 
@@ -385,28 +428,100 @@
 		`.trim()
 	}
 
-	function buildHeadInjection(theme: ThemeName | null) {
+	function buildHeadInjection(
+		theme: ThemeName | null,
+		appSession: AppSessionEnvelope | null,
+	) {
 		const baseHref = getBaseHref()
 		const escapedBaseHref = baseHref ? escapeHtmlAttribute(baseHref) : null
+		const bridgeRuntime = escapeInlineScriptSource(
+			buildChildBridgeRuntimeSource(appSession),
+		)
 		return `
 ${escapedBaseHref ? `<base href="${escapedBaseHref}" />` : ''}
 <style>
 ${buildShellStyles(theme)}
 </style>
 <script>
-${buildChildBridgeRuntimeSource()}
+${bridgeRuntime}
 </script>
 		`.trim()
 	}
 
-	function buildChildBridgeRuntimeSource() {
+	function buildChildBridgeRuntimeSource(
+		appSession: AppSessionEnvelope | null,
+	) {
+		const serializedAppSession = JSON.stringify(appSession)
 		return `
 const shellMessagePrefix = '${childMessagePrefix}';
+const appSession = ${serializedAppSession};
 let requestCounter = 0;
 function nextRequestId() {
 	requestCounter += 1;
 	return 'generated-ui-' + requestCounter;
 }
+function getAppSessionHeaders() {
+	if (!appSession) return null;
+	return {
+		accept: 'application/json',
+		authorization: 'Bearer ' + appSession.token,
+	};
+}
+function wrapActionCode(code, params) {
+	if (params === undefined) return code;
+	const paramsJson = JSON.stringify(params ?? {});
+	return \`async () => {
+  const params = \${paramsJson};
+  const action = (\${code});
+  return await action(params);
+}\`;
+}
+async function invokeActionWithSession(input) {
+	if (!appSession) return null;
+	const response = await fetch(appSession.endpoints.action, {
+		method: 'POST',
+		headers: {
+			...getAppSessionHeaders(),
+			'content-type': 'application/json',
+		},
+		body: JSON.stringify(input),
+	});
+	const payload = await response.json().catch(() => null);
+	if (!payload || typeof payload !== 'object') {
+		return {
+			ok: false,
+			error: 'Generated UI action endpoint returned an invalid response.',
+		};
+	}
+	return payload;
+}
+async function submitSecureInputWithSession(input) {
+	if (!appSession) {
+		return {
+			ok: false,
+			error: 'Secure input is unavailable without a generated UI app session.',
+		};
+	}
+	const response = await fetch(appSession.endpoints.secureInput, {
+		method: 'POST',
+		headers: {
+			...getAppSessionHeaders(),
+			'content-type': 'application/json',
+		},
+		body: JSON.stringify({
+			setup_id: input.setupId,
+			fields: input.fields,
+		}),
+	});
+	const payload = await response.json().catch(() => null);
+	if (!payload || typeof payload !== 'object') {
+		return {
+			ok: false,
+			error: 'Secure input endpoint returned an invalid response.',
+		};
+	}
+	return payload;
+}
 function waitForShellMessage(type, requestId) {
 	return new Promise((resolve) => {
 		function handleMessage(event) {
@@ -463,6 +578,19 @@
 	},
 	async executeCode(code) {
 		if (typeof code !== 'string' || code.length === 0) return null;
+		if (appSession) {
+			const payload = await invokeActionWithSession({ code });
+			if (payload && typeof payload === 'object' && payload.ok === false) {
+				throw new Error(
+					typeof payload.error === 'string'
+						? payload.error
+						: 'Generated UI action failed.',
+				);
+			}
+			return payload && typeof payload === 'object' && 'result' in payload
+				? payload.result ?? null
+				: null;
+		}
 		if (window.parent === window) return null;
 		const requestId = nextRequestId();
 		const payload = await postRequestAndWaitForShellMessage(
@@ -479,6 +607,63 @@
 		}
 		return 'result' in payload ? payload.result ?? null : null;
 	},
+	async invokeAction(input) {
+		if (!input || typeof input !== 'object') {
+			return {
+				ok: false,
+				error: 'Action input must be an object.',
+			};
+		}
+		const code =
+			typeof input.code === 'string' && input.code.length > 0 ? input.code : null;
+		if (!code) {
+			return {
+				ok: false,
+				error: 'Action code must be a non-empty string.',
+			};
+		}
+		const params =
+			input.params && typeof input.params === 'object' ? input.params : undefined;
+		if (appSession) {
+			return await invokeActionWithSession(
+				params === undefined ? { code } : { code, params },
+			);
+		}
+		try {
+			const result = await this.executeCode(wrapActionCode(code, params));
+			return {
+				ok: true,
+				result,
+			};
+		} catch (error) {
+			return {
+				ok: false,
+				error:
+					error instanceof Error ? error.message : 'Generated UI action failed.',
+			};
+		}
+	},
+	async submitSecureInput(input) {
+		if (!input || typeof input !== 'object') {
+			return {
+				ok: false,
+				error: 'Secure input must be an object.',
+			};
+		}
+		const setupId =
+			typeof input.setupId === 'string' && input.setupId.length > 0
+				? input.setupId
+				: null;
+		const fields =
+			input.fields && typeof input.fields === 'object' ? input.fields : null;
+		if (!setupId || !fields) {
+			return {
+				ok: false,
+				error: 'Secure input requires setupId and fields.',
+			};
+		}
+		return await submitSecureInputWithSession({ setupId, fields });
+	},
 };
 window.addEventListener('error', (event) => {
 	console.error(
@@ -496,9 +681,13 @@
 	}
 
 	function buildInlineModuleSource(code: string) {
-		const safeCode = escapeInlineModuleSource(code)
+		const safeCode = escapeInlineScriptSource(code)
+		const appSession = latestEnvelope?.appSession ?? null
+		const bridgeRuntime = escapeInlineScriptSource(
+			buildChildBridgeRuntimeSource(appSession),
+		)
 		return `
-${buildChildBridgeRuntimeSource()}
+${bridgeRuntime}
 ${safeCode}
 		`.trim()
 	}
@@ -519,13 +708,14 @@
 
 	function buildHtmlDocumentFromFragment(code: string) {
 		const theme = coerceTheme(latestRenderData?.theme)
+		const appSession = latestEnvelope?.appSession ?? null
 		return `
 <!doctype html>
 <html lang="en"${theme ? ` data-kody-theme="${theme}"` : ''}>
 	<head>
 		<meta charset="utf-8" />
 		<meta name="viewport" content="width=device-width, initial-scale=1" />
-		${buildHeadInjection(theme)}
+		${buildHeadInjection(theme, appSession)}
 	</head>
 	<body data-kody-runtime="fragment">
 ${code}
@@ -560,13 +750,45 @@
 		}
 
 		const theme = coerceTheme(latestRenderData?.theme)
+		const appSession = latestEnvelope?.appSession ?? null
 		const htmlSource = /<(?:!doctype|html|head|body)\b/i.test(code)
-			? injectIntoHtmlDocument(code, buildHeadInjection(theme), theme)
+			? injectIntoHtmlDocument(
+					code,
+					buildHeadInjection(theme, appSession),
+					theme,
+				)
 			: buildHtmlDocumentFromFragment(code)
 		frameElement.srcdoc = htmlSource
 	}
 
 	async function resolveSavedAppCode(appId: string) {
+		const appSession = latestEnvelope?.appSession ?? null
+		if (appSession) {
+			const url = new URL(appSession.endpoints.appSource)
+			url.searchParams.set('app_id', appId)
+			const response = await fetch(url.toString(), {
+				headers: {
+					accept: 'application/json',
+					authorization: `Bearer ${appSession.token}`,
+				},
+			})
+			const payload = (await response.json().catch(() => null)) as Record<
+				string,
+				unknown
+			> | null
+			const app = isRecord(payload?.app) ? payload.app : null
+			if (!response.ok || payload?.ok !== true || !app) {
+				throw new Error('Failed to load saved app source.')
+			}
+			const code = typeof app.code === 'string' ? app.code : null
+			if (!code) {
+				throw new Error('Saved app source is missing code.')
+			}
+			return {
+				code,
+				runtime: coerceRuntime(app.runtime) ?? 'html',
+			}
+		}
 		const result = (await hostBridge.callTool({
 			name: 'ui_load_app_source',
 			arguments: { app_id: appId },
@@ -591,6 +813,7 @@
 	}
 
 	async function renderEnvelope(envelope: RenderEnvelope | null) {
+		latestEnvelope = envelope
 		if (!envelope) {
 			frameElement.srcdoc = ''
 			return

diff --git a/packages/worker/migrations/0008-provider-connections.sql b/packages/worker/migrations/0008-provider-connections.sql
new file mode 100644
--- /dev/null
+++ b/packages/worker/migrations/0008-provider-connections.sql
@@ -1,0 +1,67 @@
+CREATE TABLE IF NOT EXISTS connection_drafts (
+	id TEXT PRIMARY KEY NOT NULL,
+	user_id TEXT NOT NULL,
+	provider_key TEXT NOT NULL,
+	display_name TEXT NOT NULL,
+	label TEXT,
+	auth_spec_json TEXT NOT NULL,
+	status TEXT NOT NULL DEFAULT 'draft',
+	state_json TEXT,
+	error_message TEXT,
+	created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
+	updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
+	expires_at TEXT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_connection_drafts_user_provider
+	ON connection_drafts(user_id, provider_key);
+
+CREATE INDEX IF NOT EXISTS idx_connection_drafts_expires_at
+	ON connection_drafts(expires_at);
+
+CREATE TABLE IF NOT EXISTS connection_draft_secrets (
+	draft_id TEXT NOT NULL,
+	secret_name TEXT NOT NULL,
+	encrypted_value TEXT NOT NULL,
+	created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
+	updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
+	PRIMARY KEY (draft_id, secret_name),
+	FOREIGN KEY (draft_id) REFERENCES connection_drafts(id) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS provider_connections (
+	id TEXT PRIMARY KEY NOT NULL,
+	user_id TEXT NOT NULL,
+	provider_key TEXT NOT NULL,
+	display_name TEXT NOT NULL,
+	label TEXT NOT NULL,
+	auth_spec_json TEXT NOT NULL,
+	status TEXT NOT NULL DEFAULT 'active',
+	account_id TEXT,
+	account_label TEXT,
+	scope_set TEXT,
+	metadata_json TEXT,
+	is_default INTEGER NOT NULL DEFAULT 0,
+	token_expires_at TEXT,
+	last_used_at TEXT,
+	created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
+	updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
+);
+
+CREATE INDEX IF NOT EXISTS idx_provider_connections_user_provider
+	ON provider_connections(user_id, provider_key);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_provider_connections_default
+	ON provider_connections(user_id, provider_key)
+	WHERE is_default = 1;
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_provider_connections_user_label
+	ON provider_connections(user_id, provider_key, label);
+
+CREATE TABLE IF NOT EXISTS provider_connection_secrets (
+	connection_id TEXT PRIMARY KEY NOT NULL,
+	encrypted_secret_json TEXT NOT NULL,
+	created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
+	updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
+	FOREIGN KEY (connection_id) REFERENCES provider_connections(id) ON DELETE CASCADE
+);

diff --git a/packages/worker/migrations/0009-skill-connection-bindings.sql b/packages/worker/migrations/0009-skill-connection-bindings.sql
new file mode 100644
--- /dev/null
+++ b/packages/worker/migrations/0009-skill-connection-bindings.sql
@@ -1,0 +1,6 @@
+ALTER TABLE mcp_skills ADD COLUMN connection_bindings TEXT;
+ALTER TABLE mcp_skills ADD COLUMN template_key TEXT;
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_skills_user_template_key
+	ON mcp_skills(user_id, template_key)
+	WHERE template_key IS NOT NULL;

diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts
--- a/packages/worker/src/index.ts
+++ b/packages/worker/src/index.ts
@@ -23,6 +23,14 @@
 	mcpResourcePath,
 	protectedResourceMetadataPath,
 } from './mcp-auth.ts'
+import {
+	handleGeneratedUiApiRequest,
+	isGeneratedUiApiRequest,
+} from './mcp/generated-ui-api.ts'
+import {
+	handleConnectionOAuthRequest,
+	isConnectionOAuthRequest,
+} from './mcp/connections/oauth-api.ts'
 import { withCors } from './utils.ts'
 import { handleCapabilityReindexRequest } from './capability-maintenance.ts'
 import { handleSkillReindexRequest } from './skill-maintenance.ts'
@@ -31,16 +39,24 @@
 
 const appHandler = withCors({
 	getCorsHeaders(request) {
+		const url = new URL(request.url)
+		if (isGeneratedUiApiRequest(url.pathname)) {
+			return new Headers({
+				'Access-Control-Allow-Origin': '*',
+				'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
+				'Access-Control-Allow-Headers': 'content-type, authorization',
+			})
+		}
 		const origin = request.headers.get('Origin')
 		if (!origin) return null
-		const requestOrigin = new URL(request.url).origin
+		const requestOrigin = url.origin
 		if (origin !== requestOrigin) return null
-		return {
+		return new Headers({
 			'Access-Control-Allow-Origin': origin,
 			'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
 			'Access-Control-Allow-Headers': 'content-type, authorization',
 			Vary: 'Origin',
-		}
+		})
 	},
 	async handler(request, env, ctx) {
 		const url = new URL(request.url)
@@ -65,6 +81,10 @@
 			return handleOAuthCallback(request)
 		}
 
+		if (isConnectionOAuthRequest(url.pathname)) {
+			return handleConnectionOAuthRequest(request, env)
+		}
+
 		if (url.pathname === '/.well-known/appspecific/com.chrome.devtools.json') {
 			return new Response(null, { status: 204 })
 		}
@@ -84,6 +104,10 @@
 			})
 		}
 
+		if (isGeneratedUiApiRequest(url.pathname)) {
+			return handleGeneratedUiApiRequest(request, env)
+		}
+
 		if (url.pathname.startsWith('/home/connectors/')) {
 			const parts = url.pathname.split('/').filter(Boolean)
 			const connectorId = parts[2]?.trim()

diff --git a/packages/worker/src/mcp-auth.ts b/packages/worker/src/mcp-auth.ts
--- a/packages/worker/src/mcp-auth.ts
+++ b/packages/worker/src/mcp-auth.ts
@@ -3,12 +3,25 @@
 	type TokenSummary,
 } from '@cloudflare/workers-oauth-provider'
 import { createMcpCallerContext, type McpServerProps } from './mcp/context.ts'
+import { ensureBuiltinSkillTemplatesForUser } from '#mcp/skills/builtin-skill-templates.ts'
 import { oauthScopes } from './oauth-handlers.ts'
 
 export const mcpResourcePath = '/mcp'
 export const protectedResourceMetadataPath =
 	'/.well-known/oauth-protected-resource'
+const builtinTemplateSeedCooldownMs = 5 * 60 * 1000
+const builtinTemplateSeedMaxUsers = 1000
+const builtinTemplateSeedByUser = new Map<string, number>()
 
+function recordBuiltinTemplateSeed(userId: string, seededAt: number) {
+	builtinTemplateSeedByUser.set(userId, seededAt)
+	while (builtinTemplateSeedByUser.size > builtinTemplateSeedMaxUsers) {
+		const oldestKey = builtinTemplateSeedByUser.keys().next().value
+		if (!oldestKey) break
+		builtinTemplateSeedByUser.delete(oldestKey)
+	}
+}
+
 type OAuthEnv = Env & {
 	OAUTH_PROVIDER?: OAuthHelpers
 }
@@ -111,6 +124,28 @@
 		user: tokenSummary.grant.props ?? null,
 		homeConnectorId: 'default',
 	})
+	if (props.user?.userId) {
+		const now = Date.now()
+		const lastSeededAt = builtinTemplateSeedByUser.get(props.user.userId)
+		if (lastSeededAt && now - lastSeededAt < builtinTemplateSeedCooldownMs) {
+			context.props = props
+			return fetchMcp(
+				request,
+				env,
+				context as ExecutionContext<OAuthContextProps>,
+			)
+		}
+		try {
+			await ensureBuiltinSkillTemplatesForUser(env, props.user.userId)
+		} catch (error) {
+			console.warn('Failed to ensure builtin MCP skill templates', {
+				userId: props.user.userId,
+				error: error instanceof Error ? error.message : String(error),
+			})
+		} finally {
+			recordBuiltinTemplateSeed(props.user.userId, now)
+		}
+	}
 	context.props = props
 
 	return fetchMcp(request, env, context as ExecutionContext<OAuthContextProps>)

diff --git a/packages/worker/src/mcp/capabilities/builtin-domains.ts b/packages/worker/src/mcp/capabilities/builtin-domains.ts
--- a/packages/worker/src/mcp/capabilities/builtin-domains.ts
+++ b/packages/worker/src/mcp/capabilities/builtin-domains.ts
@@ -1,5 +1,6 @@
 import { appsDomain } from './apps/domain.ts'
 import { codingDomain } from './coding/domain.ts'
+import { connectionsDomain } from './connections/domain.ts'
 import { metaDomain } from './meta/domain.ts'
 
 /**
@@ -10,4 +11,9 @@
  * extra domain is a real `DomainSpec` with bundled `Capability` handlers
  * (Workers typically snapshot capabilities at deploy time).
  */
-export const builtinDomains = [appsDomain, codingDomain, metaDomain] as const
+export const builtinDomains = [
+	appsDomain,
+	codingDomain,
+	connectionsDomain,
+	metaDomain,
+] as const

diff --git a/packages/worker/src/mcp/capabilities/connections/connections-begin-setup.ts b/packages/worker/src/mcp/capabilities/connections/connections-begin-setup.ts
new file mode 100644
--- /dev/null
+++ b/packages/worker/src/mcp/capabilities/connections/connections-begin-setup.ts
@@ -1,0 +1,72 @@
+import { z } from 'zod'
+import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts'
+import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts'
+import { type CapabilityContext } from '#mcp/capabilities/types.ts'
+import { connectionAuthSpecSchema } from '#mcp/connections/auth-spec.ts'
+import { beginConnectionSetup } from '#mcp/connections/connection-service.ts'
+import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts'
+
+const outputSchema = z.object({
+	setup_id: z.string(),
+	provider: z.object({
+		key: z.string(),
+		display_name: z.string(),
+	}),
+	label: z.string().nullable(),
+	auth_strategy: z.string(),
+	status: z.string(),
+	secret_fields: z.array(
+		z.object({
+			name: z.string(),
+			label: z.string(),
+			description: z.string().optional(),
+			input_type: z.enum(['text', 'password']),
+		}),
+	),
+	instructions: z.array(z.string()),
+	expires_at: z.string(),
+})
+
+export const connectionsBeginSetupCapability = defineDomainCapability(
+	capabilityDomainNames.connections,
+	{
+		name: 'connections_begin_setup',
+		description:
+			'Begin a user-owned provider connection draft. Use this when a skill can do something useful after initiating setup, such as returning a setup id that a generated UI can continue with secure secret collection or OAuth.',
+		keywords: [
+			'connection',
+			'provider',
+			'oauth',
+			'api key',
+			'manual token',
+			'secure input',
+			'setup',
+		],
+		readOnly: false,
+		idempotent: false,
+		destructive: false,
+		inputSchema: z.object({
+			provider: z.object({
+				key: z.string().min(1),
+				display_name: z.string().min(1),
+			}),
+			auth: connectionAuthSpecSchema,
+			label: z
+				.string()
+				.min(1)
+				.optional()
+				.describe('Optional preferred label for the resulting connection.'),
+		}),
+		outputSchema,
+		async handler(args, ctx: CapabilityContext) {
+			const user = requireMcpUser(ctx.callerContext)
+			return beginConnectionSetup({
+				env: ctx.env,
+				userId: user.userId,
+				provider: args.provider,
+				auth: args.auth,
+				label: args.label,
+			})
+		},
+	},
+)

diff --git a/packages/worker/src/mcp/capabilities/connections/connections-disconnect.ts b/packages/worker/src/mcp/capabilities/connections/connections-disconnect.ts
new file mode 100644
--- /dev/null
+++ b/packages/worker/src/mcp/capabilities/connections/connections-disconnect.ts
@@ -1,0 +1,34 @@
+import { z } from 'zod'
+import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts'
+import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts'
+import { type CapabilityContext } from '#mcp/capabilities/types.ts'
+import { disconnectConnection } from '#mcp/connections/connection-service.ts'
+import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts'
+
+export const connectionsDisconnectCapability = defineDomainCapability(
+	capabilityDomainNames.connections,
+	{
+		name: 'connections_disconnect',
+		description:
+			'Disconnect and delete a stored provider connection for the signed-in user.',
+		keywords: ['connection', 'disconnect', 'revoke', 'delete', 'provider'],
+		readOnly: false,
+		idempotent: false,
+		destructive: true,
+		inputSchema: z.object({
+			connection_id: z.string().min(1),
+		}),
+		outputSchema: z.object({
+			connection_id: z.string(),
+			disconnected: z.boolean(),
+		}),
+		async handler(args, ctx: CapabilityContext) {
+			const user = requireMcpUser(ctx.callerContext)
+			return disconnectConnection({
+				env: ctx.env,
+				userId: user.userId,
+				connectionId: args.connection_id,
+			})
+		},
+	},
+)

diff --git a/packages/worker/src/mcp/capabilities/connections/connections-finalize.ts b/packages/worker/src/mcp/capabilities/connections/connections-finalize.ts
new file mode 100644
--- /dev/null
+++ b/packages/worker/src/mcp/capabilities/connections/connections-finalize.ts
@@ -1,0 +1,50 @@
+import { z } from 'zod'
+import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts'
+import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts'
+import { type CapabilityContext } from '#mcp/capabilities/types.ts'
+import { finalizeConnectionSetup } from '#mcp/connections/connection-service.ts'
+import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts'
+
+export const connectionsFinalizeCapability = defineDomainCapability(
+	capabilityDomainNames.connections,
+	{
+		name: 'connections_finalize',
+		description:
+			'Finalize a connection draft into a durable user-owned provider connection after secure inputs or OAuth credentials have been collected.',
+		keywords: [
+			'connection',
+			'finalize',
+			'provider',
+			'oauth',
+			'token',
+			'api key',
+		],
+		readOnly: false,
+		idempotent: false,
+		destructive: false,
+		inputSchema: z.object({
+			setup_id: z.string().min(1),
+			make_default: z.boolean().optional(),
+		}),
+		outputSchema: z.object({
+			connection_id: z.string(),
+			provider_key: z.string(),
+			display_name: z.string(),
+			label: z.string(),
... diff truncated: showing 800 of 5036 lines

Comment thread packages/worker/src/mcp/generated-ui-api.ts
Comment thread packages/worker/src/mcp/connections/provider-request.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Comment thread packages/worker/src/mcp-auth.ts
Comment thread packages/worker/client/mcp-apps/generated-ui-shell.ts
Comment thread packages/worker/src/index.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Comment thread packages/worker/src/index.ts
Comment thread packages/worker/src/index.ts
started_at: new Date().toISOString(),
}),
error_message: null,
})

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.

PKCE code verifier stored unencrypted in database

Medium Severity

The PKCE code_verifier is stored as plaintext in the state_json column of connection_drafts. PKCE's security relies on the code verifier being kept confidential. Other secret values in the draft flow are encrypted via encryptJson before storage, but the code verifier bypasses this pattern, leaving it exposed to anyone with database read access.

Fix in Cursor Fix in Web

.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/g, '')
}

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.

Redundant base64UrlString function duplicated across files

Low Severity

base64UrlString in connection-service.ts duplicates the base64UrlEncode function in crypto.ts. Both perform identical base64url encoding of Uint8Array data. The connection-service.ts version uses the spread-in-fromCharCode pattern while crypto.ts uses a loop, but they produce the same output. The service file already imports from crypto.ts, so the shared utility could be reused instead of duplicated.

Additional Locations (1)
Fix in Cursor Fix in Web

@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: 2

🤖 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/mcp-apps/generated-ui-shell.ts`:
- Around line 469-489: The generated bridge currently serializes appSession
including appSession.token into buildChildBridgeRuntimeSource, exposing the
bearer to injected app code; remove token injection and instead proxy
authenticated operations via postMessage: stop embedding appSession.token in
serializedAppSession, change getAppSessionHeaders (in the generated runtime) to
send a postMessage using shellMessagePrefix and nextRequestId and await a
response from the host frame, and update any code paths that used
appSession.token to call this message-based proxy (host will perform fetches or
return headers). Keep buildChildBridgeRuntimeSource, appSession (without token),
getAppSessionHeaders, shellMessagePrefix and nextRequestId as the contract
points so the host can handle requests and respond with headers/results.

In `@packages/worker/src/mcp/generated-ui-api.ts`:
- Around line 57-65: The error responses in the action/secure-input handlers
(around the request.method check, the actionRequestSchema.safeParse validation,
and the verification/storage catch blocks) currently return only { error } or
echo raw exceptions and sometimes map server faults into 4xx; change every
handled error branch to return jsonResponse({ ok: false, error: <message> },
<status>) instead of { error } (e.g., method not allowed -> jsonResponse({ ok:
false, error: 'Method not allowed.' }, 405); validation -> jsonResponse({ ok:
false, error: body.error.message }, 400)), and for unexpected
verification/storage exceptions catch and log the exception internally but
return a generic jsonResponse({ ok: false, error: 'Internal server error' },
500); update all jsonResponse invocations in this file (including the other
ranges noted) accordingly so the client always receives { ok: false, error } and
internal errors aren’t leaked.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8a8e33dd-666a-4c3e-9381-805c270054b4

📥 Commits

Reviewing files that changed from the base of the PR and between 9698935 and 7331f81.

📒 Files selected for processing (47)
  • docs/agents/end-to-end-testing.md
  • packages/home-connector/app/handlers.ts
  • packages/worker/client/mcp-apps/generated-ui-shell.ts
  • packages/worker/migrations/0008-provider-connections.sql
  • packages/worker/migrations/0009-skill-connection-bindings.sql
  • packages/worker/src/index.ts
  • packages/worker/src/mcp-auth.ts
  • packages/worker/src/mcp/capabilities/builtin-domains.ts
  • packages/worker/src/mcp/capabilities/connections/connections-begin-setup.ts
  • packages/worker/src/mcp/capabilities/connections/connections-disconnect.ts
  • packages/worker/src/mcp/capabilities/connections/connections-finalize.ts
  • packages/worker/src/mcp/capabilities/connections/connections-list.ts
  • packages/worker/src/mcp/capabilities/connections/connections-resolve.ts
  • packages/worker/src/mcp/capabilities/connections/connections-set-default.ts
  • packages/worker/src/mcp/capabilities/connections/connections-start-oauth.ts
  • packages/worker/src/mcp/capabilities/connections/domain.ts
  • packages/worker/src/mcp/capabilities/connections/provider-graphql-request.ts
  • packages/worker/src/mcp/capabilities/connections/provider-http-request.ts
  • packages/worker/src/mcp/capabilities/connections/provider-refresh-token.ts
  • packages/worker/src/mcp/capabilities/domain-metadata.ts
  • packages/worker/src/mcp/capabilities/meta/meta-get-skill.ts
  • packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts
  • packages/worker/src/mcp/capabilities/meta/meta-update-skill.ts
  • packages/worker/src/mcp/capabilities/unified-search.ts
  • packages/worker/src/mcp/connections/auth-spec.ts
  • packages/worker/src/mcp/connections/connection-drafts-repo.ts
  • packages/worker/src/mcp/connections/connection-drafts-types.ts
  • packages/worker/src/mcp/connections/connection-handles.ts
  • packages/worker/src/mcp/connections/connection-service.ts
  • packages/worker/src/mcp/connections/crypto.ts
  • packages/worker/src/mcp/connections/oauth-api.ts
  • packages/worker/src/mcp/connections/provider-connections-repo.ts
  • packages/worker/src/mcp/connections/provider-connections-types.ts
  • packages/worker/src/mcp/connections/provider-request.ts
  • packages/worker/src/mcp/generated-ui-api.ts
  • packages/worker/src/mcp/generated-ui-app-session.ts
  • packages/worker/src/mcp/index.ts
  • packages/worker/src/mcp/mcp-server-e2e.test.ts
  • packages/worker/src/mcp/skills/builtin-skill-templates.ts
  • packages/worker/src/mcp/skills/mcp-skills-repo.ts
  • packages/worker/src/mcp/skills/mcp-skills-types.ts
  • packages/worker/src/mcp/skills/skill-connections.ts
  • packages/worker/src/mcp/skills/skill-embed-and-flags.ts
  • packages/worker/src/mcp/skills/skill-mutation.ts
  • packages/worker/src/mcp/tools/open-generated-ui.ts
  • packages/worker/src/mcp/tools/search.ts
  • playwright.config.ts
✅ Files skipped from review due to trivial changes (13)
  • docs/agents/end-to-end-testing.md
  • packages/worker/src/mcp/index.ts
  • packages/worker/src/mcp/capabilities/domain-metadata.ts
  • packages/worker/src/mcp/tools/search.ts
  • packages/worker/src/mcp/skills/mcp-skills-types.ts
  • packages/worker/src/mcp/mcp-server-e2e.test.ts
  • packages/worker/src/mcp/connections/provider-connections-types.ts
  • packages/home-connector/app/handlers.ts
  • packages/worker/src/mcp/connections/connection-drafts-types.ts
  • packages/worker/src/mcp/capabilities/connections/provider-refresh-token.ts
  • playwright.config.ts
  • packages/worker/migrations/0008-provider-connections.sql
  • packages/worker/src/mcp/connections/provider-request.ts
🚧 Files skipped from review as they are similar to previous changes (23)
  • packages/worker/migrations/0009-skill-connection-bindings.sql
  • packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts
  • packages/worker/src/mcp/capabilities/builtin-domains.ts
  • packages/worker/src/mcp/capabilities/connections/domain.ts
  • packages/worker/src/mcp/capabilities/meta/meta-get-skill.ts
  • packages/worker/src/mcp/capabilities/connections/connections-set-default.ts
  • packages/worker/src/mcp/capabilities/connections/provider-graphql-request.ts
  • packages/worker/src/mcp/connections/oauth-api.ts
  • packages/worker/src/mcp/capabilities/connections/provider-http-request.ts
  • packages/worker/src/mcp/capabilities/meta/meta-update-skill.ts
  • packages/worker/src/mcp/capabilities/connections/connections-begin-setup.ts
  • packages/worker/src/mcp/generated-ui-app-session.ts
  • packages/worker/src/mcp/capabilities/connections/connections-start-oauth.ts
  • packages/worker/src/mcp/skills/skill-embed-and-flags.ts
  • packages/worker/src/index.ts
  • packages/worker/src/mcp/skills/skill-connections.ts
  • packages/worker/src/mcp/capabilities/connections/connections-resolve.ts
  • packages/worker/src/mcp/skills/mcp-skills-repo.ts
  • packages/worker/src/mcp/capabilities/connections/connections-list.ts
  • packages/worker/src/mcp/connections/crypto.ts
  • packages/worker/src/mcp/tools/open-generated-ui.ts
  • packages/worker/src/mcp/connections/connection-drafts-repo.ts
  • packages/worker/src/mcp/connections/provider-connections-repo.ts

Comment on lines +469 to +489
function buildChildBridgeRuntimeSource(
appSession: AppSessionEnvelope | null,
) {
const serializedAppSession = escapeInlineJsonSource(
JSON.stringify(appSession),
)
return `
const shellMessagePrefix = '${childMessagePrefix}';
const appSession = ${serializedAppSession};
let requestCounter = 0;
function nextRequestId() {
requestCounter += 1;
return 'generated-ui-' + requestCounter;
}
function getAppSessionHeaders() {
if (!appSession) return null;
return {
accept: 'application/json',
authorization: 'Bearer ' + appSession.token,
};
}

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 | 🔴 Critical

Do not inject the app-session bearer into the generated app's JS realm.

This bridge source is injected into HTML documents and prepended to inline module code, so both runtimes expose appSession.token to arbitrary generated app code and any third-party script it loads. That token can be replayed directly against the generated-UI action/secure-input/app-source endpoints, so the trust boundary effectively collapses to the app code itself. Keep the bearer in the host frame and proxy these calls over postMessage instead of embedding the token into the rendered document.

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

In `@packages/worker/client/mcp-apps/generated-ui-shell.ts` around lines 469 -
489, The generated bridge currently serializes appSession including
appSession.token into buildChildBridgeRuntimeSource, exposing the bearer to
injected app code; remove token injection and instead proxy authenticated
operations via postMessage: stop embedding appSession.token in
serializedAppSession, change getAppSessionHeaders (in the generated runtime) to
send a postMessage using shellMessagePrefix and nextRequestId and await a
response from the host frame, and update any code paths that used
appSession.token to call this message-based proxy (host will perform fetches or
return headers). Keep buildChildBridgeRuntimeSource, appSession (without token),
getAppSessionHeaders, shellMessagePrefix and nextRequestId as the contract
points so the host can handle requests and respond with headers/results.

Comment on lines +57 to +65
if (request.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed.' }, 405)
}
const body = actionRequestSchema.safeParse(
await request.json().catch(() => null),
)
if (!body.success) {
return jsonResponse({ error: body.error.message }, 400)
}

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

Standardize generated-UI failures as { ok: false }, and don't flatten server faults into 4xx.

packages/worker/client/mcp-apps/generated-ui-shell.ts consumes the action/secure-input routes as { ok, error }, but the method/validation/auth branches here still return { error } only. On top of that, the catch-all blocks turn unexpected verification/storage failures into 400/401 and echo raw exception messages. That combination can make expired/broken sessions look like successful null results on the client and leaks internal failure details. Return { ok: false, error } on every handled error path, and reserve a generic 500 for unexpected exceptions after logging them.

Also applies to: 94-123, 131-150

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

In `@packages/worker/src/mcp/generated-ui-api.ts` around lines 57 - 65, The error
responses in the action/secure-input handlers (around the request.method check,
the actionRequestSchema.safeParse validation, and the verification/storage catch
blocks) currently return only { error } or echo raw exceptions and sometimes map
server faults into 4xx; change every handled error branch to return
jsonResponse({ ok: false, error: <message> }, <status>) instead of { error }
(e.g., method not allowed -> jsonResponse({ ok: false, error: 'Method not
allowed.' }, 405); validation -> jsonResponse({ ok: false, error:
body.error.message }, 400)), and for unexpected verification/storage exceptions
catch and log the exception internally but return a generic jsonResponse({ ok:
false, error: 'Internal server error' }, 500); update all jsonResponse
invocations in this file (including the other ranges noted) accordingly so the
client always receives { ok: false, error } and internal errors aren’t leaked.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

@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 3 potential issues.

There are 5 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

user,
iat: now,
exp: expiresAtMs,
})

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.

Signed token discarded, app session always null

High Severity

createGeneratedUiAppSession calls await signToken(...) but discards the return value — the signed token string is never stored or included in the returned object. The GeneratedUiAppSession type also lacks a token field. On the client side, AppSessionEnvelope requires token: string, so coerceAppSession always returns null when typeof value.token !== 'string' fails. This makes appSession permanently null in the iframe, completely disabling the direct worker action and secure-input endpoints.

Additional Locations (1)
Fix in Cursor Fix in Web

'Access-Control-Allow-Headers': 'content-type, authorization',
Vary: 'Origin',
})
}

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.

CORS missing custom session header allowance

Medium Severity

The CORS headers for generated UI API requests only allow content-type, authorization in Access-Control-Allow-Headers, but the client sends the custom X-Generated-Ui-Session header. Cross-origin preflight requests from srcdoc iframes or Claude widget domains would be rejected by the browser because this header isn't listed in the allowed headers.

Fix in Cursor Fix in Web

if (origin) return origin === url.origin
if (secFetchSite) return secFetchSite === 'same-origin'
return true
}

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.

Same-origin check rejects legitimate cross-origin iframe requests

Medium Severity

isSameOriginRequest checks origin === url.origin, but generated UI iframes use srcdoc (sending origin "null") or run on Claude widget domains — neither matches the worker's origin. This causes the action and secure-input POST endpoints to always return 403 for legitimate generated UI requests, contradicting the CORS configuration that explicitly allows these origins.

Fix in Cursor Fix in Web

@kentcdodds kentcdodds closed this Mar 25, 2026
@kentcdodds
kentcdodds deleted the dynamic-skills-oauth-runtime branch March 25, 2026 15:54
tk-shockwave pushed a commit to tk-shockwave/kody that referenced this pull request Oct 17, 2026
* feat(execute): add --credential call map for ADR 0068

Parse repeatable --credential key=name|org:name|personal:name flags into
the CapabilityProxy/MCP credentials map. Local execute preflights via
credentialClientCheck, attaches the map outside args on every hop, and
requires session feature credential-call-map. MCP execute receives the
map in tool args; token-auth cloud execute rejects the flag (map stays
off kody.execute). Sync host credential()/credentials() with openAll
and hint.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

* fix(execute): align --credential with final ADR 0068 host contract

Reject empty+named picks for the same key, trim pick names, use the
exact feature-detection refusal message, and render credentialClient*
ok:false errors with code/message plus --credential retry suggestions.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

* fix(execute): trim --credential picks before org/personal scope parse

A leading space before org: or personal: must not turn a scoped pick into
a plain credential name.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
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