@mandujs/mcp 0.28.2 → 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 +3 -3
- package/src/activity-monitor.ts +176 -0
- package/src/tools/ate-run.ts +404 -154
- package/src/tools/ate.ts +154 -5
- package/src/tools/brain.ts +37 -1
- package/src/tools/index.ts +33 -9
- package/src/tools/project.ts +128 -35
package/src/tools/ate.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
3
|
import {
|
|
3
4
|
ateExtract,
|
|
4
5
|
ateGenerate,
|
|
@@ -13,7 +14,13 @@ import {
|
|
|
13
14
|
detectCoverageGaps,
|
|
14
15
|
precommitCheck,
|
|
15
16
|
} from "@mandujs/ate";
|
|
16
|
-
import type { OracleLevel } from "@mandujs/ate";
|
|
17
|
+
import type { OracleLevel, AteMonitorEvent, FailureV1 } from "@mandujs/ate";
|
|
18
|
+
import { eventBus } from "@mandujs/core/observability";
|
|
19
|
+
import {
|
|
20
|
+
writePartialResults,
|
|
21
|
+
createAteProgressTracker,
|
|
22
|
+
type PartialRunResults,
|
|
23
|
+
} from "./ate-run.js";
|
|
17
24
|
|
|
18
25
|
export const ateToolDefinitions: Tool[] = [
|
|
19
26
|
{
|
|
@@ -83,7 +90,13 @@ export const ateToolDefinitions: Tool[] = [
|
|
|
83
90
|
"ATE Step 3 — Run: Execute the generated Playwright specs against a running Mandu dev server. " +
|
|
84
91
|
"Collects test artifacts (screenshots, traces, results) in .mandu/ate/runs/{runId}/. " +
|
|
85
92
|
"Requires the Mandu dev server to be running (use mandu_dev_start first). " +
|
|
86
|
-
"Returns a runId for use with mandu.ate.report and mandu.ate.heal."
|
|
93
|
+
"Returns a runId for use with mandu.ate.report and mandu.ate.heal. " +
|
|
94
|
+
"Streams notifications/progress per spec_done event (issue #238). " +
|
|
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. " +
|
|
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.",
|
|
87
100
|
inputSchema: {
|
|
88
101
|
type: "object",
|
|
89
102
|
properties: {
|
|
@@ -99,6 +112,32 @@ export const ateToolDefinitions: Tool[] = [
|
|
|
99
112
|
items: { type: "string", enum: ["chromium", "firefox", "webkit"] },
|
|
100
113
|
description: "Browsers to test against (default: ['chromium'])",
|
|
101
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
|
+
},
|
|
135
|
+
progressToken: {
|
|
136
|
+
type: ["string", "number"],
|
|
137
|
+
description:
|
|
138
|
+
"Optional MCP progress token. When present, per-spec progress notifications are " +
|
|
139
|
+
"sent with this token so the client can correlate them with the originating call.",
|
|
140
|
+
},
|
|
102
141
|
},
|
|
103
142
|
required: ["repoRoot"],
|
|
104
143
|
},
|
|
@@ -154,6 +193,10 @@ export const ateToolDefinitions: Tool[] = [
|
|
|
154
193
|
"ATE Step 5 — Heal: Analyze test failures from a run and generate safe diff suggestions for fixing the code. " +
|
|
155
194
|
"Classifies failures by root cause (schema mismatch, missing handler, wrong status, selector stale, etc.) " +
|
|
156
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. " +
|
|
157
200
|
"Use mandu.ate.apply_heal to apply a specific suggestion after review. " +
|
|
158
201
|
"Supports rollback via mandu_rollback if applied changes cause regressions.",
|
|
159
202
|
inputSchema: {
|
|
@@ -288,7 +331,87 @@ export const ateToolDefinitions: Tool[] = [
|
|
|
288
331
|
},
|
|
289
332
|
];
|
|
290
333
|
|
|
291
|
-
export function ateTools(projectRoot: string) {
|
|
334
|
+
export function ateTools(projectRoot: string, server?: Server) {
|
|
335
|
+
/**
|
|
336
|
+
* Shared subscription helper for `mandu.ate.run`. Wraps ateRun (which
|
|
337
|
+
* drives Playwright) with eventBus listeners so per-spec progress
|
|
338
|
+
* notifications flow through the MCP transport and a partial
|
|
339
|
+
* results.json is persisted on timeout / kill. Downstream consumers
|
|
340
|
+
* can then hand the runId to `mandu.ate.heal` even when the 10-min
|
|
341
|
+
* watchdog fired mid-run.
|
|
342
|
+
*/
|
|
343
|
+
const runWithObservability = async (
|
|
344
|
+
input: Parameters<typeof ateRun>[0],
|
|
345
|
+
opts: { progressToken?: string | number } = {},
|
|
346
|
+
) => {
|
|
347
|
+
const started = new Date().toISOString();
|
|
348
|
+
|
|
349
|
+
const tracker = createAteProgressTracker({
|
|
350
|
+
progressToken: opts.progressToken,
|
|
351
|
+
sendProgress: async (progress, total, message) => {
|
|
352
|
+
if (!server) return;
|
|
353
|
+
const snap = tracker.snapshot();
|
|
354
|
+
const token = opts.progressToken ?? snap.runId;
|
|
355
|
+
if (!token) return;
|
|
356
|
+
try {
|
|
357
|
+
await server.notification({
|
|
358
|
+
method: "notifications/progress",
|
|
359
|
+
params: { progressToken: token, progress, total, message },
|
|
360
|
+
});
|
|
361
|
+
} catch {
|
|
362
|
+
/* transport offline — never fail the run */
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const unsubscribe = eventBus.on("ate", (event) => {
|
|
368
|
+
try {
|
|
369
|
+
const data = event.data as unknown as AteMonitorEvent | undefined;
|
|
370
|
+
if (!data || typeof data.kind !== "string") return;
|
|
371
|
+
tracker.handle(data);
|
|
372
|
+
} catch {
|
|
373
|
+
/* swallow — never break the run */
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
return await ateRun(input);
|
|
379
|
+
} catch (err) {
|
|
380
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
381
|
+
const isTimeout = /timed out/i.test(message);
|
|
382
|
+
const snap = tracker.snapshot();
|
|
383
|
+
const partial: PartialRunResults = {
|
|
384
|
+
runId: snap.runId ?? `unknown-${Date.now()}`,
|
|
385
|
+
status: isTimeout ? "timed_out" : "error",
|
|
386
|
+
graphVersion: snap.graphVersion,
|
|
387
|
+
completedSpecs: snap.completedSpecs,
|
|
388
|
+
inProgressSpec: snap.inProgressSpec,
|
|
389
|
+
failures: snap.failures,
|
|
390
|
+
startedAt: started,
|
|
391
|
+
killedAt: new Date().toISOString(),
|
|
392
|
+
error: message,
|
|
393
|
+
};
|
|
394
|
+
const resultsPath = writePartialResults(input.repoRoot, partial);
|
|
395
|
+
return {
|
|
396
|
+
ok: false,
|
|
397
|
+
error: `ateRun failed: ${message}`,
|
|
398
|
+
partial,
|
|
399
|
+
resultsPath,
|
|
400
|
+
runId: partial.runId,
|
|
401
|
+
};
|
|
402
|
+
} finally {
|
|
403
|
+
try {
|
|
404
|
+
unsubscribe();
|
|
405
|
+
} catch {
|
|
406
|
+
/* no-op */
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
// Reserved for future use (progress capability detection). Not used
|
|
411
|
+
// during registration today but documented on the closure so the
|
|
412
|
+
// next caller understands the parameter shape.
|
|
413
|
+
void projectRoot;
|
|
414
|
+
|
|
292
415
|
return {
|
|
293
416
|
"mandu.ate.extract": async (args: Record<string, unknown>) => {
|
|
294
417
|
const { repoRoot, tsconfigPath, routeGlobs, buildSalt } = args as {
|
|
@@ -308,14 +431,40 @@ export function ateTools(projectRoot: string) {
|
|
|
308
431
|
return ateGenerate({ repoRoot, oracleLevel, onlyRoutes });
|
|
309
432
|
},
|
|
310
433
|
"mandu.ate.run": async (args: Record<string, unknown>) => {
|
|
311
|
-
const {
|
|
434
|
+
const {
|
|
435
|
+
repoRoot,
|
|
436
|
+
baseURL,
|
|
437
|
+
ci,
|
|
438
|
+
headless,
|
|
439
|
+
browsers,
|
|
440
|
+
onlyFiles,
|
|
441
|
+
onlyRoutes,
|
|
442
|
+
grep,
|
|
443
|
+
progressToken,
|
|
444
|
+
} = args as {
|
|
312
445
|
repoRoot: string;
|
|
313
446
|
baseURL?: string;
|
|
314
447
|
ci?: boolean;
|
|
315
448
|
headless?: boolean;
|
|
316
449
|
browsers?: ("chromium" | "firefox" | "webkit")[];
|
|
450
|
+
onlyFiles?: string[];
|
|
451
|
+
onlyRoutes?: string[];
|
|
452
|
+
grep?: string;
|
|
453
|
+
progressToken?: string | number;
|
|
317
454
|
};
|
|
318
|
-
return await
|
|
455
|
+
return await runWithObservability(
|
|
456
|
+
{
|
|
457
|
+
repoRoot,
|
|
458
|
+
baseURL,
|
|
459
|
+
ci,
|
|
460
|
+
headless,
|
|
461
|
+
browsers,
|
|
462
|
+
onlyFiles,
|
|
463
|
+
onlyRoutes,
|
|
464
|
+
grep,
|
|
465
|
+
},
|
|
466
|
+
{ progressToken },
|
|
467
|
+
);
|
|
319
468
|
},
|
|
320
469
|
"mandu.ate.report": async (args: Record<string, unknown>) => {
|
|
321
470
|
const { repoRoot, runId, startedAt, finishedAt, exitCode, oracleLevel, format, impact } = args as {
|
package/src/tools/brain.ts
CHANGED
|
@@ -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.
|
|
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,
|
package/src/tools/index.ts
CHANGED
|
@@ -160,7 +160,19 @@ interface ToolModule {
|
|
|
160
160
|
server?: Server,
|
|
161
161
|
monitor?: ActivityMonitor
|
|
162
162
|
) => Record<string, (args: Record<string, unknown>) => Promise<unknown>>;
|
|
163
|
+
/**
|
|
164
|
+
* Hard requirement: skip registration entirely when `server` is
|
|
165
|
+
* absent. Used for tools that cannot function without MCP transport
|
|
166
|
+
* access (e.g. brain, project).
|
|
167
|
+
*/
|
|
163
168
|
requiresServer?: boolean;
|
|
169
|
+
/**
|
|
170
|
+
* Soft requirement: forward the `Server` instance when one is
|
|
171
|
+
* available, but register the tool either way. Used for tools that
|
|
172
|
+
* gracefully degrade (e.g. notifications/progress silently no-ops
|
|
173
|
+
* when the transport isn't attached).
|
|
174
|
+
*/
|
|
175
|
+
acceptsServer?: boolean;
|
|
164
176
|
}
|
|
165
177
|
|
|
166
178
|
/**
|
|
@@ -182,10 +194,14 @@ const TOOL_MODULES: ToolModule[] = [
|
|
|
182
194
|
{ category: "runtime", definitions: runtimeToolDefinitions, handlers: runtimeTools },
|
|
183
195
|
{ category: "seo", definitions: seoToolDefinitions, handlers: seoTools },
|
|
184
196
|
{ category: "project", definitions: projectToolDefinitions, handlers: projectTools as ToolModule["handlers"], requiresServer: true },
|
|
185
|
-
|
|
197
|
+
// ate + ate-run accept an optional Server so notifications/progress
|
|
198
|
+
// can flow (issue #238). `acceptsServer: true` forwards the server
|
|
199
|
+
// when available but still registers when it isn't — callers that
|
|
200
|
+
// boot without an MCP transport get progress no-oped silently.
|
|
201
|
+
{ category: "ate", definitions: ateToolDefinitions, handlers: ateTools as ToolModule["handlers"], acceptsServer: true },
|
|
186
202
|
{ category: "ate-phase5", definitions: atePhase5ToolDefinitions, handlers: createAtePhase5Handlers as unknown as ToolModule["handlers"] },
|
|
187
203
|
{ category: "ate-context", definitions: ateContextToolDefinitions, handlers: ateContextTools },
|
|
188
|
-
{ category: "ate-run", definitions: ateRunToolDefinitions, handlers: ateRunTools },
|
|
204
|
+
{ category: "ate-run", definitions: ateRunToolDefinitions, handlers: ateRunTools as ToolModule["handlers"], acceptsServer: true },
|
|
189
205
|
{ category: "ate-flakes", definitions: ateFlakesToolDefinitions, handlers: ateFlakesTools },
|
|
190
206
|
{ category: "ate-prompt", definitions: atePromptToolDefinitions, handlers: atePromptTools },
|
|
191
207
|
{ category: "ate-exemplar", definitions: ateExemplarToolDefinitions, handlers: ateExemplarTools },
|
|
@@ -290,13 +306,21 @@ export function registerBuiltinTools(
|
|
|
290
306
|
}
|
|
291
307
|
|
|
292
308
|
try {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
309
|
+
let handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>>;
|
|
310
|
+
if (module.requiresServer) {
|
|
311
|
+
handlers = (module.handlers as (root: string, srv: Server, mon: ActivityMonitor) => Record<string, (args: Record<string, unknown>) => Promise<unknown>>)(
|
|
312
|
+
projectRoot,
|
|
313
|
+
server!,
|
|
314
|
+
monitor!,
|
|
315
|
+
);
|
|
316
|
+
} else if (module.acceptsServer) {
|
|
317
|
+
// Forward the Server when available; fall back to just projectRoot.
|
|
318
|
+
handlers = server
|
|
319
|
+
? module.handlers(projectRoot, server)
|
|
320
|
+
: module.handlers(projectRoot);
|
|
321
|
+
} else {
|
|
322
|
+
handlers = module.handlers(projectRoot);
|
|
323
|
+
}
|
|
300
324
|
|
|
301
325
|
const plugins = moduleToPlugins(module.definitions, handlers);
|
|
302
326
|
mcpToolRegistry.registerAll(plugins, module.category);
|
package/src/tools/project.ts
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
393
|
-
|
|
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:
|
|
399
|
-
url
|
|
491
|
+
port: detectedPort ?? polledPort,
|
|
492
|
+
url,
|
|
400
493
|
cwd: targetDir,
|
|
401
494
|
startedAt: state.startedAt.toISOString(),
|
|
402
|
-
message:
|
|
403
|
-
? `Dev server
|
|
404
|
-
:
|
|
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;
|