@xlvane/mcp 1.2.3 → 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
@@ -61,7 +61,7 @@ Every tool has a JSON Schema for its input and returns its result as `structured
61
61
  import { createServer, runCli } from '@xlvane/mcp';
62
62
  ```
63
63
 
64
- `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.
65
65
 
66
66
  ## Links
67
67
 
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as runCli } from "./server-Lws8m9Ct.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.js CHANGED
@@ -1 +1 @@
1
- export { t as createServer, n as runCli } from "./server-Lws8m9Ct.js";
1
+ export { t as createServer, n as runCli } from "./server-BKislJBl.js";
@@ -4,7 +4,7 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
4
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
6
  import { lstat, open, readlink, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
7
- import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
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";
@@ -48,59 +48,79 @@ function inside(roots, real) {
48
48
  return within(roots.dirs, real);
49
49
  }
50
50
  /**
51
- * The real path of `target`. When it cannot be resolved (it does not exist, or a file stands where
52
- * a directory should), the nearest ancestor that exists is resolved instead: if that leads outside
53
- * the roots, the path is refused as outside, so the answer does not depend on what exists behind a
54
- * symbolic link inside a root (D-J01). Otherwise `onMissing` gives the error for the missing path.
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
55
  */
56
56
  async function realInside(roots, path, target, onMissing) {
57
- let real;
57
+ let real = null;
58
+ let failure = null;
58
59
  try {
59
60
  real = await realpath(target);
60
61
  } catch (error) {
61
- if (await leadsOutside(roots, target, 0)) throw notAllowed(roots, path, "");
62
- throw onMissing(error);
62
+ failure = error;
63
63
  }
64
+ if (await passesOutside(roots, target)) throw notAllowed(roots, path, "");
65
+ if (real === null) throw onMissing(failure);
64
66
  if (!inside(roots, real)) throw notAllowed(roots, path, "");
65
67
  return real;
66
68
  }
67
69
  /**
68
- * Whether a path that does not resolve would lead outside the roots: its nearest existing
69
- * ancestor is found with `lstat`; a symbolic link there (also one whose target is missing) is
70
- * followed by its text, anything else by its real path, and the rest of the path is added.
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).
71
72
  */
72
- async function leadsOutside(roots, path, depth) {
73
- if (depth > 40) return true;
74
- let ancestor = path;
75
- const rest = [];
76
- for (;;) {
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;
77
101
  let info;
78
102
  try {
79
- info = await lstat(ancestor);
103
+ info = await lstat(next);
80
104
  } catch {
81
- const parent = dirname(ancestor);
82
- if (parent === ancestor) return false;
83
- rest.unshift(basename(ancestor));
84
- ancestor = parent;
85
- continue;
105
+ return !inside(roots, resolve(next, ...pending));
86
106
  }
87
- if (info.isSymbolicLink()) {
88
- let text;
89
- try {
90
- text = await readlink(ancestor);
91
- } catch {
92
- return true;
93
- }
94
- return leadsOutside(roots, join(resolve(dirname(ancestor), text), ...rest), depth + 1);
107
+ if (!info.isSymbolicLink()) {
108
+ current = next;
109
+ continue;
95
110
  }
96
- let real;
111
+ if (++links > 40) return true;
112
+ let text;
97
113
  try {
98
- real = await realpath(ancestor);
114
+ text = await readlink(next);
99
115
  } catch {
100
116
  return true;
101
117
  }
102
- return !inside(roots, join(real, ...rest));
118
+ if (isAbsolute(text)) {
119
+ current = parse(text).root;
120
+ pending.unshift(...split(text));
121
+ } else pending.unshift(...text.split(sep === "\\" ? /[\\/]/ : "/").filter((part) => part !== ""));
103
122
  }
123
+ return !inside(roots, current);
104
124
  }
105
125
  function notAllowed(roots, path, why) {
106
126
  return new XlvError("XLV_E_PATH_NOT_ALLOWED", `"${path}" is outside the directories this server may use${why}.`, {
@@ -129,6 +149,21 @@ function absolute(roots, path) {
129
149
  });
130
150
  return full;
131
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
+ }
132
167
  /** Reads a file inside the roots, after checking its real path and size. */
133
168
  function readAllowed(roots, path) {
134
169
  return readAllowedWith(roots, path, {});
@@ -152,7 +187,21 @@ async function readAllowedWith(roots, path, hooks) {
152
187
  hint: "Pass the path of a file.",
153
188
  details: { path }
154
189
  });
155
- if (!(await stat(await resolveInside())).isFile()) throw notAFile();
190
+ const statOf = async () => {
191
+ const real = await resolveInside();
192
+ try {
193
+ return await stat(real);
194
+ } catch (error) {
195
+ throw new XlvError("XLV_E_FILE_READ", `Cannot read "${path}" (${errorCode(error)}).`, {
196
+ hint: "Check that the file still exists.",
197
+ details: {
198
+ path,
199
+ code: errorCode(error)
200
+ }
201
+ });
202
+ }
203
+ };
204
+ if (!(await statOf()).isFile()) throw notAFile();
156
205
  await hooks.afterCheck?.();
157
206
  let handle;
158
207
  try {
@@ -169,7 +218,7 @@ async function readAllowedWith(roots, path, hooks) {
169
218
  try {
170
219
  await hooks.afterOpen?.();
171
220
  const opened = await handle.stat();
172
- const now = await stat(await resolveInside());
221
+ const now = await statOf();
173
222
  if (now.dev !== opened.dev || now.ino !== opened.ino) throw new XlvError("XLV_E_FILE_READ", `"${path}" changed while it was being read.`, {
174
223
  hint: "Read the file again when nothing else is writing to it.",
175
224
  details: { path }
@@ -238,7 +287,13 @@ async function writeAllowed(roots, path, data, overwrite) {
238
287
  let existing = null;
239
288
  try {
240
289
  const info = await lstat(file);
241
- 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
+ });
242
297
  if (!info.isFile()) throw new XlvError("XLV_E_FILE_WRITE", `"${path}" is not a file.`, {
243
298
  hint: "Choose a file name that is not a directory.",
244
299
  details: {
@@ -284,7 +339,7 @@ async function writeAllowed(roots, path, data, overwrite) {
284
339
  };
285
340
  }
286
341
  const random = Array.from(globalThis.crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(8)), (b) => b.toString(16).padStart(2, "0")).join("");
287
- const temp = join(dir, `.${name}.xlvane-${random}.tmp`);
342
+ const temp = join(dir, `.${shortName(name)}.xlvane-${random}.tmp`);
288
343
  let created = false;
289
344
  try {
290
345
  const handle = await open(temp, "wx", 384);
@@ -473,6 +528,31 @@ async function book(roots, limits, path) {
473
528
  }
474
529
  }
475
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
+ /**
476
556
  * `JSON.stringify(rows, null, 2)` plus a line feed, built row by row: every row repeats the header
477
557
  * names, so a small file can make a text far larger than the file limit, or than a string can be.
478
558
  * The text stops as soon as it is longer than `maxChars` characters, before encoding (D-J01).
@@ -563,7 +643,7 @@ function typeOf(v) {
563
643
  function registerTools(server, roots, allowWrite, limits) {
564
644
  server.registerTool("inspect_workbook", {
565
645
  title: "Inspect a workbook",
566
- 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.",
567
647
  inputSchema: object({
568
648
  path: PATH,
569
649
  sheet: {
@@ -744,6 +824,7 @@ function registerTools(server, roots, allowWrite, limits) {
744
824
  };
745
825
  let sheetName = null;
746
826
  let rowsChecked = 0;
827
+ if (typeof a["sheet"] === "string") workbook.getSheet(a["sheet"]);
747
828
  if (rules.length > 0) {
748
829
  const table = readTable(workbook, typeof a["sheet"] === "string" ? { sheet: a["sheet"] } : {});
749
830
  sheetName = table.sheet;
@@ -816,6 +897,7 @@ function registerTools(server, roots, allowWrite, limits) {
816
897
  }, ["path", "rows"]),
817
898
  annotations: WRITE
818
899
  }, handler(limits.maxResponseChars, async (a) => {
900
+ checkCells(a["rows"], limits.maxCells, a["path"]);
819
901
  const rows = a["rows"].map((r) => Array.isArray(r) ? r.map(cellInput) : Object.fromEntries(Object.entries(r).map(([k, v]) => [k, cellInput(v)])));
820
902
  const bytes = writeTable(rows, {
821
903
  ...Array.isArray(a["columns"]) ? { columns: a["columns"] } : {},
@@ -889,6 +971,8 @@ function registerTools(server, roots, allowWrite, limits) {
889
971
  const edits = a["edits"] ?? [];
890
972
  const append = a["append"];
891
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);
892
976
  let bytes = await readAllowed(roots, path);
893
977
  const warnings = [];
894
978
  try {
@@ -970,7 +1054,14 @@ function registerTools(server, roots, allowWrite, limits) {
970
1054
  });
971
1055
  }
972
1056
  try {
973
- 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, {
974
1065
  ...sheet,
975
1066
  preset: "plain",
976
1067
  maxCells: limits.maxCells
@@ -998,7 +1089,7 @@ function registerTools(server, roots, allowWrite, limits) {
998
1089
  }
999
1090
  // src/server.ts
1000
1091
  /** Version reported in server info; kept equal to package.json by tests/mcp/server.test.ts. */
1001
- const SERVER_VERSION = "1.2.3";
1092
+ const SERVER_VERSION = "1.3.0";
1002
1093
  /** Default largest file read or written by the tools: 64 MiB. */
1003
1094
  const DEFAULT_MAX_FILE_BYTES = 67108864;
1004
1095
  /** Default largest number of rows returned by one read_table call. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xlvane/mcp",
3
- "version": "1.2.3",
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.3"
48
+ "xlvane": "^1.3.0"
49
49
  },
50
50
  "publishConfig": {
51
51
  "access": "public",