mcp-google-multi 5.2.0-alpha.1 → 5.2.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ import { getClient } from './client.js';
2
+ export declare const MAX_RESPONSE_CHARS = 100000;
3
+ export type QueryValue = string | number | boolean;
4
+ export type QueryParams = Record<string, QueryValue | QueryValue[]>;
5
+ export interface ApiMethodRef {
6
+ id: string;
7
+ httpMethod: string;
8
+ path: string;
9
+ baseUrl: string;
10
+ requiredParams: string[];
11
+ }
12
+ export interface ExecuteDeps {
13
+ getClientFn?: typeof getClient;
14
+ }
15
+ export interface ExecuteArgs {
16
+ account: string;
17
+ pathParams?: Record<string, string | number>;
18
+ queryParams?: QueryParams;
19
+ body?: unknown;
20
+ }
21
+ export declare function jsonResult(payload: unknown, isError?: boolean): {
22
+ content: {
23
+ type: "text";
24
+ text: string;
25
+ }[];
26
+ } | {
27
+ isError: true;
28
+ content: {
29
+ type: "text";
30
+ text: string;
31
+ }[];
32
+ };
33
+ export declare function buildQueryString(queryParams: QueryParams | undefined): string;
34
+ export declare function executeApiMethod(method: ApiMethodRef, args: ExecuteArgs, deps?: ExecuteDeps): Promise<{
35
+ content: {
36
+ type: "text";
37
+ text: string;
38
+ }[];
39
+ }>;
@@ -0,0 +1,75 @@
1
+ import { getClient } from './client.js';
2
+ import { expandPath, isGoogleApiUrl } from './discovery-client.js';
3
+ import { handleGoogleApiError } from './tools/_errors.js';
4
+ export const MAX_RESPONSE_CHARS = 100_000;
5
+ export function jsonResult(payload, isError = false) {
6
+ const base = { content: [{ type: 'text', text: JSON.stringify(payload) }] };
7
+ return isError ? { ...base, isError: true } : base;
8
+ }
9
+ export function buildQueryString(queryParams) {
10
+ const usp = new URLSearchParams();
11
+ const merged = { alt: 'json', ...queryParams };
12
+ for (const [key, value] of Object.entries(merged)) {
13
+ if (Array.isArray(value)) {
14
+ for (const v of value)
15
+ usp.append(key, String(v));
16
+ }
17
+ else {
18
+ usp.append(key, String(value));
19
+ }
20
+ }
21
+ return usp.toString();
22
+ }
23
+ export async function executeApiMethod(method, args, deps = {}) {
24
+ if (args.queryParams?.alt === 'media') {
25
+ return jsonResult({
26
+ error: 'binary_unsupported',
27
+ message: 'Binary media download (alt=media) returns no usable JSON through this tool.',
28
+ hint: 'Use drive_download / drive_export for file content.',
29
+ retriable: false,
30
+ }, true);
31
+ }
32
+ let url;
33
+ try {
34
+ url = method.baseUrl + expandPath(method.path, args.pathParams ?? {});
35
+ }
36
+ catch (err) {
37
+ return jsonResult({
38
+ error: 'invalid_params',
39
+ message: err.message,
40
+ hint: `Required params for ${method.id}: ${method.requiredParams.join(', ') || '(none)'}`,
41
+ retriable: false,
42
+ }, true);
43
+ }
44
+ if (!isGoogleApiUrl(url)) {
45
+ return jsonResult({
46
+ error: 'untrusted_host',
47
+ message: `Refusing to send credentials to a non-googleapis.com host for "${method.id}".`,
48
+ hint: 'The discovery cache may be corrupt; delete DISCOVERY_CACHE_PATH and retry.',
49
+ retriable: false,
50
+ }, true);
51
+ }
52
+ try {
53
+ const getClientFn = deps.getClientFn ?? getClient;
54
+ const auth = await getClientFn(args.account);
55
+ const res = await auth.request({
56
+ // query string built by hand: gaxios comma-joins arrays, Google needs repeated keys
57
+ url: `${url}?${buildQueryString(args.queryParams)}`,
58
+ method: method.httpMethod,
59
+ data: args.body ?? undefined,
60
+ });
61
+ const text = JSON.stringify(res.data ?? null);
62
+ if (text.length > MAX_RESPONSE_CHARS) {
63
+ return jsonResult({
64
+ truncated: true,
65
+ totalChars: text.length,
66
+ head: text.slice(0, MAX_RESPONSE_CHARS),
67
+ hint: 'Narrow the request (fields mask, pageSize) to get complete JSON.',
68
+ });
69
+ }
70
+ return { content: [{ type: 'text', text }] };
71
+ }
72
+ catch (error) {
73
+ return handleGoogleApiError(error, args.account);
74
+ }
75
+ }
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { registerFormsTools } from './tools/forms.js';
19
19
  import { registerChatTools } from './tools/chat.js';
