@vscode/component-explorer-cli 0.1.1-7 → 0.1.1-8

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.
Files changed (65) hide show
  1. package/dist/commands/mcpCommand.d.ts +2 -0
  2. package/dist/commands/mcpCommand.d.ts.map +1 -1
  3. package/dist/commands/mcpCommand.js +35 -6
  4. package/dist/commands/mcpCommand.js.map +1 -1
  5. package/dist/commands/serveCommand.d.ts +3 -0
  6. package/dist/commands/serveCommand.d.ts.map +1 -1
  7. package/dist/commands/serveCommand.js +52 -3
  8. package/dist/commands/serveCommand.js.map +1 -1
  9. package/dist/component-explorer-config.schema.json +183 -0
  10. package/dist/componentExplorer.d.ts +10 -0
  11. package/dist/componentExplorer.d.ts.map +1 -1
  12. package/dist/componentExplorer.js +48 -2
  13. package/dist/componentExplorer.js.map +1 -1
  14. package/dist/daemon/DaemonService.d.ts +26 -4
  15. package/dist/daemon/DaemonService.d.ts.map +1 -1
  16. package/dist/daemon/DaemonService.js +39 -3
  17. package/dist/daemon/DaemonService.js.map +1 -1
  18. package/dist/daemon/lifecycle.js +2 -2
  19. package/dist/daemon/lifecycle.js.map +1 -1
  20. package/dist/daemon/pipeClient.js +3 -3
  21. package/dist/daemon/pipeClient.js.map +1 -1
  22. package/dist/daemon/pipeServer.js +2 -2
  23. package/dist/daemon/pipeServer.js.map +1 -1
  24. package/dist/dependencyInstaller.js +1 -1
  25. package/dist/dependencyInstaller.js.map +1 -1
  26. package/dist/external/vscode-observables/observables/dist/disposables.js +24 -1
  27. package/dist/external/vscode-observables/observables/dist/disposables.js.map +1 -1
  28. package/dist/external/vscode-observables/observables/dist/observableInternal/commonFacade/deps.js +1 -4
  29. package/dist/external/vscode-observables/observables/dist/observableInternal/commonFacade/deps.js.map +1 -1
  30. package/dist/external/vscode-observables/observables/dist/observableInternal/index.js +2 -5
  31. package/dist/external/vscode-observables/observables/dist/observableInternal/index.js.map +1 -1
  32. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/consoleObservableLogger.js +30 -6
  33. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/consoleObservableLogger.js.map +1 -1
  34. package/dist/external/vscode-observables/observables/dist/observableInternal/observables/baseObservable.js +1 -1
  35. package/dist/external/vscode-observables/observables/dist/observableInternal/observables/baseObservable.js.map +1 -1
  36. package/dist/external/vscode-observables/observables/dist/observableInternal/observables/derived.js +12 -1
  37. package/dist/external/vscode-observables/observables/dist/observableInternal/observables/derived.js.map +1 -1
  38. package/dist/external/vscode-observables/observables/dist/observableInternal/utils/utilsCancellation.js +55 -0
  39. package/dist/external/vscode-observables/observables/dist/observableInternal/utils/utilsCancellation.js.map +1 -0
  40. package/dist/git/gitUtils.js +1 -1
  41. package/dist/git/gitUtils.js.map +1 -1
  42. package/dist/mcp/McpServer.d.ts +29 -4
  43. package/dist/mcp/McpServer.d.ts.map +1 -1
  44. package/dist/mcp/McpServer.js +393 -81
  45. package/dist/mcp/McpServer.js.map +1 -1
  46. package/dist/packages/simple-api/dist/{chunk-A5PE72HI.js → chunk-Q24JOMNK.js} +7 -1
  47. package/dist/packages/simple-api/dist/chunk-Q24JOMNK.js.map +1 -0
  48. package/dist/utils.d.ts +20 -0
  49. package/dist/utils.d.ts.map +1 -1
  50. package/dist/utils.js +22 -1
  51. package/dist/utils.js.map +1 -1
  52. package/dist/watchConfig.d.ts +36 -0
  53. package/dist/watchConfig.d.ts.map +1 -1
  54. package/dist/watchConfig.js +32 -22
  55. package/dist/watchConfig.js.map +1 -1
  56. package/package.json +6 -4
  57. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/debuggerRpc.js +0 -72
  58. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/debuggerRpc.js.map +0 -1
  59. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/devToolsLogger.js +0 -447
  60. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/devToolsLogger.js.map +0 -1
  61. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/rpc.js +0 -64
  62. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/rpc.js.map +0 -1
  63. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/utils.js +0 -52
  64. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/utils.js.map +0 -1
  65. package/dist/packages/simple-api/dist/chunk-A5PE72HI.js.map +0 -1
