@a1-x-tech/mcp-google-slides 0.1.0

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/dist/client.js ADDED
@@ -0,0 +1,1032 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { basename, resolve as resolvePath } from "node:path";
4
+ import { GoogleSlidesError } from "./types.js";
5
+ import { CredentialsError } from "./config.js";
6
+ /** Google's OAuth2 token endpoint — refresh tokens are exchanged here. */
7
+ const TOKEN_URL = "https://oauth2.googleapis.com/token";
8
+ /** Slides rejects image URLs longer than 2 kB. */
9
+ const MAX_IMAGE_URL_LENGTH = 2000;
10
+ /** Slides rejects images larger than 50 MB. */
11
+ const MAX_IMAGE_BYTES = 50 * 1024 * 1024;
12
+ /** Maps a normalized shape type to the API's wire ShapeType. */
13
+ const SHAPE_TYPE_WIRE = {
14
+ text_box: "TEXT_BOX",
15
+ rectangle: "RECTANGLE",
16
+ round_rectangle: "ROUND_RECTANGLE",
17
+ ellipse: "ELLIPSE",
18
+ diamond: "DIAMOND",
19
+ triangle: "TRIANGLE",
20
+ right_arrow: "RIGHT_ARROW",
21
+ left_arrow: "LEFT_ARROW",
22
+ up_arrow: "UP_ARROW",
23
+ down_arrow: "DOWN_ARROW",
24
+ star: "STAR_5",
25
+ heart: "HEART",
26
+ cloud: "CLOUD",
27
+ callout: "WEDGE_RECTANGLE_CALLOUT",
28
+ };
29
+ /** Export formats → the Drive export MIME types for a Google Slides file. */
30
+ const EXPORT_MIME = {
31
+ pdf: "application/pdf",
32
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
33
+ odp: "application/vnd.oasis.opendocument.presentation",
34
+ txt: "text/plain",
35
+ };
36
+ /**
37
+ * A trimmed `fields` mask for slide summaries — a full presentation JSON runs
38
+ * to hundreds of kilobytes; the consumer is an LLM, so only ids, structure and
39
+ * text survive.
40
+ */
41
+ const SUMMARY_FIELDS = "presentationId,title,revisionId,pageSize," +
42
+ "slides(objectId,slideProperties(layoutObjectId,masterObjectId,notesPage(notesProperties(speakerNotesObjectId)))," +
43
+ "pageElements(objectId,shape(shapeType,placeholder(type),text(textElements(textRun(content))))," +
44
+ "table(rows,columns),image(sourceUrl),video(id),line(lineProperties),elementGroup(children(objectId))))";
45
+ /** `fields` mask for the layouts/masters catalog. */
46
+ const LAYOUTS_FIELDS = "layouts(objectId,layoutProperties(name,displayName,masterObjectId))," +
47
+ "masters(objectId,masterProperties(displayName)),notesMaster(objectId)";
48
+ /**
49
+ * Validates an image URL before it is handed to the Slides API: https-only, no
50
+ * private/loopback hosts (the URL lands in the deck and is fetched by Google —
51
+ * an internal hostname would leak into a shared document and never resolve),
52
+ * and within the API's 2 kB limit. Pure validation — no request is made.
53
+ */
54
+ export function validateImageUrl(url) {
55
+ let parsed;
56
+ try {
57
+ parsed = new URL(url);
58
+ }
59
+ catch {
60
+ throw new Error("image_url must be an absolute URL.");
61
+ }
62
+ if (parsed.protocol !== "https:") {
63
+ throw new Error("image_url must use https — Google's servers fetch it and reject plain http.");
64
+ }
65
+ if (url.length > MAX_IMAGE_URL_LENGTH) {
66
+ throw new Error(`image_url is too long (${url.length} chars) — the Slides API rejects URLs over 2 kB.`);
67
+ }
68
+ const host = parsed.hostname.toLowerCase();
69
+ const privateHost = host === "localhost" ||
70
+ host.endsWith(".localhost") ||
71
+ host.endsWith(".local") ||
72
+ host.endsWith(".internal") ||
73
+ /^127\./.test(host) ||
74
+ /^10\./.test(host) ||
75
+ /^192\.168\./.test(host) ||
76
+ /^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
77
+ /^169\.254\./.test(host) ||
78
+ host === "0.0.0.0" ||
79
+ host === "[::1]" ||
80
+ host.startsWith("[fc") ||
81
+ host.startsWith("[fd") ||
82
+ host.startsWith("[fe80");
83
+ if (privateHost) {
84
+ throw new Error(`image_url points at a private or local host (${host}) — Google's servers cannot fetch it. ` +
85
+ "Use a public https URL, or pass image_path to upload a local file.");
86
+ }
87
+ }
88
+ /**
89
+ * Identifies an image by magic bytes. The Slides API accepts only PNG, JPEG
90
+ * and GIF; trusting the file extension would upload junk to Drive first and
91
+ * fail late, so the real bytes are checked instead.
92
+ */
93
+ export function sniffImageType(data) {
94
+ if (data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
95
+ return { mimeType: "image/png", format: "png" };
96
+ }
97
+ if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
98
+ return { mimeType: "image/jpeg", format: "jpeg" };
99
+ }
100
+ if (data.length >= 6 && data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x38) {
101
+ return { mimeType: "image/gif", format: "gif" };
102
+ }
103
+ return undefined;
104
+ }
105
+ /** A Dimension in points. */
106
+ function pt(magnitude) {
107
+ return { magnitude, unit: "PT" };
108
+ }
109
+ /** Parses "#RRGGBB" (leading # optional) into the API's rgbColor (floats 0..1). */
110
+ export function hexToRgbColor(hex) {
111
+ const clean = hex.replace(/^#/, "");
112
+ const n = parseInt(clean, 16);
113
+ return {
114
+ red: Math.round((((n >> 16) & 0xff) / 255) * 10000) / 10000,
115
+ green: Math.round((((n >> 8) & 0xff) / 255) * 10000) / 10000,
116
+ blue: Math.round(((n & 0xff) / 255) * 10000) / 10000,
117
+ };
118
+ }
119
+ /** A fresh valid object id (5-50 chars of [a-zA-Z0-9_-:], leading alnum/underscore). */
120
+ function generateObjectId(prefix) {
121
+ return `${prefix}_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
122
+ }
123
+ /**
124
+ * Builds elementProperties from a page id and optional placement. Size needs
125
+ * both width+height and position both x+y — half a pair is a caller mistake,
126
+ * not something to guess.
127
+ */
128
+ function elementProperties(pageObjectId, p) {
129
+ if ((p.widthPt === undefined) !== (p.heightPt === undefined)) {
130
+ throw new Error("width_pt and height_pt must be provided together.");
131
+ }
132
+ if ((p.xPt === undefined) !== (p.yPt === undefined)) {
133
+ throw new Error("x_pt and y_pt must be provided together.");
134
+ }
135
+ return compact({
136
+ pageObjectId,
137
+ size: p.widthPt !== undefined ? { width: pt(p.widthPt), height: pt(p.heightPt) } : undefined,
138
+ transform: p.xPt !== undefined
139
+ ? { scaleX: 1, scaleY: 1, translateX: p.xPt, translateY: p.yPt, unit: "PT" }
140
+ : undefined,
141
+ });
142
+ }
143
+ /** Table cell location: rowIndex and columnIndex travel together or not at all. */
144
+ function cellLocation(rowIndex, columnIndex) {
145
+ if ((rowIndex === undefined) !== (columnIndex === undefined)) {
146
+ throw new Error("row_index and column_index must be provided together (they address one table cell).");
147
+ }
148
+ if (rowIndex === undefined)
149
+ return undefined;
150
+ return { rowIndex, columnIndex: columnIndex };
151
+ }
152
+ /** Concatenates the text runs of a Slides `text` payload into plain text. */
153
+ export function extractText(text) {
154
+ const elements = text?.textElements;
155
+ if (!Array.isArray(elements))
156
+ return "";
157
+ let out = "";
158
+ for (const el of elements) {
159
+ const content = el.textRun?.content;
160
+ if (typeof content === "string")
161
+ out += content;
162
+ }
163
+ return out.replace(/\n$/, "");
164
+ }
165
+ export class GoogleSlidesClient {
166
+ config;
167
+ base;
168
+ driveBase;
169
+ timeoutMs;
170
+ maxRetries;
171
+ retryBaseMs;
172
+ /** Cached access token from the refresh flow, with its expiry. */
173
+ cachedToken;
174
+ /** In-flight refresh, deduping concurrent token requests. */
175
+ refreshInFlight;
176
+ constructor(config) {
177
+ this.config = config;
178
+ this.base = config.apiBase.endsWith("/") ? config.apiBase : config.apiBase + "/";
179
+ const drive = config.driveApiBase ?? "https://www.googleapis.com";
180
+ this.driveBase = drive.endsWith("/") ? drive : drive + "/";
181
+ this.timeoutMs = config.timeoutMs ?? 60_000;
182
+ this.maxRetries = config.maxRetries ?? 3;
183
+ this.retryBaseMs = config.retryBaseMs ?? 500;
184
+ }
185
+ canRefresh() {
186
+ return Boolean(this.config.refreshToken && this.config.clientId && this.config.clientSecret);
187
+ }
188
+ /**
189
+ * Returns a valid Bearer token. With the refresh triple configured, mints an
190
+ * access token from the refresh token and caches it until shortly before it
191
+ * expires (concurrent callers share one in-flight refresh); otherwise the
192
+ * static GOOGLE_SLIDES_ACCESS_TOKEN is used as-is. With neither configured,
193
+ * throws {@link CredentialsError} BEFORE any fetch — a missing setup must
194
+ * never enter the retry/backoff loop or trigger the 401 re-mint, because no
195
+ * amount of retrying mints credentials.
196
+ */
197
+ async accessToken(forceRefresh = false) {
198
+ if (!this.canRefresh()) {
199
+ if (!this.config.accessToken)
200
+ throw new CredentialsError();
201
+ return this.config.accessToken;
202
+ }
203
+ if (!forceRefresh && this.cachedToken && Date.now() < this.cachedToken.expiresAt) {
204
+ return this.cachedToken.value;
205
+ }
206
+ if (!this.refreshInFlight) {
207
+ this.refreshInFlight = this.refreshAccessToken().finally(() => {
208
+ this.refreshInFlight = undefined;
209
+ });
210
+ }
211
+ return this.refreshInFlight;
212
+ }
213
+ /** Exchanges the refresh token for a fresh access token at Google's token endpoint. */
214
+ async refreshAccessToken() {
215
+ const body = new URLSearchParams({
216
+ client_id: this.config.clientId,
217
+ client_secret: this.config.clientSecret,
218
+ refresh_token: this.config.refreshToken,
219
+ grant_type: "refresh_token",
220
+ }).toString();
221
+ const { res, text } = await this.fetchWithTimeout(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body }, "oauth2 token refresh");
222
+ let data;
223
+ try {
224
+ data = JSON.parse(text);
225
+ }
226
+ catch {
227
+ data = text;
228
+ }
229
+ if (!res.ok)
230
+ throw new GoogleSlidesError(res.status, data);
231
+ const token = data.access_token;
232
+ if (typeof token !== "string" || !token) {
233
+ throw new Error("OAuth2 token endpoint returned no access_token.");
234
+ }
235
+ const expiresIn = Number(data.expires_in);
236
+ const ttl = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600;
237
+ // Refresh 60s ahead of the real expiry so requests never race a dying token.
238
+ this.cachedToken = { value: token, expiresAt: Date.now() + Math.max(ttl - 60, 30) * 1000 };
239
+ return token;
240
+ }
241
+ /** Verifies the OAuth credentials by minting a fresh access token (refresh flow only). */
242
+ async authCheck() {
243
+ if (!this.canRefresh()) {
244
+ throw new Error("authCheck needs the refresh flow (GOOGLE_SLIDES_CLIENT_ID / _CLIENT_SECRET / _REFRESH_TOKEN); with a static GOOGLE_SLIDES_ACCESS_TOKEN fetch a presentation instead.");
245
+ }
246
+ await this.accessToken(true);
247
+ return { ok: true, auth: "refresh_token" };
248
+ }
249
+ /** Backoff before a retry: honors Retry-After when present, else exponential (capped at 30s). */
250
+ backoffMs(attempt, res) {
251
+ const retryAfter = res ? Number(res.headers.get("Retry-After")) : NaN;
252
+ if (Number.isFinite(retryAfter) && retryAfter > 0)
253
+ return Math.min(retryAfter, 30) * 1000;
254
+ return Math.min(this.retryBaseMs * 2 ** attempt, 30_000);
255
+ }
256
+ /**
257
+ * fetch with an AbortController timeout. Reads the response body inside the
258
+ * guarded zone so the timeout also covers a slow or drip-feeding body, not
259
+ * just the initial headers, and returns the text alongside the response.
260
+ */
261
+ async fetchWithTimeout(url, init, label) {
262
+ const controller = new AbortController();
263
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
264
+ try {
265
+ const res = await fetch(url, { ...init, signal: controller.signal });
266
+ const text = await res.text();
267
+ return { res, text };
268
+ }
269
+ catch (err) {
270
+ if (err instanceof Error && err.name === "AbortError") {
271
+ throw new Error(`Request to "${label}" timed out after ${this.timeoutMs}ms`);
272
+ }
273
+ throw err;
274
+ }
275
+ finally {
276
+ clearTimeout(timer);
277
+ }
278
+ }
279
+ /** Like fetchWithTimeout, but keeps the body as bytes (thumbnails, exports). */
280
+ async fetchBytesWithTimeout(url, init, label) {
281
+ const controller = new AbortController();
282
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
283
+ try {
284
+ const res = await fetch(url, { ...init, signal: controller.signal });
285
+ const bytes = new Uint8Array(await res.arrayBuffer());
286
+ return { res, bytes };
287
+ }
288
+ catch (err) {
289
+ if (err instanceof Error && err.name === "AbortError") {
290
+ throw new Error(`Request to "${label}" timed out after ${this.timeoutMs}ms`);
291
+ }
292
+ throw err;
293
+ }
294
+ finally {
295
+ clearTimeout(timer);
296
+ }
297
+ }
298
+ /**
299
+ * Low-level request to the Slides API (paths like "v1/presentations/abc").
300
+ * Auth is a Bearer token (refreshed transparently; a 401 forces one re-mint +
301
+ * retry). 429 is always retried with backoff; 5xx and network errors/timeouts
302
+ * are retried only for GET — batchUpdate is a real write, and replaying it
303
+ * after the write committed would duplicate slides/elements. Any other
304
+ * non-2xx throws a {@link GoogleSlidesError}.
305
+ */
306
+ async request(method, path, body, query) {
307
+ return this.requestAgainst(this.base, method, path, body, query);
308
+ }
309
+ /**
310
+ * Internal Drive API v3 calls (comments, export metadata, image upload
311
+ * cleanup). Same auth/retry/SSRF rules as {@link request}, resolved against
312
+ * the Drive base — never exposed as a generic Drive tool.
313
+ */
314
+ async driveRequest(method, path, body, query) {
315
+ return this.requestAgainst(this.driveBase, method, path, body, query);
316
+ }
317
+ async requestAgainst(base, method, path, body, query) {
318
+ // Guard method !== "GET" keeps undici from crashing on a GET-with-body.
319
+ const hasBody = body !== undefined && method !== "GET";
320
+ // Resolve the path against the API base, then reject anything that escaped
321
+ // to a foreign origin (an absolute "https://evil/x" or a "\\evil/x" slipped
322
+ // through raw_request) so the Bearer token can never leak to another host.
323
+ const url = new URL(path.replace(/^\//, ""), base);
324
+ if (url.origin !== new URL(base).origin) {
325
+ throw new Error(`raw_request path must be a relative API path (resolved to foreign origin ${url.origin})`);
326
+ }
327
+ if (query) {
328
+ for (const [key, value] of Object.entries(query)) {
329
+ if (value !== undefined)
330
+ url.searchParams.set(key, String(value));
331
+ }
332
+ }
333
+ const target = url.toString();
334
+ // Writes must not be replayed on ambiguous failures (see the retry gate below).
335
+ const idempotent = method === "GET";
336
+ let refreshedOn401 = false;
337
+ for (let attempt = 0;; attempt++) {
338
+ const token = await this.accessToken();
339
+ const headers = { Authorization: `Bearer ${token}` };
340
+ if (hasBody)
341
+ headers["Content-Type"] = "application/json";
342
+ let res;
343
+ let text;
344
+ try {
345
+ ({ res, text } = await this.fetchWithTimeout(target, { method, headers, body: hasBody ? JSON.stringify(body) : undefined }, path));
346
+ }
347
+ catch (err) {
348
+ // Network error or timeout: the request may or may not have reached the
349
+ // API, so only reads are retried; writes rethrow immediately.
350
+ if (idempotent && attempt < this.maxRetries) {
351
+ await delay(this.backoffMs(attempt));
352
+ continue;
353
+ }
354
+ throw err;
355
+ }
356
+ // An expired/revoked access token: re-mint once and replay. The request
357
+ // never executed, so this is safe for writes too. The replay must not
358
+ // eat into the transient-retry budget (attempt-- cancels the loop's
359
+ // attempt++), so a 429/5xx after the re-mint still gets maxRetries
360
+ // tries; refreshedOn401 guarantees this runs at most once.
361
+ if (res.status === 401 && this.canRefresh() && !refreshedOn401) {
362
+ refreshedOn401 = true;
363
+ await this.accessToken(true);
364
+ attempt--;
365
+ continue;
366
+ }
367
+ // 429 means the request was rejected before executing — safe to retry for
368
+ // any method. 5xx is ambiguous (the write may have committed), so it is
369
+ // gated to idempotent requests.
370
+ const transient = res.status === 429 || (idempotent && res.status >= 500 && res.status < 600);
371
+ if (transient && attempt < this.maxRetries) {
372
+ await delay(this.backoffMs(attempt, res));
373
+ continue;
374
+ }
375
+ let data = undefined;
376
+ if (text) {
377
+ try {
378
+ data = JSON.parse(text);
379
+ }
380
+ catch {
381
+ data = text;
382
+ }
383
+ }
384
+ if (!res.ok)
385
+ throw new GoogleSlidesError(res.status, data);
386
+ return data;
387
+ }
388
+ }
389
+ /**
390
+ * Authenticated binary download (Drive export). GET-only; one forced token
391
+ * re-mint on 401, no other retries — an export that dies halfway is safer
392
+ * re-requested by the caller than silently re-streamed.
393
+ */
394
+ async downloadWithAuth(url, label) {
395
+ for (let attempt = 0;; attempt++) {
396
+ const token = await this.accessToken(attempt > 0);
397
+ const { res, bytes } = await this.fetchBytesWithTimeout(url, { method: "GET", headers: { Authorization: `Bearer ${token}` } }, label);
398
+ if (res.status === 401 && this.canRefresh() && attempt === 0)
399
+ continue;
400
+ if (!res.ok) {
401
+ let parsed;
402
+ const text = new TextDecoder().decode(bytes);
403
+ try {
404
+ parsed = JSON.parse(text);
405
+ }
406
+ catch {
407
+ parsed = text;
408
+ }
409
+ throw new GoogleSlidesError(res.status, parsed);
410
+ }
411
+ return bytes;
412
+ }
413
+ }
414
+ // ---- Presentations ----
415
+ /**
416
+ * Creates a presentation. presentations.create honors only the title —
417
+ * everything else (slides, text, elements) is added via batchUpdate
418
+ * afterwards. The new deck starts with one title slide.
419
+ */
420
+ async createPresentation(title) {
421
+ return this.request("POST", "v1/presentations", { title });
422
+ }
423
+ /** Full presentation structure, optionally trimmed by a `fields` mask. */
424
+ async getPresentation(presentationId, fields) {
425
+ return this.request("GET", `v1/presentations/${encodeURIComponent(presentationId)}`, undefined, compact({ fields }));
426
+ }
427
+ /** One page (slide, layout, master or notes page) by its object id. */
428
+ async getPage(presentationId, pageObjectId) {
429
+ return this.request("GET", `v1/presentations/${encodeURIComponent(presentationId)}/pages/${encodeURIComponent(pageObjectId)}`);
430
+ }
431
+ /**
432
+ * Compact per-slide inventory: object ids, layout, speaker-notes shape id and
433
+ * every page element with its type and visible text. A full presentation
434
+ * JSON is enormous; this is the read the model should start from.
435
+ */
436
+ async listSlides(presentationId) {
437
+ const data = (await this.getPresentation(presentationId, SUMMARY_FIELDS));
438
+ return summarizePresentation(data);
439
+ }
440
+ /** The presentation's layouts and masters — what add_slide can instantiate. */
441
+ async listLayouts(presentationId) {
442
+ const data = (await this.getPresentation(presentationId, LAYOUTS_FIELDS));
443
+ return {
444
+ layouts: (data.layouts ?? []).map((l) => {
445
+ const props = (l.layoutProperties ?? {});
446
+ return compact({
447
+ objectId: l.objectId,
448
+ name: props.name,
449
+ displayName: props.displayName,
450
+ masterObjectId: props.masterObjectId,
451
+ });
452
+ }),
453
+ masters: (data.masters ?? []).map((m) => compact({
454
+ objectId: m.objectId,
455
+ displayName: (m.masterProperties ?? {}).displayName,
456
+ })),
457
+ notesMasterObjectId: data.notesMaster?.objectId,
458
+ };
459
+ }
460
+ /**
461
+ * A rendered thumbnail of one page. Returns contentUrl (short-lived, ~30
462
+ * minutes) + dimensions; with outputPath the PNG is downloaded and saved.
463
+ * The download goes only to *.googleusercontent.com — where Google serves
464
+ * thumbnails — and carries no auth header.
465
+ */
466
+ async getThumbnail(p) {
467
+ const data = await this.request("GET", `v1/presentations/${encodeURIComponent(p.presentationId)}/pages/${encodeURIComponent(p.pageObjectId)}/thumbnail`, undefined, compact({
468
+ "thumbnailProperties.mimeType": "PNG",
469
+ "thumbnailProperties.thumbnailSize": p.size ? p.size.toUpperCase() : undefined,
470
+ }));
471
+ if (!p.outputPath)
472
+ return data;
473
+ if (!data.contentUrl)
474
+ throw new Error("The API returned no contentUrl for this page's thumbnail.");
475
+ const parsed = new URL(data.contentUrl);
476
+ const host = parsed.hostname.toLowerCase();
477
+ if (parsed.protocol !== "https:" || !(host === "googleusercontent.com" || host.endsWith(".googleusercontent.com"))) {
478
+ throw new Error(`Unexpected thumbnail host ${parsed.hostname} — refusing to download.`);
479
+ }
480
+ const { res, bytes } = await this.fetchBytesWithTimeout(data.contentUrl, { method: "GET" }, "thumbnail download");
481
+ if (!res.ok)
482
+ throw new GoogleSlidesError(res.status, new TextDecoder().decode(bytes));
483
+ await writeFile(p.outputPath, bytes);
484
+ return { width: data.width, height: data.height, saved_to: resolvePath(p.outputPath), bytes: bytes.byteLength };
485
+ }
486
+ /**
487
+ * Low-level batchUpdate — the write channel for everything except create.
488
+ * Atomic: if any request in the batch is invalid, nothing is applied.
489
+ */
490
+ async batchUpdate(presentationId, requests) {
491
+ return this.request("POST", `v1/presentations/${encodeURIComponent(presentationId)}:batchUpdate`, {
492
+ requests,
493
+ });
494
+ }
495
+ // ---- Slides ----
496
+ /** Adds a slide (createSlide), from a predefined layout or a concrete layout object id. */
497
+ async addSlide(p) {
498
+ if (p.predefinedLayout && p.layoutObjectId) {
499
+ throw new Error("Provide either predefined_layout or layout_object_id, not both.");
500
+ }
501
+ const slideLayoutReference = p.predefinedLayout
502
+ ? { predefinedLayout: p.predefinedLayout }
503
+ : p.layoutObjectId
504
+ ? { layoutId: p.layoutObjectId }
505
+ : undefined;
506
+ return this.batchUpdate(p.presentationId, [
507
+ {
508
+ createSlide: compact({
509
+ objectId: p.objectId,
510
+ insertionIndex: p.insertionIndex,
511
+ slideLayoutReference,
512
+ }),
513
+ },
514
+ ]);
515
+ }
516
+ /** Duplicates a slide (or any page element); the copy lands right after the original. */
517
+ async duplicateSlide(presentationId, objectId) {
518
+ return this.batchUpdate(presentationId, [{ duplicateObject: { objectId } }]);
519
+ }
520
+ /** Moves slides to a new 0-based position (updateSlidesPosition). */
521
+ async moveSlides(presentationId, slideObjectIds, insertionIndex) {
522
+ return this.batchUpdate(presentationId, [{ updateSlidesPosition: { slideObjectIds, insertionIndex } }]);
523
+ }
524
+ /** Deletes a slide or a page element by object id (deleteObject). */
525
+ async deleteObject(presentationId, objectId) {
526
+ return this.batchUpdate(presentationId, [{ deleteObject: { objectId } }]);
527
+ }
528
+ // ---- Text ----
529
+ /** Inserts text into a shape or table cell at a character index. */
530
+ async insertText(p) {
531
+ return this.batchUpdate(p.presentationId, [
532
+ {
533
+ insertText: compact({
534
+ objectId: p.objectId,
535
+ cellLocation: cellLocation(p.rowIndex, p.columnIndex),
536
+ text: p.text,
537
+ insertionIndex: p.insertionIndex ?? 0,
538
+ }),
539
+ },
540
+ ]);
541
+ }
542
+ /**
543
+ * Replaces the entire text of a shape or table cell: one atomic batch of
544
+ * deleteText(ALL) + insertText. An empty target makes deleteText fail with a
545
+ * 400 saying the object "has no text" — batchUpdate is atomic, so that
546
+ * rejection applied nothing and the insert is safely re-sent alone. Only
547
+ * that specific rejection triggers the fallback: other deleteText 400s
548
+ * (object not found, object cannot hold text) are rethrown untouched, so
549
+ * the root cause is never masked by a doomed insert-only resend. An empty
550
+ * `text` just clears the object.
551
+ */
552
+ async setText(p) {
553
+ const cell = cellLocation(p.rowIndex, p.columnIndex);
554
+ const del = { deleteText: compact({ objectId: p.objectId, cellLocation: cell, textRange: { type: "ALL" } }) };
555
+ const ins = p.text
556
+ ? { insertText: compact({ objectId: p.objectId, cellLocation: cell, text: p.text, insertionIndex: 0 }) }
557
+ : undefined;
558
+ try {
559
+ return await this.batchUpdate(p.presentationId, ins ? [del, ins] : [del]);
560
+ }
561
+ catch (err) {
562
+ const noText = err instanceof GoogleSlidesError &&
563
+ err.status === 400 &&
564
+ /deleteText/i.test(err.message) &&
565
+ /no text/i.test(err.message);
566
+ if (!noText)
567
+ throw err;
568
+ if (ins)
569
+ return this.batchUpdate(p.presentationId, [ins]);
570
+ return { cleared: false, note: "The object had no text to delete." };
571
+ }
572
+ }
573
+ /** replaceAllText across the deck (or only the given pages). Returns occurrencesChanged. */
574
+ async replaceText(p) {
575
+ return this.batchUpdate(p.presentationId, [
576
+ {
577
+ replaceAllText: compact({
578
+ containsText: { text: p.find, matchCase: p.matchCase ?? true },
579
+ replaceText: p.replace,
580
+ pageObjectIds: p.pageObjectIds,
581
+ }),
582
+ },
583
+ ]);
584
+ }
585
+ /** updateTextStyle on ALL text or a fixed character range, with a computed field mask. */
586
+ async updateTextStyle(p) {
587
+ const style = {};
588
+ const fields = [];
589
+ if (p.bold !== undefined) {
590
+ style.bold = p.bold;
591
+ fields.push("bold");
592
+ }
593
+ if (p.italic !== undefined) {
594
+ style.italic = p.italic;
595
+ fields.push("italic");
596
+ }
597
+ if (p.underline !== undefined) {
598
+ style.underline = p.underline;
599
+ fields.push("underline");
600
+ }
601
+ if (p.strikethrough !== undefined) {
602
+ style.strikethrough = p.strikethrough;
603
+ fields.push("strikethrough");
604
+ }
605
+ if (p.fontFamily !== undefined) {
606
+ style.fontFamily = p.fontFamily;
607
+ fields.push("fontFamily");
608
+ }
609
+ if (p.fontSizePt !== undefined) {
610
+ style.fontSize = pt(p.fontSizePt);
611
+ fields.push("fontSize");
612
+ }
613
+ if (p.foregroundColor !== undefined) {
614
+ style.foregroundColor = { opaqueColor: { rgbColor: hexToRgbColor(p.foregroundColor) } };
615
+ fields.push("foregroundColor");
616
+ }
617
+ if (p.linkUrl !== undefined) {
618
+ style.link = { url: p.linkUrl };
619
+ fields.push("link");
620
+ }
621
+ if (fields.length === 0) {
622
+ throw new Error("At least one style field (bold, italic, font_family, font_size_pt, ...) is required.");
623
+ }
624
+ if ((p.startIndex === undefined) !== (p.endIndex === undefined)) {
625
+ throw new Error("start_index and end_index must be provided together.");
626
+ }
627
+ const textRange = p.startIndex !== undefined
628
+ ? { type: "FIXED_RANGE", startIndex: p.startIndex, endIndex: p.endIndex }
629
+ : { type: "ALL" };
630
+ return this.batchUpdate(p.presentationId, [
631
+ {
632
+ updateTextStyle: compact({
633
+ objectId: p.objectId,
634
+ cellLocation: cellLocation(p.rowIndex, p.columnIndex),
635
+ style,
636
+ textRange,
637
+ fields: fields.join(","),
638
+ }),
639
+ },
640
+ ]);
641
+ }
642
+ // ---- Shapes, images, transforms ----
643
+ /** createShape, optionally chaining the initial text into the same atomic batch. */
644
+ async createShape(p) {
645
+ const objectId = p.objectId ?? generateObjectId("shape");
646
+ const requests = [
647
+ {
648
+ createShape: {
649
+ objectId,
650
+ shapeType: SHAPE_TYPE_WIRE[p.shapeType],
651
+ elementProperties: elementProperties(p.pageObjectId, {
652
+ // A shape without explicit geometry gets a sane visible default
653
+ // instead of the API's zero-size speck.
654
+ widthPt: p.widthPt ?? 300,
655
+ heightPt: p.heightPt ?? 80,
656
+ xPt: p.xPt ?? 50,
657
+ yPt: p.yPt ?? 50,
658
+ }),
659
+ },
660
+ },
661
+ ];
662
+ if (p.text) {
663
+ requests.push({ insertText: { objectId, text: p.text, insertionIndex: 0 } });
664
+ }
665
+ return this.batchUpdate(p.presentationId, requests);
666
+ }
667
+ /**
668
+ * Runs `run` with a usable image URL: a public URL is validated in place; a
669
+ * local path is sniffed (PNG/JPEG/GIF magic bytes, ≤50 MB), uploaded to
670
+ * Drive, shared read-only by link, used, and the temp file deleted
671
+ * best-effort afterwards — success and failure alike (Slides copies the
672
+ * image bytes at insert time, so the Drive original is disposable).
673
+ */
674
+ async withImageUrl(source, run) {
675
+ const provided = [source.imageUrl, source.imagePath].filter(Boolean).length;
676
+ if (provided !== 1)
677
+ throw new Error("Provide exactly one of image_url or image_path.");
678
+ if (source.imageUrl) {
679
+ validateImageUrl(source.imageUrl);
680
+ return run(source.imageUrl);
681
+ }
682
+ const uploaded = await this.uploadLocalImage(source.imagePath);
683
+ let result;
684
+ try {
685
+ result = await run(uploaded.url);
686
+ }
687
+ catch (err) {
688
+ await this.tryDeleteDriveFile(uploaded.fileId);
689
+ throw err;
690
+ }
691
+ const cleaned = await this.tryDeleteDriveFile(uploaded.fileId);
692
+ return {
693
+ ...result,
694
+ uploaded_image: {
695
+ drive_file_id: uploaded.fileId,
696
+ temp_file: cleaned ? "deleted" : "cleanup failed — delete the file from Drive manually",
697
+ },
698
+ };
699
+ }
700
+ /** Inserts an image from a public URL or a local file (see withImageUrl). */
701
+ async createImage(p) {
702
+ return this.withImageUrl(p, (url) => this.batchUpdate(p.presentationId, [
703
+ {
704
+ createImage: compact({
705
+ objectId: p.objectId,
706
+ url,
707
+ elementProperties: elementProperties(p.pageObjectId, p),
708
+ }),
709
+ },
710
+ ]));
711
+ }
712
+ /** Swaps the bitmap of an existing image element, keeping its frame. */
713
+ async replaceImage(p) {
714
+ return this.withImageUrl(p, (url) => this.batchUpdate(p.presentationId, [
715
+ {
716
+ replaceImage: {
717
+ imageObjectId: p.imageObjectId,
718
+ url,
719
+ imageReplaceMethod: (p.replaceMethod ?? "center_inside").toUpperCase(),
720
+ },
721
+ },
722
+ ]));
723
+ }
724
+ /**
725
+ * updatePageElementTransform. ABSOLUTE replaces the whole matrix — omitted
726
+ * scales default to 1 (not to the current value!) and omitted translates to
727
+ * 0. RELATIVE multiplies onto the existing transform.
728
+ */
729
+ async updateTransform(p) {
730
+ return this.batchUpdate(p.presentationId, [
731
+ {
732
+ updatePageElementTransform: {
733
+ objectId: p.objectId,
734
+ applyMode: p.mode.toUpperCase(),
735
+ transform: {
736
+ scaleX: p.scaleX ?? 1,
737
+ scaleY: p.scaleY ?? 1,
738
+ translateX: p.translateXPt ?? 0,
739
+ translateY: p.translateYPt ?? 0,
740
+ unit: "PT",
741
+ },
742
+ },
743
+ },
744
+ ]);
745
+ }
746
+ // ---- Tables ----
747
+ /** createTable with rows x columns. */
748
+ async createTable(p) {
749
+ return this.batchUpdate(p.presentationId, [
750
+ {
751
+ createTable: compact({
752
+ objectId: p.objectId,
753
+ elementProperties: elementProperties(p.pageObjectId, p),
754
+ rows: p.rows,
755
+ columns: p.columns,
756
+ }),
757
+ },
758
+ ]);
759
+ }
760
+ /** Structural table edits: insert/delete rows and columns. */
761
+ async editTable(p) {
762
+ const need = (field) => {
763
+ const value = p[field];
764
+ if (value === undefined) {
765
+ throw new Error(`action "${p.action}" requires ${field === "rowIndex" ? "row_index" : "column_index"}.`);
766
+ }
767
+ return value;
768
+ };
769
+ let request;
770
+ switch (p.action) {
771
+ case "insert_rows":
772
+ request = {
773
+ insertTableRows: {
774
+ tableObjectId: p.tableObjectId,
775
+ cellLocation: { rowIndex: need("rowIndex") },
776
+ insertBelow: p.below ?? true,
777
+ number: p.count ?? 1,
778
+ },
779
+ };
780
+ break;
781
+ case "insert_columns":
782
+ request = {
783
+ insertTableColumns: {
784
+ tableObjectId: p.tableObjectId,
785
+ cellLocation: { columnIndex: need("columnIndex") },
786
+ insertRight: p.right ?? true,
787
+ number: p.count ?? 1,
788
+ },
789
+ };
790
+ break;
791
+ case "delete_row":
792
+ request = {
793
+ deleteTableRow: { tableObjectId: p.tableObjectId, cellLocation: { rowIndex: need("rowIndex") } },
794
+ };
795
+ break;
796
+ case "delete_column":
797
+ request = {
798
+ deleteTableColumn: {
799
+ tableObjectId: p.tableObjectId,
800
+ cellLocation: { columnIndex: need("columnIndex") },
801
+ },
802
+ };
803
+ break;
804
+ }
805
+ return this.batchUpdate(p.presentationId, [request]);
806
+ }
807
+ // ---- Speaker notes ----
808
+ /** Finds the slide's speaker-notes shape id (every slide has a notes page). */
809
+ async speakerNotes(presentationId, slideObjectId) {
810
+ const page = (await this.getPage(presentationId, slideObjectId));
811
+ const notesPage = page.slideProperties?.notesPage;
812
+ const notesId = (notesPage?.notesProperties ?? {}).speakerNotesObjectId;
813
+ if (typeof notesId !== "string" || !notesId) {
814
+ throw new Error("No speaker-notes shape found — the object id must be a slide (layouts and masters have no notes).");
815
+ }
816
+ return { notesId, notesPage: notesPage };
817
+ }
818
+ /** The slide's speaker notes as plain text. */
819
+ async getSpeakerNotes(presentationId, slideObjectId) {
820
+ const { notesId, notesPage } = await this.speakerNotes(presentationId, slideObjectId);
821
+ const elements = (notesPage.pageElements ?? []);
822
+ const shape = elements.find((el) => el.objectId === notesId);
823
+ const text = extractText((shape?.shape ?? {}).text);
824
+ return { slideObjectId, speakerNotesObjectId: notesId, text };
825
+ }
826
+ /** Replaces the slide's speaker notes (empty text clears them). */
827
+ async setSpeakerNotes(presentationId, slideObjectId, text) {
828
+ const { notesId } = await this.speakerNotes(presentationId, slideObjectId);
829
+ const result = await this.setText({ presentationId, objectId: notesId, text });
830
+ return { speakerNotesObjectId: notesId, result };
831
+ }
832
+ // ---- Comments (Drive API v3, scoped to this one file) ----
833
+ /** Comment threads on the presentation file. Drive requires an explicit fields mask. */
834
+ async manageComments(p) {
835
+ const filePath = `drive/v3/files/${encodeURIComponent(p.presentationId)}/comments`;
836
+ const needComment = () => {
837
+ if (!p.commentId)
838
+ throw new Error(`action "${p.action}" requires comment_id.`);
839
+ return encodeURIComponent(p.commentId);
840
+ };
841
+ switch (p.action) {
842
+ case "list":
843
+ return this.driveRequest("GET", filePath, undefined, compact({
844
+ fields: "comments(id,content,author(displayName),createdTime,modifiedTime,resolved,quotedFileContent(value),replies(id,content,author(displayName),createdTime,action)),nextPageToken",
845
+ pageSize: p.pageSize,
846
+ pageToken: p.pageToken,
847
+ includeDeleted: p.includeDeleted,
848
+ }));
849
+ case "get":
850
+ return this.driveRequest("GET", `${filePath}/${needComment()}`, undefined, { fields: "*" });
851
+ case "create":
852
+ if (!p.content)
853
+ throw new Error('action "create" requires content.');
854
+ return this.driveRequest("POST", filePath, { content: p.content }, { fields: "*" });
855
+ case "reply": {
856
+ if (!p.content && !p.resolve) {
857
+ throw new Error('action "reply" requires content (and/or resolve=true).');
858
+ }
859
+ return this.driveRequest("POST", `${filePath}/${needComment()}/replies`, compact({ content: p.content, action: p.resolve ? "resolve" : undefined }), { fields: "*" });
860
+ }
861
+ case "delete":
862
+ return (await this.driveRequest("DELETE", `${filePath}/${needComment()}`)) ?? { deleted: true };
863
+ }
864
+ }
865
+ // ---- Export ----
866
+ /**
867
+ * Downloads the presentation via Drive export (pdf/pptx/odp/txt) and writes
868
+ * it to outputPath. Drive's export endpoint caps files at 10 MB.
869
+ */
870
+ async exportPresentation(presentationId, format, outputPath) {
871
+ const mime = EXPORT_MIME[format];
872
+ const url = new URL(`drive/v3/files/${encodeURIComponent(presentationId)}/export`, this.driveBase);
873
+ url.searchParams.set("mimeType", mime);
874
+ const bytes = await this.downloadWithAuth(url.toString(), `export ${format}`);
875
+ await writeFile(outputPath, bytes);
876
+ return { saved_to: resolvePath(outputPath), bytes: bytes.byteLength, mime_type: mime };
877
+ }
878
+ // ---- Drive helpers (image upload / disposable-resource cleanup) ----
879
+ /**
880
+ * Uploads a local image to Drive (multipart) and shares it read-only by
881
+ * link so the Slides API can fetch it. The caller is expected to delete the
882
+ * file afterwards — {@link withImageUrl} does exactly that.
883
+ */
884
+ async uploadLocalImage(filePath) {
885
+ const data = await readFile(filePath);
886
+ if (data.byteLength > MAX_IMAGE_BYTES) {
887
+ throw new Error(`Image is too large (${data.byteLength} bytes) — the Slides API caps images at 50 MB.`);
888
+ }
889
+ const sniffed = sniffImageType(data);
890
+ if (!sniffed) {
891
+ throw new Error("Unsupported image content — only PNG, JPEG and GIF are accepted (checked by magic bytes).");
892
+ }
893
+ const boundary = `mcp_slides_${randomUUID().replaceAll("-", "")}`;
894
+ const metadata = JSON.stringify({
895
+ name: `mcp-google-slides-upload-${basename(filePath)}`,
896
+ mimeType: sniffed.mimeType,
897
+ });
898
+ const body = Buffer.concat([
899
+ Buffer.from(`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n` +
900
+ `--${boundary}\r\nContent-Type: ${sniffed.mimeType}\r\n\r\n`),
901
+ data,
902
+ Buffer.from(`\r\n--${boundary}--\r\n`),
903
+ ]);
904
+ const uploadUrl = new URL("upload/drive/v3/files", this.driveBase);
905
+ uploadUrl.searchParams.set("uploadType", "multipart");
906
+ uploadUrl.searchParams.set("fields", "id");
907
+ // Single-shot write with one forced re-mint on 401 — an upload is a write
908
+ // and must never be replayed after an ambiguous failure.
909
+ let uploadedId;
910
+ for (let attempt = 0;; attempt++) {
911
+ const token = await this.accessToken(attempt > 0);
912
+ const { res, text } = await this.fetchWithTimeout(uploadUrl.toString(), {
913
+ method: "POST",
914
+ headers: {
915
+ Authorization: `Bearer ${token}`,
916
+ "Content-Type": `multipart/related; boundary=${boundary}`,
917
+ },
918
+ body,
919
+ }, "drive image upload");
920
+ if (res.status === 401 && this.canRefresh() && attempt === 0)
921
+ continue;
922
+ let parsed;
923
+ try {
924
+ parsed = JSON.parse(text);
925
+ }
926
+ catch {
927
+ parsed = text;
928
+ }
929
+ if (!res.ok)
930
+ throw new GoogleSlidesError(res.status, parsed);
931
+ uploadedId = parsed.id;
932
+ break;
933
+ }
934
+ if (!uploadedId)
935
+ throw new Error("Drive upload returned no file id.");
936
+ // Anyone-with-the-link reader — the Slides fetcher is not the user.
937
+ await this.driveRequest("POST", `drive/v3/files/${encodeURIComponent(uploadedId)}/permissions`, {
938
+ role: "reader",
939
+ type: "anyone",
940
+ });
941
+ return { fileId: uploadedId, url: `https://drive.google.com/uc?id=${uploadedId}` };
942
+ }
943
+ /** Deletes a Drive file this server created (temp images, smoke decks). */
944
+ async deleteDriveFile(fileId) {
945
+ await this.driveRequest("DELETE", `drive/v3/files/${encodeURIComponent(fileId)}`);
946
+ }
947
+ async tryDeleteDriveFile(fileId) {
948
+ try {
949
+ await this.deleteDriveFile(fileId);
950
+ return true;
951
+ }
952
+ catch {
953
+ return false;
954
+ }
955
+ }
956
+ }
957
+ // ---- Summaries (pure, exported for tests) ----
958
+ /** EMU → points (1 pt = 12700 EMU). */
959
+ function emuToPt(value) {
960
+ const magnitude = value?.magnitude;
961
+ const unit = value?.unit;
962
+ if (typeof magnitude !== "number")
963
+ return undefined;
964
+ return unit === "EMU" ? Math.round((magnitude / 12700) * 100) / 100 : magnitude;
965
+ }
966
+ /** One page element, compacted to id + type + visible text. */
967
+ function summarizeElement(el) {
968
+ const out = { objectId: el.objectId };
969
+ if (el.shape) {
970
+ const shape = el.shape;
971
+ out.type = (shape.shapeType ?? "SHAPE").toLowerCase();
972
+ if (shape.placeholder?.type)
973
+ out.placeholder = shape.placeholder.type.toLowerCase();
974
+ const text = extractText(shape.text);
975
+ if (text)
976
+ out.text = text.length > 160 ? `${text.slice(0, 160)}…` : text;
977
+ }
978
+ else if (el.table) {
979
+ const table = el.table;
980
+ out.type = "table";
981
+ out.rows = table.rows;
982
+ out.columns = table.columns;
983
+ }
984
+ else if (el.image) {
985
+ out.type = "image";
986
+ }
987
+ else if (el.video) {
988
+ out.type = "video";
989
+ }
990
+ else if (el.line) {
991
+ out.type = "line";
992
+ }
993
+ else if (el.elementGroup) {
994
+ out.type = "group";
995
+ out.children = (el.elementGroup.children ?? []).length;
996
+ }
997
+ else {
998
+ out.type = "unknown";
999
+ }
1000
+ return out;
1001
+ }
1002
+ /** Compacts a fields-masked presentation into the list_slides result. */
1003
+ export function summarizePresentation(data) {
1004
+ const slides = (data.slides ?? []);
1005
+ const pageSize = data.pageSize;
1006
+ return compact({
1007
+ presentationId: data.presentationId,
1008
+ title: data.title,
1009
+ revisionId: data.revisionId,
1010
+ pageSizePt: pageSize
1011
+ ? { width: emuToPt(pageSize.width), height: emuToPt(pageSize.height) }
1012
+ : undefined,
1013
+ slideCount: slides.length,
1014
+ slides: slides.map((slide, index) => {
1015
+ const props = (slide.slideProperties ?? {});
1016
+ return compact({
1017
+ index,
1018
+ objectId: slide.objectId,
1019
+ layoutObjectId: props.layoutObjectId,
1020
+ speakerNotesObjectId: props.notesPage?.notesProperties?.speakerNotesObjectId,
1021
+ elements: (slide.pageElements ?? []).map(summarizeElement),
1022
+ });
1023
+ }),
1024
+ });
1025
+ }
1026
+ /** Drops keys whose value is `undefined` so they are not sent to the API. */
1027
+ function compact(obj) {
1028
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
1029
+ }
1030
+ function delay(ms) {
1031
+ return new Promise((resolve) => setTimeout(resolve, ms));
1032
+ }