Repository navigation
Add ui_update_app capability - #64
Conversation
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughThis PR adds a new Changes
Sequence DiagramsequenceDiagram
actor Client as Client
participant Cap as ui_update_app<br/>Capability
participant Repo as UI Artifacts Repo
participant DB as D1 Database
participant Vec as Vector Index
Client->>Cap: ui_update_app(app_id, fields...)
Cap->>Cap: requireMcpUser() (auth)
Cap->>Cap: validate input (at least one field)
Cap->>Repo: updateUiArtifact(db, userId, appId, updates)
Repo->>DB: UPDATE ui_artifacts SET ... WHERE id = ? AND user_id = ?
DB-->>Repo: result (rowCount)
Repo-->>Cap: success boolean
Cap->>Repo: getUiArtifactById(db, userId, appId)
Repo->>DB: SELECT * FROM ui_artifacts WHERE id = ? AND user_id = ?
DB-->>Repo: artifact row
Repo-->>Cap: artifact
Cap->>Cap: buildUiArtifactEmbedText(artifact)
Cap->>Vec: upsertUiArtifactVector(artifactId, embeddings)
Vec-->>Cap: ack (errors swallowed)
Cap-->>Client: { app_id, runtime, hosted_url }
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
|
🔎 Preview deployed: https://kody-pr-64.kentcdodds.workers.dev Worker: Mocks:
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Vectorize failure after DB commit causes inconsistent state
- Wrapped the vector upsert in a try/catch so update requests succeed even if vectorization fails, matching the delete handler pattern.
- ✅ Fixed: Identical
parseStringArrayduplicated across three capability files- Extracted
parseStringArrayintoui-artifacts-repo.tsand imported it from the three capability files to remove duplication.
- Extracted
Preview (626002e618)
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,6 +5,7 @@
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,
@@ -16,6 +17,7 @@
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,5 +6,6 @@
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-get-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-get-app.ts
--- a/packages/worker/src/mcp/capabilities/apps/ui-get-app.ts
+++ b/packages/worker/src/mcp/capabilities/apps/ui-get-app.ts
@@ -2,19 +2,9 @@
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 { getUiArtifactById } from '#mcp/ui-artifacts-repo.ts'
+import { getUiArtifactById, parseStringArray } from '#mcp/ui-artifacts-repo.ts'
import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts'
-function parseStringArray(raw: string): Array<string> {
- try {
- const value = JSON.parse(raw) as unknown
- if (!Array.isArray(value)) return []
- return value.filter((entry): entry is string => typeof entry === 'string')
- } catch {
- return []
- }
-}
-
const outputSchema = z.object({
app_id: z.string(),
title: z.string(),
diff --git a/packages/worker/src/mcp/capabilities/apps/ui-list-apps.ts b/packages/worker/src/mcp/capabilities/apps/ui-list-apps.ts
--- a/packages/worker/src/mcp/capabilities/apps/ui-list-apps.ts
+++ b/packages/worker/src/mcp/capabilities/apps/ui-list-apps.ts
@@ -2,7 +2,7 @@
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 { listUiArtifactsByUserId } from '#mcp/ui-artifacts-repo.ts'
+import { listUiArtifactsByUserId, parseStringArray } from '#mcp/ui-artifacts-repo.ts'
import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts'
const outputSchema = z.object({
@@ -19,16 +19,6 @@
),
})
-function parseStringArray(raw: string): Array<string> {
- try {
- const value = JSON.parse(raw) as unknown
- if (!Array.isArray(value)) return []
- return value.filter((entry): entry is string => typeof entry === 'string')
- } catch {
- return []
- }
-}
-
export const uiListAppsCapability = defineDomainCapability(
capabilityDomainNames.apps,
{
diff --git a/packages/worker/src/mcp/capabilities/apps/ui-update-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-update-app.ts
new file mode 100644
--- /dev/null
+++ b/packages/worker/src/mcp/capabilities/apps/ui-update-app.ts
@@ -1,0 +1,151 @@
+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,
+ parseStringArray,
+ updateUiArtifact,
+} from '#mcp/ui-artifacts-repo.ts'
+import { upsertUiArtifactVector } from '#mcp/ui-artifacts-vectorize.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.'),
+ keywords: z
+ .array(z.string())
+ .optional()
+ .describe('Extra search keywords for discovery in the search tool.'),
+ 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.',
+ ),
+ search_text: z
+ .string()
+ .optional()
+ .describe(
+ 'Optional retrieval-only text that improves search recall without being part of the visible app description.',
+ ),
+ })
+ .refine(
+ (value) =>
+ value.title !== undefined ||
+ value.description !== undefined ||
+ value.keywords !== undefined ||
+ value.code !== undefined ||
+ value.runtime !== undefined ||
+ value.search_text !== 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(),
+})
+
+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.keywords !== undefined) {
+ updates.keywords = JSON.stringify(args.keywords)
+ }
+ if (args.code !== undefined) {
+ updates.code = args.code
+ }
+ if (args.runtime !== undefined) {
+ updates.runtime = args.runtime
+ }
+ if (args.search_text !== undefined) {
+ updates.search_text = args.search_text
+ }
+
+ 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,
+ keywords: parseStringArray(refreshed.keywords),
+ searchText: refreshed.search_text,
+ runtime: refreshed.runtime,
+ }),
+ })
+ } 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),
+ }
+ },
+ },
+)
diff --git a/packages/worker/src/mcp/mcp-server-e2e.test.ts b/packages/worker/src/mcp/mcp-server-e2e.test.ts
--- a/packages/worker/src/mcp/mcp-server-e2e.test.ts
+++ b/packages/worker/src/mcp/mcp-server-e2e.test.ts
@@ -1445,3 +1445,79 @@
const apps = listPayload?.apps as Array<{ app_id?: string }> | undefined
expect(apps?.some((app) => app.app_id === appId)).toBe(false)
})
+
+test('mcp server updates saved ui app artifacts', async () => {
+ await using database = await createTestDatabase()
+ await using server = await startDevServer(database.persistDir)
+ await using mcpClient = await createMcpClient(server.origin, database.user)
+
+ const saveResult = await mcpClient.client.callTool({
+ name: 'execute',
+ arguments: {
+ code: `async () =>
+ await codemode.ui_save_app({
+ title: 'Original App',
+ description: 'Original app description.',
+ keywords: ['original', 'ui'],
+ code: '<main><h1>Original</h1></main>',
+ })`,
+ },
+ })
+ const saveStructured = (saveResult as CallToolResult).structuredContent as
+ | {
+ result?: Record<string, unknown>
+ }
+ | undefined
+ const savedApp = saveStructured?.result as Record<string, unknown> | undefined
+ const appId = typeof savedApp?.app_id === 'string' ? savedApp.app_id : null
+ expect(appId).not.toBeNull()
+
+ const updateResult = await mcpClient.client.callTool({
+ name: 'execute',
+ arguments: {
+ code: `async () =>
+ await codemode.ui_update_app({
+ app_id: ${JSON.stringify(appId)},
+ title: 'Updated App',
+ description: 'Updated description.',
+ keywords: ['updated', 'ui'],
+ code: '<main><h1>Updated</h1></main>',
+ runtime: 'javascript',
+ search_text: 'updated searchable text',
+ })`,
+ },
+ })
+ const updateStructured = (updateResult as CallToolResult).structuredContent as
+ | {
+ result?: Record<string, unknown>
+ }
+ | undefined
+ const updatePayload = updateStructured?.result as
+ | Record<string, unknown>
+ | undefined
+ expect(updatePayload?.app_id).toBe(appId)
+ expect(updatePayload?.runtime).toBe('javascript')
+ expect(updatePayload?.hosted_url).toBe(`${server.origin}/ui/${appId}`)
+
+ const getResult = await mcpClient.client.callTool({
+ name: 'execute',
+ arguments: {
+ code: `async () =>
+ await codemode.ui_get_app({ app_id: ${JSON.stringify(appId)} })`,
+ },
+ })
+ const getStructured = (getResult as CallToolResult).structuredContent as
+ | {
+ result?: Record<string, unknown>
+ }
+ | undefined
+ const getPayload = getStructured?.result as
+ | Record<string, unknown>
+ | undefined
+ expect(getPayload?.title).toBe('Updated App')
+ expect(getPayload?.description).toBe('Updated description.')
+ expect(getPayload?.keywords).toEqual(['updated', 'ui'])
+ expect(getPayload?.code).toBe('<main><h1>Updated</h1></main>')
+ expect(getPayload?.runtime).toBe('javascript')
+ expect(getPayload?.search_text).toBe('updated searchable text')
+})
diff --git a/packages/worker/src/mcp/ui-artifacts-repo.ts b/packages/worker/src/mcp/ui-artifacts-repo.ts
--- a/packages/worker/src/mcp/ui-artifacts-repo.ts
+++ b/packages/worker/src/mcp/ui-artifacts-repo.ts
@@ -1,5 +1,15 @@
import { type UiArtifactRow } from './ui-artifacts-types.ts'
+export function parseStringArray(raw: string): Array<string> {
+ try {
+ const value = JSON.parse(raw) as unknown
+ if (!Array.isArray(value)) return []
+ return value.filter((entry): entry is string => typeof entry === 'string')
+ } catch {
+ return []
+ }
+}
+
export function uiArtifactVectorId(artifactId: string): string {
return `ui_artifact_${artifactId}`
}
@@ -86,6 +96,54 @@
return (out.meta.changes ?? 0) > 0
}
+export async function updateUiArtifact(
+ db: D1Database,
+ userId: string,
+ artifactId: string,
+ updates: Partial<
+ Pick<
+ UiArtifactRow,
+ 'title' | 'description' | 'keywords' | 'code' | 'runtime' | 'search_text'
+ >
+ >,
+): Promise<boolean> {
+ const assignments: Array<string> = []
+ const values: Array<unknown> = []
+ const addAssignment = (column: string, value: unknown) => {
+ assignments.push(`${column} = ?`)
+ values.push(value)
+ }
+
+ if (updates.title !== undefined) {
+ addAssignment('title', updates.title)
+ }
+ if (updates.description !== undefined) {
+ addAssignment('description', updates.description)
+ }
+ if (updates.keywords !== undefined) {
+ addAssignment('keywords', updates.keywords)
+ }
+ if (updates.code !== undefined) {
+ addAssignment('source_code', updates.code)
+ }
+ if (updates.runtime !== undefined) {
+ addAssignment('source_type', updates.runtime)
+ }
+ if (updates.search_text !== undefined) {
+ addAssignment('search_text', updates.search_text ?? null)
+ }
+
+ addAssignment('updated_at', new Date().toISOString())
+
+ const out = await db
+ .prepare(
+ `UPDATE ui_artifacts SET ${assignments.join(', ')} WHERE id = ? AND user_id = ?`,
+ )
+ .bind(...values, artifactId, userId)
+ .run()
+ return (out.meta.changes ?? 0) > 0
+}
+
export async function listUiArtifactsByUserId(
db: D1Database,
userId: string,There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/worker/src/mcp/mcp-server-e2e.test.ts (1)
1449-1523: Nice happy-path coverage; consider adding partial/clear-path assertions.Given this capability supports partial updates, it would be valuable to add one case that updates a single field and one case that clears
search_text(when null support is enabled) to lock in behavior.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/worker/src/mcp/mcp-server-e2e.test.ts` around lines 1449 - 1523, Add two focused assertions to the existing test "mcp server updates saved ui app artifacts": after saving the app (appId) perform a partial update via codemode.ui_update_app that only changes title (e.g., title: 'Partially Updated') and assert via codemode.ui_get_app that only the title changed while other fields (description, keywords, code, runtime) remain unchanged; then perform another update call setting search_text to null via codemode.ui_update_app (if null support is enabled) and assert via codemode.ui_get_app that getPayload.search_text is null (and hosted_url/app_id remain unchanged). Use the existing variables appId, updatePayload, getPayload and the same callTool pattern to locate where to insert these extra calls and assertions.
🤖 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/capabilities/apps/ui-update-app.ts`:
- Around line 54-57: The schema for the update input in ui-update-app.ts
currently defines search_text as z.string().optional(), preventing callers from
explicitly clearing it to null; update the schema for the search_text field
(both occurrences around the search_text lines) to accept null as well by
changing the Zod type to include nullable (e.g.,
z.string().nullable().optional()) so the ui_update_app input allows string |
null | undefined.
---
Nitpick comments:
In `@packages/worker/src/mcp/mcp-server-e2e.test.ts`:
- Around line 1449-1523: Add two focused assertions to the existing test "mcp
server updates saved ui app artifacts": after saving the app (appId) perform a
partial update via codemode.ui_update_app that only changes title (e.g., title:
'Partially Updated') and assert via codemode.ui_get_app that only the title
changed while other fields (description, keywords, code, runtime) remain
unchanged; then perform another update call setting search_text to null via
codemode.ui_update_app (if null support is enabled) and assert via
codemode.ui_get_app that getPayload.search_text is null (and hosted_url/app_id
remain unchanged). Use the existing variables appId, updatePayload, getPayload
and the same callTool pattern to locate where to insert these extra calls and
assertions.
🪄 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: d413e04a-1920-4809-b674-5340080b4f6a
📒 Files selected for processing (5)
packages/worker/src/mcp/capabilities/apps/domain.tspackages/worker/src/mcp/capabilities/apps/index.tspackages/worker/src/mcp/capabilities/apps/ui-update-app.tspackages/worker/src/mcp/mcp-server-e2e.test.tspackages/worker/src/mcp/ui-artifacts-repo.ts
| search_text: z | ||
| .string() | ||
| .optional() | ||
| .describe( |
There was a problem hiding this comment.
Allow explicit search_text clearing in update input.
Line 54 currently accepts only string | undefined, so callers cannot clear search_text to NULL via ui_update_app, despite repo support for null updates.
Suggested patch
- search_text: z
- .string()
- .optional()
+ search_text: z
+ .string()
+ .nullable()
+ .optional()
.describe(
'Optional retrieval-only text that improves search recall without being part of the visible app description.',
),Also applies to: 111-113
🤖 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-update-app.ts` around lines 54 -
57, The schema for the update input in ui-update-app.ts currently defines
search_text as z.string().optional(), preventing callers from explicitly
clearing it to null; update the schema for the search_text field (both
occurrences around the search_text lines) to accept null as well by changing the
Zod type to include nullable (e.g., z.string().nullable().optional()) so the
ui_update_app input allows string | null | undefined.
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

Summary
Testing
Summary by CodeRabbit
New Features
Refactor
Tests