@llui/mcp 0.0.15 → 0.0.17

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
@@ -8,14 +8,72 @@ pnpm add -D @llui/mcp
8
8
 
9
9
  ## Usage
10
10
 
11
- The MCP server auto-connects to running LLui apps via the vite-plugin's `mcpPort` bridge (default port 5200). No manual setup needed -- just enable the plugin and point your MCP client at the server.
11
+ The MCP server has two transports and two usage patterns.
12
+
13
+ ### Plugin-launched (recommended): one-terminal dev
14
+
15
+ Install `@llui/mcp` as a dev dependency. The Vite plugin auto-detects the package and spawns `llui-mcp --http` as a child of the dev server. One `pnpm dev` starts everything; no second terminal, no stdio fuss.
12
16
 
13
17
  ```ts
14
- // vite.config.ts -- MCP is enabled by default
18
+ // vite.config.ts
15
19
  import llui from '@llui/vite-plugin'
20
+ export default defineConfig({ plugins: [llui()] })
21
+ ```
22
+
23
+ Point your MCP client (e.g. Claude Code) at the HTTP endpoint. In `.mcp.json`:
24
+
25
+ ```json
26
+ {
27
+ "mcpServers": {
28
+ "llui": {
29
+ "type": "http",
30
+ "url": "http://127.0.0.1:5200/mcp"
31
+ }
32
+ }
33
+ }
34
+ ```
35
+
36
+ The MCP protocol runs on `POST /mcp`; the browser-relay WebSocket bridge shares the same port via upgrade on `/bridge`.
37
+
38
+ ### Stdio (manual spawn): traditional MCP client
39
+
40
+ If your MCP client spawns servers over stdio (the older pattern), run the CLI without `--http`:
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "llui": {
46
+ "command": "npx",
47
+ "args": ["llui-mcp"]
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ The server talks stdio to the client and stands up its own WebSocket bridge on port 5200 for the browser relay. With this pattern, set `mcpPort: 5200` explicitly in the Vite plugin so it wires to the externally-managed server instead of spawning its own:
54
+
55
+ ```ts
16
56
  export default defineConfig({ plugins: [llui({ mcpPort: 5200 })] })
17
57
  ```
18
58
 
59
+ ### Troubleshooting: `llui-mcp doctor`
60
+
61
+ If a tool call returns a `bridge-unavailable` error or Claude simply can't talk to a running app, run the doctor to see what's wrong:
62
+
63
+ ```bash
64
+ npx llui-mcp doctor
65
+ ```
66
+
67
+ It checks, in order:
68
+
69
+ - Is the active-marker file at `node_modules/.cache/llui-mcp/active.json` present?
70
+ - Is the marker JSON parseable?
71
+ - Has the Vite plugin stamped its `devUrl` into the marker?
72
+ - Is the bridge port listening on 127.0.0.1?
73
+ - Is the PID recorded in the marker still alive?
74
+
75
+ Each check prints `✓` or `✗` with a one-line detail. Exit code is 0 when everything passes, 1 when any check fails.
76
+
19
77
  ## Tools
20
78
 
21
79
  ### State Inspection
package/dist/cli.js CHANGED
@@ -1,15 +1,216 @@
1
1
  #!/usr/bin/env node
2
- import { LluiMcpServer } from './index.js';
3
- const port = Number(process.env.LLUI_MCP_PORT ?? 5200);
4
- const server = new LluiMcpServer(port);
5
- server.startBridge();
6
- server.start();
7
- process.stderr.write(`[llui-mcp] listening on stdio; bridge ws://127.0.0.1:${port}\n`);
8
- // Clean up the active marker file on graceful shutdown
9
- const cleanup = () => {
10
- server.stopBridge();
11
- process.exit(0);
12
- };
13
- process.on('SIGINT', cleanup);
14
- process.on('SIGTERM', cleanup);
2
+ import { createServer } from 'node:http';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
7
+ import { LluiMcpServer, mcpActiveFilePath } from './index.js';
8
+ /**
9
+ * Parse `--http [port]` from argv. Returns:
10
+ * - null → stdio mode (default)
11
+ * - number → HTTP mode on that port
12
+ */
13
+ function parseHttpFlag(argv) {
14
+ const idx = argv.indexOf('--http');
15
+ if (idx < 0)
16
+ return null;
17
+ const next = argv[idx + 1];
18
+ if (next && !next.startsWith('-') && /^\d+$/.test(next)) {
19
+ return Number(next);
20
+ }
21
+ return Number(process.env.LLUI_MCP_PORT ?? 5200);
22
+ }
23
+ const bridgePort = Number(process.env.LLUI_MCP_PORT ?? 5200);
24
+ const args = process.argv.slice(2);
25
+ const httpPort = parseHttpFlag(args);
26
+ if (args[0] === 'doctor') {
27
+ doctor(bridgePort).then((ok) => process.exit(ok ? 0 : 1), (err) => {
28
+ process.stderr.write(`[llui-mcp doctor] fatal: ${String(err)}\n`);
29
+ process.exit(2);
30
+ });
31
+ }
32
+ else {
33
+ main().catch((err) => {
34
+ process.stderr.write(`[llui-mcp] fatal: ${String(err)}\n`);
35
+ process.exit(1);
36
+ });
37
+ }
38
+ async function main() {
39
+ if (httpPort === null) {
40
+ // Stdio mode — Claude's `.mcp.json` spawns llui-mcp and talks over
41
+ // stdin/stdout. The bridge runs on its own WebSocket server on
42
+ // `bridgePort`.
43
+ const server = new LluiMcpServer(bridgePort);
44
+ server.startBridge();
45
+ const transport = new StdioServerTransport();
46
+ await server.connect(transport);
47
+ process.stderr.write(`[llui-mcp] listening on stdio; bridge ws://127.0.0.1:${bridgePort}\n`);
48
+ const shutdown = () => {
49
+ server.stopBridge();
50
+ process.exit(0);
51
+ };
52
+ process.on('SIGINT', shutdown);
53
+ process.on('SIGTERM', shutdown);
54
+ return;
55
+ }
56
+ // HTTP mode — plugin-spawned. One `http.Server` serves both the MCP
57
+ // Streamable HTTP transport (`/mcp`) and the browser bridge WebSocket
58
+ // (upgrade on `/bridge`). `.mcp.json` uses type: "http" with url
59
+ // `http://127.0.0.1:<port>/mcp`.
60
+ const mcpTransports = new Map();
61
+ const httpServer = createServer((req, res) => {
62
+ handleHttp(req, res).catch((err) => {
63
+ res.statusCode = 500;
64
+ res.setHeader('content-type', 'application/json');
65
+ res.end(JSON.stringify({ error: String(err) }));
66
+ });
67
+ });
68
+ // Single bridge host: owns the WS relay, tool registry, and marker
69
+ // file. All MCP sessions route tool calls through its relay via
70
+ // `createSessionMcp()` — ensures the browser-connected state is
71
+ // shared instead of each session creating its own dead relay.
72
+ const bridgeHost = new LluiMcpServer({ bridgePort: httpPort, attachTo: httpServer });
73
+ bridgeHost.startBridge();
74
+ httpServer.listen(httpPort, '127.0.0.1', () => {
75
+ process.stderr.write(`[llui-mcp] HTTP transport on http://127.0.0.1:${httpPort}/mcp; bridge ws://127.0.0.1:${httpPort}/bridge\n`);
76
+ });
77
+ const shutdown = async () => {
78
+ bridgeHost.stopBridge();
79
+ for (const t of mcpTransports.values())
80
+ await t.close();
81
+ mcpTransports.clear();
82
+ httpServer.close();
83
+ process.exit(0);
84
+ };
85
+ process.on('SIGINT', () => {
86
+ shutdown().catch(() => process.exit(1));
87
+ });
88
+ process.on('SIGTERM', () => {
89
+ shutdown().catch(() => process.exit(1));
90
+ });
91
+ async function handleHttp(req, res) {
92
+ const url = req.url ?? '/';
93
+ if (!url.startsWith('/mcp')) {
94
+ res.statusCode = 404;
95
+ res.end('not found');
96
+ return;
97
+ }
98
+ // Session routing: the SDK's StreamableHTTPServerTransport is
99
+ // stateful. The first request (initialize) creates a session id
100
+ // returned in the `mcp-session-id` response header; subsequent
101
+ // requests carry it as the `mcp-session-id` header.
102
+ const sessionHeader = req.headers['mcp-session-id'];
103
+ const sessionId = typeof sessionHeader === 'string' ? sessionHeader : undefined;
104
+ let transport = sessionId ? mcpTransports.get(sessionId) : undefined;
105
+ if (!transport) {
106
+ // New session. SDK requires one `McpServer` per transport, but
107
+ // all sessions must share the single browser bridge — route
108
+ // through `createSessionMcp()` so the session's tool dispatch
109
+ // lands on bridgeHost's registry + relay.
110
+ transport = new StreamableHTTPServerTransport({
111
+ sessionIdGenerator: () => randomUUID(),
112
+ onsessioninitialized: (id) => {
113
+ mcpTransports.set(id, transport);
114
+ },
115
+ });
116
+ transport.onclose = () => {
117
+ const id = transport.sessionId;
118
+ if (id)
119
+ mcpTransports.delete(id);
120
+ };
121
+ const sessionMcp = bridgeHost.createSessionMcp();
122
+ await sessionMcp.connect(transport);
123
+ }
124
+ await transport.handleRequest(req, res);
125
+ }
126
+ }
127
+ async function doctor(port) {
128
+ // Offline checks only — doctor doesn't require the server to be
129
+ // running. Walks the same states the RelayUnavailableError diagnostic
130
+ // surfaces at runtime, plus a port-liveness probe.
131
+ //
132
+ // Glyphs: emoji ✓/✗ by default, fall back to `OK`/`FAIL` when the
133
+ // environment requests plain output. Honors `--plain` and the
134
+ // standard `NO_COLOR` env var (https://no-color.org).
135
+ const plain = args.includes('--plain') || process.env.NO_COLOR !== undefined;
136
+ const ok = plain ? 'OK ' : '✓';
137
+ const fail = plain ? 'FAIL' : '✗';
138
+ const markerPath = mcpActiveFilePath();
139
+ const checks = [];
140
+ checks.push({
141
+ name: 'marker file',
142
+ ok: existsSync(markerPath),
143
+ detail: markerPath,
144
+ });
145
+ let markerPayload = null;
146
+ if (existsSync(markerPath)) {
147
+ try {
148
+ markerPayload = JSON.parse(readFileSync(markerPath, 'utf8'));
149
+ }
150
+ catch {
151
+ markerPayload = null;
152
+ }
153
+ checks.push({
154
+ name: 'marker valid JSON',
155
+ ok: markerPayload !== null,
156
+ detail: markerPayload !== null ? 'OK' : 'malformed — delete and restart MCP',
157
+ });
158
+ checks.push({
159
+ name: 'plugin devUrl stamped',
160
+ ok: typeof markerPayload?.devUrl === 'string',
161
+ detail: typeof markerPayload?.devUrl === 'string'
162
+ ? markerPayload.devUrl
163
+ : 'vite-plugin has not stamped its dev URL',
164
+ });
165
+ }
166
+ const targetPort = markerPayload?.port ?? port;
167
+ const reachable = await probePort(targetPort);
168
+ checks.push({
169
+ name: `bridge port ${targetPort} listening`,
170
+ ok: reachable,
171
+ detail: reachable ? '127.0.0.1 connectable' : 'no process bound; MCP server not running',
172
+ });
173
+ if (typeof markerPayload?.pid === 'number') {
174
+ const alive = isPidAlive(markerPayload.pid);
175
+ checks.push({
176
+ name: `marker pid ${markerPayload.pid}`,
177
+ ok: alive,
178
+ detail: alive ? 'process alive' : 'stale — delete the marker',
179
+ });
180
+ }
181
+ let allOk = true;
182
+ process.stdout.write('llui-mcp doctor\n');
183
+ process.stdout.write('—\n');
184
+ for (const c of checks) {
185
+ allOk = allOk && c.ok;
186
+ process.stdout.write(`${c.ok ? ok : fail} ${c.name.padEnd(32)} ${c.detail}\n`);
187
+ }
188
+ process.stdout.write('—\n');
189
+ process.stdout.write(allOk ? 'All checks passed.\n' : 'Some checks failed — see above.\n');
190
+ return allOk;
191
+ }
192
+ async function probePort(port) {
193
+ const { Socket } = await import('node:net');
194
+ return new Promise((resolve) => {
195
+ const sock = new Socket();
196
+ const done = (ok) => {
197
+ sock.destroy();
198
+ resolve(ok);
199
+ };
200
+ sock.setTimeout(500);
201
+ sock.on('connect', () => done(true));
202
+ sock.on('error', () => done(false));
203
+ sock.on('timeout', () => done(false));
204
+ sock.connect(port, '127.0.0.1');
205
+ });
206
+ }
207
+ function isPidAlive(pid) {
208
+ try {
209
+ process.kill(pid, 0);
210
+ return true;
211
+ }
212
+ catch {
213
+ return false;
214
+ }
215
+ }
15
216
  //# sourceMappingURL=cli.js.map
package/dist/cli.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAE1C,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,IAAI,CAAC,CAAA;AACtD,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,IAAI,CAAC,CAAA;AACtC,MAAM,CAAC,WAAW,EAAE,CAAA;AACpB,MAAM,CAAC,KAAK,EAAE,CAAA;AACd,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,wDAAwD,IAAI,IAAI,CAAC,CAAA;AAEtF,uDAAuD;AACvD,MAAM,OAAO,GAAG,GAAS,EAAE;IACzB,MAAM,CAAC,UAAU,EAAE,CAAA;IACnB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC,CAAA;AACD,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAA;AAC7B,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,CAAC,CAAA","sourcesContent":["#!/usr/bin/env node\nimport { LluiMcpServer } from './index.js'\n\nconst port = Number(process.env.LLUI_MCP_PORT ?? 5200)\nconst server = new LluiMcpServer(port)\nserver.startBridge()\nserver.start()\nprocess.stderr.write(`[llui-mcp] listening on stdio; bridge ws://127.0.0.1:${port}\\n`)\n\n// Clean up the active marker file on graceful shutdown\nconst cleanup = (): void => {\n server.stopBridge()\n process.exit(0)\n}\nprocess.on('SIGINT', cleanup)\nprocess.on('SIGTERM', cleanup)\n"]}
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAA;AAExC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAClD,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAA;AAChF,OAAO,EAAE,6BAA6B,EAAE,MAAM,oDAAoD,CAAA;AAClG,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAA;AAE7D;;;;GAIG;AACH,SAAS,aAAa,CAAC,IAAc;IACnC,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAA;IAClC,IAAI,GAAG,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IACxB,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,GAAG,CAAC,CAAC,CAAA;IAC1B,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QACxD,OAAO,MAAM,CAAC,IAAI,CAAC,CAAA;IACrB,CAAC;IACD,OAAO,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,IAAI,CAAC,CAAA;AAClD,CAAC;AAED,MAAM,UAAU,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,IAAI,CAAC,CAAA;AAC5D,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;AAClC,MAAM,QAAQ,GAAG,aAAa,CAAC,IAAI,CAAC,CAAA;AAEpC,IAAI,IAAI,CAAC,CAAC,CAAC,KAAK,QAAQ,EAAE,CAAC;IACzB,MAAM,CAAC,UAAU,CAAC,CAAC,IAAI,CACrB,CAAC,EAAE,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAChC,CAAC,GAAG,EAAE,EAAE;QACN,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,4BAA4B,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QACjE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACjB,CAAC,CACF,CAAA;AACH,CAAC;KAAM,CAAC;IACN,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;QACnB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,qBAAqB,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QAC1D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACjB,CAAC,CAAC,CAAA;AACJ,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,IAAI,QAAQ,KAAK,IAAI,EAAE,CAAC;QACtB,mEAAmE;QACnE,+DAA+D;QAC/D,gBAAgB;QAChB,MAAM,MAAM,GAAG,IAAI,aAAa,CAAC,UAAU,CAAC,CAAA;QAC5C,MAAM,CAAC,WAAW,EAAE,CAAA;QACpB,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAA;QAC5C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAA;QAC/B,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,wDAAwD,UAAU,IAAI,CAAC,CAAA;QAE5F,MAAM,QAAQ,GAAG,GAAS,EAAE;YAC1B,MAAM,CAAC,UAAU,EAAE,CAAA;YACnB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;QACjB,CAAC,CAAA;QACD,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAA;QAC9B,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAA;QAC/B,OAAM;IACR,CAAC;IAED,oEAAoE;IACpE,sEAAsE;IACtE,iEAAiE;IACjE,iCAAiC;IACjC,MAAM,aAAa,GAAG,IAAI,GAAG,EAAyC,CAAA;IACtE,MAAM,UAAU,GAAG,YAAY,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;QAC3C,UAAU,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;YACjC,GAAG,CAAC,UAAU,GAAG,GAAG,CAAA;YACpB,GAAG,CAAC,SAAS,CAAC,cAAc,EAAE,kBAAkB,CAAC,CAAA;YACjD,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAA;QACjD,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,mEAAmE;IACnE,gEAAgE;IAChE,gEAAgE;IAChE,8DAA8D;IAC9D,MAAM,UAAU,GAAG,IAAI,aAAa,CAAC,EAAE,UAAU,EAAE,QAAQ,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAC,CAAA;IACpF,UAAU,CAAC,WAAW,EAAE,CAAA;IAExB,UAAU,CAAC,MAAM,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE;QAC5C,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,iDAAiD,QAAQ,+BAA+B,QAAQ,WAAW,CAC5G,CAAA;IACH,CAAC,CAAC,CAAA;IAEF,MAAM,QAAQ,GAAG,KAAK,IAAmB,EAAE;QACzC,UAAU,CAAC,UAAU,EAAE,CAAA;QACvB,KAAK,MAAM,CAAC,IAAI,aAAa,CAAC,MAAM,EAAE;YAAE,MAAM,CAAC,CAAC,KAAK,EAAE,CAAA;QACvD,aAAa,CAAC,KAAK,EAAE,CAAA;QACrB,UAAU,CAAC,KAAK,EAAE,CAAA;QAClB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACjB,CAAC,CAAA;IACD,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,EAAE;QACxB,QAAQ,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAA;IACzC,CAAC,CAAC,CAAA;IACF,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE;QACzB,QAAQ,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAA;IACzC,CAAC,CAAC,CAAA;IAEF,KAAK,UAAU,UAAU,CAAC,GAAoB,EAAE,GAAmB;QACjE,MAAM,GAAG,GAAG,GAAG,CAAC,GAAG,IAAI,GAAG,CAAA;QAC1B,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;YAC5B,GAAG,CAAC,UAAU,GAAG,GAAG,CAAA;YACpB,GAAG,CAAC,GAAG,CAAC,WAAW,CAAC,CAAA;YACpB,OAAM;QACR,CAAC;QAED,8DAA8D;QAC9D,gEAAgE;QAChE,+DAA+D;QAC/D,oDAAoD;QACpD,MAAM,aAAa,GAAG,GAAG,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAA;QACnD,MAAM,SAAS,GAAG,OAAO,aAAa,KAAK,QAAQ,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,SAAS,CAAA;QAC/E,IAAI,SAAS,GAAG,SAAS,CAAC,CAAC,CAAC,aAAa,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;QAEpE,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,+DAA+D;YAC/D,4DAA4D;YAC5D,8DAA8D;YAC9D,0CAA0C;YAC1C,SAAS,GAAG,IAAI,6BAA6B,CAAC;gBAC5C,kBAAkB,EAAE,GAAG,EAAE,CAAC,UAAU,EAAE;gBACtC,oBAAoB,EAAE,CAAC,EAAU,EAAE,EAAE;oBACnC,aAAa,CAAC,GAAG,CAAC,EAAE,EAAE,SAAU,CAAC,CAAA;gBACnC,CAAC;aACF,CAAC,CAAA;YACF,SAAS,CAAC,OAAO,GAAG,GAAG,EAAE;gBACvB,MAAM,EAAE,GAAG,SAAU,CAAC,SAAS,CAAA;gBAC/B,IAAI,EAAE;oBAAE,aAAa,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;YAClC,CAAC,CAAA;YACD,MAAM,UAAU,GAAG,UAAU,CAAC,gBAAgB,EAAE,CAAA;YAChD,MAAM,UAAU,CAAC,OAAO,CAAC,SAAS,CAAC,CAAA;QACrC,CAAC;QAED,MAAM,SAAS,CAAC,aAAa,CAAC,GAAG,EAAE,GAAG,CAAC,CAAA;IACzC,CAAC;AACH,CAAC;AAED,KAAK,UAAU,MAAM,CAAC,IAAY;IAChC,gEAAgE;IAChE,sEAAsE;IACtE,mDAAmD;IACnD,EAAE;IACF,kEAAkE;IAClE,8DAA8D;IAC9D,sDAAsD;IACtD,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,SAAS,CAAA;IAC5E,MAAM,EAAE,GAAG,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAA;IAC/B,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAA;IACjC,MAAM,UAAU,GAAG,iBAAiB,EAAE,CAAA;IACtC,MAAM,MAAM,GAAyD,EAAE,CAAA;IAEvE,MAAM,CAAC,IAAI,CAAC;QACV,IAAI,EAAE,aAAa;QACnB,EAAE,EAAE,UAAU,CAAC,UAAU,CAAC;QAC1B,MAAM,EAAE,UAAU;KACnB,CAAC,CAAA;IAEF,IAAI,aAAa,GAA4D,IAAI,CAAA;IACjF,IAAI,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC;QAC3B,IAAI,CAAC;YACH,aAAa,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,UAAU,EAAE,MAAM,CAAC,CAI1D,CAAA;QACH,CAAC;QAAC,MAAM,CAAC;YACP,aAAa,GAAG,IAAI,CAAA;QACtB,CAAC;QACD,MAAM,CAAC,IAAI,CAAC;YACV,IAAI,EAAE,mBAAmB;YACzB,EAAE,EAAE,aAAa,KAAK,IAAI;YAC1B,MAAM,EAAE,aAAa,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,oCAAoC;SAC7E,CAAC,CAAA;QACF,MAAM,CAAC,IAAI,CAAC;YACV,IAAI,EAAE,uBAAuB;YAC7B,EAAE,EAAE,OAAO,aAAa,EAAE,MAAM,KAAK,QAAQ;YAC7C,MAAM,EACJ,OAAO,aAAa,EAAE,MAAM,KAAK,QAAQ;gBACvC,CAAC,CAAC,aAAa,CAAC,MAAM;gBACtB,CAAC,CAAC,yCAAyC;SAChD,CAAC,CAAA;IACJ,CAAC;IAED,MAAM,UAAU,GAAG,aAAa,EAAE,IAAI,IAAI,IAAI,CAAA;IAC9C,MAAM,SAAS,GAAG,MAAM,SAAS,CAAC,UAAU,CAAC,CAAA;IAC7C,MAAM,CAAC,IAAI,CAAC;QACV,IAAI,EAAE,eAAe,UAAU,YAAY;QAC3C,EAAE,EAAE,SAAS;QACb,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,uBAAuB,CAAC,CAAC,CAAC,0CAA0C;KACzF,CAAC,CAAA;IAEF,IAAI,OAAO,aAAa,EAAE,GAAG,KAAK,QAAQ,EAAE,CAAC;QAC3C,MAAM,KAAK,GAAG,UAAU,CAAC,aAAa,CAAC,GAAG,CAAC,CAAA;QAC3C,MAAM,CAAC,IAAI,CAAC;YACV,IAAI,EAAE,cAAc,aAAa,CAAC,GAAG,EAAE;YACvC,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,2BAA2B;SAC9D,CAAC,CAAA;IACJ,CAAC;IAED,IAAI,KAAK,GAAG,IAAI,CAAA;IAChB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,mBAAmB,CAAC,CAAA;IACzC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;IAC3B,KAAK,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC;QACvB,KAAK,GAAG,KAAK,IAAI,CAAC,CAAC,EAAE,CAAA;QACrB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,MAAM,IAAI,CAAC,CAAA;IAChF,CAAC;IACD,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;IAC3B,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,sBAAsB,CAAC,CAAC,CAAC,mCAAmC,CAAC,CAAA;IAC1F,OAAO,KAAK,CAAA;AACd,CAAC;AAED,KAAK,UAAU,SAAS,CAAC,IAAY;IACnC,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,CAAA;IAC3C,OAAO,IAAI,OAAO,CAAU,CAAC,OAAO,EAAE,EAAE;QACtC,MAAM,IAAI,GAAG,IAAI,MAAM,EAAE,CAAA;QACzB,MAAM,IAAI,GAAG,CAAC,EAAW,EAAQ,EAAE;YACjC,IAAI,CAAC,OAAO,EAAE,CAAA;YACd,OAAO,CAAC,EAAE,CAAC,CAAA;QACb,CAAC,CAAA;QACD,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAA;QACpB,IAAI,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAA;QACpC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAA;QACnC,IAAI,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAA;QACrC,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,WAAW,CAAC,CAAA;IACjC,CAAC,CAAC,CAAA;AACJ,CAAC;AAED,SAAS,UAAU,CAAC,GAAW;IAC7B,IAAI,CAAC;QACH,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAA;QACpB,OAAO,IAAI,CAAA;IACb,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC","sourcesContent":["#!/usr/bin/env node\nimport { createServer } from 'node:http'\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport { randomUUID } from 'node:crypto'\nimport { existsSync, readFileSync } from 'node:fs'\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'\nimport { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'\nimport { LluiMcpServer, mcpActiveFilePath } from './index.js'\n\n/**\n * Parse `--http [port]` from argv. Returns:\n * - null → stdio mode (default)\n * - number → HTTP mode on that port\n */\nfunction parseHttpFlag(argv: string[]): number | null {\n const idx = argv.indexOf('--http')\n if (idx < 0) return null\n const next = argv[idx + 1]\n if (next && !next.startsWith('-') && /^\\d+$/.test(next)) {\n return Number(next)\n }\n return Number(process.env.LLUI_MCP_PORT ?? 5200)\n}\n\nconst bridgePort = Number(process.env.LLUI_MCP_PORT ?? 5200)\nconst args = process.argv.slice(2)\nconst httpPort = parseHttpFlag(args)\n\nif (args[0] === 'doctor') {\n doctor(bridgePort).then(\n (ok) => process.exit(ok ? 0 : 1),\n (err) => {\n process.stderr.write(`[llui-mcp doctor] fatal: ${String(err)}\\n`)\n process.exit(2)\n },\n )\n} else {\n main().catch((err) => {\n process.stderr.write(`[llui-mcp] fatal: ${String(err)}\\n`)\n process.exit(1)\n })\n}\n\nasync function main(): Promise<void> {\n if (httpPort === null) {\n // Stdio mode — Claude's `.mcp.json` spawns llui-mcp and talks over\n // stdin/stdout. The bridge runs on its own WebSocket server on\n // `bridgePort`.\n const server = new LluiMcpServer(bridgePort)\n server.startBridge()\n const transport = new StdioServerTransport()\n await server.connect(transport)\n process.stderr.write(`[llui-mcp] listening on stdio; bridge ws://127.0.0.1:${bridgePort}\\n`)\n\n const shutdown = (): void => {\n server.stopBridge()\n process.exit(0)\n }\n process.on('SIGINT', shutdown)\n process.on('SIGTERM', shutdown)\n return\n }\n\n // HTTP mode — plugin-spawned. One `http.Server` serves both the MCP\n // Streamable HTTP transport (`/mcp`) and the browser bridge WebSocket\n // (upgrade on `/bridge`). `.mcp.json` uses type: \"http\" with url\n // `http://127.0.0.1:<port>/mcp`.\n const mcpTransports = new Map<string, StreamableHTTPServerTransport>()\n const httpServer = createServer((req, res) => {\n handleHttp(req, res).catch((err) => {\n res.statusCode = 500\n res.setHeader('content-type', 'application/json')\n res.end(JSON.stringify({ error: String(err) }))\n })\n })\n\n // Single bridge host: owns the WS relay, tool registry, and marker\n // file. All MCP sessions route tool calls through its relay via\n // `createSessionMcp()` — ensures the browser-connected state is\n // shared instead of each session creating its own dead relay.\n const bridgeHost = new LluiMcpServer({ bridgePort: httpPort, attachTo: httpServer })\n bridgeHost.startBridge()\n\n httpServer.listen(httpPort, '127.0.0.1', () => {\n process.stderr.write(\n `[llui-mcp] HTTP transport on http://127.0.0.1:${httpPort}/mcp; bridge ws://127.0.0.1:${httpPort}/bridge\\n`,\n )\n })\n\n const shutdown = async (): Promise<void> => {\n bridgeHost.stopBridge()\n for (const t of mcpTransports.values()) await t.close()\n mcpTransports.clear()\n httpServer.close()\n process.exit(0)\n }\n process.on('SIGINT', () => {\n shutdown().catch(() => process.exit(1))\n })\n process.on('SIGTERM', () => {\n shutdown().catch(() => process.exit(1))\n })\n\n async function handleHttp(req: IncomingMessage, res: ServerResponse): Promise<void> {\n const url = req.url ?? '/'\n if (!url.startsWith('/mcp')) {\n res.statusCode = 404\n res.end('not found')\n return\n }\n\n // Session routing: the SDK's StreamableHTTPServerTransport is\n // stateful. The first request (initialize) creates a session id\n // returned in the `mcp-session-id` response header; subsequent\n // requests carry it as the `mcp-session-id` header.\n const sessionHeader = req.headers['mcp-session-id']\n const sessionId = typeof sessionHeader === 'string' ? sessionHeader : undefined\n let transport = sessionId ? mcpTransports.get(sessionId) : undefined\n\n if (!transport) {\n // New session. SDK requires one `McpServer` per transport, but\n // all sessions must share the single browser bridge — route\n // through `createSessionMcp()` so the session's tool dispatch\n // lands on bridgeHost's registry + relay.\n transport = new StreamableHTTPServerTransport({\n sessionIdGenerator: () => randomUUID(),\n onsessioninitialized: (id: string) => {\n mcpTransports.set(id, transport!)\n },\n })\n transport.onclose = () => {\n const id = transport!.sessionId\n if (id) mcpTransports.delete(id)\n }\n const sessionMcp = bridgeHost.createSessionMcp()\n await sessionMcp.connect(transport)\n }\n\n await transport.handleRequest(req, res)\n }\n}\n\nasync function doctor(port: number): Promise<boolean> {\n // Offline checks only — doctor doesn't require the server to be\n // running. Walks the same states the RelayUnavailableError diagnostic\n // surfaces at runtime, plus a port-liveness probe.\n //\n // Glyphs: emoji ✓/✗ by default, fall back to `OK`/`FAIL` when the\n // environment requests plain output. Honors `--plain` and the\n // standard `NO_COLOR` env var (https://no-color.org).\n const plain = args.includes('--plain') || process.env.NO_COLOR !== undefined\n const ok = plain ? 'OK ' : '✓'\n const fail = plain ? 'FAIL' : '✗'\n const markerPath = mcpActiveFilePath()\n const checks: Array<{ name: string; ok: boolean; detail: string }> = []\n\n checks.push({\n name: 'marker file',\n ok: existsSync(markerPath),\n detail: markerPath,\n })\n\n let markerPayload: { port?: number; pid?: number; devUrl?: string } | null = null\n if (existsSync(markerPath)) {\n try {\n markerPayload = JSON.parse(readFileSync(markerPath, 'utf8')) as {\n port?: number\n pid?: number\n devUrl?: string\n }\n } catch {\n markerPayload = null\n }\n checks.push({\n name: 'marker valid JSON',\n ok: markerPayload !== null,\n detail: markerPayload !== null ? 'OK' : 'malformed — delete and restart MCP',\n })\n checks.push({\n name: 'plugin devUrl stamped',\n ok: typeof markerPayload?.devUrl === 'string',\n detail:\n typeof markerPayload?.devUrl === 'string'\n ? markerPayload.devUrl\n : 'vite-plugin has not stamped its dev URL',\n })\n }\n\n const targetPort = markerPayload?.port ?? port\n const reachable = await probePort(targetPort)\n checks.push({\n name: `bridge port ${targetPort} listening`,\n ok: reachable,\n detail: reachable ? '127.0.0.1 connectable' : 'no process bound; MCP server not running',\n })\n\n if (typeof markerPayload?.pid === 'number') {\n const alive = isPidAlive(markerPayload.pid)\n checks.push({\n name: `marker pid ${markerPayload.pid}`,\n ok: alive,\n detail: alive ? 'process alive' : 'stale — delete the marker',\n })\n }\n\n let allOk = true\n process.stdout.write('llui-mcp doctor\\n')\n process.stdout.write('—\\n')\n for (const c of checks) {\n allOk = allOk && c.ok\n process.stdout.write(`${c.ok ? ok : fail} ${c.name.padEnd(32)} ${c.detail}\\n`)\n }\n process.stdout.write('—\\n')\n process.stdout.write(allOk ? 'All checks passed.\\n' : 'Some checks failed — see above.\\n')\n return allOk\n}\n\nasync function probePort(port: number): Promise<boolean> {\n const { Socket } = await import('node:net')\n return new Promise<boolean>((resolve) => {\n const sock = new Socket()\n const done = (ok: boolean): void => {\n sock.destroy()\n resolve(ok)\n }\n sock.setTimeout(500)\n sock.on('connect', () => done(true))\n sock.on('error', () => done(false))\n sock.on('timeout', () => done(false))\n sock.connect(port, '127.0.0.1')\n })\n}\n\nfunction isPidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0)\n return true\n } catch {\n return false\n }\n}\n"]}
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import type { LluiDebugAPI } from '@llui/dom';
2
+ import type { Server as HttpServer } from 'node:http';
3
+ import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
2
5
  import { type ToolDefinition } from './tool-registry.js';
3
6
  /**
4
7
  * Walk up from `start` until we find a workspace root marker. Used by
@@ -23,12 +26,67 @@ export declare function findWorkspaceRoot(start?: string): string;
23
26
  * when one runs from the repo root and the other from a subpackage.
24
27
  */
25
28
  export declare function mcpActiveFilePath(cwd?: string): string;
29
+ export interface LluiMcpServerOptions {
30
+ /**
31
+ * Port for the browser-relay WebSocket bridge. When the MCP transport
32
+ * is stdio (the CLI default), the relay stands up its own server on
33
+ * this port. When the MCP transport is HTTP, the relay attaches to
34
+ * that HTTP server and the MCP protocol + bridge share a single port.
35
+ */
36
+ bridgePort?: number;
37
+ /**
38
+ * Optional pre-existing `http.Server` to share with the bridge. When
39
+ * provided, the bridge attaches to it via upgrade routing on
40
+ * `/bridge`; `bridgePort` is ignored for server-creation purposes
41
+ * (but still written into the marker file so consumers know where to
42
+ * connect).
43
+ */
44
+ attachTo?: HttpServer;
45
+ }
26
46
  export declare class LluiMcpServer {
27
47
  private readonly registry;
28
48
  private readonly relay;
29
49
  private readonly bridgePort;
50
+ private readonly mcp;
30
51
  private devUrl;
31
- constructor(bridgePort?: number);
52
+ /**
53
+ * @param optsOrPort options object (preferred) or bridge port (legacy).
54
+ * The numeric-port form is kept for one release cycle of back-compat;
55
+ * new code should always pass an options object. The options form
56
+ * supports `attachTo` for HTTP-transport deployments that share a
57
+ * single port between MCP and the browser bridge — the numeric form
58
+ * can't express that.
59
+ * @deprecated numeric `optsOrPort` — pass `{ bridgePort }` instead.
60
+ * This overload will be removed in a future breaking release.
61
+ */
62
+ constructor(optsOrPort?: LluiMcpServerOptions | number);
63
+ /**
64
+ * Build a fresh SDK `McpServer` wired to THIS instance's tool
65
+ * registry and browser relay. The primary `this.mcp` uses one.
66
+ * `createSessionMcp()` returns additional ones for HTTP-transport
67
+ * deployments where every session needs its own SDK Server — each
68
+ * routes tool calls through the shared relay, so the single
69
+ * bridgeHost owns all the browser-facing state.
70
+ */
71
+ private buildMcpServer;
72
+ /**
73
+ * Build a new SDK MCP server sharing this instance's registry + relay,
74
+ * for HTTP-transport deployments where each session needs its own
75
+ * `Server` (SDK requirement). Call-site pattern:
76
+ *
77
+ * const bridgeHost = new LluiMcpServer({ bridgePort, attachTo: httpServer })
78
+ * bridgeHost.startBridge()
79
+ * // Per session:
80
+ * const sessionMcp = bridgeHost.createSessionMcp()
81
+ * await sessionMcp.connect(transport)
82
+ */
83
+ createSessionMcp(): McpServer;
84
+ /**
85
+ * Connect the SDK MCP server to a transport (stdio, HTTP, etc).
86
+ * The CLI builds the transport based on command-line flags and
87
+ * hands it in here.
88
+ */
89
+ connect(transport: Transport): Promise<void>;
32
90
  /** Connect to a debug API instance directly (for in-process usage). */
33
91
  connectDirect(api: LluiDebugAPI): void;
34
92
  /**
@@ -51,9 +109,6 @@ export declare class LluiMcpServer {
51
109
  getTools(): ToolDefinition[];
52
110
  /** Handle an MCP tool call */
53
111
  handleToolCall(name: string, args: Record<string, unknown>): Promise<unknown>;
54
- /** Start the MCP server on stdin/stdout */
55
- start(): void;
56
- private handleRequest;
57
112
  }
58
113
  /**
59
114
  * Snapshot of all registered tool definitions. Kept as a named export for
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAA;AAG7C,OAAO,EAAkC,KAAK,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAIxF;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,GAAE,MAAsB,GAAG,MAAM,CAgBvE;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,MAAsB,GAAG,MAAM,CAErE;AAoBD,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAc;IACvC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAyB;IAC/C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAQ;IACnC,OAAO,CAAC,MAAM,CAAsB;gBAExB,UAAU,SAAO;IAO7B,uEAAuE;IACvE,aAAa,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI;IAItC;;;;;OAKG;IACH,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAK5B;;;;OAIG;IACH,WAAW,IAAI,IAAI;IAQnB,UAAU,IAAI,IAAI;IAKlB,OAAO,CAAC,eAAe;IAevB,OAAO,CAAC,gBAAgB;IASxB,6CAA6C;IAC7C,QAAQ,IAAI,cAAc,EAAE;IAI5B,8BAA8B;IACxB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC;IAKnF,2CAA2C;IAC3C,KAAK,IAAI,IAAI;YAuBC,aAAa;CAsD5B;AAED;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,EAAE,cAAc,EAI3C,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAA;AAI7C,OAAO,KAAK,EAAE,MAAM,IAAI,UAAU,EAAE,MAAM,WAAW,CAAA;AACrD,OAAO,EAAE,MAAM,IAAI,SAAS,EAAE,MAAM,2CAA2C,CAAA;AAC/E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAA;AAE9E,OAAO,EAAkC,KAAK,cAAc,EAAE,MAAM,oBAAoB,CAAA;AA0BxF;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,GAAE,MAAsB,GAAG,MAAM,CAgBvE;AAED;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,MAAsB,GAAG,MAAM,CAErE;AAID,MAAM,WAAW,oBAAoB;IACnC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,UAAU,CAAA;CACtB;AAED,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAc;IACvC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAyB;IAC/C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAQ;IACnC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAW;IAC/B,OAAO,CAAC,MAAM,CAAsB;IAEpC;;;;;;;;;OASG;gBACS,UAAU,GAAE,oBAAoB,GAAG,MAAa;IAqB5D;;;;;;;OAOG;IACH,OAAO,CAAC,cAAc;IAwCtB;;;;;;;;;;OAUG;IACH,gBAAgB,IAAI,SAAS;IAI7B;;;;OAIG;IACG,OAAO,CAAC,SAAS,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAIlD,uEAAuE;IACvE,aAAa,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI;IAItC;;;;;OAKG;IACH,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAK5B;;;;OAIG;IACH,WAAW,IAAI,IAAI;IAQnB,UAAU,IAAI,IAAI;IAKlB,OAAO,CAAC,eAAe;IAevB,OAAO,CAAC,gBAAgB;IASxB,6CAA6C;IAC7C,QAAQ,IAAI,cAAc,EAAE;IAI5B,8BAA8B;IACxB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC;CAIpF;AAED;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,EAAE,cAAc,EAI3C,CAAA"}
package/dist/index.js CHANGED
@@ -1,8 +1,31 @@
1
- import { mkdirSync, writeFileSync, unlinkSync, existsSync } from 'node:fs';
1
+ import { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs';
2
2
  import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
5
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
3
6
  import { ToolRegistry } from './tool-registry.js';
4
7
  import { registerDebugApiTools } from './tools/index.js';
5
- import { WebSocketRelayTransport } from './transports/index.js';
8
+ import { WebSocketRelayTransport, RelayUnavailableError } from './transports/index.js';
9
+ /**
10
+ * Version advertised in the MCP `initialize` handshake. Read once from
11
+ * our own `package.json` so it stays in sync with the publish bump,
12
+ * instead of a hardcoded literal that silently drifts each release.
13
+ *
14
+ * Falls back to `'unknown'` on read failure — SDK initialization still
15
+ * succeeds; only the cosmetic serverInfo.version is affected.
16
+ */
17
+ const PACKAGE_VERSION = (() => {
18
+ try {
19
+ // dist layout: `dist/index.js` → `package.json` is two levels up
20
+ // from the module file at runtime.
21
+ const here = dirname(fileURLToPath(import.meta.url));
22
+ const pkg = JSON.parse(readFileSync(resolve(here, '../package.json'), 'utf8'));
23
+ return typeof pkg.version === 'string' ? pkg.version : 'unknown';
24
+ }
25
+ catch {
26
+ return 'unknown';
27
+ }
28
+ })();
6
29
  /**
7
30
  * Walk up from `start` until we find a workspace root marker. Used by
8
31
  * both the MCP server (writing the active marker) and the Vite plugin
@@ -47,17 +70,106 @@ export function findWorkspaceRoot(start = process.cwd()) {
47
70
  export function mcpActiveFilePath(cwd = process.cwd()) {
48
71
  return resolve(findWorkspaceRoot(cwd), 'node_modules/.cache/llui-mcp/active.json');
49
72
  }
50
- // ── MCP Server ──────────────────────────────────────────────────
51
73
  export class LluiMcpServer {
52
74
  registry;
53
75
  relay;
54
76
  bridgePort;
77
+ mcp;
55
78
  devUrl = null;
56
- constructor(bridgePort = 5200) {
57
- this.bridgePort = bridgePort;
79
+ /**
80
+ * @param optsOrPort options object (preferred) or bridge port (legacy).
81
+ * The numeric-port form is kept for one release cycle of back-compat;
82
+ * new code should always pass an options object. The options form
83
+ * supports `attachTo` for HTTP-transport deployments that share a
84
+ * single port between MCP and the browser bridge — the numeric form
85
+ * can't express that.
86
+ * @deprecated numeric `optsOrPort` — pass `{ bridgePort }` instead.
87
+ * This overload will be removed in a future breaking release.
88
+ */
89
+ constructor(optsOrPort = 5200) {
90
+ const opts = typeof optsOrPort === 'number' ? { bridgePort: optsOrPort } : optsOrPort;
91
+ this.bridgePort = opts.bridgePort ?? 5200;
58
92
  this.registry = new ToolRegistry();
59
- this.relay = new WebSocketRelayTransport({ port: bridgePort });
93
+ // Pass bridgePort even in attachTo mode — the relay's diagnose()
94
+ // needs it for the port field of BridgeDiagnostic. The `start()`
95
+ // path is gated on `attachTo` first so a standalone listener
96
+ // never gets created twice.
97
+ this.relay = new WebSocketRelayTransport({
98
+ port: this.bridgePort,
99
+ attachTo: opts.attachTo,
100
+ markerPath: mcpActiveFilePath(),
101
+ });
60
102
  registerDebugApiTools(this.registry);
103
+ // SDK-managed MCP server — owns the JSON-RPC protocol, handshake,
104
+ // session lifecycle. Transport is plugged in later via `connect()`.
105
+ this.mcp = this.buildMcpServer();
106
+ }
107
+ /**
108
+ * Build a fresh SDK `McpServer` wired to THIS instance's tool
109
+ * registry and browser relay. The primary `this.mcp` uses one.
110
+ * `createSessionMcp()` returns additional ones for HTTP-transport
111
+ * deployments where every session needs its own SDK Server — each
112
+ * routes tool calls through the shared relay, so the single
113
+ * bridgeHost owns all the browser-facing state.
114
+ */
115
+ buildMcpServer() {
116
+ const mcp = new McpServer({ name: '@llui/mcp', version: PACKAGE_VERSION }, { capabilities: { tools: {} } });
117
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
118
+ tools: this.getTools().map((t) => ({
119
+ name: t.name,
120
+ description: t.description,
121
+ inputSchema: t.inputSchema,
122
+ })),
123
+ }));
124
+ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
125
+ const { name, arguments: args } = request.params;
126
+ try {
127
+ const result = await this.handleToolCall(name, args ?? {});
128
+ return {
129
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
130
+ };
131
+ }
132
+ catch (err) {
133
+ // Bridge-unavailable errors carry a structured diagnostic — surface
134
+ // it as an isError tool result so the caller (typically Claude) sees
135
+ // WHY the browser isn't reachable, not just that it failed.
136
+ if (err instanceof RelayUnavailableError) {
137
+ return {
138
+ isError: true,
139
+ content: [
140
+ {
141
+ type: 'text',
142
+ text: JSON.stringify({ error: 'bridge-unavailable', ...err.diagnostic }, null, 2),
143
+ },
144
+ ],
145
+ };
146
+ }
147
+ throw err;
148
+ }
149
+ });
150
+ return mcp;
151
+ }
152
+ /**
153
+ * Build a new SDK MCP server sharing this instance's registry + relay,
154
+ * for HTTP-transport deployments where each session needs its own
155
+ * `Server` (SDK requirement). Call-site pattern:
156
+ *
157
+ * const bridgeHost = new LluiMcpServer({ bridgePort, attachTo: httpServer })
158
+ * bridgeHost.startBridge()
159
+ * // Per session:
160
+ * const sessionMcp = bridgeHost.createSessionMcp()
161
+ * await sessionMcp.connect(transport)
162
+ */
163
+ createSessionMcp() {
164
+ return this.buildMcpServer();
165
+ }
166
+ /**
167
+ * Connect the SDK MCP server to a transport (stdio, HTTP, etc).
168
+ * The CLI builds the transport based on command-line flags and
169
+ * hands it in here.
170
+ */
171
+ async connect(transport) {
172
+ await this.mcp.connect(transport);
61
173
  }
