@makeshkumar/mcp-xl-reader 1.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.
Files changed (3) hide show
  1. package/README.md +65 -0
  2. package/dist/index.js +325 -0
  3. package/package.json +38 -0
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # XL Reader MCP Server
2
+
3
+ A Model Context Protocol (MCP) server that provides AI assistants with the ability to read, search, and update local `.xlsx` Excel files.
4
+
5
+ ## Features
6
+
7
+ - **Read Spreadsheets**: Stream memory-efficient JSON rows from large Excel workbooks.
8
+ - **Search within Sheets**: Perform case-insensitive fast searches on specific columns or headers.
9
+ - **Update Cells**: Dynamically write string or numeric values back to the local Excel document.
10
+ - **List Sheets**: Easily pull the names of all worksheets in a workbook.
11
+
12
+ ## Installation
13
+
14
+ ### Method 1: NPX (Recommended for quick use)
15
+ You can directly run this server using `npx` in your MCP client configuration without globally installing it.
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "xl-reader": {
21
+ "command": "npx",
22
+ "args": ["-y", "@makeshkumar/mcp-xl-reader"]
23
+ }
24
+ }
25
+ }
26
+ ```
27
+ *(Replace `@makeshkumar/mcp-xl-reader` with your actual published npm package name once published)*
28
+
29
+ ### Method 2: Global Install
30
+ ```bash
31
+ npm install -g @makeshkumar/mcp-xl-reader
32
+ mcp-xl-reader
33
+ ```
34
+
35
+ ### Method 3: Local Build
36
+ 1. Clone the repository.
37
+ 2. Run `npm install` to install dependencies.
38
+ 3. Run `npm run build` to compile the TypeScript to JavaScript.
39
+ 4. Start the server using `npm start` or point your MCP client to the `dist/index.js` file.
40
+
41
+ ## Configuration Details
42
+
43
+ ### Security
44
+ By default, the server can access any `.xlsx` file on your system using the absolute path provided by the language model.
45
+
46
+ To restrict access, set the `ALLOWED_DIRECTORIES` environment variable:
47
+ ```bash
48
+ export ALLOWED_DIRECTORIES="/Users/yourname/Documents/Spreadsheets,/Users/yourname/Downloads"
49
+ ```
50
+
51
+ If defined, the server will strictly reject paths outside these directory trees.
52
+
53
+ ## Tools Exposed
54
+
55
+ | Tool Name | Description |
56
+ |-----------|-------------|
57
+ | `list_sheets` | Returns an array of worksheet names available in the workbook. |
58
+ | `read_spreadsheet` | Returns JSON representation of rows. Streams using `worksheet.eachRow` for memory efficiency. |
59
+ | `search_spreadsheet` | Performs a filtered search based on a `columnName` and `queryValue` and returns matching rows. |
60
+ | `update_cell` | Updates the local file and saves changes by setting a new cell value at a specific alphanumeric address (e.g. 'B2'). |
61
+
62
+ ## Publishing to NPM
63
+
64
+ 1. Login to your npm account using `npm login`.
65
+ 2. Run `npm publish`.
package/dist/index.js ADDED
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
+ import ExcelJS from "exceljs";
6
+ import fs from "fs/promises";
7
+ import path from "path";
8
+ // Initialize the server
9
+ const server = new Server({
10
+ name: "mcp-excel-server",
11
+ version: "1.0.0",
12
+ }, {
13
+ capabilities: {
14
+ tools: {},
15
+ },
16
+ });
17
+ // Helper for security and file existence check
18
+ async function validateFilePath(filePath) {
19
+ const absolutePath = path.resolve(filePath);
20
+ try {
21
+ const stats = await fs.stat(absolutePath);
22
+ if (!stats.isFile()) {
23
+ throw new Error(`Path exists but is not a file: ${absolutePath}`);
24
+ }
25
+ }
26
+ catch (error) {
27
+ if (error.code === 'ENOENT') {
28
+ throw new Error(`File Not Found: ${absolutePath}`);
29
+ }
30
+ else if (error.code === 'EACCES') {
31
+ throw new Error(`Permission Denied: ${absolutePath}`);
32
+ }
33
+ throw error;
34
+ }
35
+ // Security warning log internally, could restrict paths here if ALLOWED_DIRECTORIES is set
36
+ const allowedDirectories = process.env.ALLOWED_DIRECTORIES ? process.env.ALLOWED_DIRECTORIES.split(',') : [];
37
+ if (allowedDirectories.length > 0) {
38
+ const isAllowed = allowedDirectories.some(dir => absolutePath.startsWith(path.resolve(dir)));
39
+ if (!isAllowed) {
40
+ throw new Error(`Security Violation: Access to ${absolutePath} is not within allowed directories.`);
41
+ }
42
+ }
43
+ return absolutePath;
44
+ }
45
+ // Helper to format cell values properly
46
+ function formatCellValue(cell) {
47
+ if (cell.value === null || cell.value === undefined) {
48
+ return null;
49
+ }
50
+ switch (cell.type) {
51
+ case ExcelJS.ValueType.Null:
52
+ case ExcelJS.ValueType.Merge:
53
+ return null;
54
+ case ExcelJS.ValueType.Number:
55
+ case ExcelJS.ValueType.String:
56
+ case ExcelJS.ValueType.Boolean:
57
+ return cell.value;
58
+ case ExcelJS.ValueType.Date:
59
+ return cell.value.toISOString();
60
+ case ExcelJS.ValueType.Hyperlink:
61
+ return cell.value.text || cell.value.hyperlink;
62
+ case ExcelJS.ValueType.Formula:
63
+ const formulaVal = cell.value;
64
+ if (formulaVal.result instanceof Date) {
65
+ return formulaVal.result.toISOString();
66
+ }
67
+ if (formulaVal.result && typeof formulaVal.result.error !== "undefined") {
68
+ return String(formulaVal.result.error);
69
+ }
70
+ return formulaVal.result ?? null;
71
+ case ExcelJS.ValueType.RichText:
72
+ return cell.value.richText.map(rt => rt.text).join("");
73
+ case ExcelJS.ValueType.SharedString:
74
+ return String(cell.value);
75
+ case ExcelJS.ValueType.Error:
76
+ return cell.value.error?.toString() || "ERROR";
77
+ default:
78
+ return String(cell.value);
79
+ }
80
+ }
81
+ // Convert an entire row to a JSON primitive representation
82
+ function rowToJson(row) {
83
+ const rowData = [];
84
+ row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
85
+ // Fill gaps with null
86
+ while (rowData.length < colNumber - 1) {
87
+ rowData.push(null);
88
+ }
89
+ rowData.push(formatCellValue(cell));
90
+ });
91
+ return rowData;
92
+ }
93
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
94
+ return {
95
+ tools: [
96
+ {
97
+ name: "list_sheets",
98
+ description: "Returns an array of worksheet names available in the workbook.",
99
+ inputSchema: {
100
+ type: "object",
101
+ properties: {
102
+ filePath: { type: "string", description: "Absolute or relative path to the .xlsx file." }
103
+ },
104
+ required: ["filePath"]
105
+ }
106
+ },
107
+ {
108
+ name: "read_spreadsheet",
109
+ description: "Returns JSON representation of rows. Streams using worksheet.eachRow for memory efficiency.",
110
+ inputSchema: {
111
+ type: "object",
112
+ properties: {
113
+ filePath: { type: "string" },
114
+ sheetName: { type: "string" },
115
+ limit: { type: "number", default: 100 }
116
+ },
117
+ required: ["filePath"]
118
+ }
119
+ },
120
+ {
121
+ name: "search_spreadsheet",
122
+ description: "Performs a filtered search based on a columnName and queryValue and returns matching rows.",
123
+ inputSchema: {
124
+ type: "object",
125
+ properties: {
126
+ filePath: { type: "string" },
127
+ sheetName: { type: "string" },
128
+ columnName: { type: "string", description: "The letter of the column (e.g., 'A', 'B') or header string if first row acts as header." },
129
+ queryValue: { type: "string", description: "The value to search for." },
130
+ limit: { type: "number", default: 100 }
131
+ },
132
+ required: ["filePath", "columnName", "queryValue"]
133
+ }
134
+ },
135
+ {
136
+ name: "update_cell",
137
+ description: "Updates the local file and saves changes by setting a new cell value.",
138
+ inputSchema: {
139
+ type: "object",
140
+ properties: {
141
+ filePath: { type: "string" },
142
+ sheetName: { type: "string" },
143
+ cellAddress: { type: "string", description: "The alphanumeric cell address, e.g., 'B2'." },
144
+ newValue: { type: "string", description: "The new value to write into the cell." }
145
+ },
146
+ required: ["filePath", "cellAddress", "newValue"]
147
+ }
148
+ }
149
+ ]
150
+ };
151
+ });
152
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
153
+ const { name, arguments: args } = request.params;
154
+ if (!args || typeof args !== 'object' || !args.filePath) {
155
+ return {
156
+ isError: true,
157
+ content: [{ type: "text", text: "Missing required filePath parameter." }]
158
+ };
159
+ }
160
+ const { filePath } = args;
161
+ let absolutePath;
162
+ try {
163
+ absolutePath = await validateFilePath(filePath);
164
+ }
165
+ catch (error) {
166
+ return {
167
+ isError: true,
168
+ content: [{ type: "text", text: error.message }]
169
+ };
170
+ }
171
+ try {
172
+ const workbook = new ExcelJS.Workbook();
173
+ switch (name) {
174
+ case "list_sheets": {
175
+ await workbook.xlsx.readFile(absolutePath);
176
+ const sheets = workbook.worksheets.map(ws => ws.name);
177
+ return {
178
+ content: [{ type: "text", text: JSON.stringify(sheets, null, 2) }]
179
+ };
180
+ }
181
+ case "read_spreadsheet": {
182
+ const { sheetName, limit = 100 } = args;
183
+ await workbook.xlsx.readFile(absolutePath);
184
+ let ws;
185
+ if (sheetName) {
186
+ const fetchedWs = workbook.getWorksheet(sheetName);
187
+ if (!fetchedWs)
188
+ throw new Error(`Worksheet ${sheetName} not found.`);
189
+ ws = fetchedWs;
190
+ }
191
+ else {
192
+ ws = workbook.worksheets[0];
193
+ if (!ws)
194
+ throw new Error("No worksheets found in the workbook.");
195
+ }
196
+ const rows = [];
197
+ let count = 0;
198
+ ws.eachRow((row, rowNumber) => {
199
+ if (count < limit) {
200
+ rows.push({ rowNumber, data: rowToJson(row) });
201
+ count++;
202
+ }
203
+ });
204
+ return {
205
+ content: [{ type: "text", text: JSON.stringify(rows, null, 2) }]
206
+ };
207
+ }
208
+ case "search_spreadsheet": {
209
+ const { sheetName, columnName, queryValue, limit = 100 } = args;
210
+ await workbook.xlsx.readFile(absolutePath);
211
+ let ws;
212
+ if (sheetName) {
213
+ const fetchedWs = workbook.getWorksheet(sheetName);
214
+ if (!fetchedWs)
215
+ throw new Error(`Worksheet ${sheetName} not found.`);
216
+ ws = fetchedWs;
217
+ }
218
+ else {
219
+ ws = workbook.worksheets[0];
220
+ if (!ws)
221
+ throw new Error("No worksheets found in the workbook.");
222
+ }
223
+ const matches = [];
224
+ let count = 0;
225
+ let isAlphaCol = /^[A-Z]+$/i.test(columnName);
226
+ ws.eachRow((row, rowNumber) => {
227
+ if (count >= limit)
228
+ return;
229
+ let matchesQuery = false;
230
+ if (isAlphaCol) {
231
+ const cell = row.getCell(columnName);
232
+ const val = formatCellValue(cell);
233
+ if (String(val).toLowerCase().includes(String(queryValue).toLowerCase())) {
234
+ matchesQuery = true;
235
+ }
236
+ }
237
+ else {
238
+ // Assume it's a header string mismatch - check all cells
239
+ // For simplicity and to be robust, if columnName is not a letter,
240
+ // maybe we map the first row.
241
+ // In large files first row headers might be easier to resolve once.
242
+ // But let's just search the whole row if it's not alphabetic or we don't know the index.
243
+ // If they provided a header name, we should find its index from row 1.
244
+ let colIndex = -1;
245
+ const headerRow = ws.getRow(1);
246
+ headerRow.eachCell({ includeEmpty: true }, (c, idx) => {
247
+ if (String(formatCellValue(c)).toLowerCase() === String(columnName).toLowerCase()) {
248
+ colIndex = idx;
249
+ }
250
+ });
251
+ if (colIndex !== -1) {
252
+ const cell = row.getCell(colIndex);
253
+ const val = formatCellValue(cell);
254
+ if (String(val).toLowerCase().includes(String(queryValue).toLowerCase())) {
255
+ matchesQuery = true;
256
+ }
257
+ }
258
+ else {
259
+ // Fallback: search anywhere
260
+ row.eachCell({ includeEmpty: true }, (c) => {
261
+ if (String(formatCellValue(c)).toLowerCase().includes(String(queryValue).toLowerCase())) {
262
+ matchesQuery = true;
263
+ }
264
+ });
265
+ }
266
+ }
267
+ if (matchesQuery) {
268
+ matches.push({ rowNumber, data: rowToJson(row) });
269
+ count++;
270
+ }
271
+ });
272
+ return {
273
+ content: [{ type: "text", text: JSON.stringify(matches, null, 2) }]
274
+ };
275
+ }
276
+ case "update_cell": {
277
+ const { sheetName, cellAddress, newValue } = args;
278
+ await workbook.xlsx.readFile(absolutePath);
279
+ let ws;
280
+ if (sheetName) {
281
+ const fetchedWs = workbook.getWorksheet(sheetName);
282
+ if (!fetchedWs)
283
+ throw new Error(`Worksheet ${sheetName} not found.`);
284
+ ws = fetchedWs;
285
+ }
286
+ else {
287
+ ws = workbook.worksheets[0];
288
+ if (!ws)
289
+ throw new Error("No worksheets found in the workbook.");
290
+ }
291
+ const cell = ws.getCell(cellAddress);
292
+ // Ensure new value is applied. If it looks like a number, cast it?
293
+ // Let's just set it as string or number appropriately
294
+ const numVal = Number(newValue);
295
+ if (!isNaN(numVal) && String(newValue).trim() !== "") {
296
+ cell.value = numVal;
297
+ }
298
+ else {
299
+ cell.value = newValue;
300
+ }
301
+ await workbook.xlsx.writeFile(absolutePath);
302
+ return {
303
+ content: [{ type: "text", text: `Successfully updated cell ${cellAddress} to ${newValue}` }]
304
+ };
305
+ }
306
+ default:
307
+ throw new Error(`Unknown tool: ${name}`);
308
+ }
309
+ }
310
+ catch (error) {
311
+ return {
312
+ isError: true,
313
+ content: [{ type: "text", text: `Error processing Excel file: ${error.message}` }]
314
+ };
315
+ }
316
+ });
317
+ async function run() {
318
+ const transport = new StdioServerTransport();
319
+ await server.connect(transport);
320
+ console.error("MCP Excel Server running on stdio");
321
+ }
322
+ run().catch((error) => {
323
+ console.error("Failed to start server", error);
324
+ process.exit(1);
325
+ });
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@makeshkumar/mcp-xl-reader",
3
+ "version": "1.0.0",
4
+ "main": "dist/index.js",
5
+ "type": "module",
6
+ "scripts": {
7
+ "build": "tsc",
8
+ "start": "node dist/index.js",
9
+ "prepublishOnly": "npm run build"
10
+ },
11
+ "bin": {
12
+ "mcp-xl-reader": "dist/index.js"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "keywords": [
18
+ "mcp",
19
+ "model context protocol",
20
+ "excel",
21
+ "xlsx",
22
+ "ai",
23
+ "claude",
24
+ "tools"
25
+ ],
26
+ "author": "Makesh Kumar",
27
+ "license": "MIT",
28
+ "description": "An MCP server to read, search, and update local Excel (.xlsx) files.",
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.27.1",
31
+ "exceljs": "^4.4.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^25.3.5",
35
+ "tsx": "^4.21.0",
36
+ "typescript": "^5.9.3"
37
+ }
38
+ }