gogcli-mcp-docs 2.5.0 → 2.6.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "Extended Google Docs for Claude via gogcli — auth + full Docs and comments support",
10
- "version": "2.5.0"
10
+ "version": "2.6.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "gogcli (Docs)",
16
16
  "source": "./",
17
17
  "description": "Extended Google Docs for Claude via gogcli — auth + full Docs and comments support",
18
- "version": "2.5.0",
18
+ "version": "2.6.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gogcli-mcp-docs",
3
3
  "displayName": "gogcli (Docs)",
4
- "version": "2.5.0",
4
+ "version": "2.6.0",
5
5
  "description": "Extended Google Docs for Claude via gogcli — auth + full Docs and comments support",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/index.js CHANGED
@@ -31318,7 +31318,7 @@ var failIfNotEmptyParam = external_exports.boolean().optional().describe(
31318
31318
  );
31319
31319
 
31320
31320
  // ../gogcli-mcp/src/server.ts
31321
- var VERSION = true ? "2.5.0" : "0.0.0";
31321
+ var VERSION = true ? "2.6.0" : "0.0.0";
31322
31322
  function createServer(options) {
31323
31323
  return new McpServer({
31324
31324
  name: options?.name ?? "gogcli",
@@ -31346,13 +31346,22 @@ function registerExtraDocsTools(server2) {
31346
31346
  annotations: { destructiveHint: true },
31347
31347
  inputSchema: {
31348
31348
  docId: external_exports.string().describe("Doc ID (from the URL)"),
31349
- start: external_exports.number().describe("Start index (character position, 1-based)"),
31350
- end: external_exports.number().describe("End index (character position, exclusive)"),
31349
+ start: external_exports.number().optional().describe("Start index (character position, 1-based). Required unless `at` is set."),
31350
+ end: external_exports.number().optional().describe("End index (character position, exclusive). Required unless `at` is set."),
31351
+ at: external_exports.string().optional().describe("Anchor by literal text and delete that matched range, instead of supplying start/end indices."),
31352
+ occurrence: external_exports.number().int().optional().describe("Use the Nth `at` match (1-based; required when `at` is ambiguous)"),
31353
+ matchCase: external_exports.boolean().optional().describe("Case-sensitive `at` matching"),
31351
31354
  tabId: external_exports.string().optional().describe("Tab ID to delete content from (for multi-tab docs)"),
31352
31355
  account: accountParam
31353
31356
  }
31354
- }, async ({ docId, start, end, tabId, account }) => {
31355
- const args = ["docs", "delete", `--start=${start}`, `--end=${end}`, docId];
31357
+ }, async ({ docId, start, end, at, occurrence, matchCase, tabId, account }) => {
31358
+ const args = ["docs", "delete"];
31359
+ if (start !== void 0) args.push(`--start=${start}`);
31360
+ if (end !== void 0) args.push(`--end=${end}`);
31361
+ args.push(docId);
31362
+ if (at) args.push(`--at=${at}`);
31363
+ if (occurrence !== void 0) args.push(`--occurrence=${occurrence}`);
31364
+ if (matchCase) args.push("--match-case");
31356
31365
  if (tabId) args.push(`--tab-id=${tabId}`);
31357
31366
  return runOrDiagnose(args, { account });
31358
31367
  });
@@ -31424,6 +31433,8 @@ function registerExtraDocsTools(server2) {
31424
31433
  strikethrough: external_exports.boolean().optional().describe("Set strikethrough"),
31425
31434
  noStrikethrough: external_exports.boolean().optional().describe("Clear strikethrough"),
31426
31435
  code: external_exports.boolean().optional().describe("Apply code style (Courier New monospace + grey background)"),
31436
+ link: external_exports.string().optional().describe("Set a hyperlink on the matched text. Accepts http://, https://, mailto:, #bookmarkId, or #heading-slug."),
31437
+ noLink: external_exports.boolean().optional().describe("Clear any hyperlink on the matched text"),
31427
31438
  alignment: external_exports.enum(["left", "center", "right", "justify", "start", "end", "justified"]).optional().describe("Paragraph alignment"),
31428
31439
  lineSpacing: external_exports.number().optional().describe("Line spacing percentage (e.g. 100 for single, 150 for 1.5x, 200 for double)"),
31429
31440
  headingLevel: external_exports.number().int().optional().describe("Set paragraph named style to HEADING_1..HEADING_6 (shortcut for namedStyle=HEADING_N)"),
@@ -31450,6 +31461,8 @@ function registerExtraDocsTools(server2) {
31450
31461
  if (a.strikethrough) argv.push("--strikethrough");
31451
31462
  if (a.noStrikethrough) argv.push("--no-strikethrough");
31452
31463
  if (a.code) argv.push("--code");
31464
+ if (a.link) argv.push(`--link=${a.link}`);
31465
+ if (a.noLink) argv.push("--no-link");
31453
31466
  if (a.alignment) argv.push(`--alignment=${a.alignment}`);
31454
31467
  if (a.lineSpacing !== void 0) argv.push(`--line-spacing=${a.lineSpacing}`);
31455
31468
  if (a.headingLevel !== void 0) argv.push(`--heading-level=${a.headingLevel}`);
@@ -31479,14 +31492,20 @@ function registerExtraDocsTools(server2) {
31479
31492
  content: external_exports.string().optional().describe("Text content to insert"),
31480
31493
  index: external_exports.number().optional().describe("Character index to insert at (1-based; default: 1 = start of doc). Prefer gog_docs_append when you want to add at the end."),
31481
31494
  file: external_exports.string().optional().describe("Path to a file whose content to insert"),
31495
+ at: external_exports.string().optional().describe("Anchor by literal text and insert at the start of the matched range, instead of computing an index."),
31496
+ occurrence: external_exports.number().int().optional().describe("Use the Nth `at` match (1-based; required when `at` is ambiguous)"),
31497
+ matchCase: external_exports.boolean().optional().describe("Case-sensitive `at` matching"),
31482
31498
  tabId: external_exports.string().optional().describe("Tab ID to insert into (for multi-tab docs)"),
31483
31499
  account: accountParam
31484
31500
  }
31485
- }, async ({ docId, content, index, file: file2, tabId, account }) => {
31501
+ }, async ({ docId, content, index, file: file2, at, occurrence, matchCase, tabId, account }) => {
31486
31502
  const args = ["docs", "insert", docId];
31487
31503
  if (content) args.push(content);
31488
31504
  if (index !== void 0) args.push(`--index=${index}`);
31489
31505
  if (file2) args.push(`--file=${file2}`);
31506
+ if (at) args.push(`--at=${at}`);
31507
+ if (occurrence !== void 0) args.push(`--occurrence=${occurrence}`);
31508
+ if (matchCase) args.push("--match-case");
31490
31509
  if (tabId) args.push(`--tab-id=${tabId}`);
31491
31510
  return runOrDiagnose(args, { account });
31492
31511
  });
@@ -31552,17 +31571,23 @@ function registerExtraDocsTools(server2) {
31552
31571
  index: external_exports.number().optional().describe("Character index to insert at"),
31553
31572
  replaceRange: external_exports.string().optional().describe('Replace a UTF-16 Docs API range START:END (e.g. "25:40") instead of inserting at index. The replacement text/file content overwrites the range.'),
31554
31573
  markdown: external_exports.boolean().optional().describe("Convert markdown in the text/file to Google Docs formatting (headings, bold, lists, etc.) instead of inserting it literally."),
31574
+ at: external_exports.string().optional().describe("Anchor by literal text and replace that matched range, instead of supplying an index or replaceRange."),
31575
+ occurrence: external_exports.number().int().optional().describe("Use the Nth `at` match (1-based; required when `at` is ambiguous)"),
31576
+ matchCase: external_exports.boolean().optional().describe("Case-sensitive `at` matching"),
31555
31577
  tabId: external_exports.string().optional().describe("Tab ID for multi-tab docs"),
31556
31578
  pageless: external_exports.boolean().optional().describe("Set document to pageless format"),
31557
31579
  account: accountParam
31558
31580
  }
31559
- }, async ({ docId, text, file: file2, index, replaceRange, markdown, tabId, pageless, account }) => {
31581
+ }, async ({ docId, text, file: file2, index, replaceRange, markdown, at, occurrence, matchCase, tabId, pageless, account }) => {
31560
31582
  const args = ["docs", "update", docId];
31561
31583
  if (text) args.push(`--text=${text}`);
31562
31584
  if (file2) args.push(`--file=${file2}`);
31563
31585
  if (index !== void 0) args.push(`--index=${index}`);
31564
31586
  if (replaceRange) args.push(`--replace-range=${replaceRange}`);
31565
31587
  if (markdown) args.push("--markdown");
31588
+ if (at) args.push(`--at=${at}`);
31589
+ if (occurrence !== void 0) args.push(`--occurrence=${occurrence}`);
31590
+ if (matchCase) args.push("--match-case");
31566
31591
  if (tabId) args.push(`--tab-id=${tabId}`);
31567
31592
  if (pageless) args.push("--pageless");
31568
31593
  return runOrDiagnose(args, { account });
@@ -31655,6 +31680,48 @@ function registerExtraDocsTools(server2) {
31655
31680
  }, async ({ docId, commentId, account }) => {
31656
31681
  return runOrDiagnose(["docs", "comments", "reopen", docId, commentId], { account });
31657
31682
  });
31683
+ server2.registerTool("gog_docs_comments_locate", {
31684
+ description: "Locate a comment's anchor in a Google Doc \u2014 resolves the comment's quoted text to its current Docs API index range, or reports the comment as orphaned if the quote can no longer be found (e.g. the anchored text was edited away). Read-only; useful before an index-based edit near a comment.",
31685
+ annotations: { readOnlyHint: true },
31686
+ inputSchema: {
31687
+ docId: external_exports.string().describe("Doc ID (from the URL)"),
31688
+ commentId: external_exports.string().describe("Comment ID to locate"),
31689
+ matchCase: external_exports.boolean().optional().describe("Case-sensitive matching of the comment quote"),
31690
+ normalizeWhitespace: external_exports.boolean().optional().describe("Collapse whitespace while matching the comment quote"),
31691
+ tab: external_exports.string().optional().describe("Target a specific tab by title or ID"),
31692
+ account: accountParam
31693
+ }
31694
+ }, async ({ docId, commentId, matchCase, normalizeWhitespace, tab, account }) => {
31695
+ const args = ["docs", "comments", "locate", docId, commentId];
31696
+ if (matchCase) args.push("--match-case");
31697
+ if (normalizeWhitespace) args.push("--normalize-whitespace");
31698
+ if (tab) args.push(`--tab=${tab}`);
31699
+ return runOrDiagnose(args, { account });
31700
+ });
31701
+ server2.registerTool("gog_docs_find_range", {
31702
+ description: "Map literal text in a Google Doc to its Docs API UTF-16 index range(s). Read-only helper for computing the start/end indices that index-based tools (gog_docs_delete, gog_docs_update --replace-range) need. Returns the first match by default; use occurrence to pick a specific one or all to return every match.",
31703
+ annotations: { readOnlyHint: true },
31704
+ inputSchema: {
31705
+ docId: external_exports.string().describe("Doc ID (from the URL)"),
31706
+ text: external_exports.string().describe("Literal text to locate"),
31707
+ occurrence: external_exports.number().int().optional().describe("Return the Nth occurrence (1-based; default: first)"),
31708
+ matchCase: external_exports.boolean().optional().describe("Case-sensitive matching"),
31709
+ normalizeWhitespace: external_exports.boolean().optional().describe("Collapse whitespace while matching"),
31710
+ all: external_exports.boolean().optional().describe("Return all matches instead of just one"),
31711
+ failEmpty: external_exports.boolean().optional().describe("Treat no matches as an error instead of returning an empty result"),
31712
+ tab: external_exports.string().optional().describe("Target a specific tab by title or ID"),
31713
+ account: accountParam
31714
+ }
31715
+ }, async ({ docId, text, occurrence, matchCase, normalizeWhitespace, all, failEmpty, tab, account }) => {
31716
+ const args = ["docs", "find-range", docId, text];
31717
+ if (occurrence !== void 0) args.push(`--occurrence=${occurrence}`);
31718
+ if (matchCase) args.push("--match-case");
31719
+ if (normalizeWhitespace) args.push("--normalize-whitespace");
31720
+ if (all) args.push("--all");
31721
+ if (failEmpty) args.push("--fail-empty");
31722
+ if (tab) args.push(`--tab=${tab}`);
31723
+ return runOrDiagnose(args, { account });
31724
+ });
31658
31725
  server2.registerTool("gog_docs_table_column_width", {
31659
31726
  description: "Set a fixed width (in points) for a table column, or reset columns to Docs-managed even distribution. Target the table by 1-based index in document order (negative counts from the end) and the column by 1-based number. Pass evenlyDistributed without col to reset every column in the table.",
31660
31727
  annotations: { destructiveHint: true },
@@ -31683,13 +31750,19 @@ function registerExtraDocsTools(server2) {
31683
31750
  docId: external_exports.string().describe("Doc ID (from the URL)"),
31684
31751
  index: external_exports.number().int().optional().describe("Character index to insert at (1 = beginning). Omit or use atEnd for end-of-doc."),
31685
31752
  atEnd: external_exports.boolean().optional().describe("Insert at end-of-doc/tab (mutually exclusive with index)"),
31753
+ at: external_exports.string().optional().describe("Anchor by literal text and insert the page break at the start of the matched range, instead of an index."),
31754
+ occurrence: external_exports.number().int().optional().describe("Use the Nth `at` match (1-based; required when `at` is ambiguous)"),
31755
+ matchCase: external_exports.boolean().optional().describe("Case-sensitive `at` matching"),
31686
31756
  tab: external_exports.string().optional().describe("Target a specific tab by title or ID"),
31687
31757
  account: accountParam
31688
31758
  }
31689
- }, async ({ docId, index, atEnd, tab, account }) => {
31759
+ }, async ({ docId, index, atEnd, at, occurrence, matchCase, tab, account }) => {
31690
31760
  const args = ["docs", "insert-page-break", docId];
31691
31761
  if (index !== void 0) args.push(`--index=${index}`);
31692
31762
  if (atEnd) args.push("--at-end");
31763
+ if (at) args.push(`--at=${at}`);
31764
+ if (occurrence !== void 0) args.push(`--occurrence=${occurrence}`);
31765
+ if (matchCase) args.push("--match-case");
31693
31766
  if (tab) args.push(`--tab=${tab}`);
31694
31767
  return runOrDiagnose(args, { account });
31695
31768
  });
@@ -31831,13 +31904,19 @@ function registerExtraDocsTools(server2) {
31831
31904
  email: external_exports.string().describe("Email address for the person chip"),
31832
31905
  index: external_exports.number().int().optional().describe("Character index to insert at. Omit or use atEnd for end-of-doc."),
31833
31906
  atEnd: external_exports.boolean().optional().describe("Insert at end-of-doc/tab (mutually exclusive with index)"),
31907
+ at: external_exports.string().optional().describe("Anchor by literal text, delete the match, and insert the person chip there, instead of an index."),
31908
+ occurrence: external_exports.number().int().optional().describe("Use the Nth `at` match (1-based; required when `at` is ambiguous)"),
31909
+ matchCase: external_exports.boolean().optional().describe("Case-sensitive `at` matching"),
31834
31910
  tab: external_exports.string().optional().describe("Target a specific tab by title or ID"),
31835
31911
  account: accountParam
31836
31912
  }
31837
- }, async ({ docId, email: email3, index, atEnd, tab, account }) => {
31913
+ }, async ({ docId, email: email3, index, atEnd, at, occurrence, matchCase, tab, account }) => {
31838
31914
  const args = ["docs", "insert-person", docId, `--email=${email3}`];
31839
31915
  if (index !== void 0) args.push(`--index=${index}`);
31840
31916
  if (atEnd) args.push("--at-end");
31917
+ if (at) args.push(`--at=${at}`);
31918
+ if (occurrence !== void 0) args.push(`--occurrence=${occurrence}`);
31919
+ if (matchCase) args.push("--match-case");
31841
31920
  if (tab) args.push(`--tab=${tab}`);
31842
31921
  return runOrDiagnose(args, { account });
31843
31922
  });
