@vscode/component-explorer-cli 0.1.1-2 → 0.1.1-20

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 (169) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/SECURITY.md +14 -0
  4. package/dist/WorktreePool.d.ts +22 -0
  5. package/dist/WorktreePool.d.ts.map +1 -0
  6. package/dist/WorktreePool.js +58 -0
  7. package/dist/WorktreePool.js.map +1 -0
  8. package/dist/WorktreePool.test.d.ts +2 -0
  9. package/dist/WorktreePool.test.d.ts.map +1 -0
  10. package/dist/_virtual/_build-info.js +4 -0
  11. package/dist/_virtual/_build-info.js.map +1 -0
  12. package/dist/browserPage.d.ts +5 -0
  13. package/dist/browserPage.d.ts.map +1 -1
  14. package/dist/browserPage.js +28 -2
  15. package/dist/browserPage.js.map +1 -1
  16. package/dist/commands/acceptCommand.d.ts +2 -1
  17. package/dist/commands/acceptCommand.d.ts.map +1 -1
  18. package/dist/commands/acceptCommand.js +15 -8
  19. package/dist/commands/acceptCommand.js.map +1 -1
  20. package/dist/commands/checkStabilityCommand.d.ts +12 -0
  21. package/dist/commands/checkStabilityCommand.d.ts.map +1 -0
  22. package/dist/commands/checkStabilityCommand.js +84 -0
  23. package/dist/commands/checkStabilityCommand.js.map +1 -0
  24. package/dist/commands/compareCommand.d.ts +5 -1
  25. package/dist/commands/compareCommand.d.ts.map +1 -1
  26. package/dist/commands/compareCommand.js +56 -71
  27. package/dist/commands/compareCommand.js.map +1 -1
  28. package/dist/commands/mcpCommand.d.ts +4 -1
  29. package/dist/commands/mcpCommand.d.ts.map +1 -1
  30. package/dist/commands/mcpCommand.js +51 -8
  31. package/dist/commands/mcpCommand.js.map +1 -1
  32. package/dist/commands/screenshotCommand.d.ts +9 -2
  33. package/dist/commands/screenshotCommand.d.ts.map +1 -1
  34. package/dist/commands/screenshotCommand.js +73 -15
  35. package/dist/commands/screenshotCommand.js.map +1 -1
  36. package/dist/commands/serveCommand.d.ts +6 -1
  37. package/dist/commands/serveCommand.d.ts.map +1 -1
  38. package/dist/commands/serveCommand.js +104 -23
  39. package/dist/commands/serveCommand.js.map +1 -1
  40. package/dist/commands/watchCommand.d.ts +3 -2
  41. package/dist/commands/watchCommand.d.ts.map +1 -1
  42. package/dist/commands/watchCommand.js +28 -80
  43. package/dist/commands/watchCommand.js.map +1 -1
  44. package/dist/comparison.d.ts +70 -0
  45. package/dist/comparison.d.ts.map +1 -0
  46. package/dist/comparison.js +264 -0
  47. package/dist/comparison.js.map +1 -0
  48. package/dist/component-explorer-config.schema.json +222 -0
  49. package/dist/componentExplorer.d.ts +40 -2
  50. package/dist/componentExplorer.d.ts.map +1 -1
  51. package/dist/componentExplorer.js +118 -24
  52. package/dist/componentExplorer.js.map +1 -1
  53. package/dist/daemon/DaemonContext.d.ts +4 -0
  54. package/dist/daemon/DaemonContext.d.ts.map +1 -0
  55. package/dist/daemon/DaemonService.d.ts +146 -21
  56. package/dist/daemon/DaemonService.d.ts.map +1 -1
  57. package/dist/daemon/DaemonService.js +620 -123
  58. package/dist/daemon/DaemonService.js.map +1 -1
  59. package/dist/daemon/dynamicSessions.test.d.ts +2 -0
  60. package/dist/daemon/dynamicSessions.test.d.ts.map +1 -0
  61. package/dist/daemon/lifecycle.d.ts +8 -3
  62. package/dist/daemon/lifecycle.d.ts.map +1 -1
  63. package/dist/daemon/lifecycle.js +27 -10
  64. package/dist/daemon/lifecycle.js.map +1 -1
  65. package/dist/daemon/pipeClient.d.ts +6 -1
  66. package/dist/daemon/pipeClient.d.ts.map +1 -1
  67. package/dist/daemon/pipeClient.js +101 -8
  68. package/dist/daemon/pipeClient.js.map +1 -1
  69. package/dist/daemon/pipeServer.d.ts +2 -1
  70. package/dist/daemon/pipeServer.d.ts.map +1 -1
  71. package/dist/daemon/pipeServer.js +56 -6
  72. package/dist/daemon/pipeServer.js.map +1 -1
  73. package/dist/daemon/types.d.ts +12 -0
  74. package/dist/daemon/types.d.ts.map +1 -0
  75. package/dist/daemon/version.d.ts +10 -0
  76. package/dist/daemon/version.d.ts.map +1 -0
  77. package/dist/daemon/version.js +17 -0
  78. package/dist/daemon/version.js.map +1 -0
  79. package/dist/dependencyInstaller.d.ts +2 -2
  80. package/dist/dependencyInstaller.d.ts.map +1 -1
  81. package/dist/dependencyInstaller.js +1 -1
  82. package/dist/dependencyInstaller.js.map +1 -1
  83. package/dist/external/vscode-observables/observables/dist/disposables.js +24 -1
  84. package/dist/external/vscode-observables/observables/dist/disposables.js.map +1 -1
  85. package/dist/external/vscode-observables/observables/dist/observableInternal/commonFacade/deps.js +1 -4
  86. package/dist/external/vscode-observables/observables/dist/observableInternal/commonFacade/deps.js.map +1 -1
  87. package/dist/external/vscode-observables/observables/dist/observableInternal/index.js +2 -5
  88. package/dist/external/vscode-observables/observables/dist/observableInternal/index.js.map +1 -1
  89. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/consoleObservableLogger.js +30 -6
  90. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/consoleObservableLogger.js.map +1 -1
  91. package/dist/external/vscode-observables/observables/dist/observableInternal/observables/baseObservable.js +1 -1
  92. package/dist/external/vscode-observables/observables/dist/observableInternal/observables/baseObservable.js.map +1 -1
  93. package/dist/external/vscode-observables/observables/dist/observableInternal/observables/derived.js +12 -1
  94. package/dist/external/vscode-observables/observables/dist/observableInternal/observables/derived.js.map +1 -1
  95. package/dist/external/vscode-observables/observables/dist/observableInternal/utils/utilsCancellation.js +55 -0
  96. package/dist/external/vscode-observables/observables/dist/observableInternal/utils/utilsCancellation.js.map +1 -0
  97. package/dist/formatValue.d.ts +2 -0
  98. package/dist/formatValue.d.ts.map +1 -0
  99. package/dist/formatValue.js +96 -0
  100. package/dist/formatValue.js.map +1 -0
  101. package/dist/formatValue.test.d.ts +2 -0
  102. package/dist/formatValue.test.d.ts.map +1 -0
  103. package/dist/git/gitIndexResolver.d.ts +25 -0
  104. package/dist/git/gitIndexResolver.d.ts.map +1 -0
  105. package/dist/git/gitIndexResolver.js +91 -0
  106. package/dist/git/gitIndexResolver.js.map +1 -0
  107. package/dist/git/gitIndexResolver.test.d.ts +2 -0
  108. package/dist/git/gitIndexResolver.test.d.ts.map +1 -0
  109. package/dist/git/gitService.d.ts +2 -0
  110. package/dist/git/gitService.d.ts.map +1 -1
  111. package/dist/git/gitService.js +6 -0
  112. package/dist/git/gitService.js.map +1 -1
  113. package/dist/git/gitUtils.js +1 -1
  114. package/dist/git/gitUtils.js.map +1 -1
  115. package/dist/git/gitWorktreeManager.d.ts +6 -0
  116. package/dist/git/gitWorktreeManager.d.ts.map +1 -1
  117. package/dist/git/gitWorktreeManager.js +42 -13
  118. package/dist/git/gitWorktreeManager.js.map +1 -1
  119. package/dist/git/gitWorktreeManager.test.d.ts +2 -0
  120. package/dist/git/gitWorktreeManager.test.d.ts.map +1 -0
  121. package/dist/git/testUtils.d.ts +13 -0
  122. package/dist/git/testUtils.d.ts.map +1 -0
  123. package/dist/httpServer.d.ts +6 -1
  124. package/dist/httpServer.d.ts.map +1 -1
  125. package/dist/httpServer.js +30 -10
  126. package/dist/httpServer.js.map +1 -1
  127. package/dist/index.js +11 -2
  128. package/dist/index.js.map +1 -1
  129. package/dist/logger.d.ts +1 -0
  130. package/dist/logger.d.ts.map +1 -1
  131. package/dist/logger.js +7 -1
  132. package/dist/logger.js.map +1 -1
  133. package/dist/mcp/McpServer.d.ts +47 -4
  134. package/dist/mcp/McpServer.d.ts.map +1 -1
  135. package/dist/mcp/McpServer.js +913 -155
  136. package/dist/mcp/McpServer.js.map +1 -1
  137. package/dist/mcp/TaskManager.d.ts +28 -0
  138. package/dist/mcp/TaskManager.d.ts.map +1 -0
  139. package/dist/mcp/TaskManager.js +54 -0
  140. package/dist/mcp/TaskManager.js.map +1 -0
  141. package/dist/packages/simple-api/dist/chunk-3R7GHWBM.js +137 -0
  142. package/dist/packages/simple-api/dist/chunk-3R7GHWBM.js.map +1 -0
  143. package/dist/packages/simple-api/dist/chunk-SGBCNXYH.js +24 -0
  144. package/dist/packages/simple-api/dist/chunk-SGBCNXYH.js.map +1 -0
  145. package/dist/packages/simple-api/dist/chunk-TAEFVNPN.js +27 -0
  146. package/dist/packages/simple-api/dist/chunk-TAEFVNPN.js.map +1 -0
  147. package/dist/packages/simple-api/dist/express.js +104 -0
  148. package/dist/packages/simple-api/dist/express.js.map +1 -0
  149. package/dist/resolveProject.d.ts +21 -0
  150. package/dist/resolveProject.d.ts.map +1 -0
  151. package/dist/resolveProject.js +39 -0
  152. package/dist/resolveProject.js.map +1 -0
  153. package/dist/utils.d.ts +31 -0
  154. package/dist/utils.d.ts.map +1 -0
  155. package/dist/utils.js +49 -0
  156. package/dist/utils.js.map +1 -0
  157. package/dist/watchConfig.d.ts +52 -9
  158. package/dist/watchConfig.d.ts.map +1 -1
  159. package/dist/watchConfig.js +67 -62
  160. package/dist/watchConfig.js.map +1 -1
  161. package/package.json +31 -8
  162. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/debuggerRpc.js +0 -72
  163. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/debuggerRpc.js.map +0 -1
  164. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/devToolsLogger.js +0 -447
  165. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/devToolsLogger.js.map +0 -1
  166. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/rpc.js +0 -64
  167. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/rpc.js.map +0 -1
  168. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/utils.js +0 -52
  169. package/dist/external/vscode-observables/observables/dist/observableInternal/logging/debugger/utils.js.map +0 -1
