gogcli-mcp 2.23.0 → 2.23.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 +234 -40
- package/dist/lib.js +243 -41
- package/manifest.json +3 -3
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/gmail-results.ts +228 -0
- package/src/lib.ts +9 -0
- package/src/pagination.ts +108 -0
- package/src/tools/calendar.ts +18 -5
- package/src/tools/classroom.ts +46 -28
- package/src/tools/drive.ts +6 -4
- package/src/tools/gmail.ts +29 -4
- package/src/tools/utils.ts +36 -5
- package/src/worker.ts +1 -1
- package/tests/gmail-results.test.ts +285 -0
- package/tests/page-cursor-contract.test.ts +50 -0
- package/tests/pagination.test.ts +102 -0
- package/tests/tools/calendar.test.ts +51 -0
- package/tests/tools/gmail.test.ts +146 -0
package/src/tools/gmail.ts
CHANGED
|
@@ -1,23 +1,48 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { accountParam, runOrDiagnose, registerRunTool, payloadArg } from './utils.js';
|
|
3
|
+
import { accountParam, runOrDiagnose, registerRunTool, payloadArg, pageTokenParam, pageAliasParam, resolvePageToken } from './utils.js';
|
|
4
|
+
import { finalizeGmailSearch, fetchGmailPages } from '../gmail-results.js';
|
|
4
5
|
import type { GogArg } from '../runner.js';
|
|
5
6
|
|
|
6
7
|
export function registerGmailTools(server: McpServer): void {
|
|
7
8
|
server.registerTool('gog_gmail_search', {
|
|
8
|
-
description: 'Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread"). The query is passed verbatim to Gmail; a bare name token (from:alison) matches per Gmail\'s own heuristics, a full address (from:alison@example.com) is exact. To match a contact across several addresses, OR them: from:(a@x.com OR b@y.com).'
|
|
9
|
+
description: 'Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread"). The query is passed verbatim to Gmail; a bare name token (from:alison) matches per Gmail\'s own heuristics, a full address (from:alison@example.com) is exact. To match a contact across several addresses, OR them: from:(a@x.com OR b@y.com). '
|
|
10
|
+
+ 'Results are ALWAYS newest-first by Gmail\'s internalDate — the wrapper sorts them, so the first result is the most recent match and a recent message can never be buried below older ones. '
|
|
11
|
+
+ 'IMPORTANT — a response carrying "truncated": true is an INCOMPLETE view of the matches: NEVER report that a message does not exist, or that there is no such mail, on the strength of one. Page through it (pass nextPageToken back as `pageToken`), set maxPages to walk several pages in one call, or narrow the query, and only then draw a conclusion. '
|
|
12
|
+
+ 'If you already know the thread, do not search for it at all — read it directly with gog_gmail_thread_get, which returns the whole thread and cannot be truncated or mis-ranked.',
|
|
9
13
|
annotations: { readOnlyHint: true },
|
|
10
14
|
inputSchema: {
|
|
11
15
|
query: z.string().describe('Gmail search query'),
|
|
12
16
|
max: z.number().int().optional().describe('Max results to return (default: 10)'),
|
|
17
|
+
pageToken: pageTokenParam,
|
|
18
|
+
page: pageAliasParam,
|
|
19
|
+
maxPages: z.number().int().positive().max(20).optional().describe('Walk up to this many pages in ONE call and merge the results, instead of returning a single page. Use it for existence questions (\"is there any mail matching X?\"), which a single page cannot answer. Stops early at the last page; if pages remain when the cap is hit the response is still marked truncated. Prefer this over all=true, which is unbounded.'),
|
|
20
|
+
all: z.boolean().optional().describe('Fetch every page instead of one. Removes truncation entirely, at the cost of one API round-trip per page — the reliable way to answer "does any message match?" for a query with few expected hits.'),
|
|
13
21
|
fromContact: z.string().optional().describe('Resolve a Google Contact (name or email) to its addresses and AND a from:(addr OR addr) clause onto the query — saves looking the contact up first when you only know who, not which address.'),
|
|
14
22
|
account: accountParam,
|
|
15
23
|
},
|
|
16
|
-
}, async ({ query, max, fromContact, account }) => {
|
|
24
|
+
}, async ({ query, max, pageToken, page, maxPages, all, fromContact, account }) => {
|
|
17
25
|
const args = ['gmail', 'search', query];
|
|
18
26
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
27
|
+
if (all) args.push('--all');
|
|
19
28
|
if (fromContact) args.push(`--from-contact=${fromContact}`);
|
|
20
|
-
|
|
29
|
+
// The cursor is applied per page rather than baked into args, so the
|
|
30
|
+
// multi-page walk can advance it.
|
|
31
|
+
const runPage = (tok: string | undefined) =>
|
|
32
|
+
runOrDiagnose(tok ? [...args, `--page=${tok}`] : args, { account });
|
|
33
|
+
const token = resolvePageToken({ pageToken, page });
|
|
34
|
+
const result = maxPages !== undefined
|
|
35
|
+
? await fetchGmailPages(runPage, 'threads', maxPages, token)
|
|
36
|
+
: await runPage(token);
|
|
37
|
+
return finalizeGmailSearch(result, {
|
|
38
|
+
itemsKey: 'threads',
|
|
39
|
+
method: 'users.threads.list',
|
|
40
|
+
query,
|
|
41
|
+
account,
|
|
42
|
+
// --from-contact is expanded INSIDE gog, against the People API, so the
|
|
43
|
+
// query Gmail actually saw is not the one we hold here.
|
|
44
|
+
queryIsExact: !fromContact,
|
|
45
|
+
});
|
|
21
46
|
});
|
|
22
47
|
|
|
23
48
|
server.registerTool('gog_gmail_get', {
|
package/src/tools/utils.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { errorResult, rawTextResult } from '@chrischall/mcp-utils';
|
|
|
5
5
|
import { run, isRunnerTransportError } from '../runner.js';
|
|
6
6
|
import type { GogArg, RunnerFailureKind } from '../runner.js';
|
|
7
7
|
import { normalizeTimestamps } from '../timestamps.js';
|
|
8
|
+
import { stripConsumedPageToken } from '../pagination.js';
|
|
8
9
|
|
|
9
10
|
// Byte size at or below which a payload stays on the plain inline flag.
|
|
10
11
|
//
|
|
@@ -77,10 +78,36 @@ export const ids = {
|
|
|
77
78
|
person: z.string().describe('Person resource name (people/...) or email'),
|
|
78
79
|
};
|
|
79
80
|
|
|
80
|
-
//
|
|
81
|
+
// THE CURSOR IS NAMED AFTER THE FIELD THAT CARRIES IT. Every paginated response
|
|
82
|
+
// reports its cursor as `nextPageToken`, so the request parameter is
|
|
83
|
+
// `pageToken` — a caller reading a response can guess the input name and be
|
|
84
|
+
// right. It used to be `page` (after gog's own `--page` flag), and that
|
|
85
|
+
// mismatch was not cosmetic: MCP tool inputs are zod objects, which SILENTLY
|
|
86
|
+
// STRIP unknown keys, so a client that inferred `pageToken` had it dropped
|
|
87
|
+
// before the handler ran and got page 1 back forever — same items, same token,
|
|
88
|
+
// no error. Two "that email doesn't exist" incidents came from exactly that.
|
|
89
|
+
export const pageTokenParam = z.string().optional().describe(
|
|
90
|
+
'Cursor for the NEXT page. Pass back the nextPageToken from a previous response verbatim, ' +
|
|
91
|
+
'keeping the query and max identical: call once, then call again with pageToken=<that value>. ' +
|
|
92
|
+
'A response with NO nextPageToken is the last page.',
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// Kept so anything already sending `page` keeps working. Prefer pageTokenParam.
|
|
96
|
+
export const pageAliasParam = z.string().optional().describe(
|
|
97
|
+
'Deprecated alias for pageToken, accepted so existing callers keep working. Use pageToken — ' +
|
|
98
|
+
'it matches the nextPageToken field in the response.',
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// The one place the alias collapses into a single value.
|
|
102
|
+
export function resolvePageToken(p: { pageToken?: string; page?: string }): string | undefined {
|
|
103
|
+
return p.pageToken ?? p.page;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Pagination params — appear in 30+ tools across base + extras.
|
|
81
107
|
export const paginationParams = {
|
|
82
108
|
max: z.number().int().optional().describe('Max results'),
|
|
83
|
-
|
|
109
|
+
pageToken: pageTokenParam,
|
|
110
|
+
page: pageAliasParam,
|
|
84
111
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
85
112
|
};
|
|
86
113
|
|
|
@@ -88,10 +115,11 @@ export const paginationParams = {
|
|
|
88
115
|
// paginationParams above. Use together to keep call sites concise.
|
|
89
116
|
export function pushPaginationFlags(
|
|
90
117
|
args: string[],
|
|
91
|
-
p: { max?: number; page?: string; all?: boolean },
|
|
118
|
+
p: { max?: number; pageToken?: string; page?: string; all?: boolean },
|
|
92
119
|
): void {
|
|
93
120
|
if (p.max !== undefined) args.push(`--max=${p.max}`);
|
|
94
|
-
|
|
121
|
+
const token = resolvePageToken(p);
|
|
122
|
+
if (token) args.push(`--page=${token}`);
|
|
95
123
|
if (p.all) args.push('--all');
|
|
96
124
|
}
|
|
97
125
|
|
|
@@ -330,7 +358,10 @@ export async function runOrDiagnose(
|
|
|
330
358
|
// truth would stop telling it. Losslessness wins over presentation there —
|
|
331
359
|
// the friendlier views of the same data are already normalized.
|
|
332
360
|
const raw = await run(args, options);
|
|
333
|
-
|
|
361
|
+
// Same seam, same reason as normalizeTimestamps: doing this per call site
|
|
362
|
+
// would let one paginated tool forget and go on reporting a spent cursor as
|
|
363
|
+
// if it were a live one. `lossless` opts the raw dumps out of both.
|
|
364
|
+
return rawTextResult(options.lossless ? raw : stripConsumedPageToken(normalizeTimestamps(raw)));
|
|
334
365
|
} catch (err) {
|
|
335
366
|
return diagnose(err);
|
|
336
367
|
}
|
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.23.
|
|
41
|
+
const VERSION = '2.23.2'; // 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.
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { rawTextResult, errorResult } from '@chrischall/mcp-utils';
|
|
3
|
+
import * as runner from '../src/runner.js';
|
|
4
|
+
import { finalizeGmailSearch, fetchGmailPages } from '../src/gmail-results.js';
|
|
5
|
+
|
|
6
|
+
vi.mock('../src/runner.js');
|
|
7
|
+
|
|
8
|
+
beforeEach(() => vi.clearAllMocks());
|
|
9
|
+
|
|
10
|
+
const THREADS = (nextPageToken: string) => JSON.stringify({
|
|
11
|
+
threads: [
|
|
12
|
+
{ id: 'a', internalDateIso: '2026-08-01T09:00:00-04:00' },
|
|
13
|
+
{ id: 'b', internalDateIso: '2026-08-12T12:36:00-04:00' },
|
|
14
|
+
{ id: 'c', internalDateIso: '2026-08-05T09:00:00-04:00' },
|
|
15
|
+
],
|
|
16
|
+
nextPageToken,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const THREAD_OPTS = { itemsKey: 'threads', method: 'users.threads.list', query: 'x' } as const;
|
|
20
|
+
|
|
21
|
+
const parse = (r: { content: { type: string; text?: string }[] }) =>
|
|
22
|
+
JSON.parse(r.content[0].text as string);
|
|
23
|
+
|
|
24
|
+
describe('finalizeGmailSearch — ordering', () => {
|
|
25
|
+
it('sorts newest-first by internalDateIso', async () => {
|
|
26
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('')), THREAD_OPTS));
|
|
27
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['b', 'c', 'a']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('falls back to date when internalDateIso is absent', async () => {
|
|
31
|
+
const raw = JSON.stringify({
|
|
32
|
+
threads: [
|
|
33
|
+
{ id: 'a', date: '2026-08-01T09:00:00-04:00' },
|
|
34
|
+
{ id: 'b', date: '2026-08-09T09:00:00-04:00' },
|
|
35
|
+
],
|
|
36
|
+
nextPageToken: '',
|
|
37
|
+
});
|
|
38
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(raw), THREAD_OPTS));
|
|
39
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['b', 'a']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('puts undated and unparseable items last, in their original order', async () => {
|
|
43
|
+
const raw = JSON.stringify({
|
|
44
|
+
threads: [
|
|
45
|
+
{ id: 'nodate' },
|
|
46
|
+
{ id: 'empty', internalDateIso: '' },
|
|
47
|
+
{ id: 'bad', internalDateIso: 'not-a-date' },
|
|
48
|
+
{ id: 'dated', internalDateIso: '2026-08-05T09:00:00-04:00' },
|
|
49
|
+
],
|
|
50
|
+
nextPageToken: '',
|
|
51
|
+
});
|
|
52
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(raw), THREAD_OPTS));
|
|
53
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['dated', 'nodate', 'empty', 'bad']);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('ignores a non-string date field', async () => {
|
|
57
|
+
const raw = JSON.stringify({
|
|
58
|
+
threads: [
|
|
59
|
+
{ id: 'weird', internalDateIso: 12345 },
|
|
60
|
+
{ id: 'dated', internalDateIso: '2026-08-05T09:00:00-04:00' },
|
|
61
|
+
],
|
|
62
|
+
nextPageToken: '',
|
|
63
|
+
});
|
|
64
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(raw), THREAD_OPTS));
|
|
65
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['dated', 'weird']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('keeps equal timestamps in their original relative order', async () => {
|
|
69
|
+
const raw = JSON.stringify({
|
|
70
|
+
threads: [
|
|
71
|
+
{ id: 'first', internalDateIso: '2026-08-05T09:00:00-04:00' },
|
|
72
|
+
{ id: 'second', internalDateIso: '2026-08-05T09:00:00-04:00' },
|
|
73
|
+
],
|
|
74
|
+
nextPageToken: '',
|
|
75
|
+
});
|
|
76
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(raw), THREAD_OPTS));
|
|
77
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['first', 'second']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('sorts the messages key too', async () => {
|
|
81
|
+
const raw = JSON.stringify({
|
|
82
|
+
messages: [
|
|
83
|
+
{ id: 'a', internalDateIso: '2026-08-01T09:00:00-04:00' },
|
|
84
|
+
{ id: 'b', internalDateIso: '2026-08-12T09:00:00-04:00' },
|
|
85
|
+
],
|
|
86
|
+
nextPageToken: '',
|
|
87
|
+
});
|
|
88
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(raw), {
|
|
89
|
+
itemsKey: 'messages', method: 'users.messages.list', query: 'x',
|
|
90
|
+
}));
|
|
91
|
+
expect(out.messages.map((t: { id: string }) => t.id)).toEqual(['b', 'a']);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('finalizeGmailSearch — truncation metadata', () => {
|
|
96
|
+
const countReply = (ids: number, more: boolean) => JSON.stringify({
|
|
97
|
+
threads: Array.from({ length: ids }, (_, i) => ({ id: `t${i}` })),
|
|
98
|
+
...(more ? { nextPageToken: 'more' } : {}),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('omits every truncation field when the result set is complete', async () => {
|
|
102
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('')), THREAD_OPTS));
|
|
103
|
+
expect(out).not.toHaveProperty('truncated');
|
|
104
|
+
expect(out).not.toHaveProperty('returned');
|
|
105
|
+
expect(out).not.toHaveProperty('totalMatches');
|
|
106
|
+
expect(out).not.toHaveProperty('warning');
|
|
107
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('omits them when gog reports no nextPageToken field at all', async () => {
|
|
111
|
+
const out = parse(await finalizeGmailSearch(rawTextResult('{"threads":[{"id":"a"}]}'), THREAD_OPTS));
|
|
112
|
+
expect(out).not.toHaveProperty('truncated');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('reports an EXACT total when the count probe reaches the end', async () => {
|
|
116
|
+
vi.mocked(runner.run).mockResolvedValue(countReply(21, false));
|
|
117
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), {
|
|
118
|
+
itemsKey: 'threads', method: 'users.threads.list', query: 'invoice', account: 'me@x.com',
|
|
119
|
+
}));
|
|
120
|
+
expect(out.truncated).toBe(true);
|
|
121
|
+
expect(out.returned).toBe(3);
|
|
122
|
+
expect(out.totalMatches).toBe(21);
|
|
123
|
+
expect(out).not.toHaveProperty('totalMatchesAtLeast');
|
|
124
|
+
expect(out.warning).toBe(
|
|
125
|
+
'INCOMPLETE RESULT SET: returned 3 of 21 matches. Do not report an absence of results ' +
|
|
126
|
+
'based on this response. Page with nextPageToken or narrow the query.',
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('reports a LOWER BOUND when the count probe fills its page', async () => {
|
|
131
|
+
vi.mocked(runner.run).mockResolvedValue(countReply(500, true));
|
|
132
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), THREAD_OPTS));
|
|
133
|
+
expect(out.totalMatchesAtLeast).toBe(500);
|
|
134
|
+
expect(out).not.toHaveProperty('totalMatches');
|
|
135
|
+
expect(out.warning).toBe(
|
|
136
|
+
'INCOMPLETE RESULT SET: returned 3 of at least 500 matches. Do not report an absence of ' +
|
|
137
|
+
'results based on this response. Page with nextPageToken or narrow the query.',
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('counts bare ids for one maximal page, scoped to the matching items key', async () => {
|
|
142
|
+
vi.mocked(runner.run).mockResolvedValue('{"messages":[{"id":"a"}]}');
|
|
143
|
+
const raw = JSON.stringify({ messages: [{ id: 'a' }], nextPageToken: 'tok' });
|
|
144
|
+
await finalizeGmailSearch(rawTextResult(raw), {
|
|
145
|
+
itemsKey: 'messages', method: 'users.messages.list', query: 'invoice', account: 'me@x.com',
|
|
146
|
+
});
|
|
147
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
148
|
+
['api', 'call', 'gmail', 'v1', 'users.messages.list',
|
|
149
|
+
`--params=${JSON.stringify({ userId: 'me', q: 'invoice', maxResults: 500, fields: 'messages/id,nextPageToken' })}`],
|
|
150
|
+
{ account: 'me@x.com' },
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('still warns, without a count, when the count probe fails', async () => {
|
|
155
|
+
vi.mocked(runner.run).mockRejectedValue(new Error('nope'));
|
|
156
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), THREAD_OPTS));
|
|
157
|
+
expect(out.truncated).toBe(true);
|
|
158
|
+
expect(out.returned).toBe(3);
|
|
159
|
+
expect(out).not.toHaveProperty('totalMatches');
|
|
160
|
+
expect(out).not.toHaveProperty('totalMatchesAtLeast');
|
|
161
|
+
expect(out.warning).toBe(
|
|
162
|
+
'INCOMPLETE RESULT SET: returned 3 matches and MORE EXIST beyond this page. Do not report ' +
|
|
163
|
+
'an absence of results based on this response. Page with nextPageToken or narrow the query.',
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('omits the count when the probe output is not JSON', async () => {
|
|
168
|
+
vi.mocked(runner.run).mockResolvedValue('not json');
|
|
169
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), THREAD_OPTS));
|
|
170
|
+
expect(out).not.toHaveProperty('totalMatches');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('omits the count when the probe returns no items array', async () => {
|
|
174
|
+
vi.mocked(runner.run).mockResolvedValue('{"nextPageToken":"x"}');
|
|
175
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), THREAD_OPTS));
|
|
176
|
+
expect(out).not.toHaveProperty('totalMatches');
|
|
177
|
+
expect(out).not.toHaveProperty('totalMatchesAtLeast');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('treats an empty nextPageToken on the probe as the end of the set', async () => {
|
|
181
|
+
vi.mocked(runner.run).mockResolvedValue('{"threads":[{"id":"a"}],"nextPageToken":""}');
|
|
182
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), THREAD_OPTS));
|
|
183
|
+
expect(out.totalMatches).toBe(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('skips the count probe when the query gog ran cannot be reproduced', async () => {
|
|
187
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), {
|
|
188
|
+
...THREAD_OPTS, queryIsExact: false,
|
|
189
|
+
}));
|
|
190
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
191
|
+
expect(out.truncated).toBe(true);
|
|
192
|
+
expect(out).not.toHaveProperty('totalMatches');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('finalizeGmailSearch — pass-through', () => {
|
|
197
|
+
it('passes an error result through untouched', async () => {
|
|
198
|
+
const err = errorResult('Error: boom');
|
|
199
|
+
expect(await finalizeGmailSearch(err, THREAD_OPTS)).toBe(err);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('passes non-JSON output through untouched', async () => {
|
|
203
|
+
const text = rawTextResult('No results');
|
|
204
|
+
expect(await finalizeGmailSearch(text, THREAD_OPTS)).toBe(text);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('passes JSON without the expected items array through untouched', async () => {
|
|
208
|
+
const text = rawTextResult('{"somethingElse":1}');
|
|
209
|
+
expect(await finalizeGmailSearch(text, THREAD_OPTS)).toBe(text);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('passes a result with no text content through untouched', async () => {
|
|
213
|
+
const empty = { content: [] } as unknown as Parameters<typeof finalizeGmailSearch>[0];
|
|
214
|
+
expect(await finalizeGmailSearch(empty, THREAD_OPTS)).toBe(empty);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('handles an empty result set without adding truncation fields', async () => {
|
|
218
|
+
const out = parse(await finalizeGmailSearch(rawTextResult('{"threads":[],"nextPageToken":""}'), THREAD_OPTS));
|
|
219
|
+
expect(out.threads).toEqual([]);
|
|
220
|
+
expect(out).not.toHaveProperty('truncated');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('fetchGmailPages', () => {
|
|
225
|
+
const page = (ids: string[], token?: string) => rawTextResult(JSON.stringify({
|
|
226
|
+
threads: ids.map((id) => ({ id })),
|
|
227
|
+
...(token === undefined ? {} : { nextPageToken: token }),
|
|
228
|
+
}));
|
|
229
|
+
|
|
230
|
+
it('merges pages and drops the cursor when it reaches the end', async () => {
|
|
231
|
+
const runPage = vi.fn()
|
|
232
|
+
.mockResolvedValueOnce(page(['a'], 'T1'))
|
|
233
|
+
.mockResolvedValueOnce(page(['b'], 'T2'))
|
|
234
|
+
.mockResolvedValueOnce(page(['c']));
|
|
235
|
+
const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 5, undefined)).content[0].text as string);
|
|
236
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['a', 'b', 'c']);
|
|
237
|
+
expect(out).not.toHaveProperty('nextPageToken');
|
|
238
|
+
expect(runPage.mock.calls.map((c) => c[0])).toEqual([undefined, 'T1', 'T2']);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('treats an empty-string cursor as the end', async () => {
|
|
242
|
+
const runPage = vi.fn().mockResolvedValueOnce(page(['a'], ''));
|
|
243
|
+
const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 5, undefined)).content[0].text as string);
|
|
244
|
+
expect(out).not.toHaveProperty('nextPageToken');
|
|
245
|
+
expect(runPage).toHaveBeenCalledTimes(1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('keeps the cursor when the page cap is reached first', async () => {
|
|
249
|
+
const runPage = vi.fn()
|
|
250
|
+
.mockResolvedValueOnce(page(['a'], 'T1'))
|
|
251
|
+
.mockResolvedValueOnce(page(['b'], 'T2'));
|
|
252
|
+
const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 2, undefined)).content[0].text as string);
|
|
253
|
+
expect(out.threads).toHaveLength(2);
|
|
254
|
+
expect(out.nextPageToken).toBe('T2');
|
|
255
|
+
expect(runPage).toHaveBeenCalledTimes(2);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('starts from a caller-supplied cursor', async () => {
|
|
259
|
+
const runPage = vi.fn().mockResolvedValue(page(['a']));
|
|
260
|
+
await fetchGmailPages(runPage, 'threads', 3, 'START');
|
|
261
|
+
expect(runPage).toHaveBeenCalledWith('START');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('surfaces a failure on the FIRST page as-is', async () => {
|
|
265
|
+
const err = errorResult('Error: boom');
|
|
266
|
+
const runPage = vi.fn().mockResolvedValue(err);
|
|
267
|
+
expect(await fetchGmailPages(runPage, 'threads', 3, undefined)).toBe(err);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('keeps pages already collected when a LATER page fails, still marked truncated', async () => {
|
|
271
|
+
const runPage = vi.fn()
|
|
272
|
+
.mockResolvedValueOnce(page(['a'], 'T1'))
|
|
273
|
+
.mockResolvedValueOnce(errorResult('Error: boom'));
|
|
274
|
+
const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 3, undefined)).content[0].text as string);
|
|
275
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['a']);
|
|
276
|
+
expect(out.nextPageToken).toBe('T1');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('bails on output it cannot parse or that has no items array', async () => {
|
|
280
|
+
for (const bad of [rawTextResult('not json'), rawTextResult('"scalar"'), rawTextResult('{"other":1}')]) {
|
|
281
|
+
const runPage = vi.fn().mockResolvedValue(bad);
|
|
282
|
+
expect(await fetchGmailPages(runPage, 'threads', 3, undefined)).toBe(bad);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
3
|
+
import { BASE_TOOL_REGISTRARS } from '../src/server.js';
|
|
4
|
+
|
|
5
|
+
// The bug this guards against was a NAME mismatch: the cursor was called `page`
|
|
6
|
+
// while every response reports `nextPageToken`, and MCP inputs are zod objects
|
|
7
|
+
// that silently strip unknown keys — so a client guessing the name from the
|
|
8
|
+
// response got page 1 forever, with no error. Prose that steers a caller back
|
|
9
|
+
// to the deprecated alias re-creates exactly that trap, one tool at a time.
|
|
10
|
+
|
|
11
|
+
type ToolDef = { name: string; description?: string; inputSchema?: { properties?: Record<string, unknown> } };
|
|
12
|
+
|
|
13
|
+
async function allTools(): Promise<ToolDef[]> {
|
|
14
|
+
const harness = await createTestHarness((server) => {
|
|
15
|
+
for (const register of BASE_TOOL_REGISTRARS) register(server);
|
|
16
|
+
});
|
|
17
|
+
const { tools } = await harness.client.listTools();
|
|
18
|
+
await harness.close();
|
|
19
|
+
return tools as ToolDef[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('page-cursor contract across every base tool', () => {
|
|
23
|
+
it('never tells a caller to pass the cursor as the deprecated `page`', async () => {
|
|
24
|
+
// The alias as its own token: `page` closed immediately, which `pageToken`
|
|
25
|
+
// can never match. A looser pattern matches the correct name's prefix and
|
|
26
|
+
// fails on a description that is already right.
|
|
27
|
+
const offenders = (await allTools())
|
|
28
|
+
.filter((t) => /`page`|\bas page\b/i.test(t.description ?? ''))
|
|
29
|
+
.map((t) => t.name);
|
|
30
|
+
expect(offenders).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('always offers the `page` alias wherever `pageToken` is accepted', async () => {
|
|
34
|
+
const mismatched = (await allTools())
|
|
35
|
+
.filter((t) => {
|
|
36
|
+
const props = t.inputSchema?.properties ?? {};
|
|
37
|
+
return 'pageToken' in props && !('page' in props);
|
|
38
|
+
})
|
|
39
|
+
.map((t) => t.name);
|
|
40
|
+
expect(mismatched).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('names the cursor after the response field wherever one is paginated', async () => {
|
|
44
|
+
const tools = await allTools();
|
|
45
|
+
const withCursor = tools.filter((t) => 'pageToken' in (t.inputSchema?.properties ?? {}));
|
|
46
|
+
// Guards the audit itself: if this ever drops to zero the two tests above
|
|
47
|
+
// pass vacuously and the contract stops being checked at all.
|
|
48
|
+
expect(withCursor.length).toBeGreaterThan(0);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { rawTextResult, errorResult } from '@chrischall/mcp-utils';
|
|
3
|
+
import {
|
|
4
|
+
stripConsumedPageToken,
|
|
5
|
+
annotateTruncatedList,
|
|
6
|
+
truncationWarning,
|
|
7
|
+
hasMorePages,
|
|
8
|
+
} from '../src/pagination.js';
|
|
9
|
+
|
|
10
|
+
describe('stripConsumedPageToken', () => {
|
|
11
|
+
it('removes an exhausted cursor so the key means "another page exists"', () => {
|
|
12
|
+
expect(stripConsumedPageToken('{"threads":[{"id":"a"}],"nextPageToken":""}'))
|
|
13
|
+
.toBe('{"threads":[{"id":"a"}]}');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('keeps a live cursor untouched, byte for byte', () => {
|
|
17
|
+
const live = '{"threads":[{"id":"a"}],"nextPageToken":"tok"}';
|
|
18
|
+
expect(stripConsumedPageToken(live)).toBe(live);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('preserves --pretty indentation when it has to re-serialize', () => {
|
|
22
|
+
const pretty = '{\n "threads": [],\n "nextPageToken": ""\n}';
|
|
23
|
+
expect(stripConsumedPageToken(pretty)).toBe('{\n "threads": []\n}');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('treats a tab indent as two spaces, matching the timestamp seam', () => {
|
|
27
|
+
expect(stripConsumedPageToken('{\n\t"a": 1,\n\t"nextPageToken": ""\n}'))
|
|
28
|
+
.toBe('{\n "a": 1\n}');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('leaves a payload with no cursor field alone', () => {
|
|
32
|
+
const plain = '{"threads":[]}';
|
|
33
|
+
expect(stripConsumedPageToken(plain)).toBe(plain);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('only touches the top level — a nested cursor is another resource\'s', () => {
|
|
37
|
+
const nested = '{"thread":{"nextPageToken":""}}';
|
|
38
|
+
expect(stripConsumedPageToken(nested)).toBe(nested);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('passes through non-JSON, empty, array and scalar output untouched', () => {
|
|
42
|
+
for (const text of ['No results', '', ' ', '[{"nextPageToken":""}]', '"a string"', '{not json']) {
|
|
43
|
+
expect(stripConsumedPageToken(text)).toBe(text);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('passes through a JSON null without dereferencing it', () => {
|
|
48
|
+
expect(stripConsumedPageToken('null')).toBe('null');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('truncationWarning', () => {
|
|
53
|
+
it('names an exact total when one is known', () => {
|
|
54
|
+
expect(truncationWarning(3, { total: 21 })).toContain('returned 3 of 21 matches');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('says "at least" for a lower bound', () => {
|
|
58
|
+
expect(truncationWarning(3, { atLeast: 500 })).toContain('returned 3 of at least 500 matches');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('still forbids a negative conclusion when no count is available', () => {
|
|
62
|
+
const w = truncationWarning(3, {});
|
|
63
|
+
expect(w).toContain('returned 3 matches and MORE EXIST beyond this page');
|
|
64
|
+
expect(w).toContain('Do not report an absence of results');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('hasMorePages', () => {
|
|
69
|
+
it('is true only for a live cursor', () => {
|
|
70
|
+
expect(hasMorePages({ nextPageToken: 'tok' })).toBe(true);
|
|
71
|
+
expect(hasMorePages({ nextPageToken: '' })).toBe(false);
|
|
72
|
+
expect(hasMorePages({})).toBe(false);
|
|
73
|
+
expect(hasMorePages({ nextPageToken: 7 })).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('annotateTruncatedList', () => {
|
|
78
|
+
it('adds the block when a live cursor remains', () => {
|
|
79
|
+
const out = JSON.parse(annotateTruncatedList(
|
|
80
|
+
rawTextResult('{"events":[{"id":"a"},{"id":"b"}],"nextPageToken":"tok"}'), 'events',
|
|
81
|
+
).content[0].text as string);
|
|
82
|
+
expect(out.truncated).toBe(true);
|
|
83
|
+
expect(out.returned).toBe(2);
|
|
84
|
+
expect(out.warning).toContain('INCOMPLETE RESULT SET');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns the result untouched when the set is complete', () => {
|
|
88
|
+
const complete = rawTextResult('{"events":[{"id":"a"}]}');
|
|
89
|
+
expect(annotateTruncatedList(complete, 'events')).toBe(complete);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('passes errors, non-JSON, missing arrays and empty content through untouched', () => {
|
|
93
|
+
const err = errorResult('boom');
|
|
94
|
+
expect(annotateTruncatedList(err, 'events')).toBe(err);
|
|
95
|
+
const text = rawTextResult('No results');
|
|
96
|
+
expect(annotateTruncatedList(text, 'events')).toBe(text);
|
|
97
|
+
const other = rawTextResult('{"somethingElse":1,"nextPageToken":"tok"}');
|
|
98
|
+
expect(annotateTruncatedList(other, 'events')).toBe(other);
|
|
99
|
+
const empty = { content: [] } as unknown as Parameters<typeof annotateTruncatedList>[0];
|
|
100
|
+
expect(annotateTruncatedList(empty, 'events')).toBe(empty);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -379,3 +379,54 @@ describe('gog_calendar_run', () => {
|
|
|
379
379
|
});
|
|
380
380
|
});
|
|
381
381
|
|
|
382
|
+
|
|
383
|
+
describe('gog_calendar_events — pagination (previously absent entirely)', () => {
|
|
384
|
+
// gog defaults this command to --max=10 and the tool exposed NEITHER max nor
|
|
385
|
+
// a cursor, so a wide date range silently returned 10 events with a live
|
|
386
|
+
// token the caller could not use. Measured live: 2025-01-01..2026-08-01 gave
|
|
387
|
+
// 10 of 12.
|
|
388
|
+
it('passes --max and --page through to gog', async () => {
|
|
389
|
+
vi.mocked(runner.run).mockResolvedValue('{"events":[]}');
|
|
390
|
+
const harness = await setupHandlers();
|
|
391
|
+
await harness.callTool('gog_calendar_events', { max: 100, pageToken: 'CURSOR' });
|
|
392
|
+
const args = vi.mocked(runner.run).mock.calls[0][0] as string[];
|
|
393
|
+
expect(args).toContain('--max=100');
|
|
394
|
+
expect(args).toContain('--page=CURSOR');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('accepts the deprecated page alias', async () => {
|
|
398
|
+
vi.mocked(runner.run).mockResolvedValue('{"events":[]}');
|
|
399
|
+
const harness = await setupHandlers();
|
|
400
|
+
await harness.callTool('gog_calendar_events', { page: 'CURSOR' });
|
|
401
|
+
expect(vi.mocked(runner.run).mock.calls[0][0]).toContain('--page=CURSOR');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('keeps --all meaning ALL CALENDARS, not all pages', async () => {
|
|
405
|
+
vi.mocked(runner.run).mockResolvedValue('{"events":[]}');
|
|
406
|
+
const harness = await setupHandlers();
|
|
407
|
+
await harness.callTool('gog_calendar_events', { all: true });
|
|
408
|
+
expect(vi.mocked(runner.run).mock.calls[0][0]).toContain('--all');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('marks a capped range truncated, with no fabricated total', async () => {
|
|
412
|
+
vi.mocked(runner.run).mockResolvedValue(JSON.stringify({
|
|
413
|
+
events: [{ id: 'e1' }, { id: 'e2' }],
|
|
414
|
+
nextPageToken: 'MORE',
|
|
415
|
+
}));
|
|
416
|
+
const harness = await setupHandlers();
|
|
417
|
+
const out = JSON.parse((await harness.callTool('gog_calendar_events',
|
|
418
|
+
{ from: '2025-01-01', to: '2026-08-01' })).content[0].text as string);
|
|
419
|
+
expect(out.truncated).toBe(true);
|
|
420
|
+
expect(out.returned).toBe(2);
|
|
421
|
+
expect(out).not.toHaveProperty('totalMatches');
|
|
422
|
+
expect(out.warning).toContain('INCOMPLETE RESULT SET: returned 2 matches and MORE EXIST');
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('leaves a complete range unannotated', async () => {
|
|
426
|
+
vi.mocked(runner.run).mockResolvedValue('{"events":[{"id":"e1"}],"nextPageToken":""}');
|
|
427
|
+
const harness = await setupHandlers();
|
|
428
|
+
const out = JSON.parse((await harness.callTool('gog_calendar_events', {})).content[0].text as string);
|
|
429
|
+
expect(out).not.toHaveProperty('truncated');
|
|
430
|
+
expect(out).not.toHaveProperty('nextPageToken');
|
|
431
|
+
});
|
|
432
|
+
});
|