From caf321fc6a565c10ea67de809435e581c8b11b1f Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:49:18 -0400 Subject: [PATCH 1/5] Expose hidden inline MCP Apps in curated catalogs --- .../hidden-inline-apps.spec.ts.snap | 275 ++++++++++++++++++ .../src/mcp/advertised-catalog.harness.ts | 10 +- .../core/src/mcp/hidden-inline-apps.spec.ts | 141 +++++++++ ...instructions-name-advertised-tools.spec.ts | 9 - .../legacy-unreviewed-destructive-hint.json | 4 - scripts/ci-test-lanes.test.ts | 1 + scripts/ci-test-lanes.ts | 1 + templates/analytics/AGENTS.md | 6 + .../actions/mcp-action-contract.spec.ts | 1 + .../actions/open-traffic-dashboard.ts | 1 + templates/calendar/AGENTS.md | 1 + .../calendar/actions/manage-event-draft.ts | 7 + .../lib/calendar-connector-catalog.spec.ts | 12 +- .../server/lib/calendar-mcp-writes.spec.ts | 3 + templates/content/AGENTS.md | 1 + .../actions/mcp-action-contract.spec.ts | 11 + .../actions/share-local-file-document.ts | 8 + templates/design/AGENTS.md | 4 +- .../design/actions/export-coding-handoff.ts | 7 + templates/design/actions/generate-screens.ts | 6 + templates/design/server/plugins/agent-chat.ts | 4 +- templates/slides-2/AGENTS.md | 1 + .../slides-2/server/plugins/agent-chat.ts | 1 + templates/slides/AGENTS.md | 6 + .../slides/actions/clone-context-slide.ts | 6 + templates/slides/server/plugins/agent-chat.ts | 1 + 26 files changed, 509 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/mcp/__snapshots__/hidden-inline-apps.spec.ts.snap create mode 100644 packages/core/src/mcp/hidden-inline-apps.spec.ts diff --git a/packages/core/src/mcp/__snapshots__/hidden-inline-apps.spec.ts.snap b/packages/core/src/mcp/__snapshots__/hidden-inline-apps.spec.ts.snap new file mode 100644 index 00000000000..17847a913d0 --- /dev/null +++ b/packages/core/src/mcp/__snapshots__/hidden-inline-apps.spec.ts.snap @@ -0,0 +1,275 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`explicit connector inline apps > advertises each inline tool and its UI resource on the default production catalog 1`] = ` +{ + "analytics": [ + { + "_meta": { + "openai/outputTemplate": "ui://analytics/open-traffic-dashboard/shell-v65", + "openai/toolInvocation/invoked": "Traffic dashboard ready", + "openai/toolInvocation/invoking": "Opening Traffic dashboard", + "openai/widgetAccessible": true, + "openai/widgetCSP": { + "connect_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + "frame_domains": [ + "$requestOrigin", + ], + "resource_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + }, + "ui": { + "resourceUri": "ui://analytics/open-traffic-dashboard/shell-v65", + "visibility": [ + "model", + "app", + ], + }, + "ui/resourceUri": "ui://analytics/open-traffic-dashboard/shell-v65", + }, + "annotations": { + "destructiveHint": false, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Open traffic dashboard", + }, + "name": "open-traffic-dashboard", + }, + ], + "calendar": [ + { + "_meta": { + "openai/outputTemplate": "ui://calendar/manage-event-draft/shell-v65", + "openai/toolInvocation/invoked": "Review calendar invite ready", + "openai/toolInvocation/invoking": "Opening Review calendar invite", + "openai/widgetAccessible": true, + "openai/widgetCSP": { + "connect_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + "frame_domains": [ + "$requestOrigin", + ], + "resource_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + }, + "ui": { + "resourceUri": "ui://calendar/manage-event-draft/shell-v65", + "visibility": [ + "model", + "app", + ], + }, + "ui/resourceUri": "ui://calendar/manage-event-draft/shell-v65", + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Manage event draft", + }, + "name": "manage-event-draft", + }, + ], + "content": [ + { + "_meta": { + "openai/outputTemplate": "ui://content/share-local-file-document/shell-v65", + "openai/toolInvocation/invoked": "Share document ready", + "openai/toolInvocation/invoking": "Opening Share document", + "openai/widgetAccessible": true, + "openai/widgetCSP": { + "connect_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + "frame_domains": [ + "$requestOrigin", + ], + "resource_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + }, + "ui": { + "resourceUri": "ui://content/share-local-file-document/shell-v65", + "visibility": [ + "model", + "app", + ], + }, + "ui/resourceUri": "ui://content/share-local-file-document/shell-v65", + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Share local file document", + }, + "name": "share-local-file-document", + }, + ], + "design": [ + { + "_meta": { + "openai/outputTemplate": "ui://design/generate-screens/shell-v65", + "openai/toolInvocation/invoked": "Design generation session ready", + "openai/toolInvocation/invoking": "Opening Design generation session", + "openai/widgetAccessible": true, + "openai/widgetCSP": { + "connect_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + "frame_domains": [ + "$requestOrigin", + ], + "resource_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + }, + "ui": { + "resourceUri": "ui://design/generate-screens/shell-v65", + "visibility": [ + "model", + "app", + ], + }, + "ui/resourceUri": "ui://design/generate-screens/shell-v65", + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Generate screens", + }, + "name": "generate-screens", + }, + { + "_meta": { + "openai/outputTemplate": "ui://design/export-coding-handoff/shell-v65", + "openai/toolInvocation/invoked": "Design agent handoff ready", + "openai/toolInvocation/invoking": "Opening Design agent handoff", + "openai/widgetAccessible": true, + "openai/widgetCSP": { + "connect_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + "frame_domains": [ + "$requestOrigin", + ], + "resource_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + }, + "ui": { + "resourceUri": "ui://design/export-coding-handoff/shell-v65", + "visibility": [ + "model", + "app", + ], + }, + "ui/resourceUri": "ui://design/export-coding-handoff/shell-v65", + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Export coding handoff", + }, + "name": "export-coding-handoff", + }, + ], + "slides": [ + { + "_meta": { + "openai/outputTemplate": "ui://slides/clone-context-slide/shell-v65", + "openai/toolInvocation/invoked": "Cloned native slide ready", + "openai/toolInvocation/invoking": "Opening Cloned native slide", + "openai/widgetAccessible": true, + "openai/widgetCSP": { + "connect_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + "frame_domains": [ + "$requestOrigin", + ], + "resource_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + }, + "ui": { + "resourceUri": "ui://slides/clone-context-slide/shell-v65", + "visibility": [ + "model", + "app", + ], + }, + "ui/resourceUri": "ui://slides/clone-context-slide/shell-v65", + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Clone context slide", + }, + "name": "clone-context-slide", + }, + ], + "slides-2": [ + { + "_meta": { + "openai/outputTemplate": "ui://slides-2/clone-context-slide/shell-v65", + "openai/toolInvocation/invoked": "Cloned native slide ready", + "openai/toolInvocation/invoking": "Opening Cloned native slide", + "openai/widgetAccessible": true, + "openai/widgetCSP": { + "connect_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + "frame_domains": [ + "$requestOrigin", + ], + "resource_domains": [ + "https://esm.sh", + "$requestOrigin", + ], + }, + "ui": { + "resourceUri": "ui://slides-2/clone-context-slide/shell-v65", + "visibility": [ + "model", + "app", + ], + }, + "ui/resourceUri": "ui://slides-2/clone-context-slide/shell-v65", + }, + "annotations": { + "destructiveHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Clone context slide", + }, + "name": "clone-context-slide", + }, + ], +} +`; diff --git a/packages/core/src/mcp/advertised-catalog.harness.ts b/packages/core/src/mcp/advertised-catalog.harness.ts index 14a120ab8c2..40ca73fed68 100644 --- a/packages/core/src/mcp/advertised-catalog.harness.ts +++ b/packages/core/src/mcp/advertised-catalog.harness.ts @@ -25,6 +25,7 @@ export const LOAD_TIMEOUT_MS = 600_000; export interface AdvertisedTool { name: string; + _meta?: Record; annotations: { readOnlyHint?: boolean; destructiveHint?: boolean; @@ -273,7 +274,11 @@ function createCloseHooks() { export async function listAdvertised( config: MCPConfig, - request: { fullCatalog: boolean; fullSurface: boolean }, + request: { + fullCatalog: boolean; + fullSurface: boolean; + inlineMcpApps?: boolean; + }, ): Promise<{ tools: AdvertisedTool[]; instructions: string }> { const server = await createMCPServerForRequest( config, @@ -285,7 +290,7 @@ export async function listAdvertised( }, { transport: "http", - inlineMcpApps: false, + inlineMcpApps: request.inlineMcpApps ?? false, ...(request.fullCatalog ? { fullCatalog: true } : {}), ...(request.fullSurface ? { fullSurface: true } : {}), }, @@ -300,6 +305,7 @@ export async function listAdvertised( const tools = (await client.listTools()).tools.map((tool) => ({ name: tool.name, annotations: { ...(tool.annotations ?? {}) }, + ...(request.inlineMcpApps ? { _meta: tool._meta } : {}), })); const instructions = client.getInstructions() ?? ""; await client.close(); diff --git a/packages/core/src/mcp/hidden-inline-apps.spec.ts b/packages/core/src/mcp/hidden-inline-apps.spec.ts new file mode 100644 index 00000000000..9f04e1a4534 --- /dev/null +++ b/packages/core/src/mcp/hidden-inline-apps.spec.ts @@ -0,0 +1,141 @@ +import { Client } from "@modelcontextprotocol/client"; +import { InMemoryTransport } from "@modelcontextprotocol/server"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { + LOAD_TIMEOUT_MS, + listAdvertised, + loadAppCatalogs, + type AppCatalog, +} from "./advertised-catalog.harness.js"; +import { createMCPServerForRequest } from "./build-server.js"; + +vi.mock("../server/better-auth-instance.js", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../server/better-auth-instance.js") + >()), + getAuthSecret: () => "fake-test-only-inline-app-approval-secret", +})); +vi.mock("./approval-store.js", () => ({ + createMcpApprovalGrant: vi.fn(async () => {}), + consumeMcpApprovalGrant: vi.fn(async () => true), +})); + +const INLINE_ACTIONS: Record = { + analytics: ["open-traffic-dashboard"], + calendar: ["manage-event-draft"], + content: ["share-local-file-document"], + design: ["generate-screens", "export-coding-handoff"], + slides: ["clone-context-slide"], + "slides-2": ["clone-context-slide"], +}; + +let apps: AppCatalog[]; +beforeAll(async () => { + apps = await loadAppCatalogs(Object.keys(INLINE_ACTIONS)); +}, LOAD_TIMEOUT_MS); + +describe("explicit connector inline apps", () => { + it( + "advertises each inline tool and its UI resource on the default production catalog", + async () => { + vi.stubEnv("AGENT_MODE", "production"); + vi.stubEnv("AGENT_PROD_CODE_EXECUTION", "off"); + try { + const catalogs: Record = {}; + expect(apps.map((app) => app.appId).sort()).toEqual( + Object.keys(INLINE_ACTIONS).sort(), + ); + for (const app of apps) { + const { tools } = await listAdvertised(app.surfaces[0]!.config, { + fullCatalog: false, + fullSurface: false, + inlineMcpApps: true, + }); + catalogs[app.appId] = INLINE_ACTIONS[app.appId]!.map((name) => { + const tool = tools.find((candidate) => candidate.name === name); + expect(tool, `${app.appId}/${name}`).toBeDefined(); + expect(tool?._meta?.ui, `${app.appId}/${name}`).toMatchObject({ + resourceUri: expect.stringMatching(/^ui:\/\//), + }); + return tool; + }); + } + expect(catalogs).toMatchSnapshot(); + } finally { + vi.unstubAllEnvs(); + } + }, + LOAD_TIMEOUT_MS, + ); + + it.each([ + ["content", "share-local-file-document", { id: "local-file-test" }], + ["design", "export-coding-handoff", { id: "design-test" }], + ] as const)( + "refuses %s/%s without host approval before running it", + async (appId, name, args) => { + const app = apps.find((candidate) => candidate.appId === appId)!; + const config = app.surfaces[0]!.config; + const entry = config.actions[name]!; + expect(entry.needsApproval).toBe(true); + const run = vi.fn(async () => ({ id: "unexpected-write" })); + const actions = { ...config.actions, [name]: { ...entry, run } }; + const server = await createMCPServerForRequest( + { ...config, actions, productionActions: actions }, + { + userEmail: "approval-contract@example.com", + orgDomain: undefined, + orgId: null, + identityAssurance: "user", + oauthScopes: ["mcp:read", "mcp:write"], + }, + { transport: "http", inlineMcpApps: true }, + ); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "no-approval-host", version: "1.0.0" }); + try { + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + const result = await client.callTool({ name, arguments: args }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain("actionApproval"); + expect(JSON.stringify(result.content)).toContain( + "did not declare the required capability", + ); + expect(run).not.toHaveBeenCalled(); + } finally { + await client.close(); + await server.close(); + } + }, + LOAD_TIMEOUT_MS, + ); + + it("keeps Content form submission delegated", () => { + const app = apps.find((candidate) => candidate.appId === "content")!; + expect( + app.surfaces[0]!.catalogs.default.map((tool) => tool.name), + ).not.toContain("submit-content-database-form"); + }); + + it("keeps Design's external export instructions within its advertised tools", () => { + const app = apps.find((candidate) => candidate.appId === "design")!; + const names = app.surfaces[0]!.catalogs.default.map((tool) => tool.name); + const instructions = app.instructions; + for (const name of ["export-png", "export-coding-handoff"]) { + expect(instructions).toContain(name); + expect(names).toContain(name); + } + for (const name of [ + "export-html", + "export-zip", + "export-design-as-figma-svg", + ]) { + expect(instructions).not.toContain(name); + } + }); +}); diff --git a/packages/core/src/mcp/instructions-name-advertised-tools.spec.ts b/packages/core/src/mcp/instructions-name-advertised-tools.spec.ts index 063029e5aaf..ad9ece3c110 100644 --- a/packages/core/src/mcp/instructions-name-advertised-tools.spec.ts +++ b/packages/core/src/mcp/instructions-name-advertised-tools.spec.ts @@ -115,8 +115,6 @@ const NOT_TOOL_NAMES: Record> = { }, }; -const FULL_CATALOG_ONLY = - "advertised only on the full catalog; owners to reword or advertise"; const PAGE_LOCAL_WEBMCP = "a page-local WebMCP tool the text itself marks as a fallback for browser-capable hosts"; @@ -132,13 +130,6 @@ const ADVERTISED_ONLY_CONDITIONALLY: Record> = { ), design: { ...prose(PAGE_LOCAL_WEBMCP, "get-visual-edit-prompt"), - ...prose( - FULL_CATALOG_ONLY, - "export-html", - "export-zip", - "export-coding-handoff", - "export-design-as-figma-svg", - ), ...prose( "Builder app tools: they act only on designs backed by a Builder app, so the default catalog leaves them out", "send-builder-message", diff --git a/packages/core/src/mcp/legacy-unreviewed-destructive-hint.json b/packages/core/src/mcp/legacy-unreviewed-destructive-hint.json index 906742f1794..b898e4e786f 100644 --- a/packages/core/src/mcp/legacy-unreviewed-destructive-hint.json +++ b/packages/core/src/mcp/legacy-unreviewed-destructive-hint.json @@ -301,7 +301,6 @@ "duplicate-booking-link", "event-action-helpers", "event-search", - "manage-event-draft", "navigate", "provider-api-request", "remove-external-calendar", @@ -493,7 +492,6 @@ "set-document-discoverability", "set-document-property", "set-image-alt-text", - "share-local-file-document", "stage-builder-revision", "stage-builder-source-bulk-update", "start-comment-ai-request", @@ -588,7 +586,6 @@ "export-html", "export-png", "export-zip", - "generate-screens", "get-design.schema", "go-to-main-component", "hydrate-figma-paste-images", @@ -746,7 +743,6 @@ "slides": [ "add-slide-comment", "apply-design-system", - "clone-context-slide", "clone-creative-context-deck", "create-deck-from-template", "create-design-system", diff --git a/scripts/ci-test-lanes.test.ts b/scripts/ci-test-lanes.test.ts index e93decafd42..26ae979ab9c 100644 --- a/scripts/ci-test-lanes.test.ts +++ b/scripts/ci-test-lanes.test.ts @@ -101,6 +101,7 @@ test("runs the catalog contract specs for any workspace source change", () => { const specs = [ "src/mcp/advertised-tool-annotations.spec.ts", "src/mcp/instructions-name-advertised-tools.spec.ts", + "src/mcp/hidden-inline-apps.spec.ts", ]; for (const changed of [ "templates/mail/actions/send-email.ts", diff --git a/scripts/ci-test-lanes.ts b/scripts/ci-test-lanes.ts index 187c0fc1532..c87738a202e 100644 --- a/scripts/ci-test-lanes.ts +++ b/scripts/ci-test-lanes.ts @@ -235,6 +235,7 @@ export function requiresFullCoreFastTests(paths: readonly string[]): boolean { const CATALOG_CONTRACT_CORE_SPECS = [ "src/mcp/advertised-tool-annotations.spec.ts", "src/mcp/instructions-name-advertised-tools.spec.ts", + "src/mcp/hidden-inline-apps.spec.ts", ] as const; const NON_CODE_ASSET_RE = /\.(?:css|scss|sass|less|png|jpe?g|gif|webp|avif|ico|svg|woff2?|ttf|otf|eot|mp3|mp4|wav|webm|mov)$/iu; diff --git a/templates/analytics/AGENTS.md b/templates/analytics/AGENTS.md index 672d86495e5..b485e7359dc 100644 --- a/templates/analytics/AGENTS.md +++ b/templates/analytics/AGENTS.md @@ -30,6 +30,12 @@ Use local docs only (no web research): `pnpm action docs-search --query " - For MCP, use allowlisted cataloged actions; use `ask_app` for interpretation, source selection, multi-step, or unsupported actions. - Replay-key MCP write: `update-analytics-public-key` appends origins; the first origin restricts replay. +## Direct inline action + +| Action | Purpose | +| --- | --- | +| `open-traffic-dashboard` | Open the read-only first-party traffic dashboard inline | + ## Sessions and state - `list-session-recordings` filters scoped replays. Use `paginated: true` for sorted pages with a real total and app counts. The Sessions triage Lab adds `didEvents`, `didNotEvents`, `slow`, and `unused` filters. Get names/counts from `list-session-event-names` and event health from `list-event-catalog`; Analytics' index covers sessions only since its coverage start. Never query BigQuery for these views. diff --git a/templates/analytics/actions/mcp-action-contract.spec.ts b/templates/analytics/actions/mcp-action-contract.spec.ts index edb703e9453..766979265d7 100644 --- a/templates/analytics/actions/mcp-action-contract.spec.ts +++ b/templates/analytics/actions/mcp-action-contract.spec.ts @@ -38,6 +38,7 @@ const actions: Record = Object.fromEntries( ); const DIRECT_MCP_READS = [ + "open-traffic-dashboard", "account-deep-dive", "builder-blog-articles", "data-source-status", diff --git a/templates/analytics/actions/open-traffic-dashboard.ts b/templates/analytics/actions/open-traffic-dashboard.ts index 7b4de43f0ce..58f6ddcb4c7 100644 --- a/templates/analytics/actions/open-traffic-dashboard.ts +++ b/templates/analytics/actions/open-traffic-dashboard.ts @@ -15,6 +15,7 @@ function trafficDashboardDeepLink(): string { } export default defineAction({ + mcpTool: true, description: "Open the first-party traffic dashboard in the real Analytics app. Use this directly when the user asks to see their traffic dashboard, site traffic, app traffic, or first-party analytics dashboard inline in ChatGPT or Claude. Do not call view-screen, ask_app, or broad resource discovery first for this known dashboard.", schema: z.object({}), diff --git a/templates/calendar/AGENTS.md b/templates/calendar/AGENTS.md index 3b7cc0b83f7..1405535c2bf 100644 --- a/templates/calendar/AGENTS.md +++ b/templates/calendar/AGENTS.md @@ -25,6 +25,7 @@ Use local docs only (no web research): `pnpm action docs-search --query " | Action | Purpose | | --- | --- | +| `manage-event-draft` | Create, edit, or delete unsent invite drafts; never sends invitations | | `list-events` | Read a schedule with account and source coverage | | `create-event` | Create on an owned Google account's primary calendar | | `update-event` | Edit an event using its id and accountEmail | diff --git a/templates/calendar/actions/manage-event-draft.ts b/templates/calendar/actions/manage-event-draft.ts index 9b1aaf490d1..77b1b878445 100644 --- a/templates/calendar/actions/manage-event-draft.ts +++ b/templates/calendar/actions/manage-event-draft.ts @@ -59,6 +59,13 @@ function setIfPresent( } export default defineAction({ + mcpTool: true, + mcpAnnotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, description: "Create, update, or delete an unsent calendar invite draft. Opening a draft shows a visible placeholder on the calendar with the event detail editor so the user can review it before creating/sending.", schema: z.object({ diff --git a/templates/calendar/server/lib/calendar-connector-catalog.spec.ts b/templates/calendar/server/lib/calendar-connector-catalog.spec.ts index 0d39ce4633d..4feba8c4101 100644 --- a/templates/calendar/server/lib/calendar-connector-catalog.spec.ts +++ b/templates/calendar/server/lib/calendar-connector-catalog.spec.ts @@ -1,9 +1,19 @@ import { describe, expect, it } from "vitest"; +import manageEventDraft from "../../actions/manage-event-draft"; import { CALENDAR_CONNECTOR_CATALOG } from "./calendar-connector-catalog"; describe("Calendar MCP connector catalog", () => { - it("exposes only deterministic event inventory reads", () => { + it("opts unsent drafts into MCP beside the event inventory allowlist", () => { + expect(manageEventDraft.mcpTool).toBe(true); + expect(manageEventDraft.mcpAnnotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + openWorldHint: false, + }); + }); + + it("keeps the shared inventory allowlist read-only", () => { expect(CALENDAR_CONNECTOR_CATALOG).toEqual(["list-events"]); expect(CALENDAR_CONNECTOR_CATALOG).not.toContain("search-events"); expect(CALENDAR_CONNECTOR_CATALOG).not.toContain("get-event"); diff --git a/templates/calendar/server/lib/calendar-mcp-writes.spec.ts b/templates/calendar/server/lib/calendar-mcp-writes.spec.ts index d80332e1acf..82037d5b1a6 100644 --- a/templates/calendar/server/lib/calendar-mcp-writes.spec.ts +++ b/templates/calendar/server/lib/calendar-mcp-writes.spec.ts @@ -81,12 +81,14 @@ vi.mock("./event-video-conferencing.js", () => ({ import createEvent from "../../actions/create-event.js"; import deleteEvent from "../../actions/delete-event.js"; import listEvents from "../../actions/list-events.js"; +import manageEventDraft from "../../actions/manage-event-draft.js"; import respondToEvent from "../../actions/respond-to-event.js"; import rsvpEvent from "../../actions/rsvp-event.js"; import updateEvent from "../../actions/update-event.js"; import "../plugins/agent-chat.js"; const actions = { + "manage-event-draft": manageEventDraft, "list-events": listEvents, "create-event": createEvent, "update-event": updateEvent, @@ -197,6 +199,7 @@ describe("Calendar direct MCP writes", () => { "create_embed_session", "list-events", "list_apps", + "manage-event-draft", "open_app", "respond-to-event", "update-event", diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index add5e40c900..1edc07a6c96 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -34,6 +34,7 @@ Every action has a schema; use `tool-search` for comments, sharing, Collections, | `view-screen` / `navigate` / `refresh-list` | Read context, move UI, refresh after out-of-band writes | | `list-documents` / `search-documents` / `get-document` / `pull-document` | Browse metadata, search, read, or flush live state before reading | | `get-blocks-field-word-count` | Count one exact collection field; omit `propertyId` for primary body | +| `share-local-file-document` | Approval required to create or refresh a shareable copy; may refresh an already shared copy | | `create-document` / `edit-document` / `update-document` / `delete-document` | Create, revision-edit, update, or move a page and children to Trash | | `import-content` / `undo-content-import` | Import Markdown as pages; undo trashes unedited imports | | `list-content-trash` / `get-trashed-document` | Find/read authorized Trash without restoring | diff --git a/templates/content/actions/mcp-action-contract.spec.ts b/templates/content/actions/mcp-action-contract.spec.ts index d73af54427f..fed7c158edb 100644 --- a/templates/content/actions/mcp-action-contract.spec.ts +++ b/templates/content/actions/mcp-action-contract.spec.ts @@ -44,6 +44,7 @@ import restoreContentDatabase from "./restore-content-database.js"; import restoreDocument from "./restore-document.js"; import searchDocuments from "./search-documents.js"; import setDocumentProperty from "./set-document-property.js"; +import shareLocalFileDocument from "./share-local-file-document.js"; import syncGitHubFolder from "./sync-github-folder.js"; import updateComment from "./update-comment.js"; import updateContentDatabaseView from "./update-content-database-view.js"; @@ -55,6 +56,7 @@ import viewScreen from "./view-screen.js"; describe("Content action-owned agent catalogs", () => { const directMcpActions = { + "share-local-file-document": shareLocalFileDocument, "create-content-database": createContentDatabase, "configure-document-property": configureDocumentProperty, "update-content-database-view": updateContentDatabaseView, @@ -107,6 +109,15 @@ describe("Content action-owned agent catalogs", () => { } }); + it("requires approval before copying local content to a shareable document", () => { + expect(shareLocalFileDocument.needsApproval).toBe(true); + expect(shareLocalFileDocument.mcpAnnotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + openWorldHint: true, + }); + }); + it("returns migration receipts as structured MCP content", () => { expect(migrateContentDatabaseRows.mcpApp?.structuredContent).toBe(true); }); diff --git a/templates/content/actions/share-local-file-document.ts b/templates/content/actions/share-local-file-document.ts index e88db32aba2..adcc1e09eba 100644 --- a/templates/content/actions/share-local-file-document.ts +++ b/templates/content/actions/share-local-file-document.ts @@ -75,6 +75,14 @@ function serializeDocument(row: typeof schema.documents.$inferSelect) { } export default defineAction({ + mcpTool: true, + needsApproval: true, + mcpAnnotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, description: "Create or refresh a collection-backed shareable copy of a local-file document. Use this before sharing a local file with other users.", schema: z.object({ diff --git a/templates/design/AGENTS.md b/templates/design/AGENTS.md index 36e7cf08ed0..69ed6719f76 100644 --- a/templates/design/AGENTS.md +++ b/templates/design/AGENTS.md @@ -59,8 +59,8 @@ Search local docs with `pnpm action docs-search --query ""`; read by slug | `get-native-shader-approvals` | Read viewer-scoped exact effect approvals for accessible Design previews | | `prepare-native-scene-export` | Pin scene for PNG | | `get-native-render-contexts` / `request-native-local-export` / `get-native-local-export` | Request foreground native PNG and inspect attachment | -| `export-png` | Export PNG | -| `export-html` / `export-zip` / `export-coding-handoff` / `export-design-as-figma-svg` | Export finished work | +| `generate-screens` | Plan screens | +| `export-png` / `export-coding-handoff` | PNG/code; handoff needs approval (7-day bearer URLs) | ## Core Rules diff --git a/templates/design/actions/export-coding-handoff.ts b/templates/design/actions/export-coding-handoff.ts index 867c3c1f1b2..4a22f356e10 100644 --- a/templates/design/actions/export-coding-handoff.ts +++ b/templates/design/actions/export-coding-handoff.ts @@ -33,6 +33,13 @@ function designDeepLink(designId: string): string { } export default defineAction({ + needsApproval: true, + mcpAnnotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, description: "Turn a design into code: create an agent handoff for a design project. " + "Returns tokenized raw and ZIP URLs external agents can fetch, plus a " + diff --git a/templates/design/actions/generate-screens.ts b/templates/design/actions/generate-screens.ts index ceb2d991669..1d68d7c3abf 100644 --- a/templates/design/actions/generate-screens.ts +++ b/templates/design/actions/generate-screens.ts @@ -158,6 +158,12 @@ const requestedScreenSchema = z }); export default defineAction({ + mcpAnnotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, description: "Start a multi-screen generation session on the Design canvas for " + "genuinely DISTINCT screens — different pages or states such as Home, " + diff --git a/templates/design/server/plugins/agent-chat.ts b/templates/design/server/plugins/agent-chat.ts index 2aba7e73f88..1a6ad2e4bc4 100644 --- a/templates/design/server/plugins/agent-chat.ts +++ b/templates/design/server/plugins/agent-chat.ts @@ -15,6 +15,8 @@ const DESIGN_BACKGROUND_RUN_SOFT_TIMEOUT_MS = 13 * 60_000; const DESIGN_BACKGROUND_RUN_NO_PROGRESS_TIMEOUT_MS = 12 * 60_000; const EXTERNAL_CONNECTOR_TOOL_NAMES = [ + "generate-screens", + "export-coding-handoff", "open-visual-edit", "get-visual-edit-pending", "acknowledge-visual-edit-pending", @@ -302,7 +304,7 @@ export default createAgentChatPlugin({ connectorCatalog: EXTERNAL_CONNECTOR_TOOL_NAMES, keyToolNames: ["get-visual-edit-pending"], instructions: - "Resolve a named template or prior design first with list-design-templates / list-designs; copy with create-design-from-template, then adapt with edit-design — never regenerate a copied screen with generate-design. For new-design exploration use create-design then present-design-variants (2-5 variants) and surface the returned open link; do not navigate. Hand-off goes through export-png for one screen, or export-html / export-zip / export-coding-handoff / export-design-as-figma-svg for other formats. Persist early: create or update the design and its files as soon as a coherent candidate exists. " + + "Resolve a named template or prior design first with list-design-templates / list-designs; copy with create-design-from-template, then adapt with edit-design — never regenerate a copied screen with generate-design. For new-design exploration use create-design then present-design-variants (2-5 variants) and surface the returned open link; do not navigate. Hand-off goes through export-png for a screen image or export-coding-handoff for code. The coding handoff requires approval because its raw and ZIP URLs grant bearer access for seven days. Persist early: create or update the design and its files as soon as a coherent candidate exists. " + 'Design system: get-design, get-design-snapshot, and view-screen return `designSystem` (a bounded summary with scope "summary" and a `next` line); call get-design-system { id } once before the first screen you author for the full context (create-design returns it in full), then reuse it. Apply designSystem.agentContext, plus index-design-tokens for an existing design, before authoring or restyling; never invent a generic palette. For a new design, pass the exact title as `designSystem` or a designSystemId; omit both to link the caller\'s default. Preserve existing screen composition as well as linked system tokens, fonts, assets, and custom instructions. Read back the saved file after every visual mutation. For a running localhost app, use open-visual-edit and keep each route/state/viewport as its own URL-backed screen. FULL APP DESIGNS: when the open design has a fusionApp (its screens are a live Builder branch container), the app code lives in that branch. Make every app code change (styles, components, copy in source, data, routes) by calling send-builder-message with the designId and a precise prompt; it delegates to Builder on that branch, streams its work into this chat, and returns when the change is done and the screens reload. Use apply-builder-edits only for edits already queued from the canvas (queue-builder-edit); one delegation per request, then report what changed. Never answer an app code change on a Builder app design by asking the user to connect Builder or to copy a prompt. On a Builder app design, pending live visual edits (text, styles, hidden or layer state) are app code changes too: call get-visual-edit-pending, then pass its prompt to send-builder-message with visualEditRevision set to that revision; send-builder-message clears the handoff when Builder finishes. Never route them through the localhost source tools or write consent, which only exist for connected localhost apps. Canvas annotations (strokes and notes from Annotate) on a Builder app design are the user describing changes to the app: work out what each mark points at and asks for, and delegate that change with send-builder-message. The annotation strokes are not part of the app or the design, so never try to edit or delete them. Update a selected screen with update-screen-source, and use add-localhost-screens or add-breakpoint for additional canvas frames. KEY VISUAL HANDOFF: after the user edits a live screen, call get-visual-edit-pending with the visual-edit designId before asking for copy/paste. It returns the latest source prompt and revision even when the Design tab is closed; apply that prompt to the connected app, then call acknowledge-visual-edit-pending with the same designId and revision only after the source change is verified, and call get-visual-edit-pending again to confirm it cleared. Never acknowledge a handoff you did not apply. The page-local get-visual-edit-prompt tool is an equivalent fallback only for browser-capable hosts.', directoryProfile: { ...CHATGPT_DIRECTORY_PROFILE, diff --git a/templates/slides-2/AGENTS.md b/templates/slides-2/AGENTS.md index db525b86b39..a103978c41f 100644 --- a/templates/slides-2/AGENTS.md +++ b/templates/slides-2/AGENTS.md @@ -44,6 +44,7 @@ Create and edit slide decks through Agent-Native chat and the visual editor, bac | `apply-design-system` | Link a design system to a deck. | | `get-workspace-defaults`, `get-deck-reference-context` | Read workspace defaults and reusable deck context. | | `create-deck`, `create-deck-from-template` | Create a deck directly or from a template. | +| `clone-context-slide` | Copy a pinned native library slide into an editable deck, preserving provenance | | `add-slide` | Add a slide to a saved deck. | | `update-slide` | Edit legacy slide content or structure. | | `get-slide-source`, `update-slide-source` | Read source and save revision-checked source edits. | diff --git a/templates/slides-2/server/plugins/agent-chat.ts b/templates/slides-2/server/plugins/agent-chat.ts index 70c4a9e1f82..72018730f93 100644 --- a/templates/slides-2/server/plugins/agent-chat.ts +++ b/templates/slides-2/server/plugins/agent-chat.ts @@ -52,6 +52,7 @@ const INITIAL_TOOL_NAMES = [ ]; const EXTERNAL_CONNECTOR_TOOL_NAMES = [ + "clone-context-slide", "view-screen", "control-presenter", "navigate", diff --git a/templates/slides/AGENTS.md b/templates/slides/AGENTS.md index 149736b8d8d..53a337192a0 100644 --- a/templates/slides/AGENTS.md +++ b/templates/slides/AGENTS.md @@ -70,3 +70,9 @@ Google Slides export produces a PPTX for the user to import; a native Slides fil Before building common workspace or agent UI, read `agent-native-toolkit`; read `customizing-agent-native` before adapting shared UI. Search with `rg --hidden --follow`; read the exact linked guide before deeper work. + +## External inline actions + +| Action | Purpose | +| --- | --- | +| `clone-context-slide` | Copy a pinned native library slide into an editable deck, preserving provenance | diff --git a/templates/slides/actions/clone-context-slide.ts b/templates/slides/actions/clone-context-slide.ts index 57356a0a418..5d5780439ab 100644 --- a/templates/slides/actions/clone-context-slide.ts +++ b/templates/slides/actions/clone-context-slide.ts @@ -81,6 +81,12 @@ export function cloneableNativeSlide(context: ContextDetail): { } export default defineAction({ + mcpAnnotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, description: "Clone one pinned Google Slides-native Creative Context item into an existing deck without regenerating or rewriting its editable HTML/CSS. Returns a link that opens the cloned slide in Slides.", schema: z.object({ diff --git a/templates/slides/server/plugins/agent-chat.ts b/templates/slides/server/plugins/agent-chat.ts index bf09159162a..30fe0566a85 100644 --- a/templates/slides/server/plugins/agent-chat.ts +++ b/templates/slides/server/plugins/agent-chat.ts @@ -55,6 +55,7 @@ const INITIAL_TOOL_NAMES = [ ]; const EXTERNAL_CONNECTOR_TOOL_NAMES = [ + "clone-context-slide", "view-screen", "navigate", "list-decks", From 7a3d54206fc92ba771e31b8a90f805a8a3fa8b80 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:11:43 -0400 Subject: [PATCH 2/5] Scope inline app approval to outside action callers --- .../core/src/mcp/hidden-inline-apps.spec.ts | 25 ++++++++++++++++++- templates/content/AGENTS.md | 2 +- .../actions/mcp-action-contract.spec.ts | 3 +-- .../actions/share-local-file-document.ts | 3 ++- templates/design/AGENTS.md | 23 +++++++++-------- .../design/actions/export-coding-handoff.ts | 3 ++- 6 files changed, 42 insertions(+), 17 deletions(-) diff --git a/packages/core/src/mcp/hidden-inline-apps.spec.ts b/packages/core/src/mcp/hidden-inline-apps.spec.ts index 9f04e1a4534..23451e68a6e 100644 --- a/packages/core/src/mcp/hidden-inline-apps.spec.ts +++ b/packages/core/src/mcp/hidden-inline-apps.spec.ts @@ -2,6 +2,7 @@ import { Client } from "@modelcontextprotocol/client"; import { InMemoryTransport } from "@modelcontextprotocol/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; +import { actionCallIntent, preparedCallNeedsApproval } from "../action.js"; import { LOAD_TIMEOUT_MS, listAdvertised, @@ -69,6 +70,29 @@ describe("explicit connector inline apps", () => { LOAD_TIMEOUT_MS, ); + it.each([ + ["content", "share-local-file-document", { id: "local-file-test" }], + ["design", "export-coding-handoff", { id: "design-test" }], + ] as const)( + "requires approval only for outside callers of %s/%s", + async (appId, name, args) => { + const app = apps.find((candidate) => candidate.appId === appId)!; + const entry = app.surfaces[0]!.config.actions[name]!; + const call = await actionCallIntent(entry, args); + if (!call.valid) throw call.error; + for (const [caller, expected] of [ + ["tool", false], + ["mcp", true], + ["a2a", true], + ] as const) { + expect( + await preparedCallNeedsApproval(entry, call, { caller }), + `${appId}/${name} approval for ${caller}`, + ).toBe(expected); + } + }, + ); + it.each([ ["content", "share-local-file-document", { id: "local-file-test" }], ["design", "export-coding-handoff", { id: "design-test" }], @@ -78,7 +102,6 @@ describe("explicit connector inline apps", () => { const app = apps.find((candidate) => candidate.appId === appId)!; const config = app.surfaces[0]!.config; const entry = config.actions[name]!; - expect(entry.needsApproval).toBe(true); const run = vi.fn(async () => ({ id: "unexpected-write" })); const actions = { ...config.actions, [name]: { ...entry, run } }; const server = await createMCPServerForRequest( diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index 1edc07a6c96..1761911100c 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -34,7 +34,7 @@ Every action has a schema; use `tool-search` for comments, sharing, Collections, | `view-screen` / `navigate` / `refresh-list` | Read context, move UI, refresh after out-of-band writes | | `list-documents` / `search-documents` / `get-document` / `pull-document` | Browse metadata, search, read, or flush live state before reading | | `get-blocks-field-word-count` | Count one exact collection field; omit `propertyId` for primary body | -| `share-local-file-document` | Approval required to create or refresh a shareable copy; may refresh an already shared copy | +| `share-local-file-document` | Create/refresh a shareable copy, possibly already shared; only direct MCP/A2A calls need approval | | `create-document` / `edit-document` / `update-document` / `delete-document` | Create, revision-edit, update, or move a page and children to Trash | | `import-content` / `undo-content-import` | Import Markdown as pages; undo trashes unedited imports | | `list-content-trash` / `get-trashed-document` | Find/read authorized Trash without restoring | diff --git a/templates/content/actions/mcp-action-contract.spec.ts b/templates/content/actions/mcp-action-contract.spec.ts index fed7c158edb..8c830fc1225 100644 --- a/templates/content/actions/mcp-action-contract.spec.ts +++ b/templates/content/actions/mcp-action-contract.spec.ts @@ -109,8 +109,7 @@ describe("Content action-owned agent catalogs", () => { } }); - it("requires approval before copying local content to a shareable document", () => { - expect(shareLocalFileDocument.needsApproval).toBe(true); + it("classifies shareable local-file copies as disclosure-capable writes", () => { expect(shareLocalFileDocument.mcpAnnotations).toMatchObject({ readOnlyHint: false, destructiveHint: true, diff --git a/templates/content/actions/share-local-file-document.ts b/templates/content/actions/share-local-file-document.ts index adcc1e09eba..aea4c11fa10 100644 --- a/templates/content/actions/share-local-file-document.ts +++ b/templates/content/actions/share-local-file-document.ts @@ -76,7 +76,8 @@ function serializeDocument(row: typeof schema.documents.$inferSelect) { export default defineAction({ mcpTool: true, - needsApproval: true, + // Direct outside calls need approval; the app agent (including ask_app) uses "tool". + needsApproval: (_args, ctx) => ctx?.caller === "mcp" || ctx?.caller === "a2a", mcpAnnotations: { readOnlyHint: false, destructiveHint: true, diff --git a/templates/design/AGENTS.md b/templates/design/AGENTS.md index 69ed6719f76..f2395955517 100644 --- a/templates/design/AGENTS.md +++ b/templates/design/AGENTS.md @@ -1,7 +1,6 @@ # Design — Agent Guide -Design agents build prototypes, systems, variants, and handoffs through -actions against shared SQL state. +Build prototypes, systems, variants, and handoffs with actions and shared SQL state. ## Skills @@ -23,12 +22,11 @@ Also read `.agents/skills//SKILL.md` for shared guides: `actions`, `delegate-to-agent`, `agent-native-docs`, `agent-native-toolkit`, `customizing-agent-native`, `client-side-routing`, `reliable-mutations`, `performance`, `external-agents`, `portability`, `self-modifying-code`, -`turn-into-skill`, and `workspace-conventions`. Each guide is at -`.agents/skills//SKILL.md`. +`turn-into-skill`, and `workspace-conventions`. ## Framework Docs -Search local docs with `pnpm action docs-search --query ""`; read by slug with `pnpm action docs-search --slug ""`. +Find docs: `pnpm action docs-search --query ""`; read: `pnpm action docs-search --slug ""`. ## Actions @@ -46,7 +44,7 @@ Search local docs with `pnpm action docs-search --query ""`; read by slug | `add-session-replay-screenshots-to-board` | Add private Analytics replay screenshots to a Design board | | `stage-journey-canvas-frames` | Stage native PNGs in Design-owned private blob storage in batches | | `discard-journey-canvas-frame-import` | Discard staged import; queue its private blobs for cleanup | -| `create-journey-canvas` | Create provenance-backed storyboard with stubs, same-recording links, and explicit recording-gap references; no cohort metrics | +| `create-journey-canvas` | Create provenance-backed storyboards with stubs, same-recording links, and recording-gap references; no cohort metrics | | `add-breakpoint` / `remove-breakpoint` | Manage responsive frames | | `edit-design` | Adapt a design/screen | | `apply-visual-edit` | Apply deterministic layer edits | @@ -58,17 +56,20 @@ Search local docs with `pnpm action docs-search --query ""`; read by slug | `get-shader` / `edit-native-shader` | Inspect curated native effects; CAS-edit one inline scene | | `get-native-shader-approvals` | Read viewer-scoped exact effect approvals for accessible Design previews | | `prepare-native-scene-export` | Pin scene for PNG | -| `get-native-render-contexts` / `request-native-local-export` / `get-native-local-export` | Request foreground native PNG and inspect attachment | +| `get-native-render-contexts` / `request-native-local-export` / `get-native-local-export` | Request foreground native PNG; inspect attachment | | `generate-screens` | Plan screens | -| `export-png` / `export-coding-handoff` | PNG/code; handoff needs approval (7-day bearer URLs) | +| `export-png` / `export-coding-handoff` | PNG/code; only direct MCP/A2A handoff calls need approval (7-day bearer URLs) | +| `export-html` | Export standalone HTML | +| `export-zip` | Export ZIP | +| `export-design-as-figma-svg` | Export Figma SVG | ## Core Rules - UI feedback: target 100 ms, never exceed 400 ms; acknowledge before network work. - Use actions for design data and writes; never write design rows directly with SQL. -- For external integrations, inspect the workspace/provider connection catalog first; reuse its scoped resolver. +- For external integrations, inspect workspace/provider connections first; reuse the scoped resolver. - `[Reprompt selection]` is preview-only: only `propose-node-rewrite` may mutate. `[Selection question]` is read-only; answer without content-writing actions. -- Generated files are complete standalone HTML (Alpine.js + Tailwind CDN), rendered without a build step. Follow `design-generation` for its quality/audit pass and `data-agent-native-locked="true"` for locked subtrees. +- Generated files are standalone HTML (Alpine.js + Tailwind CDN), rendered without a build step. Follow `design-generation` for its quality/audit pass and `data-agent-native-locked="true"` for locked subtrees. - Source modes are `inline`, `localhost`, and `fusion` (`full-app-build`). `/design/:id` is read-only; `/visual-edit/:id` allows DOM-only localhost edits. Source writes and snapshot publishing require editor access. - `capability:visual-edit` scopes handoff actions. External agents use `get-visual-edit-pending`; browser agents use the page-local tool. @@ -83,6 +84,6 @@ Search local docs with `pnpm action docs-search --query ""`; read by slug ## Source Changes -Before building common workspace or agent UI, read `agent-native-toolkit`; read `customizing-agent-native` before adapting shared UI. Editor behavior lives in `app/pages/design-editor/commands/*.ts`; read `design-editor-architecture` before changing it. +For workspace/agent UI, read `agent-native-toolkit`; for shared UI changes, read `customizing-agent-native`. Before editor changes, read `design-editor-architecture`; commands live in `app/pages/design-editor/commands/*.ts`. Search with `rg --hidden --follow`; read the exact linked guide before deeper work. diff --git a/templates/design/actions/export-coding-handoff.ts b/templates/design/actions/export-coding-handoff.ts index 4a22f356e10..250ac2e90e1 100644 --- a/templates/design/actions/export-coding-handoff.ts +++ b/templates/design/actions/export-coding-handoff.ts @@ -33,7 +33,8 @@ function designDeepLink(designId: string): string { } export default defineAction({ - needsApproval: true, + // Direct outside calls need approval; the app agent (including ask_app) uses "tool". + needsApproval: (_args, ctx) => ctx?.caller === "mcp" || ctx?.caller === "a2a", mcpAnnotations: { readOnlyHint: false, destructiveHint: false, From 4d0369956b4bab1466e2ee1fdcd4b37f4ad0ca44 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:25:44 -0400 Subject: [PATCH 3/5] docs: align external action guides after rebase --- templates/design/AGENTS.md | 4 ++-- templates/slides-2/AGENTS.md | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/templates/design/AGENTS.md b/templates/design/AGENTS.md index f2395955517..219f01c22a7 100644 --- a/templates/design/AGENTS.md +++ b/templates/design/AGENTS.md @@ -16,7 +16,7 @@ Read `.agents/skills//SKILL.md` before deeper work: - `native-effects` — WebGPU fills/effects, source edits, and PNG. - `journey-storyboards` — onboarding-journey storyboards. -Also read `.agents/skills//SKILL.md` for shared guides: `actions`, +Also read `.agents/skills//SKILL.md`: `actions`, `adding-a-feature`, `storing-data`, `security`, `secrets`, `sharing`, `frontend-design`, `shadcn-ui`, `real-time-sync`, `context-awareness`, `delegate-to-agent`, `agent-native-docs`, `agent-native-toolkit`, @@ -69,7 +69,7 @@ Find docs: `pnpm action docs-search --query ""`; read: `pnpm action docs- - Use actions for design data and writes; never write design rows directly with SQL. - For external integrations, inspect workspace/provider connections first; reuse the scoped resolver. - `[Reprompt selection]` is preview-only: only `propose-node-rewrite` may mutate. `[Selection question]` is read-only; answer without content-writing actions. -- Generated files are standalone HTML (Alpine.js + Tailwind CDN), rendered without a build step. Follow `design-generation` for its quality/audit pass and `data-agent-native-locked="true"` for locked subtrees. +- Generated files are complete standalone HTML (Alpine.js + Tailwind CDN), rendered without a build step. Follow `design-generation` for its quality/audit pass and `data-agent-native-locked="true"` for locked subtrees. - Source modes are `inline`, `localhost`, and `fusion` (`full-app-build`). `/design/:id` is read-only; `/visual-edit/:id` allows DOM-only localhost edits. Source writes and snapshot publishing require editor access. - `capability:visual-edit` scopes handoff actions. External agents use `get-visual-edit-pending`; browser agents use the page-local tool. diff --git a/templates/slides-2/AGENTS.md b/templates/slides-2/AGENTS.md index a103978c41f..f85b9c91754 100644 --- a/templates/slides-2/AGENTS.md +++ b/templates/slides-2/AGENTS.md @@ -44,7 +44,6 @@ Create and edit slide decks through Agent-Native chat and the visual editor, bac | `apply-design-system` | Link a design system to a deck. | | `get-workspace-defaults`, `get-deck-reference-context` | Read workspace defaults and reusable deck context. | | `create-deck`, `create-deck-from-template` | Create a deck directly or from a template. | -| `clone-context-slide` | Copy a pinned native library slide into an editable deck, preserving provenance | | `add-slide` | Add a slide to a saved deck. | | `update-slide` | Edit legacy slide content or structure. | | `get-slide-source`, `update-slide-source` | Read source and save revision-checked source edits. | @@ -55,6 +54,12 @@ Create and edit slide decks through Agent-Native chat and the visual editor, bac | `search-svgl-logos`, `import-svgl-logo` | Find a logo and import it as a slide asset. | | `export-pptx` | Export a deck as PowerPoint. | +## External inline actions + +| Action | Purpose | +| --- | --- | +| `clone-context-slide` | Copy a pinned native library slide into an editable deck, preserving provenance | + ## Application State - `navigation` identifies the current view, deck, and slide. Home also records `homeTab` (`recent` or `templates`), `deckSearch`, and `deckFilter` (`all`, `created-by-me`, or `shared`); the templates route uses `search`; the themes route can include `themeId`. From 71df48ce211144e9e5f6628b76630540f3ef90fa Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:57:43 -0400 Subject: [PATCH 4/5] docs: retain canonical Design workspace guidance --- templates/design/AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/design/AGENTS.md b/templates/design/AGENTS.md index 219f01c22a7..3d5b8ce71a9 100644 --- a/templates/design/AGENTS.md +++ b/templates/design/AGENTS.md @@ -52,7 +52,7 @@ Find docs: `pnpm action docs-search --query ""`; read: `pnpm action docs- | `create-design` / `generate-design` | Start empty design / generate a fresh screen | | `present-design-variants` | Generate 2–5 variants | | `view-screen` / `navigate` | Read current screen / move UI | -| `get-view-settings` / `update-view-settings` | Read/update view toggles (grid, snap, rulers, cursors, hidden comments) | +| `get-view-settings` / `update-view-settings` | Read/update view toggles | | `get-shader` / `edit-native-shader` | Inspect curated native effects; CAS-edit one inline scene | | `get-native-shader-approvals` | Read viewer-scoped exact effect approvals for accessible Design previews | | `prepare-native-scene-export` | Pin scene for PNG | @@ -67,7 +67,7 @@ Find docs: `pnpm action docs-search --query ""`; read: `pnpm action docs- - UI feedback: target 100 ms, never exceed 400 ms; acknowledge before network work. - Use actions for design data and writes; never write design rows directly with SQL. -- For external integrations, inspect workspace/provider connections first; reuse the scoped resolver. +- For external integrations, inspect the workspace/provider connection catalog first; reuse its scoped resolver. - `[Reprompt selection]` is preview-only: only `propose-node-rewrite` may mutate. `[Selection question]` is read-only; answer without content-writing actions. - Generated files are complete standalone HTML (Alpine.js + Tailwind CDN), rendered without a build step. Follow `design-generation` for its quality/audit pass and `data-agent-native-locked="true"` for locked subtrees. - Source modes are `inline`, `localhost`, and `fusion` (`full-app-build`). `/design/:id` is read-only; `/visual-edit/:id` allows DOM-only localhost edits. Source writes and snapshot publishing require editor access. @@ -84,6 +84,6 @@ Find docs: `pnpm action docs-search --query ""`; read: `pnpm action docs- ## Source Changes -For workspace/agent UI, read `agent-native-toolkit`; for shared UI changes, read `customizing-agent-native`. Before editor changes, read `design-editor-architecture`; commands live in `app/pages/design-editor/commands/*.ts`. +Before building common workspace or agent UI, read `agent-native-toolkit`; read `customizing-agent-native` before adapting shared UI. Editor behavior lives in `app/pages/design-editor/commands/*.ts`; read `design-editor-architecture` before changing it. Search with `rg --hidden --follow`; read the exact linked guide before deeper work. From 4b89e0952f45f195b6b74cf5fc8c3c0b6c12c1a5 Mon Sep 17 00:00:00 2001 From: 3mdistal <86723305+3mdistal@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:27:59 -0400 Subject: [PATCH 5/5] fix: enforce outside approval for inline MCP actions --- .changeset/hidden-mcp-apps-core-helper.md | 5 ++ packages/core/src/action-caller.spec.ts | 76 +++++++++++++++++++ packages/core/src/action-caller.ts | 14 ++++ packages/core/src/index.ts | 1 + .../core/src/mcp/hidden-inline-apps.spec.ts | 24 ------ ...n-open-the-traffic-dashboard-inside-the.md | 6 ++ templates/calendar/AGENTS.md | 2 +- .../calendar/actions/manage-event-draft.ts | 4 +- ...n-open-invite-drafts-in-calendar-for-re.md | 6 ++ .../server/lib/calendar-mcp-writes.spec.ts | 74 +++++++++++++++++- templates/content/AGENTS.md | 2 +- .../actions/mcp-action-contract.spec.ts | 42 ++++++++++ .../actions/share-local-file-document.ts | 5 +- ...n-open-shareable-local-file-copies-in-c.md | 6 ++ templates/design/AGENTS.md | 2 +- .../actions/export-coding-handoff.spec.ts | 40 ++++++++++ .../design/actions/export-coding-handoff.ts | 5 +- ...n-generate-screens-and-open-coding-hand.md | 6 ++ ...n-clone-a-slide-from-existing-context-a.md | 6 ++ ...n-clone-a-slide-from-existing-context-a.md | 6 ++ 20 files changed, 296 insertions(+), 36 deletions(-) create mode 100644 .changeset/hidden-mcp-apps-core-helper.md create mode 100644 packages/core/src/action-caller.spec.ts create mode 100644 packages/core/src/action-caller.ts create mode 100644 templates/analytics/changelog/2026-10-22-outside-assistants-can-open-the-traffic-dashboard-inside-the.md create mode 100644 templates/calendar/changelog/2026-10-22-outside-assistants-can-open-invite-drafts-in-calendar-for-re.md create mode 100644 templates/content/changelog/2026-10-22-outside-assistants-can-open-shareable-local-file-copies-in-c.md create mode 100644 templates/design/changelog/2026-10-22-outside-assistants-can-generate-screens-and-open-coding-hand.md create mode 100644 templates/slides-2/changelog/2026-10-22-outside-assistants-can-clone-a-slide-from-existing-context-a.md create mode 100644 templates/slides/changelog/2026-10-22-outside-assistants-can-clone-a-slide-from-existing-context-a.md diff --git a/.changeset/hidden-mcp-apps-core-helper.md b/.changeset/hidden-mcp-apps-core-helper.md new file mode 100644 index 00000000000..539fbda2b59 --- /dev/null +++ b/.changeset/hidden-mcp-apps-core-helper.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": none +--- + +Carry the shared `isOutsideAgentCall` helper from #8005, whose changeset supplies the release note. diff --git a/packages/core/src/action-caller.spec.ts b/packages/core/src/action-caller.spec.ts new file mode 100644 index 00000000000..231196c33d4 --- /dev/null +++ b/packages/core/src/action-caller.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { isOutsideAgentCall } from "./action-caller.js"; +import type { ActionCaller, ActionRunContext } from "./action.js"; + +const callers = { + frontend: { outside: false, reason: "the app's own UI" }, + tool: { outside: false, reason: "the app's own agent without delegation" }, + http: { + outside: true, + reason: + "HTTP alone does not prove a human UI click; action routes verify UI capability separately", + }, + cli: { + outside: true, + reason: + "scripts and the development CLI do not prove an in-app human interaction", + }, + automation: { + outside: true, + reason: "scheduled or triggered execution is not an interactive human call", + }, + mcp: { outside: true, reason: "a direct MCP tool call" }, + a2a: { outside: true, reason: "a direct A2A action call" }, + webmcp: { outside: true, reason: "a browser agent's WebMCP call" }, + "mcp-widget": { outside: true, reason: "a card's scoped read capability" }, + "mcp-widget-write": { + outside: true, + reason: "a card's scoped write capability", + }, +} satisfies Record; + +const protocols = { + a2a: "the default A2A chat handoff retains tool as the caller", + mcp: "ask_app runs the app agent as tool on an outside agent's behalf", + "provider-api": + "external provider provenance does not establish an in-app human call", +} satisfies Record, string>; + +describe("isOutsideAgentCall", () => { + for (const [caller, { outside, reason }] of Object.entries(callers)) { + const ctx = { caller: caller as ActionCaller }; + + it(`classifies ${caller}: ${reason}`, () => { + expect(isOutsideAgentCall(ctx)).toBe(outside); + }); + + it(`treats ${caller} with automation context as outside`, () => { + expect( + isOutsideAgentCall({ + ...ctx, + automation: { triggerId: "trigger-1", triggerName: "Scheduled run" }, + }), + ).toBe(true); + }); + + for (const [protocol, reason] of Object.entries(protocols)) { + it(`treats ${caller} over ${protocol} as outside: ${reason}`, () => { + expect( + isOutsideAgentCall({ + ...ctx, + networkProtocol: protocol as ActionRunContext["networkProtocol"], + }), + ).toBe(true); + }); + } + } + + it("fails closed without context or a recognized caller", () => { + expect(isOutsideAgentCall()).toBe(true); + expect(isOutsideAgentCall({} as ActionRunContext)).toBe(true); + expect( + isOutsideAgentCall({ caller: "future-caller" } as ActionRunContext), + ).toBe(true); + }); +}); diff --git a/packages/core/src/action-caller.ts b/packages/core/src/action-caller.ts new file mode 100644 index 00000000000..ccf74e55def --- /dev/null +++ b/packages/core/src/action-caller.ts @@ -0,0 +1,14 @@ +import type { ActionRunContext } from "./action.js"; + +/** Classify provenance for approval policy; this does not authenticate a caller. */ +export function isOutsideAgentCall( + ctx?: Pick, +): boolean { + // CLI defaults to outside because coding agents drive it; Alice's decision is pending. + // Automation has no trusted origin in ctx yet; #8070's mcpManaged is job frontmatter. + return ( + ctx?.networkProtocol !== undefined || + ctx?.automation !== undefined || + (ctx?.caller !== "frontend" && ctx?.caller !== "tool") + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 357e727e912..8871c4a3ab2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -53,6 +53,7 @@ export { type ActionMcpAppResourceConfig, type ActionMcpAppResourceMeta, } from "./action.js"; +export { isOutsideAgentCall } from "./action-caller.js"; export * from "./user-profile/index.js"; export { embedApp, diff --git a/packages/core/src/mcp/hidden-inline-apps.spec.ts b/packages/core/src/mcp/hidden-inline-apps.spec.ts index 23451e68a6e..4b4996890fd 100644 --- a/packages/core/src/mcp/hidden-inline-apps.spec.ts +++ b/packages/core/src/mcp/hidden-inline-apps.spec.ts @@ -2,7 +2,6 @@ import { Client } from "@modelcontextprotocol/client"; import { InMemoryTransport } from "@modelcontextprotocol/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; -import { actionCallIntent, preparedCallNeedsApproval } from "../action.js"; import { LOAD_TIMEOUT_MS, listAdvertised, @@ -70,29 +69,6 @@ describe("explicit connector inline apps", () => { LOAD_TIMEOUT_MS, ); - it.each([ - ["content", "share-local-file-document", { id: "local-file-test" }], - ["design", "export-coding-handoff", { id: "design-test" }], - ] as const)( - "requires approval only for outside callers of %s/%s", - async (appId, name, args) => { - const app = apps.find((candidate) => candidate.appId === appId)!; - const entry = app.surfaces[0]!.config.actions[name]!; - const call = await actionCallIntent(entry, args); - if (!call.valid) throw call.error; - for (const [caller, expected] of [ - ["tool", false], - ["mcp", true], - ["a2a", true], - ] as const) { - expect( - await preparedCallNeedsApproval(entry, call, { caller }), - `${appId}/${name} approval for ${caller}`, - ).toBe(expected); - } - }, - ); - it.each([ ["content", "share-local-file-document", { id: "local-file-test" }], ["design", "export-coding-handoff", { id: "design-test" }], diff --git a/templates/analytics/changelog/2026-10-22-outside-assistants-can-open-the-traffic-dashboard-inside-the.md b/templates/analytics/changelog/2026-10-22-outside-assistants-can-open-the-traffic-dashboard-inside-the.md new file mode 100644 index 00000000000..d2a70f78c73 --- /dev/null +++ b/templates/analytics/changelog/2026-10-22-outside-assistants-can-open-the-traffic-dashboard-inside-the.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-10-22 +--- + +Outside assistants can open the Traffic dashboard inside their chat. diff --git a/templates/calendar/AGENTS.md b/templates/calendar/AGENTS.md index 1405535c2bf..d4e7e736ae5 100644 --- a/templates/calendar/AGENTS.md +++ b/templates/calendar/AGENTS.md @@ -25,7 +25,7 @@ Use local docs only (no web research): `pnpm action docs-search --query " | Action | Purpose | | --- | --- | -| `manage-event-draft` | Create, edit, or delete unsent invite drafts; never sends invitations | +| `manage-event-draft` | Manage unsent invite drafts; outside agents need approval for delete-all; never sends invitations | | `list-events` | Read a schedule with account and source coverage | | `create-event` | Create on an owned Google account's primary calendar | | `update-event` | Edit an event using its id and accountEmail | diff --git a/templates/calendar/actions/manage-event-draft.ts b/templates/calendar/actions/manage-event-draft.ts index 77b1b878445..bc25463b3fe 100644 --- a/templates/calendar/actions/manage-event-draft.ts +++ b/templates/calendar/actions/manage-event-draft.ts @@ -1,4 +1,4 @@ -import { defineAction, embedApp } from "@agent-native/core"; +import { defineAction, embedApp, isOutsideAgentCall } from "@agent-native/core"; import { readAppState, writeAppState, @@ -60,6 +60,8 @@ function setIfPresent( export default defineAction({ mcpTool: true, + needsApproval: (args, ctx) => + args.action === "delete-all" && isOutsideAgentCall(ctx), mcpAnnotations: { readOnlyHint: false, destructiveHint: true, diff --git a/templates/calendar/changelog/2026-10-22-outside-assistants-can-open-invite-drafts-in-calendar-for-re.md b/templates/calendar/changelog/2026-10-22-outside-assistants-can-open-invite-drafts-in-calendar-for-re.md new file mode 100644 index 00000000000..a7e42bf7d79 --- /dev/null +++ b/templates/calendar/changelog/2026-10-22-outside-assistants-can-open-invite-drafts-in-calendar-for-re.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-10-22 +--- + +Outside assistants can open invite drafts in Calendar for review, with approval required before deleting all drafts. diff --git a/templates/calendar/server/lib/calendar-mcp-writes.spec.ts b/templates/calendar/server/lib/calendar-mcp-writes.spec.ts index 82037d5b1a6..3e18880c5d5 100644 --- a/templates/calendar/server/lib/calendar-mcp-writes.spec.ts +++ b/templates/calendar/server/lib/calendar-mcp-writes.spec.ts @@ -1,5 +1,9 @@ import { createRequire } from "node:module"; +import { + actionCallIntent, + preparedCallNeedsApproval, +} from "@agent-native/core/action"; import { createMCPServerForRequest, type MCPConfig, @@ -28,6 +32,7 @@ const mocks = vi.hoisted(() => ({ rsvpEvent: vi.fn(), getUserSetting: vi.fn(), approval: vi.fn(), + deleteDrafts: vi.fn(), })); const { approvalAuth, approvalStore } = vi.hoisted(() => ({ @@ -64,6 +69,12 @@ vi.mock("@agent-native/core/event-bus", () => ({ registerEvent: vi.fn(), })); vi.mock("@agent-native/core/tracking", () => ({ track: vi.fn() })); +vi.mock("@agent-native/core/application-state", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@agent-native/core/application-state") + >()), + deleteAppStateByPrefix: mocks.deleteDrafts, +})); vi.mock("../onboarding.js", () => ({})); vi.mock("../../.generated/actions-registry.js", () => ({ default: {} })); vi.mock("./google-calendar.js", () => ({ @@ -124,6 +135,7 @@ type ListedTool = { async function clientFor( userEmail = ownerEmail, oauthScopes = ["mcp:read", "mcp:write"], + supportsApproval = true, ) { const server = await createMCPServerForRequest( { @@ -149,8 +161,10 @@ async function clientFor( { name: "calendar-write-test", version: "1.0.0" }, { versionNegotiation: { mode: "auto" } }, ); - client.registerCapabilities({ elicitation: { form: {} } }); - client.setRequestHandler("elicitation/create", mocks.approval); + if (supportsApproval) { + client.registerCapabilities({ elicitation: { form: {} } }); + client.setRequestHandler("elicitation/create", mocks.approval); + } closeClients.push(async () => { await client.close(); await server.close(); @@ -180,6 +194,7 @@ describe("Calendar direct MCP writes", () => { mocks.createEvent.mockResolvedValue({ id: "event-1" }); mocks.updateEvent.mockResolvedValue({}); mocks.rsvpEvent.mockResolvedValue(undefined); + mocks.deleteDrafts.mockResolvedValue(3); mocks.approval.mockResolvedValue({ action: "accept", content: { decision: "deny" }, @@ -249,6 +264,61 @@ describe("Calendar direct MCP writes", () => { expect(askAgent).not.toHaveBeenCalled(); }); + it("refuses bulk draft deletion when the MCP host cannot request approval", async () => { + const client = await clientFor(ownerEmail, undefined, false); + const result = await client.callTool({ + name: "manage-event-draft", + arguments: { action: "delete-all" }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain("actionApproval"); + expect(JSON.stringify(result.content)).toContain( + "did not declare the required capability", + ); + expect(mocks.deleteDrafts).not.toHaveBeenCalled(); + }); + + it("limits draft approval to outside bulk deletion", async () => { + for (const action of ["create", "update", "delete", "delete-all"]) { + const call = await actionCallIntent(manageEventDraft, { + action, + id: "draft-test", + }); + if (!call.valid) throw call.error; + expect( + await preparedCallNeedsApproval(manageEventDraft, call, { + caller: "tool", + }), + ).toBe(false); + expect( + await preparedCallNeedsApproval(manageEventDraft, call, { + caller: "mcp", + }), + ).toBe(action === "delete-all"); + } + }); + + it("deletes all drafts only after the MCP client approves the call", async () => { + mocks.approval.mockResolvedValue({ + action: "accept", + content: { decision: "approve" }, + }); + const client = await clientFor(); + const result = await client.callTool({ + name: "manage-event-draft", + arguments: { action: "delete-all" }, + }); + expect(result.isError).not.toBe(true); + expect(mocks.approval).toHaveBeenCalledOnce(); + expect(mocks.deleteDrafts).toHaveBeenCalledWith("calendar-draft-"); + expect(result.content).toEqual([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Deleted 3 calendar event draft(s)"), + }), + ]); + }); + it.each([undefined, "single", "all"] as const)( "RSVPs with scope %s without delegating", async (scope) => { diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index 1761911100c..3a178536fef 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -34,7 +34,7 @@ Every action has a schema; use `tool-search` for comments, sharing, Collections, | `view-screen` / `navigate` / `refresh-list` | Read context, move UI, refresh after out-of-band writes | | `list-documents` / `search-documents` / `get-document` / `pull-document` | Browse metadata, search, read, or flush live state before reading | | `get-blocks-field-word-count` | Count one exact collection field; omit `propertyId` for primary body | -| `share-local-file-document` | Create/refresh a shareable copy, possibly already shared; only direct MCP/A2A calls need approval | +| `share-local-file-document` | Create/refresh a shareable copy, possibly already shared; outside-agent calls need approval | | `create-document` / `edit-document` / `update-document` / `delete-document` | Create, revision-edit, update, or move a page and children to Trash | | `import-content` / `undo-content-import` | Import Markdown as pages; undo trashes unedited imports | | `list-content-trash` / `get-trashed-document` | Find/read authorized Trash without restoring | diff --git a/templates/content/actions/mcp-action-contract.spec.ts b/templates/content/actions/mcp-action-contract.spec.ts index 8c830fc1225..8cdb2c09643 100644 --- a/templates/content/actions/mcp-action-contract.spec.ts +++ b/templates/content/actions/mcp-action-contract.spec.ts @@ -1,3 +1,7 @@ +import { + actionCallIntent, + preparedCallNeedsApproval, +} from "@agent-native/core/action"; import { describe, expect, it } from "vitest"; import { mcpToolInputSchema } from "../../../packages/core/src/mcp/tool-input-schema.js"; @@ -102,6 +106,44 @@ describe("Content action-owned agent catalogs", () => { "manage-content-database-migration": manageContentDatabaseMigration, }; + it("requires approval for outside calls while preserving ordinary in-app tools", async () => { + const call = await actionCallIntent(shareLocalFileDocument, { + id: "local-file-test", + }); + if (!call.valid) throw call.error; + for (const [ctx, expected] of [ + [{ caller: "frontend" }, false], + [{ caller: "tool" }, false], + [{ caller: "http" }, true], + [{ caller: "cli" }, true], + [{ caller: "mcp-widget" }, true], + [{ caller: "mcp-widget-write" }, true], + [{ caller: "automation" }, true], + [{ caller: "mcp" }, true], + [{ caller: "webmcp" }, true], + [{ caller: "a2a" }, true], + [{ caller: "tool", networkProtocol: "mcp" }, true], + [{ caller: "tool", networkProtocol: "a2a" }, true], + [{ caller: "tool", networkProtocol: "provider-api" }, true], + [ + { + caller: "tool", + automation: { + triggerId: "trigger-test", + triggerName: "Test automation", + policyId: "policy-test", + }, + }, + true, + ], + ] as const) { + expect( + await preparedCallNeedsApproval(shareLocalFileDocument, call, ctx), + JSON.stringify(ctx), + ).toBe(expected); + } + }); + it("owns compact MCP membership beside each directly callable action", () => { for (const action of Object.values(directMcpActions)) { expect(action.mcpTool).toBe(true); diff --git a/templates/content/actions/share-local-file-document.ts b/templates/content/actions/share-local-file-document.ts index aea4c11fa10..7b79263f567 100644 --- a/templates/content/actions/share-local-file-document.ts +++ b/templates/content/actions/share-local-file-document.ts @@ -1,4 +1,4 @@ -import { defineAction, embedApp } from "@agent-native/core"; +import { defineAction, embedApp, isOutsideAgentCall } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { parseIconValue, serializeIconValue } from "@agent-native/core/icons"; import { buildDeepLink } from "@agent-native/core/server"; @@ -76,8 +76,7 @@ function serializeDocument(row: typeof schema.documents.$inferSelect) { export default defineAction({ mcpTool: true, - // Direct outside calls need approval; the app agent (including ask_app) uses "tool". - needsApproval: (_args, ctx) => ctx?.caller === "mcp" || ctx?.caller === "a2a", + needsApproval: (_args, ctx) => isOutsideAgentCall(ctx), mcpAnnotations: { readOnlyHint: false, destructiveHint: true, diff --git a/templates/content/changelog/2026-10-22-outside-assistants-can-open-shareable-local-file-copies-in-c.md b/templates/content/changelog/2026-10-22-outside-assistants-can-open-shareable-local-file-copies-in-c.md new file mode 100644 index 00000000000..e069456ce85 --- /dev/null +++ b/templates/content/changelog/2026-10-22-outside-assistants-can-open-shareable-local-file-copies-in-c.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-10-22 +--- + +Outside assistants can open shareable local-file copies in Content, with approval before creating or refreshing the copy. diff --git a/templates/design/AGENTS.md b/templates/design/AGENTS.md index 3d5b8ce71a9..df5b0a26b3b 100644 --- a/templates/design/AGENTS.md +++ b/templates/design/AGENTS.md @@ -58,7 +58,7 @@ Find docs: `pnpm action docs-search --query ""`; read: `pnpm action docs- | `prepare-native-scene-export` | Pin scene for PNG | | `get-native-render-contexts` / `request-native-local-export` / `get-native-local-export` | Request foreground native PNG; inspect attachment | | `generate-screens` | Plan screens | -| `export-png` / `export-coding-handoff` | PNG/code; only direct MCP/A2A handoff calls need approval (7-day bearer URLs) | +| `export-png` / `export-coding-handoff` | PNG/code; outside-agent handoff calls need approval (7-day bearer URLs) | | `export-html` | Export standalone HTML | | `export-zip` | Export ZIP | | `export-design-as-figma-svg` | Export Figma SVG | diff --git a/templates/design/actions/export-coding-handoff.spec.ts b/templates/design/actions/export-coding-handoff.spec.ts index 78d84a3c6ce..941ad7da424 100644 --- a/templates/design/actions/export-coding-handoff.spec.ts +++ b/templates/design/actions/export-coding-handoff.spec.ts @@ -1,3 +1,7 @@ +import { + actionCallIntent, + preparedCallNeedsApproval, +} from "@agent-native/core/action"; import { describe, expect, it, vi } from "vitest"; vi.mock("@agent-native/core/server", () => ({ @@ -37,6 +41,42 @@ vi.mock("../server/lib/design-snapshot.js", () => ({ import action from "./export-coding-handoff.js"; describe("export-coding-handoff", () => { + it("requires approval for outside calls while preserving ordinary in-app tools", async () => { + const call = await actionCallIntent(action, { id: "design-test" }); + if (!call.valid) throw call.error; + for (const [ctx, expected] of [ + [{ caller: "frontend" }, false], + [{ caller: "tool" }, false], + [{ caller: "http" }, true], + [{ caller: "cli" }, true], + [{ caller: "mcp-widget" }, true], + [{ caller: "mcp-widget-write" }, true], + [{ caller: "automation" }, true], + [{ caller: "mcp" }, true], + [{ caller: "webmcp" }, true], + [{ caller: "a2a" }, true], + [{ caller: "tool", networkProtocol: "mcp" }, true], + [{ caller: "tool", networkProtocol: "a2a" }, true], + [{ caller: "tool", networkProtocol: "provider-api" }, true], + [ + { + caller: "tool", + automation: { + triggerId: "trigger-test", + triggerName: "Test automation", + policyId: "policy-test", + }, + }, + true, + ], + ] as const) { + expect( + await preparedCallNeedsApproval(action, call, ctx), + JSON.stringify(ctx), + ).toBe(expected); + } + }); + it("is available in the compact MCP Apps catalog", () => { expect(action.mcpApp?.compactCatalog).toBe(true); expect(action.mcpApp?.resource.title).toBe("Design agent handoff"); diff --git a/templates/design/actions/export-coding-handoff.ts b/templates/design/actions/export-coding-handoff.ts index 250ac2e90e1..8fb7fb670b4 100644 --- a/templates/design/actions/export-coding-handoff.ts +++ b/templates/design/actions/export-coding-handoff.ts @@ -1,4 +1,4 @@ -import { defineAction, embedApp } from "@agent-native/core"; +import { defineAction, embedApp, isOutsideAgentCall } from "@agent-native/core"; import { signShortLivedToken, buildDeepLink, @@ -33,8 +33,7 @@ function designDeepLink(designId: string): string { } export default defineAction({ - // Direct outside calls need approval; the app agent (including ask_app) uses "tool". - needsApproval: (_args, ctx) => ctx?.caller === "mcp" || ctx?.caller === "a2a", + needsApproval: (_args, ctx) => isOutsideAgentCall(ctx), mcpAnnotations: { readOnlyHint: false, destructiveHint: false, diff --git a/templates/design/changelog/2026-10-22-outside-assistants-can-generate-screens-and-open-coding-hand.md b/templates/design/changelog/2026-10-22-outside-assistants-can-generate-screens-and-open-coding-hand.md new file mode 100644 index 00000000000..da8cac8b1ae --- /dev/null +++ b/templates/design/changelog/2026-10-22-outside-assistants-can-generate-screens-and-open-coding-hand.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-10-22 +--- + +Outside assistants can generate screens and open coding handoffs in Design, with approval before creating shareable code links. diff --git a/templates/slides-2/changelog/2026-10-22-outside-assistants-can-clone-a-slide-from-existing-context-a.md b/templates/slides-2/changelog/2026-10-22-outside-assistants-can-clone-a-slide-from-existing-context-a.md new file mode 100644 index 00000000000..60e2559f4d7 --- /dev/null +++ b/templates/slides-2/changelog/2026-10-22-outside-assistants-can-clone-a-slide-from-existing-context-a.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-10-22 +--- + +Outside assistants can clone a slide from existing context and open the copy in the Slides editor. diff --git a/templates/slides/changelog/2026-10-22-outside-assistants-can-clone-a-slide-from-existing-context-a.md b/templates/slides/changelog/2026-10-22-outside-assistants-can-clone-a-slide-from-existing-context-a.md new file mode 100644 index 00000000000..60e2559f4d7 --- /dev/null +++ b/templates/slides/changelog/2026-10-22-outside-assistants-can-clone-a-slide-from-existing-context-a.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-10-22 +--- + +Outside assistants can clone a slide from existing context and open the copy in the Slides editor.