gogcli-mcp-docs 2.0.9 → 2.0.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.
@@ -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.0.9"
10
+ "version": "2.0.11"
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.0.9",
18
+ "version": "2.0.11",
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.0.9",
4
+ "version": "2.0.11",
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/README.md CHANGED
@@ -3,11 +3,11 @@
3
3
  > [!WARNING]
4
4
  > **AI-developed project.** This codebase was built and is actively maintained by [Claude Code](https://www.anthropic.com/claude). Review all code and tool permissions before use.
5
5
 
6
- Extended Google Docs [MCP](https://modelcontextprotocol.io) server via [gogcli](https://github.com/steipete/gogcli). Includes auth tools plus 14 additional dedicated Docs tools for editing, exporting, comments, and more.
6
+ Extended Google Docs [MCP](https://modelcontextprotocol.io) server via [gogcli](https://github.com/openclaw/gogcli). Includes auth tools plus 18 additional dedicated Docs tools for editing, exporting, comments, and more.
7
7
 
8
8
  ## Requirements
9
9
 
10
- - [gogcli](https://github.com/steipete/gogcli) installed and authenticated
10
+ - [gogcli](https://github.com/openclaw/gogcli) installed and authenticated
11
11
  - Node.js 18+
12
12
 
13
13
  ```bash
@@ -44,19 +44,23 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
44
44
  claude mcp add gogcli-docs -- gogcli-mcp-docs
45
45
  ```
46
46
 
47
- ## Extra Docs Tools (14)
47
+ ## Extra Docs Tools (18)
48
48
 
49
49
  Plus 5 auth tools and 7 base Docs tools (info, cat, create, write, find-replace, structure, run).
50
50
 
51
51
  | Tool | Description |
52
52
  |------|-------------|
53
+ | `gog_docs_append` | Append text or markdown to the end of a doc |
53
54
  | `gog_docs_copy` | Copy a document |
54
55
  | `gog_docs_delete` | Delete content by character index range |
55
56
  | `gog_docs_edit` | Find and replace with case-sensitivity control |
56
57
  | `gog_docs_export` | Export as PDF, TXT, HTML, DOCX, RTF, ODT, or EPUB |
58
+ | `gog_docs_format` | Apply character / paragraph formatting (bold, color, alignment, …) |
57
59
  | `gog_docs_insert` | Insert text at a specific position |
58
60
  | `gog_docs_list_tabs` | List all document tabs |
61
+ | `gog_docs_read` | Read doc as plain text or raw JSON |
59
62
  | `gog_docs_sed` | Stream-edit with sed-like regex expressions |
63
+ | `gog_docs_trash` | Move a doc to Drive trash |
60
64
  | `gog_docs_update` | Update document content at a specific position |
61
65
  | `gog_docs_comments_list` | List comments (open or resolved) |
62
66
  | `gog_docs_comments_get` | Get a comment with its replies |
@@ -65,6 +69,16 @@ Plus 5 auth tools and 7 base Docs tools (info, cat, create, write, find-replace,
65
69
  | `gog_docs_comments_resolve` | Resolve a comment |
66
70
  | `gog_docs_comments_delete` | Delete a comment |
67
71
 
72
+ ## Known limitations: `gog_docs_append` with `markdown: true`
73
+
74
+ These bugs live in gogcli's upstream markdown → Docs converter; the wrapper just passes `--markdown` through. Tracked upstream:
75
+
76
+ | Symptom | Workaround | Upstream |
77
+ |---------|-----------|----------|
78
+ | 3+ tables in one call reorders the trailing punctuation of the paragraph before the 3rd table | Split into multiple calls with ≤2 tables each | [openclaw/gogcli#607](https://github.com/openclaw/gogcli/issues/607) |
79
+ | Inline `**bold**` / `*italic*` / `` `code` `` inside table cells renders as literal characters | Insert plain cell text, then apply formatting via `gog_docs_format` | [openclaw/gogcli#608](https://github.com/openclaw/gogcli/issues/608) |
80
+ | Tables with an empty header row leak the last data row as literal pipe text | Always supply a non-empty header row | [openclaw/gogcli#609](https://github.com/openclaw/gogcli/issues/609) |
81
+
68
82
  ## License
69
83
 
70
84
  MIT
package/SKILL.md CHANGED
@@ -5,13 +5,13 @@ description: Use when the user asks to read, write, edit, export, or comment on
5
5
 
6
6
  # gogcli-mcp-docs
7
7
 
8
- Extended Google Docs MCP server via [gogcli](https://github.com/steipete/gogcli) — 26 tools: auth + 7 base Docs + 14 extra dedicated Docs tools.
8
+ Extended Google Docs MCP server via [gogcli](https://github.com/openclaw/gogcli) — 30 tools: auth + 7 base Docs + 18 extra dedicated Docs tools.
9
9
 
10
10
  - **Source:** [github.com/chrischall/gogcli-mcp](https://github.com/chrischall/gogcli-mcp)
11
11
 
12
12
  ## Requirements
13
13
 
14
- - [gogcli](https://github.com/steipete/gogcli) installed and authenticated
14
+ - [gogcli](https://github.com/openclaw/gogcli) installed and authenticated
15
15
  - Node.js 18 or later
16
16
 
17
17
  ## Setup
@@ -34,13 +34,17 @@ Extended Google Docs MCP server via [gogcli](https://github.com/steipete/gogcli)
34
34
 
35
35
  | Tool | What it does |
36
36
  |------|-------------|
37
+ | `gog_docs_append` | Append text or markdown to the end of a doc |
37
38
  | `gog_docs_copy` | Copy a document |
38
39
  | `gog_docs_delete` | Delete content by character index range |
39
40
  | `gog_docs_edit` | Find and replace with case-sensitivity |
40
41
  | `gog_docs_export` | Export as PDF, TXT, HTML, DOCX, RTF, ODT, or EPUB |
42
+ | `gog_docs_format` | Apply character / paragraph formatting |
41
43
  | `gog_docs_insert` | Insert text at a specific position |
42
44
  | `gog_docs_list_tabs` | List all document tabs |
45
+ | `gog_docs_read` | Read doc as plain text or raw JSON |
43
46
  | `gog_docs_sed` | Stream-edit with sed-like regex |
47
+ | `gog_docs_trash` | Move a doc to Drive trash |
44
48
  | `gog_docs_update` | Update content at a specific position |
45
49
  | `gog_docs_comments_list` | List comments (open or resolved) |
46
50
  | `gog_docs_comments_get` | Get a comment with replies |
@@ -50,3 +54,11 @@ Extended Google Docs MCP server via [gogcli](https://github.com/steipete/gogcli)
50
54
  | `gog_docs_comments_delete` | Delete a comment |
51
55
 
52
56
  Plus 5 auth tools and 7 base Docs tools.
57
+
58
+ ### `gog_docs_append` with `markdown: true` — known limitations
59
+
60
+ Three bugs in gogcli's upstream markdown→Docs converter to be aware of:
61
+
62
+ - **3+ tables in one call** reorders the trailing punctuation of the paragraph before the 3rd table — split into multiple calls with ≤2 tables each ([openclaw/gogcli#607](https://github.com/openclaw/gogcli/issues/607)).
63
+ - **Inline `**bold**` / `*italic*` / `` `code` `` inside table cells** renders as literal characters — insert plain cell text, then apply formatting via `gog_docs_format` ([openclaw/gogcli#608](https://github.com/openclaw/gogcli/issues/608)).
64
+ - **Empty-header tables** leak the last data row as literal pipe text — always supply a non-empty header row ([openclaw/gogcli#609](https://github.com/openclaw/gogcli/issues/609)).
package/dist/index.js CHANGED
@@ -31063,7 +31063,7 @@ async function run(args, options = {}) {
31063
31063
  settled = true;
31064
31064
  if (err.code === "ENOENT") {
31065
31065
  reject(new Error(
31066
- "gog executable not found. Install gogcli (https://github.com/steipete/gogcli) or set GOG_PATH in your MCP client config to the absolute binary path (run `which gog` in a terminal to find it)."
31066
+ "gog executable not found. Install gogcli (https://github.com/openclaw/gogcli) or set GOG_PATH in your MCP client config to the absolute binary path (run `which gog` in a terminal to find it)."
31067
31067
  ));
31068
31068
  return;
31069
31069
  }
@@ -31288,8 +31288,11 @@ function registerDocsTools(server2) {
31288
31288
  registerRunTool(server2, { service: "docs", examples: '"copy", "clear", "insert", "sed", "export"' });
31289
31289
  }
31290
31290
 
31291
+ // ../gogcli-mcp/src/tools/sheets.ts
31292
+ var cellValueParam = external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.null()]);
31293
+
31291
31294
  // ../gogcli-mcp/src/server.ts
31292
- var VERSION = true ? "2.0.9" : "0.0.0";
31295
+ var VERSION = true ? "2.0.11" : "0.0.0";
31293
31296
  function createServer(options) {
31294
31297
  return new McpServer({
31295
31298
  name: options?.name ?? "gogcli",
@@ -31313,7 +31316,7 @@ function registerExtraDocsTools(server2) {
31313
31316
  return runOrDiagnose(args, { account });
31314
31317
  });
31315
31318
  server2.registerTool("gog_docs_delete", {
31316
- description: "Delete content within a Google Doc by character index range.",
31319
+ description: "Delete content within a Google Doc by character index range. To remove the entire document (move to Drive trash), use gog_docs_trash.",
31317
31320
  annotations: { destructiveHint: true },
31318
31321
  inputSchema: {
31319
31322
  docId: external_exports.string().describe("Doc ID (from the URL)"),
@@ -31327,6 +31330,16 @@ function registerExtraDocsTools(server2) {
31327
31330
  if (tabId) args.push(`--tab-id=${tabId}`);
31328
31331
  return runOrDiagnose(args, { account });
31329
31332
  });
31333
+ server2.registerTool("gog_docs_trash", {
31334
+ description: "Move an entire Google Doc to Drive trash. Convenience wrapper around `gog drive delete` so docs-only users can clean up without installing gogcli-mcp-drive. The doc remains recoverable from Drive trash for ~30 days.",
31335
+ annotations: { destructiveHint: true },
31336
+ inputSchema: {
31337
+ docId: external_exports.string().describe("Doc ID to move to trash"),
31338
+ account: accountParam
31339
+ }
31340
+ }, async ({ docId, account }) => {
31341
+ return runOrDiagnose(["drive", "delete", docId], { account });
31342
+ });
31330
31343
  server2.registerTool("gog_docs_edit", {
31331
31344
  description: "Edit a Google Doc by finding and replacing text (stream-edit style).",
31332
31345
  annotations: { destructiveHint: true },
@@ -31342,6 +31355,75 @@ function registerExtraDocsTools(server2) {
31342
31355
  if (matchCase) args.push("--match-case");
31343
31356
  return runOrDiagnose(args, { account });
31344
31357
  });
31358
+ server2.registerTool("gog_docs_read", {
31359
+ description: 'Read the content of a Google Doc. Default: plain text body. Use format="json" for the raw Google Docs API response (lossless, includes character indices needed for index-based gog_docs_insert / gog_docs_delete calls). For markdown output, use gog_docs_export with format="md" \u2014 it writes to a file. Use gog_docs_structure to see paragraph-by-paragraph layout with indices.',
31360
+ annotations: { readOnlyHint: true },
31361
+ inputSchema: {
31362
+ docId: external_exports.string().describe("Doc ID (from the URL)"),
31363
+ format: external_exports.enum(["text", "json"]).optional().describe("Output format (default: text)"),
31364
+ tab: external_exports.string().optional().describe("Target tab title or ID (text mode only)"),
31365
+ allTabs: external_exports.boolean().optional().describe("Show all tabs with headers (text mode only)"),
31366
+ maxBytes: external_exports.number().optional().describe("Max bytes to read in text mode (0 = unlimited; default 2000000)"),
31367
+ account: accountParam
31368
+ }
31369
+ }, async ({ docId, format, tab, allTabs, maxBytes, account }) => {
31370
+ if (format === "json") {
31371
+ return runOrDiagnose(["docs", "raw", docId, "--pretty"], { account });
31372
+ }
31373
+ const args = ["docs", "cat", docId];
31374
+ if (tab) args.push(`--tab=${tab}`);
31375
+ if (allTabs) args.push("--all-tabs");
31376
+ if (maxBytes !== void 0) args.push(`--max-bytes=${maxBytes}`);
31377
+ return runOrDiagnose(args, { account });
31378
+ });
31379
+ server2.registerTool("gog_docs_format", {
31380
+ description: "Apply text or paragraph formatting to a Google Doc. Use `match` to format a specific text occurrence, `matchAll` to format every occurrence, or omit both to format the whole doc. Boolean flags (bold/italic/etc.) set the attribute; negated flags (noBold/noItalic/etc.) clear it.",
31381
+ annotations: { destructiveHint: true },
31382
+ inputSchema: {
31383
+ docId: external_exports.string().describe("Doc ID (from the URL)"),
31384
+ match: external_exports.string().optional().describe("Format only the first text match"),
31385
+ matchAll: external_exports.boolean().optional().describe("Format all matches instead of only the first"),
31386
+ matchCase: external_exports.boolean().optional().describe("Case-sensitive matching"),
31387
+ tab: external_exports.string().optional().describe("Target tab title or ID"),
31388
+ fontFamily: external_exports.string().optional().describe("Font family (e.g. Arial, Georgia)"),
31389
+ fontSize: external_exports.number().optional().describe("Font size in points"),
31390
+ textColor: external_exports.string().optional().describe("Text color as #RRGGBB or #RGB"),
31391
+ bgColor: external_exports.string().optional().describe("Text background color as #RRGGBB or #RGB"),
31392
+ bold: external_exports.boolean().optional().describe("Set bold"),
31393
+ noBold: external_exports.boolean().optional().describe("Clear bold"),
31394
+ italic: external_exports.boolean().optional().describe("Set italic"),
31395
+ noItalic: external_exports.boolean().optional().describe("Clear italic"),
31396
+ underline: external_exports.boolean().optional().describe("Set underline"),
31397
+ noUnderline: external_exports.boolean().optional().describe("Clear underline"),
31398
+ strikethrough: external_exports.boolean().optional().describe("Set strikethrough"),
31399
+ noStrikethrough: external_exports.boolean().optional().describe("Clear strikethrough"),
31400
+ alignment: external_exports.enum(["left", "center", "right", "justify", "start", "end", "justified"]).optional().describe("Paragraph alignment"),
31401
+ lineSpacing: external_exports.number().optional().describe("Line spacing percentage (e.g. 100 for single, 150 for 1.5x, 200 for double)"),
31402
+ account: accountParam
31403
+ }
31404
+ }, async (args) => {
31405
+ const a = args;
31406
+ const argv = ["docs", "format", a.docId];
31407
+ if (a.match) argv.push(`--match=${a.match}`);
31408
+ if (a.matchAll) argv.push("--match-all");
31409
+ if (a.matchCase) argv.push("--match-case");
31410
+ if (a.tab) argv.push(`--tab=${a.tab}`);
31411
+ if (a.fontFamily) argv.push(`--font-family=${a.fontFamily}`);
31412
+ if (a.fontSize !== void 0) argv.push(`--font-size=${a.fontSize}`);
31413
+ if (a.textColor) argv.push(`--text-color=${a.textColor}`);
31414
+ if (a.bgColor) argv.push(`--bg-color=${a.bgColor}`);
31415
+ if (a.bold) argv.push("--bold");
31416
+ if (a.noBold) argv.push("--no-bold");
31417
+ if (a.italic) argv.push("--italic");
31418
+ if (a.noItalic) argv.push("--no-italic");
31419
+ if (a.underline) argv.push("--underline");
31420
+ if (a.noUnderline) argv.push("--no-underline");
31421
+ if (a.strikethrough) argv.push("--strikethrough");
31422
+ if (a.noStrikethrough) argv.push("--no-strikethrough");
31423
+ if (a.alignment) argv.push(`--alignment=${a.alignment}`);
31424
+ if (a.lineSpacing !== void 0) argv.push(`--line-spacing=${a.lineSpacing}`);
31425
+ return runOrDiagnose(argv, { account: a.account });
31426
+ });
31345
31427
  server2.registerTool("gog_docs_export", {
31346
31428
  description: "Export a Google Doc as PDF, plain text, HTML, DOCX, or other format.",
31347
31429
  annotations: { readOnlyHint: true },
@@ -31358,12 +31440,12 @@ function registerExtraDocsTools(server2) {
31358
31440
  return runOrDiagnose(args, { account });
31359
31441
  });
31360
31442
  server2.registerTool("gog_docs_insert", {
31361
- description: "Insert text at a specific position in a Google Doc.",
31443
+ description: "Insert text at a specific character index in a Google Doc. When `index` is omitted, gog defaults to 1 (the very beginning), NOT the end \u2014 sequential inserts without an explicit index produce reversed output. To append at the end of the doc, use gog_docs_append (which uses `gog docs write --append` and is the right tool for iterative document construction). To find a valid index for mid-document inserts, call gog_docs_structure or gog_docs_read first.",
31362
31444
  annotations: { destructiveHint: true },
31363
31445
  inputSchema: {
31364
31446
  docId: external_exports.string().describe("Doc ID (from the URL)"),
31365
31447
  content: external_exports.string().optional().describe("Text content to insert"),
31366
- index: external_exports.number().optional().describe("Character index to insert at (default: end of document)"),
31448
+ 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."),
31367
31449
  file: external_exports.string().optional().describe("Path to a file whose content to insert"),
31368
31450
  tabId: external_exports.string().optional().describe("Tab ID to insert into (for multi-tab docs)"),
31369
31451
  account: accountParam
@@ -31376,6 +31458,25 @@ function registerExtraDocsTools(server2) {
31376
31458
  if (tabId) args.push(`--tab-id=${tabId}`);
31377
31459
  return runOrDiagnose(args, { account });
31378
31460
  });
31461
+ server2.registerTool("gog_docs_append", {
31462
+ description: "Append text to the end of a Google Doc. This is the right tool for iterative document construction \u2014 multiple sequential calls produce content in the order they were called. Use gog_docs_insert only when you need to insert at a specific character position. Known markdown=true limitations (tracked upstream): (a) 3+ tables in one call reorders the trailing punctuation of the paragraph before the 3rd table \u2014 split into multiple calls with \u22642 tables each (openclaw/gogcli#607); (b) inline **bold** / *italic* / `code` inside table cells renders as literal characters \u2014 pre-format cell text separately or apply formatting after the append via gog_docs_format (openclaw/gogcli#608); (c) tables with an empty header row leak the last data row as literal pipe text \u2014 always supply a non-empty header (openclaw/gogcli#609).",
31463
+ annotations: { destructiveHint: true },
31464
+ inputSchema: {
31465
+ docId: external_exports.string().describe("Doc ID (from the URL)"),
31466
+ text: external_exports.string().optional().describe("Text content to append"),
31467
+ file: external_exports.string().optional().describe('Path to a text file to append (use "-" for stdin)'),
31468
+ markdown: external_exports.boolean().optional().describe("Convert markdown to Google Docs formatting (headings, bold, lists, etc.). See the tool description for known upstream limitations around tables."),
31469
+ tab: external_exports.string().optional().describe("Target tab title or ID (for multi-tab docs)"),
31470
+ account: accountParam
31471
+ }
31472
+ }, async ({ docId, text, file: file2, markdown, tab, account }) => {
31473
+ const args = ["docs", "write", docId, "--append"];
31474
+ if (text) args.push(`--text=${text}`);
31475
+ if (file2) args.push(`--file=${file2}`);
31476
+ if (markdown) args.push("--markdown");
31477
+ if (tab) args.push(`--tab=${tab}`);
31478
+ return runOrDiagnose(args, { account });
31479
+ });
31379
31480
  server2.registerTool("gog_docs_list_tabs", {
31380
31481
  description: "List all tabs in a Google Doc.",
31381
31482
  annotations: { readOnlyHint: true },
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.0.9",
6
+ "version": "2.0.11",
7
7
  "description": "Extended Google Docs for Claude via gogcli — auth + full Docs and comments support",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -158,6 +158,22 @@
158
158
  {
159
159
  "name": "gog_docs_comments_delete",
160
160
  "description": "Delete a comment from a Google Doc. This action is permanent."
161
+ },
162
+ {
163
+ "name": "gog_docs_append",
164
+ "description": "Append text to the end of a Google Doc (the right tool for iterative construction). markdown=true known limitations (upstream): 3+ tables/call reorders trailing punctuation (split calls); inline bold/italic/code in cells renders literally (format after); empty-header tables leak literal pipes (use non-empty header)."
165
+ },
166
+ {
167
+ "name": "gog_docs_read",
168
+ "description": "Read a Google Doc as plain text or as raw Docs API JSON"
169
+ },
170
+ {
171
+ "name": "gog_docs_format",
172
+ "description": "Apply text/paragraph formatting (bold, italic, font size, color, alignment, line spacing) — supports match, matchAll, matchCase"
173
+ },
174
+ {
175
+ "name": "gog_docs_trash",
176
+ "description": "Move an entire Google Doc to Drive trash (convenience wrapper around gog drive delete)"
161
177
  }
162
178
  ],
163
179
  "compatibility": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp-docs",
3
- "version": "2.0.9",
3
+ "version": "2.0.11",
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.0.9",
10
+ "version": "2.0.11",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp-docs",
15
- "version": "2.0.9",
15
+ "version": "2.0.11",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -18,7 +18,7 @@ export function registerExtraDocsTools(server: McpServer): void {
18
18
  });
19
19
 
20
20
  server.registerTool('gog_docs_delete', {
21
- description: 'Delete content within a Google Doc by character index range.',
21
+ description: 'Delete content within a Google Doc by character index range. To remove the entire document (move to Drive trash), use gog_docs_trash.',
22
22
  annotations: { destructiveHint: true },
23
23
  inputSchema: {
24
24
  docId: z.string().describe('Doc ID (from the URL)'),
@@ -33,6 +33,17 @@ export function registerExtraDocsTools(server: McpServer): void {
33
33
  return runOrDiagnose(args, { account });
34
34
  });
35
35
 
36
+ server.registerTool('gog_docs_trash', {
37
+ description: 'Move an entire Google Doc to Drive trash. Convenience wrapper around `gog drive delete` so docs-only users can clean up without installing gogcli-mcp-drive. The doc remains recoverable from Drive trash for ~30 days.',
38
+ annotations: { destructiveHint: true },
39
+ inputSchema: {
40
+ docId: z.string().describe('Doc ID to move to trash'),
41
+ account: accountParam,
42
+ },
43
+ }, async ({ docId, account }) => {
44
+ return runOrDiagnose(['drive', 'delete', docId], { account });
45
+ });
46
+
36
47
  server.registerTool('gog_docs_edit', {
37
48
  description: 'Edit a Google Doc by finding and replacing text (stream-edit style).',
38
49
  annotations: { destructiveHint: true },
@@ -49,6 +60,94 @@ export function registerExtraDocsTools(server: McpServer): void {
49
60
  return runOrDiagnose(args, { account });
50
61
  });
51
62
 
63
+ server.registerTool('gog_docs_read', {
64
+ description: 'Read the content of a Google Doc. Default: plain text body. Use format="json" for the raw Google Docs API response (lossless, includes character indices needed for index-based gog_docs_insert / gog_docs_delete calls). For markdown output, use gog_docs_export with format="md" — it writes to a file. Use gog_docs_structure to see paragraph-by-paragraph layout with indices.',
65
+ annotations: { readOnlyHint: true },
66
+ inputSchema: {
67
+ docId: z.string().describe('Doc ID (from the URL)'),
68
+ format: z.enum(['text', 'json']).optional().describe('Output format (default: text)'),
69
+ tab: z.string().optional().describe('Target tab title or ID (text mode only)'),
70
+ allTabs: z.boolean().optional().describe('Show all tabs with headers (text mode only)'),
71
+ maxBytes: z.number().optional().describe('Max bytes to read in text mode (0 = unlimited; default 2000000)'),
72
+ account: accountParam,
73
+ },
74
+ }, async ({ docId, format, tab, allTabs, maxBytes, account }) => {
75
+ if (format === 'json') {
76
+ return runOrDiagnose(['docs', 'raw', docId, '--pretty'], { account });
77
+ }
78
+ const args = ['docs', 'cat', docId];
79
+ if (tab) args.push(`--tab=${tab}`);
80
+ if (allTabs) args.push('--all-tabs');
81
+ if (maxBytes !== undefined) args.push(`--max-bytes=${maxBytes}`);
82
+ return runOrDiagnose(args, { account });
83
+ });
84
+
85
+ server.registerTool('gog_docs_format', {
86
+ description: 'Apply text or paragraph formatting to a Google Doc. Use `match` to format a specific text occurrence, `matchAll` to format every occurrence, or omit both to format the whole doc. Boolean flags (bold/italic/etc.) set the attribute; negated flags (noBold/noItalic/etc.) clear it.',
87
+ annotations: { destructiveHint: true },
88
+ inputSchema: {
89
+ docId: z.string().describe('Doc ID (from the URL)'),
90
+ match: z.string().optional().describe('Format only the first text match'),
91
+ matchAll: z.boolean().optional().describe('Format all matches instead of only the first'),
92
+ matchCase: z.boolean().optional().describe('Case-sensitive matching'),
93
+ tab: z.string().optional().describe('Target tab title or ID'),
94
+ fontFamily: z.string().optional().describe('Font family (e.g. Arial, Georgia)'),
95
+ fontSize: z.number().optional().describe('Font size in points'),
96
+ textColor: z.string().optional().describe('Text color as #RRGGBB or #RGB'),
97
+ bgColor: z.string().optional().describe('Text background color as #RRGGBB or #RGB'),
98
+ bold: z.boolean().optional().describe('Set bold'),
99
+ noBold: z.boolean().optional().describe('Clear bold'),
100
+ italic: z.boolean().optional().describe('Set italic'),
101
+ noItalic: z.boolean().optional().describe('Clear italic'),
102
+ underline: z.boolean().optional().describe('Set underline'),
103
+ noUnderline: z.boolean().optional().describe('Clear underline'),
104
+ strikethrough: z.boolean().optional().describe('Set strikethrough'),
105
+ noStrikethrough: z.boolean().optional().describe('Clear strikethrough'),
106
+ alignment: z.enum(['left', 'center', 'right', 'justify', 'start', 'end', 'justified']).optional().describe('Paragraph alignment'),
107
+ lineSpacing: z.number().optional().describe('Line spacing percentage (e.g. 100 for single, 150 for 1.5x, 200 for double)'),
108
+ account: accountParam,
109
+ },
110
+ }, async (args) => {
111
+ const a = args as {
112
+ docId: string;
113
+ match?: string;
114
+ matchAll?: boolean;
115
+ matchCase?: boolean;
116
+ tab?: string;
117
+ fontFamily?: string;
118
+ fontSize?: number;
119
+ textColor?: string;
120
+ bgColor?: string;
121
+ bold?: boolean; noBold?: boolean;
122
+ italic?: boolean; noItalic?: boolean;
123
+ underline?: boolean; noUnderline?: boolean;
124
+ strikethrough?: boolean; noStrikethrough?: boolean;
125
+ alignment?: string;
126
+ lineSpacing?: number;
127
+ account?: string;
128
+ };
129
+ const argv = ['docs', 'format', a.docId];
130
+ if (a.match) argv.push(`--match=${a.match}`);
131
+ if (a.matchAll) argv.push('--match-all');
132
+ if (a.matchCase) argv.push('--match-case');
133
+ if (a.tab) argv.push(`--tab=${a.tab}`);
134
+ if (a.fontFamily) argv.push(`--font-family=${a.fontFamily}`);
135
+ if (a.fontSize !== undefined) argv.push(`--font-size=${a.fontSize}`);
136
+ if (a.textColor) argv.push(`--text-color=${a.textColor}`);
137
+ if (a.bgColor) argv.push(`--bg-color=${a.bgColor}`);
138
+ if (a.bold) argv.push('--bold');
139
+ if (a.noBold) argv.push('--no-bold');
140
+ if (a.italic) argv.push('--italic');
141
+ if (a.noItalic) argv.push('--no-italic');
142
+ if (a.underline) argv.push('--underline');
143
+ if (a.noUnderline) argv.push('--no-underline');
144
+ if (a.strikethrough) argv.push('--strikethrough');
145
+ if (a.noStrikethrough) argv.push('--no-strikethrough');
146
+ if (a.alignment) argv.push(`--alignment=${a.alignment}`);
147
+ if (a.lineSpacing !== undefined) argv.push(`--line-spacing=${a.lineSpacing}`);
148
+ return runOrDiagnose(argv, { account: a.account });
149
+ });
150
+
52
151
  server.registerTool('gog_docs_export', {
53
152
  description: 'Export a Google Doc as PDF, plain text, HTML, DOCX, or other format.',
54
153
  annotations: { readOnlyHint: true },
@@ -66,12 +165,12 @@ export function registerExtraDocsTools(server: McpServer): void {
66
165
  });
67
166
 
68
167
  server.registerTool('gog_docs_insert', {
69
- description: 'Insert text at a specific position in a Google Doc.',
168
+ description: 'Insert text at a specific character index in a Google Doc. When `index` is omitted, gog defaults to 1 (the very beginning), NOT the end — sequential inserts without an explicit index produce reversed output. To append at the end of the doc, use gog_docs_append (which uses `gog docs write --append` and is the right tool for iterative document construction). To find a valid index for mid-document inserts, call gog_docs_structure or gog_docs_read first.',
70
169
  annotations: { destructiveHint: true },
71
170
  inputSchema: {
72
171
  docId: z.string().describe('Doc ID (from the URL)'),
73
172
  content: z.string().optional().describe('Text content to insert'),
74
- index: z.number().optional().describe('Character index to insert at (default: end of document)'),
173
+ 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.'),
75
174
  file: z.string().optional().describe('Path to a file whose content to insert'),
76
175
  tabId: z.string().optional().describe('Tab ID to insert into (for multi-tab docs)'),
77
176
  account: accountParam,
@@ -85,6 +184,26 @@ export function registerExtraDocsTools(server: McpServer): void {
85
184
  return runOrDiagnose(args, { account });
86
185
  });
87
186
 
187
+ server.registerTool('gog_docs_append', {
188
+ description: 'Append text to the end of a Google Doc. This is the right tool for iterative document construction — multiple sequential calls produce content in the order they were called. Use gog_docs_insert only when you need to insert at a specific character position. Known markdown=true limitations (tracked upstream): (a) 3+ tables in one call reorders the trailing punctuation of the paragraph before the 3rd table — split into multiple calls with ≤2 tables each (openclaw/gogcli#607); (b) inline **bold** / *italic* / `code` inside table cells renders as literal characters — pre-format cell text separately or apply formatting after the append via gog_docs_format (openclaw/gogcli#608); (c) tables with an empty header row leak the last data row as literal pipe text — always supply a non-empty header (openclaw/gogcli#609).',
189
+ annotations: { destructiveHint: true },
190
+ inputSchema: {
191
+ docId: z.string().describe('Doc ID (from the URL)'),
192
+ text: z.string().optional().describe('Text content to append'),
193
+ file: z.string().optional().describe('Path to a text file to append (use "-" for stdin)'),
194
+ markdown: z.boolean().optional().describe('Convert markdown to Google Docs formatting (headings, bold, lists, etc.). See the tool description for known upstream limitations around tables.'),
195
+ tab: z.string().optional().describe('Target tab title or ID (for multi-tab docs)'),
196
+ account: accountParam,
197
+ },
198
+ }, async ({ docId, text, file, markdown, tab, account }) => {
199
+ const args = ['docs', 'write', docId, '--append'];
200
+ if (text) args.push(`--text=${text}`);
201
+ if (file) args.push(`--file=${file}`);
202
+ if (markdown) args.push('--markdown');
203
+ if (tab) args.push(`--tab=${tab}`);
204
+ return runOrDiagnose(args, { account });
205
+ });
206
+
88
207
  server.registerTool('gog_docs_list_tabs', {
89
208
  description: 'List all tabs in a Google Doc.',
90
209
  annotations: { readOnlyHint: true },
@@ -518,3 +518,151 @@ describe('gog_docs_comments_delete', () => {
518
518
  );
519
519
  });
520
520
  });
521
+
522
+ describe('gog_docs_trash', () => {
523
+ it('routes to gog drive delete <docId>', async () => {
524
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
525
+ const handlers = setupHandlers();
526
+ await handlers.get('gog_docs_trash')!({ docId: 'd1' });
527
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['drive', 'delete', 'd1'], { account: undefined });
528
+ });
529
+ });
530
+
531
+ describe('gog_docs_append', () => {
532
+ it('uses gog docs write --append', async () => {
533
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
534
+ const handlers = setupHandlers();
535
+ await handlers.get('gog_docs_append')!({ docId: 'd1', text: 'Hello' });
536
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
537
+ ['docs', 'write', 'd1', '--append', '--text=Hello'],
538
+ { account: undefined },
539
+ );
540
+ });
541
+
542
+ it('passes file, markdown, and tab flags', async () => {
543
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
544
+ const handlers = setupHandlers();
545
+ await handlers.get('gog_docs_append')!({
546
+ docId: 'd1', file: '/tmp/section.md', markdown: true, tab: 'Notes',
547
+ });
548
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
549
+ ['docs', 'write', 'd1', '--append', '--file=/tmp/section.md', '--markdown', '--tab=Notes'],
550
+ { account: undefined },
551
+ );
552
+ });
553
+
554
+ // Regression: the tool description must warn about the 3 known upstream
555
+ // markdown converter bugs (openclaw/gogcli#607, #608, #609). If upstream
556
+ // fixes any of these and the warning is removed, this test fails as a
557
+ // prompt to revisit + reopen the README/SKILL/TODO sections too.
558
+ it('description warns about all 3 known upstream markdown limitations', async () => {
559
+ // Local mock to capture the registration config (the shared harness only
560
+ // captures the handler callback).
561
+ const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
562
+ const server = new McpServer({ name: 'test', version: '0.0.0' });
563
+ const configs = new Map<string, { description?: string }>();
564
+ vi.spyOn(server, 'registerTool').mockImplementation((name, config) => {
565
+ configs.set(name, config as { description?: string });
566
+ return undefined as never;
567
+ });
568
+ const { registerExtraDocsTools } = await import('../../src/tools/docs-extra.js');
569
+ registerExtraDocsTools(server);
570
+ const desc = configs.get('gog_docs_append')?.description ?? '';
571
+ expect(desc).toMatch(/openclaw\/gogcli#607/);
572
+ expect(desc).toMatch(/openclaw\/gogcli#608/);
573
+ expect(desc).toMatch(/openclaw\/gogcli#609/);
574
+ });
575
+ });
576
+
577
+ describe('gog_docs_read', () => {
578
+ it('defaults to plain text via gog docs cat', async () => {
579
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('hello'));
580
+ const handlers = setupHandlers();
581
+ await handlers.get('gog_docs_read')!({ docId: 'd1' });
582
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['docs', 'cat', 'd1'], { account: undefined });
583
+ });
584
+
585
+ it('routes json format to gog docs raw --pretty', async () => {
586
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
587
+ const handlers = setupHandlers();
588
+ await handlers.get('gog_docs_read')!({ docId: 'd1', format: 'json' });
589
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['docs', 'raw', 'd1', '--pretty'], { account: undefined });
590
+ });
591
+
592
+ it('passes tab, allTabs, maxBytes in text mode', async () => {
593
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText(''));
594
+ const handlers = setupHandlers();
595
+ await handlers.get('gog_docs_read')!({ docId: 'd1', tab: 'Section A', allTabs: true, maxBytes: 0 });
596
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
597
+ ['docs', 'cat', 'd1', '--tab=Section A', '--all-tabs', '--max-bytes=0'],
598
+ { account: undefined },
599
+ );
600
+ });
601
+ });
602
+
603
+ describe('gog_docs_format', () => {
604
+ it('passes all text/paragraph attribute flags', async () => {
605
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
606
+ const handlers = setupHandlers();
607
+ await handlers.get('gog_docs_format')!({
608
+ docId: 'd1',
609
+ match: 'Title',
610
+ matchAll: true,
611
+ matchCase: true,
612
+ tab: 'Body',
613
+ fontFamily: 'Arial',
614
+ fontSize: 18,
615
+ textColor: '#333333',
616
+ bgColor: '#FFF5D9',
617
+ bold: true,
618
+ italic: true,
619
+ underline: true,
620
+ strikethrough: true,
621
+ alignment: 'center',
622
+ lineSpacing: 150,
623
+ });
624
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
625
+ [
626
+ 'docs', 'format', 'd1',
627
+ '--match=Title',
628
+ '--match-all',
629
+ '--match-case',
630
+ '--tab=Body',
631
+ '--font-family=Arial',
632
+ '--font-size=18',
633
+ '--text-color=#333333',
634
+ '--bg-color=#FFF5D9',
635
+ '--bold',
636
+ '--italic',
637
+ '--underline',
638
+ '--strikethrough',
639
+ '--alignment=center',
640
+ '--line-spacing=150',
641
+ ],
642
+ { account: undefined },
643
+ );
644
+ });
645
+
646
+ it('emits clear-style flags (noBold/noItalic/...) without the set variants', async () => {
647
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
648
+ const handlers = setupHandlers();
649
+ await handlers.get('gog_docs_format')!({
650
+ docId: 'd1',
651
+ noBold: true,
652
+ noItalic: true,
653
+ noUnderline: true,
654
+ noStrikethrough: true,
655
+ });
656
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
657
+ ['docs', 'format', 'd1', '--no-bold', '--no-italic', '--no-underline', '--no-strikethrough'],
658
+ { account: undefined },
659
+ );
660
+ });
661
+
662
+ it('omits all flags when not provided (whole-doc no-op call passes through)', async () => {
663
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
664
+ const handlers = setupHandlers();
665
+ await handlers.get('gog_docs_format')!({ docId: 'd1' });
666
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['docs', 'format', 'd1'], { account: undefined });
667
+ });
668
+ });