62
174
  /** Connect to a debug API instance directly (for in-process usage). */
63
175
  connectDirect(api) {
@@ -124,79 +236,6 @@ export class LluiMcpServer {
124
236
  const ctx = { relay: this.relay, cdp: null };
125
237
  return this.registry.dispatch(name, args, ctx);
126
238
  }
127
- /** Start the MCP server on stdin/stdout */
128
- start() {
129
- let buffer = '';
130
- process.stdin.setEncoding('utf8');
131
- process.stdin.on('data', (chunk) => {
132
- buffer += chunk;
133
- // MCP uses newline-delimited JSON
134
- const lines = buffer.split('\n');
135
- buffer = lines.pop(); // keep incomplete line
136
- for (const line of lines) {
137
- if (!line.trim())
138
- continue;
139
- try {
140
- const request = JSON.parse(line);
141
- this.handleRequest(request).then((response) => {
142
- process.stdout.write(JSON.stringify(response) + '\n');
143
- });
144
- }
145
- catch {
146
- // Ignore parse errors
147
- }
148
- }
149
- });
150
- }
151
- async handleRequest(request) {
152
- try {
153
- switch (request.method) {
154
- case 'initialize':
155
- return {
156
- jsonrpc: '2.0',
157
- id: request.id,
158
- result: {
159
- protocolVersion: '2024-11-05',
160
- capabilities: { tools: {} },
161
- serverInfo: { name: '@llui/mcp', version: '0.0.0' },
162
- },
163
- };
164
- case 'tools/list':
165
- return {
166
- jsonrpc: '2.0',
167
- id: request.id,
168
- result: { tools: this.getTools() },
169
- };
170
- case 'tools/call': {
171
- const params = request.params;
172
- const result = await this.handleToolCall(params.name, params.arguments ?? {});
173
- return {
174
- jsonrpc: '2.0',
175
- id: request.id,
176
- result: {
177
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
178
- },
179
- };
180
- }
181
- default:
182
- return {
183
- jsonrpc: '2.0',
184
- id: request.id,
185
- error: {
186
- code: -32601,
187
- message: `Method not found: ${request.method}`,
188
- },
189
- };
190
- }
191
- }
192
- catch (err) {
193
- return {
194
- jsonrpc: '2.0',
195
- id: request.id,
196
- error: { code: -32000, message: String(err) },
197
- };
198
- }
199
- }
200
239
  }
