gogcli-mcp-docs 4.8.1 → 4.10.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.
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": "4.8.1",
6
+ "version": "4.10.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",
@@ -414,6 +414,10 @@
414
414
  {
415
415
  "name": "gog_docs_insert_date_chip",
416
416
  "description": "Insert a native Google Docs date smart chip."
417
+ },
418
+ {
419
+ "name": "gog_upload_slots",
420
+ "description": "Get signed upload URLs so a hosted (remote) server can receive files you hold; pass the returned blobKeys to the tool that takes the file"
417
421
  }
418
422
  ],
419
423
  "compatibility": {
package/mint.yaml CHANGED
@@ -4,6 +4,10 @@ slug: gogcli-mcp-docs
4
4
  summary: >-
5
5
  Extended Google Docs MCP server via gogcli — all base tools plus full Docs
6
6
  support
7
+ examples:
8
+ - prompt: "Summarize this Google Doc for me: <doc link>"
9
+ - prompt: "Show me the outline of headings in my project proposal doc."
10
+ - prompt: "List the open comments on the design spec and who left each one."
7
11
  command:
8
12
  # This package publishes a single bin; naming it keeps the install
9
13
  # unambiguous alongside its eight sibling packages.
@@ -92,7 +96,7 @@ dependencies:
92
96
  # bumping these nine pins, and the pin stored on each mcp-host registration.
93
97
  - kind: github-release
94
98
  repo: openclaw/gogcli
95
- tag: v0.44.0
99
+ tag: v0.46.0
96
100
  asset: "gogcli_*_linux_amd64.tar.gz"
97
101
  bin: [gog]
98
102
  state:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp-docs",
3
- "version": "4.8.1",
3
+ "version": "4.10.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>",
@@ -24,7 +24,7 @@
24
24
  "test:coverage": "vitest run --coverage"
25
25
  },
26
26
  "dependencies": {
27
- "@chrischall/mcp-utils": "^3.0.3",
27
+ "@chrischall/mcp-utils": "^3.6.0",
28
28
  "@modelcontextprotocol/server": "^2.3.1",
29
29
  "zod": "^4.6.1"
30
30
  },
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp-docs"
9
9
  },
10
- "version": "4.8.1",
10
+ "version": "4.10.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp-docs",
15
- "version": "4.8.1",
15
+ "version": "4.10.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { runMcp } from '@chrischall/mcp-utils';
3
- import { VERSION, authToolsFor, registerDocsTools, bootstrapGogAuth } from '../../gogcli-mcp/src/lib.js';
3
+ import { VERSION, authToolsFor, registerDocsTools, registerUploadTools, bootstrapGogAuth } from '../../gogcli-mcp/src/lib.js';
4
4
  import { registerExtraDocsTools } from './tools/docs-extra.js';
5
5
 
6
6
 
@@ -10,5 +10,5 @@ await bootstrapGogAuth();
10
10
  await runMcp({
11
11
  name: 'gogcli-docs',
12
12
  version: VERSION,
13
- tools: [authToolsFor('docs'), registerDocsTools, registerExtraDocsTools],
13
+ tools: [authToolsFor('docs'), registerDocsTools, registerExtraDocsTools, registerUploadTools],
14
14
  });
