🐛 Improved error handling on a tool error (#10148)

This commit is contained in:
Alonso Torres 2026-06-12 08:59:05 +02:00 committed by GitHub
parent 045f177a1f
commit d6c973e269
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 218 additions and 3 deletions

View File

@ -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;

View File

@ -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/"
},

View File

@ -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");
});

View File

@ -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<unknown>).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<unknown>);
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);
}
}

View File

@ -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);

View File

@ -20,5 +20,6 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/*.test.ts"]
}