package/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.3",
4
4
  "name": "gogcli-mcp-docs",
5
5
  "display_name": "gogcli (Docs)",
6
- "version": "2.5.0",
6
+ "version": "2.6.0",
7
7
  "description": "Extended Google Docs for Claude via gogcli — auth + full Docs and comments support",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -195,6 +195,14 @@
195
195
  "name": "gog_docs_comments_reopen",
196
196
  "description": "Reopen a previously resolved comment."
197
197
  },
198
+ {
199
+ "name": "gog_docs_comments_locate",
200
+ "description": "Resolve a comment's quoted anchor to its current Docs API index range, or report it as orphaned."
201
+ },
202
+ {
203
+ "name": "gog_docs_find_range",
204
+ "description": "Map literal text to its Docs API UTF-16 index range(s) for index-based edits."
205
+ },
198
206
  {
199
207
  "name": "gog_docs_table_column_width",
200
208
  "description": "Set a fixed table column width in points, or reset columns to Docs-managed even distribution."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp-docs",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "mcpName": "io.github.chrischall/gogcli-mcp-docs",
5
5
  "description": "Extended Google Docs MCP server via gogcli — all base tools plus full Docs support",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp-docs"
9
9
  },
10
- "version": "2.5.0",
10
+ "version": "2.6.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp-docs",
15
- "version": "2.5.0",
15
+ "version": "2.6.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -22,13 +22,22 @@ export function registerExtraDocsTools(server: McpServer): void {
22
22
  annotations: { destructiveHint: true },
