@nudge-ui/mcp 0.1.3 → 0.2.1

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/README.md CHANGED
@@ -1,58 +1,88 @@
1
1
  # `@nudge-ui/mcp`
2
2
 
3
- `@nudge-ui/mcp` connects a local Nudge UI inspector to an MCP-capable coding
4
- agent. It combines a standard MCP stdio server with a project-scoped loopback
5
- browser bridge. The browser does not speak MCP directly.
3
+ > **Early alpha:** This integration can make breaking changes while host
4
+ > support and the setup workflow stabilize.
6
5
 
7
- ## Install
6
+ `@nudge-ui/mcp` connects a local Nudge inspector to an MCP-capable coding
7
+ agent. The development server owns the browser bridge. The agent host starts a
8
+ small stdio adapter that discovers live bridges in the current checkout or Git
9
+ worktree.
10
+
11
+ ## Configure an agent
12
+
13
+ The Nudge initializer can install the dependency and configure supported agent
14
+ hosts. A manual project-scoped configuration uses this command:
8
15
 
9
16
  ```sh
10
- pnpm add -D @nudge-ui/mcp
17
+ node ./node_modules/@nudge-ui/mcp/dist/cli.mjs --workspace-root /path/to/project
11
18
  ```
12
19
 
13
- ### Configure automatically
20
+ The adapter does not need an origin, port, project ID, or branch. A running
21
+ development integration registers those values in a private per-user runtime
22
+ directory. Restart the agent host after changing its MCP configuration, then
23
+ ask it to **Listen to Nudge**. Agent configuration contains absolute checkout
24
+ and application paths, so rerun agent setup in each clone or Git worktree that
25
+ you intend the agent to edit.
14
26
 
