@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 +90 -0
- package/dist/excel.js +112 -0
- package/dist/index.js +83 -0
- package/dist/logger.js +47 -0
- package/package.json +55 -0
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
|
+
}
|