gogcli-mcp-docs 2.7.1 → 2.18.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.
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
- import { accountParam, runOrDiagnose, paginationParams, pushPaginationFlags } from '../../../gogcli-mcp/src/lib.js';
3
+ import { accountParam, runOrDiagnose, paginationParams, pushPaginationFlags, payloadArg } from '../../../gogcli-mcp/src/lib.js';
4
+ import type { GogArg } from '../../../gogcli-mcp/src/lib.js';
4
5
 
5
6
  export function registerExtraDocsTools(server: McpServer): void {
6
7
  server.registerTool('gog_docs_copy', {
@@ -28,9 +29,11 @@ export function registerExtraDocsTools(server: McpServer): void {
28
29
  occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
29
30
  matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
30
31
  tabId: z.string().optional().describe('Tab ID to delete content from (for multi-tab docs)'),
32
+ segment: z.string().optional().describe('Target an exact header, footer, or footnote segment ID (header/footer IDs come from gog_docs_header_list / gog_docs_footer_list; footnote segment IDs appear in gog_docs_read json mode) instead of the document body.'),
33
+ batch: z.string().optional().describe('Append this mutation to a persisted batch (from gog_batch_begin) instead of applying it — nothing changes in the doc until gog_batch_end submits the batch.'),
31
34
  account: accountParam,
32
35
  },
33
- }, async ({ docId, start, end, at, occurrence, matchCase, tabId, account }) => {
36
+ }, async ({ docId, start, end, at, occurrence, matchCase, tabId, segment, batch, account }) => {
34
37
  const args = ['docs', 'delete'];
35
38
  if (start !== undefined) args.push(`--start=${start}`);
36
39
  if (end !== undefined) args.push(`--end=${end}`);
@@ -39,6 +42,8 @@ export function registerExtraDocsTools(server: McpServer): void {
39
42
  if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
40
43
  if (matchCase) args.push('--match-case');
41
44
  if (tabId) args.push(`--tab-id=${tabId}`);
45
+ if (segment) args.push(`--segment=${segment}`);
46
+ if (batch) args.push(`--batch=${batch}`);
42
47
  return runOrDiagnose(args, { account });
43
48
  });
44
49
 
@@ -50,7 +55,7 @@ export function registerExtraDocsTools(server: McpServer): void {
50
55
  account: accountParam,
51
56
  },
52
57
  }, async ({ docId, account }) => {
53
- return runOrDiagnose(['drive', 'delete', docId], { account });
58
+ return runOrDiagnose(['drive', 'delete', docId, '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
54
59
  });
55
60
 
56
61
  server.registerTool('gog_docs_edit', {
@@ -78,9 +83,10 @@ export function registerExtraDocsTools(server: McpServer): void {
78
83
  tab: z.string().optional().describe('Target tab title or ID. In json mode, returns that one tab in the legacy top-level Document shape.'),
79
84
  allTabs: z.boolean().optional().describe('Show all tabs. In json mode, returns the canonical Document response with all tab content populated.'),
80
85
  maxBytes: z.number().optional().describe('Max bytes to read in text mode (0 = unlimited; default 2000000)'),
86
+ chips: z.boolean().optional().describe('Render Google Docs smart chips (people, dates, rich links) inline in text mode; ignored in json mode'),
81
87
  account: accountParam,
82
88
  },
83
- }, async ({ docId, format, tab, allTabs, maxBytes, account }) => {
89
+ }, async ({ docId, format, tab, allTabs, maxBytes, chips, account }) => {
84
90
  if (format === 'json') {
85
91
  const args = ['docs', 'raw', docId, '--pretty'];
86
92
  if (tab) args.push(`--tab=${tab}`);
@@ -91,6 +97,7 @@ export function registerExtraDocsTools(server: McpServer): void {
91
97
  if (tab) args.push(`--tab=${tab}`);
92
98
  if (allTabs) args.push('--all-tabs');
93
99
  if (maxBytes !== undefined) args.push(`--max-bytes=${maxBytes}`);
100
+ if (chips) args.push('--chips');
94
101
  return runOrDiagnose(args, { account });
95
102
  });
96
103
 
@@ -122,6 +129,20 @@ export function registerExtraDocsTools(server: McpServer): void {
122
129
  lineSpacing: z.number().optional().describe('Line spacing percentage (e.g. 100 for single, 150 for 1.5x, 200 for double)'),
123
130
  headingLevel: z.number().int().optional().describe('Set paragraph named style to HEADING_1..HEADING_6 (shortcut for namedStyle=HEADING_N)'),
124
131
  namedStyle: z.enum(['NORMAL_TEXT', 'TITLE', 'SUBTITLE', 'HEADING_1', 'HEADING_2', 'HEADING_3', 'HEADING_4', 'HEADING_5', 'HEADING_6']).optional().describe('Set paragraph named style explicitly'),
132
+ bullets: z.boolean().optional().describe('Turn the matched paragraphs into a bulleted list with the default disc preset'),
133
+ bulletPreset: z.string().optional().describe('Create a bulleted list with a specific Google Docs bullet glyph preset (e.g. BULLET_DISC_CIRCLE_SQUARE)'),
134
+ ordered: z.boolean().optional().describe('Turn the matched paragraphs into a numbered list with the default decimal preset'),
135
+ noBullets: z.boolean().optional().describe('Remove bullets or numbering from the matched paragraphs'),
136
+ indentStart: z.number().optional().describe('Paragraph start indentation in points'),
137
+ indentEnd: z.number().optional().describe('Paragraph end indentation in points'),
138
+ indentFirstLine: z.number().optional().describe('Paragraph first-line indentation in points'),
139
+ spaceAbove: z.number().optional().describe('Space above the paragraph in points'),
140
+ spaceBelow: z.number().optional().describe('Space below the paragraph in points'),
141
+ spacingMode: z.enum(['NEVER_COLLAPSE', 'COLLAPSE_LISTS']).optional().describe('Paragraph spacing collapse behavior: NEVER_COLLAPSE always keeps space-above/space-below; COLLAPSE_LISTS collapses spacing between list items'),
142
+ keepLinesTogether: z.boolean().optional().describe('Keep all lines of the paragraph on one page/column (true) or clear that setting (false)'),
143
+ keepWithNext: z.boolean().optional().describe('Keep the paragraph with the next paragraph (true) or clear that setting (false)'),
144
+ segment: z.string().optional().describe('Target an exact header, footer, or footnote segment ID (header/footer IDs come from gog_docs_header_list / gog_docs_footer_list; footnote segment IDs appear in gog_docs_read json mode) instead of the document body.'),
145
+ batch: z.string().optional().describe('Append this mutation to a persisted batch (from gog_batch_begin) instead of applying it — nothing changes in the doc until gog_batch_end submits the batch.'),
125
146
  account: accountParam,
126
147
  },
127
148
  }, async (args) => {
@@ -142,9 +163,15 @@ export function registerExtraDocsTools(server: McpServer): void {
142
163
  code?: boolean;
143
164
  link?: string; noLink?: boolean;
144
165
  alignment?: string;
166
+ batch?: string;
145
167
  lineSpacing?: number;
146
168
  headingLevel?: number;
147
169
  namedStyle?: string;
170
+ bullets?: boolean; bulletPreset?: string; ordered?: boolean; noBullets?: boolean;
171
+ indentStart?: number; indentEnd?: number; indentFirstLine?: number;
172
+ spaceAbove?: number; spaceBelow?: number; spacingMode?: string;
173
+ keepLinesTogether?: boolean; keepWithNext?: boolean;
174
+ segment?: string;
148
175
  account?: string;
149
176
  };
150
177
  const argv = ['docs', 'format', a.docId];
@@ -171,9 +198,37 @@ export function registerExtraDocsTools(server: McpServer): void {
171
198
  if (a.lineSpacing !== undefined) argv.push(`--line-spacing=${a.lineSpacing}`);
172
199
  if (a.headingLevel !== undefined) argv.push(`--heading-level=${a.headingLevel}`);
173
200
  if (a.namedStyle) argv.push(`--named-style=${a.namedStyle}`);
201
+ if (a.bullets) argv.push('--bullets');
202
+ if (a.bulletPreset) argv.push(`--bullet-preset=${a.bulletPreset}`);
203
+ if (a.ordered) argv.push('--ordered');
204
+ if (a.noBullets) argv.push('--no-bullets');
205
+ if (a.indentStart !== undefined) argv.push(`--indent-start=${a.indentStart}`);
206
+ if (a.indentEnd !== undefined) argv.push(`--indent-end=${a.indentEnd}`);
207
+ if (a.indentFirstLine !== undefined) argv.push(`--indent-first-line=${a.indentFirstLine}`);
208
+ if (a.spaceAbove !== undefined) argv.push(`--space-above=${a.spaceAbove}`);
209
+ if (a.spaceBelow !== undefined) argv.push(`--space-below=${a.spaceBelow}`);
210
+ if (a.spacingMode) argv.push(`--spacing-mode=${a.spacingMode}`);
211
+ if (a.keepLinesTogether !== undefined) argv.push(a.keepLinesTogether ? '--keep-lines-together' : '--no-keep-lines-together');
212
+ if (a.keepWithNext !== undefined) argv.push(a.keepWithNext ? '--keep-with-next' : '--no-keep-with-next');
213
+ if (a.segment) argv.push(`--segment=${a.segment}`);
214
+ if (a.batch) argv.push(`--batch=${a.batch}`);
174
215
  return runOrDiagnose(argv, { account: a.account });
175
216
  });
176
217
 
218
+ server.registerTool('gog_docs_suggestions_list', {
219
+ description: 'List pending suggestions (suggested text insertions and deletions) in a Google Doc, with exact UTF-16 ranges and segment context. Read-only; does not accept or reject suggestions.',
220
+ annotations: { readOnlyHint: true },
221
+ inputSchema: {
222
+ docId: z.string().describe('Doc ID (from the URL)'),
223
+ tab: z.string().optional().describe('Tab title or ID (omit for the first tab)'),
224
+ account: accountParam,
225
+ },
226
+ }, async ({ docId, tab, account }) => {
227
+ const args = ['docs', 'suggestions', 'list', docId];
228
+ if (tab) args.push(`--tab=${tab}`);
229
+ return runOrDiagnose(args, { account });
230
+ });
231
+
177
232
  server.registerTool('gog_docs_export', {
178
233
  description: 'Export a Google Doc as PDF, plain text, HTML, DOCX, or other format.',
179
234
  annotations: { readOnlyHint: true },
@@ -181,12 +236,14 @@ export function registerExtraDocsTools(server: McpServer): void {
181
236
  docId: z.string().describe('Doc ID (from the URL)'),
182
237
  format: z.string().optional().describe('Export format: pdf, txt, html, docx, rtf, odt, epub (default: pdf)'),
183
238
  out: z.string().optional().describe('Output file path'),
239
+ overwrite: z.boolean().optional().describe('Overwrite the output file if it already exists (gog refuses otherwise)'),
184
240
  account: accountParam,
185
241
  },
186
- }, async ({ docId, format, out, account }) => {
242
+ }, async ({ docId, format, out, overwrite, account }) => {
187
243
  const args = ['docs', 'export', docId];
188
244
  if (format) args.push(`--format=${format}`);
189
245
  if (out) args.push(`--out=${out}`);
246
+ if (overwrite) args.push('--overwrite');
190
247
  return runOrDiagnose(args, { account });
191
248
  });
192
249
 
@@ -202,9 +259,11 @@ export function registerExtraDocsTools(server: McpServer): void {
202
259
  occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
203
260
  matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
204
261
  tabId: z.string().optional().describe('Tab ID to insert into (for multi-tab docs)'),
262
+ segment: z.string().optional().describe('Target an exact header, footer, or footnote segment ID (header/footer IDs come from gog_docs_header_list / gog_docs_footer_list; footnote segment IDs appear in gog_docs_read json mode) instead of the document body.'),
263
+ batch: z.string().optional().describe('Append this mutation to a persisted batch (from gog_batch_begin) instead of applying it — nothing changes in the doc until gog_batch_end submits the batch.'),
205
264
  account: accountParam,
206
265
  },
207
- }, async ({ docId, content, index, file, at, occurrence, matchCase, tabId, account }) => {
266
+ }, async ({ docId, content, index, file, at, occurrence, matchCase, tabId, segment, batch, account }) => {
208
267
  const args = ['docs', 'insert', docId];
209
268
  if (content) args.push(content);
210
269
  if (index !== undefined) args.push(`--index=${index}`);
@@ -213,6 +272,8 @@ export function registerExtraDocsTools(server: McpServer): void {
213
272
  if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
214
273
  if (matchCase) args.push('--match-case');
215
274
  if (tabId) args.push(`--tab-id=${tabId}`);
275
+ if (segment) args.push(`--segment=${segment}`);
276
+ if (batch) args.push(`--batch=${batch}`);
216
277
  return runOrDiagnose(args, { account });
217
278
  });
218
279
 
@@ -285,10 +346,12 @@ export function registerExtraDocsTools(server: McpServer): void {
285
346
  occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
286
347
  matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
287
348
  tabId: z.string().optional().describe('Tab ID for multi-tab docs'),
349
+ segment: z.string().optional().describe('Target an exact header, footer, or footnote segment ID (header/footer IDs come from gog_docs_header_list / gog_docs_footer_list; footnote segment IDs appear in gog_docs_read json mode) instead of the document body.'),
288
350
  pageless: z.boolean().optional().describe('Set document to pageless format'),
351
+ batch: z.string().optional().describe('Append this mutation to a persisted batch (from gog_batch_begin) instead of applying it — nothing changes in the doc until gog_batch_end submits the batch.'),
289
352
  account: accountParam,
290
353
  },
291
- }, async ({ docId, text, file, index, replaceRange, markdown, at, occurrence, matchCase, tabId, pageless, account }) => {
354
+ }, async ({ docId, text, file, index, replaceRange, markdown, at, occurrence, matchCase, tabId, segment, pageless, batch, account }) => {
292
355
  const args = ['docs', 'update', docId];
293
356
  if (text) args.push(`--text=${text}`);
294
357
  if (file) args.push(`--file=${file}`);
@@ -299,7 +362,9 @@ export function registerExtraDocsTools(server: McpServer): void {
299
362
  if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
300
363
  if (matchCase) args.push('--match-case');
301
364
  if (tabId) args.push(`--tab-id=${tabId}`);
365
+ if (segment) args.push(`--segment=${segment}`);
302
366
  if (pageless) args.push('--pageless');
367
+ if (batch) args.push(`--batch=${batch}`);
303
368
  return runOrDiagnose(args, { account });
304
369
  });
305
370
 
@@ -386,7 +451,8 @@ export function registerExtraDocsTools(server: McpServer): void {
386
451
  account: accountParam,
387
452
  },
388
453
  }, async ({ docId, commentId, account }) => {
389
- return runOrDiagnose(['docs', 'comments', 'delete', docId, commentId], { account });
454
+ // --force: gog refuses this delete under the runner's --no-input without it.
455
+ return runOrDiagnose(['docs', 'comments', 'delete', docId, commentId, '--force'], { account });
390
456
  });
