Skip to content

Consolidate ui_save_app into a single upsert capability - #95

Merged
kentcdodds merged 6 commits into
mainfrom
cursor/app-upsert-capability-a15f
Mar 30, 2026
Merged

kentcdodds merged 6 commits into
mainfrom
cursor/app-upsert-capability-a15f

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Mar 29, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • add optional app_id support to ui_save_app
  • update existing apps in place when that app_id belongs to the signed-in user
  • remove the duplicate ui_update_app capability and update MCP coverage for the upsert flow
  • log vector refresh failures for in-place UI app updates with contextual fields so index drift is observable without failing the DB update

Testing

  • npm run test -- packages/worker/src/mcp/observability.workers.test.ts packages/worker/src/mcp/capabilities/capability-search.workers.test.ts
  • node tools/prepare-e2e-env.ts && npm run build:mcp-apps && npx vitest run --project mcp-e2e packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features

    • Save operation now supports upsert via an optional app ID (create or update in one action).
  • Bug Fixes

    • Update flows no longer roll back artifact on vector-refresh failures; such failures are logged and reported.
  • Refactor

    • Removed the separate update capability; update behavior consolidated into the save capability.
  • Tests

    • Added test covering vector-refresh failure during in-place updates.
  • Chores

    • Observability payloads accept an optional message and structured context.

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

coderabbitai Bot commented Mar 29, 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 1 minutes and 6 seconds before requesting another review.

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

⌛ How to resolve this issue?

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

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

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

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

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 53d2b0d7-b58b-4d8a-91d8-77d7c376d7a4

📥 Commits

Reviewing files that changed from the base of the PR and between d3405aa and db7cc8d.

📒 Files selected for processing (2)
  • packages/worker/src/mcp/capabilities/apps/ui-save-app.ts
  • packages/worker/src/mcp/observability.workers.test.ts
📝 Walkthrough

Walkthrough

Removed the separate uiUpdateAppCapability and consolidated update semantics into uiSaveAppCapability by adding an optional app_id; domain exports and index re-exports were adjusted, tests updated, and observability payload extended to report vector-refresh failures during in-place updates.

Changes

Cohort / File(s) Summary
Removed uiUpdateAppCapability
packages/worker/src/mcp/capabilities/apps/domain.ts, packages/worker/src/mcp/capabilities/apps/index.ts, packages/worker/src/mcp/capabilities/apps/ui-update-app.ts
Deleted the uiUpdateAppCapability implementation and removed it from domain capabilities and index re-exports.
Enhanced uiSaveAppCapability for upsert
packages/worker/src/mcp/capabilities/apps/ui-save-app.ts
Added optional app_id (upsert semantics); handler now chooses insertUiArtifact or updateUiArtifact, serializes parameters once, and does not delete on vector-refresh failure for updates (logs and emits error instead).
Tests updated for upsert behavior
packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts, packages/worker/src/mcp/observability.workers.test.ts
Reworked e2e test to exercise upsert via ui_save_app and validate via ui_list_apps; added observability test asserting failure+success events when vector refresh fails during an in-place update.
Observability payload shape
packages/worker/src/mcp/observability.ts
Extended McpObservabilityPayload with optional message?: string and context?: Record<string, unknown> to carry additional error/context data.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client
  participant MCP as MCP Handler (ui_save_app)
  participant DB as APP_DB
  participant AI as AI (embed/params)
  participant Index as CAPABILITY_VECTOR_INDEX
  participant Obs as Observability

  rect rgba(200,230,201,0.5)
  Client->>MCP: ui_save_app(payload { optional app_id })
  end

  alt app_id absent (create)
    MCP->>AI: normalize & embed parameters
    AI-->>MCP: embedding
    MCP->>DB: insertUiArtifact(serializedParameters,...)
    DB-->>MCP: inserted meta (id)
    MCP->>Index: upsert(vector)
    Index-->>MCP: success
    MCP->>Obs: emit success event
    MCP-->>Client: success (new app_id)
  else app_id present (in-place update)
    MCP->>DB: fetch artifact for user+app_id
    DB-->>MCP: artifact found
    MCP->>AI: normalize & embed parameters
    AI-->>MCP: embedding
    MCP->>DB: updateUiArtifact(partialUpdates)
    DB-->>MCP: update meta (changes)
    MCP->>Index: upsert(vector)
    alt vector upsert fails
      Index--x MCP: error
      MCP->>Obs: emit failure event (failurePhase: handler, isUpdate: true)
      MCP->>Obs: emit success event (handler completed)
      MCP-->>Client: success (app_id)  %% update not rolled back
    else vector upsert succeeds
      Index-->>MCP: success
      MCP->>Obs: emit success event
      MCP-->>Client: success (app_id)
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 I hopped in code with nibbles bright,
One save to rule both day and night.
Create or tweak with a single call,
I log the bumps and stand up tall.
Hop, save, and hum — the app stands whole.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: consolidating the separate ui_update_app and ui_save_app capabilities into a single upsert-capable ui_save_app capability.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/app-upsert-capability-a15f

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.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@kentcdodds
kentcdodds marked this pull request as ready for review March 29, 2026 22:46
@github-actions

