@imenam/xls-reader 2.0.0 → 2.1.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
@@ -23,7 +23,9 @@ Reads a sheet and returns its content as a Markdown table.
23
23
  | Parameter | Type | Required | Description |
24
24
  |---|---|---|---|
25
25
  | `file_path` | `string` | ✅ | Absolute or relative path to the Excel file |
26
- | `sheet_name` | `string` | ✅ | Name of the sheet to read |
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`) |
27
29
  | `cell_budget` | `number` | ❌ | Maximum number of cells to return (cols × rows). Default: `2000` |
28
30
  | `max_cols` | `number` | ❌ | Override the maximum number of columns |
29
31
  | `max_rows` | `number` | ❌ | Override the maximum number of rows |
@@ -36,6 +38,42 @@ Reads a sheet and returns its content as a Markdown table.
36
38
  - **In-cell line breaks** are converted to a literal `\n` so they don't break the Markdown table row.
37
39
  - **Dates and booleans** are rendered as readable text rather than Excel serial numbers.
38
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
+
39
77
  #### Size limit
40
78
 
41
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).
package/dist/excel.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import XLSX from "xlsx";
2
2
  import fs from "fs";
3
+ import path from "path";
3
4
  export function listSheets(filePath) {
4
5
  if (!fs.existsSync(filePath)) {
5
6
  throw new Error(`File not found: ${filePath}`);
@@ -7,26 +8,39 @@ export function listSheets(filePath) {
7
8
  const workbook = XLSX.readFile(filePath);
8
9
  return workbook.SheetNames;
9
10
  }
11
+ // ─── Cell value helpers ─────────────────────────────────────────────────────
12
+ function isEmptyValue(value) {
13
+ return value === null || value === undefined || value === "";
14
+ }
15
+ /** Stringify a cell for Markdown (in-cell line breaks escaped to keep the table intact). */
10
16
  function cellToString(value) {
11
- if (value === null || value === undefined || value === "")
17
+ if (isEmptyValue(value))
12
18
  return "";
13
19
  if (value instanceof Date)
14
20
  return value.toLocaleDateString();
15
21
  if (typeof value === "boolean")
16
22
  return value ? "TRUE" : "FALSE";
17
- // Replace in-cell line breaks with a literal \n so they don't break the Markdown table row.
18
23
  return String(value).trim().replace(/\r\n|\r|\n/g, "\\n");
19
24
  }
20
- function toStringRow(raw, numCols) {
25
+ /** Convert a cell to a native JSON value (types preserved, dates as ISO, real line breaks kept). */
26
+ function cellToJson(value) {
27
+ if (value instanceof Date)
28
+ return value.toISOString();
29
+ if (typeof value === "string")
30
+ return value.trim();
31
+ return value;
32
+ }
33
+ function toStringCells(raw, numCols) {
21
34
  return Array.from({ length: numCols }, (_, i) => cellToString(raw[i]));
22
35
  }
23
- function isEmptyRow(cells) {
24
- return cells.every((c) => c === "");
36
+ // ─── Sections (an empty row delimits a new table) ───────────────────────────
37
+ function isEmptyRawRow(row) {
38
+ return row.every(isEmptyValue);
25
39
  }
26
40
  function groupIntoSections(rows) {
27
41
  const sections = [[]];
28
42
  for (const row of rows) {
29
- if (isEmptyRow(row)) {
43
+ if (isEmptyRawRow(row)) {
30
44
  if (sections[sections.length - 1].length > 0) {
31
45
  sections.push([]);
32
46
  }
@@ -37,22 +51,95 @@ function groupIntoSections(rows) {
37
51
  }
38
52
  return sections.filter((s) => s.length > 0);
39
53
  }
54
+ /** Read a worksheet into header + grouped sections, applying the size limit unless `unlimited`. */
55
+ function extractSheetData(ws, options) {
56
+ const rawRows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: "" });
57
+ if (rawRows.length === 0)
58
+ return null;
59
+ const { cellBudget = 2000, maxCols, maxRows, headerRow = true, unlimited = false } = options;
60
+ const totalCols = rawRows.reduce((max, row) => Math.max(max, row.length), 0);
61
+ let effectiveCols = totalCols;
62
+ let effectiveRows = rawRows.length;
63
+ let note = null;
64
+ if (!unlimited) {
65
+ effectiveCols = Math.min(totalCols, maxCols ?? totalCols);
66
+ effectiveRows = Math.min(rawRows.length, maxRows ?? Math.floor(cellBudget / Math.max(effectiveCols, 1)));
67
+ const truncatedCols = effectiveCols < totalCols;
68
+ const truncatedRows = effectiveRows < rawRows.length;
69
+ if (truncatedCols || truncatedRows) {
70
+ const w = [];
71
+ if (truncatedCols)
72
+ w.push(`${effectiveCols} columns out of ${totalCols}`);
73
+ if (truncatedRows)
74
+ w.push(`${effectiveRows} rows out of ${rawRows.length}`);
75
+ note = `Display limited to ${w.join(", ")}. Use max_cols / max_rows / cell_budget to adjust.`;
76
+ }
77
+ }
78
+ const matrix = rawRows
79
+ .slice(0, effectiveRows)
80
+ .map((row) => row.slice(0, effectiveCols));
81
+ let headerRaw = null;
82
+ let dataRows = matrix;
83
+ if (headerRow && matrix.length > 0) {
84
+ headerRaw = matrix[0];
85
+ dataRows = matrix.slice(1);
86
+ }
87
+ return { headerRaw, sections: groupIntoSections(dataRows), numCols: effectiveCols, note };
88
+ }
89
+ // ─── JSON groups builder (shared by single-sheet and whole-workbook output) ──
90
+ function buildHeaderKeys(headerRaw, numCols) {
91
+ return Array.from({ length: numCols }, (_, i) => {
92
+ const key = cellToString(headerRaw[i]);
93
+ return key === "" ? `column_${i + 1}` : key;
94
+ });
95
+ }
96
+ function rowToObject(row, keys) {
97
+ const obj = {};
98
+ keys.forEach((key, i) => {
99
+ if (!isEmptyValue(row[i]))
100
+ obj[key] = cellToJson(row[i]);
101
+ });
102
+ return obj;
103
+ }
104
+ function rowToArray(row, numCols) {
105
+ return Array.from({ length: numCols }, (_, i) => isEmptyValue(row[i]) ? null : cellToJson(row[i]));
106
+ }
107
+ /** Turn a sheet's sections into JSON groups (objects keyed by header, or arrays of values). */
108
+ function buildGroups(data) {
109
+ if (data.headerRaw) {
110
+ const keys = buildHeaderKeys(data.headerRaw, data.numCols);
111
+ return data.sections.map((section) => section.map((row) => rowToObject(row, keys)));
112
+ }
113
+ return data.sections.map((section) => section.map((row) => rowToArray(row, data.numCols)));
114
+ }
115
+ // ─── Markdown renderer ──────────────────────────────────────────────────────
40
116
  function formatRow(cells, numCols) {
41
117
  const padded = Array.from({ length: numCols }, (_, i) => cells[i] ?? "");
42
118
  return "| " + padded.join(" | ") + " |";
43
119
  }
44
- function sectionToMarkdown(section, header, numCols) {
45
- const lines = [];
46
- if (header) {
47
- lines.push(formatRow(header, numCols));
48
- const sep = Array.from({ length: numCols }, (_, i) => "-".repeat(Math.max((header[i] ?? "").length, 3)));
49
- lines.push("| " + sep.join(" | ") + " |");
50
- }
51
- for (const row of section) {
52
- lines.push(formatRow(row, numCols));
120
+ function markdownHeader(header, numCols) {
121
+ const sep = Array.from({ length: numCols }, (_, i) => "-".repeat(Math.max((header[i] ?? "").length, 3)));
122
+ return [formatRow(header, numCols), "| " + sep.join(" | ") + " |"];
123
+ }
124
+ function renderMarkdown(data) {
125
+ const header = data.headerRaw ? toStringCells(data.headerRaw, data.numCols) : null;
126
+ if (data.sections.length === 0) {
127
+ if (!header)
128
+ return "_Empty sheet._";
129
+ return [...markdownHeader(header, data.numCols), "_No data rows._"].join("\n");
53
130
  }
54
- return lines.join("\n");
131
+ const parts = data.sections.map((section) => {
132
+ const lines = header ? markdownHeader(header, data.numCols) : [];
133
+ for (const row of section)
134
+ lines.push(formatRow(toStringCells(row, data.numCols), data.numCols));
135
+ return lines.join("\n");
136
+ });
137
+ let result = parts.join("\n\n");
138
+ if (data.note)
139
+ result += `\n\n> ⚠ ${data.note}`;
140
+ return result;
55
141
  }
142
+ // ─── Public API ─────────────────────────────────────────────────────────────
56
143
  export function readSheet(filePath, sheetName, options = {}) {
57
144
  if (!fs.existsSync(filePath)) {
58
145
  throw new Error(`File not found: ${filePath}`);
@@ -61,52 +148,39 @@ export function readSheet(filePath, sheetName, options = {}) {
61
148
  if (!workbook.SheetNames.includes(sheetName)) {
62
149
  throw new Error(`Sheet '${sheetName}' not found. Available: ${workbook.SheetNames.join(", ")}`);
63
150
  }
64
- const ws = workbook.Sheets[sheetName];
65
- const rawRows = XLSX.utils.sheet_to_json(ws, {
66
- header: 1,
67
- defval: "",
68
- });
69
- if (rawRows.length === 0) {
70
- return "_Empty sheet._";
151
+ const { format = "markdown", ...rest } = options;
152
+ const data = extractSheetData(workbook.Sheets[sheetName], rest);
153
+ if (format === "json") {
154
+ const out = { sheet: sheetName, truncated: data?.note != null };
155
+ if (data?.note)
156
+ out.note = data.note;
157
+ out.groups = data ? buildGroups(data) : [];
158
+ return JSON.stringify(out, null, 2);
71
159
  }
72
- const { cellBudget = 2000, maxCols, maxRows, headerRow = true } = options;
73
- const totalCols = rawRows.reduce((max, row) => Math.max(max, row.length), 0);
74
- const effectiveCols = Math.min(totalCols, maxCols ?? totalCols);
75
- const effectiveRows = Math.min(rawRows.length, maxRows ?? Math.floor(cellBudget / Math.max(effectiveCols, 1)));
76
- const truncatedCols = effectiveCols < totalCols;
77
- const truncatedRows = effectiveRows < rawRows.length;
78
- const stringRows = rawRows
79
- .slice(0, effectiveRows)
80
- .map((row) => toStringRow(row, effectiveCols));
81
- let header = null;
82
- let dataRows = stringRows;
83
- if (headerRow && stringRows.length > 0) {
84
- header = stringRows[0];
85
- dataRows = stringRows.slice(1);
160
+ return data ? renderMarkdown(data) : "_Empty sheet._";
161
+ }
162
+ /**
163
+ * Read a workbook into a single JSON object, with no size limit.
164
+ * Exports every sheet, or only `sheetName` when provided.
165
+ */
166
+ export function readWorkbook(filePath, options = {}) {
167
+ if (!fs.existsSync(filePath)) {
168
+ throw new Error(`File not found: ${filePath}`);
86
169
  }
87
- const sections = groupIntoSections(dataRows);
88
- if (sections.length === 0) {
89
- const lines = [];
90
- if (header) {
91
- lines.push(formatRow(header, effectiveCols));
92
- const sep = Array.from({ length: effectiveCols }, (_, i) => "-".repeat(Math.max((header[i] ?? "").length, 3)));
93
- lines.push("| " + sep.join(" | ") + " |");
94
- lines.push("_No data rows._");
95
- }
96
- else {
97
- return "_Empty sheet._";
170
+ const workbook = XLSX.readFile(filePath, { cellDates: true });
171
+ let names = workbook.SheetNames;
172
+ if (options.sheetName) {
173
+ if (!names.includes(options.sheetName)) {
174
+ throw new Error(`Sheet '${options.sheetName}' not found. Available: ${names.join(", ")}`);
98
175
  }
99
- return lines.join("\n");
176
+ names = [options.sheetName];
100
177
  }
101
- const parts = sections.map((section) => sectionToMarkdown(section, header, effectiveCols));
102
- let result = parts.join("\n\n");
103
- if (truncatedCols || truncatedRows) {
104
- const warnings = [];
105
- if (truncatedCols)
106
- warnings.push(`${effectiveCols} columns out of ${totalCols}`);
107
- if (truncatedRows)
108
- warnings.push(`${effectiveRows} rows out of ${rawRows.length}`);
109
- result += `\n\n> ⚠ Display limited to ${warnings.join(", ")}. Use \`max_cols\` / \`max_rows\` / \`cell_budget\` to adjust.`;
110
- }
111
- return result;
178
+ const sheets = names.map((name) => {
179
+ const data = extractSheetData(workbook.Sheets[name], {
180
+ headerRow: options.headerRow,
181
+ unlimited: true,
182
+ });
183
+ return { sheet: name, groups: data ? buildGroups(data) : [] };
184
+ });
185
+ return JSON.stringify({ file: path.basename(filePath), sheets }, null, 2);
112
186
  }
