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.
|
|
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": {
|