diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml index 0ab2909b72..9e622ca83a 100644 --- a/.github/workflows/tests-mcp.yml +++ b/.github/workflows/tests-mcp.yml @@ -45,3 +45,8 @@ jobs: pnpm run fmt:check; pnpm -r run build; pnpm -r run types:check; + + - name: Tests + working-directory: ./mcp + run: | + pnpm -r run test; diff --git a/mcp/packages/plugin/package.json b/mcp/packages/plugin/package.json index 0ccf276181..9dddf112fd 100644 --- a/mcp/packages/plugin/package.json +++ b/mcp/packages/plugin/package.json @@ -7,6 +7,7 @@ "start": "vite build --watch --config vite.config.ts", "start:multi-user": "pnpm run start", "build": "tsc && vite build --config vite.release.config.ts", + "test": "node --experimental-strip-types --test src/*.test.ts", "types:check": "tsc --noEmit", "clean": "rm -rf dist/" }, diff --git a/mcp/packages/plugin/src/ErrorUtils.test.ts b/mcp/packages/plugin/src/ErrorUtils.test.ts new file mode 100644 index 0000000000..e88b444463 --- /dev/null +++ b/mcp/packages/plugin/src/ErrorUtils.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { formatTaskError } from "./ErrorUtils.ts"; + +test("includes ClojureScript exception data in the task error message", () => { + const keyword = (name: string) => ({ name, fqn: name }); + const entries = [ + { key: keyword("type"), val: keyword("validation") }, + { key: keyword("code"), val: keyword("request-body-too-large") }, + { key: keyword("status"), val: 413 }, + ]; + const data = { + *[Symbol.iterator]() { + yield* entries; + }, + }; + const error = Object.assign(new Error("http error"), { data }); + + assert.equal(formatTaskError(error), "http error (type: validation, code: request-body-too-large, status: 413)"); +}); + +test("falls back to the printed representation when map internals are renamed by the Closure compiler", () => { + // In release builds MapEntry/Keyword field names are minified, so entry + // extraction finds nothing; only toString keeps the readable CLJS form. + const entries = [{ a7: { Eb: "type" }, gb: { Eb: "validation" } }]; + const data = { + *[Symbol.iterator]() { + yield* entries; + }, + toString: () => "{:type :validation, :code :request-body-too-large}", + }; + const error = Object.assign(new Error("http error"), { data }); + + assert.equal(formatTaskError(error), "http error ({:type :validation, :code :request-body-too-large})"); +}); + +test("formats plain object data and nested printable values", () => { + const data = { + status: 413, + uri: "https://example.test/api/export", + detail: { toString: () => "{:code :too-large}" }, + }; + const error = Object.assign(new Error("http error"), { data }); + + assert.equal( + formatTaskError(error), + "http error (status: 413, uri: https://example.test/api/export, detail: {:code :too-large})" + ); +}); + +test("keeps string values verbatim", () => { + const error = Object.assign(new Error("http error"), { data: { hint: ":not-a-keyword" } }); + + assert.equal(formatTaskError(error), "http error (hint: :not-a-keyword)"); +}); + +test("returns the original message when no structured data is available", () => { + assert.equal(formatTaskError(new Error("export timed out")), "export timed out"); +}); + +test("formats non-Error values", () => { + assert.equal(formatTaskError("connection closed"), "connection closed"); +}); diff --git a/mcp/packages/plugin/src/ErrorUtils.ts b/mcp/packages/plugin/src/ErrorUtils.ts new file mode 100644 index 0000000000..c898037b7d --- /dev/null +++ b/mcp/packages/plugin/src/ErrorUtils.ts @@ -0,0 +1,145 @@ +type ErrorWithData = Error & { + data?: unknown; +}; + +/** + * Produces a useful task error message, including structured ClojureScript exception data. + * + * @param error - the value thrown while handling a plugin task + * @returns A human-readable error message + */ +export function formatTaskError(error: unknown): string { + if (!(error instanceof Error)) { + return String(error); + } + + const details = formatErrorData((error as ErrorWithData).data); + return details ? `${error.message} (${details})` : error.message; +} + +/** + * Formats plain objects and iterable ClojureScript maps as key-value pairs. + * + * Falls back to the value's printed representation: in release builds the + * Closure compiler renames ClojureScript internals (MapEntry `key`/`val`, + * Keyword `fqn`), so entry extraction can come up empty even though `toString` + * still prints the data readably (e.g. `{:type :validation}`). + * + * @param data - structured exception data + * @returns Formatted exception details, or an empty string when unavailable + */ +function formatErrorData(data: unknown): string { + const entries = getEntries(data); + if (entries.length > 0) { + return entries.map(([key, value]) => `${formatKey(key)}: ${formatValue(value)}`).join(", "); + } + + return printedForm(data); +} + +/** + * Returns the value's own printed representation, or an empty string when it + * only has the default `Object.prototype.toString` one. + */ +function printedForm(value: unknown): string { + if (!value || typeof value !== "object") { + return ""; + } + + try { + const text = String(value); + return text === "[object Object]" ? "" : text; + } catch { + return ""; + } +} + +/** + * Extracts entries from JavaScript objects or ClojureScript map-like values. + */ +function getEntries(data: unknown): Array<[unknown, unknown]> { + if (!data || typeof data !== "object") { + return []; + } + + if (Symbol.iterator in data) { + try { + return Array.from(data as Iterable).flatMap((entry) => { + if (Array.isArray(entry) && entry.length >= 2) { + return [[entry[0], entry[1]]]; + } + + if (entry && typeof entry === "object") { + const mapEntry = entry as { key?: unknown; val?: unknown }; + if ("key" in mapEntry && "val" in mapEntry) { + return [[mapEntry.key, mapEntry.val]]; + } + + if (Symbol.iterator in entry) { + const pair = Array.from(entry as Iterable); + return pair.length >= 2 ? [[pair[0], pair[1]]] : []; + } + } + + return []; + }); + } catch { + // fall through to ordinary object properties + } + } + + return Object.entries(data); +} + +/** + * Formats ClojureScript keywords without their leading colon. + */ +function formatKey(key: unknown): string { + if (key && typeof key === "object") { + const keyword = key as { fqn?: unknown; name?: unknown }; + if (typeof keyword.fqn === "string") { + return keyword.fqn; + } + if (typeof keyword.name === "string") { + return keyword.name; + } + } + + return String(key).replace(/^:/, ""); +} + +/** + * Formats detail values while keeping nested data readable. + */ +function formatValue(value: unknown): string { + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + if (value && typeof value === "object") { + const keyword = value as { fqn?: unknown; name?: unknown }; + if (typeof keyword.fqn === "string") { + return keyword.fqn; + } + if (typeof keyword.name === "string") { + return keyword.name; + } + + if (!Array.isArray(value)) { + const printed = printedForm(value); + if (printed) { + return printed.replace(/^:/, ""); + } + } + } + + if (value === null || value === undefined) { + return String(value); + } + + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/mcp/packages/plugin/src/plugin.ts b/mcp/packages/plugin/src/plugin.ts index 34e0349e96..502116707f 100644 --- a/mcp/packages/plugin/src/plugin.ts +++ b/mcp/packages/plugin/src/plugin.ts @@ -1,5 +1,6 @@ import { ExecuteCodeTaskHandler } from "./task-handlers/ExecuteCodeTaskHandler"; import { Task, TaskHandler } from "./TaskHandler"; +import { formatTaskError } from "./ErrorUtils"; /** * indicates whether the plugin is running in an environment with the Penpot-integrated remote MCP server @@ -97,8 +98,7 @@ async function handlePluginTaskRequest(request: { id: string; task: string; para console.log("Task handled successfully:", task); } catch (error) { console.error("Error handling task:", error); - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - task.sendError(`Error handling task: ${errorMessage}`); + task.sendError(`Error handling task: ${formatTaskError(error)}`); } } else { console.error("Unknown plugin task:", request.task); diff --git a/mcp/packages/plugin/tsconfig.json b/mcp/packages/plugin/tsconfig.json index 0db6d8a2c6..de5c7b8ff9 100644 --- a/mcp/packages/plugin/tsconfig.json +++ b/mcp/packages/plugin/tsconfig.json @@ -20,5 +20,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/*.test.ts"] }