@nudge-ui/mcp 0.2.0 → 0.2.2

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/dist/bridge.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { createServer, } from "node:http";
2
2
  import { randomBytes, randomUUID } from "node:crypto";
3
- import { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, canonicalOrigin, isAgentStatusUpdate, isAllowedOrigin, isCanvasCommand, isCanvasGroup, isCanvasState, isSameOriginRoute, validateRoutes, } from "./internal/agent-protocol.js";
4
- import { BRIDGE_ENDPOINTS } from "./protocol.js";
3
+ import { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, canonicalOrigin, isAgentStatusUpdate, isAllowedOrigin, isCanvasCommand, isCanvasGroup, isCanvasState, isSameOriginRoute, validateSketchAttachments, validateRoutes, } from "./internal/agent-protocol.js";
4
+ import { AGENT_CONTROL_ENDPOINTS, BRIDGE_ENDPOINTS } from "./protocol.js";
5
5
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
6
6
  const MAX_BODY_BYTES = 256 * 1024;
7
+ const MAX_PROMPT_BODY_BYTES = 12 * 1024 * 1024;
7
8
  const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
9
+ const AGENT_CLAIM_TIMEOUT_MS = 30_000;
8
10
  const SYSTEM_CLOCK = {
9
11
  now: () => Date.now(),
10
12
  setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
@@ -85,13 +87,13 @@ function sendError(response, error, origin) {
85
87
  }
86
88
  sendJson(response, 500, { error: { code: "internal_error", message: "The bridge could not complete the request." } }, origin);
87
89
  }
