apple-numbers-mcp 0.6.1 → 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.
- package/README.md +5 -1
- package/package.json +2 -2
- package/requirements.txt +1 -1
- package/scripts/setup.sh +11 -1
- package/src/utils/numbers_reader.py +37 -12
package/README.md
CHANGED
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that enables AI assistants like Claude to read, write, search, and modify Apple Numbers (`.numbers`) spreadsheet files. Backed by the [numbers-parser](https://pypi.org/project/numbers-parser/) Python library.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/apple-numbers-mcp)
|
|
6
|
+
[](https://www.npmjs.com/package/apple-numbers-mcp)
|
|
7
|
+
[](https://www.npmjs.com/package/apple-numbers-mcp)
|
|
6
8
|
[](https://github.com/sweetrb/apple-numbers-mcp/actions/workflows/ci.yml)
|
|
9
|
+
[](https://www.apple.com/macos/)
|
|
7
10
|
[](https://opensource.org/licenses/MIT)
|
|
11
|
+
[](https://modelcontextprotocol.io)
|
|
8
12
|
|
|
9
13
|
<p align="center">
|
|
10
14
|
<img src="codex/assets/screenshot.png" alt="Apple Numbers MCP — read, write, search, and format spreadsheets from Codex, Claude, and other AI assistants" width="680">
|
|
@@ -124,7 +128,7 @@ The entrypoint is written as:
|
|
|
124
128
|
|
|
125
129
|
- **macOS or Linux** — `numbers-parser` reads the file format directly, so most tools work anywhere. **Formatting and formula tools require macOS with Numbers.app.**
|
|
126
130
|
- **Node.js 20+** — Required for the MCP server
|
|
127
|
-
- **Python 3.
|
|
131
|
+
- **Python 3.11+** — the `numbers-parser` library installs automatically into a project-local venv on first use (or pre-warm with `npm run setup`). numbers-parser requires Python ≥ 3.10; macOS ships 3.9, so install a newer Python first (e.g. `brew install python@3.12`).
|
|
128
132
|
- **Automation permission (writes only)** — Reads and exports need no special permission, but write/format tools drive Numbers.app via AppleScript and require the host app to have Automation permission for Numbers, granted on first use. See the [Automation Permission guide](docs/AUTOMATION-PERMISSION.md). Run the **`doctor`** tool to verify your setup.
|
|
129
133
|
|
|
130
134
|
## Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apple-numbers-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "MCP server for Apple Numbers - read, write, search, and format .numbers spreadsheets via Claude and other AI assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "build/index.js",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"darwin"
|
|
67
67
|
],
|
|
68
68
|
"dependencies": {
|
|
69
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
69
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
70
70
|
"zod": "^3.22.4"
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
package/requirements.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
numbers-parser>=4.0.0
|
|
1
|
+
numbers-parser>=4.0.0,<5.0
|
package/scripts/setup.sh
CHANGED
|
@@ -13,7 +13,7 @@ echo "==> Setting up Python venv in $VENV_DIR"
|
|
|
13
13
|
|
|
14
14
|
# Find python3
|
|
15
15
|
PYTHON=""
|
|
16
|
-
for cmd in python3 python; do
|
|
16
|
+
for cmd in python3.14 python3.13 python3.12 python3.11 python3 python; do
|
|
17
17
|
if command -v "$cmd" &>/dev/null; then
|
|
18
18
|
PYTHON="$cmd"
|
|
19
19
|
break
|
|
@@ -27,6 +27,16 @@ fi
|
|
|
27
27
|
|
|
28
28
|
echo " Using: $PYTHON ($($PYTHON --version))"
|
|
29
29
|
|
|
30
|
+
# Require Python >= 3.11 (numbers-parser needs >=3.10; full ISO-8601 date
|
|
31
|
+
# parsing in the date filters needs 3.11). macOS ships 3.9 — fail early.
|
|
32
|
+
if ! "$PYTHON" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)'; then
|
|
33
|
+
echo "ERROR: Python >= 3.11 required, but '$PYTHON' is $($PYTHON --version 2>&1)." >&2
|
|
34
|
+
echo " macOS ships Python 3.9. Install a newer one, e.g.:" >&2
|
|
35
|
+
echo " brew install python@3.12 # then re-run: npm run setup" >&2
|
|
36
|
+
echo " or from https://www.python.org/downloads/" >&2
|
|
37
|
+
exit 1
|
|
38
|
+
fi
|
|
39
|
+
|
|
30
40
|
# Create venv if it doesn't exist
|
|
31
41
|
if [ ! -d "$VENV_DIR" ]; then
|
|
32
42
|
echo "==> Creating virtual environment..."
|
|
@@ -7,17 +7,42 @@ Called by the TypeScript MCP server via child_process.
|
|
|
7
7
|
import sys
|
|
8
8
|
import json
|
|
9
9
|
import argparse
|
|
10
|
+
import os
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
|
|
12
13
|
try:
|
|
13
14
|
from numbers_parser import Document
|
|
14
15
|
except ImportError:
|
|
15
16
|
print(json.dumps({
|
|
16
|
-
"error": "numbers-parser not installed. Run:
|
|
17
|
+
"error": "numbers-parser not installed. Run: npm run setup"
|
|
17
18
|
}))
|
|
18
19
|
sys.exit(1)
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
def _atomic_save(doc, path):
|
|
23
|
+
"""Save a .numbers document atomically.
|
|
24
|
+
|
|
25
|
+
numbers-parser's Document.save() rewrites the entire .numbers archive; an
|
|
26
|
+
interrupted or failed save in place can leave the user's only copy
|
|
27
|
+
truncated or corrupt (and several mutations, e.g. delete-rows, are not
|
|
28
|
+
undoable). Because a .numbers file is a single zip archive, we write to a
|
|
29
|
+
sibling temp file and os.replace() it onto the target — atomic on the same
|
|
30
|
+
filesystem — so on any failure the original file is left untouched.
|
|
31
|
+
"""
|
|
32
|
+
target = Path(path)
|
|
33
|
+
tmp = target.parent / f".{target.stem}.tmp-{os.getpid()}.numbers"
|
|
34
|
+
try:
|
|
35
|
+
doc.save(str(tmp))
|
|
36
|
+
os.replace(str(tmp), str(target))
|
|
37
|
+
except BaseException:
|
|
38
|
+
try:
|
|
39
|
+
if tmp.exists():
|
|
40
|
+
tmp.unlink()
|
|
41
|
+
except OSError:
|
|
42
|
+
pass
|
|
43
|
+
raise
|
|
44
|
+
|
|
45
|
+
|
|
21
46
|
def cell_to_serializable(cell):
|
|
22
47
|
"""Convert a numbers-parser cell to a JSON-serializable dict."""
|
|
23
48
|
val = cell.value
|
|
@@ -307,7 +332,7 @@ def cmd_create(args):
|
|
|
307
332
|
table.write(row_idx, col_idx, coerced)
|
|
308
333
|
rows_written += 1
|
|
309
334
|
|
|
310
|
-
doc
|
|
335
|
+
_atomic_save(doc, args.file)
|
|
311
336
|
result = {
|
|
312
337
|
"path": str(Path(args.file).resolve()),
|
|
313
338
|
"sheetName": sheet.name,
|
|
@@ -327,7 +352,7 @@ def cmd_set_cell(args):
|
|
|
327
352
|
value = _coerce_value(json.loads(args.value), args.type)
|
|
328
353
|
if value is not None:
|
|
329
354
|
table.write(row, col, value)
|
|
330
|
-
doc
|
|
355
|
+
_atomic_save(doc, args.file)
|
|
331
356
|
result = {
|
|
332
357
|
"path": str(Path(args.file).resolve()),
|
|
333
358
|
"sheetName": sheet.name,
|
|
@@ -352,7 +377,7 @@ def cmd_set_cells(args):
|
|
|
352
377
|
if value is not None:
|
|
353
378
|
table.write(row, col, value)
|
|
354
379
|
cells_written += 1
|
|
355
|
-
doc
|
|
380
|
+
_atomic_save(doc, args.file)
|
|
356
381
|
result = {
|
|
357
382
|
"path": str(Path(args.file).resolve()),
|
|
358
383
|
"sheetName": sheet.name,
|
|
@@ -379,7 +404,7 @@ def cmd_add_rows(args):
|
|
|
379
404
|
# Ensure empty rows still expand the table
|
|
380
405
|
if not has_value:
|
|
381
406
|
table.write(start_row + row_offset, 0, "")
|
|
382
|
-
doc
|
|
407
|
+
_atomic_save(doc, args.file)
|
|
383
408
|
result = {
|
|
384
409
|
"path": str(Path(args.file).resolve()),
|
|
385
410
|
"sheetName": sheet.name,
|
|
@@ -406,7 +431,7 @@ def cmd_delete_rows(args):
|
|
|
406
431
|
# delete_row(num_rows, start_row) deletes num_rows starting at start_row
|
|
407
432
|
table.delete_row(num_to_delete, start_row)
|
|
408
433
|
rows_deleted = num_to_delete
|
|
409
|
-
doc
|
|
434
|
+
_atomic_save(doc, args.file)
|
|
410
435
|
result = {
|
|
411
436
|
"path": str(Path(args.file).resolve()),
|
|
412
437
|
"sheetName": sheet.name,
|
|
@@ -432,7 +457,7 @@ def cmd_add_sheet(args):
|
|
|
432
457
|
for col_idx, header in enumerate(headers):
|
|
433
458
|
if header is not None:
|
|
434
459
|
table.write(0, col_idx, str(header))
|
|
435
|
-
doc
|
|
460
|
+
_atomic_save(doc, args.file)
|
|
436
461
|
result = {
|
|
437
462
|
"path": str(Path(args.file).resolve()),
|
|
438
463
|
"sheetName": sheet.name,
|
|
@@ -457,7 +482,7 @@ def cmd_add_table(args):
|
|
|
457
482
|
for col_idx, header in enumerate(headers):
|
|
458
483
|
if header is not None:
|
|
459
484
|
table.write(0, col_idx, str(header))
|
|
460
|
-
doc
|
|
485
|
+
_atomic_save(doc, args.file)
|
|
461
486
|
result = {
|
|
462
487
|
"path": str(Path(args.file).resolve()),
|
|
463
488
|
"sheetName": sheet.name,
|
|
@@ -522,7 +547,7 @@ def cmd_import(args):
|
|
|
522
547
|
if coerced is not None:
|
|
523
548
|
table.write(row_idx, col_idx, coerced)
|
|
524
549
|
|
|
525
|
-
doc
|
|
550
|
+
_atomic_save(doc, args.output)
|
|
526
551
|
result = {
|
|
527
552
|
"path": str(Path(args.output).resolve()),
|
|
528
553
|
"inputPath": str(input_path.resolve()),
|
|
@@ -569,7 +594,7 @@ def cmd_update_rows(args):
|
|
|
569
594
|
if coerced is not None:
|
|
570
595
|
table.write(row_idx, col_idx, coerced)
|
|
571
596
|
rows_updated += 1
|
|
572
|
-
doc
|
|
597
|
+
_atomic_save(doc, args.file)
|
|
573
598
|
result = {
|
|
574
599
|
"path": str(Path(args.file).resolve()),
|
|
575
600
|
"sheetName": sheet.name,
|
|
@@ -585,7 +610,7 @@ def cmd_rename_sheet(args):
|
|
|
585
610
|
sheet = _find_sheet(doc, args.sheet)
|
|
586
611
|
old_name = sheet.name
|
|
587
612
|
sheet.name = args.new_name
|
|
588
|
-
doc
|
|
613
|
+
_atomic_save(doc, args.file)
|
|
589
614
|
result = {
|
|
590
615
|
"path": str(Path(args.file).resolve()),
|
|
591
616
|
"oldName": old_name,
|
|
@@ -601,7 +626,7 @@ def cmd_rename_table(args):
|
|
|
601
626
|
table = _find_table(sheet, args.table)
|
|
602
627
|
old_name = table.name
|
|
603
628
|
table.name = args.new_name
|
|
604
|
-
doc
|
|
629
|
+
_atomic_save(doc, args.file)
|
|
605
630
|
result = {
|
|
606
631
|
"path": str(Path(args.file).resolve()),
|
|
607
632
|
"sheetName": sheet.name,
|