391
457
 
392
458
  server.registerTool('gog_docs_comments_reopen', {
@@ -432,9 +498,10 @@ export function registerExtraDocsTools(server: McpServer): void {
432
498
  all: z.boolean().optional().describe('Return all matches instead of just one'),
433
499
  failEmpty: z.boolean().optional().describe('Treat no matches as an error instead of returning an empty result'),
434
500
  tab: z.string().optional().describe('Target a specific tab by title or ID'),
501
+ segment: z.string().optional().describe('Target an exact header, footer, or footnote segment ID (header/footer IDs come from gog_docs_header_list / gog_docs_footer_list; footnote segment IDs appear in gog_docs_read json mode) instead of the document body.'),
435
502
  account: accountParam,
436
503
  },
437
- }, async ({ docId, text, occurrence, matchCase, normalizeWhitespace, all, failEmpty, tab, account }) => {
504
+ }, async ({ docId, text, occurrence, matchCase, normalizeWhitespace, all, failEmpty, tab, segment, account }) => {
438
505
  const args = ['docs', 'find-range', docId, text];
439
506
  if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
440
507
  if (matchCase) args.push('--match-case');
@@ -442,6 +509,84 @@ export function registerExtraDocsTools(server: McpServer): void {
442
509
  if (all) args.push('--all');
443
510
  if (failEmpty) args.push('--fail-empty');
444
511
  if (tab) args.push(`--tab=${tab}`);
512
+ if (segment) args.push(`--segment=${segment}`);
513
+ return runOrDiagnose(args, { account });
514
+ });
515
+
516
+ // Persisted, revision-locked Docs request batches (gog 0.25): begin a batch,
517
+ // run docs mutation tools with batch=<id> to compose requests locally, then
518
+ // end to submit them atomically against the locked revision.
519
+ server.registerTool('gog_batch_begin', {
520
+ description: 'Open a persisted, revision-locked request batch for a Google Doc. Subsequent docs mutation tools called with batch=<batchId> append their requests locally instead of applying them; gog_batch_end submits everything atomically. Returns the batchId.',
521
+ inputSchema: {
522
+ docId: z.string().describe('Google Doc ID the batch is locked to'),
523
+ name: z.string().optional().describe('Optional batch label'),
524
+ account: accountParam,
525
+ },
526
+ }, async ({ docId, name, account }) => {
527
+ const args = ['batch', 'begin', `--doc=${docId}`];
528
+ if (name) args.push(`--name=${name}`);
529
+ return runOrDiagnose(args, { account });
530
+ });
531
+
532
+ server.registerTool('gog_batch_end', {
533
+ description: 'Submit a persisted batch: applies every composed request to the doc in one atomic batchUpdate against the locked revision. Atomic by default — set autoSplit to submit >500-request batches as ordered chunks (non-atomic), or continueOnError to retry individually after an atomic validation failure, retaining failures in the batch.',
534
+ annotations: { destructiveHint: true },
535
+ inputSchema: {
536
+ batchId: z.string().describe('Batch ID (from gog_batch_begin)'),
537
+ autoSplit: z.boolean().optional().describe('Submit batches over 500 requests as ordered chunks (non-atomic)'),
538
+ continueOnError: z.boolean().optional().describe('After an atomic validation failure, submit requests individually and retain failures'),
539
+ account: accountParam,
540
+ },
541
+ }, async ({ batchId, autoSplit, continueOnError, account }) => {
542
+ const args = ['batch', 'end', batchId];
543
+ if (autoSplit) args.push('--auto-split');
544
+ if (continueOnError) args.push('--continue-on-error');
545
+ return runOrDiagnose(args, { account });
546
+ });
547
+
548
+ server.registerTool('gog_batch_abort', {
549
+ description: 'Discard a persisted batch and its composed requests without applying anything to the doc.',
550
+ annotations: { destructiveHint: true },
551
+ inputSchema: {
552
+ batchId: z.string().describe('Batch ID to discard'),
553
+ account: accountParam,
554
+ },
555
+ }, async ({ batchId, account }) => {
556
+ return runOrDiagnose(['batch', 'abort', batchId], { account });
557
+ });
558
+
559
+ server.registerTool('gog_batch_list', {
560
+ description: 'List persisted Docs request batches (id, doc, label, request count, status).',
561
+ annotations: { readOnlyHint: true },
562
+ inputSchema: {
563
+ account: accountParam,
564
+ },
565
+ }, async ({ account }) => {
566
+ return runOrDiagnose(['batch', 'list'], { account });
567
+ });
568
+
569
+ server.registerTool('gog_batch_show', {
570
+ description: 'Show one persisted batch: its doc, locked revision, and the composed requests awaiting submission.',
571
+ annotations: { readOnlyHint: true },
572
+ inputSchema: {
573
+ batchId: z.string().describe('Batch ID'),
574
+ account: accountParam,
575
+ },
576
+ }, async ({ batchId, account }) => {
577
+ return runOrDiagnose(['batch', 'show', batchId], { account });
578
+ });
579
+
580
+ server.registerTool('gog_batch_prune', {
581
+ description: 'Delete stale persisted batches (not updated within olderThan).',
582
+ annotations: { destructiveHint: true },
583
+ inputSchema: {
584
+ olderThan: z.string().optional().describe('Delete batches not updated within this duration (e.g. 72h, 7d)'),
585
+ account: accountParam,
586
+ },
587
+ }, async ({ olderThan, account }) => {
588
+ const args = ['batch', 'prune'];
589
+ if (olderThan) args.push(`--older-than=${olderThan}`);
445
590
  return runOrDiagnose(args, { account });
446
591
  });
447
592
 
@@ -455,15 +600,17 @@ export function registerExtraDocsTools(server: McpServer): void {
455
600
  evenlyDistributed: z.boolean().optional().describe('Reset the selected column (or all columns when col is omitted) to Docs-managed equal width.'),
456
601
  tableIndex: z.number().int().optional().describe('1-based table index in document order; negative counts from the end (default: 1)'),
457
602
  tab: z.string().optional().describe('Target tab title or ID'),
603
+ batch: z.string().optional().describe('Append this mutation to a persisted batch (from gog_batch_begin) instead of applying it — nothing changes in the doc until gog_batch_end submits the batch.'),
458
604
  account: accountParam,
459
605
  },
460
- }, async ({ docId, col, width, evenlyDistributed, tableIndex, tab, account }) => {
606
+ }, async ({ docId, col, width, evenlyDistributed, tableIndex, tab, batch, account }) => {
461
607
  const args = ['docs', 'table-column-width', docId];
462
608
  if (col !== undefined) args.push(`--col=${col}`);
463
609
  if (width !== undefined) args.push(`--width=${width}`);
464
610
  if (evenlyDistributed) args.push('--evenly-distributed');
465
611
  if (tableIndex !== undefined) args.push(`--table-index=${tableIndex}`);
466
612
  if (tab) args.push(`--tab=${tab}`);
613
+ if (batch) args.push(`--batch=${batch}`);
467
614
  return runOrDiagnose(args, { account });
468
615
  });