23
23
  inputSchema: {
24
24
  docId: z.string().describe('Doc ID (from the URL)'),
25
- start: z.number().describe('Start index (character position, 1-based)'),
26
- end: z.number().describe('End index (character position, exclusive)'),
25
+ start: z.number().optional().describe('Start index (character position, 1-based). Required unless `at` is set.'),
26
+ end: z.number().optional().describe('End index (character position, exclusive). Required unless `at` is set.'),
27
+ at: z.string().optional().describe('Anchor by literal text and delete that matched range, instead of supplying start/end indices.'),
28
+ occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
29
+ matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
27
30
  tabId: z.string().optional().describe('Tab ID to delete content from (for multi-tab docs)'),
28
31
  account: accountParam,
29
32
  },
30
- }, async ({ docId, start, end, tabId, account }) => {
31
- const args = ['docs', 'delete', `--start=${start}`, `--end=${end}`, docId];
33
+ }, async ({ docId, start, end, at, occurrence, matchCase, tabId, account }) => {
34
+ const args = ['docs', 'delete'];
35
+ if (start !== undefined) args.push(`--start=${start}`);
36
+ if (end !== undefined) args.push(`--end=${end}`);
37
+ args.push(docId);
38
+ if (at) args.push(`--at=${at}`);
39
+ if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
40
+ if (matchCase) args.push('--match-case');
32
41
  if (tabId) args.push(`--tab-id=${tabId}`);
33
42
  return runOrDiagnose(args, { account });
34
43
  });
