@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/server.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { realpath } from "node:fs/promises";
3
4
  import { createRequire } from "node:module";
5
+ import { isAbsolute } from "node:path";
4
6
  import { z } from "zod";
5
- import { validateRoutes } from "./internal/agent-protocol.js";
6
7
  import { createLoopbackBridge, } from "./bridge.js";
8
+ import { AdapterRouting } from "./adapterRouting.js";
9
+ import { DiscoveredProjectRouter, diagnoseProjectSessions, discoverProjectSessions } from "./discovery.js";
7
10
  export const MCP_SERVER_NAME = "nudge-ui";
8
11
  export const MCP_SERVER_VERSION = readPackageVersion();
9
12
  function readPackageVersion() {
@@ -18,12 +21,15 @@ function readPackageVersion() {
18
21
  }
19
22
  /** Instructions are sent through MCP initialization for every host. */
20
23
  export const MCP_SERVER_INSTRUCTIONS = [
21
- "Nudge UI is a local browser companion for one project workspace.",
22
- "Browser pairing requires the app origin to be configured explicitly with --origin or NUDGE_UI_ORIGIN; an unconfigured bridge accepts no HTTP pairing.",
23
- "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.",
24
+ "Nudge UI is a local browser companion for one exact project workspace or Git worktree.",
25
+ "Supply workspaceRoot with the absolute checkout or application path you are editing on every tool call. Listing and selecting sessions requires this explicit scope; repeat the same workspaceRoot and sessionId for status, Canvas, and release calls. Never use the MCP process working directory to infer the task workspace.",
26
+ "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.",
27
+ "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.",
24
28
  "After a prompt is delivered, edit the project source using the host's normal approval flow.",
25
29
  "Report completed, failed, or interrupted status with nudge_report_status before listening again.",
30
+ "When the user asks to stop listening, call nudge_release when that tool is available so another agent can claim the project session.",
26
31
  "Canvas tools operate only on same-origin routes from the paired project.",
32
+ "Automated browsers such as Playwright and headless Chrome get the plain app without the Nudge editor; add ?nudge-ui=on to a URL only when testing Nudge itself.",
27
33
  "No request, prompt, or browser state is persisted by this companion.",
28
34
  ].join(" ");
29
35
  function textResult(value) {
@@ -39,12 +45,21 @@ function errorResult(error) {
39
45
  };
40
46
  }
41
47
  function promptResult(request) {
42
- return textResult({
43
- requestId: request.requestId,
44
- projectId: request.projectId,
45
- prompt: request.prompt,
46
- ...(request.changeRevision === undefined ? {} : { changeRevision: request.changeRevision }),
47
- });
48
+ const content = [{
49
+ type: "text",
50
+ text: JSON.stringify({
51
+ requestId: request.requestId,
52
+ projectId: request.projectId,
53
+ prompt: request.prompt,
54
+ ...(request.changeRevision === undefined ? {} : { changeRevision: request.changeRevision }),
55
+ ...(request.clientDispatchId === undefined ? {} : { clientDispatchId: request.clientDispatchId }),
56
+ ...(request.sketches === undefined ? {} : { sketches: request.sketches }),
57
+ }),
58
+ }];
59
+ for (const attachment of request.attachments ?? []) {
60
+ content.push({ type: "text", text: `Sketch image: ${attachment.filename}` }, { type: "image", data: attachment.data, mimeType: attachment.mimeType });
61
+ }
62
+ return { content };
48
63
  }
49
64
  function commandResult(result) {
50
65
  return {
@@ -59,27 +74,44 @@ function boundedWorkspaceRoot(value) {
59
74
  throw new TypeError("workspaceRoot must be a bounded non-empty string");
60
75
  return value;
61
76
  }
62
- function installTools(mcpServer, bridge) {
63
- const listen = async (_args, extra) => {
77
+ function installTools(mcpServer, bridge, routing) {
78
+ const target = async (args) => {
79
+ if (routing)
80
+ return await routing.target(args);
81
+ if (bridge instanceof DiscoveredProjectRouter && args.workspaceRoot !== undefined
82
+ && await realpath(args.workspaceRoot) !== bridge.applicationRoot) {
83
+ throw new Error("This legacy MCP configuration is restricted to another application. Rerun agent setup to migrate it to the reusable adapter.");
84
+ }
85
+ return bridge;
86
+ };
87
+ const workspaceInput = z.string().min(1).max(4096)
88
+ .refine(isAbsolute, "workspaceRoot must be an absolute path.")
89
+ .describe("Absolute path of the checkout or application being edited.");
90
+ const scopeInput = {
91
+ workspaceRoot: routing ? workspaceInput : workspaceInput.optional(),
92
+ sessionId: z.string().uuid().optional(),
93
+ };
94
+ const listen = async (args, extra) => {
64
95
  try {
65
- return promptResult(await bridge.waitForPrompt(extra.signal));
96
+ return promptResult(await (await target(args)).waitForPrompt(extra.signal, args.sessionId));
66
97
  }
67
98
  catch (error) {
68
99
  return errorResult(error);
69
100
  }
70
101
  };
71
- 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.";
72
- const emptyInput = {};
102
+ 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.";
103
+ const emptyInput = scopeInput;
104
+ const listenInput = scopeInput;
73
105
  mcpServer.registerTool("nudge_listen", {
74
106
  title: "Listen for Nudge requests",
75
107
  description: listenDescription,
76
- inputSchema: emptyInput,
108
+ inputSchema: listenInput,
77
109
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
78
110
  }, listen);
79
111
  mcpServer.registerTool("nudge_connect", {
80
112
  title: "Connect to Nudge",
81
113
  description: "Open the long-lived Nudge browser connection and wait for one prompt.",
82
- inputSchema: emptyInput,
114
+ inputSchema: listenInput,
83
115
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
84
116
  }, listen);
85
117
  mcpServer.registerTool("nudge_get_status", {
@@ -87,11 +119,51 @@ function installTools(mcpServer, bridge) {
87
119
  description: "Read the in-memory browser pairing and request status for this project.",
88
120
  inputSchema: emptyInput,
89
121
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
90
- }, async () => textResult(bridge.getStatus()));
122
+ }, async (args) => {
123
+ try {
124
+ return textResult(await (await target(args)).getStatus());
125
+ }
126
+ catch (error) {
127
+ return errorResult(error);
128
+ }
129
+ });
130
+ if (routing || bridge?.listSessions) {
131
+ mcpServer.registerTool("nudge_list_sessions", {
132
+ title: "List Nudge project sessions",
133
+ description: "List live local Nudge sessions for the supplied checkout or application path. The workspaceRoot value is required so the adapter can select only sessions in that explicit scope.",
134
+ inputSchema: emptyInput,
135
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
136
+ }, async (args) => textResult(routing ? await discoverProjectSessions(args.workspaceRoot, routing.registryRoot) : await bridge.listSessions()));
137
+ }
138
+ if (routing) {
139
+ mcpServer.registerTool("nudge_diagnose", {
140
+ title: "Diagnose Nudge discovery",
141
+ description: "Diagnose Nudge discovery for the supplied checkout or application path. The result includes the resolved registry, workspace, and application scope plus counts for missing registrations, invalid descriptors or health responses, unreachable loopback endpoints, and incompatible bridge protocols; credentials are never exposed.",
142
+ inputSchema: scopeInput,
143
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
144
+ }, async (args) => textResult(await diagnoseProjectSessions(args.workspaceRoot, routing.registryRoot)));
145
+ }
146
+ if (routing || bridge?.release) {
147
+ mcpServer.registerTool("nudge_release", {
148
+ title: "Release Nudge session",
149
+ description: "Stop listening and release this adapter's project-session claim so another agent can connect.",
150
+ inputSchema: emptyInput,
151
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
152
+ }, async (args) => {
153
+ try {
154
+ await (await target(args)).release();
155
+ return textResult({ released: true });
156
+ }
157
+ catch (error) {
158
+ return errorResult(error);
159
+ }
160
+ });
161
+ }
91
162
  mcpServer.registerTool("nudge_report_status", {
92
163
  title: "Report Nudge request status",
93
- description: "Report working, completed, failed, or interrupted status for the prompt currently being handled.",
164
+ description: "Report working, completed, failed, or interrupted status for the prompt currently being handled. Report completed only after implementing the request; the browser removes its submitted sketches if their revisions are unchanged.",
94
165
  inputSchema: {
166
+ ...scopeInput,
95
167
  requestId: z.string().min(1).max(256),
96
168
  status: z.enum(["working", "completed", "failed", "interrupted"]),
97
169
  summary: z.string().max(32_000).optional(),
@@ -106,8 +178,8 @@ function installTools(mcpServer, bridge) {
106
178
  ...(args.error === undefined ? {} : { error: args.error }),
107
179
  };
108
180
  try {
109
- bridge.updateRequestStatus(update);
110
- return textResult(bridge.getStatus());
181
+ const updated = await (await target(args)).updateRequestStatus(update);
182
+ return textResult(updated ?? await (await target(args)).getStatus());
111
183
  }
112
184
  catch (error) {
113
185
  return errorResult(error);
@@ -118,9 +190,9 @@ function installTools(mcpServer, bridge) {
118
190
  description: "Read the paired browser's current Canvas groups and focus.",
119
191
  inputSchema: emptyInput,
120
192
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
121
- }, async () => {
193
+ }, async (args) => {
122
194
  try {
123
- return commandResult(await bridge.dispatchCanvasCommand({ type: "read-state" }));
195
+ return commandResult(await (await target(args)).dispatchCanvasCommand({ type: "read-state" }));
124
196
  }
125
197
  catch (error) {
126
198
  return errorResult(error);
@@ -130,6 +202,7 @@ function installTools(mcpServer, bridge) {
130
202
  title: "Present routes in Canvas",
131
203
  description: "Append or focus an agent-owned, labeled comparison group of same-origin project routes in Canvas.",
132
204
  inputSchema: {
205
+ ...scopeInput,
133
206
  label: z.string().min(1).max(160),
134
207
  groupId: z.string().min(1).max(256).regex(/^agent-[A-Za-z0-9._:-]+$/).optional(),
135
208
  routes: z.array(z.object({
@@ -141,14 +214,11 @@ function installTools(mcpServer, bridge) {
141
214
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
142
215
  }, async (args) => {
143
216
  try {
144
- if (!bridge.effectiveOrigin)
145
- throw new Error("Canvas routes require a paired or configured browser origin.");
146
- const routes = validateRoutes(args.routes, bridge.effectiveOrigin);
147
- return commandResult(await bridge.dispatchCanvasCommand({
217
+ return commandResult(await (await target(args)).dispatchCanvasCommand({
148
218
  type: "present-routes",
149
219
  groupId: args.groupId ?? `agent-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
150
220
  label: args.label,
151
- routes,
221
+ routes: args.routes,
152
222
  }));
153
223
  }
154
224
  catch (error) {
@@ -158,11 +228,11 @@ function installTools(mcpServer, bridge) {
158
228
  mcpServer.registerTool("nudge_focus_canvas_group", {
159
229
  title: "Focus Canvas group",
160
230
  description: "Focus an existing Canvas comparison group by its stable group ID.",
161
- inputSchema: { groupId: z.string().min(1).max(256) },
231
+ inputSchema: { ...scopeInput, groupId: z.string().min(1).max(256) },
162
232
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
163
233
  }, async (args) => {
164
234
  try {
165
- return commandResult(await bridge.dispatchCanvasCommand({ type: "focus-group", groupId: args.groupId }));
235
+ return commandResult(await (await target(args)).dispatchCanvasCommand({ type: "focus-group", groupId: args.groupId }));
166
236
  }
167
237
  catch (error) {
168
238
  return errorResult(error);
@@ -173,9 +243,9 @@ function installTools(mcpServer, bridge) {
173
243
  description: "Fit all current Canvas cards into the browser board.",
174
244
  inputSchema: emptyInput,
175
245
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
176
- }, async () => {
246
+ }, async (args) => {
177
247
  try {
178
- return commandResult(await bridge.dispatchCanvasCommand({ type: "fit-all" }));
248
+ return commandResult(await (await target(args)).dispatchCanvasCommand({ type: "fit-all" }));
179
249
  }
180
250
  catch (error) {
181
251
  return errorResult(error);
@@ -184,11 +254,11 @@ function installTools(mcpServer, bridge) {
184
254
  mcpServer.registerTool("nudge_remove_canvas_group", {
185
255
  title: "Remove agent Canvas group",
186
256
  description: "Remove an agent-owned Canvas comparison group. The browser refuses removal of user-owned groups.",
187
- inputSchema: { groupId: z.string().min(1).max(256).regex(/^agent-[A-Za-z0-9._:-]+$/) },
257
+ inputSchema: { ...scopeInput, groupId: z.string().min(1).max(256).regex(/^agent-[A-Za-z0-9._:-]+$/) },
188
258
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
189
259
  }, async (args) => {
190
260
  try {
191
- return commandResult(await bridge.dispatchCanvasCommand({ type: "remove-group", groupId: args.groupId }));
261
+ return commandResult(await (await target(args)).dispatchCanvasCommand({ type: "remove-group", groupId: args.groupId }));
192
262
  }
193
263
  catch (error) {
194
264
  return errorResult(error);
@@ -252,3 +322,41 @@ export function createAgentCompanion(options) {
252
322
  close,
253
323
  };
254
324
  }
325
+ /** Creates a stdio MCP adapter that discovers project-owned bridges. */
326
+ export async function createDiscoveredAgentAdapter(options) {
327
+ const routing = options.workspaceRoot === undefined ? new AdapterRouting(options.registryRoot) : undefined;
328
+ const router = options.workspaceRoot === undefined
329
+ ? undefined
330
+ : await DiscoveredProjectRouter.create(options.workspaceRoot, options.registryRoot);
331
+ const mcpServer = new McpServer({
332
+ name: options.serverName ?? MCP_SERVER_NAME,
333
+ version: options.serverVersion ?? MCP_SERVER_VERSION,
334
+ }, { instructions: MCP_SERVER_INSTRUCTIONS });
335
+ installTools(mcpServer, router, routing);
336
+ let transport = null;
337
+ let started = false;
338
+ let closePromise = null;
339
+ return {
340
+ mcpServer,
341
+ router: (router ?? routing),
342
+ async start(providedTransport) {
343
+ if (started)
344
+ return;
345
+ if (closePromise)
346
+ throw new Error("The Nudge MCP adapter is closing.");
347
+ started = true;
348
+ transport = providedTransport ?? new StdioServerTransport();
349
+ await mcpServer.connect(transport);
350
+ },
351
+ close() {
352
+ if (closePromise)
353
+ return closePromise;
354
+ closePromise = (async () => {
355
+ await (router ?? routing).close();
356
+ if (transport)
357
+ await mcpServer.close().catch(() => undefined);
358
+ })();
359
+ return closePromise;
360
+ },
361
+ };
362
+ }
@@ -0,0 +1,17 @@
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
+ export interface SessionInspection {
8
+ readonly descriptorCount: number;
9
+ readonly invalidCount: number;
10
+ readonly incompatibleCount: number;
11
+ readonly unreachable: readonly Pick<StoredProjectSession, "workspaceRoot" | "appRoot" | "sessionId">[];
12
+ readonly live: LiveProjectSession[];
13
+ }
14
+ /** Separates absent, invalid, incompatible, and unreachable registrations. */
15
+ export declare function inspectProjectSessions(registryRoot: string): Promise<SessionInspection>;
16
+ /** Keeps only compatible bridges that answer authenticated probes. */
17
+ export declare function readLiveProjectSessions(registryRoot: string): Promise<LiveProjectSession[]>;
@@ -0,0 +1,121 @@
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 { ProjectSessionResponseError, readSessionHealth } from "./projectSessionClient.js";
5
+ const PROBE_TIMEOUT_MS = 750;
6
+ const MAX_DESCRIPTOR_BYTES = 32 * 1024;
7
+ /** Separates absent, invalid, incompatible, and unreachable registrations. */
8
+ export async function inspectProjectSessions(registryRoot) {
9
+ const names = (await readRegistryDirectory(registryRoot)).filter((name) => name.endsWith(".json"));
10
+ const records = await Promise.all(names.map((name) => readDescriptor(resolve(registryRoot, name))));
11
+ const descriptors = records.filter((record) => typeof record === "object");
12
+ const probes = await Promise.all(descriptors.map(probeSession));
13
+ return {
14
+ descriptorCount: names.length,
15
+ invalidCount: records.filter((record) => record === undefined).length
16
+ + probes.filter((probe) => probe.kind === "invalid").length,
17
+ incompatibleCount: records.filter((record) => record === "incompatible").length,
18
+ unreachable: descriptors.filter((_, index) => probes[index]?.kind === "unreachable")
19
+ .map(({ workspaceRoot, appRoot, sessionId }) => ({ workspaceRoot, appRoot, sessionId })),
20
+ live: probes.flatMap((probe) => probe.kind === "live" ? [probe.session] : []),
21
+ };
22
+ }
23
+ /** Keeps only compatible bridges that answer authenticated probes. */
24
+ export async function readLiveProjectSessions(registryRoot) {
25
+ return (await inspectProjectSessions(registryRoot)).live;
26
+ }
27
+ async function readRegistryDirectory(registryRoot) {
28
+ try {
29
+ return await readdir(registryRoot);
30
+ }
31
+ catch (error) {
32
+ if (error.code === "ENOENT")
33
+ return [];
34
+ throw error;
35
+ }
36
+ }
37
+ async function readDescriptor(path) {
38
+ try {
39
+ const metadata = await lstat(path);
40
+ if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_DESCRIPTOR_BYTES)
41
+ return undefined;
42
+ if (typeof process.getuid === "function" && metadata.uid !== process.getuid())
43
+ return undefined;
44
+ if ((metadata.mode & 0o077) !== 0)
45
+ return undefined;
46
+ const parsed = JSON.parse(await readFile(path, "utf8"));
47
+ if (!isStoredSession(parsed))
48
+ return undefined;
49
+ if (parsed.protocolVersion !== AGENT_PROTOCOL_VERSION)
50
+ return "incompatible";
51
+ const [workspaceRoot, appRoot] = await Promise.all([realpath(parsed.workspaceRoot), realpath(parsed.appRoot)]);
52
+ return { ...parsed, protocolVersion: AGENT_PROTOCOL_VERSION, workspaceRoot, appRoot };
53
+ }
54
+ catch {
55
+ // Records can disappear during shutdown or refer to a removed worktree.
56
+ return undefined;
57
+ }
58
+ }
59
+ async function probeSession(descriptor) {
60
+ try {
61
+ const health = await readSessionHealth(descriptor, PROBE_TIMEOUT_MS);
62
+ if (!isSessionHealth(health) || health.projectId !== descriptor.projectId)
63
+ return { kind: "invalid" };
64
+ return { kind: "live", session: { descriptor, health } };
65
+ }
66
+ catch (error) {
67
+ // An HTTP response proves that the endpoint exists. Network and timeout errors are unreachable.
68
+ return { kind: error instanceof ProjectSessionResponseError ? "invalid" : "unreachable" };
69
+ }
70
+ }
71
+ function isSessionHealth(value) {
72
+ if (!value || typeof value !== "object" || Array.isArray(value))
73
+ return false;
74
+ const health = value;
75
+ return typeof health.projectId === "string"
76
+ && health.available === true
77
+ && typeof health.claimed === "boolean"
78
+ && isAgentStatusSnapshot(health.status);
79
+ }
80
+ function isAgentStatusSnapshot(value) {
81
+ if (!value || typeof value !== "object" || Array.isArray(value))
82
+ return false;
83
+ const status = value;
84
+ return status.protocolVersion === AGENT_PROTOCOL_VERSION
85
+ && typeof status.projectId === "string"
86
+ && (status.connection === "offline" || status.connection === "listening" || status.connection === "paired" || status.connection === "working")
87
+ && typeof status.listenerActive === "boolean"
88
+ && typeof status.paired === "boolean"
89
+ && (status.request === null || (typeof status.request === "object" && !Array.isArray(status.request)));
90
+ }
91
+ function isLoopbackEndpoint(value) {
92
+ if (typeof value !== "string")
93
+ return false;
94
+ try {
95
+ const url = new URL(value);
96
+ return url.protocol === "http:"
97
+ && (url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1" || url.hostname === "localhost")
98
+ && url.pathname === "/" && !url.username && !url.password && !url.search && !url.hash;
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
104
+ function isStoredSession(value) {
105
+ if (!value || typeof value !== "object" || Array.isArray(value))
106
+ return false;
107
+ const session = value;
108
+ return session.schemaVersion === 1
109
+ && typeof session.protocolVersion === "number" && Number.isSafeInteger(session.protocolVersion)
110
+ && typeof session.sessionId === "string" && session.sessionId.length > 0
111
+ && typeof session.projectId === "string" && session.projectId.length > 0
112
+ && typeof session.workspaceRoot === "string" && session.workspaceRoot.length > 0
113
+ && typeof session.appRoot === "string" && session.appRoot.length > 0
114
+ && (session.gitCommonDir === undefined || typeof session.gitCommonDir === "string")
115
+ && (session.branch === undefined || typeof session.branch === "string")
116
+ && typeof session.origin === "string"
117
+ && isLoopbackEndpoint(session.endpoint)
118
+ && typeof session.startedAt === "string"
119
+ && typeof session.pid === "number" && Number.isSafeInteger(session.pid)
120
+ && typeof session.controlToken === "string" && session.controlToken.length >= 32 && session.controlToken.length <= 256;
121
+ }
@@ -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.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Optional local MCP companion for pairing Nudge UI with coding agents.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,6 +42,11 @@
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": {