@imenam/xls-reader 2.1.0 → 2.4.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
@@ -1,128 +1,164 @@
1
- # @imenam/xls-reader
2
-
3
- An MCP (Model Context Protocol) server that lets AI agents read Excel files. It can list the sheets of a workbook and render any sheet as a Markdown table, with smart handling of empty rows, empty cells, and in-cell line breaks, plus a configurable size limit to keep responses LLM-friendly.
4
-
5
- Supports `.xlsx`, `.xls`, `.ods` and `.csv` (anything [SheetJS](https://sheetjs.com/) can read).
6
-
7
- ## Tools
8
-
9
- ### `list_sheets`
10
-
11
- Lists all sheet names (tabs) in a workbook.
12
-
13
- | Parameter | Type | Required | Description |
14
- |---|---|---|---|
15
- | `file_path` | `string` | ✅ | Absolute or relative path to the Excel file |
16
-
17
- Returns the sheet names, one per line.
18
-
19
- ### `get_sheet`
20
-
21
- Reads a sheet and returns its content as a Markdown table.
22
-
23
- | Parameter | Type | Required | Description |
24
- |---|---|---|---|
25
- | `file_path` | `string` | ✅ | Absolute or relative path to the Excel file |
26
- | `sheet_name` | `string` | ⚠️ | Name of the sheet to read. Required for `markdown`/`json`. For `json-file`: omit to export the whole workbook, or set it to export only that sheet |
27
- | `format` | `"markdown" \| "json" \| "json-file"` | ❌ | Output format (default: `markdown`) |
28
- | `output_path` | `string` | ⚠️ | Destination file for the JSON export (required when `format` is `json-file`) |
29
- | `cell_budget` | `number` | ❌ | Maximum number of cells to return (cols × rows). Default: `2000` |
30
- | `max_cols` | `number` | ❌ | Override the maximum number of columns |
31
- | `max_rows` | `number` | ❌ | Override the maximum number of rows |
32
- | `header_row` | `boolean` | ❌ | Treat the first row as a column header (default: `true`) |
33
-
34
- #### Smart content handling
35
-
36
- - **Empty rows are section separators.** A fully empty row closes the current table and starts a new one; when `header_row` is enabled, the header is repeated on each section.
37
- - **Empty cells are kept as blanks** so columns stay aligned.
38
- - **In-cell line breaks** are converted to a literal `\n` so they don't break the Markdown table row.
39
- - **Dates and booleans** are rendered as readable text rather than Excel serial numbers.
40
-
41
- #### Output formats
42
-
43
- With `format: "markdown"` (default) you get one Markdown table per section.
44
-
45
- With `format: "json"` you get a structured object — better when the agent needs to *process* the data rather than read it. Native types are preserved (numbers, booleans, dates as ISO strings) and real line breaks are kept:
46
-
47
- ```json
48
- {
49
- "sheet": "Feuille1",
50
- "truncated": false,
51
- "groups": [
52
- [
53
- { "Nom": "Dupont", "Prénom": "Jean", "Age": 42 },
54
- { "Nom": "Martin", "Age": 35 }
55
- ]
56
- ]
57
- }
58
- ```
59
-
60
- - `groups` is an array of sections (one per block separated by an empty row).
61
- - With `header_row: true`, each row is an **object** keyed by column name; **empty cells are omitted**.
62
- - With `header_row: false`, each row is an **array** of values (empty cells become `null`).
63
- - When data is truncated, `truncated` is `true` and a `note` field explains what was omitted.
64
-
65
- With `format: "json-file"` the data (no size limit) is written to `output_path` and the tool returns the saved path. Use this when the data is too large to return inline. By default the **entire workbook** is exported; provide `sheet_name` to export only that one sheet. Size limits are ignored. The file content is:
66
-
67
- ```json
68
- {
69
- "file": "data.xlsx",
70
- "sheets": [
71
- { "sheet": "Feuille1", "groups": [ /* …same shape as json… */ ] },
72
- { "sheet": "Feuille2", "groups": [ /* … */ ] }
73
- ]
74
- }
75
- ```
76
-
77
- #### Size limit
78
-
79
- By default the response is capped at `cell_budget = 2000` cells (`cols × rows`). With 10 columns you get ~200 rows; with 20 columns, ~100 rows. When data is truncated, a warning is appended telling you how much was omitted. Override with `cell_budget`, `max_cols`, or `max_rows` to read more (or less).
80
-
81
- ## Installation
82
-
83
- ```bash
84
- npm install -g @imenam/xls-reader
85
- ```
86
-
87
- Or use it directly with `npx`:
88
-
89
- ```json
90
- {
91
- "xls-reader": {
92
- "command": "npx",
93
- "args": ["-y", "@imenam/xls-reader"]
94
- }
95
- }
96
- ```
97
-
98
- Or point to a local build:
99
-
100
- ```json
101
- {
102
- "xls-reader": {
103
- "command": "node",
104
- "args": ["/absolute/path/to/xls-reader/dist/index.js"]
105
- }
106
- }
107
- ```
108
-
109
- ## Configuration
110
-
111
- No environment variables are required. Optionally, set `MCP_LOG_DIR` to change where logs are written (default: `C:\var\log\xls-reader` on Windows, `/var/log/xls-reader` otherwise).
112
-
113
- ```env
114
- # .env
115
- MCP_LOG_DIR=/absolute/path/to/logs
116
- ```
117
-
118
- ## Development
119
-
120
- ```bash
121
- npm install
122
- npm run build # compile TypeScript to dist/
123
- npm run dev # run from source with tsx
124
- ```
125
-
126
- ## License
127
-
128
- MIT
1
+ # @imenam/xls-reader
2
+
3
+ An MCP (Model Context Protocol) server that lets AI agents read Excel files. It can list the sheets of a workbook and render any sheet as a Markdown table, with smart handling of empty rows, empty cells, and in-cell line breaks, plus a configurable size limit to keep responses LLM-friendly.
4
+
5
+ Supports `.xlsx`, `.xls`, `.ods` and `.csv` (anything [SheetJS](https://sheetjs.com/) can read).
6
+
7
+ ## Where the file comes from
8
+
9
+ Both tools take the workbook either **by path** or **by content**:
10
+
11
+ - `file_path` — a path on the machine *running the server*. This is what a caller provides.
12
+ - `file_content` — the file itself, base64-encoded (a `data:…;base64,` prefix is accepted), with `file_name` to label the output.
13
+
14
+ At least one of the two is required. When both are given, `file_content` wins.
15
+
16
+ **When the server runs on another machine, the content is what must travel** — a path would point at a file the server cannot see. That translation is not the agent's job: the client-side relay does it. With [`@imenam/mcp-http-gateway`](https://www.npmjs.com/package/@imenam/mcp-http-gateway) in `--mcp` mode, the relay runs next to the agent, reads the file named by `file_path`, sends `file_content`, and hides `file_content` / `file_name` from the schema it publishes — so the agent only ever names a path. Enable it with `GATEWAY_INLINE_ROOTS`, which also confines *which* folders can be read; see that package's README.
17
+
18
+ Over stdio on a single machine, `file_path` is read directly and nothing else is needed.
19
+
20
+ ## Tools
21
+
22
+ ### `list_sheets`
23
+
24
+ Lists all sheet names (tabs) in a workbook.
25
+
26
+ | Parameter | Type | Required | Description |
27
+ |---|---|---|---|
28
+ | `file_path` | `string` | ⚠️ | Path to the Excel file. Resolved on the server, or read by the relay (see above) |
29
+ | `file_content` | `string` | ⚠️ | Base64 content of the file. Normally filled in by the relay, not by the agent |
30
+ | `file_name` | `string` | ❌ | Original file name, used only for labelling. Filled in with `file_content` |
31
+
32
+ Returns the sheet names, one per line.
33
+
34
+ ### `get_sheet`
35
+
36
+ Reads a sheet and returns its content as a Markdown table.
37
+
38
+ | Parameter | Type | Required | Description |
39
+ |---|---|---|---|
40
+ | `file_path` | `string` | ⚠️ | Path to the Excel file. Resolved on the server, or read by the relay (see above) |
41
+ | `file_content` | `string` | ⚠️ | Base64 content of the file. Normally filled in by the relay, not by the agent |
42
+ | `file_name` | `string` | ❌ | Original file name, used only for labelling. Filled in with `file_content` |
43
+ | `sheet_name` | `string` | ✅ | Name of the sheet to read |
44
+ | `format` | `"markdown" \| "json"` | ❌ | Output format (default: `markdown`) |
45
+ | `cell_budget` | `number` | ❌ | Maximum number of cells to return (cols × rows). Default: `2000` |
46
+ | `max_cols` | `number` | ❌ | Override the maximum number of columns |
47
+ | `max_rows` | `number` | ❌ | Override the maximum number of rows |
48
+ | `header_row` | `boolean` | ❌ | Treat the first row as a column header (default: `true`) |
49
+
50
+ #### Smart content handling
51
+
52
+ - **Empty rows are section separators.** A fully empty row closes the current table and starts a new one; when `header_row` is enabled, the header is repeated on each section.
53
+ - **Empty cells are kept as blanks** so columns stay aligned.
54
+ - **In-cell line breaks** are converted to a literal `\n` so they don't break the Markdown table row.
55
+ - **Dates and booleans** are rendered as readable text rather than Excel serial numbers.
56
+
57
+ #### Output formats
58
+
59
+ With `format: "markdown"` (default) you get one Markdown table per section.
60
+
61
+ With `format: "json"` you get a structured object — better when the agent needs to *process* the data rather than read it. Native types are preserved (numbers, booleans, dates as ISO strings) and real line breaks are kept:
62
+
63
+ ```json
64
+ {
65
+ "sheet": "Feuille1",
66
+ "truncated": false,
67
+ "groups": [
68
+ [
69
+ { "Nom": "Dupont", "Prénom": "Jean", "Age": 42 },
70
+ { "Nom": "Martin", "Age": 35 }
71
+ ]
72
+ ]
73
+ }
74
+ ```
75
+
76
+ - `groups` is an array of sections (one per block separated by an empty row).
77
+ - With `header_row: true`, each row is an **object** keyed by column name; **empty cells are omitted**.
78
+ - With `header_row: false`, each row is an **array** of values (empty cells become `null`).
79
+ - When data is truncated, `truncated` is `true` and a `note` field explains what was omitted.
80
+
81
+ To export without any size limit, use [`export_workbook`](#export_workbook), which writes a JSON file instead of returning it.
82
+
83
+ #### Size limit
84
+
85
+ By default the response is capped at `cell_budget = 2000` cells (`cols × rows`). With 10 columns you get ~200 rows; with 20 columns, ~100 rows. When data is truncated, a warning is appended telling you how much was omitted. Override with `cell_budget`, `max_cols`, or `max_rows` to read more (or less).
86
+
87
+ ### `export_workbook`
88
+
89
+ Exports a workbook as a JSON file, with **no size limit**, and writes it **on the agent machine**. Use it when the data is too large to return inline. By default the entire workbook is exported; provide `sheet_name` to export only one sheet.
90
+
91
+ | Parameter | Type | Required | Description |
92
+ |---|---|---|---|
93
+ | `file_path` | `string` | ⚠️ | Path to the Excel file. Resolved on the server, or read by the relay (see above) |
94
+ | `file_content` | `string` | ⚠️ | Base64 content of the file. Normally filled in by the relay, not by the agent |
95
+ | `file_name` | `string` | ❌ | Original file name, used only for labelling. Filled in with `file_content` |
96
+ | `sheet_name` | `string` | ❌ | Omit to export the whole workbook, or set it to export only that sheet |
97
+ | `header_row` | `boolean` | ❌ | Treat the first row as a column header (default: `true`) |
98
+ | `destination_path` | `string` | ✅ | **Absolute directory** on the agent machine. Created if missing |
99
+ | `filename` | `string` | ✅ | Name the exported file gets there, e.g. `data.json`. No path separators |
100
+ | `destination_overwrite` | `boolean` | ❌ | Replace the destination file if it exists (default: `false`) |
101
+ | `destination_relay` | `boolean` | ❌ | Filled in by the relay, never by the agent |
102
+
103
+ The exported file never passes through the conversation. When this MCP runs next to the agent (stdio), it writes the file itself. Behind [`@imenam/mcp-http-gateway`](https://www.npmjs.com/package/@imenam/mcp-http-gateway) it runs on the gateway's machine and cannot reach the agent's disk: it then returns the bytes, and the relay — which *does* run on the agent machine — writes them at `destination_path`. That path requires `GATEWAY_INLINE_WRITE_ROOTS` in the relay's `env` block; without it the call fails explicitly rather than dropping the file on the wrong machine.
104
+
105
+ The file content is:
106
+
107
+ ```json
108
+ {
109
+ "file": "data.xlsx",
110
+ "sheets": [
111
+ { "sheet": "Feuille1", "groups": [ /* …same shape as json… */ ] },
112
+ { "sheet": "Feuille2", "groups": [ /* … */ ] }
113
+ ]
114
+ }
115
+ ```
116
+
117
+ ## Installation
118
+
119
+ ```bash
120
+ npm install -g @imenam/xls-reader
121
+ ```
122
+
123
+ Or use it directly with `npx`:
124
+
125
+ ```json
126
+ {
127
+ "xls-reader": {
128
+ "command": "npx",
129
+ "args": ["-y", "@imenam/xls-reader"]
130
+ }
131
+ }
132
+ ```
133
+
134
+ Or point to a local build:
135
+
136
+ ```json
137
+ {
138
+ "xls-reader": {
139
+ "command": "node",
140
+ "args": ["/absolute/path/to/xls-reader/dist/index.js"]
141
+ }
142
+ }
143
+ ```
144
+
145
+ ## Configuration
146
+
147
+ No environment variables are required. Optionally, set `MCP_LOG_DIR` to change where logs are written (default: `C:\var\log\xls-reader` on Windows, `/var/log/xls-reader` otherwise).
148
+
149
+ ```env
150
+ # .env
151
+ MCP_LOG_DIR=/absolute/path/to/logs
152
+ ```
153
+
154
+ ## Development
155
+
156
+ ```bash
157
+ npm install
158
+ npm run build # compile TypeScript to dist/
159
+ npm run dev # run from source with tsx
160
+ ```
161
+
162
+ ## License
163
+
164
+ MIT
package/dist/excel.js CHANGED
@@ -1,12 +1,39 @@
1
1
  import XLSX from "xlsx";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
- export function listSheets(filePath) {
5
- if (!fs.existsSync(filePath)) {
6
- throw new Error(`File not found: ${filePath}`);
4
+ const BASE64_DATA_URL = /^data:[^;,]*(;[^;,]+)*;base64,/i;
5
+ function decodeBase64(content) {
6
+ const cleaned = content.replace(BASE64_DATA_URL, "").replace(/\s+/g, "");
7
+ if (cleaned === "") {
8
+ throw new Error("file_content is empty.");
7
9
  }
8
- const workbook = XLSX.readFile(filePath);
9
- return workbook.SheetNames;
10
+ const buffer = Buffer.from(cleaned, "base64");
11
+ if (buffer.length === 0) {
12
+ throw new Error("file_content is not valid base64 data.");
13
+ }
14
+ return buffer;
15
+ }
16
+ function loadWorkbook(source, options = {}) {
17
+ if (source.fileContent != null && source.fileContent !== "") {
18
+ return XLSX.read(decodeBase64(source.fileContent), { ...options, type: "buffer" });
19
+ }
20
+ if (!source.filePath) {
21
+ throw new Error("Either file_path or file_content must be provided.");
22
+ }
23
+ if (!fs.existsSync(source.filePath)) {
24
+ throw new Error(`File not found: ${source.filePath}. If the file lives on another machine, send it with file_content (base64) instead.`);
25
+ }
26
+ return XLSX.readFile(source.filePath, options);
27
+ }
28
+ function sourceLabel(source) {
29
+ if (source.fileName)
30
+ return path.basename(source.fileName);
31
+ if (source.filePath)
32
+ return path.basename(source.filePath);
33
+ return "workbook";
34
+ }
35
+ export function listSheets(source) {
36
+ return loadWorkbook(source).SheetNames;
10
37
  }
11
38
  // ─── Cell value helpers ─────────────────────────────────────────────────────
12
39
  function isEmptyValue(value) {
@@ -140,11 +167,8 @@ function renderMarkdown(data) {
140
167
  return result;
141
168
  }
142
169
  // ─── Public API ─────────────────────────────────────────────────────────────
143
- export function readSheet(filePath, sheetName, options = {}) {
144
- if (!fs.existsSync(filePath)) {
145
- throw new Error(`File not found: ${filePath}`);
146
- }
147
- const workbook = XLSX.readFile(filePath, { cellDates: true });
170
+ export function readSheet(source, sheetName, options = {}) {
171
+ const workbook = loadWorkbook(source, { cellDates: true });
148
172
  if (!workbook.SheetNames.includes(sheetName)) {
149
173
  throw new Error(`Sheet '${sheetName}' not found. Available: ${workbook.SheetNames.join(", ")}`);
150
174
  }
@@ -163,11 +187,8 @@ export function readSheet(filePath, sheetName, options = {}) {
163
187
  * Read a workbook into a single JSON object, with no size limit.
164
188
  * Exports every sheet, or only `sheetName` when provided.
165
189
  */
166
- export function readWorkbook(filePath, options = {}) {
167
- if (!fs.existsSync(filePath)) {
168
- throw new Error(`File not found: ${filePath}`);
169
- }
170
- const workbook = XLSX.readFile(filePath, { cellDates: true });
190
+ export function readWorkbook(source, options = {}) {
191
+ const workbook = loadWorkbook(source, { cellDates: true });
171
192
  let names = workbook.SheetNames;
172
193
  if (options.sheetName) {
173
194
  if (!names.includes(options.sheetName)) {
@@ -182,5 +203,5 @@ export function readWorkbook(filePath, options = {}) {
182
203
  });
183
204
  return { sheet: name, groups: data ? buildGroups(data) : [] };
184
205
  });
185
- return JSON.stringify({ file: path.basename(filePath), sheets }, null, 2);
206
+ return JSON.stringify({ file: sourceLabel(source), sheets }, null, 2);
186
207
  }
package/dist/index.js CHANGED
@@ -2,12 +2,12 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import dotenv from "dotenv";
5
- import fs from "fs";
6
5
  import path from "path";
7
6
  import { fileURLToPath } from "url";
8
7
  import { z } from "zod";
9
8
  import { setupLogging } from "./logger.js";
10
9
  import { listSheets, readSheet, readWorkbook } from "./excel.js";
10
+ import { deliverFile, RELAY_SHAPE_DESCRIPTIONS } from "./relay.js";
11
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
12
  const rootDir = path.resolve(__dirname, "..");
13
13
  dotenv.config({ path: path.join(rootDir, ".env"), override: true });
@@ -16,12 +16,34 @@ const server = new McpServer({
16
16
  name: "xls-reader",
17
17
  version: "1.0.0",
18
18
  });
19
+ // ─── Workbook source (path on this server, or inline content) ─────────────────
20
+ const sourceShape = {
21
+ file_path: z
22
+ .string()
23
+ .optional()
24
+ .describe("Path to the Excel file on the machine running this server. Use it only when the file is local to the server; otherwise send file_content"),
25
+ file_content: z
26
+ .string()
27
+ .optional()
28
+ .describe("Base64-encoded content of the Excel file (a 'data:' URL prefix is accepted). Filled in by the client-side relay, which reads the file named by file_path on the caller's machine — a caller that provides file_path has nothing to put here. Takes precedence over file_path"),
29
+ file_name: z
30
+ .string()
31
+ .optional()
32
+ .describe("Original file name, used only to label the output. Also filled in by the relay alongside file_content"),
33
+ };
34
+ function toSource(args) {
35
+ if (!args.file_path && !args.file_content) {
36
+ throw new Error("Provide either file_path (file local to the server) or file_content (base64 content of the file).");
37
+ }
38
+ return { filePath: args.file_path, fileContent: args.file_content, fileName: args.file_name };
39
+ }
19
40
  // ─── list_sheets ──────────────────────────────────────────────────────────────
20
- server.tool("list_sheets", "List all sheet names (tabs) in an Excel file (.xlsx, .xls, .ods, .csv).", {
21
- file_path: z.string().describe("Absolute or relative path to the Excel file"),
22
- }, async ({ file_path }) => {
41
+ server.tool("list_sheets", [
42
+ "List all sheet names (tabs) in an Excel file (.xlsx, .xls, .ods, .csv).",
43
+ "Pass file_path when the file sits on the machine running this server, or file_content (base64) when it does not.",
44
+ ].join(" "), sourceShape, async (args) => {
23
45
  try {
24
- const sheets = listSheets(file_path);
46
+ const sheets = listSheets(toSource(args));
25
47
  return {
26
48
  content: [{ type: "text", text: sheets.join("\n") }],
27
49
  };
@@ -37,25 +59,21 @@ server.tool("list_sheets", "List all sheet names (tabs) in an Excel file (.xlsx,
37
59
  // ─── get_sheet ────────────────────────────────────────────────────────────────
38
60
  server.tool("get_sheet", [
39
61
  "Read an Excel sheet and return its content.",
62
+ "Pass file_path when the file sits on the machine running this server, or file_content (base64) when it does not.",
40
63
  "Empty rows are treated as section separators — they close the current table and start a new one.",
41
64
  "format='markdown' (default) returns Markdown table(s); format='json' returns { sheet, truncated, groups }",
42
65
  "where groups is an array of sections. With header_row, each row is an object keyed by column name (empty cells omitted, types preserved); otherwise each row is an array of values.",
43
- "format='json-file' writes JSON (no size limit) to output_path (required) and returns the path; it exports the whole workbook, or only sheet_name when provided. Size limits are ignored.",
44
- "Data is limited by a cell budget (cols × rows, default 2000) for 'markdown' and 'json'. Use max_cols / max_rows / cell_budget to override.",
66
+ "To export without any size limit, use export_workbook, which writes a JSON file instead of returning it.",
67
+ "Data is limited by a cell budget (cols × rows, default 2000). Use max_cols / max_rows / cell_budget to override.",
45
68
  ].join(" "), {
46
- file_path: z.string().describe("Absolute or relative path to the Excel file"),
69
+ ...sourceShape,
47
70
  sheet_name: z
48
71
  .string()
49
- .optional()
50
- .describe("Name of the sheet to read. Required for 'markdown'/'json'. For 'json-file': omit to export the whole workbook, or provide it to export only that sheet"),
72
+ .describe("Name of the sheet to read"),
51
73
  format: z
52
- .enum(["markdown", "json", "json-file"])
74
+ .enum(["markdown", "json"])
53
75
  .optional()
54
- .describe("Output format: 'markdown' (default), 'json', or 'json-file' (whole workbook to disk)"),
55
- output_path: z
56
- .string()
57
- .optional()
58
- .describe("Destination file path for the JSON export (required when format is 'json-file')"),
76
+ .describe("Output format: 'markdown' (default) or 'json'"),
59
77
  cell_budget: z
60
78
  .number()
61
79
  .optional()
@@ -66,30 +84,10 @@ server.tool("get_sheet", [
66
84
  .boolean()
67
85
  .optional()
68
86
  .describe("Treat the first row as a column header (default: true)"),
69
- }, async ({ file_path, sheet_name, format, output_path, cell_budget, max_cols, max_rows, header_row }) => {
87
+ }, async ({ sheet_name, format, cell_budget, max_cols, max_rows, header_row, ...rest }) => {
70
88
  try {
71
- if (format === "json-file") {
72
- if (!output_path) {
73
- return {
74
- content: [{ type: "text", text: "Error: output_path is required when format is 'json-file'." }],
75
- isError: true,
76
- };
77
- }
78
- const json = readWorkbook(file_path, { headerRow: header_row, sheetName: sheet_name });
79
- const resolved = path.resolve(output_path);
80
- fs.mkdirSync(path.dirname(resolved), { recursive: true });
81
- fs.writeFileSync(resolved, json, "utf-8");
82
- return {
83
- content: [{ type: "text", text: `Workbook exported to ${resolved}` }],
84
- };
85
- }
86
- if (!sheet_name) {
87
- return {
88
- content: [{ type: "text", text: "Error: sheet_name is required." }],
89
- isError: true,
90
- };
91
- }
92
- const output = readSheet(file_path, sheet_name, {
89
+ const source = toSource(rest);
90
+ const output = readSheet(source, sheet_name, {
93
91
  format,
94
92
  cellBudget: cell_budget,
95
93
  maxCols: max_cols,
@@ -108,6 +106,54 @@ server.tool("get_sheet", [
108
106
  };
109
107
  }
110
108
  });
109
+ // ─── export_workbook ──────────────────────────────────────────────────────────
110
+ // L'export est un outil à part, et non un `format` de get_sheet : le relais rend
111
+ // obligatoire tout `<x>_path` dont il voit la paire, donc porter `destination_path` dans
112
+ // get_sheet forcerait une destination sur chaque simple lecture de feuille.
113
+ server.tool("export_workbook", [
114
+ "Export an Excel workbook as a JSON file written on the agent machine, with no size limit.",
115
+ "Exports the whole workbook, or only sheet_name when provided.",
116
+ "Pass file_path when the file sits on the machine running this server, or file_content (base64) when it does not.",
117
+ "The exported file never passes through the conversation.",
118
+ ].join(" "), {
119
+ ...sourceShape,
120
+ sheet_name: z
121
+ .string()
122
+ .optional()
123
+ .describe("Omit to export the whole workbook, or provide it to export only that sheet"),
124
+ header_row: z
125
+ .boolean()
126
+ .optional()
127
+ .describe("Treat the first row as a column header (default: true)"),
128
+ destination_path: z.string().describe(RELAY_SHAPE_DESCRIPTIONS.destination_path),
129
+ filename: z.string().describe(RELAY_SHAPE_DESCRIPTIONS.filename),
130
+ destination_overwrite: z
131
+ .boolean()
132
+ .optional()
133
+ .describe(RELAY_SHAPE_DESCRIPTIONS.destination_overwrite),
134
+ destination_relay: z
135
+ .boolean()
136
+ .optional()
137
+ .describe(RELAY_SHAPE_DESCRIPTIONS.destination_relay),
138
+ }, async ({ sheet_name, header_row, destination_path, filename, destination_overwrite, destination_relay, ...rest }) => {
139
+ try {
140
+ const json = readWorkbook(toSource(rest), { headerRow: header_row, sheetName: sheet_name });
141
+ const blocks = deliverFile(Buffer.from(json, "utf-8"), "application/json", {
142
+ destination_path,
143
+ filename,
144
+ destination_overwrite,
145
+ destination_relay,
146
+ });
147
+ return { content: blocks };
148
+ }
149
+ catch (error) {
150
+ const message = error instanceof Error ? error.message : String(error);
151
+ return {
152
+ content: [{ type: "text", text: `Error: ${message}` }],
153
+ isError: true,
154
+ };
155
+ }
156
+ });
111
157
  // ─── main ─────────────────────────────────────────────────────────────────────
112
158
  async function main() {
113
159
  const transport = new StdioServerTransport();
package/dist/relay.js ADDED
@@ -0,0 +1,67 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ // Un fichier produit par ce MCP n'a aucun moyen d'atteindre la machine de l'agent quand
4
+ // le MCP tourne derrière mcp-http-gateway : le protocole n'offre au serveur aucune
5
+ // primitive pour y écrire. La convention du relais règle ça — dès qu'un outil déclare la
6
+ // paire `destination_path` / `destination_relay`, le relais retient la destination, pose
7
+ // `destination_relay`, et écrit lui-même les octets qu'on lui renvoie.
8
+ //
9
+ // Ici, on ne sait pas si un relais est dans la chaîne : `destination_relay` le dit. Posé,
10
+ // on renvoie le contenu ; absent, les deux machines n'en font qu'une et on écrit nous-même.
11
+ /** Plafond de ce qui peut être encodé dans une réponse d'outil. Le relais applique sa
12
+ * propre limite, configurable ; celle-ci n'existe que pour qu'un fichier démesuré
13
+ * échoue proprement ici plutôt qu'en épuisant la mémoire pendant l'encodage base64. */
14
+ const RELAY_MAX_BYTES = 32 * 1024 * 1024;
15
+ export const RELAY_SHAPE_DESCRIPTIONS = {
16
+ destination_path: "ABSOLUTE path of the destination DIRECTORY on the agent machine. It is created if missing, and the file keeps the name given in filename",
17
+ filename: "Name of the file to create, extension included (e.g. 'workbook.json'). Must not contain path separators",
18
+ destination_overwrite: "Replace the destination file if it already exists. Defaults to false",
19
+ destination_relay: "Filled in by the mcp-http-gateway relay, never by the agent. Signals that the destination lives on another machine and that the file content must travel in the response",
20
+ };
21
+ /**
22
+ * Livre un fichier produit par le MCP, du bon côté de la chaîne.
23
+ *
24
+ * Renvoie les blocs de contenu à faire figurer dans le résultat de l'outil : un bloc
25
+ * `resource` que le relais interceptera et retirera, ou une simple confirmation quand
26
+ * l'écriture a eu lieu ici. Toute impossibilité est une erreur franche : mieux vaut un
27
+ * fichier perdu qu'un fichier écrit sur la mauvaise machine.
28
+ */
29
+ export function deliverFile(data, mimeType, target) {
30
+ const name = path.basename(target.filename);
31
+ if (name !== target.filename || name === "." || name === "..") {
32
+ throw new Error(`filename must be a bare file name, without path separators: ${target.filename}`);
33
+ }
34
+ if (data.length > RELAY_MAX_BYTES) {
35
+ throw new Error(`File too large to transfer: ${data.length} bytes, the limit is ${RELAY_MAX_BYTES}.`);
36
+ }
37
+ // Le relais tourne sur la machine de l'agent et a retenu la destination : il attend les
38
+ // octets, qu'il écrira lui-même. Écrire ici viserait le mauvais disque.
39
+ if (target.destination_relay) {
40
+ return [
41
+ {
42
+ type: "resource",
43
+ resource: {
44
+ uri: `file:///${name}`,
45
+ name,
46
+ mimeType,
47
+ blob: data.toString("base64"),
48
+ },
49
+ },
50
+ ];
51
+ }
52
+ // Pas de relais : le MCP tourne à côté de l'agent, son disque est le bon.
53
+ if (!path.isAbsolute(target.destination_path)) {
54
+ throw new Error(`destination_path must be an absolute directory path: ${target.destination_path}`);
55
+ }
56
+ if (fs.existsSync(target.destination_path) &&
57
+ !fs.statSync(target.destination_path).isDirectory()) {
58
+ throw new Error(`destination_path must be a directory, but a file already exists there: ${target.destination_path}`);
59
+ }
60
+ const written = path.join(target.destination_path, name);
61
+ if (!target.destination_overwrite && fs.existsSync(written)) {
62
+ throw new Error(`Destination already exists: ${written}. Pass destination_overwrite to replace it.`);
63
+ }
64
+ fs.mkdirSync(target.destination_path, { recursive: true });
65
+ fs.writeFileSync(written, data);
66
+ return [{ type: "text", text: `File written to ${written} (${data.length} bytes).` }];
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/xls-reader",
3
- "version": "2.1.0",
3
+ "version": "2.4.0",
4
4
  "description": "MCP server that lets AI agents read Excel files: list sheets and render any sheet as a Markdown table.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",