@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.
@@ -0,0 +1,93 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { AGENT_CONTROL_ENDPOINTS } from "./protocol.js";
3
+ const CONTROL_TIMEOUT_MS = 5_000;
4
+ const CANVAS_TIMEOUT_MS = 65_000;
5
+ async function requestJson(session, path, init = {}, { signal, timeoutMs = CONTROL_TIMEOUT_MS } = {}) {
6
+ const response = await fetch(`${session.endpoint}${path}`, {
7
+ ...init,
8
+ redirect: "error",
9
+ signal: signal ?? (timeoutMs === null ? undefined : AbortSignal.timeout(timeoutMs)),
10
+ headers: {
11
+ Authorization: `Bearer ${session.controlToken}`,
12
+ ...(init.body === undefined ? {} : { "Content-Type": "application/json" }),
13
+ ...init.headers,
14
+ },
15
+ });
16
+ const body = await response.json().catch(() => undefined);
17
+ if (!response.ok)
18
+ throw new Error(body?.error?.message ?? `Nudge project bridge returned ${response.status}.`);
19
+ return body;
20
+ }
21
+ export async function readSessionHealth(session, timeoutMs = CONTROL_TIMEOUT_MS) {
22
+ return await requestJson(session, AGENT_CONTROL_ENDPOINTS.health, {}, { timeoutMs });
23
+ }
24
+ /** Owns one project's claim, heartbeat, and authenticated control requests. */
25
+ export class ProjectSessionClient {
26
+ descriptor;
27
+ agentId;
28
+ claimed = false;
29
+ claimPromise = null;
30
+ heartbeat = null;
31
+ closing = false;
32
+ constructor(descriptor, agentId) {
33
+ this.descriptor = descriptor;
34
+ this.agentId = agentId;
35
+ }
36
+ async send(path, payload = {}, options) {
37
+ return await requestJson(this.descriptor, path, {
38
+ method: "POST",
39
+ body: JSON.stringify({ agentId: this.agentId, ...payload }),
40
+ }, options);
41
+ }
42
+ async acquireClaim() {
43
+ await this.send(AGENT_CONTROL_ENDPOINTS.claim);
44
+ this.claimed = true;
45
+ this.heartbeat = setInterval(() => {
46
+ void this.send(AGENT_CONTROL_ENDPOINTS.heartbeat).catch(() => undefined);
47
+ }, 10_000);
48
+ this.heartbeat.unref();
49
+ }
50
+ async claim() {
51
+ if (this.closing)
52
+ throw new Error("The MCP adapter is closing.");
53
+ if (this.claimed)
54
+ return;
55
+ if (this.claimPromise)
56
+ return await this.claimPromise;
57
+ this.claimPromise = this.acquireClaim();
58
+ try {
59
+ await this.claimPromise;
60
+ }
61
+ finally {
62
+ this.claimPromise = null;
63
+ }
64
+ }
65
+ async waitForPrompt(signal) {
66
+ await this.claim();
67
+ return await this.send(AGENT_CONTROL_ENDPOINTS.listen, {}, { signal, timeoutMs: null });
68
+ }
69
+ async getStatus() {
70
+ const health = await readSessionHealth(this.descriptor);
71
+ return health.status;
72
+ }
73
+ async updateRequestStatus(update) {
74
+ await this.claim();
75
+ return await this.send(AGENT_CONTROL_ENDPOINTS.reportStatus, { update });
76
+ }
77
+ async dispatchCanvasCommand(input) {
78
+ await this.claim();
79
+ const command = { ...input, commandId: input.commandId ?? randomUUID() };
80
+ return await this.send(AGENT_CONTROL_ENDPOINTS.canvas, { command }, { timeoutMs: CANVAS_TIMEOUT_MS });
81
+ }
82
+ async close() {
83
+ this.closing = true;
84
+ await this.claimPromise?.catch(() => undefined);
85
+ if (!this.claimed)
86
+ return;
87
+ this.claimed = false;
88
+ if (this.heartbeat)
89
+ clearInterval(this.heartbeat);
90
+ this.heartbeat = null;
91
+ await this.send(AGENT_CONTROL_ENDPOINTS.release).catch(() => undefined);
92
+ }
93
+ }
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Companion-facing aliases and transport envelopes.
3
3
  *
