@mandujs/mcp 0.29.0 → 0.30.0

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.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Mandu MCP Server - Agent-native interface for Mandu framework operations",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@mandujs/core": "^0.42.0",
38
- "@mandujs/ate": "^0.25.0",
38
+ "@mandujs/ate": "^0.25.1",
39
39
  "@mandujs/skills": "^0.18.0",
40
40
  "@modelcontextprotocol/sdk": "^1.25.3"
41
41
  },
@@ -57,7 +57,10 @@ export const ateRunToolDefinitions: Tool[] = [
57
57
  "`graphVersion` (agent cache invalidation key), and trace/screenshot/dom artifacts " +
58
58
  "staged under .mandu/ate-artifacts/<runId>/. Use `shard: { current, total }` to " +
59
59
  "distribute across CI workers. Emits notifications/progress per spec_done event. " +
60
- "On timeout / cancel, writes .mandu/reports/run-<runId>/results.json with partial state.",
60
+ "On timeout / cancel, writes .mandu/reports/run-<runId>/results.json with partial state. " +
61
+ "Issue #237 — `grep` narrows execution to specific `test(...)` titles inside the " +
62
+ "selected spec (forwarded to Playwright --grep / bun:test --test-name-pattern). " +
63
+ "For batch / multi-spec runs use mandu.ate.run (which also accepts onlyFiles + onlyRoutes).",
61
64
  inputSchema: {
62
65
  type: "object",
63
66
  properties: {
@@ -88,6 +91,12 @@ export const ateRunToolDefinitions: Tool[] = [
88
91
  type: "boolean",
89
92
  description: "Playwright only — capture trace. Default: true.",
90
93
  },
94
+ grep: {
95
+ type: "string",
96
+ description:
97
+ "Issue #237 — pass-through to Playwright --grep / bun:test --test-name-pattern. " +
98
+ "Filters by test-block title within the selected spec.",
99
+ },
91
100
  shard: {
92
101
  type: "object",
93
102
  properties: {
@@ -264,12 +273,13 @@ export function createAteProgressTracker(options: {
264
273
  export function ateRunTools(_projectRoot: string, server?: Server) {
265
274
  return {
266
275
  mandu_ate_run: async (args: Record<string, unknown>) => {
267
- const { repoRoot, spec, headed, trace, shard, progressToken } = args as {
276
+ const { repoRoot, spec, headed, trace, shard, grep, progressToken } = args as {
268
277
  repoRoot: string;
269
278
  spec: string | { path: string };
270
279
  headed?: boolean;
271
280
  trace?: boolean;
272
281
  shard?: { current: number; total: number };
282
+ grep?: string;
273
283
  progressToken?: string | number;
274
284
  };
275
285
  if (!repoRoot || typeof repoRoot !== "string") {
@@ -336,6 +346,7 @@ export function ateRunTools(_projectRoot: string, server?: Server) {
336
346
  headed,
337
347
  trace,
338
348
  shard,
349
+ grep,
339
350
  });
340
351
  } catch (err) {
341
352
  // Runner timeout / exec error — persist partial state so heal
package/src/tools/ate.ts CHANGED
@@ -93,7 +93,10 @@ export const ateToolDefinitions: Tool[] = [
93
93
  "Returns a runId for use with mandu.ate.report and mandu.ate.heal. " +
94
94
  "Streams notifications/progress per spec_done event (issue #238). " +
95
95
  "On timeout / kill, persists partial state under .mandu/reports/run-<runId>/results.json " +
96
- "so mandu.ate.heal remains reachable after the 10-min watchdog.",
96
+ "so mandu.ate.heal remains reachable after the 10-min watchdog. " +
97
+ "Issue #237 — scope filters (onlyFiles / onlyRoutes / grep) let callers " +
98
+ "run a single spec or route and stay under the 10-min MCP watchdog; omit " +
99
+ "them for the full suite.",
97
100
  inputSchema: {
98
101
  type: "object",
99
102
  properties: {
@@ -109,6 +112,26 @@ export const ateToolDefinitions: Tool[] = [
109
112
  items: { type: "string", enum: ["chromium", "firefox", "webkit"] },
110
113
  description: "Browsers to test against (default: ['chromium'])",
111
114
  },
115
+ onlyFiles: {
116
+ type: "array",
117
+ items: { type: "string" },
118
+ description:
119
+ "Issue #237 — explicit spec file paths (absolute or relative to repoRoot). " +
120
+ "Forwarded to Playwright as positional <file> args.",
121
+ },
122
+ onlyRoutes: {
123
+ type: "array",
124
+ items: { type: "string" },
125
+ description:
126
+ "Issue #237 — route ids (e.g. 'api-signup'). Resolved to spec paths via " +
127
+ "the spec-indexer. Unknown ids emit a warning; the remaining ids run.",
128
+ },
129
+ grep: {
130
+ type: "string",
131
+ description:
132
+ "Issue #237 — pass-through to Playwright --grep. Applied on top of the " +
133
+ "onlyFiles ∪ resolved(onlyRoutes) set.",
134
+ },
112
135
  progressToken: {
113
136
  type: ["string", "number"],
114
137
  description:
@@ -170,6 +193,10 @@ export const ateToolDefinitions: Tool[] = [
170
193
  "ATE Step 5 — Heal: Analyze test failures from a run and generate safe diff suggestions for fixing the code. " +
171
194
  "Classifies failures by root cause (schema mismatch, missing handler, wrong status, selector stale, etc.) " +
172
195
  "and produces reviewable diffs — never auto-commits or overwrites files. " +
196
+ "Requires a runId produced by mandu.ate.run (or the partial-results stub written " +
197
+ "on timeout under .mandu/reports/run-<runId>/results.json). " +
198
+ "See mandu.brain.status for LLM availability — template-based heals always run; " +
199
+ "LLM-assisted analysis activates when active_tier is openai or anthropic. " +
173
200
  "Use mandu.ate.apply_heal to apply a specific suggestion after review. " +
174
201
  "Supports rollback via mandu_rollback if applied changes cause regressions.",
175
202
  inputSchema: {
@@ -404,16 +431,38 @@ export function ateTools(projectRoot: string, server?: Server) {
404
431
  return ateGenerate({ repoRoot, oracleLevel, onlyRoutes });
405
432
  },
406
433
  "mandu.ate.run": async (args: Record<string, unknown>) => {
407
- const { repoRoot, baseURL, ci, headless, browsers, progressToken } = args as {
434
+ const {
435
+ repoRoot,
436
+ baseURL,
437
+ ci,
438
+ headless,
439
+ browsers,
440
+ onlyFiles,
441
+ onlyRoutes,
442
+ grep,
443
+ progressToken,
444
+ } = args as {
408
445
  repoRoot: string;
409
446
  baseURL?: string;
410
447
  ci?: boolean;
411
448
  headless?: boolean;
412
449
  browsers?: ("chromium" | "firefox" | "webkit")[];
450
+ onlyFiles?: string[];
451
+ onlyRoutes?: string[];
452
+ grep?: string;
413
453
  progressToken?: string | number;
414
454
  };
415
455
  return await runWithObservability(
416
- { repoRoot, baseURL, ci, headless, browsers },
456
+ {
457
+ repoRoot,
458
+ baseURL,
459
+ ci,
460
+ headless,
461
+ browsers,
462
+ onlyFiles,
463
+ onlyRoutes,
464
+ grep,
465
+ },
417
466
  { progressToken },
418
467
  );
419
468
  },
@@ -33,7 +33,10 @@ export const brainToolDefinitions: Tool[] = [
33
33
  {
34
34
  name: "mandu.brain.doctor",
35
35
  description:
36
- "Analyze Guard failures and suggest patches. Works with or without LLM - template-based analysis is always available.",
36
+ "Analyze Guard failures and suggest patches. Returns early with no LLM call when " +
37
+ "guard has no violations (passed: true). When violations are present and an LLM " +
38
+ "tier is active (see mandu.brain.status), runs LLM-assisted analysis and returns " +
39
+ "llmAssisted: true. Template-based analysis is always available as a fallback.",
37
40
  annotations: {
38
41
  readOnlyHint: true,
39
42
  },
@@ -212,6 +215,32 @@ export const brainToolDefinitions: Tool[] = [
212
215
  /** Module-level unsubscribe handle for MCP warning notifications */
213
216
  let mcpWarningUnsubscribe: (() => void) | null = null;
214
217
 
218
+ /**
219
+ * Issue #237 Concern 4 — build a list of next-step suggestions for the
220
+ * currently resolved brain tier. Exposed for unit tests so the tier →
221
+ * suggestion mapping is pinned without spinning up a credential store.
222
+ *
223
+ * - openai / anthropic tiers → point at the LLM-heal loop + guard doctor.
224
+ * - ollama / template tiers → point at `mandu brain login` for higher
225
+ * quality. Everyone also gets a generic status pointer.
226
+ */
227
+ export function buildBrainStatusSuggestions(activeTier: string): string[] {
228
+ const suggestions: string[] = [];
229
+ if (activeTier === "openai" || activeTier === "anthropic") {
230
+ suggestions.push(
231
+ "Run mandu.ate.auto_pipeline or mandu.ate.run followed by mandu.ate.heal to exercise the LLM-healing loop.",
232
+ );
233
+ suggestions.push(
234
+ "Call mandu.brain.doctor after a mandu.guard.check failure to get LLM-assisted diagnosis + patch suggestions.",
235
+ );
236
+ } else if (activeTier === "ollama" || activeTier === "template") {
237
+ suggestions.push(
238
+ "Run `mandu brain login --provider=openai` (or --provider=anthropic) to unlock higher-quality LLM-assisted heal + doctor output.",
239
+ );
240
+ }
241
+ return suggestions;
242
+ }
243
+
215
244
  /**
216
245
  * #236 — surface a clear error when a stale `@mandujs/core` resolves
217
246
  * under `node_modules/@mandujs/mcp/node_modules/` (Bun's installer
@@ -669,6 +698,12 @@ export function brainTools(projectRoot: string, server?: Server, monitor?: Activ
669
698
  : { logged_in: false };
670
699
  }
671
700
 
701
+ // Issue #237 Concern 4 — surface next-step suggestions keyed to the
702
+ // active tier so agents can find the LLM invocation paths without
703
+ // grep-archaeology. LLM tiers point at ate.heal / brain.doctor;
704
+ // offline tiers point at `mandu brain login` for an upgrade.
705
+ const suggestions = buildBrainStatusSuggestions(resolution.resolved);
706
+
672
707
  return {
673
708
  content: [
674
709
  {
@@ -679,6 +714,7 @@ export function brainTools(projectRoot: string, server?: Server, monitor?: Activ
679
714
  reason: resolution.reason,
680
715
  backend: store.backendName,
681
716
  providers,
717
+ suggestions,
682
718
  },
683
719
  null,
684
720
  2,
@@ -11,9 +11,93 @@ import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
11
11
  import type { ActivityMonitor } from "../activity-monitor.js";
12
12
  import { spawn, type Subprocess } from "bun";
13
13
  import { execSync } from "child_process";
14
+ import { createConnection } from "node:net";
14
15
  import path from "path";
15
16
  import fs from "fs/promises";
16
17
 
18
+ /**
19
+ * Issue #237 Concern 3 — read `server.port` from `mandu.config.*` so
20
+ * `mandu.dev.start` can poll a deterministic port instead of timing
21
+ * out while scraping stdout. Returns `null` if the config is absent
22
+ * or doesn't explicitly set `server.port` (callers fall back to 3333,
23
+ * Mandu's documented default). We use the un-schema'd raw loader
24
+ * (`loadManduConfig`) so the schema's fill-in default doesn't mask a
25
+ * missing value — a user who set no port should poll 3333, not the
26
+ * schema's internal default.
27
+ *
28
+ * We intentionally catch every error — a brittle config reader here
29
+ * must never block dev_start. The polling path still proves liveness.
30
+ */
31
+ export async function readConfiguredServerPort(
32
+ cwd: string,
33
+ ): Promise<number | null> {
34
+ try {
35
+ const core = await import("@mandujs/core");
36
+ const raw = await core.loadManduConfig(cwd);
37
+ const port = raw?.server?.port;
38
+ if (typeof port === "number" && Number.isFinite(port) && port > 0) {
39
+ return port;
40
+ }
41
+ } catch {
42
+ /* ignore — fall back to default */
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Issue #237 Concern 3 — TCP connect probe. Resolves `true` on the
49
+ * first successful `connect`, `false` on any error or timeout.
50
+ * `node:net` is Node builtin and ships with Bun; no new dependency.
51
+ */
52
+ export function probeTcpPort(
53
+ port: number,
54
+ hostname: string,
55
+ timeoutMs: number,
56
+ ): Promise<boolean> {
57
+ return new Promise((resolve) => {
58
+ const sock = createConnection({ host: hostname, port });
59
+ const done = (ok: boolean) => {
60
+ try {
61
+ sock.destroy();
62
+ } catch {
63
+ /* noop */
64
+ }
65
+ resolve(ok);
66
+ };
67
+ sock.setTimeout(timeoutMs);
68
+ sock.once("connect", () => done(true));
69
+ sock.once("timeout", () => done(false));
70
+ sock.once("error", () => done(false));
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Issue #237 Concern 3 — poll the configured port at a fixed interval
76
+ * until `waitMs` elapses. Returns the port on success, `null` on
77
+ * timeout. The caller chooses whether to fall back to the stdout
78
+ * scrape or report `port: <polled>` alongside the timeout message.
79
+ */
80
+ export async function pollServerPort(
81
+ port: number,
82
+ hostname: string,
83
+ waitMs: number,
84
+ intervalMs = 200,
85
+ ): Promise<number | null> {
86
+ const deadline = Date.now() + waitMs;
87
+ while (Date.now() < deadline) {
88
+ const remaining = deadline - Date.now();
89
+ const probeTimeout = Math.min(500, Math.max(50, remaining));
90
+ if (await probeTcpPort(port, hostname, probeTimeout)) {
91
+ return port;
92
+ }
93
+ const sleep = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
94
+ if (sleep > 0) {
95
+ await new Promise((r) => setTimeout(r, sleep));
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
17
101
  type DevServerState = {
18
102
  process: Subprocess;
19
103
  cwd: string;
@@ -173,7 +257,11 @@ export const projectToolDefinitions: Tool[] = [
173
257
  },
174
258
  {
175
259
  name: "mandu.dev.start",
176
- description: "Start Mandu dev server (bun run dev).",
260
+ description:
261
+ "Start Mandu dev server (bun run dev). Issue #237 — polls server.port from " +
262
+ "mandu.config.ts (fallback 3333) via TCP connect for up to waitMs (default 15s) " +
263
+ "before declaring a port-detection timeout. On success: { port, url, message }. " +
264
+ "On timeout: still returns { port: <polled>, message } so callers can retry / probe.",
177
265
  annotations: {
178
266
  readOnlyHint: false,
179
267
  },
@@ -184,6 +272,12 @@ export const projectToolDefinitions: Tool[] = [
184
272
  type: "string",
185
273
  description: "Project directory to run dev server in (default: current project)",
186
274
  },
275
+ waitMs: {
276
+ type: "number",
277
+ description:
278
+ "How long (ms) to wait for the dev server to accept TCP connections on " +
279
+ "the configured port. Default 15000 (15s).",
280
+ },
187
281
  },
188
282
  required: [],
189
283
  },
@@ -318,7 +412,7 @@ export function projectTools(projectRoot: string, server?: Server, monitor?: Act
318
412
  },
319
413
 
320
414
  "mandu.dev.start": async (args: Record<string, unknown>) => {
321
- const { cwd } = args as { cwd?: string };
415
+ const { cwd, waitMs } = args as { cwd?: string; waitMs?: number };
322
416
  if (devServerState || devServerStarting) {
323
417
  return {
324
418
  success: false,
@@ -334,6 +428,22 @@ export function projectTools(projectRoot: string, server?: Server, monitor?: Act
334
428
  try {
335
429
  const targetDir = cwd ? path.resolve(projectRoot, cwd) : projectRoot;
336
430
 
431
+ // Issue #237 Concern 3 — read `server.port` from mandu.config.*
432
+ // so we can poll a deterministic port instead of racing a
433
+ // regex against stdout. The env override takes precedence (it
434
+ // also takes precedence in the CLI — see cli/commands/dev.ts).
435
+ // Fall back to 3333 (Mandu's default) when neither is set.
436
+ const envPort = process.env.PORT ? Number(process.env.PORT) : null;
437
+ const configPort =
438
+ envPort && Number.isFinite(envPort)
439
+ ? envPort
440
+ : await readConfiguredServerPort(targetDir);
441
+ const polledPort = configPort ?? 3333;
442
+ const pollWaitMs =
443
+ typeof waitMs === "number" && Number.isFinite(waitMs) && waitMs > 0
444
+ ? waitMs
445
+ : 15_000;
446
+
337
447
  const proc = spawn(["bun", "run", "dev"], {
338
448
  cwd: targetDir,
339
449
  stdout: "pipe",
@@ -350,32 +460,6 @@ export function projectTools(projectRoot: string, server?: Server, monitor?: Act
350
460
  };
351
461
  devServerState = state;
352
462
 
353
- // Wait for the server to output its port before returning
354
- const portPromise = new Promise<{ port: number; url: string } | null>((resolve) => {
355
- const PORT_DETECT_TIMEOUT_MS = 15_000;
356
- const timeout = setTimeout(() => resolve(null), PORT_DETECT_TIMEOUT_MS);
357
- const portPattern = /https?:\/\/[^:\s]+:(\d+)/;
358
-
359
- const originalPush = state.output.push.bind(state.output);
360
- state.output.push = (...items: string[]) => {
361
- const result = originalPush(...items);
362
- for (const item of items) {
363
- const match = item.match(portPattern);
364
- if (match) {
365
- const detectedPort = parseInt(match[1], 10);
366
- clearTimeout(timeout);
367
- state.output.push = originalPush;
368
- resolve({
369
- port: detectedPort,
370
- url: match[0],
371
- });
372
- break;
373
- }
374
- }
375
- return result;
376
- };
377
- });
378
-
379
463
  consumeStream(proc.stdout, state, "stdout", server).catch(() => {});
380
464
  consumeStream(proc.stderr, state, "stderr", server).catch(() => {});
381
465
 
@@ -389,19 +473,28 @@ export function projectTools(projectRoot: string, server?: Server, monitor?: Act
389
473
  monitor.logEvent("dev", `Dev server started (${targetDir})`);
390
474
  }
391
475
 
392
- // Wait for port detection (with timeout fallback)
393
- const detected = await portPromise;
476
+ // Issue #237 Concern 3 TCP poll the expected port. 127.0.0.1
477
+ // matches what the CLI prints; dual-stack (`::`) binds accept
478
+ // loopback v4 connects. We use 127.0.0.1 because `localhost`
479
+ // resolution varies across Windows + macOS.
480
+ const detectedPort = await pollServerPort(
481
+ polledPort,
482
+ "127.0.0.1",
483
+ pollWaitMs,
484
+ );
485
+
486
+ const url = detectedPort ? `http://localhost:${detectedPort}` : null;
394
487
 
395
488
  return {
396
489
  success: true,
397
490
  pid: proc.pid,
398
- port: detected?.port ?? null,
399
- url: detected?.url ?? null,
491
+ port: detectedPort ?? polledPort,
492
+ url,
400
493
  cwd: targetDir,
401
494
  startedAt: state.startedAt.toISOString(),
402
- message: detected
403
- ? `Dev server started on port ${detected.port}`
404
- : "Dev server started (port detection timed out)",
495
+ message: detectedPort
496
+ ? `Dev server ready at http://localhost:${detectedPort}`
497
+ : `Dev server started (port detection timed out after ${pollWaitMs}ms polling ${polledPort})`,
405
498
  };
406
499
  } finally {
407
500
  devServerStarting = false;