@wonderwhy-er/desktop-commander 0.2.41 β†’ 0.2.43

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 (45) hide show
  1. package/README.md +0 -2
  2. package/dist/handlers/filesystem-handlers.js +24 -4
  3. package/dist/remote-device/remote-channel.d.ts +14 -0
  4. package/dist/remote-device/remote-channel.js +107 -28
  5. package/dist/server.d.ts +6 -1
  6. package/dist/server.js +112 -25
  7. package/dist/setup-claude-server.js +56 -50
  8. package/dist/terminal-manager.d.ts +18 -0
  9. package/dist/terminal-manager.js +130 -18
  10. package/dist/tools/edit.js +11 -4
  11. package/dist/tools/filesystem.d.ts +5 -0
  12. package/dist/tools/filesystem.js +43 -0
  13. package/dist/tools/fuzzySearch.d.ts +10 -20
  14. package/dist/tools/fuzzySearch.js +66 -105
  15. package/dist/tools/fuzzySearchCore.d.ts +52 -0
  16. package/dist/tools/fuzzySearchCore.js +125 -0
  17. package/dist/tools/improved-process-tools.js +8 -1
  18. package/dist/tools/pdf/markdown.d.ts +13 -0
  19. package/dist/tools/pdf/markdown.js +93 -29
  20. package/dist/tools/schemas.d.ts +20 -0
  21. package/dist/tools/schemas.js +45 -2
  22. package/dist/track-installation.js +57 -38
  23. package/dist/types.d.ts +6 -1
  24. package/dist/ui/contracts.d.ts +1 -1
  25. package/dist/ui/contracts.js +4 -1
  26. package/dist/ui/file-preview/preview-runtime.js +111 -113
  27. package/dist/ui/file-preview/src/app.js +19 -17
  28. package/dist/ui/file-preview/src/directory-controller.js +3 -3
  29. package/dist/ui/file-preview/src/file-type-handlers.js +2 -2
  30. package/dist/ui/file-preview/src/host/external-actions.d.ts +0 -11
  31. package/dist/ui/file-preview/src/host/external-actions.js +0 -39
  32. package/dist/ui/file-preview/src/markdown/controller.js +3 -1
  33. package/dist/ui/file-preview/src/panel-actions.js +2 -2
  34. package/dist/uninstall-claude-server.js +54 -47
  35. package/dist/utils/ab-test.d.ts +4 -0
  36. package/dist/utils/ab-test.js +6 -0
  37. package/dist/utils/capture.d.ts +10 -2
  38. package/dist/utils/capture.js +92 -60
  39. package/dist/utils/mcp-ui-ab-test.d.ts +13 -0
  40. package/dist/utils/mcp-ui-ab-test.js +62 -0
  41. package/dist/utils/unsupportedParams.d.ts +24 -0
  42. package/dist/utils/unsupportedParams.js +66 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +6 -1
package/README.md CHANGED
@@ -17,8 +17,6 @@ Work with code and text, run processes, and automate tasks, going far beyond oth
17
17
  <img width="380" height="200" src="https://glama.ai/mcp/servers/zempur9oh4/badge" alt="Desktop Commander MCP" />
18
18
  </a>
19
19
 
20
- ## πŸ‘‹ We’re hiring β€” come build with us: https://desktopcommander.app/careers/
21
-
22
20
  ## πŸ–₯️ Try the Desktop Commander App (Beta)
23
21
 
24
22
  **Want a better experience?** The Desktop Commander App gives you everything the MCP server does, plus:
@@ -1,4 +1,4 @@
1
- import { readFile, readMultipleFiles, writeFile, createDirectory, listDirectory, moveFile, getFileInfo, writePdf } from '../tools/filesystem.js';
1
+ import { readFile, readMultipleFiles, writeFile, createDirectory, listDirectory, moveFile, getFileInfo, writePdf, getDefaultEditorMetadata } from '../tools/filesystem.js';
2
2
  import { withTimeout } from '../utils/withTimeout.js';
3
3
  import { createErrorResponse } from '../error-handlers.js';
4
4
  import { configManager } from '../config-manager.js';
