import { defineEventHandler, readBody, setResponseStatus } from 'h3'; import { assignIdsRecursively, buildActionMenu, buildActivityRing, buildAlert, buildAvatar, buildBadge, buildBodyText, buildBottomNav, buildBreadcrumb, buildCalendarGrid, buildCardRow, buildCarouselDots, buildChartBars, buildChartLine, buildChartPie, buildCheckbox, buildChipInput, buildComment, buildCodeBlock, buildColorSwatch, buildDatePicker, buildDivider, buildEmptyChart, buildEmptyState, buildFab, buildFaqItem, buildFormField, buildHeading, buildHeadingV1, buildIconButton, buildIconLabel, buildImagePlaceholder, buildVideoPlaceholder, buildModalShell, buildModalShellV1, buildUploadDropzone, buildOtpInput, buildAttachmentRow, buildChatBubble, buildStatCard, buildSocialLoginRow, buildPricingCard, buildToastV1, buildRangeSlider, buildEmptyChartV1, buildPhoneInput, buildInputWithAction, buildCookieBanner, buildSidebarNav, buildAvatarGroup, buildDataTableRow, buildTag, buildUserCard, buildDrawerShell, buildCombobox, buildToolbar, buildCallout, buildShareRow, buildInlineAction, buildLegendItem, buildInboxMessage, buildProfileHeader, buildSettingRow, buildMemberRow, buildFilterGroup, buildInviteRow, buildActivityLog, buildEventCard, buildStepCard, buildKbd, buildLink, buildListRow, buildMetricComparison, buildMetricRow, buildNavChipRow, buildNotificationRow, buildPagination, buildPrice, buildProgressBar, buildQuoteBlock, buildRadio, buildRatingStars, buildSearchBar, buildSectionHeader, buildSegmentedControl, buildSelect, buildSkeleton, buildStatGrid, buildStatusBadge, buildSpinner, buildStepper, buildSwitch, buildTabs, buildTextarea, buildTextButton, buildTimeline, buildToast, buildTooltip, buildTopNavBar, buildCardRowV1, buildSettingRowV1, buildMemberRowV1, buildActivityLogV1, buildAvatarV1, buildBadgeV1, buildDividerV1, buildBodyTextV1, buildIconLabelV1, buildAlertV1, buildBottomNavV1, buildBreadcrumbV1, buildActivityRingV1, buildCarouselDotsV1, buildActionMenuV1, buildAttachmentRowV1, buildCalendarGridV1, buildAvatarGroupV1, buildCalloutV1, buildChartBarsV1, buildChartLineV1, buildChartPieV1, buildChatBubbleV1, buildCheckboxV1, buildChipInputV1, buildCodeBlockV1, buildColorSwatchV1, buildComboboxV1, buildCommentV1, buildCookieBannerV1, buildDataTableRowV1, buildDatePickerV1, buildDrawerShellV1, buildEmptyStateV1, buildEventCardV1, buildFabV1, buildFaqItemV1, buildFilterGroupV1, buildFormFieldV1, buildIconButtonV1, buildImagePlaceholderV1, buildInboxMessageV1, buildInlineActionV1, buildInputWithActionV1, buildInviteRowV1, buildKbdV1, buildLegendItemV1, buildLinkV1, buildListRowV1, buildMetricComparisonV1, buildMetricRowV1, buildNavChipRowV1, buildNotificationRowV1, buildOtpInputV1, buildPaginationV1, buildPhoneInputV1, buildPriceV1, buildPricingCardV1, buildProfileHeaderV1, buildProgressBarV1, buildQuoteBlockV1, buildRadioV1, buildRangeSliderV1, buildRatingStarsV1, buildSearchBarV1, buildSectionHeaderV1, buildSegmentedControlV1, buildSelectV1, buildShareRowV1, buildSidebarNavV1, buildSkeletonV1, buildSocialLoginRowV1, buildSpinnerV1, buildStatCardV1, buildStatGridV1, buildStatusBadgeV1, buildStepCardV1, buildStepperV1, buildSwitchV1, buildTabsV1, buildTagV1, buildTextButtonV1, buildTextareaV1, buildTimelineV1, buildToolbarV1, buildTooltipV1, buildTopNavBarV1, buildUploadDropzoneV1, buildUserCardV1, buildVideoPlaceholderV1, findNodeInTree, insertNodeInTree, type ElementTree, } from '@zseven-w/pen-core'; import { runBatchDesignDsl } from '@zseven-w/pen-mcp'; import { getSyncDocument, setSyncDocument } from '../../utils/mcp-sync-state'; import { serverLog } from '../../utils/server-logger'; import type { PenDocument, PenNode } from '../../../src/types/pen'; /** * POST /api/mcp/exec-tool — Phase 2 M3 HTTP fallback. * * Accepts either: * - `{ name: 'add_X_v0', arguments: {...} }` — an element-tool call * the browser couldn't match to a client shim (or for which the * caller wants authoritative server-side state) * - `{ dsl: '...' }` — a batch_design DSL string * * For known element tools, the endpoint uses the SAME pen-core * builders the client shim uses, so the tree shape is byte-identical * across paths. The constructed tree is inserted under the * explicitly-named parent (parent_id) or the caller-supplied * default (default_parent_id) or the first page's root. * * For batch_design DSL, the endpoint runs pen-mcp's * `runBatchDesignDsl` against the in-memory sync-state doc — no * file I/O, no post-processing hooks (those live in the pen-mcp * server process). This is the fallback path advertised to the * embedded orchestrator's AI when no covered `add_*_v0` matches * its intent; it must actually execute or the prompt contract is * broken. * * Both paths call `setSyncDocument` to broadcast via SSE and * return the updated doc in the response body so the dispatching * client can `applyExternalDocument` immediately without waiting * for its own SSE to arrive. */ type BuilderFn = (args: unknown) => ElementTree; /** * Server-side builder registry. MUST stay in sync with * `apps/web/src/services/ai/element-tool-shims/index.ts` * (SUPPORTED_EMBEDDED_ELEMENT_TOOLS) — when extending one, extend * the other in the same commit. Both dispatch through pen-core * `buildX` so the tree shape is drift-free; this registry exists * only to gate unknown tool names + own the sync-state mutation. * * Coverage gap: elements.md names 42 element tools (the full * pen-mcp catalog for external MCP clients). Embedded runtime — * shim + this file — covers a subset until more builders are * extracted to pen-core. Until then, unsupported names return 404 * with the canonical supported list and the dispatcher short- * circuits before calling us. */ const SERVER_BUILDERS: Record = { add_card_row_v0: (a) => buildCardRow(a as Parameters[0]), add_metric_row_v0: (a) => buildMetricRow(a as Parameters[0]), add_bottom_nav_v0: (a) => buildBottomNav(a as Parameters[0]), add_section_header_v0: (a) => buildSectionHeader(a as Parameters[0]), add_top_nav_bar_v0: (a) => buildTopNavBar(a as Parameters[0]), add_heading_v0: (a) => buildHeading(a as Parameters[0]), add_heading_v1: (a) => buildHeadingV1(a as Parameters[0]), add_body_text_v0: (a) => buildBodyText(a as Parameters[0]), add_text_button_v0: (a) => buildTextButton(a as Parameters[0]), add_search_bar_v0: (a) => buildSearchBar(a as Parameters[0]), add_list_row_v0: (a) => buildListRow(a as Parameters[0]), add_divider_v0: (a) => buildDivider(a as Parameters[0]), add_badge_v0: (a) => buildBadge(a as Parameters[0]), add_avatar_v0: (a) => buildAvatar(a as Parameters[0]), add_icon_button_v0: (a) => buildIconButton(a as Parameters[0]), add_icon_label_v0: (a) => buildIconLabel(a as Parameters[0]), add_stat_grid_v0: (a) => buildStatGrid(a as Parameters[0]), add_switch_v0: (a) => buildSwitch(a as Parameters[0]), add_checkbox_v0: (a) => buildCheckbox(a as Parameters[0]), add_radio_v0: (a) => buildRadio(a as Parameters[0]), add_tabs_v0: (a) => buildTabs(a as Parameters[0]), add_segmented_control_v0: (a) => buildSegmentedControl(a as Parameters[0]), add_empty_state_v0: (a) => buildEmptyState(a as Parameters[0]), add_alert_v0: (a) => buildAlert(a as Parameters[0]), add_toast_v0: (a) => buildToast(a as Parameters[0]), add_progress_bar_v0: (a) => buildProgressBar(a as Parameters[0]), add_fab_v0: (a) => buildFab(a as Parameters[0]), add_breadcrumb_v0: (a) => buildBreadcrumb(a as Parameters[0]), add_stepper_v0: (a) => buildStepper(a as Parameters[0]), add_form_field_v0: (a) => buildFormField(a as Parameters[0]), add_textarea_v0: (a) => buildTextarea(a as Parameters[0]), add_skeleton_v0: (a) => buildSkeleton(a as Parameters[0]), add_select_v0: (a) => buildSelect(a as Parameters[0]), add_chart_line_v0: (a) => buildChartLine(a as Parameters[0]), add_chart_pie_v0: (a) => buildChartPie(a as Parameters[0]), add_image_placeholder_v0: (a) => buildImagePlaceholder(a as Parameters[0]), add_video_placeholder_v0: (a) => buildVideoPlaceholder(a as Parameters[0]), add_comment_v0: (a) => buildComment(a as Parameters[0]), add_modal_shell_v0: (a) => buildModalShell(a as Parameters[0]), add_status_badge_v0: (a) => buildStatusBadge(a as Parameters[0]), add_spinner_v0: (a) => buildSpinner(a as Parameters[0]), add_tooltip_v0: (a) => buildTooltip(a as Parameters[0]), add_metric_comparison_v0: (a) => buildMetricComparison(a as Parameters[0]), add_notification_row_v0: (a) => buildNotificationRow(a as Parameters[0]), add_nav_chip_row_v0: (a) => buildNavChipRow(a as Parameters[0]), add_activity_ring_v0: (a) => buildActivityRing(a as Parameters[0]), add_rating_stars_v0: (a) => buildRatingStars(a as Parameters[0]), add_carousel_dots_v0: (a) => buildCarouselDots(a as Parameters[0]), add_link_v0: (a) => buildLink(a as Parameters[0]), add_kbd_v0: (a) => buildKbd(a as Parameters[0]), add_price_v0: (a) => buildPrice(a as Parameters[0]), add_quote_block_v0: (a) => buildQuoteBlock(a as Parameters[0]), add_code_block_v0: (a) => buildCodeBlock(a as Parameters[0]), add_color_swatch_v0: (a) => buildColorSwatch(a as Parameters[0]), add_chart_bars_v0: (a) => buildChartBars(a as Parameters[0]), add_timeline_v0: (a) => buildTimeline(a as Parameters[0]), add_calendar_grid_v0: (a) => buildCalendarGrid(a as Parameters[0]), add_pagination_v0: (a) => buildPagination(a as Parameters[0]), add_faq_item_v0: (a) => buildFaqItem(a as Parameters[0]), add_chip_input_v0: (a) => buildChipInput(a as Parameters[0]), add_empty_chart_v0: (a) => buildEmptyChart(a as Parameters[0]), add_action_menu_v0: (a) => buildActionMenu(a as Parameters[0]), add_date_picker_v0: (a) => buildDatePicker(a as Parameters[0]), add_modal_shell_v1: (a) => buildModalShellV1(a as Parameters[0]), add_upload_dropzone_v0: (a) => buildUploadDropzone(a as Parameters[0]), add_otp_input_v0: (a) => buildOtpInput(a as Parameters[0]), add_attachment_row_v0: (a) => buildAttachmentRow(a as Parameters[0]), add_chat_bubble_v0: (a) => buildChatBubble(a as Parameters[0]), add_stat_card_v0: (a) => buildStatCard(a as Parameters[0]), add_social_login_row_v0: (a) => buildSocialLoginRow(a as Parameters[0]), add_pricing_card_v0: (a) => buildPricingCard(a as Parameters[0]), add_toast_v1: (a) => buildToastV1(a as Parameters[0]), add_range_slider_v0: (a) => buildRangeSlider(a as Parameters[0]), add_empty_chart_v1: (a) => buildEmptyChartV1(a as Parameters[0]), add_phone_input_v0: (a) => buildPhoneInput(a as Parameters[0]), add_input_with_action_v0: (a) => buildInputWithAction(a as Parameters[0]), add_cookie_banner_v0: (a) => buildCookieBanner(a as Parameters[0]), add_sidebar_nav_v0: (a) => buildSidebarNav(a as Parameters[0]), add_avatar_group_v0: (a) => buildAvatarGroup(a as Parameters[0]), add_data_table_row_v0: (a) => buildDataTableRow(a as Parameters[0]), add_tag_v0: (a) => buildTag(a as Parameters[0]), add_user_card_v0: (a) => buildUserCard(a as Parameters[0]), add_drawer_shell_v0: (a) => buildDrawerShell(a as Parameters[0]), add_combobox_v0: (a) => buildCombobox(a as Parameters[0]), add_toolbar_v0: (a) => buildToolbar(a as Parameters[0]), add_callout_v0: (a) => buildCallout(a as Parameters[0]), add_share_row_v0: (a) => buildShareRow(a as Parameters[0]), add_inline_action_v0: (a) => buildInlineAction(a as Parameters[0]), add_legend_item_v0: (a) => buildLegendItem(a as Parameters[0]), add_inbox_message_v0: (a) => buildInboxMessage(a as Parameters[0]), add_profile_header_v0: (a) => buildProfileHeader(a as Parameters[0]), add_setting_row_v0: (a) => buildSettingRow(a as Parameters[0]), add_member_row_v0: (a) => buildMemberRow(a as Parameters[0]), add_filter_group_v0: (a) => buildFilterGroup(a as Parameters[0]), add_invite_row_v0: (a) => buildInviteRow(a as Parameters[0]), add_activity_log_v0: (a) => buildActivityLog(a as Parameters[0]), add_event_card_v0: (a) => buildEventCard(a as Parameters[0]), add_step_card_v0: (a) => buildStepCard(a as Parameters[0]), add_card_row_v1: (a) => buildCardRowV1(a as Parameters[0]), add_setting_row_v1: (a) => buildSettingRowV1(a as Parameters[0]), add_member_row_v1: (a) => buildMemberRowV1(a as Parameters[0]), add_activity_log_v1: (a) => buildActivityLogV1(a as Parameters[0]), add_avatar_v1: (a) => buildAvatarV1(a as Parameters[0]), add_badge_v1: (a) => buildBadgeV1(a as Parameters[0]), add_divider_v1: (a) => buildDividerV1(a as Parameters[0]), add_body_text_v1: (a) => buildBodyTextV1(a as Parameters[0]), add_icon_label_v1: (a) => buildIconLabelV1(a as Parameters[0]), add_alert_v1: (a) => buildAlertV1(a as Parameters[0]), add_bottom_nav_v1: (a) => buildBottomNavV1(a as Parameters[0]), add_breadcrumb_v1: (a) => buildBreadcrumbV1(a as Parameters[0]), add_activity_ring_v1: (a) => buildActivityRingV1(a as Parameters[0]), add_carousel_dots_v1: (a) => buildCarouselDotsV1(a as Parameters[0]), add_action_menu_v1: (a) => buildActionMenuV1(a as Parameters[0]), add_attachment_row_v1: (a) => buildAttachmentRowV1(a as Parameters[0]), add_calendar_grid_v1: (a) => buildCalendarGridV1(a as Parameters[0]), add_avatar_group_v1: (a) => buildAvatarGroupV1(a as Parameters[0]), add_callout_v1: (a) => buildCalloutV1(a as Parameters[0]), add_chart_bars_v1: (a) => buildChartBarsV1(a as Parameters[0]), add_chart_line_v1: (a) => buildChartLineV1(a as Parameters[0]), add_chart_pie_v1: (a) => buildChartPieV1(a as Parameters[0]), add_chat_bubble_v1: (a) => buildChatBubbleV1(a as Parameters[0]), add_checkbox_v1: (a) => buildCheckboxV1(a as Parameters[0]), add_chip_input_v1: (a) => buildChipInputV1(a as Parameters[0]), add_code_block_v1: (a) => buildCodeBlockV1(a as Parameters[0]), add_color_swatch_v1: (a) => buildColorSwatchV1(a as Parameters[0]), add_combobox_v1: (a) => buildComboboxV1(a as Parameters[0]), add_comment_v1: (a) => buildCommentV1(a as Parameters[0]), add_cookie_banner_v1: (a) => buildCookieBannerV1(a as Parameters[0]), add_data_table_row_v1: (a) => buildDataTableRowV1(a as Parameters[0]), add_date_picker_v1: (a) => buildDatePickerV1(a as Parameters[0]), add_drawer_shell_v1: (a) => buildDrawerShellV1(a as Parameters[0]), add_empty_state_v1: (a) => buildEmptyStateV1(a as Parameters[0]), add_event_card_v1: (a) => buildEventCardV1(a as Parameters[0]), add_fab_v1: (a) => buildFabV1(a as Parameters[0]), add_faq_item_v1: (a) => buildFaqItemV1(a as Parameters[0]), add_filter_group_v1: (a) => buildFilterGroupV1(a as Parameters[0]), add_form_field_v1: (a) => buildFormFieldV1(a as Parameters[0]), add_icon_button_v1: (a) => buildIconButtonV1(a as Parameters[0]), add_image_placeholder_v1: (a) => buildImagePlaceholderV1(a as Parameters[0]), add_inbox_message_v1: (a) => buildInboxMessageV1(a as Parameters[0]), add_inline_action_v1: (a) => buildInlineActionV1(a as Parameters[0]), add_input_with_action_v1: (a) => buildInputWithActionV1(a as Parameters[0]), add_invite_row_v1: (a) => buildInviteRowV1(a as Parameters[0]), add_kbd_v1: (a) => buildKbdV1(a as Parameters[0]), add_legend_item_v1: (a) => buildLegendItemV1(a as Parameters[0]), add_link_v1: (a) => buildLinkV1(a as Parameters[0]), add_list_row_v1: (a) => buildListRowV1(a as Parameters[0]), add_metric_comparison_v1: (a) => buildMetricComparisonV1(a as Parameters[0]), add_metric_row_v1: (a) => buildMetricRowV1(a as Parameters[0]), add_nav_chip_row_v1: (a) => buildNavChipRowV1(a as Parameters[0]), add_notification_row_v1: (a) => buildNotificationRowV1(a as Parameters[0]), add_otp_input_v1: (a) => buildOtpInputV1(a as Parameters[0]), add_pagination_v1: (a) => buildPaginationV1(a as Parameters[0]), add_phone_input_v1: (a) => buildPhoneInputV1(a as Parameters[0]), add_price_v1: (a) => buildPriceV1(a as Parameters[0]), add_pricing_card_v1: (a) => buildPricingCardV1(a as Parameters[0]), add_profile_header_v1: (a) => buildProfileHeaderV1(a as Parameters[0]), add_progress_bar_v1: (a) => buildProgressBarV1(a as Parameters[0]), add_quote_block_v1: (a) => buildQuoteBlockV1(a as Parameters[0]), add_radio_v1: (a) => buildRadioV1(a as Parameters[0]), add_range_slider_v1: (a) => buildRangeSliderV1(a as Parameters[0]), add_rating_stars_v1: (a) => buildRatingStarsV1(a as Parameters[0]), add_search_bar_v1: (a) => buildSearchBarV1(a as Parameters[0]), add_section_header_v1: (a) => buildSectionHeaderV1(a as Parameters[0]), add_segmented_control_v1: (a) => buildSegmentedControlV1(a as Parameters[0]), add_select_v1: (a) => buildSelectV1(a as Parameters[0]), add_share_row_v1: (a) => buildShareRowV1(a as Parameters[0]), add_sidebar_nav_v1: (a) => buildSidebarNavV1(a as Parameters[0]), add_skeleton_v1: (a) => buildSkeletonV1(a as Parameters[0]), add_social_login_row_v1: (a) => buildSocialLoginRowV1(a as Parameters[0]), add_spinner_v1: (a) => buildSpinnerV1(a as Parameters[0]), add_stat_card_v1: (a) => buildStatCardV1(a as Parameters[0]), add_stat_grid_v1: (a) => buildStatGridV1(a as Parameters[0]), add_status_badge_v1: (a) => buildStatusBadgeV1(a as Parameters[0]), add_step_card_v1: (a) => buildStepCardV1(a as Parameters[0]), add_stepper_v1: (a) => buildStepperV1(a as Parameters[0]), add_switch_v1: (a) => buildSwitchV1(a as Parameters[0]), add_tabs_v1: (a) => buildTabsV1(a as Parameters[0]), add_tag_v1: (a) => buildTagV1(a as Parameters[0]), add_text_button_v1: (a) => buildTextButtonV1(a as Parameters[0]), add_textarea_v1: (a) => buildTextareaV1(a as Parameters[0]), add_timeline_v1: (a) => buildTimelineV1(a as Parameters[0]), add_toolbar_v1: (a) => buildToolbarV1(a as Parameters[0]), add_tooltip_v1: (a) => buildTooltipV1(a as Parameters[0]), add_top_nav_bar_v1: (a) => buildTopNavBarV1(a as Parameters[0]), add_upload_dropzone_v1: (a) => buildUploadDropzoneV1(a as Parameters[0]), add_user_card_v1: (a) => buildUserCardV1(a as Parameters[0]), add_video_placeholder_v1: (a) => buildVideoPlaceholderV1(a as Parameters[0]), }; interface ExecToolBody { name?: string; arguments?: unknown; dsl?: string; /** * Fallback parent id when `arguments.parent_id` is not provided. * Forwarded by the client dispatcher from the orchestrator's subtask * context (`subtask.parentFrameId ?? plan.rootFrame.id`). Without it * rootless payloads would land at page root outside the generation's * scope — mirrors the client-shim `ctx.defaultParentId` contract. */ default_parent_id?: string; } interface ExecToolError { ok: false; error: string; } interface ExecToolSuccess { ok: true; document: PenDocument; /** * Single inserted root id (element-tool path) or the first node * produced by a batch_design run. Kept for legacy callers that * only read one id. */ insertedNodeId: string; /** * All inserted root ids. Element-tool path populates exactly one; * batch_design DSL can populate many (one per top-level I / C / R * binding). Orchestrator uses this to update progress tallies. */ insertedNodeIds: string[]; } export default defineEventHandler(async (event): Promise => { const body = (await readBody(event).catch(() => null)) as ExecToolBody | null; if (!body) { setResponseStatus(event, 400); return { ok: false, error: 'Missing or malformed JSON body' }; } if (body.dsl) { const { doc: currentDsl } = getSyncDocument(); if (!currentDsl) { setResponseStatus(event, 409); return { ok: false, error: 'No live canvas document is currently synced. Open a document before invoking ' + '/api/mcp/exec-tool with a DSL payload.', }; } // Resolve pageId for the DSL executor. `live://canvas` sentinel + // real filePaths are already rejected above; DSL writes always // target the in-memory sync-state (which represents the live // canvas). Reject real filePaths here too for parity with the // element-tool branch. const LIVE_CANVAS_SENTINEL_DSL = 'live://canvas'; const rawDslFilePath = typeof (body as Record).filePath === 'string' ? ((body as Record).filePath as string) : null; if ( rawDslFilePath !== null && rawDslFilePath.length > 0 && rawDslFilePath !== LIVE_CANVAS_SENTINEL_DSL ) { setResponseStatus(event, 501); return { ok: false, error: `filePath="${rawDslFilePath}" is not supported by /api/mcp/exec-tool's DSL path — ` + `this endpoint mutates the in-memory live canvas only. Route through pen-mcp's ` + `stdio / HTTP transport for file-backed DSL.`, }; } const dslPageId = typeof (body as Record).pageId === 'string' && ((body as Record).pageId as string).length > 0 ? ((body as Record).pageId as string) : undefined; // runBatchDesignDsl mutates the cloned doc in place; bindings + // per-line errors are collected and surfaced in the response so // the dispatcher / user sees exactly which ops failed. const nextDsl = structuredClone(currentDsl) as PenDocument; const { results: dslResults, errors: dslErrors } = await runBatchDesignDsl(nextDsl, body.dsl, { pageId: dslPageId, }); if (dslErrors.length > 0) { setResponseStatus(event, 400); return { ok: false, error: `batch_design DSL had ${dslErrors.length} failing operation(s): ` + dslErrors .map((e: { line: string; error: string }) => `${e.line.slice(0, 80)}: ${e.error}`) .join('; '), }; } setSyncDocument(nextDsl); const insertedIds = dslResults .map((r: { nodeId: string }) => r.nodeId) .filter((id: string) => typeof id === 'string' && id.length > 0); const firstId = insertedIds[0] ?? ''; serverLog.info( `[exec-tool] applied batch_design DSL → ${insertedIds.length} op result(s)` + (dslPageId ? ` on page ${dslPageId}` : ''), ); return { ok: true, document: nextDsl, insertedNodeId: firstId, insertedNodeIds: insertedIds, }; } if (!body.name) { setResponseStatus(event, 400); return { ok: false, error: 'Missing "name" (or "dsl") in request body' }; } const builder = SERVER_BUILDERS[body.name]; if (!builder) { setResponseStatus(event, 404); return { ok: false, error: `Element tool "${body.name}" has no server-side builder. Phase 2 M3 currently ` + `covers: ${Object.keys(SERVER_BUILDERS).join(', ')}. Extend SERVER_BUILDERS in ` + `apps/web/server/api/mcp/exec-tool.post.ts (and the matching shim in ` + `apps/web/src/services/ai/element-tool-shims/index.ts) to add more.`, }; } // Split meta fields out of the payload before the builder sees them. // Builder signatures only accept tool-specific params; leaving // parent_id/pageId in would either be silently ignored or rejected // as unknown. Mirrors the client shim wrap() contract. const rawArgs = (body.arguments ?? {}) as Record; const parentId = typeof rawArgs.parent_id === 'string' && rawArgs.parent_id.length > 0 ? rawArgs.parent_id : null; const targetPageId = typeof rawArgs.pageId === 'string' && rawArgs.pageId.length > 0 ? rawArgs.pageId : null; // `live://canvas` is pen-mcp's explicit sentinel for "the live // canvas" (document-manager.ts::resolveDocPath treats it the same // as an undefined filePath). Normalize to null so the non-live- // canvas rejection below only triggers for real file paths. const LIVE_CANVAS_SENTINEL = 'live://canvas'; const rawFilePath = typeof rawArgs.filePath === 'string' && rawArgs.filePath.length > 0 ? rawArgs.filePath : null; const targetFilePath = rawFilePath === LIVE_CANVAS_SENTINEL ? null : rawFilePath; const builderArgs = (() => { const { parent_id: _pid, pageId: _pg, filePath: _fp, ...rest } = rawArgs; return rest; })(); // filePath is only meaningful for pen-mcp's file-backed handlers // (they read/write .op files via document-manager). This endpoint // targets the in-memory live document (mcp-sync-state). Silently // dropping filePath would report success while the named file stays // untouched — surface the mismatch with 501 so the caller routes // through pen-mcp's stdio/HTTP transport instead. if (targetFilePath !== null) { setResponseStatus(event, 501); return { ok: false, error: `filePath="${targetFilePath}" is not supported by /api/mcp/exec-tool — this ` + `endpoint mutates the in-memory live canvas only. Route through pen-mcp's ` + `stdio / HTTP transport (which owns .op file I/O via document-manager), or ` + `omit filePath to target the currently-synced live canvas.`, }; } let tree: ElementTree; try { tree = builder(builderArgs); } catch (err) { setResponseStatus(event, 400); return { ok: false, error: `Builder "${body.name}" rejected arguments: ${err instanceof Error ? err.message : String(err)}`, }; } assignIdsRecursively(tree); const insertedNodeId = String(tree.id); const { doc: current } = getSyncDocument(); if (!current) { setResponseStatus(event, 409); return { ok: false, error: 'No live canvas document is currently synced. Open a document before invoking ' + '/api/mcp/exec-tool.', }; } const next = structuredClone(current) as PenDocument; // Resolve target page: explicit pageId > first page > legacy children[] const pageList = Array.isArray(next.pages) ? next.pages : []; let targetPage: { id?: string; children: PenNode[] } | null = null; if (targetPageId !== null) { const match = pageList.find((p) => p.id === targetPageId); if (!match) { setResponseStatus(event, 404); return { ok: false, error: `pageId "${targetPageId}" not found in the live document.`, }; } targetPage = match; } else if (pageList.length > 0) { targetPage = pageList[0]; } // Resolve target parent: explicit parent_id > caller-supplied // default_parent_id > page root. Explicit parent_id must exist on // the chosen page (not silently inserted at root). default_parent_id // also must exist (caller is committing to a real target frame). const pageChildren: PenNode[] = targetPage ? targetPage.children : ((next.children ?? []) as PenNode[]); if (parentId !== null) { const found = findNodeInTree(pageChildren, parentId); if (!found) { setResponseStatus(event, 404); return { ok: false, error: `parent_id "${parentId}" not found in ${ targetPageId !== null ? `page "${targetPageId}"` : 'the live document' }.`, }; } } const defaultParentId = typeof body.default_parent_id === 'string' && body.default_parent_id.length > 0 ? body.default_parent_id : null; if (parentId === null && defaultParentId !== null) { const defaultParentNode = findNodeInTree(pageChildren, defaultParentId); if (!defaultParentNode) { setResponseStatus(event, 404); return { ok: false, error: `default_parent_id "${defaultParentId}" not found in ${ targetPageId !== null ? `page "${targetPageId}"` : 'the live document' }. Fix the orchestrator's subtask parent binding or omit default_parent_id ` + `to fall through to page-root insertion.`, }; } } const effectiveParent = parentId ?? defaultParentId; // Append (index=Infinity) to match the streaming path's generation- // order semantics. Default document-store.addNode prepends (index=0) // because new user-created nodes want to be on top of the layer // panel; AI-generation wants each element to stack after earlier // siblings so multi-call output renders top-to-bottom as emitted. const updatedChildren = insertNodeInTree( pageChildren, effectiveParent, tree as unknown as PenNode, Infinity, ); if (targetPage) { targetPage.children = updatedChildren; } else { next.children = updatedChildren; } setSyncDocument(next); serverLog.info( `[exec-tool] applied ${body.name} → node ${insertedNodeId}` + (effectiveParent !== null ? ` under ${parentId !== null ? 'parent' : 'default parent'} ${effectiveParent}` : '') + (targetPageId !== null ? ` on page ${targetPageId}` : ''), ); return { ok: true, document: next, insertedNodeId, insertedNodeIds: [insertedNodeId], }; });