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