@@ -1,6 +1,14 @@
1
1
  import { z } from 'zod';
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import '../external/vscode-observables/observables/dist/observableInternal/index.js';
5
+ import { Disposable } from '../external/vscode-observables/observables/dist/disposables.js';
6
+ import '../external/vscode-observables/observables/dist/observableInternal/debugLocation.js';
7
+ import { autorun } from '../external/vscode-observables/observables/dist/observableInternal/reactions/autorun.js';
8
+ import '../external/vscode-observables/observables/dist/observableInternal/observables/derived.js';
9
+ import '../external/vscode-observables/observables/dist/observableInternal/utils/utils.js';
10
+ import '../external/vscode-observables/observables/dist/observableInternal/observables/observableFromEvent.js';
11
+ import { buildExplorerUrl } from '../utils.js';
4
12
 
5
13
  // ---------------------------------------------------------------------------
6
14
  // Client-local state
@@ -40,43 +48,141 @@ class WatchList {
40
48
  };
41
49
  }
42
50
  }
51
+ function noDaemonError(hint) {
52
+ let text = 'Error: No daemon is currently running.';
53
+ if (hint) {
54
+ text += ` ${hint}`;
55
+ }
56
+ else {
57
+ text += ' Please start the Component Explorer daemon first by running:\n\n' +
58
+ ' component-explorer serve --project <config.json>\n\n' +
59
+ 'Or start it in the background:\n\n' +
60
+ ' component-explorer serve --project <config.json> --background\n\n' +
61
+ 'The daemon manages dev servers and enables fixture screenshots.';
62
+ }
63
+ return {
64
+ content: [{ type: 'text', text }],
65
+ isError: true,
66
+ };
67
+ }
68
+ // ---------------------------------------------------------------------------
69
+ // DaemonConnection - wrapper to avoid Proxy issues with observables
70
+ // ---------------------------------------------------------------------------
71
+ class DaemonConnection {
72
+ client;
73
+ constructor(client) {
74
+ this.client = client;
75
+ }
76
+ }
43
77
  // ---------------------------------------------------------------------------
44
78
  // ComponentExplorerMcpServer
45
79
  // ---------------------------------------------------------------------------
