Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/agents/mcp-apps-spec-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ over project-local conventions.

- The repo exposes a single generic shell via `open_generated_ui`.
- Saved apps are reopened by `app_id`; inline renders are ephemeral.
- Saved apps are hidden from `search` by default; set
`hidden: false` in `ui_save_app` only for reusable apps that
should be discoverable.
- If an OAuth provider requires a callback URL, use a persisted hosted saved app
rather than an inline render.
- For secret-bearing requests and host approval policy, also read
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE ui_artifacts
ADD COLUMN hidden INTEGER NOT NULL DEFAULT 1;
2 changes: 2 additions & 0 deletions packages/worker/src/app/saved-ui-hosted-html.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ test('renderHostedSavedUiHtml emits shared runtime assets for html apps', () =>
code: '<main>Hello</main>',
runtime: 'html',
parameters: null,
hidden: true,
created_at: '2026-03-27T00:00:00.000Z',
updated_at: '2026-03-27T00:00:00.000Z',
},
Expand Down Expand Up @@ -80,6 +81,7 @@ test('renderHostedSavedUiHtml keeps user javascript separate from runtime bootst
code: 'document.querySelector("[data-generated-ui-root]")?.append("hello")',
runtime: 'javascript',
parameters: null,
hidden: true,
created_at: '2026-03-27T00:00:00.000Z',
updated_at: '2026-03-27T00:00:00.000Z',
},
Expand Down
2 changes: 2 additions & 0 deletions packages/worker/src/mcp/capabilities/apps/ui-get-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const outputSchema = z.object({
title: z.string(),
description: z.string(),
parameters: z.array(uiArtifactParameterSchema).nullable(),
hidden: z.boolean(),
code: z
.string()
.describe('Generated UI source code to render inside the generic shell.'),
Expand Down Expand Up @@ -54,6 +55,7 @@ export const uiGetAppCapability = defineDomainCapability(
title: row.title,
description: row.description,
parameters: parseUiArtifactParameters(row.parameters),
hidden: row.hidden,
code: row.code,
runtime: row.runtime,
created_at: row.created_at,
Expand Down
2 changes: 2 additions & 0 deletions packages/worker/src/mcp/capabilities/apps/ui-list-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const outputSchema = z.object({
description: z.string(),
runtime: z.string(),
parameters: z.array(uiArtifactParameterSchema).nullable(),
hidden: z.boolean(),
created_at: z.string(),
updated_at: z.string(),
}),
Expand Down Expand Up @@ -45,6 +46,7 @@ export const uiListAppsCapability = defineDomainCapability(
description: row.description,
runtime: row.runtime,
parameters: parseUiArtifactParameters(row.parameters),
hidden: row.hidden,
created_at: row.created_at,
updated_at: row.updated_at,
})),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const outputSchema = z.object({
runtime: z.enum(['html', 'javascript']),
code: z.string(),
parameters: z.array(uiArtifactParameterSchema).nullable(),
hidden: z.boolean(),
})

export const uiLoadAppSourceCapability = defineDomainCapability(
Expand Down Expand Up @@ -52,6 +53,7 @@ export const uiLoadAppSourceCapability = defineDomainCapability(
runtime: row.runtime,
code: row.code,
parameters: parseUiArtifactParameters(row.parameters),
hidden: row.hidden,
}
},
},
Expand Down
57 changes: 45 additions & 12 deletions packages/worker/src/mcp/capabilities/apps/ui-save-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ import { type CapabilityContext } from '#mcp/capabilities/types.ts'
import { errorFields, logMcpEvent } from '#mcp/observability.ts'
import {
deleteUiArtifact,
getUiArtifactById,
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 {
deleteUiArtifactVector,
upsertUiArtifactVector,
} from '#mcp/ui-artifacts-vectorize.ts'
import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts'
import {
normalizeUiArtifactParameters,
Expand Down Expand Up @@ -48,13 +52,20 @@ const inputSchema = z.object({
.describe(
'Optional parameter definitions for reusable saved apps. Resolved values are exposed at runtime on the imported `kodyWidget.params` helper from `@kody/ui-utils`.',
),
hidden: z
.boolean()
.optional()
.describe(
'Whether this saved app should stay hidden from search results. Defaults to true so one-off apps stay private unless explicitly made discoverable.',
),
Comment on lines +55 to +60

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

Clarify that hidden only defaults on create.

This description says the field “defaults to true,” but omitted hidden values on updates actually preserve the stored flag. That mismatch can mislead callers/agents when patching an existing app.

✏️ Suggested wording
 	hidden: z
 		.boolean()
 		.optional()
 		.describe(
-			'Whether this saved app should stay hidden from search results. Defaults to true so one-off apps stay private unless explicitly made discoverable.',
+			'Whether this saved app should stay hidden from search results. New apps default to true so one-off apps stay private; updates preserve the current value unless hidden is explicitly provided.',
 		),
🤖 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 55 -
60, Update the description for the "hidden" field in the schema in
ui-save-app.ts to clarify that the default-to-true behavior applies only when
creating a new app; when updating an existing app, omitting "hidden" preserves
the stored value. Locate the "hidden" field (the
z.boolean().optional().describe(...) entry) and replace the misleading text with
a concise note that defaults only on create and that patch/update requests that
omit the field do not change the stored hidden flag.

})

const outputSchema = z.object({
app_id: z.string(),
runtime: z.enum(['html', 'javascript']),
hosted_url: z.string().url(),
parameters: z.array(uiArtifactParameterSchema).nullable(),
hidden: z.boolean(),
})

export const uiSaveAppCapability = defineDomainCapability(
Expand All @@ -77,6 +88,7 @@ export const uiSaveAppCapability = defineDomainCapability(
const serializedParameters = parameters
? JSON.stringify(parameters)
: null
let hidden: boolean

if (isUpdate) {
const updated = await updateUiArtifact(
Expand All @@ -89,12 +101,27 @@ export const uiSaveAppCapability = defineDomainCapability(
code: args.code,
runtime: args.runtime,
parameters: serializedParameters,
hidden: args.hidden,
},
)
if (!updated) {
throw new Error('Saved UI artifact not found for this user.')
}
if (args.hidden === undefined) {
const existing = await getUiArtifactById(
ctx.env.APP_DB,
user.userId,
appId,
)
if (!existing) {
throw new Error('Saved UI artifact not found for this user.')
}
hidden = existing.hidden
} else {
hidden = args.hidden
}
} else {
hidden = args.hidden ?? true
const now = new Date().toISOString()
await insertUiArtifact(ctx.env.APP_DB, {
id: appId,
Expand All @@ -104,23 +131,28 @@ export const uiSaveAppCapability = defineDomainCapability(
code: args.code,
runtime: args.runtime,
parameters: serializedParameters,
hidden,
created_at: now,
updated_at: now,
})
}

try {
await upsertUiArtifactVector(ctx.env, {
appId,
userId: user.userId,
embedText: buildUiArtifactEmbedText({
title: args.title,
description: args.description,
code: args.code,
runtime: args.runtime,
parameters,
}),
})
if (!hidden) {
await upsertUiArtifactVector(ctx.env, {
appId,
userId: user.userId,
embedText: buildUiArtifactEmbedText({
title: args.title,
description: args.description,
code: args.code,
runtime: args.runtime,
parameters,
}),
})
} else if (isUpdate) {
await deleteUiArtifactVector(ctx.env, appId)
Comment thread
cursor[bot] marked this conversation as resolved.
}
} catch (cause) {
if (!isUpdate) {
await deleteUiArtifact(ctx.env.APP_DB, user.userId, appId)
Expand Down Expand Up @@ -156,6 +188,7 @@ export const uiSaveAppCapability = defineDomainCapability(
runtime: args.runtime,
hosted_url: buildSavedUiUrl(ctx.callerContext.baseUrl, appId),
parameters,
hidden,
}
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function createUiArtifactRow(appId: string): UiArtifactRow {
required: true,
},
]),
hidden: false,
created_at: '2026-03-20T00:00:00.000Z',
updated_at: '2026-03-20T00:00:00.000Z',
}
Expand Down
2 changes: 1 addition & 1 deletion packages/worker/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Quick start
- Never ask the user to paste secrets, tokens, API keys, passwords, OAuth codes, or client secrets into chat. Use saved secrets when available, or use 'open_generated_ui' to collect and save sensitive values instead.
- Use 'meta_save_skill' only for workflows that are reasonably repeatable—patterns you expect to run again with similar structure or inputs. Do not save one-off tasks, unique ad-hoc work, or highly bespoke requests as skills; run those with 'execute' instead. Use the optional 'collection' field to group related saved skills, and use 'meta_update_skill' to replace an existing skill's code in place.
- When a saved skill declares parameters, pass values via meta_run_skill params; the codemode can read them from the params variable.
- Use 'ui_save_app' to persist reusable UI source for later reopening via 'app_id'. Saved apps are user-scoped UI artifacts, not codemode skills.
- Use 'ui_save_app' to persist reusable UI source for later reopening via 'app_id'. Saved apps are user-scoped UI artifacts, not codemode skills. They are hidden from search by default unless you explicitly set \`hidden: false\` for reusable apps.
- Use \`codemode.secret_list(args)\` during execute-time code to list secret metadata only; it does not return plaintext values.
- Use \`codemode.secret_set(args)\` only to persist secret values that are already available inside trusted execution, such as refreshed OAuth tokens. It returns metadata only and never returns plaintext values.

Expand Down
103 changes: 103 additions & 0 deletions packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,109 @@ test('mcp server executes user code against codemode and tracks execute context'
expect(executeResult?.hosted_url).toBe(
`${server.origin}/ui/${executeResult?.app_id}`,
)
expect(executeResult?.hidden).toBe(true)

const hiddenSearchResult = await mcpClient.client.callTool({
name: 'search',
arguments: {
query: 'Execute generated app',
detail: true,
},
})
const hiddenSearchStructured = (hiddenSearchResult as CallToolResult)
.structuredContent as
| {
result?: {
matches?: Array<{
type?: string
appId?: string
}>
}
}
| undefined
expect(
hiddenSearchStructured?.result?.matches?.some(
(match) =>
match.type === 'app' && match.appId === executeResult?.app_id,
),
).toBe(false)

const savedAppMetadata = await mcpClient.client.callTool({
name: 'execute',
arguments: {
code: `async () => {
return await codemode.ui_get_app({
app_id: ${JSON.stringify(executeResult?.app_id)},
})
}`,
},
})
const savedAppMetadataStructured = (savedAppMetadata as CallToolResult)
.structuredContent as
| {
result?: {
hidden?: boolean
}
}
| undefined
expect(savedAppMetadataStructured?.result?.hidden).toBe(true)

const searchableUpdateResult = await mcpClient.client.callTool({
name: 'execute',
arguments: {
code: `async () => {
return await codemode.ui_save_app({
app_id: ${JSON.stringify(executeResult?.app_id)},
title: 'Execute generated app',
description: 'Saved through execute.',
code: '<main><h1>Execute App</h1></main>',
hidden: false,
})
}`,
},
})
const searchableUpdateStructured = (
searchableUpdateResult as CallToolResult
).structuredContent as
| {
result?: {
hidden?: boolean
}
}
| undefined
expect(searchableUpdateStructured?.result?.hidden).toBe(false)

const visibleSearchResult = await mcpClient.client.callTool({
name: 'search',
arguments: {
query: 'Execute generated app',
detail: true,
limit: 20,
maxResponseSize: 20_000,
},
})
const visibleSearchStructured = (visibleSearchResult as CallToolResult)
.structuredContent as
| {
result?: {
matches?: Array<{
type?: string
appId?: string
}>
}
}
| undefined
expect(
visibleSearchStructured?.result?.matches?.find(
(match) =>
match.type === 'app' && match.appId === executeResult?.app_id,
),
).toEqual(
expect.objectContaining({
type: 'app',
appId: executeResult?.app_id,
}),
)

const contextResult = await mcpClient.client.callTool({
name: 'execute',
Expand Down
3 changes: 2 additions & 1 deletion packages/worker/src/mcp/observability.workers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@ test('ui_save_app capability logs success for valid invocation', async () => {
{
title: 'Observed app',
description: 'Observation test app.',
keywords: ['observability'],
code: 'document.querySelector("#app")!.innerHTML = "<h1>Observed app</h1>"',
},
{
Expand Down Expand Up @@ -175,6 +174,7 @@ test('ui_save_app capability logs success for valid invocation', async () => {
},
)
expect(typeof (result as { app_id: string }).app_id).toBe('string')
expect((result as { hidden: boolean }).hidden).toBe(true)
} finally {
console.info = originalInfo
}
Expand All @@ -201,6 +201,7 @@ test('ui_save_app logs vector refresh failure for in-place updates and still suc
title: 'Observed app',
description: 'Observation test app.',
code: '<main><h1>Observed app</h1></main>',
hidden: false,
},
{
env: {
Expand Down
1 change: 1 addition & 0 deletions packages/worker/src/mcp/tools/search.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ test('optional search rows fall back when saved skills lookup fails', async () =
code: '<div />',
runtime: 'html',
parameters: null,
hidden: false,
created_at: '2026-03-24T00:00:00.000Z',
updated_at: '2026-03-24T00:00:00.000Z',
},
Expand Down
4 changes: 3 additions & 1 deletion packages/worker/src/mcp/tools/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,9 @@ export async function registerSearchTool(agent: McpRegistrationAgent) {
loadSkills: () =>
listMcpSkillsByUserId(agent.getEnv().APP_DB, userId!),
loadUiArtifacts: () =>
listUiArtifactsByUserId(agent.getEnv().APP_DB, userId!),
listUiArtifactsByUserId(agent.getEnv().APP_DB, userId!, {
hidden: false,
}),
loadUserSecrets: () =>
listUserSecretsForSearch({
env: agent.getEnv(),
Expand Down
Loading
Loading