ELEMENT_TOOL_OUTPUT_FORMAT tells the AI to emit
`<op_tool>{"name":"batch_design", ...}` when no element-tool fits.
Prior Nitro implementation hard-coded a 501 for any DSL payload,
so the FALLBACK branch advertised to the AI was a lie — any AI
that actually took the guidance would see its generation fail.
Fix extracts pen-mcp's `handleBatchDesign` pure executor
(`runBatchDesignDsl`) from the file-I/O wrapper and exposes it on
the package's main barrel. Nitro's `/api/mcp/exec-tool` now
accepts `{dsl}`, runs the executor against a clone of the
sync-state doc (no file I/O, no post-processing hooks — those
belong to the pen-mcp server process), and calls setSyncDocument
to broadcast the result via SSE. Response shape gains
`insertedNodeIds: string[]` so batch inserts (multiple root
bindings in one DSL) surface all their root nodes to the
orchestrator's progress accounting, not just the first.
Client dispatcher updated to prefer the array form with fallback
to the legacy single-id field. Adds a test asserting the
dispatcher actually calls fetch when taking the DSL fallback
(proves the route wires end-to-end). JSDoc in dispatcher +
endpoint updated so code and docs agree.
handleBatchDesign's external behavior is unchanged — it still
opens / post-processes / saves around the refactored executor;
311/311 pen-mcp tests pass unchanged.
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
import { defineEventHandler, readBody, setResponseStatus } from 'h3';
|
|
import {
|
|
assignIdsRecursively,
|
|
buildBodyText,
|
|
buildBottomNav,
|
|
buildCardRow,
|
|
buildHeading,
|
|
buildListRow,
|
|
buildMetricRow,
|
|
buildSearchBar,
|
|
buildSectionHeader,
|
|
buildTextButton,
|
|
buildTopNavBar,
|
|
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<string, BuilderFn> = {
|
|
add_card_row_v0: (a) => buildCardRow(a as Parameters<typeof buildCardRow>[0]),
|
|
add_metric_row_v0: (a) => buildMetricRow(a as Parameters<typeof buildMetricRow>[0]),
|
|
add_bottom_nav_v0: (a) => buildBottomNav(a as Parameters<typeof buildBottomNav>[0]),
|
|
add_section_header_v0: (a) => buildSectionHeader(a as Parameters<typeof buildSectionHeader>[0]),
|
|
add_top_nav_bar_v0: (a) => buildTopNavBar(a as Parameters<typeof buildTopNavBar>[0]),
|
|
add_heading_v0: (a) => buildHeading(a as Parameters<typeof buildHeading>[0]),
|
|
add_body_text_v0: (a) => buildBodyText(a as Parameters<typeof buildBodyText>[0]),
|
|
add_text_button_v0: (a) => buildTextButton(a as Parameters<typeof buildTextButton>[0]),
|
|
add_search_bar_v0: (a) => buildSearchBar(a as Parameters<typeof buildSearchBar>[0]),
|
|
add_list_row_v0: (a) => buildListRow(a as Parameters<typeof buildListRow>[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<ExecToolSuccess | ExecToolError> => {
|
|
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<string, unknown>).filePath === 'string'
|
|
? ((body as Record<string, unknown>).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<string, unknown>).pageId === 'string' &&
|
|
((body as Record<string, unknown>).pageId as string).length > 0
|
|
? ((body as Record<string, unknown>).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<string, unknown>;
|
|
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],
|
|
};
|
|
});
|