Skip to content

Commit 01d5a53

Browse files
rahmanunverclaude
andcommitted
feat(pluggable-widgets-mcp): add project tools, session state, and deploy support
Adds get-project-info, set-project-directory, and deploy-widget tools. Introduces SessionState for per-session isolation and MENDIX_PROJECT_DIR env var for project configuration. Includes findMpkFile utility and new error codes for project/deploy failures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ec1c760 commit 01d5a53

7 files changed

Lines changed: 321 additions & 8 deletions

File tree

packages/pluggable-widgets-mcp/src/config.ts

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { readFileSync } from "node:fs";
2-
import { dirname, join } from "node:path";
2+
import { readdir, stat } from "node:fs/promises";
3+
import { dirname, join, resolve } from "node:path";
34
import { fileURLToPath } from "node:url";
45

56
// Server configuration
@@ -13,8 +14,17 @@ export const SERVER_ICON = {
1314
mimeType: "image/png"
1415
};
1516
export const SERVER_WEBSITE_URL = "https://github.com/mendix/web-widgets";
16-
export const SERVER_INSTRUCTIONS =
17-
"This is a MCP server for Mendix Pluggable Widgets. It allows you to create and edit widgets.";
17+
export const SERVER_INSTRUCTIONS = `This is a MCP server for Mendix Pluggable Widgets. It allows you to create, build, and deploy widgets to a Mendix project.
18+
19+
WORKFLOW GUIDE:
20+
1. Call get-project-info first to discover the configured Mendix project directory.
21+
2. If a project is configured, you can scaffold, build, and deploy widgets without asking for filesystem paths.
22+
3. If no project is configured, use set-project-directory to configure one, or proceed without deployment.
23+
4. Use create-widget to scaffold a new widget (output goes to the generations/ directory).
24+
5. Use build-widget to compile the widget and produce an .mpk file.
25+
6. Use deploy-widget to copy the .mpk to the project's widgets/ folder.
26+
27+
IMPORTANT: Do NOT ask the user for filesystem paths — use get-project-info to discover the project context automatically.`;
1828

1929
// Paths - use fileURLToPath for Node.js 18 compatibility (import.meta.dirname requires Node 20.11+)
2030
const __dirname = import.meta.dirname ?? dirname(fileURLToPath(import.meta.url));
@@ -29,3 +39,70 @@ export const DOCS_DIR = join(PACKAGE_ROOT, "docs");
2939

3040
// Timeouts
3141
export const SCAFFOLD_TIMEOUT_MS = 300000; // 5 minutes
42+
43+
// Project directory configuration
44+
export const MENDIX_PROJECT_DIR = process.env.MENDIX_PROJECT_DIR ? resolve(process.env.MENDIX_PROJECT_DIR) : undefined;
45+
46+
export interface ProjectValidation {
47+
valid: boolean;
48+
projectDir: string;
49+
projectName?: string;
50+
widgetsDir: string;
51+
existingWidgets: string[];
52+
error?: string;
53+
}
54+
55+
/**
56+
* Validates a Mendix project directory.
57+
* Checks that it exists and contains a .mpr file.
58+
* Returns the project name and list of existing .mpk widgets.
59+
*/
60+
export async function validateProjectDir(dir: string): Promise<ProjectValidation> {
61+
const widgetsDir = join(dir, "widgets");
62+
63+
try {
64+
await stat(dir);
65+
} catch {
66+
return {
67+
valid: false,
68+
projectDir: dir,
69+
widgetsDir,
70+
existingWidgets: [],
71+
error: `Directory does not exist: ${dir}`
72+
};
73+
}
74+
75+
let projectName: string | undefined;
76+
try {
77+
const entries = await readdir(dir);
78+
const mprFile = entries.find(entry => entry.endsWith(".mpr"));
79+
if (!mprFile) {
80+
return {
81+
valid: false,
82+
projectDir: dir,
83+
widgetsDir,
84+
existingWidgets: [],
85+
error: `No .mpr file found in ${dir}. This does not appear to be a Mendix project directory.`
86+
};
87+
}
88+
projectName = mprFile.replace(/\.mpr$/, "");
89+
} catch {
90+
return {
91+
valid: false,
92+
projectDir: dir,
93+
widgetsDir,
94+
existingWidgets: [],
95+
error: `Failed to read directory: ${dir}`
96+
};
97+
}
98+
99+
let existingWidgets: string[] = [];
100+
try {
101+
const entries = await readdir(widgetsDir);
102+
existingWidgets = entries.filter(entry => entry.endsWith(".mpk"));
103+
} catch {
104+
// widgets/ dir may not exist yet — that's fine
105+
}
106+
107+
return { valid: true, projectDir: dir, projectName, widgetsDir, existingWidgets };
108+
}

