diff --git a/README.md b/README.md index a9aeb748..2e947c97 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ Figma also provides official [remote and desktop MCP servers](https://developers With the TemPad Dev panel open and MCP enabled, the MCP server exposes: - `get_code`: High-fidelity JSX/Vue + TailwindCSS code output by default, plus attached assets and the codegen preset/config used. -- `get_structure`: A structural outline (ids, types, geometry) for the current selection. +- `get_structure`: A structural outline (ids, types, geometry) for one or more nodes in the current selection. - Binary assets are returned as metadata + HTTP download URLs (`asset.url`) in tool responses. Asset MCP resources are not exposed. ### Setup guide diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 6a6e63cd..2ba53936 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -209,7 +209,7 @@ Figma 也提供官方的 [remote 与 desktop MCP server](https://developers.figm 打开 TemPad Dev 面板并启用 MCP 后,MCP 服务器会暴露以下能力: - `get_code`:默认输出高保真的 JSX/Vue + TailwindCSS 代码,同时包含相关资源以及使用的 codegen 预设和配置。 -- `get_structure`:当前选中节点的结构信息(id、类型、几何数据)。 +- `get_structure`:当前选中的一个或多个节点的结构信息(id、类型、几何数据)。 - 二进制资源会通过工具响应中的元数据 + HTTP 下载地址(`asset.url`)提供;MCP 不再暴露 asset 资源模板。 ### 配置指南 diff --git a/agent-plugins/tempad-dev/skills/figma-design-to-code/SKILL.md b/agent-plugins/tempad-dev/skills/figma-design-to-code/SKILL.md index a65e8e30..84658dcf 100644 --- a/agent-plugins/tempad-dev/skills/figma-design-to-code/SKILL.md +++ b/agent-plugins/tempad-dev/skills/figma-design-to-code/SKILL.md @@ -136,12 +136,19 @@ ask the user instead of inferring it. ### 2. Fetch the top-level design snapshot -Call `tempad-dev:get_code` first. +If multiple top-level designs are selected, call `tempad-dev:get_structure` +with `options.depth: 1` to enumerate the selected roots, then call +`tempad-dev:get_code` separately with each returned root `nodeId`. Use this +multi-root structure response only for selection discovery and hierarchy, not +as style evidence. + +For each single design root, call `tempad-dev:get_code` first. Use these defaults: - `resolveTokens: false` -- pass `nodeId` only when the user provided one; otherwise use the current +- pass `nodeId` when the user provided one or when iterating roots returned by + a multi-selection `get_structure` call; otherwise use the current single selection - set `preferredLang` to match the project target, such as `jsx` or `vue` diff --git a/docs/extension/mcp-context-strategy.md b/docs/extension/mcp-context-strategy.md index 774312a8..061639be 100644 --- a/docs/extension/mcp-context-strategy.md +++ b/docs/extension/mcp-context-strategy.md @@ -19,6 +19,7 @@ This document records the current context-control strategy for TemPad Dev MCP ou rendering. Other overflow causes still reuse full-tree context for correctness. - Only fail fast when a usable shell cannot be generated. 2. `get_structure` keeps existing API but output is compacted by default. + - When `nodeId` is omitted, accept one or more visible roots from the current selection. - Limit total nodes. - Normalize/trim long names. - Round geometry values. diff --git a/docs/skill/rationale.md b/docs/skill/rationale.md index 97d7d3ec..41026c29 100644 --- a/docs/skill/rationale.md +++ b/docs/skill/rationale.md @@ -70,6 +70,9 @@ TemPad Dev `get_code` is treated as the primary design-evidence source. `get_structure` is only a structural aid for hierarchy, geometry, overlap, and scope recovery. +The only ordering exception is a current multi-selection: use `get_structure` +to enumerate its root node ids, then call `get_code` separately for each root. + This prevents the agent from reconstructing detailed UI from structural hints alone. diff --git a/packages/extension/mcp/runtime.ts b/packages/extension/mcp/runtime.ts index 1ceb5994..9fae17cb 100644 --- a/packages/extension/mcp/runtime.ts +++ b/packages/extension/mcp/runtime.ts @@ -47,6 +47,21 @@ function resolveSingleNode(nodeId?: string): SceneNode { return selection.value[0] } +function resolveVisibleNodes(nodeId?: string): SceneNode[] { + if (nodeId) { + return [resolveSingleNode(nodeId)] + } + + if (!selection.value.length || selection.value.some((node) => !node.visible)) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.INVALID_SELECTION, + 'Select one or more visible nodes (or provide nodeId) to proceed.' + ) + } + + return [...selection.value] +} + async function handleGetCode(args?: GetCodeParametersInput): Promise { return dispatchGetCode(args) } @@ -88,9 +103,9 @@ async function handleGetScreenshot( async function handleGetStructure(args?: GetStructureParametersInput): Promise { const { nodeId, options } = args ?? {} - const root = resolveSingleNode(nodeId) + const roots = resolveVisibleNodes(nodeId) const depth = options?.depth - return runGetStructure([root], depth) + return runGetStructure(roots, depth) } export type MCPHandlers = { diff --git a/packages/extension/tests/mcp/runtime.test.ts b/packages/extension/tests/mcp/runtime.test.ts index 3a1e2779..97a75a36 100644 --- a/packages/extension/tests/mcp/runtime.test.ts +++ b/packages/extension/tests/mcp/runtime.test.ts @@ -174,7 +174,7 @@ describe('mcp/runtime', () => { }) }) - it('throws coded error for invalid current selection (empty or invisible)', async () => { + it('throws coded error for invalid get_code selection (empty, invisible or multiple)', async () => { setFigmaGetNodeById(null) const runtime = await importRuntime() @@ -183,6 +183,11 @@ describe('mcp/runtime', () => { code: TEMPAD_MCP_ERROR_CODES.INVALID_SELECTION }) + mocks.selection.value = [createSceneNode('first'), createSceneNode('second')] + await expect(runtime.MCP_TOOL_HANDLERS.get_code()).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.INVALID_SELECTION + }) + mocks.selection.value = [createSceneNode('hidden', false)] await expect(runtime.MCP_TOOL_HANDLERS.get_code()).rejects.toMatchObject({ code: TEMPAD_MCP_ERROR_CODES.INVALID_SELECTION @@ -249,4 +254,32 @@ describe('mcp/runtime', () => { await runtime.MCP_TOOL_HANDLERS.get_structure() expect(mocks.runGetStructure).toHaveBeenLastCalledWith([node], undefined) }) + + it('routes all visible selected nodes to get_structure', async () => { + const first = createSceneNode('first') + const second = createSceneNode('second') + mocks.selection.value = [first, second] + setFigmaGetNodeById(null) + mocks.runGetStructure.mockResolvedValue({ roots: [] }) + + const runtime = await importRuntime() + await runtime.MCP_TOOL_HANDLERS.get_structure({ options: { depth: 1 } }) + + expect(mocks.runGetStructure).toHaveBeenCalledWith([first, second], 1) + }) + + it('throws coded error when get_structure selection is empty or contains an invisible node', async () => { + setFigmaGetNodeById(null) + const runtime = await importRuntime() + + mocks.selection.value = [] + await expect(runtime.MCP_TOOL_HANDLERS.get_structure()).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.INVALID_SELECTION + }) + + mocks.selection.value = [createSceneNode('visible'), createSceneNode('hidden', false)] + await expect(runtime.MCP_TOOL_HANDLERS.get_structure()).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.INVALID_SELECTION + }) + }) }) diff --git a/packages/extension/tests/mcp/tools/structure.test.ts b/packages/extension/tests/mcp/tools/structure.test.ts index e6afd8a9..e1dbc544 100644 --- a/packages/extension/tests/mcp/tools/structure.test.ts +++ b/packages/extension/tests/mcp/tools/structure.test.ts @@ -46,6 +46,25 @@ describe('mcp/tools/structure', () => { expect(buildSemanticTree).toHaveBeenCalledWith([], { depthLimit: 3 }) }) + it('preserves separate outlines for multiple selected roots', () => { + const roots = [ + { id: 'node-1', visible: true }, + { id: 'node-2', visible: true } + ] as unknown as SceneNode[] + vi.mocked(buildSemanticTree).mockReturnValue({ + roots: [{ id: 'semantic-1' }, { id: 'semantic-2' }] + } as unknown as ReturnType) + vi.mocked(semanticTreeToOutline).mockReturnValue([ + { id: 'outline-1', name: 'First', type: 'FRAME', x: 0, y: 0, width: 100, height: 100 }, + { id: 'outline-2', name: 'Second', type: 'FRAME', x: 200, y: 0, width: 100, height: 100 } + ]) + + const result = handleGetStructure(roots, 1) + + expect(buildSemanticTree).toHaveBeenCalledWith(roots, { depthLimit: 1 }) + expect(result.roots.map(({ id }) => id)).toEqual(['outline-1', 'outline-2']) + }) + it('compacts large outlines to keep structure output small', () => { vi.mocked(buildSemanticTree).mockReturnValue({ roots: [] } as unknown as ReturnType< typeof buildSemanticTree diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 8ca7350b..d17a4365 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -20,7 +20,7 @@ For agent-specific setup, open TemPad Dev's **Preferences → Agent integration Supported tools/resources: - `get_code`: Tailwind-first JSX/Vue markup plus assets and token references. -- `get_structure`: Hierarchy/geometry outline for the selection. +- `get_structure`: Hierarchy/geometry outline for one or more selected nodes. Notes: diff --git a/packages/mcp-server/README.zh-Hans.md b/packages/mcp-server/README.zh-Hans.md index c1c0553c..daf8166d 100644 --- a/packages/mcp-server/README.zh-Hans.md +++ b/packages/mcp-server/README.zh-Hans.md @@ -18,7 +18,7 @@ 支持的工具和资源: - `get_code`:以 Tailwind 优先的 JSX/Vue 标记输出,并附带资源和变量引用。 -- `get_structure`:当前选中节点的层级/几何结构信息。 +- `get_structure`:当前选中的一个或多个节点的层级/几何结构信息。 说明: diff --git a/packages/mcp-server/src/instructions.md b/packages/mcp-server/src/instructions.md index c622743d..4598effa 100644 --- a/packages/mcp-server/src/instructions.md +++ b/packages/mcp-server/src/instructions.md @@ -7,7 +7,7 @@ Rules: - Never output any `data-hint-*` attributes from tool outputs (hints only). - If `get_code` warns `depth-cap`, keep the returned parent code as composition evidence and use returned `data-hint-id` values to choose narrower `get_code` follow-ups. - If `get_code` warns `shell`, read the inline code comment for omitted direct child ids, then call `get_code` for those ids in order and fill the results back into the returned shell. -- Use `get_structure` only to resolve layout/overlap uncertainty; do not derive numeric values from images. +- Use `get_structure` only to enumerate multi-selected roots or resolve layout/overlap uncertainty; do not derive numeric values from images. - Tokens: `get_code.tokens` keys are canonical names (`--...`). Multi‑mode values use `${collectionName}:${modeName}`. Nodes may hint per-node overrides via `data-hint-variable-mode="Collection=Mode;..."`. - Vectors: `vectorMode=smart` is the default. Treat the emitted markup as the source of truth for the current response; vector code is emitted as `` placeholders, but if asset upload fails after export the tool may inline the SVG as a fallback to preserve source of truth. - Themeable vectors: `themeable=true` means the SVG can safely adopt one contextual color channel. In `smart` mode, that color is typically already evidenced on the emitted `svg` root markup for the placeholder. It does not mean the SVG exposes multiple independent color parameters. diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index b8e2c0bc..797e332c 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -87,7 +87,10 @@ const CONNECTIVITY_TROUBLESHOOTING_LINES = [ '- Keep the Figma tab active/foreground while using the MCP server.' ] -const SELECTION_TROUBLESHOOTING_LINE = 'Tip: Select exactly one visible node, or pass nodeId.' +const SINGLE_SELECTION_TROUBLESHOOTING_LINE = + 'Tip: Select exactly one visible node, or pass nodeId.' +const MULTI_SELECTION_TROUBLESHOOTING_LINE = + 'Tip: Select one or more visible nodes, or pass nodeId.' function getRecordProperty(record: unknown, key: string): unknown { if (!record || typeof record !== 'object') { @@ -138,7 +141,7 @@ export const TOOL_DEFS = [ extTool({ name: 'get_structure', description: - 'Get a compact structural + geometry outline for nodeId/current single selection to understand hierarchy and layout intent.', + 'Get a compact structural + geometry outline for nodeId or all visible nodes in the current selection. Use multi-selection to enumerate design roots before calling get_code once per root nodeId.', parameters: GetStructureParametersSchema, target: 'extension', format: createStructureToolResponse @@ -181,7 +184,7 @@ function createToolErrorResponse(toolName: string, error: unknown): CallToolResu const message = extractToolErrorMessage(error) const code = extractToolErrorCode(error) const codeLabel = code ? ` [${code}]` : '' - const troubleshooting = buildTroubleshootingText(code, message) + const troubleshooting = buildTroubleshootingText(toolName, code, message) return { isError: true, @@ -194,7 +197,11 @@ function createToolErrorResponse(toolName: string, error: unknown): CallToolResu } } -function buildTroubleshootingText(code: TempadMcpErrorCode | undefined, message: string): string { +function buildTroubleshootingText( + toolName: string, + code: TempadMcpErrorCode | undefined, + message: string +): string { const help: string[] = [] if (isConnectivityToolError(code, message)) { @@ -202,7 +209,11 @@ function buildTroubleshootingText(code: TempadMcpErrorCode | undefined, message: } if (isSelectionToolError(code, message)) { - help.push(SELECTION_TROUBLESHOOTING_LINE) + help.push( + toolName === 'get_structure' + ? MULTI_SELECTION_TROUBLESHOOTING_LINE + : SINGLE_SELECTION_TROUBLESHOOTING_LINE + ) } return help.length ? `\n\n${help.join('\n')}` : '' @@ -221,6 +232,7 @@ function isSelectionToolError(code: TempadMcpErrorCode | undefined, message: str return ( (code ? SELECTION_ERROR_CODES.has(code) : false) || /select exactly one visible node/i.test(message) || + /select one or more visible nodes/i.test(message) || /no visible node found/i.test(message) ) } diff --git a/packages/mcp-server/tests/tools.test.ts b/packages/mcp-server/tests/tools.test.ts index 26f8babf..cb46d854 100644 --- a/packages/mcp-server/tests/tools.test.ts +++ b/packages/mcp-server/tests/tools.test.ts @@ -195,6 +195,14 @@ describe('tools response helpers', () => { expect(textContent(selectionError.content[0])).toContain('[INVALID_SELECTION]') expect(textContent(selectionError.content[0])).toContain('Tip: Select exactly one visible node') + const structureSelectionError = createToolErrorResponse('get_structure', { + code: TEMPAD_MCP_ERROR_CODES.INVALID_SELECTION, + message: 'Select one or more visible nodes.' + }) + expect(textContent(structureSelectionError.content[0])).toContain( + 'Tip: Select one or more visible nodes' + ) + const unknownError = createToolErrorResponse('get_assets', 42) expect(unknownError.isError).toBe(true) expect(textContent(unknownError.content[0])).toBe( diff --git a/packages/shared/src/mcp/tools.ts b/packages/shared/src/mcp/tools.ts index d42c7bf3..45a90117 100644 --- a/packages/shared/src/mcp/tools.ts +++ b/packages/shared/src/mcp/tools.ts @@ -114,7 +114,7 @@ export const GetStructureParametersSchema = z.object({ nodeId: z .string() .describe( - 'Optional node id to outline; defaults to the current single selection. Useful when auto-layout hints are none/inferred or you need explicit geometry for refactors.' + 'Optional node id to outline; defaults to all visible nodes in the current selection. Useful for enumerating multiple selected design roots or confirming hierarchy and geometry.' ) .optional(), options: z diff --git a/skill/SKILL.md b/skill/SKILL.md index a65e8e30..84658dcf 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -136,12 +136,19 @@ ask the user instead of inferring it. ### 2. Fetch the top-level design snapshot -Call `tempad-dev:get_code` first. +If multiple top-level designs are selected, call `tempad-dev:get_structure` +with `options.depth: 1` to enumerate the selected roots, then call +`tempad-dev:get_code` separately with each returned root `nodeId`. Use this +multi-root structure response only for selection discovery and hierarchy, not +as style evidence. + +For each single design root, call `tempad-dev:get_code` first. Use these defaults: - `resolveTokens: false` -- pass `nodeId` only when the user provided one; otherwise use the current +- pass `nodeId` when the user provided one or when iterating roots returned by + a multi-selection `get_structure` call; otherwise use the current single selection - set `preferredLang` to match the project target, such as `jsx` or `vue`