apple-numbers-mcp 1.1.18 → 1.2.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 +10 -3
  2. package/build/index.js +177 -42
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -303,7 +303,7 @@ Case-insensitive partial match across every cell in a `.numbers` file.
303
303
 
304
304
  Export a table to CSV, TSV, or JSON.
305
305
 
306
- **⚠️ Safety:** Writes a file to disk at `outputPath`. The path is written unconditionally — any existing file there is **overwritten** — so confirm the destination before calling.
306
+ **⚠️ Safety:** Writes a file to disk at `outputPath`. The path is written unconditionally — any existing file there is **overwritten** — so confirm the destination before calling. Paths are bounded: `outputPath` must resolve — after `~` expansion and symlink resolution — to a location under your **home directory**, `/tmp`, `/private/tmp`, or `/Volumes`; anything else is rejected with an error naming those roots.
307
307
 
308
308
  | Parameter | Type | Required | Description |
309
309
  |-----------|------|----------|-------------|
@@ -321,7 +321,7 @@ Export a table to CSV, TSV, or JSON.
321
321
 
322
322
  Create a new `.numbers` file with one sheet and table.
323
323
 
324
- **⚠️ Safety:** Overwrites the file at `path` if it already exists — confirm the destination first.
324
+ **⚠️ Safety:** Overwrites the file at `path` if it already exists — confirm the destination first. Paths are bounded: `path` must resolve — after `~` expansion and symlink resolution — to a location under your **home directory**, `/tmp`, `/private/tmp`, or `/Volumes`; anything else is rejected with an error naming those roots.
325
325
 
326
326
  | Parameter | Type | Required | Description |
327
327
  |-----------|------|----------|-------------|
@@ -573,7 +573,7 @@ Merge a rectangular range of cells, or undo a merge.
573
573
 
574
574
  Import a CSV, TSV, or JSON file into a new `.numbers` spreadsheet. Auto-detects format from extension, or pass `format` explicitly.
575
575
 
576
- **⚠️ Safety:** Overwrites the file at `outputPath` if it already exists — confirm the destination first.
576
+ **⚠️ Safety:** Overwrites the file at `outputPath` if it already exists — confirm the destination first. Paths are bounded: both `inputPath` and `outputPath` must resolve — after `~` expansion and symlink resolution — to locations under your **home directory**, `/tmp`, `/private/tmp`, or `/Volumes`; anything else is rejected with an error naming those roots.
577
577
 
578
578
  | Parameter | Type | Required | Description |
579
579
  |-----------|------|----------|-------------|
@@ -675,6 +675,7 @@ All configuration is optional — the server works out of the box.
675
675
  | `APPLE_NUMBERS_MCP_MAX_BUFFER` | 50 MB (Python reader) / 64 MB (AppleScript) | Max bytes captured from a subprocess's stdout, applied to both the Python reader and the AppleScript layer. Raise it if a very large spreadsheet is truncated; lower it to cap memory. |
676
676
  | `APPLE_NUMBERS_MCP_CONFIG_FILE` | `~/Library/Application Support/apple-numbers-mcp/config.json` | Path to the JSON config file (see below). |
677
677
  | `APPLE_NUMBERS_MCP_NO_AUTO_SETUP` | unset (auto-setup on) | Set to a truthy value (`1`, `true`) to disable the one-time automatic creation of the Python venv. When set, the server will not run `setup.sh` on its own — you must provide `numbers-parser` yourself (`pip3 install numbers-parser` or `pnpm run setup`). |
678
+ | `APPLE_NUMBERS_MCP_EXTRA_ROOTS` | unset | Colon-separated **absolute** directories to add to the allowed-path roots, e.g. `/Data/Finance:/srv/shared`. The built-in roots already cover your home directory, the temp dirs and `/Volumes` (where external and network mounts appear), so this is for genuinely unusual layouts rather than routine configuration. Entries are canonicalized like the built-ins, so a symlinked entry cannot smuggle in a wider parent; relative and empty entries are ignored. **Widening a security boundary — set it deliberately.** |
678
679
  | `APPLE_NUMBERS_MCP_SETUP_TIMEOUT` | `300000` (5 minutes) | Timeout in milliseconds for the automatic venv bootstrap (`setup.sh`). Raise it on a slow network where the `numbers-parser` pip install would otherwise time out. |