package/dist/index.js CHANGED
@@ -2,11 +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";
5
6
  import path from "path";
6
7
  import { fileURLToPath } from "url";
7
8
  import { z } from "zod";
8
9
  import { setupLogging } from "./logger.js";
9
- import { listSheets, readSheet } from "./excel.js";
10
+ import { listSheets, readSheet, readWorkbook } from "./excel.js";
10
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
12
  const rootDir = path.resolve(__dirname, "..");
12
13
  dotenv.config({ path: path.join(rootDir, ".env"), override: true });
@@ -35,12 +36,26 @@ server.tool("list_sheets", "List all sheet names (tabs) in an Excel file (.xlsx,
35
36
  });
36
37
  // ─── get_sheet ────────────────────────────────────────────────────────────────
37
38
  server.tool("get_sheet", [
38
- "Read an Excel sheet and return its content as a Markdown table.",
39
+ "Read an Excel sheet and return its content.",
39
40
  "Empty rows are treated as section separators — they close the current table and start a new one.",
40
- "Data is limited by a cell budget (cols × rows, default 2000). Use max_cols / max_rows / cell_budget to override.",
41
+ "format='markdown' (default) returns Markdown table(s); format='json' returns { sheet, truncated, groups }",
42
+ "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.",
41
45
  ].join(" "), {
42
46
  file_path: z.string().describe("Absolute or relative path to the Excel file"),
43
- sheet_name: z.string().describe("Name of the sheet to read"),
47
+ sheet_name: z
48
+ .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"),
51
+ format: z
52
+ .enum(["markdown", "json", "json-file"])
53
+ .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')"),
44
59
  cell_budget: z
45
60
  .number()
46
61
  .optional()
@@ -51,16 +66,38 @@ server.tool("get_sheet", [
51
66
  .boolean()
52
67
  .optional()
53
68
  .describe("Treat the first row as a column header (default: true)"),
54
- }, async ({ file_path, sheet_name, cell_budget, max_cols, max_rows, header_row }) => {
69
+ }, async ({ file_path, sheet_name, format, output_path, cell_budget, max_cols, max_rows, header_row }) => {
55
70
  try {
56
- const markdown = readSheet(file_path, sheet_name, {
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, {
93
+ format,
57
94
  cellBudget: cell_budget,
58
95
  maxCols: max_cols,
59
96
  maxRows: max_rows,
60
97
  headerRow: header_row,
61
98
  });
62
99
  return {
63
- content: [{ type: "text", text: markdown }],
100
+ content: [{ type: "text", text: output }],
64
101
  };
65
102
  }
66
103
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/xls-reader",
3
- "version": "2.0.0",
3
+ "version": "2.1.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",