@a1-x-tech/mcp-google-slides 0.2.0 → 1.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/README.md CHANGED
@@ -274,7 +274,7 @@ Every variable is optional — with none of them the server connects [from the c
274
274
 
275
275
  ## Data, limits and background work
276
276
 
277
- - **Requests go to Google Slides.** The local server refreshes Google OAuth tokens and calls the Slides API; comments, export and local-image upload ride the Drive API, scoped to the one presentation file. Its anonymous telemetry contains an installation ID, package version, AI client and platform versions, and tool names — never OAuth tokens, presentation content, tool arguments or prompts. Set `ASKADS_TELEMETRY=0` to opt out.
277
+ - **Requests go to Google Slides.** The local server refreshes Google OAuth tokens and calls the Slides API; comments, export and local-image upload ride the Drive API, scoped to the one presentation file. Its anonymous telemetry contains an installation ID, package version, AI client and platform versions, and tool names — never OAuth tokens, presentation content, tool arguments or prompts. For each tool call it also sends the outcome and, on failure, an excerpt of the error text with e-mails, addresses, tokens, numbers of 4+ digits and quoted text replaced by `#`; a value from your request can appear in it if the error text contains it. Set `ASKADS_TELEMETRY=0` to opt out.
278
278
  - **Google applies rate limits.** On `429`, the server uses backoff; reads also retry after network and `5xx` errors, while writes are not replayed after an uncertain failure. Thumbnails count against a more expensive read quota.
279
279
  - **There is no background polling.** The server runs only when called. Nothing watches a deck between calls; if your AI app supports scheduled tasks, it can check a presentation periodically.
280
280
 
package/README.ru.md CHANGED
@@ -274,7 +274,7 @@ Refresh token OAuth-приложения в режиме Testing может ис
274
274
 
275
275
  ## Данные, лимиты и работа в фоне
276
276
 
277
- - **Запросы идут в Google Slides.** Локальный сервер обновляет OAuth-токены Google и вызывает Slides API; комментарии, экспорт и загрузка локальных изображений проходят через Drive API строго для одного файла презентации. Анонимная телеметрия содержит ID установки, версию пакета, версии AI-клиента и платформы и имена инструментов — но не OAuth-токены, содержимое презентаций, аргументы или промпты. Чтобы отключить её, задайте `ASKADS_TELEMETRY=0`.
277
+ - **Запросы идут в Google Slides.** Локальный сервер обновляет OAuth-токены Google и вызывает Slides API; комментарии, экспорт и загрузка локальных изображений проходят через Drive API строго для одного файла презентации. Анонимная телеметрия содержит ID установки, версию пакета, версии AI-клиента и платформы и имена инструментов — но не OAuth-токены, содержимое презентаций, аргументы или промпты. При вызове инструмента сервер также отправляет его исход, а при ошибке - фрагмент её текста: код и сообщение, в которых e-mail, адреса, токены, числа от 4 цифр и текст в кавычках заменены на «#». Если в тексте ошибки есть значение из запроса, оно тоже может попасть во фрагмент. Чтобы отключить её, задайте `ASKADS_TELEMETRY=0`.
278
278
  - **У Google есть лимиты частоты.** При `429` сервер использует задержку; чтение также повторяется после сетевых и `5xx` ошибок, а запись после неопределённой ошибки не повторяется. Миниатюры расходуют более дорогую квоту чтения.
279
279
  - **Постоянного опроса нет.** Сервер работает только при вызове. Между вызовами за презентацией никто не следит; если AI-приложение поддерживает задания по расписанию, оно может периодически её проверять.
280
280
 
package/dist/telemetry.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Anonymous usage telemetry — fire-and-forget pings to usage.gistrec.cloud.
3
3
  *
4
4
  * Privacy contract (mirrored by the receiver): only an anonymous random
5
- * instance id, event/tool names, a fixed-vocabulary failure reason and
5
+ * instance id, event/tool names, a fixed-vocabulary failure reason or tool outcome and
6
6
  * environment versions ever leave the machine. The OAuth credentials,
7
7
  * presentation content, tool arguments, prompts and the names or values of
8
8
  * environment variables are never read, serialized or sent. Sends are capped
@@ -64,6 +64,10 @@ export class Telemetry {
64
64
  setClientInfo(info) {
65
65
  this.clientInfo = info;
66
66
  }
67
+ /** Whether pings go out at all (ASKADS_TELEMETRY opt-out). */
68
+ isEnabled() {
69
+ return this.enabled;
70
+ }
67
71
  /** Fire-and-forget: the server's own work never waits on a ping. */
68
72
  send(event, fields = {}) {
69
73
  void this.post(event, fields);
@@ -87,7 +91,8 @@ export class Telemetry {
87
91
  client_name: this.clientInfo?.name,
88
92
  client_version: this.clientInfo?.version,
89
93
  tool: fields.tool,
90
- reason: fields.reason,
94
+ reason: wireReason(event, fields.reason),
95
+ error_detail: event === "tool_call" ? fields.error_detail : undefined,
91
96
  node_version: process.version,
92
97
  os: process.platform,
93
98
  });
@@ -104,17 +109,221 @@ export class Telemetry {
104
109
  }
105
110
  }
106
111
  }