4
- * The domain contracts live in `@nudge-ui/agent-protocol`; this module keeps
5
- * transport names discoverable for Node consumers while preserving a single
6
- * protocol vocabulary for browser and agent adapters.
4
+ * The domain contracts live in the private shared protocol module. This public
5
+ * subpath keeps transport names discoverable for Node consumers while
6
+ * preserving one protocol vocabulary for browser and agent adapters.
7
7
  */
8
- export { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, CANVAS_COMMAND_TYPES, canonicalOrigin, defaultBridgePort, isAgentPromptRequest, isAgentStatusUpdate, isAllowedOrigin, isCanvasCommand, isCanvasGroup, isCanvasState, isCanonicalOrigin, isProjectIdentity, isSameOriginRoute, protocolError, validateRoutes, } from "@nudge-ui/agent-protocol";
9
- export type { AgentConnectionState, AgentProjectIdentity, AgentPromptRequest, AgentRequestOutcome, AgentRequestStatus, AgentRoute, AgentStatusSnapshot, AgentStatusUpdate, BridgeCanvasCommandEvent, BridgeConnectedEvent, BridgeDisconnectedEvent, BridgeEnvelope, BridgeEvent, BridgeEventType, BridgeStatusEvent, CanvasCommand, CanvasCommandAcknowledgement, CanvasCommandError, CanvasCommandResult, CanvasGroup, CanvasGroupOwner, CanvasState, FitCanvasCommand, FocusCanvasGroupCommand, PairingRequest, PairingResponse, PresentRoutesCommand, PromptDispatchRequest, PromptDispatchResponse, ReadCanvasStateCommand, RemoveCanvasGroupCommand, StatusEventRequest, } from "@nudge-ui/agent-protocol";
8
+ export { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, CANVAS_COMMAND_TYPES, canonicalOrigin, defaultBridgePort, isAgentPromptRequest, isAgentSketchAttachment, isAgentSketchCaptureMetadata, isAgentSketchMetadata, isAgentStatusUpdate, isAllowedOrigin, isCanvasCommand, isCanvasGroup, isCanvasState, isCanonicalOrigin, isProjectIdentity, isSameOriginRoute, protocolError, validateSketchAttachments, validateRoutes, } from "./internal/agent-protocol.js";
9
+ export type { AgentConnectionState, AgentProjectIdentity, AgentPromptRequest, AgentDeliveredPrompt, AgentSketchAttachment, AgentSketchCaptureMetadata, AgentSketchMetadata, AgentRequestOutcome, AgentRequestStatus, AgentRoute, AgentStatusSnapshot, AgentStatusUpdate, BridgeCanvasCommandEvent, BridgeConnectedEvent, BridgeDisconnectedEvent, BridgeEnvelope, BridgeEvent, BridgeEventType, BridgeStatusEvent, CanvasCommand, CanvasCommandAcknowledgement, CanvasCommandError, CanvasCommandResult, CanvasGroup, CanvasGroupOwner, CanvasState, FitCanvasCommand, FocusCanvasGroupCommand, PairingRequest, PairingResponse, PresentRoutesCommand, PromptDispatchRequest, PromptDispatchResponse, ReadCanvasStateCommand, RemoveCanvasGroupCommand, StatusEventRequest, } from "./internal/agent-protocol.js";
10
10
  /** Stable endpoint names used by the loopback browser bridge. */
