@monster-orange/den 0.2.5 → 0.2.6

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.
@@ -9,6 +9,11 @@
9
9
  * `install` (which also restarts a running service) says the service is
10
10
  * running only once the web server answers /api/server-info with JSON: a
11
11
  * fresh server takes a few seconds to start, and answers empty until then.
12
+ *
13
+ * `install --plan` is read-only: it prints the plist install would write
14
+ * (install.sh print), the label, the paths and whose server answers on the
15
+ * port, and writes and loads nothing; exit 1 when that server is foreign.
16
+ * `status` also names a foreign server on the port.
12
17
  */
13
18
  import { spawnSync } from "child_process";
14
19
  import * as os from "os";
@@ -20,6 +25,17 @@ import {
20
25
  } from "../../shared/den-config.js";
21
26
  import { readWebPort } from "../../shared/edge/config.js";
22
27
  import { getDistRoot, getEngineRoot } from "../../shared/paths.js";
28
+ import { product } from "../../shared/product.js";
29
+ import {
30
+ classifyServerInfo,
31
+ ForeignServerError,
32
+ foreignServerMessage,
33
+ identifyServer,
34
+ ownServer,
35
+ pickAnotherPortHint,
36
+ type OwnServer,
37
+ type ServerIdentity,
38
+ } from "./server-identity.js";
23
39
 
24
40
  /** Runs a command with extra env vars; returns its exit code. */
