@xlvane/mcp 1.2.2 → 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/README.md +5 -4
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -1
- package/dist/{server-B1Px_tvv.js → server-BKislJBl.js} +235 -52
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -29,6 +29,7 @@ Many MCP clients take a configuration like this one; check your client's documen
|
|
|
29
29
|
| `--max-file-size <bytes>` | Largest file read or written. Default 64 MiB. |
|
|
30
30
|
| `--max-rows <n>` | Largest number of rows one `read_table` call returns. Default 1000. |
|
|
31
31
|
| `--max-response-chars <n>` | Largest tool result (also an error), in characters of its JSON text; the message carries the same data again as `structuredContent`. `read_table` returns the rows that fit (`truncated`, `nextOffset`); other tools return `XLV_E_LIMIT_EXCEEDED`. Default 1 Mi (1 048 576). |
|
|
32
|
+
| `--max-cells <n>` | Largest number of cells in a workbook a tool reads (the `maxCells` read limit). Default 5 000 000. Lower it when the server must answer quickly: `diff` holds both workbooks in memory. |
|
|
32
33
|
|
|
33
34
|
The server speaks MCP over stdin and stdout; messages for people go to stderr.
|
|
34
35
|
|
|
@@ -48,9 +49,9 @@ Every tool has a JSON Schema for its input and returns its result as `structured
|
|
|
48
49
|
|
|
49
50
|
## Security
|
|
50
51
|
|
|
51
|
-
- **Directories:** files are read and written only inside the `--root` directories. Paths are checked after symbolic links are resolved, so `..`, absolute paths and links that lead outside are refused (`XLV_E_PATH_NOT_ALLOWED`). A file is checked again after it is opened (the path must still lead to the same file), so swapping it for a link meanwhile does not help. Writing through a symbolic link is refused. A path outside the roots gets the same answer whether or not something exists there. Only regular files are read.
|
|
52
|
-
- **Read-only by default:** the write tools exist only with `--allow-write`. They create files exclusively and replace an existing file only when the call passes `"overwrite": true`, through a temporary file and a rename; the replacement keeps the file's
|
|
53
|
-
- **Limits:** files above `--max-file-size` are refused for reading and writing, and results above `--max-response-chars` are cut (`read_table`) or refused. The xlvane reader applies its limits for untrusted files (ZIP bombs, XML without DTDs or entities, the budgets of xlvane 1.1.1). `validate` lists at most 1 000 problems (`problemsTotal` counts all).
|
|
52
|
+
- **Directories:** files are read and written only inside the `--root` directories. Paths are checked after symbolic links are resolved, so `..`, absolute paths and links that lead outside are refused (`XLV_E_PATH_NOT_ALLOWED`). A file is checked again after it is opened (the path must still lead to the same file), so swapping it for a link meanwhile does not help. Writing through a symbolic link is refused. A path outside the roots gets the same answer whether or not something exists there, also when it is reached through a symbolic link inside a root. Only regular files are read. Messages on stderr show control characters as `\uXXXX`.
|
|
53
|
+
- **Read-only by default:** the write tools exist only with `--allow-write`. They create files exclusively and replace an existing file only when the call passes `"overwrite": true`, through a temporary file and a rename; the replacement keeps the file's permission bits (not setuid, setgid or sticky), and read-only files are not replaced. `edit_cells` writes formulas as given (for example `HYPERLINK(...)`); they are calculated when a spreadsheet application opens the file. Any file name inside a root may be written, also dot files such as `.gitignore`; choose the roots accordingly.
|
|
54
|
+
- **Limits:** files above `--max-file-size` are refused for reading and writing (`convert` to JSON stops building the text at that size), workbooks above `--max-cells` cells are refused, and results above `--max-response-chars` are cut (`read_table`) or refused. The official MCP SDK closes the connection after a message larger than 10 MiB. The xlvane reader applies its limits for untrusted files (ZIP bombs, XML without DTDs or entities, the budgets of xlvane 1.1.1). `validate` lists at most 1 000 problems (`problemsTotal` counts all).
|
|
54
55
|
- **File content is data:** values, sheet names and other text come from the files and may contain any text, also text meant to influence a model. The server returns it as data and does not interpret it.
|
|
55
56
|
- **No code execution:** macros are never run, external links are never followed, and formulas are not calculated.
|
|
56
57
|
|
|
@@ -60,7 +61,7 @@ Every tool has a JSON Schema for its input and returns its result as `structured
|
|
|
60
61
|
import { createServer, runCli } from '@xlvane/mcp';
|
|
61
62
|
```
|
|
62
63
|
|
|
63
|
-
`createServer({ roots, allowWrite, maxFileBytes, maxRows, maxResponseChars })` returns an `McpServer` to connect to any transport; `runCli(args)` is the command line.
|
|
64
|
+
`createServer({ roots, allowWrite, maxFileBytes, maxRows, maxResponseChars, maxCells, cwd })` returns an `McpServer` to connect to any transport; `runCli(args)` is the command line.
|
|
64
65
|
|
|
65
66
|
## Links
|
|
66
67
|
|
package/dist/cli.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ interface ServerOptions {
|
|
|
23
23
|
* (`truncated`, `nextOffset`); other tools return `XLV_E_LIMIT_EXCEEDED`. Default 1 Mi.
|
|
24
24
|
*/
|
|
25
25
|
readonly maxResponseChars?: number;
|
|
26
|
+
/** Largest number of cells in a workbook the tools read (the `maxCells` read limit). Default 5 000 000. */
|
|
27
|
+
readonly maxCells?: number;
|
|
26
28
|
/** Directory that relative roots are resolved against. Default: the current directory. */
|
|
27
29
|
readonly cwd?: string;
|
|
28
30
|
}
|
|
@@ -42,7 +44,7 @@ interface ServerOptions {
|
|
|
42
44
|
export declare function createServer(options: ServerOptions): Promise<McpServer>;
|
|
43
45
|
/**
|
|
44
46
|
* Runs the server from the command line: parses `--root`, `--allow-write`, `--max-file-size`,
|
|
45
|
-
* `--max-rows` and `--max-
|
|
47
|
+
* `--max-rows`, `--max-response-chars` and `--max-cells`, then serves MCP over stdio until the client closes stdin. Returns the exit
|
|
46
48
|
* code. `xlvane mcp` calls this function.
|
|
47
49
|
*
|
|
48
50
|
* @example
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { t as createServer, n as runCli } from "./server-
|
|
1
|
+
export { t as createServer, n as runCli } from "./server-BKislJBl.js";
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
2
|
import { McpServer, fromJsonSchema } from "@modelcontextprotocol/server";
|
|
3
3
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
4
|
-
import { ERROR_VALUES, XlvError, appendRows, diff, editCells, fromCSV, fromJSON, inspect, isXlvError, readTable, readWorkbook, toCSV, toJSON, writeTable } from "xlvane";
|
|
4
|
+
import { DEFAULT_READ_LIMITS, ERROR_VALUES, XlvError, appendRows, diff, editCells, fromCSV, fromJSON, inspect, isXlvError, readTable, readWorkbook, toCSV, toJSON, writeTable } from "xlvane";
|
|
5
5
|
import { constants } from "node:fs";
|
|
6
|
-
import {
|
|
7
|
-
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
6
|
+
import { lstat, open, readlink, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
|
|
8
8
|
// src/paths.ts
|
|
9
9
|
function errorCode(error) {
|
|
10
10
|
return typeof error === "object" && error !== null && "code" in error ? String(error.code) : "UNKNOWN";
|
|
@@ -47,6 +47,81 @@ function within(dirs, path) {
|
|
|
47
47
|
function inside(roots, real) {
|
|
48
48
|
return within(roots.dirs, real);
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* The real path of `target`. The path is refused as outside when it leads outside the roots, or when
|
|
52
|
+
* a symbolic link on the way passes through a directory outside them, also when it then comes back
|
|
53
|
+
* inside: so the answer does not depend on what exists outside the roots (D-J01, D-K04). Otherwise
|
|
54
|
+
* `onMissing` gives the error for a path that does not exist.
|
|
55
|
+
*/
|
|
56
|
+
async function realInside(roots, path, target, onMissing) {
|
|
57
|
+
let real = null;
|
|
58
|
+
let failure = null;
|
|
59
|
+
try {
|
|
60
|
+
real = await realpath(target);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
failure = error;
|
|
63
|
+
}
|
|
64
|
+
if (await passesOutside(roots, target)) throw notAllowed(roots, path, "");
|
|
65
|
+
if (real === null) throw onMissing(failure);
|
|
66
|
+
if (!inside(roots, real)) throw notAllowed(roots, path, "");
|
|
67
|
+
return real;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Whether `dir` is a root, inside one, or a parent directory of one, as a real path or as given
|
|
71
|
+
* (a root given through a link, such as /tmp on macOS, is reached through its given path too).
|
|
72
|
+
*/
|
|
73
|
+
function onTheWay(roots, dir) {
|
|
74
|
+
if (inside(roots, dir) || within(roots.given, dir)) return true;
|
|
75
|
+
const prefix = dir.endsWith(sep) ? dir : dir + sep;
|
|
76
|
+
return roots.dirs.some((root) => root.startsWith(prefix)) || roots.given.some((root) => root.startsWith(prefix));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Whether resolving `path` leads outside the roots. The path is walked one component at a time
|
|
80
|
+
* from the file system root, as the system resolves it: a symbolic link (also one whose target is
|
|
81
|
+
* missing) is replaced by its text, so ".." in that text goes to the parent of the directory the
|
|
82
|
+
* link leads to, not of the link (D-J01, D-K03). Once a link has been followed, every directory the
|
|
83
|
+
* walk reaches, existing or not, must be a root, inside one or a parent of one; otherwise the path
|
|
84
|
+
* is outside even if it ends inside (D-K04). From the first component that does not exist the rest
|
|
85
|
+
* of the path is taken as written.
|
|
86
|
+
*/
|
|
87
|
+
async function passesOutside(roots, path) {
|
|
88
|
+
const split = (p) => p.slice(parse(p).root.length).split(sep === "\\" ? /[\\/]/ : "/").filter((part) => part !== "");
|
|
89
|
+
const pending = split(path);
|
|
90
|
+
let current = parse(path).root;
|
|
91
|
+
let links = 0;
|
|
92
|
+
while (pending.length > 0) {
|
|
93
|
+
const part = pending.shift();
|
|
94
|
+
if (part === ".") continue;
|
|
95
|
+
if (part === "..") {
|
|
96
|
+
current = dirname(current);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const next = join(current, part);
|
|
100
|
+
if (links > 0 && !onTheWay(roots, next)) return true;
|
|
101
|
+
let info;
|
|
102
|
+
try {
|
|
103
|
+
info = await lstat(next);
|
|
104
|
+
} catch {
|
|
105
|
+
return !inside(roots, resolve(next, ...pending));
|
|
106
|
+
}
|
|
107
|
+
if (!info.isSymbolicLink()) {
|
|
108
|
+
current = next;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (++links > 40) return true;
|
|
112
|
+
let text;
|
|
113
|
+
try {
|
|
114
|
+
text = await readlink(next);
|
|
115
|
+
} catch {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (isAbsolute(text)) {
|
|
119
|
+
current = parse(text).root;
|
|
120
|
+
pending.unshift(...split(text));
|
|
121
|
+
} else pending.unshift(...text.split(sep === "\\" ? /[\\/]/ : "/").filter((part) => part !== ""));
|
|
122
|
+
}
|
|
123
|
+
return !inside(roots, current);
|
|
124
|
+
}
|
|
50
125
|
function notAllowed(roots, path, why) {
|
|
51
126
|
return new XlvError("XLV_E_PATH_NOT_ALLOWED", `"${path}" is outside the directories this server may use${why}.`, {
|
|
52
127
|
hint: `Use a path inside ${roots.dirs.map((d) => `"${d}"`).join(" or ")}. Relative paths are resolved against the first one.`,
|
|
@@ -74,6 +149,21 @@ function absolute(roots, path) {
|
|
|
74
149
|
});
|
|
75
150
|
return full;
|
|
76
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* The start of a file name, at most 100 bytes of UTF-8 and never cut inside a character, for the
|
|
154
|
+
* name of a temporary file next to it (file systems limit names in bytes, D-K03).
|
|
155
|
+
*/
|
|
156
|
+
function shortName(name) {
|
|
157
|
+
let bytes = 0;
|
|
158
|
+
let end = 0;
|
|
159
|
+
for (const ch of name) {
|
|
160
|
+
const size = new TextEncoder().encode(ch).length;
|
|
161
|
+
if (bytes + size > 100) break;
|
|
162
|
+
bytes += size;
|
|
163
|
+
end += ch.length;
|
|
164
|
+
}
|
|
165
|
+
return name.slice(0, end);
|
|
166
|
+
}
|
|
77
167
|
/** Reads a file inside the roots, after checking its real path and size. */
|
|
78
168
|
function readAllowed(roots, path) {
|
|
79
169
|
return readAllowedWith(roots, path, {});
|
|
@@ -86,27 +176,32 @@ function readAllowed(roots, path) {
|
|
|
86
176
|
*/
|
|
87
177
|
async function readAllowedWith(roots, path, hooks) {
|
|
88
178
|
const target = absolute(roots, path);
|
|
89
|
-
const resolveInside =
|
|
90
|
-
|
|
179
|
+
const resolveInside = () => realInside(roots, path, target, (error) => new XlvError("XLV_E_FILE_READ", `Cannot read "${path}" (${errorCode(error)}).`, {
|
|
180
|
+
hint: errorCode(error) === "ENOENT" ? "The file does not exist. Use inspect_workbook on a file that exists, or check the name." : "Check that the path points to a readable file.",
|
|
181
|
+
details: {
|
|
182
|
+
path,
|
|
183
|
+
code: errorCode(error)
|
|
184
|
+
}
|
|
185
|
+
}));
|
|
186
|
+
const notAFile = () => new XlvError("XLV_E_FILE_READ", `"${path}" is not a file.`, {
|
|
187
|
+
hint: "Pass the path of a file.",
|
|
188
|
+
details: { path }
|
|
189
|
+
});
|
|
190
|
+
const statOf = async () => {
|
|
191
|
+
const real = await resolveInside();
|
|
91
192
|
try {
|
|
92
|
-
|
|
193
|
+
return await stat(real);
|
|
93
194
|
} catch (error) {
|
|
94
195
|
throw new XlvError("XLV_E_FILE_READ", `Cannot read "${path}" (${errorCode(error)}).`, {
|
|
95
|
-
hint:
|
|
196
|
+
hint: "Check that the file still exists.",
|
|
96
197
|
details: {
|
|
97
198
|
path,
|
|
98
199
|
code: errorCode(error)
|
|
99
200
|
}
|
|
100
201
|
});
|
|
101
202
|
}
|
|
102
|
-
if (!inside(roots, real)) throw notAllowed(roots, path, real === target ? "" : " (after following symbolic links)");
|
|
103
|
-
return real;
|
|
104
203
|
};
|
|
105
|
-
|
|
106
|
-
hint: "Pass the path of a file.",
|
|
107
|
-
details: { path }
|
|
108
|
-
});
|
|
109
|
-
if (!(await stat(await resolveInside())).isFile()) throw notAFile();
|
|
204
|
+
if (!(await statOf()).isFile()) throw notAFile();
|
|
110
205
|
await hooks.afterCheck?.();
|
|
111
206
|
let handle;
|
|
112
207
|
try {
|
|
@@ -123,7 +218,7 @@ async function readAllowedWith(roots, path, hooks) {
|
|
|
123
218
|
try {
|
|
124
219
|
await hooks.afterOpen?.();
|
|
125
220
|
const opened = await handle.stat();
|
|
126
|
-
const now = await
|
|
221
|
+
const now = await statOf();
|
|
127
222
|
if (now.dev !== opened.dev || now.ino !== opened.ino) throw new XlvError("XLV_E_FILE_READ", `"${path}" changed while it was being read.`, {
|
|
128
223
|
hint: "Read the file again when nothing else is writing to it.",
|
|
129
224
|
details: { path }
|
|
@@ -172,19 +267,13 @@ async function writeAllowed(roots, path, data, overwrite) {
|
|
|
172
267
|
hint: "Pass a path that ends in a file name.",
|
|
173
268
|
details: { path }
|
|
174
269
|
});
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
path,
|
|
183
|
-
code: errorCode(error)
|
|
184
|
-
}
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
if (!inside(roots, dir)) throw notAllowed(roots, path, dir === dirname(target) ? "" : " (after following symbolic links)");
|
|
270
|
+
const dir = await realInside(roots, path, dirname(target), (error) => new XlvError("XLV_E_FILE_WRITE", `The directory of "${path}" does not exist (${errorCode(error)}).`, {
|
|
271
|
+
hint: "Write into an existing directory inside the allowed roots.",
|
|
272
|
+
details: {
|
|
273
|
+
path,
|
|
274
|
+
code: errorCode(error)
|
|
275
|
+
}
|
|
276
|
+
}));
|
|
188
277
|
const file = join(dir, name);
|
|
189
278
|
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
190
279
|
if (bytes.length > roots.maxFileBytes) throw new XlvError("XLV_E_LIMIT_EXCEEDED", `The output would be ${String(bytes.length)} bytes, more than the server's limit of ${String(roots.maxFileBytes)}.`, {
|
|
@@ -198,7 +287,13 @@ async function writeAllowed(roots, path, data, overwrite) {
|
|
|
198
287
|
let existing = null;
|
|
199
288
|
try {
|
|
200
289
|
const info = await lstat(file);
|
|
201
|
-
if (info.isSymbolicLink()) throw
|
|
290
|
+
if (info.isSymbolicLink()) throw new XlvError("XLV_E_PATH_NOT_ALLOWED", `"${path}" is a symbolic link; the server does not write through symbolic links.`, {
|
|
291
|
+
hint: "Write to another file name, or replace the link with a regular file.",
|
|
292
|
+
details: {
|
|
293
|
+
path,
|
|
294
|
+
roots: [...roots.dirs]
|
|
295
|
+
}
|
|
296
|
+
});
|
|
202
297
|
if (!info.isFile()) throw new XlvError("XLV_E_FILE_WRITE", `"${path}" is not a file.`, {
|
|
203
298
|
hint: "Choose a file name that is not a directory.",
|
|
204
299
|
details: {
|
|
@@ -244,12 +339,17 @@ async function writeAllowed(roots, path, data, overwrite) {
|
|
|
244
339
|
};
|
|
245
340
|
}
|
|
246
341
|
const random = Array.from(globalThis.crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(8)), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
247
|
-
const temp = join(dir, `.${name}.xlvane-${random}.tmp`);
|
|
342
|
+
const temp = join(dir, `.${shortName(name)}.xlvane-${random}.tmp`);
|
|
248
343
|
let created = false;
|
|
249
344
|
try {
|
|
250
|
-
await
|
|
345
|
+
const handle = await open(temp, "wx", 384);
|
|
251
346
|
created = true;
|
|
252
|
-
|
|
347
|
+
try {
|
|
348
|
+
await handle.writeFile(bytes);
|
|
349
|
+
await handle.chmod(existing.mode & 511);
|
|
350
|
+
} finally {
|
|
351
|
+
await handle.close();
|
|
352
|
+
}
|
|
253
353
|
await rename(temp, file);
|
|
254
354
|
} catch (error) {
|
|
255
355
|
if (created) await rm(temp, { force: true });
|
|
@@ -419,14 +519,64 @@ function handler(maxChars, run) {
|
|
|
419
519
|
}
|
|
420
520
|
};
|
|
421
521
|
}
|
|
422
|
-
async function book(roots, path) {
|
|
522
|
+
async function book(roots, limits, path) {
|
|
423
523
|
const bytes = await readAllowed(roots, path);
|
|
424
524
|
try {
|
|
425
|
-
return readWorkbook(bytes);
|
|
525
|
+
return readWorkbook(bytes, { limits: { maxCells: limits.maxCells } });
|
|
426
526
|
} catch (error) {
|
|
427
527
|
throw withPath(error, path);
|
|
428
528
|
}
|
|
429
529
|
}
|
|
530
|
+
/**
|
|
531
|
+
* Refuses rows that hold more cells than `--max-cells` (values other than null, plus one header
|
|
532
|
+
* cell per key), so the write tools cannot create a workbook the read tools refuse (D-K03). For
|
|
533
|
+
* `edit_cells` it checks the request; the library then counts the cells of the result (D-K04).
|
|
534
|
+
*/
|
|
535
|
+
function checkCells(rows, maxCells, path) {
|
|
536
|
+
let cells = 0;
|
|
537
|
+
const keys = /* @__PURE__ */ new Set();
|
|
538
|
+
for (const row of rows) {
|
|
539
|
+
if (Array.isArray(row)) {
|
|
540
|
+
for (const v of row) if (v !== null && v !== void 0) cells++;
|
|
541
|
+
} else if (typeof row === "object" && row !== null) for (const [k, v] of Object.entries(row)) {
|
|
542
|
+
keys.add(k);
|
|
543
|
+
if (v !== null && v !== void 0) cells++;
|
|
544
|
+
}
|
|
545
|
+
if (cells + keys.size > maxCells) throw new XlvError("XLV_E_LIMIT_EXCEEDED", `The rows hold more than ${String(maxCells)} cells, the server's limit (--max-cells).`, {
|
|
546
|
+
hint: "Write fewer rows per file, or start the server with a higher --max-cells.",
|
|
547
|
+
details: {
|
|
548
|
+
path,
|
|
549
|
+
limit: "maxCells",
|
|
550
|
+
value: maxCells
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* `JSON.stringify(rows, null, 2)` plus a line feed, built row by row: every row repeats the header
|
|
557
|
+
* names, so a small file can make a text far larger than the file limit, or than a string can be.
|
|
558
|
+
* The text stops as soon as it is longer than `maxChars` characters, before encoding (D-J01).
|
|
559
|
+
*/
|
|
560
|
+
function jsonText(rows, maxChars, path) {
|
|
561
|
+
if (rows.length === 0) return "[]\n";
|
|
562
|
+
const parts = ["["];
|
|
563
|
+
let length = 1;
|
|
564
|
+
for (let i = 0; i < rows.length; i++) {
|
|
565
|
+
const part = `${i === 0 ? "" : ","}\n ${JSON.stringify(rows[i], null, 2).replace(/\n/g, "\n ")}`;
|
|
566
|
+
length += part.length;
|
|
567
|
+
if (length > maxChars) throw new XlvError("XLV_E_LIMIT_EXCEEDED", `The JSON text would be longer than the server's limit of ${String(maxChars)} bytes.`, {
|
|
568
|
+
hint: "Convert fewer rows or columns (sheet with a smaller table), or raise --max-file-size.",
|
|
569
|
+
details: {
|
|
570
|
+
path,
|
|
571
|
+
limit: "maxFileBytes",
|
|
572
|
+
value: maxChars
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
parts.push(part);
|
|
576
|
+
}
|
|
577
|
+
parts.push("\n]\n");
|
|
578
|
+
return parts.join("");
|
|
579
|
+
}
|
|
430
580
|
function withPath(error, path) {
|
|
431
581
|
if (error instanceof XlvError && error.details["path"] === void 0) return new XlvError(error.code, error.message, {
|
|
432
582
|
location: error.location,
|
|
@@ -493,7 +643,7 @@ function typeOf(v) {
|
|
|
493
643
|
function registerTools(server, roots, allowWrite, limits) {
|
|
494
644
|
server.registerTool("inspect_workbook", {
|
|
495
645
|
title: "Inspect a workbook",
|
|
496
|
-
description: "Describes an XLSX file without changing it: sheets (including hidden ones), used ranges, detected header rows, column types with sample values, merged cells, example formulas, and warnings (macros, external links, damaged parts). Macros are never run and links are never followed. Use it before read_table to find the sheet and header row.",
|
|
646
|
+
description: "Describes an XLSX file without changing it: sheets (including hidden ones), used ranges, detected header rows, column types with sample values, merged cells, example formulas, pictures and charts (position, chart types, title, series count), and warnings (macros, external links, damaged parts). Macros are never run and links are never followed. Use it before read_table to find the sheet and header row.",
|
|
497
647
|
inputSchema: object({
|
|
498
648
|
path: PATH,
|
|
499
649
|
sheet: {
|
|
@@ -503,7 +653,7 @@ function registerTools(server, roots, allowWrite, limits) {
|
|
|
503
653
|
}, ["path"]),
|
|
504
654
|
annotations: READ
|
|
505
655
|
}, handler(limits.maxResponseChars, async (a) => {
|
|
506
|
-
const workbook = await book(roots, a["path"]);
|
|
656
|
+
const workbook = await book(roots, limits, a["path"]);
|
|
507
657
|
const report = inspect(workbook);
|
|
508
658
|
if (typeof a["sheet"] !== "string") return report;
|
|
509
659
|
const sheet = workbook.getSheet(a["sheet"]);
|
|
@@ -538,7 +688,7 @@ function registerTools(server, roots, allowWrite, limits) {
|
|
|
538
688
|
}, ["path"]),
|
|
539
689
|
annotations: READ
|
|
540
690
|
}, handler(limits.maxResponseChars, async (a) => {
|
|
541
|
-
const workbook = await book(roots, a["path"]);
|
|
691
|
+
const workbook = await book(roots, limits, a["path"]);
|
|
542
692
|
const table = readTable(workbook, {
|
|
543
693
|
...typeof a["sheet"] === "string" ? { sheet: a["sheet"] } : {},
|
|
544
694
|
...typeof a["range"] === "string" ? { range: a["range"] } : {},
|
|
@@ -610,8 +760,8 @@ function registerTools(server, roots, allowWrite, limits) {
|
|
|
610
760
|
}, ["before", "after"]),
|
|
611
761
|
annotations: READ
|
|
612
762
|
}, handler(limits.maxResponseChars, async (a) => {
|
|
613
|
-
const before = await book(roots, a["before"]);
|
|
614
|
-
const after = await book(roots, a["after"]);
|
|
763
|
+
const before = await book(roots, limits, a["before"]);
|
|
764
|
+
const after = await book(roots, limits, a["after"]);
|
|
615
765
|
const r = diff(before, after, { styles: a["styles"] === true });
|
|
616
766
|
const max = a["maxChanges"] ?? 200;
|
|
617
767
|
return {
|
|
@@ -655,7 +805,7 @@ function registerTools(server, roots, allowWrite, limits) {
|
|
|
655
805
|
}, ["path"]),
|
|
656
806
|
annotations: READ
|
|
657
807
|
}, handler(limits.maxResponseChars, async (a) => {
|
|
658
|
-
const workbook = await book(roots, a["path"]);
|
|
808
|
+
const workbook = await book(roots, limits, a["path"]);
|
|
659
809
|
const warnings = inspect(workbook).warnings;
|
|
660
810
|
const rules = a["columns"] ?? [];
|
|
661
811
|
const names = /* @__PURE__ */ new Set();
|
|
@@ -674,6 +824,7 @@ function registerTools(server, roots, allowWrite, limits) {
|
|
|
674
824
|
};
|
|
675
825
|
let sheetName = null;
|
|
676
826
|
let rowsChecked = 0;
|
|
827
|
+
if (typeof a["sheet"] === "string") workbook.getSheet(a["sheet"]);
|
|
677
828
|
if (rules.length > 0) {
|
|
678
829
|
const table = readTable(workbook, typeof a["sheet"] === "string" ? { sheet: a["sheet"] } : {});
|
|
679
830
|
sheetName = table.sheet;
|
|
@@ -746,6 +897,7 @@ function registerTools(server, roots, allowWrite, limits) {
|
|
|
746
897
|
}, ["path", "rows"]),
|
|
747
898
|
annotations: WRITE
|
|
748
899
|
}, handler(limits.maxResponseChars, async (a) => {
|
|
900
|
+
checkCells(a["rows"], limits.maxCells, a["path"]);
|
|
749
901
|
const rows = a["rows"].map((r) => Array.isArray(r) ? r.map(cellInput) : Object.fromEntries(Object.entries(r).map(([k, v]) => [k, cellInput(v)])));
|
|
750
902
|
const bytes = writeTable(rows, {
|
|
751
903
|
...Array.isArray(a["columns"]) ? { columns: a["columns"] } : {},
|
|
@@ -819,6 +971,8 @@ function registerTools(server, roots, allowWrite, limits) {
|
|
|
819
971
|
const edits = a["edits"] ?? [];
|
|
820
972
|
const append = a["append"];
|
|
821
973
|
if (edits.length === 0 && append === void 0) throw new XlvError("XLV_E_INVALID_INPUT", "Nothing to do: pass edits, append or both.", { hint: "For example {\"path\": \"report.xlsx\", \"edits\": [{\"sheet\": \"Sales\", \"cell\": \"B2\", \"value\": 1250}], \"output\": \"updated.xlsx\"}." });
|
|
974
|
+
if (edits.length > limits.maxCells) checkCells([edits.map(() => 0)], limits.maxCells, path);
|
|
975
|
+
if (append) checkCells(append.rows, limits.maxCells, path);
|
|
822
976
|
let bytes = await readAllowed(roots, path);
|
|
823
977
|
const warnings = [];
|
|
824
978
|
try {
|
|
@@ -827,13 +981,16 @@ function registerTools(server, roots, allowWrite, limits) {
|
|
|
827
981
|
sheet: e.sheet,
|
|
828
982
|
cell: e.cell.toUpperCase(),
|
|
829
983
|
value: cellInput(e.value)
|
|
830
|
-
})));
|
|
984
|
+
})), { limits: { maxCells: limits.maxCells } });
|
|
831
985
|
bytes = r.bytes;
|
|
832
986
|
warnings.push(...r.warnings);
|
|
833
987
|
}
|
|
834
988
|
if (append) {
|
|
835
989
|
const rows = append.rows.map((r) => Array.isArray(r) ? r.map(cellInput) : Object.fromEntries(Object.entries(r).map(([k, v]) => [k, cellInput(v)])));
|
|
836
|
-
const r = appendRows(bytes, append.sheet, rows,
|
|
990
|
+
const r = appendRows(bytes, append.sheet, rows, {
|
|
991
|
+
limits: { maxCells: limits.maxCells },
|
|
992
|
+
...append.header !== void 0 ? { header: append.header } : {}
|
|
993
|
+
});
|
|
837
994
|
bytes = r.bytes;
|
|
838
995
|
warnings.push(...r.warnings);
|
|
839
996
|
}
|
|
@@ -880,8 +1037,8 @@ function registerTools(server, roots, allowWrite, limits) {
|
|
|
880
1037
|
const sheet = typeof a["sheet"] === "string" ? { sheet: a["sheet"] } : {};
|
|
881
1038
|
let data;
|
|
882
1039
|
if (books.includes(from) && tables.includes(to)) {
|
|
883
|
-
const workbook = await book(roots, input);
|
|
884
|
-
data = to === "json" ?
|
|
1040
|
+
const workbook = await book(roots, limits, input);
|
|
1041
|
+
data = to === "json" ? jsonText(toJSON(workbook, sheet).rows, roots.maxFileBytes, output) : toCSV(workbook, {
|
|
885
1042
|
...sheet,
|
|
886
1043
|
delimiter: a["delimiter"] ?? (to === "tsv" ? " " : ",")
|
|
887
1044
|
});
|
|
@@ -897,12 +1054,21 @@ function registerTools(server, roots, allowWrite, limits) {
|
|
|
897
1054
|
});
|
|
898
1055
|
}
|
|
899
1056
|
try {
|
|
900
|
-
|
|
1057
|
+
let parsed = text;
|
|
1058
|
+
if (from === "json") {
|
|
1059
|
+
try {
|
|
1060
|
+
parsed = JSON.parse(text);
|
|
1061
|
+
} catch {}
|
|
1062
|
+
if (Array.isArray(parsed)) checkCells(parsed, limits.maxCells, input);
|
|
1063
|
+
}
|
|
1064
|
+
data = from === "json" ? fromJSON(parsed, {
|
|
901
1065
|
...sheet,
|
|
902
|
-
preset: "plain"
|
|
1066
|
+
preset: "plain",
|
|
1067
|
+
maxCells: limits.maxCells
|
|
903
1068
|
}) : fromCSV(text, {
|
|
904
1069
|
...sheet,
|
|
905
|
-
delimiter: a["delimiter"] ?? (from === "tsv" ? " " : ",")
|
|
1070
|
+
delimiter: a["delimiter"] ?? (from === "tsv" ? " " : ","),
|
|
1071
|
+
maxCells: limits.maxCells
|
|
906
1072
|
});
|
|
907
1073
|
} catch (error) {
|
|
908
1074
|
throw withPath(error, input);
|
|
@@ -923,7 +1089,7 @@ function registerTools(server, roots, allowWrite, limits) {
|
|
|
923
1089
|
}
|
|
924
1090
|
// src/server.ts
|
|
925
1091
|
/** Version reported in server info; kept equal to package.json by tests/mcp/server.test.ts. */
|
|
926
|
-
const SERVER_VERSION = "1.
|
|
1092
|
+
const SERVER_VERSION = "1.3.0";
|
|
927
1093
|
/** Default largest file read or written by the tools: 64 MiB. */
|
|
928
1094
|
const DEFAULT_MAX_FILE_BYTES = 67108864;
|
|
929
1095
|
/** Default largest number of rows returned by one read_table call. */
|
|
@@ -954,7 +1120,8 @@ function build(roots, allowWrite, limits) {
|
|
|
954
1120
|
async function prepare(options) {
|
|
955
1121
|
const limits = {
|
|
956
1122
|
maxRows: options.maxRows ?? 1e3,
|
|
957
|
-
maxResponseChars: options.maxResponseChars ?? 1048576
|
|
1123
|
+
maxResponseChars: options.maxResponseChars ?? 1048576,
|
|
1124
|
+
maxCells: options.maxCells ?? DEFAULT_READ_LIMITS.maxCells
|
|
958
1125
|
};
|
|
959
1126
|
return {
|
|
960
1127
|
roots: await resolveRoots(options.roots, options.cwd ?? ".", options.maxFileBytes ?? 67108864),
|
|
@@ -992,6 +1159,7 @@ Options:
|
|
|
992
1159
|
--max-rows <n> Largest number of rows per read_table call (default ${String(DEFAULT_MAX_ROWS)})
|
|
993
1160
|
--max-response-chars <n>
|
|
994
1161
|
Largest tool result in characters of JSON (default ${String(DEFAULT_MAX_RESPONSE_CHARS)})
|
|
1162
|
+
--max-cells <n> Largest number of cells in a workbook read (default ${String(DEFAULT_READ_LIMITS.maxCells)})
|
|
995
1163
|
-h, --help Show this help
|
|
996
1164
|
|
|
997
1165
|
The server speaks MCP over stdin and stdout; messages for people go to stderr.
|
|
@@ -1002,8 +1170,20 @@ function positive(value, name) {
|
|
|
1002
1170
|
if (!/^\d+$/.test(value) || Number(value) < 1) throw new Error(`--${name} must be a whole number of 1 or more, not "${value}".`);
|
|
1003
1171
|
return Number(value);
|
|
1004
1172
|
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Text for stderr with control characters (other than line feeds), C1 controls and bidirectional
|
|
1175
|
+
* formatting characters written as `\uXXXX`: arguments and directory names come from the client
|
|
1176
|
+
* configuration and must not reach the terminal as escape sequences (D-J01).
|
|
1177
|
+
*/
|
|
1178
|
+
function printable(text) {
|
|
1179
|
+
return text.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
1180
|
+
}
|
|
1005
1181
|
/** runCli with its environment passed in (tests). */
|
|
1006
|
-
async function runWith(args,
|
|
1182
|
+
async function runWith(args, rawIO) {
|
|
1183
|
+
const io = {
|
|
1184
|
+
...rawIO,
|
|
1185
|
+
stderr: (text) => rawIO.stderr(printable(text))
|
|
1186
|
+
};
|
|
1007
1187
|
let values;
|
|
1008
1188
|
try {
|
|
1009
1189
|
values = parseArgs({
|
|
@@ -1017,6 +1197,7 @@ async function runWith(args, io) {
|
|
|
1017
1197
|
"max-file-size": { type: "string" },
|
|
1018
1198
|
"max-rows": { type: "string" },
|
|
1019
1199
|
"max-response-chars": { type: "string" },
|
|
1200
|
+
"max-cells": { type: "string" },
|
|
1020
1201
|
help: {
|
|
1021
1202
|
type: "boolean",
|
|
1022
1203
|
short: "h"
|
|
@@ -1044,13 +1225,15 @@ async function runWith(args, io) {
|
|
|
1044
1225
|
const maxFileBytes = positive(values["max-file-size"], "max-file-size");
|
|
1045
1226
|
const maxRows = positive(values["max-rows"], "max-rows");
|
|
1046
1227
|
const maxResponseChars = positive(values["max-response-chars"], "max-response-chars");
|
|
1228
|
+
const maxCells = positive(values["max-cells"], "max-cells");
|
|
1047
1229
|
prepared = await prepare({
|
|
1048
1230
|
roots,
|
|
1049
1231
|
cwd: io.cwd,
|
|
1050
1232
|
allowWrite: values["allow-write"] === true,
|
|
1051
1233
|
...maxFileBytes !== void 0 ? { maxFileBytes } : {},
|
|
1052
1234
|
...maxRows !== void 0 ? { maxRows } : {},
|
|
1053
|
-
...maxResponseChars !== void 0 ? { maxResponseChars } : {}
|
|
1235
|
+
...maxResponseChars !== void 0 ? { maxResponseChars } : {},
|
|
1236
|
+
...maxCells !== void 0 ? { maxCells } : {}
|
|
1054
1237
|
});
|
|
1055
1238
|
} catch (error) {
|
|
1056
1239
|
io.stderr(`xlvane-mcp: ${error instanceof Error ? error.message : String(error)}\n${isXlvError(error) ? `${error.hint}\n` : ""}`);
|
|
@@ -1063,7 +1246,7 @@ async function runWith(args, io) {
|
|
|
1063
1246
|
}
|
|
1064
1247
|
/**
|
|
1065
1248
|
* Runs the server from the command line: parses `--root`, `--allow-write`, `--max-file-size`,
|
|
1066
|
-
* `--max-rows` and `--max-
|
|
1249
|
+
* `--max-rows`, `--max-response-chars` and `--max-cells`, then serves MCP over stdio until the client closes stdin. Returns the exit
|
|
1067
1250
|
* code. `xlvane mcp` calls this function.
|
|
1068
1251
|
*
|
|
1069
1252
|
* @example
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xlvane/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"mcpName": "io.github.DevXord/xlvane",
|
|
5
5
|
"description": "MCP server for XLSX files: inspect, read, write, edit, convert, compare and validate workbooks with xlvane, limited to the directories given at start.",
|
|
6
6
|
"keywords": [
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@modelcontextprotocol/server": "2.4.0",
|
|
48
|
-
"xlvane": "^1.
|
|
48
|
+
"xlvane": "^1.3.0"
|
|
49
49
|
},
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"access": "public",
|