@vectojs/numera-mcp 0.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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ ## 0.1.0 - 2026-07-11
6
+
7
+ ### Added
8
+
9
+ - Initial root-confined stdio MCP server with bounded workbook inspection,
10
+ range reads, and hash-guarded atomic cell writes.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VectoJS contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # Numera MCP
2
+
3
+ `@vectojs/numera-mcp` is the safe local-first Model Context Protocol adapter for
4
+ Numera workbooks. It lets an MCP client inspect workbooks, read bounded ranges,
5
+ and write validated cell batches without launching a browser or subprocess.
6
+ The published stdio executable requires Bun.
7
+
8
+ The server supports `.numera.json`, `.json`, `.csv`, and `.xlsx` files through
9
+ exact-pinned Numera Core and XLSX packages. It is intentionally local and does
10
+ not provide collaboration, accounts, cloud storage, or remote network access.
11
+
12
+ ## Run
13
+
14
+ ```sh
15
+ numera-mcp --root /absolute/path/to/workbooks
16
+ ```
17
+
18
+ The server uses stdio. Paths passed to tools are relative to `--root`. Mutation
19
+ tools require an expected input SHA-256 and a distinct, non-existing output
20
+ path, so a stale Agent cannot silently overwrite newer work.
21
+
22
+ ## Tools
23
+
24
+ - `numera_inspect_workbook`: return the format, SHA-256, active sheet, sheet
25
+ dimensions, populated-cell count, and used range.
26
+ - `numera_read_range`: return raw and display values for at most 10,000 cells.
27
+ - `numera_write_cells`: validate and atomically apply 1–1,000 typed cell writes
28
+ to a new output workbook.
29
+
30
+ ## Development
31
+
32
+ ```sh
33
+ bun install
34
+ bun run verify
35
+ npm pack --dry-run
36
+ ```
37
+
38
+ ## Security
39
+
40
+ See [SECURITY.md](./SECURITY.md). Numera MCP treats workbook bytes, formulas,
41
+ paths, and MCP arguments as untrusted input.
@@ -0,0 +1,7 @@
1
+ export type NumeraErrorCode = "INVALID_ARGUMENT" | "PATH_OUTSIDE_ROOT" | "INPUT_NOT_FOUND" | "OUTPUT_EXISTS" | "OUTPUT_EQUALS_INPUT" | "FORMAT_UNSUPPORTED" | "FORMAT_MISMATCH" | "STALE_INPUT" | "SHEET_NOT_FOUND" | "RANGE_OUT_OF_BOUNDS" | "RESOURCE_LIMIT";
2
+ /** Stable diagnostic safe to return to an MCP caller. */
3
+ export declare class NumeraError extends Error {
4
+ readonly code: NumeraErrorCode;
5
+ constructor(code: NumeraErrorCode, message: string);
6
+ }
7
+ //# sourceMappingURL=NumeraError.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NumeraError.d.ts","sourceRoot":"","sources":["../src/NumeraError.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GACvB,kBAAkB,GAClB,mBAAmB,GACnB,iBAAiB,GACjB,eAAe,GACf,qBAAqB,GACrB,oBAAoB,GACpB,iBAAiB,GACjB,aAAa,GACb,iBAAiB,GACjB,qBAAqB,GACrB,gBAAgB,CAAC;AAErB,yDAAyD;AACzD,qBAAa,WAAY,SAAQ,KAAK;IAElC,QAAQ,CAAC,IAAI,EAAE,eAAe;IADhC,YACW,IAAI,EAAE,eAAe,EAC9B,OAAO,EAAE,MAAM,EAIhB;CACF"}
@@ -0,0 +1,73 @@
1
+ import { type WorkbookFormat } from "./workbookCodec";
2
+ export type CellWrite = {
3
+ address: string;
4
+ type: "blank";
5
+ } | {
6
+ address: string;
7
+ type: "text";
8
+ value: string;
9
+ } | {
10
+ address: string;
11
+ type: "number";
12
+ value: number;
13
+ } | {
14
+ address: string;
15
+ type: "boolean";
16
+ value: boolean;
17
+ } | {
18
+ address: string;
19
+ type: "formula";
20
+ value: string;
21
+ };
22
+ /**
23
+ * Owns the filesystem security boundary and all workbook I/O for MCP tools.
24
+ * The adapter layer never receives an unchecked host path.
25
+ */
26
+ export declare class NumeraWorkspace {
27
+ readonly root: string;
28
+ private constructor();
29
+ static create(root: string): Promise<NumeraWorkspace>;
30
+ inspect(path: string): Promise<{
31
+ path: string;
32
+ format: WorkbookFormat;
33
+ sha256: string;
34
+ activeSheet: string;
35
+ sheets: {
36
+ name: string;
37
+ rows: number;
38
+ cols: number;
39
+ populatedCells: number;
40
+ usedRange: import("@vectojs/numera-core").Rect | null;
41
+ }[];
42
+ }>;
43
+ readRange(path: string, sheetName: string, sourceRange: string): Promise<{
44
+ path: string;
45
+ sha256: string;
46
+ sheet: string;
47
+ range: string;
48
+ cells: {
49
+ address: string;
50
+ raw: string;
51
+ display: string;
52
+ }[];
53
+ }>;
54
+ writeCells(options: {
55
+ inputPath: string;
56
+ outputPath: string;
57
+ expectedInputSha256: string;
58
+ sheet: string;
59
+ writes: CellWrite[];
60
+ }): Promise<{
61
+ inputPath: string;
62
+ outputPath: string;
63
+ inputSha256: string;
64
+ outputSha256: string;
65
+ sheet: string;
66
+ writtenCells: number;
67
+ }>;
68
+ private load;
69
+ private validateRelative;
70
+ private resolveExisting;
71
+ private resolveNew;
72
+ }
73
+ //# sourceMappingURL=NumeraWorkspace.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NumeraWorkspace.d.ts","sourceRoot":"","sources":["../src/NumeraWorkspace.ts"],"names":[],"mappings":"AAeA,OAAO,EAIL,KAAK,cAAc,EACpB,MAAM,iBAAiB,CAAC;AAOzB,MAAM,MAAM,SAAS,GACjB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAClC;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAChD;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAClD;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GACpD;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAQxD;;;GAGG;AACH,qBAAa,eAAe;IACN,QAAQ,CAAC,IAAI,EAAE,MAAM;IAAzC,OAAO,eAAsC;IAE7C,OAAa,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC,CAa1D;IAEK,OAAO,CAAC,IAAI,EAAE,MAAM;;;;;;;;;;;;OAezB;IAEK,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM;;;;;;;;;;OAgCnE;IAEK,UAAU,CAAC,OAAO,EAAE;QACxB,SAAS,EAAE,MAAM,CAAC;QAClB,UAAU,EAAE,MAAM,CAAC;QACnB,mBAAmB,EAAE,MAAM,CAAC;QAC5B,KAAK,EAAE,MAAM,CAAC;QACd,MAAM,EAAE,SAAS,EAAE,CAAC;KACrB;;;;;;;OAyCA;YAEa,IAAI;IAWlB,OAAO,CAAC,gBAAgB;YAYV,eAAe;YAgBf,UAAU;CAwBzB"}
package/dist/a1.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { type Rect } from "@vectojs/numera-core";
2
+ export declare function parseCellAddress(source: string): {
3
+ row: number;
4
+ col: number;
5
+ };
6
+ export declare function parseCellRange(source: string): Rect;
7
+ //# sourceMappingURL=a1.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"a1.d.ts","sourceRoot":"","sources":["../src/a1.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,IAAI,EAAE,MAAM,sBAAsB,CAAC;AAOtE,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAa7E;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAanD"}
@@ -0,0 +1,483 @@
1
+ // src/NumeraError.ts
2
+ var NumeraError = class extends Error {
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = "NumeraError";
7
+ }
8
+ code;
9
+ };
10
+
11
+ // src/cliArguments.ts
12
+ function parseRoot(arguments_) {
13
+ if (arguments_.length !== 2 || arguments_[0] !== "--root" || !arguments_[1])
14
+ throw new NumeraError(
15
+ "INVALID_ARGUMENT",
16
+ "Usage: numera-mcp --root <absolute-directory>"
17
+ );
18
+ return arguments_[1];
19
+ }
20
+
21
+ // src/NumeraWorkspace.ts
22
+ import { toA1 } from "@vectojs/numera-core";
23
+ import { createHash, randomUUID } from "crypto";
24
+ import {
25
+ link,
26
+ lstat,
27
+ open,
28
+ realpath,
29
+ rm,
30
+ stat,
31
+ writeFile
32
+ } from "fs/promises";
33
+ import { basename, dirname, isAbsolute, relative, resolve } from "path";
34
+
35
+ // src/a1.ts
36
+ import { parseA1, parseRange } from "@vectojs/numera-core";
37
+ var MAX_WORKSHEET_ROWS = 1048576;
38
+ var MAX_WORKSHEET_COLUMNS = 16384;
39
+ function parseCellAddress(source) {
40
+ const cell = parseA1(source.toUpperCase());
41
+ if (!cell)
42
+ throw new NumeraError(
43
+ "INVALID_ARGUMENT",
44
+ `Invalid cell address: ${source}`
45
+ );
46
+ if (cell.row >= MAX_WORKSHEET_ROWS || cell.col >= MAX_WORKSHEET_COLUMNS)
47
+ throw new NumeraError(
48
+ "RESOURCE_LIMIT",
49
+ `Cell exceeds worksheet limits: ${source}`
50
+ );
51
+ return cell;
52
+ }
53
+ function parseCellRange(source) {
54
+ const normalized = source.toUpperCase();
55
+ const range = normalized.includes(":") ? parseRange(normalized) : (() => {
56
+ const cell = parseA1(normalized);
57
+ return cell ? { r1: cell.row, c1: cell.col, r2: cell.row, c2: cell.col } : null;
58
+ })();
59
+ if (!range)
60
+ throw new NumeraError("INVALID_ARGUMENT", `Invalid range: ${source}`);
61
+ return range;
62
+ }
63
+
64
+ // src/workbookCodec.ts
65
+ import {
66
+ fromCsv,
67
+ parseWorkbookJson,
68
+ toCsv,
69
+ toWorkbookJson,
70
+ Workbook
71
+ } from "@vectojs/numera-core";
72
+ import { decodeXlsx, encodeXlsx } from "@vectojs/numera-xlsx";
73
+ function detectWorkbookFormat(path) {
74
+ const normalized = path.toLowerCase();
75
+ if (normalized.endsWith(".xlsx")) return "xlsx";
76
+ if (normalized.endsWith(".csv")) return "csv";
77
+ if (normalized.endsWith(".numera.json") || normalized.endsWith(".json"))
78
+ return "json";
79
+ throw new NumeraError(
80
+ "FORMAT_UNSUPPORTED",
81
+ `Unsupported workbook format: ${path}`
82
+ );
83
+ }
84
+ async function decodeWorkbook(bytes, format) {
85
+ if (format === "xlsx") return decodeXlsx(bytes);
86
+ const source = new TextDecoder().decode(bytes);
87
+ if (format === "json") return parseWorkbookJson(source);
88
+ const workbook = new Workbook();
89
+ const sheet = workbook.activeSheet.model;
90
+ for (const write of fromCsv(
91
+ source,
92
+ { row: 0, col: 0 },
93
+ { rows: sheet.rows, cols: sheet.cols }
94
+ ))
95
+ sheet.setCell(write.row, write.col, write.raw);
96
+ return workbook;
97
+ }
98
+ async function encodeWorkbook(workbook, format) {
99
+ if (format === "xlsx") return encodeXlsx(workbook);
100
+ if (format === "json")
101
+ return new TextEncoder().encode(toWorkbookJson(workbook));
102
+ const sheet = workbook.activeSheet.model;
103
+ const used = sheet.getUsedRange();
104
+ const range = used ?? { r1: 0, c1: 0, r2: 0, c2: 0 };
105
+ return new TextEncoder().encode(toCsv(sheet, range));
106
+ }
107
+
108
+ // src/NumeraWorkspace.ts
109
+ var MAX_READ_CELLS = 1e4;
110
+ var MAX_WRITES = 1e3;
111
+ var MAX_INPUT_BYTES = 50 * 1024 * 1024;
112
+ var MAX_CELL_TEXT_LENGTH = 32767;
113
+ var NumeraWorkspace = class _NumeraWorkspace {
114
+ constructor(root) {
115
+ this.root = root;
116
+ }
117
+ root;
118
+ static async create(root) {
119
+ if (!isAbsolute(root))
120
+ throw new NumeraError("INVALID_ARGUMENT", "--root must be absolute");
121
+ const canonical = await realpath(root).catch(() => {
122
+ throw new NumeraError(
123
+ "INVALID_ARGUMENT",
124
+ "--root must be an existing directory"
125
+ );
126
+ });
127
+ const metadata = await stat(canonical);
128
+ if (!metadata.isDirectory())
129
+ throw new NumeraError("INVALID_ARGUMENT", "--root must be a directory");
130
+ return new _NumeraWorkspace(canonical);
131
+ }
132
+ async inspect(path) {
133
+ const loaded = await this.load(path);
134
+ return {
135
+ path,
136
+ format: loaded.format,
137
+ sha256: loaded.sha256,
138
+ activeSheet: loaded.workbook.activeSheet.name,
139
+ sheets: loaded.workbook.sheets.map((sheet) => ({
140
+ name: sheet.name,
141
+ rows: sheet.model.rows,
142
+ cols: sheet.model.cols,
143
+ populatedCells: sheet.model.cellCount,
144
+ usedRange: sheet.model.getUsedRange()
145
+ }))
146
+ };
147
+ }
148
+ async readRange(path, sheetName, sourceRange) {
149
+ const loaded = await this.load(path);
150
+ const sheet = findSheet(loaded.workbook, sheetName).model;
151
+ const range = parseCellRange(sourceRange);
152
+ if (range.r2 >= sheet.rows || range.c2 >= sheet.cols)
153
+ throw new NumeraError(
154
+ "RANGE_OUT_OF_BOUNDS",
155
+ `Range is outside sheet bounds: ${sourceRange}`
156
+ );
157
+ const cellCount = (range.r2 - range.r1 + 1) * (range.c2 - range.c1 + 1);
158
+ if (cellCount > MAX_READ_CELLS)
159
+ throw new NumeraError(
160
+ "RESOURCE_LIMIT",
161
+ `Range exceeds ${MAX_READ_CELLS} cells`
162
+ );
163
+ const cells = [];
164
+ for (let row = range.r1; row <= range.r2; row++) {
165
+ for (let col = range.c1; col <= range.c2; col++) {
166
+ cells.push({
167
+ address: toA1({ row, col }),
168
+ raw: sheet.getRaw(row, col),
169
+ display: sheet.getDisplay(row, col)
170
+ });
171
+ }
172
+ }
173
+ return {
174
+ path,
175
+ sha256: loaded.sha256,
176
+ sheet: sheetName,
177
+ range: sourceRange.toUpperCase(),
178
+ cells
179
+ };
180
+ }
181
+ async writeCells(options) {
182
+ validateWriteCount(options.writes);
183
+ const input = await this.resolveExisting(options.inputPath);
184
+ const outputCandidate = this.validateRelative(options.outputPath);
185
+ if (input === outputCandidate)
186
+ throw new NumeraError(
187
+ "OUTPUT_EQUALS_INPUT",
188
+ "Output path must differ from input path"
189
+ );
190
+ const output = await this.resolveNew(options.outputPath);
191
+ const inputFormat = detectWorkbookFormat(input);
192
+ const outputFormat = detectWorkbookFormat(output);
193
+ if (inputFormat !== outputFormat)
194
+ throw new NumeraError(
195
+ "FORMAT_MISMATCH",
196
+ "Input and output workbook formats must match"
197
+ );
198
+ const bytes = await readBounded(input);
199
+ const sha256 = hash(bytes);
200
+ if (sha256 !== options.expectedInputSha256.toLowerCase())
201
+ throw new NumeraError(
202
+ "STALE_INPUT",
203
+ "Input SHA-256 does not match expected_input_sha256"
204
+ );
205
+ const workbook = await decodeWorkbook(bytes, inputFormat);
206
+ const sheet = findSheet(workbook, options.sheet).model;
207
+ const normalized = normalizeWrites(options.writes);
208
+ for (const write of normalized) {
209
+ expandSheetToCell(sheet, write.row, write.col);
210
+ sheet.setCell(write.row, write.col, write.raw);
211
+ }
212
+ const outputBytes = await encodeWorkbook(workbook, outputFormat);
213
+ await writeNewAtomically(output, outputBytes);
214
+ return {
215
+ inputPath: options.inputPath,
216
+ outputPath: options.outputPath,
217
+ inputSha256: sha256,
218
+ outputSha256: hash(outputBytes),
219
+ sheet: options.sheet,
220
+ writtenCells: normalized.length
221
+ };
222
+ }
223
+ async load(path) {
224
+ const resolved = await this.resolveExisting(path);
225
+ const format = detectWorkbookFormat(resolved);
226
+ const bytes = await readBounded(resolved);
227
+ return {
228
+ format,
229
+ sha256: hash(bytes),
230
+ workbook: await decodeWorkbook(bytes, format)
231
+ };
232
+ }
233
+ validateRelative(path) {
234
+ if (path.includes("\0") || path === "" || isAbsolute(path))
235
+ throw new NumeraError(
236
+ "PATH_OUTSIDE_ROOT",
237
+ `Invalid relative path: ${path}`
238
+ );
239
+ const candidate = resolve(this.root, path);
240
+ if (!isWithin(this.root, candidate))
241
+ throw new NumeraError("PATH_OUTSIDE_ROOT", `Path escapes root: ${path}`);
242
+ return candidate;
243
+ }
244
+ async resolveExisting(path) {
245
+ const candidate = this.validateRelative(path);
246
+ let canonical;
247
+ try {
248
+ canonical = await realpath(candidate);
249
+ } catch {
250
+ throw new NumeraError("INPUT_NOT_FOUND", `Input not found: ${path}`);
251
+ }
252
+ if (!isWithin(this.root, canonical))
253
+ throw new NumeraError("PATH_OUTSIDE_ROOT", `Path escapes root: ${path}`);
254
+ const metadata = await stat(canonical);
255
+ if (!metadata.isFile())
256
+ throw new NumeraError("INPUT_NOT_FOUND", `Input is not a file: ${path}`);
257
+ return canonical;
258
+ }
259
+ async resolveNew(path) {
260
+ const candidate = this.validateRelative(path);
261
+ const canonicalParent = await realpath(dirname(candidate)).catch(() => {
262
+ throw new NumeraError(
263
+ "PATH_OUTSIDE_ROOT",
264
+ `Output parent does not exist: ${path}`
265
+ );
266
+ });
267
+ if (!isWithin(this.root, canonicalParent))
268
+ throw new NumeraError("PATH_OUTSIDE_ROOT", `Path escapes root: ${path}`);
269
+ const resolved = resolve(canonicalParent, basename(candidate));
270
+ try {
271
+ await lstat(resolved);
272
+ throw new NumeraError("OUTPUT_EXISTS", `Output already exists: ${path}`);
273
+ } catch (error) {
274
+ if (error instanceof NumeraError) throw error;
275
+ if (error.code !== "ENOENT")
276
+ throw new NumeraError(
277
+ "INVALID_ARGUMENT",
278
+ `Cannot inspect output path: ${path}`
279
+ );
280
+ }
281
+ return resolved;
282
+ }
283
+ };
284
+ function isWithin(root, candidate) {
285
+ const path = relative(root, candidate);
286
+ return path === "" || !path.startsWith("..") && !isAbsolute(path);
287
+ }
288
+ function hash(bytes) {
289
+ return createHash("sha256").update(bytes).digest("hex");
290
+ }
291
+ async function readBounded(path) {
292
+ const handle = await open(path, "r");
293
+ try {
294
+ const metadata = await handle.stat();
295
+ if (metadata.size > MAX_INPUT_BYTES)
296
+ throw new NumeraError(
297
+ "RESOURCE_LIMIT",
298
+ `Workbook exceeds ${MAX_INPUT_BYTES} bytes`
299
+ );
300
+ return new Uint8Array(await handle.readFile());
301
+ } finally {
302
+ await handle.close();
303
+ }
304
+ }
305
+ function findSheet(workbook, name) {
306
+ const sheet = workbook.sheets.find((candidate) => candidate.name === name);
307
+ if (!sheet)
308
+ throw new NumeraError("SHEET_NOT_FOUND", `Sheet not found: ${name}`);
309
+ return sheet;
310
+ }
311
+ function validateWriteCount(writes) {
312
+ if (writes.length < 1 || writes.length > MAX_WRITES)
313
+ throw new NumeraError(
314
+ "RESOURCE_LIMIT",
315
+ `writes must contain between 1 and ${MAX_WRITES} cells`
316
+ );
317
+ }
318
+ function normalizeWrites(writes) {
319
+ const seen = /* @__PURE__ */ new Set();
320
+ return writes.map((write) => {
321
+ const cell = parseCellAddress(write.address);
322
+ const key = `${cell.row}:${cell.col}`;
323
+ if (seen.has(key))
324
+ throw new NumeraError(
325
+ "INVALID_ARGUMENT",
326
+ `Duplicate write address: ${write.address}`
327
+ );
328
+ seen.add(key);
329
+ let raw;
330
+ if (write.type === "blank") raw = "";
331
+ else if (write.type === "boolean") raw = write.value ? "TRUE" : "FALSE";
332
+ else if (write.type === "number") {
333
+ if (!Number.isFinite(write.value))
334
+ throw new NumeraError(
335
+ "INVALID_ARGUMENT",
336
+ `Number must be finite: ${write.address}`
337
+ );
338
+ raw = String(write.value);
339
+ } else if (write.type === "formula")
340
+ raw = write.value.startsWith("=") ? write.value : `=${write.value}`;
341
+ else raw = write.value;
342
+ if (raw.length > MAX_CELL_TEXT_LENGTH)
343
+ throw new NumeraError(
344
+ "RESOURCE_LIMIT",
345
+ `Cell content exceeds ${MAX_CELL_TEXT_LENGTH} characters: ${write.address}`
346
+ );
347
+ return { ...cell, raw };
348
+ });
349
+ }
350
+ function expandSheetToCell(sheet, row, col) {
351
+ if (row < sheet.rows && col < sheet.cols) return;
352
+ const snapshot = sheet.toSnapshot();
353
+ sheet.restoreSnapshot({
354
+ ...snapshot,
355
+ rows: Math.max(sheet.rows, row + 1),
356
+ cols: Math.max(sheet.cols, col + 1)
357
+ });
358
+ }
359
+ async function writeNewAtomically(outputPath, content) {
360
+ const temporaryPath = `${outputPath}.numera-${randomUUID()}.tmp`;
361
+ try {
362
+ await writeFile(temporaryPath, content, { flag: "wx" });
363
+ await link(temporaryPath, outputPath).catch((error) => {
364
+ const code = error.code;
365
+ if (code === "EEXIST")
366
+ throw new NumeraError("OUTPUT_EXISTS", "Output already exists");
367
+ throw error;
368
+ });
369
+ } finally {
370
+ await rm(temporaryPath, { force: true });
371
+ }
372
+ }
373
+
374
+ // src/server.ts
375
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
376
+ import * as z from "zod/v4";
377
+ var writeSchema = z.discriminatedUnion("type", [
378
+ z.object({ address: z.string(), type: z.literal("blank") }),
379
+ z.object({ address: z.string(), type: z.literal("text"), value: z.string() }),
380
+ z.object({
381
+ address: z.string(),
382
+ type: z.literal("number"),
383
+ value: z.number()
384
+ }),
385
+ z.object({
386
+ address: z.string(),
387
+ type: z.literal("boolean"),
388
+ value: z.boolean()
389
+ }),
390
+ z.object({
391
+ address: z.string(),
392
+ type: z.literal("formula"),
393
+ value: z.string()
394
+ })
395
+ ]);
396
+ function createNumeraServer(workspace) {
397
+ const server = new McpServer({ name: "numera-mcp", version: "0.1.0" });
398
+ server.registerTool(
399
+ "numera_inspect_workbook",
400
+ {
401
+ title: "Inspect Numera workbook",
402
+ description: "Inspect a root-relative Numera JSON, CSV, or XLSX workbook and return its SHA-256 and sheet metadata.",
403
+ inputSchema: { path: z.string() },
404
+ annotations: {
405
+ readOnlyHint: true,
406
+ destructiveHint: false,
407
+ idempotentHint: true,
408
+ openWorldHint: false
409
+ }
410
+ },
411
+ async ({ path }) => toolResult(() => workspace.inspect(path))
412
+ );
413
+ server.registerTool(
414
+ "numera_read_range",
415
+ {
416
+ title: "Read Numera range",
417
+ description: "Read raw and displayed cell values from a bounded A1 range in a root-relative workbook.",
418
+ inputSchema: {
419
+ path: z.string(),
420
+ sheet: z.string(),
421
+ range: z.string()
422
+ },
423
+ annotations: {
424
+ readOnlyHint: true,
425
+ destructiveHint: false,
426
+ idempotentHint: true,
427
+ openWorldHint: false
428
+ }
429
+ },
430
+ async ({ path, sheet, range }) => toolResult(() => workspace.readRange(path, sheet, range))
431
+ );
432
+ server.registerTool(
433
+ "numera_write_cells",
434
+ {
435
+ title: "Write Numera cells",
436
+ description: "Apply a validated typed cell batch to a new output workbook. Requires the current input SHA-256 and never overwrites either file.",
437
+ inputSchema: {
438
+ input_path: z.string(),
439
+ output_path: z.string(),
440
+ expected_input_sha256: z.string().regex(/^[a-fA-F0-9]{64}$/),
441
+ sheet: z.string(),
442
+ writes: z.array(writeSchema).min(1).max(1e3)
443
+ },
444
+ annotations: {
445
+ readOnlyHint: false,
446
+ destructiveHint: false,
447
+ idempotentHint: false,
448
+ openWorldHint: false
449
+ }
450
+ },
451
+ async (arguments_) => toolResult(
452
+ () => workspace.writeCells({
453
+ inputPath: arguments_.input_path,
454
+ outputPath: arguments_.output_path,
455
+ expectedInputSha256: arguments_.expected_input_sha256,
456
+ sheet: arguments_.sheet,
457
+ writes: arguments_.writes
458
+ })
459
+ )
460
+ );
461
+ return server;
462
+ }
463
+ async function toolResult(operation) {
464
+ try {
465
+ const value = await operation();
466
+ return {
467
+ content: [{ type: "text", text: JSON.stringify(value) }]
468
+ };
469
+ } catch (error) {
470
+ const diagnostic = error instanceof NumeraError ? { code: error.code, message: error.message } : { code: "UNEXPECTED", message: "Unexpected workbook failure" };
471
+ return {
472
+ isError: true,
473
+ content: [{ type: "text", text: JSON.stringify(diagnostic) }]
474
+ };
475
+ }
476
+ }
477
+
478
+ export {
479
+ NumeraError,
480
+ parseRoot,
481
+ NumeraWorkspace,
482
+ createNumeraServer
483
+ };
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bun
2
+ import {
3
+ NumeraError,
4
+ NumeraWorkspace,
5
+ createNumeraServer,
6
+ parseRoot
7
+ } from "./chunk-RD4J723A.js";
8
+
9
+ // src/cli.ts
10
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
+ async function main() {
12
+ const workspace = await NumeraWorkspace.create(
13
+ parseRoot(process.argv.slice(2))
14
+ );
15
+ const transport = new StdioServerTransport();
16
+ await createNumeraServer(workspace).connect(transport);
17
+ process.stdin.resume();
18
+ process.stdin.ref?.();
19
+ }
20
+ void main().catch((error) => {
21
+ const diagnostic = error instanceof NumeraError ? `${error.code}: ${error.message}` : `UNEXPECTED: ${String(error)}`;
22
+ process.stderr.write(`${diagnostic}
23
+ `);
24
+ process.exitCode = 1;
25
+ });
@@ -0,0 +1,2 @@
1
+ export declare function parseRoot(arguments_: string[]): string;
2
+ //# sourceMappingURL=cliArguments.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cliArguments.d.ts","sourceRoot":"","sources":["../src/cliArguments.ts"],"names":[],"mappings":"AAEA,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,MAAM,CAOtD"}
@@ -0,0 +1,5 @@
1
+ export { NumeraError, type NumeraErrorCode } from "./NumeraError";
2
+ export { NumeraWorkspace, type CellWrite } from "./NumeraWorkspace";
3
+ export { parseRoot } from "./cliArguments";
4
+ export { createNumeraServer } from "./server";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAClE,OAAO,EAAE,eAAe,EAAE,KAAK,SAAS,EAAE,MAAM,mBAAmB,CAAC;AACpE,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC3C,OAAO,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ import {
2
+ NumeraError,
3
+ NumeraWorkspace,
4
+ createNumeraServer,
5
+ parseRoot
6
+ } from "./chunk-RD4J723A.js";
7
+ export {
8
+ NumeraError,
9
+ NumeraWorkspace,
10
+ createNumeraServer,
11
+ parseRoot
12
+ };
@@ -0,0 +1,4 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { NumeraWorkspace } from "./NumeraWorkspace";
3
+ export declare function createNumeraServer(workspace: NumeraWorkspace): McpServer;
4
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAIpE,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAsBpD,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,eAAe,GAAG,SAAS,CA2ExE"}
@@ -0,0 +1,6 @@
1
+ import { Workbook } from "@vectojs/numera-core";
2
+ export type WorkbookFormat = "csv" | "json" | "xlsx";
3
+ export declare function detectWorkbookFormat(path: string): WorkbookFormat;
4
+ export declare function decodeWorkbook(bytes: Uint8Array, format: WorkbookFormat): Promise<Workbook>;
5
+ export declare function encodeWorkbook(workbook: Workbook, format: WorkbookFormat): Promise<Uint8Array>;
6
+ //# sourceMappingURL=workbookCodec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workbookCodec.d.ts","sourceRoot":"","sources":["../src/workbookCodec.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,QAAQ,EACT,MAAM,sBAAsB,CAAC;AAK9B,MAAM,MAAM,cAAc,GAAG,KAAK,GAAG,MAAM,GAAG,MAAM,CAAC;AAErD,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,CAUjE;AAED,wBAAsB,cAAc,CAClC,KAAK,EAAE,UAAU,EACjB,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC,QAAQ,CAAC,CAanB;AAED,wBAAsB,cAAc,CAClC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC,UAAU,CAAC,CAQrB"}
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@vectojs/numera-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Safe local-first MCP server for Numera workbooks.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/vectojs/numera-mcp.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/vectojs/numera-mcp/issues"
16
+ },
17
+ "homepage": "https://github.com/vectojs/numera-mcp#readme",
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "bin": {
21
+ "numera-mcp": "./dist/cli.js"
22
+ },
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md",
32
+ "LICENSE",
33
+ "CHANGELOG.md"
34
+ ],
35
+ "scripts": {
36
+ "build": "tsup src/index.ts src/cli.ts --format esm --clean && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
37
+ "changeset": "changeset",
38
+ "changeset:version": "changeset version",
39
+ "test": "bun test",
40
+ "format:check": "prettier --check .",
41
+ "lint": "oxlint --deny-warnings src test scripts",
42
+ "verify": "bun run format:check && bun run lint && bun test && bun run build && node scripts/stdio-smoke.mjs"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "1.29.0",
46
+ "@vectojs/numera-core": "0.4.0",
47
+ "@vectojs/numera-xlsx": "0.1.1",
48
+ "zod": "4.4.3"
49
+ },
50
+ "devDependencies": {
51
+ "@changesets/cli": "2.31.0",
52
+ "@types/bun": "1.3.14",
53
+ "oxlint": "1.73.0",
54
+ "prettier": "3.9.5",
55
+ "tsup": "8.5.1",
56
+ "typescript": "7.0.2"
57
+ }
58
+ }