@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/config.js ADDED
@@ -0,0 +1,87 @@
1
+ /** Default Google Slides API host. */
2
+ export const DEFAULT_BASE = "https://slides.googleapis.com";
3
+ /** Default Google Drive API host (internal dependency: comments, export, image upload). */
4
+ export const DEFAULT_DRIVE_BASE = "https://www.googleapis.com";
5
+ /**
6
+ * A malformed environment variable. Thrown instead of exiting on the spot so
7
+ * index.ts can catch it, report the drop-off and start degraded instead of
8
+ * dying; `reason` is the machine-readable code that ships with that ping
9
+ * (never a variable's value).
10
+ */
11
+ export class ConfigError extends Error {
12
+ reason;
13
+ constructor(message, reason) {
14
+ super(message);
15
+ this.name = "ConfigError";
16
+ this.reason = reason;
17
+ }
18
+ }
19
+ /**
20
+ * What a tool call without credentials reads. The first sentence is the
21
+ * historical startup error, verbatim — the rest exists because credentials come
22
+ * only from the environment, so the fix is an operator action plus a restart,
23
+ * never a retry.
24
+ */
25
+ export const MISSING_CREDENTIALS_MESSAGE = "Google OAuth credentials are required: set GOOGLE_SLIDES_CLIENT_ID + " +
26
+ "GOOGLE_SLIDES_CLIENT_SECRET + GOOGLE_SLIDES_REFRESH_TOKEN (recommended), or " +
27
+ "GOOGLE_SLIDES_ACCESS_TOKEN with a short-lived access token. " +
28
+ "This is not a network failure and retrying will not help: the operator must set these " +
29
+ "environment variables in the MCP client's server config and restart the server — they are " +
30
+ "read only at startup.";
31
+ /**
32
+ * Raised when a tool call needs credentials and none were configured. The
33
+ * message is the whole point of the class: it is the only text the calling
34
+ * model reads about the missing setup, so it names the fix (which variables,
35
+ * and that a restart is needed) instead of the failure.
36
+ */
37
+ export class CredentialsError extends Error {
38
+ constructor(message = MISSING_CREDENTIALS_MESSAGE) {
39
+ super(message);
40
+ this.name = "CredentialsError";
41
+ }
42
+ }
43
+ /** True when the config carries usable credentials (the full refresh triple or a static token). */
44
+ export function hasCredentials(config) {
45
+ return Boolean(config.accessToken || (config.clientId && config.clientSecret && config.refreshToken));
46
+ }
47
+ /**
48
+ * Builds the client config from environment variables.
49
+ *
50
+ * Missing credentials are NOT an error here: the server starts anyway and the
51
+ * client raises {@link CredentialsError} on the first tool call, so an
52
+ * unconfigured install completes the MCP handshake and carries the fix into
53
+ * the session instead of dying before it with nothing to read. A malformed
54
+ * setup — the refresh triple set only partially — still throws, because
55
+ * guessing what the user meant is worse.
56
+ *
57
+ * GOOGLE_SLIDES_CLIENT_ID OAuth2 client id (refresh flow, recommended)
58
+ * GOOGLE_SLIDES_CLIENT_SECRET OAuth2 client secret (refresh flow)
59
+ * GOOGLE_SLIDES_REFRESH_TOKEN OAuth2 refresh token (refresh flow)
60
+ * GOOGLE_SLIDES_ACCESS_TOKEN static access token (alternative; expires in ~1h)
61
+ * GOOGLE_SLIDES_API_BASE Slides API root override (default https://slides.googleapis.com)
62
+ * GOOGLE_SLIDES_DRIVE_API_BASE Drive API root override (default https://www.googleapis.com)
63
+ * GOOGLE_SLIDES_TIMEOUT_MS per-request timeout (default 60000)
64
+ * GOOGLE_SLIDES_MAX_RETRIES retries on transient errors (default 3)
65
+ */
66
+ export function loadConfig() {
67
+ const clientId = process.env.GOOGLE_SLIDES_CLIENT_ID;
68
+ const clientSecret = process.env.GOOGLE_SLIDES_CLIENT_SECRET;
69
+ const refreshToken = process.env.GOOGLE_SLIDES_REFRESH_TOKEN;
70
+ const accessToken = process.env.GOOGLE_SLIDES_ACCESS_TOKEN;
71
+ const oauthProvided = [clientId, clientSecret, refreshToken].filter(Boolean).length;
72
+ if (oauthProvided > 0 && oauthProvided < 3) {
73
+ throw new ConfigError("GOOGLE_SLIDES_CLIENT_ID, GOOGLE_SLIDES_CLIENT_SECRET and GOOGLE_SLIDES_REFRESH_TOKEN must be set together (OAuth2 refresh flow).", "incomplete_oauth_config");
74
+ }
75
+ const timeoutMs = Number(process.env.GOOGLE_SLIDES_TIMEOUT_MS);
76
+ const maxRetries = Number(process.env.GOOGLE_SLIDES_MAX_RETRIES);
77
+ return {
78
+ clientId,
79
+ clientSecret,
80
+ refreshToken,
81
+ accessToken,
82
+ apiBase: process.env.GOOGLE_SLIDES_API_BASE || DEFAULT_BASE,
83
+ driveApiBase: process.env.GOOGLE_SLIDES_DRIVE_API_BASE || DEFAULT_DRIVE_BASE,
84
+ timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 60_000,
85
+ maxRetries: Number.isFinite(maxRetries) && maxRetries >= 0 ? maxRetries : 3,
86
+ };
87
+ }
package/dist/index.js ADDED
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { GoogleSlidesClient } from "./client.js";
6
+ import { ConfigError, DEFAULT_BASE, DEFAULT_DRIVE_BASE, hasCredentials, loadConfig } from "./config.js";
7
+ import { instrumentToolCalls, Telemetry } from "./telemetry.js";
8
+ import { registerPresentationTools } from "./tools/presentations.js";
9
+ import { registerSlideTools } from "./tools/slides.js";
10
+ import { registerTextTools } from "./tools/text.js";
11
+ import { registerElementTools } from "./tools/elements.js";
12
+ import { registerTableTools } from "./tools/tables.js";
13
+ import { registerNoteTools } from "./tools/notes.js";
14
+ import { registerCommentTools } from "./tools/comments.js";
15
+ import { registerExportTools } from "./tools/export.js";
16
+ import { registerRawTool } from "./tools/raw.js";
17
+ /**
18
+ * Prose handed to the calling model in the `initialize` result — the only place
19
+ * it learns what the tool list cannot say: which Google product this API is,
20
+ * what the API refuses to do, and the behaviours that make a naive loop
21
+ * expensive, lossy or duplicating.
22
+ */
23
+ const INSTRUCTIONS = "Google Slides API v1 builds and edits presentations — not Docs, Sheets or Drive browsing: " +
24
+ "comments and export ride the Drive API internally for this one file; sharing, renaming, listing " +
25
+ "and deleting files are out of reach (create_presentation cannot be undone here). Everything is " +
26
+ "addressed by object ids — start with list_slides, not get_presentation (a full deck is hundreds " +
27
+ "of kilobytes). All writes are batchUpdate and atomic: one invalid request voids the whole batch, " +
28
+ "nothing is applied. Coordinates are points (a 16:9 slide is 720x405 pt); JSON responses use EMU " +
29
+ "(1 pt = 12700 EMU). update_transform mode=absolute replaces the entire transform — omitted scale " +
30
+ "resets to 1, not to the current value. create_image needs a URL Google's servers can fetch " +
31
+ "(public https, PNG/JPEG/GIF ≤50 MB, ≤25 MP); a local image_path is uploaded to Drive, inserted " +
32
+ "and the temp file deleted automatically. Thumbnails return a ~30-minute contentUrl; export is " +
33
+ "capped at 10 MB; both plus comments need a Drive scope on the token. Themes cannot be imported — " +
34
+ "add_slide only instantiates layouts the deck already has (list_layouts). replace_text returning " +
35
+ "occurrencesChanged:0 means the wording didn't match — re-check with list_slides. Writes are never " +
36
+ "retried after a 5xx or timeout: verify with list_slides before re-sending; delete_object is final.";
37
+ /**
38
+ * Prepended to INSTRUCTIONS when no credentials are configured. The model reads
39
+ * this before it picks a tool, so an unconfigured session opens with the fix
40
+ * rather than with a failed call. There is no in-chat login here: credentials
41
+ * come only from the environment, so the fix is an operator action + restart.
42
+ */
43
+ const UNCONFIGURED_PREFIX = "ATTENTION: Google Slides is not connected yet — no credentials are configured, so every " +
44
+ "tool call will fail. The operator must set GOOGLE_SLIDES_CLIENT_ID + " +
45
+ "GOOGLE_SLIDES_CLIENT_SECRET + GOOGLE_SLIDES_REFRESH_TOKEN (recommended), or " +
46
+ "GOOGLE_SLIDES_ACCESS_TOKEN with a short-lived access token, in the MCP client's " +
47
+ "server config and restart this server — the variables are read only at startup. ";
48
+ /** Reads the package version so the server reports its real version to MCP clients. */
49
+ function readVersion() {
50
+ try {
51
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
52
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
53
+ }
54
+ catch {
55
+ return "0.0.0";
56
+ }
57
+ }
58
+ /**
59
+ * Loads the config without dying on a bad value. A server that exits here never
60
+ * completes the MCP handshake, so the user sees a dead server and no reason.
61
+ * Instead the problem is carried into the session, where the model can read it
62
+ * and relay it: the config degrades to "no credentials" and every tool call
63
+ * fails with the actionable message.
64
+ */
65
+ function loadConfigOrDegraded(telemetry) {
66
+ try {
67
+ return { config: loadConfig() };
68
+ }
69
+ catch (err) {
70
+ if (!(err instanceof ConfigError))
71
+ throw err;
72
+ console.error(`Error: ${err.message}`);
73
+ // Fire-and-forget now that the process survives: the historical
74
+ // `startup_failed` funnel stays comparable, but nothing blocks startup.
75
+ telemetry.send("startup_failed", { reason: err.reason });
76
+ return {
77
+ config: {
78
+ apiBase: process.env.GOOGLE_SLIDES_API_BASE || DEFAULT_BASE,
79
+ driveApiBase: process.env.GOOGLE_SLIDES_DRIVE_API_BASE || DEFAULT_DRIVE_BASE,
80
+ },
81
+ problem: err,
82
+ };
83
+ }
84
+ }
85
+ async function main() {
86
+ // Anonymous usage pings (ids/names/versions only, never data or arguments);
87
+ // opt out with ASKADS_TELEMETRY=0. Built before the config so missing
88
+ // credentials can be reported; wired to the server before tools register.
89
+ const telemetry = new Telemetry(readVersion());
90
+ const { config, problem } = loadConfigOrDegraded(telemetry);
91
+ const client = new GoogleSlidesClient(config);
92
+ // Decided once, at startup: credentials come only from the environment, so
93
+ // "restart after setting the variables" is the accurate advice to give.
94
+ const connected = hasCredentials(config);
95
+ const server = new McpServer({
96
+ name: "mcp-google-slides",
97
+ version: readVersion(),
98
+ },
99
+ // Surfaces in the initialize result, before the client sees a single tool.
100
+ {
101
+ instructions: connected
102
+ ? INSTRUCTIONS
103
+ : UNCONFIGURED_PREFIX + (problem ? `Configuration problem: ${problem.message} ` : "") + INSTRUCTIONS,
104
+ });
105
+ instrumentToolCalls(server, telemetry);
106
+ server.server.oninitialized = () => {
107
+ telemetry.setClientInfo(server.server.getClientVersion());
108
+ // Split on purpose: `server_start` keeps meaning "a usable install started",
109
+ // so the unconfigured case gets its own event instead of inflating that number.
110
+ if (connected)
111
+ telemetry.send("server_start");
112
+ else
113
+ telemetry.send("unconfigured_start", { reason: problem?.reason ?? "missing_credentials" });
114
+ };
115
+ registerPresentationTools(server, client);
116
+ registerSlideTools(server, client);
117
+ registerTextTools(server, client);
118
+ registerElementTools(server, client);
119
+ registerTableTools(server, client);
120
+ registerNoteTools(server, client);
121
+ registerCommentTools(server, client);
122
+ registerExportTools(server, client);
123
+ registerRawTool(server, client);
124
+ const transport = new StdioServerTransport();
125
+ await server.connect(transport);
126
+ console.error(`mcp-google-slides running on stdio${connected ? "" : " (no credentials — set the environment variables and restart)"}`);
127
+ }
128
+ main().catch((err) => {
129
+ console.error("Fatal error starting mcp-google-slides:", err);
130
+ process.exit(1);
131
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Anonymous usage telemetry — fire-and-forget pings to usage.gistrec.cloud.
3
+ *
4
+ * Privacy contract (mirrored by the receiver): only an anonymous random
5
+ * instance id, event/tool names, a fixed-vocabulary failure reason and
6
+ * environment versions ever leave the machine. The OAuth credentials,
7
+ * presentation content, tool arguments, prompts and the names or values of
8
+ * environment variables are never read, serialized or sent. Sends are capped
9
+ * at 2 s and swallow every error — telemetry must never affect the server's
10
+ * operation. Opt out for every server of this line at once: ASKADS_TELEMETRY=0
11
+ * (also accepts false/off/no).
12
+ */
13
+ import { randomUUID } from "node:crypto";
14
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ const ENDPOINT = "https://usage.gistrec.cloud/v1/events";
18
+ const APP = "mcp-google-slides";
19
+ const SEND_TIMEOUT_MS = 2000;
20
+ export function telemetryEnabled(env = process.env) {
21
+ const value = (env.ASKADS_TELEMETRY ?? "").trim().toLowerCase();
22
+ return !["0", "false", "off", "no"].includes(value);
23
+ }
24
+ /**
25
+ * Stable anonymous installation id, persisted under the user's config dir.
26
+ * Any filesystem failure degrades to an ephemeral id: the ping still counts
27
+ * today's activity, just not installation uniqueness across restarts.
28
+ */
29
+ function loadInstanceId() {
30
+ try {
31
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
32
+ const dir = join(base, APP);
33
+ const file = join(dir, "instance-id");
34
+ try {
35
+ const existing = readFileSync(file, "utf8").trim();
36
+ if (/^[0-9a-f]{8}-[0-9a-f-]{27}$/.test(existing))
37
+ return existing;
38
+ }
39
+ catch {
40
+ // first run — fall through and mint one
41
+ }
42
+ const id = randomUUID();
43
+ mkdirSync(dir, { recursive: true });
44
+ writeFileSync(file, `${id}\n`);
45
+ return id;
46
+ }
47
+ catch {
48
+ return randomUUID();
49
+ }
50
+ }
51
+ export class Telemetry {
52
+ appVersion;
53
+ enabled;
54
+ fetchImpl;
55
+ instanceId;
56
+ clientInfo;
57
+ constructor(appVersion, enabled = telemetryEnabled(), fetchImpl = fetch) {
58
+ this.appVersion = appVersion;
59
+ this.enabled = enabled;
60
+ this.fetchImpl = fetchImpl;
61
+ this.instanceId = enabled ? loadInstanceId() : "";
62
+ }
63
+ /** The MCP initialize handshake tells us which agent host is on the other side. */
64
+ setClientInfo(info) {
65
+ this.clientInfo = info;
66
+ }
67
+ /** Fire-and-forget: the server's own work never waits on a ping. */
68
+ send(event, fields = {}) {
69
+ void this.post(event, fields);
70
+ }
71
+ /**
72
+ * The same ping, awaited. Only for a caller that exits right after — a
73
+ * fire-and-forget request would die in flight with the process. No startup
74
+ * path needs it anymore (a config problem degrades instead of exiting).
75
+ */
76
+ async sendBlocking(event, fields = {}) {
77
+ await this.post(event, fields);
78
+ }
79
+ async post(event, fields) {
80
+ if (!this.enabled)
81
+ return;
82
+ const body = JSON.stringify({
83
+ app: APP,
84
+ event,
85
+ instance_id: this.instanceId,
86
+ app_version: this.appVersion,
87
+ client_name: this.clientInfo?.name,
88
+ client_version: this.clientInfo?.version,
89
+ tool: fields.tool,
90
+ reason: fields.reason,
91
+ node_version: process.version,
92
+ os: process.platform,
93
+ });
94
+ try {
95
+ await this.fetchImpl(ENDPOINT, {
96
+ method: "POST",
97
+ headers: { "Content-Type": "application/json" },
98
+ body,
99
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
100
+ });
101
+ }
102
+ catch {
103
+ // network failure, timeout abort or a synchronous throw — all silent
104
+ }
105
+ }
106
+ }
107
+ /**
108
+ * Count every tool invocation by name. Wraps registerTool once, before the
109
+ * domain modules register their tools, so no per-tool wiring is needed.
110
+ * Only the tool *name* is recorded — arguments never reach telemetry.
111
+ */
112
+ export function instrumentToolCalls(server, telemetry) {
113
+ const original = server.registerTool.bind(server);
114
+ // registerTool's generic signature does not survive a wrapper; the runtime
115
+ // shape (name, config, callback) is stable per SDK contract.
116
+ server.registerTool = ((name, config, callback) => original(name, config, ((...args) => {
117
+ telemetry.send("tool_call", { tool: name });
118
+ return callback(...args);
119
+ })));
120
+ }
@@ -0,0 +1,39 @@
1
+ import { z } from "zod";
2
+ import { DESTRUCTIVE, fail, ok, presentationIdSchema } from "./util.js";
3
+ export function registerCommentTools(server, client) {
4
+ server.registerTool("manage_comments", {
5
+ title: "Manage comments",
6
+ // One tool covers list/get/create/reply/delete; delete removes state, so
7
+ // the whole tool carries the destructive, non-idempotent hints.
8
+ annotations: DESTRUCTIVE,
9
+ description: "Comment threads on the presentation file (served by the Drive API internally — scoped to this one file, no generic Drive access). action=list returns comments with replies and resolved status (pageSize/page_token to paginate, include_deleted for tombstones); get needs comment_id; create needs content and starts a new unanchored thread (the API cannot anchor a new comment to a specific slide or shape); reply needs comment_id + content and/or resolve=true (resolve closes the thread); delete needs comment_id and permanently removes the thread with all replies. Requires a Drive scope on the OAuth token (https://www.googleapis.com/auth/drive) — a presentations-only token gets a 403 here.",
10
+ inputSchema: {
11
+ presentation_id: presentationIdSchema(),
12
+ action: z
13
+ .enum(["list", "get", "create", "reply", "delete"])
14
+ .describe("What to do with the presentation's comments."),
15
+ comment_id: z.string().min(1).optional().describe("get/reply/delete: the comment id from list."),
16
+ content: z.string().min(1).optional().describe("create/reply: the comment text (plain text)."),
17
+ resolve: z.boolean().optional().describe("reply only: also mark the whole thread resolved."),
18
+ page_size: z.number().int().min(1).max(100).optional().describe("list: comments per page (1-100)."),
19
+ page_token: z.string().optional().describe("list: nextPageToken from the previous page."),
20
+ include_deleted: z.boolean().optional().describe("list: include deleted comments as tombstones."),
21
+ },
22
+ }, async ({ presentation_id, action, comment_id, content, resolve, page_size, page_token, include_deleted }) => {
23
+ try {
24
+ return ok(await client.manageComments({
25
+ presentationId: presentation_id,
26
+ action,
27
+ commentId: comment_id,
28
+ content,
29
+ resolve,
30
+ pageSize: page_size,
31
+ pageToken: page_token,
32
+ includeDeleted: include_deleted,
33
+ }));
34
+ }
35
+ catch (e) {
36
+ return fail(e);
37
+ }
38
+ });
39
+ }
@@ -0,0 +1,164 @@
1
+ import { z } from "zod";
2
+ import { fail, newObjectIdSchema, objectIdSchema, ok, pointsSchema, presentationIdSchema, UPDATE, WRITE, } from "./util.js";
3
+ export function registerElementTools(server, client) {
4
+ server.registerTool("create_shape", {
5
+ title: "Create a shape",
6
+ annotations: WRITE,
7
+ description: "Creates a shape on a slide (batchUpdate createShape), optionally with its initial text in the same atomic batch. Types: text_box, rectangle, round_rectangle, ellipse, diamond, triangle, right_arrow, left_arrow, up_arrow, down_arrow, star, heart, cloud, callout — the API's other ~130 ShapeTypes go through batch_update. Position/size are in points ((0,0) is top-left; a default 16:9 slide is 720x405 pt); omitted geometry defaults to 300x80 pt at (50,50). Returns the shape's objectId in the reply. Fill/outline/shadow are not parameters here — style via batch_update updateShapeProperties; text styling via update_text_style.",
8
+ inputSchema: {
9
+ presentation_id: presentationIdSchema(),
10
+ page_object_id: objectIdSchema("The slide's object id to place the shape on (from list_slides)."),
11
+ shape_type: z
12
+ .enum([
13
+ "text_box",
14
+ "rectangle",
15
+ "round_rectangle",
16
+ "ellipse",
17
+ "diamond",
18
+ "triangle",
19
+ "right_arrow",
20
+ "left_arrow",
21
+ "up_arrow",
22
+ "down_arrow",
23
+ "star",
24
+ "heart",
25
+ "cloud",
26
+ "callout",
27
+ ])
28
+ .describe("The shape type."),
29
+ text: z.string().optional().describe("Initial text, inserted in the same atomic batch."),
30
+ x_pt: pointsSchema().optional().describe("Left edge in points (requires y_pt; default 50)."),
31
+ y_pt: pointsSchema().optional().describe("Top edge in points (requires x_pt; default 50)."),
32
+ width_pt: pointsSchema().optional().describe("Width in points (requires height_pt; default 300)."),
33
+ height_pt: pointsSchema().optional().describe("Height in points (requires width_pt; default 80)."),
34
+ object_id: newObjectIdSchema()
35
+ .optional()
36
+ .describe("Optional custom object id (5-50 chars of [a-zA-Z0-9_-:]); auto-generated when omitted."),
37
+ },
38
+ }, async (args) => {
39
+ try {
40
+ return ok(await client.createShape({
41
+ presentationId: args.presentation_id,
42
+ pageObjectId: args.page_object_id,
43
+ shapeType: args.shape_type,
44
+ text: args.text,
45
+ xPt: args.x_pt,
46
+ yPt: args.y_pt,
47
+ widthPt: args.width_pt,
48
+ heightPt: args.height_pt,
49
+ objectId: args.object_id,
50
+ }));
51
+ }
52
+ catch (e) {
53
+ return fail(e);
54
+ }
55
+ });
56
+ server.registerTool("create_image", {
57
+ title: "Insert an image",
58
+ annotations: WRITE,
59
+ description: "Inserts an image on a slide (batchUpdate createImage) from EITHER image_url — a public https URL Google's servers can fetch (validated: https-only, no private/local hosts, ≤2 kB; the image itself must be PNG/JPEG/GIF, ≤50 MB, ≤25 megapixels) — OR image_path, a local file that is checked by magic bytes, uploaded to Drive, shared by link for the fetch and DELETED from Drive right after (Slides stores its own copy, so the temp file is disposable; the result reports the cleanup). Omitted size keeps the image's native proportions fitted to the page; position/size are in points. Returns the image objectId in the reply.",
60
+ inputSchema: {
61
+ presentation_id: presentationIdSchema(),
62
+ page_object_id: objectIdSchema("The slide's object id to place the image on (from list_slides)."),
63
+ image_url: z
64
+ .string()
65
+ .url()
66
+ .optional()
67
+ .describe("Public https URL of a PNG/JPEG/GIF (exactly one of image_url / image_path)."),
68
+ image_path: z
69
+ .string()
70
+ .min(1)
71
+ .optional()
72
+ .describe("Absolute local path of a PNG/JPEG/GIF file (exactly one of image_url / image_path)."),
73
+ x_pt: pointsSchema().optional().describe("Left edge in points (requires y_pt)."),
74
+ y_pt: pointsSchema().optional().describe("Top edge in points (requires x_pt)."),
75
+ width_pt: pointsSchema().optional().describe("Width in points (requires height_pt)."),
76
+ height_pt: pointsSchema().optional().describe("Height in points (requires width_pt)."),
77
+ object_id: newObjectIdSchema().optional().describe("Optional custom object id for the new image."),
78
+ },
79
+ }, async (args) => {
80
+ try {
81
+ return ok(await client.createImage({
82
+ presentationId: args.presentation_id,
83
+ pageObjectId: args.page_object_id,
84
+ imageUrl: args.image_url,
85
+ imagePath: args.image_path,
86
+ xPt: args.x_pt,
87
+ yPt: args.y_pt,
88
+ widthPt: args.width_pt,
89
+ heightPt: args.height_pt,
90
+ objectId: args.object_id,
91
+ }));
92
+ }
93
+ catch (e) {
94
+ return fail(e);
95
+ }
96
+ });
97
+ server.registerTool("replace_image", {
98
+ title: "Replace an image",
99
+ annotations: UPDATE,
100
+ description: "Swaps the bitmap of an existing image element while keeping its frame, position and effects (batchUpdate replaceImage). The new picture comes from image_url or image_path with exactly the same validation/upload/cleanup rules as create_image. replace_method: center_inside (default — scales to fit inside the frame, keeping aspect) or center_crop (fills the frame, cropping the overflow).",
101
+ inputSchema: {
102
+ presentation_id: presentationIdSchema(),
103
+ image_object_id: objectIdSchema("The existing image element's object id (type \"image\" in list_slides)."),
104
+ image_url: z
105
+ .string()
106
+ .url()
107
+ .optional()
108
+ .describe("Public https URL of the new PNG/JPEG/GIF (exactly one of image_url / image_path)."),
109
+ image_path: z
110
+ .string()
111
+ .min(1)
112
+ .optional()
113
+ .describe("Absolute local path of the new image (exactly one of image_url / image_path)."),
114
+ replace_method: z
115
+ .enum(["center_inside", "center_crop"])
116
+ .optional()
117
+ .describe("How the new image fills the old frame (default center_inside)."),
118
+ },
119
+ }, async ({ presentation_id, image_object_id, image_url, image_path, replace_method }) => {
120
+ try {
121
+ return ok(await client.replaceImage({
122
+ presentationId: presentation_id,
123
+ imageObjectId: image_object_id,
124
+ imageUrl: image_url,
125
+ imagePath: image_path,
126
+ replaceMethod: replace_method,
127
+ }));
128
+ }
129
+ catch (e) {
130
+ return fail(e);
131
+ }
132
+ });
133
+ server.registerTool("update_transform", {
134
+ title: "Move / scale an element",
135
+ annotations: UPDATE,
136
+ description: "Moves and/or scales a page element (batchUpdate updatePageElementTransform). mode=absolute REPLACES the element's whole transform: translate_x_pt/translate_y_pt become the new top-left-ish origin and omitted scales default to 1 — NOT to the current values, so an absolute move of a scaled element also resets its scale unless you resend it. mode=relative multiplies onto the existing transform: translate shifts by the given points, scale 2 doubles the current size (scaling happens around the origin, which also shifts the element — for a simple move prefer relative translate only). Element geometry to reason from is in get_page.",
137
+ inputSchema: {
138
+ presentation_id: presentationIdSchema(),
139
+ object_id: objectIdSchema("The page element's object id (from list_slides)."),
140
+ mode: z
141
+ .enum(["absolute", "relative"])
142
+ .describe("absolute = replace the whole transform; relative = multiply onto the current one."),
143
+ translate_x_pt: pointsSchema().optional().describe("Horizontal translation in points (default 0)."),
144
+ translate_y_pt: pointsSchema().optional().describe("Vertical translation in points (default 0)."),
145
+ scale_x: z.number().finite().optional().describe("Horizontal scale factor (default 1)."),
146
+ scale_y: z.number().finite().optional().describe("Vertical scale factor (default 1)."),
147
+ },
148
+ }, async ({ presentation_id, object_id, mode, translate_x_pt, translate_y_pt, scale_x, scale_y }) => {
149
+ try {
150
+ return ok(await client.updateTransform({
151
+ presentationId: presentation_id,
152
+ objectId: object_id,
153
+ mode,
154
+ translateXPt: translate_x_pt,
155
+ translateYPt: translate_y_pt,
156
+ scaleX: scale_x,
157
+ scaleY: scale_y,
158
+ }));
159
+ }
160
+ catch (e) {
161
+ return fail(e);
162
+ }
163
+ });
164
+ }
@@ -0,0 +1,54 @@
1
+ import { z } from "zod";
2
+ import { fail, objectIdSchema, ok, presentationIdSchema, READ_ONLY } from "./util.js";
3
+ export function registerExportTools(server, client) {
4
+ server.registerTool("get_thumbnail", {
5
+ title: "Get a slide thumbnail",
6
+ annotations: READ_ONLY,
7
+ description: "Renders one page (slide, layout or master) as a PNG. Without output_path it returns contentUrl + width/height — the URL is short-lived (~30 minutes) and served from googleusercontent.com. With output_path the PNG is downloaded and saved to that local file instead (the download is restricted to Google's image hosts). size: large (1600px, default), medium (800px) or small (200px). This is the way to SEE what a slide actually looks like — the JSON never shows rendering. Counts as an expensive read in the API quota.",
8
+ inputSchema: {
9
+ presentation_id: presentationIdSchema(),
10
+ page_object_id: objectIdSchema("The slide's object id to render (from list_slides)."),
11
+ size: z
12
+ .enum(["large", "medium", "small"])
13
+ .optional()
14
+ .describe("Thumbnail width: large=1600px (default), medium=800px, small=200px."),
15
+ output_path: z
16
+ .string()
17
+ .min(1)
18
+ .optional()
19
+ .describe("Local file path to save the PNG to; omitted = return the short-lived contentUrl."),
20
+ },
21
+ }, async ({ presentation_id, page_object_id, size, output_path }) => {
22
+ try {
23
+ return ok(await client.getThumbnail({
24
+ presentationId: presentation_id,
25
+ pageObjectId: page_object_id,
26
+ size,
27
+ outputPath: output_path,
28
+ }));
29
+ }
30
+ catch (e) {
31
+ return fail(e);
32
+ }
33
+ });
34
+ server.registerTool("export_presentation", {
35
+ title: "Export the presentation",
36
+ annotations: READ_ONLY,
37
+ description: "Downloads the whole presentation as pdf, pptx, odp or txt (txt = all text incl. speaker notes) and saves it to output_path, overwriting an existing file. Served by the Drive export endpoint internally, which caps exports at 10 MB — a bigger deck fails with an export-size error (split it or use the Slides UI). Requires a Drive scope on the OAuth token (drive.readonly is enough; presentations-only gets a 403). Returns saved_to, bytes and mime_type; nothing in the presentation changes.",
38
+ inputSchema: {
39
+ presentation_id: presentationIdSchema(),
40
+ format: z.enum(["pdf", "pptx", "odp", "txt"]).describe("The export format."),
41
+ output_path: z
42
+ .string()
43
+ .min(1)
44
+ .describe("Local file path to write, e.g. /tmp/deck.pdf — existing files are overwritten."),
45
+ },
46
+ }, async ({ presentation_id, format, output_path }) => {
47
+ try {
48
+ return ok(await client.exportPresentation(presentation_id, format, output_path));
49
+ }
50
+ catch (e) {
51
+ return fail(e);
52
+ }
53
+ });
54
+ }
@@ -0,0 +1,37 @@
1
+ import { z } from "zod";
2
+ import { fail, objectIdSchema, ok, presentationIdSchema, READ_ONLY, UPDATE } from "./util.js";
3
+ export function registerNoteTools(server, client) {
4
+ server.registerTool("get_speaker_notes", {
5
+ title: "Get speaker notes",
6
+ annotations: READ_ONLY,
7
+ description: "Returns a slide's speaker notes as plain text, plus the speakerNotesObjectId (the text shape on the slide's hidden notes page). The lookup fetches the slide's notes page internally, so you only need the slide's object id from list_slides. An empty string means the slide has no notes yet. Notes text is also searched by replace_text.",
8
+ inputSchema: {
9
+ presentation_id: presentationIdSchema(),
10
+ slide_object_id: objectIdSchema("The slide's object id (from list_slides) — not the notes page id."),
11
+ },
12
+ }, async ({ presentation_id, slide_object_id }) => {
13
+ try {
14
+ return ok(await client.getSpeakerNotes(presentation_id, slide_object_id));
15
+ }
16
+ catch (e) {
17
+ return fail(e);
18
+ }
19
+ });
20
+ server.registerTool("set_speaker_notes", {
21
+ title: "Set speaker notes",
22
+ annotations: UPDATE,
23
+ description: "Replaces a slide's speaker notes with the given text (empty string clears them). The tool resolves the slide's speakerNotesObjectId internally and rewrites that shape atomically — you pass the slide's object id, not the notes shape id. Notes are visible in the presenter view and exported by format txt; use \\n for paragraphs.",
24
+ inputSchema: {
25
+ presentation_id: presentationIdSchema(),
26
+ slide_object_id: objectIdSchema("The slide's object id (from list_slides) — not the notes page id."),
27
+ text: z.string().describe("The new speaker notes; empty string clears them. \\n starts a new paragraph."),
28
+ },
29
+ }, async ({ presentation_id, slide_object_id, text }) => {
30
+ try {
31
+ return ok(await client.setSpeakerNotes(presentation_id, slide_object_id, text));
32
+ }
33
+ catch (e) {
34
+ return fail(e);
35
+ }
36
+ });
37
+ }