mcp-google-multi 5.0.0-beta.1 → 5.1.0-alpha.1
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 +8 -1
- package/dist/discover.d.ts +3 -0
- package/dist/discover.js +36 -0
- package/dist/index.js +48 -17
- package/dist/registry.d.ts +27 -0
- package/dist/registry.js +89 -3
- 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,17 @@ 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`) |
|
|
73
74
|
|
|
74
75
|
Inspect the resolved setup any time: `mcp-google-multi config check`.
|
|
75
76
|
|
|
77
|
+
## Discover-first tools (tiny idle context)
|
|
78
|
+
|
|
79
|
+
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.
|
|
80
|
+
|
|
81
|
+
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
|
+
|
|
76
83
|
## Write-control (deny-by-default)
|
|
77
84
|
|
|
78
85
|
Reads are never gated. **Every create/update/delete is off until you opt in** — pick a profile:
|
|
@@ -112,7 +119,7 @@ Your OAuth, your machine. Refresh tokens are **AES-256-GCM encrypted at rest** (
|
|
|
112
119
|
|
|
113
120
|
## Roadmap
|
|
114
121
|
|
|
115
|
-
Maintainer-led. Direction is tracked publicly as **[GitHub Milestones](https://github.com/bakissation/mcp-google-multi/milestones)** (
|
|
122
|
+
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
123
|
|
|
117
124
|
## Contributing
|
|
118
125
|
|
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
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -19,27 +19,54 @@ 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 { getToolsets, toolsetEnabled } from './toolsets.js';
|
|
22
24
|
import { resolvePolicy, isAllowed, describePolicy } from './write-control.js';
|
|
23
25
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
26
|
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf-8'));
|
|
27
|
+
const SERVICES = [
|
|
28
|
+
{ name: 'gmail', register: registerGmailTools },
|
|
29
|
+
{ name: 'drive', register: registerDriveTools },
|
|
30
|
+
{ name: 'calendar', register: registerCalendarTools },
|
|
31
|
+
{ name: 'sheets', register: registerSheetsTools },
|
|
32
|
+
{ name: 'docs', register: registerDocsTools },
|
|
33
|
+
{ name: 'contacts', register: registerContactsTools },
|
|
34
|
+
{ name: 'searchconsole', register: registerSearchConsoleTools },
|
|
35
|
+
{ name: 'tasks', register: registerTasksTools },
|
|
36
|
+
{ name: 'meet', register: registerMeetTools },
|
|
37
|
+
{ name: 'forms', register: registerFormsTools, enabled: () => new Set(getOptionalBundles()).has('forms') },
|
|
38
|
+
{ name: 'chat', register: registerChatTools, enabled: () => new Set(getOptionalBundles()).has('chat') },
|
|
39
|
+
{ name: 'admin', register: registerAdminTools, enabled: () => getAdminAccounts().length > 0 },
|
|
40
|
+
];
|
|
25
41
|
function buildRegistry(server, policy) {
|
|
26
42
|
const registry = new ToolRegistry(server, policy);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
const toolsets = getToolsets();
|
|
44
|
+
if (toolsets !== 'all') {
|
|
45
|
+
const known = new Set(SERVICES.map((s) => s.name));
|
|
46
|
+
for (const requested of toolsets) {
|
|
47
|
+
if (!known.has(requested)) {
|
|
48
|
+
process.stderr.write(`GOOGLE_TOOLSETS: unknown service "${requested}" ignored\n`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const svc of SERVICES) {
|
|
53
|
+
if (!toolsetEnabled(toolsets, svc.name))
|
|
54
|
+
continue;
|
|
55
|
+
if (svc.enabled && !svc.enabled()) {
|
|
56
|
+
if (toolsets !== 'all') {
|
|
57
|
+
const hint = svc.name === 'admin' ? 'set GOOGLE_ADMIN_ACCOUNTS' : `add "${svc.name}" to GOOGLE_OPTIONAL_SCOPES`;
|
|
58
|
+
process.stderr.write(`GOOGLE_TOOLSETS: "${svc.name}" requested but not enabled — ${hint}\n`);
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
svc.register(registry);
|
|
63
|
+
}
|
|
64
|
+
registerDiscoverTools(registry, policy);
|
|
65
|
+
if (registry.tools.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
|
+
}
|
|
43
70
|
return registry;
|
|
44
71
|
}
|
|
45
72
|
async function main() {
|
|
@@ -58,9 +85,12 @@ async function main() {
|
|
|
58
85
|
const registry = buildRegistry(new McpServer({ name: 'mcp-google-multi', version: pkg.version }), policy);
|
|
59
86
|
const cud = registry.tools.filter((t) => t.cud !== 'read');
|
|
60
87
|
const disabled = cud.filter((t) => !isAllowed(t, policy));
|
|
88
|
+
const counts = registry.visibleCount();
|
|
61
89
|
console.log(`Write-control: ${describePolicy(policy)}`);
|
|
62
90
|
console.log(`CUD tools enabled: ${cud.length - disabled.length}/${cud.length}`);
|
|
63
91
|
console.log(`Disabled: ${disabled.map((t) => t.name).join(', ') || '(none)'}`);
|
|
92
|
+
console.log(`Services: ${registry.services().join(', ')}`);
|
|
93
|
+
console.log(`Tool surface: ${counts.eager} eager (discover), ${counts.hidden} deferred until discovery`);
|
|
64
94
|
return;
|
|
65
95
|
}
|
|
66
96
|
const policy = resolvePolicy();
|
|
@@ -68,7 +98,8 @@ async function main() {
|
|
|
68
98
|
name: 'mcp-google-multi',
|
|
69
99
|
version: pkg.version,
|
|
70
100
|
});
|
|
71
|
-
buildRegistry(server, policy);
|
|
101
|
+
const registry = buildRegistry(server, policy);
|
|
102
|
+
registry.installListHandler();
|
|
72
103
|
const transport = new StdioServerTransport();
|
|
73
104
|
await server.connect(transport);
|
|
74
105
|
}
|
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
|
}
|
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.
|
|
3
|
+
"version": "5.1.0-alpha.1",
|
|
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
|
}
|