@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 CHANGED
@@ -5,33 +5,61 @@
5
5
 
6
6
  `@nudge-ui/mcp` connects a local Nudge inspector to an MCP-capable coding
7
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.
8
+ small stdio adapter installed independently of any checkout. Each tool call supplies
9
+ the checkout or application being edited.
10
10
 
11
11
  ## Configure an agent
12
12
 
13
- The Nudge initializer can install the dependency and configure supported agent
14
- hosts. A manual project-scoped configuration uses this command:
13
+ Run `npx nudge-ui agent setup` from an application, or
14
+ `npm create nudge-ui@latest -- --agent-only`. Setup installs:
15
+
16
+ - A project-local `@nudge-ui/mcp` dependency for the development server's bridge.
17
+ - A versioned adapter under `~/.nudge-ui/adapters/<initializer-version>/` using npm.
18
+
19
+ Setup registers the adapter globally with an absolute Node executable and adapter
20
+ entry path. The entry contains no application path, origin, or port. npm must be
21
+ available even when the application uses pnpm, Yarn, or Bun. The adapter version
22
+ comes from the initializer; the bridge version follows the application package.
23
+
24
+ Before changing agent settings, setup starts a fresh MCP client, completes
25
+ initialization, and checks that the tools support connection-time selection.
26
+ This verifies the executable, not whether an already running agent host has
27
+ reloaded it. Fully quit and restart the host after migration.
28
+
29
+ When testing a local `@nudge-ui/mcp` tarball, pass the same artifact to setup:
15
30
 
16
31
  ```sh
17
- node ./node_modules/@nudge-ui/mcp/dist/cli.mjs --workspace-root /path/to/project
32
+ npx nudge-ui agent setup --mcp-package /absolute/path/to/nudge-ui-mcp.tgz
18
33
  ```
19
34
 
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.
35
+ This installs the local build for both the application's project bridge and the
36
+ reusable adapter. Without the override, the adapter is intentionally resolved
37
+ from the published registry and may not contain local changes.
38
+
39
+ Setup backs up existing `nudge_ui` settings, replaces the global entry, and removes
40
+ the current project's overriding entry. Other agent settings are preserved.
41
+ Run setup once in other projects that still have legacy overrides; setup does
42
+ not scan unrelated repositories. New checkouts need the bridge dependency and
43
+ framework integration, but no project-specific MCP registration.
26
44
 
27
- Run diagnostics without starting MCP stdio framing:
45
+ To configure a host manually, use the Node executable and managed adapter entry
46
+ printed by setup, with no `--workspace-root` argument. A bare `nudge-mcp` command
47
+ also works if that executable is installed independently and available on the
48
+ host's PATH.
49
+
50
+ Run diagnostics from an application:
28
51
 
29
52
  ```sh
30
- node ./node_modules/@nudge-ui/mcp/dist/cli.mjs doctor --workspace-root /path/to/project
53
+ npx nudge-ui agent doctor
31
54
  ```
32
55
 
33
- The doctor reports live and exact-workspace session counts. It never prints
34
- control credentials.
56
+ The doctor distinguishes descriptor, reachable, matching-unreachable, invalid,
57
+ and incompatible-protocol counts. Failed loopback probes do not prove that a
58
+ bridge never registered: a sandbox can block local networking. Retry diagnostics
59
+ outside the command sandbox when appropriate. Credentials are never printed.
60
+ The output includes the resolved registry path and the workspace/application
61
+ scope, even when the registry has no sessions.
62
+ The reusable MCP adapter exposes the same diagnostics through `nudge_diagnose`.
35
63
 
36
64
  ## Project integration
37
65
 
@@ -65,13 +93,51 @@ paths, optional Git common directory and branch metadata, origin, and loopback
65
93
  endpoint. The descriptor is mode `0600` inside a mode `0700` registry. It also
66
94
  contains a private random credential used only between the bridge and adapter.