46
- class ComponentExplorerMcpServer {
47
- _daemon;
80
+ class ComponentExplorerMcpServer extends Disposable {
81
+ _daemonConnection;
82
+ static async create(daemon, options) {
83
+ const server = new ComponentExplorerMcpServer(daemon, options ?? {});
84
+ const transport = new StdioServerTransport();
85
+ await server._mcp.connect(transport);
86
+ return server;
87
+ }
48
88
  _mcp;
49
89
  _watchList = new WatchList();
90
+ _pollFn;
91
+ _noAutostartHint;
92
+ _multiSessionTools = [];
50
93
  _sessions = [];
51
- constructor(_daemon) {
52
- this._daemon = _daemon;
94
+ _eventStreamAbortController;
95
+ constructor(_daemonConnection, options) {
96
+ super();
97
+ this._daemonConnection = _daemonConnection;
98
+ this._pollFn = options.pollFn;
99
+ this._noAutostartHint = options.noAutostartHint;
53
100
  this._mcp = new McpServer({
54
101
  name: 'component-explorer',
55
102
  version: '0.1.0',
56
103
  });
57
104
  this._registerTools();
105
+ this._store.add(autorun(async (reader) => {
106
+ const conn = this._daemonConnection.read(reader);
107
+ await this._onDaemonChanged(conn?.client);
108
+ }));
58
109
  }
59
- async connect() {
60
- this._sessions = await this._daemon.methods.sessions();
61
- this._startEventListener();
62
- const transport = new StdioServerTransport();
63
- await this._mcp.connect(transport);
110
+ async _onDaemonChanged(daemon) {
111
+ // Cancel any existing event stream
112
+ this._eventStreamAbortController?.abort();
113
+ this._eventStreamAbortController = undefined;
114
+ if (!daemon) {
115
+ this._sessions = [];
116
+ this._log('info', { type: 'daemon-disconnected' });
117
+ return;
118
+ }
119
+ // Fetch sessions and start event listener
120
+ try {
121
+ this._sessions = await daemon.methods.sessions();
122
+ this._log('info', { type: 'daemon-connected', sessions: this._sessions.length });
123
+ this._updateMultiSessionToolVisibility();
124
+ this._startEventListener(daemon);
125
+ }
126
+ catch (e) {
127
+ this._log('info', { type: 'daemon-error', error: String(e) });
128
+ this._sessions = [];
129
+ }
130
+ }
131
+ _getDaemon() {
132
+ return this._daemonConnection.get()?.client;
133
+ }
134
+ /**
135
+ * If no daemon is connected and we have a pollFn, poll immediately and wait
136
+ * up to 3 seconds for the daemon to become available.
137
+ */
138
+ async _waitForDaemon() {
139
+ let daemon = this._getDaemon();
140
+ if (daemon || !this._pollFn) {
141
+ return daemon;
142
+ }
143
+ // Poll immediately and wait up to 3 seconds
144
+ const startTime = Date.now();
145
+ const timeout = 3000;
146
+ while (Date.now() - startTime < timeout) {
147
+ await this._pollFn();
148
+ daemon = this._getDaemon();
149
+ if (daemon) {
150
+ return daemon;
151
+ }
152
+ // Wait 200ms before next poll
153
+ await new Promise(resolve => setTimeout(resolve, 200));
154
+ }
155
+ return undefined;
156
+ }
157
+ _noDaemonError() {
158
+ return noDaemonError(this._noAutostartHint);
64
159
  }
65
160
  _log(level, data) {
66
161
  this._mcp.sendLoggingMessage({ level, logger: 'daemon', data }).catch(() => { });
67
162
  }
68
- async _startEventListener() {
69
- const stream = await this._daemon.methods.events();
163
+ _startEventListener(daemon) {
164
+ const controller = new AbortController();
165
+ this._eventStreamAbortController = controller;
70
166
  (async () => {
71
- for await (const raw of stream) {
72
- const event = raw;
73
- if (event.type === 'source-change' && event.sessionName && event.sourceTreeId) {
74
- this._updateSessionSourceTreeId(event.sessionName, event.sourceTreeId);
167
+ try {
168
+ const stream = await daemon.methods.events();
169
+ for await (const raw of stream) {
170
+ if (controller.signal.aborted)
171
+ break;
172
+ const event = raw;
173
+ if (event.type === 'source-change' && event.sessionName && event.sourceTreeId) {
174
+ this._updateSessionSourceTreeId(event.sessionName, event.sourceTreeId);
175
+ }
176
+ if (event.type === 'ref-change') {
177
+ await this._refreshSessions();
178
+ }
179
+ this._log(event.type === 'log' && event.level === 'debug' ? 'debug' : 'info', event);
75
180
  }
76
- if (event.type === 'ref-change') {
77
- await this._refreshSessions();
181
+ }
182
+ catch (e) {
183
+ if (!controller.signal.aborted) {
184
+ this._log('info', { type: 'event-stream-error', error: String(e) });
78
185
  }
79
- this._log(event.type === 'log' && event.level === 'debug' ? 'debug' : 'info', event);
80
186
  }
81
187
  })();
82
188
  }
@@ -94,7 +200,7 @@ class ComponentExplorerMcpServer {
94
200
  }
95
201
  _sourceTreeId(sessionName) {
96
202
  const s = this._sessions.find(s => s.name === sessionName);
97
- return s?.sourceTreeId ?? '';
203
+ return s && !s.isLoading ? s.sourceTreeId : '';
98
204
  }
99
205
  _updateSessionSourceTreeId(sessionName, sourceTreeId) {
100
206
  const s = this._sessions.find(s => s.name === sessionName);
@@ -103,7 +209,25 @@ class ComponentExplorerMcpServer {
103
209
  }
104
210
  }
105
211
  async _refreshSessions() {
106
- this._sessions = await this._daemon.methods.sessions();
212
+ const daemon = this._getDaemon();
213
+ if (daemon) {
214
+ const prevCount = this._sessions.length;
215
+ this._sessions = await daemon.methods.sessions();
216
+ if (this._sessions.length !== prevCount) {
217
+ this._updateMultiSessionToolVisibility();
218
+ }
219
+ }
220
+ }
221
+ _updateMultiSessionToolVisibility() {
222
+ const isMultiSession = this._sessions.length > 1;
223
+ for (const tool of this._multiSessionTools) {
224
+ if (isMultiSession) {
225
+ tool.enable();
226
+ }
227
+ else {
228
+ tool.disable();
229
+ }
230
+ }
107
231
  }
108
232
  async _withSourceTreeRetry(fn) {
109
233
  try {
@@ -125,12 +249,14 @@ class ComponentExplorerMcpServer {
125
249
  this._registerCompareScreenshot();
126
250
  this._registerApproveDiff();
127
251
  this._registerEvaluateJs();
252
+ this._registerDebugReloadPage();
128
253
  this._registerWatchAdd();
129
254
  this._registerWatchRemove();
130
255
  this._registerWatchSet();
131
256
  this._registerWatchCompare();
132
257
  this._registerWaitForUpdate();
133
258
  this._registerSessions();
259
+ this._registerGetUrl();
134
260
  }
135
261
  _registerListFixtures() {
136
262
  this._mcp.registerTool('list_fixtures', {
@@ -141,10 +267,14 @@ class ComponentExplorerMcpServer {
141
267
  },
142
268
  annotations: { readOnlyHint: true },
143
269
  }, async (args) => {
270
+ const daemon = await this._waitForDaemon();
271
+ if (!daemon) {
272
+ return this._noDaemonError();
273
+ }
144
274
  const sessionName = args.sessionName ?? this._defaultSessionName();
145
275
  return this._withSourceTreeRetry(async () => {
146
276
  const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
147
- const fixtures = await this._daemon.methods.fixtures.list({ sessionName, sourceTreeId });
277
+ const fixtures = await daemon.methods.fixtures.list({ sessionName, sourceTreeId });
148
278
  return {
149
279
  content: [{ type: 'text', text: JSON.stringify(fixtures, null, 2) }],
150
280
  };
@@ -153,22 +283,29 @@ class ComponentExplorerMcpServer {
153
283
  }
154
284
  _registerScreenshot() {
155
285
  this._mcp.registerTool('screenshot', {
156
- description: 'Take a screenshot of a single fixture',
286
+ description: 'Take a screenshot of a single fixture. ' +
287
+ 'When stabilityCheck is true, the fixture is unmounted and re-mounted, then three screenshots are taken. ',
157
288
  inputSchema: {
158
289
  fixtureId: z.string().describe('The fixture ID'),
159
290
  sessionName: z.string().optional().describe('Session name (defaults to first session)'),
160
291
  sourceTreeId: z.string().optional().describe('Source tree ID (defaults to latest known)'),
292
+ stabilityCheck: z.boolean().optional().describe('If true, takes three screenshots (at render time, ~500ms, ~3000ms) to check visual stability. Expensive (~3s extra) — only use it when you need to verify rendering stability..'),
161
293
  },
162
294
  annotations: { readOnlyHint: true },
163
295
  }, async (args) => {
296
+ const daemon = await this._waitForDaemon();
297
+ if (!daemon) {
298
+ return this._noDaemonError();
299
+ }
164
300
  const sessionName = args.sessionName ?? this._defaultSessionName();
165
301
  return this._withSourceTreeRetry(async () => {
166
302
  const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
167
- const result = await this._daemon.methods.screenshots.take({
303
+ const result = await daemon.methods.screenshots.take({
168
304
  fixtureId: args.fixtureId,
169
305
  sessionName,
170
306
  sourceTreeId,
171
307
  includeImage: true,
308
+ stabilityCheck: args.stabilityCheck,
172
309
  });
173
310
  const r = result;
174
311
  this._updateSessionSourceTreeId(sessionName, r.sourceTreeId);
@@ -179,18 +316,34 @@ class ComponentExplorerMcpServer {
179
316
  if (r.errors && r.errors.length > 0) {
180
317
  info.errors = r.errors;
181
318
  }
182
- const content = [
183
- { type: 'text', text: JSON.stringify(info, null, 2) },
184
- ];
185
- if (r.image) {
319
+ if (r.isStable !== undefined) {
320
+ info.isStable = r.isStable;
321
+ }
322
+ const content = [];
323
+ if (r.isStable === false && r.stabilityScreenshots) {
324
+ // Not stable: return all distinct screenshots
325
+ const seenHashes = new Set();
326
+ const screenshotDetails = [];
327
+ for (const s of r.stabilityScreenshots) {
328
+ screenshotDetails.push({ hash: s.hash, delayMs: s.delayMs });
329
+ if (!seenHashes.has(s.hash) && s.image) {
330
+ seenHashes.add(s.hash);
331
+ content.push({ type: 'image', data: s.image, mimeType: 'image/png' });
332
+ }
333
+ }
334
+ info.stabilityScreenshots = screenshotDetails;
335
+ }
336
+ else if (r.image) {
337
+ // Stable or no stability check: return single image
186
338
  content.push({ type: 'image', data: r.image, mimeType: 'image/png' });
187
339
  }
340
+ content.unshift({ type: 'text', text: JSON.stringify(info, null, 2) });
188
341
  return { content };
189
342
  });
190
343
  });
191
344
  }
192
345
  _registerCompareScreenshot() {
193
- this._mcp.registerTool('compare_screenshot', {
346
+ const tool = this._mcp.registerTool('compare_screenshot', {
194
347
  description: 'Compare a fixture\'s screenshot across two sessions (e.g. baseline vs current)',
195
348
  inputSchema: {
196
349
  fixtureId: z.string().describe('The fixture ID'),
@@ -201,12 +354,16 @@ class ComponentExplorerMcpServer {
201
354
  },
202
355
  annotations: { readOnlyHint: true },
203
356
  }, async (args) => {
357
+ const daemon = await this._waitForDaemon();
358
+ if (!daemon) {
359
+ return this._noDaemonError();
360
+ }
204
361
  const baselineSessionName = args.baselineSessionName ?? this._defaultBaselineSessionName();
205
362
  const currentSessionName = args.currentSessionName ?? this._defaultCurrentSessionName();
206
363
  return this._withSourceTreeRetry(async () => {
207
364
  const baselineSourceTreeId = args.baselineSourceTreeId ?? this._sourceTreeId(baselineSessionName);
208
365
  const currentSourceTreeId = args.currentSourceTreeId ?? this._sourceTreeId(currentSessionName);
209
- const result = await this._daemon.methods.screenshots.compare({
366
+ const result = await daemon.methods.screenshots.compare({
210
367
  fixtureId: args.fixtureId,
211
368
  baselineSessionName,
212
369
  baselineSourceTreeId,
@@ -241,9 +398,11 @@ class ComponentExplorerMcpServer {
241
398
  return { content };
242
399
  });
243
400
  });
401
+ tool.disable();
402
+ this._multiSessionTools.push(tool);
244
403
  }
245
404
  _registerApproveDiff() {
246
- this._mcp.registerTool('approve_diff', {
405
+ const tool = this._mcp.registerTool('approve_diff', {
247
406
  description: 'Approve a visual diff so it won\'t require re-inspection next time',
248
407
  inputSchema: {
249
408
  fixtureId: z.string(),
@@ -252,7 +411,11 @@ class ComponentExplorerMcpServer {
252
411
  comment: z.string().describe('Reason for approving this diff'),
253
412
  },
254
413
  }, async (args) => {
255
- await this._daemon.methods.approvals.approve(args);
414
+ const daemon = await this._waitForDaemon();
415
+ if (!daemon) {
416
+ return this._noDaemonError();
417
+ }
418
+ await daemon.methods.approvals.approve(args);
256
419
  return {
257
420
  content: [{
258
421
  type: 'text',
@@ -260,6 +423,8 @@ class ComponentExplorerMcpServer {
260
423
  }],
261
424
  };
262
425
  });
426
+ tool.disable();
427
+ this._multiSessionTools.push(tool);
263
428
  }
264
429
  _registerEvaluateJs() {
265
430
  this._mcp.registerTool('evaluate_js', {
@@ -274,10 +439,14 @@ class ComponentExplorerMcpServer {
274
439
  sourceTreeId: z.string().optional().describe('Source tree ID (defaults to latest known)'),
275
440
  },
276
441
  }, async (args) => {
442
+ const daemon = await this._waitForDaemon();
443
+ if (!daemon) {
444
+ return this._noDaemonError();
445
+ }
277
446
  const sessionName = args.sessionName ?? this._defaultSessionName();
278
447
  return this._withSourceTreeRetry(async () => {
279
448
  const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
280
- const result = await this._daemon.methods.evaluate({
449
+ const result = await daemon.methods.evaluate({
281
450
  sessionName,
282
451
  sourceTreeId,
283
452
  expression: args.expression,
@@ -297,6 +466,32 @@ class ComponentExplorerMcpServer {
297
466
  });
298
467
  });
299
468
  }
469
+ _registerDebugReloadPage() {
470
+ this._mcp.registerTool('debug_reload_page', {
471
+ description: 'Force-reload the browser page used for rendering fixtures. ' +
472
+ 'Only use this as a last resort if screenshots or evaluate_js return stale/broken results ' +
473
+ 'that persist after source changes. Normal HMR updates should handle most cases automatically.',
474
+ inputSchema: {
475
+ sessionName: z.string().optional().describe('Session name (defaults to first session)'),
476
+ },
477
+ annotations: { destructiveHint: true },
478
+ }, async (args) => {
479
+ const daemon = await this._waitForDaemon();
480
+ if (!daemon) {
481
+ return this._noDaemonError();
482
+ }
483
+ const sessionName = args.sessionName ?? this._defaultSessionName();
484
+ const sourceTreeId = this._sourceTreeId(sessionName);
485
+ await daemon.methods.evaluate({
486
+ sessionName,
487
+ sourceTreeId,
488
+ expression: 'location.reload()',
489
+ });
490
+ return {
491
+ content: [{ type: 'text', text: `Reloaded page for session '${sessionName}'.` }],
492
+ };
493
+ });
494
+ }
300
495
  _registerWatchAdd() {
301
496
  this._mcp.registerTool('watch_add', {
302
497
  description: 'Add fixtures to the watch list. Watched fixtures are automatically re-screenshotted when source changes.',
@@ -346,7 +541,7 @@ class ComponentExplorerMcpServer {
346
541
  });
347
542
  }
348
543
  _registerWatchCompare() {
349
- this._mcp.registerTool('watch_compare', {
544
+ const tool = this._mcp.registerTool('watch_compare', {
350
545
  description: 'Compare all watched fixtures across two sessions. Takes fresh screenshots from both sessions and reports which fixtures differ.',
351
546
  inputSchema: {
352
547
  baselineSessionName: z.string().optional().describe('Baseline session name (defaults to worktree session)'),
@@ -356,6 +551,10 @@ class ComponentExplorerMcpServer {
356
551
  },
357
552
  annotations: { readOnlyHint: true },
358
553
  }, async (args) => {
554
+ const daemon = await this._waitForDaemon();
555
+ if (!daemon) {
556
+ return this._noDaemonError();
557
+ }
359
558
  const ids = [...this._watchList.fixtureIds];
360
559
  if (ids.length === 0) {
361
560
  return { content: [{ type: 'text', text: 'Watch list is empty. Use watch_add or watch_set first.' }] };
@@ -366,12 +565,12 @@ class ComponentExplorerMcpServer {
366
565
  const baselineSourceTreeId = args.baselineSourceTreeId ?? this._sourceTreeId(baselineSessionName);
367
566
  const currentSourceTreeId = args.currentSourceTreeId ?? this._sourceTreeId(currentSessionName);
368
567
  const [baselineResult, currentResult] = await Promise.all([
369
- this._daemon.methods.screenshots.takeBatch({
568
+ daemon.methods.screenshots.takeBatch({
370
569
  fixtureIds: ids,
371
570
  sessionName: baselineSessionName,
372
571
  sourceTreeId: baselineSourceTreeId,
373
572
  }),
374
- this._daemon.methods.screenshots.takeBatch({
573
+ daemon.methods.screenshots.takeBatch({
375
574
  fixtureIds: ids,
376
575
  sessionName: currentSessionName,
377
576
  sourceTreeId: currentSourceTreeId,
@@ -396,7 +595,7 @@ class ComponentExplorerMcpServer {
396
595
  for (const entry of entries) {
397
596
  let approval = undefined;
398
597
  if (!entry.match && entry.baselineHash && entry.currentHash) {
399
- approval = await this._daemon.methods.approvals.lookup({
598
+ approval = await daemon.methods.approvals.lookup({
400
599
  fixtureId: entry.fixtureId,
401
600
  originalHash: entry.baselineHash,
402
601
  modifiedHash: entry.currentHash,
@@ -409,74 +608,187 @@ class ComponentExplorerMcpServer {
409
608
  };
410
609
  });
411
610
  });
611
+ tool.disable();
612
+ this._multiSessionTools.push(tool);
412
613
  }
413
614
  _registerWaitForUpdate() {
414
615
  this._mcp.registerTool('wait_for_update', {
415
- description: 'Block until the next source change or ref change event. If fixtures are on the watch list, automatically re-screenshots them and reports which changed.',
616
+ description: 'Block until the source tree changes from the given sourceTreeId. ' +
617
+ 'Pass the sourceTreeId you already observed — resolves immediately if it already differs, ' +
618
+ 'otherwise waits for a source-change or ref-change event. ' +
619
+ 'If fixtures are on the watch list, automatically re-screenshots them and reports which changed.',
620
+ inputSchema: {
621
+ sourceTreeId: z.string().describe('The sourceTreeId the client currently knows about. The call resolves once the source tree differs from this value.'),
622
+ sessionName: z.string().optional().describe('Session name (defaults to first session)'),
623
+ },
416
624
  annotations: { readOnlyHint: true },
417
- }, async () => {
418
- const events = await this._daemon.methods.events();
419
- const iterator = events[Symbol.asyncIterator]();
420
- const { value: event, done } = await iterator.next();
421
- // Close the stream after consuming one event
422
- await iterator.return?.();
423
- if (done || !event) {
424
- return { content: [{ type: 'text', text: 'Event stream ended.' }] };
425
- }
426
- const ev = event;
427
- // Update cached session info
428
- if (ev.type === 'source-change' && ev.sourceTreeId) {
429
- this._updateSessionSourceTreeId(ev.sessionName, ev.sourceTreeId);
625
+ }, async (args) => {
626
+ const daemon = await this._waitForDaemon();
627
+ if (!daemon) {
628
+ return this._noDaemonError();
430
629
  }
431
- if (ev.type === 'ref-change') {
432
- await this._refreshSessions();
630
+ const sessionName = args.sessionName ?? this._defaultSessionName();
631
+ const knownSourceTreeId = args.sourceTreeId;
632
+ // Check if already changed
633
+ const currentSourceTreeId = this._sourceTreeId(sessionName);
634
+ if (currentSourceTreeId && currentSourceTreeId !== knownSourceTreeId) {
635
+ return this._waitForUpdateResult(daemon, sessionName, currentSourceTreeId);
433
636
  }
434
- // If there are watched fixtures and this is a source-change, re-screenshot them
435
- const watchedIds = [...this._watchList.fixtureIds];
436
- if (ev.type === 'source-change' && watchedIds.length > 0 && ev.sourceTreeId) {
437
- const batchResult = await this._daemon.methods.screenshots.takeBatch({
438
- fixtureIds: watchedIds,
439
- sessionName: ev.sessionName,
440
- sourceTreeId: ev.sourceTreeId,
441
- });
442
- const br = batchResult;
443
- const changes = [];
444
- for (const s of br.screenshots) {
445
- const prevHash = this._watchList.getHash(s.fixtureId);
446
- const changed = prevHash !== undefined && prevHash !== s.hash;
447
- this._watchList.setHash(s.fixtureId, s.hash);
448
- if (changed) {
449
- changes.push({ fixtureId: s.fixtureId, previousHash: prevHash, hash: s.hash });
637
+ // Wait for an event that changes the source tree (max 5s)
638
+ const events = await daemon.methods.events();
639
+ const iterator = events[Symbol.asyncIterator]();
640
+ try {
641
+ const timeout = new Promise(resolve => setTimeout(() => resolve('timeout'), 5000));
642
+ while (true) {
643
+ const next = iterator.next();
644
+ const result = await Promise.race([next, timeout]);
645
+ if (result === 'timeout') {
646
+ return { content: [{ type: 'text', text: JSON.stringify({ timeout: true, sessionName, sourceTreeId: knownSourceTreeId }, null, 2) }] };
647
+ }
648
+ const { value: event, done } = result;
649
+ if (done || !event) {
650
+ return { content: [{ type: 'text', text: 'Event stream ended.' }] };
651
+ }
652
+ const ev = event;
653
+ if (ev.type === 'source-change' && ev.sourceTreeId) {
654
+ this._updateSessionSourceTreeId(ev.sessionName, ev.sourceTreeId);
655
+ }
656
+ if (ev.type === 'ref-change') {
657
+ await this._refreshSessions();
658
+ }
659
+ const newSourceTreeId = this._sourceTreeId(sessionName);
660
+ if (newSourceTreeId && newSourceTreeId !== knownSourceTreeId) {
661
+ return this._waitForUpdateResult(daemon, sessionName, newSourceTreeId);
450
662
  }
451
663
  }
452
- return {
453
- content: [{
454
- type: 'text',
455
- text: JSON.stringify({
456
- event: ev,
457
- watchedFixtures: br.screenshots.length,
458
- changed: changes,
459
- }, null, 2),
460
- }],
461
- };
664
+ }
665
+ finally {
666
+ await iterator.return?.();
667
+ }
668
+ });
669
+ }
670
+ async _waitForUpdateResult(daemon, sessionName, sourceTreeId) {
671
+ const watchedIds = [...this._watchList.fixtureIds];
672
+ if (watchedIds.length > 0) {
673
+ const batchResult = await daemon.methods.screenshots.takeBatch({
674
+ fixtureIds: watchedIds,
675
+ sessionName,
676
+ sourceTreeId,
677
+ });
678
+ const br = batchResult;
679
+ const changes = [];
680
+ for (const s of br.screenshots) {
681
+ const prevHash = this._watchList.getHash(s.fixtureId);
682
+ const changed = prevHash !== undefined && prevHash !== s.hash;
683
+ this._watchList.setHash(s.fixtureId, s.hash);
684
+ if (changed) {
685
+ changes.push({ fixtureId: s.fixtureId, previousHash: prevHash, hash: s.hash });
686
+ }
462
687
  }
463
688
  return {
464
- content: [{ type: 'text', text: JSON.stringify(ev, null, 2) }],
689
+ content: [{
690
+ type: 'text',
691
+ text: JSON.stringify({
692
+ sourceTreeId,
693
+ sessionName,
694
+ watchedFixtures: br.screenshots.length,
695
+ changed: changes,
696
+ }, null, 2),
697
+ }],
465
698
  };
466
- });
699
+ }
700
+ return {
701
+ content: [{
702
+ type: 'text',
703
+ text: JSON.stringify({ sourceTreeId, sessionName }, null, 2),
704
+ }],
705
+ };
467
706
  }
468
707
  _registerSessions() {
469
708
  this._mcp.registerTool('sessions', {
470
709
  description: 'List active sessions with their names, URLs, and current sourceTreeIds',
471
710
  annotations: { readOnlyHint: true },
472
711
  }, async () => {
712
+ const daemon = await this._waitForDaemon();
713
+ if (!daemon) {
714
+ return this._noDaemonError();
715
+ }
473
716
  await this._refreshSessions();
474
717
  return {
475
718
  content: [{ type: 'text', text: JSON.stringify(this._sessions, null, 2) }],
476
719
  };
477
720
  });
478
721
  }
722
+ _registerGetUrl() {
723
+ this._mcp.registerTool('get_url', {
724
+ description: 'Get URL(s) for viewing fixtures. Returns the Component Explorer UI URL by default, ' +
725
+ 'or the raw render URL for embedding/screenshots when useRawDirectRenderingWithoutExplorerUi is true.',
726
+ inputSchema: {
727
+ sessionName: z.string().optional().describe('Session name (defaults to first session)'),
728
+ fixtureId: z.string().optional().describe('Specific fixture ID. If omitted, returns URL for the explorer root or all fixtures.'),
729
+ useRawDirectRenderingWithoutExplorerUi: z.boolean().optional().describe('If true, returns the raw rendering URL (for embedding or screenshots) instead of the Explorer UI URL. ' +
730
+ 'The raw URL renders only the fixture without the explorer chrome. Default: false.'),
731
+ },
732
+ annotations: { readOnlyHint: true },
733
+ }, async (args) => {
734
+ const sessionName = args.sessionName ?? this._defaultSessionName();
735
+ const session = this._sessions.find(s => s.name === sessionName);
736
+ if (!session) {
737
+ // Try to refresh sessions if we don't have the requested session
738
+ const daemon = await this._waitForDaemon();
739
+ if (daemon) {
740
+ await this._refreshSessions();
741
+ }
742
+ const refreshedSession = this._sessions.find(s => s.name === sessionName);
743
+ if (!refreshedSession) {
744
+ return {
745
+ content: [{
746
+ type: 'text',
747
+ text: `Error: Session '${sessionName}' not found. Available sessions: ${this._sessions.map(s => s.name).join(', ') || '(none)'}`,
748
+ }],
749
+ isError: true,
750
+ };
751
+ }
752
+ }
753
+ const resolved = session ?? this._sessions.find(s => s.name === sessionName);
754
+ const baseUrl = resolved && !resolved.isLoading ? resolved.serverUrl : undefined;
755
+ if (!baseUrl) {
756
+ return {
757
+ content: [{
758
+ type: 'text',
759
+ text: `Error: Session '${sessionName}' is still loading.`,
760
+ }],
761
+ isError: true,
762
+ };
763
+ }
764
+ const useRaw = args.useRawDirectRenderingWithoutExplorerUi ?? false;
765
+ const url = buildExplorerUrl({
766
+ baseUrl,
767
+ rawRender: useRaw,
768
+ fixtureId: args.fixtureId,
769
+ });
770
+ const result = {
771
+ url,
772
+ sessionName,
773
+ };
774
+ if (args.fixtureId) {
775
+ result.fixtureId = args.fixtureId;
776
+ }
777
+ if (useRaw) {
778
+ result.mode = 'raw-render';
779
+ }
780
+ else {
781
+ result.mode = 'explorer';
782
+ if (args.fixtureId) {
783
+ result.note = 'Fixture selection in the Explorer UI is not URL-based. Navigate to the fixture manually in the tree view.';
784
+ }
785
+ }
786
+ return {
787
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
788
+ };
789
+ });
790
+ }
479
791
  }
480
792
 
481
- export { ComponentExplorerMcpServer };
793
+ export { ComponentExplorerMcpServer, DaemonConnection };
482
794
  //# sourceMappingURL=McpServer.js.map