github-actions Bot commented Mar 29, 2026 •

Copy link
Copy Markdown
Contributor

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

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

Mocks:

@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.

🧹 Nitpick comments (1)
packages/worker/src/mcp/capabilities/apps/ui-save-app.ts (1)

116-123: Consider logging vector refresh failures for observability.

When an in-place update succeeds but vector refresh fails, the error is silently swallowed. While the rationale (not failing a successful DB update) is sound, this could lead to search index drift without any visibility.

🔧 Proposed fix to add observability
 			} catch (cause) {
 				if (!isUpdate) {
 					await deleteUiArtifact(ctx.env.APP_DB, user.userId, appId)
 					throw cause
 				}
 
-				// A vector refresh should not fail an in-place app update.
+				// A vector refresh should not fail an in-place app update.
+				console.error('Failed to refresh vector index for app update', {
+					appId,
+					userId: user.userId,
+					error: cause instanceof Error ? cause.message : String(cause),
+				})
 			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/capabilities/apps/ui-save-app.ts` around lines 116 -
123, The catch block currently swallows vector-refresh errors for in-place
updates; change it so when isUpdate is true you log the failure (instead of
throwing) with contextual fields (user.userId, appId, and isUpdate) and the
error/cause so we have observability into index drift. Locate the catch in
saveUiApp/saveUiArtifact flow (symbols: deleteUiArtifact, ctx.env.APP_DB,
user.userId, appId, isUpdate) and add a single log call to the existing logger
(e.g., processLogger.error or ctx.logger.error) that includes a descriptive
message and the caught cause, then continue to allow the DB update to succeed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/worker/src/mcp/capabilities/apps/ui-save-app.ts`:
- Around line 116-123: The catch block currently swallows vector-refresh errors
for in-place updates; change it so when isUpdate is true you log the failure
(instead of throwing) with contextual fields (user.userId, appId, and isUpdate)
and the error/cause so we have observability into index drift. Locate the catch
in saveUiApp/saveUiArtifact flow (symbols: deleteUiArtifact, ctx.env.APP_DB,
user.userId, appId, isUpdate) and add a single log call to the existing logger
(e.g., processLogger.error or ctx.logger.error) that includes a descriptive
message and the caught cause, then continue to allow the DB update to succeed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8ae526be-f233-42bc-99a2-3def0062cb21

📥 Commits

Reviewing files that changed from the base of the PR and between fe3eb20 and 947981a.

📒 Files selected for processing (5)
  • packages/worker/src/mcp/capabilities/apps/domain.ts
  • packages/worker/src/mcp/capabilities/apps/index.ts
  • packages/worker/src/mcp/capabilities/apps/ui-save-app.ts
  • packages/worker/src/mcp/capabilities/apps/ui-update-app.ts
  • packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
💤 Files with no reviewable changes (3)
  • packages/worker/src/mcp/capabilities/apps/index.ts
  • packages/worker/src/mcp/capabilities/apps/ui-update-app.ts
  • packages/worker/src/mcp/capabilities/apps/domain.ts

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

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

🤖 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/src/mcp/observability.workers.test.ts`:
- Around line 250-256: The test should assert the actual emitted payload shape:
check that driftEvent.errorMessage equals the raw cause string ("vector refresh
failed") rather than the rewritten message, and move the capabilityName, userId,
appId and isUpdate assertions to the nested context object (e.g., assert
driftEvent.context.capabilityName, driftEvent.context.userId,
driftEvent.context.appId, driftEvent.context.isUpdate). Update the expectations
that reference driftEvent to read from driftEvent.context and adjust the
errorMessage expectation to match the raw cause value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: afe2acc1-e965-40cb-a8d7-a06cd8ab5ded

📥 Commits

Reviewing files that changed from the base of the PR and between 947981a and d3405aa.

📒 Files selected for processing (3)
  • packages/worker/src/mcp/capabilities/apps/ui-save-app.ts
  • packages/worker/src/mcp/observability.ts
  • packages/worker/src/mcp/observability.workers.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/worker/src/mcp/capabilities/apps/ui-save-app.ts

Comment thread packages/worker/src/mcp/observability.workers.test.ts Outdated
Comment thread packages/worker/src/mcp/capabilities/apps/ui-save-app.ts
Comment thread packages/worker/src/mcp/capabilities/apps/ui-save-app.ts
cursoragent and others added 2 commits March 29, 2026 23:19
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
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.

Fix All in Cursor

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

  • ✅ Fixed: Contextual fields not wrapped in context object
    • Wrapped the user/app metadata in the context field so observability payloads match the expected structure.
  • ✅ Fixed: errorFields extracts from wrapper instead of cause
    • Extracted error fields from the caught cause to preserve the original failure message.
Preview (db7cc8d60d)
diff --git a/packages/worker/src/mcp/capabilities/apps/domain.ts b/packages/worker/src/mcp/capabilities/apps/domain.ts
--- a/packages/worker/src/mcp/capabilities/apps/domain.ts
+++ b/packages/worker/src/mcp/capabilities/apps/domain.ts
@@ -5,7 +5,6 @@
 import { uiListAppsCapability } from './ui-list-apps.ts'
 import { uiLoadAppSourceCapability } from './ui-load-app-source.ts'
 import { uiSaveAppCapability } from './ui-save-app.ts'
-import { uiUpdateAppCapability } from './ui-update-app.ts'
 
 export const appsDomain = defineDomain({
 	name: capabilityDomainNames.apps,
@@ -17,7 +16,6 @@
 		uiGetAppCapability,
 		uiListAppsCapability,
 		uiLoadAppSourceCapability,
-		uiUpdateAppCapability,
 		uiDeleteAppCapability,
 	],
 })

diff --git a/packages/worker/src/mcp/capabilities/apps/index.ts b/packages/worker/src/mcp/capabilities/apps/index.ts
--- a/packages/worker/src/mcp/capabilities/apps/index.ts
+++ b/packages/worker/src/mcp/capabilities/apps/index.ts
@@ -6,6 +6,5 @@
 export { uiListAppsCapability } from './ui-list-apps.ts'
 export { uiLoadAppSourceCapability } from './ui-load-app-source.ts'
 export { uiSaveAppCapability } from './ui-save-app.ts'
-export { uiUpdateAppCapability } from './ui-update-app.ts'
 
 export const appsCapabilities = appsDomain.capabilities

diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts
--- a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts
+++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts
@@ -3,7 +3,12 @@
 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 { deleteUiArtifact, insertUiArtifact } from '#mcp/ui-artifacts-repo.ts'
+import { errorFields, logMcpEvent } from '#mcp/observability.ts'
+import {
+	deleteUiArtifact,
+	insertUiArtifact,
+	updateUiArtifact,
+} from '#mcp/ui-artifacts-repo.ts'
 import { buildUiArtifactEmbedText } from '#mcp/ui-artifacts-embed.ts'
 import { upsertUiArtifactVector } from '#mcp/ui-artifacts-vectorize.ts'
 import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts'
@@ -13,6 +18,13 @@
 } from '#mcp/ui-artifact-parameters.ts'
 
 const inputSchema = z.object({
+	app_id: z
+		.string()
+		.min(1)
+		.optional()
+		.describe(
+			'Optional saved UI artifact id to update in place. Omit to create a new saved app.',
+		),
 	title: z.string().min(1).describe('Short title for the saved UI artifact.'),
 	description: z
 		.string()
@@ -50,8 +62,8 @@
 	{
 		name: 'ui_save_app',
 		description:
-			'Save a generated UI artifact for the signed-in user so it can be reopened later by app_id without sending the source back through the model context.',
-		keywords: ['ui', 'app', 'artifact', 'save', 'persist', 'mcp app'],
+			'Create or replace a saved UI artifact for the signed-in user so it can be reopened later by app_id without sending the source back through the model context.',
+		keywords: ['ui', 'app', 'artifact', 'save', 'persist', 'update', 'mcp app'],
 		readOnly: false,
 		idempotent: false,
 		destructive: false,
@@ -59,21 +71,44 @@
 		outputSchema,
 		async handler(args, ctx: CapabilityContext) {
 			const user = requireMcpUser(ctx.callerContext)
-			const appId = crypto.randomUUID()
-			const now = new Date().toISOString()
+			const isUpdate = args.app_id !== undefined
+			const appId = args.app_id ?? crypto.randomUUID()
 			const parameters = normalizeUiArtifactParameters(args.parameters)
-			await insertUiArtifact(ctx.env.APP_DB, {
-				id: appId,
-				user_id: user.userId,
-				title: args.title,
-				description: args.description,
-				code: args.code,
-				runtime: args.runtime,
-				parameters: parameters ? JSON.stringify(parameters) : null,
-				created_at: now,
-				updated_at: now,
-			})
+			const serializedParameters = parameters
+				? JSON.stringify(parameters)
+				: null
 
+			if (isUpdate) {
+				const updated = await updateUiArtifact(
+					ctx.env.APP_DB,
+					user.userId,
+					appId,
+					{
+						title: args.title,
+						description: args.description,
+						code: args.code,
+						runtime: args.runtime,
+						parameters: serializedParameters,
+					},
+				)
+				if (!updated) {
+					throw new Error('Saved UI artifact not found for this user.')
+				}
+			} else {
+				const now = new Date().toISOString()
+				await insertUiArtifact(ctx.env.APP_DB, {
+					id: appId,
+					user_id: user.userId,
+					title: args.title,
+					description: args.description,
+					code: args.code,
+					runtime: args.runtime,
+					parameters: serializedParameters,
+					created_at: now,
+					updated_at: now,
+				})
+			}
+
 			try {
 				await upsertUiArtifactVector(ctx.env, {
 					appId,
@@ -87,8 +122,33 @@
 					}),
 				})
 			} catch (cause) {
-				await deleteUiArtifact(ctx.env.APP_DB, user.userId, appId)
-				throw cause
+				if (!isUpdate) {
+					await deleteUiArtifact(ctx.env.APP_DB, user.userId, appId)
+					throw cause
+				}
+
+				const { errorName, errorMessage } = errorFields(cause)
+				logMcpEvent({
+					category: 'mcp',
+					tool: 'capability',
+					capabilityName: 'ui_save_app',
+					domain: capabilityDomainNames.apps,
+					outcome: 'failure',
+					durationMs: 0,
+					baseUrl: ctx.callerContext.baseUrl,
+					hasUser: true,
+					failurePhase: 'handler',
+					message:
+						'Failed to refresh saved app vector index after in-place update.',
+					errorName,
+					errorMessage,
+					cause,
+					context: {
+						userId: user.userId,
+						appId,
+						isUpdate,
+					},
+				})
 			}
 
 			return {

diff --git a/packages/worker/src/mcp/capabilities/apps/ui-update-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-update-app.ts
deleted file mode 100644
--- a/packages/worker/src/mcp/capabilities/apps/ui-update-app.ts
+++ /dev/null
@@ -1,147 +1,0 @@
-import { z } from 'zod'
-import { buildSavedUiUrl } from '#worker/ui-artifact-urls.ts'
-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 { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts'
-import { buildUiArtifactEmbedText } from '#mcp/ui-artifacts-embed.ts'
-import { getUiArtifactById, updateUiArtifact } from '#mcp/ui-artifacts-repo.ts'
-import { upsertUiArtifactVector } from '#mcp/ui-artifacts-vectorize.ts'
-import {
-	normalizeUiArtifactParameters,
-	parseUiArtifactParameters,
-	uiArtifactParameterSchema,
-} from '#mcp/ui-artifact-parameters.ts'
-
-const inputSchema = z
-	.object({
-		app_id: z
-			.string()
-			.min(1)
-			.describe('Saved UI artifact id returned by ui_save_app.'),
-		title: z
-			.string()
-			.min(1)
-			.optional()
-			.describe('Short title for the saved UI artifact.'),
-		description: z
-			.string()
-			.min(1)
-			.optional()
-			.describe('What the saved app does and when it is useful.'),
-		code: z
-			.string()
-			.min(1)
-			.optional()
-			.describe(
-				'App source for the generic MCP UI shell. Prefer a self-contained HTML document or fragment so the generated app owns the visible UI. Legacy `javascript` source is still supported for previously saved apps.',
-			),
-		runtime: z
-			.enum(['html', 'javascript'])
-			.optional()
-			.describe(
-				'Source format accepted by the generic UI shell. Prefer `html`; `javascript` is kept for legacy saved apps.',
-			),
-		parameters: z
-			.array(uiArtifactParameterSchema)
-			.optional()
-			.describe(
-				'Optional parameter definitions for reusable saved apps. Resolved values are exposed at runtime on the imported `kodyWidget.params` API from `@kody/ui-utils`.',
-			),
-	})
-	.refine(
-		(value) =>
-			value.title !== undefined ||
-			value.description !== undefined ||
-			value.code !== undefined ||
-			value.runtime !== undefined ||
-			value.parameters !== undefined,
-		{
-			message: 'Provide at least one field to update.',
-		},
-	)
-
-const outputSchema = z.object({
-	app_id: z.string(),
-	runtime: z.enum(['html', 'javascript']),
-	hosted_url: z.string().url(),
-	parameters: z.array(uiArtifactParameterSchema).nullable(),
-})
-
-export const uiUpdateAppCapability = defineDomainCapability(
-	capabilityDomainNames.apps,
-	{
-		name: 'ui_update_app',
-		description:
-			'Update an existing saved UI artifact owned by the signed-in user. Only the fields provided will be changed.',
-		keywords: ['ui', 'app', 'artifact', 'update', 'edit', 'modify'],
-		readOnly: false,
-		idempotent: false,
-		destructive: false,
-		inputSchema,
-		outputSchema,
-		async handler(args, ctx: CapabilityContext) {
-			const user = requireMcpUser(ctx.callerContext)
-			const updates: Parameters<typeof updateUiArtifact>[3] = {}
-
-			if (args.title !== undefined) {
-				updates.title = args.title
-			}
-			if (args.description !== undefined) {
-				updates.description = args.description
-			}
-			if (args.code !== undefined) {
-				updates.code = args.code
-			}
-			if (args.runtime !== undefined) {
-				updates.runtime = args.runtime
-			}
-			if (args.parameters !== undefined) {
-				const parameters = normalizeUiArtifactParameters(args.parameters)
-				updates.parameters = parameters ? JSON.stringify(parameters) : null
-			}
-
-			const updated = await updateUiArtifact(
-				ctx.env.APP_DB,
-				user.userId,
-				args.app_id,
-				updates,
-			)
-			if (!updated) {
-				throw new Error('Saved UI artifact not found for this user.')
-			}
-
-			const refreshed = await getUiArtifactById(
-				ctx.env.APP_DB,
-				user.userId,
-				args.app_id,
-			)
-			if (!refreshed) {
-				throw new Error('Saved UI artifact not found after update.')
-			}
-
-			try {
-				await upsertUiArtifactVector(ctx.env, {
-					appId: refreshed.id,
-					userId: user.userId,
-					embedText: buildUiArtifactEmbedText({
-						title: refreshed.title,
-						description: refreshed.description,
-						code: refreshed.code,
-						runtime: refreshed.runtime,
-						parameters: parseUiArtifactParameters(refreshed.parameters),
-					}),
-				})
-			} catch {
-				// Vector refresh should not fail the primary update.
-			}
-
-			return {
-				app_id: refreshed.id,
-				runtime: refreshed.runtime,
-				hosted_url: buildSavedUiUrl(ctx.callerContext.baseUrl, refreshed.id),
-				parameters: parseUiArtifactParameters(refreshed.parameters),
-			}
-		},
-	},
-)
\ No newline at end of file

diff --git a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
--- a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
+++ b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
@@ -589,7 +589,6 @@
 		'code',
 	])
 	expect(saveApp?.readOnly).toBeUndefined()
-	expect(saveApp?.inputFields).toBeUndefined()
 
 	const textOutput =
 		(result as CallToolResult).content.find(
@@ -1840,7 +1839,7 @@
 	expect(apps?.some((app) => app.app_id === appId)).toBe(false)
 })
 
-test('mcp server updates saved ui app artifacts', async () => {
+test('mcp server upserts saved ui app artifacts through ui_save_app', async () => {
 	await using database = await createTestDatabase()
 	await using server = await startDevServer(database.persistDir)
 	await using mcpClient = await createMcpClient(server.origin, database.user)
@@ -1869,7 +1868,7 @@
 		name: 'execute',
 		arguments: {
 			code: `async () =>
-					await codemode.ui_update_app({
+					await codemode.ui_save_app({
 						app_id: ${JSON.stringify(appId)},
 						title: 'Updated App',
 						description: 'Updated description.',
@@ -1909,4 +1908,25 @@
 	expect(getPayload?.description).toBe('Updated description.')
 	expect(getPayload?.code).toBe('<main><h1>Updated</h1></main>')
 	expect(getPayload?.runtime).toBe('javascript')
+
+	const listResult = await mcpClient.client.callTool({
+		name: 'execute',
+		arguments: {
+			code: `async () => await codemode.ui_list_apps({})`,
+		},
+	})
+	const listStructured = (listResult as CallToolResult).structuredContent as
+		| {
+				result?: Record<string, unknown>
+		  }
+		| undefined
+	const listPayload = listStructured?.result as
+		| Record<string, unknown>
+		| undefined
+	const apps = listPayload?.apps as
+		| Array<{ app_id?: string; title?: string }>
+		| undefined
+	expect(apps).toHaveLength(1)
+	expect(apps?.[0]?.app_id).toBe(appId)
+	expect(apps?.[0]?.title).toBe('Updated App')
 })

diff --git a/packages/worker/src/mcp/observability.ts b/packages/worker/src/mcp/observability.ts
--- a/packages/worker/src/mcp/observability.ts
+++ b/packages/worker/src/mcp/observability.ts
@@ -20,6 +20,8 @@
 	registeredCapabilityCount?: number
 	errorName?: string
 	errorMessage?: string
+	message?: string
+	context?: Record<string, unknown>
 	timestamp: string
 }
 

diff --git a/packages/worker/src/mcp/observability.workers.test.ts b/packages/worker/src/mcp/observability.workers.test.ts
--- a/packages/worker/src/mcp/observability.workers.test.ts
+++ b/packages/worker/src/mcp/observability.workers.test.ts
@@ -184,3 +184,82 @@
 	expect(event.outcome).toBe('success')
 	expect(event.failurePhase).toBeUndefined()
 })
