@imenam/xls-reader 2.0.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 ADDED
@@ -0,0 +1,90 @@
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 |
27
+ | `cell_budget` | `number` | ❌ | Maximum number of cells to return (cols × rows). Default: `2000` |
28
+ | `max_cols` | `number` | ❌ | Override the maximum number of columns |
29
+ | `max_rows` | `number` | ❌ | Override the maximum number of rows |
30
+ | `header_row` | `boolean` | ❌ | Treat the first row as a column header (default: `true`) |
31
+
32
+ #### Smart content handling
33
+
34
+ - **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.
35
+ - **Empty cells are kept as blanks** so columns stay aligned.
36
+ - **In-cell line breaks** are converted to a literal `\n` so they don't break the Markdown table row.
37
+ - **Dates and booleans** are rendered as readable text rather than Excel serial numbers.
38
+
39
+ #### Size limit
40
+
41
+ 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).
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ npm install -g @imenam/xls-reader
47
+ ```
48
+
49
+ Or use it directly with `npx`:
50
+
51
+ ```json
52
+ {
53
+ "xls-reader": {
54
+ "command": "npx",
55
+ "args": ["-y", "@imenam/xls-reader"]
56
+ }
57
+ }
58
+ ```
59
+
60
+ Or point to a local build:
61
+
62
+ ```json
63
+ {
64
+ "xls-reader": {
65
+ "command": "node",
66
+ "args": ["/absolute/path/to/xls-reader/dist/index.js"]
67
+ }
68
+ }
69
+ ```
70
+
71
+ ## Configuration
72
+
73
+ 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).
74
+
75
+ ```env
76
+ # .env
77
+ MCP_LOG_DIR=/absolute/path/to/logs
78
+ ```
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ npm install
84
+ npm run build # compile TypeScript to dist/
85
+ npm run dev # run from source with tsx
86
+ ```
87
+
88
+ ## License
89
+
90
+ MIT
package/dist/excel.js ADDED
@@ -0,0 +1,112 @@
1
+ import XLSX from "xlsx";
2
+ import fs from "fs";
3
+ export function listSheets(filePath) {
4
+ if (!fs.existsSync(filePath)) {
5
+ throw new Error(`File not found: ${filePath}`);
6
+ }
7
+ const workbook = XLSX.readFile(filePath);
8
+ return workbook.SheetNames;
9
+ }
10
+ function cellToString(value) {
11
+ if (value === null || value === undefined || value === "")
12
+ return "";
13
+ if (value instanceof Date)
14
+ return value.toLocaleDateString();
15
+ if (typeof value === "boolean")
16
+ return value ? "TRUE" : "FALSE";
17
+ // Replace in-cell line breaks with a literal \n so they don't break the Markdown table row.
18
+ return String(value).trim().replace(/\r\n|\r|\n/g, "\\n");
19
+ }
20
+ function toStringRow(raw, numCols) {
21
+ return Array.from({ length: numCols }, (_, i) => cellToString(raw[i]));
22
+ }
23
+ function isEmptyRow(cells) {
24
+ return cells.every((c) => c === "");
25
+ }
26
+ function groupIntoSections(rows) {
27
+ const sections = [[]];
28
+ for (const row of rows) {
29
+ if (isEmptyRow(row)) {
30
+ if (sections[sections.length - 1].length > 0) {
31
+ sections.push([]);
32
+ }
33
+ }
34
+ else {
35
+ sections[sections.length - 1].push(row);
36
+ }
37
+ }
38
+ return sections.filter((s) => s.length > 0);
39
+ }
40
+ function formatRow(cells, numCols) {
41
+ const padded = Array.from({ length: numCols }, (_, i) => cells[i] ?? "");
42
+ return "| " + padded.join(" | ") + " |";
43
+ }
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));
53
+ }
54
+ return lines.join("\n");
55
+ }
56
+ export function readSheet(filePath, sheetName, options = {}) {
57
+ if (!fs.existsSync(filePath)) {
58
+ throw new Error(`File not found: ${filePath}`);
59
+ }
60
+ const workbook = XLSX.readFile(filePath, { cellDates: true });
61
+ if (!workbook.SheetNames.includes(sheetName)) {
62
+ throw new Error(`Sheet '${sheetName}' not found. Available: ${workbook.SheetNames.join(", ")}`);
63
+ }
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._";
71
+ }
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);
86
+ }
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._";
98
+ }
99
+ return lines.join("\n");
100
+ }
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;
112
+ }
package/dist/index.js ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import dotenv from "dotenv";
5
+ import path from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { z } from "zod";
8
+ import { setupLogging } from "./logger.js";
9
+ import { listSheets, readSheet } from "./excel.js";
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const rootDir = path.resolve(__dirname, "..");
12
+ dotenv.config({ path: path.join(rootDir, ".env"), override: true });
13
+ setupLogging("MASTER");
14
+ const server = new McpServer({
15
+ name: "xls-reader",
16
+ version: "1.0.0",
17
+ });
18
+ // ─── list_sheets ──────────────────────────────────────────────────────────────
19
+ server.tool("list_sheets", "List all sheet names (tabs) in an Excel file (.xlsx, .xls, .ods, .csv).", {
20
+ file_path: z.string().describe("Absolute or relative path to the Excel file"),
21
+ }, async ({ file_path }) => {
22
+ try {
23
+ const sheets = listSheets(file_path);
24
+ return {
25
+ content: [{ type: "text", text: sheets.join("\n") }],
26
+ };
27
+ }
28
+ catch (error) {
29
+ const message = error instanceof Error ? error.message : String(error);
30
+ return {
31
+ content: [{ type: "text", text: `Error: ${message}` }],
32
+ isError: true,
33
+ };
34
+ }
35
+ });
36
+ // ─── get_sheet ────────────────────────────────────────────────────────────────
37
+ server.tool("get_sheet", [
38
+ "Read an Excel sheet and return its content as a Markdown table.",
39
+ "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
+ ].join(" "), {
42
+ 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"),
44
+ cell_budget: z
45
+ .number()
46
+ .optional()
47
+ .describe("Maximum number of cells to return (cols × rows). Default: 2000"),
48
+ max_cols: z.number().optional().describe("Override maximum number of columns"),
49
+ max_rows: z.number().optional().describe("Override maximum number of rows"),
50
+ header_row: z
51
+ .boolean()
52
+ .optional()
53
+ .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 }) => {
55
+ try {
56
+ const markdown = readSheet(file_path, sheet_name, {
57
+ cellBudget: cell_budget,
58
+ maxCols: max_cols,
59
+ maxRows: max_rows,
60
+ headerRow: header_row,
61
+ });
62
+ return {
63
+ content: [{ type: "text", text: markdown }],
64
+ };
65
+ }
66
+ catch (error) {
67
+ const message = error instanceof Error ? error.message : String(error);
68
+ return {
69
+ content: [{ type: "text", text: `Error: ${message}` }],
70
+ isError: true,
71
+ };
72
+ }
73
+ });
74
+ // ─── main ─────────────────────────────────────────────────────────────────────
75
+ async function main() {
76
+ const transport = new StdioServerTransport();
77
+ await server.connect(transport);
78
+ console.error("[xls-reader] MCP server started");
79
+ }
80
+ main().catch((err) => {
81
+ console.error("[xls-reader] Fatal error:", err);
82
+ process.exit(1);
83
+ });
package/dist/logger.js ADDED
@@ -0,0 +1,47 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ let currentPrefix = "";
4
+ export function logToFile(message, prefix = "") {
5
+ const defaultLogDir = process.platform === "win32"
6
+ ? "C:\\var\\log\\xls-reader"
7
+ : "/var/log/xls-reader";
8
+ const logDir = process.env.MCP_LOG_DIR ?? defaultLogDir;
9
+ const logFile = path.join(logDir, "server.log");
10
+ const now = new Date().toLocaleString("fr-FR", {
11
+ year: "numeric",
12
+ month: "2-digit",
13
+ day: "2-digit",
14
+ hour: "2-digit",
15
+ minute: "2-digit",
16
+ second: "2-digit",
17
+ });
18
+ const effectivePrefix = prefix || currentPrefix;
19
+ const label = effectivePrefix ? ` [${effectivePrefix}]` : "";
20
+ const logEntry = `[${now}] [PID ${process.pid}]${label} ${message}\n`;
21
+ try {
22
+ if (!fs.existsSync(logDir)) {
23
+ fs.mkdirSync(logDir, { recursive: true });
24
+ }
25
+ fs.appendFileSync(logFile, logEntry);
26
+ }
27
+ catch {
28
+ process.stderr.write("Critical: Failed to write to log file\n");
29
+ }
30
+ }
31
+ export function setupLogging(prefix) {
32
+ currentPrefix = prefix;
33
+ if (prefix === "MASTER") {
34
+ console.log = (...args) => {
35
+ console.error(...args);
36
+ };
37
+ }
38
+ const originalError = console.error;
39
+ console.error = (...args) => {
40
+ const message = args
41
+ .map((arg) => (typeof arg === "object" ? JSON.stringify(arg, null, 2) : arg))
42
+ .join(" ");
43
+ logToFile(message, prefix);
44
+ originalError.apply(console, args);
45
+ };
46
+ logToFile("--- Logging system initialized ---", prefix);
47
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@imenam/xls-reader",
3
+ "version": "2.0.0",
4
+ "description": "MCP server that lets AI agents read Excel files: list sheets and render any sheet as a Markdown table.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "xls-reader": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "start": "node dist/index.js",
16
+ "dev": "tsx src/index.ts",
17
+ "prepublishOnly": "npm run build",
18
+ "release:patch": "npm version patch && npm publish --access public && git push origin master --follow-tags",
19
+ "release:minor": "npm version minor && npm publish --access public && git push origin master --follow-tags",
20
+ "release:major": "npm version major && npm publish --access public && git push origin master --follow-tags"
21
+ },
22
+ "keywords": [
23
+ "mcp",
24
+ "model-context-protocol",
25
+ "excel",
26
+ "xlsx",
27
+ "xls",
28
+ "ods",
29
+ "csv",
30
+ "spreadsheet"
31
+ ],
32
+ "author": "imenam",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/Matthieu-Pesnot-Pin/xls-reader.git"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "engines": {
42
+ "node": ">=18.0.0"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.28.0",
46
+ "dotenv": "^17.3.1",
47
+ "xlsx": "^0.18.5",
48
+ "zod": "^4.3.6"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^25.5.0",
52
+ "tsx": "^4.21.0",
53
+ "typescript": "^6.0.2"
54
+ }
55
+ }