mcp-google-multi 5.0.0 → 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 +15 -1
- package/dist/discover.d.ts +3 -0
- package/dist/discover.js +36 -0
- package/dist/discovery-client.d.ts +46 -0
- package/dist/discovery-client.js +185 -0
- package/dist/index.js +51 -17
- package/dist/registry.d.ts +27 -0
- package/dist/registry.js +89 -3
- package/dist/tools/google-api.d.ts +10 -0
- package/dist/tools/google-api.js +211 -0
- package/dist/toolsets.d.ts +3 -0
- package/dist/toolsets.js +12 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -69,10 +69,24 @@ Now the only thing on disk is the **encrypted** token store. Pass the token via
|
|
|
69
69
|
| `GOOGLE_WRITE_ALLOW` / `GOOGLE_WRITE_DENY` | — | glob overrides, e.g. `calendar:*`, `*:delete*` |
|
|
70
70
|
| `GOOGLE_OPTIONAL_SCOPES` | — | extra bundles: `forms`, `chat` |
|
|
71
71
|
| `GOOGLE_ADMIN_ACCOUNTS` | — | aliases granted Workspace-admin scopes (the account's own super-admin OAuth) |
|
|
72
|
+
| `GOOGLE_TOOLSETS` | — | `all` (default) or a CSV filter of: `gmail`, `drive`, `calendar`, `sheets`, `docs`, `contacts`, `searchconsole`, `tasks`, `meet`, `forms`, `chat`, `admin` |
|
|
72
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`) |
|
|
73
75
|
|
|
74
76
|
Inspect the resolved setup any time: `mcp-google-multi config check`.
|
|
75
77
|
|
|
78
|
+
## Discover-first tools (tiny idle context)
|
|
79
|
+
|
|
80
|
+
The server does **not** dump ~170 tool schemas into your model's context. At boot, `tools/list` exposes only one small `{service}_discover` tool per service (plus nothing else). Calling e.g. `drive_discover` returns that service's operation catalog (name, one-line summary, arguments, read/write class), reveals the operational tools, and emits `notifications/tools/list_changed` so the client re-fetches the list. An optional `query` argument filters the catalog.
|
|
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.
|
|
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
|
+
|
|
76
90
|
## Write-control (deny-by-default)
|
|
77
91
|
|
|
78
92
|
Reads are never gated. **Every create/update/delete is off until you opt in** — pick a profile:
|
|
@@ -112,7 +126,7 @@ Your OAuth, your machine. Refresh tokens are **AES-256-GCM encrypted at rest** (
|
|
|
112
126
|
|
|
113
127
|
## Roadmap
|
|
114
128
|
|
|
115
|
-
Maintainer-led. Direction is tracked publicly as **[GitHub Milestones](https://github.com/bakissation/mcp-google-multi/milestones)** (
|
|
129
|
+
Maintainer-led. Direction is tracked publicly as **[GitHub Milestones](https://github.com/bakissation/mcp-google-multi/milestones)** (exhaustive API coverage → service accounts + hosting in v6). Not accepting unsolicited feature PRs; bug reports are welcome.
|
|
116
130
|
|
|
117
131
|
## Contributing
|
|
118
132
|
|
package/dist/discover.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { describePolicy } from './write-control.js';
|
|
3
|
+
function opVocabulary(registry, service) {
|
|
4
|
+
const ops = registry.catalog(service).map((o) => o.tool.startsWith(`${service}_`) ? o.tool.slice(service.length + 1) : o.tool);
|
|
5
|
+
return [...new Set(ops)].join(', ');
|
|
6
|
+
}
|
|
7
|
+
export function registerDiscoverTools(registry, policy) {
|
|
8
|
+
for (const service of registry.services()) {
|
|
9
|
+
registry.registerMeta(`${service}_discover`, {
|
|
10
|
+
description: `List the available ${service} operations. Operational ${service} tools are hidden ` +
|
|
11
|
+
`until discovered — call this first, then call the tool you need by name. ` +
|
|
12
|
+
`Operations: ${opVocabulary(registry, service)}.`,
|
|
13
|
+
inputSchema: {
|
|
14
|
+
query: z.string().optional().describe('Keyword to filter the returned operations'),
|
|
15
|
+
},
|
|
16
|
+
}, async ({ query }) => {
|
|
17
|
+
const operations = registry.catalog(service, query);
|
|
18
|
+
registry.reveal(service);
|
|
19
|
+
return {
|
|
20
|
+
content: [
|
|
21
|
+
{
|
|
22
|
+
type: 'text',
|
|
23
|
+
text: JSON.stringify({
|
|
24
|
+
service,
|
|
25
|
+
operations,
|
|
26
|
+
writeControl: describePolicy(policy),
|
|
27
|
+
next: operations.length > 0
|
|
28
|
+
? 'Call the chosen tool by name; it is now listed and callable.'
|
|
29
|
+
: `No ${service} operation matches "${query}". Call again without query for the full catalog.`,
|
|
30
|
+
}),
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -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
|
@@ -19,27 +19,56 @@ import { registerChatTools } from './tools/chat.js';
|
|
|
19
19
|
import { registerAdminTools } from './tools/admin.js';
|
|
20
20
|
import { getOptionalBundles, getAdminAccounts } from './auth.js';
|
|
21
21
|
import { ToolRegistry } from './registry.js';
|
|
22
|
+
import { registerDiscoverTools } from './discover.js';
|
|
23
|
+
import { registerEscapeTools } from './tools/google-api.js';
|
|
24
|
+
import { getToolsets, toolsetEnabled } from './toolsets.js';
|
|
22
25
|
import { resolvePolicy, isAllowed, describePolicy } from './write-control.js';
|
|
23
26
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
27
|
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
|
|
28
|
+
const SERVICES = [
|
|
29
|
+
{ name: 'gmail', register: registerGmailTools },
|
|
30
|
+
{ name: 'drive', register: registerDriveTools },
|
|
31
|
+
{ name: 'calendar', register: registerCalendarTools },
|
|
32
|
+
{ name: 'sheets', register: registerSheetsTools },
|
|
33
|
+
{ name: 'docs', register: registerDocsTools },
|
|
34
|
+
{ name: 'contacts', register: registerContactsTools },
|
|
35
|
+
{ name: 'searchconsole', register: registerSearchConsoleTools },
|
|
36
|
+
{ name: 'tasks', register: registerTasksTools },
|
|
37
|
+
{ name: 'meet', register: registerMeetTools },
|
|
38
|
+
{ name: 'forms', register: registerFormsTools, enabled: () => new Set(getOptionalBundles()).has('forms') },
|
|
39
|
+
{ name: 'chat', register: registerChatTools, enabled: () => new Set(getOptionalBundles()).has('chat') },
|
|
40
|
+
{ name: 'admin', register: registerAdminTools, enabled: () => getAdminAccounts().length > 0 },
|
|
41
|
+
];
|
|
25
42
|
function buildRegistry(server, policy) {
|
|
26
43
|
const registry = new ToolRegistry(server, policy);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
const toolsets = getToolsets();
|
|
45
|
+
if (toolsets !== 'all') {
|
|
46
|
+
const known = new Set(SERVICES.map((s) => s.name));
|
|
47
|
+
for (const requested of toolsets) {
|
|
48
|
+
if (!known.has(requested)) {
|
|
49
|
+
process.stderr.write(`GOOGLE_TOOLSETS: unknown service "${requested}" ignored\n`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const svc of SERVICES) {
|
|
54
|
+
if (!toolsetEnabled(toolsets, svc.name))
|
|
55
|
+
continue;
|
|
56
|
+
if (svc.enabled && !svc.enabled()) {
|
|
57
|
+
if (toolsets !== 'all') {
|
|
58
|
+
const hint = svc.name === 'admin' ? 'set GOOGLE_ADMIN_ACCOUNTS' : `add "${svc.name}" to GOOGLE_OPTIONAL_SCOPES`;
|
|
59
|
+
process.stderr.write(`GOOGLE_TOOLSETS: "${svc.name}" requested but not enabled — ${hint}\n`);
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
svc.register(registry);
|
|
64
|
+
}
|
|
65
|
+
if (registry.services().length === 0) {
|
|
66
|
+
throw new Error(`GOOGLE_TOOLSETS="${process.env.GOOGLE_TOOLSETS ?? ''}" selected no enabled services. ` +
|
|
67
|
+
`Known services: ${SERVICES.map((s) => s.name).join(', ')}. ` +
|
|
68
|
+
`Note: forms/chat require GOOGLE_OPTIONAL_SCOPES, admin requires GOOGLE_ADMIN_ACCOUNTS.`);
|
|
69
|
+
}
|
|
70
|
+
registerDiscoverTools(registry, policy);
|
|
71
|
+
registerEscapeTools(registry, policy);
|
|
43
72
|
return registry;
|
|
44
73
|
}
|
|
45
74
|
async function main() {
|
|
@@ -58,9 +87,13 @@ async function main() {
|
|
|
58
87
|
const registry = buildRegistry(new McpServer({ name: 'mcp-google-multi', version: pkg.version }), policy);
|
|
59
88
|
const cud = registry.tools.filter((t) => t.cud !== 'read');
|
|
60
89
|
const disabled = cud.filter((t) => !isAllowed(t, policy));
|
|
90
|
+
const counts = registry.visibleCount();
|
|
61
91
|
console.log(`Write-control: ${describePolicy(policy)}`);
|
|
62
92
|
console.log(`CUD tools enabled: ${cud.length - disabled.length}/${cud.length}`);
|
|
63
93
|
console.log(`Disabled: ${disabled.map((t) => t.name).join(', ') || '(none)'}`);
|
|
94
|
+
console.log(`Services: ${registry.services().join(', ')}`);
|
|
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`);
|
|
64
97
|
return;
|
|
65
98
|
}
|
|
66
99
|
const policy = resolvePolicy();
|
|
@@ -68,7 +101,8 @@ async function main() {
|
|
|
68
101
|
name: 'mcp-google-multi',
|
|
69
102
|
version: pkg.version,
|
|
70
103
|
});
|
|
71
|
-
buildRegistry(server, policy);
|
|
104
|
+
const registry = buildRegistry(server, policy);
|
|
105
|
+
registry.installListHandler();
|
|
72
106
|
const transport = new StdioServerTransport();
|
|
73
107
|
await server.connect(transport);
|
|
74
108
|
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -1,14 +1,41 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
2
3
|
import { type Policy } from './write-control.js';
|
|
3
4
|
export type Cud = 'read' | 'create' | 'update' | 'delete';
|
|
4
5
|
export interface ToolEntry {
|
|
5
6
|
name: string;
|
|
6
7
|
service: string;
|
|
7
8
|
cud: Cud;
|
|
9
|
+
description: string;
|
|
10
|
+
inputShape: z.ZodRawShape;
|
|
11
|
+
annotations: Record<string, unknown>;
|
|
12
|
+
meta: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface CatalogOperation {
|
|
15
|
+
tool: string;
|
|
16
|
+
summary: string;
|
|
17
|
+
args: string[];
|
|
18
|
+
cud: Cud;
|
|
8
19
|
}
|
|
9
20
|
export declare function inferCud(name: string): Cud;
|
|
10
21
|
export declare class ToolRegistry {
|
|
22
|
+
private readonly server;
|
|
11
23
|
readonly tools: ToolEntry[];
|
|
12
24
|
readonly registerTool: McpServer['registerTool'];
|
|
25
|
+
private readonly revealed;
|
|
26
|
+
private readonly jsonSchemaCache;
|
|
27
|
+
private registeringMeta;
|
|
13
28
|
constructor(server: McpServer, policy: Policy);
|
|
29
|
+
registerMeta: McpServer['registerTool'];
|
|
30
|
+
services(): string[];
|
|
31
|
+
catalog(service: string, query?: string): CatalogOperation[];
|
|
32
|
+
reveal(service: string): boolean;
|
|
33
|
+
isVisible(tool: ToolEntry): boolean;
|
|
34
|
+
visibleCount(): {
|
|
35
|
+
eager: number;
|
|
36
|
+
revealed: number;
|
|
37
|
+
hidden: number;
|
|
38
|
+
};
|
|
39
|
+
installListHandler(): void;
|
|
40
|
+
private toToolJson;
|
|
14
41
|
}
|
package/dist/registry.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { z } from 'zod';
|
|
1
3
|
import { isAllowed, writeDisabledResult } from './write-control.js';
|
|
2
4
|
const CUD_OVERRIDES = {
|
|
3
5
|
drive_untrash: 'update',
|
|
4
6
|
};
|
|
7
|
+
const SERVICE_OVERRIDES = {
|
|
8
|
+
reports_activities_list: 'admin',
|
|
9
|
+
};
|
|
5
10
|
const DELETE_VERB = /(^|_)(delete|remove|trash|clear|empty)(_|$)/;
|
|
6
11
|
const CREATE_VERB = /(^|_)(create|add|insert|send|upload|copy|import|append|submit|duplicate|share|quick)(_|$)/;
|
|
7
12
|
const UPDATE_VERB = /(^|_)(update|patch|modify|set|move|write|format|merge|unmerge|sort|replace|resize|publish|resolve)(_|$)/;
|
|
@@ -18,19 +23,100 @@ export function inferCud(name) {
|
|
|
18
23
|
return 'read';
|
|
19
24
|
}
|
|
20
25
|
export class ToolRegistry {
|
|
26
|
+
server;
|
|
21
27
|
tools = [];
|
|
22
28
|
registerTool;
|
|
29
|
+
revealed = new Set();
|
|
30
|
+
jsonSchemaCache = new Map();
|
|
31
|
+
registeringMeta = false;
|
|
23
32
|
constructor(server, policy) {
|
|
33
|
+
this.server = server;
|
|
24
34
|
this.registerTool = ((name, config, handler) => {
|
|
25
|
-
const service = name.includes('_') ? name.slice(0, name.indexOf('_')) : name;
|
|
35
|
+
const service = SERVICE_OVERRIDES[name] ?? (name.includes('_') ? name.slice(0, name.indexOf('_')) : name);
|
|
26
36
|
const cud = inferCud(name);
|
|
27
|
-
|
|
37
|
+
// destructiveHint=false claims "additive only" (MCP spec) — updates overwrite, so they stay true.
|
|
38
|
+
const annotations = {
|
|
39
|
+
readOnlyHint: cud === 'read',
|
|
40
|
+
destructiveHint: cud === 'delete' || cud === 'update',
|
|
41
|
+
...config.annotations,
|
|
42
|
+
};
|
|
43
|
+
this.tools.push({
|
|
44
|
+
name,
|
|
45
|
+
service,
|
|
46
|
+
cud,
|
|
47
|
+
description: config.description ?? '',
|
|
48
|
+
inputShape: config.inputSchema ?? {},
|
|
49
|
+
annotations,
|
|
50
|
+
meta: this.registeringMeta,
|
|
51
|
+
});
|
|
28
52
|
const guarded = cud === 'read'
|
|
29
53
|
? handler
|
|
30
54
|
: (...args) => isAllowed({ name, service, cud }, policy)
|
|
31
55
|
? handler(...args)
|
|
32
56
|
: writeDisabledResult({ name, service, cud }, policy);
|
|
33
|
-
return server.registerTool(name, config, guarded);
|
|
57
|
+
return server.registerTool(name, { ...config, annotations }, guarded);
|
|
34
58
|
});
|
|
35
59
|
}
|
|
60
|
+
registerMeta = ((name, config, handler) => {
|
|
61
|
+
this.registeringMeta = true;
|
|
62
|
+
try {
|
|
63
|
+
return this.registerTool(name, config, handler);
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
this.registeringMeta = false;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
services() {
|
|
70
|
+
return [...new Set(this.tools.filter((t) => !t.meta).map((t) => t.service))];
|
|
71
|
+
}
|
|
72
|
+
catalog(service, query) {
|
|
73
|
+
const q = query?.trim().toLowerCase();
|
|
74
|
+
return this.tools
|
|
75
|
+
.filter((t) => !t.meta && t.service === service)
|
|
76
|
+
.filter((t) => !q || t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q))
|
|
77
|
+
.map((t) => ({
|
|
78
|
+
tool: t.name,
|
|
79
|
+
summary: t.description,
|
|
80
|
+
args: Object.keys(t.inputShape),
|
|
81
|
+
cud: t.cud,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
reveal(service) {
|
|
85
|
+
if (this.revealed.has(service))
|
|
86
|
+
return false;
|
|
87
|
+
this.revealed.add(service);
|
|
88
|
+
this.server.sendToolListChanged();
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
isVisible(tool) {
|
|
92
|
+
return tool.meta || this.revealed.has(tool.service);
|
|
93
|
+
}
|
|
94
|
+
visibleCount() {
|
|
95
|
+
const meta = this.tools.filter((t) => t.meta).length;
|
|
96
|
+
const visible = this.tools.filter((t) => !t.meta && this.isVisible(t)).length;
|
|
97
|
+
return { eager: meta, revealed: visible, hidden: this.tools.length - meta - visible };
|
|
98
|
+
}
|
|
99
|
+
// Replaces the SDK list handler so hidden tools stay registered (and callable —
|
|
100
|
+
// graceful dispatch) while tools/list only advertises the visible set.
|
|
101
|
+
installListHandler() {
|
|
102
|
+
if (this.tools.length === 0) {
|
|
103
|
+
throw new Error('installListHandler() requires at least one registered tool');
|
|
104
|
+
}
|
|
105
|
+
this.server.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
106
|
+
tools: this.tools.filter((t) => this.isVisible(t)).map((t) => this.toToolJson(t)),
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
toToolJson(tool) {
|
|
110
|
+
let inputSchema = this.jsonSchemaCache.get(tool.name);
|
|
111
|
+
if (!inputSchema) {
|
|
112
|
+
inputSchema = z.toJSONSchema(z.object(tool.inputShape), { target: 'draft-7', io: 'input' });
|
|
113
|
+
this.jsonSchemaCache.set(tool.name, inputSchema);
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
name: tool.name,
|
|
117
|
+
description: tool.description,
|
|
118
|
+
inputSchema,
|
|
119
|
+
annotations: tool.annotations,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
36
122
|
}
|
|
@@ -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/dist/toolsets.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function getToolsets(env = process.env) {
|
|
2
|
+
const parts = (env.GOOGLE_TOOLSETS ?? '')
|
|
3
|
+
.split(',')
|
|
4
|
+
.map((s) => s.trim().toLowerCase())
|
|
5
|
+
.filter(Boolean);
|
|
6
|
+
if (parts.length === 0 || parts.includes('all'))
|
|
7
|
+
return 'all';
|
|
8
|
+
return new Set(parts);
|
|
9
|
+
}
|
|
10
|
+
export function toolsetEnabled(toolsets, service) {
|
|
11
|
+
return toolsets === 'all' || toolsets.has(service);
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-google-multi",
|
|
3
|
-
"version": "5.0.
|
|
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",
|
|
@@ -74,6 +74,6 @@
|
|
|
74
74
|
"semantic-release": "^25.0.3",
|
|
75
75
|
"typescript": "^6.0.3",
|
|
76
76
|
"typescript-eslint": "^8.59.1",
|
|
77
|
-
"vitest": "^3.2.
|
|
77
|
+
"vitest": "^3.2.6"
|
|
78
78
|
}
|
|
79
79
|
}
|