packages/pluggable-widgets-mcp/src/server/server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import { SERVER_ICON, SERVER_INSTRUCTIONS, SERVER_NAME, SERVER_VERSION, SERVER_WEBSITE_URL } from "@/config";
22
import { registerResources } from "@/resources";
33
import { registerAllTools } from "@/tools";
4+
import { createSessionState } from "@/tools/session-state";
45
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
56

67
/**
78
* Creates and configures a new MCP server instance with all registered tools and resources.
9+
* Each instance gets its own session state so concurrent HTTP sessions are isolated.
810
*/
911
export function createMcpServer(): McpServer {
12+
const state = createSessionState();
13+
1014
const server = new McpServer(
1115
{
1216
name: SERVER_NAME,
@@ -25,7 +29,7 @@ export function createMcpServer(): McpServer {
2529
}
2630
);
2731

28-
registerAllTools(server);
32+
registerAllTools(server, state);
2933
registerResources(server);
3034

3135
return server;

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
import { registerBuildTools } from "./build.tools";
33
import { registerCodeGenerationTools } from "./code-generation.tools";
44
import { registerFileOperationTools } from "./file-operations.tools";
5+
import { registerProjectTools } from "./project.tools";
56
import { registerPropertyUpdateTools } from "./property-update.tools";
67
import { registerScaffoldingTools } from "./scaffolding.tools";
8+
import type { SessionState } from "./session-state";
79

810
/**
911
* Registers all tools with the MCP server.
@@ -14,14 +16,16 @@ import { registerScaffoldingTools } from "./scaffolding.tools";
1416
* - Build: Widget building and validation (build-widget)
1517
* - Code Generation: Generate widget XML and TSX (generate-widget-code)
1618
* - Property Update: Incremental property updates (update-widget-properties)
19+
* - Project: Project directory config and deployment (get-project-info, set-project-directory, deploy-widget)
1720
*
1821
* Each category registers its tools directly with the server, preserving
1922
* full type safety through the SDK's generic inference.
2023
*/
21-
export function registerAllTools(server: McpServer): void {
22-
registerScaffoldingTools(server);
24+
export function registerAllTools(server: McpServer, state: SessionState): void {
25+
registerScaffoldingTools(server, state);
2326
registerFileOperationTools(server);
24-
registerBuildTools(server);
27+
registerBuildTools(server, state);
2528
registerCodeGenerationTools(server);
2629
registerPropertyUpdateTools(server);
30+
registerProjectTools(server, state);
2731
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import { copyFile, mkdir } from "node:fs/promises";
3+
import { basename, join, resolve } from "node:path";
4+
import { z } from "zod";
5+
import { GENERATIONS_DIR, validateProjectDir } from "@/config";
6+
import { isPathAllowed } from "./utils/sandbox";
7+
import type { ToolResponse } from "@/tools/types";
8+
import { findMpkFile } from "@/tools/utils/mpk";
9+
import { createStructuredError, createStructuredErrorResponse, createToolResponse } from "@/tools/utils/response";
10+
import type { SessionState } from "./session-state";
11+
12+
function formatProjectInfo(validation: Awaited<ReturnType<typeof validateProjectDir>>): string {
13+
const lines: string[] = [
14+
`Project Directory: ${validation.projectDir}`,
15+
...(validation.projectName ? [`Project Name: ${validation.projectName}`] : []),
16+
`Widgets Directory: ${validation.widgetsDir}`
17+
];
18+
19+
if (validation.existingWidgets.length > 0) {
20+
lines.push(`Existing Widgets (${validation.existingWidgets.length}):`);
21+
for (const widget of validation.existingWidgets) {
22+
lines.push(` - ${widget}`);
23+
}
24+
} else {
25+
lines.push(`Existing Widgets: (none)`);
26+
}
27+
28+
return lines.join("\n");
29+
}
30+
31+
export function registerProjectTools(server: McpServer, state: SessionState): void {
32+
server.registerTool(
33+
"get-project-info",
34+
{
35+
title: "Get Project Info",
36+
description:
37+
"Returns information about the configured Mendix project directory. " +
38+
"Call this first to discover the project context before creating or deploying widgets. " +
39+
"Returns the project directory, project name, widgets directory, and existing .mpk files.",
40+
inputSchema: z.object({})
41+
},
42+
async (): Promise<ToolResponse> => {
43+
if (!state.projectDir) {
44+
return createStructuredErrorResponse(
45+
createStructuredError("ERR_PROJECT_NOT_CONFIGURED", "No Mendix project directory is configured.", {
46+
suggestion:
47+
"Set the MENDIX_PROJECT_DIR environment variable when starting the server, e.g.:\n" +
48+
" MENDIX_PROJECT_DIR=/Users/you/Mendix/MyProject node dist/index.js http\n" +
49+
"Or call set-project-directory to configure it at runtime."
50+
})
51+
);
52+
}
53+
54+
const validation = await validateProjectDir(state.projectDir);
55+
if (!validation.valid) {
56+
return createStructuredErrorResponse(
57+
createStructuredError(
58+
"ERR_PROJECT_NOT_CONFIGURED",
59+
`Configured project directory is invalid: ${validation.error}`,
60+
{ suggestion: "Use set-project-directory to set a valid Mendix project directory." }
61+
)
62+
);
63+
}
64+
65+
return createToolResponse(`✅ Project configured\n\n${formatProjectInfo(validation)}`);
66+
}
67+
);
68+
69+
server.registerTool(
70+
"set-project-directory",
71+
{
72+
title: "Set Project Directory",
73+
description:
74+
"Configures the Mendix project directory for this session. " +
75+
"The directory must exist and contain a .mpr file. " +
76+
"Once set, deploy-widget can copy built .mpk files to the project's widgets/ folder.",
77+
inputSchema: z.object({
78+
projectDir: z
79+
.string()
80+
.describe("Absolute path to the Mendix project directory (must contain a .mpr file)")
81+
})
82+
},
83+
async (args: { projectDir: string }): Promise<ToolResponse> => {
84+
const resolvedDir = resolve(args.projectDir);
85+
const validation = await validateProjectDir(resolvedDir);
86+
if (!validation.valid) {
87+
return createStructuredErrorResponse(
88+
createStructuredError(
89+
"ERR_PROJECT_NOT_CONFIGURED",
90+
`Invalid project directory: ${validation.error}`,
91+
{
92+
suggestion:
93+
"Provide the absolute path to a directory that exists and contains a .mpr file, e.g.:\n" +
94+
" /Users/you/Mendix/MyProject"
95+
}
96+
)
97+
);
98+
}
99+
100+
state.projectDir = validation.projectDir;
101+
return createToolResponse(`✅ Project directory configured\n\n${formatProjectInfo(validation)}`);
102+
}
103+
);
104+
105+
server.registerTool(
106+
"deploy-widget",
107+
{
108+
title: "Deploy Widget",
109+
description:
110+
"Copies a built widget .mpk file to the configured Mendix project's widgets/ directory. " +
111+
"Requires a project directory to be configured (via MENDIX_PROJECT_DIR env var or set-project-directory). " +
112+
"Looks for the .mpk file in the widget's dist/ directory. " +
113+
"After deploying, synchronize the app directory in Studio Pro to pick up the new widget.",
114+
inputSchema: z.object({
115+
widgetPath: z
116+
.string()
117+
.describe("Absolute path to the widget directory (the one containing package.json and dist/)")
118+
})
119+
},
120+
async (args: { widgetPath: string }): Promise<ToolResponse> => {
121+
if (!state.projectDir) {
122+
return createStructuredErrorResponse(
123+
createStructuredError("ERR_PROJECT_NOT_CONFIGURED", "No Mendix project directory is configured.", {
124+
suggestion:
125+
"Call get-project-info to check the current configuration, " +
126+
"or set-project-directory to configure a project directory."
127+
})
128+
);
129+
}
130+
131+
if (!isPathAllowed(args.widgetPath, state, "MCP_ALLOWED_BUILD_PATHS")) {
132+
return createStructuredErrorResponse(
133+
createStructuredError(
134+
"ERR_NOT_FOUND",
135+
`Widget path is not within an allowed directory: ${args.widgetPath}`,
136+
{
137+
suggestion: `Widget must be within ${GENERATIONS_DIR} or an allowed build path.`
138+
}
139+
)
140+
);
141+
}
142+
143+
const mpkPath = findMpkFile(args.widgetPath);
144+
if (!mpkPath) {
145+
return createStructuredErrorResponse(
146+
createStructuredError("ERR_MPK_NOT_FOUND", `No .mpk file found in ${args.widgetPath}/dist/`, {
147+
suggestion: "Run build-widget first to compile the widget and produce the .mpk file."
148+
})
149+
);
150+
}
151+
152+
const widgetsDir = join(state.projectDir, "widgets");
153+
try {
154+
await mkdir(widgetsDir, { recursive: true });
155+
const mpkFileName = basename(mpkPath);
156+
const destPath = join(widgetsDir, mpkFileName);
157+
await copyFile(mpkPath, destPath);
158+
159+
return createToolResponse(
160+
[
161+
`✅ Widget deployed successfully!`,
162+
``,
163+
`Source: ${mpkPath}`,
164+
`Destination: ${destPath}`,
165+
``,
166+
`Synchronize the app directory in Studio Pro to pick up the new widget.`
167+
].join("\n")
168+
);
169+
} catch (error) {
170+
const message = error instanceof Error ? error.message : String(error);
171+
return createStructuredErrorResponse(
172+
createStructuredError("ERR_DEPLOY_FAILED", `Failed to deploy widget: ${message}`, {
173+
suggestion: "Check write permissions on the widgets directory.",
174+
rawOutput: message
175+
})
176+
);
177+
}
178+
}
179+
);
180+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { MENDIX_PROJECT_DIR } from "@/config";
2+
3+
export interface SessionState {
4+
projectDir: string | undefined;
5+
}
6+
7+
/**
8+
* Creates a new session state, initialized from the MENDIX_PROJECT_DIR env var (if set).
9+
* Each MCP server instance gets its own state, so concurrent sessions are isolated.
10+
*/
11+
export function createSessionState(): SessionState {
12+
return {
13+
projectDir: MENDIX_PROJECT_DIR
14+
};
15+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { existsSync, readdirSync } from "node:fs";
2+
import { join } from "node:path";
3+
4+
/**
5+
* Finds the .mpk file in the widget's dist directory.
6+
* Searches recursively (usually in dist/x.x.x/).
7+
*/
8+
export function findMpkFile(widgetPath: string): string | undefined {
9+
const distPath = join(widgetPath, "dist");
10+
if (!existsSync(distPath)) return undefined;
11+
12+
try {
13+
const searchDir = (dir: string): string | undefined => {
14+
const entries = readdirSync(dir, { withFileTypes: true });
15+
for (const entry of entries) {
16+
const fullPath = join(dir, entry.name);
17+
if (entry.isDirectory()) {
18+
const found = searchDir(fullPath);
19+
if (found) return found;
20+
} else if (entry.name.endsWith(".mpk")) {
21+
return fullPath;
22+
}
23+
}
24+
return undefined;
25+
};
26+
return searchDir(distPath);
27+
} catch {
28+
return undefined;
29+
}
30+
}

packages/pluggable-widgets-mcp/src/tools/utils/response.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ export type ErrorCode =
1515
| "ERR_FILE_WRITE" // File write failure
1616
| "ERR_NOT_FOUND" // Resource not found
1717
| "ERR_OUTPUT_PATH_REQUIRED" // Output path required (e.g., in Claude Desktop)
18-
| "ERR_OUTPUT_PATH_INVALID"; // Output path is not accessible
18+
| "ERR_OUTPUT_PATH_INVALID" // Output path is not accessible
19+
| "ERR_PROJECT_NOT_CONFIGURED" // Project directory not configured or invalid
20+
| "ERR_MPK_NOT_FOUND" // Built .mpk file not found in dist/
21+
| "ERR_DEPLOY_FAILED"; // Failed to deploy .mpk to project widgets dir
1922

2023
/**
2124
* Structured error with code, message, and optional details.

0 commit comments

Comments
 (0)