@@ -8,6 +8,11 @@ import {
8
8
  payloadArg,
9
9
  pos,
10
10
  confinePath,
11
+ assertOneFileSource,
12
+ confineUploadPath,
13
+ readBlobFiles,
14
+ releaseBlobs,
15
+ singleBlobKeyParam,
11
16
  bodyPreview,
12
17
  commentMentions,
13
18
  commentSnapshot,
@@ -18,6 +23,9 @@ import {
18
23
  } from '../../../gogcli-mcp/src/lib.js';
19
24
  import type { GogArg } from '../../../gogcli-mcp/src/lib.js';
20
25
 
26
+ const URL_ALTERNATIVE = 'or pass a public HTTPS image `url`';
27
+ const IMAGE_BLOB_NOTE = ' On a hosted deployment, where no path on your machine exists, upload the image with gog_upload_slots and pass blobKeys instead of file.';
28
+
21
29
  /** A Doc's title from `gog docs info`, for a comment's prompt. Unreadable output names nothing. */
22
30
  export function docTitle(raw: string): string | undefined {
23
31
  try {
@@ -1135,12 +1143,13 @@ export function registerExtraDocsTools(server: McpServer): void {
1135
1143
  });
1136
1144
 
1137
1145
  server.registerTool('gog_docs_insert_image', {
1138
- description: 'Insert an image into a Google Doc from a local file or a public HTTPS URL. file: uploaded to Drive, temporarily shared so Docs can fetch it, inserted, then the public permission is revoked. url: inserted directly with no Drive upload or temporary sharing. Replaces placeholder text (at) or appends at end-of-doc.',
1146
+ description: 'Insert an image into a Google Doc from a local file or a public HTTPS URL. file: uploaded to Drive, temporarily shared so Docs can fetch it, inserted, then the public permission is revoked. url: inserted directly with no Drive upload or temporary sharing. Replaces placeholder text (at) or appends at end-of-doc.' + IMAGE_BLOB_NOTE,
1139
1147
  annotations: { destructiveHint: true, openWorldHint: true },
1140
1148
  inputSchema: z.object({
1141
1149
  docId: z.string().describe('Doc ID (from the URL)'),
1142
- file: z.string().optional().describe('Local PNG, JPEG, or GIF image to upload and insert (exactly one of file or url)'),
1143
- url: z.string().optional().describe('Public HTTPS image URL to insert directly — no Drive upload or temporary public sharing (exactly one of file or url)'),
1150
+ file: z.string().optional().describe('PNG, JPEG, or GIF image on the gog server to upload and insert (exactly one of file, url or blobKeys)'),
1151
+ url: z.string().optional().describe('Public HTTPS image URL to insert directly — no Drive upload or temporary public sharing (exactly one of file, url or blobKeys)'),
1152
+ blobKeys: singleBlobKeyParam,
1144
1153
  at: z.string().optional().describe('Placeholder text to replace, or "end" to append (default: end)'),
1145
1154
  before: z.string().optional().describe('Insert the image immediately before the first literal text match (no text is deleted)'),
1146
1155
  after: z.string().optional().describe('Insert the image immediately after the first literal text match (no text is deleted)'),
@@ -1152,10 +1161,13 @@ export function registerExtraDocsTools(server: McpServer): void {
1152
1161
  tab: z.string().optional().describe('Target a specific tab by title or ID'),
1153
1162
  account: accountParam,
1154
1163
  }),
1155
- }, async ({ docId, file, url, at, before, after, width, height, name, parent, onRestricted, tab, account }) => {
1156
- if (file) confinePath(file, 'file');
1164
+ }, async ({ docId, file, url, blobKeys, at, before, after, width, height, name, parent, onRestricted, tab, account }) => {
1165
+ if (file) confineUploadPath(file, 'file', URL_ALTERNATIVE);
1166
+ assertOneFileSource({ file, url, blobKeys });
1167
+ const blobs = await readBlobFiles(blobKeys, 'file');
1157
1168
  const args: GogArg[] = ['docs', 'insert-image', pos(docId)];
1158
1169
  if (file) args.push(`--file=${file}`);
1170
+ for (const blob of blobs) args.push(blob.arg);
1159
1171
  if (url) args.push(`--url=${url}`);
1160
1172
  if (at) args.push(`--at=${at}`);
1161
1173
  if (before) args.push(`--before=${before}`);
@@ -1166,8 +1178,8 @@ export function registerExtraDocsTools(server: McpServer): void {
1166
1178
  if (parent) args.push(`--parent=${parent}`);
1167
1179
  if (onRestricted) args.push(`--on-restricted=${onRestricted}`);
1168
1180
  if (tab) args.push(`--tab=${tab}`);
1169
- if (file) args.push('--force'); // gog gates this op; without --force the runner's --no-input makes it refuse (local files upload via a temporary public share)
1170
- return runOrDiagnose(args, { account });
1181
+ if (file || blobs.length > 0) args.push('--force'); // gog gates this op; without --force the runner's --no-input makes it refuse (local files upload via a temporary public share)
1182
+ return releaseBlobs(await runOrDiagnose(args, { account }), blobs);
1171
1183
  });
1172
1184
 
1173
1185
  server.registerTool('gog_docs_insert_person', {
@@ -1517,12 +1529,13 @@ export function registerExtraDocsTools(server: McpServer): void {
1517
1529
  // --- gog 0.30 image replacement & table-row pin/style ---
1518
1530
 
1519
1531
  server.registerTool('gog_docs_replace_image', {
1520
- description: 'Replace an existing image in a Google Doc in place — keeping its position and bounds — with a new image from a local file or public HTTPS URL. Target the image by exact object ID (from gog_docs_images_list), by alt-text substring, or leave both off to replace the only image in the doc/tab.',
1532
+ description: 'Replace an existing image in a Google Doc in place — keeping its position and bounds — with a new image from a local file or public HTTPS URL. Target the image by exact object ID (from gog_docs_images_list), by alt-text substring, or leave both off to replace the only image in the doc/tab.' + IMAGE_BLOB_NOTE,
1521
1533
  annotations: { destructiveHint: true, openWorldHint: true },
1522
1534
  inputSchema: z.object({
1523
1535
  docId: z.string().describe('Doc ID (from the URL)'),
1524
- file: z.string().optional().describe('Local PNG, JPEG, or GIF image to upload and use (exactly one of file or url)'),
1525
- url: z.string().optional().describe('Public HTTPS image URL to use directly — no Drive upload (exactly one of file or url)'),
1536
+ file: z.string().optional().describe('PNG, JPEG, or GIF image on the gog server to upload and use (exactly one of file, url or blobKeys)'),
1537
+ url: z.string().optional().describe('Public HTTPS image URL to use directly — no Drive upload (exactly one of file, url or blobKeys)'),
1538
+ blobKeys: singleBlobKeyParam,
1526
1539
  objectId: z.string().optional().describe('Exact image object ID to replace (from gog_docs_images_list)'),
1527
1540
  matchAlt: z.string().optional().describe('Select the image whose alt text contains this value (case-insensitive)'),
1528
1541
  name: z.string().optional().describe('Override the uploaded Drive filename (file mode)'),
@@ -1530,18 +1543,21 @@ export function registerExtraDocsTools(server: McpServer): void {
1530
1543
  tab: z.string().optional().describe('Target a specific tab by title or ID'),
1531
1544
  account: accountParam,
1532
1545
  }),
1533
- }, async ({ docId, file, url, objectId, matchAlt, name, parent, tab, account }) => {
1534
- if (file) confinePath(file, 'file');
1546
+ }, async ({ docId, file, url, blobKeys, objectId, matchAlt, name, parent, tab, account }) => {
1547
+ if (file) confineUploadPath(file, 'file', URL_ALTERNATIVE);
1548
+ assertOneFileSource({ file, url, blobKeys });
1549
+ const blobs = await readBlobFiles(blobKeys, 'file');
1535
1550
  const args: GogArg[] = ['docs', 'replace-image', pos(docId)];
1536
1551
  if (file) args.push(`--file=${file}`);
1552
+ for (const blob of blobs) args.push(blob.arg);
1537
1553
  if (url) args.push(`--url=${url}`);
1538
1554
  if (objectId) args.push(`--object-id=${objectId}`);
1539
1555
  if (matchAlt) args.push(`--match-alt=${matchAlt}`);
1540
1556
  if (name) args.push(`--name=${name}`);
1541
1557
  if (parent) args.push(`--parent=${parent}`);
1542
1558
  if (tab) args.push(`--tab=${tab}`);
1543
- if (file) args.push('--force'); // gog gates this op; without --force the runner's --no-input makes it refuse (local files upload via a temporary public share)
1544
- return runOrDiagnose(args, { account });
1559
+ if (file || blobs.length > 0) args.push('--force'); // gog gates this op; without --force the runner's --no-input makes it refuse (local files upload via a temporary public share)
1560
+ return releaseBlobs(await runOrDiagnose(args, { account }), blobs);
1545
1561
  });
1546
1562
 
1547
1563
  server.registerTool('gog_docs_table_row_pin_header', {
@@ -1,10 +1,10 @@
1
- import { authToolsFor, registerDocsTools } from '../../gogcli-mcp/src/lib.js';
1
+ import { authToolsFor, registerDocsTools, registerUploadTools } from '../../gogcli-mcp/src/lib.js';
2
2
  import { registerExtraDocsTools } from '../src/tools/docs-extra.js';
3
3
  import { describeToolAnnotations, LOCAL_AUTH_TOOLS } from '../../gogcli-mcp/tests/support/tool-annotations.js';
4
4
 
5
5
  // The same registrar list src/index.ts hands to runMcp.
6
- describeToolAnnotations([authToolsFor('docs'), registerDocsTools, registerExtraDocsTools] as never, {
7
- tools: 83,
6
+ describeToolAnnotations([authToolsFor('docs'), registerDocsTools, registerExtraDocsTools, registerUploadTools] as never, {
7
+ tools: 84,
8
8
  destructive: 52,
9
- local: [...LOCAL_AUTH_TOOLS, 'gog_batch_abort', 'gog_batch_begin', 'gog_batch_list', 'gog_batch_prune', 'gog_batch_show'],
9
+ local: [...LOCAL_AUTH_TOOLS, 'gog_upload_slots', 'gog_batch_abort', 'gog_batch_begin', 'gog_batch_list', 'gog_batch_prune', 'gog_batch_show'],
10
10
  });
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { createTestHarness, type TestHarness } from '@chrischall/mcp-utils/test';
6
+ import { rawTextResult, errorResult } from '@chrischall/mcp-utils';
7
+ import { blobStoreFromEnv, mintUploadSlot } from '@chrischall/mcp-utils/export';
8
+ import * as lib from '../../../gogcli-mcp/src/lib.js';
9
+ import type { GogArg, GogFileArg } from '../../../gogcli-mcp/src/lib.js';
10
+ import { registerExtraDocsTools } from '../../src/tools/docs-extra.js';
11
+ import { installFakeBlobStore, type FakeBlobStore } from '../../../gogcli-mcp/tests/support/fake-blob-store.js';
12
+
13
+ vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
14
+ const actual = await importOriginal<typeof lib>();
15
+ return { ...actual, runOrDiagnose: vi.fn() };
16
+ });
17
+
18
+ const ORIGINAL_ENV = { ...process.env };
19
+ let fake: FakeBlobStore;
20
+ let harness: TestHarness;
21
+ let root: string;
22
+
23
+ beforeEach(async () => {
24
+ vi.clearAllMocks();
25
+ process.env = { ...ORIGINAL_ENV };
26
+ delete process.env.MCP_DATA_DIR;
27
+ root = mkdtempSync(join(tmpdir(), 'docs-blobkeys-'));
28
+ process.env.GOG_FILE_ROOTS = root;
29
+ fake = installFakeBlobStore();
30
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
31
+ harness = await createTestHarness(registerExtraDocsTools, {
32
+ elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
33
+ });
34
+ });
35
+
36
+ afterEach(() => {
37
+ process.env = ORIGINAL_ENV;
38
+ vi.unstubAllGlobals();
39
+ });
40
+
41
+ function upload(filename: string, bytes: string): string {
42
+ const key = mintUploadSlot(blobStoreFromEnv()!, { filename }).blobKey;
43
+ fake.put(key, bytes);
44
+ return key;
45
+ }
46
+
47
+ const lastArgs = () => vi.mocked(lib.runOrDiagnose).mock.calls.at(-1)![0] as GogArg[];
48
+ const fileArg = (args: GogArg[]) => args.find((a): a is GogFileArg => typeof a === 'object' && a.kind === 'file');
49
+
50
+ describe('docs image tools + blobKeys', () => {
51
+ it.each(['gog_docs_insert_image', 'gog_docs_replace_image'])('%s passes the blob as --file, with --force, and deletes it after', async (tool) => {
52
+ const key = upload('logo.png', 'PNGBYTES');
53
+ const result = await harness.callTool(tool, { docId: 'd1', blobKeys: [key] });
54
+ expect(result.isError).toBeFalsy();
55
+ const args = lastArgs();
56
+ expect(fileArg(args)).toMatchObject({ flag: 'file', filename: 'logo.png', encoding: 'base64' });
57
+ expect(fileArg(args)!.positional).toBeUndefined();
58
+ expect(args.at(-1)).toBe('--force');
59
+ expect(fake.deleted).toEqual([key]);
60
+ });
61
+
62
+ it('keeps the blob when gog fails', async () => {
63
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(errorResult('Error: restricted'));
64
+ const key = upload('logo.png', 'PNGBYTES');
65
+ await harness.callTool('gog_docs_insert_image', { docId: 'd1', blobKeys: [key] });
66
+ expect(fake.deleted).toEqual([]);
67
+ });
68
+
69
+ it('wants exactly one image source', async () => {
70
+ const result = await harness.callTool('gog_docs_replace_image', { docId: 'd1' });
71
+ expect(result.content[0]!.text).toMatch(/exactly one of file, url or blobKeys/);
72
+ });
73
+
74
+ it('url mode needs no --force', async () => {
75
+ await harness.callTool('gog_docs_insert_image', { docId: 'd1', url: 'https://x.test/i.png' });
76
+ expect(lastArgs()).not.toContain('--force');
77
+ });
78
+
79
+ it('on a hosted deployment, a file path that is not on the server points at the slot tool', async () => {
80
+ const result = await harness.callTool('gog_docs_insert_image', { docId: 'd1', file: '/Users/someone/a.png' });
81
+ expect(result.content[0]!.text).toMatch(/server is remote.*gog_upload_slots/s);
82
+ });
83
+ });