15
- From the application project root, use [`add-mcp`](https://github.com/neon-solutions/add-mcp) to detect supported coding agents and write each host's native configuration format:
27
+ Run diagnostics without starting MCP stdio framing:
16
28
 
17
29
  ```sh
18
- npx add-mcp \
19
- 'npx -y @nudge-ui/mcp@latest --project-id my-app --origin http://localhost:5173 --workspace-root .' \
20
- --name nudge_ui
30
+ node ./node_modules/@nudge-ui/mcp/dist/cli.mjs doctor --workspace-root /path/to/project
21
31
  ```
22
32
 
23
- The command prompts for detected project agents. Use `-a codex` (or another
24
- supported agent name) to target one host, or `-y` to skip the prompt and use
25
- the detected project agents. Keep this project-scoped; do not use `-g` unless
26
- the configuration intentionally targets one fixed project.
33
+ The doctor reports live and exact-workspace session counts. It never prints
34
+ control credentials.
35
+
36
+ ## Project integration
37
+
38
+ Framework adapters start and stop the project bridge with the development
39
+ server:
40
+
41
+ ```ts
42
+ import { startProjectBridge } from "@nudge-ui/mcp/project";
27
43
 
28
- `--origin` must exactly match the development application's browser origin.
29
- The `--project-id` value must match the ID used by the Nudge host integration.
30
- For standard Vite and Astro projects, `my-app` normally matches the project
31
- directory name. Next.js and standalone HTML projects use host-specific IDs, so
32
- retain the explicit ID from their integration configuration.
44
+ const runtime = await startProjectBridge({
45
+ workspaceRoot: process.cwd(),
46
+ appRoot: process.cwd(),
47
+ projectId: "my-app",
48
+ origin: "http://localhost:5173",
49
+ allowedOrigins: ["http://127.0.0.1:5173"],
50
+ });
33
51
 
34
- The generated command uses the published `@nudge-ui/mcp` package through
35
- `npx`. Pin the package version instead of `@latest` when reproducible tool
36
- versions are required. After configuration, restart or reload the agent host
37
- so it refreshes its MCP tool catalog.
52
+ // Give browser code only these non-secret values.
53
+ console.log(runtime.browser.bridgeUrl, runtime.browser.projectId);
38
54
 
39
- The package has no install-time project mutation. The manual configuration
40
- below remains useful when an agent host is not supported by `add-mcp` or when
41
- you need an explicit custom command.
55
+ await runtime.close();
56
+ ```
57
+
58
+ `origin` must be the actual canonical development origin after the framework
59
+ chooses its port. `workspaceRoot` defaults to the Git worktree root when one is
60
+ available and otherwise defaults to `appRoot`. `appRoot` distinguishes several
61
+ applications running from one monorepo.
62
+
63
+ Each live bridge registers a random session ID, canonical workspace and app
64
+ paths, optional Git common directory and branch metadata, origin, and loopback
65
+ endpoint. The descriptor is mode `0600` inside a mode `0700` registry. It also
66
+ contains a private random credential used only between the bridge and adapter.
42
67
 
43
- After adding or changing the host configuration, restart or reload the MCP
44
- server, or start a fresh agent task. Open **Connect MCP** from the inspector's
45
- prompt menu for setup instructions, connection status, and explicit pairing.
46
- You can pair a page before the agent starts listening. Ask the agent to call
47
- `nudge_listen` and keep it active to enable sending. **Copy prompt** remains
48
- available while the agent is idle; prompts are not queued.
68
+ The adapter lists all live local sessions through `nudge_list_sessions`, but it
69
+ can listen only to sessions whose canonical workspace and configured
70
+ application path match. An adapter configured at the worktree root can select
71
+ any app in that worktree explicitly. A branch name or shared Git common
72
+ directory never causes fallback to another worktree. When several apps run in
73
+ one workspace, pass the chosen `sessionId` to `nudge_listen`. The adapter keeps
74
+ that app selection across a bridge restart and does not replay old requests.
49
75
 
50
- For Codex desktop, confirm that `nudge_ui` appears in the current task after
51
- the MCP restart. For Codex CLI, `codex mcp list` verifies the project entry.
52
- Restart a long-running OpenCode service after adding or changing its MCP
53
- configuration so it refreshes its tool catalog.
76
+ One adapter owns a selected project session until it closes. Other adapters
77
+ receive a claimed-session error instead of taking browser work. A heartbeat
78
+ allows the bridge to recover a claim after an adapter process is killed. Call
79
+ `nudge_release` when the user stops listening so another adapter can claim the
80
+ session without restarting the MCP host.
54
81
 
55
- ## Run manually
82
+ ## Legacy coupled mode
83
+
84
+ Passing `--origin` retains the previous behavior in which the MCP process also
85
+ owns its browser bridge:
56
86
 
57
87
  ```sh
58
88
  pnpm exec nudge-mcp \
@@ -61,17 +91,9 @@ pnpm exec nudge-mcp \
61
91
  --workspace-root /path/to/my-app
62
92
  ```
63
93
 
64
- Pass the exact application origin with `--origin` (or
65
- `NUDGE_UI_ORIGIN`). The bridge rejects browser origins that are not explicitly
66
- configured; it never trusts the first origin that connects. The bridge chooses
67
- a deterministic loopback port from the project ID so the inspector can find
68
- it without a port file. Use `--port 0` for an ephemeral test port.
69
-
70
- For integrations that establish the browser origin out of band, trusted host
71
- code can use the `pairBrowser` API as an explicit manual approval path. An
72
- unconfigured bridge does not accept HTTP pairing requests.
94
+ This mode remains available for custom integrations during migration. New
95
+ framework integrations should use the project-owned bridge.
73
96
 
74
- The agent should call `nudge_listen` immediately, apply the delivered prompt
75
- through its normal source-editing workflow, call `nudge_report_status`, and
76
- listen again. Canvas tools are limited to same-origin routes and agent-owned
77
- groups.
97
+ The agent calls `nudge_listen`, applies the delivered prompt, reports the
98
+ result through `nudge_report_status`, and listens again. Canvas tools remain
99
+ limited to routes from the paired application origin and agent-owned groups.
package/dist/bridge.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Server } from "node:http";
2
- import type { AgentPromptRequest, AgentRequestOutcome, AgentStatusSnapshot, AgentStatusUpdate, CanvasCommandResult, PairingResponse } from "@nudge-ui/agent-protocol";
2
+ import type { AgentDeliveredPrompt, AgentRequestOutcome, AgentStatusSnapshot, AgentStatusUpdate, CanvasCommandResult, PairingResponse } from "./internal/agent-protocol.js";
3
3
  /** A clock port keeps command timeout tests deterministic. */
