gogcli-mcp-docs 2.8.0 → 2.18.1

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,10 +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.'),
31
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.'),
32
34
  account: accountParam,
33
35
  },
34
- }, async ({ docId, start, end, at, occurrence, matchCase, tabId, batch, account }) => {
36
+ }, async ({ docId, start, end, at, occurrence, matchCase, tabId, segment, batch, account }) => {
35
37
  const args = ['docs', 'delete'];
36
38
  if (start !== undefined) args.push(`--start=${start}`);
37
39
  if (end !== undefined) args.push(`--end=${end}`);
@@ -40,6 +42,7 @@ export function registerExtraDocsTools(server: McpServer): void {
40
42
  if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
41
43
  if (matchCase) args.push('--match-case');
42
44
  if (tabId) args.push(`--tab-id=${tabId}`);
45
+ if (segment) args.push(`--segment=${segment}`);
43
46
  if (batch) args.push(`--batch=${batch}`);
44
47
  return runOrDiagnose(args, { account });
45
48
  });
@@ -52,7 +55,7 @@ export function registerExtraDocsTools(server: McpServer): void {
52
55
  account: accountParam,
53
56
  },
54
57
  }, async ({ docId, account }) => {
55
- 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
56
59
  });
57
60
 
58
61
  server.registerTool('gog_docs_edit', {
@@ -80,9 +83,10 @@ export function registerExtraDocsTools(server: McpServer): void {
80
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.'),
81
84
  allTabs: z.boolean().optional().describe('Show all tabs. In json mode, returns the canonical Document response with all tab content populated.'),
82
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'),
83
87
  account: accountParam,
84
88
  },
85
- }, async ({ docId, format, tab, allTabs, maxBytes, account }) => {
89
+ }, async ({ docId, format, tab, allTabs, maxBytes, chips, account }) => {
86
90
  if (format === 'json') {
87
91
  const args = ['docs', 'raw', docId, '--pretty'];
88
92
  if (tab) args.push(`--tab=${tab}`);
@@ -93,6 +97,7 @@ export function registerExtraDocsTools(server: McpServer): void {
93
97
  if (tab) args.push(`--tab=${tab}`);
94
98
  if (allTabs) args.push('--all-tabs');
95
99
  if (maxBytes !== undefined) args.push(`--max-bytes=${maxBytes}`);
100
+ if (chips) args.push('--chips');
96
101
  return runOrDiagnose(args, { account });
97
102
  });
98
103
 
@@ -124,6 +129,19 @@ export function registerExtraDocsTools(server: McpServer): void {
124
129
  lineSpacing: z.number().optional().describe('Line spacing percentage (e.g. 100 for single, 150 for 1.5x, 200 for double)'),
125
130
  headingLevel: z.number().int().optional().describe('Set paragraph named style to HEADING_1..HEADING_6 (shortcut for namedStyle=HEADING_N)'),
126
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.'),
127
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.'),
128
146
  account: accountParam,
129
147
  },
@@ -149,6 +167,11 @@ export function registerExtraDocsTools(server: McpServer): void {
149
167
  lineSpacing?: number;
150
168
  headingLevel?: number;
151
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;
152
175
  account?: string;
153
176
  };
154
177
  const argv = ['docs', 'format', a.docId];
@@ -175,10 +198,37 @@ export function registerExtraDocsTools(server: McpServer): void {
175
198
  if (a.lineSpacing !== undefined) argv.push(`--line-spacing=${a.lineSpacing}`);
176
199
  if (a.headingLevel !== undefined) argv.push(`--heading-level=${a.headingLevel}`);
177
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}`);
178
214
  if (a.batch) argv.push(`--batch=${a.batch}`);
179
215
  return runOrDiagnose(argv, { account: a.account });
180
216
  });
181
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
+
182
232
  server.registerTool('gog_docs_export', {
183
233
  description: 'Export a Google Doc as PDF, plain text, HTML, DOCX, or other format.',
184
234
  annotations: { readOnlyHint: true },
@@ -186,12 +236,14 @@ export function registerExtraDocsTools(server: McpServer): void {
186
236
  docId: z.string().describe('Doc ID (from the URL)'),
187
237
  format: z.string().optional().describe('Export format: pdf, txt, html, docx, rtf, odt, epub (default: pdf)'),
188
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)'),
189
240
  account: accountParam,
190
241
  },
191
- }, async ({ docId, format, out, account }) => {
242
+ }, async ({ docId, format, out, overwrite, account }) => {
192
243
  const args = ['docs', 'export', docId];
193
244
  if (format) args.push(`--format=${format}`);
194
245
  if (out) args.push(`--out=${out}`);
246
+ if (overwrite) args.push('--overwrite');
195
247
  return runOrDiagnose(args, { account });
196
248
  });
197
249
 
@@ -207,10 +259,11 @@ export function registerExtraDocsTools(server: McpServer): void {
207
259
  occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
208
260
  matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
209
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.'),
210
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.'),
211
264
  account: accountParam,
212
265
  },
213
- }, async ({ docId, content, index, file, at, occurrence, matchCase, tabId, batch, account }) => {
266
+ }, async ({ docId, content, index, file, at, occurrence, matchCase, tabId, segment, batch, account }) => {
214
267
  const args = ['docs', 'insert', docId];
215
268
  if (content) args.push(content);
216
269
  if (index !== undefined) args.push(`--index=${index}`);
@@ -219,6 +272,7 @@ export function registerExtraDocsTools(server: McpServer): void {
219
272
  if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
220
273
  if (matchCase) args.push('--match-case');
221
274
  if (tabId) args.push(`--tab-id=${tabId}`);
275
+ if (segment) args.push(`--segment=${segment}`);
222
276
  if (batch) args.push(`--batch=${batch}`);
223
277
  return runOrDiagnose(args, { account });
224
278
  });
@@ -292,11 +346,12 @@ export function registerExtraDocsTools(server: McpServer): void {
292
346
  occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
293
347
  matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
294
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.'),
295
350
  pageless: z.boolean().optional().describe('Set document to pageless format'),
296
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.'),
297
352
  account: accountParam,
298
353
  },
299
- }, async ({ docId, text, file, index, replaceRange, markdown, at, occurrence, matchCase, tabId, pageless, batch, account }) => {
354
+ }, async ({ docId, text, file, index, replaceRange, markdown, at, occurrence, matchCase, tabId, segment, pageless, batch, account }) => {
300
355
  const args = ['docs', 'update', docId];
301
356
  if (text) args.push(`--text=${text}`);
302
357
  if (file) args.push(`--file=${file}`);
@@ -307,6 +362,7 @@ export function registerExtraDocsTools(server: McpServer): void {
307
362
  if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
308
363
  if (matchCase) args.push('--match-case');
309
364
  if (tabId) args.push(`--tab-id=${tabId}`);
365
+ if (segment) args.push(`--segment=${segment}`);
310
366
  if (pageless) args.push('--pageless');
311
367
  if (batch) args.push(`--batch=${batch}`);
312
368
  return runOrDiagnose(args, { account });
@@ -395,7 +451,8 @@ export function registerExtraDocsTools(server: McpServer): void {
395
451
  account: accountParam,
396
452
  },
397
453
  }, async ({ docId, commentId, account }) => {
398
- 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 });
399
456
  });
400
457
 
401
458
  server.registerTool('gog_docs_comments_reopen', {
@@ -441,9 +498,10 @@ export function registerExtraDocsTools(server: McpServer): void {
441
498
  all: z.boolean().optional().describe('Return all matches instead of just one'),
442
499
  failEmpty: z.boolean().optional().describe('Treat no matches as an error instead of returning an empty result'),
443
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.'),
444
502
  account: accountParam,
445
503
  },
446
- }, async ({ docId, text, occurrence, matchCase, normalizeWhitespace, all, failEmpty, tab, account }) => {
504
+ }, async ({ docId, text, occurrence, matchCase, normalizeWhitespace, all, failEmpty, tab, segment, account }) => {
447
505
  const args = ['docs', 'find-range', docId, text];
448
506
  if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
449
507
  if (matchCase) args.push('--match-case');
@@ -451,6 +509,7 @@ export function registerExtraDocsTools(server: McpServer): void {
451
509
  if (all) args.push('--all');
452
510
  if (failEmpty) args.push('--fail-empty');
453
511
  if (tab) args.push(`--tab=${tab}`);
512
+ if (segment) args.push(`--segment=${segment}`);
454
513
  return runOrDiagnose(args, { account });
455
514
  });
456
515
 
@@ -879,8 +938,8 @@ export function registerExtraDocsTools(server: McpServer): void {
879
938
  docId: z.string().describe('Doc ID (from the URL)'),
880
939
  row: z.number().int().describe('1-based row number'),
881
940
  col: z.number().int().describe('1-based column number'),
882
- content: z.string().optional().describe('Replacement content (omit when using contentFile)'),
883
- 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.'),
884
943
  append: z.boolean().optional().describe('Append inside the cell instead of replacing existing cell content'),
885
944
  format: z.enum(['markdown', 'plain']).optional().describe('Content format (default: markdown)'),
886
945
  tableIndex: z.number().int().optional().describe('1-based table index in document order; negative counts from the end (default: 1)'),
@@ -888,8 +947,21 @@ export function registerExtraDocsTools(server: McpServer): void {
888
947
  account: accountParam,
889
948
  },
890
949
  }, async ({ docId, row, col, content, contentFile, append, format, tableIndex, tab, account }) => {
891
- const args = ['docs', 'cell-update', docId, `--row=${row}`, `--col=${col}`];
892
- 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'));
893
965
  if (contentFile) args.push(`--content-file=${contentFile}`);
894
966
  if (append) args.push('--append');
895
967
  if (format) args.push(`--format=${format}`);
@@ -912,12 +984,23 @@ export function registerExtraDocsTools(server: McpServer): void {
912
984
  bold: z.boolean().optional().describe('Set cell text bold'),
913
985
  italic: z.boolean().optional().describe('Set cell text italic'),
914
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'),
915
998
  tableIndex: z.number().int().optional().describe('0-based table index in document order (default: 0)'),
916
999
  tab: z.string().optional().describe('Target a specific tab by title or ID'),
917
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.'),
918
1001
  account: accountParam,
919
1002
  },
920
- }, async ({ docId, row, col, rowSpan, colSpan, backgroundColor, textColor, bold, italic, underline, tableIndex, tab, batch, 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 }) => {
921
1004
  const args = ['docs', 'cell-style', docId, `--row=${row}`, `--col=${col}`];
922
1005
  if (rowSpan !== undefined) args.push(`--row-span=${rowSpan}`);
923
1006
  if (colSpan !== undefined) args.push(`--col-span=${colSpan}`);
@@ -926,6 +1009,17 @@ export function registerExtraDocsTools(server: McpServer): void {
926
1009
  if (bold) args.push('--bold');
927
1010
  if (italic) args.push('--italic');
928
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}`);
929
1023
  if (tableIndex !== undefined) args.push(`--table-index=${tableIndex}`);
930
1024
  if (tab) args.push(`--tab=${tab}`);
931
1025
  if (batch) args.push(`--batch=${batch}`);
@@ -940,6 +1034,8 @@ export function registerExtraDocsTools(server: McpServer): void {
940
1034
  file: z.string().optional().describe('Local PNG, JPEG, or GIF image to upload and insert (exactly one of file or url)'),
941
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)'),
942
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)'),
943
1039
  width: z.number().optional().describe('Image width in points (default: 468)'),
