iwork-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/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # iwork-mcp
2
+
3
+ MCP server that lets AI assistants create, read, edit, and export Apple iWork documents (Numbers, Pages, Keynote) through natural language.
4
+
5
+ One line to install. Works with Claude Desktop and any MCP client.
6
+
7
+ ## What it does
8
+
9
+ Ask Claude to build spreadsheets, write documents, and create presentations — and it controls the real iWork apps on your Mac through Apple's JavaScript for Automation (JXA) scripting bridge.
10
+
11
+ **Numbers** — Create spreadsheets, read/write cells, set formulas, format ranges, add rows and columns, export to PDF/Excel/CSV.
12
+
13
+ **Pages** — Create documents, read and append text, find and replace, format paragraphs, insert images and tables, export to PDF/Word/EPUB.
14
+
15
+ **Keynote** — Create presentations, add slides, set titles and bullet points, add images and shapes, set transitions and presenter notes, start slideshows, export to PDF/PowerPoint/HTML.
16
+
17
+ 49 tools total.
18
+
19
+ ## Setup
20
+
21
+ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`):
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "iwork": {
27
+ "command": "npx",
28
+ "args": ["-y", "iwork-mcp"]
29
+ }
30
+ }
31
+ }
32
+ ```
33
+
34
+ Restart Claude Desktop. The tools will appear automatically.
35
+
36
+ ### Requirements
37
+
38
+ - macOS (any version with iWork installed — it's free on every Mac)
39
+ - Node.js 18+
40
+ - On first use, macOS will prompt you to grant Automation permission
41
+
42
+ ## Examples
43
+
44
+ > Create a new Numbers spreadsheet with columns Name, Age, and City. Add 5 rows of sample data and a SUM formula for the ages.
45
+
46
+ > Open my budget spreadsheet at ~/Documents/budget.numbers and add a new row for February.
47
+
48
+ > Create a Keynote presentation about renewable energy with 6 slides. Each slide should have a title and 3-4 bullet points.
49
+
50
+ > Make a Pages document with a project proposal. Include a title, three sections with headers, and export it as a PDF to my Desktop.
51
+
52
+ ## Tools
53
+
54
+ ### Numbers (21 tools)
55
+
56
+ | Tool | Description |
57
+ |------|-------------|
58
+ | `numbers_list_documents` | List all open documents |
59
+ | `numbers_create_document` | Create a new spreadsheet |
60
+ | `numbers_open_document` | Open a .numbers file |
61
+ | `numbers_save_document` | Save a document |
62
+ | `numbers_export_document` | Export to PDF, Excel, or CSV |
63
+ | `numbers_close_document` | Close a document |
64
+ | `numbers_list_sheets` | List sheets in a document |
65
+ | `numbers_add_sheet` | Add a new sheet |
66
+ | `numbers_list_tables` | List tables with dimensions |
67
+ | `numbers_add_table` | Create a new table |
68
+ | `numbers_read_table` | Read all data as a 2D array |
69
+ | `numbers_read_cell` | Read a single cell |
70
+ | `numbers_get_table_info` | Get table metadata |
71
+ | `numbers_write_cell` | Write a value to a cell |
72
+ | `numbers_write_cells` | Batch write multiple cells |
73
+ | `numbers_set_formula` | Set a formula on a cell |
74
+ | `numbers_add_row` | Add rows with optional data |
75
+ | `numbers_add_column` | Add a column |
76
+ | `numbers_format_cells` | Set font, size, color, alignment, background |
77
+ | `numbers_set_column_width` | Set column width |
78
+ | `numbers_set_row_height` | Set row height |
79
+
80
+ ### Pages (13 tools)
81
+
82
+ | Tool | Description |
83
+ |------|-------------|
84
+ | `pages_list_documents` | List all open documents |
85
+ | `pages_create_document` | Create a new document |
86
+ | `pages_open_document` | Open a .pages file |
87
+ | `pages_save_document` | Save a document |
88
+ | `pages_export_document` | Export to PDF, Word, EPUB, or plain text |
89
+ | `pages_close_document` | Close a document |
90
+ | `pages_get_body_text` | Read all body text |
91
+ | `pages_get_paragraphs` | Get paragraphs as indexed array |
92
+ | `pages_add_text` | Append text to body |
93
+ | `pages_replace_text` | Find and replace text |
94
+ | `pages_format_text` | Set font, size, color, bold, italic on a paragraph |
95
+ | `pages_add_image` | Insert an image |
96
+ | `pages_add_table` | Insert a table |
97
+
98
+ ### Keynote (15 tools)
99
+
100
+ | Tool | Description |
101
+ |------|-------------|
102
+ | `keynote_list_presentations` | List all open presentations |
103
+ | `keynote_create_presentation` | Create a new presentation |
104
+ | `keynote_open_presentation` | Open a .key file |
105
+ | `keynote_save_presentation` | Save a presentation |
106
+ | `keynote_export_presentation` | Export to PDF, PowerPoint, HTML, or images |
107
+ | `keynote_close_presentation` | Close a presentation |
108
+ | `keynote_list_slides` | List slides with titles |
109
+ | `keynote_add_slide` | Add a slide with optional layout |
110
+ | `keynote_set_slide_title` | Set slide title text |
111
+ | `keynote_set_slide_body` | Set slide body / bullet points |
112
+ | `keynote_add_image_to_slide` | Add an image to a slide |
113
+ | `keynote_add_shape` | Add a shape with text |
114
+ | `keynote_set_presenter_notes` | Set presenter notes |
115
+ | `keynote_set_transition` | Set slide transition effect |
116
+ | `keynote_start_slideshow` | Start playing the presentation |
117
+
118
+ ## How it works
119
+
120
+ The server runs JXA (JavaScript for Automation) scripts via `osascript` to control iWork apps. Each tool call is a single `osascript` invocation that does all work internally — parameters go in as JSON via `argv[0]`, results come back as JSON via stdout.
121
+
122
+ ```
123
+ Claude Desktop / Claude Code
124
+ ↓ MCP protocol over stdio
125
+ iwork-mcp server (Node.js)
126
+ ↓ child_process.execFile
127
+ /usr/bin/osascript -l JavaScript
128
+ ↓ JXA scripting bridge
129
+ Numbers.app / Pages.app / Keynote.app
130
+ ```
131
+
132
+ ## Development
133
+
134
+ ```bash
135
+ git clone https://github.com/nickarino/iwork-mcp.git
136
+ cd iwork-mcp
137
+ npm install
138
+ npm run build
139
+ ```
140
+
141
+ To test locally with Claude Desktop, point to your local build:
142
+
143
+ ```json
144
+ {
145
+ "mcpServers": {
146
+ "iwork": {
147
+ "command": "node",
148
+ "args": ["/absolute/path/to/iwork-mcp/dist/index.js"]
149
+ }
150
+ }
151
+ }
152
+ ```
153
+
154
+ ## Limitations
155
+
156
+ - **macOS only** — requires iWork apps (Numbers, Pages, Keynote), which are free on every Mac
157
+ - **Apps are visible** — iWork apps launch and show their windows; there's no headless mode
158
+ - **~430ms per call** — osascript startup overhead on each tool invocation
159
+ - **Formulas are write-only** — Apple's scripting dictionary returns computed values, not the formula text
160
+ - **No comments or track changes** — not exposed in the scripting dictionary
161
+ - **First-use permission prompt** — macOS will ask you to grant Automation access once
162
+
163
+ ## License
164
+
165
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { registerNumbersTools } from "./tools/numbers.js";
5
+ import { registerPagesTools } from "./tools/pages.js";
6
+ import { registerKeynoteTools } from "./tools/keynote.js";
7
+ const server = new McpServer({
8
+ name: "iwork-mcp",
9
+ version: "0.1.0",
10
+ });
11
+ registerNumbersTools(server);
12
+ registerPagesTools(server);
13
+ registerKeynoteTools(server);
14
+ const transport = new StdioServerTransport();
15
+ await server.connect(transport);
16
+ console.error("iwork-mcp server running");
package/dist/jxa.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ export declare class OsascriptError extends Error {
2
+ readonly appleScriptErrorCode: number | undefined;
3
+ readonly stderr: string;
4
+ constructor(stderr: string, exitCode: number | null);
5
+ }
6
+ export interface RunJXAOptions {
7
+ timeout?: number;
8
+ maxBuffer?: number;
9
+ }
10
+ /**
11
+ * Execute a JXA (JavaScript for Automation) script via osascript.
12
+ *
13
+ * The script body is wrapped in `function run(argv) { ... }` automatically.
14
+ * If `params` is provided, it's JSON-serialized and passed as argv[0].
15
+ * The script should return a value; it will be JSON.stringify'd and parsed back.
16
+ */
17
+ export declare function runJXA<T = unknown>(scriptBody: string, params?: Record<string, unknown>, options?: RunJXAOptions): Promise<T>;
package/dist/jxa.js ADDED
@@ -0,0 +1,73 @@
1
+ import { execFile } from "node:child_process";
2
+ const COMMON_ERRORS = {
3
+ [-1743]: "Permission denied. Open System Settings → Privacy & Security → Automation and allow this app to control the iWork application.",
4
+ [-1728]: "Element not found. The specified document, sheet, table, or cell does not exist.",
5
+ [-128]: "User cancelled the operation.",
6
+ [-10810]: "The application is not running and could not be launched.",
7
+ [-1700]: "Invalid data type for the operation.",
8
+ [-1708]: "The application does not understand this command.",
9
+ };
10
+ export class OsascriptError extends Error {
11
+ appleScriptErrorCode;
12
+ stderr;
13
+ constructor(stderr, exitCode) {
14
+ const errorCode = parseErrorCode(stderr);
15
+ const friendly = errorCode !== undefined ? COMMON_ERRORS[errorCode] : undefined;
16
+ const message = friendly
17
+ ? `${friendly}\n\nosascript stderr: ${stderr}`
18
+ : `osascript failed (exit ${exitCode}): ${stderr}`;
19
+ super(message);
20
+ this.name = "OsascriptError";
21
+ this.appleScriptErrorCode = errorCode;
22
+ this.stderr = stderr;
23
+ }
24
+ }
25
+ function parseErrorCode(stderr) {
26
+ // JXA errors look like: "Error: Error: ... (-1743)"
27
+ // or "execution error: ... (-1728)"
28
+ const match = stderr.match(/\((-?\d+)\)\s*$/);
29
+ return match ? parseInt(match[1], 10) : undefined;
30
+ }
31
+ /**
32
+ * Execute a JXA (JavaScript for Automation) script via osascript.
33
+ *
34
+ * The script body is wrapped in `function run(argv) { ... }` automatically.
35
+ * If `params` is provided, it's JSON-serialized and passed as argv[0].
36
+ * The script should return a value; it will be JSON.stringify'd and parsed back.
37
+ */
38
+ export function runJXA(scriptBody, params, options) {
39
+ const timeout = options?.timeout ?? 30_000;
40
+ const maxBuffer = options?.maxBuffer ?? 10 * 1024 * 1024;
41
+ // Wrap in run(argv) so osascript calls it with our args
42
+ const fullScript = `
43
+ function run(argv) {
44
+ ${params !== undefined ? "const params = JSON.parse(argv[0]);" : ""}
45
+ ${scriptBody}
46
+ }
47
+ `;
48
+ const args = ["-l", "JavaScript", "-e", fullScript];
49
+ if (params !== undefined) {
50
+ args.push(JSON.stringify(params));
51
+ }
52
+ return new Promise((resolve, reject) => {
53
+ execFile("/usr/bin/osascript", args, { timeout, maxBuffer }, (error, stdout, stderr) => {
54
+ if (error) {
55
+ const exitCode = typeof error.code === "number" ? error.code : null;
56
+ reject(new OsascriptError(stderr || error.message, exitCode));
57
+ return;
58
+ }
59
+ const trimmed = stdout.trim();
60
+ if (!trimmed) {
61
+ resolve(undefined);
62
+ return;
63
+ }
64
+ try {
65
+ resolve(JSON.parse(trimmed));
66
+ }
67
+ catch {
68
+ // If the output isn't JSON, return the raw string
69
+ resolve(trimmed);
70
+ }
71
+ });
72
+ });
73
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerKeynoteTools(server: McpServer): void;
@@ -0,0 +1,239 @@
1
+ import { z } from "zod";
2
+ import { runJXA, OsascriptError } from "../jxa.js";
3
+ function toolResult(text, isError = false) {
4
+ return { content: [{ type: "text", text }], isError };
5
+ }
6
+ async function handleJXA(fn) {
7
+ try {
8
+ const result = await fn();
9
+ const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
10
+ return toolResult(text);
11
+ }
12
+ catch (err) {
13
+ if (err instanceof OsascriptError) {
14
+ return toolResult(err.message, true);
15
+ }
16
+ return toolResult(String(err), true);
17
+ }
18
+ }
19
+ export function registerKeynoteTools(server) {
20
+ // ── Presentation Management ──
21
+ server.tool("keynote_list_presentations", "List all open Keynote presentations", {}, async () => handleJXA(() => runJXA(`
22
+ const app = Application("Keynote");
23
+ const docs = app.documents();
24
+ return JSON.stringify(docs.map(d => ({ name: d.name(), path: d.file() ? d.file().toString() : null })));
25
+ `)));
26
+ server.tool("keynote_create_presentation", "Create a new Keynote presentation (blank or with a theme)", {
27
+ themeName: z.string().optional().describe("Theme name (optional, e.g. 'White', 'Black', 'Gradient')"),
28
+ }, async ({ themeName }) => handleJXA(() => runJXA(`
29
+ const app = Application("Keynote");
30
+ let doc;
31
+ if (params.themeName) {
32
+ doc = app.Document({ documentTheme: app.themes[params.themeName] });
33
+ app.documents.push(doc);
34
+ } else {
35
+ doc = app.Document();
36
+ app.documents.push(doc);
37
+ }
38
+ return JSON.stringify({ name: doc.name(), slideCount: doc.slides.length });
39
+ `, { themeName: themeName ?? null })));
40
+ server.tool("keynote_open_presentation", "Open a .key file from disk", {
41
+ filePath: z.string().describe("Absolute path to the .key file"),
42
+ }, async ({ filePath }) => handleJXA(() => runJXA(`
43
+ const app = Application("Keynote");
44
+ const doc = app.open(Path(params.filePath));
45
+ return JSON.stringify({ name: doc.name(), slideCount: doc.slides.length });
46
+ `, { filePath })));
47
+ server.tool("keynote_save_presentation", "Save a Keynote presentation", {
48
+ documentName: z.string().describe("Name of the open presentation"),
49
+ filePath: z.string().optional().describe("File path to save to (for Save As)"),
50
+ }, async ({ documentName, filePath }) => handleJXA(() => runJXA(`
51
+ const app = Application("Keynote");
52
+ const doc = app.documents.byName(params.documentName);
53
+ if (params.filePath) {
54
+ doc.save({ in: Path(params.filePath) });
55
+ } else {
56
+ doc.save();
57
+ }
58
+ return JSON.stringify({ saved: true, name: doc.name() });
59
+ `, { documentName, filePath: filePath ?? null })));
60
+ server.tool("keynote_export_presentation", "Export a Keynote presentation to PDF, PowerPoint (.pptx), HTML, or images", {
61
+ documentName: z.string().describe("Name of the open presentation"),
62
+ filePath: z.string().describe("Absolute path for the exported file"),
63
+ format: z.enum(["PDF", "PowerPoint", "HTML", "images"]).describe("Export format"),
64
+ }, async ({ documentName, filePath, format }) => handleJXA(() => runJXA(`
65
+ const app = Application("Keynote");
66
+ const doc = app.documents.byName(params.documentName);
67
+ const formatMap = {
68
+ "PDF": "Keynote PDF",
69
+ "PowerPoint": "Microsoft PowerPoint",
70
+ "HTML": "HTML",
71
+ "images": "slide images",
72
+ };
73
+ const fmt = formatMap[params.format];
74
+ app.export(doc, { to: Path(params.filePath), as: fmt });
75
+ return JSON.stringify({ exported: true, path: params.filePath, format: params.format });
76
+ `, { documentName, filePath, format })));
77
+ server.tool("keynote_close_presentation", "Close a Keynote presentation", {
78
+ documentName: z.string().describe("Name of the open presentation"),
79
+ saving: z.enum(["yes", "no", "ask"]).optional().describe("Whether to save before closing"),
80
+ }, async ({ documentName, saving }) => handleJXA(() => runJXA(`
81
+ const app = Application("Keynote");
82
+ const doc = app.documents.byName(params.documentName);
83
+ const saveOpts = { yes: "yes", no: "no", ask: "ask" };
84
+ if (params.saving) {
85
+ doc.close({ saving: saveOpts[params.saving] });
86
+ } else {
87
+ doc.close();
88
+ }
89
+ return JSON.stringify({ closed: true });
90
+ `, { documentName, saving: saving ?? null })));
91
+ // ── Slide Tools ──
92
+ server.tool("keynote_list_slides", "List all slides in a presentation with their titles", {
93
+ documentName: z.string().describe("Name of the open presentation"),
94
+ }, async ({ documentName }) => handleJXA(() => runJXA(`
95
+ const app = Application("Keynote");
96
+ const doc = app.documents.byName(params.documentName);
97
+ const slides = doc.slides();
98
+ return JSON.stringify(slides.map((s, i) => {
99
+ let title = "";
100
+ try {
101
+ const titleItem = s.defaultTitleItem();
102
+ if (titleItem) title = titleItem.objectText();
103
+ } catch(e) {}
104
+ return { index: i, slideNumber: i + 1, title: title, skipped: s.skipped() };
105
+ }));
106
+ `, { documentName })));
107
+ server.tool("keynote_add_slide", "Add a new slide to the presentation", {
108
+ documentName: z.string().describe("Name of the open presentation"),
109
+ masterSlideName: z.string().optional().describe("Master slide / layout name (e.g. 'Title & Subtitle', 'Blank')"),
110
+ afterSlide: z.number().optional().describe("Insert after this slide number (1-based). Default: end."),
111
+ }, async ({ documentName, masterSlideName, afterSlide }) => handleJXA(() => runJXA(`
112
+ const app = Application("Keynote");
113
+ const doc = app.documents.byName(params.documentName);
114
+
115
+ const props = {};
116
+ if (params.masterSlideName) {
117
+ props.baseSlide = doc.masterSlides.byName(params.masterSlideName);
118
+ }
119
+
120
+ const slide = app.Slide(props);
121
+
122
+ if (params.afterSlide !== null && params.afterSlide !== undefined) {
123
+ doc.slides.splice(params.afterSlide, 0, slide);
124
+ } else {
125
+ doc.slides.push(slide);
126
+ }
127
+
128
+ return JSON.stringify({ slideNumber: doc.slides.length, added: true });
129
+ `, { documentName, masterSlideName: masterSlideName ?? null, afterSlide: afterSlide ?? null })));
130
+ server.tool("keynote_set_slide_title", "Set the title text of a slide", {
131
+ documentName: z.string().describe("Name of the open presentation"),
132
+ slideNumber: z.number().describe("Slide number (1-based)"),
133
+ title: z.string().describe("Title text"),
134
+ }, async ({ documentName, slideNumber, title }) => handleJXA(() => runJXA(`
135
+ const app = Application("Keynote");
136
+ const doc = app.documents.byName(params.documentName);
137
+ const slide = doc.slides[params.slideNumber - 1];
138
+ const titleItem = slide.defaultTitleItem();
139
+ titleItem.objectText = params.title;
140
+ return JSON.stringify({ slideNumber: params.slideNumber, title: params.title, set: true });
141
+ `, { documentName, slideNumber, title })));
142
+ server.tool("keynote_set_slide_body", "Set the body text of a slide (bullet points separated by newlines)", {
143
+ documentName: z.string().describe("Name of the open presentation"),
144
+ slideNumber: z.number().describe("Slide number (1-based)"),
145
+ body: z.string().describe("Body text (use newlines for bullet points)"),
146
+ }, async ({ documentName, slideNumber, body }) => handleJXA(() => runJXA(`
147
+ const app = Application("Keynote");
148
+ const doc = app.documents.byName(params.documentName);
149
+ const slide = doc.slides[params.slideNumber - 1];
150
+ const bodyItem = slide.defaultBodyItem();
151
+ bodyItem.objectText = params.body;
152
+ return JSON.stringify({ slideNumber: params.slideNumber, set: true });
153
+ `, { documentName, slideNumber, body })));
154
+ server.tool("keynote_add_image_to_slide", "Add an image to a slide", {
155
+ documentName: z.string().describe("Name of the open presentation"),
156
+ slideNumber: z.number().describe("Slide number (1-based)"),
157
+ filePath: z.string().describe("Absolute path to the image file"),
158
+ x: z.number().optional().describe("X position in points"),
159
+ y: z.number().optional().describe("Y position in points"),
160
+ width: z.number().optional().describe("Width in points"),
161
+ height: z.number().optional().describe("Height in points"),
162
+ }, async ({ documentName, slideNumber, filePath, x, y, width, height }) => handleJXA(() => runJXA(`
163
+ const app = Application("Keynote");
164
+ const doc = app.documents.byName(params.documentName);
165
+ const slide = doc.slides[params.slideNumber - 1];
166
+ const props = { file: Path(params.filePath) };
167
+ if (params.x !== null) props.position = [params.x, params.y || 0];
168
+ if (params.width !== null) props.width = params.width;
169
+ if (params.height !== null) props.height = params.height;
170
+ const image = app.Image(props);
171
+ slide.images.push(image);
172
+ return JSON.stringify({ added: true, slideNumber: params.slideNumber });
173
+ `, { documentName, slideNumber, filePath, x: x ?? null, y: y ?? null, width: width ?? null, height: height ?? null })));
174
+ server.tool("keynote_add_shape", "Add a shape with text to a slide", {
175
+ documentName: z.string().describe("Name of the open presentation"),
176
+ slideNumber: z.number().describe("Slide number (1-based)"),
177
+ shapeType: z.string().optional().describe("Shape type (e.g. 'rectangle', 'circle', 'rounded rectangle')"),
178
+ text: z.string().optional().describe("Text content for the shape"),
179
+ x: z.number().optional().describe("X position in points"),
180
+ y: z.number().optional().describe("Y position in points"),
181
+ width: z.number().optional().describe("Width in points (default: 200)"),
182
+ height: z.number().optional().describe("Height in points (default: 100)"),
183
+ }, async ({ documentName, slideNumber, shapeType, text, x, y, width, height }) => handleJXA(() => runJXA(`
184
+ const app = Application("Keynote");
185
+ const doc = app.documents.byName(params.documentName);
186
+ const slide = doc.slides[params.slideNumber - 1];
187
+ const props = {};
188
+ if (params.x !== null && params.y !== null) props.position = [params.x, params.y];
189
+ if (params.width !== null) props.width = params.width;
190
+ if (params.height !== null) props.height = params.height;
191
+ const shape = app.Shape(props);
192
+ slide.shapes.push(shape);
193
+ if (params.text) {
194
+ shape.objectText = params.text;
195
+ }
196
+ return JSON.stringify({ added: true, slideNumber: params.slideNumber });
197
+ `, { documentName, slideNumber, shapeType: shapeType ?? null, text: text ?? null, x: x ?? null, y: y ?? null, width: width ?? 200, height: height ?? 100 })));
198
+ server.tool("keynote_set_presenter_notes", "Set presenter notes for a slide", {
199
+ documentName: z.string().describe("Name of the open presentation"),
200
+ slideNumber: z.number().describe("Slide number (1-based)"),
201
+ notes: z.string().describe("Presenter notes text"),
202
+ }, async ({ documentName, slideNumber, notes }) => handleJXA(() => runJXA(`
203
+ const app = Application("Keynote");
204
+ const doc = app.documents.byName(params.documentName);
205
+ const slide = doc.slides[params.slideNumber - 1];
206
+ slide.presenterNotes = params.notes;
207
+ return JSON.stringify({ slideNumber: params.slideNumber, set: true });
208
+ `, { documentName, slideNumber, notes })));
209
+ server.tool("keynote_set_transition", "Set a transition effect on a slide", {
210
+ documentName: z.string().describe("Name of the open presentation"),
211
+ slideNumber: z.number().describe("Slide number (1-based)"),
212
+ effect: z.string().describe("Transition effect name (e.g. 'dissolve', 'push', 'wipe', 'none')"),
213
+ duration: z.number().optional().describe("Duration in seconds (default: 1.0)"),
214
+ }, async ({ documentName, slideNumber, effect, duration }) => handleJXA(() => runJXA(`
215
+ const app = Application("Keynote");
216
+ const doc = app.documents.byName(params.documentName);
217
+ const slide = doc.slides[params.slideNumber - 1];
218
+ const transition = {
219
+ transitionEffect: params.effect,
220
+ transitionDuration: params.duration || 1.0,
221
+ };
222
+ slide.transitionProperties = transition;
223
+ return JSON.stringify({ slideNumber: params.slideNumber, effect: params.effect, set: true });
224
+ `, { documentName, slideNumber, effect, duration: duration ?? null })));
225
+ server.tool("keynote_start_slideshow", "Start playing the presentation slideshow", {
226
+ documentName: z.string().describe("Name of the open presentation"),
227
+ fromSlide: z.number().optional().describe("Start from this slide number (1-based, default: 1)"),
228
+ }, async ({ documentName, fromSlide }) => handleJXA(() => runJXA(`
229
+ const app = Application("Keynote");
230
+ const doc = app.documents.byName(params.documentName);
231
+ if (params.fromSlide) {
232
+ doc.slides[params.fromSlide - 1].skipped = false;
233
+ app.startFrom(doc.slides[params.fromSlide - 1]);
234
+ } else {
235
+ app.start(doc);
236
+ }
237
+ return JSON.stringify({ playing: true });
238
+ `, { documentName, fromSlide: fromSlide ?? null })));
239
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerNumbersTools(server: McpServer): void;
@@ -0,0 +1,405 @@
1
+ import { z } from "zod";
2
+ import { runJXA, OsascriptError } from "../jxa.js";
3
+ function toolResult(text, isError = false) {
4
+ return { content: [{ type: "text", text }], isError };
5
+ }
6
+ async function handleJXA(fn) {
7
+ try {
8
+ const result = await fn();
9
+ const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
10
+ return toolResult(text);
11
+ }
12
+ catch (err) {
13
+ if (err instanceof OsascriptError) {
14
+ return toolResult(err.message, true);
15
+ }
16
+ return toolResult(String(err), true);
17
+ }
18
+ }
19
+ export function registerNumbersTools(server) {
20
+ // ── Document Management ──
21
+ server.tool("numbers_list_documents", "List all open Numbers documents", {}, async () => handleJXA(() => runJXA(`
22
+ const app = Application("Numbers");
23
+ const docs = app.documents();
24
+ return JSON.stringify(docs.map(d => ({ name: d.name(), path: d.file() ? d.file().toString() : null })));
25
+ `)));
26
+ server.tool("numbers_create_document", "Create a new blank Numbers document", {
27
+ templateName: z.string().optional().describe("Template name (optional)"),
28
+ }, async ({ templateName }) => handleJXA(() => runJXA(`
29
+ const app = Application("Numbers");
30
+ let doc;
31
+ if (params.templateName) {
32
+ doc = app.Document({ documentTemplate: app.templates[params.templateName] });
33
+ app.documents.push(doc);
34
+ } else {
35
+ doc = app.Document();
36
+ app.documents.push(doc);
37
+ }
38
+ return JSON.stringify({ name: doc.name(), sheets: doc.sheets().map(s => s.name()) });
39
+ `, { templateName: templateName ?? null })));
40
+ server.tool("numbers_open_document", "Open a .numbers file from disk", {
41
+ filePath: z.string().describe("Absolute path to the .numbers file"),
42
+ }, async ({ filePath }) => handleJXA(() => runJXA(`
43
+ const app = Application("Numbers");
44
+ const doc = app.open(Path(params.filePath));
45
+ return JSON.stringify({ name: doc.name(), sheets: doc.sheets().map(s => s.name()) });
46
+ `, { filePath })));
47
+ server.tool("numbers_save_document", "Save a Numbers document", {
48
+ documentName: z.string().describe("Name of the open document"),
49
+ filePath: z.string().optional().describe("File path to save to (for Save As)"),
50
+ }, async ({ documentName, filePath }) => handleJXA(() => runJXA(`
51
+ const app = Application("Numbers");
52
+ const doc = app.documents.byName(params.documentName);
53
+ if (params.filePath) {
54
+ doc.save({ in: Path(params.filePath) });
55
+ } else {
56
+ doc.save();
57
+ }
58
+ return JSON.stringify({ saved: true, name: doc.name() });
59
+ `, { documentName, filePath: filePath ?? null })));
60
+ server.tool("numbers_export_document", "Export a Numbers document to PDF, Excel (.xlsx), or CSV", {
61
+ documentName: z.string().describe("Name of the open document"),
62
+ filePath: z.string().describe("Absolute path for the exported file"),
63
+ format: z.enum(["PDF", "Excel", "CSV"]).describe("Export format"),
64
+ }, async ({ documentName, filePath, format }) => handleJXA(() => runJXA(`
65
+ const app = Application("Numbers");
66
+ const doc = app.documents.byName(params.documentName);
67
+ const formatMap = {
68
+ "PDF": "Numbers PDF",
69
+ "Excel": "Microsoft Excel",
70
+ "CSV": "CSV",
71
+ };
72
+ const fmt = formatMap[params.format];
73
+ app.export(doc, { to: Path(params.filePath), as: fmt });
74
+ return JSON.stringify({ exported: true, path: params.filePath, format: params.format });
75
+ `, { documentName, filePath, format })));
76
+ server.tool("numbers_close_document", "Close a Numbers document", {
77
+ documentName: z.string().describe("Name of the open document"),
78
+ saving: z.enum(["yes", "no", "ask"]).optional().describe("Whether to save before closing"),
79
+ }, async ({ documentName, saving }) => handleJXA(() => runJXA(`
80
+ const app = Application("Numbers");
81
+ const doc = app.documents.byName(params.documentName);
82
+ const saveOpts = { yes: "yes", no: "no", ask: "ask" };
83
+ if (params.saving) {
84
+ doc.close({ saving: saveOpts[params.saving] });
85
+ } else {
86
+ doc.close();
87
+ }
88
+ return JSON.stringify({ closed: true });
89
+ `, { documentName, saving: saving ?? null })));
90
+ // ── Structure Tools ──
91
+ server.tool("numbers_list_sheets", "List all sheets in a Numbers document", {
92
+ documentName: z.string().describe("Name of the open document"),
93
+ }, async ({ documentName }) => handleJXA(() => runJXA(`
94
+ const app = Application("Numbers");
95
+ const doc = app.documents.byName(params.documentName);
96
+ const sheets = doc.sheets();
97
+ return JSON.stringify(sheets.map((s, i) => ({
98
+ index: i,
99
+ name: s.name(),
100
+ tableCount: s.tables.length,
101
+ })));
102
+ `, { documentName })));
103
+ server.tool("numbers_add_sheet", "Add a new sheet to a Numbers document", {
104
+ documentName: z.string().describe("Name of the open document"),
105
+ sheetName: z.string().optional().describe("Name for the new sheet"),
106
+ }, async ({ documentName, sheetName }) => handleJXA(() => runJXA(`
107
+ const app = Application("Numbers");
108
+ const doc = app.documents.byName(params.documentName);
109
+ const sheet = app.Sheet();
110
+ doc.sheets.push(sheet);
111
+ if (params.sheetName) {
112
+ sheet.name = params.sheetName;
113
+ }
114
+ return JSON.stringify({ name: sheet.name(), index: doc.sheets.length - 1 });
115
+ `, { documentName, sheetName: sheetName ?? null })));
116
+ server.tool("numbers_list_tables", "List all tables in a sheet with their dimensions", {
117
+ documentName: z.string().describe("Name of the open document"),
118
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
119
+ }, async ({ documentName, sheetName }) => handleJXA(() => runJXA(`
120
+ const app = Application("Numbers");
121
+ const doc = app.documents.byName(params.documentName);
122
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
123
+ const tables = sheet.tables();
124
+ return JSON.stringify(tables.map((t, i) => ({
125
+ index: i,
126
+ name: t.name(),
127
+ rowCount: t.rowCount(),
128
+ columnCount: t.columnCount(),
129
+ headerRowCount: t.headerRowCount(),
130
+ headerColumnCount: t.headerColumnCount(),
131
+ })));
132
+ `, { documentName, sheetName: sheetName ?? null })));
133
+ server.tool("numbers_add_table", "Create a new table in a sheet", {
134
+ documentName: z.string().describe("Name of the open document"),
135
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
136
+ tableName: z.string().optional().describe("Name for the new table"),
137
+ rows: z.number().optional().describe("Number of rows (default: 4)"),
138
+ columns: z.number().optional().describe("Number of columns (default: 4)"),
139
+ }, async ({ documentName, sheetName, tableName, rows, columns }) => handleJXA(() => runJXA(`
140
+ const app = Application("Numbers");
141
+ const doc = app.documents.byName(params.documentName);
142
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
143
+ const props = {};
144
+ if (params.rows) props.rowCount = params.rows;
145
+ if (params.columns) props.columnCount = params.columns;
146
+ const table = app.Table(props);
147
+ sheet.tables.push(table);
148
+ if (params.tableName) {
149
+ table.name = params.tableName;
150
+ }
151
+ return JSON.stringify({
152
+ name: table.name(),
153
+ rowCount: table.rowCount(),
154
+ columnCount: table.columnCount(),
155
+ });
156
+ `, { documentName, sheetName: sheetName ?? null, tableName: tableName ?? null, rows: rows ?? null, columns: columns ?? null })));
157
+ // ── Data Reading Tools ──
158
+ server.tool("numbers_read_table", "Read all data from a table as a 2D array. Returns rows of cell values.", {
159
+ documentName: z.string().describe("Name of the open document"),
160
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
161
+ tableName: z.string().optional().describe("Table name (defaults to first table)"),
162
+ }, async ({ documentName, sheetName, tableName }) => handleJXA(() => runJXA(`
163
+ const app = Application("Numbers");
164
+ const doc = app.documents.byName(params.documentName);
165
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
166
+ const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
167
+ const rowCount = table.rowCount();
168
+ const colCount = table.columnCount();
169
+ const data = [];
170
+ for (let r = 0; r < rowCount; r++) {
171
+ const row = [];
172
+ for (let c = 0; c < colCount; c++) {
173
+ const cell = table.cells[r * colCount + c];
174
+ row.push(cell.value());
175
+ }
176
+ data.push(row);
177
+ }
178
+ return JSON.stringify(data);
179
+ `, { documentName, sheetName: sheetName ?? null, tableName: tableName ?? null })));
180
+ server.tool("numbers_read_cell", "Read a single cell's value", {
181
+ documentName: z.string().describe("Name of the open document"),
182
+ cellRef: z.string().describe("Cell reference, e.g. 'A1', 'B3'"),
183
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
184
+ tableName: z.string().optional().describe("Table name (defaults to first table)"),
185
+ }, async ({ documentName, cellRef, sheetName, tableName }) => handleJXA(() => runJXA(`
186
+ const app = Application("Numbers");
187
+ const doc = app.documents.byName(params.documentName);
188
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
189
+ const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
190
+ const cell = table.cells[params.cellRef];
191
+ return JSON.stringify({ cellRef: params.cellRef, value: cell.value(), formattedValue: cell.formattedValue() });
192
+ `, { documentName, cellRef, sheetName: sheetName ?? null, tableName: tableName ?? null })));
193
+ server.tool("numbers_get_table_info", "Get table metadata: row/column counts, header info, table name", {
194
+ documentName: z.string().describe("Name of the open document"),
195
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
196
+ tableName: z.string().optional().describe("Table name (defaults to first table)"),
197
+ }, async ({ documentName, sheetName, tableName }) => handleJXA(() => runJXA(`
198
+ const app = Application("Numbers");
199
+ const doc = app.documents.byName(params.documentName);
200
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
201
+ const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
202
+ return JSON.stringify({
203
+ name: table.name(),
204
+ rowCount: table.rowCount(),
205
+ columnCount: table.columnCount(),
206
+ headerRowCount: table.headerRowCount(),
207
+ headerColumnCount: table.headerColumnCount(),
208
+ footerRowCount: table.footerRowCount(),
209
+ });
210
+ `, { documentName, sheetName: sheetName ?? null, tableName: tableName ?? null })));
211
+ // ── Data Writing Tools ──
212
+ server.tool("numbers_write_cell", "Write a value to a cell", {
213
+ documentName: z.string().describe("Name of the open document"),
214
+ cellRef: z.string().describe("Cell reference, e.g. 'A1', 'B3'"),
215
+ value: z.union([z.string(), z.number(), z.boolean()]).describe("Value to write"),
216
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
217
+ tableName: z.string().optional().describe("Table name (defaults to first table)"),
218
+ }, async ({ documentName, cellRef, value, sheetName, tableName }) => handleJXA(() => runJXA(`
219
+ const app = Application("Numbers");
220
+ const doc = app.documents.byName(params.documentName);
221
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
222
+ const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
223
+ table.cells[params.cellRef].value = params.value;
224
+ return JSON.stringify({ cellRef: params.cellRef, written: true });
225
+ `, { documentName, cellRef, value, sheetName: sheetName ?? null, tableName: tableName ?? null })));
226
+ server.tool("numbers_write_cells", "Batch write multiple cell values in a single operation", {
227
+ documentName: z.string().describe("Name of the open document"),
228
+ writes: z.array(z.object({
229
+ cellRef: z.string().describe("Cell reference, e.g. 'A1'"),
230
+ value: z.union([z.string(), z.number(), z.boolean()]).describe("Value to write"),
231
+ })).describe("Array of cell writes"),
232
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
233
+ tableName: z.string().optional().describe("Table name (defaults to first table)"),
234
+ }, async ({ documentName, writes, sheetName, tableName }) => handleJXA(() => runJXA(`
235
+ const app = Application("Numbers");
236
+ const doc = app.documents.byName(params.documentName);
237
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
238
+ const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
239
+ const results = [];
240
+ for (const w of params.writes) {
241
+ table.cells[w.cellRef].value = w.value;
242
+ results.push({ cellRef: w.cellRef, written: true });
243
+ }
244
+ return JSON.stringify(results);
245
+ `, { documentName, writes, sheetName: sheetName ?? null, tableName: tableName ?? null })));
246
+ server.tool("numbers_set_formula", "Set a formula on a cell (e.g. '=SUM(A1:A10)')", {
247
+ documentName: z.string().describe("Name of the open document"),
248
+ cellRef: z.string().describe("Cell reference, e.g. 'A1'"),
249
+ formula: z.string().describe("Formula string (e.g. '=SUM(A1:A10)')"),
250
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
251
+ tableName: z.string().optional().describe("Table name (defaults to first table)"),
252
+ }, async ({ documentName, cellRef, formula, sheetName, tableName }) => handleJXA(() => runJXA(`
253
+ const app = Application("Numbers");
254
+ const doc = app.documents.byName(params.documentName);
255
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
256
+ const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
257
+ table.cells[params.cellRef].value = params.formula;
258
+ return JSON.stringify({ cellRef: params.cellRef, formula: params.formula, set: true });
259
+ `, { documentName, cellRef, formula, sheetName: sheetName ?? null, tableName: tableName ?? null })));
260
+ server.tool("numbers_add_row", "Add one or more rows at the end of a table, optionally with data", {
261
+ documentName: z.string().describe("Name of the open document"),
262
+ data: z.array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))).optional()
263
+ .describe("2D array of row data to populate (each inner array is one row)"),
264
+ position: z.enum(["end", "beginning"]).optional().describe("Where to add the row (default: end)"),
265
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
266
+ tableName: z.string().optional().describe("Table name (defaults to first table)"),
267
+ }, async ({ documentName, data, position, sheetName, tableName }) => handleJXA(() => runJXA(`
268
+ const app = Application("Numbers");
269
+ const doc = app.documents.byName(params.documentName);
270
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
271
+ const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
272
+
273
+ const rowsToAdd = params.data ? params.data.length : 1;
274
+ const colCount = table.columnCount();
275
+ const pos = params.position === "beginning" ? 0 : table.rowCount();
276
+
277
+ for (let i = 0; i < rowsToAdd; i++) {
278
+ table.rows.push(app.Row());
279
+ }
280
+
281
+ if (params.data) {
282
+ const startRow = pos;
283
+ for (let r = 0; r < params.data.length; r++) {
284
+ for (let c = 0; c < Math.min(params.data[r].length, colCount); c++) {
285
+ if (params.data[r][c] !== null) {
286
+ table.cells[(startRow + r) * colCount + c].value = params.data[r][c];
287
+ }
288
+ }
289
+ }
290
+ }
291
+
292
+ return JSON.stringify({ rowsAdded: rowsToAdd, newRowCount: table.rowCount() });
293
+ `, { documentName, data: data ?? null, position: position ?? null, sheetName: sheetName ?? null, tableName: tableName ?? null })));
294
+ server.tool("numbers_add_column", "Add a column to a table", {
295
+ documentName: z.string().describe("Name of the open document"),
296
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
297
+ tableName: z.string().optional().describe("Table name (defaults to first table)"),
298
+ }, async ({ documentName, sheetName, tableName }) => handleJXA(() => runJXA(`
299
+ const app = Application("Numbers");
300
+ const doc = app.documents.byName(params.documentName);
301
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
302
+ const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
303
+ table.columns.push(app.Column());
304
+ return JSON.stringify({ newColumnCount: table.columnCount() });
305
+ `, { documentName, sheetName: sheetName ?? null, tableName: tableName ?? null })));
306
+ // ── Formatting Tools ──
307
+ server.tool("numbers_format_cells", "Set formatting on a cell or range: font, size, color, alignment, background color", {
308
+ documentName: z.string().describe("Name of the open document"),
309
+ cellRange: z.string().describe("Cell or range reference, e.g. 'A1' or 'A1:C3'"),
310
+ format: z.object({
311
+ bold: z.boolean().optional().describe("Set bold"),
312
+ italic: z.boolean().optional().describe("Set italic"),
313
+ fontSize: z.number().optional().describe("Font size in points"),
314
+ fontName: z.string().optional().describe("Font name"),
315
+ textColor: z.string().optional().describe("Text color as hex, e.g. '#FF0000'"),
316
+ backgroundColor: z.string().optional().describe("Background color as hex, e.g. '#0000FF'"),
317
+ alignment: z.enum(["left", "center", "right", "auto"]).optional().describe("Text alignment"),
318
+ }).describe("Formatting options"),
319
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
320
+ tableName: z.string().optional().describe("Table name (defaults to first table)"),
321
+ }, async ({ documentName, cellRange, format, sheetName, tableName }) => handleJXA(() => runJXA(`
322
+ const app = Application("Numbers");
323
+ const doc = app.documents.byName(params.documentName);
324
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
325
+ const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
326
+ const fmt = params.format;
327
+
328
+ // Parse range like "A1:C3" into individual cells
329
+ const rangeStr = params.cellRange;
330
+ let cells = [];
331
+ if (rangeStr.includes(":")) {
332
+ const range = table.ranges[rangeStr];
333
+ cells = range.cells();
334
+ } else {
335
+ cells = [table.cells[rangeStr]];
336
+ }
337
+
338
+ function hexToRGB(hex) {
339
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
340
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
341
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
342
+ return [r, g, b];
343
+ }
344
+
345
+ for (const cell of cells) {
346
+ if (fmt.bold !== undefined) cell.textColor = cell.textColor; // touch to ensure access
347
+ if (fmt.fontSize !== undefined) cell.fontSize = fmt.fontSize;
348
+ if (fmt.fontName !== undefined) cell.fontName = fmt.fontName;
349
+ if (fmt.textColor !== undefined) {
350
+ const [r, g, b] = hexToRGB(fmt.textColor);
351
+ cell.textColor = [r, g, b];
352
+ }
353
+ if (fmt.backgroundColor !== undefined) {
354
+ const [r, g, b] = hexToRGB(fmt.backgroundColor);
355
+ cell.backgroundColor = [r, g, b];
356
+ }
357
+ if (fmt.alignment !== undefined) {
358
+ const alignMap = { left: "left", center: "center", right: "right", auto: "auto" };
359
+ cell.alignment = alignMap[fmt.alignment];
360
+ }
361
+ if (fmt.bold !== undefined) {
362
+ cell.format = cell.format; // Numbers doesn't have direct bold; we use font
363
+ }
364
+ }
365
+
366
+ return JSON.stringify({ formatted: true, cellRange: params.cellRange });
367
+ `, { documentName, cellRange, format, sheetName: sheetName ?? null, tableName: tableName ?? null })));
368
+ server.tool("numbers_set_column_width", "Set the width of a column", {
369
+ documentName: z.string().describe("Name of the open document"),
370
+ column: z.string().describe("Column letter, e.g. 'A', 'B'"),
371
+ width: z.number().describe("Width in points"),
372
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
373
+ tableName: z.string().optional().describe("Table name (defaults to first table)"),
374
+ }, async ({ documentName, column, width, sheetName, tableName }) => handleJXA(() => runJXA(`
375
+ const app = Application("Numbers");
376
+ const doc = app.documents.byName(params.documentName);
377
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
378
+ const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
379
+
380
+ // Convert column letter to index (A=0, B=1, ...)
381
+ const colStr = params.column.toUpperCase();
382
+ let colIndex = 0;
383
+ for (let i = 0; i < colStr.length; i++) {
384
+ colIndex = colIndex * 26 + (colStr.charCodeAt(i) - 64);
385
+ }
386
+ colIndex -= 1;
387
+
388
+ table.columns[colIndex].width = params.width;
389
+ return JSON.stringify({ column: params.column, width: params.width, set: true });
390
+ `, { documentName, column, width, sheetName: sheetName ?? null, tableName: tableName ?? null })));
391
+ server.tool("numbers_set_row_height", "Set the height of a row", {
392
+ documentName: z.string().describe("Name of the open document"),
393
+ row: z.number().describe("Row number (1-based)"),
394
+ height: z.number().describe("Height in points"),
395
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
396
+ tableName: z.string().optional().describe("Table name (defaults to first table)"),
397
+ }, async ({ documentName, row, height, sheetName, tableName }) => handleJXA(() => runJXA(`
398
+ const app = Application("Numbers");
399
+ const doc = app.documents.byName(params.documentName);
400
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
401
+ const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
402
+ table.rows[params.row - 1].height = params.height;
403
+ return JSON.stringify({ row: params.row, height: params.height, set: true });
404
+ `, { documentName, row, height, sheetName: sheetName ?? null, tableName: tableName ?? null })));
405
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerPagesTools(server: McpServer): void;
@@ -0,0 +1,203 @@
1
+ import { z } from "zod";
2
+ import { runJXA, OsascriptError } from "../jxa.js";
3
+ function toolResult(text, isError = false) {
4
+ return { content: [{ type: "text", text }], isError };
5
+ }
6
+ async function handleJXA(fn) {
7
+ try {
8
+ const result = await fn();
9
+ const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
10
+ return toolResult(text);
11
+ }
12
+ catch (err) {
13
+ if (err instanceof OsascriptError) {
14
+ return toolResult(err.message, true);
15
+ }
16
+ return toolResult(String(err), true);
17
+ }
18
+ }
19
+ export function registerPagesTools(server) {
20
+ // ── Document Management ──
21
+ server.tool("pages_list_documents", "List all open Pages documents", {}, async () => handleJXA(() => runJXA(`
22
+ const app = Application("Pages");
23
+ const docs = app.documents();
24
+ return JSON.stringify(docs.map(d => ({ name: d.name(), path: d.file() ? d.file().toString() : null })));
25
+ `)));
26
+ server.tool("pages_create_document", "Create a new blank Pages document (or from a template)", {
27
+ templateName: z.string().optional().describe("Template name (optional)"),
28
+ }, async ({ templateName }) => handleJXA(() => runJXA(`
29
+ const app = Application("Pages");
30
+ let doc;
31
+ if (params.templateName) {
32
+ doc = app.Document({ documentTemplate: app.templates[params.templateName] });
33
+ app.documents.push(doc);
34
+ } else {
35
+ doc = app.Document();
36
+ app.documents.push(doc);
37
+ }
38
+ return JSON.stringify({ name: doc.name() });
39
+ `, { templateName: templateName ?? null })));
40
+ server.tool("pages_open_document", "Open a .pages file from disk", {
41
+ filePath: z.string().describe("Absolute path to the .pages file"),
42
+ }, async ({ filePath }) => handleJXA(() => runJXA(`
43
+ const app = Application("Pages");
44
+ const doc = app.open(Path(params.filePath));
45
+ return JSON.stringify({ name: doc.name() });
46
+ `, { filePath })));
47
+ server.tool("pages_save_document", "Save a Pages document", {
48
+ documentName: z.string().describe("Name of the open document"),
49
+ filePath: z.string().optional().describe("File path to save to (for Save As)"),
50
+ }, async ({ documentName, filePath }) => handleJXA(() => runJXA(`
51
+ const app = Application("Pages");
52
+ const doc = app.documents.byName(params.documentName);
53
+ if (params.filePath) {
54
+ doc.save({ in: Path(params.filePath) });
55
+ } else {
56
+ doc.save();
57
+ }
58
+ return JSON.stringify({ saved: true, name: doc.name() });
59
+ `, { documentName, filePath: filePath ?? null })));
60
+ server.tool("pages_export_document", "Export a Pages document to PDF, Word (.docx), EPUB, or plain text", {
61
+ documentName: z.string().describe("Name of the open document"),
62
+ filePath: z.string().describe("Absolute path for the exported file"),
63
+ format: z.enum(["PDF", "Word", "EPUB", "Text"]).describe("Export format"),
64
+ }, async ({ documentName, filePath, format }) => handleJXA(() => runJXA(`
65
+ const app = Application("Pages");
66
+ const doc = app.documents.byName(params.documentName);
67
+ const formatMap = {
68
+ "PDF": "Pages PDF",
69
+ "Word": "Microsoft Word",
70
+ "EPUB": "EPUB",
71
+ "Text": "unformatted text",
72
+ };
73
+ const fmt = formatMap[params.format];
74
+ app.export(doc, { to: Path(params.filePath), as: fmt });
75
+ return JSON.stringify({ exported: true, path: params.filePath, format: params.format });
76
+ `, { documentName, filePath, format })));
77
+ server.tool("pages_close_document", "Close a Pages document", {
78
+ documentName: z.string().describe("Name of the open document"),
79
+ saving: z.enum(["yes", "no", "ask"]).optional().describe("Whether to save before closing"),
80
+ }, async ({ documentName, saving }) => handleJXA(() => runJXA(`
81
+ const app = Application("Pages");
82
+ const doc = app.documents.byName(params.documentName);
83
+ const saveOpts = { yes: "yes", no: "no", ask: "ask" };
84
+ if (params.saving) {
85
+ doc.close({ saving: saveOpts[params.saving] });
86
+ } else {
87
+ doc.close();
88
+ }
89
+ return JSON.stringify({ closed: true });
90
+ `, { documentName, saving: saving ?? null })));
91
+ // ── Text Reading Tools ──
92
+ server.tool("pages_get_body_text", "Read all body text from a Pages document", {
93
+ documentName: z.string().describe("Name of the open document"),
94
+ }, async ({ documentName }) => handleJXA(() => runJXA(`
95
+ const app = Application("Pages");
96
+ const doc = app.documents.byName(params.documentName);
97
+ const text = doc.bodyText();
98
+ return JSON.stringify({ text: text });
99
+ `, { documentName })));
100
+ server.tool("pages_get_paragraphs", "Get all paragraphs from a Pages document as an indexed array", {
101
+ documentName: z.string().describe("Name of the open document"),
102
+ }, async ({ documentName }) => handleJXA(() => runJXA(`
103
+ const app = Application("Pages");
104
+ const doc = app.documents.byName(params.documentName);
105
+ const paragraphs = doc.paragraphs();
106
+ return JSON.stringify(paragraphs.map((p, i) => ({
107
+ index: i,
108
+ text: p.text ? p.text() : "",
109
+ })));
110
+ `, { documentName })));
111
+ // ── Text Writing Tools ──
112
+ server.tool("pages_add_text", "Append text to the end of the document body", {
113
+ documentName: z.string().describe("Name of the open document"),
114
+ text: z.string().describe("Text to append"),
115
+ }, async ({ documentName, text }) => handleJXA(() => runJXA(`
116
+ const app = Application("Pages");
117
+ const doc = app.documents.byName(params.documentName);
118
+ const current = doc.bodyText();
119
+ doc.bodyText = current + params.text;
120
+ return JSON.stringify({ appended: true });
121
+ `, { documentName, text })));
122
+ server.tool("pages_replace_text", "Find and replace text in a Pages document", {
123
+ documentName: z.string().describe("Name of the open document"),
124
+ find: z.string().describe("Text to find"),
125
+ replace: z.string().describe("Replacement text"),
126
+ all: z.boolean().optional().describe("Replace all occurrences (default: true)"),
127
+ }, async ({ documentName, find, replace, all }) => handleJXA(() => runJXA(`
128
+ const app = Application("Pages");
129
+ const doc = app.documents.byName(params.documentName);
130
+ const current = doc.bodyText();
131
+ let count = 0;
132
+ let newText;
133
+ if (params.all !== false) {
134
+ const parts = current.split(params.find);
135
+ count = parts.length - 1;
136
+ newText = parts.join(params.replace);
137
+ } else {
138
+ const idx = current.indexOf(params.find);
139
+ if (idx !== -1) {
140
+ newText = current.substring(0, idx) + params.replace + current.substring(idx + params.find.length);
141
+ count = 1;
142
+ } else {
143
+ newText = current;
144
+ }
145
+ }
146
+ doc.bodyText = newText;
147
+ return JSON.stringify({ replacements: count });
148
+ `, { documentName, find, replace, all: all ?? true })));
149
+ server.tool("pages_format_text", "Set formatting on a paragraph: font, size, color, bold, italic", {
150
+ documentName: z.string().describe("Name of the open document"),
151
+ paragraphIndex: z.number().describe("Paragraph index (0-based)"),
152
+ format: z.object({
153
+ bold: z.boolean().optional().describe("Set bold"),
154
+ italic: z.boolean().optional().describe("Set italic"),
155
+ fontSize: z.number().optional().describe("Font size in points"),
156
+ fontName: z.string().optional().describe("Font name"),
157
+ textColor: z.string().optional().describe("Text color as hex, e.g. '#FF0000'"),
158
+ }).describe("Formatting options"),
159
+ }, async ({ documentName, paragraphIndex, format }) => handleJXA(() => runJXA(`
160
+ const app = Application("Pages");
161
+ const doc = app.documents.byName(params.documentName);
162
+ const paragraph = doc.paragraphs[params.paragraphIndex];
163
+ const fmt = params.format;
164
+
165
+ if (fmt.fontSize !== undefined) paragraph.fontSize = fmt.fontSize;
166
+ if (fmt.fontName !== undefined) paragraph.fontName = fmt.fontName;
167
+ if (fmt.bold !== undefined) paragraph.bold = fmt.bold;
168
+ if (fmt.italic !== undefined) paragraph.italic = fmt.italic;
169
+ if (fmt.textColor !== undefined) {
170
+ const hex = fmt.textColor;
171
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
172
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
173
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
174
+ paragraph.color = [r, g, b];
175
+ }
176
+
177
+ return JSON.stringify({ formatted: true, paragraphIndex: params.paragraphIndex });
178
+ `, { documentName, paragraphIndex, format })));
179
+ server.tool("pages_add_image", "Insert an image into the document", {
180
+ documentName: z.string().describe("Name of the open document"),
181
+ filePath: z.string().describe("Absolute path to the image file"),
182
+ }, async ({ documentName, filePath }) => handleJXA(() => runJXA(`
183
+ const app = Application("Pages");
184
+ const doc = app.documents.byName(params.documentName);
185
+ const image = app.Image({ file: Path(params.filePath) });
186
+ doc.images.push(image);
187
+ return JSON.stringify({ added: true, path: params.filePath });
188
+ `, { documentName, filePath })));
189
+ server.tool("pages_add_table", "Insert a table into the document", {
190
+ documentName: z.string().describe("Name of the open document"),
191
+ rows: z.number().optional().describe("Number of rows (default: 3)"),
192
+ columns: z.number().optional().describe("Number of columns (default: 3)"),
193
+ }, async ({ documentName, rows, columns }) => handleJXA(() => runJXA(`
194
+ const app = Application("Pages");
195
+ const doc = app.documents.byName(params.documentName);
196
+ const props = {};
197
+ if (params.rows) props.rowCount = params.rows;
198
+ if (params.columns) props.columnCount = params.columns;
199
+ const table = app.Table(props);
200
+ doc.tables.push(table);
201
+ return JSON.stringify({ added: true, name: table.name() });
202
+ `, { documentName, rows: rows ?? null, columns: columns ?? null })));
203
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "iwork-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Apple iWork (Numbers, Pages, Keynote) automation",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "iwork-mcp": "dist/index.js"
9
+ },
10
+ "files": ["dist"],
11
+ "scripts": {
12
+ "build": "tsc && chmod +x dist/index.js",
13
+ "dev": "tsc --watch",
14
+ "prepare": "npm run build"
15
+ },
16
+ "dependencies": {
17
+ "@modelcontextprotocol/sdk": "^1.26.0",
18
+ "zod": "^3.23.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^22",
22
+ "typescript": "^5.8.0"
23
+ },
24
+ "engines": {
25
+ "node": ">=18"
26
+ }
27
+ }