@@ -99,6 +99,8 @@ export async function handleReadFile(args) {
99
99
  fileName: path.basename(resolvedFilePath),
100
100
  filePath: resolvedFilePath,
101
101
  fileType: 'unsupported',
102
+ sourceTool: 'read_file',
103
+ ...await getDefaultEditorMetadata(resolvedFilePath),
102
104
  content: pdfContent
103
105
  .filter((item) => item.type === "text")
104
106
  .map((item) => item.text)
@@ -108,8 +110,9 @@ export async function handleReadFile(args) {
108
110
  }
109
111
  // Handle image files
110
112
  if (fileResult.metadata?.isImage) {
111
- // For image files, keep content payload text-only for broad host compatibility.
112
- // The preview widget reads image bytes from structuredContent.
113
+ // Return the image bytes in the MCP content array so the host model can
114
+ // actually see the image. structuredContent additionally carries the bytes
115
+ // for the preview widget to render.
113
116
  const imageData = typeof fileResult.content === 'string'
114
117
  ? fileResult.content
115
118
  : fileResult.content.toString('base64');
@@ -119,14 +122,22 @@ export async function handleReadFile(args) {
119
122
  {
120
123
  type: "text",
121
124
  text: imageSummary
125
+ },
126
+ {
127
+ type: "image",
128
+ data: imageData,
129
+ mimeType: fileResult.mimeType
122
130
  }
123
131
  ],
124
132
  structuredContent: {
125
133
  fileName: path.basename(resolvedFilePath),
126
134
  filePath: resolvedFilePath,
127
135
  fileType: 'image',
136
+ sourceTool: 'read_file',
137
+ ...await getDefaultEditorMetadata(resolvedFilePath),
138
+ // Image bytes for the preview widget, carried once. Deduped from the
139
+ // old `content` + `imageData` pair down to this single `content` field.
128
140
  content: imageData,
129
- imageData,
130
141
  mimeType: fileResult.mimeType
131
142
  }
132
143
  };
@@ -145,6 +156,8 @@ export async function handleReadFile(args) {
145
156
  fileName: path.basename(resolvedFilePath),
146
157
  filePath: resolvedFilePath,
147
158
  fileType,
159
+ sourceTool: 'read_file',
160
+ ...await getDefaultEditorMetadata(resolvedFilePath),
148
161
  content: textContent,
149
162
  },
150
163
  };
@@ -252,6 +265,8 @@ export async function handleWriteFile(args) {
252
265
  fileName: path.basename(resolvedWritePath),
253
266
  filePath: resolvedWritePath,
254
267
  fileType: resolvePreviewFileType(resolvedWritePath),
268
+ sourceTool: 'write_file',
269
+ ...await getDefaultEditorMetadata(resolvedWritePath),
255
270
  },
256
271
  };
257
272
  }
@@ -293,6 +308,11 @@ export async function handleListDirectory(args) {
293
308
  fileName: path.basename(resolvedPath),
294
309
  filePath: resolvedPath,
295
310
  fileType: 'directory',
311
+ sourceTool: 'list_directory',
312
+ // Carry the listing in structuredContent too. Chat reads the text
313
+ // content array, but structuredContent-only consumers (e.g. Cowork)
314
+ // render from here and would otherwise show an empty directory.
315
+ content: resultText,
296
316
  },
297
317
  };
298
318
  }