944
1040
  height: z.number().optional().describe('Image height in points (optional; width-only preserves aspect ratio)'),
945
1041
  name: z.string().optional().describe('Override the uploaded Drive filename'),
@@ -948,17 +1044,20 @@ export function registerExtraDocsTools(server: McpServer): void {
948
1044
  tab: z.string().optional().describe('Target a specific tab by title or ID'),
949
1045
  account: accountParam,
950
1046
  },
951
- }, 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 }) => {
952
1048
  const args = ['docs', 'insert-image', docId];
953
1049
  if (file) args.push(`--file=${file}`);
954
1050
  if (url) args.push(`--url=${url}`);
955
1051
  if (at) args.push(`--at=${at}`);
1052
+ if (before) args.push(`--before=${before}`);
1053
+ if (after) args.push(`--after=${after}`);
956
1054
  if (width !== undefined) args.push(`--width=${width}`);
957
1055
  if (height !== undefined) args.push(`--height=${height}`);
958
1056
  if (name) args.push(`--name=${name}`);
959
1057
  if (parent) args.push(`--parent=${parent}`);
960
1058
  if (onRestricted) args.push(`--on-restricted=${onRestricted}`);
961
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)
962
1061
  return runOrDiagnose(args, { account });
963
1062
  });
964
1063
 
@@ -1054,7 +1153,7 @@ export function registerExtraDocsTools(server: McpServer): void {
1054
1153
  account: accountParam,
1055
1154
  },
1056
1155
  }, async ({ docId, tab, account }) => {
1057
- 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
1058
1157
  return runOrDiagnose(args, { account });
1059
1158
  });
1060
1159
 
@@ -1068,4 +1167,302 @@ export function registerExtraDocsTools(server: McpServer): void {
1068
1167
  }, async ({ docId, account }) => {
1069
1168
  return runOrDiagnose(['docs', 'clear', docId], { account });
1070
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
+ });
1071
1468
  }