apple-numbers-mcp 0.6.2 → 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 CHANGED
@@ -128,7 +128,7 @@ The entrypoint is written as:
128
128
 
129
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.**
130
130
  - **Node.js 20+** — Required for the MCP server
131
- - **Python 3.9+ with numbers-parser** — Install via `pip3 install numbers-parser` or via `npm run setup` if installing from source
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`).
132
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.
133
133
 
134
134
  ## Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-numbers-mcp",
3
- "version": "0.6.2",
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.4.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: pip3 install numbers-parser"
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.save(args.file)
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.save(args.file)
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.save(args.file)
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.save(args.file)
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.save(args.file)
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.save(args.file)
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.save(args.file)
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.save(args.output)
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.save(args.file)
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.save(args.file)
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.save(args.file)
629
+ _atomic_save(doc, args.file)
605
630
  result = {
606
631
  "path": str(Path(args.file).resolve()),
607
632
  "sheetName": sheet.name,