469
616
 
@@ -719,9 +866,10 @@ export function registerExtraDocsTools(server: McpServer): void {
719
866
  occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
720
867
  matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
721
868
  tab: z.string().optional().describe('Target a specific tab by title or ID'),
869
+ batch: z.string().optional().describe('Append this mutation to a persisted batch (from gog_batch_begin) instead of applying it — nothing changes in the doc until gog_batch_end submits the batch.'),
722
870
  account: accountParam,
723
871
  },
724
- }, async ({ docId, index, atEnd, at, occurrence, matchCase, tab, account }) => {
872
+ }, async ({ docId, index, atEnd, at, occurrence, matchCase, tab, batch, account }) => {
725
873
  const args = ['docs', 'insert-page-break', docId];
726
874
  if (index !== undefined) args.push(`--index=${index}`);
727
875
  if (atEnd) args.push('--at-end');
@@ -729,6 +877,7 @@ export function registerExtraDocsTools(server: McpServer): void {
729
877
  if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
730
878
  if (matchCase) args.push('--match-case');
731
879
  if (tab) args.push(`--tab=${tab}`);
880
+ if (batch) args.push(`--batch=${batch}`);
732
881
  return runOrDiagnose(args, { account });
733
882
  });
734
883
 
@@ -789,8 +938,8 @@ export function registerExtraDocsTools(server: McpServer): void {
789
938
  docId: z.string().describe('Doc ID (from the URL)'),
790
939
  row: z.number().int().describe('1-based row number'),
791
940
  col: z.number().int().describe('1-based column number'),
792
- content: z.string().optional().describe('Replacement content (omit when using contentFile)'),
793
- contentFile: z.string().optional().describe('Read replacement content from a file instead of content'),
941
+ content: z.string().optional().describe('Replacement content (omit when using contentFile). Pass an empty string to clear the cell. Large values are written to a temp file and passed to gog as --content-file automatically.'),
942
+ contentFile: z.string().optional().describe('Read replacement content from a file instead of content. Mutually exclusive with content — gog rejects both together.'),
794
943
  append: z.boolean().optional().describe('Append inside the cell instead of replacing existing cell content'),
795
944
  format: z.enum(['markdown', 'plain']).optional().describe('Content format (default: markdown)'),
796
945
  tableIndex: z.number().int().optional().describe('1-based table index in document order; negative counts from the end (default: 1)'),
@@ -798,8 +947,21 @@ export function registerExtraDocsTools(server: McpServer): void {
798
947
  account: accountParam,
799
948
  },
800
949
  }, async ({ docId, row, col, content, contentFile, append, format, tableIndex, tab, account }) => {
801
- const args = ['docs', 'cell-update', docId, `--row=${row}`, `--col=${col}`];
802
- if (content !== undefined) args.push(`--content=${content}`);
950
+ // gog HARD-ERRORS on both forms ("cannot use both --content and
951
+ // --content-file"), so reject here with a clearer message before gog runs.
952
+ // (The slides commands are the opposite: they accept both and the file
953
+ // wins — see the note in slides-extra.ts.)
954
+ if (content !== undefined && contentFile) {
955
+ throw new Error(
956
+ 'gog_docs_cell_update accepts content or contentFile, not both — gog refuses the combination ' +
957
+ '("cannot use both --content and --content-file"). Drop one of them.',
958
+ );
959
+ }
960
+ const args: GogArg[] = ['docs', 'cell-update', docId, `--row=${row}`, `--col=${col}`];
961
+ // `content !== undefined` (not truthiness) is deliberate: an empty string is
962
+ // a meaningful value that clears the cell, so it must reach gog as
963
+ // `--content=`. contentFile stays on truthiness — an empty path is nothing.
964
+ if (content !== undefined) args.push(payloadArg('content', 'content-file', content, 'md'));
803
965
  if (contentFile) args.push(`--content-file=${contentFile}`);
804
966
  if (append) args.push('--append');
805
967
  if (format) args.push(`--format=${format}`);
@@ -822,11 +984,23 @@ export function registerExtraDocsTools(server: McpServer): void {
822
984
  bold: z.boolean().optional().describe('Set cell text bold'),
823
985
  italic: z.boolean().optional().describe('Set cell text italic'),
824
986
  underline: z.boolean().optional().describe('Set cell text underline'),
987
+ borderAll: z.string().optional().describe('All borders as WIDTH[,COLOR[,SOLID|DOT|DASH]] (e.g. 1pt,#000,DASH)'),
988
+ borderTop: z.string().optional().describe('Top border (same format as borderAll); overrides borderAll'),
989
+ borderBottom: z.string().optional().describe('Bottom border (same format as borderAll); overrides borderAll'),
990
+ borderLeft: z.string().optional().describe('Left border (same format as borderAll); overrides borderAll'),
991
+ borderRight: z.string().optional().describe('Right border (same format as borderAll); overrides borderAll'),
992
+ paddingAll: z.string().optional().describe('All cell padding (points by default; supports pt, in, cm, mm)'),
993
+ paddingTop: z.string().optional().describe('Top cell padding; overrides paddingAll'),
994
+ paddingBottom: z.string().optional().describe('Bottom cell padding; overrides paddingAll'),
995
+ paddingLeft: z.string().optional().describe('Left cell padding; overrides paddingAll'),
996
+ paddingRight: z.string().optional().describe('Right cell padding; overrides paddingAll'),
997
+ contentAlign: z.enum(['top', 'middle', 'bottom']).optional().describe('Vertical content alignment within the cell'),
825
998
  tableIndex: z.number().int().optional().describe('0-based table index in document order (default: 0)'),
826
999
  tab: z.string().optional().describe('Target a specific tab by title or ID'),
1000
+ batch: z.string().optional().describe('Append this mutation to a persisted batch (from gog_batch_begin) instead of applying it — nothing changes in the doc until gog_batch_end submits the batch.'),
827
1001
  account: accountParam,
828
1002
  },
829
- }, async ({ docId, row, col, rowSpan, colSpan, backgroundColor, textColor, bold, italic, underline, tableIndex, tab, account }) => {
1003
+ }, async ({ docId, row, col, rowSpan, colSpan, backgroundColor, textColor, bold, italic, underline, borderAll, borderTop, borderBottom, borderLeft, borderRight, paddingAll, paddingTop, paddingBottom, paddingLeft, paddingRight, contentAlign, tableIndex, tab, batch, account }) => {
830
1004
  const args = ['docs', 'cell-style', docId, `--row=${row}`, `--col=${col}`];
831
1005
  if (rowSpan !== undefined) args.push(`--row-span=${rowSpan}`);
832
1006
  if (colSpan !== undefined) args.push(`--col-span=${colSpan}`);
@@ -835,8 +1009,20 @@ export function registerExtraDocsTools(server: McpServer): void {
835
1009
  if (bold) args.push('--bold');
836
1010
  if (italic) args.push('--italic');
837
1011
  if (underline) args.push('--underline');
1012
+ if (borderAll) args.push(`--border-all=${borderAll}`);
1013
+ if (borderTop) args.push(`--border-top=${borderTop}`);
1014
+ if (borderBottom) args.push(`--border-bottom=${borderBottom}`);
1015
+ if (borderLeft) args.push(`--border-left=${borderLeft}`);
1016
+ if (borderRight) args.push(`--border-right=${borderRight}`);
1017
+ if (paddingAll) args.push(`--padding-all=${paddingAll}`);
1018
+ if (paddingTop) args.push(`--padding-top=${paddingTop}`);
1019
+ if (paddingBottom) args.push(`--padding-bottom=${paddingBottom}`);
1020
+ if (paddingLeft) args.push(`--padding-left=${paddingLeft}`);
1021
+ if (paddingRight) args.push(`--padding-right=${paddingRight}`);
1022
+ if (contentAlign) args.push(`--content-align=${contentAlign}`);
838
1023
  if (tableIndex !== undefined) args.push(`--table-index=${tableIndex}`);
839
1024
  if (tab) args.push(`--tab=${tab}`);
1025
+ if (batch) args.push(`--batch=${batch}`);
840
1026
  return runOrDiagnose(args, { account });
841
1027
  });
