mirror of
https://github.com/penpot/penpot.git
synced 2026-07-27 16:38:13 +00:00
🐛 Improved error handling on a tool error (#10148)
This commit is contained in:
parent
045f177a1f
commit
d6c973e269
5
.github/workflows/tests-mcp.yml
vendored
5
.github/workflows/tests-mcp.yml
vendored
@ -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;
|
||||
|
||||
@ -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/"
|
||||
},
|
||||
|
||||
63
mcp/packages/plugin/src/ErrorUtils.test.ts
Normal file
63
mcp/packages/plugin/src/ErrorUtils.test.ts
Normal 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");
|
||||
});
|
||||
145
mcp/packages/plugin/src/ErrorUtils.ts
Normal file
145
mcp/packages/plugin/src/ErrorUtils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -20,5 +20,6 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/*.test.ts"]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user