google-tools-mcp 1.2.11 → 1.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -109,6 +109,24 @@ Add the credentials directly to your MCP configuration:
109
109
 
110
110
  ### Step 3: Add to Your MCP Client
111
111
 
112
+ #### Local unpublished checkout
113
+
114
+ To call a local tool directly without publishing or using MCP transport, use
115
+ `local:tool`. This loads the unpublished checkout, registers the real tools in
116
+ memory, validates the arguments, and calls the tool's `execute` function:
117
+
118
+ ```bash
119
+ npm run local:tool -- list
120
+ npm run local:tool -- help
121
+ npm run local:tool -- readDocument documentId=... format=markdown
122
+ ```
123
+
124
+ For larger arguments, put JSON in a file and pass it with `@`:
125
+
126
+ ```bash
127
+ npm run local:tool -- replaceDocumentWithMarkdown @args.json
128
+ ```
129
+
112
130
  #### Codex
113
131
 
114
132
  ```bash
@@ -15,34 +15,43 @@ const CODE_FONT_FAMILIES = new Set(['Roboto Mono', 'Courier New', 'Consolas', 'm
15
15
  * underline, links, code), ordered & unordered lists with nesting, tables,
16
16
  * and section breaks.
17
17
  */
18
- export function docsJsonToMarkdown(docData) {
18
+ export function docsJsonToMarkdown(docData, options = {}) {
19
19
  const body = docData.body;
20
20
  if (!body?.content) {
21
21
  return '';
22
22
  }
23
23
  const lists = docData.lists ?? {};
24
+ const conversionOptions = {
25
+ richMarkdown: options.plainMarkdown ? false : options.richMarkdown ?? true,
26
+ };
24
27
  let markdown = '';
25
28
  for (const element of body.content) {
26
29
  if (element.paragraph) {
27
- markdown += convertParagraph(element.paragraph, lists);
30
+ markdown += convertParagraph(element.paragraph, lists, conversionOptions);
28
31
  }
29
32
  else if (element.table) {
30
- markdown += convertTable(element.table);
33
+ markdown += convertTable(element.table, conversionOptions);
31
34
  }
32
35
  else if (element.sectionBreak) {
36
+ if (isInitialDocumentSectionBreak(element)) {
37
+ continue;
38
+ }
33
39
  markdown += '\n---\n\n';
34
40
  }
35
41
  }
36
42
  return markdown.trim();
37
43
  }
44
+ function isInitialDocumentSectionBreak(element) {
45
+ return element.endIndex === 1 && element.startIndex === undefined;
46
+ }
38
47
  // --- Paragraph Conversion ---
39
- function convertParagraph(paragraph, lists) {
48
+ function convertParagraph(paragraph, lists, options) {
40
49
  // 1. Determine paragraph type
41
50
  const headingLevel = getHeadingLevel(paragraph);
42
51
  const listInfo = getListInfo(paragraph, lists);
43
52
  // 2. Extract text content with inline formatting
44
53
  const elements = paragraph.elements ?? [];
45
- const text = extractFormattedText(elements);
54
+ const text = extractFormattedText(elements, options);
46
55
  // 3. Format based on type
47
56
  if (headingLevel && text.trim()) {
48
57
  const hashes = '#'.repeat(Math.min(headingLevel, 6));
@@ -54,7 +63,15 @@ function convertParagraph(paragraph, lists) {
54
63
  return `${indent}${marker} ${text.trim()}\n`;
55
64
  }
56
65
  if (text.trim()) {
57
- return `${text.trim()}\n\n`;
66
+ const trimmed = text.trim();
67
+ if (options.richMarkdown && isBlockquoteParagraph(paragraph)) {
68
+ return `<blockquote>${trimmed}</blockquote>\n\n`;
69
+ }
70
+ const alignment = paragraphAlignmentToHtml(paragraph.paragraphStyle?.alignment);
71
+ if (options.richMarkdown && alignment) {
72
+ return `<p align="${alignment}">${trimmed}</p>\n\n`;
73
+ }
74
+ return `${trimmed}\n\n`;
58
75
  }
59
76
  return '\n';
60
77
  }
@@ -91,16 +108,16 @@ function getListInfo(paragraph, lists) {
91
108
  return { ordered, nestingLevel };
92
109
  }
93
110
  // --- Text Run Conversion ---
94
- function extractFormattedText(elements) {
111
+ function extractFormattedText(elements, options) {
95
112
  let result = '';
96
113
  for (const element of elements) {
97
114
  if (element.textRun) {
98
- result += convertTextRun(element.textRun);
115
+ result += convertTextRun(element.textRun, options);
99
116
  }
100
117
  }
101
118
  return result;
102
119
  }
103
- function convertTextRun(textRun) {
120
+ function convertTextRun(textRun, options) {
104
121
  let text = textRun.content ?? '';
105
122
  const style = textRun.textStyle;
106
123
  if (!style)
@@ -134,20 +151,72 @@ function convertTextRun(textRun) {
134
151
  if (style.strikethrough) {
135
152
  formatted = `~~${formatted}~~`;
136
153
  }
137
- if (style.underline && !style.link) {
154
+ if (options.richMarkdown && style.underline && !style.link) {
138
155
  formatted = `<u>${formatted}</u>`;
139
156
  }
157
+ if (options.richMarkdown) {
158
+ formatted = applyRichTextStyle(formatted, style);
159
+ }
140
160
  if (style.link?.url) {
141
161
  formatted = `[${formatted}](${style.link.url})`;
142
162
  }
143
163
  return formatted + (trailingNewline ? '\n' : '');
144
164
  }
165
+ function applyRichTextStyle(text, style) {
166
+ const styles = [];
167
+ const fg = rgbColorToHex(style.foregroundColor?.color?.rgbColor);
168
+ const bg = rgbColorToHex(style.backgroundColor?.color?.rgbColor);
169
+ const fontSize = style.fontSize?.magnitude;
170
+ const fontFamily = style.weightedFontFamily?.fontFamily;
171
+ if (fg)
172
+ styles.push(`color:${fg}`);
173
+ if (bg)
174
+ styles.push(`background-color:${bg}`);
175
+ if (typeof fontSize === 'number')
176
+ styles.push(`font-size:${fontSize}pt`);
177
+ if (fontFamily && !CODE_FONT_FAMILIES.has(fontFamily))
178
+ styles.push(`font-family:${escapeHtmlAttr(fontFamily)}`);
179
+ if (styles.length === 0)
180
+ return text;
181
+ return `<span style="${styles.join(';')}">${text}</span>`;
182
+ }
145
183
  function isCodeStyled(style) {
146
184
  const fontFamily = style.weightedFontFamily?.fontFamily;
147
185
  return typeof fontFamily === 'string' && CODE_FONT_FAMILIES.has(fontFamily);
148
186
  }
187
+ function isBlockquoteParagraph(paragraph) {
188
+ const style = paragraph.paragraphStyle;
189
+ return Boolean(style?.borderLeft ||
190
+ style?.indentStart?.magnitude >= 30 ||
191
+ style?.indentFirstLine?.magnitude >= 30);
192
+ }
193
+ function paragraphAlignmentToHtml(alignment) {
194
+ switch (alignment) {
195
+ case 'CENTER':
196
+ return 'center';
197
+ case 'END':
198
+ case 'RIGHT':
199
+ return 'right';
200
+ case 'JUSTIFIED':
201
+ return 'justify';
202
+ default:
203
+ return null;
204
+ }
205
+ }
206
+ function rgbColorToHex(rgb) {
207
+ if (!rgb)
208
+ return null;
209
+ const toHex = (value) => {
210
+ const normalized = Math.max(0, Math.min(255, Math.round((value ?? 0) * 255)));
211
+ return normalized.toString(16).padStart(2, '0');
212
+ };
213
+ return `#${toHex(rgb.red)}${toHex(rgb.green)}${toHex(rgb.blue)}`;
214
+ }
215
+ function escapeHtmlAttr(value) {
216
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
217
+ }
149
218
  // --- Table Conversion ---
