@mandujs/mcp 0.29.0 → 0.31.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.
@@ -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;
@@ -318,7 +318,8 @@ async function readResourceDefinition(
318
318
  return parsed.definition;
319
319
  } catch (error) {
320
320
  throw new Error(
321
- `Failed to parse resource schema: ${error instanceof Error ? error.message : String(error)}`
321
+ `Failed to parse resource schema: ${error instanceof Error ? error.message : String(error)}`,
322
+ { cause: error }
322
323
  );
323
324
  }
324
325
  }