iwork-mcp 0.3.0 → 0.4.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.
@@ -171,6 +171,27 @@ export function registerKeynoteTools(server) {
171
171
  const masters = doc.masterSlides();
172
172
  return JSON.stringify(masters.map((m, i) => ({ index: i, name: m.name() })));
173
173
  `, { documentName })));
174
+ server.tool("keynote_reorder_slide", "Move a slide from one position to another", {
175
+ documentName: z.string().describe("Name of the open presentation"),
176
+ fromSlideNumber: z.number().describe("Current slide number (1-based)"),
177
+ toSlideNumber: z.number().describe("Target slide number (1-based)"),
178
+ }, async ({ documentName, fromSlideNumber, toSlideNumber }) => handleJXA(() => runJXA(`
179
+ const app = Application("Keynote");
180
+ const doc = app.documents.byName(params.documentName);
181
+ const slide = doc.slides[params.fromSlideNumber - 1];
182
+ app.move(slide, { to: doc.slides[params.toSlideNumber - 1] });
183
+ return JSON.stringify({ moved: true, from: params.fromSlideNumber, to: params.toSlideNumber, totalSlides: doc.slides.length });
184
+ `, { documentName, fromSlideNumber, toSlideNumber })));
185
+ server.tool("keynote_skip_slide", "Mark a slide as skipped (hidden) or unskipped", {
186
+ documentName: z.string().describe("Name of the open presentation"),
187
+ slideNumber: z.number().describe("Slide number (1-based)"),
188
+ skipped: z.boolean().describe("True to skip/hide, false to unskip/show"),
189
+ }, async ({ documentName, slideNumber, skipped }) => handleJXA(() => runJXA(`
190
+ const app = Application("Keynote");
191
+ const doc = app.documents.byName(params.documentName);
192
+ doc.slides[params.slideNumber - 1].skipped = params.skipped;
193
+ return JSON.stringify({ slideNumber: params.slideNumber, skipped: params.skipped });
194
+ `, { documentName, slideNumber, skipped })));
174
195
  server.tool("keynote_stop_slideshow", "Stop a running slideshow", {
175
196
  documentName: z.string().describe("Name of the open presentation"),
176
197
  }, async ({ documentName }) => handleJXA(() => runJXA(`
@@ -441,18 +441,113 @@ export function registerNumbersTools(server) {
441
441
  table.columns.push(app.Column());
442
442
  return JSON.stringify({ newColumnCount: table.columnCount() });
443
443
  `, { documentName, sheetName: sheetName ?? null, tableName: tableName ?? null })));
444
+ server.tool("numbers_read_range", "Read a specific cell range (e.g. 'B2:D10') instead of the entire table. Faster for large tables.", {
445
+ documentName: z.string().describe("Name of the open document"),
446
+ cellRange: z.string().describe("Cell range, e.g. 'A1:C10'"),
447
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
448
+ tableName: z.string().optional().describe("Table name (defaults to first table)"),
449
+ }, async ({ documentName, cellRange, sheetName, tableName }) => handleJXA(() => runJXA(`
450
+ const app = Application("Numbers");
451
+ const doc = app.documents.byName(params.documentName);
452
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
453
+ const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
454
+ const range = table.ranges[params.cellRange];
455
+ const cells = range.cells();
456
+ const values = cells.map(c => c.value());
457
+
458
+ // Figure out the range dimensions from the cell references
459
+ const rangeRef = range.name();
460
+ const colCount = range.columnCount();
461
+ const rowCount = range.rowCount();
462
+ const data = [];
463
+ for (let r = 0; r < rowCount; r++) {
464
+ data.push(values.slice(r * colCount, (r + 1) * colCount));
465
+ }
466
+ return JSON.stringify(data);
467
+ `, { documentName, cellRange, sheetName: sheetName ?? null, tableName: tableName ?? null })));
468
+ server.tool("numbers_merge_cells", "Merge a range of cells", {
469
+ documentName: z.string().describe("Name of the open document"),
470
+ cellRange: z.string().describe("Cell range to merge, e.g. 'A1:C1'"),
471
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
472
+ tableName: z.string().optional().describe("Table name (defaults to first table)"),
473
+ }, async ({ documentName, cellRange, sheetName, tableName }) => handleJXA(() => runJXA(`
474
+ const app = Application("Numbers");
475
+ const doc = app.documents.byName(params.documentName);
476
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
477
+ const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
478
+ const range = table.ranges[params.cellRange];
479
+ range.merge();
480
+ return JSON.stringify({ merged: true, cellRange: params.cellRange });
481
+ `, { documentName, cellRange, sheetName: sheetName ?? null, tableName: tableName ?? null })));
482
+ server.tool("numbers_unmerge_cells", "Unmerge a previously merged cell range", {
483
+ documentName: z.string().describe("Name of the open document"),
484
+ cellRange: z.string().describe("Cell range to unmerge, e.g. 'A1:C1'"),
485
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
486
+ tableName: z.string().optional().describe("Table name (defaults to first table)"),
487
+ }, async ({ documentName, cellRange, sheetName, tableName }) => handleJXA(() => runJXA(`
488
+ const app = Application("Numbers");
489
+ const doc = app.documents.byName(params.documentName);
490
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
491
+ const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
492
+ const range = table.ranges[params.cellRange];
493
+ range.unmerge();
494
+ return JSON.stringify({ unmerged: true, cellRange: params.cellRange });
495
+ `, { documentName, cellRange, sheetName: sheetName ?? null, tableName: tableName ?? null })));
496
+ server.tool("numbers_clear_cells", "Clear the contents of a cell or range", {
497
+ documentName: z.string().describe("Name of the open document"),
498
+ cellRange: z.string().describe("Cell or range to clear, e.g. 'A1' or 'A1:C10'"),
499
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
500
+ tableName: z.string().optional().describe("Table name (defaults to first table)"),
501
+ }, async ({ documentName, cellRange, sheetName, tableName }) => handleJXA(() => runJXA(`
502
+ const app = Application("Numbers");
503
+ const doc = app.documents.byName(params.documentName);
504
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
505
+ const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
506
+ if (params.cellRange.includes(":")) {
507
+ const range = table.ranges[params.cellRange];
508
+ const cells = range.cells();
509
+ for (const cell of cells) { cell.value = null; }
510
+ } else {
511
+ table.cells[params.cellRange].value = null;
512
+ }
513
+ return JSON.stringify({ cleared: true, cellRange: params.cellRange });
514
+ `, { documentName, cellRange, sheetName: sheetName ?? null, tableName: tableName ?? null })));
515
+ server.tool("numbers_sort_rows", "Sort table rows by a column", {
516
+ documentName: z.string().describe("Name of the open document"),
517
+ column: z.string().describe("Column letter to sort by, e.g. 'A'"),
518
+ order: z.enum(["ascending", "descending"]).optional().describe("Sort order (default: ascending)"),
519
+ sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
520
+ tableName: z.string().optional().describe("Table name (defaults to first table)"),
521
+ }, async ({ documentName, column, order, sheetName, tableName }) => handleJXA(() => runJXA(`
522
+ const app = Application("Numbers");
523
+ const doc = app.documents.byName(params.documentName);
524
+ const sheet = params.sheetName ? doc.sheets.byName(params.sheetName) : doc.sheets[0];
525
+ const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
526
+ const colStr = params.column.toUpperCase();
527
+ let colIndex = 0;
528
+ for (let i = 0; i < colStr.length; i++) {
529
+ colIndex = colIndex * 26 + (colStr.charCodeAt(i) - 64);
530
+ }
531
+ colIndex -= 1;
532
+ const col = table.columns[colIndex];
533
+ const direction = params.order === "descending" ? "descending" : "ascending";
534
+ table.sort({ by: col, direction: direction });
535
+ return JSON.stringify({ sorted: true, column: params.column, order: direction });
536
+ `, { documentName, column, order: order ?? null, sheetName: sheetName ?? null, tableName: tableName ?? null })));
444
537
  // ── Formatting Tools ──
445
- server.tool("numbers_format_cells", "Set formatting on a cell or range: font, size, color, alignment, background color", {
538
+ server.tool("numbers_format_cells", "Set formatting on a cell or range: font, size, color, alignment, background color, bold, italic", {
446
539
  documentName: z.string().describe("Name of the open document"),
447
540
  cellRange: z.string().describe("Cell or range reference, e.g. 'A1' or 'A1:C3'"),
448
541
  format: z.object({
449
- bold: z.boolean().optional().describe("Set bold"),
450
- italic: z.boolean().optional().describe("Set italic"),
542
+ bold: z.boolean().optional().describe("Set bold (switches to bold variant of current font)"),
543
+ italic: z.boolean().optional().describe("Set italic (switches to italic variant of current font)"),
451
544
  fontSize: z.number().optional().describe("Font size in points"),
452
545
  fontName: z.string().optional().describe("Font name"),
453
546
  textColor: z.string().optional().describe("Text color as hex, e.g. '#FF0000'"),
454
547
  backgroundColor: z.string().optional().describe("Background color as hex, e.g. '#0000FF'"),
455
548
  alignment: z.enum(["left", "center", "right", "auto"]).optional().describe("Text alignment"),
549
+ verticalAlignment: z.enum(["top", "center", "bottom"]).optional().describe("Vertical alignment"),
550
+ textWrap: z.boolean().optional().describe("Enable text wrapping"),
456
551
  }).describe("Formatting options"),
457
552
  sheetName: z.string().optional().describe("Sheet name (defaults to first sheet)"),
458
553
  tableName: z.string().optional().describe("Table name (defaults to first table)"),
@@ -463,7 +558,6 @@ export function registerNumbersTools(server) {
463
558
  const table = params.tableName ? sheet.tables.byName(params.tableName) : sheet.tables[0];
464
559
  const fmt = params.format;
465
560
 
466
- // Parse range like "A1:C3" into individual cells
467
561
  const rangeStr = params.cellRange;
468
562
  let cells = [];
469
563
  if (rangeStr.includes(":")) {
@@ -481,7 +575,6 @@ export function registerNumbersTools(server) {
481
575
  }
482
576
 
483
577
  for (const cell of cells) {
484
- if (fmt.bold !== undefined) cell.textColor = cell.textColor; // touch to ensure access
485
578
  if (fmt.fontSize !== undefined) cell.fontSize = fmt.fontSize;
486
579
  if (fmt.fontName !== undefined) cell.fontName = fmt.fontName;
487
580
  if (fmt.textColor !== undefined) {
@@ -493,11 +586,25 @@ export function registerNumbersTools(server) {
493
586
  cell.backgroundColor = [r, g, b];
494
587
  }
495
588
  if (fmt.alignment !== undefined) {
496
- const alignMap = { left: "left", center: "center", right: "right", auto: "auto" };
497
- cell.alignment = alignMap[fmt.alignment];
589
+ cell.alignment = fmt.alignment;
590
+ }
591
+ if (fmt.verticalAlignment !== undefined) {
592
+ cell.verticalAlignment = fmt.verticalAlignment;
593
+ }
594
+ if (fmt.textWrap !== undefined) {
595
+ cell.textWrap = fmt.textWrap;
498
596
  }
499
- if (fmt.bold !== undefined) {
500
- cell.format = cell.format; // Numbers doesn't have direct bold; we use font
597
+ // Bold/italic: switch font to bold/italic variant
598
+ if (fmt.bold !== undefined || fmt.italic !== undefined) {
599
+ let fontName = fmt.fontName || cell.fontName();
600
+ const baseName = fontName.replace(/ ?(Bold|Italic|Bold Italic|BoldItalic)$/i, "").trim();
601
+ let suffix = "";
602
+ const wantBold = fmt.bold !== undefined ? fmt.bold : /Bold/i.test(fontName);
603
+ const wantItalic = fmt.italic !== undefined ? fmt.italic : /Italic/i.test(fontName);
604
+ if (wantBold && wantItalic) suffix = " Bold Italic";
605
+ else if (wantBold) suffix = " Bold";
606
+ else if (wantItalic) suffix = " Italic";
607
+ cell.fontName = baseName + suffix;
501
608
  }
502
609
  }
503
610
 
@@ -109,17 +109,42 @@ export function registerPagesTools(server) {
109
109
  })));
110
110
  `, { documentName })));
