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.
Files changed (40) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +15416 -18013
  4. package/dist/lib.js +13476 -8827
  5. package/manifest.json +1 -1
  6. package/package.json +3 -3
  7. package/server.json +2 -2
  8. package/src/connector-auth.ts +22 -6
  9. package/src/connector-login.ts +87 -0
  10. package/src/connector-runtime.ts +33 -10
  11. package/src/gmail-dispatch-guard.ts +106 -0
  12. package/src/gmail-results.ts +1 -1
  13. package/src/lib.ts +10 -0
  14. package/src/pagination.ts +1 -1
  15. package/src/tools/api.ts +7 -7
  16. package/src/tools/appscript.ts +15 -15
  17. package/src/tools/auth.ts +11 -11
  18. package/src/tools/calendar.ts +13 -13
  19. package/src/tools/chat.ts +25 -25
  20. package/src/tools/classroom.ts +49 -49
  21. package/src/tools/contacts.ts +9 -9
  22. package/src/tools/docs.ts +13 -13
  23. package/src/tools/drive.ts +22 -22
  24. package/src/tools/gmail.ts +136 -23
  25. package/src/tools/sheets.ts +15 -15
  26. package/src/tools/slides.ts +13 -13
  27. package/src/tools/tasks.ts +13 -13
  28. package/src/tools/utils.ts +2 -3
  29. package/src/worker.ts +52 -64
  30. package/tests/connector-login.test.ts +151 -0
  31. package/tests/connector-runtime.test.ts +21 -1
  32. package/tests/gmail-dispatch-guard.test.ts +132 -0
  33. package/tests/sdk-single-copy.test.ts +18 -36
  34. package/tests/tools/appscript.test.ts +1 -1
  35. package/tests/tools/chat.test.ts +1 -1
  36. package/tests/tools/gmail.test.ts +242 -12
  37. package/tests/tools/sheets.test.ts +1 -1
  38. package/tests/zod-single-copy.test.ts +2 -2
  39. package/tsconfig.json +1 -3
  40. 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": "2.30.0",
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": "2.30.0",
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.23.3",
45
- "@modelcontextprotocol/sdk": "^1.30.0",
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": "2.30.0",
10
+ "version": "3.0.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "2.30.0",
15
+ "version": "3.0.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -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`'s `buildClient` turns this key straight into a
13
- * per-session Fly executor. These props are encrypted at rest in `OAUTH_KV` by
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 satisfies `createConnector`'s
21
- * `Props extends Record<string, unknown>` constraint.
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`'s `buildClient` turns into a per-session Fly executor.
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('&', '&amp;')
6
+ .replaceAll('<', '&lt;')
7
+ .replaceAll('>', '&gt;')
8
+ .replaceAll('"', '&quot;')
9
+ .replaceAll("'", '&#39;');
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
+ }
@@ -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 `@chrischall/mcp-connector`/`agents` runtime and cannot load in
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: on the Worker one
508
- // executor is built per agent session (`worker.ts` `init()`), on stdio one per
509
- // process (`remote-runner.ts`). So a session that is hammering a refused
510
- // credential rate-limits itself without a second, unrelated session's probe
511
- // being suppressed by it — a module global would let one caller's retry loop
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 per-session Fly
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
+ }
@@ -1,4 +1,4 @@
1
- import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
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/sdk/types.js';
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/sdk/server/mcp.js';
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}`);
@@ -1,4 +1,4 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
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/sdk/server/mcp.js';
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,