mcp-google-multi 5.1.3-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.
- package/README.md +8 -8
- package/dist/auth.js +4 -1
- package/dist/executor.d.ts +39 -0
- package/dist/executor.js +75 -0
- package/dist/index.js +21 -2
- package/dist/registry.js +3 -2
- package/dist/tools/forms.js +93 -0
- package/dist/tools/generated/_shared.d.ts +21 -0
- package/dist/tools/generated/_shared.js +35 -0
- package/dist/tools/generated/index.d.ts +5 -0
- package/dist/tools/generated/index.js +4 -0
- package/dist/tools/generated/tasks.d.ts +2 -0
- package/dist/tools/generated/tasks.js +35 -0
- package/dist/tools/google-api.js +8 -71
- package/dist/tools/slides.d.ts +4 -0
- package/dist/tools/slides.js +183 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# mcp-google-multi
|
|
2
2
|
|
|
3
|
-
A **local** [MCP](https://modelcontextprotocol.io) server that gives Claude Code (and any MCP client) access to your **Google Workspace** — Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console (plus optional Forms, Chat, and Workspace Admin) — across **multiple Google accounts** at once.
|
|
3
|
+
A **local** [MCP](https://modelcontextprotocol.io) server that gives Claude Code (and any MCP client) access to your **Google Workspace** — Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console (plus optional Slides, Forms, Chat, and Workspace Admin) — across **multiple Google accounts** at once.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/mcp-google-multi)
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@ A **local** [MCP](https://modelcontextprotocol.io) server that gives Claude Code
|
|
|
9
9
|
- 🔑 **Multi-account** — drive any number of your Google accounts from one server, each by a short alias.
|
|
10
10
|
- 🔒 **Secure by default** — refresh tokens **encrypted at rest** (AES-256-GCM); **writes are deny-by-default**; no telemetry — it talks only to Google.
|
|
11
11
|
- 📦 **npm-first** — install and run with `npx`; everything configured through env vars.
|
|
12
|
-
- 🧰 **~
|
|
12
|
+
- 🧰 **~175 tools** across 13 services → full list in **[COVERAGE.md](./COVERAGE.md)**.
|
|
13
13
|
|
|
14
14
|
> v5 is **local + user-OAuth only**. Service accounts and hosting (and the APIs they unlock) are on the [roadmap](https://github.com/bakissation/mcp-google-multi/milestones). **Upgrading from v4?** Jump to [Upgrading](#upgrading-from-v4).
|
|
15
15
|
|
|
@@ -67,9 +67,9 @@ Now the only thing on disk is the **encrypted** token store. Pass the token via
|
|
|
67
67
|
| `GOOGLE_PROFILE` | — | write policy: `read-only` (default) · `safe-writes` · `full-writes` |
|
|
68
68
|
| `GOOGLE_READ_ONLY` | — | `true` = hard kill-switch for all writes |
|
|
69
69
|
| `GOOGLE_WRITE_ALLOW` / `GOOGLE_WRITE_DENY` | — | glob overrides, e.g. `calendar:*`, `*:delete*` |
|
|
70
|
-
| `GOOGLE_OPTIONAL_SCOPES` | — | extra bundles: `forms`, `chat` |
|
|
70
|
+
| `GOOGLE_OPTIONAL_SCOPES` | — | extra bundles: `slides`, `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
|
+
| `GOOGLE_TOOLSETS` | — | `all` (default) or a CSV filter of: `gmail`, `drive`, `calendar`, `sheets`, `docs`, `contacts`, `searchconsole`, `tasks`, `meet`, `slides`, `forms`, `chat`, `admin` |
|
|
73
73
|
| `TOKEN_STORE_PATH` | — | override the encrypted token dir (default: `~/.config/mcp-google-multi/tokens`) |
|
|
74
74
|
| `DISCOVERY_CACHE_PATH` | — | override the Discovery-doc cache dir (default: `~/.config/mcp-google-multi/discovery`) |
|
|
75
75
|
| `GOOGLE_TRIM` | — | `off` (or `0`/`false`/`no`) disables compact JSON serialization of tool responses |
|
|
@@ -80,7 +80,7 @@ Inspect the resolved setup any time: `mcp-google-multi config check`.
|
|
|
80
80
|
|
|
81
81
|
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.
|
|
82
82
|
|
|
83
|
-
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
|
+
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 `slides`/`forms`/`chat`/`admin` does not enable them without their `GOOGLE_OPTIONAL_SCOPES` / `GOOGLE_ADMIN_ACCOUNTS` gates.
|
|
84
84
|
|
|
85
85
|
## Multi-account: fan out one call across accounts
|
|
86
86
|
|
|
@@ -100,7 +100,7 @@ Fan-out is **read-only by design** (write tools take exactly one account), and t
|
|
|
100
100
|
|
|
101
101
|
## Escape hatch: any Workspace REST method
|
|
102
102
|
|
|
103
|
-
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
|
|
103
|
+
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 Drive Activity), 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`; `driveactivity`/`drivelabels`/`groupssettings` have no named service and are always available).
|
|
104
104
|
|
|
105
105
|
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.
|
|
106
106
|
|
|
@@ -126,12 +126,12 @@ Reads are never gated. **Every create/update/delete is off until you opt in**
|
|
|
126
126
|
|
|
127
127
|
## What's covered
|
|
128
128
|
|
|
129
|
-
~
|
|
129
|
+
~175 tools across Gmail, Drive, Calendar, Sheets, Docs, Contacts, Search Console, Tasks, Meet, and (optional) Slides, Forms, Chat, Workspace Admin. **Full per-tool list → [COVERAGE.md](./COVERAGE.md).** Every tool takes an `account` argument matching one of your aliases.
|
|
130
130
|
|
|
131
131
|
## Google Cloud setup
|
|
132
132
|
|
|
133
133
|
1. [Google Cloud Console](https://console.cloud.google.com) → create or select a project.
|
|
134
|
-
2. Enable the APIs you'll use: Gmail, Drive, Calendar, Sheets, Docs, People, Search Console, Tasks, Meet (+ Forms / Chat / Admin SDK if you enable those bundles).
|
|
134
|
+
2. Enable the APIs you'll use: Gmail, Drive, Calendar, Sheets, Docs, People, Search Console, Tasks, Meet (+ Slides / Forms / Chat / Admin SDK if you enable those bundles).
|
|
135
135
|
3. **APIs & Services → Credentials → Create Credentials → OAuth client ID → Desktop app**.
|
|
136
136
|
4. Add the redirect URI `http://localhost:4242/oauth2callback`.
|
|
137
137
|
5. Copy the **Client ID** + **Client Secret** into your environment.
|
package/dist/auth.js
CHANGED
|
@@ -9,7 +9,7 @@ import { writeToken } from './token-store.js';
|
|
|
9
9
|
// ─── Scope tiers ────────────────────────────────────────────────────────
|
|
10
10
|
//
|
|
11
11
|
// BASE: always granted. Existing v3 surface + Tasks + Meet (added in v4.0.0).
|
|
12
|
-
// OPTIONAL: per-account opt-in via env GOOGLE_OPTIONAL_SCOPES="forms,chat".
|
|
12
|
+
// OPTIONAL: per-account opt-in via env GOOGLE_OPTIONAL_SCOPES="slides,forms,chat".
|
|
13
13
|
// ADMIN: per-account opt-in via env GOOGLE_ADMIN_ACCOUNTS="alias1,alias2".
|
|
14
14
|
//
|
|
15
15
|
// Personal Gmail accounts will 403 on admin scopes — never grant by default.
|
|
@@ -27,6 +27,9 @@ export const BASE_SCOPES = [
|
|
|
27
27
|
'https://www.googleapis.com/auth/meetings.space.readonly',
|
|
28
28
|
];
|
|
29
29
|
export const OPTIONAL_SCOPE_BUNDLES = {
|
|
30
|
+
slides: [
|
|
31
|
+
'https://www.googleapis.com/auth/presentations',
|
|
32
|
+
],
|
|
30
33
|
forms: [
|
|
31
34
|
'https://www.googleapis.com/auth/forms.body',
|
|
32
35
|
'https://www.googleapis.com/auth/forms.responses.readonly',
|
|
@@ -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
|
+
}>;
|
package/dist/executor.js
ADDED
|
@@ -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
|
@@ -14,10 +14,12 @@ import { registerContactsTools } from './tools/contacts.js';
|
|
|
14
14
|
import { registerSearchConsoleTools } from './tools/searchconsole.js';
|
|
15
15
|
import { registerTasksTools } from './tools/tasks.js';
|
|
16
16
|
import { registerMeetTools } from './tools/meet.js';
|
|
17
|
+
import { registerSlidesTools } from './tools/slides.js';
|
|
17
18
|
import { registerFormsTools } from './tools/forms.js';
|
|
18
19
|
import { registerChatTools } from './tools/chat.js';
|
|
19
20
|
import { registerAdminTools } from './tools/admin.js';
|
|
20
21
|
import { getOptionalBundles, getAdminAccounts } from './auth.js';
|
|
22
|
+
import { GENERATED_SERVICES } from './tools/generated/index.js';
|
|
21
23
|
import { ToolRegistry } from './registry.js';
|
|
22
24
|
import { registerDiscoverTools } from './discover.js';
|
|
23
25
|
import { registerEscapeTools } from './tools/google-api.js';
|
|
@@ -36,15 +38,19 @@ const SERVICES = [
|
|
|
36
38
|
{ name: 'searchconsole', register: registerSearchConsoleTools },
|
|
37
39
|
{ name: 'tasks', register: registerTasksTools },
|
|
38
40
|
{ name: 'meet', register: registerMeetTools },
|
|
41
|
+
{ name: 'slides', register: registerSlidesTools, enabled: () => new Set(getOptionalBundles()).has('slides') },
|
|
39
42
|
{ name: 'forms', register: registerFormsTools, enabled: () => new Set(getOptionalBundles()).has('forms') },
|
|
40
43
|
{ name: 'chat', register: registerChatTools, enabled: () => new Set(getOptionalBundles()).has('chat') },
|
|
41
44
|
{ name: 'admin', register: registerAdminTools, enabled: () => getAdminAccounts().length > 0 },
|
|
42
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 = {};
|
|
43
49
|
function buildRegistry(server, policy) {
|
|
44
50
|
const registry = new ToolRegistry(server, policy);
|
|
45
51
|
const toolsets = getToolsets();
|
|
46
52
|
if (toolsets !== 'all') {
|
|
47
|
-
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)]);
|
|
48
54
|
for (const requested of toolsets) {
|
|
49
55
|
if (!known.has(requested)) {
|
|
50
56
|
process.stderr.write(`GOOGLE_TOOLSETS: unknown service "${requested}" ignored\n`);
|
|
@@ -63,10 +69,23 @@ function buildRegistry(server, policy) {
|
|
|
63
69
|
}
|
|
64
70
|
svc.register(registry);
|
|
65
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
|
+
}
|
|
66
85
|
if (registry.services().length === 0) {
|
|
67
86
|
throw new Error(`GOOGLE_TOOLSETS="${process.env.GOOGLE_TOOLSETS ?? ''}" selected no enabled services. ` +
|
|
68
87
|
`Known services: ${SERVICES.map((s) => s.name).join(', ')}. ` +
|
|
69
|
-
`Note: forms/chat require GOOGLE_OPTIONAL_SCOPES, admin requires GOOGLE_ADMIN_ACCOUNTS.`);
|
|
88
|
+
`Note: slides/forms/chat require GOOGLE_OPTIONAL_SCOPES, admin requires GOOGLE_ADMIN_ACCOUNTS.`);
|
|
70
89
|
}
|
|
71
90
|
registerDiscoverTools(registry, policy);
|
|
72
91
|
registerEscapeTools(registry, policy);
|
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
|
-
|
|
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) => {
|
package/dist/tools/forms.js
CHANGED
|
@@ -3,6 +3,7 @@ import { google } from 'googleapis';
|
|
|
3
3
|
import { ACCOUNTS } from '../accounts.js';
|
|
4
4
|
import { getClient } from '../client.js';
|
|
5
5
|
import { handleGoogleApiError } from './_errors.js';
|
|
6
|
+
import { coerceArray, coerceBoolean, coerceJson } from './_coerce.js';
|
|
6
7
|
const accountEnum = z.enum(ACCOUNTS);
|
|
7
8
|
export function registerFormsTools(server) {
|
|
8
9
|
server.registerTool('forms_get', {
|
|
@@ -90,6 +91,98 @@ export function registerFormsTools(server) {
|
|
|
90
91
|
return handleFormsError(error, account);
|
|
91
92
|
}
|
|
92
93
|
});
|
|
94
|
+
server.registerTool('forms_create', {
|
|
95
|
+
description: 'Create a new Google Form with a title (add questions with forms_batch_update)',
|
|
96
|
+
inputSchema: {
|
|
97
|
+
account: accountEnum.describe('Google account alias'),
|
|
98
|
+
title: z.string().describe('Form title shown to responders'),
|
|
99
|
+
documentTitle: z.string().optional().describe('Drive file name (default: the title)'),
|
|
100
|
+
},
|
|
101
|
+
}, async ({ account, title, documentTitle }) => {
|
|
102
|
+
try {
|
|
103
|
+
const auth = await getClient(account);
|
|
104
|
+
const forms = google.forms({ version: 'v1', auth });
|
|
105
|
+
const res = await forms.forms.create({
|
|
106
|
+
requestBody: { info: { title, documentTitle } },
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
110
|
+
formId: res.data.formId,
|
|
111
|
+
title: res.data.info?.title,
|
|
112
|
+
responderUri: res.data.responderUri,
|
|
113
|
+
revisionId: res.data.revisionId,
|
|
114
|
+
}, null, 2) }],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
return handleFormsError(error, account);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
server.registerTool('forms_batch_update', {
|
|
122
|
+
description: 'Generic forms.batchUpdate pass-through: add/edit/delete questions, update form info and settings. See https://developers.google.com/workspace/forms/api/reference/rest/v1/forms/request',
|
|
123
|
+
inputSchema: {
|
|
124
|
+
account: accountEnum.describe('Google account alias'),
|
|
125
|
+
formId: z.string().describe('Form ID'),
|
|
126
|
+
requests: coerceArray(coerceJson(z.record(z.string(), z.unknown())))
|
|
127
|
+
.describe('Array of Request objects, each with one request-type key like {createItem: {...}}'),
|
|
128
|
+
includeFormInResponse: coerceBoolean.optional().describe('Return the updated form in the response'),
|
|
129
|
+
writeControl: coerceJson(z.object({
|
|
130
|
+
requiredRevisionId: z.string().optional(),
|
|
131
|
+
targetRevisionId: z.string().optional(),
|
|
132
|
+
}).optional()).describe('Optional optimistic concurrency control'),
|
|
133
|
+
},
|
|
134
|
+
}, async ({ account, formId, requests, includeFormInResponse, writeControl }) => {
|
|
135
|
+
try {
|
|
136
|
+
const auth = await getClient(account);
|
|
137
|
+
const forms = google.forms({ version: 'v1', auth });
|
|
138
|
+
const res = await forms.forms.batchUpdate({
|
|
139
|
+
formId,
|
|
140
|
+
requestBody: {
|
|
141
|
+
requests: requests,
|
|
142
|
+
includeFormInResponse,
|
|
143
|
+
writeControl,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
return handleFormsError(error, account);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
server.registerTool('forms_set_publish_settings', {
|
|
155
|
+
description: 'Publish/unpublish a form and toggle whether it accepts responses (legacy forms without publish state are not supported)',
|
|
156
|
+
inputSchema: {
|
|
157
|
+
account: accountEnum.describe('Google account alias'),
|
|
158
|
+
formId: z.string().describe('Form ID'),
|
|
159
|
+
isPublished: coerceBoolean.describe('Form is published and reachable by responders'),
|
|
160
|
+
isAcceptingResponses: coerceBoolean.optional().describe('Form accepts responses (requires published; default: follows isPublished)'),
|
|
161
|
+
},
|
|
162
|
+
}, async ({ account, formId, isPublished, isAcceptingResponses }) => {
|
|
163
|
+
try {
|
|
164
|
+
const auth = await getClient(account);
|
|
165
|
+
const forms = google.forms({ version: 'v1', auth });
|
|
166
|
+
const updateMask = ['publishState.isPublished'];
|
|
167
|
+
if (isAcceptingResponses !== undefined)
|
|
168
|
+
updateMask.push('publishState.isAcceptingResponses');
|
|
169
|
+
const res = await forms.forms.setPublishSettings({
|
|
170
|
+
formId,
|
|
171
|
+
requestBody: {
|
|
172
|
+
publishSettings: {
|
|
173
|
+
publishState: { isPublished, isAcceptingResponses },
|
|
174
|
+
},
|
|
175
|
+
updateMask: updateMask.join(','),
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
return {
|
|
179
|
+
content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
return handleFormsError(error, account);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
93
186
|
}
|
|
94
187
|
function handleFormsError(error, account) {
|
|
95
188
|
return handleGoogleApiError(error, account, "Forms tools require the optional \"forms\" scope bundle. Add GOOGLE_OPTIONAL_SCOPES=forms (or include \"forms\" in the list) and re-auth.");
|
|
@@ -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,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
|
+
}
|
package/dist/tools/google-api.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ToolRegistry } from '../registry.js';
|
|
2
|
+
export declare function registerSlidesTools(server: ToolRegistry): void;
|
|
3
|
+
export declare function extractPageText(elements: Array<Record<string, any>> | undefined): string;
|
|
4
|
+
export declare function summarizePresentation(p: Record<string, any>): Record<string, unknown>;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { google } from 'googleapis';
|
|
3
|
+
import { ACCOUNTS } from '../accounts.js';
|
|
4
|
+
import { getClient } from '../client.js';
|
|
5
|
+
import { handleGoogleApiError } from './_errors.js';
|
|
6
|
+
import { coerceArray, coerceBoolean, coerceJson } from './_coerce.js';
|
|
7
|
+
import { sliceClean } from '../trim.js';
|
|
8
|
+
const accountEnum = z.enum(ACCOUNTS);
|
|
9
|
+
const SLIDE_TEXT_DIGEST_CHARS = 200;
|
|
10
|
+
export function registerSlidesTools(server) {
|
|
11
|
+
server.registerTool('slides_create', {
|
|
12
|
+
description: 'Create a new Google Slides presentation (starts with one blank slide)',
|
|
13
|
+
inputSchema: {
|
|
14
|
+
account: accountEnum.describe('Google account alias'),
|
|
15
|
+
title: z.string().describe('Presentation title'),
|
|
16
|
+
},
|
|
17
|
+
}, async ({ account, title }) => {
|
|
18
|
+
try {
|
|
19
|
+
const auth = await getClient(account);
|
|
20
|
+
const slides = google.slides({ version: 'v1', auth });
|
|
21
|
+
const res = await slides.presentations.create({ requestBody: { title } });
|
|
22
|
+
return {
|
|
23
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
24
|
+
presentationId: res.data.presentationId,
|
|
25
|
+
title: res.data.title,
|
|
26
|
+
url: `https://docs.google.com/presentation/d/${res.data.presentationId}/edit`,
|
|
27
|
+
firstSlideObjectId: res.data.slides?.[0]?.objectId,
|
|
28
|
+
}, null, 2) }],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
return handleSlidesError(error, account);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
server.registerTool('slides_get', {
|
|
36
|
+
description: 'Get a presentation as a compact summary (title, per-slide objectId + text digest). Pass full:true for the raw Presentation JSON (can be very large).',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
account: accountEnum.describe('Google account alias'),
|
|
39
|
+
presentationId: z.string().describe('Presentation ID'),
|
|
40
|
+
full: coerceBoolean.optional().describe('Return the untrimmed Presentation resource'),
|
|
41
|
+
},
|
|
42
|
+
}, async ({ account, presentationId, full }) => {
|
|
43
|
+
try {
|
|
44
|
+
const auth = await getClient(account);
|
|
45
|
+
const slides = google.slides({ version: 'v1', auth });
|
|
46
|
+
const res = await slides.presentations.get({ presentationId });
|
|
47
|
+
const payload = full ? res.data : summarizePresentation(res.data);
|
|
48
|
+
return {
|
|
49
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
return handleSlidesError(error, account);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
server.registerTool('slides_page_get', {
|
|
57
|
+
description: 'Get the full JSON of one slide/page (all page elements with geometry and text)',
|
|
58
|
+
inputSchema: {
|
|
59
|
+
account: accountEnum.describe('Google account alias'),
|
|
60
|
+
presentationId: z.string().describe('Presentation ID'),
|
|
61
|
+
pageObjectId: z.string().describe('Page object ID (from slides_get)'),
|
|
62
|
+
},
|
|
63
|
+
}, async ({ account, presentationId, pageObjectId }) => {
|
|
64
|
+
try {
|
|
65
|
+
const auth = await getClient(account);
|
|
66
|
+
const slides = google.slides({ version: 'v1', auth });
|
|
67
|
+
const res = await slides.presentations.pages.get({ presentationId, pageObjectId });
|
|
68
|
+
return {
|
|
69
|
+
content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return handleSlidesError(error, account);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
server.registerTool('slides_page_thumbnail', {
|
|
77
|
+
description: 'Get a rendered thumbnail image URL for one slide (the contentUrl expires after ~30 minutes)',
|
|
78
|
+
inputSchema: {
|
|
79
|
+
account: accountEnum.describe('Google account alias'),
|
|
80
|
+
presentationId: z.string().describe('Presentation ID'),
|
|
81
|
+
pageObjectId: z.string().describe('Page object ID (from slides_get)'),
|
|
82
|
+
mimeType: z.enum(['PNG']).optional().describe('Thumbnail mime type (default: PNG)'),
|
|
83
|
+
thumbnailSize: z.enum(['LARGE', 'MEDIUM', 'SMALL', 'WIDTH2000_PX']).optional()
|
|
84
|
+
.describe('LARGE=1600px, MEDIUM=800px, SMALL=200px, WIDTH2000_PX=2000px wide (default: server-chosen)'),
|
|
85
|
+
},
|
|
86
|
+
}, async ({ account, presentationId, pageObjectId, mimeType, thumbnailSize }) => {
|
|
87
|
+
try {
|
|
88
|
+
const auth = await getClient(account);
|
|
89
|
+
const slides = google.slides({ version: 'v1', auth });
|
|
90
|
+
const res = await slides.presentations.pages.getThumbnail({
|
|
91
|
+
presentationId,
|
|
92
|
+
pageObjectId,
|
|
93
|
+
'thumbnailProperties.mimeType': mimeType,
|
|
94
|
+
'thumbnailProperties.thumbnailSize': thumbnailSize,
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
return handleSlidesError(error, account);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
server.registerTool('slides_batch_update', {
|
|
105
|
+
description: 'Generic presentations.batchUpdate pass-through. Accepts the full Request union (create/move/delete slides, insert text/shapes/images/tables, styling, replaceAllText, …). See https://developers.google.com/workspace/slides/api/reference/rest/v1/presentations/request',
|
|
106
|
+
inputSchema: {
|
|
107
|
+
account: accountEnum.describe('Google account alias'),
|
|
108
|
+
presentationId: z.string().describe('Presentation ID'),
|
|
109
|
+
requests: coerceArray(coerceJson(z.record(z.string(), z.unknown())))
|
|
110
|
+
.describe('Array of Request objects, each with one request-type key like {createSlide: {...}}'),
|
|
111
|
+
writeControl: coerceJson(z.object({
|
|
112
|
+
requiredRevisionId: z.string().optional(),
|
|
113
|
+
}).optional()).describe('Optional optimistic concurrency control'),
|
|
114
|
+
},
|
|
115
|
+
}, async ({ account, presentationId, requests, writeControl }) => {
|
|
116
|
+
try {
|
|
117
|
+
const auth = await getClient(account);
|
|
118
|
+
const slides = google.slides({ version: 'v1', auth });
|
|
119
|
+
const res = await slides.presentations.batchUpdate({
|
|
120
|
+
presentationId,
|
|
121
|
+
requestBody: {
|
|
122
|
+
requests: requests,
|
|
123
|
+
writeControl,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
return {
|
|
127
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
128
|
+
presentationId: res.data.presentationId,
|
|
129
|
+
replies: res.data.replies ?? [],
|
|
130
|
+
}, null, 2) }],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
return handleSlidesError(error, account);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// ─── Helpers (exported for unit tests) ───────────────────────────────────
|
|
139
|
+
export function extractPageText(elements) {
|
|
140
|
+
const parts = [];
|
|
141
|
+
const visit = (els) => {
|
|
142
|
+
for (const el of els ?? []) {
|
|
143
|
+
for (const te of el.shape?.text?.textElements ?? []) {
|
|
144
|
+
const content = te.textRun?.content ?? te.autoText?.content;
|
|
145
|
+
if (content)
|
|
146
|
+
parts.push(content);
|
|
147
|
+
}
|
|
148
|
+
for (const row of el.table?.tableRows ?? []) {
|
|
149
|
+
for (const cell of row.tableCells ?? []) {
|
|
150
|
+
for (const te of cell.text?.textElements ?? []) {
|
|
151
|
+
if (te.textRun?.content)
|
|
152
|
+
parts.push(te.textRun.content);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
visit(el.elementGroup?.children);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
visit(elements);
|
|
160
|
+
return parts.join('').replace(/\s+/g, ' ').trim();
|
|
161
|
+
}
|
|
162
|
+
export function summarizePresentation(p) {
|
|
163
|
+
const slides = (p.slides ?? []).map((s, index) => {
|
|
164
|
+
const text = extractPageText(s.pageElements);
|
|
165
|
+
return {
|
|
166
|
+
index,
|
|
167
|
+
objectId: s.objectId,
|
|
168
|
+
elementCount: (s.pageElements ?? []).length,
|
|
169
|
+
...(text ? { text: sliceClean(text, SLIDE_TEXT_DIGEST_CHARS) } : {}),
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
return {
|
|
173
|
+
presentationId: p.presentationId,
|
|
174
|
+
title: p.title,
|
|
175
|
+
revisionId: p.revisionId,
|
|
176
|
+
slideCount: slides.length,
|
|
177
|
+
slides,
|
|
178
|
+
hint: 'Pass full:true for the raw Presentation JSON, or slides_page_get for one slide.',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function handleSlidesError(error, account) {
|
|
182
|
+
return handleGoogleApiError(error, account, "Slides tools require the optional \"slides\" scope bundle. Add GOOGLE_OPTIONAL_SCOPES=slides (or include \"slides\" in the list) and re-auth.");
|
|
183
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-google-multi",
|
|
3
|
-
"version": "5.
|
|
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",
|
|
@@ -52,9 +52,11 @@
|
|
|
52
52
|
"auth": "node dist/index.js auth --account",
|
|
53
53
|
"migrate-tokens": "node dist/index.js migrate-tokens",
|
|
54
54
|
"lint": "eslint .",
|
|
55
|
-
"typecheck": "tsc --noEmit",
|
|
55
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.scripts.json",
|
|
56
56
|
"test": "vitest run",
|
|
57
|
-
"test:watch": "vitest"
|
|
57
|
+
"test:watch": "vitest",
|
|
58
|
+
"gen:discovery": "tsx scripts/fetch-discovery.ts",
|
|
59
|
+
"gen:tools": "tsx scripts/gen-tools.ts"
|
|
58
60
|
},
|
|
59
61
|
"dependencies": {
|
|
60
62
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
@@ -72,6 +74,7 @@
|
|
|
72
74
|
"@types/server-destroy": "^1.0.3",
|
|
73
75
|
"eslint": "^10.2.1",
|
|
74
76
|
"semantic-release": "^25.0.3",
|
|
77
|
+
"tsx": "^4.23.1",
|
|
75
78
|
"typescript": "^6.0.3",
|
|
76
79
|
"typescript-eslint": "^8.59.1",
|
|
77
80
|
"vitest": "^3.2.6"
|