@nudge-ui/mcp 0.2.1 → 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/discovery.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { realpath } from "node:fs/promises";
3
- import { resolve } from "node:path";
3
+ import { isAbsolute, resolve } from "node:path";
4
4
  import { ProjectSessionClient } from "./projectSessionClient.js";
5
- import { readLiveProjectSessions } from "./sessionRegistry.js";
5
+ import { inspectProjectSessions, readLiveProjectSessions } from "./sessionRegistry.js";
6
6
  import { matchesApplication, selectProjectSession } from "./sessionSelection.js";
7
7
  import { defaultSessionRegistryRoot, resolveWorkspaceRoot, } from "./project.js";
8
8
  function publicSession(session) {
@@ -10,15 +10,17 @@ function publicSession(session) {
10
10
  return visible;
11
11
  }
12
12
  export async function discoverProjectSessions(applicationRoot, registryRoot = defaultSessionRegistryRoot()) {
13
- const canonicalApplication = await realpath(resolve(applicationRoot));
14
- const canonicalWorkspace = await resolveWorkspaceRoot(canonicalApplication);
13
+ const canonicalApplication = applicationRoot === undefined
14
+ ? undefined
15
+ : await realpath(requireAbsoluteWorkspaceRoot(applicationRoot));
16
+ const canonicalWorkspace = canonicalApplication === undefined ? undefined : await resolveWorkspaceRoot(canonicalApplication);
15
17
  const live = await readLiveProjectSessions(resolve(registryRoot));
16
18
  return live.map(({ descriptor, health }) => {
17
19
  const matchesWorkspace = descriptor.workspaceRoot === canonicalWorkspace;
18
20
  return {
19
21
  ...publicSession(descriptor),
20
22
  matchesWorkspace,
21
- matchesApplication: matchesApplication(descriptor, {
23
+ matchesApplication: canonicalWorkspace !== undefined && canonicalApplication !== undefined && matchesApplication(descriptor, {
22
24
  workspaceRoot: canonicalWorkspace,
23
25
  applicationRoot: canonicalApplication,
24
26
  }),
@@ -39,20 +41,34 @@ export class DiscoveredProjectRouter {
39
41
  agentId;
40
42
  client = null;
41
43
  releaseGeneration = 0;
44
+ connectionQueue = Promise.resolve();
42
45
  constructor(workspaceRoot, applicationRoot, registryRoot, agentId) {
43
46
  this.workspaceRoot = workspaceRoot;
44
47
  this.applicationRoot = applicationRoot;
45
48
  this.registryRoot = registryRoot;
46
49
  this.agentId = agentId;
47
50
  }
51
+ get selectedSessionId() {
52
+ return this.client?.descriptor.sessionId;
53
+ }
54
+ static forSession(session, registryRoot) {
55
+ const router = new DiscoveredProjectRouter(session.workspaceRoot, session.appRoot, registryRoot, randomUUID());
56
+ router.client = new ProjectSessionClient(session, router.agentId);
57
+ return router;
58
+ }
48
59
  static async create(workspaceRoot, registryRoot = defaultSessionRegistryRoot()) {
49
- const applicationRoot = await realpath(resolve(workspaceRoot));
60
+ const applicationRoot = await realpath(requireAbsoluteWorkspaceRoot(workspaceRoot));
50
61
  return new DiscoveredProjectRouter(await resolveWorkspaceRoot(applicationRoot), applicationRoot, resolve(registryRoot), randomUUID());
51
62
  }
52
63
  async listSessions() {
53
64
  return await discoverProjectSessions(this.applicationRoot, this.registryRoot);
54
65
  }
55
- async connectToSession(sessionId) {
66
+ connectToSession(sessionId) {
67
+ const connection = this.connectionQueue.then(() => this.selectSession(sessionId));
68
+ this.connectionQueue = connection.then(() => undefined, () => undefined);
69
+ return connection;
70
+ }
71
+ async selectSession(sessionId) {
56
72
  const live = await readLiveProjectSessions(this.registryRoot);
57
73
  const selected = selectProjectSession(live.map(({ descriptor }) => descriptor), {
58
74
  workspaceRoot: this.workspaceRoot,
@@ -102,6 +118,7 @@ export class DiscoveredProjectRouter {
102
118
  }
103
119
  async release() {
104
120
  this.releaseGeneration += 1;
121
+ await this.connectionQueue;
105
122
  const client = this.client;
106
123
  this.client = null;
107
124
  await client?.close();
@@ -110,3 +127,34 @@ export class DiscoveredProjectRouter {
110
127
  await this.release();
111
128
  }
112
129
  }
130
+ /** Returns diagnostic counts, scope, and public paths without exposing registry credentials. */
131
+ export async function diagnoseProjectSessions(applicationRoot, registryRoot = defaultSessionRegistryRoot()) {
132
+ const resolvedRegistryRoot = resolve(registryRoot);
133
+ const app = applicationRoot === undefined
134
+ ? undefined
135
+ : await realpath(requireAbsoluteWorkspaceRoot(applicationRoot));
136
+ const workspace = app === undefined ? undefined : await resolveWorkspaceRoot(app);
137
+ const inspection = await inspectProjectSessions(resolvedRegistryRoot);
138
+ const matching = (session) => app === undefined || (session.workspaceRoot === workspace && (app === workspace || session.appRoot === app));
139
+ return {
140
+ registryRoot: resolvedRegistryRoot,
141
+ workspaceRoot: workspace,
142
+ applicationRoot: app,
143
+ descriptorCount: inspection.descriptorCount,
144
+ invalidCount: inspection.invalidCount,
145
+ incompatibleCount: inspection.incompatibleCount,
146
+ reachableCount: inspection.live.length,
147
+ matchingReachableCount: inspection.live.filter(({ descriptor }) => matching(descriptor)).length,
148
+ matchingUnreachableCount: inspection.unreachable.filter(matching).length,
149
+ guidance: [
150
+ ...(inspection.invalidCount > 0 ? ["Some session descriptors or authenticated health responses are invalid. Check the adapter and project bridge versions, descriptor permissions, and bridge logs."] : []),
151
+ ...(inspection.unreachable.some(matching) ? ["Matching descriptors exist but their loopback endpoints are unreachable. The server may have stopped or local networking may be blocked. Retry outside the agent command sandbox; do not treat this as proof that no bridge registered."] : []),
152
+ ...(inspection.incompatibleCount > 0 ? ["Some registrations use an incompatible protocol. Upgrade the reusable adapter and the project's @nudge-ui/mcp dependency, then restart the development server and agent host."] : []),
153
+ ],
154
+ };
155
+ }
156
+ function requireAbsoluteWorkspaceRoot(workspaceRoot) {
157
+ if (!isAbsolute(workspaceRoot))
158
+ throw new TypeError("workspaceRoot must be an absolute path.");
159
+ return workspaceRoot;
160
+ }
@@ -7,6 +7,11 @@ export interface SessionHealth {
7
7
  readonly claimed: boolean;
8
8
  readonly status: AgentStatusSnapshot;
9
9
  }
10
+ /** Represents an HTTP response from a project bridge that could be classified. */
11
+ export declare class ProjectSessionResponseError extends Error {
12
+ readonly status: number;
13
+ constructor(status: number, message: string);
14
+ }
10
15
  export declare function readSessionHealth(session: StoredProjectSession, timeoutMs?: number): Promise<SessionHealth>;
11
16
  /** Owns one project's claim, heartbeat, and authenticated control requests. */
12
17
  export declare class ProjectSessionClient {
@@ -2,6 +2,15 @@ import { randomUUID } from "node:crypto";
2
2
  import { AGENT_CONTROL_ENDPOINTS } from "./protocol.js";
3
3
  const CONTROL_TIMEOUT_MS = 5_000;
4
4
  const CANVAS_TIMEOUT_MS = 65_000;
5
+ /** Represents an HTTP response from a project bridge that could be classified. */
6
+ export class ProjectSessionResponseError extends Error {
7
+ status;
8
+ constructor(status, message) {
9
+ super(message);
10
+ this.name = "ProjectSessionResponseError";
11
+ this.status = status;
12
+ }
13
+ }
5
14
  async function requestJson(session, path, init = {}, { signal, timeoutMs = CONTROL_TIMEOUT_MS } = {}) {
6
15
  const response = await fetch(`${session.endpoint}${path}`, {
7
16
  ...init,
@@ -14,8 +23,9 @@ async function requestJson(session, path, init = {}, { signal, timeoutMs = CONTR
14
23
  },
15
24
  });
16
25
  const body = await response.json().catch(() => undefined);
17
- if (!response.ok)
18
- throw new Error(body?.error?.message ?? `Nudge project bridge returned ${response.status}.`);
26
+ if (!response.ok) {
27
+ throw new ProjectSessionResponseError(response.status, body?.error?.message ?? `Nudge project bridge returned ${response.status}.`);
28
+ }
19
29
  return body;
20
30
  }
21
31
  export async function readSessionHealth(session, timeoutMs = CONTROL_TIMEOUT_MS) {
@@ -11,6 +11,7 @@ export type { AgentConnectionState, AgentProjectIdentity, AgentPromptRequest, Ag
11
11
  export declare const BRIDGE_ENDPOINTS: {
12
12
  readonly health: "/health";
13
13
  readonly pair: "/pair";
14
+ readonly takeover: "/takeover";
14
15
  readonly events: "/events";
15
16
  readonly prompt: "/prompt";
16
17
  readonly status: "/status";
package/dist/protocol.js CHANGED
@@ -10,6 +10,7 @@ export { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, CANVAS_COMMAND_TYPES, ca
10
10
  export const BRIDGE_ENDPOINTS = {
11
11
  health: "/health",
12
12
  pair: "/pair",
13
+ takeover: "/takeover",
13
14
  events: "/events",
14
15
  prompt: "/prompt",
15
16
  status: "/status",
package/dist/server.d.ts CHANGED
@@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
3
3
  import type { AgentProjectIdentity } from "./internal/agent-protocol.js";
4
4
  import { type BrowserBridge, type BrowserBridgeOptions, type BridgeAddress } from "./bridge.js";
5
+ import { AdapterRouting } from "./adapterRouting.js";
5
6
  import { DiscoveredProjectRouter } from "./discovery.js";
6
7
  export declare const MCP_SERVER_NAME = "nudge-ui";
7
8
  export declare const MCP_SERVER_VERSION: string;
@@ -29,13 +30,13 @@ export interface AgentCompanion {
29
30
  export declare function createAgentCompanion(options: AgentCompanionOptions): AgentCompanion;
30
31
  export interface DiscoveredAgentAdapter {
31
32
  readonly mcpServer: McpServer;
32
- readonly router: DiscoveredProjectRouter;
33
+ readonly router: DiscoveredProjectRouter | AdapterRouting;
33
34
  start(transport?: Transport): Promise<void>;
34
35
  close(): Promise<void>;
35
36
  }
36
37
  /** Creates a stdio MCP adapter that discovers project-owned bridges. */
37
38
  export declare function createDiscoveredAgentAdapter(options: {
38
- readonly workspaceRoot: string;
39
+ readonly workspaceRoot?: string;
39
40
  readonly registryRoot?: string;
40
41
  readonly serverName?: string;
41
42
  readonly serverVersion?: string;
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
7
  import { createLoopbackBridge, } from "./bridge.js";
6
- import { DiscoveredProjectRouter } from "./discovery.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() {
@@ -19,13 +22,14 @@ function readPackageVersion() {
19
22
  /** Instructions are sent through MCP initialization for every host. */
20
23
  export const MCP_SERVER_INSTRUCTIONS = [
21
24
  "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.",
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.",
23
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.",
24
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.",
25
28
  "After a prompt is delivered, edit the project source using the host's normal approval flow.",
26
29
  "Report completed, failed, or interrupted status with nudge_report_status before listening again.",
27
30
  "When the user asks to stop listening, call nudge_release when that tool is available so another agent can claim the project session.",
28
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.",
29
33
  "No request, prompt, or browser state is persisted by this companion.",
30
34
  ].join(" ");
31
35
  function textResult(value) {
@@ -70,18 +74,34 @@ function boundedWorkspaceRoot(value) {
70
74
  throw new TypeError("workspaceRoot must be a bounded non-empty string");
71
75
  return value;
72
76
  }
73
- function installTools(mcpServer, bridge) {
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
+ };
74
94
  const listen = async (args, extra) => {
75
95
  try {
76
- return promptResult(await bridge.waitForPrompt(extra.signal, args.sessionId));
96
+ return promptResult(await (await target(args)).waitForPrompt(extra.signal, args.sessionId));
77
97
  }
78
98
  catch (error) {
79
99
  return errorResult(error);
80
100
  }
81
101
  };
82
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.";
83
- const emptyInput = {};
84
- const listenInput = bridge.listSessions ? { sessionId: z.string().uuid().optional() } : emptyInput;
103
+ const emptyInput = scopeInput;
104
+ const listenInput = scopeInput;
85
105
  mcpServer.registerTool("nudge_listen", {
86
106
  title: "Listen for Nudge requests",
87
107
  description: listenDescription,
@@ -99,24 +119,39 @@ function installTools(mcpServer, bridge) {
99
119
  description: "Read the in-memory browser pairing and request status for this project.",
100
120
  inputSchema: emptyInput,
101
121
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
102
- }, async () => textResult(await bridge.getStatus()));
103
- if (bridge.listSessions) {
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) {
104
131
  mcpServer.registerTool("nudge_list_sessions", {
105
132
  title: "List Nudge project sessions",
106
- description: "List live local Nudge sessions. Only sessions marked matchesApplication can be selected by an application-scoped adapter.",
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.",
107
134
  inputSchema: emptyInput,
108
135
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
109
- }, async () => textResult(await bridge.listSessions()));
136
+ }, async (args) => textResult(routing ? await discoverProjectSessions(args.workspaceRoot, routing.registryRoot) : await bridge.listSessions()));
110
137
  }
111
- if (bridge.release) {
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) {
112
147
  mcpServer.registerTool("nudge_release", {
113
148
  title: "Release Nudge session",
114
149
  description: "Stop listening and release this adapter's project-session claim so another agent can connect.",
115
150
  inputSchema: emptyInput,
116
151
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
117
- }, async () => {
152
+ }, async (args) => {
118
153
  try {
119
- await bridge.release();
154
+ await (await target(args)).release();
120
155
  return textResult({ released: true });
121
156
  }
122
157
  catch (error) {
@@ -126,8 +161,9 @@ function installTools(mcpServer, bridge) {
126
161
  }
127
162
  mcpServer.registerTool("nudge_report_status", {
128
163
  title: "Report Nudge request status",
129
- 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.",
130
165
  inputSchema: {
166
+ ...scopeInput,
131
167
  requestId: z.string().min(1).max(256),
132
168
  status: z.enum(["working", "completed", "failed", "interrupted"]),
133
169
  summary: z.string().max(32_000).optional(),
@@ -142,8 +178,8 @@ function installTools(mcpServer, bridge) {
142
178
  ...(args.error === undefined ? {} : { error: args.error }),
143
179
  };
144
180
  try {
145
- const updated = await bridge.updateRequestStatus(update);
146
- return textResult(updated ?? await bridge.getStatus());
181
+ const updated = await (await target(args)).updateRequestStatus(update);
182
+ return textResult(updated ?? await (await target(args)).getStatus());
147
183
  }
148
184
  catch (error) {
149
185
  return errorResult(error);
@@ -154,9 +190,9 @@ function installTools(mcpServer, bridge) {
154
190
  description: "Read the paired browser's current Canvas groups and focus.",
155
191
  inputSchema: emptyInput,
156
192
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
157
- }, async () => {
193
+ }, async (args) => {
158
194
  try {
159
- return commandResult(await bridge.dispatchCanvasCommand({ type: "read-state" }));
195
+ return commandResult(await (await target(args)).dispatchCanvasCommand({ type: "read-state" }));
160
196
  }
161
197
  catch (error) {
162
198
  return errorResult(error);
@@ -166,6 +202,7 @@ function installTools(mcpServer, bridge) {
166
202
  title: "Present routes in Canvas",
167
203
  description: "Append or focus an agent-owned, labeled comparison group of same-origin project routes in Canvas.",
168
204
  inputSchema: {
205
+ ...scopeInput,
169
206
  label: z.string().min(1).max(160),
170
207
  groupId: z.string().min(1).max(256).regex(/^agent-[A-Za-z0-9._:-]+$/).optional(),
171
208
  routes: z.array(z.object({
@@ -177,7 +214,7 @@ function installTools(mcpServer, bridge) {
177
214
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
178
215
  }, async (args) => {
179
216
  try {
180
- return commandResult(await bridge.dispatchCanvasCommand({
217
+ return commandResult(await (await target(args)).dispatchCanvasCommand({
181
218
  type: "present-routes",
182
219
  groupId: args.groupId ?? `agent-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
183
220
  label: args.label,
@@ -191,11 +228,11 @@ function installTools(mcpServer, bridge) {
191
228
  mcpServer.registerTool("nudge_focus_canvas_group", {
192
229
  title: "Focus Canvas group",
193
230
  description: "Focus an existing Canvas comparison group by its stable group ID.",
194
- inputSchema: { groupId: z.string().min(1).max(256) },
231
+ inputSchema: { ...scopeInput, groupId: z.string().min(1).max(256) },
195
232
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
196
233
  }, async (args) => {
197
234
  try {
198
- 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 }));
199
236
  }
200
237
  catch (error) {
201
238
  return errorResult(error);
@@ -206,9 +243,9 @@ function installTools(mcpServer, bridge) {
206
243
  description: "Fit all current Canvas cards into the browser board.",
207
244
  inputSchema: emptyInput,
208
245
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
209
- }, async () => {
246
+ }, async (args) => {
210
247
  try {
211
- return commandResult(await bridge.dispatchCanvasCommand({ type: "fit-all" }));
248
+ return commandResult(await (await target(args)).dispatchCanvasCommand({ type: "fit-all" }));
212
249
  }
213
250
  catch (error) {
214
251
  return errorResult(error);
@@ -217,11 +254,11 @@ function installTools(mcpServer, bridge) {
217
254
  mcpServer.registerTool("nudge_remove_canvas_group", {
218
255
  title: "Remove agent Canvas group",
219
256
  description: "Remove an agent-owned Canvas comparison group. The browser refuses removal of user-owned groups.",
220
- 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._:-]+$/) },
221
258
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
222
259
  }, async (args) => {
223
260
  try {
224
- 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 }));
225
262
  }
226
263
  catch (error) {
227
264
  return errorResult(error);
@@ -287,18 +324,21 @@ export function createAgentCompanion(options) {
287
324
  }
288
325
  /** Creates a stdio MCP adapter that discovers project-owned bridges. */
289
326
  export async function createDiscoveredAgentAdapter(options) {
290
- const router = await DiscoveredProjectRouter.create(options.workspaceRoot, options.registryRoot);
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);
291
331
  const mcpServer = new McpServer({
292
332
  name: options.serverName ?? MCP_SERVER_NAME,
293
333
  version: options.serverVersion ?? MCP_SERVER_VERSION,
294
334
  }, { instructions: MCP_SERVER_INSTRUCTIONS });
295
- installTools(mcpServer, router);
335
+ installTools(mcpServer, router, routing);
296
336
  let transport = null;
297
337
  let started = false;
298
338
  let closePromise = null;
299
339
  return {
300
340
  mcpServer,
301
- router,
341
+ router: (router ?? routing),
302
342
  async start(providedTransport) {
303
343
  if (started)
304
344
  return;
@@ -312,7 +352,7 @@ export async function createDiscoveredAgentAdapter(options) {
312
352
  if (closePromise)
313
353
  return closePromise;
314
354
  closePromise = (async () => {
315
- await router.close();
355
+ await (router ?? routing).close();
316
356
  if (transport)
317
357
  await mcpServer.close().catch(() => undefined);
318
358
  })();
@@ -4,5 +4,14 @@ export interface LiveProjectSession {
4
4
  readonly descriptor: StoredProjectSession;
5
5
  readonly health: SessionHealth;
6
6
  }
7
- /** Reads private registry records and keeps only bridges that answer authenticated probes. */
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. */
8
17
  export declare function readLiveProjectSessions(registryRoot: string): Promise<LiveProjectSession[]>;
@@ -1,15 +1,28 @@
1
1
  import { lstat, readFile, readdir, realpath } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import { AGENT_PROTOCOL_VERSION } from "./internal/agent-protocol.js";
4
- import { readSessionHealth } from "./projectSessionClient.js";
4
+ import { ProjectSessionResponseError, readSessionHealth } from "./projectSessionClient.js";
5
5
  const PROBE_TIMEOUT_MS = 750;
6
6
  const MAX_DESCRIPTOR_BYTES = 32 * 1024;
7
- /** Reads private registry records and keeps only bridges that answer authenticated probes. */
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. */
8
24
  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);
25
+ return (await inspectProjectSessions(registryRoot)).live;
13
26
  }
14
27
  async function readRegistryDirectory(registryRoot) {
15
28
  try {
@@ -33,8 +46,10 @@ async function readDescriptor(path) {
33
46
  const parsed = JSON.parse(await readFile(path, "utf8"));
34
47
  if (!isStoredSession(parsed))
35
48
  return undefined;
49
+ if (parsed.protocolVersion !== AGENT_PROTOCOL_VERSION)
50
+ return "incompatible";
36
51
  const [workspaceRoot, appRoot] = await Promise.all([realpath(parsed.workspaceRoot), realpath(parsed.appRoot)]);
37
- return { ...parsed, workspaceRoot, appRoot };
52
+ return { ...parsed, protocolVersion: AGENT_PROTOCOL_VERSION, workspaceRoot, appRoot };
38
53
  }
39
54
  catch {
40
55
  // Records can disappear during shutdown or refer to a removed worktree.
@@ -44,17 +59,34 @@ async function readDescriptor(path) {
44
59
  async function probeSession(descriptor) {
45
60
  try {
46
61
  const health = await readSessionHealth(descriptor, PROBE_TIMEOUT_MS);
47
- if (health.projectId !== descriptor.projectId || health.available !== true)
48
- return undefined;
49
- return { descriptor, health };
62
+ if (!isSessionHealth(health) || health.projectId !== descriptor.projectId)
63
+ return { kind: "invalid" };
64
+ return { kind: "live", session: { descriptor, health } };
50
65
  }
51
- catch {
52
- // A stale or unreachable bridge is not a selectable session.
53
- return undefined;
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" };
54
69
  }
55
70
  }
56
- function isPresent(value) {
57
- return value !== undefined;
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)));
58
90
  }
59
91
  function isLoopbackEndpoint(value) {
60
92
  if (typeof value !== "string")
@@ -74,7 +106,7 @@ function isStoredSession(value) {
74
106
  return false;
75
107
  const session = value;
76
108
  return session.schemaVersion === 1
77
- && session.protocolVersion === AGENT_PROTOCOL_VERSION
109
+ && typeof session.protocolVersion === "number" && Number.isSafeInteger(session.protocolVersion)
78
110
  && typeof session.sessionId === "string" && session.sessionId.length > 0
79
111
  && typeof session.projectId === "string" && session.projectId.length > 0
80
112
  && typeof session.workspaceRoot === "string" && session.workspaceRoot.length > 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nudge-ui/mcp",
3
- "version": "0.2.1",
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": {