@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 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 permissions, and read-only files are not replaced. Any file name inside a root may be written, also dot files such as `.gitignore`; choose the roots accordingly.
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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as runCli } from "./server-B1Px_tvv.js";
2
+ import { n as runCli } from "./server-BKislJBl.js";
3
3
  import process from "node:process";
4
4
  // src/bin.ts
5
5
  runCli(process.argv.slice(2)).then((code) => {
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-response-chars`, then serves MCP over stdio until the client closes stdin. Returns the exit
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-B1Px_tvv.js";
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 { chmod, lstat, open, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
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 = async () => {
90
- let real;
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
- real = await realpath(target);
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: 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.",
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
- const notAFile = () => new XlvError("XLV_E_FILE_READ", `"${path}" is not a file.`, {
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 stat(await resolveInside());
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
- let dir;
176
- try {
177
- dir = await realpath(dirname(target));
178
- } catch (error) {
179
- throw new XlvError("XLV_E_FILE_WRITE", `The directory of "${path}" does not exist (${errorCode(error)}).`, {
180
- hint: "Write into an existing directory inside the allowed roots.",
181
- details: {
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 notAllowed(roots, path, " (it is a symbolic link)");
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 writeFile(temp, bytes, { flag: "wx" });
345
+ const handle = await open(temp, "wx", 384);
251
346
  created = true;
252
- await chmod(temp, existing.mode & 4095);
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, append.header !== void 0 ? { header: append.header } : void 0);
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" ? `${JSON.stringify(toJSON(workbook, sheet).rows, null, 2)}\n` : toCSV(workbook, {
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
- data = from === "json" ? fromJSON(text, {
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.2.2";
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, io) {
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-response-chars`, then serves MCP over stdio until the client closes stdin. Returns the exit
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.2.2",
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.2.2"
48
+ "xlvane": "^1.3.0"
49
49
  },
50
50
  "publishConfig": {
51
51
  "access": "public",