679
680
 
680
681
  > **Per-call timeouts are not configurable.** Every `numbers-parser` sidecar call
@@ -774,6 +775,12 @@ These are tracked for future releases. The underlying `numbers-parser` library h
774
775
  - Check the path; expand `~` if your shell isn't doing it.
775
776
  - Ensure the file extension is `.numbers`.
776
777
 
778
+ ### "Output path … is outside the allowed roots" / "Input path … is outside the allowed roots"
779
+ - Every tool that touches a file on disk is bounded to your **home directory**, `/tmp`, `/private/tmp`, and `/Volumes` — the server will not write into `/etc`, `/Library`, an app bundle, or any other system location, and will not read from one.
780
+ - **As of 1.2.0 this covers the `.numbers` file itself**, not just export/import destinations. `get-file-info`, `read-table`, `search` and the in-place writes (`set-cell`, `add-rows`, `set-formula`, …) previously accepted a path anywhere on disk.
781
+ - Symlinks are resolved **before** the check, so a link inside an allowed directory that points outside it is rejected too; the error reports the resolved path, which is usually the quickest way to see where a path really went.
782
+ - Fix: choose a path under one of those roots (`~/Documents/report.numbers`, `/tmp/report.csv`). If your spreadsheets genuinely live somewhere else, add that directory to `APPLE_NUMBERS_MCP_EXTRA_ROOTS` — see [Environment variables](#environment-variables).
783
+
777
784
  ### Formatting / formula tools fail with "Numbers.app not running" or "Not authorized to send Apple events to Numbers"
778
785
  - Open Numbers.app at least once. macOS will prompt for automation permission — accept it.
779
786
  - Verify in **System Settings → Privacy & Security → Automation**, or reset with `tccutil reset AppleEvents`.
package/build/index.js CHANGED
@@ -2985,7 +2985,7 @@ var require_compile = __commonJS({
2985
2985
  const schOrFunc = root.refs[ref];
2986
2986
  if (schOrFunc)
2987
2987
  return schOrFunc;
2988
- let _sch = resolve2.call(this, root, ref);
2988
+ let _sch = resolve3.call(this, root, ref);
2989
2989
  if (_sch === void 0) {
2990
2990
  const schema = (_a = root.localRefs) === null || _a === void 0 ? void 0 : _a[ref];
2991
2991
  const { schemaId } = this.opts;
@@ -3012,7 +3012,7 @@ var require_compile = __commonJS({
3012
3012
  function sameSchemaEnv(s1, s2) {
3013
3013
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
3014
3014
  }
3015
- function resolve2(root, ref) {
3015
+ function resolve3(root, ref) {
3016
3016
  let sch;
3017
3017
  while (typeof (sch = this.refs[ref]) == "string")
3018
3018
  ref = sch;
@@ -3643,7 +3643,7 @@ var require_fast_uri = __commonJS({
3643
3643
  }
3644
3644
  return uri;
3645
3645
  }
3646
- function resolve2(baseURI, relativeURI, options) {
3646
+ function resolve3(baseURI, relativeURI, options) {
3647
3647
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3648
3648
  const { parsed: baseParsed, malformedAuthorityOrPort: baseMalformed } = parseWithStatus(baseURI, schemelessOptions);
3649
3649
  const { parsed: relativeParsed, malformedAuthorityOrPort: relativeMalformed } = parseWithStatus(relativeURI, schemelessOptions);
@@ -3927,7 +3927,7 @@ var require_fast_uri = __commonJS({
3927
3927
  var fastUri = {
3928
3928
  SCHEMES,
3929
3929
  normalize,
3930
- resolve: resolve2,
3930
+ resolve: resolve3,
3931
3931
  resolveComponent,
3932
3932
  equal,
3933
3933
  serialize,
@@ -19023,7 +19023,7 @@ var Protocol = class {
19023
19023
  return;
19024
19024
  }
19025
19025
  const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
19026
- await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
19026
+ await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
19027
19027
  options?.signal?.throwIfAborted();
19028
19028
  }
19029
19029
  } catch (error2) {
@@ -19040,7 +19040,7 @@ var Protocol = class {
19040
19040
  */
19041
19041
  request(request, resultSchema, options) {
19042
19042
  const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
19043
- return new Promise((resolve2, reject) => {
19043
+ return new Promise((resolve3, reject) => {
19044
19044
  const earlyReject = (error2) => {
19045
19045
  reject(error2);
19046
19046
  };
@@ -19118,7 +19118,7 @@ var Protocol = class {
19118
19118
  if (!parseResult.success) {
19119
19119
  reject(parseResult.error);
19120
19120
  } else {
19121
- resolve2(parseResult.data);
19121
+ resolve3(parseResult.data);
19122
19122
  }
19123
19123
  } catch (error2) {
19124
19124
  reject(error2);
@@ -19379,12 +19379,12 @@ var Protocol = class {
19379
19379
  }
19380
19380
  } catch {
19381
19381
  }
19382
- return new Promise((resolve2, reject) => {
19382
+ return new Promise((resolve3, reject) => {
19383
19383
  if (signal.aborted) {
19384
19384
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
19385
19385
  return;
19386
19386
  }
19387
- const timeoutId = setTimeout(resolve2, interval);
19387
+ const timeoutId = setTimeout(resolve3, interval);
19388
19388
  signal.addEventListener("abort", () => {
19389
19389
  clearTimeout(timeoutId);
19390
19390
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
@@ -20697,7 +20697,7 @@ var McpServer = class {
20697
20697
  let task = createTaskResult.task;
20698
20698
  const pollInterval = task.pollInterval ?? 5e3;
20699
20699
  while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
20700
- await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
20700
+ await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
20701
20701
  const updatedTask = await extra.taskStore.getTask(taskId);
20702
20702
  if (!updatedTask) {
20703
20703
  throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
@@ -21385,12 +21385,12 @@ var StdioServerTransport = class {
21385
21385
  this.onclose?.();
21386
21386
  }
21387
21387
  send(message) {
21388
- return new Promise((resolve2) => {
21388
+ return new Promise((resolve3) => {
21389
21389
  const json2 = serializeMessage(message);
21390
21390
  if (this._stdout.write(json2)) {
21391
- resolve2();
21391
+ resolve3();
21392
21392
  } else {
21393
- this._stdout.once("drain", resolve2);
21393
+ this._stdout.once("drain", resolve3);
21394
21394
  }
21395
21395
  });
21396
21396
  }
@@ -21399,7 +21399,7 @@ var StdioServerTransport = class {
21399
21399
  // src/index.ts
21400
21400
  import { readFileSync as readFileSync3 } from "node:fs";
21401
21401
  import { fileURLToPath as fileURLToPath2 } from "node:url";
21402
- import { dirname as dirname2, join as join3 } from "node:path";
21402
+ import { dirname as dirname3, join as join4 } from "node:path";
21403
21403
 
21404
21404
  // src/utils/python.ts
21405
21405
  import { execSync, execFileSync } from "node:child_process";
@@ -21812,14 +21812,138 @@ function unmergeCells(filePath, sheet, table, startRow, startCol, endRow, endCol
21812
21812
  }
21813
21813
 
21814
21814
  // src/services/numbersManager.ts
21815
- import { existsSync as existsSync2 } from "node:fs";
21816
- import { resolve, extname } from "node:path";
21817
- import { homedir } from "node:os";
21815
+ import { existsSync as existsSync3 } from "node:fs";
21816
+ import { extname } from "node:path";
21817
+
21818
+ // src/utils/exportPath.ts
21819
+ import { lstatSync, readlinkSync, realpathSync } from "node:fs";
21820
+ import { homedir, tmpdir } from "node:os";
21821
+ import { basename, dirname as dirname2, isAbsolute, join as join2, resolve, sep } from "node:path";
21822
+ var ALLOWED_EXPORT_ROOTS = [
21823
+ homedir(),
21824
+ // os.tmpdir() is the canonical per-user temp dir on macOS (/var/folders/<hash>/T,
21825
+ // real path /private/var/folders/...). It is what Node's os.tmpdir(), Python's
21826
+ // tempfile and $TMPDIR all return — and what this repo's own fixtures use — so
21827
+ // omitting it refuses the most ordinary scratch destination there is.
21828
+ tmpdir(),
21829
+ "/private/var/folders",
21830
+ "/tmp",
21831
+ "/private/tmp",
21832
+ "/Volumes"
21833
+ ];
21834
+ var ALLOWED_EXPORT_ROOTS_TEXT = "your home directory, /tmp, /private/tmp, or /Volumes";
21835
+ var EXTRA_ROOTS_ENV = "APPLE_NUMBERS_MCP_EXTRA_ROOTS";
21836
+ function extraRoots() {
21837
+ const raw = process.env[EXTRA_ROOTS_ENV];
21838
+ if (!raw) return [];
21839
+ return raw.split(":").map((s) => s.trim()).filter((s) => s.length > 0 && isAbsolute(s));
21840
+ }
21841
+ function canonicalize(path) {
21842
+ return realpathSync.native(path);
21843
+ }
21844
+ function allowedRoots() {
21845
+ const roots = [];
21846
+ for (const root of [...ALLOWED_EXPORT_ROOTS, ...extraRoots()]) {
21847
+ if (!roots.includes(root)) roots.push(root);
21848
+ try {
21849
+ const canonical = canonicalize(root);
21850
+ if (!roots.includes(canonical)) roots.push(canonical);
21851
+ } catch {
21852
+ }
21853
+ }
21854
+ return roots;
21855
+ }
21856
+ function isPathWithinAllowedRoots(resolvedPath) {
21857
+ return allowedRoots().some((root) => {
21858
+ const base = root.endsWith(sep) ? root.slice(0, -1) : root;
21859
+ return resolvedPath === base || resolvedPath.startsWith(base + sep);
21860
+ });
21861
+ }
21862
+ function expandTilde(p) {
21863
+ if (p === "~") return homedir();
21864
+ if (p.startsWith(`~${sep}`) || p.startsWith("~/")) return join2(homedir(), p.slice(2));
21865
+ return p;
21866
+ }
21867
+ function entryPresent(p) {
21868
+ try {
21869
+ lstatSync(p);
21870
+ return true;
21871
+ } catch (e) {
21872
+ const code = e.code;
21873
+ return !(code === "ENOENT" || code === "ENOTDIR");
21874
+ }
21875
+ }
21876
+ function canonicalizeCandidate(p, depth = 0) {
21877
+ if (depth > 32) throw new Error(`Too many symbolic links resolving "${p}"`);
21878
+ let existing = p;
21879
+ const tail = [];
21880
+ while (!entryPresent(existing)) {
21881
+ const parent = dirname2(existing);
21882
+ if (parent === existing) break;
21883
+ tail.unshift(basename(existing));
21884
+ existing = parent;
21885
+ }
21886
+ let real;
21887
+ try {
21888
+ real = canonicalize(existing);
21889
+ } catch {
21890
+ const viaLink = resolveDanglingLink(existing, depth);
21891
+ if (viaLink !== null) {
21892
+ return canonicalizeCandidate(tail.length ? join2(viaLink, ...tail) : viaLink, depth + 1);
21893
+ }
21894
+ real = existing;
21895
+ }
21896
+ return tail.length ? join2(real, ...tail) : real;
21897
+ }
21898
+ function resolveDanglingLink(p, depth) {
21899
+ try {
21900
+ if (!lstatSync(p).isSymbolicLink()) return null;
21901
+ const target = readlinkSync(p);
21902
+ if (isAbsolute(target)) return target;
21903
+ return resolve(canonicalizeCandidate(dirname2(p), depth + 1), target);
21904
+ } catch {
21905
+ return null;
21906
+ }
21907
+ }
21908
+ function resolveWithinAllowedRoots(path, label) {
21909
+ const resolved = canonicalizeCandidate(resolve(expandTilde(path)));
21910
+ if (!isPathWithinAllowedRoots(resolved)) {
21911
+ throw new Error(
21912
+ `${label} "${path}" resolves to "${resolved}", which is outside the allowed roots (${ALLOWED_EXPORT_ROOTS_TEXT}). Choose a path under one of those roots.`
21913
+ );
21914
+ }
21915
+ return resolved;
21916
+ }
21917
+
21918
+ // src/services/numbersManager.ts
21818
21919
  var NumbersManager = class {
21920
+ /**
21921
+ * Resolve and validate the path of an EXISTING `.numbers` file.
21922
+ *
21923
+ * This is the resolver behind `get-file-info`, `read-table`, `search` and
21924
+ * every in-place write (`set-cell`, `add-rows`, `set-formula`, …). It used to
21925
+ * expand `~`, `resolve()` and check the extension — and nothing else — so
21926
+ * those tools could read and modify a `.numbers` file **anywhere on disk**,
21927
+ * while its sibling `validateOutputPath` had enforced the allowed-roots
21928
+ * boundary since 1.1.19. That asymmetry was the largest remaining gap here.
21929
+ *
21930
+ * It now goes through the same `resolveWithinAllowedRoots` as the write side,
21931
+ * which also brings the hardening that came with it: canonicalization via
21932
+ * `realpathSync.native` (so a case-respelled segment cannot defeat the check
21933
+ * on case-insensitive APFS) and symlink resolution BEFORE the comparison (so
21934
+ * a link inside an allowed directory cannot point out of it).
21935
+ *
21936
+ * The extension is checked on the CANONICAL path for the same reason it is on
21937
+ * the output side: a symlink must not be able to present a `.numbers` name
21938
+ * for a target that is something else entirely.
21939
+ *
21940
+ * The built-in roots cover home, the temp dirs and `/Volumes`, so a file the
21941
+ * user could plausibly want to open is almost always already inside them.
21942
+ * `APPLE_NUMBERS_MCP_EXTRA_ROOTS` exists for the layouts they are not.
21943
+ */
21819
21944
  validatePath(filePath) {
21820
- const expanded = filePath.startsWith("~") ? filePath.replace(/^~/, homedir()) : filePath;
21821
- const resolved = resolve(expanded);
21822
- if (!existsSync2(resolved)) {
21945
+ const resolved = resolveWithinAllowedRoots(filePath, "Input path");
21946
+ if (!existsSync3(resolved)) {
21823
21947
  throw new Error(`File not found: ${resolved}`);
21824
21948
  }
21825
21949
  if (extname(resolved).toLowerCase() !== ".numbers") {
@@ -21829,22 +21953,33 @@ var NumbersManager = class {
21829
21953
  }
21830
21954
  /**
21831
21955
  * Resolve and validate an output path for new files.
21832
- * Expands ~ and checks .numbers extension, but does NOT require the file to exist.
21956
+ * Expands ~, enforces the allowed-roots boundary, and checks the .numbers
21957
+ * extension, but does NOT require the file to exist. The extension is checked
21958
+ * on the CANONICAL path so a symlink can't present a .numbers name for a
21959
+ * target that is something else entirely.
21833
21960
  */
21834
21961
  validateOutputPath(filePath) {
21835
- const expanded = filePath.startsWith("~") ? filePath.replace(/^~/, homedir()) : filePath;
21836
- const resolved = resolve(expanded);
21962
+ const resolved = resolveWithinAllowedRoots(filePath, "Output path");
21837
21963
  if (extname(resolved).toLowerCase() !== ".numbers") {
21838
21964
  throw new Error(`Not a Numbers file path: ${resolved}. Expected .numbers extension.`);
21839
21965
  }
21840
21966
  return resolved;
21841
21967
  }
21842
21968
  /**
21843
- * Resolve a generic path (expand ~ and resolve).
21969
+ * Resolve a path that will be WRITTEN (export destinations), enforcing the
21970
+ * allowed-roots boundary. Symlinks are resolved before the check, so a link
21971
+ * inside an allowed directory cannot escape to a system location.
21844
21972
  */
21845
- resolvePath(filePath) {
21846
- const expanded = filePath.startsWith("~") ? filePath.replace(/^~/, homedir()) : filePath;
21847
- return resolve(expanded);
21973
+ resolveWritePath(filePath) {
21974
+ return resolveWithinAllowedRoots(filePath, "Output path");
21975
+ }
21976
+ /**
21977
+ * Resolve a path that will be READ (import sources), enforcing the same
21978
+ * allowed-roots boundary so this server can't be turned into an arbitrary
21979
+ * local-file reader.
21980
+ */
21981
+ resolveReadPath(filePath) {
21982
+ return resolveWithinAllowedRoots(filePath, "Input path");
21848
21983
  }
21849
21984
  /**
21850
21985
  * Get file structure: sheets, tables, dimensions, headers.
@@ -21886,7 +22021,7 @@ var NumbersManager = class {
21886
22021
  */
21887
22022
  exportTable(filePath, format, outputPath, sheet, table) {
21888
22023
  const resolved = this.validatePath(filePath);
21889
- const outputResolved = this.resolvePath(outputPath);
22024
+ const outputResolved = this.resolveWritePath(outputPath);
21890
22025
  const args = [resolved, format, outputResolved];
21891
22026
  if (sheet) args.push("--sheet", sheet);
21892
22027
  if (table) args.push("--table", table);
@@ -22000,8 +22135,8 @@ var NumbersManager = class {
22000
22135
  * Import a CSV/TSV/JSON file into a new .numbers file.
22001
22136
  */
22002
22137
  importFile(inputPath, outputPath, options) {
22003
- const inputResolved = this.resolvePath(inputPath);
22004
- if (!existsSync2(inputResolved)) {
22138
+ const inputResolved = this.resolveReadPath(inputPath);
22139
+ if (!existsSync3(inputResolved)) {
22005
22140
  throw new Error(`Input file not found: ${inputResolved}`);
22006
22141
  }
22007
22142
  const outputResolved = this.validateOutputPath(outputPath);
@@ -22139,7 +22274,7 @@ function withErrorHandling(handler, prefix) {
22139
22274
 
22140
22275
  // src/tools/doctor.ts
22141
22276
  import { execFileSync as execFileSync3 } from "node:child_process";
22142
- import { existsSync as existsSync3 } from "node:fs";
22277
+ import { existsSync as existsSync4 } from "node:fs";
22143
22278
  var NUMBERS_APP_PATHS = [
22144
22279
  "/Applications/Numbers.app",
22145
22280
  "/System/Applications/Numbers.app",
@@ -22148,7 +22283,7 @@ var NUMBERS_APP_PATHS = [
22148
22283
  ];
22149
22284
  var NUMBERS_BUNDLE_IDS = ["com.apple.Numbers", "com.apple.iWork.Numbers"];
22150
22285
  function findNumbersApp() {
22151
- const byPath = NUMBERS_APP_PATHS.find((p) => existsSync3(p));
22286
+ const byPath = NUMBERS_APP_PATHS.find((p) => existsSync4(p));
22152
22287
  if (byPath) return byPath;
22153
22288
  for (const id of NUMBERS_BUNDLE_IDS) {
22154
22289
  try {
@@ -22158,7 +22293,7 @@ function findNumbersApp() {
22158
22293
  { encoding: "utf8", timeout: 5e3, stdio: ["ignore", "pipe", "ignore"] }
22159
22294
  ).trim();
22160
22295
  const path = out.endsWith("/") ? out.slice(0, -1) : out;
22161
- if (path && existsSync3(path)) return path;
22296
+ if (path && existsSync4(path)) return path;
22162
22297
  } catch {
22163
22298
  }
22164
22299
  }
@@ -22335,18 +22470,18 @@ First use get-file-info and read-table to read the relevant table(s) so you know
22335
22470
  }
22336
22471
 
22337
22472
  // src/services/fileConfig.ts
22338
- import { existsSync as existsSync4, readFileSync as readFileSync2 } from "node:fs";
22339
- import { join as join2 } from "node:path";
22473
+ import { existsSync as existsSync5, readFileSync as readFileSync2 } from "node:fs";
22474
+ import { join as join3 } from "node:path";
22340
22475
  import { homedir as homedir2 } from "node:os";
22341
22476
  function fileConfigPath(env = process.env) {
22342
22477
  const override = env.APPLE_NUMBERS_MCP_CONFIG_FILE;
22343
22478
  if (override && override.trim()) return override.trim();
22344
- return join2(homedir2(), "Library", "Application Support", "apple-numbers-mcp", "config.json");
22479
+ return join3(homedir2(), "Library", "Application Support", "apple-numbers-mcp", "config.json");
22345
22480
  }
22346
22481
  function loadFileConfig(env = process.env, path = fileConfigPath(env)) {
22347
22482
  const applied = [];
22348
22483
  try {
22349
- if (!existsSync4(path)) return applied;
22484
+ if (!existsSync5(path)) return applied;
22350
22485
  const parsed = JSON.parse(readFileSync2(path, "utf8"));
22351
22486
  if (!parsed || typeof parsed !== "object") return applied;
22352
22487
  for (const [k, v] of Object.entries(parsed)) {
@@ -22476,8 +22611,8 @@ function withJsonSchema2020_12(transport) {
22476
22611
  // src/index.ts
22477
22612
  loadFileConfig();
22478
22613
  var __filename2 = fileURLToPath2(import.meta.url);
22479
- var __dirname2 = dirname2(__filename2);
22480
- var pkg = JSON.parse(readFileSync3(join3(__dirname2, "..", "package.json"), "utf-8"));
22614
+ var __dirname2 = dirname3(__filename2);
22615
+ var pkg = JSON.parse(readFileSync3(join4(__dirname2, "..", "package.json"), "utf-8"));
22481
22616
  var version2 = pkg.version;
22482
22617
  var manager = new NumbersManager();
22483
22618
  var MAX_INDEX = 1e6;
@@ -22661,7 +22796,7 @@ ${lines.join("\n")}`, {
22661
22796
  registerTool(
22662
22797
  "export-table",
22663
22798
  {
22664
- description: "Use when: you want to write a table's data out to a CSV, TSV, or JSON file on disk. Defaults to the first sheet and first table if sheet/table are omitted.\nReturns: the exported row count, format, and output path.\nDo not use when: you want the data inline in the response rather than a file \u2014 use read-table; or you want to build a new .numbers file from a CSV/TSV/JSON source \u2014 use import-csv.\nSafety: writes a file to disk at outputPath. The path is written unconditionally \u2014 any existing file there is OVERWRITTEN \u2014 so confirm the destination before calling.",
22799
+ description: "Use when: you want to write a table's data out to a CSV, TSV, or JSON file on disk. Defaults to the first sheet and first table if sheet/table are omitted.\nReturns: the exported row count, format, and output path.\nDo not use when: you want the data inline in the response rather than a file \u2014 use read-table; or you want to build a new .numbers file from a CSV/TSV/JSON source \u2014 use import-csv.\nSafety: writes a file to disk at outputPath. The path is written unconditionally \u2014 any existing file there is OVERWRITTEN \u2014 so confirm the destination before calling. outputPath must resolve \u2014 after ~ expansion and symlink resolution \u2014 to a path under your home directory, /tmp, /private/tmp, or /Volumes; anything else is rejected with an error naming those roots.",
22665
22800
  inputSchema: {
22666
22801
  path: external_exports.string().describe("Path to the .numbers file"),
22667
22802
  format: external_exports.enum(["csv", "tsv", "json"]).describe("Output format"),
@@ -22725,7 +22860,7 @@ Merged: yes`;
22725
22860
  registerTool(
22726
22861
  "create-spreadsheet",
22727
22862
  {
22728
- description: "Use when: you want to create a brand-new .numbers file with a single sheet and table from a list of headers, optionally with initial data rows.\nReturns: the file path, sheet name, table name, header count, and data-row count.\nDo not use when: you want to build the file from an existing CSV/TSV/JSON source \u2014 use import-csv; or you want to add to a file that already exists \u2014 use add-sheet / add-table / add-rows.\nSafety: writes a .numbers file via the numbers-parser sidecar (does not require Numbers.app). The target path is written unconditionally \u2014 if a file already exists at that path it is OVERWRITTEN in place; choose a fresh path or confirm overwrite first.",
22863
+ description: "Use when: you want to create a brand-new .numbers file with a single sheet and table from a list of headers, optionally with initial data rows.\nReturns: the file path, sheet name, table name, header count, and data-row count.\nDo not use when: you want to build the file from an existing CSV/TSV/JSON source \u2014 use import-csv; or you want to add to a file that already exists \u2014 use add-sheet / add-table / add-rows.\nSafety: writes a .numbers file via the numbers-parser sidecar (does not require Numbers.app). The target path is written unconditionally \u2014 if a file already exists at that path it is OVERWRITTEN in place; choose a fresh path or confirm overwrite first. path must resolve \u2014 after ~ expansion and symlink resolution \u2014 to a location under your home directory, /tmp, /private/tmp, or /Volumes; anything else is rejected with an error naming those roots.",
22729
22864
  inputSchema: {
22730
22865
  path: external_exports.string().describe("Path for the new .numbers file"),
22731
22866
  headers: external_exports.array(headerCellSchema).min(1).max(MAX_BATCH).describe("Column header names"),
@@ -22931,7 +23066,7 @@ registerTool(
22931
23066
  registerTool(
22932
23067
  "import-csv",
22933
23068
  {
22934
- description: 'Use when: you want to convert an existing CSV, TSV, or JSON file into a new .numbers spreadsheet. Format is auto-detected from the input file extension unless you specify it explicitly; any unrecognized extension falls back to CSV. CSV/TSV fields are auto-typed (numbers, booleans, empty cells), so zero-padded identifiers LOSE their leading zeros \u2014 "01234" becomes 1234. JSON values are passed through untouched, but an array of objects takes its column set from the FIRST object only, silently dropping keys that appear later.\nReturns: the imported row and column counts, detected format, and the input/output paths plus sheet/table names.\nDo not use when: building a file from headers/rows you already have in hand \u2014 use create-spreadsheet; or exporting a .numbers table out to CSV/TSV/JSON \u2014 use export-table.\nSafety: writes the output .numbers file via the numbers-parser sidecar (does not require Numbers.app). The output path is written unconditionally \u2014 if a file already exists there it is OVERWRITTEN in place; choose a fresh output path or confirm overwrite first.',
23069
+ description: 'Use when: you want to convert an existing CSV, TSV, or JSON file into a new .numbers spreadsheet. Format is auto-detected from the input file extension unless you specify it explicitly; any unrecognized extension falls back to CSV. CSV/TSV fields are auto-typed (numbers, booleans, empty cells), so zero-padded identifiers LOSE their leading zeros \u2014 "01234" becomes 1234. JSON values are passed through untouched, but an array of objects takes its column set from the FIRST object only, silently dropping keys that appear later.\nReturns: the imported row and column counts, detected format, and the input/output paths plus sheet/table names.\nDo not use when: building a file from headers/rows you already have in hand \u2014 use create-spreadsheet; or exporting a .numbers table out to CSV/TSV/JSON \u2014 use export-table.\nSafety: writes the output .numbers file via the numbers-parser sidecar (does not require Numbers.app). The output path is written unconditionally \u2014 if a file already exists there it is OVERWRITTEN in place; choose a fresh output path or confirm overwrite first. Both inputPath and outputPath must resolve \u2014 after ~ expansion and symlink resolution \u2014 to locations under your home directory, /tmp, /private/tmp, or /Volumes; anything else is rejected with an error naming those roots.',
22935
23070
  inputSchema: {
22936
23071
  inputPath: external_exports.string().describe("Path to the CSV/TSV/JSON input file"),
22937
23072
  outputPath: external_exports.string().describe("Path for the output .numbers file"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-numbers-mcp",
3
- "version": "1.1.18",
3
+ "version": "1.2.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",