@mandujs/mcp 0.18.3 → 0.18.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/mcp",
3
- "version": "0.18.3",
3
+ "version": "0.18.4",
4
4
  "description": "Mandu MCP Server - Agent-native interface for Mandu framework operations",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -7,7 +7,7 @@
7
7
 
8
8
  import fs from "fs";
9
9
  import path from "path";
10
- import { spawn, type ChildProcess } from "child_process";
10
+ import type { Subprocess } from "bun";
11
11
 
12
12
  const TOOL_ICONS: Record<string, string> = {
13
13
  // Spec
@@ -266,7 +266,7 @@ function writeDefaultConfig(projectRoot: string, config: Required<MonitorConfig>
266
266
  export class ActivityMonitor {
267
267
  private logFile = "";
268
268
  private logStream: fs.WriteStream | null = null;
269
- private tailProcess: ChildProcess | null = null;
269
+ private tailProcess: Subprocess | null = null;
270
270
  private projectRoot: string;
271
271
  private config: Required<MonitorConfig>;
272
272
  private outputFormat: MonitorOutputFormat;
@@ -813,9 +813,9 @@ export class ActivityMonitor {
813
813
  private openTerminal(): void {
814
814
  try {
815
815
  if (process.platform === "win32") {
816
- this.tailProcess = spawn(
817
- "cmd",
816
+ this.tailProcess = Bun.spawn(
818
817
  [
818
+ "cmd",
819
819
  "/c",
820
820
  "start",
821
821
  "Mandu Activity Monitor",
@@ -824,22 +824,19 @@ export class ActivityMonitor {
824
824
  "-Command",
825
825
  `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; chcp 65001 | Out-Null; Get-Content '${this.logFile}' -Wait -Encoding UTF8`,
826
826
  ],
827
- { cwd: this.projectRoot, detached: true, stdio: "ignore" }
827
+ { cwd: this.projectRoot, stdio: ["ignore", "ignore", "ignore"] }
828
828
  );
829
829
  } else if (process.platform === "darwin") {
830
- this.tailProcess = spawn(
831
- "osascript",
832
- ["-e", `tell application "Terminal" to do script "tail -f '${this.logFile}'"`],
833
- { detached: true, stdio: "ignore" }
830
+ this.tailProcess = Bun.spawn(
831
+ ["osascript", "-e", `tell application "Terminal" to do script "tail -f '${this.logFile}'"`],
832
+ { stdio: ["ignore", "ignore", "ignore"] }
834
833
  );
835
834
  } else {
836
- this.tailProcess = spawn(
837
- "x-terminal-emulator",
838
- ["-e", `tail -f '${this.logFile}'`],
839
- { cwd: this.projectRoot, detached: true, stdio: "ignore" }
835
+ this.tailProcess = Bun.spawn(
836
+ ["x-terminal-emulator", "-e", `tail -f '${this.logFile}'`],
837
+ { cwd: this.projectRoot, stdio: ["ignore", "ignore", "ignore"] }
840
838
  );
841
839
  }
842
- this.tailProcess?.unref();
843
840
  } catch {
844
841
  // Terminal auto-open failed silently
845
842
  }
package/src/server.ts CHANGED
@@ -322,7 +322,10 @@ export class ManduMcpServer {
322
322
  * 리소스 패턴 매칭
323
323
  */
324
324
  function matchResourcePattern(pattern: string, uri: string): boolean {
325
- const regexPattern = pattern.replace(/\{[^}]+\}/g, "([^/]+)");
325
+ const regexPattern = pattern
326
+ .split(/\{[^}]+\}/)
327
+ .map(part => part.replace(/[.+*?^${}()|[\]\\]/g, "\\$&"))
328
+ .join("([^/]+)");
326
329
  const regex = new RegExp(`^${regexPattern}$`);
327
330
  return regex.test(uri);
328
331
  }
@@ -332,10 +335,16 @@ function matchResourcePattern(pattern: string, uri: string): boolean {
332
335
  */
333
336
  function extractResourceParams(pattern: string, uri: string): Record<string, string> {
334
337
  const paramNames: string[] = [];
335
- const regexPattern = pattern.replace(/\{([^}]+)\}/g, (_, name) => {
336
- paramNames.push(name);
337
- return "([^/]+)";
338
- });
338
+ const regexPattern = pattern
339
+ .split(/\{([^}]+)\}/)
340
+ .map((part, index) => {
341
+ if (index % 2 === 1) {
342
+ paramNames.push(part);
343
+ return "([^/]+)";
344
+ }
345
+ return part.replace(/[.+*?^${}()|[\]\\]/g, "\\$&");
346
+ })
347
+ .join("");
339
348
 
340
349
  const regex = new RegExp(`^${regexPattern}$`);
341
350
  const match = uri.match(regex);
@@ -26,7 +26,7 @@ import {
26
26
  generateJsonStatus,
27
27
  initializeArchitectureAnalyzer,
28
28
  getArchitectureAnalyzer,
29
- } from "../../../core/src/index.js";
29
+ } from "@mandujs/core";
30
30
  import { getProjectPaths } from "../utils/project.js";
31
31
 
32
32
  export const brainToolDefinitions: Tool[] = [
@@ -22,23 +22,36 @@ type DevServerState = {
22
22
  };
23
23
 
24
24
  let devServerState: DevServerState | null = null;
25
+ let devServerStarting = false;
25
26
 
26
27
  function trimOutput(text: string, maxChars: number = 4000): string {
27
28
  if (text.length <= maxChars) return text;
28
29
  return text.slice(-maxChars);
29
30
  }
30
31
 
31
- async function runCommand(cmd: string[], cwd: string) {
32
+ const COMMAND_TIMEOUT_MS = 120_000; // 2 minutes
33
+
34
+ async function runCommand(cmd: string[], cwd: string, timeoutMs: number = COMMAND_TIMEOUT_MS) {
32
35
  const proc = spawn(cmd, {
33
36
  cwd,
34
37
  stdout: "pipe",
35
38
  stderr: "pipe",
36
39
  });
37
40
 
38
- const [stdout, stderr, exitCode] = await Promise.all([
39
- new Response(proc.stdout).text(),
40
- new Response(proc.stderr).text(),
41
- proc.exited,
41
+ const timeoutPromise = new Promise<never>((_, reject) =>
42
+ setTimeout(() => {
43
+ proc.kill();
44
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd.join(" ")}`));
45
+ }, timeoutMs)
46
+ );
47
+
48
+ const [stdout, stderr, exitCode] = await Promise.race([
49
+ Promise.all([
50
+ new Response(proc.stdout).text(),
51
+ new Response(proc.stderr).text(),
52
+ proc.exited,
53
+ ]),
54
+ timeoutPromise,
42
55
  ]);
43
56
 
44
57
  return {
@@ -192,6 +205,16 @@ export function projectTools(projectRoot: string, server?: Server, monitor?: Act
192
205
 
193
206
  await fs.mkdir(baseDir, { recursive: true });
194
207
 
208
+ // Runtime whitelist validation for spawn arguments
209
+ const VALID_CSS = ["tailwind", "panda", "none"];
210
+ const VALID_UI = ["shadcn", "ark", "none"];
211
+ if (css !== undefined && !VALID_CSS.includes(css)) {
212
+ return { success: false, error: `Invalid css value: ${css}. Must be one of: ${VALID_CSS.join(", ")}` };
213
+ }
214
+ if (ui !== undefined && !VALID_UI.includes(ui)) {
215
+ return { success: false, error: `Invalid ui value: ${ui}. Must be one of: ${VALID_UI.join(", ")}` };
216
+ }
217
+
195
218
  const initArgs = ["@mandujs/cli", "init", name];
196
219
  if (minimal) {
197
220
  initArgs.push("--minimal");
@@ -201,7 +224,16 @@ export function projectTools(projectRoot: string, server?: Server, monitor?: Act
201
224
  if (theme) initArgs.push("--theme");
202
225
  }
203
226
 
204
- const initResult = await runCommand(["bunx", ...initArgs], baseDir);
227
+ let initResult: { exitCode: number | null; stdout: string; stderr: string };
228
+ try {
229
+ initResult = await runCommand(["bunx", ...initArgs], baseDir);
230
+ } catch (err) {
231
+ return {
232
+ success: false,
233
+ step: "init",
234
+ error: err instanceof Error ? err.message : String(err),
235
+ };
236
+ }
205
237
  if (initResult.exitCode !== 0) {
206
238
  return {
207
239
  success: false,
@@ -216,7 +248,16 @@ export function projectTools(projectRoot: string, server?: Server, monitor?: Act
216
248
 
217
249
  let installResult: { exitCode: number | null; stdout: string; stderr: string } | null = null;
218
250
  if (install !== false) {
219
- installResult = await runCommand(["bun", "install"], projectDir);
251
+ try {
252
+ installResult = await runCommand(["bun", "install"], projectDir);
253
+ } catch (err) {
254
+ return {
255
+ success: false,
256
+ step: "install",
257
+ projectDir,
258
+ error: err instanceof Error ? err.message : String(err),
259
+ };
260
+ }
220
261
  if (installResult.exitCode !== 0) {
221
262
  return {
222
263
  success: false,
@@ -253,53 +294,60 @@ export function projectTools(projectRoot: string, server?: Server, monitor?: Act
253
294
 
254
295
  mandu_dev_start: async (args: Record<string, unknown>) => {
255
296
  const { cwd } = args as { cwd?: string };
256
- if (devServerState) {
297
+ if (devServerState || devServerStarting) {
257
298
  return {
258
299
  success: false,
259
- message: "Dev server is already running",
260
- pid: devServerState.process.pid,
261
- cwd: devServerState.cwd,
300
+ message: devServerStarting
301
+ ? "Dev server is starting up, please wait"
302
+ : "Dev server is already running",
303
+ pid: devServerState?.process.pid,
304
+ cwd: devServerState?.cwd,
262
305
  };
263
306
  }
264
307
 
265
- const targetDir = cwd ? path.resolve(projectRoot, cwd) : projectRoot;
266
-
267
- const proc = spawn(["bun", "run", "dev"], {
268
- cwd: targetDir,
269
- stdout: "pipe",
270
- stderr: "pipe",
271
- stdin: "ignore",
272
- });
273
-
274
- const state: DevServerState = {
275
- process: proc,
276
- cwd: targetDir,
277
- startedAt: new Date(),
278
- output: [],
279
- maxLines: 50,
280
- };
281
- devServerState = state;
308
+ devServerStarting = true;
309
+ try {
310
+ const targetDir = cwd ? path.resolve(projectRoot, cwd) : projectRoot;
311
+
312
+ const proc = spawn(["bun", "run", "dev"], {
313
+ cwd: targetDir,
314
+ stdout: "pipe",
315
+ stderr: "pipe",
316
+ stdin: "ignore",
317
+ });
318
+
319
+ const state: DevServerState = {
320
+ process: proc,
321
+ cwd: targetDir,
322
+ startedAt: new Date(),
323
+ output: [],
324
+ maxLines: 50,
325
+ };
326
+ devServerState = state;
327
+
328
+ consumeStream(proc.stdout, state, "stdout", server).catch(() => {});
329
+ consumeStream(proc.stderr, state, "stderr", server).catch(() => {});
282
330
 
283
- consumeStream(proc.stdout, state, "stdout", server).catch(() => {});
284
- consumeStream(proc.stderr, state, "stderr", server).catch(() => {});
331
+ proc.exited.then(() => {
332
+ if (devServerState?.process === proc) {
333
+ devServerState = null;
334
+ }
335
+ }).catch(() => {});
285
336
 
286
- proc.exited.then(() => {
287
- if (devServerState?.process === proc) {
288
- devServerState = null;
337
+ if (monitor) {
338
+ monitor.logEvent("dev", `Dev server started (${targetDir})`);
289
339
  }
290
- }).catch(() => {});
291
340
 
292
- if (monitor) {
293
- monitor.logEvent("dev", `Dev server started (${targetDir})`);
341
+ return {
342
+ success: true,
343
+ pid: proc.pid,
344
+ cwd: targetDir,
345
+ startedAt: state.startedAt.toISOString(),
346
+ message: "Dev server started",
347
+ };
348
+ } finally {
349
+ devServerStarting = false;
294
350
  }
295
-
296
- return {
297
- success: true,
298
- pid: proc.pid,
299
- cwd: targetDir,
300
- startedAt: state.startedAt.toISOString(),
301
- message: "Dev server started",
302
- };
303
351
  },
304
352
 
305
353
  mandu_dev_stop: async () => {
@@ -54,7 +54,7 @@ export function getProjectPaths(rootDir: string) {
54
54
  export function isInsideProject(filePath: string, rootDir: string): boolean {
55
55
  const resolved = path.resolve(filePath);
56
56
  const root = path.resolve(rootDir);
57
- return resolved.startsWith(root);
57
+ return resolved === root || resolved.startsWith(root + path.sep);
58
58
  }
59
59
 
60
60
  /**
@@ -73,7 +73,9 @@ export async function readJsonFile<T>(filePath: string): Promise<T | null> {
73
73
  }
74
74
 
75
75
  /**
76
- * Write JSON file safely
76
+ * Write JSON file safely.
77
+ * Note: Callers are responsible for ensuring filePath is within the project root.
78
+ * All internal callers use paths derived from getProjectPaths() which are scoped to projectRoot.
77
79
  */
78
80
  export async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
79
81
  const dir = path.dirname(filePath);