google-tools-mcp 1.2.10 → 1.2.11

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.
@@ -291,6 +291,18 @@ function normalizeForSearch(text) {
291
291
  }
292
292
  return result;
293
293
  }
294
+ /**
295
+ * readDocument(format='markdown') includes markdown list markers ("- ",
296
+ * "1. ", etc.), but Google Docs text runs do not include list glyphs. When a
297
+ * caller copies a bullet line from markdown into textToFind, retry without
298
+ * those line-start markers.
299
+ */
300
+ export function stripMarkdownListMarkersForSearch(text) {
301
+ return text
302
+ .split('\n')
303
+ .map((line) => line.replace(/^(\s*)(?:[-*+]\s+|\d+[.)]\s+)/, '$1'))
304
+ .join('\n');
305
+ }
294
306
  /**
295
307
  * Finds all occurrences of textToFind in the document and returns them with
296
308
  * surrounding context and mapped document indices.
@@ -332,6 +344,15 @@ export async function findTextRange(docs, documentId, textToFind, instance, tabI
332
344
  const { fullText, segments } = result;
333
345
  logger.debug(`Document ${documentId} contains ${segments.length} text segments and ${fullText.length} characters in total.`);
334
346
  let allOccurrences = findAllOccurrences(fullText, segments, textToFind);
347
+ // Fallback: markdown exports include list markers that are absent from
348
+ // Docs API text runs. Retry after stripping line-start markdown markers.
349
+ if (allOccurrences.length === 0) {
350
+ const listMarkerStrippedSearch = stripMarkdownListMarkersForSearch(textToFind);
351
+ if (listMarkerStrippedSearch !== textToFind) {
352
+ logger.debug(`Exact match failed, trying match without markdown list markers`);
353
+ allOccurrences = findAllOccurrences(fullText, segments, listMarkerStrippedSearch);
354
+ }
355
+ }
335
356
  // Fallback: try normalized matching if exact match fails (issue #11)
336
357
  if (allOccurrences.length === 0) {
337
358
  const normalizedSearch = normalizeForSearch(textToFind);
@@ -365,6 +386,39 @@ export async function findTextRange(docs, documentId, textToFind, instance, tabI
365
386
  }
366
387
  }
367
388
  }
389
+ // Fallback: combine both approaches for copied markdown bullets that
390
+ // also contain smart quotes, non-breaking spaces, or typographic dashes.
391
+ if (allOccurrences.length === 0) {
392
+ const listMarkerStrippedSearch = stripMarkdownListMarkersForSearch(textToFind);
393
+ const normalizedSearch = normalizeForSearch(listMarkerStrippedSearch);
394
+ const { normalized: normalizedFull, posMap } = normalizeWithPositionMap(fullText);
395
+ if (normalizedSearch !== textToFind || normalizedFull !== fullText) {
396
+ logger.debug(`Exact match failed, trying normalized match without markdown list markers`);
397
+ const CONTEXT_CHARS = 30;
398
+ let searchFrom = 0;
399
+ while (true) {
400
+ const idx = normalizedFull.indexOf(normalizedSearch, searchFrom);
401
+ if (idx === -1) break;
402
+ const origStart = posMap[idx];
403
+ const origEnd = posMap[idx + normalizedSearch.length];
404
+ const docStart = mapFullTextPositionToDocIndex(origStart, segments);
405
+ const docEnd = mapFullTextPositionToDocIndex(origEnd, segments);
406
+ const contextStart = Math.max(0, origStart - CONTEXT_CHARS);
407
+ const contextEnd = Math.min(fullText.length, origEnd + CONTEXT_CHARS);
408
+ const before = fullText.slice(contextStart, origStart).replace(/\n/g, '\\n');
409
+ const match = fullText.slice(origStart, origEnd).replace(/\n/g, '\\n');
410
+ const after = fullText.slice(origEnd, contextEnd).replace(/\n/g, '\\n');
411
+ const context = `${contextStart > 0 ? '...' : ''}${before}[${match}]${after}${contextEnd < fullText.length ? '...' : ''}`;
412
+ allOccurrences.push({
413
+ instance: allOccurrences.length + 1,
414
+ startIndex: docStart,
415
+ endIndex: docEnd,
416
+ context,
417
+ });
418
+ searchFrom = idx + 1;
419
+ }
420
+ }
421
+ }
368
422
  if (allOccurrences.length === 0) {
369
423
  logger.warn(`Text "${textToFind}" not found in document ${documentId}`);
370
424
  return null;
@@ -101,6 +101,7 @@ export function register(server) {
101
101
  "Use readDocument with format='json' to determine indices. " +
102
102
  'Supports \\n for line breaks and \\t for tabs in replacement text. ' +
103
103
  'When using textToFind, if multiple matches exist the tool returns all instances with context so you can specify matchInstance. ' +
104
+ "textToFind tolerates markdown list markers copied from readDocument(format='markdown'), because Google Docs stores bullets outside text runs. " +
104
105
  'For multi-line or section-level rewrites, use replaceDocumentWithMarkdown instead. ' +
105
106
  'To add content to the end of a doc, use appendMarkdown or appendText.',
106
107
  parameters: ModifyTextParameters,
@@ -1,6 +1,7 @@
1
- import { describe, it } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { buildModifyTextRequests } from './modifyText.js';
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildModifyTextRequests } from './modifyText.js';
4
+ import { stripMarkdownListMarkersForSearch } from '../../googleDocsApiHelpers.js';
4
5
 
5
6
  describe('buildModifyTextRequests', () => {
6
7
  it('generates deleteContentRange + insertText for a replacement', () => {
@@ -50,7 +51,7 @@ describe('buildModifyTextRequests', () => {
50
51
  });
51
52
  });
52
53
 
53
- describe('escape sequence normalization (issue #9)', () => {
54
+ describe('escape sequence normalization (issue #9)', () => {
54
55
  // Simulate the normalization that happens in the execute handler
55
56
  function normalize(text) {
56
57
  return text?.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
@@ -86,5 +87,22 @@ describe('escape sequence normalization (issue #9)', () => {
86
87
 
87
88
  it('returns undefined for undefined input', () => {
88
89
  assert.equal(normalize(undefined), undefined);
89
- });
90
- });
90
+ });
91
+ });
92
+
93
+ describe('markdown list marker search normalization', () => {
94
+ it('strips unordered markdown list markers copied from readDocument markdown', () => {
95
+ const input = '- Existing Google Docs bullet text';
96
+ assert.equal(stripMarkdownListMarkersForSearch(input), 'Existing Google Docs bullet text');
97
+ });
98
+
99
+ it('strips ordered markdown list markers copied from readDocument markdown', () => {
100
+ const input = '1. Existing Google Docs numbered item';
101
+ assert.equal(stripMarkdownListMarkersForSearch(input), 'Existing Google Docs numbered item');
102
+ });
103
+
104
+ it('preserves indentation when stripping nested list markers', () => {
105
+ const input = ' - Nested item';
106
+ assert.equal(stripMarkdownListMarkersForSearch(input), ' Nested item');
107
+ });
108
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "google-tools-mcp",
3
- "version": "1.2.10",
3
+ "version": "1.2.11",
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": {