@@ -104,6 +113,8 @@ export function registerExtraDocsTools(server: McpServer): void {
104
113
  strikethrough: z.boolean().optional().describe('Set strikethrough'),
105
114
  noStrikethrough: z.boolean().optional().describe('Clear strikethrough'),
106
115
  code: z.boolean().optional().describe('Apply code style (Courier New monospace + grey background)'),
116
+ link: z.string().optional().describe('Set a hyperlink on the matched text. Accepts http://, https://, mailto:, #bookmarkId, or #heading-slug.'),
117
+ noLink: z.boolean().optional().describe('Clear any hyperlink on the matched text'),
107
118
  alignment: z.enum(['left', 'center', 'right', 'justify', 'start', 'end', 'justified']).optional().describe('Paragraph alignment'),
108
119
  lineSpacing: z.number().optional().describe('Line spacing percentage (e.g. 100 for single, 150 for 1.5x, 200 for double)'),
109
120
  headingLevel: z.number().int().optional().describe('Set paragraph named style to HEADING_1..HEADING_6 (shortcut for namedStyle=HEADING_N)'),
@@ -126,6 +137,7 @@ export function registerExtraDocsTools(server: McpServer): void {
126
137
  underline?: boolean; noUnderline?: boolean;
127
138
  strikethrough?: boolean; noStrikethrough?: boolean;
128
139
  code?: boolean;
140
+ link?: string; noLink?: boolean;
129
141
  alignment?: string;
130
142
  lineSpacing?: number;
131
143
  headingLevel?: number;
@@ -150,6 +162,8 @@ export function registerExtraDocsTools(server: McpServer): void {
150
162
  if (a.strikethrough) argv.push('--strikethrough');
151
163
  if (a.noStrikethrough) argv.push('--no-strikethrough');
152
164
  if (a.code) argv.push('--code');
165
+ if (a.link) argv.push(`--link=${a.link}`);
166
+ if (a.noLink) argv.push('--no-link');
153
167
  if (a.alignment) argv.push(`--alignment=${a.alignment}`);
154
168
  if (a.lineSpacing !== undefined) argv.push(`--line-spacing=${a.lineSpacing}`);
155
169
  if (a.headingLevel !== undefined) argv.push(`--heading-level=${a.headingLevel}`);
@@ -181,14 +195,20 @@ export function registerExtraDocsTools(server: McpServer): void {
181
195
  content: z.string().optional().describe('Text content to insert'),
182
196
  index: z.number().optional().describe('Character index to insert at (1-based; default: 1 = start of doc). Prefer gog_docs_append when you want to add at the end.'),
183
197
  file: z.string().optional().describe('Path to a file whose content to insert'),
198
+ at: z.string().optional().describe('Anchor by literal text and insert at the start of the matched range, instead of computing an index.'),
199
+ occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
200
+ matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
184
201
  tabId: z.string().optional().describe('Tab ID to insert into (for multi-tab docs)'),
185
202
  account: accountParam,
186
203
  },
187
- }, async ({ docId, content, index, file, tabId, account }) => {
204
+ }, async ({ docId, content, index, file, at, occurrence, matchCase, tabId, account }) => {
188
205
  const args = ['docs', 'insert', docId];
189
206
  if (content) args.push(content);
190
207
  if (index !== undefined) args.push(`--index=${index}`);
191
208
  if (file) args.push(`--file=${file}`);
209
+ if (at) args.push(`--at=${at}`);
210
+ if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
211
+ if (matchCase) args.push('--match-case');
192
212
  if (tabId) args.push(`--tab-id=${tabId}`);
193
213
  return runOrDiagnose(args, { account });
194
214
  });
@@ -258,17 +278,23 @@ export function registerExtraDocsTools(server: McpServer): void {
258
278
  index: z.number().optional().describe('Character index to insert at'),
259
279
  replaceRange: z.string().optional().describe('Replace a UTF-16 Docs API range START:END (e.g. "25:40") instead of inserting at index. The replacement text/file content overwrites the range.'),
260
280
  markdown: z.boolean().optional().describe('Convert markdown in the text/file to Google Docs formatting (headings, bold, lists, etc.) instead of inserting it literally.'),
281
+ at: z.string().optional().describe('Anchor by literal text and replace that matched range, instead of supplying an index or replaceRange.'),
282
+ occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
283
+ matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
261
284
  tabId: z.string().optional().describe('Tab ID for multi-tab docs'),
262
285
  pageless: z.boolean().optional().describe('Set document to pageless format'),
263
286
  account: accountParam,
264
287
  },
265
- }, async ({ docId, text, file, index, replaceRange, markdown, tabId, pageless, account }) => {
288
+ }, async ({ docId, text, file, index, replaceRange, markdown, at, occurrence, matchCase, tabId, pageless, account }) => {
266
289
  const args = ['docs', 'update', docId];
267
290
  if (text) args.push(`--text=${text}`);
268
291
  if (file) args.push(`--file=${file}`);
269
292
  if (index !== undefined) args.push(`--index=${index}`);
270
293
  if (replaceRange) args.push(`--replace-range=${replaceRange}`);
271
294
  if (markdown) args.push('--markdown');
295
+ if (at) args.push(`--at=${at}`);
296
+ if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
297
+ if (matchCase) args.push('--match-case');
272
298
  if (tabId) args.push(`--tab-id=${tabId}`);
273
299
  if (pageless) args.push('--pageless');
274
300
  return runOrDiagnose(args, { account });
@@ -372,6 +398,50 @@ export function registerExtraDocsTools(server: McpServer): void {
372
398
  return runOrDiagnose(['docs', 'comments', 'reopen', docId, commentId], { account });
373
399
  });