67
95
 
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.
96
+ The adapter lists live local sessions through `nudge_list_sessions`. Supply
97
+ `workspaceRoot` on every tool call, including status, Canvas, and release. Use
98
+ the absolute checkout root or application directory being edited. The adapter
99
+ does not infer a task's project from its process working directory.
100
+
101
+ For example, the first discovery call is:
102
+
103
+ ```json
104
+ {
105
+ "name": "nudge_list_sessions",
106
+ "arguments": {
107
+ "workspaceRoot": "/path/to/nudge-examples"
108
+ }
109
+ }
110
+ ```
111
+
112
+ Selection is automatic only when the supplied scope contains one live session.
113
+ If several applications match, also supply the chosen `sessionId` and repeat
114
+ both arguments on subsequent calls. A workspace mismatch never falls back to
115
+ the only session elsewhere. Independent selections can run concurrently within
116
+ one MCP connection. Each selection stays bound to its application through
117
+ restarts; old requests are not replayed. An explicit stale session ID requires
118
+ rediscovery before listening again.
119
+
120
+ Existing configurations with `--workspace-root` remain restricted to that scope
121
+ until migrated. Branch names and shared Git metadata never authorize selecting
122
+ another checkout.
123
+
124
+ ## OpenCode configuration
125
+
126
+ The setup integration delegates OpenCode file handling to `add-mcp`. It
127
+ preserves the documented V2 shape, `mcp.servers.<name>`, and also preserves the
128
+ legacy `mcp.<name>` shape when that is what an existing file uses. Setup tests
129
+ both layouts and migrates only the Nudge entry.
130
+
131
+ OpenCode's CLI does not currently provide a Nudge-specific removal command. To
132
+ remove only Nudge's registration, use `add-mcp` and select the scope explicitly:
133
+
134
+ ```sh
135
+ npx add-mcp remove nudge_ui --global --agent opencode --yes
136
+ npx add-mcp remove nudge_ui --agent opencode --yes
137
+ ```
138
+
139
+ Run the first command for the global registration and the second for a project
140
+ override. These commands leave other MCP servers in place.
75
141
 
76
142
  One adapter owns a selected project session until it closes. Other adapters
77
143
  receive a claimed-session error instead of taking browser work. A heartbeat
@@ -91,8 +157,9 @@ pnpm exec nudge-mcp \
91
157
  --workspace-root /path/to/my-app
