@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/dist/project.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
|
+
import { chmod, lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
|
|
4
|
+
import { userInfo } from "node:os";
|
|
5
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { AGENT_PROTOCOL_VERSION, canonicalOrigin } from "./internal/agent-protocol.js";
|
|
8
|
+
import { createLoopbackBridge } from "./bridge.js";
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
const REGISTRY_SCHEMA_VERSION = 1;
|
|
11
|
+
export function defaultSessionRegistryRoot() {
|
|
12
|
+
// MCP hosts commonly sanitize TMPDIR and XDG_RUNTIME_DIR for stdio child
|
|
13
|
+
// processes. The OS account home is stable across the development server
|
|
14
|
+
// and agent adapter, so both processes discover the same private registry.
|
|
15
|
+
return join(userInfo().homedir, ".cache", "nudge-ui", "sessions");
|
|
16
|
+
}
|
|
17
|
+
async function gitValue(cwd, args) {
|
|
18
|
+
try {
|
|
19
|
+
const result = await execFileAsync("git", ["-C", cwd, ...args], {
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
timeout: 2_000,
|
|
22
|
+
windowsHide: true,
|
|
23
|
+
});
|
|
24
|
+
const value = result.stdout.trim();
|
|
25
|
+
return value.length > 0 ? value : undefined;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function workspaceMetadata(appRootInput, workspaceRootInput) {
|
|
32
|
+
const appRoot = await realpath(resolve(appRootInput));
|
|
33
|
+
const workspaceRoot = await resolveWorkspaceRoot(workspaceRootInput ?? appRoot);
|
|
34
|
+
assertAppInsideWorkspace(appRoot, workspaceRoot);
|
|
35
|
+
const git = await readGitMetadata(workspaceRoot);
|
|
36
|
+
return { appRoot, workspaceRoot, ...git };
|
|
37
|
+
}
|
|
38
|
+
function assertAppInsideWorkspace(appRoot, workspaceRoot) {
|
|
39
|
+
const appRelative = relative(workspaceRoot, appRoot);
|
|
40
|
+
if (appRelative === ".." || appRelative.startsWith(`..${sep}`) || isAbsolute(appRelative)) {
|
|
41
|
+
throw new TypeError("appRoot must be inside the canonical workspace root");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function readGitMetadata(workspaceRoot) {
|
|
45
|
+
const [commonDirectory, branch] = await Promise.all([
|
|
46
|
+
gitValue(workspaceRoot, ["rev-parse", "--git-common-dir"]),
|
|
47
|
+
gitValue(workspaceRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
|
|
48
|
+
]);
|
|
49
|
+
const gitCommonDir = commonDirectory === undefined
|
|
50
|
+
? undefined
|
|
51
|
+
: await realpath(resolve(workspaceRoot, commonDirectory));
|
|
52
|
+
return { ...(gitCommonDir ? { gitCommonDir } : {}), ...(branch ? { branch } : {}) };
|
|
53
|
+
}
|
|
54
|
+
/** Resolves a checkout or worktree without conflating related worktrees. */
|
|
55
|
+
export async function resolveWorkspaceRoot(input) {
|
|
56
|
+
const canonicalInput = await realpath(resolve(input));
|
|
57
|
+
const gitTopLevel = await gitValue(canonicalInput, ["rev-parse", "--show-toplevel"]);
|
|
58
|
+
return gitTopLevel ? await realpath(gitTopLevel) : canonicalInput;
|
|
59
|
+
}
|
|
60
|
+
async function ensurePrivateRegistryDirectory(registryRoot) {
|
|
61
|
+
await mkdir(registryRoot, { recursive: true, mode: 0o700 });
|
|
62
|
+
const directory = await lstat(registryRoot);
|
|
63
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) {
|
|
64
|
+
throw new Error("The Nudge session registry must be a real directory.");
|
|
65
|
+
}
|
|
66
|
+
if (typeof process.getuid === "function" && directory.uid !== process.getuid()) {
|
|
67
|
+
throw new Error("The Nudge session registry must be owned by the current user.");
|
|
68
|
+
}
|
|
69
|
+
await chmod(registryRoot, 0o700);
|
|
70
|
+
}
|
|
71
|
+
async function writeDescriptor(registryRoot, descriptor) {
|
|
72
|
+
await ensurePrivateRegistryDirectory(registryRoot);
|
|
73
|
+
const destination = join(registryRoot, `${descriptor.sessionId}.json`);
|
|
74
|
+
const temporary = join(registryRoot, `.${descriptor.sessionId}.${process.pid}.tmp`);
|
|
75
|
+
// Publish by renaming a private file so discovery never reads a partial record.
|
|
76
|
+
const file = await open(temporary, "wx", 0o600);
|
|
77
|
+
try {
|
|
78
|
+
await file.writeFile(`${JSON.stringify(descriptor)}\n`, "utf8").finally(() => file.close());
|
|
79
|
+
await rename(temporary, destination);
|
|
80
|
+
await chmod(destination, 0o600);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
await rm(temporary, { force: true });
|
|
84
|
+
}
|
|
85
|
+
return destination;
|
|
86
|
+
}
|
|
87
|
+
async function closeProjectBridge(descriptorPath, bridge) {
|
|
88
|
+
try {
|
|
89
|
+
await rm(descriptorPath, { force: true });
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
await bridge.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Starts the project-owned browser bridge and registers it for local MCP
|
|
97
|
+
* adapters. The descriptor contains a private control credential and is never
|
|
98
|
+
* suitable for browser bundles or user-facing tool results.
|
|
99
|
+
*/
|
|
100
|
+
export async function startProjectBridge(options) {
|
|
101
|
+
const origin = canonicalOrigin(options.origin);
|
|
102
|
+
if (!origin)
|
|
103
|
+
throw new TypeError("origin must be a canonical HTTP(S) origin");
|
|
104
|
+
const metadata = await workspaceMetadata(options.appRoot, options.workspaceRoot);
|
|
105
|
+
const sessionId = randomUUID();
|
|
106
|
+
const projectId = options.projectId ?? basename(metadata.appRoot);
|
|
107
|
+
if (!projectId)
|
|
108
|
+
throw new TypeError("projectId must be a bounded non-empty string");
|
|
109
|
+
const controlToken = randomBytes(32).toString("base64url");
|
|
110
|
+
const bridge = createLoopbackBridge({
|
|
111
|
+
projectId,
|
|
112
|
+
origin,
|
|
113
|
+
allowedOrigins: options.allowedOrigins,
|
|
114
|
+
host: options.host,
|
|
115
|
+
port: options.port,
|
|
116
|
+
commandTimeoutMs: options.commandTimeoutMs,
|
|
117
|
+
agentControlToken: controlToken,
|
|
118
|
+
});
|
|
119
|
+
const address = await bridge.start();
|
|
120
|
+
const session = {
|
|
121
|
+
schemaVersion: REGISTRY_SCHEMA_VERSION,
|
|
122
|
+
protocolVersion: AGENT_PROTOCOL_VERSION,
|
|
123
|
+
sessionId,
|
|
124
|
+
projectId,
|
|
125
|
+
...metadata,
|
|
126
|
+
origin,
|
|
127
|
+
endpoint: address.url,
|
|
128
|
+
startedAt: new Date().toISOString(),
|
|
129
|
+
};
|
|
130
|
+
const registryRoot = resolve(options.registryRoot ?? defaultSessionRegistryRoot());
|
|
131
|
+
let descriptorPath;
|
|
132
|
+
try {
|
|
133
|
+
descriptorPath = await writeDescriptor(registryRoot, { ...session, pid: process.pid, controlToken });
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
await bridge.close();
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
let closePromise = null;
|
|
140
|
+
const close = () => {
|
|
141
|
+
closePromise ??= closeProjectBridge(descriptorPath, bridge);
|
|
142
|
+
return closePromise;
|
|
143
|
+
};
|
|
144
|
+
return {
|
|
145
|
+
bridge,
|
|
146
|
+
address,
|
|
147
|
+
session,
|
|
148
|
+
browser: { projectId, origin, bridgeUrl: address.url },
|
|
149
|
+
close,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AgentPromptRequest, AgentStatusSnapshot, AgentStatusUpdate, CanvasCommandResult } from "./internal/agent-protocol.js";
|
|
2
|
+
import type { CanvasCommandInput } from "./bridge.js";
|
|
3
|
+
import type { StoredProjectSession } from "./project.js";
|
|
4
|
+
export interface SessionHealth {
|
|
5
|
+
readonly projectId: string;
|
|
6
|
+
readonly available: true;
|
|
7
|
+
readonly claimed: boolean;
|
|
8
|
+
readonly status: AgentStatusSnapshot;
|
|
9
|
+
}
|
|
10
|
+
export declare function readSessionHealth(session: StoredProjectSession, timeoutMs?: number): Promise<SessionHealth>;
|
|
11
|
+
/** Owns one project's claim, heartbeat, and authenticated control requests. */
|
|
12
|
+
export declare class ProjectSessionClient {
|
|
13
|
+
readonly descriptor: StoredProjectSession;
|
|
14
|
+
readonly agentId: string;
|
|
15
|
+
private claimed;
|
|
16
|
+
private claimPromise;
|
|
17
|
+
private heartbeat;
|
|
18
|
+
private closing;
|
|
19
|
+
constructor(descriptor: StoredProjectSession, agentId: string);
|
|
20
|
+
private send;
|
|
21
|
+
private acquireClaim;
|
|
22
|
+
private claim;
|
|
23
|
+
waitForPrompt(signal?: AbortSignal): Promise<AgentPromptRequest>;
|
|
24
|
+
getStatus(): Promise<AgentStatusSnapshot>;
|
|
25
|
+
updateRequestStatus(update: AgentStatusUpdate): Promise<AgentStatusSnapshot>;
|
|
26
|
+
dispatchCanvasCommand(input: CanvasCommandInput): Promise<CanvasCommandResult>;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { AGENT_CONTROL_ENDPOINTS } from "./protocol.js";
|
|
3
|
+
const CONTROL_TIMEOUT_MS = 5_000;
|
|
4
|
+
const CANVAS_TIMEOUT_MS = 65_000;
|
|
5
|
+
async function requestJson(session, path, init = {}, { signal, timeoutMs = CONTROL_TIMEOUT_MS } = {}) {
|
|
6
|
+
const response = await fetch(`${session.endpoint}${path}`, {
|
|
7
|
+
...init,
|
|
8
|
+
redirect: "error",
|
|
9
|
+
signal: signal ?? (timeoutMs === null ? undefined : AbortSignal.timeout(timeoutMs)),
|
|
10
|
+
headers: {
|
|
11
|
+
Authorization: `Bearer ${session.controlToken}`,
|
|
12
|
+
...(init.body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
13
|
+
...init.headers,
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
const body = await response.json().catch(() => undefined);
|
|
17
|
+
if (!response.ok)
|
|
18
|
+
throw new Error(body?.error?.message ?? `Nudge project bridge returned ${response.status}.`);
|
|
19
|
+
return body;
|
|
20
|
+
}
|
|
21
|
+
export async function readSessionHealth(session, timeoutMs = CONTROL_TIMEOUT_MS) {
|
|
22
|
+
return await requestJson(session, AGENT_CONTROL_ENDPOINTS.health, {}, { timeoutMs });
|
|
23
|
+
}
|
|
24
|
+
/** Owns one project's claim, heartbeat, and authenticated control requests. */
|
|
25
|
+
export class ProjectSessionClient {
|
|
26
|
+
descriptor;
|
|
27
|
+
agentId;
|
|
28
|
+
claimed = false;
|
|
29
|
+
claimPromise = null;
|
|
30
|
+
heartbeat = null;
|
|
31
|
+
closing = false;
|
|
32
|
+
constructor(descriptor, agentId) {
|
|
33
|
+
this.descriptor = descriptor;
|
|
34
|
+
this.agentId = agentId;
|
|
35
|
+
}
|
|
36
|
+
async send(path, payload = {}, options) {
|
|
37
|
+
return await requestJson(this.descriptor, path, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
body: JSON.stringify({ agentId: this.agentId, ...payload }),
|
|
40
|
+
}, options);
|
|
41
|
+
}
|
|
42
|
+
async acquireClaim() {
|
|
43
|
+
await this.send(AGENT_CONTROL_ENDPOINTS.claim);
|
|
44
|
+
this.claimed = true;
|
|
45
|
+
this.heartbeat = setInterval(() => {
|
|
46
|
+
void this.send(AGENT_CONTROL_ENDPOINTS.heartbeat).catch(() => undefined);
|
|
47
|
+
}, 10_000);
|
|
48
|
+
this.heartbeat.unref();
|
|
49
|
+
}
|
|
50
|
+
async claim() {
|
|
51
|
+
if (this.closing)
|
|
52
|
+
throw new Error("The MCP adapter is closing.");
|
|
53
|
+
if (this.claimed)
|
|
54
|
+
return;
|
|
55
|
+
if (this.claimPromise)
|
|
56
|
+
return await this.claimPromise;
|
|
57
|
+
this.claimPromise = this.acquireClaim();
|
|
58
|
+
try {
|
|
59
|
+
await this.claimPromise;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
this.claimPromise = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async waitForPrompt(signal) {
|
|
66
|
+
await this.claim();
|
|
67
|
+
return await this.send(AGENT_CONTROL_ENDPOINTS.listen, {}, { signal, timeoutMs: null });
|
|
68
|
+
}
|
|
69
|
+
async getStatus() {
|
|
70
|
+
const health = await readSessionHealth(this.descriptor);
|
|
71
|
+
return health.status;
|
|
72
|
+
}
|
|
73
|
+
async updateRequestStatus(update) {
|
|
74
|
+
await this.claim();
|
|
75
|
+
return await this.send(AGENT_CONTROL_ENDPOINTS.reportStatus, { update });
|
|
76
|
+
}
|
|
77
|
+
async dispatchCanvasCommand(input) {
|
|
78
|
+
await this.claim();
|
|
79
|
+
const command = { ...input, commandId: input.commandId ?? randomUUID() };
|
|
80
|
+
return await this.send(AGENT_CONTROL_ENDPOINTS.canvas, { command }, { timeoutMs: CANVAS_TIMEOUT_MS });
|
|
81
|
+
}
|
|
82
|
+
async close() {
|
|
83
|
+
this.closing = true;
|
|
84
|
+
await this.claimPromise?.catch(() => undefined);
|
|
85
|
+
if (!this.claimed)
|
|
86
|
+
return;
|
|
87
|
+
this.claimed = false;
|
|
88
|
+
if (this.heartbeat)
|
|
89
|
+
clearInterval(this.heartbeat);
|
|
90
|
+
this.heartbeat = null;
|
|
91
|
+
await this.send(AGENT_CONTROL_ENDPOINTS.release).catch(() => undefined);
|
|
92
|
+
}
|
|
93
|
+
}
|
package/dist/protocol.d.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* subpath keeps transport names discoverable for Node consumers while
|
|
6
6
|
* preserving one protocol vocabulary for browser and agent adapters.
|
|
7
7
|
*/
|
|
8
|
-
export { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, CANVAS_COMMAND_TYPES, canonicalOrigin, defaultBridgePort, isAgentPromptRequest, isAgentStatusUpdate, isAllowedOrigin, isCanvasCommand, isCanvasGroup, isCanvasState, isCanonicalOrigin, isProjectIdentity, isSameOriginRoute, protocolError, validateRoutes, } from "./internal/agent-protocol.js";
|
|
9
|
-
export type { AgentConnectionState, AgentProjectIdentity, AgentPromptRequest, AgentRequestOutcome, AgentRequestStatus, AgentRoute, AgentStatusSnapshot, AgentStatusUpdate, BridgeCanvasCommandEvent, BridgeConnectedEvent, BridgeDisconnectedEvent, BridgeEnvelope, BridgeEvent, BridgeEventType, BridgeStatusEvent, CanvasCommand, CanvasCommandAcknowledgement, CanvasCommandError, CanvasCommandResult, CanvasGroup, CanvasGroupOwner, CanvasState, FitCanvasCommand, FocusCanvasGroupCommand, PairingRequest, PairingResponse, PresentRoutesCommand, PromptDispatchRequest, PromptDispatchResponse, ReadCanvasStateCommand, RemoveCanvasGroupCommand, StatusEventRequest, } from "./internal/agent-protocol.js";
|
|
8
|
+
export { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, CANVAS_COMMAND_TYPES, canonicalOrigin, defaultBridgePort, isAgentPromptRequest, isAgentSketchAttachment, isAgentSketchCaptureMetadata, isAgentSketchMetadata, isAgentStatusUpdate, isAllowedOrigin, isCanvasCommand, isCanvasGroup, isCanvasState, isCanonicalOrigin, isProjectIdentity, isSameOriginRoute, protocolError, validateSketchAttachments, validateRoutes, } from "./internal/agent-protocol.js";
|
|
9
|
+
export type { AgentConnectionState, AgentProjectIdentity, AgentPromptRequest, AgentDeliveredPrompt, AgentSketchAttachment, AgentSketchCaptureMetadata, AgentSketchMetadata, AgentRequestOutcome, AgentRequestStatus, AgentRoute, AgentStatusSnapshot, AgentStatusUpdate, BridgeCanvasCommandEvent, BridgeConnectedEvent, BridgeDisconnectedEvent, BridgeEnvelope, BridgeEvent, BridgeEventType, BridgeStatusEvent, CanvasCommand, CanvasCommandAcknowledgement, CanvasCommandError, CanvasCommandResult, CanvasGroup, CanvasGroupOwner, CanvasState, FitCanvasCommand, FocusCanvasGroupCommand, PairingRequest, PairingResponse, PresentRoutesCommand, PromptDispatchRequest, PromptDispatchResponse, ReadCanvasStateCommand, RemoveCanvasGroupCommand, StatusEventRequest, } from "./internal/agent-protocol.js";
|
|
10
10
|
/** Stable endpoint names used by the loopback browser bridge. */
|
|
11
11
|
export declare const BRIDGE_ENDPOINTS: {
|
|
12
12
|
readonly health: "/health";
|
|
@@ -17,6 +17,17 @@ export declare const BRIDGE_ENDPOINTS: {
|
|
|
17
17
|
readonly canvasAcknowledgement: "/canvas/ack";
|
|
18
18
|
readonly disconnect: "/disconnect";
|
|
19
19
|
};
|
|
20
|
+
/** Private, loopback-only endpoints used by an MCP adapter process. */
|
|
21
|
+
export declare const AGENT_CONTROL_ENDPOINTS: {
|
|
22
|
+
readonly health: "/__nudge/agent/health";
|
|
23
|
+
readonly claim: "/__nudge/agent/claim";
|
|
24
|
+
readonly heartbeat: "/__nudge/agent/heartbeat";
|
|
25
|
+
readonly release: "/__nudge/agent/release";
|
|
26
|
+
readonly listen: "/__nudge/agent/listen";
|
|
27
|
+
readonly status: "/__nudge/agent/status";
|
|
28
|
+
readonly reportStatus: "/__nudge/agent/report-status";
|
|
29
|
+
readonly canvas: "/__nudge/agent/canvas";
|
|
30
|
+
};
|
|
20
31
|
export type BridgeEndpoint = (typeof BRIDGE_ENDPOINTS)[keyof typeof BRIDGE_ENDPOINTS];
|
|
21
32
|
/** Error body returned by every rejected bridge request. */
|
|
22
33
|
export interface BridgeHttpError {
|
package/dist/protocol.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* subpath keeps transport names discoverable for Node consumers while
|
|
6
6
|
* preserving one protocol vocabulary for browser and agent adapters.
|
|
7
7
|
*/
|
|
8
|
-
export { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, CANVAS_COMMAND_TYPES, canonicalOrigin, defaultBridgePort, isAgentPromptRequest, isAgentStatusUpdate, isAllowedOrigin, isCanvasCommand, isCanvasGroup, isCanvasState, isCanonicalOrigin, isProjectIdentity, isSameOriginRoute, protocolError, validateRoutes, } from "./internal/agent-protocol.js";
|
|
8
|
+
export { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, CANVAS_COMMAND_TYPES, canonicalOrigin, defaultBridgePort, isAgentPromptRequest, isAgentSketchAttachment, isAgentSketchCaptureMetadata, isAgentSketchMetadata, isAgentStatusUpdate, isAllowedOrigin, isCanvasCommand, isCanvasGroup, isCanvasState, isCanonicalOrigin, isProjectIdentity, isSameOriginRoute, protocolError, validateSketchAttachments, validateRoutes, } from "./internal/agent-protocol.js";
|
|
9
9
|
/** Stable endpoint names used by the loopback browser bridge. */
|
|
10
10
|
export const BRIDGE_ENDPOINTS = {
|
|
11
11
|
health: "/health",
|
|
@@ -16,3 +16,14 @@ export const BRIDGE_ENDPOINTS = {
|
|
|
16
16
|
canvasAcknowledgement: "/canvas/ack",
|
|
17
17
|
disconnect: "/disconnect",
|
|
18
18
|
};
|
|
19
|
+
/** Private, loopback-only endpoints used by an MCP adapter process. */
|
|
20
|
+
export const AGENT_CONTROL_ENDPOINTS = {
|
|
21
|
+
health: "/__nudge/agent/health",
|
|
22
|
+
claim: "/__nudge/agent/claim",
|
|
23
|
+
heartbeat: "/__nudge/agent/heartbeat",
|
|
24
|
+
release: "/__nudge/agent/release",
|
|
25
|
+
listen: "/__nudge/agent/listen",
|
|
26
|
+
status: "/__nudge/agent/status",
|
|
27
|
+
reportStatus: "/__nudge/agent/report-status",
|
|
28
|
+
canvas: "/__nudge/agent/canvas",
|
|
29
|
+
};
|
package/dist/server.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
3
3
|
import type { AgentProjectIdentity } from "./internal/agent-protocol.js";
|
|
4
4
|
import { type BrowserBridge, type BrowserBridgeOptions, type BridgeAddress } from "./bridge.js";
|
|
5
|
+
import { DiscoveredProjectRouter } from "./discovery.js";
|
|
5
6
|
export declare const MCP_SERVER_NAME = "nudge-ui";
|
|
6
7
|
export declare const MCP_SERVER_VERSION: string;
|
|
7
8
|
/** Instructions are sent through MCP initialization for every host. */
|
|
@@ -26,3 +27,16 @@ export interface AgentCompanion {
|
|
|
26
27
|
* The returned companion is inert until `start` is called.
|
|
27
28
|
*/
|
|
28
29
|
export declare function createAgentCompanion(options: AgentCompanionOptions): AgentCompanion;
|
|
30
|
+
export interface DiscoveredAgentAdapter {
|
|
31
|
+
readonly mcpServer: McpServer;
|
|
32
|
+
readonly router: DiscoveredProjectRouter;
|
|
33
|
+
start(transport?: Transport): Promise<void>;
|
|
34
|
+
close(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
/** Creates a stdio MCP adapter that discovers project-owned bridges. */
|
|
37
|
+
export declare function createDiscoveredAgentAdapter(options: {
|
|
38
|
+
readonly workspaceRoot: string;
|
|
39
|
+
readonly registryRoot?: string;
|
|
40
|
+
readonly serverName?: string;
|
|
41
|
+
readonly serverVersion?: string;
|
|
42
|
+
}): Promise<DiscoveredAgentAdapter>;
|
package/dist/server.js
CHANGED
|
@@ -2,8 +2,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { validateRoutes } from "./internal/agent-protocol.js";
|
|
6
5
|
import { createLoopbackBridge, } from "./bridge.js";
|
|
6
|
+
import { DiscoveredProjectRouter } from "./discovery.js";
|
|
7
7
|
export const MCP_SERVER_NAME = "nudge-ui";
|
|
8
8
|
export const MCP_SERVER_VERSION = readPackageVersion();
|
|
9
9
|
function readPackageVersion() {
|
|
@@ -18,11 +18,13 @@ function readPackageVersion() {
|
|
|
18
18
|
}
|
|
19
19
|
/** Instructions are sent through MCP initialization for every host. */
|
|
20
20
|
export const MCP_SERVER_INSTRUCTIONS = [
|
|
21
|
-
"Nudge UI is a local browser companion for one project workspace.",
|
|
22
|
-
"
|
|
23
|
-
"
|
|
21
|
+
"Nudge UI is a local browser companion for one exact project workspace or Git worktree.",
|
|
22
|
+
"Before listening, compare each session's workspaceRoot and appRoot with the checkout and application you will edit; if they differ, rerun Nudge agent setup in the intended checkout.",
|
|
23
|
+
"When the user asks to listen to Nudge, call nudge_list_sessions when that tool is available; if several apps are running, select only a session marked matchesApplication.",
|
|
24
|
+
"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
25
|
"After a prompt is delivered, edit the project source using the host's normal approval flow.",
|
|
25
26
|
"Report completed, failed, or interrupted status with nudge_report_status before listening again.",
|
|
27
|
+
"When the user asks to stop listening, call nudge_release when that tool is available so another agent can claim the project session.",
|
|
26
28
|
"Canvas tools operate only on same-origin routes from the paired project.",
|
|
27
29
|
"No request, prompt, or browser state is persisted by this companion.",
|
|
28
30
|
].join(" ");
|
|
@@ -39,12 +41,21 @@ function errorResult(error) {
|
|
|
39
41
|
};
|
|
40
42
|
}
|
|
41
43
|
function promptResult(request) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
const content = [{
|
|
45
|
+
type: "text",
|
|
46
|
+
text: JSON.stringify({
|
|
47
|
+
requestId: request.requestId,
|
|
48
|
+
projectId: request.projectId,
|
|
49
|
+
prompt: request.prompt,
|
|
50
|
+
...(request.changeRevision === undefined ? {} : { changeRevision: request.changeRevision }),
|
|
51
|
+
...(request.clientDispatchId === undefined ? {} : { clientDispatchId: request.clientDispatchId }),
|
|
52
|
+
...(request.sketches === undefined ? {} : { sketches: request.sketches }),
|
|
53
|
+
}),
|
|
54
|
+
}];
|
|
55
|
+
for (const attachment of request.attachments ?? []) {
|
|
56
|
+
content.push({ type: "text", text: `Sketch image: ${attachment.filename}` }, { type: "image", data: attachment.data, mimeType: attachment.mimeType });
|
|
57
|
+
}
|
|
58
|
+
return { content };
|
|
48
59
|
}
|
|
49
60
|
function commandResult(result) {
|
|
50
61
|
return {
|
|
@@ -60,26 +71,27 @@ function boundedWorkspaceRoot(value) {
|
|
|
60
71
|
return value;
|
|
61
72
|
}
|
|
62
73
|
function installTools(mcpServer, bridge) {
|
|
63
|
-
const listen = async (
|
|
74
|
+
const listen = async (args, extra) => {
|
|
64
75
|
try {
|
|
65
|
-
return promptResult(await bridge.waitForPrompt(extra.signal));
|
|
76
|
+
return promptResult(await bridge.waitForPrompt(extra.signal, args.sessionId));
|
|
66
77
|
}
|
|
67
78
|
catch (error) {
|
|
68
79
|
return errorResult(error);
|
|
69
80
|
}
|
|
70
81
|
};
|
|
71
|
-
const listenDescription = "
|
|
82
|
+
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.";
|
|
72
83
|
const emptyInput = {};
|
|
84
|
+
const listenInput = bridge.listSessions ? { sessionId: z.string().uuid().optional() } : emptyInput;
|
|
73
85
|
mcpServer.registerTool("nudge_listen", {
|
|
74
86
|
title: "Listen for Nudge requests",
|
|
75
87
|
description: listenDescription,
|
|
76
|
-
inputSchema:
|
|
88
|
+
inputSchema: listenInput,
|
|
77
89
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
78
90
|
}, listen);
|
|
79
91
|
mcpServer.registerTool("nudge_connect", {
|
|
80
92
|
title: "Connect to Nudge",
|
|
81
93
|
description: "Open the long-lived Nudge browser connection and wait for one prompt.",
|
|
82
|
-
inputSchema:
|
|
94
|
+
inputSchema: listenInput,
|
|
83
95
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
84
96
|
}, listen);
|
|
85
97
|
mcpServer.registerTool("nudge_get_status", {
|
|
@@ -87,7 +99,31 @@ function installTools(mcpServer, bridge) {
|
|
|
87
99
|
description: "Read the in-memory browser pairing and request status for this project.",
|
|
88
100
|
inputSchema: emptyInput,
|
|
89
101
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
90
|
-
}, async () => textResult(bridge.getStatus()));
|
|
102
|
+
}, async () => textResult(await bridge.getStatus()));
|
|
103
|
+
if (bridge.listSessions) {
|
|
104
|
+
mcpServer.registerTool("nudge_list_sessions", {
|
|
105
|
+
title: "List Nudge project sessions",
|
|
106
|
+
description: "List live local Nudge sessions. Only sessions marked matchesApplication can be selected by an application-scoped adapter.",
|
|
107
|
+
inputSchema: emptyInput,
|
|
108
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
109
|
+
}, async () => textResult(await bridge.listSessions()));
|
|
110
|
+
}
|
|
111
|
+
if (bridge.release) {
|
|
112
|
+
mcpServer.registerTool("nudge_release", {
|
|
113
|
+
title: "Release Nudge session",
|
|
114
|
+
description: "Stop listening and release this adapter's project-session claim so another agent can connect.",
|
|
115
|
+
inputSchema: emptyInput,
|
|
116
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
117
|
+
}, async () => {
|
|
118
|
+
try {
|
|
119
|
+
await bridge.release();
|
|
120
|
+
return textResult({ released: true });
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
return errorResult(error);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
91
127
|
mcpServer.registerTool("nudge_report_status", {
|
|
92
128
|
title: "Report Nudge request status",
|
|
93
129
|
description: "Report working, completed, failed, or interrupted status for the prompt currently being handled.",
|
|
@@ -106,8 +142,8 @@ function installTools(mcpServer, bridge) {
|
|
|
106
142
|
...(args.error === undefined ? {} : { error: args.error }),
|
|
107
143
|
};
|
|
108
144
|
try {
|
|
109
|
-
bridge.updateRequestStatus(update);
|
|
110
|
-
return textResult(bridge.getStatus());
|
|
145
|
+
const updated = await bridge.updateRequestStatus(update);
|
|
146
|
+
return textResult(updated ?? await bridge.getStatus());
|
|
111
147
|
}
|
|
112
148
|
catch (error) {
|
|
113
149
|
return errorResult(error);
|
|
@@ -141,14 +177,11 @@ function installTools(mcpServer, bridge) {
|
|
|
141
177
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
142
178
|
}, async (args) => {
|
|
143
179
|
try {
|
|
144
|
-
if (!bridge.effectiveOrigin)
|
|
145
|
-
throw new Error("Canvas routes require a paired or configured browser origin.");
|
|
146
|
-
const routes = validateRoutes(args.routes, bridge.effectiveOrigin);
|
|
147
180
|
return commandResult(await bridge.dispatchCanvasCommand({
|
|
148
181
|
type: "present-routes",
|
|
149
182
|
groupId: args.groupId ?? `agent-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
|
|
150
183
|
label: args.label,
|
|
151
|
-
routes,
|
|
184
|
+
routes: args.routes,
|
|
152
185
|
}));
|
|
153
186
|
}
|
|
154
187
|
catch (error) {
|
|
@@ -252,3 +285,38 @@ export function createAgentCompanion(options) {
|
|
|
252
285
|
close,
|
|
253
286
|
};
|
|
254
287
|
}
|
|
288
|
+
/** Creates a stdio MCP adapter that discovers project-owned bridges. */
|
|
289
|
+
export async function createDiscoveredAgentAdapter(options) {
|
|
290
|
+
const router = await DiscoveredProjectRouter.create(options.workspaceRoot, options.registryRoot);
|
|
291
|
+
const mcpServer = new McpServer({
|
|
292
|
+
name: options.serverName ?? MCP_SERVER_NAME,
|
|
293
|
+
version: options.serverVersion ?? MCP_SERVER_VERSION,
|
|
294
|
+
}, { instructions: MCP_SERVER_INSTRUCTIONS });
|
|
295
|
+
installTools(mcpServer, router);
|
|
296
|
+
let transport = null;
|
|
297
|
+
let started = false;
|
|
298
|
+
let closePromise = null;
|
|
299
|
+
return {
|
|
300
|
+
mcpServer,
|
|
301
|
+
router,
|
|
302
|
+
async start(providedTransport) {
|
|
303
|
+
if (started)
|
|
304
|
+
return;
|
|
305
|
+
if (closePromise)
|
|
306
|
+
throw new Error("The Nudge MCP adapter is closing.");
|
|
307
|
+
started = true;
|
|
308
|
+
transport = providedTransport ?? new StdioServerTransport();
|
|
309
|
+
await mcpServer.connect(transport);
|
|
310
|
+
},
|
|
311
|
+
close() {
|
|
312
|
+
if (closePromise)
|
|
313
|
+
return closePromise;
|
|
314
|
+
closePromise = (async () => {
|
|
315
|
+
await router.close();
|
|
316
|
+
if (transport)
|
|
317
|
+
await mcpServer.close().catch(() => undefined);
|
|
318
|
+
})();
|
|
319
|
+
return closePromise;
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
/** Reads private registry records and keeps only bridges that answer authenticated probes. */
|
|
8
|
+
export declare function readLiveProjectSessions(registryRoot: string): Promise<LiveProjectSession[]>;
|
|
@@ -0,0 +1,89 @@
|
|
|
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 { readSessionHealth } from "./projectSessionClient.js";
|
|
5
|
+
const PROBE_TIMEOUT_MS = 750;
|
|
6
|
+
const MAX_DESCRIPTOR_BYTES = 32 * 1024;
|
|
7
|
+
/** Reads private registry records and keeps only bridges that answer authenticated probes. */
|
|
8
|
+
export async function readLiveProjectSessions(registryRoot) {
|
|
9
|
+
const names = await readRegistryDirectory(registryRoot);
|
|
10
|
+
const descriptors = await Promise.all(names.filter((name) => name.endsWith(".json")).map((name) => readDescriptor(resolve(registryRoot, name))));
|
|
11
|
+
const live = await Promise.all(descriptors.filter(isPresent).map(probeSession));
|
|
12
|
+
return live.filter(isPresent);
|
|
13
|
+
}
|
|
14
|
+
async function readRegistryDirectory(registryRoot) {
|
|
15
|
+
try {
|
|
16
|
+
return await readdir(registryRoot);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code === "ENOENT")
|
|
20
|
+
return [];
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function readDescriptor(path) {
|
|
25
|
+
try {
|
|
26
|
+
const metadata = await lstat(path);
|
|
27
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_DESCRIPTOR_BYTES)
|
|
28
|
+
return undefined;
|
|
29
|
+
if (typeof process.getuid === "function" && metadata.uid !== process.getuid())
|
|
30
|
+
return undefined;
|
|
31
|
+
if ((metadata.mode & 0o077) !== 0)
|
|
32
|
+
return undefined;
|
|
33
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
34
|
+
if (!isStoredSession(parsed))
|
|
35
|
+
return undefined;
|
|
36
|
+
const [workspaceRoot, appRoot] = await Promise.all([realpath(parsed.workspaceRoot), realpath(parsed.appRoot)]);
|
|
37
|
+
return { ...parsed, workspaceRoot, appRoot };
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Records can disappear during shutdown or refer to a removed worktree.
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function probeSession(descriptor) {
|
|
45
|
+
try {
|
|
46
|
+
const health = await readSessionHealth(descriptor, PROBE_TIMEOUT_MS);
|
|
47
|
+
if (health.projectId !== descriptor.projectId || health.available !== true)
|
|
48
|
+
return undefined;
|
|
49
|
+
return { descriptor, health };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// A stale or unreachable bridge is not a selectable session.
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function isPresent(value) {
|
|
57
|
+
return value !== undefined;
|
|
58
|
+
}
|
|
59
|
+
function isLoopbackEndpoint(value) {
|
|
60
|
+
if (typeof value !== "string")
|
|
61
|
+
return false;
|
|
62
|
+
try {
|
|
63
|
+
const url = new URL(value);
|
|
64
|
+
return url.protocol === "http:"
|
|
65
|
+
&& (url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1" || url.hostname === "localhost")
|
|
66
|
+
&& url.pathname === "/" && !url.username && !url.password && !url.search && !url.hash;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function isStoredSession(value) {
|
|
73
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
74
|
+
return false;
|
|
75
|
+
const session = value;
|
|
76
|
+
return session.schemaVersion === 1
|
|
77
|
+
&& session.protocolVersion === AGENT_PROTOCOL_VERSION
|
|
78
|
+
&& typeof session.sessionId === "string" && session.sessionId.length > 0
|
|
79
|
+
&& typeof session.projectId === "string" && session.projectId.length > 0
|
|
80
|
+
&& typeof session.workspaceRoot === "string" && session.workspaceRoot.length > 0
|
|
81
|
+
&& typeof session.appRoot === "string" && session.appRoot.length > 0
|
|
82
|
+
&& (session.gitCommonDir === undefined || typeof session.gitCommonDir === "string")
|
|
83
|
+
&& (session.branch === undefined || typeof session.branch === "string")
|
|
84
|
+
&& typeof session.origin === "string"
|
|
85
|
+
&& isLoopbackEndpoint(session.endpoint)
|
|
86
|
+
&& typeof session.startedAt === "string"
|
|
87
|
+
&& typeof session.pid === "number" && Number.isSafeInteger(session.pid)
|
|
88
|
+
&& typeof session.controlToken === "string" && session.controlToken.length >= 32 && session.controlToken.length <= 256;
|
|
89
|
+
}
|
|
@@ -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 {};
|