🎉 Add two new MCP tools for Clojure development

* CljsCompilerOutputTool: Checks compiler output and reports errors
* CljCheckParentheses: Precisely locates incorrect/unbalanced parentheses

GitHub #9214
This commit is contained in:
Michael Panchenko 2026-05-11 14:51:11 +02:00 committed by Dominik Jain
parent b952783621
commit 7a2ca6c08f
5 changed files with 348 additions and 0 deletions

View File

@ -18,6 +18,7 @@ Memories:
- connection between the JavaScript API and the ClojureScript code: `frontend/js-api-to-cljs-binding`.
- executing ClojureScript code in the frontend: `frontend/cljs-repl`.
- programmatically navigating to a file in the workspace: `frontend/navigation`.
- handling Clojure compiler errors, runtime patching and debug helpers: `frontend/handling-errors-and-debugging`.
## Detecting Crashes

View File

@ -0,0 +1,49 @@
# Handling Errors and Debugging
## Finding source errors
You have access to two tools for finding errors in Clojure source code (which you may introduce yourself through edits):
1. cljs_compiler_output
2. clj_check_parentheses
The latter is needed because syntax errors in parentheses give an uninformative compiler error, and the second
tool can often find the exact location of such errors.
## Runtime patching with `set!`
Some frontend vars are deliberately mutable escape hatches for runtime instrumentation or circular-dependency patching.
From `cljs_repl`, use `set!` for temporary debugging of CLJS vars such as
`app.main.store/on-event`, `app.main.errors/reload-file`, `app.main.errors/is-plugin-error?`,
`app.main.errors/last-report`, or `app.main.errors/last-exception`.
These patches affect only the live browser runtime and disappear on reload or recompilation.
```clojure
;; Log non-noisy Potok events temporarily.
(set! app.main.store/on-event
(fn [event]
(when (potok.v2.core/event? event)
(.log js/console (potok.v2.core/repr-event event)))))
```
Restore mutable hooks after debugging, or reload the frontend. Use JVM `alter-var-root` only for JVM Clojure;
it is not the normal way to patch live CLJS browser vars.
## Browser-console debug namespace
In development, the JS console exposes `debug` helpers from `frontend/src/debug.cljs`:
```javascript
debug.set_logging("namespace", "debug");
debug.dump_state();
debug.dump_buffer();
debug.get_state(":workspace-local :selected");
debug.dump_objects();
debug.dump_object("Rect-1");
debug.dump_selected();
debug.dump_tree(true, true);
```
Visual workspace debug overlays can be toggled with `debug.toggle_debug("bounding-boxes")`, `"group"`, `"events"`, or `"rotation-handler"`; `debug.debug_all()` and `debug.debug_none()` toggle all visual aids.
For temporary source traces, prefer existing logging (`app.common.logging` / `app.util.logging`) or short-lived `prn`, `app.common.pprint/pprint`, `js/console.log`, or `js-debugger` calls. Remove temporary source instrumentation before committing.

View File

