diff --git a/packages/core/src/mcp/directory-template-profiles.spec.ts b/packages/core/src/mcp/directory-template-profiles.spec.ts index 32196757aa5..9aa863af829 100644 --- a/packages/core/src/mcp/directory-template-profiles.spec.ts +++ b/packages/core/src/mcp/directory-template-profiles.spec.ts @@ -69,6 +69,11 @@ const templateProfiles = [ { appId: "content", profile: contentProfile }, ] as const; +const registryProfiles = [ + ...templateProfiles, + { appId: "slides-2", profile: slides2Profile }, +] as const; + // Framework actions the runtime merges into every app (mergeCoreSharingActions // and the review kit) that a profile binds to widget grants, so they have no // file under the template's actions/. @@ -92,6 +97,12 @@ const sharedActionsByApp: Record> = { "unshare-resource": unshareResource, "set-resource-visibility": setResourceVisibility, }, + "slides-2": { + "list-resource-shares": listResourceShares, + "share-resource": shareResource, + "unshare-resource": unshareResource, + "set-resource-visibility": setResourceVisibility, + }, }; function externalMcpActions( @@ -111,10 +122,9 @@ async function loadTemplateActions(appId: string) { path.join(projectRoot, ".generated/actions-registry.ts"), "utf8", ); - const profile = - appId === "slides-2" - ? slides2Profile - : templateProfiles.find((profile) => profile.appId === appId)?.profile; + const profile = registryProfiles.find( + (profile) => profile.appId === appId, + )?.profile; if (!profile) throw new Error(`Unknown ChatGPT directory template ${appId}.`); const toolNames = profile.connectorCatalog; const sharedActions = @@ -122,6 +132,7 @@ async function loadTemplateActions(appId: string) { const loadNames = [ ...new Set([ ...toolNames, + ...Object.keys(profile.widgetTargets ?? {}), ...Object.keys(profile.widgetReadActionArguments ?? {}), ...Object.keys(profile.widgetWriteActionArguments ?? {}), ]), @@ -311,6 +322,104 @@ describe("ChatGPT directory template profiles", () => { ACTION_REGISTRY_TEST_TIMEOUT_MS, ); + it.each(registryProfiles)( + "$appId validates ordinary targets against the real registry without expanding directory tools", + async ({ appId, profile }) => { + const { actions, productionActions } = await loadTemplateActions(appId); + const { directoryProfile } = resolveAgentChatMcpOptions({ + mcp: { directoryProfile: profile }, + }); + const config = { + name: `agent-native-${appId}`, + appId, + description: "Ordinary card and directory compatibility", + catalogMode: "directory" as const, + connectorCatalog: profile.connectorCatalog, + widgetDomain: profile.widgetDomain, + actions: productionActions, + productionActions, + directoryProfile, + widgetReadActions: selectMcpDirectoryWidgetReadActions( + directoryProfile, + actions, + ), + widgetWriteActions: selectMcpDirectoryWidgetWriteActions( + directoryProfile, + actions, + ), + }; + expect(() => validateMcpDirectoryProfile(config)).not.toThrow(); + expect(() => + validateMcpDirectoryProfile({ + ...config, + directoryProfile: { + ...directoryProfile!, + widgetTargets: { + ...profile.widgetTargets, + "unknown-card": () => ({ + targetPath: "/unknown", + resourceIds: {}, + }), + }, + }, + }), + ).toThrow(/widget target "unknown-card"/); + + for (const name of Object.keys(profile.widgetTargets).filter( + (name) => !profile.connectorCatalog.includes(name), + )) { + expect(productionActions[name]?.mcpApp?.resource).toBeDefined(); + const optedOutActions = { + ...productionActions, + [name]: { ...productionActions[name]!, mcpTool: false }, + }; + expect(() => + validateMcpDirectoryProfile({ + ...config, + actions: optedOutActions, + productionActions: optedOutActions, + }), + ).toThrow(/widget target/); + expect(() => + validateMcpDirectoryProfile({ + ...config, + externalAgents: { denyActions: [name] }, + }), + ).toThrow(/widget target/); + } + + const server = await createMCPServerForRequest( + config, + { + userEmail: "reviewer@example.test", + }, + { origin: profile.widgetDomain, transport: "http" }, + ); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ + name: "ordinary-target-validation", + version: "1", + }); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + try { + const { tools } = await client.listTools(); + expect( + tools + .filter((tool) => Object.hasOwn(productionActions, tool.name)) + .map((tool) => tool.name) + .sort(), + ).toEqual([...profile.connectorCatalog].sort()); + } finally { + await Promise.all([client.close(), server.close()]); + } + }, + 120_000, + ); + it.each(templateProfiles)( "$appId keeps the full widget enabled at its canonical domain", ({ appId, profile }) => { @@ -1190,7 +1299,9 @@ describe("ChatGPT directory template profiles", () => { ]); try { const { tools } = await client.listTools(); - const widgetTargetNames = Object.keys(profile.widgetTargets).sort(); + const widgetTargetNames = Object.keys(profile.widgetTargets) + .filter((name) => profile.connectorCatalog.includes(name)) + .sort(); const widgetToolNames = tools .filter((tool) => typeof tool._meta?.ui?.resourceUri === "string") .map((tool) => tool.name) diff --git a/packages/core/src/mcp/existing-target-cards.spec.ts b/packages/core/src/mcp/existing-target-cards.spec.ts new file mode 100644 index 00000000000..241e39a1b0a --- /dev/null +++ b/packages/core/src/mcp/existing-target-cards.spec.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; + +import { CHATGPT_DIRECTORY_PROFILE as content } from "../../../../templates/content/server/lib/chatgpt-directory-tools.js"; +import { CHATGPT_DIRECTORY_PROFILE as design } from "../../../../templates/design/server/lib/chatgpt-directory-tools.js"; +import { CHATGPT_DIRECTORY_PROFILE as slides2 } from "../../../../templates/slides-2/server/lib/chatgpt-directory-tools.js"; +import { CHATGPT_DIRECTORY_PROFILE as slides } from "../../../../templates/slides/server/lib/chatgpt-directory-tools.js"; +import { + createMcpDirectoryWidgetWriteCapability, + normalizeMcpDirectoryWidgetReadActionArguments, + normalizeMcpDirectoryWidgetWriteActionArguments, + type McpDirectoryWidgetReadArgument, +} from "../shared/embed-auth.js"; +import type { McpDirectoryWidgetTarget } from "./build-server.js"; + +const cards = [ + { + appId: "design", + tool: "generate-screens", + profile: design, + args: { designId: "design/from-input" }, + result: { designId: "design/one", path: "/untrusted" }, + path: "/design/design%2Fone?editorView=overview", + resourceKey: "designId", + read: "get-design", + write: "update-design", + writeArgs: { title: "Updated" }, + }, + { + appId: "design", + tool: "export-coding-handoff", + profile: design, + args: { id: "design/from-input" }, + result: { designId: "design/one", rawUrl: "https://example.test/raw" }, + path: "/design/design%2Fone", + resourceKey: "designId", + read: "get-design", + write: "update-design", + writeArgs: { title: "Updated" }, + }, + { + appId: "content", + tool: "share-local-file-document", + profile: content, + args: { id: "local-file:source.md" }, + result: { id: "shared/copy" }, + path: "/page/shared%2Fcopy", + resourceKey: "documentId", + read: "get-document", + write: "update-document", + writeArgs: { title: "Updated" }, + }, +]; + +describe("existing-resource inline cards", () => { + it.each(cards)( + "$appId $tool opens the saved resource and bounds its action arguments", + ({ + appId, + tool, + profile, + args, + result, + path, + resourceKey, + read, + write, + writeArgs, + }) => { + const targets = profile.widgetTargets as Record< + string, + ( + args: Record, + result: unknown, + ) => McpDirectoryWidgetTarget | null + >; + expect(targets[tool]).toBeTypeOf("function"); + const target = targets[tool]!(args, result)!; + expect(target.targetPath).toBe(path); + const resourceId = + appId === "content" + ? "shared/copy" + : appId === "design" + ? "design/one" + : "deck/one"; + expect(target.resourceIds[resourceKey]).toBe(resourceId); + expect(target.writeActions).toContain(write); + + const bind = (map: Record) => + Object.fromEntries( + Object.entries(map).map(([key, rule]) => [ + key, + typeof rule === "string" ? target.resourceIds[rule] : rule, + ]), + ); + const readMap = profile.widgetReadActionArguments as Record< + string, + Record + >; + const writeMap = profile.widgetWriteActionArguments as Record< + string, + Record + >; + const resourceUri = `ui://${appId}/existing-target`; + const identity = { appId, resourceUri, userEmail: "editor@example.test" }; + const scope = createMcpDirectoryWidgetWriteCapability({ + ...identity, + resourceIds: target.resourceIds, + expiresAtMs: Date.now() + 60_000, + readActionArguments: { [read]: bind(readMap[read]!) }, + writeActionArguments: { [write]: bind(writeMap[write]!) }, + }); + expect(scope).toBeDefined(); + const normalizeRead = ( + actionName: string, + args: Record, + ) => + normalizeMcpDirectoryWidgetReadActionArguments(scope, { + ...identity, + actionName, + args, + allowedArgumentNames: Object.keys(readMap[read]!), + }); + const normalizeWrite = ( + actionName: string, + args: Record, + ) => + normalizeMcpDirectoryWidgetWriteActionArguments(scope, { + ...identity, + actionName, + args, + allowedArgumentNames: Object.keys(writeMap[write]!), + }); + const readArgs = { id: resourceId }; + const writeIdKey = appId.startsWith("slides") ? "deckId" : "id"; + const allowedWrite = { [writeIdKey]: resourceId, ...writeArgs }; + expect(normalizeRead(read, readArgs)).toEqual(readArgs); + expect(normalizeWrite(write, allowedWrite)).toEqual(allowedWrite); + expect(normalizeRead(read, { id: "other-resource" })).toBeUndefined(); + expect( + normalizeWrite(write, { + ...allowedWrite, + [writeIdKey]: "other-resource", + }), + ).toBeUndefined(); + expect(normalizeRead("list-documents", readArgs)).toBeUndefined(); + expect(normalizeWrite("delete-document", allowedWrite)).toBeUndefined(); + expect( + normalizeRead(read, { ...readArgs, undeclared: true }), + ).toBeUndefined(); + expect( + normalizeWrite(write, { ...allowedWrite, undeclared: true }), + ).toBeUndefined(); + expect(targets[tool]!({}, {})).toBeNull(); + }, + ); + + it("requires authoritative result IDs for every new card", () => { + for (const { profile, tool, args } of cards) { + const targets = profile.widgetTargets as Record< + string, + ( + args: Record, + result: unknown, + ) => McpDirectoryWidgetTarget | null + >; + for (const result of [ + {}, + null, + undefined, + { id: " ", designId: " ", deckId: " " }, + ]) { + expect(targets[tool]!(args, result)).toBeNull(); + } + } + }); + + it("keeps Slides clones on their full-app link until loading is deck-bound", () => { + expect(slides.widgetTargets).not.toHaveProperty("clone-context-slide"); + expect(slides.widgetReadActionArguments).not.toHaveProperty( + "get-deck-access-status", + ); + }); + + it("keeps Slides-2 HTML clones on their full-app link", () => { + expect(slides2.widgetTargets).not.toHaveProperty("clone-context-slide"); + }); + + it("never binds Content sharing to the local source or a missing copy", () => { + expect( + content.widgetTargets["share-local-file-document"]( + { id: "local-file:source.md" }, + {}, + ), + ).toBeNull(); + }); +}); diff --git a/packages/core/src/mcp/server.spec.ts b/packages/core/src/mcp/server.spec.ts index b2ec11c128c..fa857987490 100644 --- a/packages/core/src/mcp/server.spec.ts +++ b/packages/core/src/mcp/server.spec.ts @@ -1025,6 +1025,7 @@ async function directoryWidgetTemplateConfig( const actionNames = [ ...new Set([ ...profile.connectorCatalog, + ...Object.keys(profile.widgetTargets ?? {}), ...Object.keys(profile.widgetReadActionArguments ?? {}), ...Object.keys(profile.widgetWriteActionArguments ?? {}), ]), @@ -1375,6 +1376,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", const actionNames = [ ...new Set([ ...profile.connectorCatalog, + ...Object.keys(profile.widgetTargets ?? {}), ...Object.keys(profile.widgetReadActionArguments ?? {}), ...Object.keys(profile.widgetWriteActionArguments ?? {}), ]), @@ -1848,7 +1850,7 @@ describe("handleMcpRequest — web-standard runtime fallback (no Node req/res)", } finally { consoleInfo.mockRestore(); } - }, 60_000); + }, 120_000); it("logs web-runtime response statuses for directory errors", async () => { const requestLogs: Array> = []; diff --git a/templates/content/changelog/2026-10-22-shareable-local-file-copies-open-inline-with-document-editin.md b/templates/content/changelog/2026-10-22-shareable-local-file-copies-open-inline-with-document-editin.md new file mode 100644 index 00000000000..3b0cdc75504 --- /dev/null +++ b/templates/content/changelog/2026-10-22-shareable-local-file-copies-open-inline-with-document-editin.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-10-22 +--- + +Shareable local-file copies open inline with document editing and sharing controls in compatible agent hosts. diff --git a/templates/content/server/lib/chatgpt-directory-tools.ts b/templates/content/server/lib/chatgpt-directory-tools.ts index 25b90b77a8e..8ece0db393e 100644 --- a/templates/content/server/lib/chatgpt-directory-tools.ts +++ b/templates/content/server/lib/chatgpt-directory-tools.ts @@ -32,6 +32,27 @@ function id(...values: unknown[]): string | null { ); } +function documentWidgetTarget(_args: Record, result: unknown) { + const documentId = id(record(result).id, record(result).documentId); + const spaceId = id(record(result).spaceId); + return documentId + ? { + targetPath: `/page/${encodeURIComponent(documentId)}`, + resourceIds: { + documentId, + resourceType: "document", + ...(spaceId ? { spaceId } : {}), + }, + writeActions: [ + "update-document", + "share-resource", + "unshare-resource", + "set-resource-visibility", + ], + } + : null; +} + type WidgetWriteAuthorizationInput = { toolName: string; args: Record; @@ -70,26 +91,8 @@ export const CHATGPT_DIRECTORY_PROFILE = { } }, widgetTargets: { - "create-document": (_args: Record, result: unknown) => { - const documentId = id(record(result).id, record(result).documentId); - const spaceId = id(record(result).spaceId); - return documentId - ? { - targetPath: `/page/${encodeURIComponent(documentId)}`, - resourceIds: { - documentId, - resourceType: "document", - ...(spaceId ? { spaceId } : {}), - }, - writeActions: [ - "update-document", - "share-resource", - "unshare-resource", - "set-resource-visibility", - ], - } - : null; - }, + "create-document": documentWidgetTarget, + "share-local-file-document": documentWidgetTarget, "create-content-database": ( _args: Record, result: unknown, diff --git a/templates/design/changelog/2026-10-22-generation-sessions-and-coding-handoffs-open-the-design-inli.md b/templates/design/changelog/2026-10-22-generation-sessions-and-coding-handoffs-open-the-design-inli.md new file mode 100644 index 00000000000..7f4ad35370f --- /dev/null +++ b/templates/design/changelog/2026-10-22-generation-sessions-and-coding-handoffs-open-the-design-inli.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-10-22 +--- + +Generation sessions and coding handoffs open the design inline in compatible agent hosts. diff --git a/templates/design/server/lib/chatgpt-directory-tools.ts b/templates/design/server/lib/chatgpt-directory-tools.ts index c26d2841af3..54f0aa76d31 100644 --- a/templates/design/server/lib/chatgpt-directory-tools.ts +++ b/templates/design/server/lib/chatgpt-directory-tools.ts @@ -133,6 +133,22 @@ export const CHATGPT_DIRECTORY_PROFILE = { const designId = id(args.designId, record(result).designId); return designId ? designWidgetTarget(designId) : null; }, + "generate-screens": (_args: Record, result: unknown) => { + const designId = id(record(result).designId); + return designId + ? designWidgetTarget( + designId, + `/design/${encodeURIComponent(designId)}?editorView=overview`, + ) + : null; + }, + "export-coding-handoff": ( + _args: Record, + result: unknown, + ) => { + const designId = id(record(result).designId); + return designId ? designWidgetTarget(designId) : null; + }, }, widgetReadActionArguments: { "get-design-snapshot": { designId: "designId" },