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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +8 -0
- package/SKILL.md +8 -0
- package/dist/index.js +1857 -377
- package/manifest.json +5 -1
- package/mint.yaml +5 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/index.ts +2 -2
- package/src/tools/docs-extra.ts +30 -14
- package/tests/tool-annotations.test.ts +4 -4
- package/tests/tools/docs-blobkeys.test.ts +83 -0
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
10
|
+
"version": "4.10.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp-docs",
|
|
15
|
-
"version": "4.
|
|
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
|
});
|
package/src/tools/docs-extra.ts
CHANGED
|
@@ -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('
|
|
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
|
|
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)
|
|
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('
|
|
1525
|
-
url: z.string().optional().describe('Public HTTPS image URL to use directly — no Drive upload (exactly one of file or
|
|
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)
|
|
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:
|
|
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
|
+
});
|