@xlvane/mcp 0.0.0-stage → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -0
- package/README.md +70 -2
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +10 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +1 -0
- package/dist/server-DqbH7i3w.js +1093 -0
- package/package.json +51 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DevXord
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
6
|
+
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
7
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
12
|
+
portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
15
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
16
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
18
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,71 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @xlvane/mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An MCP (Model Context Protocol) server for XLSX files, built on [xlvane](https://www.npmjs.com/package/xlvane) and the official MCP TypeScript SDK (`@modelcontextprotocol/server` 2.4.0, protocol revision 2026-07-28). An MCP client, such as a coding agent, can inspect, read, compare and validate workbooks, and optionally write, edit and convert them, through tools instead of code.
|
|
4
|
+
|
|
5
|
+
Requires Node.js 22 or later. Dependencies: `xlvane` and `@modelcontextprotocol/server` (which brings `@modelcontextprotocol/core` and `zod`).
|
|
6
|
+
|
|
7
|
+
## Start
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx @xlvane/mcp --root ./data # read-only, files inside ./data
|
|
11
|
+
npx @xlvane/mcp --root ./data --allow-write # also write_table, edit_cells, convert
|
|
12
|
+
xlvane mcp --root ./data # the same server, through the xlvane CLI
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Many MCP clients take a configuration like this one; check your client's documentation for its exact format and file:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"mcpServers": {
|
|
20
|
+
"xlvane": { "command": "npx", "args": ["@xlvane/mcp", "--root", "/absolute/path/to/data"] }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
| Option | Meaning |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `--root <dir>` | A directory the tools may use; repeat for several. Required. Relative paths in tool calls are resolved against the first one. |
|
|
28
|
+
| `--allow-write` | Also offer the tools that write files. Without it they are not registered at all. |
|
|
29
|
+
| `--max-file-size <bytes>` | Largest file read or written. Default 64 MiB. |
|
|
30
|
+
| `--max-rows <n>` | Largest number of rows one `read_table` call returns. Default 1000. |
|
|
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
|
+
|
|
33
|
+
The server speaks MCP over stdin and stdout; messages for people go to stderr.
|
|
34
|
+
|
|
35
|
+
## Tools
|
|
36
|
+
|
|
37
|
+
| Tool | Writes | What it does |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `inspect_workbook` | no | Sheets (also hidden ones), used ranges, header rows, column types with samples, merged cells, formulas, warnings. |
|
|
40
|
+
| `read_table` | no | Rows of a sheet as objects keyed by the header row; paging with `offset` and `limit`; dates as ISO 8601. |
|
|
41
|
+
| `diff` | no | Cell-by-cell differences between two workbooks. |
|
|
42
|
+
| `validate` | no | Read warnings, and column rules (`name`, `type`, `optional`) with the cell of each problem. |
|
|
43
|
+
| `write_table` | yes | Rows to a new XLSX file (report preset: bold header, frozen row, autofilter, widths). |
|
|
44
|
+
| `edit_cells` | yes | Set cells and append rows, keeping the rest of the file unchanged. |
|
|
45
|
+
| `convert` | yes | XLSX to CSV/TSV/JSON and back; CSV output escapes formula-like text. |
|
|
46
|
+
|
|
47
|
+
Every tool has a JSON Schema for its input and returns its result as `structuredContent` and as JSON text. Errors are tool results with `isError: true` and the `XlvError` data: a stable `code`, the `location`, a `hint` and `details` (docs/errors.md).
|
|
48
|
+
|
|
49
|
+
## Security
|
|
50
|
+
|
|
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).
|
|
54
|
+
- **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
|
+
- **No code execution:** macros are never run, external links are never followed, and formulas are not calculated.
|
|
56
|
+
|
|
57
|
+
## API
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { createServer, runCli } from '@xlvane/mcp';
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`createServer({ roots, allowWrite, maxFileBytes, maxRows, maxResponseChars })` returns an `McpServer` to connect to any transport; `runCli(args)` is the command line.
|
|
64
|
+
|
|
65
|
+
## Links
|
|
66
|
+
|
|
67
|
+
- Changes in each version: [CHANGELOG.md](https://github.com/DevXord/xlvane/blob/main/CHANGELOG.md)
|
|
68
|
+
- Bugs and questions: [GitHub issues](https://github.com/DevXord/xlvane/issues)
|
|
69
|
+
- Reporting a vulnerability: [SECURITY.md](https://github.com/DevXord/xlvane/blob/main/SECURITY.md)
|
|
70
|
+
|
|
71
|
+
License: MIT. The MCP SDK is licensed under Apache-2.0.
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { n as runCli } from "./server-DqbH7i3w.js";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
// src/bin.ts
|
|
5
|
+
runCli(process.argv.slice(2)).then((code) => {
|
|
6
|
+
process.exitCode = code;
|
|
7
|
+
}, (error) => {
|
|
8
|
+
process.stderr.write(`xlvane-mcp: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
|
9
|
+
process.exitCode = 3;
|
|
10
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
2
|
+
/**
|
|
3
|
+
* Options of {@link createServer}.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```ts
|
|
7
|
+
* import type { ServerOptions } from '@xlvane/mcp';
|
|
8
|
+
*
|
|
9
|
+
* const options: ServerOptions = { roots: ['./data'], allowWrite: false };
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
interface ServerOptions {
|
|
13
|
+
/** Directories the tools may use. Relative paths in tool calls are resolved against the first one. Required, at least one. */
|
|
14
|
+
readonly roots: readonly string[];
|
|
15
|
+
/** Register the tools that write files (write_table, edit_cells, convert). Default false. */
|
|
16
|
+
readonly allowWrite?: boolean;
|
|
17
|
+
/** Largest file read or written, in bytes. Default 64 MiB. */
|
|
18
|
+
readonly maxFileBytes?: number;
|
|
19
|
+
/** Largest number of rows one read_table call returns. Default 1000. */
|
|
20
|
+
readonly maxRows?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Largest tool result, in characters of its JSON text. read_table returns the rows that fit
|
|
23
|
+
* (`truncated`, `nextOffset`); other tools return `XLV_E_LIMIT_EXCEEDED`. Default 1 Mi.
|
|
24
|
+
*/
|
|
25
|
+
readonly maxResponseChars?: number;
|
|
26
|
+
/** Directory that relative roots are resolved against. Default: the current directory. */
|
|
27
|
+
readonly cwd?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Creates the xlvane MCP server with its tools. Connect it to any MCP transport; `runCli` serves
|
|
31
|
+
* it over stdio. The roots are resolved (symbolic links followed) once, here.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* import { createServer } from '@xlvane/mcp';
|
|
36
|
+
* import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
37
|
+
*
|
|
38
|
+
* const server = await createServer({ roots: ['./data'] });
|
|
39
|
+
* await server.connect(new StdioServerTransport());
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export declare function createServer(options: ServerOptions): Promise<McpServer>;
|
|
43
|
+
/**
|
|
44
|
+
* 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
|
|
46
|
+
* code. `xlvane mcp` calls this function.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* import { runCli } from '@xlvane/mcp';
|
|
51
|
+
*
|
|
52
|
+
* const code = await runCli(['--root', './data', '--allow-write']);
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export declare function runCli(args: readonly string[]): Promise<number>;
|
|
56
|
+
export type { ServerOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { t as createServer, n as runCli } from "./server-DqbH7i3w.js";
|
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { McpServer, fromJsonSchema } from "@modelcontextprotocol/server";
|
|
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";
|
|
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";
|
|
8
|
+
// src/paths.ts
|
|
9
|
+
function errorCode(error) {
|
|
10
|
+
return typeof error === "object" && error !== null && "code" in error ? String(error.code) : "UNKNOWN";
|
|
11
|
+
}
|
|
12
|
+
/** Resolves the --root directories; each must exist and be a directory. */
|
|
13
|
+
async function resolveRoots(dirs, cwd, maxFileBytes) {
|
|
14
|
+
if (dirs.length === 0) throw new XlvError("XLV_E_INVALID_OPTION", "No directory was given.", {
|
|
15
|
+
hint: "Start the server with at least one directory it may use, for example: xlvane-mcp --root ./data",
|
|
16
|
+
details: { option: "roots" }
|
|
17
|
+
});
|
|
18
|
+
const out = [];
|
|
19
|
+
const given = [];
|
|
20
|
+
for (const dir of dirs) {
|
|
21
|
+
const full = resolve(cwd, dir);
|
|
22
|
+
let real;
|
|
23
|
+
try {
|
|
24
|
+
real = await realpath(full);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw new XlvError("XLV_E_FILE_READ", `The directory "${dir}" does not exist (${errorCode(error)}).`, {
|
|
27
|
+
hint: "Pass an existing directory to --root.",
|
|
28
|
+
details: { path: dir }
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (!(await stat(real)).isDirectory()) throw new XlvError("XLV_E_FILE_READ", `"${dir}" is not a directory.`, {
|
|
32
|
+
hint: "Pass a directory to --root, not a file.",
|
|
33
|
+
details: { path: dir }
|
|
34
|
+
});
|
|
35
|
+
out.push(real);
|
|
36
|
+
given.push(full);
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
dirs: out,
|
|
40
|
+
given,
|
|
41
|
+
maxFileBytes
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function within(dirs, path) {
|
|
45
|
+
return dirs.some((root) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep));
|
|
46
|
+
}
|
|
47
|
+
function inside(roots, real) {
|
|
48
|
+
return within(roots.dirs, real);
|
|
49
|
+
}
|
|
50
|
+
function notAllowed(roots, path, why) {
|
|
51
|
+
return new XlvError("XLV_E_PATH_NOT_ALLOWED", `"${path}" is outside the directories this server may use${why}.`, {
|
|
52
|
+
hint: `Use a path inside ${roots.dirs.map((d) => `"${d}"`).join(" or ")}. Relative paths are resolved against the first one.`,
|
|
53
|
+
details: {
|
|
54
|
+
path,
|
|
55
|
+
roots: [...roots.dirs]
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The absolute, normalized path of a tool argument. A path that is not inside a root even before
|
|
61
|
+
* symbolic links are resolved is refused here, before the file system is touched, so the answer
|
|
62
|
+
* for a path outside the roots does not depend on what exists there (D-I04).
|
|
63
|
+
*/
|
|
64
|
+
function absolute(roots, path) {
|
|
65
|
+
if (typeof path !== "string" || path === "" || path.includes("\0")) throw new XlvError("XLV_E_INVALID_INPUT", "The path must be a non-empty string.", {
|
|
66
|
+
hint: "Pass a file name such as \"report.xlsx\".",
|
|
67
|
+
details: { path: String(path).slice(0, 200) }
|
|
68
|
+
});
|
|
69
|
+
const full = isAbsolute(path) ? resolve(path) : resolve(roots.dirs[0], path);
|
|
70
|
+
if (!within(roots.dirs, full) && !within(roots.given, full)) throw notAllowed(roots, path, "");
|
|
71
|
+
if (sep === "\\" && full.slice(2).includes(":")) throw new XlvError("XLV_E_INVALID_INPUT", `"${path}" contains ":" after the drive.`, {
|
|
72
|
+
hint: "Use a file name without \":\".",
|
|
73
|
+
details: { path }
|
|
74
|
+
});
|
|
75
|
+
return full;
|
|
76
|
+
}
|
|
77
|
+
/** Reads a file inside the roots, after checking its real path and size. */
|
|
78
|
+
function readAllowed(roots, path) {
|
|
79
|
+
return readAllowedWith(roots, path, {});
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* {@link readAllowed} with hooks for tests. The file is opened first and checked afterwards: the
|
|
83
|
+
* path must still resolve inside a root to the same file (device and inode) as the one opened,
|
|
84
|
+
* so a file or directory swapped for a symbolic link between the check and the read is refused
|
|
85
|
+
* (D-I02). At most the size limit plus one byte is read, whatever the size was when checked.
|
|
86
|
+
*/
|
|
87
|
+
async function readAllowedWith(roots, path, hooks) {
|
|
88
|
+
const target = absolute(roots, path);
|
|
89
|
+
const resolveInside = async () => {
|
|
90
|
+
let real;
|
|
91
|
+
try {
|
|
92
|
+
real = await realpath(target);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
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.",
|
|
96
|
+
details: {
|
|
97
|
+
path,
|
|
98
|
+
code: errorCode(error)
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (!inside(roots, real)) throw notAllowed(roots, path, real === target ? "" : " (after following symbolic links)");
|
|
103
|
+
return real;
|
|
104
|
+
};
|
|
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();
|
|
110
|
+
await hooks.afterCheck?.();
|
|
111
|
+
let handle;
|
|
112
|
+
try {
|
|
113
|
+
handle = await open(target, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new XlvError("XLV_E_FILE_READ", `Cannot read "${path}" (${errorCode(error)}).`, {
|
|
116
|
+
hint: "Check that the path points to a readable file.",
|
|
117
|
+
details: {
|
|
118
|
+
path,
|
|
119
|
+
code: errorCode(error)
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
await hooks.afterOpen?.();
|
|
125
|
+
const opened = await handle.stat();
|
|
126
|
+
const now = await stat(await resolveInside());
|
|
127
|
+
if (now.dev !== opened.dev || now.ino !== opened.ino) throw new XlvError("XLV_E_FILE_READ", `"${path}" changed while it was being read.`, {
|
|
128
|
+
hint: "Read the file again when nothing else is writing to it.",
|
|
129
|
+
details: { path }
|
|
130
|
+
});
|
|
131
|
+
if (!opened.isFile()) throw notAFile();
|
|
132
|
+
const tooLarge = (size) => new XlvError("XLV_E_LIMIT_EXCEEDED", `"${path}" is ${String(size)} bytes or more, more than the server's limit of ${String(roots.maxFileBytes)}.`, {
|
|
133
|
+
hint: "The limit is set when the server starts (--max-file-size).",
|
|
134
|
+
details: {
|
|
135
|
+
path,
|
|
136
|
+
limit: "maxFileBytes",
|
|
137
|
+
value: roots.maxFileBytes
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
if (opened.size > roots.maxFileBytes) throw tooLarge(opened.size);
|
|
141
|
+
await hooks.afterVerify?.();
|
|
142
|
+
const chunks = [];
|
|
143
|
+
let total = 0;
|
|
144
|
+
for (;;) {
|
|
145
|
+
const buffer = new Uint8Array(Math.min(1 << 20, roots.maxFileBytes + 1 - total));
|
|
146
|
+
if (buffer.length === 0) throw tooLarge(total);
|
|
147
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
|
|
148
|
+
if (bytesRead === 0) break;
|
|
149
|
+
chunks.push(buffer.subarray(0, bytesRead));
|
|
150
|
+
total += bytesRead;
|
|
151
|
+
}
|
|
152
|
+
const out = new Uint8Array(total);
|
|
153
|
+
let at = 0;
|
|
154
|
+
for (const c of chunks) {
|
|
155
|
+
out.set(c, at);
|
|
156
|
+
at += c.length;
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
} finally {
|
|
160
|
+
await handle.close();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Writes a file inside the roots. The real path of the directory must be inside a root and the
|
|
165
|
+
* file itself must not be a symbolic link. A new file is created exclusively ('wx'); an existing
|
|
166
|
+
* one is replaced only with `overwrite`, through a temporary file and a rename.
|
|
167
|
+
*/
|
|
168
|
+
async function writeAllowed(roots, path, data, overwrite) {
|
|
169
|
+
const target = absolute(roots, path);
|
|
170
|
+
const name = basename(target);
|
|
171
|
+
if (name === "" || name === "." || name === "..") throw new XlvError("XLV_E_INVALID_INPUT", `"${path}" is not a file name.`, {
|
|
172
|
+
hint: "Pass a path that ends in a file name.",
|
|
173
|
+
details: { path }
|
|
174
|
+
});
|
|
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)");
|
|
188
|
+
const file = join(dir, name);
|
|
189
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
190
|
+
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)}.`, {
|
|
191
|
+
hint: "Write fewer rows per file.",
|
|
192
|
+
details: {
|
|
193
|
+
path,
|
|
194
|
+
limit: "maxFileBytes",
|
|
195
|
+
value: roots.maxFileBytes
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
let existing = null;
|
|
199
|
+
try {
|
|
200
|
+
const info = await lstat(file);
|
|
201
|
+
if (info.isSymbolicLink()) throw notAllowed(roots, path, " (it is a symbolic link)");
|
|
202
|
+
if (!info.isFile()) throw new XlvError("XLV_E_FILE_WRITE", `"${path}" is not a file.`, {
|
|
203
|
+
hint: "Choose a file name that is not a directory.",
|
|
204
|
+
details: {
|
|
205
|
+
path,
|
|
206
|
+
code: "EISDIR"
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
existing = info;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (error instanceof XlvError) throw error;
|
|
212
|
+
}
|
|
213
|
+
if (existing !== null && !overwrite) throw new XlvError("XLV_E_FILE_EXISTS", `"${path}" already exists.`, {
|
|
214
|
+
hint: "Choose another path, or pass \"overwrite\": true to replace the file.",
|
|
215
|
+
details: { path }
|
|
216
|
+
});
|
|
217
|
+
if (existing !== null && (existing.mode & 128) === 0) throw new XlvError("XLV_E_FILE_WRITE", `"${path}" is read-only.`, {
|
|
218
|
+
hint: "Write to another path, or make the file writable first.",
|
|
219
|
+
details: {
|
|
220
|
+
path,
|
|
221
|
+
code: "EACCES"
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
if (existing === null) {
|
|
225
|
+
try {
|
|
226
|
+
await writeFile(file, bytes, { flag: "wx" });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (errorCode(error) === "EEXIST") throw new XlvError("XLV_E_FILE_EXISTS", `"${path}" already exists.`, {
|
|
229
|
+
hint: "Choose another path, or pass \"overwrite\": true to replace the file.",
|
|
230
|
+
details: { path }
|
|
231
|
+
});
|
|
232
|
+
throw new XlvError("XLV_E_FILE_WRITE", `Cannot write "${path}" (${errorCode(error)}).`, {
|
|
233
|
+
hint: "Check that the directory is writable.",
|
|
234
|
+
details: {
|
|
235
|
+
path,
|
|
236
|
+
code: errorCode(error)
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
path,
|
|
242
|
+
bytes: bytes.length,
|
|
243
|
+
replaced: false
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
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`);
|
|
248
|
+
let created = false;
|
|
249
|
+
try {
|
|
250
|
+
await writeFile(temp, bytes, { flag: "wx" });
|
|
251
|
+
created = true;
|
|
252
|
+
await chmod(temp, existing.mode & 4095);
|
|
253
|
+
await rename(temp, file);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (created) await rm(temp, { force: true });
|
|
256
|
+
throw new XlvError("XLV_E_FILE_WRITE", `Cannot write "${path}" (${errorCode(error)}).`, {
|
|
257
|
+
hint: "Check that the directory is writable.",
|
|
258
|
+
details: {
|
|
259
|
+
path,
|
|
260
|
+
code: errorCode(error)
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
path,
|
|
266
|
+
bytes: bytes.length,
|
|
267
|
+
replaced: true
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
// src/tools.ts
|
|
271
|
+
/** Problems `validate` lists; `problemsTotal` counts all of them (D-I02). */
|
|
272
|
+
const MAX_PROBLEMS = 1e3;
|
|
273
|
+
const READ = {
|
|
274
|
+
readOnlyHint: true,
|
|
275
|
+
openWorldHint: false
|
|
276
|
+
};
|
|
277
|
+
const WRITE = {
|
|
278
|
+
readOnlyHint: false,
|
|
279
|
+
destructiveHint: false,
|
|
280
|
+
idempotentHint: false,
|
|
281
|
+
openWorldHint: false
|
|
282
|
+
};
|
|
283
|
+
const PATH = {
|
|
284
|
+
type: "string",
|
|
285
|
+
minLength: 1,
|
|
286
|
+
description: "File path, relative to the first allowed directory or absolute inside an allowed directory."
|
|
287
|
+
};
|
|
288
|
+
const SHEET = {
|
|
289
|
+
type: "string",
|
|
290
|
+
description: "Sheet name. Default: the first visible worksheet."
|
|
291
|
+
};
|
|
292
|
+
const OVERWRITE = {
|
|
293
|
+
type: "boolean",
|
|
294
|
+
description: "Replace the output file if it exists. Default false: an existing file is never replaced."
|
|
295
|
+
};
|
|
296
|
+
const HEADER = {
|
|
297
|
+
description: "Header row: \"auto\" (default) detects it, a 1-based row number, or false for no header (columns are named by letter).",
|
|
298
|
+
anyOf: [
|
|
299
|
+
{ const: "auto" },
|
|
300
|
+
{
|
|
301
|
+
type: "integer",
|
|
302
|
+
minimum: 1
|
|
303
|
+
},
|
|
304
|
+
{ const: false }
|
|
305
|
+
]
|
|
306
|
+
};
|
|
307
|
+
const CELL_VALUE = {
|
|
308
|
+
description: "A number, string, boolean or null (clears the cell); {\"formula\": \"SUM(A1:A3)\", \"result\": 6} for a formula (without \"=\"; xlvane does not calculate it); {\"error\": \"#N/A\"}; {\"date\": \"2024-01-31\"} for a date (ISO 8601, no time zone).",
|
|
309
|
+
anyOf: [
|
|
310
|
+
{ type: [
|
|
311
|
+
"number",
|
|
312
|
+
"string",
|
|
313
|
+
"boolean",
|
|
314
|
+
"null"
|
|
315
|
+
] },
|
|
316
|
+
{
|
|
317
|
+
type: "object",
|
|
318
|
+
properties: {
|
|
319
|
+
formula: {
|
|
320
|
+
type: "string",
|
|
321
|
+
minLength: 1
|
|
322
|
+
},
|
|
323
|
+
result: { type: [
|
|
324
|
+
"number",
|
|
325
|
+
"string",
|
|
326
|
+
"boolean",
|
|
327
|
+
"null"
|
|
328
|
+
] }
|
|
329
|
+
},
|
|
330
|
+
required: ["formula"],
|
|
331
|
+
additionalProperties: false
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
type: "object",
|
|
335
|
+
properties: { error: { enum: [...ERROR_VALUES] } },
|
|
336
|
+
required: ["error"],
|
|
337
|
+
additionalProperties: false
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
type: "object",
|
|
341
|
+
properties: { date: {
|
|
342
|
+
type: "string",
|
|
343
|
+
pattern: "^\\d{4}-\\d{2}-\\d{2}([T ]\\d{2}:\\d{2}(:\\d{2}(\\.\\d{1,3})?)?)?$"
|
|
344
|
+
} },
|
|
345
|
+
required: ["date"],
|
|
346
|
+
additionalProperties: false
|
|
347
|
+
}
|
|
348
|
+
]
|
|
349
|
+
};
|
|
350
|
+
function object(properties, required = []) {
|
|
351
|
+
return fromJsonSchema({
|
|
352
|
+
type: "object",
|
|
353
|
+
properties,
|
|
354
|
+
required: [...required],
|
|
355
|
+
additionalProperties: false
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
function ok(result, maxChars) {
|
|
359
|
+
const text = JSON.stringify(result);
|
|
360
|
+
if (text.length > maxChars) return fail(new XlvError("XLV_E_LIMIT_EXCEEDED", `The result is ${String(text.length)} characters of JSON, more than the server's limit of ${String(maxChars)}.`, {
|
|
361
|
+
hint: "Ask for less: one sheet, a smaller range, fewer changes (maxChanges) or fewer rows; the limit is set when the server starts (--max-response-chars).",
|
|
362
|
+
details: {
|
|
363
|
+
limit: "maxResponseChars",
|
|
364
|
+
value: maxChars
|
|
365
|
+
}
|
|
366
|
+
}));
|
|
367
|
+
return {
|
|
368
|
+
content: [{
|
|
369
|
+
type: "text",
|
|
370
|
+
text
|
|
371
|
+
}],
|
|
372
|
+
structuredContent: result
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
function fail(error, maxChars = Number.POSITIVE_INFINITY) {
|
|
376
|
+
const e = isXlvError(error) ? error : new XlvError("XLV_E_INTERNAL", `Unexpected error: ${error instanceof Error ? error.message : String(error)}`, {
|
|
377
|
+
hint: "This is a bug in xlvane; the call may succeed with other input.",
|
|
378
|
+
details: { name: error instanceof Error ? error.name : typeof error }
|
|
379
|
+
});
|
|
380
|
+
let result = { error: e.toJSON() };
|
|
381
|
+
let text = JSON.stringify(result);
|
|
382
|
+
if (text.length > maxChars) {
|
|
383
|
+
const json = e.toJSON();
|
|
384
|
+
result = { error: {
|
|
385
|
+
...json,
|
|
386
|
+
message: `${json.message.slice(0, Math.max(0, Math.floor(maxChars / 4)))}…`,
|
|
387
|
+
hint: json.hint.slice(0, Math.max(0, Math.floor(maxChars / 4))),
|
|
388
|
+
details: { cut: true }
|
|
389
|
+
} };
|
|
390
|
+
text = JSON.stringify(result);
|
|
391
|
+
if (text.length > maxChars) {
|
|
392
|
+
result = { error: {
|
|
393
|
+
name: json.name,
|
|
394
|
+
code: json.code,
|
|
395
|
+
message: "The error message is longer than the result limit.",
|
|
396
|
+
location: {},
|
|
397
|
+
hint: "",
|
|
398
|
+
docsUrl: json.docsUrl,
|
|
399
|
+
details: { cut: true }
|
|
400
|
+
} };
|
|
401
|
+
text = JSON.stringify(result);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
content: [{
|
|
406
|
+
type: "text",
|
|
407
|
+
text
|
|
408
|
+
}],
|
|
409
|
+
structuredContent: result,
|
|
410
|
+
isError: true
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
function handler(maxChars, run) {
|
|
414
|
+
return async (args) => {
|
|
415
|
+
try {
|
|
416
|
+
return ok(await run(args ?? {}), maxChars);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
return fail(error, maxChars);
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
async function book(roots, path) {
|
|
423
|
+
const bytes = await readAllowed(roots, path);
|
|
424
|
+
try {
|
|
425
|
+
return readWorkbook(bytes);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
throw withPath(error, path);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function withPath(error, path) {
|
|
431
|
+
if (error instanceof XlvError && error.details["path"] === void 0) return new XlvError(error.code, error.message, {
|
|
432
|
+
location: error.location,
|
|
433
|
+
hint: error.hint,
|
|
434
|
+
details: {
|
|
435
|
+
...error.details,
|
|
436
|
+
path
|
|
437
|
+
},
|
|
438
|
+
cause: error
|
|
439
|
+
});
|
|
440
|
+
return error;
|
|
441
|
+
}
|
|
442
|
+
function isoValues(row) {
|
|
443
|
+
return Object.fromEntries(Object.entries(row).map(([k, v]) => [k, v instanceof Date ? v.toISOString() : v]));
|
|
444
|
+
}
|
|
445
|
+
/** Converts a JSON cell value of the tools to a CellInput ({ date } becomes a Date, read as UTC). */
|
|
446
|
+
function cellInput(value) {
|
|
447
|
+
if (value !== null && typeof value === "object" && "date" in value) {
|
|
448
|
+
const text = String(value.date).replace(" ", "T");
|
|
449
|
+
const invalid = () => new XlvError("XLV_E_INVALID_VALUE", `"${text.slice(0, 40)}" is not a valid date.`, { hint: "Use ISO 8601, for example {\"date\": \"2024-01-31\"} or {\"date\": \"2024-01-31T13:45:00\"}." });
|
|
450
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?$/.exec(text);
|
|
451
|
+
if (m === null) throw invalid();
|
|
452
|
+
const d = /* @__PURE__ */ new Date(/T/.test(text) ? `${text}Z` : `${text}T00:00:00Z`);
|
|
453
|
+
const parts = [
|
|
454
|
+
d.getUTCFullYear(),
|
|
455
|
+
d.getUTCMonth() + 1,
|
|
456
|
+
d.getUTCDate(),
|
|
457
|
+
d.getUTCHours(),
|
|
458
|
+
d.getUTCMinutes(),
|
|
459
|
+
d.getUTCSeconds()
|
|
460
|
+
];
|
|
461
|
+
const wanted = [
|
|
462
|
+
m[1],
|
|
463
|
+
m[2],
|
|
464
|
+
m[3],
|
|
465
|
+
m[4] ?? "0",
|
|
466
|
+
m[5] ?? "0",
|
|
467
|
+
m[6] ?? "0"
|
|
468
|
+
].map(Number);
|
|
469
|
+
if (Number.isNaN(d.getTime()) || parts.some((v, i) => v !== wanted[i])) throw invalid();
|
|
470
|
+
return d;
|
|
471
|
+
}
|
|
472
|
+
return value;
|
|
473
|
+
}
|
|
474
|
+
function extension(path) {
|
|
475
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(path);
|
|
476
|
+
return m ? m[1].toLowerCase() : "";
|
|
477
|
+
}
|
|
478
|
+
const TYPES = [
|
|
479
|
+
"number",
|
|
480
|
+
"string",
|
|
481
|
+
"date",
|
|
482
|
+
"boolean",
|
|
483
|
+
"any"
|
|
484
|
+
];
|
|
485
|
+
function typeOf(v) {
|
|
486
|
+
if (v === null || v === void 0 || v === "") return "empty";
|
|
487
|
+
if (typeof v === "number") return "number";
|
|
488
|
+
if (typeof v === "boolean") return "boolean";
|
|
489
|
+
if (v instanceof Date) return "date";
|
|
490
|
+
return "string";
|
|
491
|
+
}
|
|
492
|
+
/** Registers the read tools, and the write tools when writing is allowed, in a fixed order. */
|
|
493
|
+
function registerTools(server, roots, allowWrite, limits) {
|
|
494
|
+
server.registerTool("inspect_workbook", {
|
|
495
|
+
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.",
|
|
497
|
+
inputSchema: object({
|
|
498
|
+
path: PATH,
|
|
499
|
+
sheet: {
|
|
500
|
+
...SHEET,
|
|
501
|
+
description: "Describe only this sheet."
|
|
502
|
+
}
|
|
503
|
+
}, ["path"]),
|
|
504
|
+
annotations: READ
|
|
505
|
+
}, handler(limits.maxResponseChars, async (a) => {
|
|
506
|
+
const workbook = await book(roots, a["path"]);
|
|
507
|
+
const report = inspect(workbook);
|
|
508
|
+
if (typeof a["sheet"] !== "string") return report;
|
|
509
|
+
const sheet = workbook.getSheet(a["sheet"]);
|
|
510
|
+
return {
|
|
511
|
+
...report,
|
|
512
|
+
sheets: report.sheets.filter((s) => s.name === sheet.name),
|
|
513
|
+
warnings: report.warnings.filter((w) => w.location.sheet === void 0 || w.location.sheet === sheet.name)
|
|
514
|
+
};
|
|
515
|
+
}));
|
|
516
|
+
server.registerTool("read_table", {
|
|
517
|
+
title: "Read a sheet as rows",
|
|
518
|
+
description: `Reads a sheet as an array of row objects keyed by the header row (or by column letters with header: false). Dates are returned as ISO 8601 strings in UTC, error cells as their code (e.g. "#N/A"), formulas as their cached result. Returns at most ${String(limits.maxRows)} rows per call; use offset to page through larger sheets (totalRows and truncated say whether more rows exist).`,
|
|
519
|
+
inputSchema: object({
|
|
520
|
+
path: PATH,
|
|
521
|
+
sheet: SHEET,
|
|
522
|
+
range: {
|
|
523
|
+
type: "string",
|
|
524
|
+
description: "Only this A1 range, e.g. \"A1:D100\"."
|
|
525
|
+
},
|
|
526
|
+
header: HEADER,
|
|
527
|
+
offset: {
|
|
528
|
+
type: "integer",
|
|
529
|
+
minimum: 0,
|
|
530
|
+
description: "Rows to skip. Default 0."
|
|
531
|
+
},
|
|
532
|
+
limit: {
|
|
533
|
+
type: "integer",
|
|
534
|
+
minimum: 1,
|
|
535
|
+
maximum: limits.maxRows,
|
|
536
|
+
description: `Rows to return. Default ${String(Math.min(200, limits.maxRows))}.`
|
|
537
|
+
}
|
|
538
|
+
}, ["path"]),
|
|
539
|
+
annotations: READ
|
|
540
|
+
}, handler(limits.maxResponseChars, async (a) => {
|
|
541
|
+
const workbook = await book(roots, a["path"]);
|
|
542
|
+
const table = readTable(workbook, {
|
|
543
|
+
...typeof a["sheet"] === "string" ? { sheet: a["sheet"] } : {},
|
|
544
|
+
...typeof a["range"] === "string" ? { range: a["range"] } : {},
|
|
545
|
+
header: a["header"] ?? "auto"
|
|
546
|
+
});
|
|
547
|
+
const offset = a["offset"] ?? 0;
|
|
548
|
+
const limit = a["limit"] ?? Math.min(200, limits.maxRows);
|
|
549
|
+
const head = {
|
|
550
|
+
sheet: table.sheet,
|
|
551
|
+
headerRow: table.headerRow,
|
|
552
|
+
range: table.range,
|
|
553
|
+
columns: table.columns.map((c) => ({
|
|
554
|
+
key: c.key,
|
|
555
|
+
header: c.header,
|
|
556
|
+
letter: c.letter
|
|
557
|
+
}))
|
|
558
|
+
};
|
|
559
|
+
let used = JSON.stringify({
|
|
560
|
+
...head,
|
|
561
|
+
rows: [],
|
|
562
|
+
rowNumbers: [],
|
|
563
|
+
offset,
|
|
564
|
+
totalRows: table.rows.length,
|
|
565
|
+
truncated: true,
|
|
566
|
+
nextOffset: table.rows.length
|
|
567
|
+
}).length;
|
|
568
|
+
const rows = [];
|
|
569
|
+
for (let i = offset; i < Math.min(table.rows.length, offset + limit); i++) {
|
|
570
|
+
const row = isoValues(table.rows[i]);
|
|
571
|
+
const cost = JSON.stringify(row).length + String(table.rowNumbers[i]).length + 2;
|
|
572
|
+
if (used + cost > limits.maxResponseChars) break;
|
|
573
|
+
used += cost;
|
|
574
|
+
rows.push(row);
|
|
575
|
+
}
|
|
576
|
+
if (rows.length === 0 && offset < table.rows.length && limit > 0) throw new XlvError("XLV_E_LIMIT_EXCEEDED", `Row ${String(table.rowNumbers[offset])} alone is larger than the server's result limit of ${String(limits.maxResponseChars)} characters.`, {
|
|
577
|
+
hint: "Read fewer columns with range (for example \"A1:D100\"); the limit is set when the server starts (--max-response-chars).",
|
|
578
|
+
details: {
|
|
579
|
+
limit: "maxResponseChars",
|
|
580
|
+
value: limits.maxResponseChars
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
const end = offset + rows.length;
|
|
584
|
+
return {
|
|
585
|
+
...head,
|
|
586
|
+
rows,
|
|
587
|
+
rowNumbers: table.rowNumbers.slice(offset, end),
|
|
588
|
+
offset,
|
|
589
|
+
totalRows: table.rows.length,
|
|
590
|
+
truncated: end < table.rows.length,
|
|
591
|
+
...end < table.rows.length ? { nextOffset: end } : {}
|
|
592
|
+
};
|
|
593
|
+
}));
|
|
594
|
+
server.registerTool("diff", {
|
|
595
|
+
title: "Compare two workbooks",
|
|
596
|
+
description: "Compares two XLSX files cell by cell (values and formulas; formatting with styles: true). Sheets are matched by name. Use it to check the result of an edit.",
|
|
597
|
+
inputSchema: object({
|
|
598
|
+
before: PATH,
|
|
599
|
+
after: PATH,
|
|
600
|
+
styles: {
|
|
601
|
+
type: "boolean",
|
|
602
|
+
description: "Also compare cell formatting. Default false."
|
|
603
|
+
},
|
|
604
|
+
maxChanges: {
|
|
605
|
+
type: "integer",
|
|
606
|
+
minimum: 1,
|
|
607
|
+
maximum: 1e4,
|
|
608
|
+
description: "Changes listed per sheet. Default 200; the summary always counts all of them."
|
|
609
|
+
}
|
|
610
|
+
}, ["before", "after"]),
|
|
611
|
+
annotations: READ
|
|
612
|
+
}, handler(limits.maxResponseChars, async (a) => {
|
|
613
|
+
const before = await book(roots, a["before"]);
|
|
614
|
+
const after = await book(roots, a["after"]);
|
|
615
|
+
const r = diff(before, after, { styles: a["styles"] === true });
|
|
616
|
+
const max = a["maxChanges"] ?? 200;
|
|
617
|
+
return {
|
|
618
|
+
...r,
|
|
619
|
+
sheets: r.sheets.map((s) => ({
|
|
620
|
+
...s,
|
|
621
|
+
cells: s.cells.slice(0, max),
|
|
622
|
+
listed: Math.min(max, s.cells.length),
|
|
623
|
+
total: s.cells.length
|
|
624
|
+
}))
|
|
625
|
+
};
|
|
626
|
+
}));
|
|
627
|
+
server.registerTool("validate", {
|
|
628
|
+
title: "Check a workbook and its columns",
|
|
629
|
+
description: "Reports read warnings of an XLSX file and, with columns, checks a table: each listed column must exist in the header row, its cells must not be empty unless optional is true, and must have the given type. Each problem names the cell. ok is false when problems were found (warnings count only with strict: true).",
|
|
630
|
+
inputSchema: object({
|
|
631
|
+
path: PATH,
|
|
632
|
+
sheet: SHEET,
|
|
633
|
+
columns: {
|
|
634
|
+
type: "array",
|
|
635
|
+
maxItems: 256,
|
|
636
|
+
items: {
|
|
637
|
+
type: "object",
|
|
638
|
+
properties: {
|
|
639
|
+
name: {
|
|
640
|
+
type: "string",
|
|
641
|
+
minLength: 1
|
|
642
|
+
},
|
|
643
|
+
type: { enum: [...TYPES] },
|
|
644
|
+
optional: { type: "boolean" }
|
|
645
|
+
},
|
|
646
|
+
required: ["name"],
|
|
647
|
+
additionalProperties: false
|
|
648
|
+
},
|
|
649
|
+
description: "Column rules, matched to the header row by name. type defaults to \"any\"."
|
|
650
|
+
},
|
|
651
|
+
strict: {
|
|
652
|
+
type: "boolean",
|
|
653
|
+
description: "Treat warnings as problems. Default false."
|
|
654
|
+
}
|
|
655
|
+
}, ["path"]),
|
|
656
|
+
annotations: READ
|
|
657
|
+
}, handler(limits.maxResponseChars, async (a) => {
|
|
658
|
+
const workbook = await book(roots, a["path"]);
|
|
659
|
+
const warnings = inspect(workbook).warnings;
|
|
660
|
+
const rules = a["columns"] ?? [];
|
|
661
|
+
const names = /* @__PURE__ */ new Set();
|
|
662
|
+
for (const rule of rules) {
|
|
663
|
+
if (names.has(rule.name)) throw new XlvError("XLV_E_INVALID_INPUT", `Column "${rule.name.slice(0, 100)}" has more than one rule.`, {
|
|
664
|
+
hint: "Give each column one rule.",
|
|
665
|
+
details: { column: rule.name.slice(0, 100) }
|
|
666
|
+
});
|
|
667
|
+
names.add(rule.name);
|
|
668
|
+
}
|
|
669
|
+
const problems = [];
|
|
670
|
+
let problemsTotal = 0;
|
|
671
|
+
const problem = (p) => {
|
|
672
|
+
problemsTotal++;
|
|
673
|
+
if (problems.length < MAX_PROBLEMS) problems.push(p());
|
|
674
|
+
};
|
|
675
|
+
let sheetName = null;
|
|
676
|
+
let rowsChecked = 0;
|
|
677
|
+
if (rules.length > 0) {
|
|
678
|
+
const table = readTable(workbook, typeof a["sheet"] === "string" ? { sheet: a["sheet"] } : {});
|
|
679
|
+
sheetName = table.sheet;
|
|
680
|
+
rowsChecked = table.rows.length;
|
|
681
|
+
for (const rule of rules) {
|
|
682
|
+
const column = table.columns.find((c) => c.header === rule.name);
|
|
683
|
+
if (!column) {
|
|
684
|
+
problem(() => ({
|
|
685
|
+
kind: "missing-column",
|
|
686
|
+
column: rule.name,
|
|
687
|
+
cell: null,
|
|
688
|
+
row: table.headerRow,
|
|
689
|
+
message: `Column "${rule.name}" is missing from the header row.`
|
|
690
|
+
}));
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
table.rows.forEach((row, i) => {
|
|
694
|
+
const cell = `${column.letter}${String(table.rowNumbers[i])}`;
|
|
695
|
+
const t = typeOf(row[column.key]);
|
|
696
|
+
const want = rule.type ?? "any";
|
|
697
|
+
if (t === "empty") {
|
|
698
|
+
if (rule.optional !== true) problem(() => ({
|
|
699
|
+
kind: "empty",
|
|
700
|
+
column: rule.name,
|
|
701
|
+
cell,
|
|
702
|
+
row: table.rowNumbers[i],
|
|
703
|
+
message: `${cell} is empty; "${rule.name}" needs a value.`
|
|
704
|
+
}));
|
|
705
|
+
} else if (want !== "any" && t !== want) problem(() => ({
|
|
706
|
+
kind: "type",
|
|
707
|
+
column: rule.name,
|
|
708
|
+
cell,
|
|
709
|
+
row: table.rowNumbers[i],
|
|
710
|
+
message: `${cell} is a ${t}; "${rule.name}" needs a ${want}.`
|
|
711
|
+
}));
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return {
|
|
716
|
+
ok: problemsTotal === 0 && (a["strict"] !== true || warnings.length === 0),
|
|
717
|
+
sheet: sheetName,
|
|
718
|
+
rowsChecked,
|
|
719
|
+
problems,
|
|
720
|
+
problemsTotal,
|
|
721
|
+
warnings
|
|
722
|
+
};
|
|
723
|
+
}));
|
|
724
|
+
if (!allowWrite) return;
|
|
725
|
+
server.registerTool("write_table", {
|
|
726
|
+
title: "Write rows to a new XLSX file",
|
|
727
|
+
description: "Writes an array of row objects (or arrays) to an XLSX file. The \"report\" preset (default) adds a bold header row, frozen first row, autofilter, fitted column widths and date and number formats; \"plain\" writes values only. Strings that look like ISO dates stay strings; pass {\"date\": \"...\"} for dates.",
|
|
728
|
+
inputSchema: object({
|
|
729
|
+
path: PATH,
|
|
730
|
+
rows: {
|
|
731
|
+
type: "array",
|
|
732
|
+
items: { type: ["object", "array"] },
|
|
733
|
+
description: "Row objects (keys become columns) or arrays."
|
|
734
|
+
},
|
|
735
|
+
columns: {
|
|
736
|
+
type: "array",
|
|
737
|
+
items: { type: "string" },
|
|
738
|
+
description: "Keys to include, in this order. Default: every key, in order of first appearance."
|
|
739
|
+
},
|
|
740
|
+
sheet: {
|
|
741
|
+
type: "string",
|
|
742
|
+
description: "Sheet name. Default \"Sheet1\"."
|
|
743
|
+
},
|
|
744
|
+
preset: { enum: ["report", "plain"] },
|
|
745
|
+
overwrite: OVERWRITE
|
|
746
|
+
}, ["path", "rows"]),
|
|
747
|
+
annotations: WRITE
|
|
748
|
+
}, handler(limits.maxResponseChars, async (a) => {
|
|
749
|
+
const rows = a["rows"].map((r) => Array.isArray(r) ? r.map(cellInput) : Object.fromEntries(Object.entries(r).map(([k, v]) => [k, cellInput(v)])));
|
|
750
|
+
const bytes = writeTable(rows, {
|
|
751
|
+
...Array.isArray(a["columns"]) ? { columns: a["columns"] } : {},
|
|
752
|
+
...typeof a["sheet"] === "string" ? { sheet: a["sheet"] } : {},
|
|
753
|
+
...typeof a["preset"] === "string" ? { preset: a["preset"] } : {}
|
|
754
|
+
});
|
|
755
|
+
return {
|
|
756
|
+
...await writeAllowed(roots, a["path"], bytes, a["overwrite"] === true),
|
|
757
|
+
rows: rows.length
|
|
758
|
+
};
|
|
759
|
+
}));
|
|
760
|
+
server.registerTool("edit_cells", {
|
|
761
|
+
title: "Change cells or append rows",
|
|
762
|
+
description: "Changes cells and appends rows in an existing XLSX file while keeping everything else in it (styles, formulas, other sheets) unchanged. The result is written to output; to change the file in place, omit output and pass \"overwrite\": true. xlvane does not calculate formulas; the edited file asks spreadsheet programs to recalculate them when it is opened.",
|
|
763
|
+
inputSchema: object({
|
|
764
|
+
path: PATH,
|
|
765
|
+
edits: {
|
|
766
|
+
type: "array",
|
|
767
|
+
items: {
|
|
768
|
+
type: "object",
|
|
769
|
+
properties: {
|
|
770
|
+
sheet: {
|
|
771
|
+
type: "string",
|
|
772
|
+
minLength: 1
|
|
773
|
+
},
|
|
774
|
+
cell: {
|
|
775
|
+
type: "string",
|
|
776
|
+
pattern: "^[A-Za-z]{1,3}[1-9][0-9]{0,6}$"
|
|
777
|
+
},
|
|
778
|
+
value: CELL_VALUE
|
|
779
|
+
},
|
|
780
|
+
required: [
|
|
781
|
+
"sheet",
|
|
782
|
+
"cell",
|
|
783
|
+
"value"
|
|
784
|
+
],
|
|
785
|
+
additionalProperties: false
|
|
786
|
+
},
|
|
787
|
+
description: "Cells to set, e.g. [{\"sheet\": \"Sales\", \"cell\": \"B2\", \"value\": 1250}]."
|
|
788
|
+
},
|
|
789
|
+
append: {
|
|
790
|
+
type: "object",
|
|
791
|
+
properties: {
|
|
792
|
+
sheet: {
|
|
793
|
+
type: "string",
|
|
794
|
+
minLength: 1
|
|
795
|
+
},
|
|
796
|
+
rows: {
|
|
797
|
+
type: "array",
|
|
798
|
+
items: { type: ["object", "array"] }
|
|
799
|
+
},
|
|
800
|
+
header: {
|
|
801
|
+
type: "integer",
|
|
802
|
+
minimum: 1,
|
|
803
|
+
description: "Row that holds the column names, for object rows. Default: detected."
|
|
804
|
+
}
|
|
805
|
+
},
|
|
806
|
+
required: ["sheet", "rows"],
|
|
807
|
+
additionalProperties: false,
|
|
808
|
+
description: "Rows to add below the last row; object keys are matched to the header row."
|
|
809
|
+
},
|
|
810
|
+
output: {
|
|
811
|
+
...PATH,
|
|
812
|
+
description: "Where to write the result. Default: the input file, which requires \"overwrite\": true."
|
|
813
|
+
},
|
|
814
|
+
overwrite: OVERWRITE
|
|
815
|
+
}, ["path"]),
|
|
816
|
+
annotations: WRITE
|
|
817
|
+
}, handler(limits.maxResponseChars, async (a) => {
|
|
818
|
+
const path = a["path"];
|
|
819
|
+
const edits = a["edits"] ?? [];
|
|
820
|
+
const append = a["append"];
|
|
821
|
+
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\"}." });
|
|
822
|
+
let bytes = await readAllowed(roots, path);
|
|
823
|
+
const warnings = [];
|
|
824
|
+
try {
|
|
825
|
+
if (edits.length > 0) {
|
|
826
|
+
const r = editCells(bytes, edits.map((e) => ({
|
|
827
|
+
sheet: e.sheet,
|
|
828
|
+
cell: e.cell.toUpperCase(),
|
|
829
|
+
value: cellInput(e.value)
|
|
830
|
+
})));
|
|
831
|
+
bytes = r.bytes;
|
|
832
|
+
warnings.push(...r.warnings);
|
|
833
|
+
}
|
|
834
|
+
if (append) {
|
|
835
|
+
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);
|
|
837
|
+
bytes = r.bytes;
|
|
838
|
+
warnings.push(...r.warnings);
|
|
839
|
+
}
|
|
840
|
+
} catch (error) {
|
|
841
|
+
throw withPath(error, path);
|
|
842
|
+
}
|
|
843
|
+
return {
|
|
844
|
+
...await writeAllowed(roots, typeof a["output"] === "string" ? a["output"] : path, bytes, a["overwrite"] === true),
|
|
845
|
+
cellsSet: edits.length,
|
|
846
|
+
rowsAppended: append?.rows.length ?? 0,
|
|
847
|
+
warnings
|
|
848
|
+
};
|
|
849
|
+
}));
|
|
850
|
+
server.registerTool("convert", {
|
|
851
|
+
title: "Convert between XLSX, CSV and JSON",
|
|
852
|
+
description: "Converts a sheet of an XLSX file to CSV, TSV or JSON (an array of row objects), or CSV, TSV or JSON to a plain XLSX file. Formats come from the file extensions. CSV output escapes text that starts with =, +, -, @ and similar characters, so spreadsheet programs do not run it as a formula; dates are written as ISO 8601.",
|
|
853
|
+
inputSchema: object({
|
|
854
|
+
input: PATH,
|
|
855
|
+
output: PATH,
|
|
856
|
+
sheet: {
|
|
857
|
+
type: "string",
|
|
858
|
+
description: "Sheet to convert (XLSX input), or the sheet name (XLSX output)."
|
|
859
|
+
},
|
|
860
|
+
delimiter: {
|
|
861
|
+
type: "string",
|
|
862
|
+
minLength: 1,
|
|
863
|
+
maxLength: 1,
|
|
864
|
+
description: "CSV delimiter. Default \",\" (tab for .tsv)."
|
|
865
|
+
},
|
|
866
|
+
overwrite: OVERWRITE
|
|
867
|
+
}, ["input", "output"]),
|
|
868
|
+
annotations: WRITE
|
|
869
|
+
}, handler(limits.maxResponseChars, async (a) => {
|
|
870
|
+
const input = a["input"];
|
|
871
|
+
const output = a["output"];
|
|
872
|
+
const from = extension(input);
|
|
873
|
+
const to = extension(output);
|
|
874
|
+
const tables = [
|
|
875
|
+
"csv",
|
|
876
|
+
"tsv",
|
|
877
|
+
"json"
|
|
878
|
+
];
|
|
879
|
+
const books = ["xlsx", "xlsm"];
|
|
880
|
+
const sheet = typeof a["sheet"] === "string" ? { sheet: a["sheet"] } : {};
|
|
881
|
+
let data;
|
|
882
|
+
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, {
|
|
885
|
+
...sheet,
|
|
886
|
+
delimiter: a["delimiter"] ?? (to === "tsv" ? " " : ",")
|
|
887
|
+
});
|
|
888
|
+
} else if (tables.includes(from) && to === "xlsx") {
|
|
889
|
+
let text;
|
|
890
|
+
try {
|
|
891
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(await readAllowed(roots, input));
|
|
892
|
+
} catch (error) {
|
|
893
|
+
if (error instanceof XlvError) throw error;
|
|
894
|
+
throw new XlvError("XLV_E_INVALID_INPUT", `"${input}" is not valid UTF-8 text.`, {
|
|
895
|
+
hint: "Save the CSV or JSON file as UTF-8 and convert it again.",
|
|
896
|
+
details: { path: input }
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
try {
|
|
900
|
+
data = from === "json" ? fromJSON(text, {
|
|
901
|
+
...sheet,
|
|
902
|
+
preset: "plain"
|
|
903
|
+
}) : fromCSV(text, {
|
|
904
|
+
...sheet,
|
|
905
|
+
delimiter: a["delimiter"] ?? (from === "tsv" ? " " : ",")
|
|
906
|
+
});
|
|
907
|
+
} catch (error) {
|
|
908
|
+
throw withPath(error, input);
|
|
909
|
+
}
|
|
910
|
+
} else throw new XlvError("XLV_E_INVALID_INPUT", `Cannot convert .${from || "?"} to .${to || "?"}.`, {
|
|
911
|
+
hint: "Supported: .xlsx/.xlsm to .csv/.tsv/.json, and .csv/.tsv/.json to .xlsx.",
|
|
912
|
+
details: {
|
|
913
|
+
input,
|
|
914
|
+
output
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
return {
|
|
918
|
+
...await writeAllowed(roots, output, data, a["overwrite"] === true),
|
|
919
|
+
from,
|
|
920
|
+
to
|
|
921
|
+
};
|
|
922
|
+
}));
|
|
923
|
+
}
|
|
924
|
+
// src/server.ts
|
|
925
|
+
/** Version reported in server info; kept equal to package.json by tests/mcp/server.test.ts. */
|
|
926
|
+
const SERVER_VERSION = "1.2.0";
|
|
927
|
+
/** Default largest file read or written by the tools: 64 MiB. */
|
|
928
|
+
const DEFAULT_MAX_FILE_BYTES = 67108864;
|
|
929
|
+
/** Default largest number of rows returned by one read_table call. */
|
|
930
|
+
const DEFAULT_MAX_ROWS = 1e3;
|
|
931
|
+
/** Default largest tool result, in characters of its JSON text: 1 Mi (D-I02). */
|
|
932
|
+
const DEFAULT_MAX_RESPONSE_CHARS = 1048576;
|
|
933
|
+
/** Facts about the configuration, shown to the client as server instructions. */
|
|
934
|
+
function instructions(roots, allowWrite, limits) {
|
|
935
|
+
return [
|
|
936
|
+
`Tools for XLSX workbooks (xlvane). Files are read only inside: ${roots.dirs.join(", ")}. Relative paths are resolved against ${roots.dirs[0]}.`,
|
|
937
|
+
allowWrite ? "Writing is enabled: write_table, edit_cells and convert create new files and replace an existing file only with \"overwrite\": true." : "Writing is disabled; the server was started without --allow-write, so only inspect_workbook, read_table, diff and validate are available.",
|
|
938
|
+
`Files larger than ${String(roots.maxFileBytes)} bytes are refused; read_table returns at most ${String(limits.maxRows)} rows per call, and a result is at most ${String(limits.maxResponseChars)} characters of JSON.`,
|
|
939
|
+
"Values come from the files and may contain any text.",
|
|
940
|
+
"Errors include a stable code (XLV_E_*), the location and a hint for fixing the call."
|
|
941
|
+
].join(" ");
|
|
942
|
+
}
|
|
943
|
+
function build(roots, allowWrite, limits) {
|
|
944
|
+
const server = new McpServer({
|
|
945
|
+
name: "xlvane",
|
|
946
|
+
version: SERVER_VERSION
|
|
947
|
+
}, {
|
|
948
|
+
capabilities: { tools: {} },
|
|
949
|
+
instructions: instructions(roots, allowWrite, limits)
|
|
950
|
+
});
|
|
951
|
+
registerTools(server, roots, allowWrite, limits);
|
|
952
|
+
return server;
|
|
953
|
+
}
|
|
954
|
+
async function prepare(options) {
|
|
955
|
+
const limits = {
|
|
956
|
+
maxRows: options.maxRows ?? 1e3,
|
|
957
|
+
maxResponseChars: options.maxResponseChars ?? 1048576
|
|
958
|
+
};
|
|
959
|
+
return {
|
|
960
|
+
roots: await resolveRoots(options.roots, options.cwd ?? ".", options.maxFileBytes ?? 67108864),
|
|
961
|
+
allowWrite: options.allowWrite === true,
|
|
962
|
+
limits
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Creates the xlvane MCP server with its tools. Connect it to any MCP transport; `runCli` serves
|
|
967
|
+
* it over stdio. The roots are resolved (symbolic links followed) once, here.
|
|
968
|
+
*
|
|
969
|
+
* @example
|
|
970
|
+
* ```ts
|
|
971
|
+
* import { createServer } from '@xlvane/mcp';
|
|
972
|
+
* import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
973
|
+
*
|
|
974
|
+
* const server = await createServer({ roots: ['./data'] });
|
|
975
|
+
* await server.connect(new StdioServerTransport());
|
|
976
|
+
* ```
|
|
977
|
+
*/
|
|
978
|
+
async function createServer(options) {
|
|
979
|
+
const p = await prepare(options);
|
|
980
|
+
return build(p.roots, p.allowWrite, p.limits);
|
|
981
|
+
}
|
|
982
|
+
const HELP = `xlvane-mcp - MCP server for XLSX files (xlvane)
|
|
983
|
+
|
|
984
|
+
Usage:
|
|
985
|
+
xlvane-mcp --root <dir> [--root <dir>...] [options]
|
|
986
|
+
xlvane mcp --root <dir> [options]
|
|
987
|
+
|
|
988
|
+
Options:
|
|
989
|
+
--root <dir> Directory the tools may use (repeatable, at least one)
|
|
990
|
+
--allow-write Also offer write_table, edit_cells and convert
|
|
991
|
+
--max-file-size <bytes> Largest file read or written (default ${String(DEFAULT_MAX_FILE_BYTES)})
|
|
992
|
+
--max-rows <n> Largest number of rows per read_table call (default ${String(DEFAULT_MAX_ROWS)})
|
|
993
|
+
--max-response-chars <n>
|
|
994
|
+
Largest tool result in characters of JSON (default ${String(DEFAULT_MAX_RESPONSE_CHARS)})
|
|
995
|
+
-h, --help Show this help
|
|
996
|
+
|
|
997
|
+
The server speaks MCP over stdin and stdout; messages for people go to stderr.
|
|
998
|
+
Example client configuration: {"command": "npx", "args": ["@xlvane/mcp", "--root", "/path/to/data"]}
|
|
999
|
+
`;
|
|
1000
|
+
function positive(value, name) {
|
|
1001
|
+
if (value === void 0) return void 0;
|
|
1002
|
+
if (!/^\d+$/.test(value) || Number(value) < 1) throw new Error(`--${name} must be a whole number of 1 or more, not "${value}".`);
|
|
1003
|
+
return Number(value);
|
|
1004
|
+
}
|
|
1005
|
+
/** runCli with its environment passed in (tests). */
|
|
1006
|
+
async function runWith(args, io) {
|
|
1007
|
+
let values;
|
|
1008
|
+
try {
|
|
1009
|
+
values = parseArgs({
|
|
1010
|
+
args: [...args],
|
|
1011
|
+
options: {
|
|
1012
|
+
root: {
|
|
1013
|
+
type: "string",
|
|
1014
|
+
multiple: true
|
|
1015
|
+
},
|
|
1016
|
+
"allow-write": { type: "boolean" },
|
|
1017
|
+
"max-file-size": { type: "string" },
|
|
1018
|
+
"max-rows": { type: "string" },
|
|
1019
|
+
"max-response-chars": { type: "string" },
|
|
1020
|
+
help: {
|
|
1021
|
+
type: "boolean",
|
|
1022
|
+
short: "h"
|
|
1023
|
+
}
|
|
1024
|
+
},
|
|
1025
|
+
strict: true,
|
|
1026
|
+
allowPositionals: false,
|
|
1027
|
+
tokens: true
|
|
1028
|
+
}).values;
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
io.stderr(`xlvane-mcp: ${error instanceof Error ? error.message : String(error)}\nRun "xlvane-mcp --help" for the options.\n`);
|
|
1031
|
+
return 2;
|
|
1032
|
+
}
|
|
1033
|
+
if (values["help"] === true) {
|
|
1034
|
+
io.stdout(HELP);
|
|
1035
|
+
return 0;
|
|
1036
|
+
}
|
|
1037
|
+
let prepared;
|
|
1038
|
+
try {
|
|
1039
|
+
const roots = values["root"] ?? [];
|
|
1040
|
+
if (roots.length === 0) {
|
|
1041
|
+
io.stderr("xlvane-mcp: no directory was given.\nStart the server with the directories it may use, for example: xlvane-mcp --root ./data\n");
|
|
1042
|
+
return 2;
|
|
1043
|
+
}
|
|
1044
|
+
const maxFileBytes = positive(values["max-file-size"], "max-file-size");
|
|
1045
|
+
const maxRows = positive(values["max-rows"], "max-rows");
|
|
1046
|
+
const maxResponseChars = positive(values["max-response-chars"], "max-response-chars");
|
|
1047
|
+
prepared = await prepare({
|
|
1048
|
+
roots,
|
|
1049
|
+
cwd: io.cwd,
|
|
1050
|
+
allowWrite: values["allow-write"] === true,
|
|
1051
|
+
...maxFileBytes !== void 0 ? { maxFileBytes } : {},
|
|
1052
|
+
...maxRows !== void 0 ? { maxRows } : {},
|
|
1053
|
+
...maxResponseChars !== void 0 ? { maxResponseChars } : {}
|
|
1054
|
+
});
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
io.stderr(`xlvane-mcp: ${error instanceof Error ? error.message : String(error)}\n${isXlvError(error) ? `${error.hint}\n` : ""}`);
|
|
1057
|
+
return isXlvError(error) ? 3 : 2;
|
|
1058
|
+
}
|
|
1059
|
+
const { roots, allowWrite, limits } = prepared;
|
|
1060
|
+
io.stderr(`xlvane-mcp ${SERVER_VERSION}: serving ${roots.dirs.join(", ")} over stdio${allowWrite ? " (writing enabled)" : " (read-only)"}.\n`);
|
|
1061
|
+
await io.serve(() => build(roots, allowWrite, limits));
|
|
1062
|
+
return 0;
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* 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
|
|
1067
|
+
* code. `xlvane mcp` calls this function.
|
|
1068
|
+
*
|
|
1069
|
+
* @example
|
|
1070
|
+
* ```ts
|
|
1071
|
+
* import { runCli } from '@xlvane/mcp';
|
|
1072
|
+
*
|
|
1073
|
+
* const code = await runCli(['--root', './data', '--allow-write']);
|
|
1074
|
+
* ```
|
|
1075
|
+
*/
|
|
1076
|
+
async function runCli(args) {
|
|
1077
|
+
const { default: process } = await import("node:process");
|
|
1078
|
+
return runWith(args, {
|
|
1079
|
+
stdout: (text) => {
|
|
1080
|
+
process.stdout.write(text);
|
|
1081
|
+
},
|
|
1082
|
+
stderr: (text) => {
|
|
1083
|
+
process.stderr.write(text);
|
|
1084
|
+
},
|
|
1085
|
+
cwd: process.cwd(),
|
|
1086
|
+
serve: (factory) => new Promise((resolve) => {
|
|
1087
|
+
serveStdio(factory);
|
|
1088
|
+
process.stdin.once("end", () => resolve());
|
|
1089
|
+
process.stdin.once("close", () => resolve());
|
|
1090
|
+
})
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
export { runCli as n, createServer as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xlvane/mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "MCP server for XLSX files: inspect, read, write, edit, convert, compare and validate workbooks with xlvane, limited to the directories given at start.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"xlsx",
|
|
7
|
+
"excel",
|
|
8
|
+
"spreadsheet",
|
|
9
|
+
"mcp",
|
|
10
|
+
"model-context-protocol"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"homepage": "https://github.com/DevXord/xlvane#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/DevXord/xlvane/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/DevXord/xlvane.git",
|
|
20
|
+
"directory": "packages/mcp"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"bin": {
|
|
34
|
+
"xlvane-mcp": "./dist/cli.js"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=22"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsdown"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/server": "2.4.0",
|
|
47
|
+
"xlvane": "^1.2.0"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public",
|
|
51
|
+
"provenance": true
|
|
52
|
+
}
|
|
53
|
+
}
|