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.
- package/README.md +10 -3
- package/build/index.js +177 -42
- 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 =
|
|
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
|
|
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
|
|
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:
|
|
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((
|
|
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((
|
|
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
|
-
|
|
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((
|
|
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(
|
|
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((
|
|
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((
|
|
21388
|
+
return new Promise((resolve3) => {
|
|
21389
21389
|
const json2 = serializeMessage(message);
|
|
21390
21390
|
if (this._stdout.write(json2)) {
|
|
21391
|
-
|
|
21391
|
+
resolve3();
|
|
21392
21392
|
} else {
|
|
21393
|
-
this._stdout.once("drain",
|
|
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
|
|
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
|
|
21816
|
-
import {
|
|
21817
|
-
|
|
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
|
|
21821
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
21846
|
-
|
|
21847
|
-
|
|
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.
|
|
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.
|
|
22004
|
-
if (!
|
|
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
|
|
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) =>
|
|
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 &&
|
|
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
|
|
22339
|
-
import { join as
|
|
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
|
|
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 (!
|
|
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 =
|
|
22480
|
-
var pkg = JSON.parse(readFileSync3(
|
|
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