@nudge-ui/mcp 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -53
- package/dist/bridge.d.ts +4 -2
- package/dist/bridge.js +237 -15
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +65 -12
- package/dist/cli.mjs +14741 -13959
- package/dist/discovery.d.ts +29 -0
- package/dist/discovery.js +112 -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 +28 -0
- package/dist/projectSessionClient.js +93 -0
- package/dist/protocol.d.ts +13 -2
- package/dist/protocol.js +12 -1
- package/dist/server.d.ts +14 -0
- package/dist/server.js +90 -22
- package/dist/sessionRegistry.d.ts +8 -0
- package/dist/sessionRegistry.js +89 -0
- package/dist/sessionSelection.d.ts +15 -0
- package/dist/sessionSelection.js +41 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -1,62 +1,88 @@
|
|
|
1
1
|
# `@nudge-ui/mcp`
|
|
2
2
|
|
|
3
|
-
> **Early alpha:** This integration
|
|
4
|
-
>
|
|
5
|
-
> not rely on it for production workflows yet.
|
|
3
|
+
> **Early alpha:** This integration can make breaking changes while host
|
|
4
|
+
> support and the setup workflow stabilize.
|
|
6
5
|
|
|
7
|
-
`@nudge-ui/mcp` connects a local Nudge
|
|
8
|
-
agent.
|
|
9
|
-
|
|
6
|
+
`@nudge-ui/mcp` connects a local Nudge inspector to an MCP-capable coding
|
|
7
|
+
agent. The development server owns the browser bridge. The agent host starts a
|
|
8
|
+
small stdio adapter that discovers live bridges in the current checkout or Git
|
|
9
|
+
worktree.
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Configure an agent
|
|
12
|
+
|
|
13
|
+
The Nudge initializer can install the dependency and configure supported agent
|
|
14
|
+
hosts. A manual project-scoped configuration uses this command:
|
|
12
15
|
|
|
13
16
|
```sh
|
|
14
|
-
|
|
17
|
+
node ./node_modules/@nudge-ui/mcp/dist/cli.mjs --workspace-root /path/to/project
|
|
15
18
|
```
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
The adapter does not need an origin, port, project ID, or branch. A running
|
|
21
|
+
development integration registers those values in a private per-user runtime
|
|
22
|
+
directory. Restart the agent host after changing its MCP configuration, then
|
|
23
|
+
ask it to **Listen to Nudge**. Agent configuration contains absolute checkout
|
|
24
|
+
and application paths, so rerun agent setup in each clone or Git worktree that
|
|
25
|
+
you intend the agent to edit.
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
Run diagnostics without starting MCP stdio framing:
|
|
20
28
|
|
|
21
29
|
```sh
|
|
22
|
-
|
|
23
|
-
'npx -y @nudge-ui/mcp@0.2.0 --project-id my-app --origin http://localhost:5173 --workspace-root .' \
|
|
24
|
-
--name nudge_ui
|
|
30
|
+
node ./node_modules/@nudge-ui/mcp/dist/cli.mjs doctor --workspace-root /path/to/project
|
|
25
31
|
```
|
|
26
32
|
|
|
27
|
-
The
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
The doctor reports live and exact-workspace session counts. It never prints
|
|
34
|
+
control credentials.
|
|
35
|
+
|
|
36
|
+
## Project integration
|
|
37
|
+
|
|
38
|
+
Framework adapters start and stop the project bridge with the development
|
|
39
|
+
server:
|
|
31
40
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
For standard Vite and Astro projects, `my-app` normally matches the project
|
|
35
|
-
directory name. Next.js and standalone HTML projects use host-specific IDs, so
|
|
36
|
-
retain the explicit ID from their integration configuration.
|
|
41
|
+
```ts
|
|
42
|
+
import { startProjectBridge } from "@nudge-ui/mcp/project";
|
|
37
43
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
const runtime = await startProjectBridge({
|
|
45
|
+
workspaceRoot: process.cwd(),
|
|
46
|
+
appRoot: process.cwd(),
|
|
47
|
+
projectId: "my-app",
|
|
48
|
+
origin: "http://localhost:5173",
|
|
49
|
+
allowedOrigins: ["http://127.0.0.1:5173"],
|
|
50
|
+
});
|
|
42
51
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
// Give browser code only these non-secret values.
|
|
53
|
+
console.log(runtime.browser.bridgeUrl, runtime.browser.projectId);
|
|
54
|
+
|
|
55
|
+
await runtime.close();
|
|
56
|
+
```
|
|
46
57
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
`nudge_listen` and keep it active to enable sending. **Copy prompt** remains
|
|
52
|
-
available while the agent is idle; prompts are not queued.
|
|
58
|
+
`origin` must be the actual canonical development origin after the framework
|
|
59
|
+
chooses its port. `workspaceRoot` defaults to the Git worktree root when one is
|
|
60
|
+
available and otherwise defaults to `appRoot`. `appRoot` distinguishes several
|
|
61
|
+
applications running from one monorepo.
|
|
53
62
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
Each live bridge registers a random session ID, canonical workspace and app
|
|
64
|
+
paths, optional Git common directory and branch metadata, origin, and loopback
|
|
65
|
+
endpoint. The descriptor is mode `0600` inside a mode `0700` registry. It also
|
|
66
|
+
contains a private random credential used only between the bridge and adapter.
|
|
58
67
|
|
|
59
|
-
|
|
68
|
+
The adapter lists all live local sessions through `nudge_list_sessions`, but it
|
|
69
|
+
can listen only to sessions whose canonical workspace and configured
|
|
70
|
+
application path match. An adapter configured at the worktree root can select
|
|
71
|
+
any app in that worktree explicitly. A branch name or shared Git common
|
|
72
|
+
directory never causes fallback to another worktree. When several apps run in
|
|
73
|
+
one workspace, pass the chosen `sessionId` to `nudge_listen`. The adapter keeps
|
|
74
|
+
that app selection across a bridge restart and does not replay old requests.
|
|
75
|
+
|
|
76
|
+
One adapter owns a selected project session until it closes. Other adapters
|
|
77
|
+
receive a claimed-session error instead of taking browser work. A heartbeat
|
|
78
|
+
allows the bridge to recover a claim after an adapter process is killed. Call
|
|
79
|
+
`nudge_release` when the user stops listening so another adapter can claim the
|
|
80
|
+
session without restarting the MCP host.
|
|
81
|
+
|
|
82
|
+
## Legacy coupled mode
|
|
83
|
+
|
|
84
|
+
Passing `--origin` retains the previous behavior in which the MCP process also
|
|
85
|
+
owns its browser bridge:
|
|
60
86
|
|
|
61
87
|
```sh
|
|
62
88
|
pnpm exec nudge-mcp \
|
|
@@ -65,17 +91,9 @@ pnpm exec nudge-mcp \
|
|
|
65
91
|
--workspace-root /path/to/my-app
|
|
66
92
|
```
|
|
67
93
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
configured; it never trusts the first origin that connects. The bridge chooses
|
|
71
|
-
a deterministic loopback port from the project ID so the inspector can find
|
|
72
|
-
it without a port file. Use `--port 0` for an ephemeral test port.
|
|
73
|
-
|
|
74
|
-
For integrations that establish the browser origin out of band, trusted host
|
|
75
|
-
code can use the `pairBrowser` API as an explicit manual approval path. An
|
|
76
|
-
unconfigured bridge does not accept HTTP pairing requests.
|
|
94
|
+
This mode remains available for custom integrations during migration. New
|
|
95
|
+
framework integrations should use the project-owned bridge.
|
|
77
96
|
|
|
78
|
-
The agent
|
|
79
|
-
through
|
|
80
|
-
|
|
81
|
-
groups.
|
|
97
|
+
The agent calls `nudge_listen`, applies the delivered prompt, reports the
|
|
98
|
+
result through `nudge_report_status`, and listens again. Canvas tools remain
|
|
99
|
+
limited to routes from the paired application origin and agent-owned groups.
|
package/dist/bridge.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Server } from "node:http";
|
|
2
|
-
import type {
|
|
2
|
+
import type { AgentDeliveredPrompt, AgentRequestOutcome, AgentStatusSnapshot, AgentStatusUpdate, CanvasCommandResult, PairingResponse } from "./internal/agent-protocol.js";
|
|
3
3
|
/** A clock port keeps command timeout tests deterministic. */
|
|
4
4
|
export interface BridgeClock {
|
|
5
5
|
now(): number;
|
|
@@ -22,6 +22,8 @@ export interface BrowserBridgeOptions {
|
|
|
22
22
|
readonly tokenFactory?: () => string;
|
|
23
23
|
/** Test seam; production defaults to random UUIDs. */
|
|
24
24
|
readonly idFactory?: () => string;
|
|
25
|
+
/** Private credential for a separately running MCP adapter. */
|
|
26
|
+
readonly agentControlToken?: string;
|
|
25
27
|
/**
|
|
26
28
|
* Best-effort hook used by the CLI to reopen the last paired page when an
|
|
27
29
|
* agent rearms listening but no browser controller stream is attached.
|
|
@@ -49,7 +51,7 @@ export interface BrowserBridge {
|
|
|
49
51
|
start(): Promise<BridgeAddress>;
|
|
50
52
|
close(): Promise<void>;
|
|
51
53
|
/** Blocks until a paired browser dispatches a prompt. */
|
|
52
|
-
waitForPrompt(signal?: AbortSignal): Promise<
|
|
54
|
+
waitForPrompt(signal?: AbortSignal): Promise<AgentDeliveredPrompt>;
|
|
53
55
|
/** Marks the agent-side listener as stopped without ending the project. */
|
|
54
56
|
cancelListener(reason?: string): void;
|
|
55
57
|
/** Reports the terminal result for the currently running browser request. */
|
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,7 +304,7 @@ 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,
|
|
@@ -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;
|
|
@@ -290,7 +333,20 @@ export function createLoopbackBridge(options) {
|
|
|
290
333
|
requestedPageUrl = new URL(body.pageUrl, requestedOrigin).href;
|
|
291
334
|
}
|
|
292
335
|
if (sessionToken) {
|
|
293
|
-
|
|
336
|
+
if (body.sessionToken !== sessionToken) {
|
|
337
|
+
throw new BridgeRequestError(409, "already_paired", "A browser is already paired with this project.");
|
|
338
|
+
}
|
|
339
|
+
lastPageUrl = requestedPageUrl ?? lastPageUrl;
|
|
340
|
+
const existing = {
|
|
341
|
+
protocolVersion: AGENT_PROTOCOL_VERSION,
|
|
342
|
+
projectId: options.projectId,
|
|
343
|
+
origin: pairedOrigin,
|
|
344
|
+
sessionToken,
|
|
345
|
+
...(lastPageUrl === null ? {} : { pageUrl: lastPageUrl }),
|
|
346
|
+
status: getStatus(),
|
|
347
|
+
};
|
|
348
|
+
sendJson(response, 200, existing, incomingOrigin);
|
|
349
|
+
return;
|
|
294
350
|
}
|
|
295
351
|
sessionToken = tokenFactory();
|
|
296
352
|
if (!sessionToken || sessionToken.length > AGENT_PROTOCOL_LIMITS.sessionToken) {
|
|
@@ -303,7 +359,7 @@ export function createLoopbackBridge(options) {
|
|
|
303
359
|
const result = {
|
|
304
360
|
protocolVersion: AGENT_PROTOCOL_VERSION,
|
|
305
361
|
projectId: options.projectId,
|
|
306
|
-
origin:
|
|
362
|
+
origin: pairedOrigin,
|
|
307
363
|
sessionToken,
|
|
308
364
|
...(lastPageUrl === null ? {} : { pageUrl: lastPageUrl }),
|
|
309
365
|
status: getStatus(),
|
|
@@ -338,7 +394,7 @@ export function createLoopbackBridge(options) {
|
|
|
338
394
|
sendJson(response, 200, getStatus(), incomingOrigin);
|
|
339
395
|
};
|
|
340
396
|
const handlePrompt = async (request, response) => {
|
|
341
|
-
const body = await readJsonBody(request);
|
|
397
|
+
const body = await readJsonBody(request, MAX_PROMPT_BODY_BYTES);
|
|
342
398
|
const incomingOrigin = requireBrowserRequest(request, new URL("http://bridge.invalid"), body.sessionToken);
|
|
343
399
|
parseProjectId(typeof body.projectId === "string" ? body.projectId : null, options.projectId);
|
|
344
400
|
if (typeof body.prompt !== "string" || body.prompt.length === 0 || body.prompt.length > AGENT_PROTOCOL_LIMITS.prompt) {
|
|
@@ -348,6 +404,13 @@ export function createLoopbackBridge(options) {
|
|
|
348
404
|
&& (!Number.isSafeInteger(body.changeRevision) || body.changeRevision < 0)) {
|
|
349
405
|
throw new BridgeRequestError(400, "invalid_revision", "changeRevision must be a non-negative safe integer");
|
|
350
406
|
}
|
|
407
|
+
if (body.clientDispatchId !== undefined
|
|
408
|
+
&& (typeof body.clientDispatchId !== "string"
|
|
409
|
+
|| body.clientDispatchId.length === 0
|
|
410
|
+
|| body.clientDispatchId.length > AGENT_PROTOCOL_LIMITS.clientDispatchId)) {
|
|
411
|
+
throw new BridgeRequestError(400, "invalid_dispatch_id", "clientDispatchId must be a bounded non-empty string");
|
|
412
|
+
}
|
|
413
|
+
const attachments = parseSketchAttachments(body.attachments);
|
|
351
414
|
if (!listener) {
|
|
352
415
|
throw new BridgeRequestError(409, "listener_unavailable", "The agent has not opened a listening call.");
|
|
353
416
|
}
|
|
@@ -359,13 +422,19 @@ export function createLoopbackBridge(options) {
|
|
|
359
422
|
projectId: options.projectId,
|
|
360
423
|
prompt: body.prompt,
|
|
361
424
|
...(body.changeRevision === undefined ? {} : { changeRevision: body.changeRevision }),
|
|
425
|
+
...(body.clientDispatchId === undefined ? {} : { clientDispatchId: body.clientDispatchId }),
|
|
426
|
+
...(attachments.length === 0 ? {} : { sketches: attachments.map(({ data: _data, ...metadata }) => metadata) }),
|
|
427
|
+
};
|
|
428
|
+
const delivered = {
|
|
429
|
+
...prompt,
|
|
430
|
+
...(attachments.length === 0 ? {} : { attachments }),
|
|
362
431
|
};
|
|
363
432
|
const pending = listener;
|
|
364
433
|
listener = null;
|
|
365
434
|
if (pending.abortHandler && pending.signal)
|
|
366
435
|
pending.signal.removeEventListener("abort", pending.abortHandler);
|
|
367
436
|
currentRequest = { ...prompt, status: "working" };
|
|
368
|
-
pending.resolve(
|
|
437
|
+
pending.resolve(delivered);
|
|
369
438
|
const result = { request: prompt, status: "working" };
|
|
370
439
|
sendJson(response, 202, result, incomingOrigin);
|
|
371
440
|
broadcastStatus();
|
|
@@ -426,9 +495,160 @@ export function createLoopbackBridge(options) {
|
|
|
426
495
|
disconnectBrowser(body.sessionToken);
|
|
427
496
|
sendJson(response, 200, { disconnected: true }, incomingOrigin);
|
|
428
497
|
};
|
|
498
|
+
const requireAgentControl = (request, bodyAgentId) => {
|
|
499
|
+
if (!agentControlToken || !loopbackAddress(request.socket.remoteAddress)) {
|
|
500
|
+
throw new BridgeRequestError(403, "agent_control_unavailable", "Private agent control is unavailable.");
|
|
501
|
+
}
|
|
502
|
+
if (headerValue(request, "authorization") !== `Bearer ${agentControlToken}`) {
|
|
503
|
+
throw new BridgeRequestError(401, "invalid_agent_credential", "The agent control credential is invalid.");
|
|
504
|
+
}
|
|
505
|
+
if (bodyAgentId === undefined)
|
|
506
|
+
return null;
|
|
507
|
+
if (typeof bodyAgentId !== "string" || bodyAgentId.length === 0 || bodyAgentId.length > 256) {
|
|
508
|
+
throw new BridgeRequestError(400, "invalid_agent_id", "agentId must be a bounded non-empty string.");
|
|
509
|
+
}
|
|
510
|
+
return bodyAgentId;
|
|
511
|
+
};
|
|
512
|
+
const requireAgentOwner = (request, body) => {
|
|
513
|
+
const agentId = requireAgentControl(request, body.agentId);
|
|
514
|
+
if (!agentId || agentOwner !== agentId) {
|
|
515
|
+
throw new BridgeRequestError(409, "agent_not_owner", "This MCP adapter does not own the project session.");
|
|
516
|
+
}
|
|
517
|
+
agentOwnerSeenAt = clock.now();
|
|
518
|
+
scheduleAgentOwnerExpiry();
|
|
519
|
+
return agentId;
|
|
520
|
+
};
|
|
521
|
+
const releaseAgentOwner = (reason) => {
|
|
522
|
+
if (agentOwnerExpiry !== null)
|
|
523
|
+
clock.clearTimeout(agentOwnerExpiry);
|
|
524
|
+
agentOwnerExpiry = null;
|
|
525
|
+
agentOwner = null;
|
|
526
|
+
agentOwnerSeenAt = 0;
|
|
527
|
+
cancelListener(reason);
|
|
528
|
+
if (currentRequest?.status === "working") {
|
|
529
|
+
currentRequest = { ...currentRequest, status: "interrupted", error: reason };
|
|
530
|
+
broadcastStatus();
|
|
531
|
+
}
|
|
532
|
+
if (pendingCanvas) {
|
|
533
|
+
const pendingCommand = pendingCanvas;
|
|
534
|
+
pendingCanvas = null;
|
|
535
|
+
clock.clearTimeout(pendingCommand.timeout);
|
|
536
|
+
pendingCommand.resolve({
|
|
537
|
+
commandId: pendingCommand.command.commandId,
|
|
538
|
+
ok: false,
|
|
539
|
+
error: { code: "agent_released", message: reason },
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
const scheduleAgentOwnerExpiry = () => {
|
|
544
|
+
if (agentOwnerExpiry !== null)
|
|
545
|
+
clock.clearTimeout(agentOwnerExpiry);
|
|
546
|
+
const expectedOwner = agentOwner;
|
|
547
|
+
if (!expectedOwner) {
|
|
548
|
+
agentOwnerExpiry = null;
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const expireIfStale = () => {
|
|
552
|
+
agentOwnerExpiry = null;
|
|
553
|
+
if (agentOwner !== expectedOwner)
|
|
554
|
+
return;
|
|
555
|
+
const remaining = AGENT_CLAIM_TIMEOUT_MS - (clock.now() - agentOwnerSeenAt);
|
|
556
|
+
if (remaining > 0) {
|
|
557
|
+
agentOwnerExpiry = clock.setTimeout(expireIfStale, remaining);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
releaseAgentOwner("The MCP adapter claim expired.");
|
|
561
|
+
};
|
|
562
|
+
agentOwnerExpiry = clock.setTimeout(expireIfStale, AGENT_CLAIM_TIMEOUT_MS);
|
|
563
|
+
};
|
|
564
|
+
const handleAgentClaim = async (request, response) => {
|
|
565
|
+
const body = await readJsonBody(request);
|
|
566
|
+
const agentId = requireAgentControl(request, body.agentId);
|
|
567
|
+
if (!agentId)
|
|
568
|
+
throw new BridgeRequestError(400, "invalid_agent_id", "agentId is required.");
|
|
569
|
+
if (agentOwner && agentOwner !== agentId && clock.now() - agentOwnerSeenAt <= AGENT_CLAIM_TIMEOUT_MS) {
|
|
570
|
+
throw new BridgeRequestError(409, "session_claimed", "Another MCP adapter owns this project session.");
|
|
571
|
+
}
|
|
572
|
+
if (agentOwner && agentOwner !== agentId)
|
|
573
|
+
releaseAgentOwner("The previous MCP adapter claim expired.");
|
|
574
|
+
agentOwner = agentId;
|
|
575
|
+
agentOwnerSeenAt = clock.now();
|
|
576
|
+
scheduleAgentOwnerExpiry();
|
|
577
|
+
sendJson(response, 200, { claimed: true }, null);
|
|
578
|
+
};
|
|
579
|
+
const handleAgentRelease = async (request, response) => {
|
|
580
|
+
const body = await readJsonBody(request);
|
|
581
|
+
const agentId = requireAgentOwner(request, body);
|
|
582
|
+
releaseAgentOwner(`Agent ${agentId} released the project session.`);
|
|
583
|
+
sendJson(response, 200, { released: true }, null);
|
|
584
|
+
};
|
|
585
|
+
const handleAgentListen = async (request, response) => {
|
|
586
|
+
const body = await readJsonBody(request);
|
|
587
|
+
requireAgentOwner(request, body);
|
|
588
|
+
const abort = new AbortController();
|
|
589
|
+
response.once("close", () => abort.abort());
|
|
590
|
+
const prompt = await waitForPrompt(abort.signal);
|
|
591
|
+
sendJson(response, 200, prompt, null);
|
|
592
|
+
};
|
|
593
|
+
const handleAgentReportStatus = async (request, response) => {
|
|
594
|
+
const body = await readJsonBody(request);
|
|
595
|
+
requireAgentOwner(request, body);
|
|
596
|
+
if (!isAgentStatusUpdate(body.update)) {
|
|
597
|
+
throw new BridgeRequestError(400, "invalid_status", "update must be a valid agent status update.");
|
|
598
|
+
}
|
|
599
|
+
updateRequestStatus(body.update);
|
|
600
|
+
sendJson(response, 200, getStatus(), null);
|
|
601
|
+
};
|
|
602
|
+
const handleAgentCanvas = async (request, response) => {
|
|
603
|
+
const body = await readJsonBody(request);
|
|
604
|
+
requireAgentOwner(request, body);
|
|
605
|
+
if (!isCanvasCommand(body.command)) {
|
|
606
|
+
throw new BridgeRequestError(400, "invalid_canvas_command", "command must be a valid Canvas command.");
|
|
607
|
+
}
|
|
608
|
+
sendJson(response, 200, await dispatchCanvasCommand(body.command), null);
|
|
609
|
+
};
|
|
429
610
|
const httpServer = createServer((request, response) => {
|
|
430
611
|
void (async () => {
|
|
431
612
|
const url = new URL(request.url ?? "/", `http://${host}:${port}`);
|
|
613
|
+
if (request.method === "GET" && url.pathname === AGENT_CONTROL_ENDPOINTS.health) {
|
|
614
|
+
requireAgentControl(request);
|
|
615
|
+
sendJson(response, 200, { projectId: options.projectId, available: true, claimed: agentOwner !== null, status: getStatus() }, null);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.claim) {
|
|
619
|
+
await handleAgentClaim(request, response);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.release) {
|
|
623
|
+
await handleAgentRelease(request, response);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.heartbeat) {
|
|
627
|
+
const body = await readJsonBody(request);
|
|
628
|
+
requireAgentOwner(request, body);
|
|
629
|
+
sendJson(response, 200, { alive: true }, null);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.listen) {
|
|
633
|
+
await handleAgentListen(request, response);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (request.method === "GET" && url.pathname === AGENT_CONTROL_ENDPOINTS.status) {
|
|
637
|
+
const agentId = url.searchParams.get("agentId");
|
|
638
|
+
requireAgentControl(request, agentId);
|
|
639
|
+
if (agentOwner !== agentId)
|
|
640
|
+
throw new BridgeRequestError(409, "agent_not_owner", "This MCP adapter does not own the project session.");
|
|
641
|
+
sendJson(response, 200, getStatus(), null);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.reportStatus) {
|
|
645
|
+
await handleAgentReportStatus(request, response);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.canvas) {
|
|
649
|
+
await handleAgentCanvas(request, response);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
432
652
|
if (request.method === "OPTIONS") {
|
|
433
653
|
handleOptions(request, response);
|
|
434
654
|
return;
|
|
@@ -508,7 +728,7 @@ export function createLoopbackBridge(options) {
|
|
|
508
728
|
listener = null;
|
|
509
729
|
if (pending.abortHandler && pending.signal)
|
|
510
730
|
pending.signal.removeEventListener("abort", pending.abortHandler);
|
|
511
|
-
pending.reject(new
|
|
731
|
+
pending.reject(new BridgeRequestError(409, "listener_cancelled", reason));
|
|
512
732
|
broadcastStatus();
|
|
513
733
|
};
|
|
514
734
|
const waitForPrompt = (signal) => {
|
|
@@ -618,9 +838,6 @@ export function createLoopbackBridge(options) {
|
|
|
618
838
|
// This method is the explicit host-side approval path. A bridge without
|
|
619
839
|
// an HTTP allow-list can still be paired by trusted host code, while all
|
|
620
840
|
// 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
841
|
if (canonicalAllowedOrigins.length > 0 && !originIsAllowed(requestCanonicalOrigin)) {
|
|
625
842
|
throw new Error("Browser origin is not in the configured allow-list");
|
|
626
843
|
}
|
|
@@ -683,6 +900,11 @@ export function createLoopbackBridge(options) {
|
|
|
683
900
|
if (closePromise)
|
|
684
901
|
return closePromise;
|
|
685
902
|
lifecycle = "closed";
|
|
903
|
+
if (agentOwnerExpiry !== null)
|
|
904
|
+
clock.clearTimeout(agentOwnerExpiry);
|
|
905
|
+
agentOwnerExpiry = null;
|
|
906
|
+
agentOwner = null;
|
|
907
|
+
agentOwnerSeenAt = 0;
|
|
686
908
|
cancelListener("bridge_closed");
|
|
687
909
|
if (pendingCanvas) {
|
|
688
910
|
clock.clearTimeout(pendingCanvas.timeout);
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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
6
|
readonly workspaceRoot: string;
|
|
@@ -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> Project root supplied to the agent\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>;
|