gogcli-mcp 2.30.0 → 3.0.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/dist/index.js +15416 -18013
- package/dist/lib.js +13476 -8827
- package/manifest.json +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/connector-auth.ts +22 -6
- package/src/connector-login.ts +87 -0
- package/src/connector-runtime.ts +33 -10
- package/src/gmail-dispatch-guard.ts +106 -0
- package/src/gmail-results.ts +1 -1
- package/src/lib.ts +10 -0
- package/src/pagination.ts +1 -1
- package/src/tools/api.ts +7 -7
- package/src/tools/appscript.ts +15 -15
- package/src/tools/auth.ts +11 -11
- package/src/tools/calendar.ts +13 -13
- package/src/tools/chat.ts +25 -25
- package/src/tools/classroom.ts +49 -49
- package/src/tools/contacts.ts +9 -9
- package/src/tools/docs.ts +13 -13
- package/src/tools/drive.ts +22 -22
- package/src/tools/gmail.ts +136 -23
- package/src/tools/sheets.ts +15 -15
- package/src/tools/slides.ts +13 -13
- package/src/tools/tasks.ts +13 -13
- package/src/tools/utils.ts +2 -3
- package/src/worker.ts +52 -64
- package/tests/connector-login.test.ts +151 -0
- package/tests/connector-runtime.test.ts +21 -1
- package/tests/gmail-dispatch-guard.test.ts +132 -0
- package/tests/sdk-single-copy.test.ts +18 -36
- package/tests/tools/appscript.test.ts +1 -1
- package/tests/tools/chat.test.ts +1 -1
- package/tests/tools/gmail.test.ts +242 -12
- package/tests/tools/sheets.test.ts +1 -1
- package/tests/zod-single-copy.test.ts +2 -2
- package/tsconfig.json +1 -3
- package/vitest.config.ts +3 -5
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.3",
|
|
4
4
|
"name": "gogcli-mcp",
|
|
5
5
|
"display_name": "gogcli",
|
|
6
|
-
"version": "
|
|
6
|
+
"version": "3.0.0",
|
|
7
7
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Hall",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/gogcli-mcp",
|
|
5
5
|
"description": "MCP server wrapping gogcli for Google service access",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"test:coverage": "vitest run --coverage"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@chrischall/mcp-utils": "^0.
|
|
45
|
-
"@modelcontextprotocol/
|
|
44
|
+
"@chrischall/mcp-utils": "^0.28.0",
|
|
45
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
46
46
|
"zod": "^4.6.1"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp"
|
|
9
9
|
},
|
|
10
|
-
"version": "
|
|
10
|
+
"version": "3.0.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "
|
|
15
|
+
"version": "3.0.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
package/src/connector-auth.ts
CHANGED
|
@@ -1,24 +1,40 @@
|
|
|
1
|
-
import type { ConnectorAuth } from '@chrischall/mcp-connector';
|
|
2
1
|
import { logAuthTransition, type AuthTransition } from './auth-log.js';
|
|
3
2
|
import { readGoogleProbe } from './google-probe.js';
|
|
4
3
|
import { redactSecrets } from './runner.js';
|
|
5
4
|
|
|
5
|
+
/** One credential field rendered by the connector authorization page. */
|
|
6
|
+
export interface LoginField {
|
|
7
|
+
name: string;
|
|
8
|
+
label: string;
|
|
9
|
+
type?: 'text' | 'password';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Login-page configuration consumed by the local authorization handler. */
|
|
13
|
+
export interface ConnectorAuth<Props> {
|
|
14
|
+
service: string;
|
|
15
|
+
fields: LoginField[];
|
|
16
|
+
userId?: string;
|
|
17
|
+
login(fields: Record<string, string>, env: unknown): Promise<Props>;
|
|
18
|
+
privacyNote?: string;
|
|
19
|
+
accent?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
6
22
|
/**
|
|
7
23
|
* OAuth props stored per user by the Cloudflare connector's OAuth provider.
|
|
8
24
|
*
|
|
9
25
|
* The gogcli remote connector authenticates each user with a single long-lived
|
|
10
26
|
* personal "connector key" — a shared secret (the Fly backend's `RUNNER_KEY`)
|
|
11
27
|
* that authorizes calls to that user's own `gog` backend on Fly.io. There is no
|
|
12
|
-
* refresh cycle: `worker.ts`
|
|
13
|
-
*
|
|
28
|
+
* refresh cycle: `worker.ts` resolves this key to a cached Fly executor for
|
|
29
|
+
* each stateless request. These props are encrypted at rest in `OAUTH_KV` by
|
|
14
30
|
* the OAuth provider.
|
|
15
31
|
*
|
|
16
32
|
* NOTE: this is a FIELD LOGIN (a personal key), NOT Google OAuth. The Google
|
|
17
33
|
* OAuth handshake lives entirely inside the Fly backend's `gog` install; the
|
|
18
34
|
* connector never sees a Google token.
|
|
19
35
|
*
|
|
20
|
-
* The index signature
|
|
21
|
-
*
|
|
36
|
+
* The index signature keeps these props usable across the OAuth provider and
|
|
37
|
+
* MCP request-context boundaries.
|
|
22
38
|
*/
|
|
23
39
|
export interface GogProps {
|
|
24
40
|
key: string;
|
|
@@ -275,7 +291,7 @@ async function recordGoogleLayerAtConnect(endpoint: string, key: string): Promis
|
|
|
275
291
|
* `ConnectorAuth` for the gogcli remote connector: the login page collects the
|
|
276
292
|
* user's connector key, verifies it by hitting the Fly backend's `/health`
|
|
277
293
|
* endpoint with the key as a bearer token, and stores `{ key }` as the OAuth
|
|
278
|
-
* props that `worker.ts`
|
|
294
|
+
* props that `worker.ts` turns into a request-scoped Fly executor.
|
|
279
295
|
*
|
|
280
296
|
* Only the runner judging the bearer (401/403) refuses the login; a backend that
|
|
281
297
|
* does not answer is retried once and then reported as unreachable, never as a
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ConnectorAuth } from './connector-auth.js';
|
|
2
|
+
|
|
3
|
+
function escapeHtml(value: unknown): string {
|
|
4
|
+
return String(value)
|
|
5
|
+
.replaceAll('&', '&')
|
|
6
|
+
.replaceAll('<', '<')
|
|
7
|
+
.replaceAll('>', '>')
|
|
8
|
+
.replaceAll('"', '"')
|
|
9
|
+
.replaceAll("'", ''');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function encodeOauthRequest(value: unknown): string {
|
|
13
|
+
return btoa(JSON.stringify(value));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function renderLoginPage<Props>(
|
|
17
|
+
auth: ConnectorAuth<Props>,
|
|
18
|
+
options: { oauthReq: unknown; error?: string },
|
|
19
|
+
): string {
|
|
20
|
+
const fields = auth.fields.map((field) => `
|
|
21
|
+
<label>${escapeHtml(field.label)}
|
|
22
|
+
<input name="${escapeHtml(field.name)}" type="${field.type ?? 'text'}" required>
|
|
23
|
+
</label>`).join('');
|
|
24
|
+
return `<!doctype html>
|
|
25
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
|
26
|
+
<title>Connect ${escapeHtml(auth.service)}</title>
|
|
27
|
+
<style>body{font:16px system-ui;max-width:34rem;margin:4rem auto;padding:0 1rem;color:#202124}form{display:grid;gap:1rem}label{display:grid;gap:.4rem}input,button{font:inherit;padding:.7rem}button{color:white;background:${escapeHtml(auth.accent ?? '#444')};border:0;border-radius:.3rem}.error{color:#b3261e}</style>
|
|
28
|
+
</head><body><h1>Connect ${escapeHtml(auth.service)}</h1>
|
|
29
|
+
${options.error ? `<p class="error">${escapeHtml(options.error)}</p>` : ''}
|
|
30
|
+
<form method="post">${fields}
|
|
31
|
+
<input type="hidden" name="oauthReq" value="${escapeHtml(encodeOauthRequest(options.oauthReq))}">
|
|
32
|
+
<button type="submit">Authorize</button></form>
|
|
33
|
+
${auth.privacyNote ? `<p>${escapeHtml(auth.privacyNote)}</p>` : ''}
|
|
34
|
+
</body></html>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function messageOf(error: unknown): string {
|
|
38
|
+
return error instanceof Error ? error.message : String(error);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Serve the connector's GET login form and POST authorization completion. */
|
|
42
|
+
export async function handleAuthorize<Props>(
|
|
43
|
+
request: Request,
|
|
44
|
+
env: {
|
|
45
|
+
OAUTH_PROVIDER: {
|
|
46
|
+
parseAuthRequest(request: Request): Promise<unknown>;
|
|
47
|
+
completeAuthorization(input: unknown): Promise<{ redirectTo: string }>;
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
auth: ConnectorAuth<Props>,
|
|
51
|
+
): Promise<Response> {
|
|
52
|
+
if (request.method === 'GET') {
|
|
53
|
+
const oauthReq = await env.OAUTH_PROVIDER.parseAuthRequest(request);
|
|
54
|
+
return new Response(renderLoginPage(auth, { oauthReq }), {
|
|
55
|
+
headers: { 'content-type': 'text/html' },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const formData = await request.formData();
|
|
60
|
+
const encodedOauthReq = formData.get('oauthReq');
|
|
61
|
+
const oauthReq = typeof encodedOauthReq === 'string'
|
|
62
|
+
? JSON.parse(atob(encodedOauthReq))
|
|
63
|
+
: undefined;
|
|
64
|
+
const fields = Object.fromEntries(auth.fields.map((field) => {
|
|
65
|
+
const value = formData.get(field.name);
|
|
66
|
+
return [field.name, typeof value === 'string' ? value : ''];
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const props = await auth.login(fields, env);
|
|
71
|
+
const firstField = auth.fields[0];
|
|
72
|
+
const userId = auth.userId ?? (firstField ? fields[firstField.name] : 'public');
|
|
73
|
+
const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
|
|
74
|
+
request: oauthReq,
|
|
75
|
+
userId,
|
|
76
|
+
scope: [],
|
|
77
|
+
metadata: {},
|
|
78
|
+
props,
|
|
79
|
+
});
|
|
80
|
+
return Response.redirect(redirectTo, 302);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return new Response(renderLoginPage(auth, { oauthReq, error: messageOf(error) }), {
|
|
83
|
+
status: 200,
|
|
84
|
+
headers: { 'content-type': 'text/html' },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/connector-runtime.ts
CHANGED
|
@@ -13,7 +13,7 @@ export type { RunnerFailureKind } from './runner.js';
|
|
|
13
13
|
|
|
14
14
|
// Runtime helpers for the Cloudflare connector (worker.ts), split out here so
|
|
15
15
|
// they can be unit-tested under the node pool — worker.ts itself imports the
|
|
16
|
-
// Worker-only
|
|
16
|
+
// Worker-only `agents` runtime and cannot load in
|
|
17
17
|
// node. These helpers touch only the `runExecutor` seam and global `fetch`.
|
|
18
18
|
|
|
19
19
|
// Mirrors runner.ts's TIMEOUT_MS: the budget the stdio path gives a `gog` call
|
|
@@ -504,14 +504,11 @@ export function makeFlyExecutor(
|
|
|
504
504
|
// Throttle state for the refusal probe, held PER EXECUTOR rather than in a
|
|
505
505
|
// module-level map.
|
|
506
506
|
//
|
|
507
|
-
// That is the scope the thing being throttled actually has:
|
|
508
|
-
//
|
|
509
|
-
// process (`remote-runner.ts`).
|
|
510
|
-
// credential
|
|
511
|
-
//
|
|
512
|
-
// silence everybody else's first and only measurement. It also means the state
|
|
513
|
-
// dies with the session instead of accumulating endpoints for the isolate's
|
|
514
|
-
// lifetime.
|
|
507
|
+
// That is the scope the thing being throttled actually has: the stateless
|
|
508
|
+
// Worker caches one executor per endpoint + connector key for the isolate,
|
|
509
|
+
// while stdio builds one per process (`remote-runner.ts`). Callers sharing a
|
|
510
|
+
// backend credential therefore share its throttle, without one credential's
|
|
511
|
+
// retry loop suppressing the first measurement for another credential.
|
|
515
512
|
let lastProbeAt = Number.NEGATIVE_INFINITY;
|
|
516
513
|
|
|
517
514
|
/**
|
|
@@ -860,10 +857,36 @@ async function attempt(
|
|
|
860
857
|
return stdout;
|
|
861
858
|
}
|
|
862
859
|
|
|
860
|
+
/**
|
|
861
|
+
* Cache Fly executors by endpoint and connector key for one runtime isolate.
|
|
862
|
+
*
|
|
863
|
+
* `makeFlyExecutor` owns the refusal-probe throttle state, so resolving a new
|
|
864
|
+
* executor for every stateless tool call would reset that state and allow each
|
|
865
|
+
* retry to spawn another keyring-locking Google probe.
|
|
866
|
+
*/
|
|
867
|
+
export function createFlyExecutorResolver(
|
|
868
|
+
factory: (endpoint: string, key: string) => GogExecutor = makeFlyExecutor,
|
|
869
|
+
): (endpoint: string, key: string) => GogExecutor {
|
|
870
|
+
const byEndpoint = new Map<string, Map<string, GogExecutor>>();
|
|
871
|
+
return (endpoint, key) => {
|
|
872
|
+
let byKey = byEndpoint.get(endpoint);
|
|
873
|
+
if (!byKey) {
|
|
874
|
+
byKey = new Map();
|
|
875
|
+
byEndpoint.set(endpoint, byKey);
|
|
876
|
+
}
|
|
877
|
+
let executor = byKey.get(key);
|
|
878
|
+
if (!executor) {
|
|
879
|
+
executor = factory(endpoint, key);
|
|
880
|
+
byKey.set(key, executor);
|
|
881
|
+
}
|
|
882
|
+
return executor;
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
863
886
|
// Wrap an McpServer in a Proxy whose `registerTool` (and `tool`, if any
|
|
864
887
|
// registrar uses it) intercepts the tool handler so it runs inside the
|
|
865
888
|
// `runExecutor` ALS scope. This is the crux of the connector: it lets the
|
|
866
|
-
// UNCHANGED base registrars forward every `gog` call to the
|
|
889
|
+
// UNCHANGED base registrars forward every `gog` call to the request's Fly
|
|
867
890
|
// executor without any change to the registrars or `runner.ts` — when a
|
|
868
891
|
// handler's `run()` looks up `runExecutor.getStore()` it finds `executor` and
|
|
869
892
|
// forwards instead of spawning. Everything else proxies through via Reflect.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { CallToolResult, InputRequiredResult, ServerContext } from '@modelcontextprotocol/server';
|
|
2
|
+
import { readEnvVar, requireConfirmation } from '@chrischall/mcp-utils';
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// THE SAFETY RAIL. gog_gmail_reply / reply_all / send / forward / autoreply are
|
|
6
|
+
// the only tools in this fleet that put a message irreversibly into someone
|
|
7
|
+
// else's mailbox on the FIRST call. Every other Gmail write either stages
|
|
8
|
+
// something (drafts) or acts on mail already in this account (labels,
|
|
9
|
+
// archive, trash). A caller that meant "save a draft" and picked the wrong
|
|
10
|
+
// tool — or an agent that inherited the wrong reply target — used to find out
|
|
11
|
+
// only after the send API call already succeeded.
|
|
12
|
+
//
|
|
13
|
+
// MCP elicitation makes the first round inert: it returns an input_required
|
|
14
|
+
// result containing the preview, and only the protocol retry carrying the
|
|
15
|
+
// user's accepted confirmation dispatches. The confirmation is never a tool
|
|
16
|
+
// argument, so a model cannot bypass the user by setting a boolean itself.
|
|
17
|
+
// ============================================================================
|
|
18
|
+
/** Apply the shared stateless confirmation flow with Gmail-specific copy. */
|
|
19
|
+
export function requireGmailDispatchConfirmation(
|
|
20
|
+
ctx: ServerContext,
|
|
21
|
+
op: string,
|
|
22
|
+
details: Record<string, unknown>,
|
|
23
|
+
): InputRequiredResult | CallToolResult | undefined {
|
|
24
|
+
return requireConfirmation(ctx, {
|
|
25
|
+
action: op,
|
|
26
|
+
message: 'Review and confirm this email dispatch:',
|
|
27
|
+
details,
|
|
28
|
+
confirmationLabel: 'Confirm that this email should be sent now.',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Over-inclusive on purpose: this feeds an audit log and a caller-facing
|
|
33
|
+
// preview, neither of which is the enforcement point (the protocol gate is).
|
|
34
|
+
// Missing a real recipient would be the dangerous direction of error; catching
|
|
35
|
+
// an extra email-shaped substring is not.
|
|
36
|
+
const EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+/gi;
|
|
37
|
+
|
|
38
|
+
export function extractEmails(...values: Array<string | undefined | null>): string[] {
|
|
39
|
+
const seen = new Set<string>();
|
|
40
|
+
const out: string[] = [];
|
|
41
|
+
for (const value of values) {
|
|
42
|
+
if (!value) continue;
|
|
43
|
+
const matches = value.match(EMAIL_PATTERN);
|
|
44
|
+
if (!matches) continue;
|
|
45
|
+
for (const match of matches) {
|
|
46
|
+
const lower = match.toLowerCase();
|
|
47
|
+
if (!seen.has(lower)) {
|
|
48
|
+
seen.add(lower);
|
|
49
|
+
out.push(lower);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// GOG_GMAIL_TRUSTED_DOMAINS names domains that are never "external" — by
|
|
57
|
+
// default just the sending account's own domain, so a reply-all that includes
|
|
58
|
+
// the account itself never reads as a surprise. Comma-separated, additive.
|
|
59
|
+
function trustedDomains(account: string | undefined): Set<string> {
|
|
60
|
+
const domains = new Set<string>();
|
|
61
|
+
const raw = readEnvVar('GOG_GMAIL_TRUSTED_DOMAINS');
|
|
62
|
+
if (raw) {
|
|
63
|
+
for (const part of raw.split(',')) {
|
|
64
|
+
const domain = part.trim().toLowerCase();
|
|
65
|
+
if (domain) domains.add(domain);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const acct = account ?? readEnvVar('GOG_ACCOUNT');
|
|
69
|
+
const at = acct?.indexOf('@') ?? -1;
|
|
70
|
+
if (acct && at > -1) domains.add(acct.slice(at + 1).toLowerCase());
|
|
71
|
+
return domains;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A distinguishable, greppable event for every mail dispatch — recipient
|
|
75
|
+
// count plus whichever recipients fall outside the trusted-domain list — so an
|
|
76
|
+
// unexpected external send (outside counsel, a wrong-number alias) can be
|
|
77
|
+
// caught after the fact even if the confirmation step above is somehow
|
|
78
|
+
// bypassed by a future caller. stdout is the JSON-RPC channel, so this goes to
|
|
79
|
+
// stderr like every other diagnostic in this repo.
|
|
80
|
+
export function logGmailDispatch(tool: string, recipients: string[], account?: string): void {
|
|
81
|
+
const domains = trustedDomains(account);
|
|
82
|
+
const externalRecipients = recipients.filter((recipient) => {
|
|
83
|
+
const at = recipient.indexOf('@');
|
|
84
|
+
const domain = at > -1 ? recipient.slice(at + 1) : '';
|
|
85
|
+
return !domain || !domains.has(domain);
|
|
86
|
+
});
|
|
87
|
+
const event = {
|
|
88
|
+
event: 'gmail_dispatch',
|
|
89
|
+
tool,
|
|
90
|
+
recipientCount: recipients.length,
|
|
91
|
+
externalRecipientCount: externalRecipients.length,
|
|
92
|
+
hasExternalRecipients: externalRecipients.length > 0,
|
|
93
|
+
externalRecipients,
|
|
94
|
+
timestamp: new Date().toISOString(),
|
|
95
|
+
};
|
|
96
|
+
process.stderr.write(`${JSON.stringify(event)}\n`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The single place a CallToolResult's text is pulled back out, for the tools
|
|
100
|
+
// here that need to read gog's own JSON before deciding what to preview or
|
|
101
|
+
// log. Mirrors the shape every runOrDiagnose result actually returns
|
|
102
|
+
// (content[0].text); never throws on an unexpected shape.
|
|
103
|
+
export function resultText(result: CallToolResult): string {
|
|
104
|
+
const first = result.content[0];
|
|
105
|
+
return first && first.type === 'text' && typeof first.text === 'string' ? first.text : '{}';
|
|
106
|
+
}
|
package/src/gmail-results.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CallToolResult } from '@modelcontextprotocol/
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/server';
|
|
2
2
|
import { rawTextResult } from '@chrischall/mcp-utils';
|
|
3
3
|
import { run } from './runner.js';
|
|
4
4
|
import { annotateTruncation, hasMorePages } from './pagination.js';
|
package/src/lib.ts
CHANGED
|
@@ -21,6 +21,16 @@ export {
|
|
|
21
21
|
// same tool name from both registrar lists would be a duplicate-name error.
|
|
22
22
|
export { replySchema, appendReplyFlags } from './tools/gmail.js';
|
|
23
23
|
export type { ReplyFlags } from './tools/gmail.js';
|
|
24
|
+
// The gmail confirmation gate — gog_gmail_reply/send/forward/autoreply are
|
|
25
|
+
// the only tools that dispatch mail irreversibly on the first call. The
|
|
26
|
+
// gmail sub-package's send-side forward/autoreply tools reuse these directly
|
|
27
|
+
// rather than re-declaring the gate; the draft-side twins never import them.
|
|
28
|
+
export {
|
|
29
|
+
extractEmails,
|
|
30
|
+
logGmailDispatch,
|
|
31
|
+
requireGmailDispatchConfirmation,
|
|
32
|
+
resultText,
|
|
33
|
+
} from './gmail-dispatch-guard.js';
|
|
24
34
|
export { run, runBinary, runExecutor, isGogFileArg, MIN_GOG_VERSION } from './runner.js';
|
|
25
35
|
// Sub-package tools that read gog JSON through bare `run()` (rather than the
|
|
26
36
|
// `runOrDiagnose` seam) must still apply this, or their timestamps skip the
|
package/src/pagination.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CallToolResult } from '@modelcontextprotocol/
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/server';
|
|
2
2
|
import { rawTextResult } from '@chrischall/mcp-utils';
|
|
3
3
|
|
|
4
4
|
// gog reports an exhausted cursor as `"nextPageToken": ""` rather than omitting
|
package/src/tools/api.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { McpServer } from '@modelcontextprotocol/
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { accountParam, runOrDiagnose } from './utils.js';
|
|
4
4
|
|
|
@@ -10,10 +10,10 @@ export function registerApiTools(server: McpServer): void {
|
|
|
10
10
|
server.registerTool('gog_api_list', {
|
|
11
11
|
description: 'List the Google Discovery APIs available for gog_api_call / gog_api_describe (name + version + title).',
|
|
12
12
|
annotations: { readOnlyHint: true },
|
|
13
|
-
inputSchema: {
|
|
13
|
+
inputSchema: z.object({
|
|
14
14
|
all: z.boolean().optional().describe('Include every Discovery API (including preview/less-common ones) instead of the curated default set'),
|
|
15
15
|
account: accountParam,
|
|
16
|
-
},
|
|
16
|
+
}),
|
|
17
17
|
}, async ({ all, account }) => {
|
|
18
18
|
const args = ['api', 'list'];
|
|
19
19
|
if (all) args.push('--all');
|
|
@@ -23,12 +23,12 @@ export function registerApiTools(server: McpServer): void {
|
|
|
23
23
|
server.registerTool('gog_api_describe', {
|
|
24
24
|
description: 'Describe a Google Discovery API, or a single method within it — its parameters, request/response schema, and required OAuth scopes. Use this to discover the exact api/version/method and params before calling gog_api_call.',
|
|
25
25
|
annotations: { readOnlyHint: true },
|
|
26
|
-
inputSchema: {
|
|
26
|
+
inputSchema: z.object({
|
|
27
27
|
api: z.string().describe('Discovery API name (e.g. drive, gmail, calendar)'),
|
|
28
28
|
version: z.string().describe('API version (e.g. v3, v1)'),
|
|
29
29
|
method: z.string().optional().describe('Optional method id to describe a single method (e.g. files.list); omit to describe the whole API'),
|
|
30
30
|
account: accountParam,
|
|
31
|
-
},
|
|
31
|
+
}),
|
|
32
32
|
}, async ({ api, version, method, account }) => {
|
|
33
33
|
const args = ['api', 'describe', api, version];
|
|
34
34
|
if (method) args.push(method);
|
|
@@ -38,7 +38,7 @@ export function registerApiTools(server: McpServer): void {
|
|
|
38
38
|
server.registerTool('gog_api_call', {
|
|
39
39
|
description: 'Call any Discovery-described Google API method directly — an escape hatch for endpoints gog has no dedicated tool for. Find the exact api/version/method/params with gog_api_describe first. Read methods (GET/LIST) run as-is. Mutating methods (POST/PUT/PATCH/DELETE) are refused unless you set allowWrite=true — keep it false to preview, or set dryRun=true to print the intended request without sending it.',
|
|
40
40
|
annotations: { destructiveHint: true },
|
|
41
|
-
inputSchema: {
|
|
41
|
+
inputSchema: z.object({
|
|
42
42
|
api: z.string().describe('Discovery API name (e.g. drive, gmail, calendar)'),
|
|
43
43
|
version: z.string().describe('API version (e.g. v3, v1)'),
|
|
44
44
|
method: z.string().describe('Method id to call (e.g. files.list, files.create)'),
|
|
@@ -48,7 +48,7 @@ export function registerApiTools(server: McpServer): void {
|
|
|
48
48
|
allowWrite: z.boolean().optional().describe('Required to invoke a mutating method (POST/PUT/PATCH/DELETE). Without it, gog refuses write methods. Leave unset for read-only calls.'),
|
|
49
49
|
dryRun: z.boolean().optional().describe('Print the intended request and exit without sending it (no changes made)'),
|
|
50
50
|
account: accountParam,
|
|
51
|
-
},
|
|
51
|
+
}),
|
|
52
52
|
}, async ({ api, version, method, params, body, scope, allowWrite, dryRun, account }) => {
|
|
53
53
|
const args = ['api', 'call', api, version, method];
|
|
54
54
|
if (params) args.push(`--params=${params}`);
|
package/src/tools/appscript.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { McpServer } from '@modelcontextprotocol/
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import {
|
|
4
4
|
accountParam,
|
|
@@ -35,10 +35,10 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
35
35
|
'Get an Apps Script project\'s metadata: title, creator, create/update times, and the parent Drive file when the '
|
|
36
36
|
+ 'project is bound to a Sheet, Doc or Form. Use gog_appscript_content to read the actual code.' + apiEnableNote,
|
|
37
37
|
annotations: { readOnlyHint: true },
|
|
38
|
-
inputSchema: {
|
|
38
|
+
inputSchema: z.object({
|
|
39
39
|
scriptId: scriptIdParam,
|
|
40
40
|
account: accountParam,
|
|
41
|
-
},
|
|
41
|
+
}),
|
|
42
42
|
}, async ({ scriptId, account }) => {
|
|
43
43
|
return runOrDiagnose(['appscript', 'get', scriptId], { account });
|
|
44
44
|
});
|
|
@@ -49,10 +49,10 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
49
49
|
+ 'tool to reach for when the question is "what does this script do"; it needs no filesystem, so it works the same '
|
|
50
50
|
+ 'on a hosted deployment as it does locally, unlike gog_appscript_pull.' + apiEnableNote,
|
|
51
51
|
annotations: { readOnlyHint: true },
|
|
52
|
-
inputSchema: {
|
|
52
|
+
inputSchema: z.object({
|
|
53
53
|
scriptId: scriptIdParam,
|
|
54
54
|
account: accountParam,
|
|
55
|
-
},
|
|
55
|
+
}),
|
|
56
56
|
}, async ({ scriptId, account }) => {
|
|
57
57
|
return runOrDiagnose(['appscript', 'content', scriptId], { account });
|
|
58
58
|
});
|
|
@@ -65,12 +65,12 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
65
65
|
+ 'reach them. Use gog_appscript_content there instead — it returns the same source in the response. Existing files '
|
|
66
66
|
+ 'are left alone unless overwrite is set. Read-only as far as Google is concerned: nothing is pushed back.'
|
|
67
67
|
+ apiEnableNote,
|
|
68
|
-
inputSchema: {
|
|
68
|
+
inputSchema: z.object({
|
|
69
69
|
scriptId: scriptIdParam,
|
|
70
70
|
dir: z.string().describe('Destination directory, resolved on the machine where gog runs'),
|
|
71
71
|
overwrite: z.boolean().optional().describe('Overwrite files that already exist in dir'),
|
|
72
72
|
account: accountParam,
|
|
73
|
-
},
|
|
73
|
+
}),
|
|
74
74
|
}, async ({ scriptId, dir, overwrite, account }) => {
|
|
75
75
|
const args = ['appscript', 'pull', scriptId, dir];
|
|
76
76
|
if (overwrite) args.push('--overwrite');
|
|
@@ -82,11 +82,11 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
82
82
|
'Create a new, empty Apps Script project. Pass parentId to bind it to a Drive file (a Sheet, Doc or Form), which is '
|
|
83
83
|
+ 'what makes the script a container-bound script with access to that document; omit it for a standalone project. '
|
|
84
84
|
+ 'gog cannot upload code, so the project starts empty either way.' + apiEnableNote,
|
|
85
|
-
inputSchema: {
|
|
85
|
+
inputSchema: z.object({
|
|
86
86
|
title: z.string().describe('Project title'),
|
|
87
87
|
parentId: z.string().optional().describe('Drive file ID to bind the project to (Sheet, Doc or Form). Omit for a standalone project.'),
|
|
88
88
|
account: accountParam,
|
|
89
|
-
},
|
|
89
|
+
}),
|
|
90
90
|
}, async ({ title, parentId, account }) => {
|
|
91
91
|
const args = ['appscript', 'create', `--title=${title}`];
|
|
92
92
|
if (parentId) args.push(`--parent-id=${parentId}`);
|
|
@@ -99,11 +99,11 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
99
99
|
+ 'deployment ID from here is what gog_appscript_run_function needs when a script is not running in dev mode.'
|
|
100
100
|
+ apiEnableNote,
|
|
101
101
|
annotations: { readOnlyHint: true },
|
|
102
|
-
inputSchema: {
|
|
102
|
+
inputSchema: z.object({
|
|
103
103
|
scriptId: scriptIdParam,
|
|
104
104
|
...paginationParams,
|
|
105
105
|
account: accountParam,
|
|
106
|
-
},
|
|
106
|
+
}),
|
|
107
107
|
}, async ({ scriptId, max, pageToken, page, all, account }) => {
|
|
108
108
|
const args = ['appscript', 'deployments', scriptId];
|
|
109
109
|
pushPaginationFlags(args, { max, pageToken, page, all });
|
|
@@ -115,11 +115,11 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
115
115
|
'List a project\'s saved versions — the immutable snapshots deployments point at, with their numbers and '
|
|
116
116
|
+ 'descriptions. Useful for answering "what is actually deployed" next to gog_appscript_deployments.' + apiEnableNote,
|
|
117
117
|
annotations: { readOnlyHint: true },
|
|
118
|
-
inputSchema: {
|
|
118
|
+
inputSchema: z.object({
|
|
119
119
|
scriptId: scriptIdParam,
|
|
120
120
|
...paginationParams,
|
|
121
121
|
account: accountParam,
|
|
122
|
-
},
|
|
122
|
+
}),
|
|
123
123
|
}, async ({ scriptId, max, pageToken, page, all, account }) => {
|
|
124
124
|
const args = ['appscript', 'versions', scriptId];
|
|
125
125
|
pushPaginationFlags(args, { max, pageToken, page, all });
|
|
@@ -136,13 +136,13 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
136
136
|
+ 'deployed version, and only works if the account owns the script. '
|
|
137
137
|
+ 'This is NOT the escape hatch — gog_appscript_run is that.' + apiEnableNote,
|
|
138
138
|
annotations: { destructiveHint: true },
|
|
139
|
-
inputSchema: {
|
|
139
|
+
inputSchema: z.object({
|
|
140
140
|
scriptId: scriptIdParam,
|
|
141
141
|
functionName: z.string().describe('Name of the function to call, e.g. "doWork"'),
|
|
142
142
|
params: z.string().optional().describe('Function parameters as a JSON ARRAY of positional arguments, e.g. \'["a", 1]\' — not an object'),
|
|
143
143
|
devMode: z.boolean().optional().describe('Run the latest saved code rather than the deployed version (owner only)'),
|
|
144
144
|
account: accountParam,
|
|
145
|
-
},
|
|
145
|
+
}),
|
|
146
146
|
}, async ({ scriptId, functionName, params, devMode, account }) => {
|
|
147
147
|
// gog passes --params through to the API as-is, so a malformed value comes
|
|
148
148
|
// back as a Google error about the request body rather than about the
|
package/src/tools/auth.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { McpServer } from '@modelcontextprotocol/
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { run } from '../runner.js';
|
|
4
4
|
import { errorResult, rawTextResult } from '@chrischall/mcp-utils';
|
|
@@ -35,7 +35,7 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
35
35
|
'exactly like a healthy one, scopes and all. Use gog_auth_health to check whether an account ' +
|
|
36
36
|
'can actually authenticate.',
|
|
37
37
|
annotations: { readOnlyHint: true },
|
|
38
|
-
inputSchema: {},
|
|
38
|
+
inputSchema: z.object({}),
|
|
39
39
|
}, async () => {
|
|
40
40
|
try {
|
|
41
41
|
return rawTextResult(await run(['auth', 'list']));
|
|
@@ -50,7 +50,7 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
50
50
|
'name this is not a health check — it reads local setup and does not contact Google, so it says ' +
|
|
51
51
|
'nothing about whether an account can still authenticate. Use gog_auth_health for that.',
|
|
52
52
|
annotations: { readOnlyHint: true },
|
|
53
|
-
inputSchema: {},
|
|
53
|
+
inputSchema: z.object({}),
|
|
54
54
|
}, async () => {
|
|
55
55
|
try {
|
|
56
56
|
return rawTextResult(await run(['auth', 'status']));
|
|
@@ -72,7 +72,7 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
72
72
|
'connector key that reaches the gog machine, and nothing else — the Google credential lives on ' +
|
|
73
73
|
'that machine and can be dead while the connection looks perfectly healthy.',
|
|
74
74
|
annotations: { readOnlyHint: true },
|
|
75
|
-
inputSchema: {},
|
|
75
|
+
inputSchema: z.object({}),
|
|
76
76
|
}, async () => {
|
|
77
77
|
try {
|
|
78
78
|
// `run` injects --json; --check makes gog probe each token live.
|
|
@@ -85,7 +85,7 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
85
85
|
server.registerTool('gog_auth_services', {
|
|
86
86
|
description: 'List all Google services supported by gogcli and the OAuth scopes each requires.',
|
|
87
87
|
annotations: { readOnlyHint: true },
|
|
88
|
-
inputSchema: {},
|
|
88
|
+
inputSchema: z.object({}),
|
|
89
89
|
}, async () => {
|
|
90
90
|
try {
|
|
91
91
|
return rawTextResult(await run(['auth', 'services']));
|
|
@@ -102,11 +102,11 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
102
102
|
'If the browser does not open automatically, a fallback URL is included in the response. ' +
|
|
103
103
|
'Use gog_auth_list to check which accounts are already configured.',
|
|
104
104
|
annotations: { destructiveHint: true },
|
|
105
|
-
inputSchema: {
|
|
105
|
+
inputSchema: z.object({
|
|
106
106
|
email: z.string().describe('Google account email to authorize'),
|
|
107
107
|
services: z.string().optional().default(defaultServices).describe(servicesDescribe),
|
|
108
108
|
extraScopes: z.string().optional().describe(extraScopesDescribe),
|
|
109
|
-
},
|
|
109
|
+
}),
|
|
110
110
|
}, async ({ email, services = defaultServices, extraScopes }) => {
|
|
111
111
|
try {
|
|
112
112
|
const args = ['auth', 'add', email, '--services', services];
|
|
@@ -135,11 +135,11 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
135
135
|
'full redirected URL (from the address bar) and you pass it to gog_auth_add_complete. The link is ' +
|
|
136
136
|
'valid for 10 minutes. If you pass a custom `services` here, pass the SAME value to ' +
|
|
137
137
|
'gog_auth_add_complete or the second step will not match this one.',
|
|
138
|
-
inputSchema: {
|
|
138
|
+
inputSchema: z.object({
|
|
139
139
|
email: z.string().describe('Google account email to authorize'),
|
|
140
140
|
services: z.string().optional().default(defaultServices).describe(servicesDescribe),
|
|
141
141
|
extraScopes: z.string().optional().describe(`${extraScopesDescribe} Pass the SAME value to gog_auth_add_complete.`),
|
|
142
|
-
},
|
|
142
|
+
}),
|
|
143
143
|
}, async ({ email, services = defaultServices, extraScopes }) => {
|
|
144
144
|
try {
|
|
145
145
|
// --force-consent guarantees a refresh token even if a prior grant exists
|
|
@@ -161,7 +161,7 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
161
161
|
'it. Use the SAME `services` value you passed to gog_auth_add_url. Must run within 10 minutes of ' +
|
|
162
162
|
'step 1 and against the same gogcli host.',
|
|
163
163
|
annotations: { destructiveHint: true },
|
|
164
|
-
inputSchema: {
|
|
164
|
+
inputSchema: z.object({
|
|
165
165
|
email: z.string().describe('Google account email being authorized (same as step 1)'),
|
|
166
166
|
redirectUrl: z.string().describe(
|
|
167
167
|
'The full localhost redirect URL the user copied from the browser address bar after signing in ' +
|
|
@@ -174,7 +174,7 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
174
174
|
'Extra OAuth scope URIs — MUST match the value passed to gog_auth_add_url, for the same reason `services` must: ' +
|
|
175
175
|
'the two steps have to describe the same grant.',
|
|
176
176
|
),
|
|
177
|
-
},
|
|
177
|
+
}),
|
|
178
178
|
}, async ({ email, redirectUrl, services = defaultServices, extraScopes }) => {
|
|
179
179
|
try {
|
|
180
180
|
const args = ['auth', 'add', email, '--remote', '--step', '2', '--auth-url', redirectUrl,
|