842
1028
 
@@ -848,6 +1034,8 @@ export function registerExtraDocsTools(server: McpServer): void {
848
1034
  file: z.string().optional().describe('Local PNG, JPEG, or GIF image to upload and insert (exactly one of file or url)'),
849
1035
  url: z.string().optional().describe('Public HTTPS image URL to insert directly — no Drive upload or temporary public sharing (exactly one of file or url)'),
850
1036
  at: z.string().optional().describe('Placeholder text to replace, or "end" to append (default: end)'),
1037
+ before: z.string().optional().describe('Insert the image immediately before the first literal text match (no text is deleted)'),
1038
+ after: z.string().optional().describe('Insert the image immediately after the first literal text match (no text is deleted)'),
851
1039
  width: z.number().optional().describe('Image width in points (default: 468)'),
852
1040
  height: z.number().optional().describe('Image height in points (optional; width-only preserves aspect ratio)'),
853
1041
  name: z.string().optional().describe('Override the uploaded Drive filename'),
@@ -856,17 +1044,20 @@ export function registerExtraDocsTools(server: McpServer): void {
856
1044
  tab: z.string().optional().describe('Target a specific tab by title or ID'),
857
1045
  account: accountParam,
858
1046
  },
859
- }, async ({ docId, file, url, at, width, height, name, parent, onRestricted, tab, account }) => {
1047
+ }, async ({ docId, file, url, at, before, after, width, height, name, parent, onRestricted, tab, account }) => {
860
1048
  const args = ['docs', 'insert-image', docId];
861
1049
  if (file) args.push(`--file=${file}`);
862
1050
  if (url) args.push(`--url=${url}`);
863
1051
  if (at) args.push(`--at=${at}`);
1052
+ if (before) args.push(`--before=${before}`);
1053
+ if (after) args.push(`--after=${after}`);
864
1054
  if (width !== undefined) args.push(`--width=${width}`);
865
1055
  if (height !== undefined) args.push(`--height=${height}`);
866
1056
  if (name) args.push(`--name=${name}`);
867
1057
  if (parent) args.push(`--parent=${parent}`);
868
1058
  if (onRestricted) args.push(`--on-restricted=${onRestricted}`);
869
1059
  if (tab) args.push(`--tab=${tab}`);
1060
+ if (file) args.push('--force'); // gog gates this op; without --force the runner's --no-input makes it refuse (local files upload via a temporary public share)
870
1061
  return runOrDiagnose(args, { account });
871
1062
  });