374
400
 
401
+ server.registerTool('gog_docs_comments_locate', {
402
+ description: 'Locate a comment\'s anchor in a Google Doc — resolves the comment\'s quoted text to its current Docs API index range, or reports the comment as orphaned if the quote can no longer be found (e.g. the anchored text was edited away). Read-only; useful before an index-based edit near a comment.',
403
+ annotations: { readOnlyHint: true },
404
+ inputSchema: {
405
+ docId: z.string().describe('Doc ID (from the URL)'),
406
+ commentId: z.string().describe('Comment ID to locate'),
407
+ matchCase: z.boolean().optional().describe('Case-sensitive matching of the comment quote'),
408
+ normalizeWhitespace: z.boolean().optional().describe('Collapse whitespace while matching the comment quote'),
409
+ tab: z.string().optional().describe('Target a specific tab by title or ID'),
410
+ account: accountParam,
411
+ },
412
+ }, async ({ docId, commentId, matchCase, normalizeWhitespace, tab, account }) => {
413
+ const args = ['docs', 'comments', 'locate', docId, commentId];
414
+ if (matchCase) args.push('--match-case');
415
+ if (normalizeWhitespace) args.push('--normalize-whitespace');
416
+ if (tab) args.push(`--tab=${tab}`);
417
+ return runOrDiagnose(args, { account });
418
+ });
419
+
420
+ server.registerTool('gog_docs_find_range', {
421
+ description: 'Map literal text in a Google Doc to its Docs API UTF-16 index range(s). Read-only helper for computing the start/end indices that index-based tools (gog_docs_delete, gog_docs_update --replace-range) need. Returns the first match by default; use occurrence to pick a specific one or all to return every match.',
422
+ annotations: { readOnlyHint: true },
423
+ inputSchema: {
424
+ docId: z.string().describe('Doc ID (from the URL)'),
425
+ text: z.string().describe('Literal text to locate'),
426
+ occurrence: z.number().int().optional().describe('Return the Nth occurrence (1-based; default: first)'),
427
+ matchCase: z.boolean().optional().describe('Case-sensitive matching'),
428
+ normalizeWhitespace: z.boolean().optional().describe('Collapse whitespace while matching'),
429
+ all: z.boolean().optional().describe('Return all matches instead of just one'),
430
+ failEmpty: z.boolean().optional().describe('Treat no matches as an error instead of returning an empty result'),
431
+ tab: z.string().optional().describe('Target a specific tab by title or ID'),
432
+ account: accountParam,
433
+ },
434
+ }, async ({ docId, text, occurrence, matchCase, normalizeWhitespace, all, failEmpty, tab, account }) => {
435
+ const args = ['docs', 'find-range', docId, text];
436
+ if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
437
+ if (matchCase) args.push('--match-case');
438
+ if (normalizeWhitespace) args.push('--normalize-whitespace');
439
+ if (all) args.push('--all');
440
+ if (failEmpty) args.push('--fail-empty');
441
+ if (tab) args.push(`--tab=${tab}`);
442
+ return runOrDiagnose(args, { account });
443
+ });
444
+
375
445
  server.registerTool('gog_docs_table_column_width', {
376
446
  description: 'Set a fixed width (in points) for a table column, or reset columns to Docs-managed even distribution. Target the table by 1-based index in document order (negative counts from the end) and the column by 1-based number. Pass evenlyDistributed without col to reset every column in the table.',
377
447
  annotations: { destructiveHint: true },
@@ -401,13 +471,19 @@ export function registerExtraDocsTools(server: McpServer): void {
401
471
  docId: z.string().describe('Doc ID (from the URL)'),
402
472
  index: z.number().int().optional().describe('Character index to insert at (1 = beginning). Omit or use atEnd for end-of-doc.'),
403
473
  atEnd: z.boolean().optional().describe('Insert at end-of-doc/tab (mutually exclusive with index)'),
474
+ at: z.string().optional().describe('Anchor by literal text and insert the page break at the start of the matched range, instead of an index.'),
475
+ occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
476
+ matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
404
477
  tab: z.string().optional().describe('Target a specific tab by title or ID'),
405
478
  account: accountParam,
406
479
  },
407
- }, async ({ docId, index, atEnd, tab, account }) => {
480
+ }, async ({ docId, index, atEnd, at, occurrence, matchCase, tab, account }) => {
408
481
  const args = ['docs', 'insert-page-break', docId];
409
482
  if (index !== undefined) args.push(`--index=${index}`);
410
483
  if (atEnd) args.push('--at-end');
484
+ if (at) args.push(`--at=${at}`);
485
+ if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
486
+ if (matchCase) args.push('--match-case');
411
487
  if (tab) args.push(`--tab=${tab}`);
412
488
  return runOrDiagnose(args, { account });
413
489
  });
