gogcli-mcp 4.2.5 → 4.4.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 +1340 -262
- package/dist/lib.js +1381 -260
- package/manifest.json +9 -2
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/arg-guard.ts +68 -0
- package/src/argv.ts +27 -0
- package/src/attachment-root.ts +111 -0
- package/src/attachments.ts +1 -0
- package/src/blob-upload.ts +4 -2
- package/src/dispatch-confirmation.ts +277 -0
- package/src/file-roots.ts +66 -0
- package/src/gmail-dispatch-guard.ts +70 -30
- package/src/gmail-results.ts +21 -2
- package/src/lib.ts +15 -0
- package/src/run-path-guard.ts +118 -0
- package/src/runner.ts +86 -18
- package/src/send-confirm-token.ts +167 -0
- package/src/tools/api.ts +60 -7
- package/src/tools/appscript.ts +12 -8
- package/src/tools/auth.ts +13 -3
- package/src/tools/calendar.ts +178 -15
- package/src/tools/chat.ts +94 -18
- package/src/tools/classroom.ts +110 -28
- package/src/tools/contacts.ts +5 -3
- package/src/tools/docs.ts +8 -6
- package/src/tools/drive.ts +96 -20
- package/src/tools/gmail.ts +175 -24
- package/src/tools/sheets.ts +10 -8
- package/src/tools/slides.ts +14 -9
- package/src/tools/tasks.ts +7 -5
- package/src/tools/utils.ts +52 -6
- package/tests/arg-guard.test.ts +80 -0
- package/tests/attachment-root.test.ts +130 -0
- package/tests/attachments.test.ts +8 -0
- package/tests/blob-upload.test.ts +4 -3
- package/tests/file-roots.test.ts +101 -0
- package/tests/gmail-dispatch-guard.test.ts +270 -11
- package/tests/gmail-results.test.ts +35 -1
- package/tests/run-path-guard.test.ts +142 -0
- package/tests/runner.test.ts +136 -0
- package/tests/send-confirm-token.test.ts +200 -0
- package/tests/tools/api.test.ts +74 -8
- package/tests/tools/appscript.test.ts +34 -8
- package/tests/tools/auth.test.ts +44 -17
- package/tests/tools/calendar.test.ts +33 -28
- package/tests/tools/chat.test.ts +50 -21
- package/tests/tools/classroom.test.ts +43 -39
- package/tests/tools/contacts.test.ts +3 -2
- package/tests/tools/dispatch-gates.test.ts +396 -0
- package/tests/tools/docs.test.ts +44 -15
- package/tests/tools/drive.test.ts +110 -20
- package/tests/tools/gmail-confirm-token.test.ts +274 -0
- package/tests/tools/gmail.test.ts +227 -29
- package/tests/tools/run-tool-examples.test.ts +69 -0
- package/tests/tools/run-vets.test.ts +131 -0
- package/tests/tools/sheets.test.ts +16 -15
- package/tests/tools/slides.test.ts +47 -11
- package/tests/tools/tasks.test.ts +7 -6
- package/tests/tools/utils.test.ts +32 -31
- package/vitest.config.ts +5 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// THE TOKEN FALLBACK for the Gmail dispatch rail (gmail-dispatch-guard.ts).
|
|
6
|
+
//
|
|
7
|
+
// A client with no MCP elicitation (claude.ai, measured) cannot be shown the
|
|
8
|
+
// confirmation prompt, so every send is refused there. With
|
|
9
|
+
// GOG_SEND_CONFIRM_FALLBACK=token the rail instead runs two phases: phase 1
|
|
10
|
+
// returns the full preview plus a token and sends nothing; phase 2 presents the
|
|
11
|
+
// token, the tool RE-READS what it would send, and dispatches only if that still
|
|
12
|
+
// hashes to what the token was issued for.
|
|
13
|
+
//
|
|
14
|
+
// WHAT THIS DOES AND DOES NOT PROVE. Unlike elicitation, the approval here is a
|
|
15
|
+
// tool argument, so the gate is the model honouring "show this to the user and
|
|
16
|
+
// wait". The token cannot make the model ask; what it guarantees is that what
|
|
17
|
+
// is sent matches what was previewed — a draft edited in a mail client (its
|
|
18
|
+
// messageId rotates), a changed recipient or body, or a swapped attachment
|
|
19
|
+
// between the two calls all refuse. Caller-supplied files are bound by content
|
|
20
|
+
// hash; a stored draft's attachments by name and size, since Gmail's
|
|
21
|
+
// attachment ids are unstable and any edit rotates the messageId anyway. One
|
|
22
|
+
// approval acts once, for one tool, account and target, within the TTL — per
|
|
23
|
+
// PROCESS: spent tokens live in memory, so with a shared GOG_CONFIRM_SECRET a
|
|
24
|
+
// restart or a second instance would accept a spent token again until it
|
|
25
|
+
// expires. That is why the mode is opt-in.
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
export type ConfirmTokenError = 'DRAFT_CHANGED' | 'TOKEN_EXPIRED' | 'TOKEN_REUSED' | 'TOKEN_INVALID';
|
|
29
|
+
|
|
30
|
+
/** What a token is bound to. Every field must match on phase 2. */
|
|
31
|
+
export interface ConfirmBinding {
|
|
32
|
+
tool: string;
|
|
33
|
+
account: string;
|
|
34
|
+
/** The draftId / messageId / query the dispatch acts on. */
|
|
35
|
+
target: string;
|
|
36
|
+
/** A version of the target that rotates on edit — a draft's messageId. */
|
|
37
|
+
revision?: string;
|
|
38
|
+
/** {@link hashSendPayload} of the canonical send payload. */
|
|
39
|
+
payloadHash: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ConfirmTokenVerdict =
|
|
43
|
+
| { ok: true }
|
|
44
|
+
| { ok: false; error: ConfirmTokenError; reason?: 'payload-changed' | 'message-id-rotated' };
|
|
45
|
+
|
|
46
|
+
export const CONFIRM_TOKEN_TTL_DEFAULT_SECONDS = 600;
|
|
47
|
+
|
|
48
|
+
const PREFIX = 'gct1';
|
|
49
|
+
|
|
50
|
+
type Claims = { t: string; a: string; g: string; r?: string; h: string; iat: number; exp: number; n: string };
|
|
51
|
+
|
|
52
|
+
let secret: Buffer | undefined;
|
|
53
|
+
// nonce → expiry (ms). Entries are dropped once they would have expired
|
|
54
|
+
// anyway, so the set is bounded by the tokens spent within one TTL. In memory
|
|
55
|
+
// only: see the header on GOG_CONFIRM_SECRET and restarts.
|
|
56
|
+
const spent = new Map<string, number>();
|
|
57
|
+
|
|
58
|
+
/** GOG_SEND_CONFIRM_FALLBACK=token turns the fallback on. Anything else is off. */
|
|
59
|
+
export function sendConfirmFallbackEnabled(): boolean {
|
|
60
|
+
return readEnvVar('GOG_SEND_CONFIRM_FALLBACK')?.trim().toLowerCase() === 'token';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function confirmTokenTtlSeconds(): number {
|
|
64
|
+
const raw = readEnvVar('GOG_CONFIRM_TTL_SECONDS')?.trim();
|
|
65
|
+
if (raw && /^\d+$/.test(raw)) {
|
|
66
|
+
const n = Number(raw);
|
|
67
|
+
if (n > 0) return n;
|
|
68
|
+
}
|
|
69
|
+
return CONFIRM_TOKEN_TTL_DEFAULT_SECONDS;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// GOG_CONFIRM_SECRET ends in _SECRET, so runner.ts strips it from every gog
|
|
73
|
+
// child — it never leaves this process.
|
|
74
|
+
function hmacKey(): Buffer {
|
|
75
|
+
if (!secret) {
|
|
76
|
+
const configured = readEnvVar('GOG_CONFIRM_SECRET');
|
|
77
|
+
secret = configured ? Buffer.from(configured, 'utf8') : randomBytes(32);
|
|
78
|
+
}
|
|
79
|
+
return secret;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Test seam: forget the secret (a "restart") and every spent token. */
|
|
83
|
+
export function resetConfirmTokenState(): void {
|
|
84
|
+
secret = undefined;
|
|
85
|
+
spent.clear();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Sorted keys, undefined dropped: the same payload re-read on phase 2 must
|
|
89
|
+
// hash identically however its object was assembled.
|
|
90
|
+
function canonicalize(value: unknown): unknown {
|
|
91
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
92
|
+
if (value && typeof value === 'object') {
|
|
93
|
+
const out: Record<string, unknown> = {};
|
|
94
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
95
|
+
const v = (value as Record<string, unknown>)[key];
|
|
96
|
+
if (v !== undefined) out[key] = canonicalize(v);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** SHA-256 (hex) of the canonical JSON of a send payload. */
|
|
104
|
+
export function hashSendPayload(payload: unknown): string {
|
|
105
|
+
return createHash('sha256').update(JSON.stringify(canonicalize(payload))).digest('hex');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sign(body: string): string {
|
|
109
|
+
return createHmac('sha256', hmacKey()).update(`${PREFIX}.${body}`).digest('base64url');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function issueConfirmToken(binding: ConfirmBinding, now = Date.now()): { token: string; expiresAt: string } {
|
|
113
|
+
const exp = now + confirmTokenTtlSeconds() * 1000;
|
|
114
|
+
const claims: Claims = {
|
|
115
|
+
t: binding.tool,
|
|
116
|
+
a: binding.account,
|
|
117
|
+
g: binding.target,
|
|
118
|
+
...(binding.revision === undefined ? {} : { r: binding.revision }),
|
|
119
|
+
h: binding.payloadHash,
|
|
120
|
+
iat: now,
|
|
121
|
+
exp,
|
|
122
|
+
n: randomBytes(16).toString('base64url'),
|
|
123
|
+
};
|
|
124
|
+
const body = Buffer.from(JSON.stringify(claims), 'utf8').toString('base64url');
|
|
125
|
+
return { token: `${PREFIX}.${body}.${sign(body)}`, expiresAt: new Date(exp).toISOString() };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function signatureMatches(body: string, sig: string): boolean {
|
|
129
|
+
const expected = Buffer.from(sign(body), 'utf8');
|
|
130
|
+
const actual = Buffer.from(sig, 'utf8');
|
|
131
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseClaims(body: string): Claims | undefined {
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as Claims;
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Check a phase-2 token against the binding recomputed from a fresh re-read.
|
|
144
|
+
* Only an `ok` verdict consumes the token; a DRAFT_CHANGED leaves it unspent,
|
|
145
|
+
* because the approval it carries is still true of the content it names.
|
|
146
|
+
*/
|
|
147
|
+
export function verifyConfirmToken(token: string, binding: ConfirmBinding, now = Date.now()): ConfirmTokenVerdict {
|
|
148
|
+
for (const [nonce, exp] of spent) if (exp < now) spent.delete(nonce);
|
|
149
|
+
|
|
150
|
+
const parts = token.split('.');
|
|
151
|
+
if (parts.length !== 3 || parts[0] !== PREFIX || !parts[1] || !parts[2]) return { ok: false, error: 'TOKEN_INVALID' };
|
|
152
|
+
const [, body, sig] = parts as [string, string, string];
|
|
153
|
+
if (!signatureMatches(body, sig)) return { ok: false, error: 'TOKEN_INVALID' };
|
|
154
|
+
const claims = parseClaims(body);
|
|
155
|
+
if (!claims) return { ok: false, error: 'TOKEN_INVALID' };
|
|
156
|
+
|
|
157
|
+
if (claims.t !== binding.tool || claims.a !== binding.account || claims.g !== binding.target) {
|
|
158
|
+
return { ok: false, error: 'TOKEN_INVALID' };
|
|
159
|
+
}
|
|
160
|
+
if (spent.has(claims.n)) return { ok: false, error: 'TOKEN_REUSED' };
|
|
161
|
+
if (now > claims.exp) return { ok: false, error: 'TOKEN_EXPIRED' };
|
|
162
|
+
if (claims.r !== binding.revision) return { ok: false, error: 'DRAFT_CHANGED', reason: 'message-id-rotated' };
|
|
163
|
+
if (claims.h !== binding.payloadHash) return { ok: false, error: 'DRAFT_CHANGED', reason: 'payload-changed' };
|
|
164
|
+
|
|
165
|
+
spent.set(claims.n, claims.exp);
|
|
166
|
+
return { ok: true };
|
|
167
|
+
}
|
package/src/tools/api.ts
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import { errorResult } from '@chrischall/mcp-utils';
|
|
4
|
+
import { accountParam, errorText, runOrDiagnose } from './utils.js';
|
|
5
|
+
import { assertSafeForwardedArgs } from '../arg-guard.js';
|
|
6
|
+
import { confineAtFile } from '../file-roots.js';
|
|
7
|
+
import { pos } from '../argv.js';
|
|
8
|
+
import type { GogArg } from '../runner.js';
|
|
9
|
+
import { gatedElsewhere } from '../dispatch-confirmation.js';
|
|
10
|
+
|
|
11
|
+
// Gmail methods gog_api_call refuses outright (audit SEC-2). `allowWrite` is a
|
|
12
|
+
// boolean the MODEL sets, so it cannot stand in for the user's confirmation of
|
|
13
|
+
// a send: `*.send` must go through gog_gmail_send / gog_gmail_drafts_send, which
|
|
14
|
+
// ask the user. Forwarding addresses, auto-forwarding, filters (which can
|
|
15
|
+
// forward) and delegates route FUTURE mail to someone else and are refused for
|
|
16
|
+
// the same reason. --gmail-no-send is pinned on as a runtime backstop too.
|
|
17
|
+
const GMAIL_API_BLOCKED = /(?:\.send$|forwarding|filters\.create|filters\.update|delegates\.create)/i;
|
|
18
|
+
|
|
19
|
+
// The same reasoning for every other action the dispatch rail gates (#400):
|
|
20
|
+
// the dedicated tool asks the user, so the raw API method must not be a way
|
|
21
|
+
// around it. Anchored on a `.` or the start so a Discovery id with the API
|
|
22
|
+
// prefix (`chat.spaces.messages.create`) matches too.
|
|
23
|
+
const DISPATCH_API_BLOCKED: Record<string, Array<{ method: RegExp; does: string; tool: string }>> = {
|
|
24
|
+
chat: [{ method: /(?:^|\.)spaces\.messages\.create$/i, does: 'posts a Chat message', tool: 'gog_chat_messages_send / gog_chat_dm_send' }],
|
|
25
|
+
drive: [{ method: /(?:^|\.)permissions\.(?:create|update)$/i, does: 'grants access to a file', tool: 'gog_drive_share' }],
|
|
26
|
+
classroom: [
|
|
27
|
+
{ method: /(?:^|\.)courses\.announcements\.create$/i, does: 'posts to a class', tool: 'gog_classroom_announcements_create' },
|
|
28
|
+
{ method: /(?:^|\.)invitations\.create$/i, does: 'invites someone to a class', tool: 'gog_classroom_invitations_create' },
|
|
29
|
+
],
|
|
30
|
+
calendar: [
|
|
31
|
+
{ method: /(?:^|\.)events\.(?:insert|import|quickadd)$/i, does: 'can put an event on guests\' calendars', tool: 'gog_calendar_create' },
|
|
32
|
+
{ method: /(?:^|\.)events\.(?:update|patch)$/i, does: 'can change what guests see', tool: 'gog_calendar_update / gog_calendar_respond' },
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function refusedApiCall(api: string, method: string): string | undefined {
|
|
37
|
+
const name = api.trim().toLowerCase();
|
|
38
|
+
const m = method.trim();
|
|
39
|
+
if (name === 'gmail' && GMAIL_API_BLOCKED.test(m)) {
|
|
40
|
+
return `gmail ${method} is not available through gog_api_call: it sends or forwards mail. `
|
|
41
|
+
+ 'Use gog_gmail_send / gog_gmail_drafts_send (which ask the user to confirm) or the dedicated gog_gmail_* tool.';
|
|
42
|
+
}
|
|
43
|
+
const hit = (Object.hasOwn(DISPATCH_API_BLOCKED, name) ? DISPATCH_API_BLOCKED[name] : [])!.find((b) => b.method.test(m));
|
|
44
|
+
return hit ? gatedElsewhere(`${name} ${m}`, 'gog_api_call', hit.does, hit.tool) : undefined;
|
|
45
|
+
}
|
|
4
46
|
|
|
5
47
|
// Generic Google Discovery API access (gog 0.31). gog_api_list / gog_api_describe
|
|
6
48
|
// are read-only Discovery lookups; gog_api_call is a Discovery-backed escape
|
|
@@ -15,7 +57,7 @@ export function registerApiTools(server: McpServer): void {
|
|
|
15
57
|
account: accountParam,
|
|
16
58
|
}),
|
|
17
59
|
}, async ({ all, account }) => {
|
|
18
|
-
const args = ['api', 'list'];
|
|
60
|
+
const args: GogArg[] = ['api', 'list'];
|
|
19
61
|
if (all) args.push('--all');
|
|
20
62
|
return runOrDiagnose(args, { account });
|
|
21
63
|
});
|
|
@@ -30,13 +72,13 @@ export function registerApiTools(server: McpServer): void {
|
|
|
30
72
|
account: accountParam,
|
|
31
73
|
}),
|
|
32
74
|
}, async ({ api, version, method, account }) => {
|
|
33
|
-
const args = ['api', 'describe', api, version];
|
|
34
|
-
if (method) args.push(method);
|
|
75
|
+
const args: GogArg[] = ['api', 'describe', pos(api), pos(version)];
|
|
76
|
+
if (method) args.push(pos(method));
|
|
35
77
|
return runOrDiagnose(args, { account });
|
|
36
78
|
});
|
|
37
79
|
|
|
38
80
|
server.registerTool('gog_api_call', {
|
|
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.',
|
|
81
|
+
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. Gmail send and forwarding methods (users.messages.send, users.drafts.send, forwarding/auto-forwarding, filters, delegates) are refused — use the dedicated gog_gmail_* tools, which ask the user to confirm. So are the other methods a dedicated tool asks about: Chat spaces.messages.create, Drive permissions.create/update, Classroom courses.announcements.create and invitations.create, and Calendar events.insert/import/quickAdd/update/patch.',
|
|
40
82
|
annotations: { destructiveHint: true },
|
|
41
83
|
inputSchema: z.object({
|
|
42
84
|
api: z.string().describe('Discovery API name (e.g. drive, gmail, calendar)'),
|
|
@@ -50,7 +92,18 @@ export function registerApiTools(server: McpServer): void {
|
|
|
50
92
|
account: accountParam,
|
|
51
93
|
}),
|
|
52
94
|
}, async ({ api, version, method, params, body, scope, allowWrite, dryRun, account }) => {
|
|
53
|
-
|
|
95
|
+
try {
|
|
96
|
+
assertSafeForwardedArgs([api, version, method]);
|
|
97
|
+
const refusal = refusedApiCall(api, method);
|
|
98
|
+
if (refusal) throw new Error(refusal);
|
|
99
|
+
// gog reads `@path` from the host for --body (and --params): confine it
|
|
100
|
+
// like every other server-side path (SEC-3/SEC-4).
|
|
101
|
+
if (body) confineAtFile(body, 'body');
|
|
102
|
+
if (params) confineAtFile(params, 'params');
|
|
103
|
+
} catch (err) {
|
|
104
|
+
return errorResult(errorText(err));
|
|
105
|
+
}
|
|
106
|
+
const args: GogArg[] = ['api', 'call', pos(api), pos(version), pos(method)];
|
|
54
107
|
if (params) args.push(`--params=${params}`);
|
|
55
108
|
if (body) args.push(`--body=${body}`);
|
|
56
109
|
if (scope) args.push(`--scope=${scope}`);
|
|
@@ -60,6 +113,6 @@ export function registerApiTools(server: McpServer): void {
|
|
|
60
113
|
if (dryRun) args.push('--dry-run');
|
|
61
114
|
// Fleet convention: --force is appended LAST (after --dry-run when both are set).
|
|
62
115
|
if (allowWrite) args.push('--force');
|
|
63
|
-
return runOrDiagnose(args, { account });
|
|
116
|
+
return runOrDiagnose(args, { account, gmailNoSend: true });
|
|
64
117
|
});
|
|
65
118
|
}
|
package/src/tools/appscript.ts
CHANGED
|
@@ -7,6 +7,9 @@ import {
|
|
|
7
7
|
paginationParams,
|
|
8
8
|
pushPaginationFlags,
|
|
9
9
|
} from './utils.js';
|
|
10
|
+
import { pos } from '../argv.js';
|
|
11
|
+
import { confinePath } from '../file-roots.js';
|
|
12
|
+
import type { GogArg } from '../runner.js';
|
|
10
13
|
|
|
11
14
|
// Google Apps Script (gog >= 0.38.0 for pull/deployments/versions).
|
|
12
15
|
//
|
|
@@ -40,7 +43,7 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
40
43
|
account: accountParam,
|
|
41
44
|
}),
|
|
42
45
|
}, async ({ scriptId, account }) => {
|
|
43
|
-
return runOrDiagnose(['appscript', 'get', scriptId], { account });
|
|
46
|
+
return runOrDiagnose(['appscript', 'get', pos(scriptId)], { account });
|
|
44
47
|
});
|
|
45
48
|
|
|
46
49
|
server.registerTool('gog_appscript_content', {
|
|
@@ -54,7 +57,7 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
54
57
|
account: accountParam,
|
|
55
58
|
}),
|
|
56
59
|
}, async ({ scriptId, account }) => {
|
|
57
|
-
return runOrDiagnose(['appscript', 'content', scriptId], { account });
|
|
60
|
+
return runOrDiagnose(['appscript', 'content', pos(scriptId)], { account });
|
|
58
61
|
});
|
|
59
62
|
|
|
60
63
|
server.registerTool('gog_appscript_pull', {
|
|
@@ -68,12 +71,13 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
68
71
|
annotations: { destructiveHint: true },
|
|
69
72
|
inputSchema: z.object({
|
|
70
73
|
scriptId: scriptIdParam,
|
|
71
|
-
dir: z.string().describe('Destination directory, resolved on the machine where gog runs'),
|
|
74
|
+
dir: z.string().describe('Destination directory, resolved on the machine where gog runs; must be inside the server\'s GOG_FILE_ROOTS directories'),
|
|
72
75
|
overwrite: z.boolean().optional().describe('Overwrite files that already exist in dir'),
|
|
73
76
|
account: accountParam,
|
|
74
77
|
}),
|
|
75
78
|
}, async ({ scriptId, dir, overwrite, account }) => {
|
|
76
|
-
|
|
79
|
+
confinePath(dir, 'dir');
|
|
80
|
+
const args: GogArg[] = ['appscript', 'pull', pos(scriptId), pos(dir)];
|
|
77
81
|
if (overwrite) args.push('--overwrite');
|
|
78
82
|
return runOrDiagnose(args, { account });
|
|
79
83
|
});
|
|
@@ -90,7 +94,7 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
90
94
|
account: accountParam,
|
|
91
95
|
}),
|
|
92
96
|
}, async ({ title, parentId, account }) => {
|
|
93
|
-
const args = ['appscript', 'create', `--title=${title}`];
|
|
97
|
+
const args: GogArg[] = ['appscript', 'create', `--title=${title}`];
|
|
94
98
|
if (parentId) args.push(`--parent-id=${parentId}`);
|
|
95
99
|
return runOrDiagnose(args, { account });
|
|
96
100
|
});
|
|
@@ -107,7 +111,7 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
107
111
|
account: accountParam,
|
|
108
112
|
}),
|
|
109
113
|
}, async ({ scriptId, max, pageToken, page, all, account }) => {
|
|
110
|
-
const args = ['appscript', 'deployments', scriptId];
|
|
114
|
+
const args: GogArg[] = ['appscript', 'deployments', pos(scriptId)];
|
|
111
115
|
pushPaginationFlags(args, { max, pageToken, page, all });
|
|
112
116
|
return runOrDiagnose(args, { account });
|
|
113
117
|
});
|
|
@@ -123,7 +127,7 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
123
127
|
account: accountParam,
|
|
124
128
|
}),
|
|
125
129
|
}, async ({ scriptId, max, pageToken, page, all, account }) => {
|
|
126
|
-
const args = ['appscript', 'versions', scriptId];
|
|
130
|
+
const args: GogArg[] = ['appscript', 'versions', pos(scriptId)];
|
|
127
131
|
pushPaginationFlags(args, { max, pageToken, page, all });
|
|
128
132
|
return runOrDiagnose(args, { account });
|
|
129
133
|
});
|
|
@@ -161,7 +165,7 @@ export function registerAppScriptTools(server: McpServer): void {
|
|
|
161
165
|
throw new Error(`params must be a JSON ARRAY of positional arguments, e.g. '["a", 1]' — Apps Script takes positional arguments, not named ones. Received: ${params}`);
|
|
162
166
|
}
|
|
163
167
|
}
|
|
164
|
-
const args = ['appscript', 'run', scriptId, functionName];
|
|
168
|
+
const args: GogArg[] = ['appscript', 'run', pos(scriptId), pos(functionName)];
|
|
165
169
|
if (params !== undefined) args.push(`--params=${params}`);
|
|
166
170
|
if (devMode) args.push('--dev-mode');
|
|
167
171
|
return runOrDiagnose(args, { account });
|
package/src/tools/auth.ts
CHANGED
|
@@ -3,6 +3,11 @@ import { z } from 'zod';
|
|
|
3
3
|
import { run } from '../runner.js';
|
|
4
4
|
import { errorResult, rawTextResult } from '@chrischall/mcp-utils';
|
|
5
5
|
import { errorText, formatAuthHealth, registerRunTool } from './utils.js';
|
|
6
|
+
import { pos } from '../argv.js';
|
|
7
|
+
import type { GogArg } from '../runner.js';
|
|
8
|
+
|
|
9
|
+
/** The `gog auth` subcommands gog_auth_run may run — account management only. */
|
|
10
|
+
export const AUTH_RUN_SUBCOMMANDS: readonly string[] = ['list', 'status', 'services', 'remove', 'alias'];
|
|
6
11
|
|
|
7
12
|
// Register the auth tools with a specific least-privilege default `services`.
|
|
8
13
|
// Kept internal so the exported `registerAuthTools` stays a bare
|
|
@@ -109,7 +114,7 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
109
114
|
}),
|
|
110
115
|
}, async ({ email, services = defaultServices, extraScopes }) => {
|
|
111
116
|
try {
|
|
112
|
-
const args = ['auth', 'add', email, '--services', services];
|
|
117
|
+
const args: GogArg[] = ['auth', 'add', pos(email), '--services', services];
|
|
113
118
|
// --force-consent rides along with extraScopes and only with them. Google
|
|
114
119
|
// re-prompts for a NEW scope only when consent is forced; without it the
|
|
115
120
|
// account can come back still missing the scope, with a success message —
|
|
@@ -147,7 +152,7 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
147
152
|
// (the whole point when recovering from a dead one). redactMode 'tokens'
|
|
148
153
|
// keeps the consent URL's scope names intact (the shared redactor mangles
|
|
149
154
|
// them) while still stripping any real token — a step-1 URL carries none.
|
|
150
|
-
const args = ['auth', 'add', email, '--remote', '--step', '1', '--services', services, '--force-consent'];
|
|
155
|
+
const args: GogArg[] = ['auth', 'add', pos(email), '--remote', '--step', '1', '--services', services, '--force-consent'];
|
|
151
156
|
if (extraScopes) args.push(`--extra-scopes=${extraScopes}`);
|
|
152
157
|
return rawTextResult(await run(args, { redactMode: 'tokens' }));
|
|
153
158
|
} catch (err) {
|
|
@@ -178,7 +183,7 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
178
183
|
}),
|
|
179
184
|
}, async ({ email, redirectUrl, services = defaultServices, extraScopes }) => {
|
|
180
185
|
try {
|
|
181
|
-
const args = ['auth', 'add', email, '--remote', '--step', '2', '--auth-url', redirectUrl,
|
|
186
|
+
const args: GogArg[] = ['auth', 'add', pos(email), '--remote', '--step', '2', '--auth-url', redirectUrl,
|
|
182
187
|
'--services', services, '--force-consent'];
|
|
183
188
|
if (extraScopes) args.push(`--extra-scopes=${extraScopes}`);
|
|
184
189
|
return rawTextResult(await run(args));
|
|
@@ -187,10 +192,15 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
187
192
|
}
|
|
188
193
|
});
|
|
189
194
|
|
|
195
|
+
// Account management only (audit SEC-3). `auth tokens export` writes a
|
|
196
|
+
// long-lived refresh token to a file, and `credentials` / `keyring` /
|
|
197
|
+
// `tokens import` handle the OAuth client secret and the token store — none of
|
|
198
|
+
// that belongs behind a model-callable escape hatch. `add` has its own tools.
|
|
190
199
|
registerRunTool(server, {
|
|
191
200
|
service: 'auth',
|
|
192
201
|
examples: '"remove", "alias", "list"',
|
|
193
202
|
omitAccount: true,
|
|
203
|
+
allowedSubcommands: AUTH_RUN_SUBCOMMANDS,
|
|
194
204
|
note: 'For browser-based authorization, use gog_auth_add instead.',
|
|
195
205
|
});
|
|
196
206
|
}
|