@n0zer0d4y/vulcan-file-ops 1.2.14 → 1.3.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/CHANGELOG.md +62 -0
- package/README.md +64 -29
- package/dist/cli.js +16 -0
- package/dist/server/index.js +136 -15
- package/dist/tools/filesystem-tools.js +222 -39
- package/dist/tools/read-tools.js +89 -16
- package/dist/tools/shell-tool.js +48 -62
- package/dist/tools/write-tools.js +19 -17
- package/dist/types/index.js +6 -3
- package/dist/utils/command-path-extraction.js +169 -174
- package/dist/utils/command-validation.js +62 -10
- package/dist/utils/document-parser.js +43 -5
- package/dist/utils/html-image-sanitizer.js +478 -0
- package/dist/utils/html-to-document.js +75 -11
- package/dist/utils/lib.js +357 -80
- package/dist/utils/limits.js +47 -0
- package/dist/utils/regex-worker.js +262 -0
- package/dist/utils/shell-parser.js +225 -0
- package/dist/utils/zip-guard.js +266 -0
- package/package.json +11 -8
|
@@ -1,14 +1,67 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
|
+
import os from "os";
|
|
2
3
|
import path from "path";
|
|
3
4
|
import { minimatch } from "minimatch";
|
|
4
5
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
5
6
|
import { ToolSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
6
7
|
import { expandHome, normalizePath } from "../utils/path-utils.js";
|
|
7
|
-
import { isPathWithinAllowedDirectories } from "../utils/path-validation.js";
|
|
8
8
|
import { MakeDirectoryArgsSchema, ListDirectoryArgsSchema, MoveFileArgsSchema, GetFileInfoArgsSchema, RegisterDirectoryArgsSchema, FileOperationsArgsSchema, DeleteFilesArgsSchema, } from "../types/index.js";
|
|
9
|
-
import { validatePath, getFileStats, formatSize, getAllowedDirectories, setAllowedDirectories, shouldIgnoreFolder, } from "../utils/lib.js";
|
|
9
|
+
import { validatePath, getFileStats, formatSize, getAllowedDirectories, setAllowedDirectories, shouldIgnoreFolder, isPathCanonicallyAllowed, ensureDirectoryWithinAllowed, } from "../utils/lib.js";
|
|
10
10
|
import { createEmptyObjectSchema, createPathArraySchema, sanitizeToolInputSchema, } from "../utils/tool-schema.js";
|
|
11
11
|
const ToolInputSchema = ToolSchema.shape.inputSchema;
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// RUNTIME DIRECTORY REGISTRATION POLICY (register_directory)
|
|
14
|
+
// ============================================================================
|
|
15
|
+
//
|
|
16
|
+
// Security (VFO-07): register_directory widens the sandbox, so by default it
|
|
17
|
+
// requires a human to confirm each registration through the MCP client
|
|
18
|
+
// (elicitation). The server entry point installs the consent handler; when
|
|
19
|
+
// none is installed (or the client cannot prompt), registration is refused.
|
|
20
|
+
export const RUNTIME_REGISTRATION_POLICIES = [
|
|
21
|
+
"confirm",
|
|
22
|
+
"allow",
|
|
23
|
+
"deny",
|
|
24
|
+
];
|
|
25
|
+
let runtimeRegistrationPolicy = "confirm";
|
|
26
|
+
let directoryRegistrationConsentHandler = null;
|
|
27
|
+
export function isRuntimeRegistrationPolicy(value) {
|
|
28
|
+
return RUNTIME_REGISTRATION_POLICIES.includes(value);
|
|
29
|
+
}
|
|
30
|
+
export function setRuntimeRegistrationPolicy(policy) {
|
|
31
|
+
if (!isRuntimeRegistrationPolicy(policy)) {
|
|
32
|
+
throw new Error(`Invalid runtime registration policy: ${policy}. Expected one of: ${RUNTIME_REGISTRATION_POLICIES.join(", ")}`);
|
|
33
|
+
}
|
|
34
|
+
runtimeRegistrationPolicy = policy;
|
|
35
|
+
}
|
|
36
|
+
export function getRuntimeRegistrationPolicy() {
|
|
37
|
+
return runtimeRegistrationPolicy;
|
|
38
|
+
}
|
|
39
|
+
export function setDirectoryRegistrationConsentHandler(handler) {
|
|
40
|
+
directoryRegistrationConsentHandler = handler;
|
|
41
|
+
}
|
|
42
|
+
function samePath(a, b) {
|
|
43
|
+
const left = path.resolve(a);
|
|
44
|
+
const right = path.resolve(b);
|
|
45
|
+
return process.platform === "win32"
|
|
46
|
+
? left.toLowerCase() === right.toLowerCase()
|
|
47
|
+
: left === right;
|
|
48
|
+
}
|
|
49
|
+
async function describeSensitiveDirectory(realPath) {
|
|
50
|
+
if (path.parse(realPath).root === realPath) {
|
|
51
|
+
return "a filesystem root";
|
|
52
|
+
}
|
|
53
|
+
let home = os.homedir();
|
|
54
|
+
try {
|
|
55
|
+
home = await fs.realpath(home);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Fall back to the lexical home directory
|
|
59
|
+
}
|
|
60
|
+
if (home && samePath(realPath, home)) {
|
|
61
|
+
return "the user's home directory";
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
12
65
|
export function getFileSystemTools() {
|
|
13
66
|
// Get current allowed directories for dynamic descriptions
|
|
14
67
|
const currentAllowedDirs = getAllowedDirectories();
|
|
@@ -65,7 +118,10 @@ export function getFileSystemTools() {
|
|
|
65
118
|
name: "register_directory",
|
|
66
119
|
description: "Register a directory for access. This allows the AI to dynamically gain access " +
|
|
67
120
|
"to directories specified by the human user during conversation. The directory " +
|
|
68
|
-
"and all its subdirectories will become accessible for all filesystem operations." +
|
|
121
|
+
"and all its subdirectories will become accessible for all filesystem operations. " +
|
|
122
|
+
"By default the user must confirm each registration in their MCP client " +
|
|
123
|
+
"(registration is refused if the client cannot show a confirmation prompt); " +
|
|
124
|
+
"filesystem roots and the home directory cannot be registered at runtime." +
|
|
69
125
|
generateApprovedDirsText(),
|
|
70
126
|
inputSchema: sanitizeToolInputSchema(zodToJsonSchema(RegisterDirectoryArgsSchema)),
|
|
71
127
|
},
|
|
@@ -456,25 +512,26 @@ export async function handleFileSystemTool(name, args) {
|
|
|
456
512
|
const pathsToCreate = Array.isArray(pathsInput)
|
|
457
513
|
? pathsInput
|
|
458
514
|
: [pathsInput];
|
|
459
|
-
// Validate all paths first (atomic - fail before any creation)
|
|
460
|
-
|
|
461
|
-
|
|
515
|
+
// Validate all paths first (atomic - fail before any creation).
|
|
516
|
+
// Security: the check is canonical (lexical + realpath + physical), so a
|
|
517
|
+
// symlink or junction inside an allowed directory that points outside it
|
|
518
|
+
// is rejected here instead of being followed by mkdir (VFO-05).
|
|
519
|
+
const validatedPaths = [];
|
|
520
|
+
for (const dirPath of pathsToCreate) {
|
|
462
521
|
const expandedPath = expandHome(dirPath);
|
|
463
|
-
// Resolve to absolute path - required by isPathWithinAllowedDirectories
|
|
464
522
|
const absolutePath = path.isAbsolute(expandedPath)
|
|
465
523
|
? path.resolve(expandedPath)
|
|
466
524
|
: path.resolve(process.cwd(), expandedPath);
|
|
467
|
-
|
|
468
|
-
// Use secure path validation function to prevent prefix collision attacks
|
|
469
|
-
// (CVE-2025-54794 pattern: ensures path separator is required, not just prefix match)
|
|
470
|
-
if (!isPathWithinAllowedDirectories(normalized, allowedDirs)) {
|
|
525
|
+
if (!(await isPathCanonicallyAllowed(absolutePath, process.cwd()))) {
|
|
471
526
|
throw new Error(`Access denied: Path ${dirPath} is not within allowed directories`);
|
|
472
527
|
}
|
|
473
|
-
|
|
474
|
-
}
|
|
475
|
-
// All validated - now create them
|
|
476
|
-
|
|
477
|
-
|
|
528
|
+
validatedPaths.push({ original: dirPath, absolutePath });
|
|
529
|
+
}
|
|
530
|
+
// All validated - now create them. ensureDirectoryWithinAllowed creates
|
|
531
|
+
// missing segments one at a time and re-checks the realpath of each, so
|
|
532
|
+
// a link swapped in after validation cannot redirect creation.
|
|
533
|
+
const results = await Promise.all(validatedPaths.map(async ({ original, absolutePath }) => {
|
|
534
|
+
await ensureDirectoryWithinAllowed(absolutePath);
|
|
478
535
|
return original;
|
|
479
536
|
}));
|
|
480
537
|
// Format response based on single vs batch
|
|
@@ -506,6 +563,35 @@ export async function handleFileSystemTool(name, args) {
|
|
|
506
563
|
}
|
|
507
564
|
const validSourcePath = await validatePath(parsed.data.source);
|
|
508
565
|
const validDestPath = await validatePath(parsed.data.destination);
|
|
566
|
+
// fs.rename silently replaces an existing destination; move_file is
|
|
567
|
+
// documented to never overwrite.
|
|
568
|
+
try {
|
|
569
|
+
const destStats = await fs.lstat(validDestPath, { bigint: true });
|
|
570
|
+
const sourceStats = await fs.lstat(validSourcePath, { bigint: true });
|
|
571
|
+
const sameFile = destStats.ino === sourceStats.ino && destStats.dev === sourceStats.dev;
|
|
572
|
+
// A case-only rename on a case-insensitive file system "finds" the
|
|
573
|
+
// source itself at the destination; that is not an overwrite.
|
|
574
|
+
// validatePath returns the existing (old-case) real path, so rename to
|
|
575
|
+
// the requested name inside the same, already-validated directory.
|
|
576
|
+
if (sameFile) {
|
|
577
|
+
await fs.rename(validSourcePath, path.join(path.dirname(validDestPath), path.basename(parsed.data.destination)));
|
|
578
|
+
return {
|
|
579
|
+
content: [
|
|
580
|
+
{
|
|
581
|
+
type: "text",
|
|
582
|
+
text: `Successfully moved ${parsed.data.source} to ${parsed.data.destination}`,
|
|
583
|
+
},
|
|
584
|
+
],
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
throw new Error(`Destination already exists: ${parsed.data.destination}. ` +
|
|
588
|
+
`move_file never overwrites; use file_operations with onConflict: "overwrite" to replace it.`);
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
if (error.code !== "ENOENT") {
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
509
595
|
await fs.rename(validSourcePath, validDestPath);
|
|
510
596
|
return {
|
|
511
597
|
content: [
|
|
@@ -541,43 +627,82 @@ export async function handleFileSystemTool(name, args) {
|
|
|
541
627
|
}
|
|
542
628
|
const expandedPath = expandHome(parsed.data.path);
|
|
543
629
|
const absolutePath = path.resolve(expandedPath);
|
|
544
|
-
|
|
545
|
-
//
|
|
630
|
+
// Resolve links so the directory that is shown to the user and stored
|
|
631
|
+
// in the allowed list is the one that will actually be accessed.
|
|
632
|
+
let realPath;
|
|
546
633
|
try {
|
|
547
|
-
|
|
634
|
+
realPath = await fs.realpath(absolutePath);
|
|
635
|
+
const stats = await fs.stat(realPath);
|
|
548
636
|
if (!stats.isDirectory()) {
|
|
549
637
|
throw new Error(`Path ${absolutePath} is not a directory`);
|
|
550
638
|
}
|
|
551
639
|
}
|
|
552
640
|
catch (error) {
|
|
553
|
-
|
|
641
|
+
const code = error.code;
|
|
642
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
554
643
|
throw new Error(`Directory ${absolutePath} does not exist`);
|
|
555
644
|
}
|
|
556
645
|
throw error;
|
|
557
646
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
if (
|
|
561
|
-
setAllowedDirectories([...currentDirs, normalizedPath]);
|
|
647
|
+
const normalizedPath = normalizePath(realPath) || realPath;
|
|
648
|
+
// Already accessible: nothing to widen, so no prompt is needed
|
|
649
|
+
if (getAllowedDirectories().includes(normalizedPath)) {
|
|
562
650
|
return {
|
|
563
651
|
content: [
|
|
564
652
|
{
|
|
565
653
|
type: "text",
|
|
566
|
-
text: `
|
|
654
|
+
text: `Directory already registered: ${parsed.data.path} (${normalizedPath})`,
|
|
567
655
|
},
|
|
568
656
|
],
|
|
569
657
|
};
|
|
570
658
|
}
|
|
571
|
-
|
|
659
|
+
if (await isPathCanonicallyAllowed(realPath, process.cwd())) {
|
|
572
660
|
return {
|
|
573
661
|
content: [
|
|
574
662
|
{
|
|
575
663
|
type: "text",
|
|
576
|
-
text: `Directory already
|
|
664
|
+
text: `Directory already accessible: ${parsed.data.path} (${normalizedPath}) is inside an allowed directory`,
|
|
577
665
|
},
|
|
578
666
|
],
|
|
579
667
|
};
|
|
580
668
|
}
|
|
669
|
+
const policy = getRuntimeRegistrationPolicy();
|
|
670
|
+
if (policy === "deny") {
|
|
671
|
+
throw new Error(`Runtime directory registration is disabled on this server (--runtime-registration deny). ` +
|
|
672
|
+
`To grant access to ${normalizedPath}, add it to --approved-folders in the MCP server configuration.`);
|
|
673
|
+
}
|
|
674
|
+
if (policy !== "allow") {
|
|
675
|
+
const sensitive = await describeSensitiveDirectory(realPath);
|
|
676
|
+
if (sensitive) {
|
|
677
|
+
throw new Error(`Refusing to register ${normalizedPath}: it is ${sensitive}. ` +
|
|
678
|
+
`Register a more specific folder, add it to --approved-folders, ` +
|
|
679
|
+
`or start the server with --runtime-registration allow.`);
|
|
680
|
+
}
|
|
681
|
+
const consent = directoryRegistrationConsentHandler
|
|
682
|
+
? await directoryRegistrationConsentHandler(normalizedPath)
|
|
683
|
+
: "unsupported";
|
|
684
|
+
if (consent === "unsupported") {
|
|
685
|
+
throw new Error(`Cannot register ${normalizedPath}: registering directories at runtime requires user confirmation, ` +
|
|
686
|
+
`but this MCP client does not support confirmation prompts (MCP elicitation). ` +
|
|
687
|
+
`Add the folder via --approved-folders, or start the server with --runtime-registration allow.`);
|
|
688
|
+
}
|
|
689
|
+
if (consent !== "accepted") {
|
|
690
|
+
throw new Error(`User declined access to ${normalizedPath}`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
// Re-read: the list may have changed while waiting for the user
|
|
694
|
+
const currentDirs = getAllowedDirectories();
|
|
695
|
+
if (!currentDirs.includes(normalizedPath)) {
|
|
696
|
+
setAllowedDirectories([...currentDirs, normalizedPath]);
|
|
697
|
+
}
|
|
698
|
+
return {
|
|
699
|
+
content: [
|
|
700
|
+
{
|
|
701
|
+
type: "text",
|
|
702
|
+
text: `Successfully registered directory: ${parsed.data.path} (${normalizedPath})`,
|
|
703
|
+
},
|
|
704
|
+
],
|
|
705
|
+
};
|
|
581
706
|
}
|
|
582
707
|
case "list_allowed_directories": {
|
|
583
708
|
return {
|
|
@@ -666,6 +791,11 @@ export async function handleFileSystemTool(name, args) {
|
|
|
666
791
|
await fs.rename(file.validSource, file.validDest);
|
|
667
792
|
break;
|
|
668
793
|
case "copy":
|
|
794
|
+
// validSource is the realpath returned by validatePath, so a
|
|
795
|
+
// top-level source that is itself a symlink/junction has already
|
|
796
|
+
// been resolved and its real target checked against the allowed
|
|
797
|
+
// directories. Links *inside* a copied directory are refused by
|
|
798
|
+
// copyDirectoryRecursive.
|
|
669
799
|
const stats = await fs.stat(file.validSource);
|
|
670
800
|
if (stats.isDirectory()) {
|
|
671
801
|
await copyDirectoryRecursive(file.validSource, file.validDest);
|
|
@@ -903,22 +1033,75 @@ export async function handleFileSystemTool(name, args) {
|
|
|
903
1033
|
throw new Error(`Unknown filesystem tool: ${name}`);
|
|
904
1034
|
}
|
|
905
1035
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
const entries = await fs.readdir(
|
|
912
|
-
|
|
1036
|
+
function symlinkCopyError(entryPath) {
|
|
1037
|
+
return new Error(`Refusing to copy symbolic link inside directory: ${entryPath} ` +
|
|
1038
|
+
`(symbolic links are not copied for security reasons)`);
|
|
1039
|
+
}
|
|
1040
|
+
async function collectDirectoryCopyPlan(root, relativeDir = "", plan = []) {
|
|
1041
|
+
const entries = await fs.readdir(path.join(root, relativeDir), {
|
|
1042
|
+
withFileTypes: true,
|
|
1043
|
+
});
|
|
913
1044
|
for (const entry of entries) {
|
|
914
|
-
const
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
1045
|
+
const relativePath = path.join(relativeDir, entry.name);
|
|
1046
|
+
const entryPath = path.join(root, relativePath);
|
|
1047
|
+
// lstat never follows links; Node reports junctions as symbolic links too
|
|
1048
|
+
const stats = await fs.lstat(entryPath);
|
|
1049
|
+
if (entry.isSymbolicLink() || stats.isSymbolicLink()) {
|
|
1050
|
+
throw symlinkCopyError(entryPath);
|
|
1051
|
+
}
|
|
1052
|
+
if (stats.isDirectory()) {
|
|
1053
|
+
plan.push({ relativePath, type: "directory" });
|
|
1054
|
+
await collectDirectoryCopyPlan(root, relativePath, plan);
|
|
1055
|
+
}
|
|
1056
|
+
else if (stats.isFile()) {
|
|
1057
|
+
plan.push({ relativePath, type: "file" });
|
|
918
1058
|
}
|
|
919
1059
|
else {
|
|
920
|
-
|
|
1060
|
+
throw new Error(`Refusing to copy special file inside directory: ${entryPath} ` +
|
|
1061
|
+
`(only regular files and directories are copied)`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
return plan;
|
|
1065
|
+
}
|
|
1066
|
+
async function copyDirectoryRecursive(source, destination) {
|
|
1067
|
+
const relativeDest = path.relative(source, destination);
|
|
1068
|
+
if (relativeDest === "" ||
|
|
1069
|
+
(!relativeDest.startsWith("..") && !path.isAbsolute(relativeDest))) {
|
|
1070
|
+
throw new Error(`Cannot copy a directory into itself: ${source} to ${destination}`);
|
|
1071
|
+
}
|
|
1072
|
+
// Validate the entire source tree before creating or copying anything
|
|
1073
|
+
const plan = await collectDirectoryCopyPlan(source);
|
|
1074
|
+
// Destination directories are created segment by segment with their
|
|
1075
|
+
// realpath re-checked, so an existing link at the destination cannot
|
|
1076
|
+
// redirect the copy outside the allowed directories.
|
|
1077
|
+
await ensureDirectoryWithinAllowed(destination);
|
|
1078
|
+
for (const entry of plan) {
|
|
1079
|
+
const sourcePath = path.join(source, entry.relativePath);
|
|
1080
|
+
const destPath = path.join(destination, entry.relativePath);
|
|
1081
|
+
if (entry.type === "directory") {
|
|
1082
|
+
await ensureDirectoryWithinAllowed(destPath);
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
// Re-check the source right before copying in case it was swapped for a
|
|
1086
|
+
// link after the tree was validated.
|
|
1087
|
+
const sourceStats = await fs.lstat(sourcePath);
|
|
1088
|
+
if (sourceStats.isSymbolicLink() || !sourceStats.isFile()) {
|
|
1089
|
+
throw symlinkCopyError(sourcePath);
|
|
1090
|
+
}
|
|
1091
|
+
// Never write through an existing link at the destination
|
|
1092
|
+
let destIsLink = false;
|
|
1093
|
+
try {
|
|
1094
|
+
destIsLink = (await fs.lstat(destPath)).isSymbolicLink();
|
|
1095
|
+
}
|
|
1096
|
+
catch (error) {
|
|
1097
|
+
if (error.code !== "ENOENT") {
|
|
1098
|
+
throw error;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if (destIsLink) {
|
|
1102
|
+
throw new Error(`Refusing to overwrite symbolic link at destination: ${destPath}`);
|
|
921
1103
|
}
|
|
1104
|
+
await fs.copyFile(sourcePath, destPath);
|
|
922
1105
|
}
|
|
923
1106
|
}
|
|
924
1107
|
//# sourceMappingURL=filesystem-tools.js.map
|
package/dist/tools/read-tools.js
CHANGED
|
@@ -1,29 +1,72 @@
|
|
|
1
|
-
import { createReadStream } from "fs";
|
|
1
|
+
import { createReadStream, promises as fs } from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
4
4
|
import { ToolSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
5
|
import { ReadFileArgsSchema, AttachImageArgsSchema, ReadMultipleFilesArgsSchema, } from "../types/index.js";
|
|
6
|
-
import { validatePath,
|
|
6
|
+
import { validatePath, tailFile, headFile, rangeFile, } from "../utils/lib.js";
|
|
7
7
|
import { isDocumentFile, parseDocument } from "../utils/document-parser.js";
|
|
8
|
+
import { MAX_IMAGE_ATTACH_BYTES, MAX_IMAGE_ATTACH_TOTAL_BYTES, MAX_TEXT_READ_BYTES, } from "../utils/limits.js";
|
|
8
9
|
import { createPathArraySchema, sanitizeToolInputSchema, } from "../utils/tool-schema.js";
|
|
9
10
|
const ToolInputSchema = ToolSchema.shape.inputSchema;
|
|
11
|
+
const formatMB = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
10
12
|
// Reads a file as a stream of buffers, concatenates them, and then encodes
|
|
11
|
-
// the result to a Base64 string.
|
|
12
|
-
//
|
|
13
|
-
async function readFileAsBase64Stream(filePath) {
|
|
13
|
+
// the result to a Base64 string. At most `maxBytes` are read: a file that
|
|
14
|
+
// grew past the limit after it was checked is rejected rather than read.
|
|
15
|
+
async function readFileAsBase64Stream(filePath, maxBytes) {
|
|
14
16
|
return new Promise((resolve, reject) => {
|
|
15
|
-
const stream = createReadStream(filePath);
|
|
17
|
+
const stream = createReadStream(filePath, { start: 0, end: maxBytes });
|
|
16
18
|
const chunks = [];
|
|
19
|
+
let total = 0;
|
|
17
20
|
stream.on("data", (chunk) => {
|
|
21
|
+
total += chunk.length;
|
|
18
22
|
chunks.push(chunk);
|
|
19
23
|
});
|
|
20
24
|
stream.on("end", () => {
|
|
25
|
+
if (total > maxBytes) {
|
|
26
|
+
reject(new Error(`Image ${path.basename(filePath)} exceeds the ${formatMB(maxBytes)} limit`));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
21
29
|
const finalBuffer = Buffer.concat(chunks);
|
|
22
30
|
resolve(finalBuffer.toString("base64"));
|
|
23
31
|
});
|
|
24
32
|
stream.on("error", (err) => reject(err));
|
|
25
33
|
});
|
|
26
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Read a whole text file ("full" mode), refusing files larger than
|
|
37
|
+
* MAX_TEXT_READ_BYTES (VFO-15). The read itself is bounded too, so a file
|
|
38
|
+
* that grows after the size check (or a device/FIFO that reports size 0)
|
|
39
|
+
* cannot exhaust memory.
|
|
40
|
+
*/
|
|
41
|
+
async function readFullTextFile(filePath) {
|
|
42
|
+
const tooLarge = (size) => new Error(`File is too large to read in full mode (${size}; limit ` +
|
|
43
|
+
`${formatMB(MAX_TEXT_READ_BYTES)}). Use mode "head", "tail" or "range" ` +
|
|
44
|
+
`to read part of it.`);
|
|
45
|
+
const handle = await fs.open(filePath, "r");
|
|
46
|
+
try {
|
|
47
|
+
const { size } = await handle.stat();
|
|
48
|
+
if (size > MAX_TEXT_READ_BYTES) {
|
|
49
|
+
throw tooLarge(formatMB(size));
|
|
50
|
+
}
|
|
51
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
52
|
+
const chunks = [];
|
|
53
|
+
let total = 0;
|
|
54
|
+
for (;;) {
|
|
55
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
|
|
56
|
+
if (bytesRead === 0)
|
|
57
|
+
break;
|
|
58
|
+
total += bytesRead;
|
|
59
|
+
if (total > MAX_TEXT_READ_BYTES) {
|
|
60
|
+
throw tooLarge(`more than ${formatMB(MAX_TEXT_READ_BYTES)}`);
|
|
61
|
+
}
|
|
62
|
+
chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
|
|
63
|
+
}
|
|
64
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await handle.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
27
70
|
export function getReadTools() {
|
|
28
71
|
return [
|
|
29
72
|
{
|
|
@@ -43,7 +86,8 @@ export function getReadTools() {
|
|
|
43
86
|
"to the AI model as if uploaded directly by the user, enabling the AI to see " +
|
|
44
87
|
"and describe visual content, read text in images, analyze diagrams, etc. " +
|
|
45
88
|
"Supports attaching a single image or multiple images at once. " +
|
|
46
|
-
"Supports PNG, JPEG, GIF
|
|
89
|
+
"Supports PNG, JPEG, GIF and WebP (the formats vision models accept). " +
|
|
90
|
+
"SVG files are returned as their XML markup text; BMP is not supported (convert to PNG). " +
|
|
47
91
|
"Note: This requires the MCP client to support vision capabilities. " +
|
|
48
92
|
"Only works within allowed directories.",
|
|
49
93
|
inputSchema: {
|
|
@@ -122,7 +166,7 @@ export async function handleReadTool(name, args) {
|
|
|
122
166
|
}
|
|
123
167
|
case "full":
|
|
124
168
|
default: {
|
|
125
|
-
const content = await
|
|
169
|
+
const content = await readFullTextFile(validPath);
|
|
126
170
|
return {
|
|
127
171
|
content: [{ type: "text", text: content }],
|
|
128
172
|
};
|
|
@@ -138,29 +182,58 @@ export async function handleReadTool(name, args) {
|
|
|
138
182
|
const paths = Array.isArray(parsed.data.path)
|
|
139
183
|
? parsed.data.path
|
|
140
184
|
: [parsed.data.path];
|
|
141
|
-
//
|
|
185
|
+
// Raster formats accepted by vision models. SVG is vector markup (XML),
|
|
186
|
+
// which vision APIs reject as an image, so it is returned as text.
|
|
142
187
|
const mimeTypes = {
|
|
143
188
|
".png": "image/png",
|
|
144
189
|
".jpg": "image/jpeg",
|
|
145
190
|
".jpeg": "image/jpeg",
|
|
146
191
|
".gif": "image/gif",
|
|
147
192
|
".webp": "image/webp",
|
|
148
|
-
".bmp": "image/bmp",
|
|
149
193
|
".svg": "image/svg+xml",
|
|
150
194
|
};
|
|
151
|
-
//
|
|
152
|
-
const
|
|
195
|
+
// Validate all images and check sizes before reading any of them
|
|
196
|
+
const images = await Promise.all(paths.map(async (imagePath) => {
|
|
153
197
|
const validPath = await validatePath(imagePath);
|
|
154
198
|
const extension = path.extname(validPath).toLowerCase();
|
|
155
199
|
const mimeType = mimeTypes[extension];
|
|
200
|
+
if (extension === ".bmp") {
|
|
201
|
+
throw new Error(`BMP images are not accepted by vision models: ${imagePath}. ` +
|
|
202
|
+
`Convert it to PNG or JPEG first.`);
|
|
203
|
+
}
|
|
156
204
|
if (!mimeType) {
|
|
157
205
|
throw new Error(`Unsupported image format: ${extension}. ` +
|
|
158
|
-
`Supported formats: PNG, JPEG, GIF, WebP
|
|
206
|
+
`Supported formats: PNG, JPEG, GIF, WebP (SVG is returned as markup text)`);
|
|
207
|
+
}
|
|
208
|
+
const { size } = await fs.stat(validPath);
|
|
209
|
+
return { imagePath, validPath, mimeType, size };
|
|
210
|
+
}));
|
|
211
|
+
// Size caps (VFO-15): per image and per call
|
|
212
|
+
const oversized = images.filter((i) => i.size > MAX_IMAGE_ATTACH_BYTES);
|
|
213
|
+
if (oversized.length > 0) {
|
|
214
|
+
throw new Error(`Image(s) exceed the ${formatMB(MAX_IMAGE_ATTACH_BYTES)} per-image limit: ` +
|
|
215
|
+
oversized
|
|
216
|
+
.map((i) => `${i.imagePath} (${formatMB(i.size)})`)
|
|
217
|
+
.join(", "));
|
|
218
|
+
}
|
|
219
|
+
const totalSize = images.reduce((sum, i) => sum + i.size, 0);
|
|
220
|
+
if (totalSize > MAX_IMAGE_ATTACH_TOTAL_BYTES) {
|
|
221
|
+
throw new Error(`Images total ${formatMB(totalSize)}, which exceeds the ` +
|
|
222
|
+
`${formatMB(MAX_IMAGE_ATTACH_TOTAL_BYTES)} limit per attach_image call. ` +
|
|
223
|
+
`Attach fewer images per call.`);
|
|
224
|
+
}
|
|
225
|
+
const imageContents = await Promise.all(images.map(async ({ imagePath, validPath, mimeType }) => {
|
|
226
|
+
if (mimeType === "image/svg+xml") {
|
|
227
|
+
const markup = await fs.readFile(validPath, "utf-8");
|
|
228
|
+
return {
|
|
229
|
+
type: "text",
|
|
230
|
+
text: `SVG image ${imagePath} (vector markup; vision models accept only PNG, JPEG, GIF and WebP, so the SVG source is provided as text):\n` +
|
|
231
|
+
markup,
|
|
232
|
+
};
|
|
159
233
|
}
|
|
160
|
-
const data = await readFileAsBase64Stream(validPath);
|
|
161
234
|
return {
|
|
162
235
|
type: "image",
|
|
163
|
-
data:
|
|
236
|
+
data: await readFileAsBase64Stream(validPath, MAX_IMAGE_ATTACH_BYTES),
|
|
164
237
|
mimeType: mimeType,
|
|
165
238
|
};
|
|
166
239
|
}));
|
|
@@ -219,7 +292,7 @@ export async function handleReadTool(name, args) {
|
|
|
219
292
|
break;
|
|
220
293
|
case "full":
|
|
221
294
|
default:
|
|
222
|
-
content = await
|
|
295
|
+
content = await readFullTextFile(validPath);
|
|
223
296
|
break;
|
|
224
297
|
}
|
|
225
298
|
return `${fileRequest.path}:\n${content}\n`;
|