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/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.0",
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.0",
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.0.0",
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.0",
10
+ "version": "4.5.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp-docs",
15
- "version": "4.5.0",
15
+ "version": "4.5.2",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -1,8 +1,34 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
- import { accountParam, runOrDiagnose, paginationParams, pushPaginationFlags, payloadArg, pos, confinePath } from '../../../gogcli-mcp/src/lib.js';
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
- }, async ({ docId, commentId, content, account }) => {
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
- const setupHandlers = () => createTestHarness(registerExtraDocsTools);
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