mcp-google-multi 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1123 @@
1
+ import { z } from 'zod';
2
+ import { google } from 'googleapis';
3
+ import { ACCOUNTS } from '../accounts.js';
4
+ import { getClient } from '../client.js';
5
+ import { handleGoogleApiError } from './_errors.js';
6
+ const accountEnum = z.enum(ACCOUNTS);
7
+ function extractPlainText(body) {
8
+ if (!body?.content)
9
+ return '';
10
+ let text = '';
11
+ for (const element of body.content) {
12
+ if (element.paragraph) {
13
+ for (const pe of element.paragraph.elements ?? []) {
14
+ if (pe.textRun?.content) {
15
+ text += pe.textRun.content;
16
+ }
17
+ }
18
+ }
19
+ else if (element.table) {
20
+ for (const row of element.table.tableRows ?? []) {
21
+ for (const cell of row.tableCells ?? []) {
22
+ text += extractPlainText(cell);
23
+ }
24
+ }
25
+ }
26
+ }
27
+ return text;
28
+ }
29
+ export function registerDocsTools(server) {
30
+ server.registerTool('docs_create', {
31
+ description: 'Create a new Google Docs document',
32
+ inputSchema: {
33
+ account: accountEnum.describe('Google account alias'),
34
+ title: z.string().describe('Document title'),
35
+ },
36
+ }, async ({ account, title }) => {
37
+ try {
38
+ const auth = await getClient(account);
39
+ const docs = google.docs({ version: 'v1', auth });
40
+ const res = await docs.documents.create({
41
+ requestBody: { title },
42
+ });
43
+ return {
44
+ content: [{ type: 'text', text: JSON.stringify({
45
+ documentId: res.data.documentId,
46
+ title: res.data.title,
47
+ }, null, 2) }],
48
+ };
49
+ }
50
+ catch (error) {
51
+ return handleDocsError(error, account);
52
+ }
53
+ });
54
+ server.registerTool('docs_get', {
55
+ description: 'Get document metadata (title, revision, named ranges). Optionally include tab content and choose a suggestionsViewMode.',
56
+ inputSchema: {
57
+ account: accountEnum.describe('Google account alias'),
58
+ documentId: z.string().describe('Google Docs document ID'),
59
+ includeTabsContent: z.boolean().optional()
60
+ .describe('Include full content for every tab (default: false — first tab only)'),
61
+ suggestionsViewMode: z.enum([
62
+ 'DEFAULT_FOR_CURRENT_ACCESS',
63
+ 'SUGGESTIONS_INLINE',
64
+ 'PREVIEW_SUGGESTIONS_ACCEPTED',
65
+ 'PREVIEW_WITHOUT_SUGGESTIONS',
66
+ ]).optional().describe('How suggestions render in the returned body'),
67
+ },
68
+ }, async ({ account, documentId, includeTabsContent, suggestionsViewMode }) => {
69
+ try {
70
+ const auth = await getClient(account);
71
+ const docs = google.docs({ version: 'v1', auth });
72
+ const res = await docs.documents.get({
73
+ documentId,
74
+ includeTabsContent: includeTabsContent ?? false,
75
+ suggestionsViewMode,
76
+ });
77
+ return {
78
+ content: [{ type: 'text', text: JSON.stringify({
79
+ documentId: res.data.documentId,
80
+ title: res.data.title,
81
+ revisionId: res.data.revisionId,
82
+ namedRanges: res.data.namedRanges ?? {},
83
+ tabs: res.data.tabs ?? undefined,
84
+ }, null, 2) }],
85
+ };
86
+ }
87
+ catch (error) {
88
+ return handleDocsError(error, account);
89
+ }
90
+ });
91
+ server.registerTool('docs_read', {
92
+ description: 'Read document content as plain text',
93
+ inputSchema: {
94
+ account: accountEnum.describe('Google account alias'),
95
+ documentId: z.string().describe('Google Docs document ID'),
96
+ },
97
+ }, async ({ account, documentId }) => {
98
+ try {
99
+ const auth = await getClient(account);
100
+ const docs = google.docs({ version: 'v1', auth });
101
+ const res = await docs.documents.get({ documentId });
102
+ const text = extractPlainText(res.data.body);
103
+ return {
104
+ content: [{ type: 'text', text: JSON.stringify({
105
+ documentId: res.data.documentId,
106
+ title: res.data.title,
107
+ text,
108
+ }, null, 2) }],
109
+ };
110
+ }
111
+ catch (error) {
112
+ return handleDocsError(error, account);
113
+ }
114
+ });
115
+ server.registerTool('docs_insert_text', {
116
+ description: 'Insert text into a document at a specific position or at the end',
117
+ inputSchema: {
118
+ account: accountEnum.describe('Google account alias'),
119
+ documentId: z.string().describe('Google Docs document ID'),
120
+ text: z.string().describe('Text to insert'),
121
+ index: z.number().min(1).optional()
122
+ .describe('Character index to insert at (1-based). Omit to append at the end'),
123
+ },
124
+ }, async ({ account, documentId, text, index }) => {
125
+ try {
126
+ const auth = await getClient(account);
127
+ const docs = google.docs({ version: 'v1', auth });
128
+ const request = { insertText: { text } };
129
+ if (index !== undefined) {
130
+ request.insertText.location = { index };
131
+ }
132
+ else {
133
+ request.insertText.endOfSegmentLocation = { segmentId: '' };
134
+ }
135
+ const res = await docs.documents.batchUpdate({
136
+ documentId,
137
+ requestBody: { requests: [request] },
138
+ });
139
+ return {
140
+ content: [{ type: 'text', text: JSON.stringify({
141
+ documentId: res.data.documentId,
142
+ insertedAt: index ?? 'end',
143
+ }, null, 2) }],
144
+ };
145
+ }
146
+ catch (error) {
147
+ return handleDocsError(error, account);
148
+ }
149
+ });
150
+ server.registerTool('docs_replace_text', {
151
+ description: 'Find and replace all occurrences of text in a document',
152
+ inputSchema: {
153
+ account: accountEnum.describe('Google account alias'),
154
+ documentId: z.string().describe('Google Docs document ID'),
155
+ findText: z.string().describe('Text to search for'),
156
+ replaceText: z.string().describe('Replacement text'),
157
+ matchCase: z.boolean().default(true).optional()
158
+ .describe('Case-sensitive match (default: true)'),
159
+ },
160
+ }, async ({ account, documentId, findText, replaceText, matchCase }) => {
161
+ try {
162
+ const auth = await getClient(account);
163
+ const docs = google.docs({ version: 'v1', auth });
164
+ const res = await docs.documents.batchUpdate({
165
+ documentId,
166
+ requestBody: {
167
+ requests: [{
168
+ replaceAllText: {
169
+ containsText: { text: findText, matchCase: matchCase ?? true },
170
+ replaceText,
171
+ },
172
+ }],
173
+ },
174
+ });
175
+ const occurrences = res.data.replies?.[0]?.replaceAllText?.occurrencesChanged ?? 0;
176
+ return {
177
+ content: [{ type: 'text', text: JSON.stringify({
178
+ documentId: res.data.documentId,
179
+ occurrencesReplaced: occurrences,
180
+ }, null, 2) }],
181
+ };
182
+ }
183
+ catch (error) {
184
+ return handleDocsError(error, account);
185
+ }
186
+ });
187
+ server.registerTool('docs_delete_range', {
188
+ description: 'Delete content in a character index range within a document',
189
+ inputSchema: {
190
+ account: accountEnum.describe('Google account alias'),
191
+ documentId: z.string().describe('Google Docs document ID'),
192
+ startIndex: z.number().min(1).describe('Start index (inclusive, 1-based)'),
193
+ endIndex: z.number().min(2).describe('End index (exclusive)'),
194
+ },
195
+ }, async ({ account, documentId, startIndex, endIndex }) => {
196
+ try {
197
+ const auth = await getClient(account);
198
+ const docs = google.docs({ version: 'v1', auth });
199
+ const res = await docs.documents.batchUpdate({
200
+ documentId,
201
+ requestBody: {
202
+ requests: [{
203
+ deleteContentRange: {
204
+ range: { startIndex, endIndex, segmentId: '' },
205
+ },
206
+ }],
207
+ },
208
+ });
209
+ return {
210
+ content: [{ type: 'text', text: JSON.stringify({
211
+ documentId: res.data.documentId,
212
+ deletedRange: { startIndex, endIndex },
213
+ }, null, 2) }],
214
+ };
215
+ }
216
+ catch (error) {
217
+ return handleDocsError(error, account);
218
+ }
219
+ });
220
+ server.registerTool('docs_update_style', {
221
+ description: 'Update text formatting (bold, italic, underline, font, size) for a range',
222
+ inputSchema: {
223
+ account: accountEnum.describe('Google account alias'),
224
+ documentId: z.string().describe('Google Docs document ID'),
225
+ startIndex: z.number().min(1).describe('Start index (inclusive, 1-based)'),
226
+ endIndex: z.number().min(2).describe('End index (exclusive)'),
227
+ bold: z.boolean().optional().describe('Set bold'),
228
+ italic: z.boolean().optional().describe('Set italic'),
229
+ underline: z.boolean().optional().describe('Set underline'),
230
+ fontSize: z.number().min(1).optional().describe('Font size in points'),
231
+ fontFamily: z.string().optional().describe('Font family name (e.g. "Arial", "Times New Roman")'),
232
+ },
233
+ }, async ({ account, documentId, startIndex, endIndex, bold, italic, underline, fontSize, fontFamily }) => {
234
+ try {
235
+ const auth = await getClient(account);
236
+ const docs = google.docs({ version: 'v1', auth });
237
+ const textStyle = {};
238
+ const fields = [];
239
+ if (bold !== undefined) {
240
+ textStyle.bold = bold;
241
+ fields.push('bold');
242
+ }
243
+ if (italic !== undefined) {
244
+ textStyle.italic = italic;
245
+ fields.push('italic');
246
+ }
247
+ if (underline !== undefined) {
248
+ textStyle.underline = underline;
249
+ fields.push('underline');
250
+ }
251
+ if (fontSize !== undefined) {
252
+ textStyle.fontSize = { magnitude: fontSize, unit: 'PT' };
253
+ fields.push('fontSize');
254
+ }
255
+ if (fontFamily !== undefined) {
256
+ textStyle.weightedFontFamily = { fontFamily };
257
+ fields.push('weightedFontFamily');
258
+ }
259
+ if (fields.length === 0) {
260
+ return {
261
+ content: [{ type: 'text', text: JSON.stringify({
262
+ error: 'No style properties provided. Set at least one of: bold, italic, underline, fontSize, fontFamily',
263
+ }, null, 2) }],
264
+ isError: true,
265
+ };
266
+ }
267
+ const res = await docs.documents.batchUpdate({
268
+ documentId,
269
+ requestBody: {
270
+ requests: [{
271
+ updateTextStyle: {
272
+ range: { startIndex, endIndex, segmentId: '' },
273
+ textStyle,
274
+ fields: fields.join(','),
275
+ },
276
+ }],
277
+ },
278
+ });
279
+ return {
280
+ content: [{ type: 'text', text: JSON.stringify({
281
+ documentId: res.data.documentId,
282
+ styledRange: { startIndex, endIndex },
283
+ appliedStyles: fields,
284
+ }, null, 2) }],
285
+ };
286
+ }
287
+ catch (error) {
288
+ return handleDocsError(error, account);
289
+ }
290
+ });
291
+ server.registerTool('docs_insert_table', {
292
+ description: 'Insert a table into a document at a specific position',
293
+ inputSchema: {
294
+ account: accountEnum.describe('Google account alias'),
295
+ documentId: z.string().describe('Google Docs document ID'),
296
+ rows: z.number().min(1).describe('Number of rows'),
297
+ columns: z.number().min(1).describe('Number of columns'),
298
+ index: z.number().min(1).optional()
299
+ .describe('Character index to insert at. Omit to append at the end'),
300
+ },
301
+ }, async ({ account, documentId, rows, columns, index }) => {
302
+ try {
303
+ const auth = await getClient(account);
304
+ const docs = google.docs({ version: 'v1', auth });
305
+ const request = { insertTable: { rows, columns } };
306
+ if (index !== undefined) {
307
+ request.insertTable.location = { index };
308
+ }
309
+ else {
310
+ request.insertTable.endOfSegmentLocation = { segmentId: '' };
311
+ }
312
+ const res = await docs.documents.batchUpdate({
313
+ documentId,
314
+ requestBody: { requests: [request] },
315
+ });
316
+ return {
317
+ content: [{ type: 'text', text: JSON.stringify({
318
+ documentId: res.data.documentId,
319
+ insertedTable: { rows, columns, at: index ?? 'end' },
320
+ }, null, 2) }],
321
+ };
322
+ }
323
+ catch (error) {
324
+ return handleDocsError(error, account);
325
+ }
326
+ });
327
+ // ─── Named ranges (mail-merge primitive) ───────────────────────────────
328
+ server.registerTool('docs_create_named_range', {
329
+ description: 'Tag a range of text with a name. Multiple ranges can share the same name. Used as a template anchor for docs_replace_named_range_content.',
330
+ inputSchema: {
331
+ account: accountEnum.describe('Google account alias'),
332
+ documentId: z.string().describe('Google Docs document ID'),
333
+ name: z.string().describe('Name (1-256 chars). Need not be unique.'),
334
+ startIndex: z.number().min(1).describe('Range start (inclusive)'),
335
+ endIndex: z.number().min(2).describe('Range end (exclusive)'),
336
+ },
337
+ }, async ({ account, documentId, name, startIndex, endIndex }) => {
338
+ try {
339
+ const auth = await getClient(account);
340
+ const docs = google.docs({ version: 'v1', auth });
341
+ const res = await docs.documents.batchUpdate({
342
+ documentId,
343
+ requestBody: {
344
+ requests: [{
345
+ createNamedRange: {
346
+ name,
347
+ range: { startIndex, endIndex },
348
+ },
349
+ }],
350
+ },
351
+ });
352
+ const created = res.data.replies?.[0]?.createNamedRange;
353
+ return {
354
+ content: [{ type: 'text', text: JSON.stringify(created ?? {}, null, 2) }],
355
+ };
356
+ }
357
+ catch (error) {
358
+ return handleDocsError(error, account);
359
+ }
360
+ });
361
+ server.registerTool('docs_delete_named_range', {
362
+ description: 'Delete a named range by its ID or by name. Removes every range matching the identifier.',
363
+ inputSchema: {
364
+ account: accountEnum.describe('Google account alias'),
365
+ documentId: z.string().describe('Google Docs document ID'),
366
+ namedRangeId: z.string().optional().describe('Specific named range ID'),
367
+ name: z.string().optional().describe('Name (deletes ALL named ranges with this name)'),
368
+ },
369
+ }, async ({ account, documentId, namedRangeId, name }) => {
370
+ try {
371
+ if (!namedRangeId && !name) {
372
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Either namedRangeId or name must be supplied' }) }], isError: true };
373
+ }
374
+ const auth = await getClient(account);
375
+ const docs = google.docs({ version: 'v1', auth });
376
+ const req = {};
377
+ if (namedRangeId)
378
+ req.namedRangeId = namedRangeId;
379
+ else
380
+ req.name = name;
381
+ await docs.documents.batchUpdate({
382
+ documentId,
383
+ requestBody: { requests: [{ deleteNamedRange: req }] },
384
+ });
385
+ return {
386
+ content: [{ type: 'text', text: JSON.stringify({ deleted: true }, null, 2) }],
387
+ };
388
+ }
389
+ catch (error) {
390
+ return handleDocsError(error, account);
391
+ }
392
+ });
393
+ server.registerTool('docs_replace_named_range_content', {
394
+ description: 'Replace the content of every named range matching the identifier with the supplied text. Real mail-merge primitive — far more robust than replaceAllText for templated docs.',
395
+ inputSchema: {
396
+ account: accountEnum.describe('Google account alias'),
397
+ documentId: z.string().describe('Google Docs document ID'),
398
+ namedRangeId: z.string().optional().describe('Specific named range ID'),
399
+ namedRangeName: z.string().optional().describe('Name to match (replaces ALL ranges with this name)'),
400
+ text: z.string().describe('Replacement text'),
401
+ },
402
+ }, async ({ account, documentId, namedRangeId, namedRangeName, text }) => {
403
+ try {
404
+ if (!namedRangeId && !namedRangeName) {
405
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Either namedRangeId or namedRangeName must be supplied' }) }], isError: true };
406
+ }
407
+ const auth = await getClient(account);
408
+ const docs = google.docs({ version: 'v1', auth });
409
+ const req = { text };
410
+ if (namedRangeId)
411
+ req.namedRangeId = namedRangeId;
412
+ else
413
+ req.namedRangeName = namedRangeName;
414
+ await docs.documents.batchUpdate({
415
+ documentId,
416
+ requestBody: { requests: [{ replaceNamedRangeContent: req }] },
417
+ });
418
+ return {
419
+ content: [{ type: 'text', text: JSON.stringify({ replaced: true }, null, 2) }],
420
+ };
421
+ }
422
+ catch (error) {
423
+ return handleDocsError(error, account);
424
+ }
425
+ });
426
+ // ─── Paragraph + document style ────────────────────────────────────────
427
+ server.registerTool('docs_update_paragraph_style', {
428
+ description: 'Update paragraph styling (alignment, heading, indents, spacing) across a range. Only supplied fields are applied.',
429
+ inputSchema: {
430
+ account: accountEnum.describe('Google account alias'),
431
+ documentId: z.string().describe('Google Docs document ID'),
432
+ startIndex: z.number().min(1),
433
+ endIndex: z.number().min(2),
434
+ namedStyleType: z.enum([
435
+ 'NORMAL_TEXT', 'TITLE', 'SUBTITLE',
436
+ 'HEADING_1', 'HEADING_2', 'HEADING_3', 'HEADING_4', 'HEADING_5', 'HEADING_6',
437
+ ]).optional(),
438
+ alignment: z.enum(['START', 'CENTER', 'END', 'JUSTIFIED']).optional(),
439
+ lineSpacing: z.number().optional().describe('Line spacing as percent (100 = single-space)'),
440
+ spaceAbove: z.number().optional().describe('Space above paragraph, in points'),
441
+ spaceBelow: z.number().optional().describe('Space below paragraph, in points'),
442
+ indentStart: z.number().optional().describe('Start indent in points'),
443
+ indentEnd: z.number().optional().describe('End indent in points'),
444
+ indentFirstLine: z.number().optional().describe('First-line indent in points'),
445
+ direction: z.enum(['LEFT_TO_RIGHT', 'RIGHT_TO_LEFT']).optional(),
446
+ keepLinesTogether: z.boolean().optional(),
447
+ keepWithNext: z.boolean().optional(),
448
+ avoidWidowAndOrphan: z.boolean().optional(),
449
+ },
450
+ }, async ({ account, documentId, startIndex, endIndex, ...style }) => {
451
+ try {
452
+ const auth = await getClient(account);
453
+ const docs = google.docs({ version: 'v1', auth });
454
+ const built = buildParagraphStyle(style);
455
+ if (built.fields.length === 0) {
456
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'No paragraph style properties supplied' }) }], isError: true };
457
+ }
458
+ await docs.documents.batchUpdate({
459
+ documentId,
460
+ requestBody: {
461
+ requests: [{
462
+ updateParagraphStyle: {
463
+ range: { startIndex, endIndex },
464
+ paragraphStyle: built.paragraphStyle,
465
+ fields: built.fields,
466
+ },
467
+ }],
468
+ },
469
+ });
470
+ return {
471
+ content: [{ type: 'text', text: JSON.stringify({ styled: true, range: { startIndex, endIndex }, applied: built.fields }, null, 2) }],
472
+ };
473
+ }
474
+ catch (error) {
475
+ return handleDocsError(error, account);
476
+ }
477
+ });
478
+ server.registerTool('docs_update_document_style', {
479
+ description: 'Update document-level styling (page size, margins, headers/footers behavior). Only supplied fields are applied.',
480
+ inputSchema: {
481
+ account: accountEnum.describe('Google account alias'),
482
+ documentId: z.string().describe('Google Docs document ID'),
483
+ pageWidth: z.number().optional().describe('Page width in points (e.g. 595 = A4)'),
484
+ pageHeight: z.number().optional().describe('Page height in points (e.g. 842 = A4)'),
485
+ marginTop: z.number().optional().describe('Margin in points'),
486
+ marginBottom: z.number().optional(),
487
+ marginLeft: z.number().optional(),
488
+ marginRight: z.number().optional(),
489
+ marginHeader: z.number().optional(),
490
+ marginFooter: z.number().optional(),
491
+ pageNumberStart: z.number().optional(),
492
+ useFirstPageHeaderFooter: z.boolean().optional(),
493
+ useEvenPageHeaderFooter: z.boolean().optional(),
494
+ useCustomHeaderFooterMargins: z.boolean().optional(),
495
+ },
496
+ }, async ({ account, documentId, ...style }) => {
497
+ try {
498
+ const auth = await getClient(account);
499
+ const docs = google.docs({ version: 'v1', auth });
500
+ const built = buildDocumentStyle(style);
501
+ if (built.fields.length === 0) {
502
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'No document style properties supplied' }) }], isError: true };
503
+ }
504
+ await docs.documents.batchUpdate({
505
+ documentId,
506
+ requestBody: {
507
+ requests: [{
508
+ updateDocumentStyle: {
509
+ documentStyle: built.documentStyle,
510
+ fields: built.fields,
511
+ },
512
+ }],
513
+ },
514
+ });
515
+ return {
516
+ content: [{ type: 'text', text: JSON.stringify({ styled: true, applied: built.fields }, null, 2) }],
517
+ };
518
+ }
519
+ catch (error) {
520
+ return handleDocsError(error, account);
521
+ }
522
+ });
523
+ // ─── Bullets ───────────────────────────────────────────────────────────
524
+ server.registerTool('docs_create_paragraph_bullets', {
525
+ description: 'Turn paragraphs in a range into a bulleted or numbered list',
526
+ inputSchema: {
527
+ account: accountEnum.describe('Google account alias'),
528
+ documentId: z.string().describe('Google Docs document ID'),
529
+ startIndex: z.number().min(1),
530
+ endIndex: z.number().min(2),
531
+ bulletPreset: z.enum([
532
+ 'BULLET_DISC_CIRCLE_SQUARE',
533
+ 'BULLET_DIAMONDX_ARROW3D_SQUARE',
534
+ 'BULLET_CHECKBOX',
535
+ 'BULLET_ARROW_DIAMOND_DISC',
536
+ 'BULLET_STAR_CIRCLE_SQUARE',
537
+ 'BULLET_ARROW3D_CIRCLE_SQUARE',
538
+ 'BULLET_LEFTTRIANGLE_DIAMOND_DISC',
539
+ 'BULLET_DIAMONDX_HOLLOWDIAMOND_SQUARE',
540
+ 'BULLET_DIAMOND_CIRCLE_SQUARE',
541
+ 'NUMBERED_DECIMAL_ALPHA_ROMAN',
542
+ 'NUMBERED_DECIMAL_ALPHA_ROMAN_PARENS',
543
+ 'NUMBERED_DECIMAL_NESTED',
544
+ 'NUMBERED_UPPERALPHA_ALPHA_ROMAN',
545
+ 'NUMBERED_UPPERROMAN_UPPERALPHA_DECIMAL',
546
+ 'NUMBERED_ZERODECIMAL_ALPHA_ROMAN',
547
+ ]).describe('Bullet/numbering preset'),
548
+ },
549
+ }, async ({ account, documentId, startIndex, endIndex, bulletPreset }) => {
550
+ try {
551
+ const auth = await getClient(account);
552
+ const docs = google.docs({ version: 'v1', auth });
553
+ await docs.documents.batchUpdate({
554
+ documentId,
555
+ requestBody: {
556
+ requests: [{
557
+ createParagraphBullets: {
558
+ range: { startIndex, endIndex },
559
+ bulletPreset,
560
+ },
561
+ }],
562
+ },
563
+ });
564
+ return {
565
+ content: [{ type: 'text', text: JSON.stringify({ bulleted: true }, null, 2) }],
566
+ };
567
+ }
568
+ catch (error) {
569
+ return handleDocsError(error, account);
570
+ }
571
+ });
572
+ server.registerTool('docs_delete_paragraph_bullets', {
573
+ description: 'Strip bullet/number formatting from paragraphs in a range',
574
+ inputSchema: {
575
+ account: accountEnum.describe('Google account alias'),
576
+ documentId: z.string().describe('Google Docs document ID'),
577
+ startIndex: z.number().min(1),
578
+ endIndex: z.number().min(2),
579
+ },
580
+ }, async ({ account, documentId, startIndex, endIndex }) => {
581
+ try {
582
+ const auth = await getClient(account);
583
+ const docs = google.docs({ version: 'v1', auth });
584
+ await docs.documents.batchUpdate({
585
+ documentId,
586
+ requestBody: {
587
+ requests: [{
588
+ deleteParagraphBullets: {
589
+ range: { startIndex, endIndex },
590
+ },
591
+ }],
592
+ },
593
+ });
594
+ return {
595
+ content: [{ type: 'text', text: JSON.stringify({ unbulleted: true }, null, 2) }],
596
+ };
597
+ }
598
+ catch (error) {
599
+ return handleDocsError(error, account);
600
+ }
601
+ });
602
+ // ─── Inline images ─────────────────────────────────────────────────────
603
+ server.registerTool('docs_insert_inline_image', {
604
+ description: 'Insert a publicly-accessible image (PNG/JPEG/GIF, <50MB, <25MP, URL <2KB) inline at an index or at the document end',
605
+ inputSchema: {
606
+ account: accountEnum.describe('Google account alias'),
607
+ documentId: z.string().describe('Google Docs document ID'),
608
+ uri: z.string().url().describe('Public image URL'),
609
+ index: z.number().min(1).optional().describe('Insert position; omit to append at the end'),
610
+ width: z.number().optional().describe('Width in points (omit for natural size)'),
611
+ height: z.number().optional().describe('Height in points'),
612
+ },
613
+ }, async ({ account, documentId, uri, index, width, height }) => {
614
+ try {
615
+ const auth = await getClient(account);
616
+ const docs = google.docs({ version: 'v1', auth });
617
+ const request = { uri };
618
+ if (index !== undefined)
619
+ request.location = { index };
620
+ else
621
+ request.endOfSegmentLocation = { segmentId: '' };
622
+ if (width !== undefined || height !== undefined) {
623
+ request.objectSize = {};
624
+ if (width !== undefined)
625
+ request.objectSize.width = { magnitude: width, unit: 'PT' };
626
+ if (height !== undefined)
627
+ request.objectSize.height = { magnitude: height, unit: 'PT' };
628
+ }
629
+ await docs.documents.batchUpdate({
630
+ documentId,
631
+ requestBody: { requests: [{ insertInlineImage: request }] },
632
+ });
633
+ return {
634
+ content: [{ type: 'text', text: JSON.stringify({ inserted: true, at: index ?? 'end' }, null, 2) }],
635
+ };
636
+ }
637
+ catch (error) {
638
+ return handleDocsError(error, account);
639
+ }
640
+ });
641
+ // ─── Breaks ────────────────────────────────────────────────────────────
642
+ server.registerTool('docs_insert_page_break', {
643
+ description: 'Insert a page break at a position or at the document end',
644
+ inputSchema: {
645
+ account: accountEnum.describe('Google account alias'),
646
+ documentId: z.string().describe('Google Docs document ID'),
647
+ index: z.number().min(1).optional().describe('Insert position; omit to append'),
648
+ },
649
+ }, async ({ account, documentId, index }) => {
650
+ try {
651
+ const auth = await getClient(account);
652
+ const docs = google.docs({ version: 'v1', auth });
653
+ const request = {};
654
+ if (index !== undefined)
655
+ request.location = { index };
656
+ else
657
+ request.endOfSegmentLocation = { segmentId: '' };
658
+ await docs.documents.batchUpdate({
659
+ documentId,
660
+ requestBody: { requests: [{ insertPageBreak: request }] },
661
+ });
662
+ return {
663
+ content: [{ type: 'text', text: JSON.stringify({ inserted: true, at: index ?? 'end' }, null, 2) }],
664
+ };
665
+ }
666
+ catch (error) {
667
+ return handleDocsError(error, account);
668
+ }
669
+ });
670
+ server.registerTool('docs_insert_section_break', {
671
+ description: 'Insert a section break at a position. CONTINUOUS keeps the same page; NEXT_PAGE starts a new page.',
672
+ inputSchema: {
673
+ account: accountEnum.describe('Google account alias'),
674
+ documentId: z.string().describe('Google Docs document ID'),
675
+ index: z.number().min(1).optional(),
676
+ sectionType: z.enum(['CONTINUOUS', 'NEXT_PAGE']).default('NEXT_PAGE').optional(),
677
+ },
678
+ }, async ({ account, documentId, index, sectionType }) => {
679
+ try {
680
+ const auth = await getClient(account);
681
+ const docs = google.docs({ version: 'v1', auth });
682
+ const request = { sectionType: sectionType ?? 'NEXT_PAGE' };
683
+ if (index !== undefined)
684
+ request.location = { index };
685
+ else
686
+ request.endOfSegmentLocation = { segmentId: '' };
687
+ await docs.documents.batchUpdate({
688
+ documentId,
689
+ requestBody: { requests: [{ insertSectionBreak: request }] },
690
+ });
691
+ return {
692
+ content: [{ type: 'text', text: JSON.stringify({ inserted: true, at: index ?? 'end' }, null, 2) }],
693
+ };
694
+ }
695
+ catch (error) {
696
+ return handleDocsError(error, account);
697
+ }
698
+ });
699
+ // ─── Headers / footers ────────────────────────────────────────────────
700
+ server.registerTool('docs_create_header', {
701
+ description: 'Create a header for the document or for a specific section. The new header is empty; insert text into it with docs_insert_text targeting its segmentId.',
702
+ inputSchema: {
703
+ account: accountEnum.describe('Google account alias'),
704
+ documentId: z.string().describe('Google Docs document ID'),
705
+ sectionBreakIndex: z.number().min(1).optional().describe('Anchor to a specific section break; omit to apply to the document'),
706
+ },
707
+ }, async ({ account, documentId, sectionBreakIndex }) => {
708
+ try {
709
+ const auth = await getClient(account);
710
+ const docs = google.docs({ version: 'v1', auth });
711
+ const request = { type: 'DEFAULT' };
712
+ if (sectionBreakIndex !== undefined)
713
+ request.sectionBreakLocation = { index: sectionBreakIndex };
714
+ const res = await docs.documents.batchUpdate({
715
+ documentId,
716
+ requestBody: { requests: [{ createHeader: request }] },
717
+ });
718
+ const reply = res.data.replies?.[0]?.createHeader;
719
+ return {
720
+ content: [{ type: 'text', text: JSON.stringify(reply ?? {}, null, 2) }],
721
+ };
722
+ }
723
+ catch (error) {
724
+ return handleDocsError(error, account);
725
+ }
726
+ });
727
+ server.registerTool('docs_delete_header', {
728
+ description: 'Delete a header by its headerId',
729
+ inputSchema: {
730
+ account: accountEnum.describe('Google account alias'),
731
+ documentId: z.string().describe('Google Docs document ID'),
732
+ headerId: z.string().describe('Header ID (from docs_get or createHeader reply)'),
733
+ },
734
+ }, async ({ account, documentId, headerId }) => {
735
+ try {
736
+ const auth = await getClient(account);
737
+ const docs = google.docs({ version: 'v1', auth });
738
+ await docs.documents.batchUpdate({
739
+ documentId,
740
+ requestBody: { requests: [{ deleteHeader: { headerId } }] },
741
+ });
742
+ return {
743
+ content: [{ type: 'text', text: JSON.stringify({ deleted: true, headerId }, null, 2) }],
744
+ };
745
+ }
746
+ catch (error) {
747
+ return handleDocsError(error, account);
748
+ }
749
+ });
750
+ server.registerTool('docs_create_footer', {
751
+ description: 'Create a footer for the document or a specific section',
752
+ inputSchema: {
753
+ account: accountEnum.describe('Google account alias'),
754
+ documentId: z.string().describe('Google Docs document ID'),
755
+ sectionBreakIndex: z.number().min(1).optional(),
756
+ },
757
+ }, async ({ account, documentId, sectionBreakIndex }) => {
758
+ try {
759
+ const auth = await getClient(account);
760
+ const docs = google.docs({ version: 'v1', auth });
761
+ const request = { type: 'DEFAULT' };
762
+ if (sectionBreakIndex !== undefined)
763
+ request.sectionBreakLocation = { index: sectionBreakIndex };
764
+ const res = await docs.documents.batchUpdate({
765
+ documentId,
766
+ requestBody: { requests: [{ createFooter: request }] },
767
+ });
768
+ const reply = res.data.replies?.[0]?.createFooter;
769
+ return {
770
+ content: [{ type: 'text', text: JSON.stringify(reply ?? {}, null, 2) }],
771
+ };
772
+ }
773
+ catch (error) {
774
+ return handleDocsError(error, account);
775
+ }
776
+ });
777
+ server.registerTool('docs_delete_footer', {
778
+ description: 'Delete a footer by its footerId',
779
+ inputSchema: {
780
+ account: accountEnum.describe('Google account alias'),
781
+ documentId: z.string().describe('Google Docs document ID'),
782
+ footerId: z.string().describe('Footer ID'),
783
+ },
784
+ }, async ({ account, documentId, footerId }) => {
785
+ try {
786
+ const auth = await getClient(account);
787
+ const docs = google.docs({ version: 'v1', auth });
788
+ await docs.documents.batchUpdate({
789
+ documentId,
790
+ requestBody: { requests: [{ deleteFooter: { footerId } }] },
791
+ });
792
+ return {
793
+ content: [{ type: 'text', text: JSON.stringify({ deleted: true, footerId }, null, 2) }],
794
+ };
795
+ }
796
+ catch (error) {
797
+ return handleDocsError(error, account);
798
+ }
799
+ });
800
+ // ─── Table mutations (one tool with operation discriminator) ───────────
801
+ server.registerTool('docs_modify_table', {
802
+ description: 'Mutate a table by operation: insertRow, insertColumn, deleteRow, deleteColumn, mergeCells, unmergeCells. Locate the cell with tableStartIndex (the table\'s start index in the doc) plus rowIndex/columnIndex (0-based).',
803
+ inputSchema: {
804
+ account: accountEnum.describe('Google account alias'),
805
+ documentId: z.string().describe('Google Docs document ID'),
806
+ operation: z.enum(['insertRow', 'insertColumn', 'deleteRow', 'deleteColumn', 'mergeCells', 'unmergeCells']),
807
+ tableStartIndex: z.number().min(1).describe('Start index of the table in the document'),
808
+ rowIndex: z.number().min(0).describe('Target row (0-based)'),
809
+ columnIndex: z.number().min(0).describe('Target column (0-based)'),
810
+ insertBelow: z.boolean().optional().describe('insertRow only: insert below the target row (default: false)'),
811
+ insertRight: z.boolean().optional().describe('insertColumn only: insert right of the target column (default: false)'),
812
+ rowSpan: z.number().min(1).optional().describe('mergeCells only: how many rows the merged cell spans (default 1)'),
813
+ columnSpan: z.number().min(1).optional().describe('mergeCells/unmergeCells: column span (default 1)'),
814
+ },
815
+ }, async ({ account, documentId, operation, tableStartIndex, rowIndex, columnIndex, insertBelow, insertRight, rowSpan, columnSpan }) => {
816
+ try {
817
+ const auth = await getClient(account);
818
+ const docs = google.docs({ version: 'v1', auth });
819
+ const cellLoc = {
820
+ tableStartLocation: { index: tableStartIndex },
821
+ rowIndex,
822
+ columnIndex,
823
+ };
824
+ let request;
825
+ switch (operation) {
826
+ case 'insertRow':
827
+ request = { insertTableRow: { tableCellLocation: cellLoc, insertBelow: insertBelow ?? false } };
828
+ break;
829
+ case 'insertColumn':
830
+ request = { insertTableColumn: { tableCellLocation: cellLoc, insertRight: insertRight ?? false } };
831
+ break;
832
+ case 'deleteRow':
833
+ request = { deleteTableRow: { tableCellLocation: cellLoc } };
834
+ break;
835
+ case 'deleteColumn':
836
+ request = { deleteTableColumn: { tableCellLocation: cellLoc } };
837
+ break;
838
+ case 'mergeCells':
839
+ request = {
840
+ mergeTableCells: {
841
+ tableRange: {
842
+ tableCellLocation: cellLoc,
843
+ rowSpan: rowSpan ?? 1,
844
+ columnSpan: columnSpan ?? 1,
845
+ },
846
+ },
847
+ };
848
+ break;
849
+ case 'unmergeCells':
850
+ request = {
851
+ unmergeTableCells: {
852
+ tableRange: {
853
+ tableCellLocation: cellLoc,
854
+ rowSpan: rowSpan ?? 1,
855
+ columnSpan: columnSpan ?? 1,
856
+ },
857
+ },
858
+ };
859
+ break;
860
+ }
861
+ await docs.documents.batchUpdate({
862
+ documentId,
863
+ requestBody: { requests: [request] },
864
+ });
865
+ return {
866
+ content: [{ type: 'text', text: JSON.stringify({ operation, completed: true }, null, 2) }],
867
+ };
868
+ }
869
+ catch (error) {
870
+ return handleDocsError(error, account);
871
+ }
872
+ });
873
+ // ─── Tabs ──────────────────────────────────────────────────────────────
874
+ server.registerTool('docs_add_tab', {
875
+ description: 'Add a new tab to a document. Can be a top-level tab or nested as a child of an existing tab.',
876
+ inputSchema: {
877
+ account: accountEnum.describe('Google account alias'),
878
+ documentId: z.string().describe('Google Docs document ID'),
879
+ title: z.string().describe('Tab title'),
880
+ parentTabId: z.string().optional().describe('Parent tab ID (omit for top-level)'),
881
+ index: z.number().min(0).optional().describe('Position among siblings'),
882
+ },
883
+ }, async ({ account, documentId, title, parentTabId, index }) => {
884
+ try {
885
+ const auth = await getClient(account);
886
+ const docs = google.docs({ version: 'v1', auth });
887
+ const tabProperties = { title };
888
+ if (parentTabId)
889
+ tabProperties.parentTabId = parentTabId;
890
+ if (index !== undefined)
891
+ tabProperties.index = index;
892
+ const res = await docs.documents.batchUpdate({
893
+ documentId,
894
+ requestBody: { requests: [{ addDocumentTab: { tabProperties } }] },
895
+ });
896
+ const reply = res.data.replies?.[0]?.addDocumentTab;
897
+ return {
898
+ content: [{ type: 'text', text: JSON.stringify(reply ?? {}, null, 2) }],
899
+ };
900
+ }
901
+ catch (error) {
902
+ return handleDocsError(error, account);
903
+ }
904
+ });
905
+ server.registerTool('docs_delete_tab', {
906
+ description: 'Delete a tab and all of its descendant tabs',
907
+ inputSchema: {
908
+ account: accountEnum.describe('Google account alias'),
909
+ documentId: z.string().describe('Google Docs document ID'),
910
+ tabId: z.string().describe('Tab ID'),
911
+ },
912
+ }, async ({ account, documentId, tabId }) => {
913
+ try {
914
+ const auth = await getClient(account);
915
+ const docs = google.docs({ version: 'v1', auth });
916
+ await docs.documents.batchUpdate({
917
+ documentId,
918
+ requestBody: { requests: [{ deleteTab: { tabId } }] },
919
+ });
920
+ return {
921
+ content: [{ type: 'text', text: JSON.stringify({ deleted: true, tabId }, null, 2) }],
922
+ };
923
+ }
924
+ catch (error) {
925
+ return handleDocsError(error, account);
926
+ }
927
+ });
928
+ server.registerTool('docs_update_tab_properties', {
929
+ description: 'Rename a tab, change its position, or move it under a different parent',
930
+ inputSchema: {
931
+ account: accountEnum.describe('Google account alias'),
932
+ documentId: z.string().describe('Google Docs document ID'),
933
+ tabId: z.string().describe('Tab ID'),
934
+ title: z.string().optional(),
935
+ index: z.number().min(0).optional(),
936
+ parentTabId: z.string().optional(),
937
+ },
938
+ }, async ({ account, documentId, tabId, title, index, parentTabId }) => {
939
+ try {
940
+ const auth = await getClient(account);
941
+ const docs = google.docs({ version: 'v1', auth });
942
+ const tabProperties = { tabId };
943
+ const fields = [];
944
+ if (title !== undefined) {
945
+ tabProperties.title = title;
946
+ fields.push('title');
947
+ }
948
+ if (index !== undefined) {
949
+ tabProperties.index = index;
950
+ fields.push('index');
951
+ }
952
+ if (parentTabId !== undefined) {
953
+ tabProperties.parentTabId = parentTabId;
954
+ fields.push('parentTabId');
955
+ }
956
+ if (fields.length === 0) {
957
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'No tab property supplied' }) }], isError: true };
958
+ }
959
+ await docs.documents.batchUpdate({
960
+ documentId,
961
+ requestBody: {
962
+ requests: [{
963
+ updateDocumentTabProperties: {
964
+ tabProperties,
965
+ fields: fields.join(','),
966
+ },
967
+ }],
968
+ },
969
+ });
970
+ return {
971
+ content: [{ type: 'text', text: JSON.stringify({ updated: true, tabId, applied: fields }, null, 2) }],
972
+ };
973
+ }
974
+ catch (error) {
975
+ return handleDocsError(error, account);
976
+ }
977
+ });
978
+ // ─── Batch update escape hatch ─────────────────────────────────────────
979
+ server.registerTool('docs_batch_update', {
980
+ description: 'Generic documents.batchUpdate pass-through. Accepts the full Request union (40 types). See https://developers.google.com/workspace/docs/api/reference/rest/v1/documents/request',
981
+ inputSchema: {
982
+ account: accountEnum.describe('Google account alias'),
983
+ documentId: z.string().describe('Google Docs document ID'),
984
+ requests: z.array(z.record(z.string(), z.any())).describe('Array of Request objects, each with one request-type key'),
985
+ writeControl: z.object({
986
+ requiredRevisionId: z.string().optional(),
987
+ targetRevisionId: z.string().optional(),
988
+ }).optional().describe('Optional optimistic concurrency control'),
989
+ },
990
+ }, async ({ account, documentId, requests, writeControl }) => {
991
+ try {
992
+ const auth = await getClient(account);
993
+ const docs = google.docs({ version: 'v1', auth });
994
+ const res = await docs.documents.batchUpdate({
995
+ documentId,
996
+ requestBody: {
997
+ requests: requests,
998
+ writeControl,
999
+ },
1000
+ });
1001
+ return {
1002
+ content: [{ type: 'text', text: JSON.stringify({
1003
+ documentId: res.data.documentId,
1004
+ replies: res.data.replies ?? [],
1005
+ }, null, 2) }],
1006
+ };
1007
+ }
1008
+ catch (error) {
1009
+ return handleDocsError(error, account);
1010
+ }
1011
+ });
1012
+ }
1013
+ // ─── Helpers (exported for unit tests) ───────────────────────────────────
1014
+ export function buildParagraphStyle(input) {
1015
+ const ps = {};
1016
+ const fields = [];
1017
+ if (input.namedStyleType) {
1018
+ ps.namedStyleType = input.namedStyleType;
1019
+ fields.push('namedStyleType');
1020
+ }
1021
+ if (input.alignment) {
1022
+ ps.alignment = input.alignment;
1023
+ fields.push('alignment');
1024
+ }
1025
+ if (input.lineSpacing !== undefined) {
1026
+ ps.lineSpacing = input.lineSpacing;
1027
+ fields.push('lineSpacing');
1028
+ }
1029
+ if (input.spaceAbove !== undefined) {
1030
+ ps.spaceAbove = { magnitude: input.spaceAbove, unit: 'PT' };
1031
+ fields.push('spaceAbove');
1032
+ }
1033
+ if (input.spaceBelow !== undefined) {
1034
+ ps.spaceBelow = { magnitude: input.spaceBelow, unit: 'PT' };
1035
+ fields.push('spaceBelow');
1036
+ }
1037
+ if (input.indentStart !== undefined) {
1038
+ ps.indentStart = { magnitude: input.indentStart, unit: 'PT' };
1039
+ fields.push('indentStart');
1040
+ }
1041
+ if (input.indentEnd !== undefined) {
1042
+ ps.indentEnd = { magnitude: input.indentEnd, unit: 'PT' };
1043
+ fields.push('indentEnd');
1044
+ }
1045
+ if (input.indentFirstLine !== undefined) {
1046
+ ps.indentFirstLine = { magnitude: input.indentFirstLine, unit: 'PT' };
1047
+ fields.push('indentFirstLine');
1048
+ }
1049
+ if (input.direction) {
1050
+ ps.direction = input.direction;
1051
+ fields.push('direction');
1052
+ }
1053
+ if (input.keepLinesTogether !== undefined) {
1054
+ ps.keepLinesTogether = input.keepLinesTogether;
1055
+ fields.push('keepLinesTogether');
1056
+ }
1057
+ if (input.keepWithNext !== undefined) {
1058
+ ps.keepWithNext = input.keepWithNext;
1059
+ fields.push('keepWithNext');
1060
+ }
1061
+ if (input.avoidWidowAndOrphan !== undefined) {
1062
+ ps.avoidWidowAndOrphan = input.avoidWidowAndOrphan;
1063
+ fields.push('avoidWidowAndOrphan');
1064
+ }
1065
+ return { paragraphStyle: ps, fields: fields.join(',') };
1066
+ }
1067
+ export function buildDocumentStyle(input) {
1068
+ const ds = {};
1069
+ const fields = [];
1070
+ const dim = (m) => ({ magnitude: m, unit: 'PT' });
1071
+ if (input.pageWidth !== undefined || input.pageHeight !== undefined) {
1072
+ ds.pageSize = {};
1073
+ if (input.pageWidth !== undefined)
1074
+ ds.pageSize.width = dim(input.pageWidth);
1075
+ if (input.pageHeight !== undefined)
1076
+ ds.pageSize.height = dim(input.pageHeight);
1077
+ fields.push('pageSize');
1078
+ }
1079
+ if (input.marginTop !== undefined) {
1080
+ ds.marginTop = dim(input.marginTop);
1081
+ fields.push('marginTop');
1082
+ }
1083
+ if (input.marginBottom !== undefined) {
1084
+ ds.marginBottom = dim(input.marginBottom);
1085
+ fields.push('marginBottom');
1086
+ }
1087
+ if (input.marginLeft !== undefined) {
1088
+ ds.marginLeft = dim(input.marginLeft);
1089
+ fields.push('marginLeft');
1090
+ }
1091
+ if (input.marginRight !== undefined) {
1092
+ ds.marginRight = dim(input.marginRight);
1093
+ fields.push('marginRight');
1094
+ }
1095
+ if (input.marginHeader !== undefined) {
1096
+ ds.marginHeader = dim(input.marginHeader);
1097
+ fields.push('marginHeader');
1098
+ }
1099
+ if (input.marginFooter !== undefined) {
1100
+ ds.marginFooter = dim(input.marginFooter);
1101
+ fields.push('marginFooter');
1102
+ }
1103
+ if (input.pageNumberStart !== undefined) {
1104
+ ds.pageNumberStart = input.pageNumberStart;
1105
+ fields.push('pageNumberStart');
1106
+ }
1107
+ if (input.useFirstPageHeaderFooter !== undefined) {
1108
+ ds.useFirstPageHeaderFooter = input.useFirstPageHeaderFooter;
1109
+ fields.push('useFirstPageHeaderFooter');
1110
+ }
1111
+ if (input.useEvenPageHeaderFooter !== undefined) {
1112
+ ds.useEvenPageHeaderFooter = input.useEvenPageHeaderFooter;
1113
+ fields.push('useEvenPageHeaderFooter');
1114
+ }
1115
+ if (input.useCustomHeaderFooterMargins !== undefined) {
1116
+ ds.useCustomHeaderFooterMargins = input.useCustomHeaderFooterMargins;
1117
+ fields.push('useCustomHeaderFooterMargins');
1118
+ }
1119
+ return { documentStyle: ds, fields: fields.join(',') };
1120
+ }
1121
+ function handleDocsError(error, account) {
1122
+ return handleGoogleApiError(error, account);
1123
+ }