25
41
  export type ServiceRunner = (
@@ -30,6 +46,14 @@ export type ServiceRunner = (
30
46
 
31
47
  const SUBCOMMANDS = ["install", "status", "print", "uninstall"] as const;
32
48
 
49
+ /** The flags each subcommand takes; any other is refused before acting. */
50
+ const SUBCOMMAND_FLAGS: Record<(typeof SUBCOMMANDS)[number], string[]> = {
51
+ install: ["--plan"],
52
+ status: [],
53
+ print: [],
54
+ uninstall: [],
55
+ };
56
+
33
57
  const defaultRunner: ServiceRunner = (cmd, args, env) =>
34
58
  spawnSync(cmd, args, { stdio: "inherit", env: { ...process.env, ...env } })
35
59
  .status ?? 1;
@@ -39,12 +63,15 @@ export const READY_TIMEOUT_MS = 30_000;
39
63
 
40
64
  /**
41
65
  * Poll `url` until it answers 2xx with a JSON object, or `timeoutMs` passes.
42
- * Each attempt is bounded too, so a hung connection cannot outlast it.
66
+ * Each attempt is bounded too, so a hung connection cannot outlast it. A
67
+ * server that is not `own`'s (another distribution's, on the same port) is
68
+ * never "running": that throws ForeignServerError at once.
43
69
  */
44
70
  export async function waitForServerInfo(
45
71
  url: string,
46
72
  timeoutMs: number = READY_TIMEOUT_MS,
47
73
  intervalMs = 500,
74
+ own: OwnServer = ownServer(),
48
75
  ): Promise<boolean> {
49
76
  const deadline = Date.now() + timeoutMs;
50
77
  for (;;) {
@@ -56,9 +83,15 @@ export async function waitForServerInfo(
56
83
  });
57
84
  if (res.ok) {
58
85
  const body: unknown = JSON.parse(await res.text());
59
- if (body !== null && typeof body === "object") return true;
86
+ if (body !== null && typeof body === "object") {
87
+ const id = classifyServerInfo(body, own);
88
+ if (id.kind === "foreign")
89
+ throw new ForeignServerError(Number(new URL(url).port), id.who);
90
+ return true;
91
+ }
60
92
  }
61
- } catch {
93
+ } catch (e) {
94
+ if (e instanceof ForeignServerError) throw e;
62
95
  /* not up yet: refused, empty or partial body */
63
96
  }
64
97
  await new Promise((r) => setTimeout(r, intervalMs));
@@ -74,9 +107,32 @@ export interface ServiceDeps {
74
107
  platform?: NodeJS.Platform;
75
108
  /** The web UI port (default: config web.port). */
76
109
  webPort?: number;
77
- /** Readiness poller (default: waitForServerInfo). */
78
- waitReady?: (url: string, timeoutMs: number) => Promise<boolean>;
110
+ /**
111
+ * Readiness poller (default: waitForServerInfo). Throws
112
+ * ForeignServerError when another distribution's server answers.
113
+ */
114
+ waitReady?: (
115
+ url: string,
116
+ timeoutMs: number,
117
+ own: OwnServer,
118
+ ) => Promise<boolean>;
79
119
  log?: (line: string) => void;
120
+ /** Whose server answers on a port (default: identifyServer). */
121
+ identify?: (port: number, own: OwnServer) => Promise<ServerIdentity>;
122
+ }
123
+
124
+ /** One line on whose server answers on `port`. */
125
+ export function identityLine(port: number, id: ServerIdentity): string {
126
+ switch (id.kind) {
127
+ case "none":
128
+ return `port ${port}: free (nothing listening)`;
129
+ case "unresponsive":
130
+ return `port ${port}: something accepted the connection but did not answer`;
131
+ case "ours":
132
+ return `port ${port}: this distribution's server${id.version ? ` (Den ${id.version})` : ""}${id.pid ? `, pid ${id.pid}` : ""}`;
133
+ case "foreign":
134
+ return `${foreignServerMessage(port, id.who)}; not this distribution's server`;
135
+ }
80
136
  }
81
137
 
82
138
  /** Run `service <sub>`; returns the exit code. */
@@ -84,13 +140,30 @@ export async function cmdService(
84
140
  args: string[],
85
141
  deps: ServiceDeps = {},
86
142
  ): Promise<number> {
87
- const sub = args[0];
143
+ const sub = args[0] as (typeof SUBCOMMANDS)[number] | undefined;
88
144
  if (!(SUBCOMMANDS as readonly string[]).includes(sub ?? ""))
89
145
  throw new Error(
90
146
  `${sub ? `Unknown service command "${sub}". ` : ""}Use: ${SUBCOMMANDS.join(" | ")}`,
91
147
  );
92
- // print only writes the plist; the rest talk to launchd.
93
- if (sub !== "print" && (deps.platform ?? process.platform) !== "darwin")
148
+ // A typo'd --plan must not install for real.
149
+ const extra = args
150
+ .slice(1)
151
+ .filter((a) => !SUBCOMMAND_FLAGS[sub!].includes(a));
152
+ if (extra.length)
153
+ throw new Error(
154
+ `service ${sub}: unknown argument${extra.length > 1 ? "s" : ""} ${extra.join(" ")}` +
155
+ (SUBCOMMAND_FLAGS[sub!].length
156
+ ? ` (takes ${SUBCOMMAND_FLAGS[sub!].join(", ")})`
157
+ : ""),
158
+ );
159
+ const plan = sub === "install" && args.includes("--plan");
160
+ // print (and install --plan) only write the plist to stdout; the rest
161
+ // talk to launchd.
162
+ if (
163
+ sub !== "print" &&
164
+ !plan &&
165
+ (deps.platform ?? process.platform) !== "darwin"
166
+ )
94
167
  throw new Error(
95
168
  "the web UI service is a launchd LaunchAgent: macOS only (print works anywhere)",
96
169
  );
@@ -105,19 +178,70 @@ export async function cmdService(
105
178
  "service",
106
179
  "install.sh",
107
180
  );
108
- const code = (deps.run ?? defaultRunner)("bash", [script, sub!], {
181
+ const env = {
109
182
  DEN_ROOT: distRoot,
110
183
  DEN_SERVICE_LABEL: label,
111
184
  DEN_SERVICE_LOGS: logs,
185
+ };
186
+ const run = deps.run ?? defaultRunner;
187
+ const log = deps.log ?? ((l: string) => console.log(l));
188
+ const port = deps.webPort ?? readWebPort(distRoot);
189
+ const own: OwnServer = { root: distRoot, product: product().name };
190
+ const identify =
191
+ deps.identify ?? ((p: number, o: OwnServer) => identifyServer(p, o));
192
+
193
+ if (plan) {
194
+ const home = deps.home ?? os.homedir();
195
+ log(`service install --plan (read-only: nothing is written or loaded)`);
196
+ log(` label: ${label}`);
197
+ log(
198
+ ` plist: ${path.join(home, "Library", "LaunchAgents", `${label}.plist`)}`,
199
+ );
200
+ log(` logs: ${path.join(logs, "web.log")}`);
201
+ log(` root: ${distRoot}`);
202
+ log(` engine: ${path.dirname(path.dirname(script))}`);
203
+ log(` url: http://localhost:${port}`);
204
+ const id = await identify(port, own);
205
+ log(` server: ${identityLine(port, id)}`);
206
+ log(` plist it would write:`);
207
+ // install.sh print writes the plist to stdout only.
208
+ const code = run("bash", [script, "print"], env);
209
+ if (code !== 0) return code;
210
+ if (id.kind === "foreign") {
211
+ log(`✗ install would refuse this port: ${pickAnotherPortHint()}`);
212
+ return 1;
213
+ }
214
+ return 0;
215
+ }
216
+
217
+ const code = run("bash", [script, sub!], {
218
+ ...env,
112
219
  // The script then leaves saying "running" to us (after the poll below).
113
220
  ...(sub === "install" ? { DEN_SERVICE_READY_CHECK: "external" } : {}),
114
221
  });
222
+ if (sub === "status") {
223
+ const id = await identify(port, own);
224
+ if (id.kind === "foreign") log(`✗ ${identityLine(port, id)}`);
225
+ return code;
226
+ }
115
227
  if (sub !== "install" || code !== 0) return code;
116
228
 
117
- const log = deps.log ?? ((l: string) => console.log(l));
118
- const port = deps.webPort ?? readWebPort(distRoot);
119
229
  const url = `http://localhost:${port}/api/server-info`;
120
- if (await (deps.waitReady ?? waitForServerInfo)(url, READY_TIMEOUT_MS)) {
230
+ const waitReady =
231
+ deps.waitReady ??
232
+ ((u: string, t: number, o: OwnServer) => waitForServerInfo(u, t, 500, o));
233
+ let ready: boolean;
234
+ try {
235
+ ready = await waitReady(url, READY_TIMEOUT_MS, own);
236
+ } catch (e) {
237
+ if (!(e instanceof ForeignServerError)) throw e;
238
+ log(
239
+ `✗ ${label} installed, but ${foreignServerMessage(e.port, e.who)}, ` +
240
+ `so it cannot serve there: ${pickAnotherPortHint()}`,
241
+ );
242
+ return 1;
243
+ }
244
+ if (ready) {
121
245
  log(
122
246
  `✓ ${label} running at http://localhost:${port} (logs: ${path.join(logs, "web.log")})`,
123
247
  );
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The CLI's progress spinners (ora).
3
+ *
4
+ * Spinners are progress, not diagnostics: on a terminal they animate on
5
+ * stderr as usual; piped (CI, scripts) their lines go to stdout so stderr
6
+ * carries only real warnings and errors.
7
+ *
8
+ * They animate only on a terminal with a known, non-zero width. ora
9
+ * divides each line's width by the column count to know how many lines to
10
+ * clear before the next frame: on a pty that reports 0 columns (one nobody
11
+ * sized, as agent shells and some CI runners give) that is Infinity and the
12
+ * spinner clears lines forever. Without a usable terminal a spinner prints
13
+ * plain lines instead: its text once on start, and the final line.
14
+ */
15
+ import oraSpinner, { type Ora } from "ora";
16
+
17
+ /** A stream as a spinner sees it: a TTY, maybe, with a width, maybe. */
18
+ export interface SpinnerStream extends NodeJS.WritableStream {
19
+ isTTY?: boolean;
20
+ columns?: number;
21
+ }
22
+
23
+ /** Whether a spinner can draw frames on `stream`. */
24
+ export function canAnimate(stream: SpinnerStream): boolean {
25
+ return (
26
+ stream.isTTY === true &&
27
+ typeof stream.columns === "number" &&
28
+ Number.isFinite(stream.columns) &&
29
+ stream.columns > 0
30
+ );
31
+ }
32
+
33
+ function defaultStream(): SpinnerStream {
34
+ return process.stderr.isTTY ? process.stderr : process.stdout;
35
+ }
36
+
37
+ /** A spinner showing `text`, on stderr (a terminal) or stdout (piped). */
38
+ export function spinner(
39
+ text: string,
40
+ stream: SpinnerStream = defaultStream(),
41
+ ): Ora {
42
+ return oraSpinner({
43
+ text,
44
+ stream: stream as NodeJS.WritableStream,
45
+ // Unset, ora decides (never under CI or TERM=dumb); false: plain lines.
46
+ ...(canAnimate(stream) ? {} : { isEnabled: false }),
47
+ });
48
+ }
@@ -13,6 +13,12 @@ import { getDistRoot, getEngineRoot, getStateDir } from "../../shared/paths.js";
13
13
  import { hasDenUiMarker } from "../../shared/ui-marker.js";
14
14
  import { engineBin, engineModuleFile } from "../../shared/engine.js";
15
15
  import { readWebPort } from "../../shared/edge/config.js";
16
+ import {
17
+ ownServer,
18
+ processCwd,
19
+ refuseForeignServer,
20
+ samePath,
21
+ } from "./server-identity.js";
16
22
 
17
23
  import { product } from "../../shared/product.js";
18
24
  interface WebCommandOptions {
@@ -569,21 +575,55 @@ async function waitForPidsExit(pids: number[]): Promise<boolean> {
569
575
  return false;
570
576
  }
571
577
 
572
- function findDenWebPids(port: number): number[] {
578
+ /**
579
+ * Whether `pid` may be this distribution's server by where it runs: a web
580
+ * server runs from its distribution root, so one whose working directory
581
+ * is another root is never ours, even when its command line matches (two
582
+ * distributions on one linked engine run the same entry point). An
583
+ * unreadable directory does not decide.
584
+ */
585
+ function runsFromRoot(pid: number, root: string): boolean {
586
+ const cwd = processCwd(pid);
587
+ if (!cwd) return true;
588
+ return samePath(cwd, root);
589
+ }
590
+
591
+ function isOurWebPid(pid: number, root: string): boolean {
592
+ const command = commandForPid(pid);
593
+ return (
594
+ !!command &&
595
+ isDenWebCommand(command, webRoots(root)) &&
596
+ runsFromRoot(pid, root)
597
+ );
598
+ }
599
+
600
+ /**
601
+ * Our server's pids on `port`. `vouched` is the pid of a server the
602
+ * identity check (server-info: our root, or an older server run from our
603
+ * root) found ours: it is stopped when it listens on the port even if its
604
+ * command line names no entry point we recognise. Before, an older Den
605
+ * installed elsewhere, or a hand-started server, matched no command line,
606
+ * so `web down` said "not running" and `web restart` "already running".
607
+ */
608
+ function findDenWebPids(port: number, vouched?: number): number[] {
573
609
  const root = getDistRoot();
574
610
  const pids = new Set<number>();
575
611
  const state = readWebServerState();
576
- if (state?.port === port && state.pid) {
577
- const command = commandForPid(state.pid);
578
- if (command && isDenWebCommand(command, webRoots(state.root || root))) {
579
- pids.add(state.pid);
580
- }
612
+ const listeners = listListeningPids(port);
613
+ // The state file may be shared by distributions of one product name: a
614
+ // record of another root's server is not ours to stop.
615
+ if (
616
+ state?.port === port &&
617
+ state.pid &&
618
+ samePath(state.root || root, root) &&
619
+ (isOurWebPid(state.pid, root) ||
620
+ (state.pid === vouched && listeners.includes(state.pid)))
621
+ ) {
622
+ pids.add(state.pid);
581
623
  }
582
624
 
583
- for (const pid of listListeningPids(port)) {
584
- if (isDenWebCommand(commandForPid(pid), webRoots(root))) {
585
- pids.add(pid);
586
- }
625
+ for (const pid of listeners) {
626
+ if (pid === vouched || isOurWebPid(pid, root)) pids.add(pid);
587
627
  }
588
628
 
589
629
  return Array.from(pids);
@@ -611,8 +651,11 @@ export function killWebProcess(pid: number, signal: NodeJS.Signals): void {
611
651
  }
612
652
  }
613
653
 
614
- async function stopDenWebServer(port: number): Promise<StopServerResult> {
615
- const pids = findDenWebPids(port);
654
+ async function stopDenWebServer(
655
+ port: number,
656
+ vouched?: number,
657
+ ): Promise<StopServerResult> {
658
+ const pids = findDenWebPids(port, vouched);
616
659
  if (pids.length === 0) {
617
660
  const state = readWebServerState();
618
661
  if (state?.port === port) removeWebServerState();
@@ -636,22 +679,25 @@ async function stopDenWebServer(port: number): Promise<StopServerResult> {
636
679
  return { stopped: forced, forced: true, pids };
637
680
  }
638
681
 
639
- async function stopBrokenDenWebServer(port: number): Promise<boolean> {
640
- return (await stopDenWebServer(port)).stopped;
682
+ async function stopBrokenDenWebServer(
683
+ port: number,
684
+ vouched?: number,
685
+ ): Promise<boolean> {
686
+ return (await stopDenWebServer(port, vouched)).stopped;
641
687
  }
642
688
 
643
689
  function recordRunningDenWebServer(port: number): void {
644
690
  const root = getDistRoot();
645
691
  const state = readWebServerState();
646
- if (state?.port === port && state.pid) {
647
- const command = commandForPid(state.pid);
648
- if (command && isDenWebCommand(command, webRoots(state.root || root)))
649
- return;
650
- }
692
+ if (
693
+ state?.port === port &&
694
+ state.pid &&
695
+ samePath(state.root || root, root) &&
696
+ isOurWebPid(state.pid, root)
697
+ )
698
+ return;
651
699
 
652
- const pids = listListeningPids(port).filter((pid) =>
653
- isDenWebCommand(commandForPid(pid), webRoots(root)),
654
- );
700
+ const pids = listListeningPids(port).filter((pid) => isOurWebPid(pid, root));
655
701
  const pid = pids[0];
656
702
  if (!pid) return;
657
703
 
@@ -965,8 +1011,13 @@ export async function cmdWeb(args: string[]): Promise<void> {
965
1011
 
966
1012
  const url = buildDenWebUrl(options.port);
967
1013
 
1014
+ // Never act on another distribution's server: not "already running", and
1015
+ // never a signal to it (down, restart).
1016
+ const identity = await refuseForeignServer(options.port, ownServer());
1017
+ const vouched = identity.kind === "ours" ? identity.pid : undefined;
1018
+
968
1019
  if (options.action === "down") {
969
- const stopped = await stopDenWebServer(options.port);
1020
+ const stopped = await stopDenWebServer(options.port, vouched);
970
1021
  if (stopped.stopped) {
971
1022
  const mode = stopped.forced ? "force stopped" : "stopped";
972
1023
  console.log(
@@ -988,7 +1039,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
988
1039
  }
989
1040
 
990
1041
  if (options.action === "restart") {
991
- const stopped = await stopDenWebServer(options.port);
1042
+ const stopped = await stopDenWebServer(options.port, vouched);
992
1043
  if (stopped.pids.length > 0 && !stopped.stopped) {
993
1044
  throw new Error(
994
1045
  `Could not stop ${product().title} web on port ${options.port}.`,
@@ -1015,7 +1066,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
1015
1066
  ),
1016
1067
  );
1017
1068
  console.log(chalk.gray(` Restarting ${product().title} web...`));
1018
- const stopped = await stopBrokenDenWebServer(options.port);
1069
+ const stopped = await stopBrokenDenWebServer(options.port, vouched);
1019
1070
  if (!stopped) {
1020
1071
  throw new Error(
1021
1072
  `Could not stop the broken ${product().title} web server on port ${options.port}.`,
package/src/ui/mcp/api.ts CHANGED
@@ -3,8 +3,19 @@
3
3
  * server holds no state of its own: agents and the web UI share the server's
4
4
  * state and its guards. This client can reach only the routes below, and it
5
5
  * sends the agent's audit headers (see shared/agent-actor.ts) on every call.
6
+ *
7
+ * With `identity`, it first makes sure the server on the port is this
8
+ * distribution's (server-identity.ts): another distribution's server (or an
9
+ * older Den from another root) on the configured port gets no request, and
10
+ * every tool says whose server it is instead of serving its data.
6
11
  */
7
12
  import { product } from "../../shared/product.js";
13
+ import {
14
+ foreignServerMessage,
15
+ identifyServer,
16
+ type OwnServer,
17
+ type ServerIdentity,
18
+ } from "../cli/server-identity.js";
8
19
  import { LIFECYCLE_CMDS, Refusal } from "./guards.js";
9
20
 
10
21
  export type Query = Record<string, string | string[] | undefined>;
@@ -20,6 +31,47 @@ export class DenWebDown extends Error {
20
31
  }
21
32
  }
22
33
 
34
+ /** Another distribution's server answers on the port: no data from it. */
35
+ export class ForeignWebServer extends Error {
36
+ constructor(
37
+ readonly port: number,
38
+ readonly who: string,
39
+ ) {
40
+ super(
41
+ `${foreignServerMessage(port, who)}; not this distribution's server. ` +
42
+ `Nothing was read from it. Point web.port (or --port) at this distribution's server.`,
43
+ );
44
+ this.name = "ForeignWebServer";
45
+ }
46
+ }
47
+
48
+ /** Something answers on the port but would not say whose server it is. */
49
+ export class UnidentifiedWebServer extends Error {
50
+ constructor(readonly port: number) {
51
+ super(
52
+ `the server on port ${port} did not answer /api/server-info in time, ` +
53
+ `so it is not known to be this distribution's; nothing was read from it. Try again.`,
54
+ );
55
+ this.name = "UnidentifiedWebServer";
56
+ }
57
+ }
58
+
59
+ /** How long a positive identity check is trusted. */
60
+ export const IDENTITY_TTL_MS = 30_000;
61
+ /** The identity probe's budget: longer than the CLI's, a busy server is ours too. */
62
+ export const IDENTITY_TIMEOUT_MS = 5_000;
63
+
64
+ export interface IdentityCheck {
65
+ /** This distribution: its root and product. */
66
+ own: OwnServer;
67
+ /** Default identifyServer. */
68
+ identify?: (port: number, own: OwnServer) => Promise<ServerIdentity>;
69
+ /** Default IDENTITY_TTL_MS. */
70
+ ttlMs?: number;
71
+ /** Default Date.now. */
72
+ now?: () => number;
73
+ }
74
+
23
75
  /** The web server answered with an error status. */
24
76
  export class DenHttpError extends Error {
25
77
  constructor(
@@ -157,8 +209,44 @@ export function createDenApi(opts: {
157
209
  baseUrl: string;
158
210
  cli: string;
159
211
  headers: () => Record<string, string>;
212
+ /** Check whose server it is before any request (the MCP server does). */
213
+ identity?: IdentityCheck;
160
214
  }): DenApi {
161
215
  const { baseUrl, cli } = opts;
216
+ const { hostname } = new URL(baseUrl);
217
+ const port = Number(new URL(baseUrl).port) || 80;
218
+ let vouchedAt: number | null = null;
219
+ let checking: Promise<void> | null = null;
220
+
221
+ /** Throws unless the server on the port is ours (cached briefly). */
222
+ async function ensureOurs(): Promise<void> {
223
+ const check = opts.identity;
224
+ if (!check) return;
225
+ const now = check.now ?? Date.now;
226
+ if (
227
+ vouchedAt !== null &&
228
+ now() - vouchedAt < (check.ttlMs ?? IDENTITY_TTL_MS)
229
+ )
230
+ return;
231
+ checking ??= (async () => {
232
+ const id = await (
233
+ check.identify ??
234
+ ((p: number, o: OwnServer) =>
235
+ identifyServer(p, o, IDENTITY_TIMEOUT_MS, hostname))
236
+ )(port, check.own);
237
+ if (id.kind === "ours") {
238
+ vouchedAt = now();
239
+ return;
240
+ }
241
+ vouchedAt = null;
242
+ if (id.kind === "foreign") throw new ForeignWebServer(port, id.who);
243
+ if (id.kind === "none") throw new DenWebDown(baseUrl, cli);
244
+ throw new UnidentifiedWebServer(port);
245
+ })().finally(() => {
246
+ checking = null;
247
+ });
248
+ return checking;
249
+ }
162
250
 
163
251
  async function request(
164
252
  method: string,
@@ -168,6 +256,7 @@ export function createDenApi(opts: {
168
256
  body?: unknown,
169
257
  ): Promise<Response> {
170
258
  assertAllowedRequest(method, path, query);
259
+ await ensureOurs();
171
260
  try {
172
261
  const res = await fetch(buildUrl(baseUrl, path, query), {
173
262
  method,
@@ -184,7 +273,11 @@ export function createDenApi(opts: {
184
273
  }
185
274
  return res;
186
275
  } catch (e) {
187
- if (isConnectionError(e)) throw new DenWebDown(baseUrl, cli);
276
+ if (isConnectionError(e)) {
277
+ // Whatever answers next on the port is checked again.
278
+ vouchedAt = null;
279
+ throw new DenWebDown(baseUrl, cli);
280
+ }
188
281
  throw e;
189
282
  }
190
283
  }
@@ -18,7 +18,9 @@ import { engineVersion } from "../../shared/paths.js";
18
18
  import { product } from "../../shared/product.js";
19
19
  import { DEFAULT_PORT } from "../../shared/constants.js";
20
20
  import { createDenApi, type DenApi } from "./api.js";
21
+ import { ownServer } from "../cli/server-identity.js";
21
22
  import { callTool, toolsFor, type ToolContext } from "./tools.js";
23
+ import { plural } from "../../shared/plural.js";
22
24
 
23
25
  export interface DenMcpOptions {
24
26
  cfg: ResolvedMcpConfig;
@@ -56,6 +58,8 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
56
58
  createDenApi({
57
59
  baseUrl: opts.baseUrl,
58
60
  cli: opts.cli,
61
+ // Serve nothing from another distribution's server on the port.
62
+ identity: { own: ownServer() },
59
63
  headers: () =>
60
64
  agentAuditHeaders({
61
65
  client: client()?.name ?? "unknown",
@@ -210,6 +214,7 @@ export async function runMcp(argv: string[]): Promise<void> {
210
214
  const api = createDenApi({
211
215
  baseUrl,
212
216
  cli,
217
+ identity: { own: ownServer() },
213
218
  headers: () =>
214
219
  agentAuditHeaders({
215
220
  client: `${cli} mcp --call`,
@@ -224,7 +229,7 @@ export async function runMcp(argv: string[]): Promise<void> {
224
229
  const server = createDenMcpServer({ cfg, baseUrl, cli });
225
230
  await server.connect(new StdioServerTransport());
226
231
  process.stderr.write(
227
- `${cli} mcp: ${toolsFor(cfg).length} tools ` +
232
+ `${cli} mcp: ${plural(toolsFor(cfg).length, "tool")} ` +
228
233
  `(${cfg.actions.length ? `actions: ${cfg.actions.join(", ")}` : "read only"}) → ${baseUrl}\n`,
229
234
  );
230
235
  }
@@ -14,6 +14,8 @@ import { redactSecrets } from "../../shared/redact.js";
14
14
  import {
15
15
  DenHttpError,
16
16
  DenWebDown,
17
+ ForeignWebServer,
18
+ UnidentifiedWebServer,
17
19
  type DenApi,
18
20
  type Query,
19
21
  type StreamResult,
@@ -154,7 +156,7 @@ const READ_TOOLS: DenTool[] = [
154
156
  read(
155
157
  "status",
156
158
  "Service health",
157
- "Health of every service in the workspace (what `status` shows): up/down, mode, and what was checked: `url` for an HTTP check, `check` for a shell command.",
159
+ "Health of every service in the workspace (what `status` shows): up/down, mode, and what was checked: `url` for an HTTP check, `check` for a shell command or a container check (`container <project>/<service>`).",
158
160
  { env: envArg, workspace: workspaceArg },
159
161
  (a, { api }) =>
160
162
  api.get("/api/health", { env: envOf(a), workspace: str(a.workspace) }),
@@ -636,7 +638,9 @@ export async function callTool(
636
638
  const msg =
637
639
  e instanceof Refusal
638
640
  ? `Refused: ${e.message}`
639
- : e instanceof DenWebDown
641
+ : e instanceof DenWebDown ||
642
+ e instanceof ForeignWebServer ||
643
+ e instanceof UnidentifiedWebServer
640
644
  ? e.message
641
645
  : `Error: ${(e as Error).message}`;
642
646
  return { text: redactSecrets(msg), isError: true };
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * The fields of a /api/health row that say what was checked. `url` is only
3
3
  * ever a URL (or null): a shell check's command (a compose stack's
4
- * `<cli> stack health …`, or any declared shell check) goes in `check`.
4
+ * `<cli> stack health …`, or any declared shell check) goes in `check`, as
5
+ * does a container check's `container <project>/<service>` (or
6
+ * `container <name>`).
5
7
  */
6
- export type HealthCheckType = "shell" | "http" | "none";
8
+ export type HealthCheckType = "shell" | "http" | "container" | "none";
7
9
 
8
10
  export function healthCheckFields(
9
11
  checkType: HealthCheckType,
10
12
  target: string,
11
13
  ): { url: string | null; check?: string } {
12
- if (checkType === "shell") return { url: null, check: target };
14
+ if (checkType === "shell" || checkType === "container")
15
+ return { url: null, check: target };
13
16
  if (checkType === "http") return { url: target };
14
17
  return { url: null };
15
18
  }