201
240
  /**
202
241
  * Snapshot of all registered tool definitions. Kept as a named export for
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type { LluiDebugAPI } from '@llui/dom'\nimport { mkdirSync, writeFileSync, unlinkSync, existsSync } from 'node:fs'\nimport { dirname, resolve } from 'node:path'\nimport { ToolRegistry, type ToolContext, type ToolDefinition } from './tool-registry.js'\nimport { registerDebugApiTools } from './tools/index.js'\nimport { WebSocketRelayTransport } from './transports/index.js'\n\n/**\n * Walk up from `start` until we find a workspace root marker. Used by\n * both the MCP server (writing the active marker) and the Vite plugin\n * (watching it) so they agree on a single shared location regardless of\n * which subdirectory each process happens to be running in.\n *\n * Strong markers (workspace root): pnpm-workspace.yaml, .git directory.\n * If neither is found anywhere up the chain, falls back to the highest\n * package.json above `start`. For pnpm monorepos this finds the workspace\n * root from any subpackage; for single-package projects it finds the\n * package root.\n */\nexport function findWorkspaceRoot(start: string = process.cwd()): string {\n let dir = resolve(start)\n let lastPackageJson: string | null = null\n while (true) {\n // Strong markers — return immediately\n if (existsSync(resolve(dir, 'pnpm-workspace.yaml'))) return dir\n if (existsSync(resolve(dir, '.git'))) return dir\n // Track the highest package.json as a fallback\n if (existsSync(resolve(dir, 'package.json'))) lastPackageJson = dir\n const parent = dirname(dir)\n if (parent === dir) {\n // Reached filesystem root — return the highest package.json we saw\n return lastPackageJson ?? start\n }\n dir = parent\n }\n}\n\n/**\n * Path where the MCP server writes its active port marker. Vite plugins\n * watch this file to auto-trigger browser-side `__lluiConnect()` whenever\n * the MCP server starts, regardless of whether Vite or MCP started first.\n *\n * Resolved relative to the workspace root (not the immediate cwd) so the\n * MCP server and the Vite plugin always agree on a single location even\n * when one runs from the repo root and the other from a subpackage.\n */\nexport function mcpActiveFilePath(cwd: string = process.cwd()): string {\n return resolve(findWorkspaceRoot(cwd), 'node_modules/.cache/llui-mcp/active.json')\n}\n\n// ── MCP Protocol Types ──────────────────────────────────────────\n\ninterface JsonRpcRequest {\n jsonrpc: '2.0'\n id: string | number\n method: string\n params?: Record<string, unknown>\n}\n\ninterface JsonRpcResponse {\n jsonrpc: '2.0'\n id: string | number\n result?: unknown\n error?: { code: number; message: string; data?: unknown }\n}\n\n// ── MCP Server ──────────────────────────────────────────────────\n\nexport class LluiMcpServer {\n private readonly registry: ToolRegistry\n private readonly relay: WebSocketRelayTransport\n private readonly bridgePort: number\n private devUrl: string | null = null\n\n constructor(bridgePort = 5200) {\n this.bridgePort = bridgePort\n this.registry = new ToolRegistry()\n this.relay = new WebSocketRelayTransport({ port: bridgePort })\n registerDebugApiTools(this.registry)\n }\n\n /** Connect to a debug API instance directly (for in-process usage). */\n connectDirect(api: LluiDebugAPI): void {\n this.relay.connectDirect(api)\n }\n\n /**\n * Set the dev-server URL that Phase 2's CDP fallback can navigate a\n * Playwright browser to. Persisted into the active marker file so the\n * Vite plugin (or other consumers) can rebroadcast it. If the bridge is\n * already running, rewrites the marker so consumers see the update.\n */\n setDevUrl(url: string): void {\n this.devUrl = url\n if (this.relay.isServerRunning()) this.writeActiveFile()\n }\n\n /**\n * Start a WebSocket server on the configured bridge port. The browser-side\n * relay (injected by the Vite plugin in dev mode) connects here and forwards\n * debug-API calls.\n */\n startBridge(): void {\n this.relay.start()\n\n // Write the active marker file so Vite plugins watching it can\n // dispatch an HMR custom event to auto-trigger browser connects.\n this.writeActiveFile()\n }\n\n stopBridge(): void {\n this.relay.stop()\n this.removeActiveFile()\n }\n\n private writeActiveFile(): void {\n try {\n const path = mcpActiveFilePath()\n mkdirSync(dirname(path), { recursive: true })\n const payload: { port: number; pid: number; devUrl?: string } = {\n port: this.bridgePort,\n pid: process.pid,\n }\n if (this.devUrl !== null) payload.devUrl = this.devUrl\n writeFileSync(path, JSON.stringify(payload))\n } catch {\n // Best-effort — failure to write the marker should not crash the server\n }\n }\n\n private removeActiveFile(): void {\n try {\n const path = mcpActiveFilePath()\n if (existsSync(path)) unlinkSync(path)\n } catch {\n // Ignore — file may already be gone\n }\n }\n\n /** Get tool definitions for MCP handshake */\n getTools(): ToolDefinition[] {\n return this.registry.listDefinitions()\n }\n\n /** Handle an MCP tool call */\n async handleToolCall(name: string, args: Record<string, unknown>): Promise<unknown> {\n const ctx: ToolContext = { relay: this.relay, cdp: null }\n return this.registry.dispatch(name, args, ctx)\n }\n\n /** Start the MCP server on stdin/stdout */\n start(): void {\n let buffer = ''\n\n process.stdin.setEncoding('utf8')\n process.stdin.on('data', (chunk: string) => {\n buffer += chunk\n // MCP uses newline-delimited JSON\n const lines = buffer.split('\\n')\n buffer = lines.pop()! // keep incomplete line\n for (const line of lines) {\n if (!line.trim()) continue\n try {\n const request = JSON.parse(line) as JsonRpcRequest\n this.handleRequest(request).then((response) => {\n process.stdout.write(JSON.stringify(response) + '\\n')\n })\n } catch {\n // Ignore parse errors\n }\n }\n })\n }\n\n private async handleRequest(request: JsonRpcRequest): Promise<JsonRpcResponse> {\n try {\n switch (request.method) {\n case 'initialize':\n return {\n jsonrpc: '2.0',\n id: request.id,\n result: {\n protocolVersion: '2024-11-05',\n capabilities: { tools: {} },\n serverInfo: { name: '@llui/mcp', version: '0.0.0' },\n },\n }\n\n case 'tools/list':\n return {\n jsonrpc: '2.0',\n id: request.id,\n result: { tools: this.getTools() },\n }\n\n case 'tools/call': {\n const params = request.params as {\n name: string\n arguments: Record<string, unknown>\n }\n const result = await this.handleToolCall(params.name, params.arguments ?? {})\n return {\n jsonrpc: '2.0',\n id: request.id,\n result: {\n content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],\n },\n }\n }\n\n default:\n return {\n jsonrpc: '2.0',\n id: request.id,\n error: {\n code: -32601,\n message: `Method not found: ${request.method}`,\n },\n }\n }\n } catch (err) {\n return {\n jsonrpc: '2.0',\n id: request.id,\n error: { code: -32000, message: String(err) },\n }\n }\n }\n}\n\n/**\n * Snapshot of all registered tool definitions. Kept as a named export for\n * backward compatibility with downstream consumers that used to import the\n * `TOOLS` array re-export under this alias.\n */\nexport const mcpToolDefinitions: ToolDefinition[] = (() => {\n const registry = new ToolRegistry()\n registerDebugApiTools(registry)\n return registry.listDefinitions()\n})()\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type { LluiDebugAPI } from '@llui/dom'\nimport { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs'\nimport { dirname, resolve } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport type { Server as HttpServer } from 'node:http'\nimport { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js'\nimport type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'\nimport { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'\nimport { ToolRegistry, type ToolContext, type ToolDefinition } from './tool-registry.js'\nimport { registerDebugApiTools } from './tools/index.js'\nimport { WebSocketRelayTransport, RelayUnavailableError } from './transports/index.js'\n\n/**\n * Version advertised in the MCP `initialize` handshake. Read once from\n * our own `package.json` so it stays in sync with the publish bump,\n * instead of a hardcoded literal that silently drifts each release.\n *\n * Falls back to `'unknown'` on read failure — SDK initialization still\n * succeeds; only the cosmetic serverInfo.version is affected.\n */\nconst PACKAGE_VERSION: string = (() => {\n try {\n // dist layout: `dist/index.js` → `package.json` is two levels up\n // from the module file at runtime.\n const here = dirname(fileURLToPath(import.meta.url))\n const pkg = JSON.parse(readFileSync(resolve(here, '../package.json'), 'utf8')) as {\n version?: string\n }\n return typeof pkg.version === 'string' ? pkg.version : 'unknown'\n } catch {\n return 'unknown'\n }\n})()\n\n/**\n * Walk up from `start` until we find a workspace root marker. Used by\n * both the MCP server (writing the active marker) and the Vite plugin\n * (watching it) so they agree on a single shared location regardless of\n * which subdirectory each process happens to be running in.\n *\n * Strong markers (workspace root): pnpm-workspace.yaml, .git directory.\n * If neither is found anywhere up the chain, falls back to the highest\n * package.json above `start`. For pnpm monorepos this finds the workspace\n * root from any subpackage; for single-package projects it finds the\n * package root.\n */\nexport function findWorkspaceRoot(start: string = process.cwd()): string {\n let dir = resolve(start)\n let lastPackageJson: string | null = null\n while (true) {\n // Strong markers — return immediately\n if (existsSync(resolve(dir, 'pnpm-workspace.yaml'))) return dir\n if (existsSync(resolve(dir, '.git'))) return dir\n // Track the highest package.json as a fallback\n if (existsSync(resolve(dir, 'package.json'))) lastPackageJson = dir\n const parent = dirname(dir)\n if (parent === dir) {\n // Reached filesystem root — return the highest package.json we saw\n return lastPackageJson ?? start\n }\n dir = parent\n }\n}\n\n/**\n * Path where the MCP server writes its active port marker. Vite plugins\n * watch this file to auto-trigger browser-side `__lluiConnect()` whenever\n * the MCP server starts, regardless of whether Vite or MCP started first.\n *\n * Resolved relative to the workspace root (not the immediate cwd) so the\n * MCP server and the Vite plugin always agree on a single location even\n * when one runs from the repo root and the other from a subpackage.\n */\nexport function mcpActiveFilePath(cwd: string = process.cwd()): string {\n return resolve(findWorkspaceRoot(cwd), 'node_modules/.cache/llui-mcp/active.json')\n}\n\n// ── MCP Server ──────────────────────────────────────────────────\n\nexport interface LluiMcpServerOptions {\n /**\n * Port for the browser-relay WebSocket bridge. When the MCP transport\n * is stdio (the CLI default), the relay stands up its own server on\n * this port. When the MCP transport is HTTP, the relay attaches to\n * that HTTP server and the MCP protocol + bridge share a single port.\n */\n bridgePort?: number\n /**\n * Optional pre-existing `http.Server` to share with the bridge. When\n * provided, the bridge attaches to it via upgrade routing on\n * `/bridge`; `bridgePort` is ignored for server-creation purposes\n * (but still written into the marker file so consumers know where to\n * connect).\n */\n attachTo?: HttpServer\n}\n\nexport class LluiMcpServer {\n private readonly registry: ToolRegistry\n private readonly relay: WebSocketRelayTransport\n private readonly bridgePort: number\n private readonly mcp: McpServer\n private devUrl: string | null = null\n\n /**\n * @param optsOrPort options object (preferred) or bridge port (legacy).\n * The numeric-port form is kept for one release cycle of back-compat;\n * new code should always pass an options object. The options form\n * supports `attachTo` for HTTP-transport deployments that share a\n * single port between MCP and the browser bridge — the numeric form\n * can't express that.\n * @deprecated numeric `optsOrPort` — pass `{ bridgePort }` instead.\n * This overload will be removed in a future breaking release.\n */\n constructor(optsOrPort: LluiMcpServerOptions | number = 5200) {\n const opts: LluiMcpServerOptions =\n typeof optsOrPort === 'number' ? { bridgePort: optsOrPort } : optsOrPort\n this.bridgePort = opts.bridgePort ?? 5200\n this.registry = new ToolRegistry()\n // Pass bridgePort even in attachTo mode — the relay's diagnose()\n // needs it for the port field of BridgeDiagnostic. The `start()`\n // path is gated on `attachTo` first so a standalone listener\n // never gets created twice.\n this.relay = new WebSocketRelayTransport({\n port: this.bridgePort,\n attachTo: opts.attachTo,\n markerPath: mcpActiveFilePath(),\n })\n registerDebugApiTools(this.registry)\n\n // SDK-managed MCP server — owns the JSON-RPC protocol, handshake,\n // session lifecycle. Transport is plugged in later via `connect()`.\n this.mcp = this.buildMcpServer()\n }\n\n /**\n * Build a fresh SDK `McpServer` wired to THIS instance's tool\n * registry and browser relay. The primary `this.mcp` uses one.\n * `createSessionMcp()` returns additional ones for HTTP-transport\n * deployments where every session needs its own SDK Server — each\n * routes tool calls through the shared relay, so the single\n * bridgeHost owns all the browser-facing state.\n */\n private buildMcpServer(): McpServer {\n const mcp = new McpServer(\n { name: '@llui/mcp', version: PACKAGE_VERSION },\n { capabilities: { tools: {} } },\n )\n mcp.setRequestHandler(ListToolsRequestSchema, async () => ({\n tools: this.getTools().map((t) => ({\n name: t.name,\n description: t.description,\n inputSchema: t.inputSchema,\n })),\n }))\n mcp.setRequestHandler(CallToolRequestSchema, async (request) => {\n const { name, arguments: args } = request.params\n try {\n const result = await this.handleToolCall(name, args ?? {})\n return {\n content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],\n }\n } catch (err) {\n // Bridge-unavailable errors carry a structured diagnostic — surface\n // it as an isError tool result so the caller (typically Claude) sees\n // WHY the browser isn't reachable, not just that it failed.\n if (err instanceof RelayUnavailableError) {\n return {\n isError: true,\n content: [\n {\n type: 'text' as const,\n text: JSON.stringify({ error: 'bridge-unavailable', ...err.diagnostic }, null, 2),\n },\n ],\n }\n }\n throw err\n }\n })\n return mcp\n }\n\n /**\n * Build a new SDK MCP server sharing this instance's registry + relay,\n * for HTTP-transport deployments where each session needs its own\n * `Server` (SDK requirement). Call-site pattern:\n *\n * const bridgeHost = new LluiMcpServer({ bridgePort, attachTo: httpServer })\n * bridgeHost.startBridge()\n * // Per session:\n * const sessionMcp = bridgeHost.createSessionMcp()\n * await sessionMcp.connect(transport)\n */\n createSessionMcp(): McpServer {\n return this.buildMcpServer()\n }\n\n /**\n * Connect the SDK MCP server to a transport (stdio, HTTP, etc).\n * The CLI builds the transport based on command-line flags and\n * hands it in here.\n */\n async connect(transport: Transport): Promise<void> {\n await this.mcp.connect(transport)\n }\n\n /** Connect to a debug API instance directly (for in-process usage). */\n connectDirect(api: LluiDebugAPI): void {\n this.relay.connectDirect(api)\n }\n\n /**\n * Set the dev-server URL that Phase 2's CDP fallback can navigate a\n * Playwright browser to. Persisted into the active marker file so the\n * Vite plugin (or other consumers) can rebroadcast it. If the bridge is\n * already running, rewrites the marker so consumers see the update.\n */\n setDevUrl(url: string): void {\n this.devUrl = url\n if (this.relay.isServerRunning()) this.writeActiveFile()\n }\n\n /**\n * Start a WebSocket server on the configured bridge port. The browser-side\n * relay (injected by the Vite plugin in dev mode) connects here and forwards\n * debug-API calls.\n */\n startBridge(): void {\n this.relay.start()\n\n // Write the active marker file so Vite plugins watching it can\n // dispatch an HMR custom event to auto-trigger browser connects.\n this.writeActiveFile()\n }\n\n stopBridge(): void {\n this.relay.stop()\n this.removeActiveFile()\n }\n\n private writeActiveFile(): void {\n try {\n const path = mcpActiveFilePath()\n mkdirSync(dirname(path), { recursive: true })\n const payload: { port: number; pid: number; devUrl?: string } = {\n port: this.bridgePort,\n pid: process.pid,\n }\n if (this.devUrl !== null) payload.devUrl = this.devUrl\n writeFileSync(path, JSON.stringify(payload))\n } catch {\n // Best-effort — failure to write the marker should not crash the server\n }\n }\n\n private removeActiveFile(): void {\n try {\n const path = mcpActiveFilePath()\n if (existsSync(path)) unlinkSync(path)\n } catch {\n // Ignore — file may already be gone\n }\n }\n\n /** Get tool definitions for MCP handshake */\n getTools(): ToolDefinition[] {\n return this.registry.listDefinitions()\n }\n\n /** Handle an MCP tool call */\n async handleToolCall(name: string, args: Record<string, unknown>): Promise<unknown> {\n const ctx: ToolContext = { relay: this.relay, cdp: null }\n return this.registry.dispatch(name, args, ctx)\n }\n}\n\n/**\n * Snapshot of all registered tool definitions. Kept as a named export for\n * backward compatibility with downstream consumers that used to import the\n * `TOOLS` array re-export under this alias.\n */\nexport const mcpToolDefinitions: ToolDefinition[] = (() => {\n const registry = new ToolRegistry()\n registerDebugApiTools(registry)\n return registry.listDefinitions()\n})()\n"]}
@@ -1,3 +1,3 @@
1
- export { WebSocketRelayTransport } from './relay.js';
2
- export type { RelayTransportOptions } from './relay.js';
1
+ export { WebSocketRelayTransport, RelayUnavailableError } from './relay.js';
2
+ export type { RelayTransportOptions, BridgeDiagnostic } from './relay.js';
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/transports/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAA;AACpD,YAAY,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/transports/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAA;AAC3E,YAAY,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA"}
@@ -1,2 +1,2 @@
1
- export { WebSocketRelayTransport } from './relay.js';
1
+ export { WebSocketRelayTransport, RelayUnavailableError } from './relay.js';
2
2
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/transports/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAA","sourcesContent":["export { WebSocketRelayTransport } from './relay.js'\nexport type { RelayTransportOptions } from './relay.js'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/transports/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAA","sourcesContent":["export { WebSocketRelayTransport, RelayUnavailableError } from './relay.js'\nexport type { RelayTransportOptions, BridgeDiagnostic } from './relay.js'\n"]}
@@ -1,7 +1,54 @@
1
+ import type { Server as HttpServer } from 'node:http';
1
2
  import type { LluiDebugAPI } from '@llui/dom';
2
3
  import type { RelayTransport } from '../tool-registry.js';
4
+ /**
5
+ * Structured snapshot of the bridge state at a single point in time.
6
+ * Returned by `RelayUnavailableError.diagnostic` so tool callers can
7
+ * see WHY the browser isn't reachable without grepping plugin source.
8
+ */
9
+ export interface BridgeDiagnostic {
10
+ connected: boolean;
11
+ bridge: {
12
+ /** Is the WS server bound and listening? */
13
+ running: boolean;
14
+ port: number | null;
15
+ };
16
+ browser: {
17
+ /** Currently connected browser tabs (0 or 1 — the bridge is single-client today). */
18
+ tabsConnected: number;
19
+ };
20
+ mcpMarker: {
21
+ present: boolean;
22
+ path: string;
23
+ /** Set by the Vite plugin once Vite's httpServer emits `listening`. */
24
+ devUrl: string | null;
25
+ };
26
+ suggestedFix: string;
27
+ }
28
+ /**
29
+ * Error thrown when a tool call needs the browser relay but no browser
30
+ * is attached. Carries a `diagnostic` payload the MCP handler can
31
+ * surface as `isError: true` tool content — no need for Claude (or any
32
+ * MCP client) to grep plugin source to guess what's wrong.
33
+ */
34
+ export declare class RelayUnavailableError extends Error {
35
+ readonly diagnostic: BridgeDiagnostic;
36
+ constructor(diagnostic: BridgeDiagnostic);
37
+ }
3
38
  export interface RelayTransportOptions {
4
- port: number;
39
+ /**
40
+ * Either `port` (stdio mode — relay owns its own WS server) or
41
+ * `attachTo` (HTTP mode — relay upgrades on an existing http.Server so
42
+ * the MCP HTTP endpoint and the browser bridge share a single port).
43
+ */
44
+ port?: number;
45
+ attachTo?: HttpServer;
46
+ /**
47
+ * Filesystem path to the MCP active-marker file. Used by `diagnose()`
48
+ * to check whether the Vite plugin has written the marker (indicating
49
+ * it's opted in) and to read back the `devUrl` it stamped.
50
+ */
51
+ markerPath?: string;
5
52
  onBrowserConnect?: () => void;
6
53
  onBrowserDisconnect?: () => void;
7
54
  }
@@ -11,6 +58,8 @@ export declare class WebSocketRelayTransport implements RelayTransport {
11
58
  private pending;
12
59
  private directApi;
13
60
  private readonly port;
61
+ private readonly attachTo;
62
+ private readonly markerPath;
14
63
  private readonly onConnect?;
15
64
  private readonly onDisconnect?;
16
65
  constructor(opts: RelayTransportOptions);
@@ -19,6 +68,13 @@ export declare class WebSocketRelayTransport implements RelayTransport {
19
68
  stop(): void;
20
69
  isAvailable(): boolean;
21
70
  isServerRunning(): boolean;
71
+ /**
72
+ * Build a snapshot of the bridge state for diagnostics. Called when a
73
+ * tool call fails because the browser isn't attached — the payload
74
+ * goes straight into `RelayUnavailableError.diagnostic` for the
75
+ * client to surface.
76
+ */
77
+ diagnose(markerPath: string): BridgeDiagnostic;
22
78
  call(method: string, args: unknown[]): Promise<unknown>;
23
79
  }
24
80
  //# sourceMappingURL=relay.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"relay.d.ts","sourceRoot":"","sources":["../../src/transports/relay.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAA;AAC7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AAEzD,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAA;IACZ,gBAAgB,CAAC,EAAE,MAAM,IAAI,CAAA;IAC7B,mBAAmB,CAAC,EAAE,MAAM,IAAI,CAAA;CACjC;AAOD,qBAAa,uBAAwB,YAAW,cAAc;IAC5D,OAAO,CAAC,QAAQ,CAA+B;IAC/C,OAAO,CAAC,SAAS,CAAyB;IAC1C,OAAO,CAAC,OAAO,CAAoC;IACnD,OAAO,CAAC,SAAS,CAA4B;IAC7C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAQ;IAC7B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAY;IACvC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAY;gBAE9B,IAAI,EAAE,qBAAqB;IAMvC,aAAa,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI;IAItC,KAAK,IAAI,IAAI;IA4Bb,IAAI,IAAI,IAAI;IAQZ,WAAW,IAAI,OAAO;IAItB,eAAe,IAAI,OAAO;IAIpB,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC;CAkB9D"}
1
+ {"version":3,"file":"relay.d.ts","sourceRoot":"","sources":["../../src/transports/relay.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,IAAI,UAAU,EAAE,MAAM,WAAW,CAAA;AAGrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAA;AAC7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AAEzD;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,OAAO,CAAA;IAClB,MAAM,EAAE;QACN,4CAA4C;QAC5C,OAAO,EAAE,OAAO,CAAA;QAChB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;KACpB,CAAA;IACD,OAAO,EAAE;QACP,qFAAqF;QACrF,aAAa,EAAE,MAAM,CAAA;KACtB,CAAA;IACD,SAAS,EAAE;QACT,OAAO,EAAE,OAAO,CAAA;QAChB,IAAI,EAAE,MAAM,CAAA;QACZ,uEAAuE;QACvE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;KACtB,CAAA;IACD,YAAY,EAAE,MAAM,CAAA;CACrB;AAED;;;;;GAKG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,QAAQ,CAAC,UAAU,EAAE,gBAAgB,CAAA;gBACzB,UAAU,EAAE,gBAAgB;CAKzC;AAuCD,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,UAAU,CAAA;IACrB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,gBAAgB,CAAC,EAAE,MAAM,IAAI,CAAA;IAC7B,mBAAmB,CAAC,EAAE,MAAM,IAAI,CAAA;CACjC;AAOD,qBAAa,uBAAwB,YAAW,cAAc;IAC5D,OAAO,CAAC,QAAQ,CAA+B;IAC/C,OAAO,CAAC,SAAS,CAAyB;IAC1C,OAAO,CAAC,OAAO,CAAoC;IACnD,OAAO,CAAC,SAAS,CAA4B;IAC7C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAoB;IACzC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAwB;IACjD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAQ;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAY;IACvC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAY;gBAE9B,IAAI,EAAE,qBAAqB;IAQvC,aAAa,CAAC,GAAG,EAAE,YAAY,GAAG,IAAI;IAItC,KAAK,IAAI,IAAI;IA4Cb,IAAI,IAAI,IAAI;IAQZ,WAAW,IAAI,OAAO;IAItB,eAAe,IAAI,OAAO;IAI1B;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,gBAAgB;IA4BxC,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,CAAC;CAqB9D"}
@@ -1,15 +1,57 @@
1
1
  import { WebSocketServer } from 'ws';
2
+ import { existsSync, readFileSync } from 'node:fs';
2
3
  import { randomUUID } from 'node:crypto';
4
+ /**
5
+ * Error thrown when a tool call needs the browser relay but no browser
6
+ * is attached. Carries a `diagnostic` payload the MCP handler can
7
+ * surface as `isError: true` tool content — no need for Claude (or any
8
+ * MCP client) to grep plugin source to guess what's wrong.
9
+ */
10
+ export class RelayUnavailableError extends Error {
11
+ diagnostic;
12
+ constructor(diagnostic) {
13
+ super(diagnostic.suggestedFix);
14
+ this.name = 'RelayUnavailableError';
15
+ this.diagnostic = diagnostic;
16
+ }
17
+ }
18
+ function buildSuggestedFix(state) {
19
+ if (!state.bridgeRunning) {
20
+ return ('The MCP bridge server is not running. Start @llui/mcp — either via the ' +
21
+ 'Vite plugin (install @llui/mcp as a dev dep and the plugin will auto-spawn ' +
22
+ 'it on `pnpm dev`) or manually with `npx llui-mcp`.');
23
+ }
24
+ if (!state.markerPresent) {
25
+ return ('The bridge is running but no active-marker file exists — internal ' +
26
+ 'state mismatch. Restart the MCP server and retry.');
27
+ }
28
+ if (state.devUrl === null) {
29
+ return ("The marker file exists but the Vite plugin hasn't stamped its dev URL " +
30
+ "(so the plugin probably isn't opted in). Check vite.config.ts: ensure " +
31
+ '`llui()` is in plugins and mcpPort is not set to false. Then restart ' +
32
+ 'Vite and load the app in a browser.');
33
+ }
34
+ if (state.tabsConnected === 0) {
35
+ return (`The bridge is running and the Vite plugin is opted in, but no browser ` +
36
+ `tab is attached. Open ${state.devUrl} (or reload the tab if already open). ` +
37
+ 'The browser relay connects on page load.');
38
+ }
39
+ return 'Unknown state — bridge running, browser attached, yet the call failed.';
40
+ }
3
41
  export class WebSocketRelayTransport {
4
42
  wsServer = null;
5
43
  browserWs = null;
6
44
  pending = new Map();
7
45
  directApi = null;
8
46
  port;
47
+ attachTo;
48
+ markerPath;
9
49
  onConnect;
10
50
  onDisconnect;
11
51
  constructor(opts) {
12
52
  this.port = opts.port;
53
+ this.attachTo = opts.attachTo;
54
+ this.markerPath = opts.markerPath ?? '';
13
55
  this.onConnect = opts.onBrowserConnect;
14
56
  this.onDisconnect = opts.onBrowserDisconnect;
15
57
  }
@@ -19,7 +61,26 @@ export class WebSocketRelayTransport {
19
61
  start() {
20
62
  if (this.wsServer)
21
63
  return;
22
- this.wsServer = new WebSocketServer({ port: this.port, host: '127.0.0.1' });
64
+ // Two modes:
65
+ // - standalone (stdio MCP transport): own server on `port`.
66
+ // - attached (HTTP MCP transport): share port with MCP's http.Server
67
+ // via upgrade routing on `/bridge`.
68
+ if (this.attachTo) {
69
+ this.wsServer = new WebSocketServer({ noServer: true });
70
+ this.attachTo.on('upgrade', (req, socket, head) => {
71
+ if (req.url !== '/bridge')
72
+ return;
73
+ this.wsServer.handleUpgrade(req, socket, head, (ws) => {
74
+ this.wsServer.emit('connection', ws, req);
75
+ });
76
+ });
77
+ }
78
+ else if (this.port !== undefined) {
79
+ this.wsServer = new WebSocketServer({ port: this.port, host: '127.0.0.1' });
80
+ }
81
+ else {
82
+ throw new Error('WebSocketRelayTransport: provide either `port` or `attachTo`.');
83
+ }
23
84
  this.wsServer.on('connection', (ws) => {
24
85
  this.browserWs = ws;
25
86
  this.onConnect?.();
@@ -62,6 +123,40 @@ export class WebSocketRelayTransport {
62
123
  isServerRunning() {
63
124
  return this.wsServer !== null;
64
125
  }
126
+ /**
127
+ * Build a snapshot of the bridge state for diagnostics. Called when a
128
+ * tool call fails because the browser isn't attached — the payload
129
+ * goes straight into `RelayUnavailableError.diagnostic` for the
130
+ * client to surface.
131
+ */
132
+ diagnose(markerPath) {
133
+ const connected = this.isAvailable();
134
+ const tabsConnected = this.browserWs !== null ? 1 : 0;
135
+ const markerPresent = existsSync(markerPath);
136
+ let devUrl = null;
137
+ if (markerPresent) {
138
+ try {
139
+ const payload = JSON.parse(readFileSync(markerPath, 'utf8'));
140
+ devUrl = typeof payload.devUrl === 'string' ? payload.devUrl : null;
141
+ }
142
+ catch {
143
+ // Ignore malformed markers — leaves devUrl null.
144
+ }
145
+ }
146
+ const suggestedFix = buildSuggestedFix({
147
+ bridgeRunning: this.wsServer !== null,
148
+ tabsConnected,
149
+ markerPresent,
150
+ devUrl,
151
+ });
152
+ return {
153
+ connected,
154
+ bridge: { running: this.wsServer !== null, port: this.port ?? null },
155
+ browser: { tabsConnected },
156
+ mcpMarker: { present: markerPresent, path: markerPath, devUrl },
157
+ suggestedFix,
158
+ };
159
+ }
65
160
  async call(method, args) {
66
161
  if (this.directApi) {
67
162
  const fn = this.directApi[method];
@@ -70,7 +165,10 @@ export class WebSocketRelayTransport {
70
165
  return fn.apply(this.directApi, args);
71
166
  }
72
167
  if (!this.browserWs) {
73
- throw new Error('No browser connected to the MCP bridge. Start your dev server.');
168
+ // Caller will typically catch + surface the diagnostic via the
169
+ // MCP tool-call error path. Throwing the structured error keeps
170
+ // the runtime contract simple — synchronous failure with context.
171
+ throw new RelayUnavailableError(this.diagnose(this.markerPath || 'unknown'));
74
172
  }
75
173
  const id = randomUUID();
76
174
  return new Promise((resolve, reject) => {
@@ -1 +1 @@
1
- {"version":3,"file":"relay.js","sourceRoot":"","sources":["../../src/transports/relay.ts"],"names":[],"mappings":"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","sourcesContent":["import { WebSocketServer, type WebSocket } from 'ws'\nimport { randomUUID } from 'node:crypto'\nimport type { LluiDebugAPI } from '@llui/dom'\nimport type { RelayTransport } from '../tool-registry.js'\n\nexport interface RelayTransportOptions {\n port: number\n onBrowserConnect?: () => void\n onBrowserDisconnect?: () => void\n}\n\ninterface PendingRequest {\n resolve: (v: unknown) => void\n reject: (e: Error) => void\n}\n\nexport class WebSocketRelayTransport implements RelayTransport {\n private wsServer: WebSocketServer | null = null\n private browserWs: WebSocket | null = null\n private pending = new Map<string, PendingRequest>()\n private directApi: LluiDebugAPI | null = null\n private readonly port: number\n private readonly onConnect?: () => void\n private readonly onDisconnect?: () => void\n\n constructor(opts: RelayTransportOptions) {\n this.port = opts.port\n this.onConnect = opts.onBrowserConnect\n this.onDisconnect = opts.onBrowserDisconnect\n }\n\n connectDirect(api: LluiDebugAPI): void {\n this.directApi = api\n }\n\n start(): void {\n if (this.wsServer) return\n this.wsServer = new WebSocketServer({ port: this.port, host: '127.0.0.1' })\n this.wsServer.on('connection', (ws) => {\n this.browserWs = ws\n this.onConnect?.()\n ws.on('message', (raw) => {\n let msg: { id: string; result?: unknown; error?: string }\n try {\n msg = JSON.parse(String(raw))\n } catch {\n return\n }\n const p = this.pending.get(msg.id)\n if (!p) return\n this.pending.delete(msg.id)\n if (msg.error) p.reject(new Error(msg.error))\n else p.resolve(msg.result)\n })\n ws.on('close', () => {\n if (this.browserWs === ws) {\n this.browserWs = null\n this.onDisconnect?.()\n }\n })\n })\n }\n\n stop(): void {\n this.wsServer?.close()\n this.wsServer = null\n this.browserWs = null\n for (const p of this.pending.values()) p.reject(new Error('relay closed'))\n this.pending.clear()\n }\n\n isAvailable(): boolean {\n return this.directApi !== null || this.browserWs !== null\n }\n\n isServerRunning(): boolean {\n return this.wsServer !== null\n }\n\n async call(method: string, args: unknown[]): Promise<unknown> {\n if (this.directApi) {\n const fn = (this.directApi as unknown as Record<string, unknown>)[method]\n if (typeof fn !== 'function') throw new Error(`unknown method: ${method}`)\n return (fn as (...a: unknown[]) => unknown).apply(this.directApi, args)\n }\n if (!this.browserWs) {\n throw new Error('No browser connected to the MCP bridge. Start your dev server.')\n }\n const id = randomUUID()\n return new Promise((resolve, reject) => {\n this.pending.set(id, { resolve, reject })\n this.browserWs!.send(JSON.stringify({ id, method, args }))\n setTimeout(() => {\n if (this.pending.delete(id)) reject(new Error(`timeout: ${method}`))\n }, 5000)\n })\n }\n}\n"]}
1
+ {"version":3,"file":"relay.js","sourceRoot":"","sources":["../../src/transports/relay.ts"],"names":[],"mappings":"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","sourcesContent":["import { WebSocketServer, type WebSocket } from 'ws'\nimport type { Server as HttpServer } from 'node:http'\nimport { existsSync, readFileSync } from 'node:fs'\nimport { randomUUID } from 'node:crypto'\nimport type { LluiDebugAPI } from '@llui/dom'\nimport type { RelayTransport } from '../tool-registry.js'\n\n/**\n * Structured snapshot of the bridge state at a single point in time.\n * Returned by `RelayUnavailableError.diagnostic` so tool callers can\n * see WHY the browser isn't reachable without grepping plugin source.\n */\nexport interface BridgeDiagnostic {\n connected: boolean\n bridge: {\n /** Is the WS server bound and listening? */\n running: boolean\n port: number | null\n }\n browser: {\n /** Currently connected browser tabs (0 or 1 — the bridge is single-client today). */\n tabsConnected: number\n }\n mcpMarker: {\n present: boolean\n path: string\n /** Set by the Vite plugin once Vite's httpServer emits `listening`. */\n devUrl: string | null\n }\n suggestedFix: string\n}\n\n/**\n * Error thrown when a tool call needs the browser relay but no browser\n * is attached. Carries a `diagnostic` payload the MCP handler can\n * surface as `isError: true` tool content — no need for Claude (or any\n * MCP client) to grep plugin source to guess what's wrong.\n */\nexport class RelayUnavailableError extends Error {\n readonly diagnostic: BridgeDiagnostic\n constructor(diagnostic: BridgeDiagnostic) {\n super(diagnostic.suggestedFix)\n this.name = 'RelayUnavailableError'\n this.diagnostic = diagnostic\n }\n}\n\nfunction buildSuggestedFix(state: {\n bridgeRunning: boolean\n tabsConnected: number\n markerPresent: boolean\n devUrl: string | null\n}): string {\n if (!state.bridgeRunning) {\n return (\n 'The MCP bridge server is not running. Start @llui/mcp — either via the ' +\n 'Vite plugin (install @llui/mcp as a dev dep and the plugin will auto-spawn ' +\n 'it on `pnpm dev`) or manually with `npx llui-mcp`.'\n )\n }\n if (!state.markerPresent) {\n return (\n 'The bridge is running but no active-marker file exists — internal ' +\n 'state mismatch. Restart the MCP server and retry.'\n )\n }\n if (state.devUrl === null) {\n return (\n \"The marker file exists but the Vite plugin hasn't stamped its dev URL \" +\n \"(so the plugin probably isn't opted in). Check vite.config.ts: ensure \" +\n '`llui()` is in plugins and mcpPort is not set to false. Then restart ' +\n 'Vite and load the app in a browser.'\n )\n }\n if (state.tabsConnected === 0) {\n return (\n `The bridge is running and the Vite plugin is opted in, but no browser ` +\n `tab is attached. Open ${state.devUrl} (or reload the tab if already open). ` +\n 'The browser relay connects on page load.'\n )\n }\n return 'Unknown state — bridge running, browser attached, yet the call failed.'\n}\n\nexport interface RelayTransportOptions {\n /**\n * Either `port` (stdio mode — relay owns its own WS server) or\n * `attachTo` (HTTP mode — relay upgrades on an existing http.Server so\n * the MCP HTTP endpoint and the browser bridge share a single port).\n */\n port?: number\n attachTo?: HttpServer\n /**\n * Filesystem path to the MCP active-marker file. Used by `diagnose()`\n * to check whether the Vite plugin has written the marker (indicating\n * it's opted in) and to read back the `devUrl` it stamped.\n */\n markerPath?: string\n onBrowserConnect?: () => void\n onBrowserDisconnect?: () => void\n}\n\ninterface PendingRequest {\n resolve: (v: unknown) => void\n reject: (e: Error) => void\n}\n\nexport class WebSocketRelayTransport implements RelayTransport {\n private wsServer: WebSocketServer | null = null\n private browserWs: WebSocket | null = null\n private pending = new Map<string, PendingRequest>()\n private directApi: LluiDebugAPI | null = null\n private readonly port: number | undefined\n private readonly attachTo: HttpServer | undefined\n private readonly markerPath: string\n private readonly onConnect?: () => void\n private readonly onDisconnect?: () => void\n\n constructor(opts: RelayTransportOptions) {\n this.port = opts.port\n this.attachTo = opts.attachTo\n this.markerPath = opts.markerPath ?? ''\n this.onConnect = opts.onBrowserConnect\n this.onDisconnect = opts.onBrowserDisconnect\n }\n\n connectDirect(api: LluiDebugAPI): void {\n this.directApi = api\n }\n\n start(): void {\n if (this.wsServer) return\n // Two modes:\n // - standalone (stdio MCP transport): own server on `port`.\n // - attached (HTTP MCP transport): share port with MCP's http.Server\n // via upgrade routing on `/bridge`.\n if (this.attachTo) {\n this.wsServer = new WebSocketServer({ noServer: true })\n this.attachTo.on('upgrade', (req, socket, head) => {\n if (req.url !== '/bridge') return\n this.wsServer!.handleUpgrade(req, socket, head, (ws) => {\n this.wsServer!.emit('connection', ws, req)\n })\n })\n } else if (this.port !== undefined) {\n this.wsServer = new WebSocketServer({ port: this.port, host: '127.0.0.1' })\n } else {\n throw new Error('WebSocketRelayTransport: provide either `port` or `attachTo`.')\n }\n this.wsServer.on('connection', (ws) => {\n this.browserWs = ws\n this.onConnect?.()\n ws.on('message', (raw) => {\n let msg: { id: string; result?: unknown; error?: string }\n try {\n msg = JSON.parse(String(raw))\n } catch {\n return\n }\n const p = this.pending.get(msg.id)\n if (!p) return\n this.pending.delete(msg.id)\n if (msg.error) p.reject(new Error(msg.error))\n else p.resolve(msg.result)\n })\n ws.on('close', () => {\n if (this.browserWs === ws) {\n this.browserWs = null\n this.onDisconnect?.()\n }\n })\n })\n }\n\n stop(): void {\n this.wsServer?.close()\n this.wsServer = null\n this.browserWs = null\n for (const p of this.pending.values()) p.reject(new Error('relay closed'))\n this.pending.clear()\n }\n\n isAvailable(): boolean {\n return this.directApi !== null || this.browserWs !== null\n }\n\n isServerRunning(): boolean {\n return this.wsServer !== null\n }\n\n /**\n * Build a snapshot of the bridge state for diagnostics. Called when a\n * tool call fails because the browser isn't attached — the payload\n * goes straight into `RelayUnavailableError.diagnostic` for the\n * client to surface.\n */\n diagnose(markerPath: string): BridgeDiagnostic {\n const connected = this.isAvailable()\n const tabsConnected = this.browserWs !== null ? 1 : 0\n const markerPresent = existsSync(markerPath)\n let devUrl: string | null = null\n if (markerPresent) {\n try {\n const payload = JSON.parse(readFileSync(markerPath, 'utf8')) as { devUrl?: string }\n devUrl = typeof payload.devUrl === 'string' ? payload.devUrl : null\n } catch {\n // Ignore malformed markers — leaves devUrl null.\n }\n }\n const suggestedFix = buildSuggestedFix({\n bridgeRunning: this.wsServer !== null,\n tabsConnected,\n markerPresent,\n devUrl,\n })\n return {\n connected,\n bridge: { running: this.wsServer !== null, port: this.port ?? null },\n browser: { tabsConnected },\n mcpMarker: { present: markerPresent, path: markerPath, devUrl },\n suggestedFix,\n }\n }\n\n async call(method: string, args: unknown[]): Promise<unknown> {\n if (this.directApi) {\n const fn = (this.directApi as unknown as Record<string, unknown>)[method]\n if (typeof fn !== 'function') throw new Error(`unknown method: ${method}`)\n return (fn as (...a: unknown[]) => unknown).apply(this.directApi, args)\n }\n if (!this.browserWs) {\n // Caller will typically catch + surface the diagnostic via the\n // MCP tool-call error path. Throwing the structured error keeps\n // the runtime contract simple — synchronous failure with context.\n throw new RelayUnavailableError(this.diagnose(this.markerPath || 'unknown'))\n }\n const id = randomUUID()\n return new Promise((resolve, reject) => {\n this.pending.set(id, { resolve, reject })\n this.browserWs!.send(JSON.stringify({ id, method, args }))\n setTimeout(() => {\n if (this.pending.delete(id)) reject(new Error(`timeout: ${method}`))\n }, 5000)\n })\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llui/mcp",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "bin": {
@@ -16,8 +16,9 @@
16
16
  "dist"
17
17
  ],
18
18
  "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.29.0",
19
20
  "ws": "^8.18.0",
20
- "@llui/dom": "0.0.21",
21
+ "@llui/dom": "0.0.23",
21
22
  "@llui/lint-idiomatic": "0.0.12"
22
23
  },
23
24
  "devDependencies": {