mcp-fs-shell-windows 0.2.4

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 (59) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +147 -0
  3. package/dist/append_files/handler.js +32 -0
  4. package/dist/append_files/schema.js +7 -0
  5. package/dist/checksum_files/handler.js +33 -0
  6. package/dist/checksum_files/schema.js +5 -0
  7. package/dist/checksum_files_verif/handler.js +66 -0
  8. package/dist/checksum_files_verif/helpers.js +6 -0
  9. package/dist/checksum_files_verif/schema.js +8 -0
  10. package/dist/content_diff/handler.js +11 -0
  11. package/dist/content_diff/schema.js +7 -0
  12. package/dist/copy_files/handler.js +43 -0
  13. package/dist/copy_files/helpers.js +24 -0
  14. package/dist/copy_files/schema.js +7 -0
  15. package/dist/count_lines/handler.js +144 -0
  16. package/dist/count_lines/schema.js +9 -0
  17. package/dist/create_directories/handler.js +29 -0
  18. package/dist/create_directories/schema.js +4 -0
  19. package/dist/delete_files/handler.js +42 -0
  20. package/dist/delete_files/schema.js +5 -0
  21. package/dist/delete_files_by_pattern/handler.js +46 -0
  22. package/dist/delete_files_by_pattern/schema.js +6 -0
  23. package/dist/directory_tree/handler.js +7 -0
  24. package/dist/directory_tree/helpers.js +47 -0
  25. package/dist/directory_tree/schema.js +5 -0
  26. package/dist/edit_files/handler.js +62 -0
  27. package/dist/edit_files/schema.js +11 -0
  28. package/dist/file_diff/handler.js +28 -0
  29. package/dist/file_diff/schema.js +5 -0
  30. package/dist/file_info/handler.js +30 -0
  31. package/dist/file_info/schema.js +4 -0
  32. package/dist/fuzzy_find_files/handler.js +74 -0
  33. package/dist/fuzzy_find_files/schema.js +8 -0
  34. package/dist/helpers/checksum.js +10 -0
  35. package/dist/helpers/diff.js +10 -0
  36. package/dist/helpers/path.js +87 -0
  37. package/dist/index.js +36 -0
  38. package/dist/list_directory/handler.js +42 -0
  39. package/dist/list_directory/schema.js +9 -0
  40. package/dist/move_files/handler.js +68 -0
  41. package/dist/move_files/schema.js +8 -0
  42. package/dist/patch_files/handler.js +34 -0
  43. package/dist/patch_files/helpers.js +103 -0
  44. package/dist/patch_files/schema.js +18 -0
  45. package/dist/read_files/handler.js +36 -0
  46. package/dist/read_files/schema.js +6 -0
  47. package/dist/search_files/handler.js +15 -0
  48. package/dist/search_files/helpers.js +71 -0
  49. package/dist/search_files/schema.js +8 -0
  50. package/dist/search_glob/handler.js +77 -0
  51. package/dist/search_glob/schema.js +8 -0
  52. package/dist/search_regex/handler.js +159 -0
  53. package/dist/search_regex/schema.js +10 -0
  54. package/dist/server.js +698 -0
  55. package/dist/shell/handler.js +687 -0
  56. package/dist/shell/schema.js +69 -0
  57. package/dist/write_new_files/handler.js +55 -0
  58. package/dist/write_new_files/schema.js +9 -0
  59. package/package.json +37 -0
