@etchv-labs/mcp-server 1.0.0 → 1.2.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 +29 -0
- package/README.md +75 -231
- package/dist/api.js +92 -9
- package/dist/batches.js +110 -0
- package/dist/cli.js +1 -0
- package/dist/io.js +69 -12
- package/dist/server.js +452 -14
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/dist/io.js
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
-
import { open, realpath, lstat, unlink } from "node:fs/promises";
|
|
2
|
+
import { glob, open, realpath, lstat, unlink } from "node:fs/promises";
|
|
3
3
|
import { resolve, relative, isAbsolute, basename, sep } from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
-
|
|
5
|
+
// The API's largest input (images); PDFs and videos are limited to 20 MiB there.
|
|
6
|
+
export const MAX_UPLOAD = 50 * 1024 * 1024;
|
|
7
|
+
/** Detection takes delivered files, up to 192 MiB through an upload session. */
|
|
8
|
+
export const MAX_DETECT_UPLOAD = 192 * 1024 * 1024;
|
|
6
9
|
export const MAX_DOWNLOAD = 512 * 1024 * 1024;
|
|
10
|
+
/** Directory entries a glob may visit before input_dir is refused as too large. */
|
|
11
|
+
export const MAX_WALK = 200000;
|
|
12
|
+
/** A batch archive holds up to 1 GiB of results plus its manifest. */
|
|
13
|
+
export const MAX_ARCHIVE_DOWNLOAD = 1100 * 1024 * 1024;
|
|
7
14
|
export class SafeError extends Error {
|
|
8
15
|
}
|
|
9
16
|
export async function fileAccess(configuredRoot) {
|
|
@@ -14,9 +21,11 @@ export async function fileAccess(configuredRoot) {
|
|
|
14
21
|
});
|
|
15
22
|
if (!(await lstat(root)).isDirectory())
|
|
16
23
|
throw new SafeError("ETCHV_FILES_ROOT must be a directory.");
|
|
17
|
-
async function checked(path) {
|
|
24
|
+
async function checked(path, allowRoot = false) {
|
|
18
25
|
const full = resolve(root, path);
|
|
19
26
|
const rel = relative(root, full);
|
|
27
|
+
if (!rel && allowRoot)
|
|
28
|
+
return full;
|
|
20
29
|
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
21
30
|
throw new SafeError("File path must be inside ETCHV_FILES_ROOT.");
|
|
22
31
|
// Reject symlinks in every existing component, including the final file.
|
|
@@ -41,15 +50,24 @@ export async function fileAccess(configuredRoot) {
|
|
|
41
50
|
}
|
|
42
51
|
return full;
|
|
43
52
|
}
|
|
53
|
+
async function directory(path, label) {
|
|
54
|
+
const full = await checked(path, true);
|
|
55
|
+
const stat = await lstat(full).catch(() => null);
|
|
56
|
+
if (!stat?.isDirectory())
|
|
57
|
+
throw new SafeError(`${label} must be an existing directory.`);
|
|
58
|
+
return full;
|
|
59
|
+
}
|
|
44
60
|
return {
|
|
45
|
-
|
|
61
|
+
directory,
|
|
62
|
+
async read(path, limit = MAX_UPLOAD) {
|
|
46
63
|
const full = await checked(path);
|
|
47
64
|
const file = await open(full, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
48
65
|
try {
|
|
49
66
|
const stat = await file.stat();
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
const mib = Math.round(limit / 1024 / 1024);
|
|
68
|
+
if (!stat.isFile() || !stat.size || stat.size > limit)
|
|
69
|
+
throw new SafeError(`Upload must be a regular file between 1 byte and ${mib} MiB.`);
|
|
70
|
+
const bytes = Buffer.alloc(Math.min(limit, stat.size) + 1);
|
|
53
71
|
let size = 0;
|
|
54
72
|
while (size < bytes.length) {
|
|
55
73
|
const { bytesRead } = await file.read(bytes, size, bytes.length - size);
|
|
@@ -57,14 +75,53 @@ export async function fileAccess(configuredRoot) {
|
|
|
57
75
|
break;
|
|
58
76
|
size += bytesRead;
|
|
59
77
|
}
|
|
60
|
-
if (size >
|
|
61
|
-
throw new SafeError(
|
|
78
|
+
if (size > limit || size > stat.size || !size)
|
|
79
|
+
throw new SafeError(`Upload must be between 1 byte and ${mib} MiB.`);
|
|
62
80
|
return { bytes: bytes.subarray(0, size), filename: basename(full) };
|
|
63
81
|
}
|
|
64
82
|
finally {
|
|
65
83
|
await file.close();
|
|
66
84
|
}
|
|
67
85
|
},
|
|
86
|
+
/**
|
|
87
|
+
* Files under a directory inside the root that match a glob and `keep`, as
|
|
88
|
+
* paths relative to the root, sorted. Symlinks and directories are skipped.
|
|
89
|
+
* Stops after `max` + 1 matches (`more` is then true); a directory with
|
|
90
|
+
* more than MAX_WALK entries is refused rather than listed in part.
|
|
91
|
+
*/
|
|
92
|
+
async list(dir, pattern, keep, max, walkLimit = MAX_WALK) {
|
|
93
|
+
// Braces are refused because expansion such as {..,x} could leave the directory.
|
|
94
|
+
if (isAbsolute(pattern) ||
|
|
95
|
+
/[{}\\\x00-\x1f]/.test(pattern) ||
|
|
96
|
+
pattern.split("/").includes(".."))
|
|
97
|
+
throw new SafeError("glob must be a relative pattern without .., braces, backslashes or control characters.");
|
|
98
|
+
const full = await directory(dir, "input_dir");
|
|
99
|
+
const found = [];
|
|
100
|
+
let seen = 0;
|
|
101
|
+
for await (const entry of glob(pattern, {
|
|
102
|
+
cwd: full,
|
|
103
|
+
withFileTypes: true,
|
|
104
|
+
})) {
|
|
105
|
+
if (++seen > walkLimit)
|
|
106
|
+
throw new SafeError(`input_dir holds more than ${walkLimit} entries matching the glob. Choose a smaller directory or a narrower glob.`);
|
|
107
|
+
if (!entry.isFile() || !keep(entry.name))
|
|
108
|
+
continue;
|
|
109
|
+
const path = resolve(full, entry.parentPath, entry.name);
|
|
110
|
+
const rel = relative(root, path);
|
|
111
|
+
const inDir = relative(full, path);
|
|
112
|
+
// Keep only files inside input_dir, reached without a symlink. Nothing
|
|
113
|
+
// outside the root is ever returned or named in an error.
|
|
114
|
+
if (!rel ||
|
|
115
|
+
!inDir ||
|
|
116
|
+
[rel, inDir].some((r) => r === ".." || r.startsWith(`..${sep}`) || isAbsolute(r)) ||
|
|
117
|
+
!(await checked(rel).then(() => true, () => false)))
|
|
118
|
+
continue;
|
|
119
|
+
found.push(rel);
|
|
120
|
+
if (found.length > max)
|
|
121
|
+
return { files: found.sort(), more: true };
|
|
122
|
+
}
|
|
123
|
+
return { files: found.sort(), more: false };
|
|
124
|
+
},
|
|
68
125
|
async reserve(path) {
|
|
69
126
|
const full = await checked(path);
|
|
70
127
|
// Exclusive creation also prevents concurrent requests overwriting each other.
|
|
@@ -74,15 +131,15 @@ export async function fileAccess(configuredRoot) {
|
|
|
74
131
|
constants.O_NOFOLLOW, 0o600);
|
|
75
132
|
let saved = false;
|
|
76
133
|
return {
|
|
77
|
-
async save(response) {
|
|
134
|
+
async save(response, limit = MAX_DOWNLOAD) {
|
|
78
135
|
let size = 0;
|
|
79
136
|
const hash = createHash("sha256");
|
|
80
137
|
if (!response.body)
|
|
81
138
|
throw new SafeError("API returned an empty file.");
|
|
82
139
|
for await (const chunk of response.body) {
|
|
83
140
|
size += chunk.length;
|
|
84
|
-
if (size >
|
|
85
|
-
throw new SafeError(
|
|
141
|
+
if (size > limit)
|
|
142
|
+
throw new SafeError(`Download exceeds the MCP server limit of ${Math.round(limit / 1024 / 1024)} MiB.`);
|
|
86
143
|
hash.update(chunk);
|
|
87
144
|
await file.writeFile(chunk);
|
|
88
145
|
}
|