@chatcut/cli 0.0.0-stage → 0.1.1
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 +96 -2
- package/bin/chatcut.mjs +4 -0
- package/package.json +25 -4
- package/src/cli.mjs +116 -0
- package/src/desktop.mjs +947 -0
- package/src/doctor.mjs +339 -0
- package/src/rpc.mjs +360 -0
package/src/doctor.mjs
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
|
|
5
|
+
import { ensureDesktop, inspectDesktop } from "./desktop.mjs";
|
|
6
|
+
import { DiagnosticError, probeMcp, requestDesktop } from "./rpc.mjs";
|
|
7
|
+
|
|
8
|
+
export function errorEvidence(error) {
|
|
9
|
+
if (typeof error.toJSON === "function") return error.toJSON();
|
|
10
|
+
return {
|
|
11
|
+
code: error.code ?? "diagnostic_failed",
|
|
12
|
+
message: error.message ?? String(error),
|
|
13
|
+
stage: error.stage ?? "diagnostic",
|
|
14
|
+
...(error.cause
|
|
15
|
+
? {
|
|
16
|
+
cause: {
|
|
17
|
+
code: error.cause.code,
|
|
18
|
+
message: error.cause.message ?? String(error.cause),
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
: {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function readDesktopEndpoint(desktop) {
|
|
26
|
+
const contents = await readFile(desktop.launcherPath, "utf8");
|
|
27
|
+
let socketPath;
|
|
28
|
+
if (desktop.platform === "win32") {
|
|
29
|
+
socketPath = contents.match(
|
|
30
|
+
/^set "CHATCUT_MCP_SOCKET=([^\r\n]*)"\r?$/m,
|
|
31
|
+
)?.[1];
|
|
32
|
+
} else {
|
|
33
|
+
const quoted = contents.match(/^export CHATCUT_MCP_SOCKET=('.*')$/m)?.[1];
|
|
34
|
+
if (quoted && /^'(?:[^']|'\\'')*'$/.test(quoted))
|
|
35
|
+
socketPath = quoted.slice(1, -1).replaceAll("'\\''", "'");
|
|
36
|
+
}
|
|
37
|
+
if (!socketPath)
|
|
38
|
+
throw new DiagnosticError(
|
|
39
|
+
"invalid_launcher",
|
|
40
|
+
"desktop",
|
|
41
|
+
"The Desktop MCP launcher has no readable socket path. Open ChatCut to refresh it.",
|
|
42
|
+
);
|
|
43
|
+
const capability = (
|
|
44
|
+
await readFile(
|
|
45
|
+
path.join(desktop.userDataPath, "mcp-invocation-capability"),
|
|
46
|
+
"utf8",
|
|
47
|
+
)
|
|
48
|
+
).trim();
|
|
49
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(capability))
|
|
50
|
+
throw new DiagnosticError(
|
|
51
|
+
"invalid_capability",
|
|
52
|
+
"desktop",
|
|
53
|
+
"The Desktop IPC capability is missing or invalid. Open ChatCut to refresh it.",
|
|
54
|
+
);
|
|
55
|
+
return { capability, socketPath };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function runDoctor(options = {}, dependencies = {}) {
|
|
59
|
+
const { client, fix = false } = options;
|
|
60
|
+
const inspect = dependencies.inspect ?? inspectDesktop;
|
|
61
|
+
const ensure = dependencies.ensure ?? ensureDesktop;
|
|
62
|
+
const probe = dependencies.probe ?? probeMcp;
|
|
63
|
+
const checks = [];
|
|
64
|
+
const findings = [];
|
|
65
|
+
let desktop = await inspect(options);
|
|
66
|
+
let repair;
|
|
67
|
+
let state;
|
|
68
|
+
if (fix) desktop = await ensure(options);
|
|
69
|
+
checks.push({
|
|
70
|
+
id: "desktop",
|
|
71
|
+
message: desktop.installed
|
|
72
|
+
? `ChatCut ${desktop.version ?? ""} is installed.`.replaceAll(" ", " ")
|
|
73
|
+
: "ChatCut Desktop is not installed. Run chatcut app.",
|
|
74
|
+
path: desktop.appPath,
|
|
75
|
+
status: desktop.installed ? "pass" : "fail",
|
|
76
|
+
});
|
|
77
|
+
if (desktop.installed) {
|
|
78
|
+
try {
|
|
79
|
+
const result = await inspectRunningDesktop(desktop, {
|
|
80
|
+
...dependencies,
|
|
81
|
+
client,
|
|
82
|
+
env: options.env,
|
|
83
|
+
fix,
|
|
84
|
+
platform: desktop.platform,
|
|
85
|
+
});
|
|
86
|
+
repair = result.repair;
|
|
87
|
+
state = result;
|
|
88
|
+
checks.push({
|
|
89
|
+
id: "desktop-inspection",
|
|
90
|
+
message: "Desktop answered the authenticated diagnostic request.",
|
|
91
|
+
status: "pass",
|
|
92
|
+
});
|
|
93
|
+
for (const [name, integration] of Object.entries(
|
|
94
|
+
result.integrations ?? {},
|
|
95
|
+
)) {
|
|
96
|
+
if (
|
|
97
|
+
integration.status === "unavailable" ||
|
|
98
|
+
integration.status === "disabled" ||
|
|
99
|
+
integration.status === "opted-out"
|
|
100
|
+
) {
|
|
101
|
+
findings.push({
|
|
102
|
+
id: `integration-${name}`,
|
|
103
|
+
message: `${name}: ${integration.status}.`,
|
|
104
|
+
status: "info",
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const connected =
|
|
109
|
+
integration.status === "connected" && !integration.errorMessage;
|
|
110
|
+
checks.push({
|
|
111
|
+
id: `integration-${name}`,
|
|
112
|
+
message: `${name}: ${integration.status}.`,
|
|
113
|
+
status: connected ? "pass" : "fail",
|
|
114
|
+
...(integration.errorMessage
|
|
115
|
+
? {
|
|
116
|
+
error: {
|
|
117
|
+
code: integration.errorCode,
|
|
118
|
+
message: integration.errorMessage,
|
|
119
|
+
stage: integration.stage,
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
: {}),
|
|
123
|
+
});
|
|
124
|
+
if (integration.restartRequired)
|
|
125
|
+
findings.push({
|
|
126
|
+
id: `restart-${name}`,
|
|
127
|
+
message: `Start a new ${name} conversation to load the current MCP connection and skills.`,
|
|
128
|
+
status: "info",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (result.account?.status === "sign-in-required")
|
|
132
|
+
findings.push({
|
|
133
|
+
id: "account",
|
|
134
|
+
message:
|
|
135
|
+
"Sign in inside ChatCut to use account-dependent online features.",
|
|
136
|
+
status: "info",
|
|
137
|
+
});
|
|
138
|
+
for (const [target, skills] of Object.entries(
|
|
139
|
+
result.skills?.targets ?? {},
|
|
140
|
+
)) {
|
|
141
|
+
const host = { chatGpt: "codex", claudeCode: "claude-code" }[target];
|
|
142
|
+
const required =
|
|
143
|
+
!host || result.integrations?.[host]?.status === "connected";
|
|
144
|
+
const stale =
|
|
145
|
+
skills.stale?.filter((name) => !skills.modified?.includes(name)) ??
|
|
146
|
+
[];
|
|
147
|
+
checks.push({
|
|
148
|
+
id: `skills-${target}`,
|
|
149
|
+
message: `${skills.names?.length ?? 0} ChatCut skills expected for ${target}${required ? "" : " (host unavailable, disconnected or opted out)"}.`,
|
|
150
|
+
path: skills.root,
|
|
151
|
+
status:
|
|
152
|
+
!required ||
|
|
153
|
+
(!skills.errorMessage &&
|
|
154
|
+
!skills.missing?.length &&
|
|
155
|
+
!stale.length &&
|
|
156
|
+
!skills.outdated?.length)
|
|
157
|
+
? "pass"
|
|
158
|
+
: "fail",
|
|
159
|
+
...(skills.errorMessage
|
|
160
|
+
? {
|
|
161
|
+
error: {
|
|
162
|
+
code: skills.errorCode,
|
|
163
|
+
message: skills.errorMessage,
|
|
164
|
+
stage: skills.stage,
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
: {}),
|
|
168
|
+
...(skills.missing?.length ? { missing: skills.missing } : {}),
|
|
169
|
+
...(stale.length ? { stale } : {}),
|
|
170
|
+
...(skills.outdated?.length ? { outdated: skills.outdated } : {}),
|
|
171
|
+
});
|
|
172
|
+
if (skills.modified?.length || skills.foreign?.length)
|
|
173
|
+
findings.push({
|
|
174
|
+
foreign: skills.foreign,
|
|
175
|
+
id: `preserved-skills-${target}`,
|
|
176
|
+
message:
|
|
177
|
+
"User-modified or foreign skill paths are preserved; inspect overlaps before changing them.",
|
|
178
|
+
modified: skills.modified,
|
|
179
|
+
path: skills.root,
|
|
180
|
+
status: "info",
|
|
181
|
+
});
|
|
182
|
+
if (skills.overlaps?.length)
|
|
183
|
+
findings.push({
|
|
184
|
+
id: `skill-overlaps-${target}`,
|
|
185
|
+
message:
|
|
186
|
+
"These skill names overlap. A name match alone does not prove duplicate loading or justify deletion.",
|
|
187
|
+
overlaps: skills.overlaps,
|
|
188
|
+
path: skills.root,
|
|
189
|
+
status: "info",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (repair?.errors?.length)
|
|
193
|
+
checks.push({
|
|
194
|
+
errors: repair.errors,
|
|
195
|
+
id: "repair",
|
|
196
|
+
message: "Some repairs failed; see the original errors.",
|
|
197
|
+
status: "fail",
|
|
198
|
+
});
|
|
199
|
+
} catch (error) {
|
|
200
|
+
checks.push({
|
|
201
|
+
error: errorEvidence(error),
|
|
202
|
+
id: "desktop-inspection",
|
|
203
|
+
message:
|
|
204
|
+
"Desktop inspection did not complete. Open ChatCut, or update it if doctor support is unavailable.",
|
|
205
|
+
status: "fail",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const mcp = await probe({
|
|
210
|
+
command: desktop.launcherPath,
|
|
211
|
+
env: options.env,
|
|
212
|
+
platform: desktop.platform,
|
|
213
|
+
});
|
|
214
|
+
checks.push({
|
|
215
|
+
evidence: mcp,
|
|
216
|
+
id: "mcp",
|
|
217
|
+
message:
|
|
218
|
+
"MCP initialization, tool discovery and get_active_project succeeded.",
|
|
219
|
+
status: "pass",
|
|
220
|
+
});
|
|
221
|
+
} catch (error) {
|
|
222
|
+
checks.push({
|
|
223
|
+
error: errorEvidence(error),
|
|
224
|
+
id: "mcp",
|
|
225
|
+
message: "The MCP connection did not complete a read-only tool call.",
|
|
226
|
+
status: "fail",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
findings.push({
|
|
231
|
+
id: "host-session",
|
|
232
|
+
message:
|
|
233
|
+
"This probes a new local MCP client. The current agent conversation's loaded tools and skills must be checked in that conversation; start a new conversation after connection or skill changes.",
|
|
234
|
+
status: "info",
|
|
235
|
+
});
|
|
236
|
+
const ok = checks.every((check) => check.status !== "fail");
|
|
237
|
+
return {
|
|
238
|
+
checks,
|
|
239
|
+
command: "doctor",
|
|
240
|
+
desktop,
|
|
241
|
+
findings,
|
|
242
|
+
ok,
|
|
243
|
+
repairRequested: fix,
|
|
244
|
+
schemaVersion: 1,
|
|
245
|
+
...(repair ? { repair } : {}),
|
|
246
|
+
...(state ? { state } : {}),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function inspectRunningDesktop(
|
|
251
|
+
desktop,
|
|
252
|
+
{
|
|
253
|
+
client,
|
|
254
|
+
endpoint = readDesktopEndpoint,
|
|
255
|
+
env = process.env,
|
|
256
|
+
expectedProfile = "production",
|
|
257
|
+
fix,
|
|
258
|
+
platform = process.platform,
|
|
259
|
+
request = requestDesktop,
|
|
260
|
+
wait = delay,
|
|
261
|
+
},
|
|
262
|
+
) {
|
|
263
|
+
const deadline = Date.now() + (fix ? 20_000 : 0);
|
|
264
|
+
const target = { client, env, expectedProfile, platform };
|
|
265
|
+
for (;;) {
|
|
266
|
+
try {
|
|
267
|
+
const connection = await endpoint(desktop);
|
|
268
|
+
const report = await request({
|
|
269
|
+
...connection,
|
|
270
|
+
client,
|
|
271
|
+
method: "desktop.doctor",
|
|
272
|
+
});
|
|
273
|
+
validateDesktopReport(report, target);
|
|
274
|
+
if (!fix) return report;
|
|
275
|
+
const repaired = await request({
|
|
276
|
+
...connection,
|
|
277
|
+
client,
|
|
278
|
+
method: "desktop.repair",
|
|
279
|
+
});
|
|
280
|
+
validateDesktopReport(repaired, target);
|
|
281
|
+
return repaired;
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (
|
|
284
|
+
fix &&
|
|
285
|
+
["ECONNREFUSED", "ENOENT"].includes(error.code) &&
|
|
286
|
+
Date.now() < deadline
|
|
287
|
+
) {
|
|
288
|
+
await wait(250);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (error.code === -32601)
|
|
292
|
+
throw new DiagnosticError(
|
|
293
|
+
"desktop_update_required",
|
|
294
|
+
"desktop",
|
|
295
|
+
"This Desktop version does not provide doctor diagnostics. Run chatcut update after closing ChatCut.",
|
|
296
|
+
{ cause: error },
|
|
297
|
+
);
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function validateDesktopReport(
|
|
304
|
+
report,
|
|
305
|
+
{ client, env, expectedProfile, platform },
|
|
306
|
+
) {
|
|
307
|
+
if (report?.version !== 1)
|
|
308
|
+
throw new DiagnosticError(
|
|
309
|
+
"unsupported_doctor_report",
|
|
310
|
+
"desktop",
|
|
311
|
+
"Desktop returned an unsupported doctor report.",
|
|
312
|
+
);
|
|
313
|
+
if (report.profileId !== expectedProfile)
|
|
314
|
+
throw new DiagnosticError(
|
|
315
|
+
"profile_mismatch",
|
|
316
|
+
"desktop",
|
|
317
|
+
`Expected the ${expectedProfile} Desktop profile, received ${report.profileId}. Repair must target the selected profile.`,
|
|
318
|
+
);
|
|
319
|
+
for (const [host, target, variable] of [
|
|
320
|
+
["codex", "chatGpt", "CODEX_HOME"],
|
|
321
|
+
["claude-code", "claudeCode", "CLAUDE_CONFIG_DIR"],
|
|
322
|
+
]) {
|
|
323
|
+
const root = report.skills?.targets?.[target]?.root;
|
|
324
|
+
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
325
|
+
const selectedRoot =
|
|
326
|
+
env[variable] && paths.resolve(env[variable], "skills");
|
|
327
|
+
const reportedRoot = root && paths.resolve(root);
|
|
328
|
+
const sameRoot =
|
|
329
|
+
platform === "win32"
|
|
330
|
+
? selectedRoot?.toLowerCase() === reportedRoot?.toLowerCase()
|
|
331
|
+
: selectedRoot === reportedRoot;
|
|
332
|
+
if ((!client || client === host) && env[variable] && root && !sameRoot)
|
|
333
|
+
throw new DiagnosticError(
|
|
334
|
+
"agent_home_mismatch",
|
|
335
|
+
"desktop",
|
|
336
|
+
`${host} in Desktop uses a different ${variable} skill root (${root}). Start Desktop in the intended host environment before repair.`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
package/src/rpc.mjs
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { connect } from "node:net";
|
|
3
|
+
import { StringDecoder } from "node:string_decoder";
|
|
4
|
+
|
|
5
|
+
import packageInfo from "../package.json" with { type: "json" };
|
|
6
|
+
|
|
7
|
+
const MAX_MESSAGE_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
const PROTOCOL_VERSIONS = [
|
|
9
|
+
"2025-11-25",
|
|
10
|
+
"2025-06-18",
|
|
11
|
+
"2025-03-26",
|
|
12
|
+
"2024-11-05",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export class DiagnosticError extends Error {
|
|
16
|
+
constructor(code, stage, message, options = {}) {
|
|
17
|
+
super(message, options);
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.stage = stage;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function probeMcp({
|
|
24
|
+
args = [],
|
|
25
|
+
command,
|
|
26
|
+
env = process.env,
|
|
27
|
+
platform = process.platform,
|
|
28
|
+
spawnProcess = spawn,
|
|
29
|
+
timeoutMs = 20_000,
|
|
30
|
+
}) {
|
|
31
|
+
const childEnv = { ...env };
|
|
32
|
+
for (const key of Object.keys(childEnv)) {
|
|
33
|
+
if (key.startsWith("CHATCUT_MCP_")) delete childEnv[key];
|
|
34
|
+
}
|
|
35
|
+
const isWindowsScript = platform === "win32" && /\.(cmd|bat)$/i.test(command);
|
|
36
|
+
if (isWindowsScript && (/[\r\n\0"]/u.test(command) || args.length))
|
|
37
|
+
throw new DiagnosticError(
|
|
38
|
+
"invalid_launcher",
|
|
39
|
+
"transport",
|
|
40
|
+
"The Windows MCP launcher must be an unquoted path without arguments.",
|
|
41
|
+
);
|
|
42
|
+
if (isWindowsScript) childEnv.CHATCUT_DOCTOR_LAUNCHER = command;
|
|
43
|
+
const child = spawnProcess(
|
|
44
|
+
isWindowsScript ? (env.ComSpec ?? env.COMSPEC ?? "cmd.exe") : command,
|
|
45
|
+
isWindowsScript
|
|
46
|
+
? ["/d", "/s", "/c", '""%CHATCUT_DOCTOR_LAUNCHER%""']
|
|
47
|
+
: args,
|
|
48
|
+
{
|
|
49
|
+
env: childEnv,
|
|
50
|
+
shell: false,
|
|
51
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
52
|
+
windowsHide: true,
|
|
53
|
+
...(isWindowsScript ? { windowsVerbatimArguments: true } : {}),
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
let stderr = "";
|
|
57
|
+
let stopped = false;
|
|
58
|
+
child.stderr.on("data", (chunk) => {
|
|
59
|
+
if (stderr.length < 64 * 1024)
|
|
60
|
+
stderr += chunk.toString("utf8").slice(0, 64 * 1024 - stderr.length);
|
|
61
|
+
});
|
|
62
|
+
const channel = createRpcChannel(
|
|
63
|
+
child.stdout,
|
|
64
|
+
(message) => child.stdin.write(`${JSON.stringify(message)}\n`),
|
|
65
|
+
timeoutMs,
|
|
66
|
+
);
|
|
67
|
+
const overallTimer = setTimeout(
|
|
68
|
+
() =>
|
|
69
|
+
channel.fail(
|
|
70
|
+
new DiagnosticError(
|
|
71
|
+
"timeout",
|
|
72
|
+
"probe",
|
|
73
|
+
"The complete MCP diagnostic exceeded 60 seconds.",
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
60_000,
|
|
77
|
+
);
|
|
78
|
+
child.on("error", (cause) =>
|
|
79
|
+
channel.fail(
|
|
80
|
+
new DiagnosticError(
|
|
81
|
+
cause.code ?? "spawn_failed",
|
|
82
|
+
"transport",
|
|
83
|
+
cause.message,
|
|
84
|
+
{ cause },
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
child.stdin.on("error", (cause) => channel.fail(cause));
|
|
89
|
+
child.on("exit", (code, signal) => {
|
|
90
|
+
stopped = true;
|
|
91
|
+
channel.fail(
|
|
92
|
+
new DiagnosticError(
|
|
93
|
+
"process_exited",
|
|
94
|
+
"transport",
|
|
95
|
+
`MCP process exited (${signal ?? code}).${stderr ? ` ${stderr.trim()}` : ""}`,
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
const initialized = await channel.request("initialize", {
|
|
101
|
+
capabilities: {},
|
|
102
|
+
clientInfo: { name: "chatcut-doctor", version: packageInfo.version },
|
|
103
|
+
protocolVersion: PROTOCOL_VERSIONS[0],
|
|
104
|
+
});
|
|
105
|
+
if (
|
|
106
|
+
!PROTOCOL_VERSIONS.includes(initialized?.protocolVersion) ||
|
|
107
|
+
!initialized.capabilities?.tools
|
|
108
|
+
) {
|
|
109
|
+
throw new DiagnosticError(
|
|
110
|
+
"unsupported_mcp_server",
|
|
111
|
+
"initialize",
|
|
112
|
+
"The server did not negotiate a supported MCP version with tools.",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
channel.notify("notifications/initialized");
|
|
116
|
+
const names = new Set();
|
|
117
|
+
let cursor;
|
|
118
|
+
const cursors = new Set();
|
|
119
|
+
for (let page = 0; page < 20; page++) {
|
|
120
|
+
const result = await channel.request(
|
|
121
|
+
"tools/list",
|
|
122
|
+
cursor ? { cursor } : {},
|
|
123
|
+
);
|
|
124
|
+
if (!Array.isArray(result?.tools))
|
|
125
|
+
throw new DiagnosticError(
|
|
126
|
+
"invalid_tools_list",
|
|
127
|
+
"tools/list",
|
|
128
|
+
"The server returned no tools array.",
|
|
129
|
+
);
|
|
130
|
+
for (const tool of result.tools)
|
|
131
|
+
if (typeof tool.name === "string") names.add(tool.name);
|
|
132
|
+
cursor = result.nextCursor;
|
|
133
|
+
if (!cursor) break;
|
|
134
|
+
if (typeof cursor !== "string" || cursors.has(cursor) || page === 19)
|
|
135
|
+
throw new DiagnosticError(
|
|
136
|
+
"invalid_tools_pagination",
|
|
137
|
+
"tools/list",
|
|
138
|
+
"MCP tool pagination did not complete within 20 pages.",
|
|
139
|
+
);
|
|
140
|
+
cursors.add(cursor);
|
|
141
|
+
}
|
|
142
|
+
if (!names.has("get_active_project"))
|
|
143
|
+
throw new DiagnosticError(
|
|
144
|
+
"missing_discovery_tool",
|
|
145
|
+
"tools/list",
|
|
146
|
+
"ChatCut's get_active_project tool is missing.",
|
|
147
|
+
);
|
|
148
|
+
const result = await channel.request("tools/call", {
|
|
149
|
+
arguments: {},
|
|
150
|
+
name: "get_active_project",
|
|
151
|
+
});
|
|
152
|
+
if (result?.isError) {
|
|
153
|
+
const message = result.content
|
|
154
|
+
?.filter((item) => item.type === "text")
|
|
155
|
+
.map((item) => item.text)
|
|
156
|
+
.join("\n");
|
|
157
|
+
throw new DiagnosticError(
|
|
158
|
+
"tool_call_failed",
|
|
159
|
+
"tools/call",
|
|
160
|
+
message || "get_active_project returned an error.",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (!result || !Array.isArray(result.content))
|
|
164
|
+
throw new DiagnosticError(
|
|
165
|
+
"invalid_tool_result",
|
|
166
|
+
"tools/call",
|
|
167
|
+
"get_active_project returned an invalid MCP tool result.",
|
|
168
|
+
);
|
|
169
|
+
const server = initialized.serverInfo;
|
|
170
|
+
return {
|
|
171
|
+
hostSessionDiscovery: "unobserved",
|
|
172
|
+
protocolVersion: initialized.protocolVersion,
|
|
173
|
+
server: {
|
|
174
|
+
name: server?.name,
|
|
175
|
+
title: server?.title,
|
|
176
|
+
version: server?.version,
|
|
177
|
+
},
|
|
178
|
+
status: "pass",
|
|
179
|
+
toolCall: { name: "get_active_project", status: "pass" },
|
|
180
|
+
toolCount: names.size,
|
|
181
|
+
};
|
|
182
|
+
} finally {
|
|
183
|
+
clearTimeout(overallTimer);
|
|
184
|
+
child.stdin.end();
|
|
185
|
+
if (!stopped) {
|
|
186
|
+
child.kill();
|
|
187
|
+
const timer = setTimeout(() => {
|
|
188
|
+
if (!stopped) child.kill("SIGKILL");
|
|
189
|
+
}, 1_000);
|
|
190
|
+
timer.unref();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function requestDesktop({
|
|
196
|
+
capability,
|
|
197
|
+
client,
|
|
198
|
+
method,
|
|
199
|
+
socketPath,
|
|
200
|
+
timeoutMs = 30_000,
|
|
201
|
+
}) {
|
|
202
|
+
const socket = connect(socketPath);
|
|
203
|
+
const channel = createRpcChannel(
|
|
204
|
+
socket,
|
|
205
|
+
(message) => socket.write(`${JSON.stringify(message)}\n`),
|
|
206
|
+
timeoutMs,
|
|
207
|
+
);
|
|
208
|
+
try {
|
|
209
|
+
return await channel.request(method, client ? { client } : {}, {
|
|
210
|
+
invocationCapability: capability,
|
|
211
|
+
});
|
|
212
|
+
} finally {
|
|
213
|
+
socket.destroy();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function createRpcChannel(stream, write, timeoutMs) {
|
|
218
|
+
const pending = new Map();
|
|
219
|
+
const decoder = new StringDecoder("utf8");
|
|
220
|
+
let buffer = "";
|
|
221
|
+
let nextId = 0;
|
|
222
|
+
let failure;
|
|
223
|
+
|
|
224
|
+
function fail(error) {
|
|
225
|
+
failure ??= error;
|
|
226
|
+
for (const request of pending.values()) {
|
|
227
|
+
clearTimeout(request.timer);
|
|
228
|
+
request.reject(error);
|
|
229
|
+
}
|
|
230
|
+
pending.clear();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
stream.on("data", (chunk) => {
|
|
234
|
+
buffer += decoder.write(chunk);
|
|
235
|
+
if (Buffer.byteLength(buffer) > MAX_MESSAGE_BYTES) {
|
|
236
|
+
fail(
|
|
237
|
+
new DiagnosticError(
|
|
238
|
+
"response_too_large",
|
|
239
|
+
"transport",
|
|
240
|
+
"MCP response exceeded 4 MiB.",
|
|
241
|
+
),
|
|
242
|
+
);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const lines = buffer.split("\n");
|
|
246
|
+
buffer = lines.pop() ?? "";
|
|
247
|
+
for (const line of lines) {
|
|
248
|
+
if (!line.trim()) continue;
|
|
249
|
+
let message;
|
|
250
|
+
try {
|
|
251
|
+
message = JSON.parse(line);
|
|
252
|
+
} catch (cause) {
|
|
253
|
+
fail(
|
|
254
|
+
new DiagnosticError(
|
|
255
|
+
"invalid_rpc_response",
|
|
256
|
+
"transport",
|
|
257
|
+
"The MCP process wrote non-JSON data to stdout.",
|
|
258
|
+
{ cause },
|
|
259
|
+
),
|
|
260
|
+
);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (
|
|
264
|
+
!message ||
|
|
265
|
+
typeof message !== "object" ||
|
|
266
|
+
message.jsonrpc !== "2.0"
|
|
267
|
+
) {
|
|
268
|
+
fail(
|
|
269
|
+
new DiagnosticError(
|
|
270
|
+
"invalid_rpc_response",
|
|
271
|
+
"transport",
|
|
272
|
+
"Invalid JSON-RPC response.",
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (message.method) {
|
|
278
|
+
if (message.id !== undefined) {
|
|
279
|
+
write({
|
|
280
|
+
id: message.id,
|
|
281
|
+
jsonrpc: "2.0",
|
|
282
|
+
...(message.method === "ping"
|
|
283
|
+
? { result: {} }
|
|
284
|
+
: {
|
|
285
|
+
error: {
|
|
286
|
+
code: -32601,
|
|
287
|
+
message: "Unsupported diagnostic client request",
|
|
288
|
+
},
|
|
289
|
+
}),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const waiting = pending.get(message.id);
|
|
295
|
+
if (!waiting) continue;
|
|
296
|
+
clearTimeout(waiting.timer);
|
|
297
|
+
pending.delete(message.id);
|
|
298
|
+
if (message.error) {
|
|
299
|
+
waiting.reject(
|
|
300
|
+
new DiagnosticError(
|
|
301
|
+
message.error.code ?? "rpc_error",
|
|
302
|
+
waiting.method,
|
|
303
|
+
message.error.message ?? "JSON-RPC request failed.",
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
} else if (Object.hasOwn(message, "result")) {
|
|
307
|
+
waiting.resolve(message.result);
|
|
308
|
+
} else {
|
|
309
|
+
waiting.reject(
|
|
310
|
+
new DiagnosticError(
|
|
311
|
+
"invalid_rpc_response",
|
|
312
|
+
waiting.method,
|
|
313
|
+
"JSON-RPC response contains neither result nor error.",
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
stream.on("error", fail);
|
|
320
|
+
stream.on("end", () =>
|
|
321
|
+
fail(
|
|
322
|
+
new DiagnosticError(
|
|
323
|
+
"connection_closed",
|
|
324
|
+
"transport",
|
|
325
|
+
"The MCP connection closed before completing the diagnostic.",
|
|
326
|
+
),
|
|
327
|
+
),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
fail,
|
|
332
|
+
notify(method, params = {}) {
|
|
333
|
+
write({ jsonrpc: "2.0", method, params });
|
|
334
|
+
},
|
|
335
|
+
request(method, params = {}, extra = {}) {
|
|
336
|
+
if (failure) return Promise.reject(failure);
|
|
337
|
+
return new Promise((resolve, reject) => {
|
|
338
|
+
const id = ++nextId;
|
|
339
|
+
const timer = setTimeout(() => {
|
|
340
|
+
pending.delete(id);
|
|
341
|
+
reject(
|
|
342
|
+
new DiagnosticError(
|
|
343
|
+
"timeout",
|
|
344
|
+
method,
|
|
345
|
+
`${method} did not respond within ${timeoutMs} ms.`,
|
|
346
|
+
),
|
|
347
|
+
);
|
|
348
|
+
}, timeoutMs);
|
|
349
|
+
pending.set(id, { method, reject, resolve, timer });
|
|
350
|
+
try {
|
|
351
|
+
write({ id, jsonrpc: "2.0", method, params, ...extra });
|
|
352
|
+
} catch (error) {
|
|
353
|
+
clearTimeout(timer);
|
|
354
|
+
pending.delete(id);
|
|
355
|
+
reject(error);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
}
|