872
1063
 
@@ -882,9 +1073,10 @@ export function registerExtraDocsTools(server: McpServer): void {
882
1073
  occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
883
1074
  matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
884
1075
  tab: z.string().optional().describe('Target a specific tab by title or ID'),
1076
+ batch: z.string().optional().describe('Append this mutation to a persisted batch (from gog_batch_begin) instead of applying it — nothing changes in the doc until gog_batch_end submits the batch.'),
885
1077
  account: accountParam,
886
1078
  },
887
- }, async ({ docId, email, index, atEnd, at, occurrence, matchCase, tab, account }) => {
1079
+ }, async ({ docId, email, index, atEnd, at, occurrence, matchCase, tab, batch, account }) => {
888
1080
  const args = ['docs', 'insert-person', docId, `--email=${email}`];
889
1081
  if (index !== undefined) args.push(`--index=${index}`);
890
1082
  if (atEnd) args.push('--at-end');
@@ -892,6 +1084,7 @@ export function registerExtraDocsTools(server: McpServer): void {
892
1084
  if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
893
1085
  if (matchCase) args.push('--match-case');
894
1086
  if (tab) args.push(`--tab=${tab}`);
1087
+ if (batch) args.push(`--batch=${batch}`);
895
1088
  return runOrDiagnose(args, { account });
896
1089
  });
897
1090
 
@@ -905,15 +1098,17 @@ export function registerExtraDocsTools(server: McpServer): void {
905
1098
  index: z.number().int().optional().describe('Character index to insert at. Omit or use atEnd for end-of-doc.'),
906
1099
  atEnd: z.boolean().optional().describe('Insert at end-of-doc/tab (mutually exclusive with index)'),
907
1100
  tab: z.string().optional().describe('Target a specific tab by title or ID'),
1101
+ batch: z.string().optional().describe('Append this mutation to a persisted batch (from gog_batch_begin) instead of applying it — nothing changes in the doc until gog_batch_end submits the batch.'),
908
1102
  account: accountParam,
909
1103
  },