4
4
  export interface BridgeClock {
5
5
  now(): number;
@@ -22,6 +22,8 @@ export interface BrowserBridgeOptions {
22
22
  readonly tokenFactory?: () => string;
23
23
  /** Test seam; production defaults to random UUIDs. */
24
24
  readonly idFactory?: () => string;
25
+ /** Private credential for a separately running MCP adapter. */
26
+ readonly agentControlToken?: string;
25
27
  /**
26
28
  * Best-effort hook used by the CLI to reopen the last paired page when an
27
29
  * agent rearms listening but no browser controller stream is attached.
@@ -49,7 +51,7 @@ export interface BrowserBridge {
49
51
  start(): Promise<BridgeAddress>;
50
52
  close(): Promise<void>;
51
53
  /** Blocks until a paired browser dispatches a prompt. */
52
- waitForPrompt(signal?: AbortSignal): Promise<AgentPromptRequest>;
54
+ waitForPrompt(signal?: AbortSignal): Promise<AgentDeliveredPrompt>;
53
55
  /** Marks the agent-side listener as stopped without ending the project. */
54
56
  cancelListener(reason?: string): void;
55
57
  /** Reports the terminal result for the currently running browser request. */
@@ -61,15 +63,15 @@ export interface BrowserBridge {
61
63
  /** Revokes a browser pairing; no data is written to disk. */
62
64
  disconnectBrowser(token: string): void;
63
65
  }
64
- export type CanvasCommandInput = Omit<import("@nudge-ui/agent-protocol").ReadCanvasStateCommand, "commandId"> & {
66
+ export type CanvasCommandInput = Omit<import("./internal/agent-protocol.js").ReadCanvasStateCommand, "commandId"> & {
65
67
  readonly commandId?: string;
66
- } | Omit<import("@nudge-ui/agent-protocol").PresentRoutesCommand, "commandId"> & {
68
+ } | Omit<import("./internal/agent-protocol.js").PresentRoutesCommand, "commandId"> & {
67
69
  readonly commandId?: string;
68
- } | Omit<import("@nudge-ui/agent-protocol").FocusCanvasGroupCommand, "commandId"> & {
70
+ } | Omit<import("./internal/agent-protocol.js").FocusCanvasGroupCommand, "commandId"> & {
69
71
  readonly commandId?: string;
70
- } | Omit<import("@nudge-ui/agent-protocol").FitCanvasCommand, "commandId"> & {
72
+ } | Omit<import("./internal/agent-protocol.js").FitCanvasCommand, "commandId"> & {
71
73
  readonly commandId?: string;
72
- } | Omit<import("@nudge-ui/agent-protocol").RemoveCanvasGroupCommand, "commandId"> & {
74
+ } | Omit<import("./internal/agent-protocol.js").RemoveCanvasGroupCommand, "commandId"> & {
73
75
  readonly commandId?: string;
74
76
  };
75
77
  /**
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 "@nudge-ui/agent-protocol";
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,7 +304,7 @@ 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,
@@ -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;
@@ -290,7 +333,20 @@ export function createLoopbackBridge(options) {
290
333
  requestedPageUrl = new URL(body.pageUrl, requestedOrigin).href;
291
334
  }
292
335
  if (sessionToken) {
293
- throw new BridgeRequestError(409, "already_paired", "A browser is already paired with this project.");
336
+ if (body.sessionToken !== sessionToken) {
337
+ throw new BridgeRequestError(409, "already_paired", "A browser is already paired with this project.");
338
+ }
339
+ lastPageUrl = requestedPageUrl ?? lastPageUrl;
340
+ const existing = {
341
+ protocolVersion: AGENT_PROTOCOL_VERSION,
342
+ projectId: options.projectId,
343
+ origin: pairedOrigin,
344
+ sessionToken,
345
+ ...(lastPageUrl === null ? {} : { pageUrl: lastPageUrl }),
346
+ status: getStatus(),
347
+ };
348
+ sendJson(response, 200, existing, incomingOrigin);
349
+ return;
294
350
  }
295
351
  sessionToken = tokenFactory();
296
352
  if (!sessionToken || sessionToken.length > AGENT_PROTOCOL_LIMITS.sessionToken) {
@@ -303,7 +359,7 @@ export function createLoopbackBridge(options) {
303
359
  const result = {
304
360
  protocolVersion: AGENT_PROTOCOL_VERSION,
305
361
  projectId: options.projectId,
306
- origin: configuredOrigin ?? pairedOrigin,
362
+ origin: pairedOrigin,
307
363
  sessionToken,
308
364
  ...(lastPageUrl === null ? {} : { pageUrl: lastPageUrl }),
309
365
  status: getStatus(),
@@ -338,7 +394,7 @@ export function createLoopbackBridge(options) {
338
394
  sendJson(response, 200, getStatus(), incomingOrigin);
339
395
  };
340
396
  const handlePrompt = async (request, response) => {
341
- const body = await readJsonBody(request);
397
+ const body = await readJsonBody(request, MAX_PROMPT_BODY_BYTES);
342
398
  const incomingOrigin = requireBrowserRequest(request, new URL("http://bridge.invalid"), body.sessionToken);
343
399
  parseProjectId(typeof body.projectId === "string" ? body.projectId : null, options.projectId);
344
400
  if (typeof body.prompt !== "string" || body.prompt.length === 0 || body.prompt.length > AGENT_PROTOCOL_LIMITS.prompt) {
@@ -348,6 +404,13 @@ export function createLoopbackBridge(options) {
348
404
  && (!Number.isSafeInteger(body.changeRevision) || body.changeRevision < 0)) {
349
405
  throw new BridgeRequestError(400, "invalid_revision", "changeRevision must be a non-negative safe integer");
350
406
  }
407
+ if (body.clientDispatchId !== undefined
408
+ && (typeof body.clientDispatchId !== "string"
409
+ || body.clientDispatchId.length === 0
410
+ || body.clientDispatchId.length > AGENT_PROTOCOL_LIMITS.clientDispatchId)) {
411
+ throw new BridgeRequestError(400, "invalid_dispatch_id", "clientDispatchId must be a bounded non-empty string");
412
+ }
413
+ const attachments = parseSketchAttachments(body.attachments);
351
414
  if (!listener) {
352
415
  throw new BridgeRequestError(409, "listener_unavailable", "The agent has not opened a listening call.");
353
416
  }
@@ -359,13 +422,19 @@ export function createLoopbackBridge(options) {
359
422
  projectId: options.projectId,
360
423
  prompt: body.prompt,
361
424
  ...(body.changeRevision === undefined ? {} : { changeRevision: body.changeRevision }),
425
+ ...(body.clientDispatchId === undefined ? {} : { clientDispatchId: body.clientDispatchId }),
426
+ ...(attachments.length === 0 ? {} : { sketches: attachments.map(({ data: _data, ...metadata }) => metadata) }),
427
+ };
428
+ const delivered = {
429
+ ...prompt,
430
+ ...(attachments.length === 0 ? {} : { attachments }),
362
431
  };
363
432
  const pending = listener;
364
433
  listener = null;
365
434
  if (pending.abortHandler && pending.signal)
366
435
  pending.signal.removeEventListener("abort", pending.abortHandler);
367
436
  currentRequest = { ...prompt, status: "working" };
368
- pending.resolve(prompt);
437
+ pending.resolve(delivered);
369
438
  const result = { request: prompt, status: "working" };
370
439
  sendJson(response, 202, result, incomingOrigin);
371
440
  broadcastStatus();
@@ -426,9 +495,160 @@ export function createLoopbackBridge(options) {
426
495
  disconnectBrowser(body.sessionToken);
427
496
  sendJson(response, 200, { disconnected: true }, incomingOrigin);
428
497
  };
498
+ const requireAgentControl = (request, bodyAgentId) => {
499
+ if (!agentControlToken || !loopbackAddress(request.socket.remoteAddress)) {
500
+ throw new BridgeRequestError(403, "agent_control_unavailable", "Private agent control is unavailable.");
501
+ }
502
+ if (headerValue(request, "authorization") !== `Bearer ${agentControlToken}`) {
503
+ throw new BridgeRequestError(401, "invalid_agent_credential", "The agent control credential is invalid.");
504
+ }
505
+ if (bodyAgentId === undefined)
506
+ return null;
507
+ if (typeof bodyAgentId !== "string" || bodyAgentId.length === 0 || bodyAgentId.length > 256) {
508
+ throw new BridgeRequestError(400, "invalid_agent_id", "agentId must be a bounded non-empty string.");
509
+ }
510
+ return bodyAgentId;
511
+ };
512
+ const requireAgentOwner = (request, body) => {
513
+ const agentId = requireAgentControl(request, body.agentId);
514
+ if (!agentId || agentOwner !== agentId) {
515
+ throw new BridgeRequestError(409, "agent_not_owner", "This MCP adapter does not own the project session.");
516
+ }
517
+ agentOwnerSeenAt = clock.now();
518
+ scheduleAgentOwnerExpiry();
519
+ return agentId;
520
+ };
521
+ const releaseAgentOwner = (reason) => {
522
+ if (agentOwnerExpiry !== null)
523
+ clock.clearTimeout(agentOwnerExpiry);
524
+ agentOwnerExpiry = null;
525
+ agentOwner = null;
526
+ agentOwnerSeenAt = 0;
527
+ cancelListener(reason);
528
+ if (currentRequest?.status === "working") {
529
+ currentRequest = { ...currentRequest, status: "interrupted", error: reason };
530
+ broadcastStatus();
531
+ }
532
+ if (pendingCanvas) {
533
+ const pendingCommand = pendingCanvas;
534
+ pendingCanvas = null;
535
+ clock.clearTimeout(pendingCommand.timeout);
536
+ pendingCommand.resolve({
537
+ commandId: pendingCommand.command.commandId,
538
+ ok: false,
539
+ error: { code: "agent_released", message: reason },
540
+ });
541
+ }
542
+ };
543
+ const scheduleAgentOwnerExpiry = () => {
544
+ if (agentOwnerExpiry !== null)
545
+ clock.clearTimeout(agentOwnerExpiry);
546
+ const expectedOwner = agentOwner;
547
+ if (!expectedOwner) {
548
+ agentOwnerExpiry = null;
549
+ return;
550
+ }
551
+ const expireIfStale = () => {
552
+ agentOwnerExpiry = null;
553
+ if (agentOwner !== expectedOwner)
554
+ return;
555
+ const remaining = AGENT_CLAIM_TIMEOUT_MS - (clock.now() - agentOwnerSeenAt);
556
+ if (remaining > 0) {
557
+ agentOwnerExpiry = clock.setTimeout(expireIfStale, remaining);
558
+ return;
559
+ }
560
+ releaseAgentOwner("The MCP adapter claim expired.");
561
+ };
562
+ agentOwnerExpiry = clock.setTimeout(expireIfStale, AGENT_CLAIM_TIMEOUT_MS);
563
+ };
564
+ const handleAgentClaim = async (request, response) => {
565
+ const body = await readJsonBody(request);
566
+ const agentId = requireAgentControl(request, body.agentId);
567
+ if (!agentId)
568
+ throw new BridgeRequestError(400, "invalid_agent_id", "agentId is required.");
569
+ if (agentOwner && agentOwner !== agentId && clock.now() - agentOwnerSeenAt <= AGENT_CLAIM_TIMEOUT_MS) {
570
+ throw new BridgeRequestError(409, "session_claimed", "Another MCP adapter owns this project session.");
571
+ }
572
+ if (agentOwner && agentOwner !== agentId)
573
+ releaseAgentOwner("The previous MCP adapter claim expired.");
574
+ agentOwner = agentId;
575
+ agentOwnerSeenAt = clock.now();
576
+ scheduleAgentOwnerExpiry();
577
+ sendJson(response, 200, { claimed: true }, null);
578
+ };
579
+ const handleAgentRelease = async (request, response) => {
580
+ const body = await readJsonBody(request);
581
+ const agentId = requireAgentOwner(request, body);
582
+ releaseAgentOwner(`Agent ${agentId} released the project session.`);
583
+ sendJson(response, 200, { released: true }, null);
584
+ };
585
+ const handleAgentListen = async (request, response) => {
586
+ const body = await readJsonBody(request);
587
+ requireAgentOwner(request, body);
588
+ const abort = new AbortController();
589
+ response.once("close", () => abort.abort());
590
+ const prompt = await waitForPrompt(abort.signal);
591
+ sendJson(response, 200, prompt, null);
592
+ };
593
+ const handleAgentReportStatus = async (request, response) => {
594
+ const body = await readJsonBody(request);
595
+ requireAgentOwner(request, body);
596
+ if (!isAgentStatusUpdate(body.update)) {
597
+ throw new BridgeRequestError(400, "invalid_status", "update must be a valid agent status update.");
598
+ }
599
+ updateRequestStatus(body.update);
600
+ sendJson(response, 200, getStatus(), null);
601
+ };
602
+ const handleAgentCanvas = async (request, response) => {
603
+ const body = await readJsonBody(request);
604
+ requireAgentOwner(request, body);
605
+ if (!isCanvasCommand(body.command)) {
606
+ throw new BridgeRequestError(400, "invalid_canvas_command", "command must be a valid Canvas command.");
607
+ }
608
+ sendJson(response, 200, await dispatchCanvasCommand(body.command), null);
609
+ };
429
610
  const httpServer = createServer((request, response) => {
430
611
  void (async () => {
431
612
  const url = new URL(request.url ?? "/", `http://${host}:${port}`);
613
+ if (request.method === "GET" && url.pathname === AGENT_CONTROL_ENDPOINTS.health) {
614
+ requireAgentControl(request);
615
+ sendJson(response, 200, { projectId: options.projectId, available: true, claimed: agentOwner !== null, status: getStatus() }, null);
616
+ return;
617
+ }
618
+ if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.claim) {
619
+ await handleAgentClaim(request, response);
620
+ return;
621
+ }
622
+ if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.release) {
623
+ await handleAgentRelease(request, response);
624
+ return;
625
+ }
626
+ if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.heartbeat) {
627
+ const body = await readJsonBody(request);
628
+ requireAgentOwner(request, body);
629
+ sendJson(response, 200, { alive: true }, null);
630
+ return;
631
+ }
632
+ if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.listen) {
633
+ await handleAgentListen(request, response);
634
+ return;
635
+ }
636
+ if (request.method === "GET" && url.pathname === AGENT_CONTROL_ENDPOINTS.status) {
637
+ const agentId = url.searchParams.get("agentId");
638
+ requireAgentControl(request, agentId);
639
+ if (agentOwner !== agentId)
640
+ throw new BridgeRequestError(409, "agent_not_owner", "This MCP adapter does not own the project session.");
641
+ sendJson(response, 200, getStatus(), null);
642
+ return;
643
+ }
644
+ if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.reportStatus) {
645
+ await handleAgentReportStatus(request, response);
646
+ return;
647
+ }
648
+ if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.canvas) {
649
+ await handleAgentCanvas(request, response);
650
+ return;
651
+ }
432
652
  if (request.method === "OPTIONS") {
433
653
  handleOptions(request, response);
434
654
  return;
@@ -508,7 +728,7 @@ export function createLoopbackBridge(options) {
508
728
  listener = null;
509
729
  if (pending.abortHandler && pending.signal)
510
730
  pending.signal.removeEventListener("abort", pending.abortHandler);
511
- pending.reject(new Error(reason));
731
+ pending.reject(new BridgeRequestError(409, "listener_cancelled", reason));
512
732
  broadcastStatus();
513
733
  };
514
734
  const waitForPrompt = (signal) => {
@@ -618,9 +838,6 @@ export function createLoopbackBridge(options) {
618
838
  // This method is the explicit host-side approval path. A bridge without
619
839
  // an HTTP allow-list can still be paired by trusted host code, while all
620
840
  // 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
841
  if (canonicalAllowedOrigins.length > 0 && !originIsAllowed(requestCanonicalOrigin)) {
625
842
  throw new Error("Browser origin is not in the configured allow-list");
626
843
  }
@@ -683,6 +900,11 @@ export function createLoopbackBridge(options) {
683
900
  if (closePromise)
684
901
  return closePromise;
685
902
  lifecycle = "closed";
903
+ if (agentOwnerExpiry !== null)
904
+ clock.clearTimeout(agentOwnerExpiry);
905
+ agentOwnerExpiry = null;
906
+ agentOwner = null;
907
+ agentOwnerSeenAt = 0;
686
908
  cancelListener("bridge_closed");
687
909
  if (pendingCanvas) {
688
910
  clock.clearTimeout(pendingCanvas.timeout);
package/dist/cli.d.ts CHANGED
@@ -1,5 +1,6 @@
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
6
  readonly workspaceRoot: string;
@@ -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> 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
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>;