@xlvane/mcp 1.2.3 → 1.3.1
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 +1 -1
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{server-Lws8m9Ct.js → server-CkZA4cVE.js} +131 -40
- package/package.json +2 -2
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
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-CkZA4cVE.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`.
|
|
52
|
-
* a
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
|
|
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
|
|
69
|
-
*
|
|
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
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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(
|
|
103
|
+
info = await lstat(next);
|
|
80
104
|
} catch {
|
|
81
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
111
|
+
if (++links > 40) return true;
|
|
112
|
+
let text;
|
|
97
113
|
try {
|
|
98
|
-
|
|
114
|
+
text = await readlink(next);
|
|
99
115
|
} catch {
|
|
100
116
|
return true;
|
|
101
117
|
}
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
1092
|
+
const SERVER_VERSION = "1.3.1";
|
|
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.
|
|
3
|
+
"version": "1.3.1",
|
|
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.1"
|
|
49
49
|
},
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"access": "public",
|