chatgpt-local-mcp 1.0.0

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/src/server.js ADDED
@@ -0,0 +1,2416 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * chatgpt-local-mcp — server
5
+ * A protected MCP/HTTP bridge that gives an AI assistant access to
6
+ * filesystem, terminal, process, network, and developer tools while protecting
7
+ * the connector's own runtime files and port from tool-initiated changes.
8
+ */
9
+
10
+ import express from "express";
11
+ import fs from "fs/promises";
12
+ import fsSync from "fs";
13
+ import path from "path";
14
+ import os from "os";
15
+ import net from "net";
16
+ import crypto from "crypto";
17
+ import { spawn, execFile } from "child_process";
18
+ import { promisify } from "util";
19
+ import { fileURLToPath } from "url";
20
+
21
+ const execFileAsync = promisify(execFile);
22
+ const __filename = fileURLToPath(import.meta.url);
23
+ const __dirname = path.dirname(__filename);
24
+
25
+ function loadEnvFile(filePath) {
26
+ if (!filePath || !fsSync.existsSync(filePath)) return;
27
+
28
+ const lines = fsSync.readFileSync(filePath, "utf8").split(/\r?\n/);
29
+ for (const line of lines) {
30
+ const trimmed = line.trim();
31
+ if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) continue;
32
+
33
+ const index = trimmed.indexOf("=");
34
+ const key = trimmed.slice(0, index).trim();
35
+ let value = trimmed.slice(index + 1).trim();
36
+
37
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
38
+ value = value.slice(1, -1);
39
+ }
40
+
41
+ if (!process.env[key]) process.env[key] = value;
42
+ }
43
+ }
44
+
45
+ const inferredHome = process.env.AI_PC_MCP_HOME || path.resolve(__dirname, "..");
46
+ const envFile = process.env.AI_PC_MCP_ENV_FILE || path.join(inferredHome, ".env");
47
+ loadEnvFile(envFile);
48
+
49
+ const SERVER_NAME = "ChatGPT Local MCP";
50
+ const SERVER_ID = "chatgpt-local-mcp";
51
+ const VERSION = "1.0.0";
52
+ const BYPASS_MODE = process.env.AI_PC_MCP_BYPASS === "true";
53
+ const PORT = Number(process.env.AI_PC_MCP_PORT || process.env.PORT || 3001);
54
+ const HOST = process.env.AI_PC_MCP_HOST || "0.0.0.0";
55
+ const SERVER_HOME = path.resolve(process.env.AI_PC_MCP_HOME || inferredHome);
56
+ const ACCESS_ROOT = path.resolve(process.env.AI_PC_MCP_ROOT || process.env.AI_PC_MCP_BASE_PATH || "/");
57
+ const DEFAULT_CWD = path.resolve(
58
+ process.env.AI_PC_MCP_DEFAULT_CWD ||
59
+ process.env.WORKSPACE_FOLDER ||
60
+ (fsSync.existsSync("/workspaces/codespaces-blank") ? "/workspaces/codespaces-blank" : process.cwd())
61
+ );
62
+ const COMMAND_TIMEOUT_MS = Number(process.env.AI_PC_MCP_COMMAND_TIMEOUT_MS || 30000);
63
+ const PROTOCOL_VERSION = "2024-11-05";
64
+
65
+ // Path separator used in AI_PC_MCP_PROTECTED_PATHS is | (pipe).
66
+ // Pipe is illegal in file paths on both Windows and Linux, so it is safe.
67
+ // We also accept , for config file compatibility. On Linux-only deployments
68
+ // the legacy : separator still works because Linux paths never contain :.
69
+ const _pathSepRe = process.platform === "win32" ? /[|,]/g : /[|,:]/g;
70
+ const protectedPathInput = [
71
+ SERVER_HOME,
72
+ envFile,
73
+ __filename,
74
+ ...(process.env.AI_PC_MCP_PROTECTED_PATHS || "").split(_pathSepRe),
75
+ ]
76
+ .map((item) => item && item.trim())
77
+ .filter(Boolean)
78
+ .map((item) => path.resolve(item));
79
+
80
+ const PROTECTED_PATHS = Array.from(new Set(protectedPathInput));
81
+ const runningProcesses = new Map();
82
+ const app = express();
83
+ const liveLogClients = new Set();
84
+ const runtimeLogs = [];
85
+
86
+ // ── per-tool stats ────────────────────────────────────────────────────────────
87
+ const toolStats = new Map(); // name → { calls, errors, totalMs, lastCalled }
88
+ const sessionStart = new Date();
89
+ let totalCalls = 0;
90
+ let totalErrors = 0;
91
+
92
+ function recordToolStat(name, ms, isError) {
93
+ const s = toolStats.get(name) || { calls: 0, errors: 0, totalMs: 0, lastCalled: null };
94
+ s.calls++;
95
+ s.totalMs += ms;
96
+ s.lastCalled = new Date().toISOString();
97
+ if (isError) s.errors++;
98
+ toolStats.set(name, s);
99
+ totalCalls++;
100
+ if (isError) totalErrors++;
101
+ }
102
+
103
+ function pushLiveLog(level, message) {
104
+ const entry = {
105
+ timestamp: new Date().toISOString(),
106
+ level,
107
+ message: String(message || "")
108
+ };
109
+
110
+ runtimeLogs.push(entry);
111
+ if (runtimeLogs.length > 500) runtimeLogs.shift();
112
+
113
+ const payload = `data: ${JSON.stringify(entry)}\n\n`;
114
+
115
+ for (const client of liveLogClients) {
116
+ try {
117
+ client.write(payload);
118
+ } catch {}
119
+ }
120
+ }
121
+
122
+ const originalConsoleLog = console.log.bind(console);
123
+ const originalConsoleError = console.error.bind(console);
124
+
125
+ console.log = (...args) => {
126
+ pushLiveLog("info", args.join(" "));
127
+ originalConsoleLog(...args);
128
+ };
129
+
130
+ console.error = (...args) => {
131
+ pushLiveLog("error", args.join(" "));
132
+ originalConsoleError(...args);
133
+ };
134
+
135
+ app.disable("x-powered-by");
136
+ app.set("trust proxy", true);
137
+ app.use(express.json({ limit: process.env.AI_PC_MCP_JSON_LIMIT || "100mb" }));
138
+ app.use((req, res, next) => {
139
+ res.setHeader("Access-Control-Allow-Origin", "*");
140
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
141
+ res.setHeader(
142
+ "Access-Control-Allow-Headers",
143
+ "Authorization,Content-Type,Accept,X-MCP-Token,Mcp-Session-Id,MCP-Protocol-Version"
144
+ );
145
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
146
+
147
+ if (req.method === "OPTIONS") return res.status(204).end();
148
+ return next();
149
+ });
150
+
151
+ // ── request logger ────────────────────────────────────────────────────────────
152
+ app.use((req, res, next) => {
153
+ // Skip health polling and robots to avoid log noise
154
+ if (req.path === "/health" || req.path === "/robots.txt") return next();
155
+ const ip =
156
+ (req.headers["x-forwarded-for"] || "").split(",")[0].trim() ||
157
+ req.socket?.remoteAddress ||
158
+ "?";
159
+ const start = Date.now();
160
+ res.on("finish", () => {
161
+ const ms = Date.now() - start;
162
+ const statusColor = res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info";
163
+ const msg = `${req.method} ${req.path} → ${res.statusCode} (${ms}ms) [${ip}]`;
164
+ if (statusColor === "error") console.error(`[REQ] ${msg}`);
165
+ else console.log(`[REQ] ${msg}`);
166
+ });
167
+ return next();
168
+ });
169
+
170
+ function isSubpath(candidate, parent) {
171
+ // On Windows the filesystem is case-insensitive; normalise before comparing.
172
+ let resolvedCandidate = path.resolve(candidate);
173
+ let resolvedParent = path.resolve(parent);
174
+ if (process.platform === "win32") {
175
+ resolvedCandidate = resolvedCandidate.toLowerCase();
176
+ resolvedParent = resolvedParent.toLowerCase();
177
+ }
178
+ const relative = path.relative(resolvedParent, resolvedCandidate);
179
+ return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
180
+ }
181
+
182
+ function isInsideProtectedPath(target) {
183
+ return PROTECTED_PATHS.some((protectedPath) => isSubpath(target, protectedPath));
184
+ }
185
+
186
+ function wouldAffectProtectedPath(target) {
187
+ return PROTECTED_PATHS.some(
188
+ (protectedPath) => isSubpath(target, protectedPath) || isSubpath(protectedPath, target)
189
+ );
190
+ }
191
+
192
+ function assertWithinAccessRoot(target) {
193
+ if (!isSubpath(target, ACCESS_ROOT)) {
194
+ throw new Error(`Access denied: ${target} is outside configured root ${ACCESS_ROOT}`);
195
+ }
196
+ }
197
+
198
+ function assertPathAllowed(target, action = "read") {
199
+ const resolved = path.resolve(target);
200
+ assertWithinAccessRoot(resolved);
201
+
202
+ if (action === "read") {
203
+ if (isInsideProtectedPath(resolved)) {
204
+ throw new Error(`Protected path: connector runtime files cannot be read by MCP tools (${resolved})`);
205
+ }
206
+ return;
207
+ }
208
+
209
+ if (wouldAffectProtectedPath(resolved)) {
210
+ throw new Error(`Protected path: connector runtime files cannot be changed or deleted (${resolved})`);
211
+ }
212
+ }
213
+
214
+ function resolveTarget(inputPath = ".", cwd = DEFAULT_CWD, action = "read") {
215
+ const base = cwd ? path.resolve(cwd) : DEFAULT_CWD;
216
+ assertWithinAccessRoot(base);
217
+ if (isInsideProtectedPath(base)) {
218
+ throw new Error(`Protected working directory: ${base}`);
219
+ }
220
+
221
+ const raw = String(inputPath || ".");
222
+ const resolved = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(base, raw);
223
+ assertPathAllowed(resolved, action);
224
+ return resolved;
225
+ }
226
+
227
+ function relativeToUsefulBase(target) {
228
+ const resolved = path.resolve(target);
229
+ if (isSubpath(resolved, DEFAULT_CWD)) return path.relative(DEFAULT_CWD, resolved) || ".";
230
+ if (isSubpath(resolved, ACCESS_ROOT)) return path.relative(ACCESS_ROOT, resolved) || ".";
231
+ return resolved;
232
+ }
233
+
234
+ function publicBaseUrl(req) {
235
+ if (process.env.AI_PC_MCP_PUBLIC_URL) return process.env.AI_PC_MCP_PUBLIC_URL.replace(/\/$/, "");
236
+ const proto = req.headers["x-forwarded-proto"] || req.protocol || "http";
237
+ const host = req.headers["x-forwarded-host"] || req.headers.host || `localhost:${PORT}`;
238
+ return `${proto}://${host}`.replace(/\/$/, "");
239
+ }
240
+
241
+ function textResult(data) {
242
+ const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
243
+ return {
244
+ content: [{ type: "text", text }],
245
+ };
246
+ }
247
+
248
+ function truncate(value, _max) {
249
+ // Truncation removed — tools return full output.
250
+ return String(value || "");
251
+ }
252
+
253
+ function safeJson(value) {
254
+ return JSON.parse(JSON.stringify(value, (_key, val) => (typeof val === "bigint" ? val.toString() : val)));
255
+ }
256
+
257
+ async function ensureParent(filePath) {
258
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
259
+ }
260
+
261
+ async function pathExists(target) {
262
+ try {
263
+ await fs.access(target);
264
+ return true;
265
+ } catch {
266
+ return false;
267
+ }
268
+ }
269
+
270
+ function looksSecret(name) {
271
+ return /(TOKEN|SECRET|PASSWORD|PASS|API[_-]?KEY|PRIVATE|CREDENTIAL|AUTH|JWT|COOKIE|SESSION)/i.test(name);
272
+ }
273
+
274
+ function redactEnvironment(entries, includeValues = false, revealSecrets = false) {
275
+ return Object.fromEntries(
276
+ entries.map(([key, value]) => {
277
+ if (!includeValues) return [key, "<hidden>"];
278
+ if (looksSecret(key) && !revealSecrets) return [key, "<redacted>"];
279
+ return [key, value];
280
+ })
281
+ );
282
+ }
283
+
284
+ function expandShellPath(token, cwd = DEFAULT_CWD) {
285
+ if (!token) return null;
286
+ let cleaned = String(token).trim().replace(/^['"]|['"]$/g, "");
287
+ cleaned = cleaned.replace(/[;,|&]+$/g, "");
288
+ if (!cleaned || cleaned.startsWith("-")) return null;
289
+ if (cleaned === "~") cleaned = os.homedir();
290
+ if (cleaned.startsWith("~/")) cleaned = path.join(os.homedir(), cleaned.slice(2));
291
+ if (cleaned.startsWith("$")) return null;
292
+ if (cleaned.includes("*") || cleaned.includes("?") || cleaned.includes("[")) return null;
293
+ return path.isAbsolute(cleaned) ? path.resolve(cleaned) : path.resolve(cwd, cleaned);
294
+ }
295
+
296
+ function shellTokens(command) {
297
+ return String(command || "").match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
298
+ }
299
+
300
+ function assertCommandAllowed(command, cwd = DEFAULT_CWD) {
301
+ const text = String(command || "").trim();
302
+ if (!text) throw new Error("Command is required.");
303
+
304
+ const workingDirectory = resolveTarget(cwd || DEFAULT_CWD, DEFAULT_CWD, "read");
305
+ if (isInsideProtectedPath(workingDirectory)) {
306
+ throw new Error("Commands cannot run from the protected connector directory.");
307
+ }
308
+
309
+ // Block process-control / system commands on all platforms.
310
+ // Linux/macOS: sudo, kill, pkill … Windows: taskkill, tskill …
311
+ const forbiddenProcessControls = /(^|[;&|()`\s])(sudo|su|kill|pkill|killall|fuser|systemctl|service|shutdown|reboot|halt|poweroff|taskkill|tskill)\b/i;
312
+ if (forbiddenProcessControls.test(text)) {
313
+ throw new Error(
314
+ "This connector blocks sudo/system/kill commands so MCP tools cannot stop the MCP port or modify protected runtime files."
315
+ );
316
+ }
317
+
318
+ const serverPortPattern = new RegExp(`(:|\b)${PORT}\b`);
319
+ if (serverPortPattern.test(text) && /\b(kill|pkill|killall|fuser|lsof\s+-ti|xargs\s+kill)\b/i.test(text)) {
320
+ throw new Error(`Commands that can stop protected MCP port ${PORT} are blocked.`);
321
+ }
322
+
323
+ for (const protectedPath of PROTECTED_PATHS) {
324
+ const aliases = [protectedPath, path.basename(protectedPath)].filter((item) => item && item.length > 4);
325
+ if (aliases.some((alias) => text.includes(alias))) {
326
+ if (/\b(rm|mv|cp|chmod|chown|truncate|dd|tee|sed|perl|python|node|bash|sh|cat)\b|>|>>/i.test(text)) {
327
+ throw new Error(`Command references protected connector path: ${protectedPath}`);
328
+ }
329
+ }
330
+ }
331
+
332
+ const tokens = shellTokens(text);
333
+ // Include Windows cmd built-ins that can delete / overwrite protected files.
334
+ const destructiveCommands = new Set([
335
+ "rm", "rmdir", "mv", "chmod", "chown", "truncate", "dd",
336
+ "del", "rd", "move", "attrib", "icacls", "takeown",
337
+ ]);
338
+ for (let index = 0; index < tokens.length; index += 1) {
339
+ const executable = path.basename(tokens[index].replace(/^['"]|['"]$/g, ""));
340
+ if (!destructiveCommands.has(executable)) continue;
341
+
342
+ for (const candidate of tokens.slice(index + 1)) {
343
+ const possiblePath = expandShellPath(candidate, workingDirectory);
344
+ if (!possiblePath) continue;
345
+ if (wouldAffectProtectedPath(possiblePath)) {
346
+ throw new Error(`Command would affect protected connector path: ${possiblePath}`);
347
+ }
348
+ }
349
+ }
350
+
351
+ return workingDirectory;
352
+ }
353
+
354
+ async function captureSpawn(command, args = [], options = {}) {
355
+ const timeout = Number(options.timeout || COMMAND_TIMEOUT_MS);
356
+
357
+ return new Promise((resolve) => {
358
+ const startedAt = new Date();
359
+ const child = spawn(command, args, {
360
+ cwd: options.cwd,
361
+ env: { ...process.env, ...(options.env || {}) },
362
+ shell: options.shell ?? false,
363
+ });
364
+
365
+ let stdout = "";
366
+ let stderr = "";
367
+ let timedOut = false;
368
+
369
+ const timer = setTimeout(() => {
370
+ timedOut = true;
371
+ child.kill("SIGTERM");
372
+ setTimeout(() => {
373
+ if (!child.killed) child.kill("SIGKILL");
374
+ }, 1500).unref();
375
+ }, timeout);
376
+
377
+ child.stdout?.on("data", (chunk) => {
378
+ stdout += chunk.toString();
379
+ });
380
+ child.stderr?.on("data", (chunk) => {
381
+ stderr += chunk.toString();
382
+ });
383
+ child.on("error", (error) => {
384
+ clearTimeout(timer);
385
+ resolve({
386
+ success: false,
387
+ command: [command, ...args].join(" "),
388
+ exitCode: null,
389
+ timedOut,
390
+ stdout,
391
+ stderr: `${stderr}\n${error.message}`.trim(),
392
+ startedAt,
393
+ finishedAt: new Date(),
394
+ });
395
+ });
396
+ child.on("close", (exitCode, signal) => {
397
+ clearTimeout(timer);
398
+ resolve({
399
+ success: exitCode === 0 && !timedOut,
400
+ command: [command, ...args].join(" "),
401
+ exitCode,
402
+ signal,
403
+ timedOut,
404
+ stdout,
405
+ stderr,
406
+ startedAt,
407
+ finishedAt: new Date(),
408
+ });
409
+ });
410
+ });
411
+ }
412
+
413
+ async function listDirectory(args) {
414
+ const directory = resolveTarget(args.path || ".", args.cwd || DEFAULT_CWD, "read");
415
+ const recursive = Boolean(args.recursive);
416
+ const includeHidden = args.includeHidden !== false;
417
+ const items = [];
418
+
419
+ async function walk(current, depth = 0) {
420
+ const entries = await fs.readdir(current, { withFileTypes: true });
421
+ for (const entry of entries) {
422
+ if (!includeHidden && entry.name.startsWith(".")) continue;
423
+
424
+ const fullPath = path.join(current, entry.name);
425
+ if (isInsideProtectedPath(fullPath)) continue;
426
+
427
+ try {
428
+ const stats = await fs.lstat(fullPath);
429
+ items.push({
430
+ name: entry.name,
431
+ path: relativeToUsefulBase(fullPath),
432
+ absolutePath: fullPath,
433
+ type: entry.isDirectory() ? "directory" : entry.isSymbolicLink() ? "symlink" : "file",
434
+ size: stats.size,
435
+ modified: stats.mtime,
436
+ permissions: `0${(stats.mode & 0o777).toString(8)}`,
437
+ });
438
+
439
+ if (recursive && entry.isDirectory() && depth < Number(args.depth || 5)) {
440
+ await walk(fullPath, depth + 1);
441
+ }
442
+ } catch {
443
+ // Skip entries that cannot be read.
444
+ }
445
+ }
446
+ }
447
+
448
+ await walk(directory);
449
+ return { path: directory, count: items.length, items };
450
+ }
451
+
452
+ async function buildTree(args) {
453
+ const root = resolveTarget(args.path || ".", args.cwd || DEFAULT_CWD, "read");
454
+ const maxDepth = Number(args.depth || 999);
455
+ const ignore = new Set(args.ignore || [".git", "node_modules", ".cache", "dist", "build"]);
456
+ let count = 0;
457
+
458
+ async function walk(current, depth) {
459
+ count += 1;
460
+
461
+ const node = {
462
+ name: path.basename(current) || current,
463
+ path: relativeToUsefulBase(current),
464
+ type: "directory",
465
+ children: [],
466
+ };
467
+
468
+ if (depth >= maxDepth) return node;
469
+
470
+ let entries = [];
471
+ try {
472
+ entries = await fs.readdir(current, { withFileTypes: true });
473
+ } catch {
474
+ node.error = "unreadable";
475
+ return node;
476
+ }
477
+
478
+ for (const entry of entries) {
479
+ if (ignore.has(entry.name)) continue;
480
+ const fullPath = path.join(current, entry.name);
481
+ if (isInsideProtectedPath(fullPath)) continue;
482
+
483
+ if (entry.isDirectory()) {
484
+ node.children.push(await walk(fullPath, depth + 1));
485
+ } else {
486
+ count += 1;
487
+ node.children.push({
488
+ name: entry.name,
489
+ path: relativeToUsefulBase(fullPath),
490
+ type: entry.isSymbolicLink() ? "symlink" : "file",
491
+ });
492
+ }
493
+ }
494
+
495
+ return node;
496
+ }
497
+
498
+ return { root, tree: await walk(root, 0), count };
499
+ }
500
+
501
+ async function searchFiles(args) {
502
+ const directory = resolveTarget(args.directory || args.path || ".", args.cwd || DEFAULT_CWD, "read");
503
+ const pattern = String(args.pattern || "");
504
+ if (!pattern) throw new Error("pattern is required");
505
+
506
+ const useRegex = Boolean(args.regex);
507
+ const matcher = useRegex ? new RegExp(pattern, args.caseSensitive ? "" : "i") : null;
508
+ const maxDepth = Number(args.depth || 9999);
509
+ const results = [];
510
+
511
+ async function walk(current, depth) {
512
+ if (depth > maxDepth) return;
513
+
514
+ let entries = [];
515
+ try {
516
+ entries = await fs.readdir(current, { withFileTypes: true });
517
+ } catch {
518
+ return;
519
+ }
520
+
521
+ for (const entry of entries) {
522
+ const fullPath = path.join(current, entry.name);
523
+ if (isInsideProtectedPath(fullPath)) continue;
524
+
525
+ const haystack = args.matchPath ? relativeToUsefulBase(fullPath) : entry.name;
526
+ const matched = useRegex
527
+ ? matcher.test(haystack)
528
+ : args.caseSensitive
529
+ ? haystack.includes(pattern)
530
+ : haystack.toLowerCase().includes(pattern.toLowerCase());
531
+
532
+ if (matched) {
533
+ results.push({
534
+ name: entry.name,
535
+ path: relativeToUsefulBase(fullPath),
536
+ absolutePath: fullPath,
537
+ type: entry.isDirectory() ? "directory" : entry.isSymbolicLink() ? "symlink" : "file",
538
+ });
539
+ }
540
+
541
+ if (entry.isDirectory() && args.recursive !== false) await walk(fullPath, depth + 1);
542
+ }
543
+ }
544
+
545
+ await walk(directory, 0);
546
+ return { directory, pattern, count: results.length, results };
547
+ }
548
+
549
+ async function searchText(args) {
550
+ const directory = resolveTarget(args.directory || args.path || ".", args.cwd || DEFAULT_CWD, "read");
551
+ const query = String(args.query || args.pattern || "");
552
+ if (!query) throw new Error("query is required");
553
+
554
+ const useRegex = Boolean(args.regex);
555
+ const matcher = useRegex ? new RegExp(query, args.caseSensitive ? "g" : "gi") : null;
556
+ const results = [];
557
+
558
+ async function scanFile(filePath) {
559
+ if (isInsideProtectedPath(filePath)) return;
560
+
561
+ let stats;
562
+ try {
563
+ stats = await fs.stat(filePath);
564
+ if (!stats.isFile()) return;
565
+ } catch {
566
+ return;
567
+ }
568
+
569
+ let content;
570
+ try {
571
+ content = await fs.readFile(filePath, "utf8");
572
+ } catch {
573
+ return;
574
+ }
575
+
576
+ if (content.includes("\u0000")) return;
577
+ const lines = content.split(/\r?\n/);
578
+ for (let index = 0; index < lines.length; index += 1) {
579
+ const line = lines[index];
580
+ const matched = useRegex
581
+ ? (matcher.lastIndex = 0, matcher.test(line))
582
+ : args.caseSensitive
583
+ ? line.includes(query)
584
+ : line.toLowerCase().includes(query.toLowerCase());
585
+ if (matched) {
586
+ results.push({
587
+ path: relativeToUsefulBase(filePath),
588
+ absolutePath: filePath,
589
+ line: index + 1,
590
+ preview: line.slice(0, 500),
591
+ });
592
+ }
593
+ }
594
+ }
595
+
596
+ async function walk(current) {
597
+ let entries = [];
598
+ try {
599
+ entries = await fs.readdir(current, { withFileTypes: true });
600
+ } catch {
601
+ return;
602
+ }
603
+
604
+ for (const entry of entries) {
605
+ const fullPath = path.join(current, entry.name);
606
+ if (isInsideProtectedPath(fullPath)) continue;
607
+ if (entry.isDirectory() && args.recursive !== false) await walk(fullPath);
608
+ if (entry.isFile()) await scanFile(fullPath);
609
+ }
610
+ }
611
+
612
+ const stats = await fs.stat(directory);
613
+ if (stats.isFile()) await scanFile(directory);
614
+ else await walk(directory);
615
+
616
+ return { directory, query, count: results.length, results };
617
+ }
618
+
619
+ async function readProcessTable(args) {
620
+ const limit = Math.min(Number(args.limit || 30), 200);
621
+
622
+ if (os.platform() === "win32") {
623
+ // Windows: tasklist /FO TABLE lists running processes
624
+ const { stdout } = await execFileAsync("tasklist", ["/FO", "TABLE"], {
625
+ timeout: 5000,
626
+ maxBuffer: 1024 * 1024,
627
+ });
628
+ const lines = stdout.trim().split(/\r?\n/);
629
+ const dataCount = Math.max(lines.length - 2, 0);
630
+ return { count: dataCount, table: lines.join("\n") };
631
+ }
632
+
633
+ // macOS ps does not support --sort; Linux does.
634
+ const psArgs = os.platform() === "linux"
635
+ ? ["-eo", "pid,ppid,stat,comm,args", "--sort=-%mem"]
636
+ : ["-eo", "pid,ppid,stat,comm,args"];
637
+ const { stdout } = await execFileAsync("ps", psArgs, {
638
+ timeout: 5000,
639
+ maxBuffer: 1024 * 1024,
640
+ });
641
+ const lines = stdout.trim().split(/\r?\n/);
642
+ return { count: Math.max(lines.length - 1, 0), table: lines.join("\n") };
643
+ }
644
+
645
+ async function httpRequest(args) {
646
+ const url = new URL(args.url);
647
+ if (!["http:", "https:"].includes(url.protocol)) throw new Error("Only http:// and https:// URLs are allowed.");
648
+ if (/^(169\.254\.169\.254|metadata\.google\.internal)$/i.test(url.hostname)) {
649
+ throw new Error("Cloud metadata endpoints are blocked.");
650
+ }
651
+
652
+ const controller = new AbortController();
653
+ const timer = setTimeout(() => controller.abort(), Math.min(Number(args.timeout || 15000), 60000));
654
+ try {
655
+ const response = await fetch(url, {
656
+ method: args.method || "GET",
657
+ headers: args.headers || {},
658
+ body: args.body,
659
+ signal: controller.signal,
660
+ });
661
+ const text = await response.text();
662
+ return {
663
+ url: url.toString(),
664
+ status: response.status,
665
+ statusText: response.statusText,
666
+ headers: Object.fromEntries(response.headers.entries()),
667
+ body: text,
668
+ };
669
+ } finally {
670
+ clearTimeout(timer);
671
+ }
672
+ }
673
+
674
+ async function checkPort(args) {
675
+ const host = args.host || "127.0.0.1";
676
+ const port = Number(args.port || PORT);
677
+ return new Promise((resolve) => {
678
+ const socket = net.createConnection({ host, port, timeout: 2000 });
679
+ socket.on("connect", () => {
680
+ socket.destroy();
681
+ resolve({ host, port, open: true });
682
+ });
683
+ socket.on("timeout", () => {
684
+ socket.destroy();
685
+ resolve({ host, port, open: false, reason: "timeout" });
686
+ });
687
+ socket.on("error", (error) => resolve({ host, port, open: false, reason: error.message }));
688
+ });
689
+ }
690
+
691
+ function startManagedProcess(args) {
692
+ const cwd = assertCommandAllowed(args.command, args.cwd || DEFAULT_CWD);
693
+ const processId = `proc-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
694
+ const record = {
695
+ processId,
696
+ command: args.command,
697
+ cwd,
698
+ startedAt: new Date(),
699
+ stdout: "",
700
+ stderr: "",
701
+ exitCode: null,
702
+ signal: null,
703
+ running: true,
704
+ };
705
+
706
+ const child = spawn(args.command, [], {
707
+ cwd,
708
+ env: { ...process.env, ...(args.env || {}) },
709
+ shell: true,
710
+ detached: false,
711
+ stdio: ["pipe", "pipe", "pipe"],
712
+ });
713
+
714
+ record.pid = child.pid;
715
+ child.stdout?.on("data", (chunk) => {
716
+ record.stdout += chunk.toString();
717
+ });
718
+ child.stderr?.on("data", (chunk) => {
719
+ record.stderr += chunk.toString();
720
+ });
721
+ child.on("close", (exitCode, signal) => {
722
+ record.exitCode = exitCode;
723
+ record.signal = signal;
724
+ record.running = false;
725
+ record.finishedAt = new Date();
726
+ });
727
+
728
+ record.child = child;
729
+ runningProcesses.set(processId, record);
730
+ return { processId, pid: child.pid, command: args.command, cwd, startedAt: record.startedAt };
731
+ }
732
+
733
+ // ─── agentic coding helpers ────────────────────────────────────────────────────
734
+
735
+ async function runScript(args) {
736
+ const lang = String(args.language || "").toLowerCase();
737
+ const code = String(args.code || "");
738
+ if (!code.trim()) throw new Error("code is required");
739
+ const extMap = { node: "js", python: "py", powershell: "ps1", bash: "sh", sh: "sh" };
740
+ const ext = extMap[lang];
741
+ if (!ext) throw new Error(`Unsupported language: ${lang}. Use: node, python, powershell, bash, sh`);
742
+ const tmpFile = path.join(os.tmpdir(), `mcp-script-${crypto.randomBytes(6).toString("hex")}.${ext}`);
743
+ try {
744
+ await fs.writeFile(tmpFile, code, "utf8");
745
+ const cwd = args.cwd ? resolveTarget(args.cwd, DEFAULT_CWD, "read") : DEFAULT_CWD;
746
+ const timeout = Math.min(Number(args.timeout || 30000), 300000);
747
+ let command, cmdArgs;
748
+ if (lang === "node") {
749
+ command = process.execPath; cmdArgs = [tmpFile, ...(args.args || [])];
750
+ } else if (lang === "python") {
751
+ command = process.platform === "win32" ? "python" : "python3"; cmdArgs = [tmpFile, ...(args.args || [])];
752
+ } else if (lang === "powershell") {
753
+ command = "powershell"; cmdArgs = ["-NoProfile", "-NonInteractive", "-File", tmpFile];
754
+ } else {
755
+ command = lang === "bash" ? "bash" : "sh"; cmdArgs = [tmpFile, ...(args.args || [])];
756
+ }
757
+ return await captureSpawn(command, cmdArgs, { cwd, shell: false, timeout, env: args.env || {} });
758
+ } finally {
759
+ fs.unlink(tmpFile).catch(() => {});
760
+ }
761
+ }
762
+
763
+ async function findReplaceAll(args) {
764
+ const directory = resolveTarget(args.directory || args.path || ".", args.cwd || DEFAULT_CWD, "write");
765
+ const search = String(args.search || "");
766
+ if (!search) throw new Error("search is required");
767
+ const replacement = String(args.replace ?? "");
768
+ const useRegex = Boolean(args.regex);
769
+ const caseSensitive = Boolean(args.caseSensitive);
770
+ const extensions = args.extensions
771
+ ? (Array.isArray(args.extensions) ? args.extensions : [args.extensions]).map((e) => (e.startsWith(".") ? e : `.${e}`))
772
+ : null;
773
+ const maxFiles = Math.min(Number(args.maxFiles || 500), 2000);
774
+ const maxDepth = Math.min(Number(args.depth || 10), 20);
775
+ const ignore = new Set(["node_modules", ".git", "dist", "build", ".cache", ...(args.ignore || [])]);
776
+ const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
777
+ const re = useRegex
778
+ ? new RegExp(search, caseSensitive ? "g" : "gi")
779
+ : new RegExp(escaped, caseSensitive ? "g" : "gi");
780
+ const changed = []; const errors = []; let scanned = 0;
781
+ async function walk(current, depth) {
782
+ let entries;
783
+ try { entries = await fs.readdir(current, { withFileTypes: true }); } catch { return; }
784
+ for (const entry of entries) {
785
+ if (changed.length >= maxFiles) return;
786
+ const fullPath = path.join(current, entry.name);
787
+ if (isInsideProtectedPath(fullPath)) continue;
788
+ if (entry.isDirectory()) {
789
+ if (depth < maxDepth && !ignore.has(entry.name)) await walk(fullPath, depth + 1);
790
+ } else if (entry.isFile()) {
791
+ if (extensions && !extensions.some((ext) => entry.name.endsWith(ext))) continue;
792
+ scanned++;
793
+ try {
794
+ const content = await fs.readFile(fullPath, "utf8");
795
+ if (content.includes("\u0000")) continue;
796
+ re.lastIndex = 0;
797
+ if (!re.test(content)) continue;
798
+ re.lastIndex = 0;
799
+ const updated = content.replace(re, replacement);
800
+ await fs.writeFile(fullPath, updated, "utf8");
801
+ changed.push(relativeToUsefulBase(fullPath));
802
+ } catch (err) {
803
+ errors.push({ path: relativeToUsefulBase(fullPath), error: err.message });
804
+ }
805
+ }
806
+ }
807
+ }
808
+ await walk(directory, 0);
809
+ return { directory, search, replace: replacement, scanned, changedCount: changed.length, changedFiles: changed, errors };
810
+ }
811
+
812
+ // ── advanced tool helpers ─────────────────────────────────────────────────────
813
+
814
+ function computeLineDiff(textA, textB) {
815
+ const a = textA.split(/\r?\n/);
816
+ const b = textB.split(/\r?\n/);
817
+ const n = a.length, m = b.length;
818
+ const CTX = 3;
819
+
820
+ if (n <= 500 && m <= 500) {
821
+ // LCS-based proper diff
822
+ const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
823
+ for (let i = n - 1; i >= 0; i--)
824
+ for (let j = m - 1; j >= 0; j--)
825
+ dp[i][j] = a[i] === b[j] ? 1 + dp[i + 1][j + 1] : Math.max(dp[i + 1][j], dp[i][j + 1]);
826
+
827
+ const ops = [];
828
+ let i = 0, j = 0, added = 0, removed = 0;
829
+ while (i < n || j < m) {
830
+ if (i < n && j < m && a[i] === b[j]) { ops.push({ op: " ", text: a[i] }); i++; j++; }
831
+ else if (j < m && (i >= n || dp[i + 1][j] >= dp[i][j + 1])) { ops.push({ op: "+", text: b[j] }); j++; added++; }
832
+ else { ops.push({ op: "-", text: a[i] }); i++; removed++; }
833
+ }
834
+ if (added === 0 && removed === 0) return { identical: true, aLines: n, bLines: m, diff: "" };
835
+
836
+ const shown = new Uint8Array(ops.length);
837
+ for (let k = 0; k < ops.length; k++)
838
+ if (ops[k].op !== " ")
839
+ for (let c = Math.max(0, k - CTX); c <= Math.min(ops.length - 1, k + CTX); c++) shown[c] = 1;
840
+
841
+ const out = []; let inHunk = false;
842
+ for (let k = 0; k < ops.length; k++) {
843
+ if (!shown[k]) { inHunk = false; continue; }
844
+ if (!inHunk) { out.push("@@ ... @@"); inHunk = true; }
845
+ out.push(ops[k].op + ops[k].text);
846
+ }
847
+ return { identical: false, aLines: n, bLines: m, added, removed, diff: out.join("\n") };
848
+ }
849
+
850
+ // Large file: positional comparison
851
+ const out = [];
852
+ const maxLen = Math.max(n, m);
853
+ let added = 0, removed = 0;
854
+ for (let k = 0; k < maxLen; k++) {
855
+ if (a[k] !== b[k]) {
856
+ if (a[k] !== undefined) { out.push(`-[L${k + 1}] ${a[k]}`); removed++; }
857
+ if (b[k] !== undefined) { out.push(`+[L${k + 1}] ${b[k]}`); added++; }
858
+ }
859
+ }
860
+ return { identical: added === 0 && removed === 0, aLines: n, bLines: m, added, removed, diff: out.join("\n"), note: "Large file: positional diff" };
861
+ }
862
+
863
+ async function readClipboard() {
864
+ const plat = os.platform();
865
+ if (plat === "win32") {
866
+ const { stdout } = await execFileAsync("powershell", ["-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard"], { timeout: 5000, maxBuffer: 512 * 1024 });
867
+ return { text: stdout.replace(/\r\n$/, "").replace(/\n$/, "") };
868
+ }
869
+ if (plat === "darwin") {
870
+ const { stdout } = await execFileAsync("pbpaste", [], { timeout: 5000, maxBuffer: 512 * 1024 });
871
+ return { text: stdout };
872
+ }
873
+ try {
874
+ const { stdout } = await execFileAsync("xclip", ["-selection", "clipboard", "-o"], { timeout: 5000, maxBuffer: 512 * 1024 });
875
+ return { text: stdout };
876
+ } catch {
877
+ const { stdout } = await execFileAsync("xsel", ["--clipboard", "--output"], { timeout: 5000, maxBuffer: 512 * 1024 });
878
+ return { text: stdout };
879
+ }
880
+ }
881
+
882
+ async function writeClipboard(text) {
883
+ const plat = os.platform();
884
+ if (plat === "win32") {
885
+ const escaped = text.replace(/'/g, "''");
886
+ await captureSpawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", `Set-Clipboard -Value '${escaped}'`], { shell: false, timeout: 5000 });
887
+ return { success: true, bytes: Buffer.byteLength(text) };
888
+ }
889
+ const [cmd, cmdArgs] = plat === "darwin"
890
+ ? ["pbcopy", []]
891
+ : ["xclip", ["-selection", "clipboard"]];
892
+ await new Promise((resolve, reject) => {
893
+ const child = spawn(cmd, cmdArgs, { stdio: ["pipe", "ignore", "ignore"] });
894
+ child.stdin.end(text, "utf8");
895
+ child.on("close", resolve);
896
+ child.on("error", reject);
897
+ });
898
+ return { success: true, bytes: Buffer.byteLength(text) };
899
+ }
900
+
901
+ function resolveJsonPath(data, queryPath) {
902
+ if (!queryPath || queryPath === "." || queryPath === "$") return data;
903
+ const keys = String(queryPath).replace(/\[(\d+)\]/g, ".$1").split(".").filter(Boolean);
904
+ let current = data;
905
+ for (const key of keys) {
906
+ if (current === null || current === undefined) return undefined;
907
+ current = current[Array.isArray(current) && /^\d+$/.test(key) ? Number(key) : key];
908
+ }
909
+ return current;
910
+ }
911
+
912
+ async function callTool(name, args = {}, req = null) {
913
+ switch (name) {
914
+ case "current_context": {
915
+ return {
916
+ server: SERVER_NAME,
917
+ version: VERSION,
918
+ accessRoot: ACCESS_ROOT,
919
+ defaultCwd: DEFAULT_CWD,
920
+ port: PORT,
921
+ mcpEndpoint: req ? `${publicBaseUrl(req)}/mcp` : "/mcp",
922
+ protectedPathCount: PROTECTED_PATHS.length,
923
+ tools: tools.length,
924
+ scopeMode: BYPASS_MODE ? "bypass" : "folder-scoped",
925
+ scopeRoot: ACCESS_ROOT,
926
+ session: {
927
+ start: sessionStart.toISOString(),
928
+ uptimeSeconds: Math.floor(process.uptime()),
929
+ totalCalls,
930
+ totalErrors,
931
+ activeProcesses: runningProcesses.size,
932
+ liveLogClients: liveLogClients.size,
933
+ },
934
+ };
935
+ }
936
+
937
+ case "system_info": {
938
+ return {
939
+ platform: os.platform(),
940
+ release: os.release(),
941
+ arch: os.arch(),
942
+ hostname: os.hostname(),
943
+ uptimeSeconds: os.uptime(),
944
+ user: os.userInfo().username,
945
+ home: os.homedir(),
946
+ temp: os.tmpdir(),
947
+ node: process.version,
948
+ cpus: os.cpus().map((cpu) => cpu.model),
949
+ totalMemory: os.totalmem(),
950
+ freeMemory: os.freemem(),
951
+ loadAverage: os.loadavg(),
952
+ accessRoot: ACCESS_ROOT,
953
+ defaultCwd: DEFAULT_CWD,
954
+ };
955
+ }
956
+
957
+ case "disk_usage": {
958
+ const target = resolveTarget(args.path || DEFAULT_CWD, args.cwd || DEFAULT_CWD, "read");
959
+ if (os.platform() === "win32") {
960
+ // Windows: use PowerShell to get drive usage
961
+ const { stdout, stderr } = await execFileAsync(
962
+ "powershell",
963
+ ["-NoProfile", "-NonInteractive", "-Command",
964
+ "Get-PSDrive -PSProvider FileSystem | "
965
+ + "Select-Object Name,"
966
+ + "@{n='Used(GB)';e={[math]::Round($_.Used/1GB,2)}},"
967
+ + "@{n='Free(GB)';e={[math]::Round($_.Free/1GB,2)}},"
968
+ + "@{n='Total(GB)';e={[math]::Round(($_.Used+$_.Free)/1GB,2)}} "
969
+ + "| Format-Table -AutoSize | Out-String"
970
+ ],
971
+ { timeout: 8000, maxBuffer: 1024 * 1024 }
972
+ );
973
+ return { target, output: stdout || stderr };
974
+ }
975
+ const { stdout, stderr } = await execFileAsync("df", ["-h", target], { timeout: 5000, maxBuffer: 1024 * 1024 });
976
+ return { target, output: stdout || stderr };
977
+ }
978
+
979
+ case "list_directory":
980
+ return listDirectory(args);
981
+
982
+ case "directory_tree":
983
+ return buildTree(args);
984
+
985
+ case "file_info": {
986
+ const target = resolveTarget(args.path, args.cwd || DEFAULT_CWD, "read");
987
+ const stats = await fs.lstat(target);
988
+ return {
989
+ path: relativeToUsefulBase(target),
990
+ absolutePath: target,
991
+ type: stats.isDirectory() ? "directory" : stats.isSymbolicLink() ? "symlink" : "file",
992
+ size: stats.size,
993
+ created: stats.birthtime,
994
+ modified: stats.mtime,
995
+ accessed: stats.atime,
996
+ permissions: `0${(stats.mode & 0o777).toString(8)}`,
997
+ uid: stats.uid,
998
+ gid: stats.gid,
999
+ };
1000
+ }
1001
+
1002
+ case "read_file": {
1003
+ const target = resolveTarget(args.path, args.cwd || DEFAULT_CWD, "read");
1004
+ const stats = await fs.stat(target);
1005
+ if (!stats.isFile()) throw new Error("Target is not a file.");
1006
+ const buffer = await fs.readFile(target);
1007
+ const offset = Number(args.offset || 0);
1008
+ const sliced = offset > 0 ? buffer.subarray(offset) : buffer;
1009
+ return {
1010
+ path: relativeToUsefulBase(target),
1011
+ absolutePath: target,
1012
+ size: buffer.length,
1013
+ encoding: args.base64 ? "base64" : args.encoding || "utf8",
1014
+ content: args.base64 ? sliced.toString("base64") : sliced.toString(args.encoding || "utf8"),
1015
+ };
1016
+ }
1017
+
1018
+ case "write_file": {
1019
+ const target = resolveTarget(args.path, args.cwd || DEFAULT_CWD, "write");
1020
+ await ensureParent(target);
1021
+ await fs.writeFile(target, args.content ?? "", args.encoding || "utf8");
1022
+ return { success: true, path: relativeToUsefulBase(target), absolutePath: target, bytes: Buffer.byteLength(args.content ?? "") };
1023
+ }
1024
+
1025
+ case "append_file": {
1026
+ const target = resolveTarget(args.path, args.cwd || DEFAULT_CWD, "write");
1027
+ await ensureParent(target);
1028
+ await fs.appendFile(target, args.content ?? "", args.encoding || "utf8");
1029
+ return { success: true, path: relativeToUsefulBase(target), absolutePath: target, appendedBytes: Buffer.byteLength(args.content ?? "") };
1030
+ }
1031
+
1032
+ case "touch_file": {
1033
+ const target = resolveTarget(args.path, args.cwd || DEFAULT_CWD, "write");
1034
+ await ensureParent(target);
1035
+ const now = new Date();
1036
+ if (await pathExists(target)) await fs.utimes(target, now, now);
1037
+ else await fs.writeFile(target, "");
1038
+ return { success: true, path: relativeToUsefulBase(target), absolutePath: target };
1039
+ }
1040
+
1041
+ case "create_directory": {
1042
+ const target = resolveTarget(args.path, args.cwd || DEFAULT_CWD, "write");
1043
+ await fs.mkdir(target, { recursive: args.recursive !== false });
1044
+ return { success: true, path: relativeToUsefulBase(target), absolutePath: target };
1045
+ }
1046
+
1047
+ case "delete_path": {
1048
+ const target = resolveTarget(args.path, args.cwd || DEFAULT_CWD, "delete");
1049
+ await fs.rm(target, { recursive: Boolean(args.recursive), force: Boolean(args.force) });
1050
+ return { success: true, deleted: relativeToUsefulBase(target), absolutePath: target };
1051
+ }
1052
+
1053
+ case "copy_path": {
1054
+ const source = resolveTarget(args.source, args.cwd || DEFAULT_CWD, "read");
1055
+ const destination = resolveTarget(args.destination, args.cwd || DEFAULT_CWD, "write");
1056
+ await ensureParent(destination);
1057
+ const stats = await fs.stat(source);
1058
+ if (stats.isDirectory()) await fs.cp(source, destination, { recursive: true, force: args.force !== false });
1059
+ else await fs.copyFile(source, destination);
1060
+ return { success: true, source: relativeToUsefulBase(source), destination: relativeToUsefulBase(destination) };
1061
+ }
1062
+
1063
+ case "move_path": {
1064
+ const source = resolveTarget(args.source, args.cwd || DEFAULT_CWD, "delete");
1065
+ const destination = resolveTarget(args.destination, args.cwd || DEFAULT_CWD, "write");
1066
+ await ensureParent(destination);
1067
+ await fs.rename(source, destination);
1068
+ return { success: true, source: relativeToUsefulBase(source), destination: relativeToUsefulBase(destination) };
1069
+ }
1070
+
1071
+ case "make_executable": {
1072
+ const target = resolveTarget(args.path, args.cwd || DEFAULT_CWD, "write");
1073
+ const stats = await fs.stat(target);
1074
+ await fs.chmod(target, stats.mode | 0o111);
1075
+ return { success: true, path: relativeToUsefulBase(target), permissions: "executable bits added" };
1076
+ }
1077
+
1078
+ case "search_files":
1079
+ return searchFiles(args);
1080
+
1081
+ case "search_text":
1082
+ return searchText(args);
1083
+
1084
+ case "replace_text": {
1085
+ const target = resolveTarget(args.path, args.cwd || DEFAULT_CWD, "write");
1086
+ const original = await fs.readFile(target, args.encoding || "utf8");
1087
+ const search = String(args.search ?? "");
1088
+ if (!search) throw new Error("search is required");
1089
+ const replacement = String(args.replace ?? "");
1090
+ const updated = args.regex
1091
+ ? original.replace(new RegExp(search, args.all === false ? "" : "g"), replacement)
1092
+ : args.all === false
1093
+ ? original.replace(search, replacement)
1094
+ : original.split(search).join(replacement);
1095
+ await fs.writeFile(target, updated, args.encoding || "utf8");
1096
+ return {
1097
+ success: true,
1098
+ path: relativeToUsefulBase(target),
1099
+ changed: original !== updated,
1100
+ deltaBytes: Buffer.byteLength(updated) - Buffer.byteLength(original),
1101
+ };
1102
+ }
1103
+
1104
+ case "run_command": {
1105
+ const cwd = assertCommandAllowed(args.command, args.cwd || DEFAULT_CWD);
1106
+ return captureSpawn(args.command, [], {
1107
+ cwd,
1108
+ shell: true,
1109
+ timeout: Math.min(Number(args.timeout || COMMAND_TIMEOUT_MS), 10 * 60 * 1000),
1110
+ env: args.env || {},
1111
+ });
1112
+ }
1113
+
1114
+ case "start_process":
1115
+ return startManagedProcess(args);
1116
+
1117
+ case "read_process": {
1118
+ const record = runningProcesses.get(args.processId);
1119
+ if (!record) throw new Error("Process not found.");
1120
+ return safeJson({ ...record, child: undefined });
1121
+ }
1122
+
1123
+ case "stop_process": {
1124
+ const record = runningProcesses.get(args.processId);
1125
+ if (!record) throw new Error("Process not found.");
1126
+ if (!record.running) return { success: true, message: "Process already stopped.", process: safeJson({ ...record, child: undefined }) };
1127
+ record.child.kill(args.signal || "SIGTERM");
1128
+ return { success: true, message: `Signal sent to managed process ${args.processId}.` };
1129
+ }
1130
+
1131
+ case "list_processes": {
1132
+ return {
1133
+ count: runningProcesses.size,
1134
+ processes: Array.from(runningProcesses.values()).map((record) => safeJson({ ...record, child: undefined })),
1135
+ };
1136
+ }
1137
+
1138
+ case "list_system_processes":
1139
+ return readProcessTable(args);
1140
+
1141
+ case "environment_list": {
1142
+ const filter = args.filter ? String(args.filter).toUpperCase() : "";
1143
+ const entries = Object.entries(process.env).filter(([key]) => !filter || key.toUpperCase().includes(filter));
1144
+ return { count: entries.length, variables: redactEnvironment(entries, Boolean(args.includeValues), Boolean(args.revealSecrets)) };
1145
+ }
1146
+
1147
+ case "environment_get": {
1148
+ const name = String(args.name || "");
1149
+ if (!name) throw new Error("name is required");
1150
+ const value = process.env[name];
1151
+ return {
1152
+ name,
1153
+ exists: value !== undefined,
1154
+ value: looksSecret(name) && !args.revealSecret ? "<redacted>" : value,
1155
+ };
1156
+ }
1157
+
1158
+ case "environment_set": {
1159
+ const name = String(args.name || "");
1160
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error("Invalid environment variable name.");
1161
+ process.env[name] = String(args.value ?? "");
1162
+
1163
+ let persistedTo = null;
1164
+ if (args.persistFile) {
1165
+ const persistTarget = resolveTarget(args.persistFile, args.cwd || DEFAULT_CWD, "write");
1166
+ await fs.appendFile(persistTarget, `\nexport ${name}=${JSON.stringify(String(args.value ?? ""))}\n`, "utf8");
1167
+ persistedTo = persistTarget;
1168
+ }
1169
+
1170
+ return { success: true, name, processUpdated: true, persistedTo };
1171
+ }
1172
+
1173
+ case "http_request":
1174
+ return httpRequest(args);
1175
+
1176
+ case "port_check":
1177
+ return checkPort(args);
1178
+
1179
+ case "git_status": {
1180
+ const repo = resolveTarget(args.path || DEFAULT_CWD, args.cwd || DEFAULT_CWD, "read");
1181
+ const { stdout, stderr } = await execFileAsync("git", ["-C", repo, "status", "--short", "--branch"], {
1182
+ timeout: 10000,
1183
+ maxBuffer: 1024 * 1024,
1184
+ });
1185
+ return { repo, output: stdout || stderr };
1186
+ }
1187
+
1188
+ case "git_diff": {
1189
+ const repo = resolveTarget(args.path || DEFAULT_CWD, args.cwd || DEFAULT_CWD, "read");
1190
+ const diffArgs = ["-C", repo, "--no-pager", "diff"];
1191
+ if (args.staged) diffArgs.push("--staged");
1192
+ if (args.file) diffArgs.push("--", args.file);
1193
+ const { stdout, stderr } = await execFileAsync("git", diffArgs, { timeout: 10000, maxBuffer: 512 * 1024 * 1024 });
1194
+ return { repo, output: stdout || stderr };
1195
+ }
1196
+
1197
+ case "open_url": {
1198
+ const url = new URL(args.url).toString();
1199
+ let child;
1200
+ if (os.platform() === "win32") {
1201
+ // Windows: 'start' is a cmd built-in; use cmd /c start
1202
+ child = spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" });
1203
+ } else if (os.platform() === "darwin") {
1204
+ child = spawn("open", [url], { detached: true, stdio: "ignore" });
1205
+ } else {
1206
+ const browser = process.env.BROWSER || (process.env.DISPLAY ? "xdg-open" : null);
1207
+ if (!browser) return { success: false, message: "No browser available in this headless environment.", url };
1208
+ child = spawn(browser, [url], { detached: true, stdio: "ignore" });
1209
+ }
1210
+ child.unref();
1211
+ return { success: true, url };
1212
+ }
1213
+
1214
+ // ─── agentic coding tools ──────────────────────────────────────────────────
1215
+
1216
+ case "read_file_lines": {
1217
+ const target = resolveTarget(args.path, args.cwd || DEFAULT_CWD, "read");
1218
+ const stats = await fs.stat(target);
1219
+ if (!stats.isFile()) throw new Error("Target is not a file.");
1220
+ const content = await fs.readFile(target, "utf8");
1221
+ const allLines = content.split(/\r?\n/);
1222
+ const totalLines = allLines.length;
1223
+ const startLine = Math.max(1, Number(args.startLine || 1));
1224
+ const endLine = args.endLine !== undefined
1225
+ ? Math.min(totalLines, Number(args.endLine))
1226
+ : totalLines;
1227
+ if (startLine > totalLines) throw new Error(`startLine (${startLine}) exceeds file length (${totalLines} lines)`);
1228
+ const slice = allLines.slice(startLine - 1, endLine);
1229
+ return {
1230
+ path: relativeToUsefulBase(target),
1231
+ absolutePath: target,
1232
+ totalLines,
1233
+ startLine,
1234
+ endLine: Math.min(endLine, totalLines),
1235
+ returnedLines: slice.length,
1236
+ content: slice.join("\n"),
1237
+ };
1238
+ }
1239
+
1240
+ case "patch_file": {
1241
+ const target = resolveTarget(args.path, args.cwd || DEFAULT_CWD, "write");
1242
+ let content = await fs.readFile(target, args.encoding || "utf8");
1243
+ const patches = Array.isArray(args.patches) ? args.patches : [];
1244
+ if (!patches.length) throw new Error("patches array is required and must not be empty");
1245
+ const results = [];
1246
+ for (const patch of patches) {
1247
+ const searchStr = String(patch.search ?? "");
1248
+ const replaceStr = String(patch.replace ?? "");
1249
+ if (!searchStr) { results.push({ search: searchStr, applied: false, reason: "empty search" }); continue; }
1250
+ const before = content;
1251
+ if (patch.regex) {
1252
+ content = content.replace(new RegExp(searchStr, patch.all === false ? "" : "g"), replaceStr);
1253
+ } else {
1254
+ content = patch.all === false ? content.replace(searchStr, replaceStr) : content.split(searchStr).join(replaceStr);
1255
+ }
1256
+ results.push({ search: searchStr.slice(0, 80), applied: content !== before });
1257
+ }
1258
+ await fs.writeFile(target, content, args.encoding || "utf8");
1259
+ return {
1260
+ success: true,
1261
+ path: relativeToUsefulBase(target),
1262
+ patchCount: patches.length,
1263
+ applied: results.filter((r) => r.applied).length,
1264
+ results,
1265
+ };
1266
+ }
1267
+
1268
+ case "read_many_files": {
1269
+ const paths = Array.isArray(args.paths) ? args.paths : [];
1270
+ if (!paths.length) throw new Error("paths array is required and must not be empty");
1271
+ const files = await Promise.all(
1272
+ paths.map(async (p) => {
1273
+ try {
1274
+ const target = resolveTarget(p, args.cwd || DEFAULT_CWD, "read");
1275
+ const buffer = await fs.readFile(target);
1276
+ return {
1277
+ path: relativeToUsefulBase(target),
1278
+ absolutePath: target,
1279
+ size: buffer.length,
1280
+ content: buffer.toString(args.encoding || "utf8"),
1281
+ };
1282
+ } catch (err) {
1283
+ return { path: p, error: err.message };
1284
+ }
1285
+ })
1286
+ );
1287
+ return { count: files.length, files };
1288
+ }
1289
+
1290
+ case "write_many_files": {
1291
+ const files = Array.isArray(args.files) ? args.files : [];
1292
+ if (!files.length) throw new Error("files array is required and must not be empty");
1293
+ const defaultEncoding = args.encoding || "utf8";
1294
+ const results = await Promise.all(
1295
+ files.map(async (f) => {
1296
+ try {
1297
+ const target = resolveTarget(f.path, args.cwd || DEFAULT_CWD, "write");
1298
+ await ensureParent(target);
1299
+ await fs.writeFile(target, f.content ?? "", f.encoding || defaultEncoding);
1300
+ return { path: relativeToUsefulBase(target), success: true, bytes: Buffer.byteLength(f.content ?? "") };
1301
+ } catch (err) {
1302
+ return { path: f.path, success: false, error: err.message };
1303
+ }
1304
+ })
1305
+ );
1306
+ const succeeded = results.filter((r) => r.success).length;
1307
+ return { total: files.length, succeeded, failed: files.length - succeeded, results };
1308
+ }
1309
+
1310
+ case "git_log": {
1311
+ const repo = resolveTarget(args.path || DEFAULT_CWD, args.cwd || DEFAULT_CWD, "read");
1312
+ const limit = Math.min(Number(args.limit || 20), 200);
1313
+ const GIT_SEP = "---GIT-LOG-RECORD---";
1314
+ const logArgs = [
1315
+ "-C", repo, "--no-pager", "log",
1316
+ `--max-count=${limit}`,
1317
+ `--pretty=format:${GIT_SEP}%n%H%n%h%n%an%n%ae%n%ai%n%s`,
1318
+ ...(args.file ? ["--", args.file] : []),
1319
+ ];
1320
+ const { stdout, stderr } = await execFileAsync("git", logArgs, { timeout: 15000, maxBuffer: 512 * 1024 * 1024 });
1321
+ if (!stdout && stderr) throw new Error(stderr.trim());
1322
+ const blocks = stdout.split(GIT_SEP).filter((b) => b.trim());
1323
+ const commits = blocks.map((block) => {
1324
+ const lines = block.trim().split("\n");
1325
+ const [hash, shortHash, authorName, authorEmail, date, ...rest] = lines;
1326
+ return { hash, shortHash, author: { name: authorName, email: authorEmail }, date, subject: rest.join(" ").trim() };
1327
+ });
1328
+ return { repo, count: commits.length, commits };
1329
+ }
1330
+
1331
+ case "git_commit": {
1332
+ const repo = resolveTarget(args.path || DEFAULT_CWD, args.cwd || DEFAULT_CWD, "write");
1333
+ const message = String(args.message || "");
1334
+ if (!message) throw new Error("message is required");
1335
+ const toAdd = args.add !== undefined
1336
+ ? (Array.isArray(args.add) ? args.add : [String(args.add)])
1337
+ : ["."];
1338
+ await execFileAsync("git", ["-C", repo, "add", ...toAdd], { timeout: 15000, maxBuffer: 1024 * 1024 });
1339
+ const commitArgs = ["-C", repo, "commit", "-m", message];
1340
+ if (args.noVerify) commitArgs.push("--no-verify");
1341
+ const { stdout, stderr } = await execFileAsync("git", commitArgs, { timeout: 15000, maxBuffer: 1024 * 1024 });
1342
+ return { repo, success: true, output: (stdout || stderr || "").trim() };
1343
+ }
1344
+
1345
+ case "git_branch": {
1346
+ const repo = resolveTarget(args.path || DEFAULT_CWD, args.cwd || DEFAULT_CWD, "read");
1347
+ const action = String(args.action || "list");
1348
+ if (action === "list") {
1349
+ const [{ stdout: branchOut }, { stdout: currentOut }] = await Promise.all([
1350
+ execFileAsync("git", ["-C", repo, "branch", "-a"], { timeout: 10000, maxBuffer: 1024 * 1024 }),
1351
+ execFileAsync("git", ["-C", repo, "branch", "--show-current"], { timeout: 5000, maxBuffer: 1024 * 1024 }).catch(() => ({ stdout: "" })),
1352
+ ]);
1353
+ const current = currentOut.trim();
1354
+ const branches = branchOut.trim().split(/\r?\n/).filter(Boolean).map((line) => {
1355
+ const name = line.replace(/^\*\s+/, "").trim();
1356
+ return { name, current: name === current, remote: name.startsWith("remotes/") };
1357
+ });
1358
+ return { repo, action, current, branches };
1359
+ }
1360
+ if (action === "create") {
1361
+ const name = String(args.name || "");
1362
+ if (!name) throw new Error("name is required for create");
1363
+ const createArgs = ["-C", repo, "checkout", "-b", name, ...(args.from ? [args.from] : [])];
1364
+ const { stdout, stderr } = await execFileAsync("git", createArgs, { timeout: 10000, maxBuffer: 1024 * 1024 });
1365
+ return { repo, action, name, output: (stdout || stderr || "").trim() };
1366
+ }
1367
+ if (action === "switch") {
1368
+ const name = String(args.name || "");
1369
+ if (!name) throw new Error("name is required for switch");
1370
+ const { stdout, stderr } = await execFileAsync("git", ["-C", repo, "checkout", name], { timeout: 10000, maxBuffer: 1024 * 1024 });
1371
+ return { repo, action, name, output: (stdout || stderr || "").trim() };
1372
+ }
1373
+ if (action === "delete") {
1374
+ const name = String(args.name || "");
1375
+ if (!name) throw new Error("name is required for delete");
1376
+ const { stdout, stderr } = await execFileAsync("git", ["-C", repo, "branch", args.force ? "-D" : "-d", name], { timeout: 10000, maxBuffer: 1024 * 1024 });
1377
+ return { repo, action, name, output: (stdout || stderr || "").trim() };
1378
+ }
1379
+ throw new Error(`Unknown git_branch action: ${action}. Use: list, create, switch, delete`);
1380
+ }
1381
+
1382
+ case "find_replace_all":
1383
+ return findReplaceAll(args);
1384
+
1385
+ case "run_script":
1386
+ return runScript(args);
1387
+
1388
+ case "send_to_process": {
1389
+ const record = runningProcesses.get(args.processId);
1390
+ if (!record) throw new Error("Process not found.");
1391
+ if (!record.running) throw new Error("Process is not running.");
1392
+ if (!record.child || !record.child.stdin || record.child.stdin.destroyed) {
1393
+ throw new Error("Process stdin is not available or has been closed.");
1394
+ }
1395
+ const input = String(args.input ?? "");
1396
+ await new Promise((resolve, reject) => {
1397
+ record.child.stdin.write(input + (args.newline !== false ? "\n" : ""), (err) => {
1398
+ if (err) reject(err); else resolve();
1399
+ });
1400
+ });
1401
+ return { success: true, processId: args.processId, sentBytes: Buffer.byteLength(input) };
1402
+ }
1403
+
1404
+ case "list_installed_packages": {
1405
+ const projectPath = resolveTarget(args.path || DEFAULT_CWD, args.cwd || DEFAULT_CWD, "read");
1406
+ const type = String(args.type || "auto").toLowerCase();
1407
+ const result = {};
1408
+ if (type === "auto" || type === "npm") {
1409
+ const pkgFile = path.join(projectPath, "package.json");
1410
+ if (await pathExists(pkgFile)) {
1411
+ try {
1412
+ const pkg = JSON.parse(await fs.readFile(pkgFile, "utf8"));
1413
+ result.npm = {
1414
+ name: pkg.name,
1415
+ version: pkg.version,
1416
+ scripts: pkg.scripts || {},
1417
+ dependencies: pkg.dependencies || {},
1418
+ devDependencies: pkg.devDependencies || {},
1419
+ peerDependencies: pkg.peerDependencies || {},
1420
+ };
1421
+ } catch (err) {
1422
+ result.npm = { error: err.message };
1423
+ }
1424
+ }
1425
+ }
1426
+ if (type === "auto" || type === "pip") {
1427
+ const reqFile = path.join(projectPath, "requirements.txt");
1428
+ if (await pathExists(reqFile)) {
1429
+ const content = await fs.readFile(reqFile, "utf8");
1430
+ result.pip = {
1431
+ file: "requirements.txt",
1432
+ packages: content.trim().split(/\r?\n/).filter((l) => l && !l.startsWith("#")),
1433
+ };
1434
+ }
1435
+ const pyprojectFile = path.join(projectPath, "pyproject.toml");
1436
+ if (await pathExists(pyprojectFile)) {
1437
+ result.pyproject = { file: "pyproject.toml", content: await fs.readFile(pyprojectFile, "utf8") };
1438
+ }
1439
+ }
1440
+ if (type === "auto" || type === "cargo") {
1441
+ const cargoFile = path.join(projectPath, "Cargo.toml");
1442
+ if (await pathExists(cargoFile)) {
1443
+ result.cargo = { file: "Cargo.toml", content: await fs.readFile(cargoFile, "utf8") };
1444
+ }
1445
+ }
1446
+ return { path: projectPath, managers: Object.keys(result), result };
1447
+ }
1448
+
1449
+ case "hash_file": {
1450
+ const target = resolveTarget(args.path, args.cwd || DEFAULT_CWD, "read");
1451
+ const stats = await fs.stat(target);
1452
+ if (!stats.isFile()) throw new Error("Target is not a file.");
1453
+ const buffer = await fs.readFile(target);
1454
+ const algorithm = ["sha256", "sha1", "md5", "sha512"].includes(String(args.algorithm || "")) ? args.algorithm : "sha256";
1455
+ const hash = crypto.createHash(algorithm).update(buffer).digest("hex");
1456
+ return { path: relativeToUsefulBase(target), absolutePath: target, algorithm, hash, size: stats.size };
1457
+ }
1458
+
1459
+ case "count_lines": {
1460
+ const target = resolveTarget(args.path, args.cwd || DEFAULT_CWD, "read");
1461
+ const stats = await fs.stat(target);
1462
+ if (!stats.isFile()) throw new Error("Target is not a file.");
1463
+ const content = await fs.readFile(target, "utf8");
1464
+ const lines = content.split(/\r?\n/);
1465
+ return {
1466
+ path: relativeToUsefulBase(target),
1467
+ totalLines: lines.length,
1468
+ nonEmptyLines: lines.filter((l) => l.trim()).length,
1469
+ characters: content.length,
1470
+ bytes: stats.size,
1471
+ words: content.split(/\s+/).filter((w) => w).length,
1472
+ };
1473
+ }
1474
+
1475
+ // ── advanced tools ────────────────────────────────────────────────────────
1476
+
1477
+ case "diff_files": {
1478
+ const pathA = resolveTarget(args.pathA, args.cwd || DEFAULT_CWD, "read");
1479
+ const pathB = resolveTarget(args.pathB, args.cwd || DEFAULT_CWD, "read");
1480
+ const [stA, stB] = await Promise.all([fs.stat(pathA), fs.stat(pathB)]);
1481
+ if (!stA.isFile()) throw new Error("pathA is not a file.");
1482
+ if (!stB.isFile()) throw new Error("pathB is not a file.");
1483
+ const [textA, textB] = await Promise.all([fs.readFile(pathA, "utf8"), fs.readFile(pathB, "utf8")]);
1484
+ const result = computeLineDiff(textA, textB);
1485
+ return { pathA: relativeToUsefulBase(pathA), pathB: relativeToUsefulBase(pathB), ...result };
1486
+ }
1487
+
1488
+ case "clipboard_read":
1489
+ return readClipboard();
1490
+
1491
+ case "clipboard_write": {
1492
+ const text = String(args.text ?? "");
1493
+ return writeClipboard(text);
1494
+ }
1495
+
1496
+ case "network_info": {
1497
+ const ifaces = os.networkInterfaces();
1498
+ const interfaces = {};
1499
+ for (const [ifName, addrs] of Object.entries(ifaces)) {
1500
+ interfaces[ifName] = (addrs || []).map((a) => ({
1501
+ family: a.family,
1502
+ address: a.address,
1503
+ netmask: a.netmask,
1504
+ cidr: a.cidr,
1505
+ internal: a.internal,
1506
+ mac: a.mac,
1507
+ }));
1508
+ }
1509
+ return {
1510
+ hostname: os.hostname(),
1511
+ platform: os.platform(),
1512
+ interfaces,
1513
+ interfaceCount: Object.keys(interfaces).length,
1514
+ };
1515
+ }
1516
+
1517
+ case "json_query": {
1518
+ const target = resolveTarget(args.path, args.cwd || DEFAULT_CWD, "read");
1519
+ const raw = await fs.readFile(target, "utf8");
1520
+ let data;
1521
+ try { data = JSON.parse(raw); } catch (e) { throw new Error(`JSON parse error: ${e.message}`); }
1522
+ const qResult = resolveJsonPath(data, args.query || ".");
1523
+ return {
1524
+ path: relativeToUsefulBase(target),
1525
+ query: args.query || ".",
1526
+ resultType: qResult === null ? "null" : Array.isArray(qResult) ? "array" : typeof qResult,
1527
+ result: qResult,
1528
+ };
1529
+ }
1530
+
1531
+ case "archive_create": {
1532
+ const sources = (Array.isArray(args.sources) ? args.sources : [args.sources || args.source || "."])
1533
+ .map((s) => resolveTarget(s, args.cwd || DEFAULT_CWD, "read"));
1534
+ const dest = resolveTarget(args.destination || args.dest, args.cwd || DEFAULT_CWD, "write");
1535
+ await ensureParent(dest);
1536
+ const ext = path.extname(dest).toLowerCase();
1537
+ let archResult;
1538
+ if (os.platform() === "win32") {
1539
+ if (ext !== ".zip") throw new Error("On Windows only .zip archives are supported (PowerShell Compress-Archive).");
1540
+ const srcList = sources.map((s) => s.replace(/\\/g, "/")).join("','");
1541
+ const cmd = `Compress-Archive -Path '${srcList}' -DestinationPath '${dest.replace(/\\/g, "/")}' -Force`;
1542
+ archResult = await captureSpawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", cmd], { shell: false, timeout: 60000 });
1543
+ } else if (ext === ".zip") {
1544
+ archResult = await captureSpawn("zip", ["-r", dest, ...sources], { timeout: 60000 });
1545
+ } else {
1546
+ archResult = await captureSpawn("tar", ["-czf", dest, ...sources], { timeout: 60000 });
1547
+ }
1548
+ if (!archResult.success) throw new Error(`Archive creation failed: ${archResult.stderr || archResult.stdout}`);
1549
+ const destStat = await fs.stat(dest).catch(() => null);
1550
+ return { success: true, destination: relativeToUsefulBase(dest), absolutePath: dest, sizeBytes: destStat?.size ?? null };
1551
+ }
1552
+
1553
+ case "archive_extract": {
1554
+ const archivePath = resolveTarget(args.path, args.cwd || DEFAULT_CWD, "read");
1555
+ const destDir = resolveTarget(args.destination || args.dest || ".", args.cwd || DEFAULT_CWD, "write");
1556
+ await fs.mkdir(destDir, { recursive: true });
1557
+ const ext = path.extname(archivePath).toLowerCase();
1558
+ let exResult;
1559
+ if (os.platform() === "win32") {
1560
+ const cmd = `Expand-Archive -Path '${archivePath.replace(/\\/g, "/")}' -DestinationPath '${destDir.replace(/\\/g, "/")}' -Force`;
1561
+ exResult = await captureSpawn("powershell", ["-NoProfile", "-NonInteractive", "-Command", cmd], { shell: false, timeout: 120000 });
1562
+ } else if (ext === ".zip") {
1563
+ exResult = await captureSpawn("unzip", ["-o", archivePath, "-d", destDir], { timeout: 120000 });
1564
+ } else {
1565
+ exResult = await captureSpawn("tar", ["-xf", archivePath, "-C", destDir], { timeout: 120000 });
1566
+ }
1567
+ if (!exResult.success) throw new Error(`Extraction failed: ${exResult.stderr || exResult.stdout}`);
1568
+ return { success: true, archive: relativeToUsefulBase(archivePath), destination: relativeToUsefulBase(destDir) };
1569
+ }
1570
+
1571
+ case "generate_token": {
1572
+ const type = String(args.type || "hex").toLowerCase();
1573
+ const length = Math.min(Math.max(Number(args.length || 32), 1), 512);
1574
+ if (type === "uuid") {
1575
+ const value = crypto.randomUUID();
1576
+ return { type: "uuid", value, length: value.length };
1577
+ }
1578
+ if (type === "base64") {
1579
+ const value = crypto.randomBytes(Math.ceil(length * 0.75)).toString("base64url").slice(0, length);
1580
+ return { type: "base64url", value, length: value.length };
1581
+ }
1582
+ if (type === "alphanumeric") {
1583
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
1584
+ const bytes = crypto.randomBytes(length);
1585
+ const value = Array.from(bytes, (b) => chars[b % chars.length]).join("");
1586
+ return { type: "alphanumeric", value, length };
1587
+ }
1588
+ // default: hex
1589
+ const value = crypto.randomBytes(Math.ceil(length / 2)).toString("hex").slice(0, length);
1590
+ return { type: "hex", value, length: value.length };
1591
+ }
1592
+
1593
+ default:
1594
+ throw new Error(`Unknown tool: ${name}`);
1595
+ }
1596
+ }
1597
+
1598
+ const tools = [
1599
+ {
1600
+ name: "current_context",
1601
+ description: "Show connector context: MCP endpoint, access root, default working directory, auth mode, and protection status.",
1602
+ inputSchema: { type: "object", properties: {} },
1603
+ },
1604
+ {
1605
+ name: "system_info",
1606
+ description: "Get OS, CPU, memory, Node.js, user, and configured filesystem context information.",
1607
+ inputSchema: { type: "object", properties: {} },
1608
+ },
1609
+ {
1610
+ name: "disk_usage",
1611
+ description: "Run df for a path and return disk usage.",
1612
+ inputSchema: { type: "object", properties: { path: { type: "string", default: "." }, cwd: { type: "string" } } },
1613
+ },
1614
+ {
1615
+ name: "list_directory",
1616
+ description: "List directory contents with file type, size, modification time, permissions, and optional recursion.",
1617
+ inputSchema: {
1618
+ type: "object",
1619
+ properties: {
1620
+ path: { type: "string", default: "." },
1621
+ cwd: { type: "string" },
1622
+ recursive: { type: "boolean", default: false },
1623
+ depth: { type: "number", default: 5 },
1624
+ includeHidden: { type: "boolean", default: true },
1625
+ maxEntries: { type: "number", default: 1000 },
1626
+ },
1627
+ },
1628
+ },
1629
+ {
1630
+ name: "directory_tree",
1631
+ description: "Return a nested directory tree, skipping protected connector files and large default folders.",
1632
+ inputSchema: {
1633
+ type: "object",
1634
+ properties: {
1635
+ path: { type: "string", default: "." },
1636
+ cwd: { type: "string" },
1637
+ depth: { type: "number", default: 4 },
1638
+ maxEntries: { type: "number", default: 500 },
1639
+ ignore: { type: "array", items: { type: "string" } },
1640
+ },
1641
+ },
1642
+ },
1643
+ {
1644
+ name: "file_info",
1645
+ description: "Get metadata for a file, directory, or symlink.",
1646
+ inputSchema: { type: "object", properties: { path: { type: "string" }, cwd: { type: "string" } }, required: ["path"] },
1647
+ },
1648
+ {
1649
+ name: "read_file",
1650
+ description: "Read a text or binary file by path. Relative paths use the default workspace; absolute paths can access the configured root.",
1651
+ inputSchema: {
1652
+ type: "object",
1653
+ properties: {
1654
+ path: { type: "string" },
1655
+ cwd: { type: "string" },
1656
+ encoding: { type: "string", default: "utf8" },
1657
+ base64: { type: "boolean", default: false },
1658
+ offset: { type: "number", default: 0 },
1659
+ maxBytes: { type: "number", default: 4194304 },
1660
+ },
1661
+ required: ["path"],
1662
+ },
1663
+ },
1664
+ {
1665
+ name: "write_file",
1666
+ description: "Create or overwrite a file, automatically creating parent directories. Protected connector files are blocked.",
1667
+ inputSchema: {
1668
+ type: "object",
1669
+ properties: { path: { type: "string" }, cwd: { type: "string" }, content: { type: "string" }, encoding: { type: "string", default: "utf8" } },
1670
+ required: ["path", "content"],
1671
+ },
1672
+ },
1673
+ {
1674
+ name: "append_file",
1675
+ description: "Append text to a file. Protected connector files are blocked.",
1676
+ inputSchema: {
1677
+ type: "object",
1678
+ properties: { path: { type: "string" }, cwd: { type: "string" }, content: { type: "string" }, encoding: { type: "string", default: "utf8" } },
1679
+ required: ["path", "content"],
1680
+ },
1681
+ },
1682
+ {
1683
+ name: "touch_file",
1684
+ description: "Create an empty file or update timestamps.",
1685
+ inputSchema: { type: "object", properties: { path: { type: "string" }, cwd: { type: "string" } }, required: ["path"] },
1686
+ },
1687
+ {
1688
+ name: "create_directory",
1689
+ description: "Create a directory recursively.",
1690
+ inputSchema: { type: "object", properties: { path: { type: "string" }, cwd: { type: "string" }, recursive: { type: "boolean", default: true } }, required: ["path"] },
1691
+ },
1692
+ {
1693
+ name: "delete_path",
1694
+ description: "Delete a file or directory. Deleting anything that contains the connector runtime is blocked.",
1695
+ inputSchema: { type: "object", properties: { path: { type: "string" }, cwd: { type: "string" }, recursive: { type: "boolean", default: false }, force: { type: "boolean", default: false } }, required: ["path"] },
1696
+ },
1697
+ {
1698
+ name: "copy_path",
1699
+ description: "Copy a file or directory.",
1700
+ inputSchema: { type: "object", properties: { source: { type: "string" }, destination: { type: "string" }, cwd: { type: "string" }, force: { type: "boolean", default: true } }, required: ["source", "destination"] },
1701
+ },
1702
+ {
1703
+ name: "move_path",
1704
+ description: "Move or rename a file or directory. Protected connector files are blocked.",
1705
+ inputSchema: { type: "object", properties: { source: { type: "string" }, destination: { type: "string" }, cwd: { type: "string" } }, required: ["source", "destination"] },
1706
+ },
1707
+ {
1708
+ name: "make_executable",
1709
+ description: "Add executable permission bits to a file.",
1710
+ inputSchema: { type: "object", properties: { path: { type: "string" }, cwd: { type: "string" } }, required: ["path"] },
1711
+ },
1712
+ {
1713
+ name: "search_files",
1714
+ description: "Search file and directory names by substring or regular expression.",
1715
+ inputSchema: {
1716
+ type: "object",
1717
+ properties: {
1718
+ directory: { type: "string", default: "." },
1719
+ cwd: { type: "string" },
1720
+ pattern: { type: "string" },
1721
+ regex: { type: "boolean", default: false },
1722
+ caseSensitive: { type: "boolean", default: false },
1723
+ recursive: { type: "boolean", default: true },
1724
+ matchPath: { type: "boolean", default: false },
1725
+ depth: { type: "number", default: 10 },
1726
+ maxResults: { type: "number", default: 200 },
1727
+ },
1728
+ required: ["pattern"],
1729
+ },
1730
+ },
1731
+ {
1732
+ name: "search_text",
1733
+ description: "Search text inside files by plain text or regular expression.",
1734
+ inputSchema: {
1735
+ type: "object",
1736
+ properties: {
1737
+ directory: { type: "string", default: "." },
1738
+ cwd: { type: "string" },
1739
+ query: { type: "string" },
1740
+ regex: { type: "boolean", default: false },
1741
+ caseSensitive: { type: "boolean", default: false },
1742
+ recursive: { type: "boolean", default: true },
1743
+ maxResults: { type: "number", default: 100 },
1744
+ maxFileBytes: { type: "number", default: 1048576 },
1745
+ },
1746
+ required: ["query"],
1747
+ },
1748
+ },
1749
+ {
1750
+ name: "replace_text",
1751
+ description: "Replace text inside one file. Protected connector files are blocked.",
1752
+ inputSchema: { type: "object", properties: { path: { type: "string" }, cwd: { type: "string" }, search: { type: "string" }, replace: { type: "string" }, regex: { type: "boolean", default: false }, all: { type: "boolean", default: true }, encoding: { type: "string", default: "utf8" } }, required: ["path", "search", "replace"] },
1753
+ },
1754
+ {
1755
+ name: "run_command",
1756
+ description: "Run a shell command and return stdout/stderr. sudo, kill/system control, and protected connector paths are blocked.",
1757
+ inputSchema: { type: "object", properties: { command: { type: "string" }, cwd: { type: "string" }, timeout: { type: "number", default: COMMAND_TIMEOUT_MS }, env: { type: "object" } }, required: ["command"] },
1758
+ },
1759
+ {
1760
+ name: "start_process",
1761
+ description: "Start a long-running managed shell process and return a process ID for polling.",
1762
+ inputSchema: { type: "object", properties: { command: { type: "string" }, cwd: { type: "string" }, env: { type: "object" } }, required: ["command"] },
1763
+ },
1764
+ {
1765
+ name: "read_process",
1766
+ description: "Read stdout/stderr and status for a managed process started by start_process.",
1767
+ inputSchema: { type: "object", properties: { processId: { type: "string" } }, required: ["processId"] },
1768
+ },
1769
+ {
1770
+ name: "stop_process",
1771
+ description: "Stop only a managed child process. It cannot stop the connector server or protected port.",
1772
+ inputSchema: { type: "object", properties: { processId: { type: "string" }, signal: { type: "string", default: "SIGTERM" } }, required: ["processId"] },
1773
+ },
1774
+ {
1775
+ name: "list_processes",
1776
+ description: "List managed processes started through this connector.",
1777
+ inputSchema: { type: "object", properties: {} },
1778
+ },
1779
+ {
1780
+ name: "list_system_processes",
1781
+ description: "List top OS processes using ps for observability.",
1782
+ inputSchema: { type: "object", properties: { limit: { type: "number", default: 30 } } },
1783
+ },
1784
+ {
1785
+ name: "environment_list",
1786
+ description: "List environment variables. Values are hidden by default and secrets are redacted unless explicitly revealed.",
1787
+ inputSchema: { type: "object", properties: { filter: { type: "string" }, includeValues: { type: "boolean", default: false }, revealSecrets: { type: "boolean", default: false } } },
1788
+ },
1789
+ {
1790
+ name: "environment_get",
1791
+ description: "Get one environment variable. Secret-looking names are redacted unless revealSecret is true.",
1792
+ inputSchema: { type: "object", properties: { name: { type: "string" }, revealSecret: { type: "boolean", default: false } }, required: ["name"] },
1793
+ },
1794
+ {
1795
+ name: "environment_set",
1796
+ description: "Set an environment variable for the connector process and optionally append an export line to a non-protected file.",
1797
+ inputSchema: { type: "object", properties: { name: { type: "string" }, value: { type: "string" }, persistFile: { type: "string" }, cwd: { type: "string" } }, required: ["name", "value"] },
1798
+ },
1799
+ {
1800
+ name: "http_request",
1801
+ description: "Make an HTTP/HTTPS request and return status, headers, and a truncated body. Cloud metadata endpoints are blocked.",
1802
+ inputSchema: { type: "object", properties: { url: { type: "string" }, method: { type: "string", default: "GET" }, headers: { type: "object" }, body: { type: "string" }, timeout: { type: "number", default: 15000 } }, required: ["url"] },
1803
+ },
1804
+ {
1805
+ name: "port_check",
1806
+ description: "Check whether a TCP host:port is open.",
1807
+ inputSchema: { type: "object", properties: { host: { type: "string", default: "127.0.0.1" }, port: { type: "number", default: PORT } } },
1808
+ },
1809
+ {
1810
+ name: "git_status",
1811
+ description: "Run git status --short --branch in a repository.",
1812
+ inputSchema: { type: "object", properties: { path: { type: "string", default: "." }, cwd: { type: "string" } } },
1813
+ },
1814
+ {
1815
+ name: "git_diff",
1816
+ description: "Return git diff output for a repository, optionally staged or limited to a file.",
1817
+ inputSchema: { type: "object", properties: { path: { type: "string", default: "." }, cwd: { type: "string" }, staged: { type: "boolean", default: false }, file: { type: "string" } } },
1818
+ },
1819
+ {
1820
+ name: "open_url",
1821
+ description: "Open a URL in the host browser when the Codespaces BROWSER helper is available.",
1822
+ inputSchema: { type: "object", properties: { url: { type: "string" } }, required: ["url"] },
1823
+ },
1824
+
1825
+ // ─── agentic coding tools ───────────────────────────────────────────────────
1826
+ {
1827
+ name: "read_file_lines",
1828
+ description: "Read a specific line range from a file (1-based, inclusive). Use this instead of read_file when you only need part of a large file — avoids loading thousands of lines unnecessarily.",
1829
+ inputSchema: {
1830
+ type: "object",
1831
+ properties: {
1832
+ path: { type: "string" },
1833
+ cwd: { type: "string" },
1834
+ startLine: { type: "number", description: "First line to return (1-based)", default: 1 },
1835
+ endLine: { type: "number", description: "Last line to return (1-based, inclusive). Omit to read to end of file." },
1836
+ },
1837
+ required: ["path"],
1838
+ },
1839
+ },
1840
+ {
1841
+ name: "patch_file",
1842
+ description: "Apply multiple search-and-replace patches to a file in a single call. More efficient than calling replace_text many times. Patches are applied in order.",
1843
+ inputSchema: {
1844
+ type: "object",
1845
+ properties: {
1846
+ path: { type: "string" },
1847
+ cwd: { type: "string" },
1848
+ encoding: { type: "string", default: "utf8" },
1849
+ patches: {
1850
+ type: "array",
1851
+ description: "Ordered list of patches to apply",
1852
+ items: {
1853
+ type: "object",
1854
+ properties: {
1855
+ search: { type: "string", description: "Exact text or regex pattern to find" },
1856
+ replace: { type: "string", description: "Replacement text (supports $1 back-references if regex)" },
1857
+ regex: { type: "boolean", default: false },
1858
+ all: { type: "boolean", default: true, description: "Replace all occurrences (true) or only the first (false)" },
1859
+ },
1860
+ required: ["search", "replace"],
1861
+ },
1862
+ },
1863
+ },
1864
+ required: ["path", "patches"],
1865
+ },
1866
+ },
1867
+ {
1868
+ name: "read_many_files",
1869
+ description: "Read multiple files at once and return an array of their contents. Much faster than calling read_file individually for each file. Failed reads are reported per-file.",
1870
+ inputSchema: {
1871
+ type: "object",
1872
+ properties: {
1873
+ paths: { type: "array", items: { type: "string" }, description: "List of file paths to read" },
1874
+ cwd: { type: "string" },
1875
+ encoding: { type: "string", default: "utf8" },
1876
+ maxBytes: { type: "number", description: "Max bytes returned per file" },
1877
+ },
1878
+ required: ["paths"],
1879
+ },
1880
+ },
1881
+ {
1882
+ name: "write_many_files",
1883
+ description: "Write multiple files at once, creating parent directories as needed. Ideal for scaffolding entire project structures or applying coordinated multi-file changes in one step.",
1884
+ inputSchema: {
1885
+ type: "object",
1886
+ properties: {
1887
+ files: {
1888
+ type: "array",
1889
+ description: "List of files to write",
1890
+ items: {
1891
+ type: "object",
1892
+ properties: {
1893
+ path: { type: "string", description: "File path (relative or absolute)" },
1894
+ content: { type: "string", description: "File content to write" },
1895
+ encoding: { type: "string", default: "utf8" },
1896
+ },
1897
+ required: ["path", "content"],
1898
+ },
1899
+ },
1900
+ cwd: { type: "string" },
1901
+ encoding: { type: "string", default: "utf8", description: "Default encoding for all files (can be overridden per file)" },
1902
+ },
1903
+ required: ["files"],
1904
+ },
1905
+ },
1906
+ {
1907
+ name: "git_log",
1908
+ description: "Get git commit history with hash, author, date, and subject. Optionally filter to commits that touched a specific file.",
1909
+ inputSchema: {
1910
+ type: "object",
1911
+ properties: {
1912
+ path: { type: "string", default: ".", description: "Repository path" },
1913
+ cwd: { type: "string" },
1914
+ limit: { type: "number", default: 20, description: "Maximum number of commits to return (max 200)" },
1915
+ file: { type: "string", description: "Filter to commits that modified this file" },
1916
+ },
1917
+ },
1918
+ },
1919
+ {
1920
+ name: "git_commit",
1921
+ description: "Stage files and create a git commit. Stages '.' (all changes) by default. Use add to specify exact files or patterns.",
1922
+ inputSchema: {
1923
+ type: "object",
1924
+ properties: {
1925
+ path: { type: "string", default: ".", description: "Repository path" },
1926
+ cwd: { type: "string" },
1927
+ message: { type: "string", description: "Commit message" },
1928
+ add: {
1929
+ description: "Files or patterns to stage. Use '.' for all changes. Defaults to ['.'].",
1930
+ oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
1931
+ },
1932
+ noVerify: { type: "boolean", default: false, description: "Skip pre-commit hooks (--no-verify)" },
1933
+ },
1934
+ required: ["message"],
1935
+ },
1936
+ },
1937
+ {
1938
+ name: "git_branch",
1939
+ description: "List, create, switch, or delete git branches. Use action='list' to see all branches and the current one.",
1940
+ inputSchema: {
1941
+ type: "object",
1942
+ properties: {
1943
+ path: { type: "string", default: ".", description: "Repository path" },
1944
+ cwd: { type: "string" },
1945
+ action: { type: "string", enum: ["list", "create", "switch", "delete"], default: "list" },
1946
+ name: { type: "string", description: "Branch name (required for create, switch, delete)" },
1947
+ from: { type: "string", description: "Starting point for branch creation (commit, tag, or branch name)" },
1948
+ force: { type: "boolean", default: false, description: "Force delete even if branch has unmerged commits (-D)" },
1949
+ },
1950
+ required: ["action"],
1951
+ },
1952
+ },
1953
+ {
1954
+ name: "find_replace_all",
1955
+ description: "Search and replace text across every matching file in a directory tree. Returns a list of changed files. Skip node_modules/.git/dist automatically. Great for renaming symbols or updating imports across a codebase.",
1956
+ inputSchema: {
1957
+ type: "object",
1958
+ properties: {
1959
+ directory: { type: "string", default: ".", description: "Root directory to scan" },
1960
+ cwd: { type: "string" },
1961
+ search: { type: "string", description: "Text or regex pattern to find" },
1962
+ replace: { type: "string", description: "Replacement text" },
1963
+ regex: { type: "boolean", default: false, description: "Treat search as a regular expression" },
1964
+ caseSensitive: { type: "boolean", default: false },
1965
+ extensions: {
1966
+ description: "Limit to files with these extensions, e.g. [\".js\",\".ts\"] or \".py\"",
1967
+ oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
1968
+ },
1969
+ ignore: { type: "array", items: { type: "string" }, description: "Additional directory names to skip" },
1970
+ depth: { type: "number", default: 10, description: "Max directory depth to traverse" },
1971
+ maxFiles: { type: "number", default: 500, description: "Max number of files to change" },
1972
+ },
1973
+ required: ["search", "replace"],
1974
+ },
1975
+ },
1976
+ {
1977
+ name: "run_script",
1978
+ description: "Execute a code snippet inline without creating a permanent file. Supports Node.js, Python, PowerShell, Bash, and sh. Perfect for quick calculations, data transformations, testing snippets, or running automation logic.",
1979
+ inputSchema: {
1980
+ type: "object",
1981
+ properties: {
1982
+ language: { type: "string", enum: ["node", "python", "powershell", "bash", "sh"], description: "Runtime to use" },
1983
+ code: { type: "string", description: "Source code to execute" },
1984
+ args: { type: "array", items: { type: "string" }, description: "Command-line arguments passed to the script" },
1985
+ cwd: { type: "string", description: "Working directory for the script" },
1986
+ env: { type: "object", description: "Extra environment variables for the script" },
1987
+ timeout: { type: "number", default: 30000, description: "Timeout in milliseconds (max 5 minutes)" },
1988
+ },
1989
+ required: ["language", "code"],
1990
+ },
1991
+ },
1992
+ {
1993
+ name: "send_to_process",
1994
+ description: "Send text to the stdin of a running managed process (started via start_process). Use to interact with interactive programs like REPLs, shells, or CLI prompts.",
1995
+ inputSchema: {
1996
+ type: "object",
1997
+ properties: {
1998
+ processId: { type: "string", description: "Process ID returned by start_process" },
1999
+ input: { type: "string", description: "Text to send to the process stdin" },
2000
+ newline: { type: "boolean", default: true, description: "Append a newline after the input (simulates pressing Enter)" },
2001
+ },
2002
+ required: ["processId", "input"],
2003
+ },
2004
+ },
2005
+ {
2006
+ name: "list_installed_packages",
2007
+ description: "Read installed package manifests for a project directory. Detects npm (package.json), pip (requirements.txt / pyproject.toml), and Cargo (Cargo.toml) automatically.",
2008
+ inputSchema: {
2009
+ type: "object",
2010
+ properties: {
2011
+ path: { type: "string", default: ".", description: "Project directory to inspect" },
2012
+ cwd: { type: "string" },
2013
+ type: { type: "string", enum: ["auto", "npm", "pip", "cargo"], default: "auto", description: "Package manager to query (auto detects all)" },
2014
+ },
2015
+ },
2016
+ },
2017
+ {
2018
+ name: "hash_file",
2019
+ description: "Compute a cryptographic hash (SHA-256 by default) of a file. Useful for verifying integrity, detecting changes, or comparing files without reading their full contents.",
2020
+ inputSchema: {
2021
+ type: "object",
2022
+ properties: {
2023
+ path: { type: "string" },
2024
+ cwd: { type: "string" },
2025
+ algorithm: { type: "string", enum: ["sha256", "sha1", "md5", "sha512"], default: "sha256" },
2026
+ },
2027
+ required: ["path"],
2028
+ },
2029
+ },
2030
+ {
2031
+ name: "count_lines",
2032
+ description: "Count lines, words, and characters in a file without loading its full content. Use this to gauge file size before deciding how to read it (e.g., whether to use read_file_lines for a large file).",
2033
+ inputSchema: {
2034
+ type: "object",
2035
+ properties: {
2036
+ path: { type: "string" },
2037
+ cwd: { type: "string" },
2038
+ },
2039
+ required: ["path"],
2040
+ },
2041
+ },
2042
+
2043
+ // ── advanced tools ──────────────────────────────────────────────────────────
2044
+ {
2045
+ name: "diff_files",
2046
+ description: "Compare two text files and return a unified-style diff showing added (+) and removed (-) lines with surrounding context. Returns identical:true if files are equal. Perfect for reviewing changes before committing, or validating a transformation was applied correctly.",
2047
+ inputSchema: {
2048
+ type: "object",
2049
+ properties: {
2050
+ pathA: { type: "string", description: "First file (original / before)" },
2051
+ pathB: { type: "string", description: "Second file (modified / after)" },
2052
+ cwd: { type: "string" },
2053
+ },
2054
+ required: ["pathA", "pathB"],
2055
+ },
2056
+ },
2057
+ {
2058
+ name: "clipboard_read",
2059
+ description: "Read the current system clipboard contents as text. Works on Windows (PowerShell Get-Clipboard), macOS (pbpaste), and Linux (xclip/xsel). Useful for ingesting user-copied content without needing a file path.",
2060
+ inputSchema: { type: "object", properties: {} },
2061
+ },
2062
+ {
2063
+ name: "clipboard_write",
2064
+ description: "Write text to the system clipboard. Works on Windows (PowerShell Set-Clipboard), macOS (pbcopy), and Linux (xclip). Useful for sharing generated content, tokens, or results directly to the user's clipboard.",
2065
+ inputSchema: {
2066
+ type: "object",
2067
+ properties: {
2068
+ text: { type: "string", description: "Text to write to clipboard" },
2069
+ },
2070
+ required: ["text"],
2071
+ },
2072
+ },
2073
+ {
2074
+ name: "network_info",
2075
+ description: "List all network interfaces with their IPv4/IPv6 addresses, netmasks, CIDR notation, MAC addresses, and whether they are internal loopback adapters. Useful for checking available interfaces or what IP the machine is reachable on.",
2076
+ inputSchema: { type: "object", properties: {} },
2077
+ },
2078
+ {
2079
+ name: "json_query",
2080
+ description: "Read a JSON file and extract a value using a dot-path query (e.g. 'user.name', 'items[0].price', 'settings.theme'). Use '.' to return the full parsed document. Returns the result with its type.",
2081
+ inputSchema: {
2082
+ type: "object",
2083
+ properties: {
2084
+ path: { type: "string", description: "Path to the JSON file" },
2085
+ cwd: { type: "string" },
2086
+ query: { type: "string", default: ".", description: "Dot-path query, e.g. 'user.address.city' or 'items[2].name'. Use '.' for the full document." },
2087
+ },
2088
+ required: ["path"],
2089
+ },
2090
+ },
2091
+ {
2092
+ name: "archive_create",
2093
+ description: "Create a zip or tar.gz archive from one or more files/directories. On Windows uses PowerShell Compress-Archive (zip only). On Unix uses zip or tar. Destination file extension determines format (.zip → zip, anything else → tar.gz).",
2094
+ inputSchema: {
2095
+ type: "object",
2096
+ properties: {
2097
+ sources: {
2098
+ description: "File or directory paths to include (string or array of strings)",
2099
+ oneOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
2100
+ },
2101
+ destination: { type: "string", description: "Output archive path, e.g. 'backup.zip' or 'dist.tar.gz'" },
2102
+ cwd: { type: "string" },
2103
+ },
2104
+ required: ["sources", "destination"],
2105
+ },
2106
+ },
2107
+ {
2108
+ name: "archive_extract",
2109
+ description: "Extract a zip or tar archive to a destination directory. On Windows uses PowerShell Expand-Archive. On Unix uses unzip or tar. Format is auto-detected from the file extension. Destination directory is created if it does not exist.",
2110
+ inputSchema: {
2111
+ type: "object",
2112
+ properties: {
2113
+ path: { type: "string", description: "Archive file to extract (.zip, .tar.gz, .tar.bz2, etc.)" },
2114
+ destination: { type: "string", description: "Directory to extract files into (created if missing)" },
2115
+ cwd: { type: "string" },
2116
+ },
2117
+ required: ["path", "destination"],
2118
+ },
2119
+ },
2120
+ {
2121
+ name: "generate_token",
2122
+ description: "Generate a cryptographically secure random token, UUID, or password using Node.js crypto. Types: hex (default, lowercase hex string), uuid (RFC 4122 v4), base64 (URL-safe base64), alphanumeric (A-Z a-z 0-9). Use for API keys, session tokens, test data, secure identifiers, or one-time codes.",
2123
+ inputSchema: {
2124
+ type: "object",
2125
+ properties: {
2126
+ type: { type: "string", enum: ["hex", "uuid", "base64", "alphanumeric"], default: "hex", description: "Token format" },
2127
+ length: { type: "number", default: 32, description: "Character length of the token (1–512). Ignored for uuid type." },
2128
+ },
2129
+ },
2130
+ },
2131
+ ];
2132
+
2133
+ async function handleMcpMessage(message, req) {
2134
+ const { id, method, params } = message || {};
2135
+
2136
+ if (!method) {
2137
+ return { jsonrpc: "2.0", id: id ?? null, error: { code: -32600, message: "Invalid request" } };
2138
+ }
2139
+
2140
+ if (method === "initialize") {
2141
+ const clientName = params?.clientInfo?.name || params?.clientInfo?.title || "unknown client";
2142
+ const clientVer = params?.clientInfo?.version || "";
2143
+ console.log(`[MCP] initialize ← ${clientName}${clientVer ? ` v${clientVer}` : ""}`);
2144
+ return {
2145
+ jsonrpc: "2.0",
2146
+ id,
2147
+ result: {
2148
+ protocolVersion: PROTOCOL_VERSION,
2149
+ capabilities: { tools: { listChanged: false } },
2150
+ serverInfo: { name: SERVER_ID, title: SERVER_NAME, version: VERSION },
2151
+ instructions:
2152
+ `ChatGPT Local MCP v${VERSION} — 53 tools for agentic coding and system control on Windows/Linux/macOS.\n\n`
2153
+ + `SCOPE: ${BYPASS_MODE ? "BYPASS MODE — full filesystem access." : `Folder-scoped — restricted to: ${ACCESS_ROOT}`}\n\n`
2154
+ + "CODING WORKFLOW:\n"
2155
+ + "• count_lines → read_file_lines: gauge a large file then read only the range you need.\n"
2156
+ + "• patch_file: apply multiple surgical edits to one file in a single call (faster than replace_text × N).\n"
2157
+ + "• diff_files: compare two files and review changes before committing.\n"
2158
+ + "• write_many_files: scaffold an entire project or apply coordinated multi-file changes atomically.\n"
2159
+ + "• read_many_files: load several source files at once for review or refactoring.\n"
2160
+ + "• run_script: execute Node.js, Python, or PowerShell snippets inline without creating permanent files.\n"
2161
+ + "• find_replace_all: rename a symbol or update imports across an entire codebase in one shot.\n"
2162
+ + "• json_query: extract values from JSON files with dot-path queries like 'user.name' or 'items[0].id'.\n"
2163
+ + "• generate_token: generate secure tokens, UUIDs, or random passwords.\n"
2164
+ + "• clipboard_read / clipboard_write: share content with the user's clipboard.\n"
2165
+ + "• archive_create / archive_extract: create and unpack zip archives.\n"
2166
+ + "• git_log / git_branch / git_commit: full version-control workflow.\n"
2167
+ + "• start_process + send_to_process + read_process: drive interactive CLIs and long-running servers.\n\n"
2168
+ + "SAFETY: Connector runtime files are write-protected. sudo / kill / taskkill commands are blocked."
2169
+ },
2170
+ };
2171
+ }
2172
+
2173
+ if (method === "notifications/initialized") return null;
2174
+ if (method === "ping") return { jsonrpc: "2.0", id, result: {} };
2175
+
2176
+ if (method === "tools/list") {
2177
+ console.log(`[MCP] tools/list → ${tools.length} tools`);
2178
+ return { jsonrpc: "2.0", id, result: { tools } };
2179
+ }
2180
+
2181
+ if (method === "tools/call") {
2182
+ const toolName = params?.name;
2183
+ const toolArgs = params?.arguments || {};
2184
+ // Build a compact args summary (redact long values)
2185
+ const argSummary = Object.entries(toolArgs)
2186
+ .map(([k, v]) => {
2187
+ const s = typeof v === "object" ? JSON.stringify(v) : String(v ?? "");
2188
+ return `${k}=${s.length > 60 ? s.slice(0, 57) + "…" : s}`;
2189
+ })
2190
+ .join(", ");
2191
+ console.log(`[TOOL] ▶ ${toolName}${argSummary ? ` {${argSummary}}` : ""}`);
2192
+ const t0 = Date.now();
2193
+ try {
2194
+ const result = await callTool(toolName, toolArgs, req);
2195
+ const ms = Date.now() - t0;
2196
+ recordToolStat(toolName, ms, false);
2197
+ // Show a brief result summary (first 120 chars of JSON)
2198
+ const preview = JSON.stringify(result);
2199
+ const short = preview.length > 120 ? preview.slice(0, 117) + "…" : preview;
2200
+ console.log(`[TOOL] ✓ ${toolName} (${ms}ms) → ${short}`);
2201
+ return { jsonrpc: "2.0", id, result: textResult(result) };
2202
+ } catch (error) {
2203
+ const ms = Date.now() - t0;
2204
+ recordToolStat(toolName, ms, true);
2205
+ console.error(`[TOOL] ✗ ${toolName} (${ms}ms) → ${error.message}`);
2206
+ return {
2207
+ jsonrpc: "2.0",
2208
+ id,
2209
+ result: {
2210
+ isError: true,
2211
+ content: [{ type: "text", text: error.message }],
2212
+ },
2213
+ };
2214
+ }
2215
+ }
2216
+
2217
+ if (method === "resources/list") return { jsonrpc: "2.0", id, result: { resources: [] } };
2218
+ if (method === "prompts/list") return { jsonrpc: "2.0", id, result: { prompts: [] } };
2219
+
2220
+ return { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } };
2221
+ }
2222
+
2223
+ async function handleMcpRequest(req, res) {
2224
+ try {
2225
+ const body = req.body;
2226
+ if (Array.isArray(body)) {
2227
+ const responses = (await Promise.all(body.map((message) => handleMcpMessage(message, req)))).filter(Boolean);
2228
+ if (!responses.length) return res.status(202).end();
2229
+ return res.json(responses);
2230
+ }
2231
+
2232
+ const response = await handleMcpMessage(body, req);
2233
+ if (!response) return res.status(202).end();
2234
+ return res.json(response);
2235
+ } catch (error) {
2236
+ return res.status(500).json({ jsonrpc: "2.0", id: null, error: { code: -32000, message: error.message } });
2237
+ }
2238
+ }
2239
+
2240
+ function dashboardHtml(req) {
2241
+ const base = publicBaseUrl(req);
2242
+ const mcpUrl = `${base}/mcp`;
2243
+ const cards = [
2244
+ ["Tools", tools.length, "filesystem, shell, processes, git, network"],
2245
+ ["Access root", ACCESS_ROOT, "absolute paths allowed inside this root"],
2246
+ ["Default cwd", DEFAULT_CWD, "relative file paths start here"],
2247
+ ["Protection", `${PROTECTED_PATHS.length} paths`, "runtime files and startup script guarded"],
2248
+ ];
2249
+
2250
+ return `<!doctype html>
2251
+ <html lang="en">
2252
+ <head>
2253
+ <meta charset="utf-8" />
2254
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2255
+ <title>${SERVER_NAME}</title>
2256
+ <style>
2257
+ :root { color-scheme: dark; --bg:#070b18; --panel:#111832cc; --panel2:#0e1428; --text:#eef4ff; --muted:#9db1d6; --line:#253154; --brand:#7c5cff; --accent:#00d4ff; --ok:#2ff0a2; --warn:#ffd166; }
2258
+ *{box-sizing:border-box} body{margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Arial;background:radial-gradient(circle at top left,#24306f55,transparent 32rem),radial-gradient(circle at top right,#0abbd455,transparent 28rem),var(--bg);color:var(--text);min-height:100vh}.wrap{width:min(1120px,92vw);margin:0 auto;padding:48px 0}.hero{border:1px solid var(--line);background:linear-gradient(145deg,#101832dd,#090e1ddd);border-radius:28px;padding:34px;box-shadow:0 24px 90px #0008;position:relative;overflow:hidden}.hero:before{content:"";position:absolute;inset:-2px;background:linear-gradient(120deg,var(--brand),transparent,var(--accent));opacity:.18;filter:blur(28px)}.hero>*{position:relative}.badge{display:inline-flex;gap:8px;align-items:center;border:1px solid #37507a;background:#10203d;padding:8px 12px;border-radius:999px;color:#bfe9ff;font-weight:700;font-size:13px}.dot{width:9px;height:9px;border-radius:50%;background:var(--ok);box-shadow:0 0 18px var(--ok)}h1{font-size:clamp(38px,6vw,78px);line-height:.95;margin:22px 0 14px;letter-spacing:-.07em}.sub{font-size:19px;line-height:1.65;color:var(--muted);max-width:820px}.actions{display:flex;flex-wrap:wrap;gap:14px;margin-top:26px}.pill{border:1px solid var(--line);background:#10172c;padding:14px 16px;border-radius:16px;color:var(--text);text-decoration:none}.primary{background:linear-gradient(135deg,var(--brand),#13b9ff);border:0;font-weight:800}.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:22px 0}.card{border:1px solid var(--line);background:var(--panel);border-radius:22px;padding:20px;min-height:150px}.label{color:var(--muted);font-size:13px;text-transform:uppercase;letter-spacing:.12em}.value{font-size:24px;font-weight:850;overflow-wrap:anywhere;margin:12px 0}.desc{color:var(--muted);line-height:1.45}.panel{border:1px solid var(--line);background:var(--panel);border-radius:22px;padding:22px;margin-top:16px}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}pre{white-space:pre-wrap;background:#060a14;border:1px solid #202a48;border-radius:18px;padding:18px;color:#d7e7ff;overflow:auto}.tools{display:flex;flex-wrap:wrap;gap:8px}.tool{padding:7px 10px;border-radius:999px;background:#17213f;border:1px solid #2a3a66;color:#cbd9ff;font-size:13px}@media (max-width:900px){.grid{grid-template-columns:1fr 1fr}}@media (max-width:620px){.grid{grid-template-columns:1fr}.wrap{padding:22px 0}.hero{padding:24px}h1{font-size:42px}}
2259
+ </style>
2260
+ </head>
2261
+ <body>
2262
+ <main class="wrap">
2263
+ <section class="hero">
2264
+ <div class="badge"><span class="dot"></span> Running on port ${PORT}</div>
2265
+ <h1>ChatGPT Local MCP</h1>
2266
+ <p class="sub">A protected desktop-style bridge for ChatGPT/MCP clients. It exposes advanced filesystem, terminal, process, git, network, and system tools while guarding the connector runtime from tool calls.</p>
2267
+ <div class="actions">
2268
+ <a class="pill primary" href="${mcpUrl}">MCP endpoint</a>
2269
+ <a class="pill" href="/health">Health JSON</a>
2270
+ </div>
2271
+ </section>
2272
+
2273
+ <section class="grid">
2274
+ ${cards
2275
+ .map(
2276
+ ([label, value, desc]) => `<article class="card"><div class="label">${label}</div><div class="value">${value}</div><div class="desc">${desc}</div></article>`
2277
+ )
2278
+ .join("")}
2279
+ </section>
2280
+
2281
+ <section class="panel">
2282
+ <div class="label">ChatGPT MCP URL</div>
2283
+ <pre>${mcpUrl}</pre>
2284
+ <p class="desc">Paste this URL in ChatGPT &rarr; Settings &rarr; Connectors &rarr; New App. Set Authentication to <strong>No Auth</strong>.</p>
2285
+ </section>
2286
+
2287
+ <section class="panel">
2288
+ <div class="label">Tool catalog</div>
2289
+ <div class="tools">${tools.map((tool) => `<span class="tool">${tool.name}</span>`).join("")}</div>
2290
+ </section>
2291
+ </main>
2292
+ </body>
2293
+ </html>`;
2294
+ }
2295
+
2296
+ app.get("/", (req, res) => res.type("html").send(dashboardHtml(req)));
2297
+ app.get("/logs/live", (req, res) => {
2298
+ res.setHeader("Content-Type", "text/event-stream");
2299
+ res.setHeader("Cache-Control", "no-cache");
2300
+ res.setHeader("Connection", "keep-alive");
2301
+ res.flushHeaders?.();
2302
+
2303
+ liveLogClients.add(res);
2304
+
2305
+ for (const log of runtimeLogs.slice(-100)) {
2306
+ res.write(`data: ${JSON.stringify(log)}\n\n`);
2307
+ }
2308
+
2309
+ req.on("close", () => {
2310
+ liveLogClients.delete(res);
2311
+ });
2312
+ });
2313
+
2314
+ app.get("/health", (req, res) => {
2315
+ res.json({
2316
+ status: "ok",
2317
+ name: SERVER_NAME,
2318
+ id: SERVER_ID,
2319
+ version: VERSION,
2320
+ port: PORT,
2321
+ mcpEndpoint: `${publicBaseUrl(req)}/mcp`,
2322
+ accessRoot: ACCESS_ROOT,
2323
+ defaultCwd: DEFAULT_CWD,
2324
+ protectedPathCount: PROTECTED_PATHS.length,
2325
+ tools: tools.length,
2326
+ uptimeSeconds: process.uptime(),
2327
+ });
2328
+ });
2329
+
2330
+ app.get("/robots.txt", (_req, res) => res.type("text").send("User-agent: *\nDisallow: /\n"));
2331
+ app.get("/tools", (_req, res) => res.json({ tools }));
2332
+ app.get("/stats", (_req, res) => {
2333
+ const topTools = Array.from(toolStats.entries())
2334
+ .sort((a, b) => b[1].calls - a[1].calls)
2335
+ .slice(0, 15)
2336
+ .map(([tName, s]) => ({
2337
+ name: tName,
2338
+ calls: s.calls,
2339
+ errors: s.errors,
2340
+ avgMs: s.calls > 0 ? Math.round(s.totalMs / s.calls) : 0,
2341
+ lastCalled: s.lastCalled,
2342
+ }));
2343
+ res.json({
2344
+ status: "ok",
2345
+ sessionStart: sessionStart.toISOString(),
2346
+ uptimeSeconds: Math.floor(process.uptime()),
2347
+ totalCalls,
2348
+ totalErrors,
2349
+ totalTools: tools.length,
2350
+ activeProcesses: runningProcesses.size,
2351
+ liveLogClients: liveLogClients.size,
2352
+ topTools,
2353
+ });
2354
+ });
2355
+ app.post("/mcp", handleMcpRequest);
2356
+ app.post("/sse", handleMcpRequest);
2357
+ app.post("/execute", async (req, res) => {
2358
+ try {
2359
+ const toolName = req.body.tool || req.body.name;
2360
+ const args = { ...req.body };
2361
+ delete args.tool;
2362
+ delete args.name;
2363
+ const result = await callTool(toolName, args, req);
2364
+ return res.json({ success: true, result });
2365
+ } catch (error) {
2366
+ return res.status(400).json({ success: false, error: error.message });
2367
+ }
2368
+ });
2369
+
2370
+ function sseHandler(_req, res) {
2371
+ const ip =
2372
+ (_req.headers["x-forwarded-for"] || "").split(",")[0].trim() ||
2373
+ _req.socket?.remoteAddress ||
2374
+ "?";
2375
+ console.log(`[SSE] client connected [${ip}]`);
2376
+ res.setHeader("Content-Type", "text/event-stream");
2377
+ res.setHeader("Cache-Control", "no-cache");
2378
+ res.setHeader("Connection", "keep-alive");
2379
+ res.write(`event: ready\ndata: ${JSON.stringify({ name: SERVER_ID, version: VERSION, mcp: "/mcp" })}\n\n`);
2380
+ const timer = setInterval(() => {
2381
+ res.write(`event: ping\ndata: ${JSON.stringify({ time: new Date().toISOString() })}\n\n`);
2382
+ }, 25000);
2383
+ res.on("close", () => {
2384
+ clearInterval(timer);
2385
+ console.log(`[SSE] client disconnected [${ip}]`);
2386
+ });
2387
+ }
2388
+
2389
+ app.get("/mcp", sseHandler);
2390
+ app.get("/sse", sseHandler);
2391
+
2392
+ const server = app.listen(PORT, HOST, () => {
2393
+ const localUrl = `http://localhost:${PORT}`;
2394
+ console.log(`\n╔══════════════════════════════════════════════════════════════╗`);
2395
+ console.log(`║ ${SERVER_NAME.padEnd(60)} ║`);
2396
+ console.log(`╠══════════════════════════════════════════════════════════════╣`);
2397
+ console.log(`║ Status: running ║`);
2398
+ console.log(`║ Local: ${localUrl.padEnd(53)} ║`);
2399
+ console.log(`║ MCP: ${(localUrl + "/mcp").padEnd(53)} ║`);
2400
+ console.log(`║ Tools: ${String(tools.length).padEnd(53)} ║`);
2401
+ console.log(`║ Root: ${ACCESS_ROOT.slice(0, 53).padEnd(53)} ║`);
2402
+ console.log(`║ Scope: ${(BYPASS_MODE ? "bypass (full filesystem)" : "folder-scoped").padEnd(53)} ║`);
2403
+ console.log(`╚══════════════════════════════════════════════════════════════╝\n`);
2404
+ });
2405
+
2406
+ server.on("error", (error) => {
2407
+ console.error(`Failed to start ${SERVER_NAME}:`, error.message);
2408
+ process.exitCode = 1;
2409
+ });
2410
+
2411
+ for (const signal of ["SIGINT", "SIGTERM"]) {
2412
+ process.on(signal, () => {
2413
+ console.log(`${signal} received. Shutting down connector process.`);
2414
+ server.close(() => process.exit(0));
2415
+ });
2416
+ }