Skip to content

Commit c765680

Browse files
committed
feat(build): extract formatBuildSuccessResponse, chain build to deploy in tool description
1 parent f5821dd commit c765680

2 files changed

Lines changed: 94 additions & 71 deletions

File tree

packages/pluggable-widgets-mcp/src/tools/build.tools.ts

Lines changed: 93 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,13 @@
66
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
77
import { spawn } from "node:child_process";
88
import { existsSync } from "node:fs";
9-
import { join } from "node:path";
9+
import { readFile } from "node:fs/promises";
10+
import { join, normalize, sep } from "node:path";
1011
import { z } from "zod";
1112
import { GENERATIONS_DIR } from "@/config";
1213
import type { ToolContext, ToolResponse } from "./types";
1314
import { ProgressTracker } from "./utils/progress-tracker";
14-
import {
15-
createStructuredError,
16-
createStructuredErrorResponse,
17-
createToolResponse,
18-
type StructuredError
19-
} from "./utils/response";
15+
import { createStructuredError, createStructuredErrorResponse, createToolResponse } from "./utils/response";
2016
import { findMpkFile } from "./utils/mpk";
2117
import { isPathAllowed } from "./utils/sandbox";
2218
import type { SessionState } from "./session-state";
@@ -33,7 +29,7 @@ type BuildWidgetInput = z.infer<typeof buildWidgetSchema>;
3329
/**
3430
* Parsed error with location information.
3531
*/
36-
interface ParsedError {
32+
export interface ParsedError {
3733
message: string;
3834
file?: string;
3935
line?: number;
@@ -137,8 +133,8 @@ function parseBuildOutput(stdout: string, stderr: string): BuildResult {
137133

138134
const lines = output.split("\n");
139135

140-
for (let i = 0; i < lines.length; i++) {
141-
const trimmed = lines[i].trim();
136+
for (const line of lines) {
137+
const trimmed = line.trim();
142138
if (!trimmed) continue;
143139

144140
// TypeScript errors (try to parse with location)
@@ -221,6 +217,80 @@ function parseBuildOutput(stdout: string, stderr: string): BuildResult {
221217
};
222218
}
223219

220+
/**
221+
* Formats a build failure response for Maia, including:
222+
* - All errors with file/line/column/code
223+
* - Content of every source file that appears in the error list
224+
*
225+
* Embedding file content lets Maia fix errors without an extra read-widget-file
226+
* round-trip. Output format is designed to be read by an AI agent.
227+
*/
228+
export async function formatBuildFailureResponse(errors: ParsedError[], widgetPath: string): Promise<string> {
229+
// Format error list — each error gets code, location (file line N col N), and message
230+
const errorLines = errors.map(e => {
231+
const loc = e.file
232+
? `${e.file}${e.line != null ? ` line ${e.line}` : ""}${e.column != null ? ` col ${e.column}` : ""}`
233+
: null;
234+
const code = e.tsCode ? `[${e.tsCode}]` : `[${e.category}]`;
235+
const locStr = loc ? ` ${loc} —` : "";
236+
return ` ${code}${locStr} ${e.message}`;
237+
});
238+
239+
// Collect unique source files that appear in errors
240+
const uniqueFiles = [...new Set(errors.map(e => e.file).filter((f): f is string => !!f))];
241+
242+
// Read each failing file (skip if not found — don't throw)
243+
const fileSections: string[] = [];
244+
for (const relPath of uniqueFiles) {
245+
// Block path traversal: only allow files under widgetPath
246+
const normalizedBase = normalize(widgetPath);
247+
const fullPath = normalize(join(widgetPath, relPath));
248+
if (!fullPath.startsWith(normalizedBase + sep) && fullPath !== normalizedBase) continue;
249+
if (!existsSync(fullPath)) continue;
250+
251+
try {
252+
const content = await readFile(fullPath, "utf-8");
253+
fileSections.push(`--- ${relPath} ---\n${content}`);
254+
} catch {
255+
// Skip unreadable files silently
256+
}
257+
}
258+
259+
const lines = [
260+
`❌ Build failed — ${errors.length} error(s). Fix the errors below, write with write-widget-file, then retry build-widget (max 3 attempts total).`,
261+
"",
262+
"Errors:",
263+
...errorLines
264+
];
265+
266+
if (fileSections.length > 0) {
267+
lines.push("", "Failing file contents:", "");
268+
lines.push(...fileSections);
269+
}
270+
271+
return lines.join("\n");
272+
}
273+
274+
/**
275+
* Formats a successful build response, including MPK path, warnings, and a
276+
* chaining instruction to call deploy-widget next.
277+
*/
278+
export function formatBuildSuccessResponse(
279+
mpkPath: string | undefined,
280+
widgetPath: string,
281+
warnings: string[]
282+
): string {
283+
let message = "✅ Build successful!";
284+
if (mpkPath) {
285+
message += `\n\n📦 MPK output: ${mpkPath}`;
286+
}
287+
if (warnings.length > 0) {
288+
message += `\n\n⚠️ Warnings:\n${warnings.map(w => ` - ${w}`).join("\n")}`;
289+
}
290+
message += `\n\n🚀 Next step: Call deploy-widget with widgetPath: "${widgetPath}" to copy the .mpk to your Mendix project's widgets/ directory.`;
291+
return message;
292+
}
293+
224294
/**
225295
* Build progress phases for user-friendly messages.
226296
*/
@@ -317,34 +387,6 @@ async function runBuild(widgetPath: string, tracker?: ProgressTracker): Promise<
317387
});
318388
}
319389

320-
/**
321-
* Converts a parsed error to a structured error with suggestions.
322-
*/
323-
function toStructuredError(error: ParsedError): StructuredError {
324-
const suggestions: Record<ParsedError["category"], string> = {
325-
typescript:
326-
"Check the TypeScript code at the specified location. Ensure props match the generated types from widget XML.",
327-
xml: "Verify your widget.xml follows the Mendix schema. Check property types and required attributes.",
328-
dependency:
329-
"Run 'npm install' in the widget directory. If the issue persists, check that all dependencies are listed in package.json.",
330-
unknown: "Review the build output for more details. Try running 'npx pluggable-widget-tools build' manually."
331-
};
332-
333-
const codeMap: Record<ParsedError["category"], StructuredError["code"]> = {
334-
typescript: "ERR_BUILD_TS",
335-
xml: "ERR_BUILD_XML",
336-
dependency: "ERR_BUILD_MISSING_DEP",
337-
unknown: "ERR_BUILD_UNKNOWN"
338-
};
339-
340-
return createStructuredError(codeMap[error.category], error.message, {
341-
suggestion: suggestions[error.category],
342-
file: error.file,
343-
line: error.line,
344-
column: error.column
345-
});
346-
}
347-
348390
/**
349391
* Handler for the build-widget tool.
350392
*/
@@ -400,42 +442,14 @@ async function handleBuildWidget(
400442
const mpkPath = result.mpkPath || findMpkFile(widgetPath);
401443

402444
if (result.success) {
403-
let message = `✅ Build successful!`;
404-
405-
if (mpkPath) {
406-
message += `\n\n📦 MPK output: ${mpkPath}`;
407-
}
408-
409-
if (result.warnings.length > 0) {
410-
message += `\n\n⚠️ Warnings:\n${result.warnings.map(w => ` - ${w}`).join("\n")}`;
411-
}
412-
413-
return createToolResponse(message);
445+
return createToolResponse(formatBuildSuccessResponse(mpkPath, widgetPath, result.warnings));
414446
} else {
415-
// Return first error as structured error (most relevant)
416447
if (result.errors.length > 0) {
417-
const primaryError = toStructuredError(result.errors[0]);
418-
419-
// Add additional errors to raw output if multiple
420-
if (result.errors.length > 1) {
421-
const additionalErrors = result.errors
422-
.slice(1)
423-
.map(e => {
424-
const loc = e.file ? `${e.file}${e.line ? `:${e.line}` : ""}` : "";
425-
return loc ? `[${loc}] ${e.message}` : e.message;
426-
})
427-
.join("\n");
428-
429-
primaryError.details = {
430-
...primaryError.details,
431-
rawOutput: `Additional errors (${result.errors.length - 1}):\n${additionalErrors}`
432-
};
433-
}
434-
435-
return createStructuredErrorResponse(primaryError);
448+
const message = await formatBuildFailureResponse(result.errors, widgetPath);
449+
return { content: [{ type: "text", text: message }], isError: true };
436450
}
437451

438-
// Fallback for unknown failures
452+
// Fallback for unknown failures (no structured errors detected)
439453
return createStructuredErrorResponse(
440454
createStructuredError("ERR_BUILD_UNKNOWN", "Build failed with unknown error", {
441455
suggestion: "Check the raw build output for details.",
@@ -459,7 +473,15 @@ export function registerBuildTools(server: McpServer, state: SessionState): void
459473
description:
460474
"Builds a Mendix pluggable widget using pluggable-widget-tools. " +
461475
"Validates XML, compiles TypeScript, generates types, and produces an .mpk file. " +
462-
"Returns build errors if any, which can be used to fix issues.",
476+
"If the build fails with TypeScript errors, the response includes ALL errors with " +
477+
"file locations AND the content of every failing source file. " +
478+
"RETRY LOOP: On failure, (1) read the errors and embedded file content, " +
479+
"(2) fix the TypeScript errors, (3) write the fixed files using write-widget-file, " +
480+
"(4) call build-widget again. Repeat until the build passes. " +
481+
"Maximum 3 total attempts — if still failing after 3 attempts, " +
482+
"report the errors and file contents to the user. " +
483+
"SUCCESS: When the build succeeds, you MUST call deploy-widget next with the same widgetPath " +
484+
"to copy the .mpk to the Mendix project. Do not stop after a successful build.",
463485
inputSchema: buildWidgetSchema
464486
},
465487
(args, context) => handleBuildWidget(args, context, state)

packages/pluggable-widgets-mcp/src/tools/project.tools.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export function registerProjectTools(server: McpServer, state: SessionState): vo
108108
title: "Deploy Widget",
109109
description:
110110
"Copies a built widget .mpk file to the configured Mendix project's widgets/ directory. " +
111+
"Call this after build-widget succeeds. " +
111112
"Requires a project directory to be configured (via MENDIX_PROJECT_DIR env var or set-project-directory). " +
112113
"Looks for the .mpk file in the widget's dist/ directory. " +
113114
"After deploying, synchronize the app directory in Studio Pro to pick up the new widget.",

0 commit comments

Comments
 (0)