111
111
  // ── Text Writing Tools ──
112
- server.tool("pages_add_text", "Append text to the end of the document body", {
112
+ server.tool("pages_add_text", "Append text to the end of the document body (preserves existing formatting)", {
113
113
  documentName: z.string().describe("Name of the open document"),
114
114
  text: z.string().describe("Text to append"),
115
115
  }, async ({ documentName, text }) => handleJXA(() => runJXA(`
116
116
  const app = Application("Pages");
117
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 });
118
+ // Append by adding a new paragraph to preserve existing formatting
119
+ const para = app.Paragraph({ text: params.text });
120
+ doc.paragraphs.push(para);
121
+ return JSON.stringify({ appended: true, paragraphCount: doc.paragraphs.length });
121
122
  `, { documentName, text })));
122
- server.tool("pages_replace_text", "Find and replace text in a Pages document", {
123
+ server.tool("pages_insert_text_at", "Insert text at a specific paragraph index", {
124
+ documentName: z.string().describe("Name of the open document"),
125
+ text: z.string().describe("Text to insert"),
126
+ afterParagraph: z.number().describe("Insert after this paragraph index (0-based). Use -1 to insert at the beginning."),
127
+ }, async ({ documentName, text, afterParagraph }) => handleJXA(() => runJXA(`
128
+ const app = Application("Pages");
129
+ const doc = app.documents.byName(params.documentName);
130
+ const para = app.Paragraph({ text: params.text });
131
+ if (params.afterParagraph < 0) {
132
+ doc.paragraphs.unshift(para);
133
+ } else {
134
+ doc.paragraphs.splice(params.afterParagraph + 1, 0, para);
135
+ }
136
+ return JSON.stringify({ inserted: true, paragraphCount: doc.paragraphs.length });
137
+ `, { documentName, text, afterParagraph })));
138
+ server.tool("pages_delete_text", "Delete a paragraph by index", {
139
+ documentName: z.string().describe("Name of the open document"),
140
+ paragraphIndex: z.number().describe("Paragraph index to delete (0-based)"),
141
+ }, async ({ documentName, paragraphIndex }) => handleJXA(() => runJXA(`
142
+ const app = Application("Pages");
143
+ const doc = app.documents.byName(params.documentName);
144
+ app.delete(doc.paragraphs[params.paragraphIndex]);
145
+ return JSON.stringify({ deleted: true, paragraphCount: doc.paragraphs.length });
146
+ `, { documentName, paragraphIndex })));
147
+ server.tool("pages_replace_text", "Find and replace text in a Pages document (operates per-paragraph to preserve formatting)", {
123
148
  documentName: z.string().describe("Name of the open document"),
124
149
  find: z.string().describe("Text to find"),
125
150
  replace: z.string().describe("Replacement text"),
@@ -127,23 +152,24 @@ export function registerPagesTools(server) {
127
152
  }, async ({ documentName, find, replace, all }) => handleJXA(() => runJXA(`
128
153
  const app = Application("Pages");
129
154
  const doc = app.documents.byName(params.documentName);
130
- const current = doc.bodyText();
155
+ const paragraphs = doc.paragraphs();
131
156
  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);
157
+ for (let i = 0; i < paragraphs.length; i++) {
158
+ const p = paragraphs[i];
159
+ let text;
160
+ try { text = p.text ? p.text() : ""; } catch(e) { continue; }
161
+ if (text.indexOf(params.find) === -1) continue;
162
+ if (params.all !== false) {
163
+ const parts = text.split(params.find);
164
+ count += parts.length - 1;
165
+ doc.paragraphs[i].text = parts.join(params.replace);
166
+ } else if (count === 0) {
167
+ const idx = text.indexOf(params.find);
168
+ doc.paragraphs[i].text = text.substring(0, idx) + params.replace + text.substring(idx + params.find.length);
141
169
  count = 1;
142
- } else {
143
- newText = current;
170
+ break;
144
171
  }
145
172
  }
146
- doc.bodyText = newText;
147
173
  return JSON.stringify({ replacements: count });
148
174
  `, { documentName, find, replace, all: all ?? true })));
149
175
  server.tool("pages_format_text", "Set formatting on a paragraph: font, size, color, bold, italic", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iwork-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for Apple iWork (Numbers, Pages, Keynote) automation",
5
5
  "license": "MIT",
6
6
  "type": "module",