@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/bridge.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createServer, } from "node:http";
|
|
2
2
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
|
-
import { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, canonicalOrigin, isAgentStatusUpdate, isAllowedOrigin, isCanvasCommand, isCanvasGroup, isCanvasState, isSameOriginRoute, validateRoutes, } from "./internal/agent-protocol.js";
|
|
4
|
-
import { BRIDGE_ENDPOINTS } from "./protocol.js";
|
|
3
|
+
import { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, canonicalOrigin, isAgentStatusUpdate, isAllowedOrigin, isCanvasCommand, isCanvasGroup, isCanvasState, isSameOriginRoute, validateSketchAttachments, validateRoutes, } from "./internal/agent-protocol.js";
|
|
4
|
+
import { AGENT_CONTROL_ENDPOINTS, BRIDGE_ENDPOINTS } from "./protocol.js";
|
|
5
5
|
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
6
6
|
const MAX_BODY_BYTES = 256 * 1024;
|
|
7
|
+
const MAX_PROMPT_BODY_BYTES = 12 * 1024 * 1024;
|
|
7
8
|
const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
|
|
9
|
+
const AGENT_CLAIM_TIMEOUT_MS = 30_000;
|
|
8
10
|
const SYSTEM_CLOCK = {
|
|
9
11
|
now: () => Date.now(),
|
|
10
12
|
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
@@ -85,13 +87,13 @@ function sendError(response, error, origin) {
|
|
|
85
87
|
}
|
|
86
88
|
sendJson(response, 500, { error: { code: "internal_error", message: "The bridge could not complete the request." } }, origin);
|
|
87
89
|
}
|
|
88
|
-
async function readJsonBody(request) {
|
|
90
|
+
async function readJsonBody(request, maxBytes = MAX_BODY_BYTES) {
|
|
89
91
|
const chunks = [];
|
|
90
92
|
let total = 0;
|
|
91
93
|
for await (const chunk of request) {
|
|
92
94
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
93
95
|
total += buffer.length;
|
|
94
|
-
if (total >
|
|
96
|
+
if (total > maxBytes) {
|
|
95
97
|
throw new BridgeRequestError(413, "body_too_large", "The bridge request body is too large.");
|
|
96
98
|
}
|
|
97
99
|
chunks.push(buffer);
|
|
@@ -110,6 +112,37 @@ async function readJsonBody(request) {
|
|
|
110
112
|
}
|
|
111
113
|
return parsed;
|
|
112
114
|
}
|
|
115
|
+
function pngDimensions(bytes) {
|
|
116
|
+
if (bytes.length < 24
|
|
117
|
+
|| !bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
|
|
118
|
+
|| bytes.toString("ascii", 12, 16) !== "IHDR")
|
|
119
|
+
return null;
|
|
120
|
+
return {
|
|
121
|
+
width: bytes.readUInt32BE(16),
|
|
122
|
+
height: bytes.readUInt32BE(20),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function parseSketchAttachments(value) {
|
|
126
|
+
let attachments;
|
|
127
|
+
try {
|
|
128
|
+
attachments = validateSketchAttachments(value);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
throw new BridgeRequestError(400, "invalid_sketches", error instanceof Error ? error.message : "Sketch attachments are invalid.");
|
|
132
|
+
}
|
|
133
|
+
for (const [index, attachment] of attachments.entries()) {
|
|
134
|
+
const bytes = Buffer.from(attachment.data, "base64");
|
|
135
|
+
if (bytes.length !== attachment.byteSize || bytes.toString("base64") !== attachment.data) {
|
|
136
|
+
throw new BridgeRequestError(400, "invalid_sketches", `attachments[${index}] is not canonical base64 PNG data.`);
|
|
137
|
+
}
|
|
138
|
+
const dimensions = pngDimensions(bytes);
|
|
139
|
+
if (!dimensions || dimensions.width !== attachment.width || dimensions.height !== attachment.height
|
|
140
|
+
|| dimensions.width > 2_048 || dimensions.height > 2_048) {
|
|
141
|
+
throw new BridgeRequestError(400, "invalid_sketches", `attachments[${index}] has invalid PNG dimensions.`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return attachments;
|
|
145
|
+
}
|
|
113
146
|
function createEnvelope(projectId, sessionToken, event) {
|
|
114
147
|
return {
|
|
115
148
|
protocolVersion: AGENT_PROTOCOL_VERSION,
|
|
@@ -165,7 +198,10 @@ export function createLoopbackBridge(options) {
|
|
|
165
198
|
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) {
|
|
166
199
|
throw new TypeError("commandTimeoutMs must be between 1 and 60000 milliseconds");
|
|
167
200
|
}
|
|
168
|
-
const allowedOrigins =
|
|
201
|
+
const allowedOrigins = [
|
|
202
|
+
...(configuredOrigin ? [configuredOrigin] : []),
|
|
203
|
+
...(options.allowedOrigins ?? []),
|
|
204
|
+
].map((candidate) => canonicalOrigin(candidate));
|
|
169
205
|
if (allowedOrigins.some((candidate) => candidate === null)) {
|
|
170
206
|
throw new TypeError("allowedOrigins must contain canonical HTTP(S) origins");
|
|
171
207
|
}
|
|
@@ -173,6 +209,10 @@ export function createLoopbackBridge(options) {
|
|
|
173
209
|
const clock = options.clock ?? SYSTEM_CLOCK;
|
|
174
210
|
const tokenFactory = options.tokenFactory ?? randomToken;
|
|
175
211
|
const idFactory = options.idFactory ?? randomId;
|
|
212
|
+
const agentControlToken = options.agentControlToken;
|
|
213
|
+
if (agentControlToken !== undefined && (agentControlToken.length < 32 || agentControlToken.length > 256)) {
|
|
214
|
+
throw new TypeError("agentControlToken must contain between 32 and 256 characters");
|
|
215
|
+
}
|
|
176
216
|
let lifecycle = "created";
|
|
177
217
|
let currentAddress = null;
|
|
178
218
|
let sessionToken = null;
|
|
@@ -184,6 +224,9 @@ export function createLoopbackBridge(options) {
|
|
|
184
224
|
let pendingCanvas = null;
|
|
185
225
|
let closePromise = null;
|
|
186
226
|
let startPromise = null;
|
|
227
|
+
let agentOwner = null;
|
|
228
|
+
let agentOwnerSeenAt = 0;
|
|
229
|
+
let agentOwnerExpiry = null;
|
|
187
230
|
const getStatus = () => statusWith(options.projectId, listener !== null, sessionToken !== null, currentRequest, lastPageUrl);
|
|
188
231
|
const broadcast = (event) => {
|
|
189
232
|
if (!sessionToken)
|
|
@@ -261,13 +304,13 @@ export function createLoopbackBridge(options) {
|
|
|
261
304
|
sendJson(response, 200, {
|
|
262
305
|
protocolVersion: AGENT_PROTOCOL_VERSION,
|
|
263
306
|
projectId: options.projectId,
|
|
264
|
-
origin:
|
|
307
|
+
origin: incomingOrigin,
|
|
265
308
|
status: getStatus(),
|
|
266
309
|
sessionRequired: true,
|
|
267
310
|
pageUrl: lastPageUrl,
|
|
268
311
|
}, incomingOrigin);
|
|
269
312
|
};
|
|
270
|
-
const
|
|
313
|
+
const readPairRequest = async (request) => {
|
|
271
314
|
if (!loopbackAddress(request.socket.remoteAddress)) {
|
|
272
315
|
throw new BridgeRequestError(403, "loopback_only", "The browser bridge accepts loopback connections only.");
|
|
273
316
|
}
|
|
@@ -278,7 +321,7 @@ export function createLoopbackBridge(options) {
|
|
|
278
321
|
const body = await readJsonBody(request);
|
|
279
322
|
parseProjectId(typeof body.projectId === "string" ? body.projectId : null, options.projectId);
|
|
280
323
|
const requestedOrigin = canonicalOrigin(body.origin);
|
|
281
|
-
if (!requestedOrigin || requestedOrigin !== incomingOrigin
|
|
324
|
+
if (!requestedOrigin || requestedOrigin !== incomingOrigin) {
|
|
282
325
|
throw new BridgeRequestError(403, "origin_mismatch", "Pairing requires an explicitly configured project origin.");
|
|
283
326
|
}
|
|
284
327
|
let requestedPageUrl;
|
|
@@ -289,8 +332,25 @@ export function createLoopbackBridge(options) {
|
|
|
289
332
|
}
|
|
290
333
|
requestedPageUrl = new URL(body.pageUrl, requestedOrigin).href;
|
|
291
334
|
}
|
|
335
|
+
return { body, incomingOrigin, requestedPageUrl };
|
|
336
|
+
};
|
|
337
|
+
const handlePair = async (request, response) => {
|
|
338
|
+
const { body, incomingOrigin, requestedPageUrl } = await readPairRequest(request);
|
|
292
339
|
if (sessionToken) {
|
|
293
|
-
|
|
340
|
+
if (body.sessionToken !== sessionToken) {
|
|
341
|
+
throw new BridgeRequestError(409, "already_paired", "A browser is already paired with this project.");
|
|
342
|
+
}
|
|
343
|
+
lastPageUrl = requestedPageUrl ?? lastPageUrl;
|
|
344
|
+
const existing = {
|
|
345
|
+
protocolVersion: AGENT_PROTOCOL_VERSION,
|
|
346
|
+
projectId: options.projectId,
|
|
347
|
+
origin: pairedOrigin,
|
|
348
|
+
sessionToken,
|
|
349
|
+
...(lastPageUrl === null ? {} : { pageUrl: lastPageUrl }),
|
|
350
|
+
status: getStatus(),
|
|
351
|
+
};
|
|
352
|
+
sendJson(response, 200, existing, incomingOrigin);
|
|
353
|
+
return;
|
|
294
354
|
}
|
|
295
355
|
sessionToken = tokenFactory();
|
|
296
356
|
if (!sessionToken || sessionToken.length > AGENT_PROTOCOL_LIMITS.sessionToken) {
|
|
@@ -303,7 +363,7 @@ export function createLoopbackBridge(options) {
|
|
|
303
363
|
const result = {
|
|
304
364
|
protocolVersion: AGENT_PROTOCOL_VERSION,
|
|
305
365
|
projectId: options.projectId,
|
|
306
|
-
origin:
|
|
366
|
+
origin: pairedOrigin,
|
|
307
367
|
sessionToken,
|
|
308
368
|
...(lastPageUrl === null ? {} : { pageUrl: lastPageUrl }),
|
|
309
369
|
status: getStatus(),
|
|
@@ -311,6 +371,15 @@ export function createLoopbackBridge(options) {
|
|
|
311
371
|
sendJson(response, 200, result, incomingOrigin);
|
|
312
372
|
broadcastStatus();
|
|
313
373
|
};
|
|
374
|
+
const handleTakeover = async (request, response) => {
|
|
375
|
+
const { incomingOrigin, requestedPageUrl } = await readPairRequest(request);
|
|
376
|
+
// Takeover is intentionally separate from normal pairing. The caller has
|
|
377
|
+
// made an explicit UI choice to revoke the existing browser session.
|
|
378
|
+
if (sessionToken)
|
|
379
|
+
disconnectBrowser(sessionToken);
|
|
380
|
+
const pairing = pairBrowser(options.projectId, incomingOrigin, requestedPageUrl);
|
|
381
|
+
sendJson(response, 200, pairing, incomingOrigin);
|
|
382
|
+
};
|
|
314
383
|
const handleEvents = (request, response, url) => {
|
|
315
384
|
parseProjectId(url.searchParams.get("projectId"), options.projectId);
|
|
316
385
|
const incomingOrigin = requireBrowserRequest(request, url);
|
|
@@ -338,7 +407,7 @@ export function createLoopbackBridge(options) {
|
|
|
338
407
|
sendJson(response, 200, getStatus(), incomingOrigin);
|
|
339
408
|
};
|
|
340
409
|
const handlePrompt = async (request, response) => {
|
|
341
|
-
const body = await readJsonBody(request);
|
|
410
|
+
const body = await readJsonBody(request, MAX_PROMPT_BODY_BYTES);
|
|
342
411
|
const incomingOrigin = requireBrowserRequest(request, new URL("http://bridge.invalid"), body.sessionToken);
|
|
343
412
|
parseProjectId(typeof body.projectId === "string" ? body.projectId : null, options.projectId);
|
|
344
413
|
if (typeof body.prompt !== "string" || body.prompt.length === 0 || body.prompt.length > AGENT_PROTOCOL_LIMITS.prompt) {
|
|
@@ -348,6 +417,13 @@ export function createLoopbackBridge(options) {
|
|
|
348
417
|
&& (!Number.isSafeInteger(body.changeRevision) || body.changeRevision < 0)) {
|
|
349
418
|
throw new BridgeRequestError(400, "invalid_revision", "changeRevision must be a non-negative safe integer");
|
|
350
419
|
}
|
|
420
|
+
if (body.clientDispatchId !== undefined
|
|
421
|
+
&& (typeof body.clientDispatchId !== "string"
|
|
422
|
+
|| body.clientDispatchId.length === 0
|
|
423
|
+
|| body.clientDispatchId.length > AGENT_PROTOCOL_LIMITS.clientDispatchId)) {
|
|
424
|
+
throw new BridgeRequestError(400, "invalid_dispatch_id", "clientDispatchId must be a bounded non-empty string");
|
|
425
|
+
}
|
|
426
|
+
const attachments = parseSketchAttachments(body.attachments);
|
|
351
427
|
if (!listener) {
|
|
352
428
|
throw new BridgeRequestError(409, "listener_unavailable", "The agent has not opened a listening call.");
|
|
353
429
|
}
|
|
@@ -359,13 +435,19 @@ export function createLoopbackBridge(options) {
|
|
|
359
435
|
projectId: options.projectId,
|
|
360
436
|
prompt: body.prompt,
|
|
361
437
|
...(body.changeRevision === undefined ? {} : { changeRevision: body.changeRevision }),
|
|
438
|
+
...(body.clientDispatchId === undefined ? {} : { clientDispatchId: body.clientDispatchId }),
|
|
439
|
+
...(attachments.length === 0 ? {} : { sketches: attachments.map(({ data: _data, ...metadata }) => metadata) }),
|
|
440
|
+
};
|
|
441
|
+
const delivered = {
|
|
442
|
+
...prompt,
|
|
443
|
+
...(attachments.length === 0 ? {} : { attachments }),
|
|
362
444
|
};
|
|
363
445
|
const pending = listener;
|
|
364
446
|
listener = null;
|
|
365
447
|
if (pending.abortHandler && pending.signal)
|
|
366
448
|
pending.signal.removeEventListener("abort", pending.abortHandler);
|
|
367
449
|
currentRequest = { ...prompt, status: "working" };
|
|
368
|
-
pending.resolve(
|
|
450
|
+
pending.resolve(delivered);
|
|
369
451
|
const result = { request: prompt, status: "working" };
|
|
370
452
|
sendJson(response, 202, result, incomingOrigin);
|
|
371
453
|
broadcastStatus();
|
|
@@ -426,9 +508,160 @@ export function createLoopbackBridge(options) {
|
|
|
426
508
|
disconnectBrowser(body.sessionToken);
|
|
427
509
|
sendJson(response, 200, { disconnected: true }, incomingOrigin);
|
|
428
510
|
};
|
|
511
|
+
const requireAgentControl = (request, bodyAgentId) => {
|
|
512
|
+
if (!agentControlToken || !loopbackAddress(request.socket.remoteAddress)) {
|
|
513
|
+
throw new BridgeRequestError(403, "agent_control_unavailable", "Private agent control is unavailable.");
|
|
514
|
+
}
|
|
515
|
+
if (headerValue(request, "authorization") !== `Bearer ${agentControlToken}`) {
|
|
516
|
+
throw new BridgeRequestError(401, "invalid_agent_credential", "The agent control credential is invalid.");
|
|
517
|
+
}
|
|
518
|
+
if (bodyAgentId === undefined)
|
|
519
|
+
return null;
|
|
520
|
+
if (typeof bodyAgentId !== "string" || bodyAgentId.length === 0 || bodyAgentId.length > 256) {
|
|
521
|
+
throw new BridgeRequestError(400, "invalid_agent_id", "agentId must be a bounded non-empty string.");
|
|
522
|
+
}
|
|
523
|
+
return bodyAgentId;
|
|
524
|
+
};
|
|
525
|
+
const requireAgentOwner = (request, body) => {
|
|
526
|
+
const agentId = requireAgentControl(request, body.agentId);
|
|
527
|
+
if (!agentId || agentOwner !== agentId) {
|
|
528
|
+
throw new BridgeRequestError(409, "agent_not_owner", "This MCP adapter does not own the project session.");
|
|
529
|
+
}
|
|
530
|
+
agentOwnerSeenAt = clock.now();
|
|
531
|
+
scheduleAgentOwnerExpiry();
|
|
532
|
+
return agentId;
|
|
533
|
+
};
|
|
534
|
+
const releaseAgentOwner = (reason) => {
|
|
535
|
+
if (agentOwnerExpiry !== null)
|
|
536
|
+
clock.clearTimeout(agentOwnerExpiry);
|
|
537
|
+
agentOwnerExpiry = null;
|
|
538
|
+
agentOwner = null;
|
|
539
|
+
agentOwnerSeenAt = 0;
|
|
540
|
+
cancelListener(reason);
|
|
541
|
+
if (currentRequest?.status === "working") {
|
|
542
|
+
currentRequest = { ...currentRequest, status: "interrupted", error: reason };
|
|
543
|
+
broadcastStatus();
|
|
544
|
+
}
|
|
545
|
+
if (pendingCanvas) {
|
|
546
|
+
const pendingCommand = pendingCanvas;
|
|
547
|
+
pendingCanvas = null;
|
|
548
|
+
clock.clearTimeout(pendingCommand.timeout);
|
|
549
|
+
pendingCommand.resolve({
|
|
550
|
+
commandId: pendingCommand.command.commandId,
|
|
551
|
+
ok: false,
|
|
552
|
+
error: { code: "agent_released", message: reason },
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
const scheduleAgentOwnerExpiry = () => {
|
|
557
|
+
if (agentOwnerExpiry !== null)
|
|
558
|
+
clock.clearTimeout(agentOwnerExpiry);
|
|
559
|
+
const expectedOwner = agentOwner;
|
|
560
|
+
if (!expectedOwner) {
|
|
561
|
+
agentOwnerExpiry = null;
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const expireIfStale = () => {
|
|
565
|
+
agentOwnerExpiry = null;
|
|
566
|
+
if (agentOwner !== expectedOwner)
|
|
567
|
+
return;
|
|
568
|
+
const remaining = AGENT_CLAIM_TIMEOUT_MS - (clock.now() - agentOwnerSeenAt);
|
|
569
|
+
if (remaining > 0) {
|
|
570
|
+
agentOwnerExpiry = clock.setTimeout(expireIfStale, remaining);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
releaseAgentOwner("The MCP adapter claim expired.");
|
|
574
|
+
};
|
|
575
|
+
agentOwnerExpiry = clock.setTimeout(expireIfStale, AGENT_CLAIM_TIMEOUT_MS);
|
|
576
|
+
};
|
|
577
|
+
const handleAgentClaim = async (request, response) => {
|
|
578
|
+
const body = await readJsonBody(request);
|
|
579
|
+
const agentId = requireAgentControl(request, body.agentId);
|
|
580
|
+
if (!agentId)
|
|
581
|
+
throw new BridgeRequestError(400, "invalid_agent_id", "agentId is required.");
|
|
582
|
+
if (agentOwner && agentOwner !== agentId && clock.now() - agentOwnerSeenAt <= AGENT_CLAIM_TIMEOUT_MS) {
|
|
583
|
+
throw new BridgeRequestError(409, "session_claimed", "Another MCP adapter owns this project session.");
|
|
584
|
+
}
|
|
585
|
+
if (agentOwner && agentOwner !== agentId)
|
|
586
|
+
releaseAgentOwner("The previous MCP adapter claim expired.");
|
|
587
|
+
agentOwner = agentId;
|
|
588
|
+
agentOwnerSeenAt = clock.now();
|
|
589
|
+
scheduleAgentOwnerExpiry();
|
|
590
|
+
sendJson(response, 200, { claimed: true }, null);
|
|
591
|
+
};
|
|
592
|
+
const handleAgentRelease = async (request, response) => {
|
|
593
|
+
const body = await readJsonBody(request);
|
|
594
|
+
const agentId = requireAgentOwner(request, body);
|
|
595
|
+
releaseAgentOwner(`Agent ${agentId} released the project session.`);
|
|
596
|
+
sendJson(response, 200, { released: true }, null);
|
|
597
|
+
};
|
|
598
|
+
const handleAgentListen = async (request, response) => {
|
|
599
|
+
const body = await readJsonBody(request);
|
|
600
|
+
requireAgentOwner(request, body);
|
|
601
|
+
const abort = new AbortController();
|
|
602
|
+
response.once("close", () => abort.abort());
|
|
603
|
+
const prompt = await waitForPrompt(abort.signal);
|
|
604
|
+
sendJson(response, 200, prompt, null);
|
|
605
|
+
};
|
|
606
|
+
const handleAgentReportStatus = async (request, response) => {
|
|
607
|
+
const body = await readJsonBody(request);
|
|
608
|
+
requireAgentOwner(request, body);
|
|
609
|
+
if (!isAgentStatusUpdate(body.update)) {
|
|
610
|
+
throw new BridgeRequestError(400, "invalid_status", "update must be a valid agent status update.");
|
|
611
|
+
}
|
|
612
|
+
updateRequestStatus(body.update);
|
|
613
|
+
sendJson(response, 200, getStatus(), null);
|
|
614
|
+
};
|
|
615
|
+
const handleAgentCanvas = async (request, response) => {
|
|
616
|
+
const body = await readJsonBody(request);
|
|
617
|
+
requireAgentOwner(request, body);
|
|
618
|
+
if (!isCanvasCommand(body.command)) {
|
|
619
|
+
throw new BridgeRequestError(400, "invalid_canvas_command", "command must be a valid Canvas command.");
|
|
620
|
+
}
|
|
621
|
+
sendJson(response, 200, await dispatchCanvasCommand(body.command), null);
|
|
622
|
+
};
|
|
429
623
|
const httpServer = createServer((request, response) => {
|
|
430
624
|
void (async () => {
|
|
431
625
|
const url = new URL(request.url ?? "/", `http://${host}:${port}`);
|
|
626
|
+
if (request.method === "GET" && url.pathname === AGENT_CONTROL_ENDPOINTS.health) {
|
|
627
|
+
requireAgentControl(request);
|
|
628
|
+
sendJson(response, 200, { projectId: options.projectId, available: true, claimed: agentOwner !== null, status: getStatus() }, null);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.claim) {
|
|
632
|
+
await handleAgentClaim(request, response);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.release) {
|
|
636
|
+
await handleAgentRelease(request, response);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.heartbeat) {
|
|
640
|
+
const body = await readJsonBody(request);
|
|
641
|
+
requireAgentOwner(request, body);
|
|
642
|
+
sendJson(response, 200, { alive: true }, null);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.listen) {
|
|
646
|
+
await handleAgentListen(request, response);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (request.method === "GET" && url.pathname === AGENT_CONTROL_ENDPOINTS.status) {
|
|
650
|
+
const agentId = url.searchParams.get("agentId");
|
|
651
|
+
requireAgentControl(request, agentId);
|
|
652
|
+
if (agentOwner !== agentId)
|
|
653
|
+
throw new BridgeRequestError(409, "agent_not_owner", "This MCP adapter does not own the project session.");
|
|
654
|
+
sendJson(response, 200, getStatus(), null);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.reportStatus) {
|
|
658
|
+
await handleAgentReportStatus(request, response);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.canvas) {
|
|
662
|
+
await handleAgentCanvas(request, response);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
432
665
|
if (request.method === "OPTIONS") {
|
|
433
666
|
handleOptions(request, response);
|
|
434
667
|
return;
|
|
@@ -441,6 +674,10 @@ export function createLoopbackBridge(options) {
|
|
|
441
674
|
await handlePair(request, response);
|
|
442
675
|
return;
|
|
443
676
|
}
|
|
677
|
+
if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.takeover) {
|
|
678
|
+
await handleTakeover(request, response);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
444
681
|
if (request.method === "GET" && url.pathname === BRIDGE_ENDPOINTS.events) {
|
|
445
682
|
handleEvents(request, response, url);
|
|
446
683
|
return;
|
|
@@ -508,7 +745,7 @@ export function createLoopbackBridge(options) {
|
|
|
508
745
|
listener = null;
|
|
509
746
|
if (pending.abortHandler && pending.signal)
|
|
510
747
|
pending.signal.removeEventListener("abort", pending.abortHandler);
|
|
511
|
-
pending.reject(new
|
|
748
|
+
pending.reject(new BridgeRequestError(409, "listener_cancelled", reason));
|
|
512
749
|
broadcastStatus();
|
|
513
750
|
};
|
|
514
751
|
const waitForPrompt = (signal) => {
|
|
@@ -618,9 +855,6 @@ export function createLoopbackBridge(options) {
|
|
|
618
855
|
// This method is the explicit host-side approval path. A bridge without
|
|
619
856
|
// an HTTP allow-list can still be paired by trusted host code, while all
|
|
620
857
|
// browser-originated requests remain closed until that approval exists.
|
|
621
|
-
if (configuredOrigin !== null && requestCanonicalOrigin !== configuredOrigin) {
|
|
622
|
-
throw new Error("Browser origin does not match this bridge");
|
|
623
|
-
}
|
|
624
858
|
if (canonicalAllowedOrigins.length > 0 && !originIsAllowed(requestCanonicalOrigin)) {
|
|
625
859
|
throw new Error("Browser origin is not in the configured allow-list");
|
|
626
860
|
}
|
|
@@ -683,6 +917,11 @@ export function createLoopbackBridge(options) {
|
|
|
683
917
|
if (closePromise)
|
|
684
918
|
return closePromise;
|
|
685
919
|
lifecycle = "closed";
|
|
920
|
+
if (agentOwnerExpiry !== null)
|
|
921
|
+
clock.clearTimeout(agentOwnerExpiry);
|
|
922
|
+
agentOwnerExpiry = null;
|
|
923
|
+
agentOwner = null;
|
|
924
|
+
agentOwnerSeenAt = 0;
|
|
686
925
|
cancelListener("bridge_closed");
|
|
687
926
|
if (pendingCanvas) {
|
|
688
927
|
clock.clearTimeout(pendingCanvas.timeout);
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type ChildProcess } from "node:child_process";
|
|
2
2
|
export interface CliOptions {
|
|
3
|
+
readonly command: "serve" | "doctor";
|
|
3
4
|
readonly projectId: string;
|
|
4
5
|
readonly origin?: string;
|
|
5
|
-
readonly workspaceRoot
|
|
6
|
+
readonly workspaceRoot?: string;
|
|
6
7
|
readonly host: "127.0.0.1" | "::1" | "localhost";
|
|
7
8
|
readonly port: number;
|
|
8
9
|
readonly commandTimeoutMs: number;
|
|
@@ -31,6 +32,6 @@ export interface PageLaunchDependencies {
|
|
|
31
32
|
export declare function openPairedPage(pageUrl: string, dependencies?: PageLaunchDependencies): Promise<boolean>;
|
|
32
33
|
/** Parses only project-scoped, host-neutral CLI options. */
|
|
33
34
|
export declare function parseCliArguments(argv?: readonly string[], environment?: CliEnvironment): CliOptions;
|
|
34
|
-
export declare const CLI_USAGE = "Usage: nudge-mcp [options]\n\nStarts a
|
|
35
|
+
export declare const CLI_USAGE = "Usage: nudge-mcp [doctor] [options]\n\nStarts a standard MCP stdio adapter. It discovers the bridge owned by the\nrunning project unless --origin selects the legacy coupled mode.\n\nOptions:\n --project-id <id> Stable project pairing identity\n --origin <origin> Exact app origin required for browser pairing\n --workspace-root <path> Optional legacy project restriction; omitted for reusable adapters\n --host <loopback> 127.0.0.1, ::1, or localhost\n --port <port> Bridge port; default is deterministic per project, 0 is ephemeral\n --command-timeout-ms <ms> Canvas acknowledgement timeout\n --help Show this message\n";
|
|
35
36
|
/** Runs the CLI until the stdio MCP connection closes or the process stops. */
|
|
36
37
|
export declare function runCli(argv?: readonly string[], environment?: CliEnvironment): Promise<void>;
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,8 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { basename, resolve } from "node:path";
|
|
4
4
|
import { defaultBridgePort } from "./internal/agent-protocol.js";
|
|
5
|
-
import {
|
|
5
|
+
import { diagnoseProjectSessions, discoverProjectSessions } from "./discovery.js";
|
|
6
|
+
import { createAgentCompanion, createDiscoveredAgentAdapter, } from "./server.js";
|
|
6
7
|
const DEFAULT_PORT = 0;
|
|
7
8
|
const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
|
|
8
9
|
const PAGE_PROBE_TIMEOUT_MS = 1_500;
|
|
@@ -59,6 +60,8 @@ function parseInteger(value, flag, maximum) {
|
|
|
59
60
|
}
|
|
60
61
|
/** Parses only project-scoped, host-neutral CLI options. */
|
|
61
62
|
export function parseCliArguments(argv = process.argv.slice(2), environment = process.env) {
|
|
63
|
+
const command = argv[0] === "doctor" ? "doctor" : "serve";
|
|
64
|
+
const argumentsToParse = command === "doctor" ? argv.slice(1) : argv;
|
|
62
65
|
const root = resolve(environment.NUDGE_UI_WORKSPACE_ROOT ?? environment.INIT_CWD ?? process.cwd());
|
|
63
66
|
let projectId = environment.NUDGE_UI_PROJECT_ID ?? projectIdFor(root);
|
|
64
67
|
let origin = environment.NUDGE_UI_ORIGIN;
|
|
@@ -70,30 +73,30 @@ export function parseCliArguments(argv = process.argv.slice(2), environment = pr
|
|
|
70
73
|
let commandTimeoutMs = environment.NUDGE_UI_COMMAND_TIMEOUT_MS === undefined
|
|
71
74
|
? DEFAULT_COMMAND_TIMEOUT_MS
|
|
72
75
|
: parseInteger(environment.NUDGE_UI_COMMAND_TIMEOUT_MS, "NUDGE_UI_COMMAND_TIMEOUT_MS", 60_000);
|
|
73
|
-
let workspaceRoot = root;
|
|
76
|
+
let workspaceRoot = environment.NUDGE_UI_WORKSPACE_ROOT === undefined ? undefined : root;
|
|
74
77
|
let help = false;
|
|
75
78
|
let explicitPort = environment.NUDGE_UI_BRIDGE_PORT !== undefined;
|
|
76
|
-
for (let index = 0; index <
|
|
77
|
-
const argument =
|
|
79
|
+
for (let index = 0; index < argumentsToParse.length; index += 1) {
|
|
80
|
+
const argument = argumentsToParse[index];
|
|
78
81
|
switch (argument) {
|
|
79
82
|
case "--help":
|
|
80
83
|
case "-h":
|
|
81
84
|
help = true;
|
|
82
85
|
break;
|
|
83
86
|
case "--project-id":
|
|
84
|
-
projectId = valueAfter(
|
|
87
|
+
projectId = valueAfter(argumentsToParse, index, argument);
|
|
85
88
|
index += 1;
|
|
86
89
|
break;
|
|
87
90
|
case "--origin":
|
|
88
|
-
origin = valueAfter(
|
|
91
|
+
origin = valueAfter(argumentsToParse, index, argument);
|
|
89
92
|
index += 1;
|
|
90
93
|
break;
|
|
91
94
|
case "--workspace-root":
|
|
92
|
-
workspaceRoot = resolve(valueAfter(
|
|
95
|
+
workspaceRoot = resolve(valueAfter(argumentsToParse, index, argument));
|
|
93
96
|
index += 1;
|
|
94
97
|
break;
|
|
95
98
|
case "--host": {
|
|
96
|
-
const candidate = valueAfter(
|
|
99
|
+
const candidate = valueAfter(argumentsToParse, index, argument);
|
|
97
100
|
if (candidate !== "127.0.0.1" && candidate !== "::1" && candidate !== "localhost") {
|
|
98
101
|
throw new TypeError("--host must be 127.0.0.1, ::1, or localhost");
|
|
99
102
|
}
|
|
@@ -102,12 +105,12 @@ export function parseCliArguments(argv = process.argv.slice(2), environment = pr
|
|
|
102
105
|
break;
|
|
103
106
|
}
|
|
104
107
|
case "--port":
|
|
105
|
-
port = parseInteger(valueAfter(
|
|
108
|
+
port = parseInteger(valueAfter(argumentsToParse, index, argument), argument, 65_535);
|
|
106
109
|
explicitPort = true;
|
|
107
110
|
index += 1;
|
|
108
111
|
break;
|
|
109
112
|
case "--command-timeout-ms":
|
|
110
|
-
commandTimeoutMs = parseInteger(valueAfter(
|
|
113
|
+
commandTimeoutMs = parseInteger(valueAfter(argumentsToParse, index, argument), argument, 60_000);
|
|
111
114
|
if (commandTimeoutMs === 0)
|
|
112
115
|
throw new TypeError("--command-timeout-ms must be greater than zero");
|
|
113
116
|
index += 1;
|
|
@@ -120,16 +123,17 @@ export function parseCliArguments(argv = process.argv.slice(2), environment = pr
|
|
|
120
123
|
throw new TypeError("--project-id must be a bounded non-empty string");
|
|
121
124
|
if (!explicitPort)
|
|
122
125
|
port = defaultBridgePort(projectId);
|
|
123
|
-
return { projectId, origin, workspaceRoot, host, port, commandTimeoutMs, help };
|
|
126
|
+
return { command, projectId, origin, workspaceRoot, host, port, commandTimeoutMs, help };
|
|
124
127
|
}
|
|
125
|
-
export const CLI_USAGE = `Usage: nudge-mcp [options]
|
|
128
|
+
export const CLI_USAGE = `Usage: nudge-mcp [doctor] [options]
|
|
126
129
|
|
|
127
|
-
Starts a
|
|
130
|
+
Starts a standard MCP stdio adapter. It discovers the bridge owned by the
|
|
131
|
+
running project unless --origin selects the legacy coupled mode.
|
|
128
132
|
|
|
129
133
|
Options:
|
|
130
134
|
--project-id <id> Stable project pairing identity
|
|
131
135
|
--origin <origin> Exact app origin required for browser pairing
|
|
132
|
-
--workspace-root <path>
|
|
136
|
+
--workspace-root <path> Optional legacy project restriction; omitted for reusable adapters
|
|
133
137
|
--host <loopback> 127.0.0.1, ::1, or localhost
|
|
134
138
|
--port <port> Bridge port; default is deterministic per project, 0 is ephemeral
|
|
135
139
|
--command-timeout-ms <ms> Canvas acknowledgement timeout
|
|
@@ -142,8 +146,65 @@ export async function runCli(argv = process.argv.slice(2), environment = process
|
|
|
142
146
|
process.stdout.write(CLI_USAGE);
|
|
143
147
|
return;
|
|
144
148
|
}
|
|
149
|
+
if (options.command === "doctor") {
|
|
150
|
+
const sessions = await discoverProjectSessions(options.workspaceRoot);
|
|
151
|
+
const matches = sessions.filter((session) => options.workspaceRoot === undefined || session.matchesApplication);
|
|
152
|
+
const diagnostics = await diagnoseProjectSessions(options.workspaceRoot);
|
|
153
|
+
process.stdout.write([
|
|
154
|
+
`[nudge-ui] registry: ${diagnostics.registryRoot}`,
|
|
155
|
+
`[nudge-ui] workspace: ${diagnostics.workspaceRoot ?? "all projects"}`,
|
|
156
|
+
`[nudge-ui] application: ${diagnostics.applicationRoot ?? "all applications"}`,
|
|
157
|
+
`[nudge-ui] descriptors: ${diagnostics.descriptorCount}; invalid: ${diagnostics.invalidCount}; incompatible: ${diagnostics.incompatibleCount}`,
|
|
158
|
+
`[nudge-ui] matching unreachable descriptors: ${diagnostics.matchingUnreachableCount}`,
|
|
159
|
+
...diagnostics.guidance.map((message) => `[nudge-ui] ${message}`),
|
|
160
|
+
`[nudge-ui] live sessions: ${sessions.length}`,
|
|
161
|
+
`[nudge-ui] matching sessions: ${matches.length}`,
|
|
162
|
+
...(matches.length === 0
|
|
163
|
+
? ["[nudge-ui] no reachable compatible bridge matches; inspect the diagnostics above or start the development server"]
|
|
164
|
+
: matches.map((session) => {
|
|
165
|
+
const state = session.status.connection === "working"
|
|
166
|
+
? "working"
|
|
167
|
+
: session.status.listenerActive ? "listening" : "available";
|
|
168
|
+
return `[nudge-ui] ${state}: ${session.projectId} (${session.origin})${session.claimed ? " [claimed]" : ""}`;
|
|
169
|
+
})),
|
|
170
|
+
].join("\n") + "\n");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (!options.origin) {
|
|
174
|
+
const adapter = await createDiscoveredAgentAdapter({ workspaceRoot: options.workspaceRoot });
|
|
175
|
+
let stopping = false;
|
|
176
|
+
let resolveAdapterWait = null;
|
|
177
|
+
const stop = () => {
|
|
178
|
+
if (stopping)
|
|
179
|
+
return;
|
|
180
|
+
stopping = true;
|
|
181
|
+
void adapter.close();
|
|
182
|
+
resolveAdapterWait?.();
|
|
183
|
+
};
|
|
184
|
+
process.once("SIGINT", stop);
|
|
185
|
+
process.once("SIGTERM", stop);
|
|
186
|
+
try {
|
|
187
|
+
await adapter.start();
|
|
188
|
+
await new Promise((resolveAdapter) => {
|
|
189
|
+
const finish = () => resolveAdapter();
|
|
190
|
+
// Keep a direct resolver so signal handlers terminate even though
|
|
191
|
+
// installing a signal handler suppresses Node's default process exit.
|
|
192
|
+
resolveAdapterWait = finish;
|
|
193
|
+
process.stdin.once("end", finish);
|
|
194
|
+
process.once("exit", finish);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
process.off("SIGINT", stop);
|
|
199
|
+
process.off("SIGTERM", stop);
|
|
200
|
+
await adapter.close();
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
process.stderr.write("[nudge-ui] --origin uses deprecated coupled mode. Rerun agent setup to use project-owned bridges and avoid port collisions.\n");
|
|
145
205
|
const companion = createAgentCompanion({
|
|
146
206
|
...options,
|
|
207
|
+
workspaceRoot: options.workspaceRoot ?? resolve(environment.INIT_CWD ?? process.cwd()),
|
|
147
208
|
onControllerUnavailable: (pageUrl) => openPairedPage(pageUrl).then(() => undefined),
|
|
148
209
|
});
|
|
149
210
|
let stopping = false;
|