@@ -555,13 +631,19 @@ export function registerExtraDocsTools(server: McpServer): void {
555
631
  email: z.string().describe('Email address for the person chip'),
556
632
  index: z.number().int().optional().describe('Character index to insert at. Omit or use atEnd for end-of-doc.'),
557
633
  atEnd: z.boolean().optional().describe('Insert at end-of-doc/tab (mutually exclusive with index)'),
634
+ at: z.string().optional().describe('Anchor by literal text, delete the match, and insert the person chip there, instead of an index.'),
635
+ occurrence: z.number().int().optional().describe('Use the Nth `at` match (1-based; required when `at` is ambiguous)'),
636
+ matchCase: z.boolean().optional().describe('Case-sensitive `at` matching'),
558
637
  tab: z.string().optional().describe('Target a specific tab by title or ID'),
559
638
  account: accountParam,
560
639
  },
561
- }, async ({ docId, email, index, atEnd, tab, account }) => {
640
+ }, async ({ docId, email, index, atEnd, at, occurrence, matchCase, tab, account }) => {
562
641
  const args = ['docs', 'insert-person', docId, `--email=${email}`];
563
642
  if (index !== undefined) args.push(`--index=${index}`);
564
643
  if (atEnd) args.push('--at-end');
644
+ if (at) args.push(`--at=${at}`);
645
+ if (occurrence !== undefined) args.push(`--occurrence=${occurrence}`);
646
+ if (matchCase) args.push('--match-case');
565
647
  if (tab) args.push(`--tab=${tab}`);
566
648
  return runOrDiagnose(args, { account });
567
649
  });
