diff --git a/docs/agents/mcp-apps-spec-notes.md b/docs/agents/mcp-apps-spec-notes.md index 51e61633f2..b481f69af9 100644 --- a/docs/agents/mcp-apps-spec-notes.md +++ b/docs/agents/mcp-apps-spec-notes.md @@ -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 diff --git a/packages/worker/migrations/0013-ui-artifact-search-visibility.sql b/packages/worker/migrations/0013-ui-artifact-search-visibility.sql new file mode 100644 index 0000000000..400fa6ebeb --- /dev/null +++ b/packages/worker/migrations/0013-ui-artifact-search-visibility.sql @@ -0,0 +1,2 @@ +ALTER TABLE ui_artifacts +ADD COLUMN hidden INTEGER NOT NULL DEFAULT 1; diff --git a/packages/worker/src/app/saved-ui-hosted-html.node.test.ts b/packages/worker/src/app/saved-ui-hosted-html.node.test.ts index fa7186b840..b16c08c41c 100644 --- a/packages/worker/src/app/saved-ui-hosted-html.node.test.ts +++ b/packages/worker/src/app/saved-ui-hosted-html.node.test.ts @@ -23,6 +23,7 @@ test('renderHostedSavedUiHtml emits shared runtime assets for html apps', () => code: '
Hello
', runtime: 'html', parameters: null, + hidden: true, created_at: '2026-03-27T00:00:00.000Z', updated_at: '2026-03-27T00:00:00.000Z', }, @@ -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', }, diff --git a/packages/worker/src/mcp/capabilities/apps/ui-get-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-get-app.ts index c99de05700..3a8b10b17d 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-get-app.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-get-app.ts @@ -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.'), @@ -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, diff --git a/packages/worker/src/mcp/capabilities/apps/ui-list-apps.ts b/packages/worker/src/mcp/capabilities/apps/ui-list-apps.ts index d9f077a31c..968f01ab45 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-list-apps.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-list-apps.ts @@ -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(), }), @@ -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, })), diff --git a/packages/worker/src/mcp/capabilities/apps/ui-load-app-source.ts b/packages/worker/src/mcp/capabilities/apps/ui-load-app-source.ts index 7849d786cc..657a786069 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-load-app-source.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-load-app-source.ts @@ -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( @@ -52,6 +53,7 @@ export const uiLoadAppSourceCapability = defineDomainCapability( runtime: row.runtime, code: row.code, parameters: parseUiArtifactParameters(row.parameters), + hidden: row.hidden, } }, }, diff --git a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts index f9b1ee9fc8..dd9e8973cf 100644 --- a/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts +++ b/packages/worker/src/mcp/capabilities/apps/ui-save-app.ts @@ -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, @@ -48,6 +52,12 @@ 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.', + ), }) const outputSchema = z.object({ @@ -55,6 +65,7 @@ const outputSchema = z.object({ runtime: z.enum(['html', 'javascript']), hosted_url: z.string().url(), parameters: z.array(uiArtifactParameterSchema).nullable(), + hidden: z.boolean(), }) export const uiSaveAppCapability = defineDomainCapability( @@ -77,6 +88,7 @@ export const uiSaveAppCapability = defineDomainCapability( const serializedParameters = parameters ? JSON.stringify(parameters) : null + let hidden: boolean if (isUpdate) { const updated = await updateUiArtifact( @@ -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, @@ -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) + } } catch (cause) { if (!isUpdate) { await deleteUiArtifact(ctx.env.APP_DB, user.userId, appId) @@ -156,6 +188,7 @@ export const uiSaveAppCapability = defineDomainCapability( runtime: args.runtime, hosted_url: buildSavedUiUrl(ctx.callerContext.baseUrl, appId), parameters, + hidden, } }, }, diff --git a/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts b/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts index b88fef634a..b255692c6f 100644 --- a/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts +++ b/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts @@ -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', } diff --git a/packages/worker/src/mcp/index.ts b/packages/worker/src/mcp/index.ts index eb4ec888a4..f952d618ae 100644 --- a/packages/worker/src/mcp/index.ts +++ b/packages/worker/src/mcp/index.ts @@ -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. diff --git a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts index 6edbc5f9b9..2738f69ef1 100644 --- a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts +++ b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts @@ -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: '

Execute App

', + 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', diff --git a/packages/worker/src/mcp/observability.workers.test.ts b/packages/worker/src/mcp/observability.workers.test.ts index b6a432f17b..dbc88ccc62 100644 --- a/packages/worker/src/mcp/observability.workers.test.ts +++ b/packages/worker/src/mcp/observability.workers.test.ts @@ -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 = "

Observed app

"', }, { @@ -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 } @@ -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: '

Observed app

', + hidden: false, }, { env: { diff --git a/packages/worker/src/mcp/tools/search.node.test.ts b/packages/worker/src/mcp/tools/search.node.test.ts index 4a766783fa..559bbc796c 100644 --- a/packages/worker/src/mcp/tools/search.node.test.ts +++ b/packages/worker/src/mcp/tools/search.node.test.ts @@ -19,6 +19,7 @@ test('optional search rows fall back when saved skills lookup fails', async () = code: '
', runtime: 'html', parameters: null, + hidden: false, created_at: '2026-03-24T00:00:00.000Z', updated_at: '2026-03-24T00:00:00.000Z', }, diff --git a/packages/worker/src/mcp/tools/search.ts b/packages/worker/src/mcp/tools/search.ts index c4085c880a..092c8b372d 100644 --- a/packages/worker/src/mcp/tools/search.ts +++ b/packages/worker/src/mcp/tools/search.ts @@ -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(), diff --git a/packages/worker/src/mcp/ui-artifacts-repo.ts b/packages/worker/src/mcp/ui-artifacts-repo.ts index c1e2cbdcd2..c872a649e8 100644 --- a/packages/worker/src/mcp/ui-artifacts-repo.ts +++ b/packages/worker/src/mcp/ui-artifacts-repo.ts @@ -16,8 +16,8 @@ export async function insertUiArtifact( .prepare( `INSERT INTO ui_artifacts ( id, user_id, title, description, source_code, source_type, - parameters, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + parameters, hidden, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( row.id, @@ -27,6 +27,7 @@ export async function insertUiArtifact( row.code, row.runtime, row.parameters ?? null, + row.hidden ? 1 : 0, row.created_at ?? now, row.updated_at ?? now, ) @@ -41,7 +42,7 @@ export async function getUiArtifactById( const result = await db .prepare( `SELECT id, user_id, title, description, source_code, source_type, - parameters, created_at, updated_at + parameters, hidden, created_at, updated_at FROM ui_artifacts WHERE id = ? AND user_id = ?`, ) .bind(artifactId, userId) @@ -62,7 +63,7 @@ export async function getUiArtifactByOwnerIds( const result = await db .prepare( `SELECT id, user_id, title, description, source_code, source_type, - parameters, created_at, updated_at + parameters, hidden, created_at, updated_at FROM ui_artifacts WHERE id = ? AND user_id IN (${placeholders}) LIMIT 1`, @@ -92,7 +93,12 @@ export async function updateUiArtifact( updates: Partial< Pick< UiArtifactRow, - 'title' | 'description' | 'code' | 'runtime' | 'parameters' + | 'title' + | 'description' + | 'code' + | 'runtime' + | 'parameters' + | 'hidden' > >, ): Promise { @@ -118,6 +124,9 @@ export async function updateUiArtifact( if (updates.parameters !== undefined) { addAssignment('parameters', updates.parameters ?? null) } + if (updates.hidden !== undefined) { + addAssignment('hidden', updates.hidden ? 1 : 0) + } addAssignment('updated_at', new Date().toISOString()) @@ -133,14 +142,21 @@ export async function updateUiArtifact( export async function listUiArtifactsByUserId( db: D1Database, userId: string, + options?: { hidden?: boolean }, ): Promise> { + const hidden = options?.hidden const { results } = await db .prepare( `SELECT id, user_id, title, description, source_code, source_type, - parameters, created_at, updated_at - FROM ui_artifacts WHERE user_id = ?`, + parameters, hidden, created_at, updated_at + FROM ui_artifacts + WHERE user_id = ? + ${hidden === undefined ? '' : 'AND hidden = ?'}`, + ) + .bind( + userId, + ...(hidden === undefined ? [] : [hidden ? 1 : 0]), ) - .bind(userId) .all>() return (results ?? []).map(mapRow) } @@ -154,6 +170,10 @@ function mapRow(row: Record): UiArtifactRow { code: String(row['source_code']), runtime: String(row['source_type']) as UiArtifactRow['runtime'], parameters: row['parameters'] == null ? null : String(row['parameters']), + hidden: + row['hidden'] === 1 || + row['hidden'] === '1' || + row['hidden'] === true, created_at: String(row['created_at']), updated_at: String(row['updated_at']), } diff --git a/packages/worker/src/mcp/ui-artifacts-types.ts b/packages/worker/src/mcp/ui-artifacts-types.ts index e8d4640ad4..69c465c580 100644 --- a/packages/worker/src/mcp/ui-artifacts-types.ts +++ b/packages/worker/src/mcp/ui-artifacts-types.ts @@ -8,6 +8,7 @@ export type UiArtifactRow = { code: string runtime: UiArtifactRuntime parameters: string | null + hidden: boolean created_at: string updated_at: string }