@@ -20,6 +20,8 @@ export declare class RemoteChannel {
20
20
  private onToolCall;
21
21
  private lastDeviceStatus;
22
22
  private lastChannelState;
23
+ private reconnectAttempt;
24
+ private isRecreatingChannel;
23
25
  private _user;
24
26
  get user(): User | null;
25
27
  initialize(url: string, key: string): void;
@@ -50,10 +52,22 @@ export declare class RemoteChannel {
50
52
  * This is used for both initial subscription and recreation after socket reconnects.
51
53
  */
52
54
  private createChannel;
55
+ /**
56
+ * Compact connection state for logs β€” e.g. "socket=open(1) ch=errored attempt=3".
57
+ * readyState 1=OPEN (a 1 while joins keep failing = a half-open socket being reused),
58
+ * 3=CLOSED, '-'=no socket. Reads realtime-js internals defensively; never throws.
59
+ */
60
+ private connState;
53
61
  /**
54
62
  * Check if channel is connected, recreate if not.
55
63
  */
56
64
  private checkConnectionHealth;
65
+ /**
66
+ * Run an async op but reject if it doesn't settle within `ms`, so a hung await
67
+ * can't leave isRecreatingChannel stuck true and disable the watchdog. Mirrors
68
+ * closeWithTimeout() in desktop-commander-integration.ts.
69
+ */
70
+ private withTimeout;
57
71
  /**
58
72
  * Recreate the channel by destroying old one and creating fresh instance.
59
73
  */
@@ -1,6 +1,9 @@
1
1
  import { createClient } from '@supabase/supabase-js';
2
2
  import { captureRemote } from '../utils/capture.js';
3
3
  const HEARTBEAT_INTERVAL = 15000;
4
+ // Cap a single channel recreate so a hung await can't pin the re-entrancy guard
5
+ // true (which would silently disable the connection watchdog).
6
+ const RECREATE_TIMEOUT_MS = 30000;
4
7
  export class RemoteChannel {
5
8
  constructor() {
6
9
  this.client = null;
@@ -14,6 +17,9 @@ export class RemoteChannel {
14
17
  this.lastDeviceStatus = 'offline';
15
18
  // Track last channel state for debug logging
16
19
  this.lastChannelState = null;
20
+ // Reconnect diagnostics + guard (see connState() / recreateChannel())
21
+ this.reconnectAttempt = 0; // recreateChannel() attempts since last success
22
+ this.isRecreatingChannel = false; // a recreate is in flight (re-entrancy guard)
17
23
  this._user = null;
18
24
  }
19
25
  get user() { return this._user; }
@@ -128,7 +134,7 @@ export class RemoteChannel {
128
134
  console.debug('[DEBUG] Calling createChannel()');
129
135
  // ! Ignore silently in Initialization to reconnect after
130
136
  await this.createChannel().catch((error) => {
131
- console.debug('[DEBUG] Failed to create channel, will retry after socket reconnect', error);
137
+ console.debug(`[DEBUG] Failed to create channel, will retry after socket reconnect: ${error?.message || error} β€” ${this.connState()}`);
132
138
  });
133
139
  }
134
140
  else {
@@ -162,9 +168,11 @@ export class RemoteChannel {
162
168
  })
163
169
  .subscribe((status, err) => {
164
170
  // Debug: Log all subscription status events
165
- console.debug(`[DEBUG] Channel subscription status: ${status}${err ? ' (error: ' + err + ')' : ''}`);
171
+ console.debug(`[DEBUG] Channel subscription status: ${status}${err ? ' (error: ' + (err?.message || err) + ')' : ''} β€” ${this.connState()}`);
166
172
  if (status === 'SUBSCRIBED') {
167
- console.log('βœ… Channel subscribed');
173
+ const recovered = this.reconnectAttempt;
174
+ this.reconnectAttempt = 0;
175
+ console.log(`βœ… Channel subscribed${recovered > 0 ? ` (recovered after ${recovered} attempt${recovered === 1 ? '' : 's'})` : ''}`);
168
176
  // Update device status on successful connection
169
177
  if (this.deviceId) {
170
178
  this.setOnlineStatus(this.deviceId, 'online').catch(e => {
@@ -174,20 +182,43 @@ export class RemoteChannel {
174
182
  resolve();
175
183
  }
176
184
  else if (status === 'CHANNEL_ERROR') {
177
- // console.error('❌ Channel subscription failed:', err);
185
+ // CHANNEL_ERROR is the only status carrying a real error message.
186
+ console.error(`❌ Channel error: ${err?.message || 'unknown'} β€” ${this.connState()}`);
178
187
  this.setOnlineStatus(this.deviceId, 'offline');
179
- captureRemote('remote_channel_subscription_error', { error: err || 'Channel error' }).catch(() => { });
188
+ captureRemote('remote_channel_subscription_error', { error: err?.message || 'Channel error' }).catch(() => { });
180
189
  reject(err || new Error('Failed to initialize tool call channel subscription'));
181
190
  }
182
191
  else if (status === 'TIMED_OUT') {
183
- console.error('⏱️ Channel subscription timed out, Reconnecting...');
192
+ console.error(`⏱️ Channel subscription timed out, Reconnecting... β€” ${this.connState()}`);
184
193
  this.setOnlineStatus(this.deviceId, 'offline');
185
- captureRemote('remote_channel_subscription_timeout', {}).catch(() => { });
194
+ captureRemote('remote_channel_subscription_timeout', { attempt: this.reconnectAttempt }).catch(() => { });
186
195
  reject(new Error('Tool call channel subscription timed out'));
187
196
  }
197
+ else if (status === 'CLOSED') {
198
+ // Settle the promise so an in-flight recreateChannel() can't await
199
+ // forever (which would wedge the re-entrancy guard / watchdog), and
200
+ // mark the device offline like the other degraded states.
201
+ console.warn(`⚠️ Channel closed β€” ${this.connState()}`);
202
+ this.setOnlineStatus(this.deviceId, 'offline');
203
+ reject(new Error('Tool call channel closed during subscribe'));
204
+ }
188
205
  });
189
206
  });
190
207
  }
208
+ /**
209
+ * Compact connection state for logs β€” e.g. "socket=open(1) ch=errored attempt=3".
210
+ * readyState 1=OPEN (a 1 while joins keep failing = a half-open socket being reused),
211
+ * 3=CLOSED, '-'=no socket. Reads realtime-js internals defensively; never throws.
212
+ */
213
+ connState() {
214
+ let socket = '?';
215
+ try {
216
+ const rt = this.client?.realtime;
217
+ socket = `${rt?.connectionState?.() ?? '?'}(${rt?.conn?.readyState ?? '-'})`;
218
+ }
219
+ catch { /* best effort */ }
220
+ return `socket=${socket} ch=${this.channel?.state ?? '-'} attempt=${this.reconnectAttempt}`;
221
+ }
191
222
  /**
192
223
  * Check if channel is connected, recreate if not.
193
224
  */
@@ -198,41 +229,89 @@ export class RemoteChannel {
198
229
  const state = this.channel.state;
199
230
  // Debug: Log current channel state (only if changed)
200
231
  if (!this.lastChannelState || this.lastChannelState !== state) {
201
- console.debug(`[DEBUG] channel state: ${state}`);
232
+ console.debug(`[DEBUG] channel state: ${state} β€” ${this.connState()}`);
202
233
  this.lastChannelState = state;
203
234
  }
204
- // Aggressive health check: Only 'joined' is considered healthy
205
- // Any other state (joining, leaving, closed, errored, etc.) triggers recreation
206
- if (state !== 'joined') {
207
- captureRemote('remote_channel_state_health', { state });
208
- console.debug(`[DEBUG] ⚠️ Channel in unhealthy state '${state}' - recreating...`);
209
- this.recreateChannel();
235
+ // 'joined' = healthy, 'joining' = transitional β€” let realtime-js's own rejoin
236
+ // backoff converge instead of tearing the channel down mid-join. (FIX: previously
237
+ // recreated on every non-joined state, which amputated that backoff.)
238
+ if (state === 'joined' || state === 'joining')
239
+ return;
240
+ // Unhealthy: closed, errored, leaving β€” recreate
241
+ captureRemote('remote_channel_state_health', { state, attempt: this.reconnectAttempt });
242
+ console.debug(`[DEBUG] ⚠️ Channel in unhealthy state '${state}' - recreating... β€” ${this.connState()}`);
243
+ this.recreateChannel();
244
+ }
245
+ /**
246
+ * Run an async op but reject if it doesn't settle within `ms`, so a hung await
247
+ * can't leave isRecreatingChannel stuck true and disable the watchdog. Mirrors
248
+ * closeWithTimeout() in desktop-commander-integration.ts.
249
+ */
250
+ async withTimeout(op, ms, name) {
251
+ let timer;
252
+ try {
253
+ return await Promise.race([
254
+ op(),
255
+ new Promise((_, reject) => {
256
+ timer = setTimeout(() => reject(new Error(`${name} timed out after ${ms}ms`)), ms);
257
+ }),
258
+ ]);
259
+ }
260
+ finally {
261
+ if (timer)
262
+ clearTimeout(timer);
210
263
  }
211
264
  }
212
265
  /**
213
266
  * Recreate the channel by destroying old one and creating fresh instance.
214
267
  */
215
- recreateChannel() {
268
+ async recreateChannel() {
216
269
  if (!this.client || !this.user?.id || !this.onToolCall) {
217
270
  console.warn('Cannot recreate channel - missing parameters');
218
271
  console.debug('[DEBUG] recreateChannel() aborted - missing prerequisites');
219
272
  return;
220
273
  }
221
- // Destroy old channel
222
- if (this.channel) {
223
- console.debug('[DEBUG] Destroying old channel');
224
- this.client.removeChannel(this.channel);
225
- this.channel = null;
274
+ // FIX: re-entrancy guard so a 10s health tick can't stack a second recreate
275
+ // on top of an in-flight one.
276
+ if (this.isRecreatingChannel) {
277
+ console.debug('[DEBUG] recreateChannel() skipped - already in progress');
278
+ return;
226
279
  }
280
+ this.isRecreatingChannel = true;
281
+ this.reconnectAttempt++;
227
282
  // Create fresh channel
228
- console.log('πŸ”„ Recreating channel...');
229
- console.debug('[DEBUG] Calling createChannel() for recreation');
230
- this.createChannel().catch(err => {
231
- captureRemote('remote_channel_recreate_error', { err });
232
- console.debug('[DEBUG] Channel recreation failed:', err.message);
233
- // TODO: enable only for debug mode
234
- // console.error('Failed to recreate channel:', err);
235
- });
283
+ console.log(`πŸ”„ Recreating channel... (attempt ${this.reconnectAttempt}) β€” ${this.connState()}`);
284
+ try {
285
+ // Cap the whole recreate: a never-settling await (e.g. a subscribe that only
286
+ // ever emits CLOSED) must not pin isRecreatingChannel=true and silently disable
287
+ // the 10s watchdog. On timeout we reject -> catch -> finally clears the guard.
288
+ await this.withTimeout(async () => {
289
+ // Destroy old channel β€” AWAIT it so the channel registry empties before we
290
+ // rebuild. (The un-awaited version raced the synchronous new-channel push, so
291
+ // realtime-js never tore the socket down and a half-open one got reused.)
292
+ if (this.channel) {
293
+ console.debug('[DEBUG] Destroying old channel');
294
+ await this.client.removeChannel(this.channel);
295
+ this.channel = null;
296
+ }
297
+ // FIX (core): force a brand-new WebSocket. After idle / wifi-loss the socket can
298
+ // be HALF-OPEN (readyState OPEN but dead); reusing it made every join TIME_OUT
299
+ // forever. disconnect() drops it so the next subscribe() dials a fresh one.
300
+ try {
301
+ await this.client.realtime?.disconnect?.();
302
+ }
303
+ catch { /* best effort */ }
304
+ console.debug('[DEBUG] Calling createChannel() for recreation');
305
+ await this.createChannel();
306
+ }, RECREATE_TIMEOUT_MS, 'recreateChannel');
307
+ }
308
+ catch (err) {
309
+ captureRemote('remote_channel_recreate_error', { errMsg: err?.message, attempt: this.reconnectAttempt });
310
+ console.debug(`[DEBUG] Channel recreation failed: ${err?.message} β€” ${this.connState()}`);
311
+ }
312
+ finally {
313
+ this.isRecreatingChannel = false;
314
+ }
236
315
  }
237
316
  async markCallExecuting(callId) {
238
317
  if (!this.client)
package/dist/server.d.ts CHANGED
@@ -38,4 +38,9 @@ declare let currentClient: {
38
38
  name: string;
39
39
  version: string;
40
40
  };
41
- export { currentClient };
41
+ declare let currentCallIsRemote: boolean;
42
+ declare let currentRemoteClient: {
43
+ name?: string;
44
+ version?: string;
45
+ } | null;
46
+ export { currentClient, currentCallIsRemote, currentRemoteClient };
package/dist/server.js CHANGED
@@ -8,7 +8,8 @@ const OS_GUIDANCE = getOSSpecificGuidance(SYSTEM_INFO);
8
8
  const DEV_TOOL_GUIDANCE = getDevelopmentToolGuidance(SYSTEM_INFO);
9
9
  const PATH_GUIDANCE = `IMPORTANT: ${getPathGuidance(SYSTEM_INFO)} Relative paths may fail as they depend on the current working directory. Tilde paths (~/...) might not work in all contexts. Unless the user explicitly asks for relative paths, use absolute paths.`;
10
10
  const CMD_PREFIX_DESCRIPTION = `This command can be referenced as "DC: ..." or "use Desktop Commander to ..." in your instructions.`;
11
- import { StartProcessArgsSchema, ReadProcessOutputArgsSchema, InteractWithProcessArgsSchema, ForceTerminateArgsSchema, ListSessionsArgsSchema, KillProcessArgsSchema, ReadFileArgsSchema, ReadMultipleFilesArgsSchema, WriteFileArgsSchema, CreateDirectoryArgsSchema, ListDirectoryArgsSchema, MoveFileArgsSchema, GetFileInfoArgsSchema, GetConfigArgsSchema, SetConfigValueArgsSchema, ListProcessesArgsSchema, EditBlockArgsSchema, GetUsageStatsArgsSchema, GiveFeedbackArgsSchema, StartSearchArgsSchema, GetMoreSearchResultsArgsSchema, StopSearchArgsSchema, ListSearchesArgsSchema, GetPromptsArgsSchema, GetRecentToolCallsArgsSchema, WritePdfArgsSchema, } from './tools/schemas.js';
11
+ import { StartProcessArgsSchema, ReadProcessOutputArgsSchema, InteractWithProcessArgsSchema, ForceTerminateArgsSchema, ListSessionsArgsSchema, KillProcessArgsSchema, ReadFileArgsSchema, ReadMultipleFilesArgsSchema, WriteFileArgsSchema, CreateDirectoryArgsSchema, ListDirectoryArgsSchema, MoveFileArgsSchema, GetFileInfoArgsSchema, GetConfigArgsSchema, SetConfigValueArgsSchema, ListProcessesArgsSchema, EditBlockArgsSchema, GetUsageStatsArgsSchema, GiveFeedbackArgsSchema, StartSearchArgsSchema, GetMoreSearchResultsArgsSchema, StopSearchArgsSchema, ListSearchesArgsSchema, GetPromptsArgsSchema, GetRecentToolCallsArgsSchema, WritePdfArgsSchema, toolArgSchemas, } from './tools/schemas.js';
12
+ import { detectUnsupportedParams, getSupportedParams, buildUnsupportedParamsWarning, } from './utils/unsupportedParams.js';
12
13
  import { getConfig, setConfigValue } from './tools/config.js';
13
14
  import { getUsageStats } from './tools/usage.js';
14
15
  import { giveFeedbackToDesktopCommander } from './tools/feedback.js';
@@ -21,8 +22,9 @@ import { handleWelcomePageOnboarding } from './utils/welcome-onboarding.js';
21
22
  import { VERSION } from './version.js';
22
23
  import { capture, capture_call_tool } from "./utils/capture.js";
23
24
  import { logToStderr, logger } from './utils/logger.js';
24
- import { buildUiToolMeta, CONFIG_EDITOR_RESOURCE_URI, FILE_PREVIEW_RESOURCE_URI } from './ui/contracts.js';
25
+ import { buildUiToolMeta, CONFIG_EDITOR_RESOURCE_URI, FILE_PREVIEW_RESOURCE_URI, } from './ui/contracts.js';
25
26
  import { listUiResources, readUiResource } from './ui/resources.js';
27
+ import { shouldShowMcpUiPreviews } from './utils/mcp-ui-ab-test.js';
26
28
  // Store startup messages to send after initialization
27
29
  const deferredMessages = [];
28
30
  function deferLog(level, message) {
@@ -70,6 +72,34 @@ server.setRequestHandler(ListPromptsRequestSchema, async () => {
70
72
  });
71
73
  // Store current client info (simple variable)
72
74
  let currentClient = { name: 'uninitialized', version: 'uninitialized' };
75
+ // Tracks whether the in-flight tool call originated from a remote device.
76
+ // Mirrors the module-level `currentClient` pattern so that telemetry events
77
+ // emitted deeper inside tool handlers (e.g. server_start_process,
78
+ // server_read_file) can be attributed to the remote path. The CallTool
79
+ // handler sets this on every call (true when _meta.remote is present,
80
+ // false otherwise) so the flag never leaks from a remote call to a
81
+ // subsequent local call.
82
+ let currentCallIsRemote = false;
83
+ /**
84
+ * Set whether the current tool call is from a remote device.
85
+ * Called once per tool call by the CallTool handler.
86
+ */
87
+ function setCurrentCallIsRemote(isRemote) {
88
+ currentCallIsRemote = isRemote;
89
+ }
90
+ // The remote caller's client for the in-flight tool call (e.g. openai-mcp,
91
+ // claude-ai). Set per CallTool when the call is remote; null for local calls.
92
+ // Mirrors currentCallIsRemote so telemetry attributes remote events to the
93
+ // actual remote client instead of the device's own currentClient (which stays
94
+ // LOCAL and must not be polluted by remote callers).
95
+ let currentRemoteClient = null;
96
+ /**
97
+ * Set the remote caller's client for the current tool call (null when local).
98
+ * Called once per tool call by the CallTool handler.
99
+ */
100
+ function setCurrentRemoteClient(clientInfo) {
101
+ currentRemoteClient = clientInfo;
102
+ }
73
103
  /**
74
104
  * Unified way to update client information
75
105
  */
@@ -131,7 +161,7 @@ server.setRequestHandler(InitializeRequestSchema, async (request) => {
131
161
  }
132
162
  });
133
163
  // Export current client info for access by other modules
134
- export { currentClient };
164
+ export { currentClient, currentCallIsRemote, currentRemoteClient };
135
165
  deferLog('info', 'Setting up request handlers...');
136
166
  /**
137
167
  * Check if a tool should be included based on current client
@@ -150,6 +180,7 @@ function shouldIncludeTool(toolName) {
150
180
  server.setRequestHandler(ListToolsRequestSchema, async () => {
151
181
  try {
152
182
  // logToStderr('debug', 'Generating tools list...');
183
+ const showMcpUiPreviews = await shouldShowMcpUiPreviews();
153
184
  // Build complete tools array
154
185
  const allTools = [
155
186
  // Configuration tools
@@ -169,7 +200,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
169
200
  - systemInfo (operating system and environment details)
170
201
  ${CMD_PREFIX_DESCRIPTION}`,
171
202
  inputSchema: zodToJsonSchema(GetConfigArgsSchema),
172
- _meta: buildUiToolMeta(CONFIG_EDITOR_RESOURCE_URI, true),
203
+ _meta: buildUiToolMeta(CONFIG_EDITOR_RESOURCE_URI, true, showMcpUiPreviews),
173
204
  annotations: {
174
205
  title: "Get Configuration",
175
206
  readOnlyHint: true,
@@ -262,7 +293,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
262
293
  ${PATH_GUIDANCE}
263
294
  ${CMD_PREFIX_DESCRIPTION}`,
264
295
  inputSchema: zodToJsonSchema(ReadFileArgsSchema),
265
- _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true),
296
+ _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true, showMcpUiPreviews),
266
297
  annotations: {
267
298
  title: "Read File or URL",
268
299
  readOnlyHint: true,
@@ -330,7 +361,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
330
361
  ${PATH_GUIDANCE}
331
362
  ${CMD_PREFIX_DESCRIPTION}`,
332
363
  inputSchema: zodToJsonSchema(WriteFileArgsSchema),
333
- _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true),
364
+ _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true, showMcpUiPreviews),
334
365
  annotations: {
335
366
  title: "Write File",
336
367
  readOnlyHint: false,
@@ -451,7 +482,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
451
482
  ${PATH_GUIDANCE}
452
483
  ${CMD_PREFIX_DESCRIPTION}`,
453
484
  inputSchema: zodToJsonSchema(ListDirectoryArgsSchema),
454
- _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true),
485
+ _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true, showMcpUiPreviews),
455
486
  annotations: {
456
487
  title: "List Directory Contents",
457
488
  readOnlyHint: true,
@@ -710,7 +741,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
710
741
  ${PATH_GUIDANCE}
711
742
  ${CMD_PREFIX_DESCRIPTION}`,
712
743
  inputSchema: zodToJsonSchema(EditBlockArgsSchema),
713
- _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true),
744
+ _meta: buildUiToolMeta(FILE_PREVIEW_RESOURCE_URI, true, showMcpUiPreviews),
714
745
  annotations: {
715
746
  title: "Edit Block",
716
747
  readOnlyHint: false,
@@ -1091,28 +1122,54 @@ import * as handlers from './handlers/index.js';
1091
1122
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1092
1123
  const { name, arguments: args } = request.params;
1093
1124
  const startTime = Date.now();
1125
+ // Hoisted above the try so the finally block can read them when emitting the
1126
+ // server_call_tool completion event (duration + status), even on the crash path.
1127
+ let telemetryData = { tool_name: name };
1128
+ let result;
1129
+ let isError = false;
1094
1130
  try {
1095
- // Prepare telemetry data - add config key for set_config_value
1096
- const telemetryData = { tool_name: name };
1097
- // Extract metadata from _meta field if present
1131
+ // telemetryData declared above; extract metadata from _meta field if present
1098
1132
  const metadata = request.params._meta;
1099
- if (metadata && typeof metadata === 'object') {
1100
- // add remote flag if present (convert to string for GA4)
1101
- if (metadata.remote) {
1102
- telemetryData.remote = String(metadata.remote);
1103
- }
1104
- // Dynamically update client info if provided in _meta
1105
- // To use in capture later
1106
- if (metadata.clientInfo) {
1107
- await updateCurrentClient(metadata.clientInfo);
1108
- telemetryData.client_name = metadata.clientInfo.name;
1109
- telemetryData.client_version = metadata.clientInfo.version;
1110
- }
1133
+ // Reset remote attribution for every call so a prior remote call never
1134
+ // leaks its flag onto a subsequent local call. Set to true only when
1135
+ // this call carries the remote marker in _meta.
1136
+ const isRemoteCall = !!(metadata && typeof metadata === 'object' && metadata.remote);
1137
+ setCurrentCallIsRemote(isRemoteCall);
1138
+ if (isRemoteCall) {
1139
+ // add remote flag (convert to string for telemetry)
1140
+ telemetryData.remote = String(metadata.remote);
1141
+ // Remote calls carry the originating MCP client (e.g. openai-mcp,
1142
+ // claude-ai) in _meta.clientInfo. Attribute this call to that remote
1143
+ // client β€” NOT the device's own currentClient. Fall back to a sentinel
1144
+ // when it's absent so the call is visibly remote-but-unattributed
1145
+ // rather than masquerading as the local device client. We deliberately
1146
+ // do NOT call updateCurrentClient here: currentClient tracks the LOCAL
1147
+ // client and must not be polluted (nor its transport reconfigured) by
1148
+ // remote callers.
1149
+ const remoteClient = metadata.clientInfo && (metadata.clientInfo.name || metadata.clientInfo.version)
1150
+ ? metadata.clientInfo
1151
+ : { name: 'remote-unknown', version: 'unknown' };
1152
+ setCurrentRemoteClient(remoteClient);
1153
+ telemetryData.client_name = remoteClient.name;
1154
+ telemetryData.client_version = remoteClient.version;
1155
+ }
1156
+ else {
1157
+ // Local call β€” clear any remote attribution left by a prior call.
1158
+ setCurrentRemoteClient(null);
1111
1159
  }
1112
1160
  if (name === 'set_config_value' && args && typeof args === 'object' && 'key' in args) {
1113
1161
  telemetryData.set_config_value_key_name = args.key;
1114
1162
  telemetryData.call_origin = args.origin === 'ui' ? 'ui' : 'llm';
1115
1163
  }
1164
+ // Attribute file-surface tool calls to the file-preview UI vs the LLM.
1165
+ // The UI passes origin:'ui' when it fires these tools to refresh/navigate
1166
+ // (pagination, folder expansion, link resolution, in-preview edits); the
1167
+ // first read_file that opens a file comes from the LLM and is recorded as
1168
+ // 'llm'. Lets us separate UI-refresh churn from genuine model-driven reads.
1169
+ if ((name === 'read_file' || name === 'write_file' || name === 'edit_block' || name === 'list_directory') &&
1170
+ args && typeof args === 'object') {
1171
+ telemetryData.call_origin = args.origin === 'ui' ? 'ui' : 'llm';
1172
+ }
1116
1173
  if (name === 'get_prompts' && args && typeof args === 'object') {
1117
1174
  const promptArgs = args;
1118
1175
  telemetryData.action = promptArgs.action;
@@ -1124,11 +1181,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1124
1181
  telemetryData.prompt_id = promptArgs.promptId;
1125
1182
  }
1126
1183
  }
1127
- capture_call_tool('server_call_tool', telemetryData);
1128
1184
  // Track tool call
1129
1185
  trackToolCall(name, args);
1130
1186
  // Using a more structured approach with dedicated handlers
1131
- let result;
1187
+ // (result is declared above so the finally block can read execution status)
1132
1188
  switch (name) {
1133
1189
  // Config tools
1134
1190
  case "get_config":
@@ -1320,6 +1376,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1320
1376
  }
1321
1377
  // Add tool call to history (exclude only get_recent_tool_calls to prevent recursion)
1322
1378
  const duration = Date.now() - startTime;
1379
+ isError = !!result.isError;
1323
1380
  const EXCLUDED_TOOLS = [
1324
1381
  'get_recent_tool_calls',
1325
1382
  'track_ui_event'
@@ -1410,9 +1467,29 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1410
1467
  // Check if should prompt about Docker environment
1411
1468
  result = await processDockerPrompt(result, name);
1412
1469
  }
1470
+ // If the caller sent parameters this tool does not support, Zod silently
1471
+ // strips them. Prepend a corrective warning so the model knows they were
1472
+ // ignored and which parameters are actually supported.
1473
+ try {
1474
+ const argSchema = toolArgSchemas[name];
1475
+ if (argSchema && result && Array.isArray(result.content)) {
1476
+ const unsupported = detectUnsupportedParams(args, argSchema);
1477
+ if (unsupported.length > 0) {
1478
+ const warning = buildUnsupportedParamsWarning(name, unsupported, getSupportedParams(argSchema));
1479
+ result.content = [
1480
+ { type: "text", text: warning },
1481
+ ...result.content,
1482
+ ];
1483
+ }
1484
+ }
1485
+ }
1486
+ catch {
1487
+ // Never let the advisory warning break an otherwise-successful call.
1488
+ }
1413
1489
  return result;
1414
1490
  }
1415
1491
  catch (error) {
1492
+ isError = true;
1416
1493
  const errorMessage = error instanceof Error ? error.message : String(error);
1417
1494
  // Track the failure
1418
1495
  await usageTracker.trackFailure(name);
@@ -1424,6 +1501,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1424
1501
  isError: true,
1425
1502
  };
1426
1503
  }
1504
+ finally {
1505
+ // Single tool-call telemetry event, fired AFTER execution so it can carry
1506
+ // timing. In a finally so it still fires on the hard-crash path (the catch
1507
+ // above). Only missed if a tool never returns or throws (a true hang).
1508
+ capture_call_tool('server_call_tool', {
1509
+ ...telemetryData,
1510
+ duration_ms: Date.now() - startTime,
1511
+ is_error: String(isError),
1512
+ });
1513
+ }
1427
1514
  });
1428
1515
  // Add no-op handlers so Visual Studio initialization succeeds
1429
1516
  server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: [] }));