+
+test('ui_save_app logs vector refresh failure for in-place updates and still succeeds', async () => {
+	const originalInfo = console.info
+	const payloads: Array<string> = []
+	console.info = ((tag: unknown, json?: unknown) => {
+		if (tag === 'mcp-event' && typeof json === 'string') {
+			payloads.push(json)
+		}
+	}) as typeof console.info
+	try {
+		const handler = capabilityMap['ui_save_app'].handler
+		const result = await handler(
+			{
+				app_id: 'app-1',
+				title: 'Observed app',
+				description: 'Observation test app.',
+				code: '<main><h1>Observed app</h1></main>',
+			},
+			{
+				env: {
+					APP_DB: {
+						prepare() {
+							return {
+								bind() {
+									return {
+										run: async () => ({
+											meta: { changes: 1 },
+										}),
+									}
+								},
+							}
+						},
+					},
+					CAPABILITY_VECTOR_INDEX: {
+						upsert: async () => {
+							throw new Error('vector refresh failed')
+						},
+					},
+					AI: {
+						run: async () => ({
+							data: [Array.from({ length: 384 }, () => 0)],
+						}),
+					},
+				} as unknown as Env,
+				callerContext: createMcpCallerContext({
+					baseUrl: 'https://example.com',
+					user: {
+						userId: 'user-1',
+						email: 'user@example.com',
+					},
+				}),
+			},
+		)
+		expect((result as { app_id: string }).app_id).toBe('app-1')
+	} finally {
+		console.info = originalInfo
+	}
+
+	expect(payloads.length).toBe(2)
+	const driftEvent = JSON.parse(payloads[0]!) as Record<string, unknown>
+	const driftContext = driftEvent.context as Record<string, unknown> | undefined
+	expect(driftEvent.outcome).toBe('failure')
+	expect(driftEvent.failurePhase).toBe('handler')
+	expect(driftEvent.errorName).toBe('Error')
+	expect(driftEvent.errorMessage).toBe('vector refresh failed')
+	expect(driftEvent.message).toBe(
+		'Failed to refresh saved app vector index after in-place update.',
+	)
+	expect(driftEvent.capabilityName).toBe('ui_save_app')
+	expect(driftContext).toEqual({
+		userId: 'user-1',
+		appId: 'app-1',
+		isUpdate: true,
+	})
+
+	const successEvent = JSON.parse(payloads[1]!) as Record<string, unknown>
+	expect(successEvent.outcome).toBe('success')
+	expect(successEvent.capabilityName).toBe('ui_save_app')
+})

Comment thread packages/worker/src/mcp/capabilities/apps/ui-save-app.ts Outdated
Comment thread packages/worker/src/mcp/capabilities/apps/ui-save-app.ts Outdated
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@kentcdodds
kentcdodds merged commit 53953b7 into main Mar 30, 2026
9 checks passed
@kentcdodds
kentcdodds deleted the cursor/app-upsert-capability-a15f branch April 17, 2026 00:59
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