mcp-google-multi 5.1.0-alpha.1 → 5.1.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.
- package/README.md +7 -0
- package/dist/discovery-client.d.ts +46 -0
- package/dist/discovery-client.js +185 -0
- package/dist/index.js +6 -3
- package/dist/tools/google-api.d.ts +10 -0
- package/dist/tools/google-api.js +211 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,6 +71,7 @@ Now the only thing on disk is the **encrypted** token store. Pass the token via
|
|
|
71
71
|
| `GOOGLE_ADMIN_ACCOUNTS` | — | aliases granted Workspace-admin scopes (the account's own super-admin OAuth) |
|
|
72
72
|
| `GOOGLE_TOOLSETS` | — | `all` (default) or a CSV filter of: `gmail`, `drive`, `calendar`, `sheets`, `docs`, `contacts`, `searchconsole`, `tasks`, `meet`, `forms`, `chat`, `admin` |
|
|
73
73
|
| `TOKEN_STORE_PATH` | — | override the encrypted token dir (default: `~/.config/mcp-google-multi/tokens`) |
|
|
74
|
+
| `DISCOVERY_CACHE_PATH` | — | override the Discovery-doc cache dir (default: `~/.config/mcp-google-multi/discovery`) |
|
|
74
75
|
|
|
75
76
|
Inspect the resolved setup any time: `mcp-google-multi config check`.
|
|
76
77
|
|
|
@@ -80,6 +81,12 @@ The server does **not** dump ~170 tool schemas into your model's context. At boo
|
|
|
80
81
|
|
|
81
82
|
Hidden is a listing concept, not a security boundary: operational tools stay callable at all times (existing prompts that call tools directly keep working), and write-control + OAuth scopes remain the real enforcement. Use `GOOGLE_TOOLSETS` to switch entire services off — it is a filter only: listing `forms`/`chat`/`admin` does not enable them without their `GOOGLE_OPTIONAL_SCOPES` / `GOOGLE_ADMIN_ACCOUNTS` gates.
|
|
82
83
|
|
|
84
|
+
## Escape hatch: any Workspace REST method
|
|
85
|
+
|
|
86
|
+
Two eager tools cover everything the curated set doesn't: `google_api_search` finds any method in Google's API Discovery index (including APIs with no dedicated tools here, like Slides), and `google_api_call` invokes a method by its Discovery id (`drive.revisions.list`, `slides.presentations.create`, …) with path/query params and a JSON body. Calls run through your account's OAuth client and the **same write-control policy** as named tools: the read/create/update/delete class is derived from the method's HTTP verb and name (POST deletes like `batchDelete`/`clear` count as deletes), and policy globs/`GOOGLE_TOOLSETS` match the same service names as named tools (`people` counts as `contacts`, `admin_*` as `admin`; `slides`/`driveactivity`/`drivelabels`/`groupssettings` have no named service and are always available).
|
|
87
|
+
|
|
88
|
+
Discovery documents are fetched from Google on first use and cached on disk for 7 days (`DISCOVERY_CACHE_PATH`, default `~/.config/mcp-google-multi/discovery`); a stale cache is used when offline.
|
|
89
|
+
|
|
83
90
|
## Write-control (deny-by-default)
|
|
84
91
|
|
|
85
92
|
Reads are never gated. **Every create/update/delete is off until you opt in** — pick a profile:
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export declare const WORKSPACE_APIS: Record<string, {
|
|
2
|
+
id: string;
|
|
3
|
+
version: string;
|
|
4
|
+
}>;
|
|
5
|
+
export interface DiscoveryParam {
|
|
6
|
+
location: 'path' | 'query';
|
|
7
|
+
required?: boolean;
|
|
8
|
+
type?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface DiscoveryMethod {
|
|
12
|
+
id: string;
|
|
13
|
+
api: string;
|
|
14
|
+
httpMethod: string;
|
|
15
|
+
path: string;
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
description: string;
|
|
18
|
+
params: Record<string, DiscoveryParam>;
|
|
19
|
+
requiredParams: string[];
|
|
20
|
+
scopes: string[];
|
|
21
|
+
}
|
|
22
|
+
interface DiscoveryDoc {
|
|
23
|
+
baseUrl?: string;
|
|
24
|
+
rootUrl?: string;
|
|
25
|
+
servicePath?: string;
|
|
26
|
+
resources?: Record<string, unknown>;
|
|
27
|
+
methods?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
export interface DiscoveryDeps {
|
|
30
|
+
fetchFn?: (url: string) => Promise<{
|
|
31
|
+
ok: boolean;
|
|
32
|
+
status: number;
|
|
33
|
+
json(): Promise<unknown>;
|
|
34
|
+
}>;
|
|
35
|
+
cacheDir?: string;
|
|
36
|
+
now?: () => number;
|
|
37
|
+
}
|
|
38
|
+
export declare function discoveryCacheDir(env?: NodeJS.ProcessEnv): string;
|
|
39
|
+
export declare function isGoogleApiUrl(url: string): boolean;
|
|
40
|
+
export declare function buildMethodIndex(doc: DiscoveryDoc, api: string): DiscoveryMethod[];
|
|
41
|
+
export declare function loadMethodIndex(api: string, deps?: DiscoveryDeps): Promise<DiscoveryMethod[]>;
|
|
42
|
+
export declare function clearDiscoveryMemoryCache(): void;
|
|
43
|
+
export declare function cudFromMethod(method: Pick<DiscoveryMethod, 'httpMethod' | 'id'>): 'read' | 'create' | 'update' | 'delete';
|
|
44
|
+
export declare function expandPath(template: string, pathParams: Record<string, string>): string;
|
|
45
|
+
export declare function searchMethods(index: DiscoveryMethod[], query: string, limit?: number): DiscoveryMethod[];
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
export const WORKSPACE_APIS = {
|
|
5
|
+
gmail: { id: 'gmail', version: 'v1' },
|
|
6
|
+
drive: { id: 'drive', version: 'v3' },
|
|
7
|
+
calendar: { id: 'calendar', version: 'v3' },
|
|
8
|
+
sheets: { id: 'sheets', version: 'v4' },
|
|
9
|
+
docs: { id: 'docs', version: 'v1' },
|
|
10
|
+
slides: { id: 'slides', version: 'v1' },
|
|
11
|
+
forms: { id: 'forms', version: 'v1' },
|
|
12
|
+
people: { id: 'people', version: 'v1' },
|
|
13
|
+
searchconsole: { id: 'searchconsole', version: 'v1' },
|
|
14
|
+
tasks: { id: 'tasks', version: 'v1' },
|
|
15
|
+
chat: { id: 'chat', version: 'v1' },
|
|
16
|
+
meet: { id: 'meet', version: 'v2' },
|
|
17
|
+
driveactivity: { id: 'driveactivity', version: 'v2' },
|
|
18
|
+
drivelabels: { id: 'drivelabels', version: 'v2' },
|
|
19
|
+
admin_directory: { id: 'admin', version: 'directory_v1' },
|
|
20
|
+
admin_reports: { id: 'admin', version: 'reports_v1' },
|
|
21
|
+
groupssettings: { id: 'groupssettings', version: 'v1' },
|
|
22
|
+
};
|
|
23
|
+
const TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
24
|
+
const STALE_RETRY_MS = 5 * 60 * 1000;
|
|
25
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
26
|
+
export function discoveryCacheDir(env = process.env) {
|
|
27
|
+
return (env.DISCOVERY_CACHE_PATH ??
|
|
28
|
+
path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'mcp-google-multi', 'discovery'));
|
|
29
|
+
}
|
|
30
|
+
// A poisoned cache file could repoint baseUrl off-Google; refuse to send the
|
|
31
|
+
// user's Bearer token anywhere but a googleapis.com host.
|
|
32
|
+
export function isGoogleApiUrl(url) {
|
|
33
|
+
try {
|
|
34
|
+
const u = new URL(url);
|
|
35
|
+
return u.protocol === 'https:' && (u.hostname === 'googleapis.com' || u.hostname.endsWith('.googleapis.com'));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const memoryCache = new Map();
|
|
42
|
+
export function buildMethodIndex(doc, api) {
|
|
43
|
+
const baseUrl = doc.baseUrl ?? `${doc.rootUrl ?? ''}${doc.servicePath ?? ''}`;
|
|
44
|
+
const out = [];
|
|
45
|
+
const walk = (node) => {
|
|
46
|
+
for (const m of Object.values(node.methods ?? {})) {
|
|
47
|
+
const method = m;
|
|
48
|
+
if (!method.id || !method.httpMethod || !method.path)
|
|
49
|
+
continue;
|
|
50
|
+
const params = method.parameters ?? {};
|
|
51
|
+
out.push({
|
|
52
|
+
id: method.id,
|
|
53
|
+
api,
|
|
54
|
+
httpMethod: method.httpMethod.toUpperCase(),
|
|
55
|
+
path: method.path,
|
|
56
|
+
baseUrl,
|
|
57
|
+
description: (method.description ?? '').split('\n')[0].slice(0, 160),
|
|
58
|
+
params,
|
|
59
|
+
requiredParams: Object.entries(params)
|
|
60
|
+
.filter(([, p]) => p.required)
|
|
61
|
+
.map(([name]) => name),
|
|
62
|
+
scopes: method.scopes ?? [],
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
for (const r of Object.values(node.resources ?? {})) {
|
|
66
|
+
walk(r);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
walk(doc);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
export async function loadMethodIndex(api, deps = {}) {
|
|
73
|
+
const now = deps.now ?? Date.now;
|
|
74
|
+
const cached = memoryCache.get(api);
|
|
75
|
+
if (cached && now() < cached.refreshAfter)
|
|
76
|
+
return cached.index;
|
|
77
|
+
const spec = WORKSPACE_APIS[api];
|
|
78
|
+
if (!spec) {
|
|
79
|
+
throw new Error(`Unknown api "${api}". Known: ${Object.keys(WORKSPACE_APIS).join(', ')}`);
|
|
80
|
+
}
|
|
81
|
+
const fetchFn = deps.fetchFn ?? ((url) => fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }));
|
|
82
|
+
const cacheDir = deps.cacheDir ?? discoveryCacheDir();
|
|
83
|
+
const cacheFile = path.join(cacheDir, `${api}.json`);
|
|
84
|
+
const readCache = () => {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(fs.readFileSync(cacheFile, 'utf-8'));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const cacheFresh = () => {
|
|
93
|
+
try {
|
|
94
|
+
return now() - fs.statSync(cacheFile).mtimeMs < TTL_MS;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
let doc;
|
|
101
|
+
let staleFallback = false;
|
|
102
|
+
if (cacheFresh())
|
|
103
|
+
doc = readCache();
|
|
104
|
+
if (!doc) {
|
|
105
|
+
const url = `https://www.googleapis.com/discovery/v1/apis/${spec.id}/${spec.version}/rest`;
|
|
106
|
+
try {
|
|
107
|
+
const res = await fetchFn(url);
|
|
108
|
+
if (!res.ok)
|
|
109
|
+
throw new Error(`HTTP ${res.status}`);
|
|
110
|
+
doc = (await res.json());
|
|
111
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
112
|
+
fs.writeFileSync(cacheFile, JSON.stringify(doc), { mode: 0o600 });
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
doc = readCache();
|
|
116
|
+
staleFallback = true;
|
|
117
|
+
if (!doc) {
|
|
118
|
+
throw new Error(`Could not fetch the Google API Discovery document for "${api}" and no local cache exists (${err.message}). Retry when online.`, { cause: err });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const index = buildMethodIndex(doc, api);
|
|
123
|
+
memoryCache.set(api, { index, refreshAfter: now() + (staleFallback ? STALE_RETRY_MS : TTL_MS) });
|
|
124
|
+
return index;
|
|
125
|
+
}
|
|
126
|
+
export function clearDiscoveryMemoryCache() {
|
|
127
|
+
memoryCache.clear();
|
|
128
|
+
}
|
|
129
|
+
const POST_READ_VERB = /^(get|list|search|query|lookup|count|batchGet|generateIds|export|download|inspect)/i;
|
|
130
|
+
const POST_UPDATE_VERB = /^(untrash|undelete|restore|modify|move|set|sort|merge|unmerge|replace|resize|publish|resolve|update|patch|write|format)/i;
|
|
131
|
+
const POST_DELETE_VERB = /^(batch)?(delete|remove|trash|clear|empty|obliterate|purge|revoke|wipeout)/i;
|
|
132
|
+
export function cudFromMethod(method) {
|
|
133
|
+
switch (method.httpMethod) {
|
|
134
|
+
case 'GET':
|
|
135
|
+
case 'HEAD':
|
|
136
|
+
return 'read';
|
|
137
|
+
case 'DELETE':
|
|
138
|
+
return 'delete';
|
|
139
|
+
case 'PUT':
|
|
140
|
+
case 'PATCH':
|
|
141
|
+
return 'update';
|
|
142
|
+
default: {
|
|
143
|
+
// POST carries reads (batchGet), updates (modify) and permanent deletes
|
|
144
|
+
// (batchDelete, clear, obliterate) — classify by verb or write-control misfires.
|
|
145
|
+
const lastSegment = method.id.split('.').pop() ?? '';
|
|
146
|
+
if (POST_READ_VERB.test(lastSegment))
|
|
147
|
+
return 'read';
|
|
148
|
+
if (POST_UPDATE_VERB.test(lastSegment))
|
|
149
|
+
return 'update';
|
|
150
|
+
if (POST_DELETE_VERB.test(lastSegment))
|
|
151
|
+
return 'delete';
|
|
152
|
+
return 'create';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
export function expandPath(template, pathParams) {
|
|
157
|
+
return template.replace(/\{(\+?)([^}]+)\}/g, (_match, plus, name) => {
|
|
158
|
+
const value = pathParams[name];
|
|
159
|
+
if (value === undefined) {
|
|
160
|
+
throw new Error(`Missing required path parameter "${name}"`);
|
|
161
|
+
}
|
|
162
|
+
return plus ? String(value).split('/').map(encodeURIComponent).join('/') : encodeURIComponent(String(value));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
export function searchMethods(index, query, limit = 10) {
|
|
166
|
+
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
167
|
+
if (tokens.length === 0)
|
|
168
|
+
return index.slice(0, limit);
|
|
169
|
+
const scored = index
|
|
170
|
+
.map((m) => {
|
|
171
|
+
const id = m.id.toLowerCase();
|
|
172
|
+
const desc = m.description.toLowerCase();
|
|
173
|
+
let score = 0;
|
|
174
|
+
for (const t of tokens) {
|
|
175
|
+
if (id.includes(t))
|
|
176
|
+
score += 3;
|
|
177
|
+
if (desc.includes(t))
|
|
178
|
+
score += 1;
|
|
179
|
+
}
|
|
180
|
+
return { m, score };
|
|
181
|
+
})
|
|
182
|
+
.filter((s) => s.score > 0);
|
|
183
|
+
scored.sort((a, b) => b.score - a.score || a.m.id.localeCompare(b.m.id));
|
|
184
|
+
return scored.slice(0, limit).map((s) => s.m);
|
|
185
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { registerAdminTools } from './tools/admin.js';
|
|
|
20
20
|
import { getOptionalBundles, getAdminAccounts } from './auth.js';
|
|
21
21
|
import { ToolRegistry } from './registry.js';
|
|
22
22
|
import { registerDiscoverTools } from './discover.js';
|
|
23
|
+
import { registerEscapeTools } from './tools/google-api.js';
|
|
23
24
|
import { getToolsets, toolsetEnabled } from './toolsets.js';
|
|
24
25
|
import { resolvePolicy, isAllowed, describePolicy } from './write-control.js';
|
|
25
26
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -61,12 +62,13 @@ function buildRegistry(server, policy) {
|
|
|
61
62
|
}
|
|
62
63
|
svc.register(registry);
|
|
63
64
|
}
|
|
64
|
-
|
|
65
|
-
if (registry.tools.length === 0) {
|
|
65
|
+
if (registry.services().length === 0) {
|
|
66
66
|
throw new Error(`GOOGLE_TOOLSETS="${process.env.GOOGLE_TOOLSETS ?? ''}" selected no enabled services. ` +
|
|
67
67
|
`Known services: ${SERVICES.map((s) => s.name).join(', ')}. ` +
|
|
68
68
|
`Note: forms/chat require GOOGLE_OPTIONAL_SCOPES, admin requires GOOGLE_ADMIN_ACCOUNTS.`);
|
|
69
69
|
}
|
|
70
|
+
registerDiscoverTools(registry, policy);
|
|
71
|
+
registerEscapeTools(registry, policy);
|
|
70
72
|
return registry;
|
|
71
73
|
}
|
|
72
74
|
async function main() {
|
|
@@ -90,7 +92,8 @@ async function main() {
|
|
|
90
92
|
console.log(`CUD tools enabled: ${cud.length - disabled.length}/${cud.length}`);
|
|
91
93
|
console.log(`Disabled: ${disabled.map((t) => t.name).join(', ') || '(none)'}`);
|
|
92
94
|
console.log(`Services: ${registry.services().join(', ')}`);
|
|
93
|
-
console.log(`Tool surface: ${counts.eager} eager (discover), ${counts.hidden} deferred until discovery`);
|
|
95
|
+
console.log(`Tool surface: ${counts.eager} eager (discover + escape hatch), ${counts.hidden} deferred until discovery`);
|
|
96
|
+
console.log(`Escape hatch: google_api_call CUD verdicts follow profile=${policy.profile} and your allow/deny globs`);
|
|
94
97
|
return;
|
|
95
98
|
}
|
|
96
99
|
const policy = resolvePolicy();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ToolRegistry } from '../registry.js';
|
|
2
|
+
import { type Policy } from '../write-control.js';
|
|
3
|
+
import { getClient } from '../client.js';
|
|
4
|
+
import { type Toolsets } from '../toolsets.js';
|
|
5
|
+
import { type DiscoveryDeps } from '../discovery-client.js';
|
|
6
|
+
export interface EscapeDeps extends DiscoveryDeps {
|
|
7
|
+
getClientFn?: typeof getClient;
|
|
8
|
+
toolsets?: Toolsets;
|
|
9
|
+
}
|
|
10
|
+
export declare function registerEscapeTools(registry: ToolRegistry, policy: Policy, deps?: EscapeDeps): void;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { isAllowed, writeDisabledResult } from '../write-control.js';
|
|
3
|
+
import { ACCOUNTS } from '../accounts.js';
|
|
4
|
+
import { getClient } from '../client.js';
|
|
5
|
+
import { handleGoogleApiError } from './_errors.js';
|
|
6
|
+
import { coerceJson } from './_coerce.js';
|
|
7
|
+
import { getToolsets, toolsetEnabled } from '../toolsets.js';
|
|
8
|
+
import { WORKSPACE_APIS, cudFromMethod, expandPath, isGoogleApiUrl, loadMethodIndex, searchMethods, } from '../discovery-client.js';
|
|
9
|
+
const accountEnum = z.enum(ACCOUNTS);
|
|
10
|
+
const MAX_RESPONSE_CHARS = 100_000;
|
|
11
|
+
// Policy/toolset namespace for each API alias must match the NAMED tools' service
|
|
12
|
+
// names, or user deny globs and GOOGLE_TOOLSETS silently miss escape-hatch calls.
|
|
13
|
+
const SERVICE_FOR_ALIAS = {
|
|
14
|
+
gmail: 'gmail',
|
|
15
|
+
drive: 'drive',
|
|
16
|
+
calendar: 'calendar',
|
|
17
|
+
sheets: 'sheets',
|
|
18
|
+
docs: 'docs',
|
|
19
|
+
slides: null,
|
|
20
|
+
forms: 'forms',
|
|
21
|
+
people: 'contacts',
|
|
22
|
+
searchconsole: 'searchconsole',
|
|
23
|
+
tasks: 'tasks',
|
|
24
|
+
chat: 'chat',
|
|
25
|
+
meet: 'meet',
|
|
26
|
+
driveactivity: null,
|
|
27
|
+
drivelabels: null,
|
|
28
|
+
admin_directory: 'admin',
|
|
29
|
+
admin_reports: 'admin',
|
|
30
|
+
groupssettings: null,
|
|
31
|
+
};
|
|
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
|
+
function describeMethod(m) {
|
|
51
|
+
return {
|
|
52
|
+
api: m.api,
|
|
53
|
+
methodId: m.id,
|
|
54
|
+
httpMethod: m.httpMethod,
|
|
55
|
+
path: m.path,
|
|
56
|
+
description: m.description,
|
|
57
|
+
requiredParams: m.requiredParams,
|
|
58
|
+
cud: cudFromMethod(m),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function registerEscapeTools(registry, policy, deps = {}) {
|
|
62
|
+
const getClientFn = deps.getClientFn ?? getClient;
|
|
63
|
+
const toolsets = deps.toolsets ?? getToolsets();
|
|
64
|
+
const apiEnabled = (alias) => {
|
|
65
|
+
const service = SERVICE_FOR_ALIAS[alias];
|
|
66
|
+
return service === null || service === undefined || toolsetEnabled(toolsets, service);
|
|
67
|
+
};
|
|
68
|
+
const enabledApis = Object.keys(WORKSPACE_APIS).filter(apiEnabled);
|
|
69
|
+
const apiList = enabledApis.join(', ');
|
|
70
|
+
const toolsetDisabled = (alias) => jsonResult({
|
|
71
|
+
error: 'toolset_disabled',
|
|
72
|
+
message: `API "${alias}" maps to service "${SERVICE_FOR_ALIAS[alias]}", which is excluded by GOOGLE_TOOLSETS.`,
|
|
73
|
+
hint: 'Add the service to GOOGLE_TOOLSETS (or unset it) to use this API.',
|
|
74
|
+
retriable: false,
|
|
75
|
+
}, true);
|
|
76
|
+
registry.registerMeta('google_api_search', {
|
|
77
|
+
description: 'Search the Google API Discovery index for any Workspace REST method, including ones with no ' +
|
|
78
|
+
'dedicated tool here. Returns method ids + parameters to invoke via google_api_call. ' +
|
|
79
|
+
`APIs: ${apiList}.`,
|
|
80
|
+
inputSchema: {
|
|
81
|
+
query: z.string().describe('Keywords, e.g. "slides create presentation" or "drive revisions"'),
|
|
82
|
+
api: z.string().optional().describe('Restrict the search to one API alias'),
|
|
83
|
+
maxResults: z.number().min(1).max(25).default(10).optional(),
|
|
84
|
+
},
|
|
85
|
+
annotations: { openWorldHint: true },
|
|
86
|
+
}, async ({ query, api, maxResults }) => {
|
|
87
|
+
if (api && !WORKSPACE_APIS[api]) {
|
|
88
|
+
return jsonResult({ error: 'unknown_api', message: `Unknown api "${api}".`, hint: `Known APIs: ${apiList}`, retriable: false }, true);
|
|
89
|
+
}
|
|
90
|
+
if (api && !apiEnabled(api))
|
|
91
|
+
return toolsetDisabled(api);
|
|
92
|
+
const apis = api ? [api] : enabledApis;
|
|
93
|
+
const unavailable = [];
|
|
94
|
+
const indexes = await Promise.all(apis.map(async (a) => {
|
|
95
|
+
try {
|
|
96
|
+
return await loadMethodIndex(a, deps);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
unavailable.push(a);
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}));
|
|
103
|
+
const matches = searchMethods(indexes.flat(), query, maxResults ?? 10);
|
|
104
|
+
return jsonResult({
|
|
105
|
+
methods: matches.map(describeMethod),
|
|
106
|
+
...(unavailable.length > 0 ? { unavailableApis: unavailable } : {}),
|
|
107
|
+
next: 'Invoke with google_api_call({account, api, methodId, pathParams, queryParams, body}).',
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
registry.registerMeta('google_api_call', {
|
|
111
|
+
description: 'Invoke any Google Workspace REST method by Discovery id (escape hatch for operations without a ' +
|
|
112
|
+
'dedicated tool). Find methods with google_api_search first. Subject to the same write-control ' +
|
|
113
|
+
'policy as named tools.',
|
|
114
|
+
inputSchema: {
|
|
115
|
+
account: accountEnum.describe('Google account alias'),
|
|
116
|
+
api: z.string().describe(`API alias: ${apiList}`),
|
|
117
|
+
methodId: z.string().describe('Discovery method id, e.g. "drive.revisions.list"'),
|
|
118
|
+
pathParams: coerceJson(z.record(z.string(), z.union([z.string(), z.number()])).optional())
|
|
119
|
+
.describe('Values for {placeholders} in the method path'),
|
|
120
|
+
queryParams: coerceJson(z
|
|
121
|
+
.record(z.string(), z.union([
|
|
122
|
+
z.string(),
|
|
123
|
+
z.number(),
|
|
124
|
+
z.boolean(),
|
|
125
|
+
z.array(z.union([z.string(), z.number(), z.boolean()])),
|
|
126
|
+
]))
|
|
127
|
+
.optional()).describe('Query-string parameters; use an array for repeated params (e.g. resourceNames)'),
|
|
128
|
+
body: coerceJson(z.record(z.string(), z.unknown()).optional()).describe('JSON request body'),
|
|
129
|
+
},
|
|
130
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
131
|
+
}, async ({ account, api, methodId, pathParams, queryParams, body }) => {
|
|
132
|
+
if (!WORKSPACE_APIS[api]) {
|
|
133
|
+
return jsonResult({ error: 'unknown_api', message: `Unknown api "${api}".`, hint: `Known APIs: ${apiList}`, retriable: false }, true);
|
|
134
|
+
}
|
|
135
|
+
if (!apiEnabled(api))
|
|
136
|
+
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
|
+
let index;
|
|
146
|
+
try {
|
|
147
|
+
index = await loadMethodIndex(api, deps);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
return jsonResult({ error: 'discovery_unavailable', message: err.message, retriable: true }, true);
|
|
151
|
+
}
|
|
152
|
+
const method = index.find((m) => m.id === methodId);
|
|
153
|
+
if (!method) {
|
|
154
|
+
return jsonResult({
|
|
155
|
+
error: 'unknown_method',
|
|
156
|
+
message: `No method "${methodId}" in ${api}.`,
|
|
157
|
+
hint: `Use google_api_search({query: "...", api: "${api}"}) to find the right method id.`,
|
|
158
|
+
retriable: false,
|
|
159
|
+
}, true);
|
|
160
|
+
}
|
|
161
|
+
const cud = cudFromMethod(method);
|
|
162
|
+
const policyService = SERVICE_FOR_ALIAS[api] ?? api;
|
|
163
|
+
const lastSegment = method.id.split('.').pop() ?? method.id;
|
|
164
|
+
const toolRef = { name: `${policyService}_${lastSegment}`, service: policyService, cud };
|
|
165
|
+
if (cud !== 'read' && !isAllowed(toolRef, policy)) {
|
|
166
|
+
return writeDisabledResult(toolRef, policy);
|
|
167
|
+
}
|
|
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
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-google-multi",
|
|
3
|
-
"version": "5.1.0-alpha.
|
|
3
|
+
"version": "5.1.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",
|