@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/README.md +138 -53
- package/dist/adapterRouting.d.ts +16 -0
- package/dist/adapterRouting.js +59 -0
- package/dist/bridge.d.ts +4 -2
- package/dist/bridge.js +255 -16
- package/dist/cli.d.ts +3 -2
- package/dist/cli.js +75 -14
- package/dist/cli.mjs +15386 -14423
- package/dist/discovery.d.ts +46 -0
- package/dist/discovery.js +160 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/internal/agent-protocol.d.ts +65 -1
- package/dist/internal/agent-protocol.js +183 -3
- package/dist/project.d.ts +54 -0
- package/dist/project.js +151 -0
- package/dist/projectSessionClient.d.ts +33 -0
- package/dist/projectSessionClient.js +103 -0
- package/dist/protocol.d.ts +14 -2
- package/dist/protocol.js +13 -1
- package/dist/server.d.ts +15 -0
- package/dist/server.js +142 -34
- package/dist/sessionRegistry.d.ts +17 -0
- package/dist/sessionRegistry.js +121 -0
- package/dist/sessionSelection.d.ts +15 -0
- package/dist/sessionSelection.js +41 -0
- package/package.json +6 -1
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
|
-
"
|
|
23
|
-
"
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
|
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 = "
|
|
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:
|
|
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:
|
|
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 () =>
|
|
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
|
-
|
|
110
|
-
return textResult(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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": {
|