910
- }, async ({ docId, date, format, index, atEnd, tab, account }) => {
1104
+ }, async ({ docId, date, format, index, atEnd, tab, batch, account }) => {
911
1105
  const args = ['docs', 'insert-date-chip', docId];
912
1106
  if (date) args.push(`--date=${date}`);
913
1107
  if (format) args.push(`--format=${format}`);
914
1108
  if (index !== undefined) args.push(`--index=${index}`);
915
1109
  if (atEnd) args.push('--at-end');
916
1110
  if (tab) args.push(`--tab=${tab}`);
1111
+ if (batch) args.push(`--batch=${batch}`);
917
1112
  return runOrDiagnose(args, { account });
918
1113
  });
919
1114
 
@@ -958,7 +1153,7 @@ export function registerExtraDocsTools(server: McpServer): void {
958
1153
  account: accountParam,
959
1154
  },
960
1155
  }, async ({ docId, tab, account }) => {
961
- const args = ['docs', 'delete-tab', docId, `--tab=${tab}`];
1156
+ const args = ['docs', 'delete-tab', docId, `--tab=${tab}`, '--force']; // gog gates this op; without --force the runner's --no-input makes it refuse
962
1157
  return runOrDiagnose(args, { account });
963
1158
  });
964
1159
 
@@ -972,4 +1167,302 @@ export function registerExtraDocsTools(server: McpServer): void {
972
1167
  }, async ({ docId, account }) => {
973
1168
  return runOrDiagnose(['docs', 'clear', docId], { account });
974
1169
  });
