@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.
- package/README.md +0 -2
- package/dist/handlers/filesystem-handlers.js +24 -4
- package/dist/remote-device/remote-channel.d.ts +14 -0
- package/dist/remote-device/remote-channel.js +107 -28
- package/dist/server.d.ts +6 -1
- package/dist/server.js +112 -25
- package/dist/setup-claude-server.js +56 -50
- package/dist/terminal-manager.d.ts +18 -0
- package/dist/terminal-manager.js +130 -18
- package/dist/tools/edit.js +11 -4
- package/dist/tools/filesystem.d.ts +5 -0
- package/dist/tools/filesystem.js +43 -0
- package/dist/tools/fuzzySearch.d.ts +10 -20
- package/dist/tools/fuzzySearch.js +66 -105
- package/dist/tools/fuzzySearchCore.d.ts +52 -0
- package/dist/tools/fuzzySearchCore.js +125 -0
- package/dist/tools/improved-process-tools.js +8 -1
- package/dist/tools/pdf/markdown.d.ts +13 -0
- package/dist/tools/pdf/markdown.js +93 -29
- package/dist/tools/schemas.d.ts +20 -0
- package/dist/tools/schemas.js +45 -2
- package/dist/track-installation.js +57 -38
- package/dist/types.d.ts +6 -1
- package/dist/ui/contracts.d.ts +1 -1
- package/dist/ui/contracts.js +4 -1
- package/dist/ui/file-preview/preview-runtime.js +111 -113
- package/dist/ui/file-preview/src/app.js +19 -17
- package/dist/ui/file-preview/src/directory-controller.js +3 -3
- package/dist/ui/file-preview/src/file-type-handlers.js +2 -2
- package/dist/ui/file-preview/src/host/external-actions.d.ts +0 -11
- package/dist/ui/file-preview/src/host/external-actions.js +0 -39
- package/dist/ui/file-preview/src/markdown/controller.js +3 -1
- package/dist/ui/file-preview/src/panel-actions.js +2 -2
- package/dist/uninstall-claude-server.js +54 -47
- package/dist/utils/ab-test.d.ts +4 -0
- package/dist/utils/ab-test.js +6 -0
- package/dist/utils/capture.d.ts +10 -2
- package/dist/utils/capture.js +92 -60
- package/dist/utils/mcp-ui-ab-test.d.ts +13 -0
- package/dist/utils/mcp-ui-ab-test.js +62 -0
- package/dist/utils/unsupportedParams.d.ts +24 -0
- package/dist/utils/unsupportedParams.js +66 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
-
//
|
|
112
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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(
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
//
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
-
|
|
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: [] }));
|