92
158
  ```
93
159
 
94
- This mode remains available for custom integrations during migration. New
95
- framework integrations should use the project-owned bridge.
160
+ This deprecated mode prints a warning and remains available for custom
161
+ integrations during migration. New framework integrations should use the
162
+ project-owned bridge.
96
163
 
97
164
  The agent calls `nudge_listen`, applies the delivered prompt, reports the
98
165
  result through `nudge_report_status`, and listens again. Canvas tools remain
@@ -0,0 +1,16 @@
1
+ import { DiscoveredProjectRouter } from "./discovery.js";
2
+ export interface ProjectTarget {
3
+ readonly workspaceRoot?: string;
4
+ readonly sessionId?: string;
5
+ }
6
+ /** Keeps each explicit project selection independent for the lifetime of an MCP connection. */
7
+ export declare class AdapterRouting {
8
+ readonly registryRoot: string;
9
+ private closing;
10
+ private readonly selections;
11
+ private readonly applications;
12
+ constructor(registryRoot?: string);
13
+ target({ workspaceRoot, sessionId }: ProjectTarget): Promise<DiscoveredProjectRouter>;
14
+ private select;
15
+ close(): Promise<void>;
16
+ }
@@ -0,0 +1,59 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { isAbsolute } from "node:path";
3
+ import { DiscoveredProjectRouter } from "./discovery.js";
4
+ import { defaultSessionRegistryRoot, resolveWorkspaceRoot } from "./project.js";
5
+ import { readLiveProjectSessions } from "./sessionRegistry.js";
6
+ import { selectProjectSession } from "./sessionSelection.js";
7
+ /** Keeps each explicit project selection independent for the lifetime of an MCP connection. */
8
+ export class AdapterRouting {
9
+ registryRoot;
10
+ closing = false;
11
+ selections = new Map();
12
+ applications = new Map();
13
+ constructor(registryRoot = defaultSessionRegistryRoot()) {
14
+ this.registryRoot = registryRoot;
15
+ }
16
+ async target({ workspaceRoot, sessionId }) {
17
+ if (!workspaceRoot || !isAbsolute(workspaceRoot)) {
18
+ throw new Error("Supply workspaceRoot as the absolute path of the checkout or application you are editing.");
19
+ }
20
+ const applicationRoot = await realpath(workspaceRoot);
21
+ if (this.closing)
22
+ throw new Error("The MCP adapter is closing.");
23
+ const key = JSON.stringify([applicationRoot, sessionId]);
24
+ let selection = this.selections.get(key);
25
+ if (!selection) {
26
+ selection = this.select(applicationRoot, sessionId);
27
+ this.selections.set(key, selection);
28
+ void selection.catch(() => {
29
+ if (this.selections.get(key) === selection)
30
+ this.selections.delete(key);
31
+ });
32
+ }
33
+ return await selection;
34
+ }
35
+ async select(applicationRoot, sessionId) {
36
+ const workspaceRoot = await resolveWorkspaceRoot(applicationRoot);
37
+ const live = await readLiveProjectSessions(this.registryRoot);
38
+ if (this.closing)
39
+ throw new Error("The MCP adapter is closing.");
40
+ const selected = selectProjectSession(live.map(({ descriptor }) => descriptor), {
41
+ workspaceRoot, applicationRoot, requestedSessionId: sessionId,
42
+ });
43
+ const key = selected.sessionId;
44
+ let router = this.applications.get(key)
45
+ ?? [...this.applications.values()].find((candidate) => candidate.selectedSessionId === selected.sessionId);
46
+ if (!router) {
47
+ router = DiscoveredProjectRouter.forSession(selected, this.registryRoot);
48
+ this.applications.set(key, router);
49
+ }
50
+ return router;
51
+ }
52
+ async close() {
53
+ this.closing = true;
54
+ await Promise.allSettled(this.selections.values());
55
+ await Promise.all([...this.applications.values()].map((router) => router.close()));
56
+ this.selections.clear();
57
+ this.applications.clear();
58
+ }
59
+ }
package/dist/bridge.js CHANGED
@@ -310,7 +310,7 @@ export function createLoopbackBridge(options) {
310
310
  pageUrl: lastPageUrl,
311
311
  }, incomingOrigin);
312
312
  };
313
- const handlePair = async (request, response) => {
313
+ const readPairRequest = async (request) => {
314
314
  if (!loopbackAddress(request.socket.remoteAddress)) {
315
315
  throw new BridgeRequestError(403, "loopback_only", "The browser bridge accepts loopback connections only.");
316
316
  }
@@ -332,6 +332,10 @@ export function createLoopbackBridge(options) {
332
332
  }
333
333
  requestedPageUrl = new URL(body.pageUrl, requestedOrigin).href;
334
334
  }
335
+ return { body, incomingOrigin, requestedPageUrl };
336
+ };
337
+ const handlePair = async (request, response) => {
338
+ const { body, incomingOrigin, requestedPageUrl } = await readPairRequest(request);
335
339
  if (sessionToken) {
336
340
  if (body.sessionToken !== sessionToken) {
337
341
  throw new BridgeRequestError(409, "already_paired", "A browser is already paired with this project.");
@@ -367,6 +371,15 @@ export function createLoopbackBridge(options) {
367
371
  sendJson(response, 200, result, incomingOrigin);
368
372
  broadcastStatus();
369
373
  };
374
+ const handleTakeover = async (request, response) => {
375
+ const { incomingOrigin, requestedPageUrl } = await readPairRequest(request);
376
+ // Takeover is intentionally separate from normal pairing. The caller has
377
+ // made an explicit UI choice to revoke the existing browser session.
378
+ if (sessionToken)
379
+ disconnectBrowser(sessionToken);
380
+ const pairing = pairBrowser(options.projectId, incomingOrigin, requestedPageUrl);
381
+ sendJson(response, 200, pairing, incomingOrigin);
382
+ };
370
383
  const handleEvents = (request, response, url) => {
371
384
  parseProjectId(url.searchParams.get("projectId"), options.projectId);
372
385
  const incomingOrigin = requireBrowserRequest(request, url);
@@ -661,6 +674,10 @@ export function createLoopbackBridge(options) {
661
674
  await handlePair(request, response);
662
675
  return;
663
676
  }
677
+ if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.takeover) {
678
+ await handleTakeover(request, response);
679
+ return;
680
+ }
664
681
  if (request.method === "GET" && url.pathname === BRIDGE_ENDPOINTS.events) {
665
682
  handleEvents(request, response, url);
666
683
  return;
package/dist/cli.d.ts CHANGED
@@ -3,7 +3,7 @@ export interface CliOptions {
3
3
  readonly command: "serve" | "doctor";
4
4
  readonly projectId: string;
5
5
  readonly origin?: string;
6
- readonly workspaceRoot: string;
6
+ readonly workspaceRoot?: string;
7
7
  readonly host: "127.0.0.1" | "::1" | "localhost";
8
8
  readonly port: number;
9
9
  readonly commandTimeoutMs: number;
@@ -32,6 +32,6 @@ export interface PageLaunchDependencies {
32
32
  export declare function openPairedPage(pageUrl: string, dependencies?: PageLaunchDependencies): Promise<boolean>;
33
33
  /** Parses only project-scoped, host-neutral CLI options. */
34
34
  export declare function parseCliArguments(argv?: readonly string[], environment?: CliEnvironment): CliOptions;
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
+ export declare const CLI_USAGE = "Usage: nudge-mcp [doctor] [options]\n\nStarts a standard MCP stdio adapter. It discovers the bridge owned by the\nrunning project unless --origin selects the legacy coupled mode.\n\nOptions:\n --project-id <id> Stable project pairing identity\n --origin <origin> Exact app origin required for browser pairing\n --workspace-root <path> Optional legacy project restriction; omitted for reusable adapters\n --host <loopback> 127.0.0.1, ::1, or localhost\n --port <port> Bridge port; default is deterministic per project, 0 is ephemeral\n --command-timeout-ms <ms> Canvas acknowledgement timeout\n --help Show this message\n";
36
36
  /** Runs the CLI until the stdio MCP connection closes or the process stops. */
37
37
  export declare function runCli(argv?: readonly string[], environment?: CliEnvironment): Promise<void>;
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { basename, resolve } from "node:path";
4
4
  import { defaultBridgePort } from "./internal/agent-protocol.js";
5
- import { discoverProjectSessions } from "./discovery.js";
5
+ import { diagnoseProjectSessions, discoverProjectSessions } from "./discovery.js";
6
6
  import { createAgentCompanion, createDiscoveredAgentAdapter, } from "./server.js";
7
7
  const DEFAULT_PORT = 0;
8
8
  const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
@@ -73,7 +73,7 @@ export function parseCliArguments(argv = process.argv.slice(2), environment = pr
73
73
  let commandTimeoutMs = environment.NUDGE_UI_COMMAND_TIMEOUT_MS === undefined
74
74
  ? DEFAULT_COMMAND_TIMEOUT_MS
75
75
  : parseInteger(environment.NUDGE_UI_COMMAND_TIMEOUT_MS, "NUDGE_UI_COMMAND_TIMEOUT_MS", 60_000);
76
- let workspaceRoot = root;
76
+ let workspaceRoot = environment.NUDGE_UI_WORKSPACE_ROOT === undefined ? undefined : root;
77
77
  let help = false;
78
78
  let explicitPort = environment.NUDGE_UI_BRIDGE_PORT !== undefined;
79
79
  for (let index = 0; index < argumentsToParse.length; index += 1) {
@@ -133,7 +133,7 @@ running project unless --origin selects the legacy coupled mode.
133
133
  Options:
134
134
  --project-id <id> Stable project pairing identity
135
135
  --origin <origin> Exact app origin required for browser pairing
136
- --workspace-root <path> Project root supplied to the agent
136
+ --workspace-root <path> Optional legacy project restriction; omitted for reusable adapters
137
137
  --host <loopback> 127.0.0.1, ::1, or localhost
138
138
  --port <port> Bridge port; default is deterministic per project, 0 is ephemeral
139
139
  --command-timeout-ms <ms> Canvas acknowledgement timeout
@@ -148,13 +148,19 @@ export async function runCli(argv = process.argv.slice(2), environment = process
148
148
  }
149
149
  if (options.command === "doctor") {
150
150
  const sessions = await discoverProjectSessions(options.workspaceRoot);
151
- const matches = sessions.filter((session) => session.matchesApplication);
151
+ const matches = sessions.filter((session) => options.workspaceRoot === undefined || session.matchesApplication);
152
+ const diagnostics = await diagnoseProjectSessions(options.workspaceRoot);
152
153
  process.stdout.write([
153
- `[nudge-ui] workspace: ${options.workspaceRoot}`,
154
+ `[nudge-ui] registry: ${diagnostics.registryRoot}`,
155
+ `[nudge-ui] workspace: ${diagnostics.workspaceRoot ?? "all projects"}`,
156
+ `[nudge-ui] application: ${diagnostics.applicationRoot ?? "all applications"}`,
157
+ `[nudge-ui] descriptors: ${diagnostics.descriptorCount}; invalid: ${diagnostics.invalidCount}; incompatible: ${diagnostics.incompatibleCount}`,
158
+ `[nudge-ui] matching unreachable descriptors: ${diagnostics.matchingUnreachableCount}`,
159
+ ...diagnostics.guidance.map((message) => `[nudge-ui] ${message}`),
154
160
  `[nudge-ui] live sessions: ${sessions.length}`,
155
161
  `[nudge-ui] matching sessions: ${matches.length}`,
156
162
  ...(matches.length === 0
157
- ? ["[nudge-ui] no matching project bridge is running; start the development server to make Nudge available"]
163
+ ? ["[nudge-ui] no reachable compatible bridge matches; inspect the diagnostics above or start the development server"]
158
164
  : matches.map((session) => {
159
165
  const state = session.status.connection === "working"
160
166
  ? "working"
@@ -195,8 +201,10 @@ export async function runCli(argv = process.argv.slice(2), environment = process
195
201
  }
196
202
  return;
197
203
  }
204
+ process.stderr.write("[nudge-ui] --origin uses deprecated coupled mode. Rerun agent setup to use project-owned bridges and avoid port collisions.\n");
198
205
  const companion = createAgentCompanion({
199
206
  ...options,
207
+ workspaceRoot: options.workspaceRoot ?? resolve(environment.INIT_CWD ?? process.cwd()),
200
208
  onControllerUnavailable: (pageUrl) => openPairedPage(pageUrl).then(() => undefined),
201
209
  });
202
210
  let stopping = false;