150
- function convertTable(table) {
219
+ function convertTable(table, options) {
151
220
  if (!table.tableRows || table.tableRows.length === 0) {
152
221
  return '';
153
222
  }
@@ -162,15 +231,15 @@ function convertTable(table) {
162
231
  continue;
163
232
  let rowText = '|';
164
233
  for (const cell of row.tableCells) {
165
- const cellText = extractCellText(cell);
234
+ const cellText = extractCellText(cell, options);
166
235
  rowText += ` ${cellText} |`;
167
236
  }
168
237
  markdown += rowText + '\n';
169
238
  // Add header separator after the first row
170
239
  if (isFirstRow) {
171
240
  let separator = '|';
172
- for (let i = 0; i < row.tableCells.length; i++) {
173
- separator += ' --- |';
241
+ for (const cell of row.tableCells) {
242
+ separator += ` ${tableAlignmentMarker(cell)} |`;
174
243
  }
175
244
  markdown += separator + '\n';
176
245
  isFirstRow = false;
@@ -242,18 +311,22 @@ function convertCodeBlockTable(table) {
242
311
  }
243
312
  return '\n```\n' + codeText + '\n```\n\n';
244
313
  }
245
- function extractCellText(cell) {
314
+ function tableAlignmentMarker(cell) {
315
+ const alignment = paragraphAlignmentToHtml(cell.content?.[0]?.paragraph?.paragraphStyle?.alignment);
316
+ if (alignment === 'center')
317
+ return ':---:';
318
+ if (alignment === 'right')
319
+ return '---:';
320
+ return '---';
321
+ }
322
+ function extractCellText(cell, options) {
246
323
  let text = '';
247
324
  if (!cell.content)
248
325
  return text;
249
326
  for (const element of cell.content) {
250
327
  if (element.paragraph?.elements) {
251
- for (const pe of element.paragraph.elements) {
252
- if (pe.textRun?.content) {
253
- text += pe.textRun.content.replace(/\n/g, ' ').trim();
254
- }
255
- }
328
+ text += extractFormattedText(element.paragraph.elements, options).replace(/\n/g, ' ');
256
329
  }
257
330
  }
258
- return text;
331
+ return text.trim().replace(/\|/g, '\\|');
259
332
  }
@@ -61,12 +61,12 @@ export async function extractMarkdown(docs, documentId, options) {
61
61
  return docsJsonToMarkdown({
62
62
  body: targetTab.documentTab.body,
63
63
  lists: targetTab.documentTab.lists,
64
- });
64
+ }, options);
65
65
  }
66
66
  return docsJsonToMarkdown({
67
67
  body: res.data.body,
68
68
  lists: res.data.lists,
69
- });
69
+ }, options);
70
70
  }