@@ -63,6 +63,17 @@ describe('gog_docs_delete', () => {
63
63
  );
64
64
  });
65
65
 
66
+ // gog 0.23.0
67
+ it('anchors by text with --at/--occurrence/--match-case', async () => {
68
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
69
+ const handlers = setupHandlers();
70
+ await handlers.get('gog_docs_delete')!({ docId: 'abc', at: 'TODO', occurrence: 2, matchCase: true });
71
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
72
+ ['docs', 'delete', 'abc', '--at=TODO', '--occurrence=2', '--match-case'],
73
+ { account: undefined },
74
+ );
75
+ });
76
+
66
77
  it('includes --tab-id when provided', async () => {
67
78
  vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
68
79
  const handlers = setupHandlers();
@@ -179,6 +190,17 @@ describe('gog_docs_insert', () => {
179
190
  );
180
191
  });
181
192
 
193
+ // gog 0.23.0
194
+ it('anchors by text with --at/--occurrence/--match-case', async () => {
195
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
196
+ const handlers = setupHandlers();
197
+ await handlers.get('gog_docs_insert')!({ docId: 'abc', content: 'X', at: 'HERE', occurrence: 3, matchCase: true });
198
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
199
+ ['docs', 'insert', 'abc', 'X', '--at=HERE', '--occurrence=3', '--match-case'],
200
+ { account: undefined },
201
+ );
202
+ });
203
+
182
204
  it('includes --index when provided', async () => {
183
205
  vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
184
206
  const handlers = setupHandlers();
@@ -312,6 +334,17 @@ describe('gog_docs_update', () => {
312
334
  );
313
335
  });
314
336
 
337
+ // gog 0.23.0
338
+ it('anchors by text with --at/--occurrence/--match-case', async () => {
339
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
340
+ const handlers = setupHandlers();
341
+ await handlers.get('gog_docs_update')!({ docId: 'abc', text: 'New', at: 'OLD', occurrence: 1, matchCase: true });
342
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
343
+ ['docs', 'update', 'abc', '--text=New', '--at=OLD', '--occurrence=1', '--match-case'],
344
+ { account: undefined },
345
+ );
346
+ });
347
+
315
348
  it('includes --file when provided', async () => {
316
349
  vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
317
350
  const handlers = setupHandlers();
@@ -495,6 +528,47 @@ describe('gog_docs_table_column_width', () => {
495
528
  });
496
529
  });