@ -13,6 +13,8 @@ import { ExportShapeTool } from "./tools/ExportShapeTool";
import { ImportImageTool } from "./tools/ImportImageTool";
import { CljsReplTool } from "./tools/CljsReplTool";
import { ImportPenpotFileTool } from "./tools/ImportPenpotFileTool";
import { CljsCompilerOutputTool } from "./tools/CljsCompilerOutputTool";
import { CljCheckParentheses } from "./tools/CljCheckParentheses";
import { NreplClient } from "./NreplClient";
import { ReplServer } from "./ReplServer";
import { ApiDocs } from "./ApiDocs";
@ -194,6 +196,8 @@ export class PenpotMcpServer {
const nreplClient = new NreplClient();
toolInstances.push(new CljsReplTool(this, nreplClient));
toolInstances.push(new ImportPenpotFileTool(this, nreplClient));
toolInstances.push(new CljsCompilerOutputTool(this, nreplClient));
toolInstances.push(new CljCheckParentheses(this));
}
return toolInstances.map((instance) => {

View File

@ -0,0 +1,242 @@
import { z } from "zod";
import { Tool } from "../Tool";
import "reflect-metadata";
import type { ToolResponse } from "../ToolResponse";
import { TextResponse } from "../ToolResponse";
import type { PenpotMcpServer } from "../PenpotMcpServer";
import * as fs from "fs";
/**
* Arguments for the FindUnclosedParensTool.
*/
export class CljCheckParenthesesArgs {
static schema = {
file: z.string().min(1).describe("Absolute path to a Clojure/ClojureScript source file"),
};
file!: string;
}
interface OpenDelim {
id: number;
line: number; // 0-based
col: number; // 0-based
char: string;
baselineKey: string; // the baseline key this delimiter owns
}
interface ParenIssue {
line: number; // 1-based
col: number; // 1-based
char: string;
detectedAtLine?: number; // 1-based line where the stack-state mismatch was observed
}
/**
* Finds unclosed delimiters in Clojure/ClojureScript source files using a
* stack-state invariant derived from cljfmt formatting conventions.
*
* Invariant: in cljfmt-formatted code, every opening delimiter of type T at
* column C must see the same stack depth each time that (T, C) combination
* occurs. A depth mismatch means delimiters opened between the baseline
* occurrence and the current one were never closed.
*
* The parser correctly handles string literals (including multi-line and escape
* sequences), comment lines, character literals, and regex literals.
*/
export class CljCheckParentheses extends Tool<CljCheckParenthesesArgs> {
constructor(mcpServer: PenpotMcpServer) {
super(mcpServer, CljCheckParenthesesArgs.schema);
}
public getToolName(): string {
return "clj_check_parentheses";
}
public getToolDescription(): string {
return "Analyzes a Clojure/ClojureScript source file for unclosed delimiters and reports the area of interest.";
}
protected async executeCore(args: CljCheckParenthesesArgs): Promise<ToolResponse> {
const filePath = args.file;
if (!fs.existsSync(filePath)) {
return new TextResponse(`File not found: ${filePath}`);
}
const content = fs.readFileSync(filePath, "utf-8");
const issues = analyzeParens(content);
if (issues.length === 0) {
return new TextResponse("All delimiters are properly balanced.");
}
const sourceLines = content.split("\n");
const parts: string[] = [`Found ${issues.length} unclosed delimiter(s):\n`];
for (const issue of issues) {
const srcLine = (sourceLines[issue.line - 1] ?? "").trimEnd();
const pointer = " ".repeat(String(issue.line).length) + " " + " ".repeat(issue.col - 1) + "^";
if (issue.detectedAtLine != null) {
const detectedSrcLine = (sourceLines[issue.detectedAtLine - 1] ?? "").trimEnd();
parts.push(
` Unclosed '${issue.char}' at line ${issue.line}, col ${issue.col}:\n` +
` ${issue.line} | ${srcLine}\n` +
` ${pointer}\n` +
` Stack-state mismatch detected at line ${issue.detectedAtLine}:\n` +
` ${issue.detectedAtLine} | ${detectedSrcLine}\n`
);
} else {
parts.push(
` Unclosed '${issue.char}' at line ${issue.line}, col ${issue.col} (still open at end of file):\n` +
` ${issue.line} | ${srcLine}\n` +
` ${pointer}\n`
);
}
}
return new TextResponse(parts.join("\n"));
}
}
/**
* Analyses delimiter balance in a Clojure/ClojureScript source string.
*
* Algorithm
* ---------
* Maintain a stack of open delimiters and a map from (delimiter-type, column)
* to the stack depth recorded on the first occurrence of that combination.
*
* Each time an opening delimiter of type T appears at column C:
* 1. Look up the key (T, C) in the map.
* 2. If absent, record the current stack depth as the baseline.
* 3. If present, compare the current depth with the baseline.
* - If deeper: the extra stack entries (from baseline depth to current
* depth) are delimiters that should have been closed. Report them.
* - If shallower: more delimiters were closed than opened between the
* baseline and here (over-closed). Update the baseline downward so
* subsequent occurrences don't cascade.
* 4. Push the delimiter onto the stack.
*
* After the full file is processed, any delimiter still on the stack is
* unclosed. If it was already reported via a mismatch, the report includes
* the detection line; otherwise it is reported as open-at-EOF.
*/
function analyzeParens(content: string): ParenIssue[] {
// Precompute line-start offsets for O(1) column lookup.
const lineStarts: number[] = [0];
for (let i = 0; i < content.length; i++) {
if (content[i] === "\n") lineStarts.push(i + 1);
}
let nextId = 0;
const stack: OpenDelim[] = [];
// (type, column) → baseline stack depth.
// Each baseline is owned by the delimiter that established it (stored
// as baselineKey on the stack entry). When that delimiter is popped,
// its baseline is discarded — it was scoped to that delimiter's lifetime.
const baseline: Map<string, number> = new Map();
let inString = false;
let inComment = false;
let escape = false;
let currentLine = 0;
for (let i = 0; i < content.length; i++) {
const ch = content[i];
// ── Newline ──────────────────────────────────────────────────────
if (ch === "\n") {
inComment = false;
currentLine++;
if (!inString) escape = false;
continue;
}
// ── Escape: skip next character ──────────────────────────────────
if (escape) {
escape = false;
continue;
}
// ── Inside comment: skip until newline ───────────────────────────
if (inComment) continue;
// ── Inside string literal ────────────────────────────────────────
if (inString) {
if (ch === "\\") escape = true;
else if (ch === '"') inString = false;
continue;
}
// ── Outside string / comment ─────────────────────────────────────
if (ch === "\\") {
escape = true;
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === ";") {
inComment = true;
continue;
}
// ── Opening delimiter ────────────────────────────────────────────
if (ch === "(" || ch === "[" || ch === "{") {
const col = i - lineStarts[currentLine];
const key = `${ch}:${col}`;
const currentDepth = stack.length;
const recorded = baseline.get(key);
if (recorded !== undefined && currentDepth > recorded) {
// Stack is deeper than expected. The entries from index
// `recorded` to `currentDepth - 1` are unclosed delimiters
// that should have been closed before reaching this
// position. Return immediately — further parsing would be
// against a corrupted stack and only produce cascading noise.
return stack.slice(recorded, currentDepth).map((delim) => ({
line: delim.line + 1,
col: delim.col + 1,
char: delim.char,
detectedAtLine: currentLine + 1,
}));
}
// Establish or re-establish the baseline for this key,
// owned by this delimiter. Discarded when it is popped.
baseline.set(key, currentDepth);
stack.push({
id: nextId++,
line: currentLine,
col,
char: ch,
baselineKey: key,
});
}
// ── Closing delimiter ────────────────────────────────────────────
else if (ch === ")" || ch === "]" || ch === "}") {
if (stack.length > 0) {
const closed = stack.pop()!;
// The baseline this delimiter owned is no longer valid —
// the context it was recorded in has closed.
baseline.delete(closed.baselineKey);
}
}
}
// ── EOF: no mismatch was found, but the stack is not empty ──────────
// This happens when the unclosed delimiter has no second occurrence of
// the same (type, column) to compare against (e.g. last form in file).
return stack.map((delim) => ({
line: delim.line + 1,
col: delim.col + 1,
char: delim.char,
}));
}

View File

@ -0,0 +1,52 @@
import { Tool, EmptyToolArgs } from "../Tool";
import "reflect-metadata";
import type { ToolResponse } from "../ToolResponse";
import { TextResponse } from "../ToolResponse";
import { PenpotMcpServer } from "../PenpotMcpServer";
import { NreplClient } from "../NreplClient";
/**
* Reports the compiler status of the shadow-cljs `:main` build.
*
* If the most recent build failed, returns the relevant fields of the failure data
* (tag, message, resource name, line, column, etc.); otherwise returns `:ok`.
*/
export class CljsCompilerOutputTool extends Tool<EmptyToolArgs> {
private static readonly STATUS_CODE =
"(require (quote [shadow.cljs.devtools.api :as shadow])) " +
"(let [fd (-> (shadow/get-worker :main) :state-ref deref :failure-data)] " +
"(if fd (pr-str fd) :ok))";
private readonly nreplClient: NreplClient;
constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) {
super(mcpServer, EmptyToolArgs.schema);
this.nreplClient = nreplClient;
}
public getToolName(): string {
return "cljs_compiler_output";
}
public getToolDescription(): string {
return (
"Reports the status of the most recent shadow-cljs `:main` build. " +
"Use this to diagnose compilation errors when needed. For syntax errors, " +
"consider using the clj_check_parentheses tool on the relevant source files."
);
}
protected async executeCore(_args: EmptyToolArgs): Promise<ToolResponse> {
const result = await this.nreplClient.eval(CljsCompilerOutputTool.STATUS_CODE);
// multiple top-level forms produce multiple values; the build status is the last one
const status = result.values[result.values.length - 1] ?? "nil";
const parts: string[] = [status];
if (result.err) {
parts.push(`stderr:\n${result.err}`);
}
return new TextResponse(parts.join("\n\n"));
}
}