112
+ /** How a tool call ended; only these constants leave the machine. */
113
+ export const TOOL_OUTCOMES = [
114
+ "ok",
115
+ "error_auth",
116
+ "error_permission",
117
+ "error_not_found",
118
+ "error_rate_limit",
119
+ "error_invalid_request",
120
+ "error_upstream",
121
+ "error_timeout",
122
+ "error_network",
123
+ "error_internal",
124
+ "error_other",
125
+ ];
126
+ /** Drops a tool_call reason that is not in TOOL_OUTCOMES. */
127
+ function wireReason(event, reason) {
128
+ if (event !== "tool_call" || reason === undefined)
129
+ return reason;
130
+ return TOOL_OUTCOMES.includes(reason) ? reason : undefined;
131
+ }
132
+ // First match wins. Direct answers "no access to this client" with auth code 53, so that
133
+ // permission line goes before auth.
134
+ const OUTCOME_PATTERNS = [
135
+ ["error_timeout", /timed?[ _-]?out|таймаут|ETIMEDOUT|AbortError|не был готов|not ready/i],
136
+ ["error_network", /ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|EPIPE|fetch failed|socket hang up/i],
137
+ ["error_rate_limit", /\b429\b|rate[ _-]?limit|too many requests|quota|квот|лимит|\[(?:56|152|506|9000)\]/i],
138
+ ["error_permission", /нет доступа к кабинету|не подключ[её]н к аккаунту/i],
139
+ [
140
+ "error_auth",
141
+ /\b401\b|unauthori[sz]ed|unauthenticated|invalid[ _-]?(?:token|grant|credentials)|token (?:expired|revoked)|login required|AuthRequired|авториз|не подключ|\[53\]/i,
142
+ ],
143
+ ["error_permission", /\b403\b|forbidden|access[ _-]?denied|permission|insufficient[ _-]?scope|нет доступа|\[(?:54|3228)\]/i],
144
+ ["error_not_found", /\b404\b|not[ _-]?found|не найден|не существует|\[8800\]/i],
145
+ ["error_upstream", /\b5\d\d\b|\b204\b|unavailable|недоступ|internal server error|bad gateway|\[(?:52|1000|1001|1002)\]/i],
146
+ ["error_invalid_request", /\b400\b|\b422\b|bad request|invalid|syntax|некоррект|неверн|недопустим|required|обязател|expected|must (?:be|contain|have)|\[(?:4000|4001|4003|5000|8000)\]/i],
147
+ ];
148
+ // An API code at the head of the message wins over the text, which may quote field names
149
+ // ("ForbiddenPlatform" in a [8000] list of allowed values). 53 is absent: the text tells a
150
+ // dead token from no access to a client.
151
+ const CODE_OUTCOMES = new Map([
152
+ ...["56", "152", "506", "9000"].map((c) => [c, "error_rate_limit"]),
153
+ ...["54", "3228"].map((c) => [c, "error_permission"]),
154
+ ["8800", "error_not_found"],
155
+ ...["52", "1000", "1001", "1002"].map((c) => [c, "error_upstream"]),
156
+ ...["4000", "4001", "4003", "5000", "8000"].map((c) => [c, "error_invalid_request"]),
157
+ ]);
158
+ const LEADING_CODE = /^[^[\n]{0,60}\[(\d{1,5})\]/;
159
+ // Inputs are cut before any regex runs: a long argument echoed in an error must not stall the call.
160
+ const TEXT_MAX = 4000;
161
+ function resultText(value) {
162
+ if (value instanceof Error)
163
+ return `${value.name} ${value.message}`.slice(0, TEXT_MAX);
164
+ const content = value?.content;
165
+ if (!Array.isArray(content))
166
+ return "";
167
+ return content
168
+ .map((part) => (part && typeof part === "object" && typeof part.text === "string" ? part.text : ""))
169
+ .join("\n")
170
+ .slice(0, TEXT_MAX);
171
+ }
172
+ /** `toolOutcome(result)` for a returned result, `toolOutcome(undefined, error)` for a thrown one. */
173
+ export function toolOutcome(result, ...thrown) {
174
+ const didThrow = thrown.length > 0;
175
+ if (!didThrow && !result?.isError)
176
+ return "ok";
177
+ const text = resultText(didThrow ? thrown[0] : result);
178
+ const byCode = CODE_OUTCOMES.get(LEADING_CODE.exec(text)?.[1] ?? "");
179
+ if (byCode)
180
+ return byCode;
181
+ for (const [outcome, pattern] of OUTCOME_PATTERNS)
182
+ if (pattern.test(text))
183
+ return outcome;
184
+ return didThrow ? "error_internal" : "error_other";
185
+ }
186
+ /** Error excerpt: allowlisted fragments of the error text, masked (port of the Ask Ads gateway's). */
187
+ export const DETAIL_MAX = 200;
188
+ const PICK = /\bHTTP (?<http>\d{3})\b(?::\s*(?<htext>[^{[]{1,200}))?|^(?:Ошибка: )?\[(?<bcode>\d{1,5})\]\s*(?<btext>[^;(]{1,200})|\[(?<tag>[A-Za-z][A-Za-z_]{2,})\]\s*(?<ttext>[^;(]{0,200})|\berror_code["']?\s*[:=]\s*["']?(?<code>\d{1,5})\b|\b(?<key>error_string|error_detail|error_type|message)["']?\s*[:=]\s*["']?(?<value>[^"'}\]]{1,80})|\b(?<net>ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|timed out|fetch failed|socket hang up|connection closed)\b|(?<netru>превысил таймаут)/gi;
189
+ const SCRUB = /(?<key>(?:login|password|secret|token|api[_-]?key|authorization)\s*[:=]\s*["']?|(?:bearer|basic)\s+)(?<val>[^,;]+)|[^\s,;]*(?:@|:\/\/|%3A|\p{L}\.\p{L})[^\s,;]*|(?<key2>\b(?:token|secret|password|api[_-]?key|access_token|refresh_token|client_secret)\s+)(?!(?:expired|invalid|is|was|for|has|not|missing|required|revoked|provided|type)\b)\S+|\b(?:y0_|AQAA|AQVN|t1\.|sk-|vk1\.|vk_|ghp_|ya29\.)\S+|\b(?=[\w-]*\d)(?=[\w-]*[A-Za-z])[\w-]{20,}\b|\+?\d(?:[\s().-]*\d){3,}/giu;
190
+ const QUOTED = /(?<!\p{L})'[^'\n]+(?:'(?!\p{L})|$)|"[^"\n]+(?:"|$)|«[^»]+(?:»|$)/gu;
191
+ function scrub(value, quoted = true) {
192
+ return (quoted ? value.replace(QUOTED, "#") : value)
193
+ .replace(SCRUB, (...args) => {
194
+ const groups = args[args.length - 1];
195
+ return (groups.key ?? groups.key2 ?? "") + "#";
196
+ })
197
+ .replace(/^[ ;:,.]+|[ ;:,.]+$/g, "");
198
+ }
199
+ export function errorDetail(text) {
200
+ const flat = text.slice(0, 2000).split(/\s+/).filter(Boolean).join(" ");
201
+ const parts = [];
202
+ PICK.lastIndex = 0;
203
+ for (let m = PICK.exec(flat); m; m = PICK.exec(flat)) {
204
+ const g = m.groups ?? {};
205
+ let token;
206
+ if (g.http)
207
+ token = `HTTP ${g.http} ${scrub(g.htext ?? "")}`;
208
+ else if (g.bcode)
209
+ token = `code=${g.bcode} ${scrub(g.btext ?? "")}`;
210
+ else if (g.tag) {
211
+ // Tags are upper-case only; the rest of PICK is case-insensitive.
212
+ if (!/^[A-Z][A-Z_]{2,}$/.test(g.tag)) {
213
+ PICK.lastIndex = m.index + 1;
214
+ continue;
215
+ }
216
+ token = `${g.tag} ${scrub(g.ttext ?? "")}`;
217
+ }
218
+ else if (g.code)
219
+ token = `code=${g.code}`;
220
+ else if (g.key)
221
+ token = `${g.key.toLowerCase()}=${scrub(g.value ?? "")}`;
222
+ else
223
+ token = (g.net ?? g.netru ?? "").toLowerCase();
224
+ token = token.trim();
225
+ if (!parts.includes(token))
226
+ parts.push(token);
227
+ }
228
+ return parts.join("; ").slice(0, DETAIL_MAX);
229
+ }
230
+ /** Set by fail(), never serialized: our own refusal ("local"), a bug in our code ("internal") or a failed fetch ("network"). */
231
+ const FAILURE_ORIGIN = new WeakMap();
232
+ const JS_ERRORS = new Set(["TypeError", "ReferenceError", "RangeError", "SyntaxError", "EvalError", "URIError"]);
233
+ function jsBug(err) {
234
+ return err instanceof Error && JS_ERRORS.has(err.name) ? `${err.name}: ${err.message.split("\n")[0]}` : undefined;
235
+ }
236
+ /**
237
+ * Node's fetch rejects a network failure as TypeError("fetch failed") with the socket/DNS/TLS error
238
+ * in `cause`. Only cause.code leaves the machine: cause.message names hosts and addresses.
239
+ */
240
+ function fetchFailure(err) {
241
+ if (!(err instanceof Error) || err.name !== "TypeError" || err.message !== "fetch failed")
242
+ return undefined;
243
+ const cause = err.cause;
244
+ if (!cause || typeof cause !== "object")
245
+ return undefined;
246
+ const raw = cause.code;
247
+ const code = typeof raw === "string" && /^[A-Z][A-Z0-9_]{2,40}$/.test(raw) ? raw : "";
248
+ return {
249
+ reason: /TIMEOUT|TIMEDOUT/.test(code) ? "error_timeout" : "error_network",
250
+ text: code ? `fetch failed; ${code.toLowerCase()}` : "fetch failed",
251
+ };
252
+ }
253
+ export function noteFailure(result, err) {
254
+ try {
255
+ if (result && typeof result === "object") {
256
+ const net = fetchFailure(err);
257
+ if (typeof err === "string")
258
+ FAILURE_ORIGIN.set(result, { origin: "local", text: err });
259
+ else if (net)
260
+ FAILURE_ORIGIN.set(result, { origin: "network", ...net });
261
+ else {
262
+ const bug = jsBug(err);
263
+ if (bug)
264
+ FAILURE_ORIGIN.set(result, { origin: "internal", text: bug });
265
+ }
266
+ }
267
+ }
268
+ catch {
269
+ // never affects the result
270
+ }
271
+ return result;
272
+ }
273
+ /** A JS error quotes identifiers of our code, not user data, so its quotes are kept. */
274
+ function maskedOwnText(prefix, text, quoted = true) {
275
+ return (prefix + scrub(text.slice(0, 2 * DETAIL_MAX).split(/\s+/).filter(Boolean).join(" "), quoted)).slice(0, DETAIL_MAX);
276
+ }
277
+ /** Best effort: classifying or sending must never change what the tool returns or throws. */
278
+ function reportCall(telemetry, tool, value, thrown) {
279
+ if (!telemetry.isEnabled())
280
+ return;
281
+ try {
282
+ let reason = thrown ? toolOutcome(undefined, value) : toolOutcome(value);
283
+ let detail = "";
284
+ if (reason !== "ok") {
285
+ const noted = value && typeof value === "object" ? FAILURE_ORIGIN.get(value) : undefined;
286
+ const net = noted?.origin === "network" ? noted : thrown ? fetchFailure(value) : undefined;
287
+ const bug = noted?.origin === "internal" ? noted.text : thrown ? jsBug(value) : undefined;
288
+ if (net) {
289
+ reason = net.reason;
290
+ detail = net.text;
291
+ }
292
+ else if (bug) {
293
+ reason = "error_internal";
294
+ detail = maskedOwnText("internal: ", bug, false);
295
+ }
296
+ else if (noted?.origin === "local") {
297
+ reason = "error_invalid_request";
298
+ detail = maskedOwnText("local: ", noted.text);
299
+ }
300
+ else
301
+ detail = errorDetail(resultText(value));
302
+ }
303
+ telemetry.send("tool_call", { tool, reason, error_detail: detail || undefined });
304
+ }
305
+ catch {
306
+ // never affects the call
307
+ }
308
+ }
107
309
  /**
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.
310
+ * Counts every tool call by name and outcome. Wraps registerTool once, before the domain
311
+ * modules register their tools. Calls rejected by SDK input validation are not counted.
111
312
  */
112
313
  export function instrumentToolCalls(server, telemetry) {
113
314
  const original = server.registerTool.bind(server);
114
315
  // registerTool's generic signature does not survive a wrapper; the runtime
115
316
  // 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);
317
+ server.registerTool = ((name, config, callback) => original(name, config, (async (...args) => {
318
+ let result;
319
+ try {
320
+ result = await callback(...args);
321
+ }
322
+ catch (e) {
323
+ reportCall(telemetry, name, e, true);
324
+ throw e;
325
+ }
326
+ reportCall(telemetry, name, result, false);
327
+ return result;
119
328
  })));
120
329
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { noteFailure } from "../telemetry.js";
2
3
  /**
3
4
  * Schema factories, not shared consts: reusing one zod object across two fields
4
5
  * makes zod-to-json-schema dedupe them into a `$ref`, which some tool-schema
@@ -29,7 +30,11 @@ export function ok(data) {
29
30
  const text = typeof data === "string" ? data : JSON.stringify(data);
30
31
  return { content: [{ type: "text", text: text ?? "null" }] };
31
32
  }
33
+ /** The failed result; also notes, off the wire, whether it is our own refusal or a JS bug (telemetry). */
32
34
  export function fail(err) {
35
+ return noteFailure(failText(err), err);
36
+ }
37
+ function failText(err) {
33
38
  let message = err instanceof Error ? err.message : String(err);
34
39
  // Surface the underlying cause (e.g. the network error behind a timeout) — no
35
40
  // secrets live in cause, and it makes failures far easier to diagnose.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a1-x-tech/mcp-google-slides",
3
- "version": "0.2.0",
3
+ "version": "1.1.0",
4
4
  "description": "MCP server for the Google Slides API — create and edit presentations, slides, text, shapes, tables and images, manage speaker notes, comments, thumbnails and export. For Claude, Cursor, Codex and other AI clients.",
5
5
  "mcpName": "io.github.A1-x-Tech/mcp-google-slides",
6
6
  "type": "module",