20
20
  import { registerAdminTools } from './tools/admin.js';
21
21
  import { getOptionalBundles, getAdminAccounts } from './auth.js';
22
+ import { GENERATED_SERVICES } from './tools/generated/index.js';
22
23
  import { ToolRegistry } from './registry.js';
23
24
  import { registerDiscoverTools } from './discover.js';
24
25
  import { registerEscapeTools } from './tools/google-api.js';
@@ -42,11 +43,14 @@ const SERVICES = [
42
43
  { name: 'chat', register: registerChatTools, enabled: () => new Set(getOptionalBundles()).has('chat') },
43
44
  { name: 'admin', register: registerAdminTools, enabled: () => getAdminAccounts().length > 0 },
44
45
  ];
46
+ // Opt-in gates for generated-only services whose scopes are not granted by
47
+ // default; shared services (admin, forms, chat) reuse their curated gate below.
48
+ const GENERATED_GATES = {};
45
49
  function buildRegistry(server, policy) {
46
50
  const registry = new ToolRegistry(server, policy);
47
51
  const toolsets = getToolsets();
48
52
  if (toolsets !== 'all') {
49
- const known = new Set(SERVICES.map((s) => s.name));
53
+ const known = new Set([...SERVICES.map((s) => s.name), ...GENERATED_SERVICES.map((s) => s.name)]);
50
54
  for (const requested of toolsets) {
51
55
  if (!known.has(requested)) {
52
56
  process.stderr.write(`GOOGLE_TOOLSETS: unknown service "${requested}" ignored\n`);
@@ -65,6 +69,19 @@ function buildRegistry(server, policy) {
65
69
  }
66
70
  svc.register(registry);
67
71
  }
72
+ for (const gen of GENERATED_SERVICES) {
73
+ if (!toolsetEnabled(toolsets, gen.name))
74
+ continue;
75
+ const curated = SERVICES.find((s) => s.name === gen.name);
76
+ const gate = curated?.enabled ?? GENERATED_GATES[gen.name]?.enabled;
77
+ if (gate && !gate()) {
78
+ if (!curated && toolsets !== 'all') {
79
+ process.stderr.write(`GOOGLE_TOOLSETS: "${gen.name}" requested but not enabled — ${GENERATED_GATES[gen.name].hint}\n`);
80
+ }
81
+ continue;
82
+ }
83
+ gen.register(registry);
84
+ }
68
85
  if (registry.services().length === 0) {
69
86
  throw new Error(`GOOGLE_TOOLSETS="${process.env.GOOGLE_TOOLSETS ?? ''}" selected no enabled services. ` +
70
87
  `Known services: ${SERVICES.map((s) => s.name).join(', ')}. ` +
package/dist/registry.js CHANGED
@@ -44,7 +44,7 @@ export class ToolRegistry {
44
44
  this.policy = policy;
45
45
  this.registerTool = ((name, config, handler) => {
46
46
  const service = SERVICE_OVERRIDES[name] ?? (name.includes('_') ? name.slice(0, name.indexOf('_')) : name);
47
- const cud = inferCud(name);
47
+ const cud = config.cud ?? inferCud(name);
48
48
  // destructiveHint=false claims "additive only" (MCP spec) — updates overwrite, so they stay true.
49
49
  const annotations = {
50
50
  readOnlyHint: cud === 'read',
@@ -84,7 +84,8 @@ export class ToolRegistry {
84
84
  const finalHandler = this.compactOutput
85
85
  ? async (...args) => compactResult(await guarded(...args))
86
86
  : guarded;
87
- return server.registerTool(name, { ...config, inputSchema: inputShape, annotations }, finalHandler);
87
+ const { cud: _cud, ...sdkConfig } = config;
88
+ return server.registerTool(name, { ...sdkConfig, inputSchema: inputShape, annotations }, finalHandler);
88
89
  });
89
90
  }
90
91
  registerMeta = ((name, config, handler) => {
@@ -0,0 +1,21 @@
1
+ import { z } from 'zod';
2
+ import { type ApiMethodRef, type ExecuteDeps } from '../../executor.js';
3
+ import type { Cud, ToolRegistry } from '../../registry.js';
4
+ export interface GeneratedParam {
5
+ field: string;
6
+ api: string;
7
+ location: 'path' | 'query';
8
+ }
9
+ export interface GeneratedToolDef {
10
+ name: string;
11
+ cud: Cud;
12
+ description: string;
13
+ method: ApiMethodRef;
14
+ params: GeneratedParam[];
15
+ hasBody: boolean;
16
+ shape: z.ZodRawShape;
17
+ }
18
+ export declare function accountField(): z.ZodEnum<{
19
+ [x: string]: string;
20
+ }>;
21
+ export declare function registerGeneratedTool(registry: ToolRegistry, def: GeneratedToolDef, deps?: ExecuteDeps): void;
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ import { ACCOUNTS } from '../../accounts.js';
3
+ import { executeApiMethod } from '../../executor.js';
4
+ export function accountField() {
5
+ return z.enum(ACCOUNTS).describe('Google account alias');
6
+ }
7
+ export function registerGeneratedTool(registry, def, deps = {}) {
8
+ // Widened locally: `cud` is a registry extension the SDK config type doesn't
9
+ // carry; the registry reads it before handing the config to the SDK.
10
+ const register = registry.registerTool;
11
+ register(def.name, {
12
+ description: def.description,
13
+ inputSchema: def.shape,
14
+ cud: def.cud,
15
+ annotations: { openWorldHint: true },
16
+ }, async (args) => {
17
+ const pathParams = {};
18
+ const queryParams = {};
19
+ for (const p of def.params) {
20
+ const value = args[p.field];
21
+ if (value === undefined)
22
+ continue;
23
+ if (p.location === 'path')
24
+ pathParams[p.api] = value;
25
+ else
26
+ queryParams[p.api] = value;
27
+ }
28
+ return executeApiMethod(def.method, {
29
+ account: args.account,
30
+ pathParams,
31
+ queryParams,
32
+ body: def.hasBody ? args.body : undefined,
33
+ }, deps);
34
+ });
35
+ }
@@ -0,0 +1,5 @@
1
+ import type { ToolRegistry } from '../../registry.js';
2
+ export declare const GENERATED_SERVICES: Array<{
3
+ name: string;
4
+ register: (registry: ToolRegistry) => void;
5
+ }>;
@@ -0,0 +1,4 @@
1
+ import { registerTasksGeneratedTools } from './tasks.js';
2
+ export const GENERATED_SERVICES = [
3
+ { name: "tasks", register: registerTasksGeneratedTools },
4
+ ];
@@ -0,0 +1,2 @@
1
+ import type { ToolRegistry } from '../../registry.js';
2
+ export declare function registerTasksGeneratedTools(registry: ToolRegistry): void;
@@ -0,0 +1,35 @@
1
+ // GENERATED by scripts/gen-tools.ts — do not edit. Regenerate: npm run gen:tools
2
+ import { z } from 'zod';
3
+ import { coerceJson } from '../_coerce.js';
4
+ import { accountField, registerGeneratedTool } from './_shared.js';
5
+ export function registerTasksGeneratedTools(registry) {
6
+ registerGeneratedTool(registry, {
7
+ name: "tasks_tasklists_update",
8
+ cud: "update",
9
+ description: "Updates the authenticated user's specified task list.",
10
+ method: { id: "tasks.tasklists.update", httpMethod: "PUT", path: "tasks/v1/users/@me/lists/{tasklist}", baseUrl: "https://tasks.googleapis.com/", requiredParams: ["tasklist"] },
11
+ params: [{ "field": "tasklist", "api": "tasklist", "location": "path" }, { "field": "fields", "api": "fields", "location": "query" }],
12
+ hasBody: true,
13
+ shape: {
14
+ account: accountField(),
15
+ tasklist: z.string().describe("Task list identifier."),
16
+ body: coerceJson(z.record(z.string(), z.unknown())).describe("TaskList JSON request body. Top-level fields: etag, id, kind, selfLink, title, updated."),
17
+ fields: z.string().optional().describe('Response field mask.'),
18
+ },
19
+ });
20
+ registerGeneratedTool(registry, {
21
+ name: "tasks_tasks_update",
22
+ cud: "update",
23
+ description: "Updates the specified task.",
24
+ method: { id: "tasks.tasks.update", httpMethod: "PUT", path: "tasks/v1/lists/{tasklist}/tasks/{task}", baseUrl: "https://tasks.googleapis.com/", requiredParams: ["tasklist", "task"] },
25
+ params: [{ "field": "task", "api": "task", "location": "path" }, { "field": "tasklist", "api": "tasklist", "location": "path" }, { "field": "fields", "api": "fields", "location": "query" }],
26
+ hasBody: true,
27
+ shape: {
28
+ account: accountField(),
29
+ task: z.string().describe("Task identifier."),
30
+ tasklist: z.string().describe("Task list identifier."),
31
+ body: coerceJson(z.record(z.string(), z.unknown())).describe("Task JSON request body. Top-level fields: assignmentInfo, completed, deleted, due, etag, hidden, id, kind, links, notes, parent, position, +5 more."),
32
+ fields: z.string().optional().describe('Response field mask.'),
33
+ },
34
+ });
35
+ }
@@ -2,12 +2,11 @@ import { z } from 'zod';
2
2
  import { isAllowed, writeDisabledResult } from '../write-control.js';
3
3
  import { ACCOUNTS } from '../accounts.js';
4
4
  import { getClient } from '../client.js';
5
- import { handleGoogleApiError } from './_errors.js';
6
5
  import { coerceJson } from './_coerce.js';
7
6
  import { getToolsets, toolsetEnabled } from '../toolsets.js';
8
- import { WORKSPACE_APIS, cudFromMethod, expandPath, isGoogleApiUrl, loadMethodIndex, searchMethods, } from '../discovery-client.js';
7
+ import { executeApiMethod, jsonResult } from '../executor.js';
8
+ import { WORKSPACE_APIS, cudFromMethod, loadMethodIndex, searchMethods, } from '../discovery-client.js';
9
9
  const accountEnum = z.enum(ACCOUNTS);
10
- const MAX_RESPONSE_CHARS = 100_000;
11
10
  // Policy/toolset namespace for each API alias must match the NAMED tools' service
12
11
  // names, or user deny globs and GOOGLE_TOOLSETS silently miss escape-hatch calls.
13
12
  const SERVICE_FOR_ALIAS = {
@@ -29,24 +28,6 @@ const SERVICE_FOR_ALIAS = {
29
28
  admin_reports: 'admin',
30
29
  groupssettings: 'groupssettings',
31
30
  };
32
- function buildQueryString(queryParams) {
33
- const usp = new URLSearchParams();
34
- const merged = { alt: 'json', ...queryParams };
35
- for (const [key, value] of Object.entries(merged)) {
36
- if (Array.isArray(value)) {
37
- for (const v of value)
38
- usp.append(key, String(v));
39
- }
40
- else {
41
- usp.append(key, String(value));
42
- }
43
- }
44
- return usp.toString();
45
- }
46
- function jsonResult(payload, isError = false) {
47
- const base = { content: [{ type: 'text', text: JSON.stringify(payload) }] };
48
- return isError ? { ...base, isError: true } : base;
49
- }
50
31
  function describeMethod(m) {
51
32
  return {
52
33
  api: m.api,
@@ -134,14 +115,6 @@ export function registerEscapeTools(registry, policy, deps = {}) {
134
115
  }
135
116
  if (!apiEnabled(api))
136
117
  return toolsetDisabled(api);
137
- if (queryParams?.alt === 'media') {
138
- return jsonResult({
139
- error: 'binary_unsupported',
140
- message: 'Binary media download (alt=media) returns no usable JSON through the escape hatch.',
141
- hint: 'Use drive_download / drive_export for file content.',
142
- retriable: false,
143
- }, true);
144
- }
145
118
  let index;
146
119
  try {
147
120
  index = await loadMethodIndex(api, deps);
@@ -165,47 +138,11 @@ export function registerEscapeTools(registry, policy, deps = {}) {
165
138
  if (cud !== 'read' && !isAllowed(toolRef, policy)) {
166
139
  return writeDisabledResult(toolRef, policy);
167
140
  }
168
- let url;
169
- try {
170
- url = method.baseUrl + expandPath(method.path, pathParams ?? {});
171
- }
172
- catch (err) {
173
- return jsonResult({
174
- error: 'invalid_params',
175
- message: err.message,
176
- hint: `Required params for ${method.id}: ${method.requiredParams.join(', ') || '(none)'}`,
177
- retriable: false,
178
- }, true);
179
- }
180
- if (!isGoogleApiUrl(url)) {
181
- return jsonResult({
182
- error: 'untrusted_host',
183
- message: `Refusing to send credentials to a non-googleapis.com host for "${method.id}".`,
184
- hint: 'The discovery cache may be corrupt; delete DISCOVERY_CACHE_PATH and retry.',
185
- retriable: false,
186
- }, true);
187
- }
188
- try {
189
- const auth = await getClientFn(account);
190
- const res = await auth.request({
191
- // query string built by hand: gaxios comma-joins arrays, Google needs repeated keys
192
- url: `${url}?${buildQueryString(queryParams)}`,
193
- method: method.httpMethod,
194
- data: body ?? undefined,
195
- });
196
- const text = JSON.stringify(res.data ?? null);
197
- if (text.length > MAX_RESPONSE_CHARS) {
198
- return jsonResult({
199
- truncated: true,
200
- totalChars: text.length,
201
- head: text.slice(0, MAX_RESPONSE_CHARS),
202
- hint: 'Narrow the request (fields mask, pageSize) to get complete JSON.',
203
- });
204
- }
205
- return { content: [{ type: 'text', text }] };
206
- }
207
- catch (error) {
208
- return handleGoogleApiError(error, account);
209
- }
141
+ return executeApiMethod(method, {
142
+ account: account,
143
+ pathParams: pathParams,
144
+ queryParams: queryParams,
145
+ body,
146
+ }, { getClientFn });
210
147
  });
211
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "5.2.0-alpha.1",
3
+ "version": "5.2.0-alpha.2",
4
4
  "description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,7 +55,8 @@
55
55
  "typecheck": "tsc --noEmit && tsc -p tsconfig.scripts.json",
56
56
  "test": "vitest run",
57
57
  "test:watch": "vitest",
58
- "gen:discovery": "tsx scripts/fetch-discovery.ts"
58
+ "gen:discovery": "tsx scripts/fetch-discovery.ts",
59
+ "gen:tools": "tsx scripts/gen-tools.ts"
59
60
  },
60
61
  "dependencies": {
61
62
  "@modelcontextprotocol/sdk": "^1.29.0",