71
71
  // --- insertMarkdown ---
72
72
  /**
@@ -4,7 +4,7 @@ import { MarkdownConversionError } from '../types.js';
4
4
  // --- Markdown-it Setup ---
5
5
  function createParser() {
6
6
  return new MarkdownIt({
7
- html: false,
7
+ html: true,
8
8
  linkify: true,
9
9
  typographer: false,
10
10
  breaks: false,
@@ -23,6 +23,79 @@ function getHeadingLevel(token) {
23
23
  const match = token.tag.match(/h(\d)/);
24
24
  return match ? parseInt(match[1], 10) : null;
25
25
  }
26
+ function getAttr(token, name) {
27
+ return token.attrs?.find((attr) => attr[0].toLowerCase() === name.toLowerCase())?.[1] ?? null;
28
+ }
29
+ function parseAlignmentFromStyle(style) {
30
+ const match = style?.match(/(?:^|;)\s*text-align\s*:\s*(left|center|right|justify)\s*(?:;|$)/i);
31
+ if (!match)
32
+ return null;
33
+ return alignmentToDocs(match[1]);
34
+ }
35
+ function alignmentToDocs(value) {
36
+ switch (value.toLowerCase()) {
37
+ case 'center':
38
+ return 'CENTER';
39
+ case 'right':
40
+ return 'END';
41
+ case 'justify':
42
+ case 'justified':
43
+ return 'JUSTIFIED';
44
+ case 'left':
45
+ return 'START';
46
+ default:
47
+ return null;
48
+ }
49
+ }
50
+ function parseStyleDeclarations(style) {
51
+ const formatting = {};
52
+ if (!style)
53
+ return formatting;
54
+ for (const declaration of style.split(';')) {
55
+ const [rawName, ...rawValueParts] = declaration.split(':');
56
+ if (!rawName || rawValueParts.length === 0)
57
+ continue;
58
+ const name = rawName.trim().toLowerCase();
59
+ const value = rawValueParts.join(':').trim();
60
+ if ((name === 'color' || name === 'foreground-color') && /^#[0-9a-f]{6}$/i.test(value)) {
61
+ formatting.foregroundColor = value;
62
+ }
63
+ else if ((name === 'background-color' || name === 'background') && /^#[0-9a-f]{6}$/i.test(value)) {
64
+ formatting.backgroundColor = value;
65
+ }
66
+ else if (name === 'font-size') {
67
+ const match = value.match(/^(\d+(?:\.\d+)?)pt$/i);
68
+ if (match)
69
+ formatting.fontSize = Number(match[1]);
70
+ }
71
+ else if (name === 'font-family') {
72
+ const family = value.split(',')[0]?.trim().replace(/^['"]|['"]$/g, '');
73
+ if (family && /^[\w .-]+$/.test(family))
74
+ formatting.fontFamily = family;
75
+ }
76
+ }
77
+ return formatting;
78
+ }
79
+ function parseRichHtmlTag(rawHtml) {
80
+ const trimmed = rawHtml.trim();
81
+ const match = trimmed.match(/^<\/?\s*([a-z0-9]+)([^>]*)\/?>$/i);
82
+ if (!match)
83
+ return null;
84
+ const tag = match[1].toLowerCase();
85
+ const attrs = match[2] ?? '';
86
+ const closing = /^<\s*\//.test(trimmed);
87
+ const selfClosing = /\/\s*>$/.test(trimmed);
88
+ return { tag, attrs, closing, selfClosing };
89
+ }
90
+ function parseAttrs(rawAttrs) {
91
+ const attrs = {};
92
+ const attrRegex = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
93
+ let match;
94
+ while ((match = attrRegex.exec(rawAttrs)) !== null) {
95
+ attrs[match[1].toLowerCase()] = match[2] ?? match[3] ?? match[4] ?? '';
96
+ }
97
+ return attrs;
98
+ }
26
99
  const CODE_FONT_FAMILY = 'Roboto Mono';
27
100
  const CODE_TEXT_HEX = '#188038';
28
101
  const CODE_BACKGROUND_HEX = '#F1F3F4';
@@ -86,6 +159,8 @@ export function convertMarkdownToRequests(markdown, startIndex = 1, tabId, optio
86
159
  codeBlockRanges: [],
87
160
  tableState: undefined,
88
161
  inTableCell: false,
162
+ paragraphFormattingStack: [],
163
+ htmlParagraphPushStack: [],
89
164
  tabId,
90
165
  titleConsumed: false,
91
166
  firstHeadingAsTitle: options?.firstHeadingAsTitle ?? false,
@@ -231,10 +306,12 @@ function processToken(token, context) {
231
306
  case 'th_open':
232
307
  case 'td_open': {
233
308
  if (context.tableState) {
309
+ const alignment = parseAlignmentFromStyle(getAttr(token, 'style'));
234
310
  context.tableState.currentCell = {
235
311
  text: '',
236
312
  isHeader: context.tableState.inHeader || token.type === 'th_open',
237
313
  textRanges: [],
314
+ alignment,
238
315
  };
239
316
  context.inTableCell = true;
240
317
  }
@@ -264,9 +341,20 @@ function processToken(token, context) {
264
341
  case 'hr':
265
342
  handleHorizontalRule(context);
266
343
  break;
267
- // Blockquotes (skip for now)
268
344
  case 'blockquote_open':
345
+ context.paragraphFormattingStack.push({
346
+ indentStart: 36,
347
+ borderLeft: true,
348
+ });
349
+ break;
269
350
  case 'blockquote_close':
351
+ context.paragraphFormattingStack.pop();
352
+ break;
353
+ case 'html_inline':
354
+ handleHtmlInlineToken(token, context);
355
+ break;
356
+ case 'html_block':
357
+ handleHtmlBlockToken(token, context);
270
358
  break;
271
359
  default:
272
360
  break;
@@ -306,6 +394,106 @@ function handleHorizontalRule(context) {
306
394
  insertText('\n', context);
307
395
  context.hrRanges.push({ startIndex: start, endIndex: context.currentIndex });
308
396
  }
397
+ function handleHtmlInlineToken(token, context) {
398
+ const parsed = parseRichHtmlTag(token.content);
399
+ if (!parsed)
400
+ return;
401
+ const attrs = parseAttrs(parsed.attrs);
402
+ if (parsed.selfClosing && parsed.tag !== 'br')
403
+ return;
404
+ if (parsed.closing) {
405
+ switch (parsed.tag) {
406
+ case 'u':
407
+ popFormatting(context, 'underline');
408
+ break;
409
+ case 'mark':
410
+ popFormatting(context, 'backgroundColor');
411
+ break;
412
+ case 'span':
413
+ popFormatting(context, 'richSpan');
414
+ break;
415
+ case 'p':
416
+ case 'div': {
417
+ const pushed = context.htmlParagraphPushStack.pop();
418
+ if (pushed)
419
+ context.paragraphFormattingStack.pop();
420
+ break;
421
+ }
422
+ case 'blockquote':
423
+ context.paragraphFormattingStack.pop();
424
+ break;
425
+ }
426
+ return;
427
+ }
428
+ switch (parsed.tag) {
429
+ case 'u':
430
+ context.formattingStack.push({ underline: true });
431
+ break;
432
+ case 'mark':
433
+ context.formattingStack.push({ backgroundColor: '#FFF2CC' });
434
+ break;
435
+ case 'span': {
436
+ const formatting = parseStyleDeclarations(attrs.style);
437
+ if (hasFormatting(formatting)) {
438
+ formatting.richSpan = true;
439
+ context.formattingStack.push(formatting);
440
+ }
441
+ break;
442
+ }
443
+ case 'p':
444
+ case 'div': {
445
+ const alignment = alignmentToDocs(attrs.align ?? '') ?? parseAlignmentFromStyle(attrs.style);
446
+ if (alignment) {
447
+ context.paragraphFormattingStack.push({ alignment });
448
+ context.htmlParagraphPushStack.push(true);
449
+ }
450
+ else {
451
+ context.htmlParagraphPushStack.push(false);
452
+ }
453
+ break;
454
+ }
455
+ case 'blockquote':
456
+ context.paragraphFormattingStack.push({
457
+ indentStart: 36,
458
+ borderLeft: true,
459
+ });
460
+ break;
461
+ case 'br':
462
+ insertText('\n', context);
463
+ break;
464
+ }
465
+ }
466
+ function handleHtmlBlockToken(token, context) {
467
+ const content = token.content.trim();
468
+ const match = content.match(/^<\s*(p|div|blockquote)\b([^>]*)>([\s\S]*)<\/\s*\1\s*>$/i);
469
+ if (!match)
470
+ return;
471
+ const tag = match[1].toLowerCase();
472
+ const attrs = parseAttrs(match[2]);
473
+ const inner = match[3];
474
+ let pushedParagraphFormatting = false;
475
+ if (tag === 'blockquote') {
476
+ context.paragraphFormattingStack.push({
477
+ indentStart: 36,
478
+ borderLeft: true,
479
+ });
480
+ pushedParagraphFormatting = true;
481
+ }
482
+ else {
483
+ const alignment = alignmentToDocs(attrs.align ?? '') ?? parseAlignmentFromStyle(attrs.style);
484
+ if (alignment) {
485
+ context.paragraphFormattingStack.push({ alignment });
486
+ pushedParagraphFormatting = true;
487
+ }
488
+ }
489
+ const parser = createParser();
490
+ for (const child of parser.parse(inner, {})) {
491
+ processToken(child, context);
492
+ }
493
+ if (pushedParagraphFormatting) {
494
+ context.paragraphFormattingStack.pop();
495
+ }
496
+ }
309
497
  // --- Paragraph Handlers ---
310
498
  function handleParagraphOpen(context) {
311
499
  if (context.listStack.length === 0) {
@@ -329,10 +517,15 @@ function handleParagraphClose(context) {
329
517
  }
330
518
  // Record the range for normal paragraphs (not list items) so we can apply spacing later
331
519
  if (paragraphStart !== undefined && context.listStack.length === 0) {
332
- context.normalParagraphRanges.push({
520
+ const paragraphRange = {
333
521
  startIndex: paragraphStart,
334
522
  endIndex: context.currentIndex,
335
- });
523
+ };
524
+ const paragraphFormatting = mergeParagraphFormattingStack(context.paragraphFormattingStack);
525
+ if (hasParagraphFormatting(paragraphFormatting)) {
526
+ paragraphRange.formatting = paragraphFormatting;
527
+ }
528
+ context.normalParagraphRanges.push(paragraphRange);
336
529
  }
337
530
  context.currentParagraphStart = undefined;
338
531
  }
@@ -548,15 +741,13 @@ function handleTableClose(tableState, context) {
548
741
  if (range.formatting.bold ||
549
742
  range.formatting.italic ||
550
743
  range.formatting.strikethrough ||
551
- range.formatting.code) {
552
- const styleReq = buildUpdateTextStyleRequest(absStart, absEnd, {
553
- bold: range.formatting.bold,
554
- italic: range.formatting.italic,
555
- strikethrough: range.formatting.strikethrough,
556
- fontFamily: range.formatting.code ? CODE_FONT_FAMILY : undefined,
557
- foregroundColor: range.formatting.code ? CODE_TEXT_HEX : undefined,
558
- backgroundColor: range.formatting.code ? CODE_BACKGROUND_HEX : undefined,
559
- }, context.tabId);
744
+ range.formatting.code ||
745
+ range.formatting.underline ||
746
+ range.formatting.foregroundColor ||
747
+ range.formatting.backgroundColor ||
748
+ range.formatting.fontSize ||
749
+ range.formatting.fontFamily) {
750
+ const styleReq = buildUpdateTextStyleRequest(absStart, absEnd, formattingToTextStyle(range.formatting), context.tabId);
560
751
  if (styleReq)
561
752
  context.formatRequests.push(styleReq.request);
562
753
  }
@@ -572,6 +763,11 @@ function handleTableClose(tableState, context) {
572
763
  if (headerStyleReq)
573
764
  context.formatRequests.push(headerStyleReq.request);
574
765
  }
766
+ if (cell.alignment) {
767
+ const alignmentReq = buildUpdateParagraphStyleRequest(adjustedIndex, adjustedIndex + cell.text.length, { alignment: cell.alignment }, context.tabId);
768
+ if (alignmentReq)
769
+ context.formatRequests.push(alignmentReq.request);
770
+ }
575
771
  cumulativeTextLength += cell.text.length;
576
772
  }
577
773
  }
@@ -606,6 +802,18 @@ function mergeFormattingStack(stack) {
606
802
  merged.code = state.code;
607
803
  if (state.link !== undefined)
608
804
  merged.link = state.link;
805
+ if (state.underline !== undefined)
806
+ merged.underline = state.underline;
807
+ if (state.foregroundColor !== undefined)
808
+ merged.foregroundColor = state.foregroundColor;
809
+ if (state.backgroundColor !== undefined)
810
+ merged.backgroundColor = state.backgroundColor;
811
+ if (state.fontSize !== undefined)
812
+ merged.fontSize = state.fontSize;
813
+ if (state.fontFamily !== undefined)
814
+ merged.fontFamily = state.fontFamily;
815
+ if (state.richSpan !== undefined)
816
+ merged.richSpan = state.richSpan;
609
817
  }
610
818
  return merged;
611
819
  }
@@ -614,7 +822,41 @@ function hasFormatting(formatting) {
614
822
  formatting.italic === true ||
615
823
  formatting.strikethrough === true ||
616
824
  formatting.code === true ||
617
- formatting.link !== undefined);
825
+ formatting.link !== undefined ||
826
+ formatting.underline === true ||
827
+ formatting.foregroundColor !== undefined ||
828
+ formatting.backgroundColor !== undefined ||
829
+ formatting.fontSize !== undefined ||
830
+ formatting.fontFamily !== undefined);
831
+ }
832
+ function formattingToTextStyle(formatting) {
833
+ return {
834
+ bold: formatting.bold,
835
+ italic: formatting.italic,
836
+ underline: formatting.underline,
837
+ strikethrough: formatting.strikethrough,
838
+ fontFamily: formatting.code ? CODE_FONT_FAMILY : formatting.fontFamily,
839
+ fontSize: formatting.fontSize,
840
+ foregroundColor: formatting.code ? CODE_TEXT_HEX : formatting.foregroundColor,
841
+ backgroundColor: formatting.code ? CODE_BACKGROUND_HEX : formatting.backgroundColor,
842
+ };
843
+ }
844
+ function mergeParagraphFormattingStack(stack) {
845
+ const merged = {};
846
+ for (const state of stack) {
847
+ if (state.alignment !== undefined)
848
+ merged.alignment = state.alignment;
849
+ if (state.indentStart !== undefined)
850
+ merged.indentStart = state.indentStart;
851
+ if (state.borderLeft !== undefined)
852
+ merged.borderLeft = state.borderLeft;
853
+ }
854
+ return merged;
855
+ }
856
+ function hasParagraphFormatting(formatting) {
857
+ return (formatting.alignment !== undefined ||
858
+ formatting.indentStart !== undefined ||
859
+ formatting.borderLeft === true);
618
860
  }
619
861
  function popFormatting(context, type) {
620
862
  for (let i = context.formattingStack.length - 1; i >= 0; i--) {
@@ -661,15 +903,13 @@ function finalizeFormatting(context) {
661
903
  if (range.formatting.bold ||
662
904
  range.formatting.italic ||
663
905
  range.formatting.strikethrough ||
664
- range.formatting.code) {
665
- const styleRequest = buildUpdateTextStyleRequest(range.startIndex, range.endIndex, {
666
- bold: range.formatting.bold,
667
- italic: range.formatting.italic,
668
- strikethrough: range.formatting.strikethrough,
669
- fontFamily: range.formatting.code ? CODE_FONT_FAMILY : undefined,
670
- foregroundColor: range.formatting.code ? CODE_TEXT_HEX : undefined,
671
- backgroundColor: range.formatting.code ? CODE_BACKGROUND_HEX : undefined,
672
- }, context.tabId);
906
+ range.formatting.code ||
907
+ range.formatting.underline ||
908
+ range.formatting.foregroundColor ||
909
+ range.formatting.backgroundColor ||
910
+ range.formatting.fontSize ||
911
+ range.formatting.fontFamily) {
912
+ const styleRequest = buildUpdateTextStyleRequest(range.startIndex, range.endIndex, formattingToTextStyle(range.formatting), context.tabId);
673
913
  if (styleRequest) {
674
914
  context.formatRequests.push(styleRequest.request);
675
915
  }
@@ -711,6 +951,38 @@ function finalizeFormatting(context) {
711
951
  fields: 'spaceBelow',
712
952
  },
713
953
  });
954
+ if (normalRange.formatting) {
955
+ const paraStyle = {};
956
+ const fields = [];
957
+ if (normalRange.formatting.alignment) {
958
+ paraStyle.alignment = normalRange.formatting.alignment;
959
+ fields.push('alignment');
960
+ }
961
+ if (normalRange.formatting.indentStart !== undefined) {
962
+ paraStyle.indentStart = { magnitude: normalRange.formatting.indentStart, unit: 'PT' };
963
+ fields.push('indentStart');
964
+ }
965
+ if (normalRange.formatting.borderLeft) {
966
+ paraStyle.borderLeft = {
967
+ color: {
968
+ color: { rgbColor: { red: 0.75, green: 0.75, blue: 0.75 } },
969
+ },
970
+ width: { magnitude: 2, unit: 'PT' },
971
+ padding: { magnitude: 6, unit: 'PT' },
972
+ dashStyle: 'SOLID',
973
+ };
974
+ fields.push('borderLeft');
975
+ }
976
+ if (fields.length > 0) {
977
+ context.formatRequests.push({
978
+ updateParagraphStyle: {
979
+ range,
980
+ paragraphStyle: paraStyle,
981
+ fields: fields.join(','),
982
+ },
983
+ });
984
+ }
985
+ }
714
986
  }
715
987
  // List trailing spacing: apply spaceBelow to the last paragraph of each
716
988
  // top-level list so there is a visible gap between the list and the content
@@ -15,7 +15,7 @@ export function register(server) {
15
15
  initialContent: z
16
16
  .string()
17
17
  .optional()
18
- .describe('Initial content to add to the document. By default, markdown syntax is converted to formatted Google Docs content (headings, bold, italic, links, lists, etc.).'),
18
+ .describe('Initial content to add to the document. By default, markdown syntax is converted to formatted Google Docs content (headings, bold, italic, links, tables, lists, and rich markdown HTML extensions).'),
19
19
  contentFormat: z
20
20
  .enum(['markdown', 'raw'])
21
21
  .optional()
@@ -49,14 +49,19 @@ export function register(server) {
49
49
  fileId: z
50
50
  .string()
51
51
  .describe('The Google Drive file ID (the long string between /d/ and /edit in a Drive URL).'),
52
- format: z
53
- .enum(['text', 'json', 'markdown'])
54
- .optional()
55
- .default('markdown')
56
- .describe("Output format for Google Docs: 'markdown' (default), 'text' (plain text), 'json' (raw structure). Ignored for non-Doc files."),
57
- range: z
58
- .string()
59
- .optional()
52
+ format: z
53
+ .enum(['text', 'json', 'markdown'])
54
+ .optional()
55
+ .default('markdown')
56
+ .describe("Output format for Google Docs: 'markdown' (default, preserves rich text/paragraph formatting with a small HTML allowlist), 'text' (plain text), 'json' (raw structure). Ignored for non-Doc files."),
57
+ plainMarkdown: z
58
+ .boolean()
59
+ .optional()
60
+ .default(false)
61
+ .describe('For Google Docs markdown output only. If true, suppresses rich HTML-style formatting extensions and returns cleaner portable markdown.'),
62
+ range: z
63
+ .string()
64
+ .optional()
60
65
  .describe("A1 notation range for Google Sheets (e.g., 'Sheet1!A1:C10'). Required for spreadsheets."),
61
66
  }),
62
67
  execute: async (args, { log }) => {
@@ -117,8 +122,8 @@ async function readGoogleDoc(args, fileMeta, log) {
117
122
  let content;
118
123
  if (args.format === 'json') {
119
124
  content = JSON.stringify(res.data, null, 2);
120
- } else if (args.format === 'markdown') {
121
- content = docsJsonToMarkdown(res.data);
125
+ } else if (args.format === 'markdown') {
126
+ content = docsJsonToMarkdown(res.data, { plainMarkdown: args.plainMarkdown });
122
127
  } else {
123
128
  // text
124
129
  let textContent = '';
@@ -10,7 +10,7 @@ export function register(server) {
10
10
  server.addTool({
11
11
  name: 'appendMarkdown',
12
12
  description: 'Best for adding new formatted content to the end of a document. ' +
13
- 'Supports headings, bold, italic, strikethrough, links, and bullet/numbered lists. ' +
13
+ 'Supports headings, bold, italic, strikethrough, links, tables, bullet/numbered lists, and rich markdown HTML extensions for underline, color, highlight, font, alignment, and blockquotes. ' +
14
14
  'Use this instead of appendText when you need formatting. ' +
15
15
  'To edit existing content, use modifyText (single-location) or replaceDocumentWithMarkdown (section/full rewrite).',
16
16
  parameters: DocumentIdParameter.extend({
@@ -10,7 +10,7 @@ export function register(server) {
10
10
  server.addTool({
11
11
  name: 'replaceDocumentWithMarkdown',
12
12
  description: "Best for rewriting entire sections or full documents. Replaces the entire document body with content parsed from markdown. " +
13
- "Supports headings, bold, italic, strikethrough, links, and bullet/numbered lists. " +
13
+ "Supports headings, bold, italic, strikethrough, links, tables, bullet/numbered lists, and rich markdown HTML extensions for underline, color, highlight, font, alignment, and blockquotes. " +
14
14
  "Use readDocument with format='markdown' first to get the current content, edit it, then call this tool to apply changes. " +
15
15
  "For small single-location edits (one line or paragraph), use modifyText instead. " +
16
16
  "To add content without rewriting, use appendMarkdown.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "google-tools-mcp",
3
- "version": "1.2.11",
3
+ "version": "1.2.12",
4
4
  "description": "The easiest MCP server for Google Workspace — Drive, Docs, Sheets, Gmail, Calendar, and Forms. 153 tools with one-click browser auth. Read Word docs, PDFs, and spreadsheets straight from Drive.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,12 +49,13 @@
49
49
  "ai-tools"
50
50
  ],
51
51
  "scripts": {
52
+ "local:tool": "node scripts/call-local-tool.js",
52
53
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
53
54
  "test:ci": "node --experimental-vm-modules node_modules/jest/bin/jest.js --ci --coverage"
54
55
  },
55
56
  "repository": {
56
57
  "type": "git",
57
- "url": "https://github.com/karthikcsq/google-tools-mcp.git"
58
+ "url": "git+https://github.com/karthikcsq/google-tools-mcp.git"
58
59
  },
59
60
  "homepage": "https://github.com/karthikcsq/google-tools-mcp#readme",
60
61
  "bugs": {