88
- async function readJsonBody(request) {
90
+ async function readJsonBody(request, maxBytes = MAX_BODY_BYTES) {
89
91
  const chunks = [];
90
92
  let total = 0;
91
93
  for await (const chunk of request) {
92
94
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
93
95
  total += buffer.length;
94
- if (total > MAX_BODY_BYTES) {
96
+ if (total > maxBytes) {
95
97
  throw new BridgeRequestError(413, "body_too_large", "The bridge request body is too large.");
96
98
  }
97
99
  chunks.push(buffer);
@@ -110,6 +112,37 @@ async function readJsonBody(request) {
110
112
  }
111
113
  return parsed;
112
114
  }
115
+ function pngDimensions(bytes) {
116
+ if (bytes.length < 24
117
+ || !bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
118
+ || bytes.toString("ascii", 12, 16) !== "IHDR")
119
+ return null;
120
+ return {
121
+ width: bytes.readUInt32BE(16),
122
+ height: bytes.readUInt32BE(20),
123
+ };
124
+ }
125
+ function parseSketchAttachments(value) {
126
+ let attachments;
127
+ try {
128
+ attachments = validateSketchAttachments(value);
129
+ }
130
+ catch (error) {
131
+ throw new BridgeRequestError(400, "invalid_sketches", error instanceof Error ? error.message : "Sketch attachments are invalid.");
132
+ }
133
+ for (const [index, attachment] of attachments.entries()) {
134
+ const bytes = Buffer.from(attachment.data, "base64");
135
+ if (bytes.length !== attachment.byteSize || bytes.toString("base64") !== attachment.data) {
136
+ throw new BridgeRequestError(400, "invalid_sketches", `attachments[${index}] is not canonical base64 PNG data.`);
137
+ }
138
+ const dimensions = pngDimensions(bytes);
139
+ if (!dimensions || dimensions.width !== attachment.width || dimensions.height !== attachment.height
140
+ || dimensions.width > 2_048 || dimensions.height > 2_048) {
141
+ throw new BridgeRequestError(400, "invalid_sketches", `attachments[${index}] has invalid PNG dimensions.`);
142
+ }
143
+ }
144
+ return attachments;
145
+ }
113
146
  function createEnvelope(projectId, sessionToken, event) {
114
147
  return {
115
148
  protocolVersion: AGENT_PROTOCOL_VERSION,
@@ -165,7 +198,10 @@ export function createLoopbackBridge(options) {
165
198
  if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) {
166
199
  throw new TypeError("commandTimeoutMs must be between 1 and 60000 milliseconds");
167
200
  }
168
- const allowedOrigins = (options.allowedOrigins ?? (configuredOrigin ? [configuredOrigin] : [])).map((candidate) => canonicalOrigin(candidate));
201
+ const allowedOrigins = [
202
+ ...(configuredOrigin ? [configuredOrigin] : []),
203
+ ...(options.allowedOrigins ?? []),
204
+ ].map((candidate) => canonicalOrigin(candidate));
169
205
  if (allowedOrigins.some((candidate) => candidate === null)) {
170
206
  throw new TypeError("allowedOrigins must contain canonical HTTP(S) origins");
171
207
  }
@@ -173,6 +209,10 @@ export function createLoopbackBridge(options) {
173
209
  const clock = options.clock ?? SYSTEM_CLOCK;
174
210
  const tokenFactory = options.tokenFactory ?? randomToken;
175
211
  const idFactory = options.idFactory ?? randomId;
212
+ const agentControlToken = options.agentControlToken;
213
+ if (agentControlToken !== undefined && (agentControlToken.length < 32 || agentControlToken.length > 256)) {
214
+ throw new TypeError("agentControlToken must contain between 32 and 256 characters");
215
+ }
176
216
  let lifecycle = "created";
177
217
  let currentAddress = null;
178
218
  let sessionToken = null;
@@ -184,6 +224,9 @@ export function createLoopbackBridge(options) {
184
224
  let pendingCanvas = null;
185
225
  let closePromise = null;
186
226
  let startPromise = null;
227
+ let agentOwner = null;
228
+ let agentOwnerSeenAt = 0;
229
+ let agentOwnerExpiry = null;
187
230
  const getStatus = () => statusWith(options.projectId, listener !== null, sessionToken !== null, currentRequest, lastPageUrl);
188
231
  const broadcast = (event) => {
189
232
  if (!sessionToken)
@@ -261,13 +304,13 @@ export function createLoopbackBridge(options) {
261
304
  sendJson(response, 200, {
262
305
  protocolVersion: AGENT_PROTOCOL_VERSION,
263
306
  projectId: options.projectId,
264
- origin: configuredOrigin ?? pairedOrigin,
307
+ origin: incomingOrigin,
265
308
  status: getStatus(),
266
309
  sessionRequired: true,
267
310
  pageUrl: lastPageUrl,
268
311
  }, incomingOrigin);
269
312
  };
270
- const handlePair = async (request, response) => {
313
+ const readPairRequest = async (request) => {
271
314
  if (!loopbackAddress(request.socket.remoteAddress)) {
272
315
  throw new BridgeRequestError(403, "loopback_only", "The browser bridge accepts loopback connections only.");
273
316
  }
@@ -278,7 +321,7 @@ export function createLoopbackBridge(options) {
278
321
  const body = await readJsonBody(request);
279
322
  parseProjectId(typeof body.projectId === "string" ? body.projectId : null, options.projectId);
280
323
  const requestedOrigin = canonicalOrigin(body.origin);
281
- if (!requestedOrigin || requestedOrigin !== incomingOrigin || (configuredOrigin !== null && requestedOrigin !== configuredOrigin)) {
324
+ if (!requestedOrigin || requestedOrigin !== incomingOrigin) {
282
325
  throw new BridgeRequestError(403, "origin_mismatch", "Pairing requires an explicitly configured project origin.");
283
326
  }
284
327
  let requestedPageUrl;
@@ -289,8 +332,25 @@ export function createLoopbackBridge(options) {
289
332
  }
290
333
  requestedPageUrl = new URL(body.pageUrl, requestedOrigin).href;
291
334
  }
335
+ return { body, incomingOrigin, requestedPageUrl };
336
+ };
337
+ const handlePair = async (request, response) => {
338
+ const { body, incomingOrigin, requestedPageUrl } = await readPairRequest(request);
292
339
  if (sessionToken) {
293
- throw new BridgeRequestError(409, "already_paired", "A browser is already paired with this project.");
340
+ if (body.sessionToken !== sessionToken) {
341
+ throw new BridgeRequestError(409, "already_paired", "A browser is already paired with this project.");
342
+ }
343
+ lastPageUrl = requestedPageUrl ?? lastPageUrl;
344
+ const existing = {
345
+ protocolVersion: AGENT_PROTOCOL_VERSION,
346
+ projectId: options.projectId,
347
+ origin: pairedOrigin,
348
+ sessionToken,
349
+ ...(lastPageUrl === null ? {} : { pageUrl: lastPageUrl }),
350
+ status: getStatus(),
351
+ };
352
+ sendJson(response, 200, existing, incomingOrigin);
353
+ return;
294
354
  }
295
355
  sessionToken = tokenFactory();
296
356
  if (!sessionToken || sessionToken.length > AGENT_PROTOCOL_LIMITS.sessionToken) {
@@ -303,7 +363,7 @@ export function createLoopbackBridge(options) {
303
363
  const result = {
304
364
  protocolVersion: AGENT_PROTOCOL_VERSION,
305
365
  projectId: options.projectId,
306
- origin: configuredOrigin ?? pairedOrigin,
366
+ origin: pairedOrigin,
307
367
  sessionToken,
308
368
  ...(lastPageUrl === null ? {} : { pageUrl: lastPageUrl }),
309
369
  status: getStatus(),
@@ -311,6 +371,15 @@ export function createLoopbackBridge(options) {
311
371
  sendJson(response, 200, result, incomingOrigin);
312
372
  broadcastStatus();
313
373
  };
374
+ const handleTakeover = async (request, response) => {
375
+ const { incomingOrigin, requestedPageUrl } = await readPairRequest(request);
376
+ // Takeover is intentionally separate from normal pairing. The caller has
377
+ // made an explicit UI choice to revoke the existing browser session.
378
+ if (sessionToken)
379
+ disconnectBrowser(sessionToken);
380
+ const pairing = pairBrowser(options.projectId, incomingOrigin, requestedPageUrl);
381
+ sendJson(response, 200, pairing, incomingOrigin);
382
+ };
314
383
  const handleEvents = (request, response, url) => {
315
384
  parseProjectId(url.searchParams.get("projectId"), options.projectId);
316
385
  const incomingOrigin = requireBrowserRequest(request, url);
@@ -338,7 +407,7 @@ export function createLoopbackBridge(options) {
338
407
  sendJson(response, 200, getStatus(), incomingOrigin);
339
408
  };
340
409
  const handlePrompt = async (request, response) => {
341
- const body = await readJsonBody(request);
410
+ const body = await readJsonBody(request, MAX_PROMPT_BODY_BYTES);
342
411
  const incomingOrigin = requireBrowserRequest(request, new URL("http://bridge.invalid"), body.sessionToken);
343
412
  parseProjectId(typeof body.projectId === "string" ? body.projectId : null, options.projectId);
344
413
  if (typeof body.prompt !== "string" || body.prompt.length === 0 || body.prompt.length > AGENT_PROTOCOL_LIMITS.prompt) {
@@ -348,6 +417,13 @@ export function createLoopbackBridge(options) {
348
417
  && (!Number.isSafeInteger(body.changeRevision) || body.changeRevision < 0)) {
349
418
  throw new BridgeRequestError(400, "invalid_revision", "changeRevision must be a non-negative safe integer");
350
419
  }
420
+ if (body.clientDispatchId !== undefined
421
+ && (typeof body.clientDispatchId !== "string"
422
+ || body.clientDispatchId.length === 0
423
+ || body.clientDispatchId.length > AGENT_PROTOCOL_LIMITS.clientDispatchId)) {
424
+ throw new BridgeRequestError(400, "invalid_dispatch_id", "clientDispatchId must be a bounded non-empty string");
425
+ }
426
+ const attachments = parseSketchAttachments(body.attachments);
351
427
  if (!listener) {
352
428
  throw new BridgeRequestError(409, "listener_unavailable", "The agent has not opened a listening call.");
353
429
  }
@@ -359,13 +435,19 @@ export function createLoopbackBridge(options) {
359
435
  projectId: options.projectId,
360
436
  prompt: body.prompt,
361
437
  ...(body.changeRevision === undefined ? {} : { changeRevision: body.changeRevision }),
438
+ ...(body.clientDispatchId === undefined ? {} : { clientDispatchId: body.clientDispatchId }),
439
+ ...(attachments.length === 0 ? {} : { sketches: attachments.map(({ data: _data, ...metadata }) => metadata) }),
440
+ };
441
+ const delivered = {
442
+ ...prompt,
443
+ ...(attachments.length === 0 ? {} : { attachments }),
362
444
  };
363
445
  const pending = listener;
364
446
  listener = null;
365
447
  if (pending.abortHandler && pending.signal)
366
448
  pending.signal.removeEventListener("abort", pending.abortHandler);
367
449
  currentRequest = { ...prompt, status: "working" };
368
- pending.resolve(prompt);
450
+ pending.resolve(delivered);
369
451
  const result = { request: prompt, status: "working" };
370
452
  sendJson(response, 202, result, incomingOrigin);
371
453
  broadcastStatus();
@@ -426,9 +508,160 @@ export function createLoopbackBridge(options) {
426
508
  disconnectBrowser(body.sessionToken);
427
509
  sendJson(response, 200, { disconnected: true }, incomingOrigin);
428
510
  };
511
+ const requireAgentControl = (request, bodyAgentId) => {
512
+ if (!agentControlToken || !loopbackAddress(request.socket.remoteAddress)) {
513
+ throw new BridgeRequestError(403, "agent_control_unavailable", "Private agent control is unavailable.");
514
+ }
515
+ if (headerValue(request, "authorization") !== `Bearer ${agentControlToken}`) {
516
+ throw new BridgeRequestError(401, "invalid_agent_credential", "The agent control credential is invalid.");
517
+ }
518
+ if (bodyAgentId === undefined)
519
+ return null;
520
+ if (typeof bodyAgentId !== "string" || bodyAgentId.length === 0 || bodyAgentId.length > 256) {
521
+ throw new BridgeRequestError(400, "invalid_agent_id", "agentId must be a bounded non-empty string.");
522
+ }
523
+ return bodyAgentId;
524
+ };
525
+ const requireAgentOwner = (request, body) => {
526
+ const agentId = requireAgentControl(request, body.agentId);
527
+ if (!agentId || agentOwner !== agentId) {
528
+ throw new BridgeRequestError(409, "agent_not_owner", "This MCP adapter does not own the project session.");
529
+ }
530
+ agentOwnerSeenAt = clock.now();
531
+ scheduleAgentOwnerExpiry();
532
+ return agentId;
533
+ };
534
+ const releaseAgentOwner = (reason) => {
535
+ if (agentOwnerExpiry !== null)
536
+ clock.clearTimeout(agentOwnerExpiry);
537
+ agentOwnerExpiry = null;
538
+ agentOwner = null;
539
+ agentOwnerSeenAt = 0;
540
+ cancelListener(reason);
541
+ if (currentRequest?.status === "working") {
542
+ currentRequest = { ...currentRequest, status: "interrupted", error: reason };
543
+ broadcastStatus();
544
+ }
545
+ if (pendingCanvas) {
546
+ const pendingCommand = pendingCanvas;
547
+ pendingCanvas = null;
548
+ clock.clearTimeout(pendingCommand.timeout);
549
+ pendingCommand.resolve({
550
+ commandId: pendingCommand.command.commandId,
551
+ ok: false,
552
+ error: { code: "agent_released", message: reason },
553
+ });
554
+ }
555
+ };
556
+ const scheduleAgentOwnerExpiry = () => {
557
+ if (agentOwnerExpiry !== null)
558
+ clock.clearTimeout(agentOwnerExpiry);
559
+ const expectedOwner = agentOwner;
560
+ if (!expectedOwner) {
561
+ agentOwnerExpiry = null;
562
+ return;
563
+ }
564
+ const expireIfStale = () => {
565
+ agentOwnerExpiry = null;
566
+ if (agentOwner !== expectedOwner)
567
+ return;
568
+ const remaining = AGENT_CLAIM_TIMEOUT_MS - (clock.now() - agentOwnerSeenAt);
569
+ if (remaining > 0) {
570
+ agentOwnerExpiry = clock.setTimeout(expireIfStale, remaining);
571
+ return;
572
+ }
573
+ releaseAgentOwner("The MCP adapter claim expired.");
574
+ };
575
+ agentOwnerExpiry = clock.setTimeout(expireIfStale, AGENT_CLAIM_TIMEOUT_MS);
576
+ };
577
+ const handleAgentClaim = async (request, response) => {
578
+ const body = await readJsonBody(request);
579
+ const agentId = requireAgentControl(request, body.agentId);
580
+ if (!agentId)
581
+ throw new BridgeRequestError(400, "invalid_agent_id", "agentId is required.");
582
+ if (agentOwner && agentOwner !== agentId && clock.now() - agentOwnerSeenAt <= AGENT_CLAIM_TIMEOUT_MS) {
583
+ throw new BridgeRequestError(409, "session_claimed", "Another MCP adapter owns this project session.");
584
+ }
585
+ if (agentOwner && agentOwner !== agentId)
586
+ releaseAgentOwner("The previous MCP adapter claim expired.");
587
+ agentOwner = agentId;
588
+ agentOwnerSeenAt = clock.now();
589
+ scheduleAgentOwnerExpiry();
590
+ sendJson(response, 200, { claimed: true }, null);
591
+ };
592
+ const handleAgentRelease = async (request, response) => {
593
+ const body = await readJsonBody(request);
594
+ const agentId = requireAgentOwner(request, body);
595
+ releaseAgentOwner(`Agent ${agentId} released the project session.`);
596
+ sendJson(response, 200, { released: true }, null);
597
+ };
598
+ const handleAgentListen = async (request, response) => {
599
+ const body = await readJsonBody(request);
600
+ requireAgentOwner(request, body);
601
+ const abort = new AbortController();
602
+ response.once("close", () => abort.abort());
603
+ const prompt = await waitForPrompt(abort.signal);
604
+ sendJson(response, 200, prompt, null);
605
+ };
606
+ const handleAgentReportStatus = async (request, response) => {
607
+ const body = await readJsonBody(request);
608
+ requireAgentOwner(request, body);
609
+ if (!isAgentStatusUpdate(body.update)) {
610
+ throw new BridgeRequestError(400, "invalid_status", "update must be a valid agent status update.");
611
+ }
612
+ updateRequestStatus(body.update);
613
+ sendJson(response, 200, getStatus(), null);
614
+ };
615
+ const handleAgentCanvas = async (request, response) => {
616
+ const body = await readJsonBody(request);
617
+ requireAgentOwner(request, body);
618
+ if (!isCanvasCommand(body.command)) {
619
+ throw new BridgeRequestError(400, "invalid_canvas_command", "command must be a valid Canvas command.");
620
+ }
621
+ sendJson(response, 200, await dispatchCanvasCommand(body.command), null);
622
+ };
429
623
  const httpServer = createServer((request, response) => {
430
624
  void (async () => {
431
625
  const url = new URL(request.url ?? "/", `http://${host}:${port}`);
626
+ if (request.method === "GET" && url.pathname === AGENT_CONTROL_ENDPOINTS.health) {
627
+ requireAgentControl(request);
628
+ sendJson(response, 200, { projectId: options.projectId, available: true, claimed: agentOwner !== null, status: getStatus() }, null);
629
+ return;
630
+ }
631
+ if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.claim) {
632
+ await handleAgentClaim(request, response);
633
+ return;
634
+ }
635
+ if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.release) {
636
+ await handleAgentRelease(request, response);
637
+ return;
638
+ }
639
+ if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.heartbeat) {
640
+ const body = await readJsonBody(request);
641
+ requireAgentOwner(request, body);
642
+ sendJson(response, 200, { alive: true }, null);
643
+ return;
644
+ }
645
+ if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.listen) {
646
+ await handleAgentListen(request, response);
647
+ return;
648
+ }
649
+ if (request.method === "GET" && url.pathname === AGENT_CONTROL_ENDPOINTS.status) {
650
+ const agentId = url.searchParams.get("agentId");
651
+ requireAgentControl(request, agentId);
652
+ if (agentOwner !== agentId)
653
+ throw new BridgeRequestError(409, "agent_not_owner", "This MCP adapter does not own the project session.");
654
+ sendJson(response, 200, getStatus(), null);
655
+ return;
656
+ }
657
+ if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.reportStatus) {
658
+ await handleAgentReportStatus(request, response);
659
+ return;
660
+ }
661
+ if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.canvas) {
662
+ await handleAgentCanvas(request, response);
663
+ return;
664
+ }
432
665
  if (request.method === "OPTIONS") {
433
666
  handleOptions(request, response);
434
667
  return;
@@ -441,6 +674,10 @@ export function createLoopbackBridge(options) {
441
674
  await handlePair(request, response);
442
675
  return;
443
676
  }
677
+ if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.takeover) {
678
+ await handleTakeover(request, response);
679
+ return;
680
+ }
444
681
  if (request.method === "GET" && url.pathname === BRIDGE_ENDPOINTS.events) {
445
682
  handleEvents(request, response, url);
446
683
  return;
@@ -508,7 +745,7 @@ export function createLoopbackBridge(options) {
508
745
  listener = null;
509
746
  if (pending.abortHandler && pending.signal)
510
747
  pending.signal.removeEventListener("abort", pending.abortHandler);
511
- pending.reject(new Error(reason));
748
+ pending.reject(new BridgeRequestError(409, "listener_cancelled", reason));
512
749
  broadcastStatus();
513
750
  };
514
751
  const waitForPrompt = (signal) => {
@@ -618,9 +855,6 @@ export function createLoopbackBridge(options) {
618
855
  // This method is the explicit host-side approval path. A bridge without
619
856
  // an HTTP allow-list can still be paired by trusted host code, while all
620
857
  // browser-originated requests remain closed until that approval exists.
621
- if (configuredOrigin !== null && requestCanonicalOrigin !== configuredOrigin) {
622
- throw new Error("Browser origin does not match this bridge");
623
- }
624
858
  if (canonicalAllowedOrigins.length > 0 && !originIsAllowed(requestCanonicalOrigin)) {
625
859
  throw new Error("Browser origin is not in the configured allow-list");
626
860
  }
@@ -683,6 +917,11 @@ export function createLoopbackBridge(options) {
683
917
  if (closePromise)
684
918
  return closePromise;
685
919
  lifecycle = "closed";
920
+ if (agentOwnerExpiry !== null)
921
+ clock.clearTimeout(agentOwnerExpiry);
922
+ agentOwnerExpiry = null;
923
+ agentOwner = null;
924
+ agentOwnerSeenAt = 0;
686
925
  cancelListener("bridge_closed");
687
926
  if (pendingCanvas) {
688
927
  clock.clearTimeout(pendingCanvas.timeout);
package/dist/cli.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { type ChildProcess } from "node:child_process";
2
2
  export interface CliOptions {
3
+ readonly command: "serve" | "doctor";
3
4
  readonly projectId: string;
4
5
  readonly origin?: string;
5
- readonly workspaceRoot: string;
6
+ readonly workspaceRoot?: string;
6
7
  readonly host: "127.0.0.1" | "::1" | "localhost";
7
8
  readonly port: number;
8
9
  readonly commandTimeoutMs: number;
@@ -31,6 +32,6 @@ export interface PageLaunchDependencies {
31
32
  export declare function openPairedPage(pageUrl: string, dependencies?: PageLaunchDependencies): Promise<boolean>;
32
33
  /** Parses only project-scoped, host-neutral CLI options. */
33
34
  export declare function parseCliArguments(argv?: readonly string[], environment?: CliEnvironment): CliOptions;
34
- export declare const CLI_USAGE = "Usage: nudge-mcp [options]\n\nStarts a project-scoped standard MCP stdio server and loopback browser bridge.\n\nOptions:\n --project-id <id> Stable project pairing identity\n --origin <origin> Exact app origin required for browser pairing\n --workspace-root <path> Project root supplied to the agent\n --host <loopback> 127.0.0.1, ::1, or localhost\n --port <port> Bridge port; default is deterministic per project, 0 is ephemeral\n --command-timeout-ms <ms> Canvas acknowledgement timeout\n --help Show this message\n";
35
+ export declare const CLI_USAGE = "Usage: nudge-mcp [doctor] [options]\n\nStarts a standard MCP stdio adapter. It discovers the bridge owned by the\nrunning project unless --origin selects the legacy coupled mode.\n\nOptions:\n --project-id <id> Stable project pairing identity\n --origin <origin> Exact app origin required for browser pairing\n --workspace-root <path> Optional legacy project restriction; omitted for reusable adapters\n --host <loopback> 127.0.0.1, ::1, or localhost\n --port <port> Bridge port; default is deterministic per project, 0 is ephemeral\n --command-timeout-ms <ms> Canvas acknowledgement timeout\n --help Show this message\n";
35
36
  /** Runs the CLI until the stdio MCP connection closes or the process stops. */
36
37
  export declare function runCli(argv?: readonly string[], environment?: CliEnvironment): Promise<void>;
package/dist/cli.js CHANGED
@@ -2,7 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { basename, resolve } from "node:path";
4
4
  import { defaultBridgePort } from "./internal/agent-protocol.js";
5
- import { createAgentCompanion } from "./server.js";
5
+ import { diagnoseProjectSessions, discoverProjectSessions } from "./discovery.js";
6
+ import { createAgentCompanion, createDiscoveredAgentAdapter, } from "./server.js";
6
7
  const DEFAULT_PORT = 0;
7
8
  const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
8
9
  const PAGE_PROBE_TIMEOUT_MS = 1_500;
@@ -59,6 +60,8 @@ function parseInteger(value, flag, maximum) {
59
60
  }
60
61
  /** Parses only project-scoped, host-neutral CLI options. */
61
62
  export function parseCliArguments(argv = process.argv.slice(2), environment = process.env) {
63
+ const command = argv[0] === "doctor" ? "doctor" : "serve";
64
+ const argumentsToParse = command === "doctor" ? argv.slice(1) : argv;
62
65
  const root = resolve(environment.NUDGE_UI_WORKSPACE_ROOT ?? environment.INIT_CWD ?? process.cwd());
63
66
  let projectId = environment.NUDGE_UI_PROJECT_ID ?? projectIdFor(root);
64
67
  let origin = environment.NUDGE_UI_ORIGIN;
@@ -70,30 +73,30 @@ export function parseCliArguments(argv = process.argv.slice(2), environment = pr
70
73
  let commandTimeoutMs = environment.NUDGE_UI_COMMAND_TIMEOUT_MS === undefined
71
74
  ? DEFAULT_COMMAND_TIMEOUT_MS
72
75
  : parseInteger(environment.NUDGE_UI_COMMAND_TIMEOUT_MS, "NUDGE_UI_COMMAND_TIMEOUT_MS", 60_000);
73
- let workspaceRoot = root;
76
+ let workspaceRoot = environment.NUDGE_UI_WORKSPACE_ROOT === undefined ? undefined : root;
74
77
  let help = false;
75
78
  let explicitPort = environment.NUDGE_UI_BRIDGE_PORT !== undefined;
76
- for (let index = 0; index < argv.length; index += 1) {
77
- const argument = argv[index];
79
+ for (let index = 0; index < argumentsToParse.length; index += 1) {
80
+ const argument = argumentsToParse[index];
78
81
  switch (argument) {
79
82
  case "--help":
80
83
  case "-h":
81
84
  help = true;
82
85
  break;
83
86
  case "--project-id":
84
- projectId = valueAfter(argv, index, argument);
87
+ projectId = valueAfter(argumentsToParse, index, argument);
85
88
  index += 1;
86
89
  break;
87
90
  case "--origin":
88
- origin = valueAfter(argv, index, argument);
91
+ origin = valueAfter(argumentsToParse, index, argument);
89
92
  index += 1;
90
93
  break;
91
94
  case "--workspace-root":
92
- workspaceRoot = resolve(valueAfter(argv, index, argument));
95
+ workspaceRoot = resolve(valueAfter(argumentsToParse, index, argument));
93
96
  index += 1;
94
97
  break;
95
98
  case "--host": {
96
- const candidate = valueAfter(argv, index, argument);
99
+ const candidate = valueAfter(argumentsToParse, index, argument);
97
100
  if (candidate !== "127.0.0.1" && candidate !== "::1" && candidate !== "localhost") {
98
101
  throw new TypeError("--host must be 127.0.0.1, ::1, or localhost");
99
102
  }
@@ -102,12 +105,12 @@ export function parseCliArguments(argv = process.argv.slice(2), environment = pr
102
105
  break;
103
106
  }
104
107
  case "--port":
105
- port = parseInteger(valueAfter(argv, index, argument), argument, 65_535);
108
+ port = parseInteger(valueAfter(argumentsToParse, index, argument), argument, 65_535);
106
109
  explicitPort = true;
107
110
  index += 1;
108
111
  break;
109
112
  case "--command-timeout-ms":
110
- commandTimeoutMs = parseInteger(valueAfter(argv, index, argument), argument, 60_000);
113
+ commandTimeoutMs = parseInteger(valueAfter(argumentsToParse, index, argument), argument, 60_000);
111
114
  if (commandTimeoutMs === 0)
112
115
  throw new TypeError("--command-timeout-ms must be greater than zero");
113
116
  index += 1;
@@ -120,16 +123,17 @@ export function parseCliArguments(argv = process.argv.slice(2), environment = pr
120
123
  throw new TypeError("--project-id must be a bounded non-empty string");
121
124
  if (!explicitPort)
122
125
  port = defaultBridgePort(projectId);
123
- return { projectId, origin, workspaceRoot, host, port, commandTimeoutMs, help };
126
+ return { command, projectId, origin, workspaceRoot, host, port, commandTimeoutMs, help };
124
127
  }
125
- export const CLI_USAGE = `Usage: nudge-mcp [options]
128
+ export const CLI_USAGE = `Usage: nudge-mcp [doctor] [options]
126
129
 
127
- Starts a project-scoped standard MCP stdio server and loopback browser bridge.
130
+ Starts a standard MCP stdio adapter. It discovers the bridge owned by the
131
+ running project unless --origin selects the legacy coupled mode.
128
132
 
129
133
  Options:
130
134
  --project-id <id> Stable project pairing identity
131
135
  --origin <origin> Exact app origin required for browser pairing
132
- --workspace-root <path> Project root supplied to the agent
136
+ --workspace-root <path> Optional legacy project restriction; omitted for reusable adapters
133
137
  --host <loopback> 127.0.0.1, ::1, or localhost
134
138
  --port <port> Bridge port; default is deterministic per project, 0 is ephemeral
135
139
  --command-timeout-ms <ms> Canvas acknowledgement timeout
@@ -142,8 +146,65 @@ export async function runCli(argv = process.argv.slice(2), environment = process
142
146
  process.stdout.write(CLI_USAGE);
143
147
  return;
144
148
  }
149
+ if (options.command === "doctor") {
150
+ const sessions = await discoverProjectSessions(options.workspaceRoot);
151
+ const matches = sessions.filter((session) => options.workspaceRoot === undefined || session.matchesApplication);
152
+ const diagnostics = await diagnoseProjectSessions(options.workspaceRoot);
153
+ process.stdout.write([
154
+ `[nudge-ui] registry: ${diagnostics.registryRoot}`,
155
+ `[nudge-ui] workspace: ${diagnostics.workspaceRoot ?? "all projects"}`,
156
+ `[nudge-ui] application: ${diagnostics.applicationRoot ?? "all applications"}`,
157
+ `[nudge-ui] descriptors: ${diagnostics.descriptorCount}; invalid: ${diagnostics.invalidCount}; incompatible: ${diagnostics.incompatibleCount}`,
158
+ `[nudge-ui] matching unreachable descriptors: ${diagnostics.matchingUnreachableCount}`,
159
+ ...diagnostics.guidance.map((message) => `[nudge-ui] ${message}`),
160
+ `[nudge-ui] live sessions: ${sessions.length}`,
161
+ `[nudge-ui] matching sessions: ${matches.length}`,
162
+ ...(matches.length === 0
163
+ ? ["[nudge-ui] no reachable compatible bridge matches; inspect the diagnostics above or start the development server"]
164
+ : matches.map((session) => {
165
+ const state = session.status.connection === "working"
166
+ ? "working"
167
+ : session.status.listenerActive ? "listening" : "available";
168
+ return `[nudge-ui] ${state}: ${session.projectId} (${session.origin})${session.claimed ? " [claimed]" : ""}`;
169
+ })),
170
+ ].join("\n") + "\n");
171
+ return;
172
+ }
173
+ if (!options.origin) {
174
+ const adapter = await createDiscoveredAgentAdapter({ workspaceRoot: options.workspaceRoot });
175
+ let stopping = false;
176
+ let resolveAdapterWait = null;
177
+ const stop = () => {
178
+ if (stopping)
179
+ return;
180
+ stopping = true;
181
+ void adapter.close();
182
+ resolveAdapterWait?.();
183
+ };
184
+ process.once("SIGINT", stop);
185
+ process.once("SIGTERM", stop);
186
+ try {
187
+ await adapter.start();
188
+ await new Promise((resolveAdapter) => {
189
+ const finish = () => resolveAdapter();
190
+ // Keep a direct resolver so signal handlers terminate even though
191
+ // installing a signal handler suppresses Node's default process exit.
192
+ resolveAdapterWait = finish;
193
+ process.stdin.once("end", finish);
194
+ process.once("exit", finish);
195
+ });
196
+ }
197
+ finally {
198
+ process.off("SIGINT", stop);
199
+ process.off("SIGTERM", stop);
200
+ await adapter.close();
201
+ }
202
+ return;
203
+ }
204
+ process.stderr.write("[nudge-ui] --origin uses deprecated coupled mode. Rerun agent setup to use project-owned bridges and avoid port collisions.\n");
145
205
  const companion = createAgentCompanion({
146
206
  ...options,
207
+ workspaceRoot: options.workspaceRoot ?? resolve(environment.INIT_CWD ?? process.cwd()),
147
208
  onControllerUnavailable: (pageUrl) => openPairedPage(pageUrl).then(() => undefined),
148
209
  });
149
210
  let stopping = false;