497
530
 
531
+ // gog 0.23.0
532
+ describe('gog_docs_find_range', () => {
533
+ it('maps matched text to index ranges', async () => {
534
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
535
+ const handlers = setupHandlers();
536
+ await handlers.get('gog_docs_find_range')!({ docId: 'd1', text: 'hello' });
537
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['docs', 'find-range', 'd1', 'hello'], { account: undefined });
538
+ });
539
+
540
+ it('passes all match flags', async () => {
541
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
542
+ const handlers = setupHandlers();
543
+ await handlers.get('gog_docs_find_range')!({
544
+ docId: 'd1', text: 'hello', occurrence: 2, matchCase: true, normalizeWhitespace: true, all: true, failEmpty: true, tab: 'T',
545
+ });
546
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
547
+ ['docs', 'find-range', 'd1', 'hello', '--occurrence=2', '--match-case', '--normalize-whitespace', '--all', '--fail-empty', '--tab=T'],
548
+ { account: undefined },
549
+ );
550
+ });
551
+ });
552
+
553
+ describe('gog_docs_comments_locate', () => {
554
+ it('locates a comment by id', async () => {
555
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
556
+ const handlers = setupHandlers();
557
+ await handlers.get('gog_docs_comments_locate')!({ docId: 'd1', commentId: 'c1' });
558
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['docs', 'comments', 'locate', 'd1', 'c1'], { account: undefined });
559
+ });
560
+
561
+ it('passes match flags', async () => {
562
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
563
+ const handlers = setupHandlers();
564
+ await handlers.get('gog_docs_comments_locate')!({ docId: 'd1', commentId: 'c1', matchCase: true, normalizeWhitespace: true, tab: 'T' });
565
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
566
+ ['docs', 'comments', 'locate', 'd1', 'c1', '--match-case', '--normalize-whitespace', '--tab=T'],
567
+ { account: undefined },
568
+ );
569
+ });
570
+ });
571
+
498
572
  describe('gog_docs_comments_get', () => {
499
573
  it('calls runOrDiagnose with correct args', async () => {
500
574
  vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
@@ -776,6 +850,27 @@ describe('gog_docs_format', () => {
776
850
  { account: undefined },
777
851
  );
778
852
  });
853
+
854
+ // gog 0.23.0
855
+ it('sets a hyperlink with --link', async () => {
856
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
857
+ const handlers = setupHandlers();
858
+ await handlers.get('gog_docs_format')!({ docId: 'd1', match: 'see docs', link: 'https://example.com' });
859
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
860
+ ['docs', 'format', 'd1', '--match=see docs', '--link=https://example.com'],
861
+ { account: undefined },
862
+ );
863
+ });
864
+
865
+ it('clears a hyperlink with --no-link', async () => {
866
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
867
+ const handlers = setupHandlers();
868
+ await handlers.get('gog_docs_format')!({ docId: 'd1', match: 'linked', noLink: true });
869
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
870
+ ['docs', 'format', 'd1', '--match=linked', '--no-link'],
871
+ { account: undefined },
872
+ );
873
+ });
779
874
  });
780
875
 
781
876
  // --- gog 0.18.0 new tools ---
@@ -791,6 +886,17 @@ describe('gog_docs_insert_page_break', () => {
791
886
  );
792
887
  });
793
888
 
889
+ // gog 0.23.0
890
+ it('anchors by text with --at/--occurrence/--match-case', async () => {
891
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
892
+ const handlers = setupHandlers();
893
+ await handlers.get('gog_docs_insert_page_break')!({ docId: 'd1', at: 'Chapter 2', occurrence: 1, matchCase: true });
894
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
895
+ ['docs', 'insert-page-break', 'd1', '--at=Chapter 2', '--occurrence=1', '--match-case'],
896
+ { account: undefined },
897
+ );
898
+ });
899
+
794
900
  it('uses --at-end when atEnd is true', async () => {
795
901
  vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
796
902
  const handlers = setupHandlers();
@@ -955,6 +1061,17 @@ describe('gog_docs_insert_person', () => {
955
1061
  );
956
1062
  });
957
1063
 
1064
+ // gog 0.23.0
1065
+ it('anchors by text with --at/--occurrence/--match-case', async () => {
1066
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
1067
+ const handlers = setupHandlers();
1068
+ await handlers.get('gog_docs_insert_person')!({ docId: 'd1', email: 'a@b.com', at: '@alice', occurrence: 1, matchCase: true });
1069
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1070
+ ['docs', 'insert-person', 'd1', '--email=a@b.com', '--at=@alice', '--occurrence=1', '--match-case'],
1071
+ { account: undefined },
1072
+ );
1073
+ });
1074
+
958
1075
  it('passes --index, --at-end and --tab', async () => {
959
1076
  vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
960
1077
  const handlers = setupHandlers();