gogcli-mcp 2.25.0 → 2.26.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 +9 -4
- package/SKILL.md +10 -5
- package/dist/index.js +483 -124
- package/dist/lib.js +407 -85
- package/manifest.json +81 -1
- package/mint.yaml +106 -0
- package/package.json +5 -5
- package/server.json +2 -2
- package/src/gmail-results.ts +29 -2
- package/src/lib.ts +2 -0
- package/src/runner.ts +1 -1
- package/src/server.ts +6 -0
- package/src/tools/appscript.ts +173 -0
- package/src/tools/calendar.ts +56 -2
- package/src/tools/chat.ts +253 -0
- package/src/worker.ts +1 -1
- package/tests/gmail-results.test.ts +47 -0
- package/tests/server.test.ts +2 -0
- package/tests/tools/appscript.test.ts +159 -0
- package/tests/tools/calendar.test.ts +121 -0
- package/tests/tools/chat.test.ts +284 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import {
|
|
4
|
+
accountParam,
|
|
5
|
+
runOrDiagnose,
|
|
6
|
+
registerRunTool,
|
|
7
|
+
paginationParams,
|
|
8
|
+
pushPaginationFlags,
|
|
9
|
+
} from './utils.js';
|
|
10
|
+
import type { GogArg } from '../runner.js';
|
|
11
|
+
import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
|
|
12
|
+
|
|
13
|
+
// Google Chat (gog >= 0.38.0 for the mention/reaction metadata in
|
|
14
|
+
// `messages list`; the rest of the surface is older).
|
|
15
|
+
//
|
|
16
|
+
// TWO NAMING SYSTEMS MEET HERE, and mixing them is the mistake this module's
|
|
17
|
+
// descriptions exist to prevent. Chat identifies everything by RESOURCE NAME —
|
|
18
|
+
// `spaces/AAAA`, `spaces/AAAA/messages/BBBB`, `spaces/AAAA/threads/CCCC` — and
|
|
19
|
+
// those names are what every tool below wants. A bare ID (`BBBB`) is accepted
|
|
20
|
+
// for a message only when `space` says which space it lives in, which is why
|
|
21
|
+
// the reaction tools carry that extra parameter.
|
|
22
|
+
//
|
|
23
|
+
// gog_chat_spaces_find / gog_chat_dm_space are the bridges from human words
|
|
24
|
+
// ("the launch room", "alice@example.com") to those names; reach for one of
|
|
25
|
+
// them first rather than guessing a resource name.
|
|
26
|
+
//
|
|
27
|
+
// WORKSPACE-ONLY, AND THAT IS NOT A SCOPE PROBLEM: gog refuses every chat call
|
|
28
|
+
// on a consumer @gmail.com account with "chat requires a Google Workspace
|
|
29
|
+
// account (non-gmail.com)" — verified live against gog 0.38.0 — no matter which
|
|
30
|
+
// scopes the token carries. Re-authorizing cannot fix it, so the note below is
|
|
31
|
+
// appended to every description here: a model that reads "permission error" as
|
|
32
|
+
// "missing scope" would otherwise burn a re-auth round trip on an account that
|
|
33
|
+
// can never work.
|
|
34
|
+
export function registerChatTools(server: McpServer): void {
|
|
35
|
+
const workspaceOnlyNote =
|
|
36
|
+
' WORKSPACE ONLY: Google Chat has no API for consumer accounts, so this fails on an @gmail.com '
|
|
37
|
+
+ 'account with "chat requires a Google Workspace account". That is the ACCOUNT, not the token — '
|
|
38
|
+
+ 're-authorizing or adding scopes will not help.';
|
|
39
|
+
const spaceParam = z.string().describe(
|
|
40
|
+
'Space resource name, e.g. "spaces/AAAAsomeID" (from gog_chat_spaces_list, gog_chat_spaces_find or gog_chat_dm_space)',
|
|
41
|
+
);
|
|
42
|
+
const threadParam = z.string().optional().describe(
|
|
43
|
+
'Thread resource name, e.g. "spaces/AAAA/threads/CCCC" — reply inside that thread instead of starting a new one',
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
server.registerTool('gog_chat_spaces_list', {
|
|
47
|
+
description:
|
|
48
|
+
'List the Google Chat spaces the account belongs to — named rooms and DMs alike — with their resource names. '
|
|
49
|
+
+ 'Start here when you do not yet have a space name; gog_chat_spaces_find is faster when you know the room\'s title.'
|
|
50
|
+
+ workspaceOnlyNote,
|
|
51
|
+
annotations: { readOnlyHint: true },
|
|
52
|
+
inputSchema: {
|
|
53
|
+
...paginationParams,
|
|
54
|
+
account: accountParam,
|
|
55
|
+
},
|
|
56
|
+
}, async ({ max, pageToken, page, all, account }) => {
|
|
57
|
+
const args = ['chat', 'spaces', 'list'];
|
|
58
|
+
pushPaginationFlags(args, { max, pageToken, page, all });
|
|
59
|
+
return runOrDiagnose(args, { account });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
server.registerTool('gog_chat_spaces_find', {
|
|
63
|
+
description:
|
|
64
|
+
'Find spaces whose display name matches. Substring and case-insensitive by default, which is what you want when the '
|
|
65
|
+
+ 'user names a room approximately ("the launch room"); pass exact=true to require the whole title. DMs have no '
|
|
66
|
+
+ 'display name — use gog_chat_dm_space to reach a person.' + workspaceOnlyNote,
|
|
67
|
+
annotations: { readOnlyHint: true },
|
|
68
|
+
inputSchema: {
|
|
69
|
+
displayName: z.string().describe('Space display name, or part of one'),
|
|
70
|
+
exact: z.boolean().optional().describe('Require an exact (still case-insensitive) match on the whole display name'),
|
|
71
|
+
max: z.number().int().optional().describe('Max results per page'),
|
|
72
|
+
account: accountParam,
|
|
73
|
+
},
|
|
74
|
+
}, async ({ displayName, exact, max, account }) => {
|
|
75
|
+
const args = ['chat', 'spaces', 'find', displayName];
|
|
76
|
+
if (exact) args.push('--exact');
|
|
77
|
+
if (max !== undefined) args.push(`--max=${max}`);
|
|
78
|
+
return runOrDiagnose(args, { account });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
server.registerTool('gog_chat_spaces_create', {
|
|
82
|
+
description:
|
|
83
|
+
'Create a named Chat space, optionally seeding its membership. Members are added immediately and are notified — this '
|
|
84
|
+
+ 'is visible to other people the moment it runs, so confirm the member list before calling it.' + workspaceOnlyNote,
|
|
85
|
+
inputSchema: {
|
|
86
|
+
displayName: z.string().describe('Display name for the new space'),
|
|
87
|
+
members: z.array(z.string()).optional().describe('Initial members, as email addresses or "users/..." resource names'),
|
|
88
|
+
account: accountParam,
|
|
89
|
+
},
|
|
90
|
+
}, async ({ displayName, members, account }) => {
|
|
91
|
+
const args = ['chat', 'spaces', 'create', displayName];
|
|
92
|
+
if (members) for (const member of members) args.push(`--member=${member}`);
|
|
93
|
+
return runOrDiagnose(args, { account });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
server.registerTool('gog_chat_threads_list', {
|
|
97
|
+
description:
|
|
98
|
+
'List the threads in a space, so a reply can be targeted at an existing conversation rather than starting a new one. '
|
|
99
|
+
+ 'Pass a thread name from here as `thread` to gog_chat_messages_send.' + workspaceOnlyNote,
|
|
100
|
+
annotations: { readOnlyHint: true },
|
|
101
|
+
inputSchema: {
|
|
102
|
+
space: spaceParam,
|
|
103
|
+
...paginationParams,
|
|
104
|
+
account: accountParam,
|
|
105
|
+
},
|
|
106
|
+
}, async ({ space, max, pageToken, page, all, account }) => {
|
|
107
|
+
const args = ['chat', 'threads', 'list', space];
|
|
108
|
+
pushPaginationFlags(args, { max, pageToken, page, all });
|
|
109
|
+
return runOrDiagnose(args, { account });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
server.registerTool('gog_chat_messages_list', {
|
|
113
|
+
description:
|
|
114
|
+
'Read messages in a space. The JSON carries each message\'s @-mentions and a summary of its emoji reactions '
|
|
115
|
+
+ '(gog >= 0.38.0) alongside the text, so "who was tagged" and "did anyone react" are answerable without extra calls. '
|
|
116
|
+
+ 'unread=true returns only what arrived after the account last read the space — the cheap way to answer "what did I '
|
|
117
|
+
+ 'miss". Newest-first needs an explicit order="createTime desc"; Chat\'s own default is oldest-first.'
|
|
118
|
+
+ workspaceOnlyNote,
|
|
119
|
+
annotations: { readOnlyHint: true },
|
|
120
|
+
inputSchema: {
|
|
121
|
+
space: spaceParam,
|
|
122
|
+
thread: threadParam,
|
|
123
|
+
unread: z.boolean().optional().describe('Only messages posted after the account last read this space'),
|
|
124
|
+
order: z.enum(['createTime asc', 'createTime desc', 'lastUpdateTime asc', 'lastUpdateTime desc'])
|
|
125
|
+
.optional().describe('Sort order (Chat default: "createTime asc", i.e. OLDEST first — ask for "createTime desc" when you want the latest messages)'),
|
|
126
|
+
...paginationParams,
|
|
127
|
+
account: accountParam,
|
|
128
|
+
},
|
|
129
|
+
}, async ({ space, thread, unread, order, max, pageToken, page, all, account }) => {
|
|
130
|
+
const args = ['chat', 'messages', 'list', space];
|
|
131
|
+
if (thread) args.push(`--thread=${thread}`);
|
|
132
|
+
if (unread) args.push('--unread');
|
|
133
|
+
if (order) args.push(`--order=${order}`);
|
|
134
|
+
pushPaginationFlags(args, { max, pageToken, page, all });
|
|
135
|
+
return runOrDiagnose(args, { account });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
server.registerTool('gog_chat_messages_send', {
|
|
139
|
+
description:
|
|
140
|
+
'Post a message to a Chat space. THIS IS IMMEDIATELY VISIBLE TO EVERYONE IN THE SPACE and cannot be unsent through '
|
|
141
|
+
+ 'this tool, so treat it like sending mail, not like saving a draft. Pass `thread` to reply inside an existing '
|
|
142
|
+
+ 'conversation (from gog_chat_threads_list or a message\'s thread field); omit it to start a new one. Text supports '
|
|
143
|
+
+ 'Chat\'s markdown-ish formatting (*bold*, _italic_, `code`).' + workspaceOnlyNote,
|
|
144
|
+
inputSchema: {
|
|
145
|
+
space: spaceParam,
|
|
146
|
+
text: z.string().optional().describe('Message text. Optional only when an attachment is supplied.'),
|
|
147
|
+
thread: threadParam,
|
|
148
|
+
attach: z.array(z.string()).optional().describe(
|
|
149
|
+
'Attachment file paths, read WHERE GOG RUNS. On a hosted or remote deployment that is not your machine — use '
|
|
150
|
+
+ 'attachInline there instead.',
|
|
151
|
+
),
|
|
152
|
+
attachInline: attachInlineParam,
|
|
153
|
+
account: accountParam,
|
|
154
|
+
},
|
|
155
|
+
}, async ({ space, text, thread, attach, attachInline, account }) => {
|
|
156
|
+
if (text === undefined && !attach?.length && !attachInline?.length) {
|
|
157
|
+
throw new Error('A Chat message needs text, an attachment, or both.');
|
|
158
|
+
}
|
|
159
|
+
const args: GogArg[] = ['chat', 'messages', 'send', space];
|
|
160
|
+
if (text !== undefined) args.push(`--text=${text}`);
|
|
161
|
+
if (thread) args.push(`--thread=${thread}`);
|
|
162
|
+
if (attach) for (const path of attach) args.push(`--attach=${path}`);
|
|
163
|
+
// Same repeatable --attach flag; the executor materializes each payload to
|
|
164
|
+
// a temp file beside gog. `args` is passed so the size check sees the whole
|
|
165
|
+
// request, not just the attachments.
|
|
166
|
+
args.push(...inlineAttachmentArgs('attach', attachInline, args));
|
|
167
|
+
return runOrDiagnose(args, { account });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
server.registerTool('gog_chat_dm_send', {
|
|
171
|
+
description:
|
|
172
|
+
'Send a direct message to one person by email address, creating the DM space if this is the first message. Delivered '
|
|
173
|
+
+ 'immediately and cannot be unsent through this tool. For a room rather than a person, use gog_chat_messages_send.'
|
|
174
|
+
+ workspaceOnlyNote,
|
|
175
|
+
inputSchema: {
|
|
176
|
+
email: z.string().describe('Recipient email address'),
|
|
177
|
+
text: z.string().describe('Message text'),
|
|
178
|
+
thread: threadParam,
|
|
179
|
+
account: accountParam,
|
|
180
|
+
},
|
|
181
|
+
}, async ({ email, text, thread, account }) => {
|
|
182
|
+
const args = ['chat', 'dm', 'send', email, `--text=${text}`];
|
|
183
|
+
if (thread) args.push(`--thread=${thread}`);
|
|
184
|
+
return runOrDiagnose(args, { account });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
server.registerTool('gog_chat_dm_space', {
|
|
188
|
+
description:
|
|
189
|
+
'Resolve the DM space for an email address — the bridge from a person to the "spaces/..." name the message tools '
|
|
190
|
+
+ 'want. Creates the space if none exists yet, which is silent: it does not message the person.' + workspaceOnlyNote,
|
|
191
|
+
inputSchema: {
|
|
192
|
+
email: z.string().describe('The other person\'s email address'),
|
|
193
|
+
account: accountParam,
|
|
194
|
+
},
|
|
195
|
+
}, async ({ email, account }) => {
|
|
196
|
+
return runOrDiagnose(['chat', 'dm', 'space', email], { account });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
server.registerTool('gog_chat_reactions_list', {
|
|
200
|
+
description:
|
|
201
|
+
'List the emoji reactions on one message, with who reacted. gog_chat_messages_list already returns a reaction '
|
|
202
|
+
+ 'SUMMARY per message; come here when you need the individual reactors, or the reaction resource names that '
|
|
203
|
+
+ 'gog_chat_reactions_delete takes.' + workspaceOnlyNote,
|
|
204
|
+
annotations: { readOnlyHint: true },
|
|
205
|
+
inputSchema: {
|
|
206
|
+
message: z.string().describe('Message resource name ("spaces/AAAA/messages/BBBB"), or a bare message ID together with `space`'),
|
|
207
|
+
space: z.string().optional().describe('Space resource name — required only when `message` is a bare ID'),
|
|
208
|
+
...paginationParams,
|
|
209
|
+
account: accountParam,
|
|
210
|
+
},
|
|
211
|
+
}, async ({ message, space, max, pageToken, page, all, account }) => {
|
|
212
|
+
const args = ['chat', 'messages', 'reactions', 'list', message];
|
|
213
|
+
if (space) args.push(`--space=${space}`);
|
|
214
|
+
pushPaginationFlags(args, { max, pageToken, page, all });
|
|
215
|
+
return runOrDiagnose(args, { account });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
server.registerTool('gog_chat_reactions_create', {
|
|
219
|
+
description:
|
|
220
|
+
'React to a message with an emoji. Visible to the space immediately. Pass the emoji itself ("👍"), not a :shortcode:.'
|
|
221
|
+
+ workspaceOnlyNote,
|
|
222
|
+
inputSchema: {
|
|
223
|
+
message: z.string().describe('Message resource name ("spaces/AAAA/messages/BBBB"), or a bare message ID together with `space`'),
|
|
224
|
+
emoji: z.string().describe('The emoji character to react with, e.g. "👍"'),
|
|
225
|
+
space: z.string().optional().describe('Space resource name — required only when `message` is a bare ID'),
|
|
226
|
+
account: accountParam,
|
|
227
|
+
},
|
|
228
|
+
}, async ({ message, emoji, space, account }) => {
|
|
229
|
+
const args = ['chat', 'messages', 'reactions', 'create', message, emoji];
|
|
230
|
+
if (space) args.push(`--space=${space}`);
|
|
231
|
+
return runOrDiagnose(args, { account });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
server.registerTool('gog_chat_reactions_delete', {
|
|
235
|
+
description:
|
|
236
|
+
'Remove one emoji reaction. Takes the REACTION\'s own resource name ("spaces/.../messages/.../reactions/..."), not '
|
|
237
|
+
+ 'the message\'s and not the emoji — get it from gog_chat_reactions_list. An account can only remove its own reaction.'
|
|
238
|
+
+ workspaceOnlyNote,
|
|
239
|
+
annotations: { destructiveHint: true },
|
|
240
|
+
inputSchema: {
|
|
241
|
+
reaction: z.string().describe('Reaction resource name, e.g. "spaces/AAAA/messages/BBBB/reactions/CCCC"'),
|
|
242
|
+
account: accountParam,
|
|
243
|
+
},
|
|
244
|
+
}, async ({ reaction, account }) => {
|
|
245
|
+
return runOrDiagnose(['chat', 'messages', 'reactions', 'delete', reaction], { account });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
registerRunTool(server, {
|
|
249
|
+
service: 'chat',
|
|
250
|
+
examples: '"spaces", "messages", "dm"',
|
|
251
|
+
note: 'Google Chat has no API for consumer accounts: every chat subcommand fails on an @gmail.com account regardless of scopes.',
|
|
252
|
+
});
|
|
253
|
+
}
|
package/src/worker.ts
CHANGED
|
@@ -38,7 +38,7 @@ import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth
|
|
|
38
38
|
// connector with all ~360 tools at once. Add whichever paths you want as separate
|
|
39
39
|
// connectors in claude.ai (each authorizes with the same connector key).
|
|
40
40
|
|
|
41
|
-
const VERSION = '2.
|
|
41
|
+
const VERSION = '2.26.0'; // x-release-please-version
|
|
42
42
|
|
|
43
43
|
// Build an McpAgent subclass whose init() registers `registrars` onto its server,
|
|
44
44
|
// each handler wrapped in the ALS scope carrying the per-session Fly executor.
|
|
@@ -255,6 +255,53 @@ describe('fetchGmailPages', () => {
|
|
|
255
255
|
expect(runPage).toHaveBeenCalledTimes(2);
|
|
256
256
|
});
|
|
257
257
|
|
|
258
|
+
// A REPEATED CURSOR IS A STALL, and gog 0.38.0 fixed the same class on its own
|
|
259
|
+
// side (openclaw/gogcli#1004) — but that fix covers gog's `--all`, and this
|
|
260
|
+
// walk is our own: it makes N separate single-page gog calls, so a cursor
|
|
261
|
+
// Google repeats comes straight back to this loop. Without a guard the walk
|
|
262
|
+
// re-fetches the same page and merges its items again on every remaining
|
|
263
|
+
// iteration, so the caller gets DUPLICATES presented as more results, which is
|
|
264
|
+
// worse than a short answer because nothing about the payload looks wrong.
|
|
265
|
+
it('stops when Google repeats a cursor, instead of re-fetching the same page', async () => {
|
|
266
|
+
const runPage = vi.fn()
|
|
267
|
+
.mockResolvedValueOnce(page(['a'], 'T1'))
|
|
268
|
+
.mockResolvedValueOnce(page(['b'], 'T1'));
|
|
269
|
+
const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 10, undefined)).content[0].text as string);
|
|
270
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['a', 'b']);
|
|
271
|
+
// Stopped after the repeat was seen — NOT after burning all 10 pages.
|
|
272
|
+
expect(runPage).toHaveBeenCalledTimes(2);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('still reads as truncated after a repeated cursor', async () => {
|
|
276
|
+
const runPage = vi.fn()
|
|
277
|
+
.mockResolvedValueOnce(page(['a'], 'T1'))
|
|
278
|
+
.mockResolvedValueOnce(page(['b'], 'T1'));
|
|
279
|
+
const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 10, undefined)).content[0].text as string);
|
|
280
|
+
// The walk could not reach the end, so the cursor STAYS. Dropping it would
|
|
281
|
+
// claim completeness we never established, and a set that reads complete is
|
|
282
|
+
// exactly how "that email doesn't exist" gets reported about mail that does.
|
|
283
|
+
expect(out.nextPageToken).toBe('T1');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('detects a cursor that repeats the caller-supplied one', async () => {
|
|
287
|
+
const runPage = vi.fn().mockResolvedValueOnce(page(['a'], 'START'));
|
|
288
|
+
const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 10, 'START')).content[0].text as string);
|
|
289
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['a']);
|
|
290
|
+
expect(out.nextPageToken).toBe('START');
|
|
291
|
+
expect(runPage).toHaveBeenCalledTimes(1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('detects a cursor that repeats a token from earlier in the walk, not just the previous one', async () => {
|
|
295
|
+
const runPage = vi.fn()
|
|
296
|
+
.mockResolvedValueOnce(page(['a'], 'T1'))
|
|
297
|
+
.mockResolvedValueOnce(page(['b'], 'T2'))
|
|
298
|
+
.mockResolvedValueOnce(page(['c'], 'T1'));
|
|
299
|
+
const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 10, undefined)).content[0].text as string);
|
|
300
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['a', 'b', 'c']);
|
|
301
|
+
expect(out.nextPageToken).toBe('T1');
|
|
302
|
+
expect(runPage).toHaveBeenCalledTimes(3);
|
|
303
|
+
});
|
|
304
|
+
|
|
258
305
|
it('starts from a caller-supplied cursor', async () => {
|
|
259
306
|
const runPage = vi.fn().mockResolvedValue(page(['a']));
|
|
260
307
|
await fetchGmailPages(runPage, 'threads', 3, 'START');
|
package/tests/server.test.ts
CHANGED
|
@@ -14,8 +14,10 @@ describe('BASE_TOOL_REGISTRARS', () => {
|
|
|
14
14
|
// One representative tool per service registrar, in registrar order.
|
|
15
15
|
for (const expected of [
|
|
16
16
|
'gog_api_list',
|
|
17
|
+
'gog_appscript_get',
|
|
17
18
|
'gog_auth_list',
|
|
18
19
|
'gog_calendar_events',
|
|
20
|
+
'gog_chat_spaces_list',
|
|
19
21
|
'gog_classroom_courses_list',
|
|
20
22
|
'gog_contacts_list',
|
|
21
23
|
'gog_docs_cat',
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { registerAppScriptTools } from '../../src/tools/appscript.js';
|
|
3
|
+
import * as runner from '../../src/runner.js';
|
|
4
|
+
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
5
|
+
|
|
6
|
+
vi.mock('../../src/runner.js');
|
|
7
|
+
|
|
8
|
+
const setupHandlers = () => createTestHarness(registerAppScriptTools);
|
|
9
|
+
|
|
10
|
+
beforeEach(() => vi.clearAllMocks());
|
|
11
|
+
|
|
12
|
+
describe('gog_appscript_get', () => {
|
|
13
|
+
it('gets project metadata', async () => {
|
|
14
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
15
|
+
const harness = await setupHandlers();
|
|
16
|
+
await harness.callTool('gog_appscript_get', { scriptId: 'S1' });
|
|
17
|
+
expect(runner.run).toHaveBeenCalledWith(['appscript', 'get', 'S1'], { account: undefined });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns error text on failure', async () => {
|
|
21
|
+
vi.mocked(runner.run).mockRejectedValue(new Error('Get failed'));
|
|
22
|
+
const harness = await setupHandlers();
|
|
23
|
+
const result = await harness.callTool('gog_appscript_get', { scriptId: 'S1' });
|
|
24
|
+
expect(result.content[0].text).toBe('Error: Get failed');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('gog_appscript_content', () => {
|
|
29
|
+
it('reads the project source', async () => {
|
|
30
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
31
|
+
const harness = await setupHandlers();
|
|
32
|
+
await harness.callTool('gog_appscript_content', { scriptId: 'S1', account: 'me@x.com' });
|
|
33
|
+
expect(runner.run).toHaveBeenCalledWith(['appscript', 'content', 'S1'], { account: 'me@x.com' });
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('gog_appscript_pull', () => {
|
|
38
|
+
it('pulls into a directory', async () => {
|
|
39
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
40
|
+
const harness = await setupHandlers();
|
|
41
|
+
await harness.callTool('gog_appscript_pull', { scriptId: 'S1', dir: '/tmp/proj' });
|
|
42
|
+
expect(runner.run).toHaveBeenCalledWith(['appscript', 'pull', 'S1', '/tmp/proj'], { account: undefined });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('passes --overwrite', async () => {
|
|
46
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
47
|
+
const harness = await setupHandlers();
|
|
48
|
+
await harness.callTool('gog_appscript_pull', { scriptId: 'S1', dir: '/tmp/proj', overwrite: true });
|
|
49
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
50
|
+
['appscript', 'pull', 'S1', '/tmp/proj', '--overwrite'],
|
|
51
|
+
{ account: undefined },
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// The directory resolves where gog runs, which is a different machine on the
|
|
56
|
+
// hosted connector. A caller who does not know that gets a "success" whose
|
|
57
|
+
// files they cannot reach.
|
|
58
|
+
it('says in its description where the directory resolves', async () => {
|
|
59
|
+
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
|
|
60
|
+
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
|
61
|
+
const configs = new Map<string, { description?: string }>();
|
|
62
|
+
vi.spyOn(server, 'registerTool').mockImplementation((name, config) => {
|
|
63
|
+
configs.set(name, config as { description?: string });
|
|
64
|
+
return undefined as never;
|
|
65
|
+
});
|
|
66
|
+
registerAppScriptTools(server);
|
|
67
|
+
const desc = configs.get('gog_appscript_pull')?.description ?? '';
|
|
68
|
+
expect(desc).toContain('RESOLVED WHERE GOG RUNS');
|
|
69
|
+
expect(desc).toMatch(/gog_appscript_content/);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('gog_appscript_create', () => {
|
|
74
|
+
it('creates a standalone project', async () => {
|
|
75
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
76
|
+
const harness = await setupHandlers();
|
|
77
|
+
await harness.callTool('gog_appscript_create', { title: 'Helpers' });
|
|
78
|
+
expect(runner.run).toHaveBeenCalledWith(['appscript', 'create', '--title=Helpers'], { account: undefined });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('binds the project to a Drive file', async () => {
|
|
82
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
83
|
+
const harness = await setupHandlers();
|
|
84
|
+
await harness.callTool('gog_appscript_create', { title: 'Bound', parentId: 'FILE1' });
|
|
85
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
86
|
+
['appscript', 'create', '--title=Bound', '--parent-id=FILE1'],
|
|
87
|
+
{ account: undefined },
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('gog_appscript_deployments', () => {
|
|
93
|
+
it('lists deployments with pagination', async () => {
|
|
94
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
95
|
+
const harness = await setupHandlers();
|
|
96
|
+
await harness.callTool('gog_appscript_deployments', { scriptId: 'S1', max: 10, pageToken: 'tok' });
|
|
97
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
98
|
+
['appscript', 'deployments', 'S1', '--max=10', '--page=tok'],
|
|
99
|
+
{ account: undefined },
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('gog_appscript_versions', () => {
|
|
105
|
+
it('lists versions', async () => {
|
|
106
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
107
|
+
const harness = await setupHandlers();
|
|
108
|
+
await harness.callTool('gog_appscript_versions', { scriptId: 'S1', all: true });
|
|
109
|
+
expect(runner.run).toHaveBeenCalledWith(['appscript', 'versions', 'S1', '--all'], { account: undefined });
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('gog_appscript_run_function', () => {
|
|
114
|
+
it('runs a deployed function', async () => {
|
|
115
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
116
|
+
const harness = await setupHandlers();
|
|
117
|
+
await harness.callTool('gog_appscript_run_function', { scriptId: 'S1', functionName: 'doWork' });
|
|
118
|
+
expect(runner.run).toHaveBeenCalledWith(['appscript', 'run', 'S1', 'doWork'], { account: undefined });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('passes params and --dev-mode', async () => {
|
|
122
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
123
|
+
const harness = await setupHandlers();
|
|
124
|
+
await harness.callTool('gog_appscript_run_function', {
|
|
125
|
+
scriptId: 'S1', functionName: 'doWork', params: '["a",1]', devMode: true,
|
|
126
|
+
});
|
|
127
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
128
|
+
['appscript', 'run', 'S1', 'doWork', '--params=["a",1]', '--dev-mode'],
|
|
129
|
+
{ account: undefined },
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('rejects params that are not a JSON array before spawning gog', async () => {
|
|
134
|
+
const harness = await setupHandlers();
|
|
135
|
+
const result = await harness.callTool('gog_appscript_run_function', {
|
|
136
|
+
scriptId: 'S1', functionName: 'doWork', params: '{"a":1}',
|
|
137
|
+
});
|
|
138
|
+
expect(result.isError).toBe(true);
|
|
139
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects params that are not JSON at all', async () => {
|
|
143
|
+
const harness = await setupHandlers();
|
|
144
|
+
const result = await harness.callTool('gog_appscript_run_function', {
|
|
145
|
+
scriptId: 'S1', functionName: 'doWork', params: 'a,1',
|
|
146
|
+
});
|
|
147
|
+
expect(result.isError).toBe(true);
|
|
148
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('gog_appscript_run', () => {
|
|
153
|
+
it('passes the subcommand and args through', async () => {
|
|
154
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
155
|
+
const harness = await setupHandlers();
|
|
156
|
+
await harness.callTool('gog_appscript_run', { subcommand: 'get', args: ['S1'] });
|
|
157
|
+
expect(runner.run).toHaveBeenCalledWith(['appscript', 'get', 'S1'], { account: undefined });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -457,3 +457,124 @@ describe('gog_calendar_events — pagination (previously absent entirely)', () =
|
|
|
457
457
|
expect(out).not.toHaveProperty('nextPageToken');
|
|
458
458
|
});
|
|
459
459
|
});
|
|
460
|
+
|
|
461
|
+
// Reminders (gog >= 0.38.0 for --no-reminders, openclaw/gogcli#1002/#1016).
|
|
462
|
+
// Three distinct states share these two params and gog spells each one
|
|
463
|
+
// differently, so each is pinned separately: custom overrides, "no reminders at
|
|
464
|
+
// all", and — on update only — "go back to whatever the calendar says", which
|
|
465
|
+
// is an EMPTY --reminder rather than a flag of its own. All three were verified
|
|
466
|
+
// against a real gog 0.38.0 with --dry-run.
|
|
467
|
+
describe('gog_calendar_create reminders', () => {
|
|
468
|
+
it('passes each reminder as its own repeated flag', async () => {
|
|
469
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
470
|
+
const harness = await setupHandlers();
|
|
471
|
+
await harness.callTool('gog_calendar_create', {
|
|
472
|
+
calendarId: 'primary',
|
|
473
|
+
summary: 'Standup',
|
|
474
|
+
from: '2026-04-14T09:00:00Z',
|
|
475
|
+
to: '2026-04-14T09:30:00Z',
|
|
476
|
+
reminders: ['popup:30m', 'email:1d'],
|
|
477
|
+
});
|
|
478
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
479
|
+
['calendar', 'create', 'primary', '--summary=Standup', '--from=2026-04-14T09:00:00Z', '--to=2026-04-14T09:30:00Z',
|
|
480
|
+
'--reminder=popup:30m', '--reminder=email:1d'],
|
|
481
|
+
{ account: undefined },
|
|
482
|
+
);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('passes --no-reminders', async () => {
|
|
486
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
487
|
+
const harness = await setupHandlers();
|
|
488
|
+
await harness.callTool('gog_calendar_create', {
|
|
489
|
+
calendarId: 'primary',
|
|
490
|
+
summary: 'Quiet',
|
|
491
|
+
from: '2026-04-14T09:00:00Z',
|
|
492
|
+
to: '2026-04-14T09:30:00Z',
|
|
493
|
+
noReminders: true,
|
|
494
|
+
});
|
|
495
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
496
|
+
['calendar', 'create', 'primary', '--summary=Quiet', '--from=2026-04-14T09:00:00Z', '--to=2026-04-14T09:30:00Z',
|
|
497
|
+
'--no-reminders'],
|
|
498
|
+
{ account: undefined },
|
|
499
|
+
);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('rejects reminders and noReminders together, as gog does', async () => {
|
|
503
|
+
const harness = await setupHandlers();
|
|
504
|
+
const result = await harness.callTool('gog_calendar_create', {
|
|
505
|
+
calendarId: 'primary',
|
|
506
|
+
summary: 'Both',
|
|
507
|
+
from: '2026-04-14T09:00:00Z',
|
|
508
|
+
to: '2026-04-14T09:30:00Z',
|
|
509
|
+
reminders: ['popup:10m'],
|
|
510
|
+
noReminders: true,
|
|
511
|
+
});
|
|
512
|
+
expect(result.isError).toBe(true);
|
|
513
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('rejects more than the five reminders Google allows', async () => {
|
|
517
|
+
const harness = await setupHandlers();
|
|
518
|
+
const result = await harness.callTool('gog_calendar_create', {
|
|
519
|
+
calendarId: 'primary',
|
|
520
|
+
summary: 'Too many',
|
|
521
|
+
from: '2026-04-14T09:00:00Z',
|
|
522
|
+
to: '2026-04-14T09:30:00Z',
|
|
523
|
+
reminders: ['popup:1m', 'popup:2m', 'popup:3m', 'popup:4m', 'popup:5m', 'popup:6m'],
|
|
524
|
+
});
|
|
525
|
+
expect(result.isError).toBe(true);
|
|
526
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
describe('gog_calendar_update reminders', () => {
|
|
531
|
+
it('replaces the overrides', async () => {
|
|
532
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
533
|
+
const harness = await setupHandlers();
|
|
534
|
+
await harness.callTool('gog_calendar_update', {
|
|
535
|
+
calendarId: 'primary', eventId: 'evt1', reminders: ['popup:15m'],
|
|
536
|
+
});
|
|
537
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
538
|
+
['calendar', 'update', 'primary', 'evt1', '--reminder=popup:15m'],
|
|
539
|
+
{ account: undefined },
|
|
540
|
+
);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// Verified live: `calendar update … --reminder= --dry-run` patches
|
|
544
|
+
// reminders.useDefault=true with overrides cleared.
|
|
545
|
+
it('restores the calendar defaults with an empty reminder list', async () => {
|
|
546
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
547
|
+
const harness = await setupHandlers();
|
|
548
|
+
await harness.callTool('gog_calendar_update', {
|
|
549
|
+
calendarId: 'primary', eventId: 'evt1', reminders: [],
|
|
550
|
+
});
|
|
551
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
552
|
+
['calendar', 'update', 'primary', 'evt1', '--reminder='],
|
|
553
|
+
{ account: undefined },
|
|
554
|
+
);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('turns every reminder off', async () => {
|
|
558
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
559
|
+
const harness = await setupHandlers();
|
|
560
|
+
await harness.callTool('gog_calendar_update', {
|
|
561
|
+
calendarId: 'primary', eventId: 'evt1', noReminders: true,
|
|
562
|
+
});
|
|
563
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
564
|
+
['calendar', 'update', 'primary', 'evt1', '--no-reminders'],
|
|
565
|
+
{ account: undefined },
|
|
566
|
+
);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('leaves reminders alone when neither param is given', async () => {
|
|
570
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
571
|
+
const harness = await setupHandlers();
|
|
572
|
+
await harness.callTool('gog_calendar_update', {
|
|
573
|
+
calendarId: 'primary', eventId: 'evt1', summary: 'Renamed',
|
|
574
|
+
});
|
|
575
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
576
|
+
['calendar', 'update', 'primary', 'evt1', '--summary=Renamed'],
|
|
577
|
+
{ account: undefined },
|
|
578
|
+
);
|
|
579
|
+
});
|
|
580
|
+
});
|