@@ -0,0 +1,687 @@
1
+ // shell/handler.ts — standalone shell tools (MCP filesystem fork).
2
+ // Windows-first (host is win32). Self-contained: node builtins + SHELL_MAX_TIMEOUT_SEC
3
+ // from ./schema.js. Design constraint: every synchronous handler must return in well
4
+ // under ~30 s (the MCP client kills longer requests with -32001); long-running work
5
+ // goes through the in-server background-job registry.
6
+ import { spawn } from "child_process";
7
+ import fs from "fs";
8
+ import fsp from "fs/promises";
9
+ import nodePath from "path";
10
+ import os from "os";
11
+ import crypto from "crypto";
12
+ import { SHELL_MAX_TIMEOUT_SEC } from "./schema.js";
13
+ // ---------------------------------------------------------------------------
14
+ // Shared state & helpers
15
+ // ---------------------------------------------------------------------------
16
+ export const SHELL_JOBS_DIR = nodePath.join(os.tmpdir(), "mcp-filesystem-extended", "shell-jobs");
17
+ const jobs = new Map();
18
+ let defaultShellCwd = process.cwd();
19
+ // Best-effort startup cleanup: drop job log files older than 24 h.
20
+ try {
21
+ const entries = fs.readdirSync(SHELL_JOBS_DIR);
22
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
23
+ for (const entry of entries) {
24
+ try {
25
+ const p = nodePath.join(SHELL_JOBS_DIR, entry);
26
+ if (fs.statSync(p).mtimeMs < cutoff) {
27
+ fs.unlinkSync(p);
28
+ }
29
+ }
30
+ catch {
31
+ // ignore individual failures
32
+ }
33
+ }
34
+ }
35
+ catch {
36
+ // Job dir may not exist yet — that's fine.
37
+ }
38
+ /** Keep only the last `max` chars; mark truncation. */
39
+ function cap(text, max) {
40
+ if (text.length <= max)
41
+ return text;
42
+ return `...[truncated] ` + text.slice(-max);
43
+ }
44
+ const CAP_OK = 100000; // cap for stdout/stderr in success payloads
45
+ const CAP_ERR = 20000; // cap for output embedded in error messages
46
+ const TAIL = 262144; // per-stream in-memory job tail (256 KB)
47
+ function newJobId() {
48
+ return `sj-${Date.now().toString(36)}-${crypto.randomBytes(3).toString("hex")}`;
49
+ }
50
+ /** Resolve the effective working directory (param override or module default) and validate it. */
51
+ async function resolveCwd(cwd) {
52
+ const abs = cwd ? nodePath.resolve(cwd) : defaultShellCwd;
53
+ const st = await fsp.stat(abs).catch(() => null);
54
+ if (!st || !st.isDirectory()) {
55
+ throw new Error(`Working directory does not exist or is not a directory: ${abs}`);
56
+ }
57
+ return abs;
58
+ }
59
+ /**
60
+ * Kill a process tree (Windows).
61
+ *
62
+ * PRIMARY: taskkill /PID <pid> /T /F — walks the LIVE process tree and
63
+ * terminates the root plus every descendant (the actual command processes).
64
+ * taskkill is waited for (its 'close'/'error' event) BEFORE any fallback —
65
+ * child.kill() is never concurrent with a running taskkill, so it can never
66
+ * destroy the root mid tree-walk and orphan descendants (the regression
67
+ * fixed 2026-08-29: an immediate child.kill() let "cancelled" ping -n 300
68
+ * jobs run to completion).
69
+ * After taskkill finishes, the root is probed: child.kill() runs ONLY if the
70
+ * root is still alive (covers a taskkill that could not finish the job).
71
+ * Safe when the root already exited: taskkill exits non-zero ("not found"),
72
+ * the probe says dead, no fallback. Every EventEmitter path is handled (no
73
+ * uncaught 'error' events); killTree is fire-and-forget — callers never block.
74
+ */
75
+ /**
76
+ * Does a Windows process with this PID currently exist? (probe, no signal —
77
+ * process.kill(pid, 0) only opens the process handle on win32.)
78
+ */
79
+ function rootAlive(pid) {
80
+ return new Promise((resolve) => {
81
+ try {
82
+ process.kill(pid, 0);
83
+ resolve(true);
84
+ }
85
+ catch (err) {
86
+ const code = err.code;
87
+ resolve(code !== "ESRCH"); // EPERM etc. = exists but not ours
88
+ }
89
+ });
90
+ }
91
+ function killTree(pid, child) {
92
+ if (pid === null || pid === undefined) {
93
+ try {
94
+ child?.kill();
95
+ }
96
+ catch {
97
+ // already dead
98
+ }
99
+ return;
100
+ }
101
+ let taskkillProc;
102
+ try {
103
+ // stdout piped (not "ignore") so the close event reliably signals that
104
+ // taskkill has FINISHED before we consider any fallback.
105
+ taskkillProc = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
106
+ stdio: ["ignore", "pipe", "pipe"],
107
+ windowsHide: true,
108
+ });
109
+ }
110
+ catch {
111
+ try {
112
+ child?.kill();
113
+ }
114
+ catch {
115
+ // already dead
116
+ }
117
+ return;
118
+ }
119
+ let done = false;
120
+ const guard = setTimeout(() => {
121
+ if (!done) {
122
+ done = true;
123
+ void rootAlive(pid).then((alive) => {
124
+ if (alive) {
125
+ try {
126
+ child?.kill();
127
+ }
128
+ catch {
129
+ // already dead
130
+ }
131
+ }
132
+ });
133
+ }
134
+ }, 15000);
135
+ guard.unref();
136
+ const finish = () => {
137
+ if (done)
138
+ return;
139
+ done = true;
140
+ clearTimeout(guard);
141
+ // taskkill has fully exited — no race possible here. Fall back only if
142
+ // the root somehow survived it (e.g. taskkill could not reach the tree).
143
+ void rootAlive(pid).then((alive) => {
144
+ if (alive) {
145
+ try {
146
+ child?.kill();
147
+ }
148
+ catch {
149
+ // already dead
150
+ }
151
+ }
152
+ });
153
+ };
154
+ // Drain taskkill's output so its pipes never apply backpressure.
155
+ taskkillProc.stdout?.resume();
156
+ taskkillProc.stderr?.resume();
157
+ taskkillProc.on("error", finish);
158
+ taskkillProc.on("close", finish);
159
+ taskkillProc.unref();
160
+ }
161
+ function sanitizeName(name) {
162
+ const cleaned = name.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 40);
163
+ return cleaned || "job";
164
+ }
165
+ /**
166
+ * Builds a line-oriented log feeder: buffers chunks per stream, emits COMPLETE lines
167
+ * prefixed with [OUT] / [ERR], and carries the partial trailing line forward.
168
+ */
169
+ function makeLineFeeder(logStream) {
170
+ const state = { outPartial: "", errPartial: "" };
171
+ const feed = (stream, chunk) => {
172
+ const key = stream === "OUT" ? "outPartial" : "errPartial";
173
+ const buffer = state[key] + chunk;
174
+ const lines = buffer.split("\n");
175
+ state[key] = lines.pop() ?? "";
176
+ const complete = lines.map((l) => `[${stream}] ${l}\n`).join("");
177
+ if (complete)
178
+ logStream.write(complete);
179
+ };
180
+ const flush = () => {
181
+ if (state.outPartial) {
182
+ logStream.write(`[OUT] ${state.outPartial}\n`);
183
+ state.outPartial = "";
184
+ }
185
+ if (state.errPartial) {
186
+ logStream.write(`[ERR] ${state.errPartial}\n`);
187
+ state.errPartial = "";
188
+ }
189
+ };
190
+ return { feed, flush };
191
+ }
192
+ async function ensureJobsDir() {
193
+ await fsp.mkdir(SHELL_JOBS_DIR, { recursive: true }).catch(() => undefined);
194
+ }
195
+ /**
196
+ * Spawn a process, capture stdout/stderr (utf-8), enforce a timeout via killTree.
197
+ * Resolves (never rejects) with the outcome; spawn failures are reported in spawnError.
198
+ */
199
+ async function runCaptured(exe, args, opts) {
200
+ const result = { stdout: "", stderr: "", code: null, timedOut: false };
201
+ await new Promise((resolve) => {
202
+ let proc;
203
+ try {
204
+ proc = spawn(exe, args, {
205
+ cwd: opts.cwd,
206
+ stdio: ["pipe", "pipe", "pipe"],
207
+ env: opts.env,
208
+ // verbatim: deliver the user command to cmd.exe byte-for-byte. Node's
209
+ // default win32 argv quoting backslash-escapes embedded quotes, which
210
+ // breaks ordinary cmd syntax (wmic where "...", tasklist /fi "...",
211
+ // paths with spaces) at the Node -> CreateProcess boundary.
212
+ windowsVerbatimArguments: opts.verbatim ?? false,
213
+ });
214
+ }
215
+ catch (err) {
216
+ result.spawnError = err instanceof Error ? err.message : String(err);
217
+ resolve();
218
+ return;
219
+ }
220
+ const p = proc;
221
+ let settled = false;
222
+ let timer;
223
+ const finish = () => {
224
+ if (settled)
225
+ return;
226
+ settled = true;
227
+ if (timer)
228
+ clearTimeout(timer);
229
+ resolve();
230
+ };
231
+ p.stdout?.setEncoding("utf-8");
232
+ p.stderr?.setEncoding("utf-8");
233
+ p.stdout?.on("data", (d) => {
234
+ result.stdout += d.toString("utf-8");
235
+ });
236
+ p.stderr?.on("data", (d) => {
237
+ result.stderr += d.toString("utf-8");
238
+ });
239
+ p.on("close", (code) => {
240
+ result.code = code;
241
+ finish();
242
+ });
243
+ p.on("error", (err) => {
244
+ result.spawnError = err.message;
245
+ finish();
246
+ });
247
+ if (opts.input) {
248
+ p.stdin?.write(opts.input);
249
+ }
250
+ // Always close stdin so non-interactive commands cannot hang waiting for input.
251
+ p.stdin?.end();
252
+ timer = setTimeout(() => {
253
+ result.timedOut = true;
254
+ killTree(p.pid ?? null, p);
255
+ }, opts.timeoutMs);
256
+ });
257
+ return result;
258
+ }
259
+ // ---------------------------------------------------------------------------
260
+ // shell_run — bounded synchronous execution
261
+ // ---------------------------------------------------------------------------
262
+ export async function handleShellRun(command, input, timeoutSeconds, cwd) {
263
+ const abs = await resolveCwd(cwd);
264
+ const timeout = Math.min(Math.max(timeoutSeconds ?? 5, 0.1), SHELL_MAX_TIMEOUT_SEC);
265
+ const started = Date.now();
266
+ const r = await runCaptured("cmd.exe", ["/d", "/c", command], {
267
+ cwd: abs,
268
+ input,
269
+ timeoutMs: timeout * 1000,
270
+ verbatim: true,
271
+ });
272
+ const durationMs = Date.now() - started;
273
+ if (r.spawnError) {
274
+ throw new Error(`Failed to launch command: ${r.spawnError}`);
275
+ }
276
+ if (r.timedOut) {
277
+ throw new Error(`Process timed out after ${timeout}s.\nSTDOUT:\n${cap(r.stdout, CAP_ERR)}\nSTDERR:\n${cap(r.stderr, CAP_ERR)}`);
278
+ }
279
+ if (r.code !== 0) {
280
+ throw new Error(`Process exited with code ${r.code ?? "unknown"}.\nSTDOUT:\n${cap(r.stdout, CAP_ERR)}\nSTDERR:\n${cap(r.stderr, CAP_ERR)}`);
281
+ }
282
+ return JSON.stringify({
283
+ command,
284
+ exitCode: 0,
285
+ stdout: cap(r.stdout, CAP_OK),
286
+ stderr: cap(r.stderr, CAP_OK),
287
+ timedOut: false,
288
+ duration_ms: durationMs,
289
+ cwd: abs,
290
+ });
291
+ }
292
+ // ---------------------------------------------------------------------------
293
+ // shell_test — test wrapper (CI=true, fixed max timeout, never errors on outcomes)
294
+ // ---------------------------------------------------------------------------
295
+ export async function handleShellTest(command, cwd) {
296
+ const abs = await resolveCwd(cwd);
297
+ const started = Date.now();
298
+ const r = await runCaptured("cmd.exe", ["/d", "/c", command], {
299
+ cwd: abs,
300
+ timeoutMs: SHELL_MAX_TIMEOUT_SEC * 1000,
301
+ env: { ...process.env, CI: "true" },
302
+ verbatim: true,
303
+ });
304
+ const durationMs = Date.now() - started;
305
+ if (r.spawnError) {
306
+ throw new Error(`Failed to launch test command: ${r.spawnError}`);
307
+ }
308
+ const code = r.code;
309
+ return JSON.stringify({
310
+ command,
311
+ exit_code: code,
312
+ stdout: cap(r.stdout, CAP_OK),
313
+ stderr: cap(r.stderr, CAP_OK),
314
+ passed: code === 0 && !r.timedOut,
315
+ timedOut: r.timedOut,
316
+ duration_ms: durationMs,
317
+ });
318
+ }
319
+ // ---------------------------------------------------------------------------
320
+ // shell_start — background job (registry + log file + 256 KB tails + auto-kill)
321
+ // ---------------------------------------------------------------------------
322
+ export async function handleShellStart(command, name, timeoutHours, cwd) {
323
+ const abs = await resolveCwd(cwd);
324
+ await ensureJobsDir();
325
+ const id = newJobId();
326
+ const jobName = name && name.trim() ? name : "shelljob";
327
+ const logFile = nodePath.join(SHELL_JOBS_DIR, `${id}-${sanitizeName(jobName)}.log`);
328
+ const job = {
329
+ id,
330
+ name: jobName,
331
+ command,
332
+ cwd: abs,
333
+ pid: null,
334
+ status: "running",
335
+ exitCode: null,
336
+ startedAt: Date.now(),
337
+ endedAt: null,
338
+ stdoutTail: "",
339
+ stderrTail: "",
340
+ logFile,
341
+ proc: null,
342
+ timeoutHandle: null,
343
+ statusPoll: null,
344
+ };
345
+ let logStream = null;
346
+ try {
347
+ logStream = fs.createWriteStream(logFile);
348
+ logStream.write(`# ${new Date().toISOString()} | ${command} | cwd=${abs}\n`);
349
+ }
350
+ catch {
351
+ // Log file is best-effort; the job still runs without it.
352
+ job.logFile = null;
353
+ }
354
+ const timeoutHoursEff = Math.min(Math.max(timeoutHours ?? 10, 0.001), 10);
355
+ try {
356
+ const proc = spawn("cmd.exe", ["/d", "/c", command], {
357
+ cwd: abs,
358
+ stdio: ["ignore", "pipe", "pipe"],
359
+ windowsVerbatimArguments: true,
360
+ });
361
+ job.proc = proc;
362
+ job.pid = proc.pid ?? null;
363
+ const feeder = logStream ? makeLineFeeder(logStream) : null;
364
+ const stream = logStream;
365
+ proc.stdout?.on("data", (d) => {
366
+ const text = d.toString("utf-8");
367
+ job.stdoutTail += text;
368
+ if (job.stdoutTail.length > TAIL)
369
+ job.stdoutTail = job.stdoutTail.slice(-TAIL);
370
+ feeder?.feed("OUT", text);
371
+ });
372
+ proc.stderr?.on("data", (d) => {
373
+ const text = d.toString("utf-8");
374
+ job.stderrTail += text;
375
+ if (job.stderrTail.length > TAIL)
376
+ job.stderrTail = job.stderrTail.slice(-TAIL);
377
+ feeder?.feed("ERR", text);
378
+ });
379
+ proc.on("close", (code) => {
380
+ job.status = job.status === "cancelled" || job.status === "timeout" ? job.status : "completed";
381
+ job.exitCode = code;
382
+ job.endedAt = Date.now();
383
+ if (job.timeoutHandle)
384
+ clearTimeout(job.timeoutHandle);
385
+ job.timeoutHandle = null;
386
+ try {
387
+ feeder?.flush();
388
+ stream?.end();
389
+ }
390
+ catch {
391
+ // best-effort
392
+ }
393
+ });
394
+ proc.on("error", (err) => {
395
+ if (job.status === "running") {
396
+ job.status = "error";
397
+ job.stderrTail += `\nError: ${err.message}`;
398
+ job.endedAt = Date.now();
399
+ }
400
+ });
401
+ const t = setTimeout(() => {
402
+ if (job.status === "running") {
403
+ job.status = "timeout";
404
+ job.endedAt = Date.now();
405
+ killTree(job.pid, job.proc);
406
+ }
407
+ }, timeoutHoursEff * 3600 * 1000);
408
+ t.unref(); // never keep the MCP server alive solely for this timer
409
+ job.timeoutHandle = t;
410
+ }
411
+ catch (err) {
412
+ job.status = "error";
413
+ job.stderrTail += `\nError: ${err instanceof Error ? err.message : String(err)}`;
414
+ job.endedAt = Date.now();
415
+ try {
416
+ logStream?.end();
417
+ }
418
+ catch {
419
+ // best-effort
420
+ }
421
+ }
422
+ jobs.set(id, job);
423
+ // Wait briefly to catch immediate failures (parity with Beledarian's run_background_command).
424
+ await new Promise((resolve) => setTimeout(resolve, 500));
425
+ return JSON.stringify({
426
+ id,
427
+ name: job.name,
428
+ pid: job.pid,
429
+ status: job.status,
430
+ logFile: job.logFile,
431
+ message: `Job started. Use shell_check with ID ${id} to poll; shell_cancel to kill.`,
432
+ });
433
+ }
434
+ // ---------------------------------------------------------------------------
435
+ // shell_check — job status + output tails
436
+ // ---------------------------------------------------------------------------
437
+ export async function handleShellCheck(id) {
438
+ const job = jobs.get(id);
439
+ if (!job) {
440
+ const active = [...jobs.keys()].join(", ") || "(none)";
441
+ throw new Error(`No shell job found with ID ${id}. Active jobs: ${active}. (Jobs live in the MCP server's memory and are lost when it restarts.)`);
442
+ }
443
+ return JSON.stringify({
444
+ id: job.id,
445
+ name: job.name,
446
+ status: job.status,
447
+ exitCode: job.exitCode,
448
+ duration_seconds: Math.floor((Date.now() - job.startedAt) / 1000),
449
+ stdout_tail: job.stdoutTail.slice(-4000),
450
+ stderr_tail: job.stderrTail.slice(-4000),
451
+ logFile: job.logFile,
452
+ });
453
+ }
454
+ // ---------------------------------------------------------------------------
455
+ // shell_cancel — kill a job (whole process tree)
456
+ // ---------------------------------------------------------------------------
457
+ export async function handleShellCancel(id) {
458
+ const job = jobs.get(id);
459
+ if (!job) {
460
+ const active = [...jobs.keys()].join(", ") || "(none)";
461
+ throw new Error(`No shell job found with ID ${id}. Active jobs: ${active}. (Jobs live in the MCP server's memory and are lost when it restarts.)`);
462
+ }
463
+ if (job.status !== "running") {
464
+ return JSON.stringify({ id: job.id, status: job.status, message: `Job is already ${job.status}.` });
465
+ }
466
+ job.status = "cancelled";
467
+ job.endedAt = Date.now();
468
+ if (job.timeoutHandle)
469
+ clearTimeout(job.timeoutHandle);
470
+ job.timeoutHandle = null;
471
+ killTree(job.pid, job.proc);
472
+ return JSON.stringify({ id: job.id, status: "cancelled", message: "Job killed." });
473
+ }
474
+ // ---------------------------------------------------------------------------
475
+ // shell_terminal — separate, persistent, interactive cmd /k console window
476
+ // ---------------------------------------------------------------------------
477
+ /**
478
+ * PID-liveness probe for terminal windows (tracked by PID rather than by
479
+ * process handle: the window is a separate process tree started via
480
+ * Start-Process). Spawned directly (no shell) so the /fi filter string is a
481
+ * single argv element — no quote mangling. NOTE: this build of tasklist does
482
+ * NOT support compound `AND` filters — the value token swallows the rest of
483
+ * the line (verified 2026-08-29: `PID eq <n> AND ImageName eq cmd.exe`
484
+ * matches nothing), so we filter by PID only and verify the matched row's
485
+ * image name is cmd.exe (mitigates PID reuse). tasklist error output =
486
+ * unknown → assume alive (conservative; the next poll retries).
487
+ */
488
+ function terminalPidAlive(pid) {
489
+ return new Promise((resolve) => {
490
+ let proc;
491
+ try {
492
+ proc = spawn("tasklist", ["/fi", `PID eq ${pid}`], {
493
+ stdio: ["ignore", "pipe", "pipe"],
494
+ windowsHide: true,
495
+ });
496
+ }
497
+ catch {
498
+ resolve(true); // conservative: assume still alive
499
+ return;
500
+ }
501
+ let out = "";
502
+ let finished = false;
503
+ const finish = (alive) => {
504
+ if (finished)
505
+ return;
506
+ finished = true;
507
+ resolve(alive);
508
+ };
509
+ proc.stdout?.on("data", (d) => {
510
+ out += d.toString("utf-8");
511
+ });
512
+ proc.stderr?.resume();
513
+ proc.on("error", () => finish(true));
514
+ proc.on("close", () => {
515
+ const text = out.trim();
516
+ if (text.startsWith("ERROR")) {
517
+ finish(true); // tasklist itself failed; assume alive, retry next poll
518
+ return;
519
+ }
520
+ if (text.includes("No tasks are running")) {
521
+ finish(false);
522
+ return;
523
+ }
524
+ for (const line of text.split("\n").map((l) => l.trim()).filter(Boolean)) {
525
+ if (line.includes(String(pid)) && /^cmd\.exe/i.test(line)) {
526
+ finish(true);
527
+ return;
528
+ }
529
+ }
530
+ finish(false);
531
+ });
532
+ const guard = setTimeout(() => finish(true), 10000);
533
+ guard.unref();
534
+ });
535
+ }
536
+ async function cleanupTerminalFiles(id) {
537
+ for (const f of [
538
+ nodePath.join(SHELL_JOBS_DIR, `${id}.cmd`),
539
+ nodePath.join(SHELL_JOBS_DIR, `${id}.ps1`),
540
+ ]) {
541
+ await fsp.unlink(f).catch(() => undefined);
542
+ }
543
+ }
544
+ export async function handleShellTerminal(command, cwd) {
545
+ const abs = await resolveCwd(cwd);
546
+ await ensureJobsDir();
547
+ const id = newJobId();
548
+ const batchFile = nodePath.join(SHELL_JOBS_DIR, `${id}.cmd`);
549
+ const launcherFile = nodePath.join(SHELL_JOBS_DIR, `${id}.ps1`);
550
+ // The user command runs as a batch file inside a NEW, separate console
551
+ // window. Start-Process gives that window a real console (the user can
552
+ // type into it) and the window is NOT attached to this MCP server's
553
+ // stdio, so the JSON-RPC transport is untouched. The launcher prints the
554
+ // window's PID for tracking.
555
+ await fsp.writeFile(batchFile, command, "utf-8");
556
+ const ps = [
557
+ "$ErrorActionPreference = 'Stop'",
558
+ `$p = Start-Process cmd -ArgumentList '/d', '/k', '${batchFile}' -WorkingDirectory '${abs}' -PassThru`,
559
+ "$p.Id",
560
+ ].join("\n");
561
+ await fsp.writeFile(launcherFile, ps, "utf-8");
562
+ const r = await runCaptured("powershell", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", launcherFile], { cwd: abs, timeoutMs: 20000 });
563
+ const pidMatch = r.stdout.match(/\d+/);
564
+ if (r.spawnError || r.timedOut || !pidMatch) {
565
+ await cleanupTerminalFiles(id);
566
+ const detail = r.spawnError
567
+ ? `: ${r.spawnError}`
568
+ : r.timedOut
569
+ ? " (launcher timed out after 20s)"
570
+ : ` (no PID in output: ${cap(r.stdout + r.stderr, 2000)})`;
571
+ throw new Error(`Failed to launch terminal${detail}`);
572
+ }
573
+ const pid = parseInt(pidMatch[0], 10);
574
+ const job = {
575
+ id,
576
+ name: "terminal",
577
+ command,
578
+ cwd: abs,
579
+ pid,
580
+ status: "running",
581
+ exitCode: null,
582
+ startedAt: Date.now(),
583
+ endedAt: null,
584
+ stdoutTail: "",
585
+ stderrTail: "",
586
+ logFile: null,
587
+ proc: null, // the window is a separate process tree; tracked by PID
588
+ timeoutHandle: null,
589
+ statusPoll: null,
590
+ };
591
+ jobs.set(id, job);
592
+ // Lifecycle: no process handle to watch, so poll PID liveness every 5 s.
593
+ // When the window goes away (user closed it, or the tree was cancelled),
594
+ // finalize the job and clean up the helper files.
595
+ const finalize = () => {
596
+ if (job.statusPoll)
597
+ clearTimeout(job.statusPoll);
598
+ job.statusPoll = null;
599
+ if (job.status === "running") {
600
+ job.status = "completed";
601
+ job.exitCode = 0; // window closed normally by the user
602
+ }
603
+ if (job.endedAt === null)
604
+ job.endedAt = Date.now();
605
+ void cleanupTerminalFiles(id);
606
+ };
607
+ const poll = () => {
608
+ if (job.statusPoll)
609
+ clearTimeout(job.statusPoll);
610
+ const active = job.status === "running" || job.status === "cancelled" || job.status === "timeout";
611
+ if (!active) {
612
+ job.statusPoll = null;
613
+ return;
614
+ }
615
+ void terminalPidAlive(pid).then((alive) => {
616
+ if (!alive) {
617
+ finalize();
618
+ }
619
+ else {
620
+ job.statusPoll = setTimeout(poll, 5000);
621
+ job.statusPoll.unref();
622
+ }
623
+ });
624
+ };
625
+ job.statusPoll = setTimeout(poll, 5000);
626
+ job.statusPoll.unref();
627
+ return JSON.stringify({
628
+ id,
629
+ pid,
630
+ status: "running",
631
+ logFile: null,
632
+ message: `Visible cmd.exe window launched (separate interactive console, cmd /k). The window persists after the command finishes; use shell_cancel with ID ${id} to close it.`,
633
+ });
634
+ }
635
+ // ---------------------------------------------------------------------------
636
+ // shell_python — temp .py + system python
637
+ // ---------------------------------------------------------------------------
638
+ export async function handleShellPython(code, timeoutSeconds, cwd) {
639
+ const abs = await resolveCwd(cwd);
640
+ await ensureJobsDir();
641
+ const file = nodePath.join(SHELL_JOBS_DIR, `py-${Date.now().toString(36)}-${crypto.randomBytes(3).toString("hex")}.py`);
642
+ await fsp.writeFile(file, code, "utf-8");
643
+ const timeout = Math.min(Math.max(timeoutSeconds ?? 5, 0.1), SHELL_MAX_TIMEOUT_SEC);
644
+ const started = Date.now();
645
+ try {
646
+ const r = await runCaptured("python", [file], {
647
+ cwd: abs,
648
+ timeoutMs: timeout * 1000,
649
+ });
650
+ const durationMs = Date.now() - started;
651
+ if (r.spawnError) {
652
+ throw new Error(`Failed to launch python: ${r.spawnError}`);
653
+ }
654
+ if (r.timedOut) {
655
+ throw new Error(`Process timed out after ${timeout}s.\nSTDOUT:\n${cap(r.stdout, CAP_ERR)}\nSTDERR:\n${cap(r.stderr, CAP_ERR)}`);
656
+ }
657
+ if (r.code !== 0) {
658
+ throw new Error(`Process exited with code ${r.code ?? "unknown"}.\nSTDOUT:\n${cap(r.stdout, CAP_ERR)}\nSTDERR:\n${cap(r.stderr, CAP_ERR)}`);
659
+ }
660
+ return JSON.stringify({
661
+ exitCode: 0,
662
+ stdout: cap(r.stdout, CAP_OK),
663
+ stderr: cap(r.stderr, CAP_OK),
664
+ timedOut: false,
665
+ duration_ms: durationMs,
666
+ });
667
+ }
668
+ finally {
669
+ await fsp.unlink(file).catch(() => undefined);
670
+ }
671
+ }
672
+ // ---------------------------------------------------------------------------
673
+ // shell_cwd — get/set the default working directory for shell_* tools
674
+ // ---------------------------------------------------------------------------
675
+ export async function handleShellCwd(dir) {
676
+ if (!dir) {
677
+ return JSON.stringify({ cwd: defaultShellCwd });
678
+ }
679
+ const abs = nodePath.resolve(defaultShellCwd, dir);
680
+ const st = await fsp.stat(abs).catch(() => null);
681
+ if (!st || !st.isDirectory()) {
682
+ throw new Error(`Path is not a directory: ${abs}`);
683
+ }
684
+ const previous = defaultShellCwd;
685
+ defaultShellCwd = abs;
686
+ return JSON.stringify({ previous_directory: previous, current_directory: abs });
687
+ }