gogcli-mcp-docs 4.5.0 → 4.5.2
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/dist/index.js +722 -61
- package/manifest.json +3 -3
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/tools/docs-extra.ts +86 -6
- package/tests/tools/docs-extra-gates.test.ts +137 -0
- package/tests/tools/docs-extra.test.ts +4 -1
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.5.
|
|
6
|
+
"version": "4.5.2",
|
|
7
7
|
"description": "Extended Google Docs for Claude via gogcli — auth + full Docs and comments support",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Hall",
|
|
@@ -180,11 +180,11 @@
|
|
|
180
180
|
},
|
|
181
181
|
{
|
|
182
182
|
"name": "gog_docs_comments_add",
|
|
183
|
-
"description": "Add a comment to a Google Doc. Optionally attach quoted text that appears as the highlighted passage in the Google Docs UI."
|
|
183
|
+
"description": "Add a comment to a Google Doc. Optionally attach quoted text that appears as the highlighted passage in the Google Docs UI. Asks the user to confirm."
|
|
184
184
|
},
|
|
185
185
|
{
|
|
186
186
|
"name": "gog_docs_comments_reply",
|
|
187
|
-
"description": "Reply to an existing comment on a Google Doc."
|
|
187
|
+
"description": "Reply to an existing comment on a Google Doc. Asks the user to confirm."
|
|
188
188
|
},
|
|
189
189
|
{
|
|
190
190
|
"name": "gog_docs_comments_resolve",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp-docs",
|
|
3
|
-
"version": "4.5.
|
|
3
|
+
"version": "4.5.2",
|
|
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>",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@chrischall/mcp-utils": "^2.6.0",
|
|
28
|
-
"@modelcontextprotocol/server": "^2.
|
|
28
|
+
"@modelcontextprotocol/server": "^2.1.0",
|
|
29
29
|
"zod": "^4.6.1"
|
|
30
30
|
},
|
|
31
31
|
"license": "MIT",
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp-docs"
|
|
9
9
|
},
|
|
10
|
-
"version": "4.5.
|
|
10
|
+
"version": "4.5.2",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp-docs",
|
|
15
|
-
"version": "4.5.
|
|
15
|
+
"version": "4.5.2",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
package/src/tools/docs-extra.ts
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
accountParam,
|
|
5
|
+
runOrDiagnose,
|
|
6
|
+
paginationParams,
|
|
7
|
+
pushPaginationFlags,
|
|
8
|
+
payloadArg,
|
|
9
|
+
pos,
|
|
10
|
+
confinePath,
|
|
11
|
+
bodyPreview,
|
|
12
|
+
commentMentions,
|
|
13
|
+
commentSnapshot,
|
|
14
|
+
CONFIRM_FALLBACK_DESCRIPTION,
|
|
15
|
+
confirmTokenParam,
|
|
16
|
+
requireDispatchConfirmation,
|
|
17
|
+
resultText,
|
|
18
|
+
} from '../../../gogcli-mcp/src/lib.js';
|
|
4
19
|
import type { GogArg } from '../../../gogcli-mcp/src/lib.js';
|
|
5
20
|
|
|
21
|
+
/** A Doc's title from `gog docs info`, for a comment's prompt. Unreadable output names nothing. */
|
|
22
|
+
export function docTitle(raw: string): string | undefined {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(raw) as { title?: unknown; document?: { title?: unknown } } | null;
|
|
25
|
+
const title = parsed?.document?.title ?? parsed?.title;
|
|
26
|
+
return typeof title === 'string' ? title : undefined;
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
6
32
|
export function registerExtraDocsTools(server: McpServer): void {
|
|
7
33
|
server.registerTool('gog_docs_copy', {
|
|
8
34
|
description: 'Copy a Google Doc to a new document with the given title.',
|
|
@@ -419,30 +445,84 @@ export function registerExtraDocsTools(server: McpServer): void {
|
|
|
419
445
|
|
|
420
446
|
server.registerTool('gog_docs_comments_add', {
|
|
421
447
|
description:
|
|
422
|
-
'Add a comment to a Google Doc. Optionally attach quoted text that appears as the highlighted passage in the Google Docs UI.'
|
|
448
|
+
'Add a comment to a Google Doc. Optionally attach quoted text that appears as the highlighted passage in the Google Docs UI. '
|
|
449
|
+
+ 'The Doc\'s owner and everyone the text +mentions are notified, so this reads the Doc and asks the MCP host to show '
|
|
450
|
+
+ 'the user a confirmation prompt with the Doc, the text and who is mentioned first; nothing is posted unless they '
|
|
451
|
+
+ 'accept.' + CONFIRM_FALLBACK_DESCRIPTION,
|
|
423
452
|
annotations: { destructiveHint: false },
|
|
424
453
|
inputSchema: z.object({
|
|
425
454
|
docId: z.string().describe('Doc ID (from the URL)'),
|
|
426
455
|
content: z.string().describe('Comment text'),
|
|
427
456
|
quoted: z.string().optional().describe('Quoted text to attach to the comment (shown in UIs when available)'),
|
|
428
457
|
account: accountParam,
|
|
458
|
+
confirmToken: confirmTokenParam,
|
|
429
459
|
}),
|
|
430
|
-
}, async ({ docId, content, quoted, account }) => {
|
|
460
|
+
}, async ({ docId, content, quoted, account, confirmToken }, ctx) => {
|
|
431
461
|
const args: GogArg[] = ['docs', 'comments', 'add', pos(docId), pos(content)];
|
|
432
462
|
if (quoted) args.push(`--quoted=${quoted}`);
|
|
463
|
+
const got = await runOrDiagnose(['docs', 'info', pos(docId)], { account });
|
|
464
|
+
if (got.isError) return got;
|
|
465
|
+
const title = docTitle(resultText(got));
|
|
466
|
+
const doc = { id: docId, ...(title !== undefined ? { title } : {}) };
|
|
467
|
+
const mentions = commentMentions(content);
|
|
468
|
+
const confirmation = await requireDispatchConfirmation(ctx, {
|
|
469
|
+
action: 'docs.comment-add',
|
|
470
|
+
message: 'Review and confirm this comment — the Doc\'s owner and anyone mentioned are notified:',
|
|
471
|
+
confirmationLabel: 'Confirm that this comment should be posted now.',
|
|
472
|
+
details: { doc, textPreview: bodyPreview(content), quoted, mentions },
|
|
473
|
+
unsupportedNote: 'Ask the user to comment from Google Docs.',
|
|
474
|
+
fallback: {
|
|
475
|
+
tool: 'gog_docs_comments_add',
|
|
476
|
+
account,
|
|
477
|
+
confirmToken,
|
|
478
|
+
subject: () => {
|
|
479
|
+
const view = { doc, text: content, quoted, mentions };
|
|
480
|
+
return { target: docId, payload: view, preview: view };
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
if (confirmation) return confirmation;
|
|
433
485
|
return runOrDiagnose(args, { account });
|
|
434
486
|
});
|
|
435
487
|
|
|
436
488
|
server.registerTool('gog_docs_comments_reply', {
|
|
437
|
-
description: 'Reply to an existing comment on a Google Doc.'
|
|
489
|
+
description: 'Reply to an existing comment on a Google Doc. Everyone on the thread and anyone the reply +mentions is '
|
|
490
|
+
+ 'notified, so this reads the Doc and the comment and asks the MCP host to show the user a confirmation prompt '
|
|
491
|
+
+ 'with both and the reply first; nothing is posted unless they accept.' + CONFIRM_FALLBACK_DESCRIPTION,
|
|
438
492
|
annotations: { destructiveHint: false },
|
|
439
493
|
inputSchema: z.object({
|
|
440
494
|
docId: z.string().describe('Doc ID (from the URL)'),
|
|
441
495
|
commentId: z.string().describe('Comment ID to reply to'),
|
|
442
496
|
content: z.string().describe('Reply text'),
|
|
443
497
|
account: accountParam,
|
|
444
|
-
|
|
445
|
-
|
|
498
|
+
confirmToken: confirmTokenParam,
|
|
499
|
+
}),
|
|
500
|
+
}, async ({ docId, commentId, content, account, confirmToken }, ctx) => {
|
|
501
|
+
const got = await runOrDiagnose(['docs', 'info', pos(docId)], { account });
|
|
502
|
+
if (got.isError) return got;
|
|
503
|
+
const thread = await runOrDiagnose(['docs', 'comments', 'get', pos(docId), pos(commentId)], { account });
|
|
504
|
+
if (thread.isError) return thread;
|
|
505
|
+
const title = docTitle(resultText(got));
|
|
506
|
+
const doc = { id: docId, ...(title !== undefined ? { title } : {}) };
|
|
507
|
+
const replyingTo = commentSnapshot(resultText(thread));
|
|
508
|
+
const mentions = commentMentions(content);
|
|
509
|
+
const confirmation = await requireDispatchConfirmation(ctx, {
|
|
510
|
+
action: 'docs.comment-reply',
|
|
511
|
+
message: 'Review and confirm this reply — everyone on the thread and anyone mentioned is notified:',
|
|
512
|
+
confirmationLabel: 'Confirm that this reply should be posted now.',
|
|
513
|
+
details: { doc, replyingTo, textPreview: bodyPreview(content), mentions },
|
|
514
|
+
unsupportedNote: 'Ask the user to reply from Google Docs.',
|
|
515
|
+
fallback: {
|
|
516
|
+
tool: 'gog_docs_comments_reply',
|
|
517
|
+
account,
|
|
518
|
+
confirmToken,
|
|
519
|
+
subject: () => {
|
|
520
|
+
const view = { doc, replyingTo, text: content, mentions };
|
|
521
|
+
return { target: `${docId}/${commentId}`, payload: view, preview: view };
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
if (confirmation) return confirmation;
|
|
446
526
|
return runOrDiagnose(['docs', 'comments', 'reply', pos(docId), pos(commentId), pos(content)], { account });
|
|
447
527
|
});
|
|
448
528
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { registerExtraDocsTools, docTitle } from '../../src/tools/docs-extra.js';
|
|
3
|
+
import * as lib from '../../../gogcli-mcp/src/lib.js';
|
|
4
|
+
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
5
|
+
import { rawTextResult } from '@chrischall/mcp-utils';
|
|
6
|
+
import type { ElicitRequest, ElicitResult } from '@modelcontextprotocol/server';
|
|
7
|
+
import { pos } from '../../../gogcli-mcp/src/argv.js';
|
|
8
|
+
import { resetConfirmTokenState } from '../../../gogcli-mcp/src/send-confirm-token.js';
|
|
9
|
+
|
|
10
|
+
vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
|
|
11
|
+
const actual = await importOriginal<typeof lib>();
|
|
12
|
+
return { ...actual, runOrDiagnose: vi.fn() };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Fleet audit 2026-09-24 SEC-6: a Doc comment or reply notifies the owner, the
|
|
16
|
+
// thread and everyone it +mentions, so both ask first — naming the Doc.
|
|
17
|
+
|
|
18
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
process.env = { ...ORIGINAL_ENV };
|
|
22
|
+
delete process.env.MCP_CONFIRM_MODE;
|
|
23
|
+
resetConfirmTokenState();
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => { process.env = ORIGINAL_ENV; });
|
|
26
|
+
|
|
27
|
+
const json = (r: { content: Array<{ text?: string }> }) => JSON.parse(r.content[0]!.text as string);
|
|
28
|
+
async function prompted(answer: ElicitResult = { action: 'accept', content: { confirmed: true } }) {
|
|
29
|
+
const seen: ElicitRequest[] = [];
|
|
30
|
+
const harness = await createTestHarness(registerExtraDocsTools, { elicitation: async (r) => { seen.push(r); return answer; } });
|
|
31
|
+
const details = () => JSON.parse(seen[0]!.params.message.split('\n').slice(1).join('\n')).details;
|
|
32
|
+
return { harness, seen, details };
|
|
33
|
+
}
|
|
34
|
+
const unprompted = () => createTestHarness(registerExtraDocsTools);
|
|
35
|
+
|
|
36
|
+
const INFO = JSON.stringify({ document: { documentId: 'd1', title: 'Q3 plan' } });
|
|
37
|
+
const COMMENT = JSON.stringify({ comment: { id: 'k1', content: 'Is this final?', author: { displayName: 'Alice' } } });
|
|
38
|
+
function stub(failing?: string, info = INFO) {
|
|
39
|
+
vi.mocked(lib.runOrDiagnose).mockImplementation(async (args) => {
|
|
40
|
+
const key = args.filter((a): a is string => typeof a === 'string' && !a.startsWith('--')).join(' ');
|
|
41
|
+
if (failing && key.startsWith(failing)) return { content: [{ type: 'text', text: 'Error: not found' }], isError: true };
|
|
42
|
+
if (key === 'docs info') return rawTextResult(info);
|
|
43
|
+
if (key === 'docs comments get') return rawTextResult(COMMENT);
|
|
44
|
+
return rawTextResult('{"ok":true}');
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const callsTo = (...words: string[]) => vi.mocked(lib.runOrDiagnose).mock.calls.filter(([args]) =>
|
|
48
|
+
words.every((w, i) => args[i] === w));
|
|
49
|
+
|
|
50
|
+
describe('gog_docs_comments_add', () => {
|
|
51
|
+
const ARGS = { docId: 'd1', content: 'Check with @bob@example.com', quoted: 'Revenue' };
|
|
52
|
+
|
|
53
|
+
it('reads the Doc and asks with its title, the text, the quote and who is mentioned', async () => {
|
|
54
|
+
stub();
|
|
55
|
+
const { harness, details } = await prompted();
|
|
56
|
+
await harness.callTool('gog_docs_comments_add', ARGS);
|
|
57
|
+
expect(details()).toEqual({ doc: { id: 'd1', title: 'Q3 plan' }, textPreview: ARGS.content, quoted: 'Revenue', mentions: ['bob@example.com'] });
|
|
58
|
+
expect(callsTo('docs', 'comments', 'add')[0]![0]).toEqual(['docs', 'comments', 'add', pos('d1'), pos(ARGS.content), '--quoted=Revenue']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('posts nothing when the user declines or the read fails', async () => {
|
|
62
|
+
stub();
|
|
63
|
+
expect(json(await (await prompted({ action: 'decline' })).harness.callTool('gog_docs_comments_add', ARGS)))
|
|
64
|
+
.toMatchObject({ cancelled: true, action: 'docs.comment-add' });
|
|
65
|
+
stub('docs info');
|
|
66
|
+
expect((await (await prompted()).harness.callTool('gog_docs_comments_add', ARGS)).isError).toBe(true);
|
|
67
|
+
expect(callsTo('docs', 'comments', 'add')).toHaveLength(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('refuses a client that cannot be prompted', async () => {
|
|
71
|
+
process.env.MCP_CONFIRM_MODE = 'refuse';
|
|
72
|
+
stub();
|
|
73
|
+
const r = json(await (await unprompted()).callTool('gog_docs_comments_add', ARGS));
|
|
74
|
+
expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'docs.comment-add' });
|
|
75
|
+
expect(r.note).toMatch(/Google Docs/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('token fallback: changed text is DRAFT_CHANGED; the same call posts', async () => {
|
|
79
|
+
process.env.MCP_CONFIRM_MODE = 'ask-user';
|
|
80
|
+
stub(undefined, '{}');
|
|
81
|
+
const harness = await unprompted();
|
|
82
|
+
const args = { docId: 'd1', content: 'Nice' };
|
|
83
|
+
const p1 = json(await harness.callTool('gog_docs_comments_add', args));
|
|
84
|
+
expect(p1.preview).toEqual({ doc: { id: 'd1' }, text: 'Nice', mentions: [] });
|
|
85
|
+
expect(json(await harness.callTool('gog_docs_comments_add', { ...args, content: 'Nicer', confirmToken: p1.confirmToken })))
|
|
86
|
+
.toMatchObject({ error: 'DRAFT_CHANGED' });
|
|
87
|
+
await harness.callTool('gog_docs_comments_add', { ...args, confirmToken: p1.confirmToken });
|
|
88
|
+
expect(callsTo('docs', 'comments', 'add')).toHaveLength(1);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('gog_docs_comments_reply', () => {
|
|
93
|
+
const ARGS = { docId: 'd1', commentId: 'k1', content: 'Yes.' };
|
|
94
|
+
|
|
95
|
+
it('reads the Doc and the comment and asks with both and the reply', async () => {
|
|
96
|
+
stub();
|
|
97
|
+
const { harness, details } = await prompted();
|
|
98
|
+
await harness.callTool('gog_docs_comments_reply', ARGS);
|
|
99
|
+
expect(details()).toEqual({ doc: { id: 'd1', title: 'Q3 plan' }, replyingTo: { author: 'Alice', content: 'Is this final?' }, textPreview: 'Yes.', mentions: [] });
|
|
100
|
+
expect(callsTo('docs', 'comments', 'reply')[0]![0]).toEqual(['docs', 'comments', 'reply', pos('d1'), pos('k1'), pos('Yes.')]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it.each(['docs info', 'docs comments get'])('returns a failed read (%s) without asking or replying', async (failing) => {
|
|
104
|
+
stub(failing);
|
|
105
|
+
const { harness, seen } = await prompted();
|
|
106
|
+
expect((await harness.callTool('gog_docs_comments_reply', ARGS)).isError).toBe(true);
|
|
107
|
+
expect(seen).toHaveLength(0);
|
|
108
|
+
expect(callsTo('docs', 'comments', 'reply')).toHaveLength(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('refuses a client that cannot be prompted', async () => {
|
|
112
|
+
process.env.MCP_CONFIRM_MODE = 'refuse';
|
|
113
|
+
stub();
|
|
114
|
+
const r = json(await (await unprompted()).callTool('gog_docs_comments_reply', ARGS));
|
|
115
|
+
expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: 'docs.comment-reply' });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('token fallback: previews the thread and posts on phase 2', async () => {
|
|
119
|
+
process.env.MCP_CONFIRM_MODE = 'ask-user';
|
|
120
|
+
stub();
|
|
121
|
+
const harness = await unprompted();
|
|
122
|
+
const p1 = json(await harness.callTool('gog_docs_comments_reply', ARGS));
|
|
123
|
+
expect(p1.preview).toEqual({ doc: { id: 'd1', title: 'Q3 plan' }, replyingTo: { author: 'Alice', content: 'Is this final?' }, text: 'Yes.', mentions: [] });
|
|
124
|
+
await harness.callTool('gog_docs_comments_reply', { ...ARGS, confirmToken: p1.confirmToken });
|
|
125
|
+
expect(callsTo('docs', 'comments', 'reply')).toHaveLength(1);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('docTitle', () => {
|
|
130
|
+
it('reads a nested or top-level title and tolerates unreadable output', () => {
|
|
131
|
+
expect(docTitle(JSON.stringify({ document: { title: 'A' } }))).toBe('A');
|
|
132
|
+
expect(docTitle(JSON.stringify({ title: 'B' }))).toBe('B');
|
|
133
|
+
expect(docTitle(JSON.stringify({ title: 7 }))).toBeUndefined();
|
|
134
|
+
expect(docTitle('null')).toBeUndefined();
|
|
135
|
+
expect(docTitle('nope')).toBeUndefined();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -13,7 +13,10 @@ vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
|
|
|
13
13
|
};
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// The comment tools ask first (docs-extra-gates.test.ts covers the prompts); here the user accepts.
|
|
17
|
+
const setupHandlers = () => createTestHarness(registerExtraDocsTools, {
|
|
18
|
+
elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
|
|
19
|
+
});
|
|
17
20
|
|
|
18
21
|
beforeEach(() => vi.clearAllMocks());
|
|
19
22
|
|