11
11
  export declare const BRIDGE_ENDPOINTS: {
12
12
  readonly health: "/health";
@@ -17,6 +17,17 @@ export declare const BRIDGE_ENDPOINTS: {
17
17
  readonly canvasAcknowledgement: "/canvas/ack";
18
18
  readonly disconnect: "/disconnect";
19
19
  };
20
+ /** Private, loopback-only endpoints used by an MCP adapter process. */
21
+ export declare const AGENT_CONTROL_ENDPOINTS: {
22
+ readonly health: "/__nudge/agent/health";
23
+ readonly claim: "/__nudge/agent/claim";
24
+ readonly heartbeat: "/__nudge/agent/heartbeat";
25
+ readonly release: "/__nudge/agent/release";
26
+ readonly listen: "/__nudge/agent/listen";
27
+ readonly status: "/__nudge/agent/status";
28
+ readonly reportStatus: "/__nudge/agent/report-status";
29
+ readonly canvas: "/__nudge/agent/canvas";
30
+ };
20
31
  export type BridgeEndpoint = (typeof BRIDGE_ENDPOINTS)[keyof typeof BRIDGE_ENDPOINTS];
21
32
  /** Error body returned by every rejected bridge request. */
22
33
  export interface BridgeHttpError {
package/dist/protocol.js CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Companion-facing aliases and transport envelopes.
3
3
  *
4
- * The domain contracts live in `@nudge-ui/agent-protocol`; this module keeps
5
- * transport names discoverable for Node consumers while preserving a single
6
- * protocol vocabulary for browser and agent adapters.
4
+ * The domain contracts live in the private shared protocol module. This public
5
+ * subpath keeps transport names discoverable for Node consumers while
6
+ * preserving one protocol vocabulary for browser and agent adapters.
7
7
  */
8
- export { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, CANVAS_COMMAND_TYPES, canonicalOrigin, defaultBridgePort, isAgentPromptRequest, isAgentStatusUpdate, isAllowedOrigin, isCanvasCommand, isCanvasGroup, isCanvasState, isCanonicalOrigin, isProjectIdentity, isSameOriginRoute, protocolError, validateRoutes, } from "@nudge-ui/agent-protocol";
8
+ export { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, CANVAS_COMMAND_TYPES, canonicalOrigin, defaultBridgePort, isAgentPromptRequest, isAgentSketchAttachment, isAgentSketchCaptureMetadata, isAgentSketchMetadata, isAgentStatusUpdate, isAllowedOrigin, isCanvasCommand, isCanvasGroup, isCanvasState, isCanonicalOrigin, isProjectIdentity, isSameOriginRoute, protocolError, validateSketchAttachments, validateRoutes, } from "./internal/agent-protocol.js";
9
9
  /** Stable endpoint names used by the loopback browser bridge. */
10
10
  export const BRIDGE_ENDPOINTS = {
11
11
  health: "/health",
@@ -16,3 +16,14 @@ export const BRIDGE_ENDPOINTS = {
16
16
  canvasAcknowledgement: "/canvas/ack",
17
17
  disconnect: "/disconnect",
18
18
  };
19
+ /** Private, loopback-only endpoints used by an MCP adapter process. */
20
+ export const AGENT_CONTROL_ENDPOINTS = {
21
+ health: "/__nudge/agent/health",
22
+ claim: "/__nudge/agent/claim",
23
+ heartbeat: "/__nudge/agent/heartbeat",
24
+ release: "/__nudge/agent/release",
25
+ listen: "/__nudge/agent/listen",
26
+ status: "/__nudge/agent/status",
27
+ reportStatus: "/__nudge/agent/report-status",
28
+ canvas: "/__nudge/agent/canvas",
29
+ };
package/dist/server.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
3
- import type { AgentProjectIdentity } from "@nudge-ui/agent-protocol";
3
+ import type { AgentProjectIdentity } from "./internal/agent-protocol.js";
4
4
  import { type BrowserBridge, type BrowserBridgeOptions, type BridgeAddress } from "./bridge.js";
5
+ import { DiscoveredProjectRouter } from "./discovery.js";
5
6
  export declare const MCP_SERVER_NAME = "nudge-ui";
6
- export declare const MCP_SERVER_VERSION = "0.1.2";
7
+ export declare const MCP_SERVER_VERSION: string;
7
8
  /** Instructions are sent through MCP initialization for every host. */
8
9
  export declare const MCP_SERVER_INSTRUCTIONS: string;
9
10
  export interface AgentCompanionOptions extends BrowserBridgeOptions {
@@ -26,3 +27,16 @@ export interface AgentCompanion {
26
27
  * The returned companion is inert until `start` is called.
27
28
  */
28
29
  export declare function createAgentCompanion(options: AgentCompanionOptions): AgentCompanion;
30
+ export interface DiscoveredAgentAdapter {
31
+ readonly mcpServer: McpServer;
32
+ readonly router: DiscoveredProjectRouter;
33
+ start(transport?: Transport): Promise<void>;
34
+ close(): Promise<void>;
35
+ }
36
+ /** Creates a stdio MCP adapter that discovers project-owned bridges. */
37
+ export declare function createDiscoveredAgentAdapter(options: {
38
+ readonly workspaceRoot: string;
39
+ readonly registryRoot?: string;
40
+ readonly serverName?: string;
41
+ readonly serverVersion?: string;
42
+ }): Promise<DiscoveredAgentAdapter>;
package/dist/server.js CHANGED
@@ -1,17 +1,30 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { createRequire } from "node:module";
3
4
  import { z } from "zod";
4
- import { validateRoutes } from "@nudge-ui/agent-protocol";
5
5
  import { createLoopbackBridge, } from "./bridge.js";
6
+ import { DiscoveredProjectRouter } from "./discovery.js";
6
7
  export const MCP_SERVER_NAME = "nudge-ui";
7
- export const MCP_SERVER_VERSION = "0.1.2";
8
+ export const MCP_SERVER_VERSION = readPackageVersion();
9
+ function readPackageVersion() {
10
+ const metadata = createRequire(import.meta.url)("../package.json");
11
+ if (typeof metadata !== "object"
12
+ || metadata === null
13
+ || !("version" in metadata)
14
+ || typeof metadata.version !== "string") {
15
+ throw new TypeError("@nudge-ui/mcp package metadata has no version.");
16
+ }
17
+ return metadata.version;
18
+ }
8
19
  /** Instructions are sent through MCP initialization for every host. */
9
20
  export const MCP_SERVER_INSTRUCTIONS = [
10
- "Nudge UI is a local browser companion for one project workspace.",
11
- "Browser pairing requires the app origin to be configured explicitly with --origin or NUDGE_UI_ORIGIN; an unconfigured bridge accepts no HTTP pairing.",
12
- "Call nudge_listen (or nudge_connect) immediately at task start and keep the call open while waiting for a browser request; the bridge may be running before a listener exists.",
21
+ "Nudge UI is a local browser companion for one exact project workspace or Git worktree.",
22
+ "Before listening, compare each session's workspaceRoot and appRoot with the checkout and application you will edit; if they differ, rerun Nudge agent setup in the intended checkout.",
23
+ "When the user asks to listen to Nudge, call nudge_list_sessions when that tool is available; if several apps are running, select only a session marked matchesApplication.",
24
+ "Call nudge_listen (or nudge_connect) and keep the call open while waiting for a browser request; the bridge may be running before a listener exists.",
13
25
  "After a prompt is delivered, edit the project source using the host's normal approval flow.",
14
26
  "Report completed, failed, or interrupted status with nudge_report_status before listening again.",
27
+ "When the user asks to stop listening, call nudge_release when that tool is available so another agent can claim the project session.",
15
28
  "Canvas tools operate only on same-origin routes from the paired project.",
16
29
  "No request, prompt, or browser state is persisted by this companion.",
17
30
  ].join(" ");
@@ -28,12 +41,21 @@ function errorResult(error) {
28
41
  };
29
42
  }
30
43
  function promptResult(request) {
31
- return textResult({
32
- requestId: request.requestId,
33
- projectId: request.projectId,
34
- prompt: request.prompt,
35
- ...(request.changeRevision === undefined ? {} : { changeRevision: request.changeRevision }),
36
- });
44
+ const content = [{
45
+ type: "text",
46
+ text: JSON.stringify({
47
+ requestId: request.requestId,
48
+ projectId: request.projectId,
49
+ prompt: request.prompt,
50
+ ...(request.changeRevision === undefined ? {} : { changeRevision: request.changeRevision }),
51
+ ...(request.clientDispatchId === undefined ? {} : { clientDispatchId: request.clientDispatchId }),
52
+ ...(request.sketches === undefined ? {} : { sketches: request.sketches }),
53
+ }),
54
+ }];
55
+ for (const attachment of request.attachments ?? []) {
56
+ content.push({ type: "text", text: `Sketch image: ${attachment.filename}` }, { type: "image", data: attachment.data, mimeType: attachment.mimeType });
57
+ }
58
+ return { content };
37
59
  }
38
60
  function commandResult(result) {
39
61
  return {
@@ -49,26 +71,27 @@ function boundedWorkspaceRoot(value) {
49
71
  return value;
50
72
  }
51
73
  function installTools(mcpServer, bridge) {
52
- const listen = async (_args, extra) => {
74
+ const listen = async (args, extra) => {
53
75
  try {
54
- return promptResult(await bridge.waitForPrompt(extra.signal));
76
+ return promptResult(await bridge.waitForPrompt(extra.signal, args.sessionId));
55
77
  }
56
78
  catch (error) {
57
79
  return errorResult(error);
58
80
  }
59
81
  };
60
- const listenDescription = "Call this immediately at task start. Wait for one browser prompt. Keep this standard MCP call open, then call nudge_report_status and listen again after the request ends.";
82
+ const listenDescription = "When the user asks to listen to Nudge, wait for one browser prompt. Keep this standard MCP call open, then call nudge_report_status and listen again after the request ends.";
61
83
  const emptyInput = {};
84
+ const listenInput = bridge.listSessions ? { sessionId: z.string().uuid().optional() } : emptyInput;
62
85
  mcpServer.registerTool("nudge_listen", {
63
86
  title: "Listen for Nudge requests",
64
87
  description: listenDescription,
65
- inputSchema: emptyInput,
88
+ inputSchema: listenInput,
66
89
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
67
90
  }, listen);
68
91
  mcpServer.registerTool("nudge_connect", {
69
92
  title: "Connect to Nudge",
70
93
  description: "Open the long-lived Nudge browser connection and wait for one prompt.",
71
- inputSchema: emptyInput,
94
+ inputSchema: listenInput,
72
95
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
73
96
  }, listen);
74
97
  mcpServer.registerTool("nudge_get_status", {
@@ -76,7 +99,31 @@ function installTools(mcpServer, bridge) {
76
99
  description: "Read the in-memory browser pairing and request status for this project.",
77
100
  inputSchema: emptyInput,
78
101
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
79
- }, async () => textResult(bridge.getStatus()));
102
+ }, async () => textResult(await bridge.getStatus()));
103
+ if (bridge.listSessions) {
104
+ mcpServer.registerTool("nudge_list_sessions", {
105
+ title: "List Nudge project sessions",
106
+ description: "List live local Nudge sessions. Only sessions marked matchesApplication can be selected by an application-scoped adapter.",
107
+ inputSchema: emptyInput,
108
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
109
+ }, async () => textResult(await bridge.listSessions()));
110
+ }
111
+ if (bridge.release) {
112
+ mcpServer.registerTool("nudge_release", {
113
+ title: "Release Nudge session",
114
+ description: "Stop listening and release this adapter's project-session claim so another agent can connect.",
115
+ inputSchema: emptyInput,
116
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
117
+ }, async () => {
118
+ try {
119
+ await bridge.release();
120
+ return textResult({ released: true });
121
+ }
122
+ catch (error) {
123
+ return errorResult(error);
124
+ }
125
+ });
126
+ }
80
127
  mcpServer.registerTool("nudge_report_status", {
81
128
  title: "Report Nudge request status",
82
129
  description: "Report working, completed, failed, or interrupted status for the prompt currently being handled.",
@@ -95,8 +142,8 @@ function installTools(mcpServer, bridge) {
95
142
  ...(args.error === undefined ? {} : { error: args.error }),
96
143
  };
97
144
  try {
98
- bridge.updateRequestStatus(update);
99
- return textResult(bridge.getStatus());
145
+ const updated = await bridge.updateRequestStatus(update);
146
+ return textResult(updated ?? await bridge.getStatus());
100
147
  }
101
148
  catch (error) {
102
149
  return errorResult(error);
@@ -130,14 +177,11 @@ function installTools(mcpServer, bridge) {
130
177
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
131
178
  }, async (args) => {
132
179
  try {
133
- if (!bridge.effectiveOrigin)
134
- throw new Error("Canvas routes require a paired or configured browser origin.");
135
- const routes = validateRoutes(args.routes, bridge.effectiveOrigin);
136
180
  return commandResult(await bridge.dispatchCanvasCommand({
137
181
  type: "present-routes",
138
182
  groupId: args.groupId ?? `agent-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
139
183
  label: args.label,
140
- routes,
184
+ routes: args.routes,
141
185
  }));
142
186
  }
143
187
  catch (error) {
@@ -241,3 +285,38 @@ export function createAgentCompanion(options) {
241
285
  close,
242
286
  };
243
287
  }
288
+ /** Creates a stdio MCP adapter that discovers project-owned bridges. */
289
+ export async function createDiscoveredAgentAdapter(options) {
290
+ const router = await DiscoveredProjectRouter.create(options.workspaceRoot, options.registryRoot);
291
+ const mcpServer = new McpServer({
292
+ name: options.serverName ?? MCP_SERVER_NAME,
293
+ version: options.serverVersion ?? MCP_SERVER_VERSION,
294
+ }, { instructions: MCP_SERVER_INSTRUCTIONS });
295
+ installTools(mcpServer, router);
296
+ let transport = null;
297
+ let started = false;
298
+ let closePromise = null;
299
+ return {
300
+ mcpServer,
301
+ router,
302
+ async start(providedTransport) {
303
+ if (started)
304
+ return;
305
+ if (closePromise)
306
+ throw new Error("The Nudge MCP adapter is closing.");
307
+ started = true;
308
+ transport = providedTransport ?? new StdioServerTransport();
309
+ await mcpServer.connect(transport);
310
+ },
311
+ close() {
312
+ if (closePromise)
313
+ return closePromise;
314
+ closePromise = (async () => {
315
+ await router.close();
316
+ if (transport)
317
+ await mcpServer.close().catch(() => undefined);
318
+ })();
319
+ return closePromise;
320
+ },
321
+ };
322
+ }
@@ -0,0 +1,8 @@
1
+ import { type SessionHealth } from "./projectSessionClient.js";
2
+ import type { StoredProjectSession } from "./project.js";
3
+ export interface LiveProjectSession {
4
+ readonly descriptor: StoredProjectSession;
5
+ readonly health: SessionHealth;
6
+ }
7
+ /** Reads private registry records and keeps only bridges that answer authenticated probes. */
8
+ export declare function readLiveProjectSessions(registryRoot: string): Promise<LiveProjectSession[]>;
@@ -0,0 +1,89 @@
1
+ import { lstat, readFile, readdir, realpath } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { AGENT_PROTOCOL_VERSION } from "./internal/agent-protocol.js";
4
+ import { readSessionHealth } from "./projectSessionClient.js";
5
+ const PROBE_TIMEOUT_MS = 750;
6
+ const MAX_DESCRIPTOR_BYTES = 32 * 1024;
7
+ /** Reads private registry records and keeps only bridges that answer authenticated probes. */
8
+ export async function readLiveProjectSessions(registryRoot) {
9
+ const names = await readRegistryDirectory(registryRoot);
10
+ const descriptors = await Promise.all(names.filter((name) => name.endsWith(".json")).map((name) => readDescriptor(resolve(registryRoot, name))));
11
+ const live = await Promise.all(descriptors.filter(isPresent).map(probeSession));
12
+ return live.filter(isPresent);
13
+ }
14
+ async function readRegistryDirectory(registryRoot) {
15
+ try {
16
+ return await readdir(registryRoot);
17
+ }
18
+ catch (error) {
19
+ if (error.code === "ENOENT")
20
+ return [];
21
+ throw error;
22
+ }
23
+ }
24
+ async function readDescriptor(path) {
25
+ try {
26
+ const metadata = await lstat(path);
27
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_DESCRIPTOR_BYTES)
28
+ return undefined;
29
+ if (typeof process.getuid === "function" && metadata.uid !== process.getuid())
30
+ return undefined;
31
+ if ((metadata.mode & 0o077) !== 0)
32
+ return undefined;
33
+ const parsed = JSON.parse(await readFile(path, "utf8"));
34
+ if (!isStoredSession(parsed))
35
+ return undefined;
36
+ const [workspaceRoot, appRoot] = await Promise.all([realpath(parsed.workspaceRoot), realpath(parsed.appRoot)]);
37
+ return { ...parsed, workspaceRoot, appRoot };
38
+ }
39
+ catch {
40
+ // Records can disappear during shutdown or refer to a removed worktree.
41
+ return undefined;
42
+ }
43
+ }
44
+ async function probeSession(descriptor) {
45
+ try {
46
+ const health = await readSessionHealth(descriptor, PROBE_TIMEOUT_MS);
47
+ if (health.projectId !== descriptor.projectId || health.available !== true)
48
+ return undefined;
49
+ return { descriptor, health };
50
+ }
51
+ catch {
52
+ // A stale or unreachable bridge is not a selectable session.
53
+ return undefined;
54
+ }
55
+ }
56
+ function isPresent(value) {
57
+ return value !== undefined;
58
+ }
59
+ function isLoopbackEndpoint(value) {
60
+ if (typeof value !== "string")
61
+ return false;
62
+ try {
63
+ const url = new URL(value);
64
+ return url.protocol === "http:"
65
+ && (url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1" || url.hostname === "localhost")
66
+ && url.pathname === "/" && !url.username && !url.password && !url.search && !url.hash;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ function isStoredSession(value) {
73
+ if (!value || typeof value !== "object" || Array.isArray(value))
74
+ return false;
75
+ const session = value;
76
+ return session.schemaVersion === 1
77
+ && session.protocolVersion === AGENT_PROTOCOL_VERSION
78
+ && typeof session.sessionId === "string" && session.sessionId.length > 0
79
+ && typeof session.projectId === "string" && session.projectId.length > 0
80
+ && typeof session.workspaceRoot === "string" && session.workspaceRoot.length > 0
81
+ && typeof session.appRoot === "string" && session.appRoot.length > 0
82
+ && (session.gitCommonDir === undefined || typeof session.gitCommonDir === "string")
83
+ && (session.branch === undefined || typeof session.branch === "string")
84
+ && typeof session.origin === "string"
85
+ && isLoopbackEndpoint(session.endpoint)
86
+ && typeof session.startedAt === "string"
87
+ && typeof session.pid === "number" && Number.isSafeInteger(session.pid)
88
+ && typeof session.controlToken === "string" && session.controlToken.length >= 32 && session.controlToken.length <= 256;
89
+ }
@@ -0,0 +1,15 @@
1
+ import type { ProjectSession, StoredProjectSession } from "./project.js";
2
+ /** Canonical paths resolved before discovery or selection. */
3
+ export interface SessionScope {
4
+ readonly workspaceRoot: string;
5
+ readonly applicationRoot: string;
6
+ }
7
+ interface SelectionOptions extends SessionScope {
8
+ readonly requestedSessionId?: string;
9
+ readonly previousSession?: Pick<ProjectSession, "sessionId" | "appRoot">;
10
+ }
11
+ /** A workspace-root adapter can select any application in that exact workspace. */
12
+ export declare function matchesApplication(session: ProjectSession, scope: SessionScope): boolean;
13
+ /** Chooses a live session without accessing files, making requests, or claiming it. */
14
+ export declare function selectProjectSession(sessions: readonly StoredProjectSession[], options: SelectionOptions): StoredProjectSession;
15
+ export {};
@@ -0,0 +1,41 @@
1
+ /** A workspace-root adapter can select any application in that exact workspace. */
2
+ export function matchesApplication(session, scope) {
3
+ return session.workspaceRoot === scope.workspaceRoot
4
+ && (scope.applicationRoot === scope.workspaceRoot || session.appRoot === scope.applicationRoot);
5
+ }
6
+ /** Chooses a live session without accessing files, making requests, or claiming it. */
7
+ export function selectProjectSession(sessions, options) {
8
+ const candidates = sessions.filter((session) => matchesApplication(session, options));
9
+ const applicationScoped = options.applicationRoot !== options.workspaceRoot;
10
+ if (options.requestedSessionId) {
11
+ const requested = candidates.find((session) => session.sessionId === options.requestedSessionId);
12
+ if (requested)
13
+ return requested;
14
+ throw new Error(applicationScoped
15
+ ? "The selected Nudge session is not live for this exact application."
16
+ : "The selected Nudge session is not live in this exact workspace.");
17
+ }
18
+ if (options.previousSession)
19
+ return restoreSelection(candidates, options.previousSession);
20
+ if (candidates.length === 1)
21
+ return candidates[0];
22
+ if (!sessions.some((session) => session.workspaceRoot === options.workspaceRoot)) {
23
+ throw new Error("No live Nudge project session matches this exact workspace. Start the project development server first.");
24
+ }
25
+ if (candidates.length === 0 && applicationScoped) {
26
+ throw new Error("No live Nudge project session matches this exact application. Start its development server first.");
27
+ }
28
+ throw new Error("Several Nudge apps are running in this workspace. Call nudge_list_sessions, then pass sessionId to nudge_listen.");
29
+ }
30
+ function restoreSelection(candidates, previous) {
31
+ const current = candidates.find((session) => session.sessionId === previous.sessionId);
32
+ if (current)
33
+ return current;
34
+ const replacements = candidates.filter((session) => session.appRoot === previous.appRoot);
35
+ if (replacements.length === 1)
36
+ return replacements[0];
37
+ if (replacements.length > 1) {
38
+ throw new Error("Several restarted Nudge sessions match the selected app. Pass sessionId explicitly.");
39
+ }
40
+ throw new Error("The previously selected Nudge app is no longer running.");
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nudge-ui/mcp",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "Optional local MCP companion for pairing Nudge UI with coding agents.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,18 +42,23 @@
42
42
  "types": "./dist/server.d.ts",
43
43
  "import": "./dist/server.js",
44
44
  "default": "./dist/server.js"
45
+ },
46
+ "./project": {
47
+ "types": "./dist/project.d.ts",
48
+ "import": "./dist/project.js",
49
+ "default": "./dist/project.js"
45
50
  }
46
51
  },
47
52
  "dependencies": {
48
53
  "@modelcontextprotocol/sdk": "^1.30.0",
49
- "zod": "^3.25.76",
50
- "@nudge-ui/agent-protocol": "0.1.3"
54
+ "zod": "^3.25.76"
51
55
  },
52
56
  "devDependencies": {
53
57
  "@types/node": "^22.20.1",
54
58
  "esbuild": "^0.21.5",
55
59
  "typescript": "^5.9.3",
56
- "vitest": "^2.1.9"
60
+ "vitest": "^2.1.9",
61
+ "@nudge-ui/agent-protocol": "0.2.0"
57
62
  },
58
63
  "engines": {
59
64
  "node": ">=20"
@@ -62,7 +67,7 @@
62
67
  "access": "public"
63
68
  },
64
69
  "scripts": {
65
- "build": "node ../../scripts/build-package.mjs && node scripts/build.mjs",
70
+ "build": "node ../../scripts/build-package.mjs && node ../../scripts/vendor-agent-protocol.mjs && node scripts/build.mjs",
66
71
  "typecheck": "tsc --noEmit -p tsconfig.json",
67
72
  "test:unit": "vitest run",
68
73
  "test:unit:watch": "vitest"