@@ -1,6 +1,15 @@
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';
12
+ import { TaskManager } from './TaskManager.js';
4
13
 
5
14
  // ---------------------------------------------------------------------------
6
15
  // Client-local state
@@ -40,26 +49,197 @@ class WatchList {
40
49
  };
41
50
  }
42
51
  }
52
+ function noDaemonError(hint) {
53
+ let text = 'Error: No daemon is currently running.';
54
+ if (hint) {
55
+ text += ` ${hint}`;
56
+ }
57
+ else {
58
+ text += ' Please start the Component Explorer daemon first by running:\n\n' +
59
+ ' component-explorer serve --project <config.json>\n\n' +
60
+ 'Or start it in the background:\n\n' +
61
+ ' component-explorer serve --project <config.json> --background\n\n' +
62
+ 'The daemon manages dev servers and enables fixture screenshots.';
63
+ }
64
+ return {
65
+ content: [{ type: 'text', text }],
66
+ isError: true,
67
+ };
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ // DaemonConnection - wrapper to avoid Proxy issues with observables
71
+ // ---------------------------------------------------------------------------
72
+ class DaemonConnection {
73
+ client;
74
+ _stale = false;
75
+ constructor(client) {
76
+ this.client = client;
77
+ }
78
+ get isStale() { return this._stale; }
79
+ markStale() { this._stale = true; }
80
+ }
81
+ function isPipeConnectionError(e) {
82
+ if (!(e instanceof Error)) {
83
+ return false;
84
+ }
85
+ const code = e.code;
86
+ if (code === 'ENOENT' || code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'EPIPE') {
87
+ return true;
88
+ }
89
+ return /connect ENOENT|ECONNREFUSED|ECONNRESET|EPIPE/.test(e.message);
90
+ }
43
91
  // ---------------------------------------------------------------------------
44
92
  // ComponentExplorerMcpServer
45
93
  // ---------------------------------------------------------------------------
46
- class ComponentExplorerMcpServer {
47
- _daemon;
94
+ class ComponentExplorerMcpServer extends Disposable {
95
+ _daemonConnection;
96
+ static async create(daemon, options) {
97
+ const server = new ComponentExplorerMcpServer(daemon, options ?? {});
98
+ const transport = new StdioServerTransport();
99
+ await server._mcp.connect(transport);
100
+ return server;
101
+ }
48
102
  _mcp;
49
103
  _watchList = new WatchList();
104
+ _taskManager = new TaskManager();
105
+ _taskLastReportedIndex = new Map();
106
+ _pollFn;
107
+ _noAutostartHint;
108
+ _multiSessionTools = [];
50
109
  _sessions = [];
51
- constructor(_daemon) {
52
- this._daemon = _daemon;
110
+ _eventStreamAbortController;
111
+ constructor(_daemonConnection, options) {
112
+ super();
113
+ this._daemonConnection = _daemonConnection;
114
+ this._pollFn = options.pollFn;
115
+ this._noAutostartHint = options.noAutostartHint;
116
+ this._callTimeoutMs = options.callTimeoutMs ?? ComponentExplorerMcpServer._DEFAULT_CALL_TIMEOUT_MS;
53
117
  this._mcp = new McpServer({
54
118
  name: 'component-explorer',
55
119
  version: '0.1.0',
56
120
  });
57
121
  this._registerTools();
122
+ this._store.add(autorun(async (reader) => {
123
+ const conn = this._daemonConnection.read(reader);
124
+ await this._onDaemonChanged(conn?.client);
125
+ }));
58
126
  }
59
- async connect() {
60
- this._sessions = await this._daemon.methods.sessions();
61
- const transport = new StdioServerTransport();
62
- await this._mcp.connect(transport);
127
+ async _onDaemonChanged(daemon) {
128
+ this._eventStreamAbortController?.abort();
129
+ this._eventStreamAbortController = undefined;
130
+ if (!daemon) {
131
+ this._sessions = [];
132
+ this._log('info', { type: 'daemon-disconnected' });
133
+ return;
134
+ }
135
+ try {
136
+ this._sessions = await daemon.methods.sessions();
137
+ this._log('debug', { type: 'daemon-connected', sessions: this._sessions.length });
138
+ this._updateMultiSessionToolVisibility();
139
+ this._startEventListener(daemon);
140
+ }
141
+ catch (e) {
142
+ this._log('info', { type: 'daemon-error', error: String(e) });
143
+ this._sessions = [];
144
+ }
145
+ }
146
+ _getConnection() {
147
+ const conn = this._daemonConnection.get();
148
+ if (conn?.isStale) {
149
+ return undefined;
150
+ }
151
+ return conn;
152
+ }
153
+ async _waitForDaemon() {
154
+ let conn = this._getConnection();
155
+ if (conn) {
156
+ return conn.client;
157
+ }
158
+ if (!this._pollFn) {
159
+ return undefined;
160
+ }
161
+ this._log('debug', { type: 'waiting-for-daemon' });
162
+ const startTime = Date.now();
163
+ const timeout = 3000;
164
+ while (Date.now() - startTime < timeout) {
165
+ await this._pollFn();
166
+ conn = this._getConnection();
167
+ if (conn) {
168
+ return conn.client;
169
+ }
170
+ await new Promise(resolve => setTimeout(resolve, 200));
171
+ }
172
+ return undefined;
173
+ }
174
+ _handleDisconnect() {
175
+ const conn = this._daemonConnection.get();
176
+ if (conn && !conn.isStale) {
177
+ conn.markStale();
178
+ this._sessions = [];
179
+ this._log('debug', { type: 'daemon-connection-lost' });
180
+ }
181
+ }
182
+ _noDaemonError() {
183
+ return noDaemonError(this._noAutostartHint);
184
+ }
185
+ static _DEFAULT_CALL_TIMEOUT_MS = 15_000;
186
+ _callTimeoutMs;
187
+ async _withDaemon(fn, options) {
188
+ const daemon = await this._waitForDaemon();
189
+ if (!daemon) {
190
+ return this._noDaemonError();
191
+ }
192
+ try {
193
+ if (options?.noTimeout) {
194
+ return await fn(daemon);
195
+ }
196
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('__mcp_timeout__')), this._callTimeoutMs));
197
+ return await Promise.race([fn(daemon), timeout]);
198
+ }
199
+ catch (e) {
200
+ if (e instanceof Error && e.message === '__mcp_timeout__') {
201
+ return {
202
+ content: [{ type: 'text', text: `Error: Operation timed out after ${this._callTimeoutMs / 1000}s. Retry, if the error persists, restart the involved session using the restart_session tool and retry.` }],
203
+ isError: true,
204
+ };
205
+ }
206
+ if (isPipeConnectionError(e)) {
207
+ this._log('debug', { type: 'daemon-call-failed', error: String(e) });
208
+ this._handleDisconnect();
209
+ return this._noDaemonError();
210
+ }
211
+ throw e;
212
+ }
213
+ }
214
+ _log(level, data) {
215
+ const mcpLevel = level === 'trace' ? 'debug' : level;
216
+ this._mcp.sendLoggingMessage({ level: mcpLevel, logger: 'daemon', data }).catch(() => { });
217
+ }
218
+ _startEventListener(daemon) {
219
+ const controller = new AbortController();
220
+ this._eventStreamAbortController = controller;
221
+ (async () => {
222
+ try {
223
+ const stream = await daemon.methods.events();
224
+ for await (const raw of stream) {
225
+ if (controller.signal.aborted)
226
+ break;
227
+ const event = raw;
228
+ if (event.type === 'source-change' && event.sessionName && event.sourceTreeId) {
229
+ this._updateSessionSourceTreeId(event.sessionName, event.sourceTreeId);
230
+ }
231
+ if (event.type === 'ref-change' || event.type === 'session-change') {
232
+ await this._refreshSessions();
233
+ }
234
+ this._log(event.type === 'log' && event.level === 'debug' ? 'debug' : 'info', event);
235
+ }
236
+ }
237
+ catch (e) {
238
+ if (!controller.signal.aborted) {
239
+ this._log('info', { type: 'event-stream-error', error: String(e) });
240
+ }
241
+ }
242
+ })();
63
243
  }
64
244
  // -- Helpers --------------------------------------------------------------
65
245
  _defaultSessionName() {
@@ -75,7 +255,7 @@ class ComponentExplorerMcpServer {
75
255
  }
76
256
  _sourceTreeId(sessionName) {
77
257
  const s = this._sessions.find(s => s.name === sessionName);
78
- return s?.sourceTreeId ?? '';
258
+ return s && !s.isLoading ? s.sourceTreeId : '';
79
259
  }
80
260
  _updateSessionSourceTreeId(sessionName, sourceTreeId) {
81
261
  const s = this._sessions.find(s => s.name === sessionName);
@@ -84,67 +264,192 @@ class ComponentExplorerMcpServer {
84
264
  }
85
265
  }
86
266
  async _refreshSessions() {
87
- this._sessions = await this._daemon.methods.sessions();
267
+ const conn = this._getConnection();
268
+ if (conn) {
269
+ try {
270
+ const prevCount = this._sessions.length;
271
+ this._sessions = await conn.client.methods.sessions();
272
+ if (this._sessions.length !== prevCount) {
273
+ this._updateMultiSessionToolVisibility();
274
+ }
275
+ }
276
+ catch (e) {
277
+ if (isPipeConnectionError(e)) {
278
+ this._handleDisconnect();
279
+ }
280
+ else {
281
+ throw e;
282
+ }
283
+ }
284
+ }
285
+ }
286
+ _updateMultiSessionToolVisibility() {
287
+ const isMultiSession = this._sessions.length > 1;
288
+ for (const tool of this._multiSessionTools) {
289
+ if (isMultiSession) {
290
+ tool.enable();
291
+ }
292
+ else {
293
+ tool.disable();
294
+ }
295
+ }
296
+ }
297
+ async _withSourceTreeRetry(fn) {
298
+ try {
299
+ return await fn();
300
+ }
301
+ catch (e) {
302
+ const msg = e instanceof Error ? e.message : String(e);
303
+ if (msg.includes('Source tree changed')) {
304
+ await this._refreshSessions();
305
+ return await fn();
306
+ }
307
+ throw e;
308
+ }
88
309
  }
89
310
  // -- Tool registration ---------------------------------------------------
311
+ _filterFixtures(allFixtures, fixtureIdPattern, labelPattern) {
312
+ let fixtureIdRegex;
313
+ if (fixtureIdPattern) {
314
+ try {
315
+ fixtureIdRegex = new RegExp(fixtureIdPattern);
316
+ }
317
+ catch {
318
+ return { error: `Error: Invalid fixtureIdPattern: ${fixtureIdPattern}` };
319
+ }
320
+ }
321
+ let labelRegex;
322
+ if (labelPattern) {
323
+ try {
324
+ labelRegex = new RegExp(labelPattern);
325
+ }
326
+ catch {
327
+ return { error: `Error: Invalid labelPattern: ${labelPattern}` };
328
+ }
329
+ }
330
+ return {
331
+ fixtures: allFixtures.filter(f => (!fixtureIdRegex || fixtureIdRegex.test(f.fixtureId)) &&
332
+ (!labelRegex || f.labels.some(l => labelRegex.test(l)))),
333
+ };
334
+ }
90
335
  _registerTools() {
91
336
  this._registerListFixtures();
92
337
  this._registerScreenshot();
93
338
  this._registerCompareScreenshot();
94
339
  this._registerApproveDiff();
340
+ this._registerEvaluateJs();
341
+ this._registerDebugReloadPage();
95
342
  this._registerWatchAdd();
96
343
  this._registerWatchRemove();
97
344
  this._registerWatchSet();
98
345
  this._registerWatchCompare();
99
346
  this._registerWaitForUpdate();
100
347
  this._registerSessions();
348
+ this._registerRestartSession();
349
+ this._registerOpenSession();
350
+ this._registerCloseSession();
351
+ this._registerUpdateSessionRef();
352
+ this._registerGetUrl();
353
+ this._registerCheckStability();
354
+ this._registerCheckTask();
355
+ this._registerCancelTask();
101
356
  }
102
357
  _registerListFixtures() {
103
358
  this._mcp.registerTool('list_fixtures', {
104
359
  description: 'List all fixtures from a session',
105
360
  inputSchema: {
361
+ fixtureIdPattern: z.string().optional().describe('RegExp to filter fixtures by fixture ID'),
362
+ labelPattern: z.string().optional().describe('RegExp to filter fixtures by label (matched against inherited labels)'),
106
363
  sessionName: z.string().optional().describe('Session name (defaults to first session)'),
107
364
  sourceTreeId: z.string().optional().describe('Source tree ID (defaults to latest known)'),
108
365
  },
109
- }, async (args) => {
366
+ annotations: { readOnlyHint: true },
367
+ }, async (args) => this._withDaemon(async (daemon) => {
110
368
  const sessionName = args.sessionName ?? this._defaultSessionName();
111
- const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
112
- const fixtures = await this._daemon.methods.fixtures.list({ sessionName, sourceTreeId });
113
- return {
114
- content: [{ type: 'text', text: JSON.stringify(fixtures, null, 2) }],
115
- };
116
- });
369
+ this._log('debug', { type: 'tool-call', tool: 'list_fixtures', sessionName });
370
+ return this._withSourceTreeRetry(async () => {
371
+ const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
372
+ const allFixtures = await daemon.methods.fixtures.list({ sessionName, sourceTreeId });
373
+ const filtered = this._filterFixtures(allFixtures, args.fixtureIdPattern, args.labelPattern);
374
+ if ('error' in filtered) {
375
+ return { content: [{ type: 'text', text: filtered.error }], isError: true };
376
+ }
377
+ return {
378
+ content: [{ type: 'text', text: JSON.stringify(filtered.fixtures, null, 2) }],
379
+ };
380
+ });
381
+ }));
117
382
  }
118
383
  _registerScreenshot() {
119
384
  this._mcp.registerTool('screenshot', {
120
- description: 'Take a screenshot of a single fixture',
385
+ description: 'Take a screenshot of a single fixture. ' +
386
+ 'When stabilityCheck is true, the fixture is unmounted and re-mounted, then three screenshots are taken. ',
121
387
  inputSchema: {
122
388
  fixtureId: z.string().describe('The fixture ID'),
123
389
  sessionName: z.string().optional().describe('Session name (defaults to first session)'),
124
390
  sourceTreeId: z.string().optional().describe('Source tree ID (defaults to latest known)'),
391
+ 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..'),
125
392
  },
126
- }, async (args) => {
393
+ annotations: { readOnlyHint: true },
394
+ }, async (args) => this._withDaemon(async (daemon) => {
127
395
  const sessionName = args.sessionName ?? this._defaultSessionName();
128
- const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
129
- const result = await this._daemon.methods.screenshots.take({
130
- fixtureId: args.fixtureId,
131
- sessionName,
132
- sourceTreeId,
133
- includeImage: true,
396
+ this._log('debug', { type: 'tool-call', tool: 'screenshot', fixtureId: args.fixtureId, sessionName });
397
+ this._log('trace', { type: 'tool-args', tool: 'screenshot', args });
398
+ return this._withSourceTreeRetry(async () => {
399
+ const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
400
+ const result = await daemon.methods.screenshots.take({
401
+ fixtureId: args.fixtureId,
402
+ sessionName,
403
+ sourceTreeId,
404
+ includeImage: true,
405
+ stabilityCheck: args.stabilityCheck,
406
+ });
407
+ const r = result;
408
+ this._updateSessionSourceTreeId(sessionName, r.sourceTreeId);
409
+ const info = {
410
+ hash: r.hash,
411
+ sourceTreeId: r.sourceTreeId,
412
+ };
413
+ if (r.hasError) {
414
+ info.hasError = true;
415
+ }
416
+ if (r.error) {
417
+ info.error = r.error;
418
+ }
419
+ if (r.events && r.events.length > 0) {
420
+ info.events = r.events;
421
+ }
422
+ if (r.resultData !== undefined) {
423
+ info.resultData = r.resultData;
424
+ }
425
+ if (r.isStable !== undefined) {
426
+ info.isStable = r.isStable;
427
+ }
428
+ const content = [];
429
+ if (r.isStable === false && r.stabilityScreenshots) {
430
+ // Not stable: return all distinct screenshots
431
+ const seenHashes = new Set();
432
+ const screenshotDetails = [];
433
+ for (const s of r.stabilityScreenshots) {
434
+ screenshotDetails.push({ hash: s.hash, delayMs: s.delayMs });
435
+ if (!seenHashes.has(s.hash) && s.image) {
436
+ seenHashes.add(s.hash);
437
+ content.push({ type: 'image', data: s.image, mimeType: 'image/png' });
438
+ }
439
+ }
440
+ info.stabilityScreenshots = screenshotDetails;
441
+ }
442
+ else if (r.image) {
443
+ // Stable or no stability check: return single image
444
+ content.push({ type: 'image', data: r.image, mimeType: 'image/png' });
445
+ }
446
+ content.unshift({ type: 'text', text: JSON.stringify(info, null, 2) });
447
+ return { content };
134
448
  });
135
- const r = result;
136
- this._updateSessionSourceTreeId(sessionName, r.sourceTreeId);
137
- const content = [
138
- { type: 'text', text: `hash: ${r.hash}\nsourceTreeId: ${r.sourceTreeId}` },
139
- ];
140
- if (r.image) {
141
- content.push({ type: 'image', data: r.image, mimeType: 'image/png' });
142
- }
143
- return { content };
144
- });
449
+ }));
145
450
  }
146
451
  _registerCompareScreenshot() {
147
- this._mcp.registerTool('compare_screenshot', {
452
+ const tool = this._mcp.registerTool('compare_screenshot', {
148
453
  description: 'Compare a fixture\'s screenshot across two sessions (e.g. baseline vs current)',
149
454
  inputSchema: {
150
455
  fixtureId: z.string().describe('The fixture ID'),
@@ -153,42 +458,74 @@ class ComponentExplorerMcpServer {
153
458
  baselineSourceTreeId: z.string().optional().describe('Baseline source tree ID (defaults to latest known)'),
154
459
  currentSourceTreeId: z.string().optional().describe('Current source tree ID (defaults to latest known)'),
155
460
  },
156
- }, async (args) => {
461
+ annotations: { readOnlyHint: true },
462
+ }, async (args) => this._withDaemon(async (daemon) => {
157
463
  const baselineSessionName = args.baselineSessionName ?? this._defaultBaselineSessionName();
158
464
  const currentSessionName = args.currentSessionName ?? this._defaultCurrentSessionName();
159
- const baselineSourceTreeId = args.baselineSourceTreeId ?? this._sourceTreeId(baselineSessionName);
160
- const currentSourceTreeId = args.currentSourceTreeId ?? this._sourceTreeId(currentSessionName);
161
- const result = await this._daemon.methods.screenshots.compare({
162
- fixtureId: args.fixtureId,
163
- baselineSessionName,
164
- baselineSourceTreeId,
165
- currentSessionName,
166
- currentSourceTreeId,
167
- includeImages: true,
465
+ this._log('debug', { type: 'tool-call', tool: 'compare_screenshot', fixtureId: args.fixtureId, baselineSessionName, currentSessionName });
466
+ return this._withSourceTreeRetry(async () => {
467
+ const baselineSourceTreeId = args.baselineSourceTreeId ?? this._sourceTreeId(baselineSessionName);
468
+ const currentSourceTreeId = args.currentSourceTreeId ?? this._sourceTreeId(currentSessionName);
469
+ const result = await daemon.methods.screenshots.compare({
470
+ fixtureId: args.fixtureId,
471
+ baselineSessionName,
472
+ baselineSourceTreeId,
473
+ currentSessionName,
474
+ currentSourceTreeId,
475
+ includeImages: true,
476
+ });
477
+ const r = result;
478
+ const info = {
479
+ match: r.match,
480
+ baselineHash: r.baselineHash,
481
+ currentHash: r.currentHash,
482
+ baselineSourceTreeId,
483
+ currentSourceTreeId,
484
+ };
485
+ if (r.baselineHasError) {
486
+ info.baselineHasError = true;
487
+ }
488
+ if (r.baselineError) {
489
+ info.baselineError = r.baselineError;
490
+ }
491
+ if (r.baselineEvents && r.baselineEvents.length > 0) {
492
+ info.baselineEvents = r.baselineEvents;
493
+ }
494
+ if (r.baselineResultData !== undefined) {
495
+ info.baselineResultData = r.baselineResultData;
496
+ }
497
+ if (r.currentHasError) {
498
+ info.currentHasError = true;
499
+ }
500
+ if (r.currentError) {
501
+ info.currentError = r.currentError;
502
+ }
503
+ if (r.currentEvents && r.currentEvents.length > 0) {
504
+ info.currentEvents = r.currentEvents;
505
+ }
506
+ if (r.currentResultData !== undefined) {
507
+ info.currentResultData = r.currentResultData;
508
+ }
509
+ if (r.approval) {
510
+ info.approval = r.approval;
511
+ }
512
+ const content = [
513
+ { type: 'text', text: JSON.stringify(info, null, 2) },
514
+ ];
515
+ if (r.baselineImage) {
516
+ content.push({ type: 'image', data: r.baselineImage, mimeType: 'image/png' });
517
+ }
518
+ if (r.currentImage) {
519
+ content.push({ type: 'image', data: r.currentImage, mimeType: 'image/png' });
520
+ }
521
+ return { content };
168
522
  });
169
- const r = result;
170
- const info = {
171
- match: r.match,
172
- baselineHash: r.baselineHash,
173
- currentHash: r.currentHash,
174
- };
175
- if (r.approval) {
176
- info.approval = r.approval;
177
- }
178
- const content = [
179
- { type: 'text', text: JSON.stringify(info, null, 2) },
180
- ];
181
- if (r.baselineImage) {
182
- content.push({ type: 'image', data: r.baselineImage, mimeType: 'image/png' });
183
- }
184
- if (r.currentImage) {
185
- content.push({ type: 'image', data: r.currentImage, mimeType: 'image/png' });
186
- }
187
- return { content };
188
- });
523
+ }));
524
+ tool.disable();
525
+ this._multiSessionTools.push(tool);
189
526
  }
190
527
  _registerApproveDiff() {
191
- this._mcp.registerTool('approve_diff', {
528
+ const tool = this._mcp.registerTool('approve_diff', {
192
529
  description: 'Approve a visual diff so it won\'t require re-inspection next time',
193
530
  inputSchema: {
194
531
  fixtureId: z.string(),
@@ -196,15 +533,77 @@ class ComponentExplorerMcpServer {
196
533
  modifiedHash: z.string(),
197
534
  comment: z.string().describe('Reason for approving this diff'),
198
535
  },
199
- }, async (args) => {
200
- await this._daemon.methods.approvals.approve(args);
536
+ }, async (args) => this._withDaemon(async (daemon) => {
537
+ await daemon.methods.approvals.approve(args);
201
538
  return {
202
539
  content: [{
203
540
  type: 'text',
204
541
  text: `Approved diff for ${args.fixtureId}: ${args.originalHash} → ${args.modifiedHash}`,
205
542
  }],
206
543
  };
207
- });
544
+ }));
545
+ tool.disable();
546
+ this._multiSessionTools.push(tool);
547
+ }
548
+ _registerEvaluateJs() {
549
+ this._mcp.registerTool('evaluate_js', {
550
+ description: 'Evaluate a JavaScript expression in the browser page where fixtures are rendered, for debugging purposes. ' +
551
+ 'Returns the expression result as JSON. The expression can return a Promise (it will be awaited). ' +
552
+ 'Use this to inspect DOM state, computed styles, element dimensions, or component output. ' +
553
+ 'Do NOT use this to modify the DOM — this tool is for read-only inspection and debugging only.',
554
+ inputSchema: {
555
+ expression: z.string().describe('JavaScript expression to evaluate. Can return a Promise. The result must be JSON-serializable.'),
556
+ fixtureId: z.string().optional().describe('If provided, renders this fixture before evaluating the expression'),
557
+ sessionName: z.string().optional().describe('Session name (defaults to first session)'),
558
+ sourceTreeId: z.string().optional().describe('Source tree ID (defaults to latest known)'),
559
+ },
560
+ }, async (args) => this._withDaemon(async (daemon) => {
561
+ const sessionName = args.sessionName ?? this._defaultSessionName();
562
+ this._log('debug', { type: 'tool-call', tool: 'evaluate_js', sessionName, hasFixtureId: !!args.fixtureId });
563
+ this._log('trace', { type: 'tool-args', tool: 'evaluate_js', expressionLength: args.expression.length, fixtureId: args.fixtureId });
564
+ return this._withSourceTreeRetry(async () => {
565
+ const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
566
+ const result = await daemon.methods.evaluate({
567
+ sessionName,
568
+ sourceTreeId,
569
+ expression: args.expression,
570
+ fixtureId: args.fixtureId,
571
+ });
572
+ const r = result;
573
+ let text;
574
+ try {
575
+ text = JSON.stringify(r.result, null, 2) ?? 'undefined';
576
+ }
577
+ catch {
578
+ text = String(r.result);
579
+ }
580
+ return {
581
+ content: [{ type: 'text', text }],
582
+ };
583
+ });
584
+ }));
585
+ }
586
+ _registerDebugReloadPage() {
587
+ this._mcp.registerTool('debug_reload_page', {
588
+ description: 'Force-reload the browser page used for rendering fixtures. ' +
589
+ 'Only use this as a last resort if screenshots or evaluate_js return stale/broken results ' +
590
+ 'that persist after source changes. Normal HMR updates should handle most cases automatically.',
591
+ inputSchema: {
592
+ sessionName: z.string().optional().describe('Session name (defaults to first session)'),
593
+ },
594
+ annotations: { destructiveHint: true },
595
+ }, async (args) => this._withDaemon(async (daemon) => {
596
+ const sessionName = args.sessionName ?? this._defaultSessionName();
597
+ const sourceTreeId = this._sourceTreeId(sessionName);
598
+ await daemon.methods.evaluate({
599
+ sessionName,
600
+ sourceTreeId,
601
+ expression: 'location.reload()',
602
+ });
603
+ return {
604
+ content: [{ type: 'text', text: `Reloaded page for session '${sessionName}'.` }],
605
+ };
606
+ }));
208
607
  }
209
608
  _registerWatchAdd() {
210
609
  this._mcp.registerTool('watch_add', {
@@ -255,7 +654,7 @@ class ComponentExplorerMcpServer {
255
654
  });
256
655
  }
257
656
  _registerWatchCompare() {
258
- this._mcp.registerTool('watch_compare', {
657
+ const tool = this._mcp.registerTool('watch_compare', {
259
658
  description: 'Compare all watched fixtures across two sessions. Takes fresh screenshots from both sessions and reports which fixtures differ.',
260
659
  inputSchema: {
261
660
  baselineSessionName: z.string().optional().describe('Baseline session name (defaults to worktree session)'),
@@ -263,124 +662,483 @@ class ComponentExplorerMcpServer {
263
662
  baselineSourceTreeId: z.string().optional().describe('Baseline source tree ID (defaults to latest known)'),
264
663
  currentSourceTreeId: z.string().optional().describe('Current source tree ID (defaults to latest known)'),
265
664
  },
266
- }, async (args) => {
665
+ annotations: { readOnlyHint: true },
666
+ }, async (args) => this._withDaemon(async (daemon) => {
267
667
  const ids = [...this._watchList.fixtureIds];
268
668
  if (ids.length === 0) {
269
669
  return { content: [{ type: 'text', text: 'Watch list is empty. Use watch_add or watch_set first.' }] };
270
670
  }
271
671
  const baselineSessionName = args.baselineSessionName ?? this._defaultBaselineSessionName();
272
672
  const currentSessionName = args.currentSessionName ?? this._defaultCurrentSessionName();
273
- const baselineSourceTreeId = args.baselineSourceTreeId ?? this._sourceTreeId(baselineSessionName);
274
- const currentSourceTreeId = args.currentSourceTreeId ?? this._sourceTreeId(currentSessionName);
275
- const [baselineResult, currentResult] = await Promise.all([
276
- this._daemon.methods.screenshots.takeBatch({
277
- fixtureIds: ids,
278
- sessionName: baselineSessionName,
279
- sourceTreeId: baselineSourceTreeId,
280
- }),
281
- this._daemon.methods.screenshots.takeBatch({
282
- fixtureIds: ids,
283
- sessionName: currentSessionName,
284
- sourceTreeId: currentSourceTreeId,
285
- }),
286
- ]);
287
- const br = baselineResult;
288
- const cr = currentResult;
289
- const baselineMap = new Map(br.screenshots.map(s => [s.fixtureId, s.hash]));
290
- const currentMap = new Map(cr.screenshots.map(s => [s.fixtureId, s.hash]));
291
- const entries = ids.map(id => {
292
- const bHash = baselineMap.get(id) ?? '';
293
- const cHash = currentMap.get(id) ?? '';
673
+ return this._withSourceTreeRetry(async () => {
674
+ const baselineSourceTreeId = args.baselineSourceTreeId ?? this._sourceTreeId(baselineSessionName);
675
+ const currentSourceTreeId = args.currentSourceTreeId ?? this._sourceTreeId(currentSessionName);
676
+ const [baselineResult, currentResult] = await Promise.all([
677
+ daemon.methods.screenshots.takeBatch({
678
+ fixtureIds: ids,
679
+ sessionName: baselineSessionName,
680
+ sourceTreeId: baselineSourceTreeId,
681
+ }),
682
+ daemon.methods.screenshots.takeBatch({
683
+ fixtureIds: ids,
684
+ sessionName: currentSessionName,
685
+ sourceTreeId: currentSourceTreeId,
686
+ }),
687
+ ]);
688
+ const br = baselineResult;
689
+ const cr = currentResult;
690
+ const baselineMap = new Map(br.screenshots.map(s => [s.fixtureId, s.hash]));
691
+ const currentMap = new Map(cr.screenshots.map(s => [s.fixtureId, s.hash]));
692
+ const entries = ids.map(id => {
693
+ const bHash = baselineMap.get(id) ?? '';
694
+ const cHash = currentMap.get(id) ?? '';
695
+ return {
696
+ fixtureId: id,
697
+ match: bHash === cHash,
698
+ baselineHash: bHash,
699
+ currentHash: cHash,
700
+ };
701
+ });
702
+ // Look up approvals for changed fixtures
703
+ const results = [];
704
+ for (const entry of entries) {
705
+ let approval = undefined;
706
+ if (!entry.match && entry.baselineHash && entry.currentHash) {
707
+ approval = await daemon.methods.approvals.lookup({
708
+ fixtureId: entry.fixtureId,
709
+ originalHash: entry.baselineHash,
710
+ modifiedHash: entry.currentHash,
711
+ }) ?? undefined;
712
+ }
713
+ results.push({ ...entry, approval });
714
+ }
294
715
  return {
295
- fixtureId: id,
296
- match: bHash === cHash,
297
- baselineHash: bHash,
298
- currentHash: cHash,
716
+ content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
299
717
  };
300
718
  });
301
- // Look up approvals for changed fixtures
302
- const results = [];
303
- for (const entry of entries) {
304
- let approval = undefined;
305
- if (!entry.match && entry.baselineHash && entry.currentHash) {
306
- approval = await this._daemon.methods.approvals.lookup({
307
- fixtureId: entry.fixtureId,
308
- originalHash: entry.baselineHash,
309
- modifiedHash: entry.currentHash,
310
- }) ?? undefined;
311
- }
312
- results.push({ ...entry, approval });
313
- }
314
- return {
315
- content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
316
- };
317
- });
719
+ }));
720
+ tool.disable();
721
+ this._multiSessionTools.push(tool);
318
722
  }
319
723
  _registerWaitForUpdate() {
320
724
  this._mcp.registerTool('wait_for_update', {
321
- 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.',
322
- }, async () => {
323
- const events = await this._daemon.methods.events();
725
+ description: 'Block until the source tree changes from the given sourceTreeId. ' +
726
+ 'Pass the sourceTreeId you already observed — resolves immediately if it already differs, ' +
727
+ 'otherwise waits for a source-change or ref-change event. ' +
728
+ 'If fixtures are on the watch list, automatically re-screenshots them and reports which changed.',
729
+ inputSchema: {
730
+ sourceTreeId: z.string().describe('The sourceTreeId the client currently knows about. The call resolves once the source tree differs from this value.'),
731
+ sessionName: z.string().optional().describe('Session name (defaults to first session)'),
732
+ },
733
+ annotations: { readOnlyHint: true },
734
+ }, async (args) => this._withDaemon(async (daemon) => {
735
+ const sessionName = args.sessionName ?? this._defaultSessionName();
736
+ const knownSourceTreeId = args.sourceTreeId;
737
+ const currentSourceTreeId = this._sourceTreeId(sessionName);
738
+ if (currentSourceTreeId && currentSourceTreeId !== knownSourceTreeId) {
739
+ return this._waitForUpdateResult(daemon, sessionName, currentSourceTreeId);
740
+ }
741
+ // Wait for an event that changes the source tree (max 5s)
742
+ const events = await daemon.methods.events();
324
743
  const iterator = events[Symbol.asyncIterator]();
325
- const { value: event, done } = await iterator.next();
326
- // Close the stream after consuming one event
327
- await iterator.return?.();
328
- if (done || !event) {
329
- return { content: [{ type: 'text', text: 'Event stream ended.' }] };
744
+ try {
745
+ const deadline = Date.now() + 5000;
746
+ while (true) {
747
+ const remaining = deadline - Date.now();
748
+ if (remaining <= 0) {
749
+ return { content: [{ type: 'text', text: JSON.stringify({ timeout: true, sessionName, sourceTreeId: knownSourceTreeId }, null, 2) }] };
750
+ }
751
+ const timeout = new Promise(resolve => setTimeout(() => resolve('timeout'), remaining));
752
+ const next = iterator.next();
753
+ const result = await Promise.race([next, timeout]);
754
+ if (result === 'timeout') {
755
+ return { content: [{ type: 'text', text: JSON.stringify({ timeout: true, sessionName, sourceTreeId: knownSourceTreeId }, null, 2) }] };
756
+ }
757
+ const { value: event, done } = result;
758
+ if (done || !event) {
759
+ return { content: [{ type: 'text', text: 'Event stream ended.' }] };
760
+ }
761
+ const ev = event;
762
+ if (ev.type === 'source-change' && ev.sourceTreeId) {
763
+ this._updateSessionSourceTreeId(ev.sessionName, ev.sourceTreeId);
764
+ }
765
+ if (ev.type === 'ref-change') {
766
+ const refreshResult = await Promise.race([this._refreshSessions(), timeout]);
767
+ if (refreshResult === 'timeout') {
768
+ return { content: [{ type: 'text', text: JSON.stringify({ timeout: true, sessionName, sourceTreeId: knownSourceTreeId }, null, 2) }] };
769
+ }
770
+ }
771
+ const newSourceTreeId = this._sourceTreeId(sessionName);
772
+ if (newSourceTreeId && newSourceTreeId !== knownSourceTreeId) {
773
+ return this._waitForUpdateResult(daemon, sessionName, newSourceTreeId);
774
+ }
775
+ }
330
776
  }
331
- const ev = event;
332
- // Update cached session info
333
- if (ev.type === 'source-change' && ev.sourceTreeId) {
334
- this._updateSessionSourceTreeId(ev.sessionName, ev.sourceTreeId);
777
+ finally {
778
+ await iterator.return?.();
335
779
  }
336
- if (ev.type === 'ref-change') {
337
- await this._refreshSessions();
780
+ }));
781
+ }
782
+ async _waitForUpdateResult(daemon, sessionName, sourceTreeId) {
783
+ const watchedIds = [...this._watchList.fixtureIds];
784
+ if (watchedIds.length > 0) {
785
+ const batchResult = await daemon.methods.screenshots.takeBatch({
786
+ fixtureIds: watchedIds,
787
+ sessionName,
788
+ sourceTreeId,
789
+ });
790
+ const br = batchResult;
791
+ const changes = [];
792
+ for (const s of br.screenshots) {
793
+ const prevHash = this._watchList.getHash(s.fixtureId);
794
+ const changed = prevHash !== undefined && prevHash !== s.hash;
795
+ this._watchList.setHash(s.fixtureId, s.hash);
796
+ if (changed) {
797
+ changes.push({ fixtureId: s.fixtureId, previousHash: prevHash, hash: s.hash });
798
+ }
338
799
  }
339
- // If there are watched fixtures and this is a source-change, re-screenshot them
340
- const watchedIds = [...this._watchList.fixtureIds];
341
- if (ev.type === 'source-change' && watchedIds.length > 0 && ev.sourceTreeId) {
342
- const batchResult = await this._daemon.methods.screenshots.takeBatch({
343
- fixtureIds: watchedIds,
344
- sessionName: ev.sessionName,
345
- sourceTreeId: ev.sourceTreeId,
346
- });
347
- const br = batchResult;
348
- const changes = [];
349
- for (const s of br.screenshots) {
350
- const prevHash = this._watchList.getHash(s.fixtureId);
351
- const changed = prevHash !== undefined && prevHash !== s.hash;
352
- this._watchList.setHash(s.fixtureId, s.hash);
353
- if (changed) {
354
- changes.push({ fixtureId: s.fixtureId, previousHash: prevHash, hash: s.hash });
800
+ return {
801
+ content: [{
802
+ type: 'text',
803
+ text: JSON.stringify({
804
+ sourceTreeId,
805
+ sessionName,
806
+ watchedFixtures: br.screenshots.length,
807
+ changed: changes,
808
+ }, null, 2),
809
+ }],
810
+ };
811
+ }
812
+ return {
813
+ content: [{
814
+ type: 'text',
815
+ text: JSON.stringify({ sourceTreeId, sessionName }, null, 2),
816
+ }],
817
+ };
818
+ }
819
+ _registerSessions() {
820
+ this._mcp.registerTool('sessions', {
821
+ description: 'List active sessions with their names, URLs, and current sourceTreeIds',
822
+ annotations: { readOnlyHint: true },
823
+ }, async () => this._withDaemon(async (_daemon) => {
824
+ await this._refreshSessions();
825
+ return {
826
+ content: [{ type: 'text', text: JSON.stringify(this._sessions, null, 2) }],
827
+ };
828
+ }));
829
+ }
830
+ _registerRestartSession() {
831
+ this._mcp.registerTool('restart_session', {
832
+ description: 'Restart a session by disposing its browser page and dev server, then recreating them. ' +
833
+ 'Use this when a session appears stuck (e.g. after a timeout).',
834
+ inputSchema: {
835
+ sessionName: z.string().optional().describe('Session name to restart (defaults to first session)'),
836
+ },
837
+ annotations: { destructiveHint: true },
838
+ }, async (args) => this._withDaemon(async (daemon) => {
839
+ const sessionName = args.sessionName ?? this._defaultSessionName();
840
+ this._log('info', { type: 'tool-call', tool: 'restart_session', sessionName });
841
+ const sessions = await daemon.methods.restartSession({ sessionName });
842
+ this._sessions = sessions;
843
+ return {
844
+ content: [{ type: 'text', text: `Session '${sessionName}' restarted.\n` + JSON.stringify(sessions, null, 2) }],
845
+ };
846
+ }));
847
+ }
848
+ _registerOpenSession() {
849
+ this._mcp.registerTool('open_session', {
850
+ description: 'Open a new worktree-backed session at a given git ref. ' +
851
+ 'The ref can be a branch name, tag, commit SHA, or the special value "INDEX" to snapshot staged changes. ' +
852
+ 'The daemon allocates a reusable worktree slot from a fixed pool (max configured in component-explorer.json). ' +
853
+ 'Returns the updated session list on success.',
854
+ inputSchema: {
855
+ name: z.string().describe('Unique session name (e.g. "baseline", "bisect")'),
856
+ ref: z.string().describe('Git ref: branch, tag, commit SHA, or "INDEX" for staged changes'),
857
+ },
858
+ }, async (args) => this._withDaemon(async (daemon) => {
859
+ this._log('info', { type: 'tool-call', tool: 'open_session', name: args.name, ref: args.ref });
860
+ const result = await daemon.methods.openSession({ name: args.name, ref: args.ref });
861
+ if ('error' in result) {
862
+ return { content: [{ type: 'text', text: result.error }], isError: true };
863
+ }
864
+ this._sessions = result.sessions;
865
+ this._updateMultiSessionToolVisibility();
866
+ return {
867
+ content: [{ type: 'text', text: JSON.stringify(result.sessions, null, 2) }],
868
+ };
869
+ }, { noTimeout: true }));
870
+ }
871
+ _registerCloseSession() {
872
+ this._mcp.registerTool('close_session', {
873
+ description: 'Close a dynamic worktree session and release its worktree slot back to the pool. ' +
874
+ 'Cannot close static sessions configured in component-explorer.json.',
875
+ inputSchema: {
876
+ name: z.string().describe('Session name to close'),
877
+ },
878
+ annotations: { destructiveHint: true },
879
+ }, async (args) => this._withDaemon(async (daemon) => {
880
+ this._log('info', { type: 'tool-call', tool: 'close_session', name: args.name });
881
+ const result = await daemon.methods.closeSession({ name: args.name });
882
+ if ('error' in result) {
883
+ return { content: [{ type: 'text', text: result.error }], isError: true };
884
+ }
885
+ this._sessions = result.sessions;
886
+ this._updateMultiSessionToolVisibility();
887
+ return {
888
+ content: [{ type: 'text', text: `Session '${args.name}' closed.\n` + JSON.stringify(result.sessions, null, 2) }],
889
+ };
890
+ }));
891
+ }
892
+ _registerUpdateSessionRef() {
893
+ this._mcp.registerTool('update_session_ref', {
894
+ description: 'Change the git ref of an existing dynamic session. ' +
895
+ 'The worktree is checked out to the new ref and Vite\'s HMR handles the incremental update (no server restart). ' +
896
+ 'Fails if the worktree has uncommitted changes — the error will list the dirty files. ' +
897
+ 'The ref can be a branch, tag, commit SHA, or "INDEX" for staged changes.',
898
+ inputSchema: {
899
+ name: z.string().describe('Session name to update'),
900
+ ref: z.string().describe('New git ref: branch, tag, commit SHA, or "INDEX"'),
901
+ },
902
+ }, async (args) => this._withDaemon(async (daemon) => {
903
+ this._log('info', { type: 'tool-call', tool: 'update_session_ref', name: args.name, ref: args.ref });
904
+ const result = await daemon.methods.updateSessionRef({ name: args.name, ref: args.ref });
905
+ if ('error' in result) {
906
+ return { content: [{ type: 'text', text: result.error }], isError: true };
907
+ }
908
+ this._sessions = result.sessions;
909
+ return {
910
+ content: [{ type: 'text', text: JSON.stringify(result.sessions, null, 2) }],
911
+ };
912
+ }, { noTimeout: true }));
913
+ }
914
+ _registerGetUrl() {
915
+ this._mcp.registerTool('get_url', {
916
+ description: 'Get URL(s) for viewing fixtures. Returns the Component Explorer UI URL by default, ' +
917
+ 'or the raw render URL for embedding/screenshots when useRawDirectRenderingWithoutExplorerUi is true.',
918
+ inputSchema: {
919
+ sessionName: z.string().optional().describe('Session name (defaults to first session)'),
920
+ fixtureId: z.string().optional().describe('Specific fixture ID. If omitted, returns URL for the explorer root or all fixtures.'),
921
+ useRawDirectRenderingWithoutExplorerUi: z.boolean().optional().describe('If true, returns the raw rendering URL (for embedding or screenshots) instead of the Explorer UI URL. ' +
922
+ 'The raw URL renders only the fixture without the explorer chrome. Default: false.'),
923
+ },
924
+ annotations: { readOnlyHint: true },
925
+ }, async (args) => {
926
+ const sessionName = args.sessionName ?? this._defaultSessionName();
927
+ let session = this._sessions.find(s => s.name === sessionName);
928
+ if (!session) {
929
+ const daemon = await this._waitForDaemon();
930
+ if (daemon) {
931
+ try {
932
+ await this._refreshSessions();
355
933
  }
934
+ catch (e) {
935
+ if (isPipeConnectionError(e)) {
936
+ this._handleDisconnect();
937
+ }
938
+ else {
939
+ throw e;
940
+ }
941
+ }
942
+ }
943
+ session = this._sessions.find(s => s.name === sessionName);
944
+ if (!session) {
945
+ return {
946
+ content: [{
947
+ type: 'text',
948
+ text: `Error: Session '${sessionName}' not found. Available sessions: ${this._sessions.map(s => s.name).join(', ') || '(none)'}`,
949
+ }],
950
+ isError: true,
951
+ };
356
952
  }
953
+ }
954
+ const baseUrl = session && !session.isLoading ? session.serverUrl : undefined;
955
+ if (!baseUrl) {
956
+ return {
957
+ content: [{
958
+ type: 'text',
959
+ text: `Error: Session '${sessionName}' is still loading.`,
960
+ }],
961
+ isError: true,
962
+ };
963
+ }
964
+ const useRaw = args.useRawDirectRenderingWithoutExplorerUi ?? false;
965
+ const url = buildExplorerUrl({
966
+ baseUrl,
967
+ rawRender: useRaw,
968
+ fixtureId: args.fixtureId,
969
+ });
970
+ const result = {
971
+ url,
972
+ sessionName,
973
+ };
974
+ if (args.fixtureId) {
975
+ result.fixtureId = args.fixtureId;
976
+ }
977
+ if (useRaw) {
978
+ result.mode = 'raw-render';
979
+ }
980
+ else {
981
+ result.mode = 'explorer';
982
+ if (args.fixtureId) {
983
+ result.note = 'Fixture selection in the Explorer UI is not URL-based. Navigate to the fixture manually in the tree view.';
984
+ }
985
+ }
986
+ return {
987
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
988
+ };
989
+ });
990
+ }
991
+ _registerCheckStability() {
992
+ this._mcp.registerTool('check_stability', {
993
+ description: 'Check rendering stability of fixtures. Each fixture is unmounted, re-mounted, and screenshotted 3 times (~3s per fixture). ' +
994
+ 'Returns results directly if finished within ~10s, otherwise returns a taskId for polling via check_task. ' +
995
+ 'When returning a taskId, includes partial results collected so far.',
996
+ inputSchema: {
997
+ fixtureIdPattern: z.string().optional().describe('RegExp to filter fixtures by fixture ID'),
998
+ labelPattern: z.string().optional().describe('RegExp to filter fixtures by label (matched against inherited labels)'),
999
+ sessionName: z.string().optional().describe('Session name (defaults to first session)'),
1000
+ sourceTreeId: z.string().optional().describe('Source tree ID (defaults to latest known)'),
1001
+ },
1002
+ annotations: { readOnlyHint: true },
1003
+ }, async (args) => this._withDaemon(async (daemon) => {
1004
+ const sessionName = args.sessionName ?? this._defaultSessionName();
1005
+ this._log('debug', { type: 'tool-call', tool: 'check_stability', sessionName, fixtureIdPattern: args.fixtureIdPattern, labelPattern: args.labelPattern });
1006
+ return this._withSourceTreeRetry(async () => {
1007
+ const sourceTreeId = args.sourceTreeId ?? this._sourceTreeId(sessionName);
1008
+ const allFixtures = await daemon.methods.fixtures.list({ sessionName, sourceTreeId });
1009
+ const filtered = this._filterFixtures(allFixtures, args.fixtureIdPattern, args.labelPattern);
1010
+ if ('error' in filtered) {
1011
+ return { content: [{ type: 'text', text: filtered.error }], isError: true };
1012
+ }
1013
+ const fixtures = filtered.fixtures;
1014
+ this._log('info', { type: 'check-stability-start', total: fixtures.length, filtered: allFixtures.length - fixtures.length });
1015
+ const task = this._taskManager.startTask(async (report, signal) => {
1016
+ const results = [];
1017
+ report({ completed: 0, total: fixtures.length, partialResult: results });
1018
+ for (let i = 0; i < fixtures.length; i++) {
1019
+ if (signal.aborted) {
1020
+ break;
1021
+ }
1022
+ const fixture = fixtures[i];
1023
+ this._log('info', { type: 'check-stability-progress', fixtureId: fixture.fixtureId, index: i + 1, total: fixtures.length });
1024
+ const result = await daemon.methods.screenshots.take({
1025
+ fixtureId: fixture.fixtureId,
1026
+ sessionName,
1027
+ sourceTreeId,
1028
+ includeImage: false,
1029
+ stabilityCheck: true,
1030
+ });
1031
+ const r = result;
1032
+ results.push({
1033
+ fixtureId: fixture.fixtureId,
1034
+ isStable: r.isStable ?? true,
1035
+ screenshots: r.stabilityScreenshots?.map(s => ({ hash: s.hash, delayMs: s.delayMs })) ?? [],
1036
+ });
1037
+ report({ completed: i + 1, total: fixtures.length, partialResult: results });
1038
+ }
1039
+ const stable = results.filter(r => r.isStable).length;
1040
+ return {
1041
+ fixtures: results,
1042
+ summary: { total: results.length, stable, unstable: results.length - stable },
1043
+ };
1044
+ });
1045
+ const waited = await this._taskManager.waitForTask(task.id, 10_000);
1046
+ if (!waited) {
1047
+ return { content: [{ type: 'text', text: 'Error: task disappeared' }], isError: true };
1048
+ }
1049
+ if (waited.done) {
1050
+ return {
1051
+ content: [{ type: 'text', text: JSON.stringify(waited.result, null, 2) }],
1052
+ };
1053
+ }
1054
+ const partial = waited.progress.partialResult;
1055
+ this._taskLastReportedIndex.set(task.id, partial.length);
357
1056
  return {
358
1057
  content: [{
359
1058
  type: 'text',
360
1059
  text: JSON.stringify({
361
- event: ev,
362
- watchedFixtures: br.screenshots.length,
363
- changed: changes,
1060
+ taskId: task.id,
1061
+ status: 'running',
1062
+ progress: { completed: waited.progress.completed, total: waited.progress.total },
1063
+ elapsedMs: waited.elapsedMs,
1064
+ results: partial,
364
1065
  }, null, 2),
365
1066
  }],
366
1067
  };
1068
+ });
1069
+ }));
1070
+ }
1071
+ _registerCheckTask() {
1072
+ this._mcp.registerTool('check_task', {
1073
+ description: 'Check on a running task. Waits up to ~2s for completion; if still running, returns progress and new results since last check.',
1074
+ inputSchema: {
1075
+ taskId: z.string().describe('The task ID returned by a previous tool call'),
1076
+ },
1077
+ annotations: { readOnlyHint: true },
1078
+ }, async (args) => {
1079
+ const waited = await this._taskManager.waitForTask(args.taskId, 2_000);
1080
+ if (!waited) {
1081
+ this._taskLastReportedIndex.delete(args.taskId);
1082
+ return {
1083
+ content: [{ type: 'text', text: `Error: No task found with id '${args.taskId}'` }],
1084
+ isError: true,
1085
+ };
367
1086
  }
1087
+ if (waited.done) {
1088
+ const lastIndex = this._taskLastReportedIndex.get(args.taskId) ?? 0;
1089
+ this._taskLastReportedIndex.delete(args.taskId);
1090
+ const fullResult = waited.result;
1091
+ const newResults = fullResult.fixtures.slice(lastIndex);
1092
+ return {
1093
+ content: [{
1094
+ type: 'text',
1095
+ text: JSON.stringify({
1096
+ status: 'done',
1097
+ newResults,
1098
+ summary: fullResult.summary,
1099
+ }, null, 2),
1100
+ }],
1101
+ };
1102
+ }
1103
+ const partial = waited.progress.partialResult;
1104
+ const lastIndex = this._taskLastReportedIndex.get(args.taskId) ?? 0;
1105
+ const newResults = partial.slice(lastIndex);
1106
+ this._taskLastReportedIndex.set(args.taskId, partial.length);
368
1107
  return {
369
- content: [{ type: 'text', text: JSON.stringify(ev, null, 2) }],
1108
+ content: [{
1109
+ type: 'text',
1110
+ text: JSON.stringify({
1111
+ taskId: args.taskId,
1112
+ status: 'running',
1113
+ progress: { completed: waited.progress.completed, total: waited.progress.total },
1114
+ elapsedMs: waited.elapsedMs,
1115
+ newResults,
1116
+ }, null, 2),
1117
+ }],
370
1118
  };
371
1119
  });
372
1120
  }
373
- _registerSessions() {
374
- this._mcp.registerTool('sessions', {
375
- description: 'List active sessions with their names, URLs, and current sourceTreeIds',
376
- }, async () => {
377
- await this._refreshSessions();
1121
+ _registerCancelTask() {
1122
+ this._mcp.registerTool('cancel_task', {
1123
+ description: 'Cancel a running task',
1124
+ inputSchema: {
1125
+ taskId: z.string().describe('The task ID to cancel'),
1126
+ },
1127
+ }, async (args) => {
1128
+ const task = this._taskManager.getTask(args.taskId);
1129
+ if (!task) {
1130
+ return {
1131
+ content: [{ type: 'text', text: `Error: No task found with id '${args.taskId}'` }],
1132
+ isError: true,
1133
+ };
1134
+ }
1135
+ this._taskManager.removeTask(args.taskId);
378
1136
  return {
379
- content: [{ type: 'text', text: JSON.stringify(this._sessions, null, 2) }],
1137
+ content: [{ type: 'text', text: `Task '${args.taskId}' cancelled.` }],
380
1138
  };
381
1139
  });
382
1140
  }
383
1141
  }
384
1142
 
385
- export { ComponentExplorerMcpServer };
1143
+ export { ComponentExplorerMcpServer, DaemonConnection };
386
1144
  //# sourceMappingURL=McpServer.js.map