1170
+
1171
+ // --- gog 0.30 structural authoring: footnotes, section breaks, rules, columns ---
1172
+
1173
+ server.registerTool('gog_docs_insert_footnote', {
1174
+ description: 'Insert a footnote at a character index (or end-of-doc) and populate its text. The footnote reference mark is placed in the body; the footnote content goes in the new footnote segment.',
1175
+ annotations: { destructiveHint: true },
1176
+ inputSchema: {
1177
+ docId: z.string().describe('Doc ID (from the URL)'),
1178
+ text: z.string().optional().describe('Footnote text'),
1179
+ file: z.string().optional().describe('Read footnote text from a file ("-" for stdin)'),
1180
+ index: z.number().int().optional().describe('Character index to place the reference mark at (1 = beginning). Omit or use atEnd for end-of-doc.'),
1181
+ atEnd: z.boolean().optional().describe('Target end-of-doc/tab (mutually exclusive with index and at)'),
1182
+ at: z.string().optional().describe('Anchor by literal text and place the reference mark at the start of the matched range'),
1183
+ occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
1184
+ matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
1185
+ tab: z.string().optional().describe('Target a specific tab by title or ID'),
1186
+ account: accountParam,
1187
+ },
1188
+ }, async ({ docId, text, file, index, atEnd, at, occurrence, matchCase, tab, account }) => {
1189
+ const args = ['docs', 'insert-footnote', docId];
1190
+ if (text !== undefined) args.push(`--text=${text}`);
1191
+ if (file) args.push(`--file=${file}`);
1192
+ if (index !== undefined) args.push(`--index=${index}`);
1193
+ if (atEnd) args.push('--at-end');
1194
+ if (at) args.push(`--at=${at}`);
1195
+ if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
1196
+ if (matchCase) args.push('--match-case');
1197
+ if (tab) args.push(`--tab=${tab}`);
1198
+ return runOrDiagnose(args, { account });
1199
+ });
1200
+
1201
+ server.registerTool('gog_docs_insert_section_break', {
1202
+ description: 'Insert a continuous or next-page section break at a character index (or end-of-doc). Section breaks enable per-section layout such as column counts (see gog_docs_section_columns).',
1203
+ annotations: { destructiveHint: true },
1204
+ inputSchema: {
1205
+ docId: z.string().describe('Doc ID (from the URL)'),
1206
+ type: z.enum(['next-page', 'continuous']).optional().describe('Section break type (default: next-page)'),
1207
+ index: z.number().int().optional().describe('Character index to insert at (1 = beginning). Omit or use atEnd for end-of-doc.'),
1208
+ atEnd: z.boolean().optional().describe('Target end-of-doc/tab (mutually exclusive with index and at)'),
1209
+ at: z.string().optional().describe('Anchor by literal text and insert at the start of the matched range'),
1210
+ occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
1211
+ matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
1212
+ tab: z.string().optional().describe('Target a specific tab by title or ID'),
1213
+ batch: z.string().optional().describe('Append this mutation to a persisted batch (from gog_batch_begin) instead of applying it — nothing changes in the doc until gog_batch_end submits the batch.'),
1214
+ account: accountParam,
1215
+ },
1216
+ }, async ({ docId, type, index, atEnd, at, occurrence, matchCase, tab, batch, account }) => {
1217
+ const args = ['docs', 'insert-section-break', docId];
1218
+ if (type) args.push(`--type=${type}`);
1219
+ if (index !== undefined) args.push(`--index=${index}`);
1220
+ if (atEnd) args.push('--at-end');
1221
+ if (at) args.push(`--at=${at}`);
1222
+ if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
1223
+ if (matchCase) args.push('--match-case');
1224
+ if (tab) args.push(`--tab=${tab}`);
1225
+ if (batch) args.push(`--batch=${batch}`);
1226
+ return runOrDiagnose(args, { account });
1227
+ });
1228
+
1229
+ server.registerTool('gog_docs_insert_horizontal_rule', {
1230
+ description: 'Insert a horizontal rule (paragraph border) at a character index (or end-of-doc).',
1231
+ annotations: { destructiveHint: true },
1232
+ inputSchema: {
1233
+ docId: z.string().describe('Doc ID (from the URL)'),
1234
+ index: z.number().int().optional().describe('Character index to insert at (1 = beginning). Omit or use atEnd for end-of-doc.'),
1235
+ atEnd: z.boolean().optional().describe('Target end-of-doc/tab (mutually exclusive with index and at)'),
1236
+ at: z.string().optional().describe('Anchor by literal text and insert at the start of the matched range'),
1237
+ occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
1238
+ matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
1239
+ tab: z.string().optional().describe('Target a specific tab by title or ID'),
1240
+ batch: z.string().optional().describe('Append this mutation to a persisted batch (from gog_batch_begin) instead of applying it — nothing changes in the doc until gog_batch_end submits the batch.'),
1241
+ account: accountParam,
1242
+ },
1243
+ }, async ({ docId, index, atEnd, at, occurrence, matchCase, tab, batch, account }) => {
1244
+ const args = ['docs', 'insert-horizontal-rule', docId];
1245
+ if (index !== undefined) args.push(`--index=${index}`);
1246
+ if (atEnd) args.push('--at-end');
1247
+ if (at) args.push(`--at=${at}`);
1248
+ if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
1249
+ if (matchCase) args.push('--match-case');
1250
+ if (tab) args.push(`--tab=${tab}`);
1251
+ if (batch) args.push(`--batch=${batch}`);
1252
+ return runOrDiagnose(args, { account });
1253
+ });
1254
+
1255
+ server.registerTool('gog_docs_section_columns', {
1256
+ description: 'Set the column count (1-3) for the document section containing the target position. Use count=1 to reset to a single column. Anchor by index, end-of-doc, or literal text.',
1257
+ annotations: { destructiveHint: true },
1258
+ inputSchema: {
1259
+ docId: z.string().describe('Doc ID (from the URL)'),
1260
+ count: z.number().int().min(1).max(3).describe('Number of columns (1-3; 1 resets to one column)'),
1261
+ separator: z.enum(['none', 'between']).optional().describe('Column separator line: none or between'),
1262
+ index: z.number().int().optional().describe('Character index identifying the section (1 = beginning). Omit or use atEnd for end-of-doc.'),
1263
+ atEnd: z.boolean().optional().describe('Target end-of-doc/tab (mutually exclusive with index and at)'),
1264
+ at: z.string().optional().describe('Anchor by literal text and target the section at the start of the matched range'),
1265
+ occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
1266
+ matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
1267
+ tab: z.string().optional().describe('Target a specific tab by title or ID'),
1268
+ batch: z.string().optional().describe('Append this mutation to a persisted batch (from gog_batch_begin) instead of applying it — nothing changes in the doc until gog_batch_end submits the batch.'),
1269
+ account: accountParam,
1270
+ },
1271
+ }, async ({ docId, count, separator, index, atEnd, at, occurrence, matchCase, tab, batch, account }) => {
1272
+ const args = ['docs', 'section-columns', docId, `--count=${count}`];
1273
+ if (separator) args.push(`--separator=${separator}`);
1274
+ if (index !== undefined) args.push(`--index=${index}`);
1275
+ if (atEnd) args.push('--at-end');
1276
+ if (at) args.push(`--at=${at}`);
1277
+ if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
1278
+ if (matchCase) args.push('--match-case');
1279
+ if (tab) args.push(`--tab=${tab}`);
1280
+ if (batch) args.push(`--batch=${batch}`);
1281
+ return runOrDiagnose(args, { account });
1282
+ });
1283
+
1284
+ // --- gog 0.30 header & footer lifecycle (segment IDs feed the segment param
1285
+ // on gog_docs_insert/update/delete/format/find-range) ---
1286
+
1287
+ server.registerTool('gog_docs_header_list', {
1288
+ description: 'List a Google Doc\'s headers and their segment IDs. Use a segment ID with the segment param on gog_docs_insert / gog_docs_update / gog_docs_delete / gog_docs_format / gog_docs_find_range to edit inside a header.',
1289
+ annotations: { readOnlyHint: true },
1290
+ inputSchema: {
1291
+ docId: z.string().describe('Doc ID (from the URL)'),
1292
+ tab: z.string().optional().describe('Limit results to a tab title or ID'),
1293
+ account: accountParam,
1294
+ },
1295
+ }, async ({ docId, tab, account }) => {
1296
+ const args = ['docs', 'header', 'list', docId];
1297
+ if (tab) args.push(`--tab=${tab}`);
1298
+ return runOrDiagnose(args, { account });
1299
+ });
1300
+
1301
+ server.registerTool('gog_docs_header_create', {
1302
+ description: 'Create a header in a Google Doc and optionally populate its initial text. Returns the new header segment ID.',
1303
+ inputSchema: {
1304
+ docId: z.string().describe('Doc ID (from the URL)'),
1305
+ text: z.string().optional().describe('Initial header text'),
1306
+ file: z.string().optional().describe('Read initial header text from a file ("-" for stdin)'),
1307
+ index: z.number().int().optional().describe('Character index identifying the section the header belongs to (1 = beginning). Omit or use atEnd for end-of-doc.'),
1308
+ atEnd: z.boolean().optional().describe('Target end-of-doc/tab (mutually exclusive with index and at)'),
1309
+ at: z.string().optional().describe('Anchor by literal text and target the section at the start of the matched range'),
1310
+ occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
1311
+ matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
1312
+ tab: z.string().optional().describe('Target a specific tab by title or ID'),
1313
+ account: accountParam,
1314
+ },
1315
+ }, async ({ docId, text, file, index, atEnd, at, occurrence, matchCase, tab, account }) => {
1316
+ const args = ['docs', 'header', 'create', docId];
1317
+ if (text !== undefined) args.push(`--text=${text}`);
1318
+ if (file) args.push(`--file=${file}`);
1319
+ if (index !== undefined) args.push(`--index=${index}`);
1320
+ if (atEnd) args.push('--at-end');
1321
+ if (at) args.push(`--at=${at}`);
1322
+ if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
1323
+ if (matchCase) args.push('--match-case');
1324
+ if (tab) args.push(`--tab=${tab}`);
1325
+ return runOrDiagnose(args, { account });
1326
+ });
1327
+
1328
+ server.registerTool('gog_docs_header_delete', {
1329
+ description: 'Delete a header from a Google Doc by its segment ID (from gog_docs_header_list).',
1330
+ annotations: { destructiveHint: true },
1331
+ inputSchema: {
1332
+ docId: z.string().describe('Doc ID (from the URL)'),
1333
+ headerId: z.string().describe('Header segment ID (from gog_docs_header_list)'),
1334
+ tab: z.string().optional().describe('Tab title or ID containing the header'),
1335
+ account: accountParam,
1336
+ },
1337
+ }, async ({ docId, headerId, tab, account }) => {
1338
+ const args = ['docs', 'header', 'delete', docId, headerId];
1339
+ if (tab) args.push(`--tab=${tab}`);
1340
+ args.push('--force'); // gog refuses this delete under the runner's --no-input without it
1341
+ return runOrDiagnose(args, { account });
1342
+ });
1343
+
1344
+ server.registerTool('gog_docs_footer_list', {
1345
+ description: 'List a Google Doc\'s footers and their segment IDs. Use a segment ID with the segment param on gog_docs_insert / gog_docs_update / gog_docs_delete / gog_docs_format / gog_docs_find_range to edit inside a footer.',
1346
+ annotations: { readOnlyHint: true },
1347
+ inputSchema: {
1348
+ docId: z.string().describe('Doc ID (from the URL)'),
1349
+ tab: z.string().optional().describe('Limit results to a tab title or ID'),
1350
+ account: accountParam,
1351
+ },
1352
+ }, async ({ docId, tab, account }) => {
1353
+ const args = ['docs', 'footer', 'list', docId];
1354
+ if (tab) args.push(`--tab=${tab}`);
1355
+ return runOrDiagnose(args, { account });
1356
+ });
1357
+
1358
+ server.registerTool('gog_docs_footer_create', {
1359
+ description: 'Create a footer in a Google Doc and optionally populate its initial text. Returns the new footer segment ID.',
1360
+ inputSchema: {
1361
+ docId: z.string().describe('Doc ID (from the URL)'),
1362
+ text: z.string().optional().describe('Initial footer text'),
1363
+ file: z.string().optional().describe('Read initial footer text from a file ("-" for stdin)'),
1364
+ index: z.number().int().optional().describe('Character index identifying the section the footer belongs to (1 = beginning). Omit or use atEnd for end-of-doc.'),
1365
+ atEnd: z.boolean().optional().describe('Target end-of-doc/tab (mutually exclusive with index and at)'),
1366
+ at: z.string().optional().describe('Anchor by literal text and target the section at the start of the matched range'),
1367
+ occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
1368
+ matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
1369
+ tab: z.string().optional().describe('Target a specific tab by title or ID'),
1370
+ account: accountParam,
1371
+ },
1372
+ }, async ({ docId, text, file, index, atEnd, at, occurrence, matchCase, tab, account }) => {
1373
+ const args = ['docs', 'footer', 'create', docId];
1374
+ if (text !== undefined) args.push(`--text=${text}`);
1375
+ if (file) args.push(`--file=${file}`);
1376
+ if (index !== undefined) args.push(`--index=${index}`);
1377
+ if (atEnd) args.push('--at-end');
1378
+ if (at) args.push(`--at=${at}`);
1379
+ if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
1380
+ if (matchCase) args.push('--match-case');
1381
+ if (tab) args.push(`--tab=${tab}`);
1382
+ return runOrDiagnose(args, { account });
1383
+ });
1384
+
1385
+ server.registerTool('gog_docs_footer_delete', {
1386
+ description: 'Delete a footer from a Google Doc by its segment ID (from gog_docs_footer_list).',
1387
+ annotations: { destructiveHint: true },
1388
+ inputSchema: {
1389
+ docId: z.string().describe('Doc ID (from the URL)'),
1390
+ footerId: z.string().describe('Footer segment ID (from gog_docs_footer_list)'),
1391
+ tab: z.string().optional().describe('Tab title or ID containing the footer'),
1392
+ account: accountParam,
1393
+ },
1394
+ }, async ({ docId, footerId, tab, account }) => {
1395
+ const args = ['docs', 'footer', 'delete', docId, footerId];
1396
+ if (tab) args.push(`--tab=${tab}`);
1397
+ args.push('--force'); // gog refuses this delete under the runner's --no-input without it
1398
+ return runOrDiagnose(args, { account });
1399
+ });
1400
+
1401
+ // --- gog 0.30 image replacement & table-row pin/style ---
1402
+
1403
+ server.registerTool('gog_docs_replace_image', {
1404
+ description: 'Replace an existing image in a Google Doc in place — keeping its position and bounds — with a new image from a local file or public HTTPS URL. Target the image by exact object ID (from gog_docs_images_list), by alt-text substring, or leave both off to replace the only image in the doc/tab.',
1405
+ annotations: { destructiveHint: true },
1406
+ inputSchema: {
1407
+ docId: z.string().describe('Doc ID (from the URL)'),
1408
+ file: z.string().optional().describe('Local PNG, JPEG, or GIF image to upload and use (exactly one of file or url)'),
1409
+ url: z.string().optional().describe('Public HTTPS image URL to use directly — no Drive upload (exactly one of file or url)'),
1410
+ objectId: z.string().optional().describe('Exact image object ID to replace (from gog_docs_images_list)'),
1411
+ matchAlt: z.string().optional().describe('Select the image whose alt text contains this value (case-insensitive)'),
1412
+ name: z.string().optional().describe('Override the uploaded Drive filename (file mode)'),
1413
+ parent: z.string().optional().describe('Drive folder ID for an uploaded local image (file mode)'),
1414
+ tab: z.string().optional().describe('Target a specific tab by title or ID'),
1415
+ account: accountParam,
1416
+ },
1417
+ }, async ({ docId, file, url, objectId, matchAlt, name, parent, tab, account }) => {
1418
+ const args = ['docs', 'replace-image', docId];
1419
+ if (file) args.push(`--file=${file}`);
1420
+ if (url) args.push(`--url=${url}`);
1421
+ if (objectId) args.push(`--object-id=${objectId}`);
1422
+ if (matchAlt) args.push(`--match-alt=${matchAlt}`);
1423
+ if (name) args.push(`--name=${name}`);
1424
+ if (parent) args.push(`--parent=${parent}`);
1425
+ if (tab) args.push(`--tab=${tab}`);
1426
+ if (file) args.push('--force'); // gog gates this op; without --force the runner's --no-input makes it refuse (local files upload via a temporary public share)
1427
+ return runOrDiagnose(args, { account });
1428
+ });
1429
+
1430
+ server.registerTool('gog_docs_table_row_pin_header', {
1431
+ description: 'Pin (or unpin) leading rows of a native Google Docs table as repeating header rows. rows=N pins the first N rows; rows=0 unpins all header rows.',
1432
+ annotations: { destructiveHint: true },
1433
+ inputSchema: {
1434
+ docId: z.string().describe('Doc ID (from the URL)'),
1435
+ rows: z.number().int().min(0).describe('Number of leading rows to pin as headers; 0 unpins all header rows'),
1436
+ table: tableSelectorParam,
1437
+ tab: z.string().optional().describe('Target a specific tab by title or ID'),
1438
+ account: accountParam,
1439
+ },
1440
+ }, async ({ docId, rows, table, tab, account }) => {
1441
+ const args = ['docs', 'table-row', 'pin-header', docId, `--rows=${rows}`];
1442
+ if (table) args.push(`--table=${table}`);
1443
+ if (tab) args.push(`--tab=${tab}`);
1444
+ return runOrDiagnose(args, { account });
1445
+ });
1446
+
1447
+ server.registerTool('gog_docs_table_row_style', {
1448
+ description: 'Set native table row height and page-overflow behaviour. Target a 1-based row (negative counts from the end) or omit row to style every row in the table.',
1449
+ annotations: { destructiveHint: true },
1450
+ inputSchema: {
1451
+ docId: z.string().describe('Doc ID (from the URL)'),
1452
+ row: z.number().int().optional().describe('1-based row number; negative indexes count from the end; omit to style all rows'),
1453
+ minHeight: z.string().optional().describe('Minimum row height (points by default; supports pt, in, cm, mm)'),
1454
+ preventOverflow: z.boolean().optional().describe('Keep the row within one page or column (true) or clear that setting (false)'),
1455
+ table: tableSelectorParam,
1456
+ tab: z.string().optional().describe('Target a specific tab by title or ID'),
1457
+ account: accountParam,
1458
+ },
1459
+ }, async ({ docId, row, minHeight, preventOverflow, table, tab, account }) => {
1460
+ const args = ['docs', 'table-row', 'style', docId];
1461
+ if (row !== undefined) args.push(`--row=${row}`);
1462
+ if (minHeight) args.push(`--min-height=${minHeight}`);
1463
+ if (preventOverflow !== undefined) args.push(preventOverflow ? '--prevent-overflow' : '--no-prevent-overflow');
1464
+ if (table) args.push(`--table=${table}`);
1465
+ if (tab) args.push(`--tab=${tab}`);
1466
+ return runOrDiagnose(args, { account });
1467
+ });
975
1468
  }