@zackbart/connecta 0.31.0 → 0.32.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.
Files changed (95) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +15 -13
  3. package/bin/connecta.mjs +1 -0
  4. package/bin/version.mjs +1 -1
  5. package/dist/auth-elicitation.js +1 -1
  6. package/dist/catalog-service.js +7 -2
  7. package/dist/config-value-sources.js +6 -3
  8. package/dist/config.d.ts +26 -8
  9. package/dist/config.js +38 -16
  10. package/dist/connector-access.d.ts +2 -1
  11. package/dist/connectors/guarded-fetch.d.ts +9 -0
  12. package/dist/connectors/guarded-fetch.js +7 -0
  13. package/dist/describe-config.d.ts +6 -3
  14. package/dist/describe-config.js +3 -2
  15. package/dist/errors.d.ts +1 -1
  16. package/dist/errors.js +1 -0
  17. package/dist/execute.js +16 -14
  18. package/dist/index.js +4 -3
  19. package/dist/invocation.js +7 -4
  20. package/dist/meta-tools.js +17 -19
  21. package/dist/operator-record.d.ts +2 -2
  22. package/dist/operator-record.js +2 -0
  23. package/dist/operator-ui/contract.d.ts +5 -4
  24. package/dist/operator-ui/generated.js +2 -2
  25. package/dist/operator-ui/model.d.ts +1 -1
  26. package/dist/operator-ui/view.js +1 -0
  27. package/dist/program-writes.js +2 -2
  28. package/dist/provider.d.ts +2 -3
  29. package/dist/providers/_shared/google/workspace.js +1 -1
  30. package/dist/providers/breeze/skill.generated.js +2 -2
  31. package/dist/providers/cloudflare/skill.generated.js +1 -1
  32. package/dist/providers/docs/index.d.ts +1 -1
  33. package/dist/providers/docs/index.js +2 -2
  34. package/dist/providers/drive/index.d.ts +1 -1
  35. package/dist/providers/drive/index.js +1 -1
  36. package/dist/providers/forms/index.d.ts +1 -1
  37. package/dist/providers/forms/index.js +2 -2
  38. package/dist/providers/github/skill.generated.js +2 -2
  39. package/dist/providers/gmail/index.d.ts +1 -1
  40. package/dist/providers/gmail/index.js +274 -23
  41. package/dist/providers/gmail/skill.generated.js +2 -2
  42. package/dist/providers/linear/index.js +1 -1
  43. package/dist/providers/mixpanel/index.d.ts +27 -12
  44. package/dist/providers/mixpanel/index.js +58 -6
  45. package/dist/providers/mixpanel/operations.js +20 -0
  46. package/dist/providers/mixpanel/rest.js +603 -0
  47. package/dist/providers/mixpanel/skill.generated.js +3 -2
  48. package/dist/providers/mixpanel/value-safety.js +16 -0
  49. package/dist/providers/notion/index.js +16 -0
  50. package/dist/providers/notion/rest.js +138 -19
  51. package/dist/providers/notion/skill.generated.js +4 -4
  52. package/dist/providers/planning-center/index.d.ts +27 -0
  53. package/dist/providers/planning-center/index.js +286 -24
  54. package/dist/providers/planning-center/skill.generated.js +24 -4
  55. package/dist/providers/sheets/index.d.ts +1 -1
  56. package/dist/providers/sheets/index.js +3 -3
  57. package/dist/providers/slides/index.d.ts +1 -1
  58. package/dist/providers/slides/index.js +3 -3
  59. package/dist/providers/stripe/skill.generated.js +2 -2
  60. package/dist/providers/vercel/index.js +2 -2
  61. package/dist/providers/vercel/skill.generated.js +2 -2
  62. package/dist/providers/wispr-flow/index.d.ts +14 -0
  63. package/dist/providers/wispr-flow/index.js +117 -0
  64. package/dist/providers/wispr-flow/skill.generated.js +8 -0
  65. package/dist/routes/mcp.js +30 -10
  66. package/dist/routes/operator.js +14 -7
  67. package/dist/routes/ui-config.js +2 -2
  68. package/dist/server.js +3 -0
  69. package/dist/skills.js +5 -4
  70. package/dist/tool-safety.js +13 -5
  71. package/dist/types.d.ts +1 -1
  72. package/dist/ui.d.ts +1 -1
  73. package/dist/ui.js +1 -1
  74. package/dist/usage-guide.js +3 -3
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/documentation/architecture.md +38 -23
  78. package/documentation/auth.md +86 -27
  79. package/documentation/code-mode.md +81 -72
  80. package/documentation/deploying.md +10 -5
  81. package/documentation/integrating.md +56 -4
  82. package/documentation/meta-tools.md +44 -37
  83. package/documentation/operating.md +11 -8
  84. package/documentation/operator-tests.md +2 -3
  85. package/documentation/operator-ui.md +9 -3
  86. package/documentation/provider-auth-migration.md +5 -4
  87. package/documentation/provider-migration-0.29.md +1 -1
  88. package/examples/worker/AGENTS.md +5 -0
  89. package/examples/worker/src/connecta.config.ts +5 -0
  90. package/package.json +5 -1
  91. package/templates/node/AGENTS.md +6 -4
  92. package/templates/node/README.md +4 -2
  93. package/templates/node/package.json +1 -1
  94. package/templates/node/src/connecta.config.ts +5 -0
  95. package/dist/tool-safety.d.ts +0 -9
@@ -58,6 +58,11 @@ const SUMMARY_CONCURRENCY = 5;
58
58
  /** A full thread without attachment bodies; Gmail's message cap is 25 MB. */
59
59
  const GMAIL_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
60
60
  const MAX_RECIPIENTS = 100;
61
+ /** Gmail cannot range-read attachment JSON, so text and binary downloads are bounded. */
62
+ const MAX_ATTACHMENT_BYTES = 1024 * 1024;
63
+ /** Same direct-call ceiling and minimum result budget as Drive content reads. */
64
+ const MAX_ATTACHMENT_RESULT_BYTES = 4 * 1024 * 1024;
65
+ const MIN_ATTACHMENT_RESULT_BYTES = 64 * 1024;
61
66
  function asRecord(value) {
62
67
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
63
68
  }
@@ -118,22 +123,37 @@ function base64UrlBytes(data) {
118
123
  }
119
124
  function charsetOf(part) {
120
125
  const type = header(part, "Content-Type") ?? "";
121
- return /charset="?([^";\s]+)"?/i.exec(type)?.[1] ?? "utf-8";
126
+ // Consume complete parameters so a semicolon inside another quoted value
127
+ // cannot masquerade as a charset. MIME allows whitespace between tokens.
128
+ const parameters = /;\s*([^=;\s]+)\s*=\s*(?:"((?:\\.|[^"\\])*)"|([^;\s]*))/g;
129
+ for (const match of type.matchAll(parameters)) {
130
+ if (match[1].toLowerCase() === "charset") {
131
+ return (match[2]?.replace(/\\(.)/g, "$1") ?? match[3] ?? "").trim();
132
+ }
133
+ }
134
+ return "utf-8";
122
135
  }
123
- /** Bytes as text, in the part's declared charset where the runtime knows it. */
136
+ /** Bytes as text, with an explicit notice when the declared charset is unsupported. */
124
137
  function decodeIn(part, data) {
125
138
  let bytes;
126
139
  try {
127
140
  bytes = base64UrlBytes(data);
128
141
  }
129
142
  catch {
130
- return "";
143
+ return { text: "" };
131
144
  }
145
+ return decodeBytes(part, bytes);
146
+ }
147
+ function decodeBytes(part, bytes) {
148
+ const charset = charsetOf(part);
132
149
  try {
133
- return new TextDecoder(charsetOf(part)).decode(bytes);
150
+ return { text: new TextDecoder(charset).decode(bytes) };
134
151
  }
135
152
  catch {
136
- return new TextDecoder().decode(bytes);
153
+ return {
154
+ text: new TextDecoder().decode(bytes),
155
+ note: `Gmail declared unsupported charset "${clipped(charset, FIELD_BYTES)}"; decoded as UTF-8.`,
156
+ };
137
157
  }
138
158
  }
139
159
  function mimeOf(part) {
@@ -161,13 +181,13 @@ const BODY_TYPES = new Set(["text/plain", "text/html"]);
161
181
  const MAX_PART_DEPTH = 20;
162
182
  /**
163
183
  * Walk a payload keeping ancestry: the message's own parts, in order, and the
164
- * attachment roots, whose subtrees are never entered.
184
+ * attachment roots, whose subtrees are entered only for a resolved forwarded root.
165
185
  */
166
- function walk(root) {
186
+ function walk(root, enterAttachmentRoot = false) {
167
187
  const own = [];
168
188
  const attachments = [];
169
189
  const visit = (part, depth) => {
170
- if (isAttachment(part)) {
190
+ if (isAttachment(part) && !(enterAttachmentRoot && depth === 0)) {
171
191
  attachments.push(part);
172
192
  return;
173
193
  }
@@ -180,6 +200,14 @@ function walk(root) {
180
200
  visit(root, 0);
181
201
  return { own, attachments };
182
202
  }
203
+ /** The same plain-first selection for messages and parsed forwarded attachments. */
204
+ function ownBody(own) {
205
+ return own.find((part) => mimeOf(part) === "text/plain") ?? own.find((part) => mimeOf(part) === "text/html");
206
+ }
207
+ function bodyText(part, decoded) {
208
+ const body = mimeOf(part) === "text/html" ? htmlToText(decoded.text) : decoded.text;
209
+ return decoded.note ? `${body}\n[${decoded.note}]` : body;
210
+ }
183
211
  /** Gmail stores a large body apart from the message, behind an attachment id. */
184
212
  const MAX_STORED_BODY_BYTES = 1024 * 1024;
185
213
  /**
@@ -190,11 +218,10 @@ const MAX_STORED_BODY_BYTES = 1024 * 1024;
190
218
  */
191
219
  async function readBody(client, ctx, messageId, payload, max) {
192
220
  const own = walk(payload).own;
193
- const plain = own.find((part) => mimeOf(part) === "text/plain");
194
- const chosen = plain ?? own.find((part) => mimeOf(part) === "text/html");
221
+ const chosen = ownBody(own);
195
222
  if (!chosen)
196
223
  return { body: "", bodyTruncated: false, bodyFormat: "none" };
197
- const format = plain ? "text" : "html";
224
+ const format = mimeOf(chosen) === "text/plain" ? "text" : "html";
198
225
  const stored = asRecord(chosen["body"]);
199
226
  let data = text(stored["data"]);
200
227
  const attachmentId = text(stored["attachmentId"]);
@@ -216,9 +243,9 @@ async function readBody(client, ctx, messageId, payload, max) {
216
243
  }, ctx));
217
244
  data = text(fetched["data"]);
218
245
  }
219
- const decoded = data ? decodeIn(chosen, data) : "";
246
+ const decoded = data ? decodeIn(chosen, data) : { text: "" };
220
247
  // Uncut: the caller caps it, by characters and by the result's bytes.
221
- return { text: format === "html" ? htmlToText(decoded) : decoded, bodyFormat: format };
248
+ return { text: bodyText(chosen, decoded), bodyFormat: format };
222
249
  }
223
250
  /**
224
251
  * Gmail's ids are short opaque tokens — message and thread ids are sixteen
@@ -243,7 +270,7 @@ function ids(fields) {
243
270
  const kept = {};
244
271
  const omitted = [];
245
272
  for (const [name, [value, maxBytes]] of Object.entries(fields)) {
246
- const id = idOf(value, maxBytes);
273
+ const id = name === "partId" ? partIdOf(value) : idOf(value, maxBytes);
247
274
  if (id !== undefined)
248
275
  kept[name] = id;
249
276
  else if (text(value) !== undefined)
@@ -269,11 +296,180 @@ function attachmentsOf(payload) {
269
296
  filename: clipped(text(part["filename"]) ?? "", FIELD_BYTES),
270
297
  mimeType: clipped(text(part["mimeType"]), FIELD_BYTES),
271
298
  size: typeof asRecord(part["body"])["size"] === "number" ? asRecord(part["body"])["size"] : undefined,
272
- ...ids({ attachmentId: [asRecord(part["body"])["attachmentId"], ATTACHMENT_ID_BYTES] }),
299
+ ...ids({
300
+ attachmentId: [asRecord(part["body"])["attachmentId"], ATTACHMENT_ID_BYTES],
301
+ partId: [part["partId"]],
302
+ }),
273
303
  })),
274
304
  omitted: Math.max(0, all.length - MAX_LISTED_ATTACHMENTS),
275
305
  };
276
306
  }
307
+ /** Unlike opaque ids, Gmail's root part id may be empty. */
308
+ function partIdOf(value) {
309
+ return typeof value === "string" && jsonBytes(value) <= ID_BYTES ? value : undefined;
310
+ }
311
+ function declaredSize(body) {
312
+ const size = body["size"];
313
+ return typeof size === "number" && Number.isSafeInteger(size) && size >= 0 ? size : undefined;
314
+ }
315
+ /** Include every descendant, even carried files that body selection will skip. */
316
+ function forwardedSize(part, depth = 0) {
317
+ if (depth > MAX_PART_DEPTH)
318
+ return undefined;
319
+ const body = asRecord(part["body"]);
320
+ const children = asArray(part["parts"]);
321
+ let total = declaredSize(body);
322
+ if (total === undefined) {
323
+ if (children.length === 0 || body["size"] !== undefined || body["attachmentId"] || body["data"])
324
+ return undefined;
325
+ total = 0;
326
+ }
327
+ for (const child of children) {
328
+ const size = forwardedSize(asRecord(child), depth + 1);
329
+ if (size === undefined)
330
+ return undefined;
331
+ total += size;
332
+ if (!Number.isSafeInteger(total))
333
+ return undefined;
334
+ }
335
+ return total;
336
+ }
337
+ /** Validate both Gmail byte counts before content can be returned as complete. */
338
+ async function attachmentBytes(client, ctx, messageId, part) {
339
+ const stored = asRecord(part["body"]);
340
+ let source = stored;
341
+ const freshId = text(stored["attachmentId"]);
342
+ if (freshId) {
343
+ source = asRecord(await client.json({
344
+ method: "GET",
345
+ path: `/messages/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(freshId)}`,
346
+ }, ctx));
347
+ }
348
+ const data = source["data"];
349
+ if (typeof data !== "string")
350
+ return undefined;
351
+ let bytes;
352
+ try {
353
+ bytes = base64UrlBytes(data);
354
+ }
355
+ catch {
356
+ throw new ConnectorCallError("connector_call_failed", "Gmail returned invalid base64url attachment data.", {
357
+ retryable: false,
358
+ });
359
+ }
360
+ if (bytes.length !== declaredSize(stored) ||
361
+ (source["size"] !== undefined && bytes.length !== declaredSize(source))) {
362
+ throw new ConnectorCallError("connector_call_failed", "Gmail attachment data does not match its declared byte size.", { retryable: false });
363
+ }
364
+ return bytes;
365
+ }
366
+ /** Read only a carried part of a fresh message, never an arbitrary download id. */
367
+ async function readAttachment(client, ctx, args) {
368
+ const messageId = String(args["messageId"]);
369
+ const partId = args["partId"];
370
+ const attachmentId = text(args["attachmentId"]);
371
+ if (partId === undefined && attachmentId === undefined) {
372
+ throw new ConnectorCallError("invalid_args", "Supply partId from the attachment listing, or attachmentId.");
373
+ }
374
+ const message = asRecord(await client.json({ method: "GET", path: `/messages/${encodeURIComponent(messageId)}`, query: { format: "full" } }, ctx));
375
+ const attachments = walk(asRecord(message["payload"])).attachments;
376
+ // partId is immutable and authoritative when supplied. An attachmentId
377
+ // from the previous listing may have rotated since that messages.get.
378
+ const part = attachments.find((entry) => partId !== undefined ? entry["partId"] === partId : asRecord(entry["body"])["attachmentId"] === attachmentId);
379
+ if (!part) {
380
+ throw new ConnectorCallError("not_found", "This attachment is not among the message's carried parts. Read get_message again and use its immutable partId.");
381
+ }
382
+ const stored = asRecord(part["body"]);
383
+ const mime = mimeOf(part).split(";")[0].trim();
384
+ const parsedForward = mime === "message/rfc822" &&
385
+ !text(stored["attachmentId"]) &&
386
+ !text(stored["data"]) &&
387
+ asArray(part["parts"]).length > 0;
388
+ const size = parsedForward ? forwardedSize(part) : declaredSize(stored);
389
+ const maxBytes = typeof args["maxBytes"] === "number" ? args["maxBytes"] : RESULT_BUDGET_BYTES;
390
+ const maxChars = typeof args["maxChars"] === "number" ? args["maxChars"] : DEFAULT_MESSAGE_BODY_CHARS;
391
+ const base = compact({
392
+ messageId,
393
+ ...ids({ partId: [part["partId"]] }),
394
+ filename: clipped(text(part["filename"]) ?? "", FIELD_BYTES),
395
+ mimeType: clipped(text(part["mimeType"]) ?? "application/octet-stream", FIELD_BYTES),
396
+ size,
397
+ });
398
+ const unavailable = (note) => ({ ...base, format: "unavailable", contentTruncated: false, note });
399
+ const isText = mime.startsWith("text/") ||
400
+ ["application/json", "application/xml", "message/rfc822"].includes(mime) ||
401
+ mime.endsWith("+json") ||
402
+ mime.endsWith("+xml");
403
+ let envelope = { ...base, format: isText ? "text" : "base64", content: "", contentTruncated: false };
404
+ const fitting = Math.floor(Math.max(0, maxBytes - jsonBytes(envelope)) / 4) * 3;
405
+ const limit = isText ? MAX_ATTACHMENT_BYTES : Math.min(MAX_ATTACHMENT_BYTES, fitting);
406
+ const tooLarge = () => unavailable(limit < MAX_ATTACHMENT_BYTES
407
+ ? `This binary attachment does not fit maxBytes (${maxBytes}), which holds ${limit} attachment bytes. Raise maxBytes, up to ${MAX_ATTACHMENT_RESULT_BYTES} for a direct call_tool only, to read up to ${MAX_ATTACHMENT_BYTES} bytes. No content is returned.`
408
+ : `This attachment exceeds the ${MAX_ATTACHMENT_BYTES}-byte download and inline cap. No content is returned; open it in Gmail.`);
409
+ // Declared size is checked before downloading, but actual bytes are checked
410
+ // too: inconsistent metadata must never bypass the cap or whole-result budget.
411
+ if (size === undefined)
412
+ return unavailable("Gmail did not declare a valid attachment size; no content is downloaded. Open it in Gmail.");
413
+ if (size > limit)
414
+ return tooLarge();
415
+ let decoded;
416
+ if (parsedForward) {
417
+ const own = walk(part, true).own;
418
+ const chosen = ownBody(own);
419
+ if (!chosen)
420
+ return unavailable("This forwarded message has no readable text body; open it in Gmail.");
421
+ const bytes = await attachmentBytes(client, ctx, messageId, chosen);
422
+ if (!bytes)
423
+ return unavailable("Gmail returned no readable forwarded message body data; open it in Gmail.");
424
+ const rendered = decodeBytes(chosen, bytes);
425
+ if (!rendered.text.trim())
426
+ return unavailable("This forwarded message has no readable text body; open it in Gmail.");
427
+ const headers = ["From", "To", "Date", "Subject"].flatMap((name) => {
428
+ const value = own.map((entry) => header(entry, name)).find((entry) => entry !== undefined);
429
+ return value === undefined ? [] : [`${name}: ${value}`];
430
+ });
431
+ const body = mimeOf(chosen) === "text/html" ? htmlToText(rendered.text) : rendered.text;
432
+ if (!body.trim())
433
+ return unavailable("This forwarded message has no readable text body; open it in Gmail.");
434
+ decoded = headers.length > 0 ? `${headers.join("\n")}\n\n${body}` : body;
435
+ envelope = { ...envelope, ...compact({ note: rendered.note }) };
436
+ }
437
+ else {
438
+ const bytes = await attachmentBytes(client, ctx, messageId, part);
439
+ if (!bytes)
440
+ return unavailable("Gmail returned no attachment data; open it in Gmail.");
441
+ if (bytes.length > limit)
442
+ return tooLarge();
443
+ if (!isText) {
444
+ let binary = "";
445
+ for (let index = 0; index < bytes.length; index += 0x8000) {
446
+ binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
447
+ }
448
+ return { ...envelope, content: btoa(binary) };
449
+ }
450
+ const rendered = decodeBytes(part, bytes);
451
+ decoded = rendered.text;
452
+ if (mime === "message/rfc822" && !decoded.trim()) {
453
+ return unavailable("This forwarded message has no readable text body; open it in Gmail.");
454
+ }
455
+ envelope = { ...envelope, ...compact({ note: rendered.note }) };
456
+ }
457
+ const characters = Array.from(decoded);
458
+ const marker = (dropped) => `\n[… ${dropped} more characters truncated; raise maxChars or maxBytes for a direct call_tool, or open it in Gmail]`;
459
+ const byChars = characters.length > maxChars
460
+ ? characters.slice(0, maxChars).join("") + marker(characters.length - maxChars)
461
+ : decoded;
462
+ if (jsonBytes({ ...envelope, content: byChars }) <= maxBytes) {
463
+ return { ...envelope, content: byChars, contentTruncated: characters.length > maxChars };
464
+ }
465
+ // Reserve the longest marker, then clamp only the original prefix. A text
466
+ // cut by both limits still has one marker, counted in the result's budget.
467
+ const prefix = characters.slice(0, maxChars).join("");
468
+ const contentBudget = maxBytes - jsonBytes(envelope) + jsonBytes("");
469
+ const kept = clampText(prefix, contentBudget - jsonBytes(marker(characters.length)) + 2, () => "");
470
+ const content = kept + marker(characters.length - Array.from(kept).length);
471
+ return { ...envelope, content, contentTruncated: true };
472
+ }
277
473
  /**
278
474
  * Per-field byte bounds, so every result is bounded by construction: a page
279
475
  * of 25 summaries, or one message beside its body, stays under
@@ -428,10 +624,10 @@ function resultLimitMarker(dropped) {
428
624
  * all, against the budget. The bounds above make a refusal here a
429
625
  * should-not-happen; it exists so nothing oversized is ever handed on.
430
626
  */
431
- function deliverable(tool, result) {
627
+ function deliverable(tool, result, budget = RESULT_BUDGET_BYTES) {
432
628
  const size = jsonBytes(result);
433
- if (size > RESULT_BUDGET_BYTES) {
434
- throw new ConnectorCallError("connector_call_failed", `Gmail's answer to ${tool} is ${size} bytes even after cutting, past connecta's ${RESULT_BUDGET_BYTES / 1024} KiB result limit. Open it in Gmail.`, { retryable: false });
629
+ if (size > budget) {
630
+ throw new ConnectorCallError("connector_call_failed", `Gmail's answer to ${tool} is ${size} bytes even after cutting, past connecta's ${budget / 1024} KiB result limit. Open it in Gmail.`, { retryable: false });
435
631
  }
436
632
  return result;
437
633
  }
@@ -870,6 +1066,7 @@ const ATTACHMENT_SCHEMA = {
870
1066
  mimeType: { type: "string" },
871
1067
  size: { type: "integer" },
872
1068
  attachmentId: { type: "string" },
1069
+ partId: { type: "string" },
873
1070
  omittedIds: OMITTED_IDS_SCHEMA,
874
1071
  },
875
1072
  };
@@ -1018,7 +1215,7 @@ function tools(client) {
1018
1215
  },
1019
1216
  {
1020
1217
  name: "get_thread",
1021
- description: "Get a Gmail thread's messages, oldest first, with headers, plain-text bodies capped by maxBodyChars, and attachment names. A long thread pages by cursor.",
1218
+ description: "Get a Gmail thread's messages, oldest first, with headers, plain-text bodies capped by maxBodyChars, and attachment metadata. Read attachments with get_attachment using partId. A long thread pages by cursor.",
1022
1219
  annotations: readOnly,
1023
1220
  inputSchema: input({
1024
1221
  threadId: idProperty("Thread id from search_threads or a message's threadId."),
@@ -1079,7 +1276,7 @@ function tools(client) {
1079
1276
  },
1080
1277
  {
1081
1278
  name: "get_message",
1082
- description: "Get one Gmail message with headers, its plain-text body (HTML converted only when no text part exists), and attachment metadata. Never downloads attachments.",
1279
+ description: "Get one Gmail message with headers, its plain-text body (HTML converted only when no text part exists), and attachment metadata. Read attachments with get_attachment using partId.",
1083
1280
  annotations: readOnly,
1084
1281
  inputSchema: input({
1085
1282
  messageId: idProperty("Message id from get_thread, or a draft's messageId."),
@@ -1108,6 +1305,58 @@ function tools(client) {
1108
1305
  return message;
1109
1306
  },
1110
1307
  },
1308
+ {
1309
+ name: "get_attachment",
1310
+ description: "Read a Gmail attachment by immutable partId: text with its charset, or small binaries as standard base64 for Drive create_file. Refuses oversized downloads; text is capped by maxChars and the whole result by maxBytes.",
1311
+ annotations: readOnly,
1312
+ inputSchema: {
1313
+ ...input({
1314
+ messageId: idProperty("Message id from get_message, get_thread, or get_draft."),
1315
+ partId: {
1316
+ type: "string",
1317
+ maxLength: ID_BYTES,
1318
+ description: "Immutable partId from the attachment listing, including an empty root id. Preferred; authoritative when attachmentId is also supplied.",
1319
+ },
1320
+ attachmentId: {
1321
+ type: "string",
1322
+ minLength: 1,
1323
+ maxLength: ATTACHMENT_ID_BYTES,
1324
+ pattern: ID_PATTERN,
1325
+ description: "Alternative attachmentId from the listing. Gmail can rotate it between reads; prefer partId.",
1326
+ },
1327
+ maxChars: {
1328
+ type: "integer",
1329
+ minimum: 1,
1330
+ maximum: MAX_BODY_CHARS,
1331
+ description: `Text code points kept; defaults to ${DEFAULT_MESSAGE_BODY_CHARS}. Longer text ends with a truncation marker. Ignored for binaries.`,
1332
+ },
1333
+ maxBytes: {
1334
+ type: "integer",
1335
+ minimum: MIN_ATTACHMENT_RESULT_BYTES,
1336
+ maximum: MAX_ATTACHMENT_RESULT_BYTES,
1337
+ description: `Whole-result UTF-8 JSON byte budget; defaults to ${RESULT_BUDGET_BYTES}. Raise it only for a direct call_tool; execute_code cannot receive large results.`,
1338
+ },
1339
+ }, ["messageId"]),
1340
+ anyOf: [{ required: ["partId"] }, { required: ["attachmentId"] }],
1341
+ },
1342
+ outputSchema: {
1343
+ type: "object",
1344
+ properties: {
1345
+ messageId: { type: "string" },
1346
+ partId: { type: "string" },
1347
+ omittedIds: OMITTED_IDS_SCHEMA,
1348
+ filename: { type: "string" },
1349
+ mimeType: { type: "string" },
1350
+ size: { type: "integer" },
1351
+ format: { type: "string", enum: ["text", "base64", "unavailable"] },
1352
+ content: { type: "string" },
1353
+ contentTruncated: { type: "boolean" },
1354
+ note: { type: "string" },
1355
+ },
1356
+ required: ["messageId", "filename", "mimeType", "format", "contentTruncated"],
1357
+ },
1358
+ handler: async (args, ctx) => await readAttachment(client, ctx, args),
1359
+ },
1111
1360
  {
1112
1361
  name: "list_labels",
1113
1362
  description: "List the user's Gmail labels, system and user-created, with the ids messages carry; no message counts. Search by name with label: in search_threads.",
@@ -1271,7 +1520,7 @@ function tools(client) {
1271
1520
  name: "create_draft",
1272
1521
  description: "Create a Gmail draft, optionally as a reply in an existing thread. It is saved, never sent: the user reviews and sends it in Gmail.",
1273
1522
  // Additive: a new draft changes nothing that existed. Not read-only, so
1274
- // program calls require a trusted pool; other writes use
1523
+ // program calls require a writable endpoint; other writes use
1275
1524
  // call_destructive_tool. The host controls approval.
1276
1525
  annotations: { readOnlyHint: false, destructiveHint: false },
1277
1526
  inputSchema: input({
@@ -1384,7 +1633,7 @@ function usageGuide(purpose, instructions) {
1384
1633
  *
1385
1634
  * `gmail.compose` technically allows sending; this connection never does,
1386
1635
  * because no tool reaches a send method. Drafts are additive writes the host
1387
- * approves through call_destructive_tool or a trusted program.
1636
+ * approves through call_destructive_tool or a program on a writable endpoint.
1388
1637
  */
1389
1638
  export const gmail = asProviderFactory({
1390
1639
  name: "gmail",
@@ -1430,7 +1679,9 @@ function gmailConnector(id, options) {
1430
1679
  // Every tool's result passes the whole-size postcondition on its way out.
1431
1680
  tools: tools(client).map((tool) => ({
1432
1681
  ...tool,
1433
- handler: async (args, ctx) => deliverable(tool.name, await tool.handler(args, ctx)),
1682
+ handler: async (args, ctx) => deliverable(tool.name, await tool.handler(args, ctx), tool.name === "get_attachment" && typeof args["maxBytes"] === "number"
1683
+ ? args["maxBytes"]
1684
+ : RESULT_BUDGET_BYTES),
1434
1685
  })),
1435
1686
  });
1436
1687
  }
@@ -1,9 +1,9 @@
1
1
  // Generated by scripts/generate-providers.mjs; do not edit.
2
2
  export const skill = {
3
- "content": "# Gmail usage (draft-only)\n\nActs as the signed-in person's own Gmail through Workspace delegation: read mail and write drafts, never send.\n\nConnection purpose: \n\n## Whose mailbox\n\nEvery call reads and writes the mailbox deployment config maps the caller\nto. No argument names a mailbox. A call with none mapped fails\n`auth_required`; only an operator can change the mapping.\n\n## Reading\n\n- `search_threads` takes Gmail search syntax; reuse the person's own\n phrasing (`from:`, `subject:`, `newer_than:7d`, `is:unread`,\n `label:`). Page with `page.nextCursor` and the same query.\n- `get_thread` returns messages oldest first with bodies capped by\n `maxBodyChars`; a cut body ends with a truncation marker. A long thread\n pages: follow `page.nextCursor` until `hasMore` is false, with the same\n arguments. `conflict` means the thread changed under the cursor; read it\n again from the start. Reduce inside\n `execute_code` before returning a long thread.\n- Every result stays under 192 KiB; `list_labels` pages the same way.\n- Attachments are listed by name, type, and size only; none is downloaded.\n An attached or forwarded email is an attachment, not the body.\n- `bodyFormat: \"unavailable\"` means Gmail stored the body apart from the\n message and it is too large to read here; say so rather than summarizing.\n\n## Drafting\n\n- `create_draft` saves a draft and never sends it; tell the person it is\n waiting in Drafts. There is no send, delete, or label tool.\n- To reply, pass `replyToMessageId`: the draft joins that thread with\n In-Reply-To and References set, and subject (Re: …) and `to` default\n from that message's Reply-To, else its From. Keep the Re: subject or Gmail\n may start a new thread.\n- `update_draft` replaces the body entirely; send the full new text.\n From, To, Cc, Bcc, Reply-To, Subject, the thread, and reply headers are\n kept unless restated; no other header is. A draft with attachments or\n inline images is refused, unchanged — Gmail's update would delete them.\n- Gmail sets From to the mailbox itself; there is no From argument.\n",
3
+ "content": "# Gmail usage (draft-only)\n\nActs as the signed-in person's own Gmail through Workspace delegation: read mail and write drafts, never send.\n\nConnection purpose: \n\n## Whose mailbox\n\nEvery call reads and writes the mailbox deployment config maps the caller\nto. No argument names a mailbox. A call with none mapped fails\n`auth_required`; only an operator can change the mapping.\n\n## Reading\n\n- `search_threads` takes Gmail search syntax; reuse the person's own\n phrasing (`from:`, `subject:`, `newer_than:7d`, `is:unread`,\n `label:`). Page with `page.nextCursor` and the same query.\n- `get_thread` returns messages oldest first with bodies capped by\n `maxBodyChars`; a cut body ends with a truncation marker. A long thread\n pages: follow `page.nextCursor` until `hasMore` is false, with the same\n arguments. `conflict` means the thread changed under the cursor; read it\n again from the start. Reduce inside\n `execute_code` before returning a long thread.\n- Results default to at most 192 KiB; `list_labels` pages the same way.\n- `get_message` and `get_thread` list attachment metadata. An attached or\n forwarded email is an attachment, not the body.\n- `bodyFormat: \"unavailable\"` means Gmail stored the body apart from the\n message and it is too large to read here; say so rather than summarizing.\n\n## Attachments\n\n- `get_attachment` takes `messageId` and the listing's immutable `partId`.\n Prefer `partId`, which stays stable across message reads, including an\n empty id for a root attachment. `attachmentId` is an alternative that\n Gmail can rotate between reads. When both are supplied, `partId` wins and\n the fresh message's download id is used. Read the listing again after\n an unknown-id refusal; never store an attachmentId for later use.\n- Small attachments can have inline data and no attachmentId. Use their\n partId too. Only carried attachment parts can be read, never arbitrary\n message body parts or blind download ids.\n- Text, JSON, XML, calendar invites, CSV, and `message/rfc822` return\n `format: \"text\"` and `content` decoded in the declared charset. An unsupported\n charset produces an explicit note that decoding used UTF-8. Message bodies\n include the same notice in their text.\n Parsed forwarded emails render From, To, Date, Subject, and their own\n plain body, else HTML converted to text. Raw forwarded data is preferred\n when present. A forwarded email without readable text returns an\n `unavailable` note.\n `maxChars` defaults to 12,000 code points, at most 100,000. A character\n or byte cut ends with a truncation marker and `contentTruncated: true`.\n Reduce attachment text inside `execute_code` before returning it.\n- Binary attachments return `format: \"base64\"` and standard base64 in\n `content`. Pass it unchanged to Drive `create_file` as `contentBase64`,\n with `name: filename` and `mimeType`. File creation is a write: use\n `call_destructive_tool` or a program on a writable endpoint for that step.\n- Downloads, text and binary, are limited to 1 MiB. Declared size is\n checked before a separate download. Parsed forwarded emails check the sum\n of descendant sizes before downloading any stored body, and return that\n sum as `size`. Attachment data must match both the message part's declared\n size and the download response's size when supplied; inconsistent payloads\n fail rather than return incomplete files. Binary content must also fit the\n whole-result `maxBytes`, which defaults to 192 KiB. `format: \"unavailable\"`\n returns a note and no content when it cannot fit. Raising `maxChars`\n or `maxBytes` cannot bypass the download cap.\n- Raise `maxBytes` only for a direct `call_tool` read, from 64 KiB up to\n 4 MiB, as with Drive `get_file_content`. `execute_code` cannot receive\n large results; keep the default there. Direct-call results may be paged.\n\n## Drafting\n\n- `create_draft` saves a draft and never sends it; tell the person it is\n waiting in Drafts. There is no send, delete, or label tool.\n- To reply, pass `replyToMessageId`: the draft joins that thread with\n In-Reply-To and References set, and subject (Re: …) and `to` default\n from that message's Reply-To, else its From. Keep the Re: subject or Gmail\n may start a new thread.\n- `update_draft` replaces the body entirely; send the full new text.\n From, To, Cc, Bcc, Reply-To, Subject, the thread, and reply headers are\n kept unless restated; no other header is. A draft with attachments or\n inline images is refused, unchanged — Gmail's update would delete them.\n- Gmail sets From to the mailbox itself; there is no From argument.\n",
4
4
  "instructionsHeading": "Connection instructions",
5
5
  "fragments": {
6
6
  "guide_0": "# Gmail usage (draft-only)\n\nActs as the signed-in person's own Gmail through Workspace delegation: read mail and write drafts, never send.\n\nConnection purpose: ",
7
- "guide_1": "\n\n## Whose mailbox\n\nEvery call reads and writes the mailbox deployment config maps the caller\nto. No argument names a mailbox. A call with none mapped fails\n`auth_required`; only an operator can change the mapping.\n\n## Reading\n\n- `search_threads` takes Gmail search syntax; reuse the person's own\n phrasing (`from:`, `subject:`, `newer_than:7d`, `is:unread`,\n `label:`). Page with `page.nextCursor` and the same query.\n- `get_thread` returns messages oldest first with bodies capped by\n `maxBodyChars`; a cut body ends with a truncation marker. A long thread\n pages: follow `page.nextCursor` until `hasMore` is false, with the same\n arguments. `conflict` means the thread changed under the cursor; read it\n again from the start. Reduce inside\n `execute_code` before returning a long thread.\n- Every result stays under 192 KiB; `list_labels` pages the same way.\n- Attachments are listed by name, type, and size only; none is downloaded.\n An attached or forwarded email is an attachment, not the body.\n- `bodyFormat: \"unavailable\"` means Gmail stored the body apart from the\n message and it is too large to read here; say so rather than summarizing.\n\n## Drafting\n\n- `create_draft` saves a draft and never sends it; tell the person it is\n waiting in Drafts. There is no send, delete, or label tool.\n- To reply, pass `replyToMessageId`: the draft joins that thread with\n In-Reply-To and References set, and subject (Re: …) and `to` default\n from that message's Reply-To, else its From. Keep the Re: subject or Gmail\n may start a new thread.\n- `update_draft` replaces the body entirely; send the full new text.\n From, To, Cc, Bcc, Reply-To, Subject, the thread, and reply headers are\n kept unless restated; no other header is. A draft with attachments or\n inline images is refused, unchanged — Gmail's update would delete them.\n- Gmail sets From to the mailbox itself; there is no From argument.\n"
7
+ "guide_1": "\n\n## Whose mailbox\n\nEvery call reads and writes the mailbox deployment config maps the caller\nto. No argument names a mailbox. A call with none mapped fails\n`auth_required`; only an operator can change the mapping.\n\n## Reading\n\n- `search_threads` takes Gmail search syntax; reuse the person's own\n phrasing (`from:`, `subject:`, `newer_than:7d`, `is:unread`,\n `label:`). Page with `page.nextCursor` and the same query.\n- `get_thread` returns messages oldest first with bodies capped by\n `maxBodyChars`; a cut body ends with a truncation marker. A long thread\n pages: follow `page.nextCursor` until `hasMore` is false, with the same\n arguments. `conflict` means the thread changed under the cursor; read it\n again from the start. Reduce inside\n `execute_code` before returning a long thread.\n- Results default to at most 192 KiB; `list_labels` pages the same way.\n- `get_message` and `get_thread` list attachment metadata. An attached or\n forwarded email is an attachment, not the body.\n- `bodyFormat: \"unavailable\"` means Gmail stored the body apart from the\n message and it is too large to read here; say so rather than summarizing.\n\n## Attachments\n\n- `get_attachment` takes `messageId` and the listing's immutable `partId`.\n Prefer `partId`, which stays stable across message reads, including an\n empty id for a root attachment. `attachmentId` is an alternative that\n Gmail can rotate between reads. When both are supplied, `partId` wins and\n the fresh message's download id is used. Read the listing again after\n an unknown-id refusal; never store an attachmentId for later use.\n- Small attachments can have inline data and no attachmentId. Use their\n partId too. Only carried attachment parts can be read, never arbitrary\n message body parts or blind download ids.\n- Text, JSON, XML, calendar invites, CSV, and `message/rfc822` return\n `format: \"text\"` and `content` decoded in the declared charset. An unsupported\n charset produces an explicit note that decoding used UTF-8. Message bodies\n include the same notice in their text.\n Parsed forwarded emails render From, To, Date, Subject, and their own\n plain body, else HTML converted to text. Raw forwarded data is preferred\n when present. A forwarded email without readable text returns an\n `unavailable` note.\n `maxChars` defaults to 12,000 code points, at most 100,000. A character\n or byte cut ends with a truncation marker and `contentTruncated: true`.\n Reduce attachment text inside `execute_code` before returning it.\n- Binary attachments return `format: \"base64\"` and standard base64 in\n `content`. Pass it unchanged to Drive `create_file` as `contentBase64`,\n with `name: filename` and `mimeType`. File creation is a write: use\n `call_destructive_tool` or a program on a writable endpoint for that step.\n- Downloads, text and binary, are limited to 1 MiB. Declared size is\n checked before a separate download. Parsed forwarded emails check the sum\n of descendant sizes before downloading any stored body, and return that\n sum as `size`. Attachment data must match both the message part's declared\n size and the download response's size when supplied; inconsistent payloads\n fail rather than return incomplete files. Binary content must also fit the\n whole-result `maxBytes`, which defaults to 192 KiB. `format: \"unavailable\"`\n returns a note and no content when it cannot fit. Raising `maxChars`\n or `maxBytes` cannot bypass the download cap.\n- Raise `maxBytes` only for a direct `call_tool` read, from 64 KiB up to\n 4 MiB, as with Drive `get_file_content`. `execute_code` cannot receive\n large results; keep the default there. Direct-call results may be paged.\n\n## Drafting\n\n- `create_draft` saves a draft and never sends it; tell the person it is\n waiting in Drafts. There is no send, delete, or label tool.\n- To reply, pass `replyToMessageId`: the draft joins that thread with\n In-Reply-To and References set, and subject (Re: …) and `to` default\n from that message's Reply-To, else its From. Keep the Re: subject or Gmail\n may start a new thread.\n- `update_draft` replaces the body entirely; send the full new text.\n From, To, Cc, Bcc, Reply-To, Subject, the thread, and reply headers are\n kept unless restated; no other header is. A draft with attachments or\n inline images is refused, unchanged — Gmail's update would delete them.\n- Gmail sets From to the mailbox itself; there is no From argument.\n"
8
8
  }
9
9
  };
@@ -302,7 +302,7 @@ export const linear = defineProvider({
302
302
  // the summary without the description.
303
303
  const accessNote = access === "read-only"
304
304
  ? "Read-only connection: bound to Linear's read-only endpoint, whose token is scope-limited downstream, so every write fails at Linear regardless of arguments. Route writes to a connector configured for read-write access."
305
- : "Read-write connection: treat every `save_`, `create_`, `delete_`, `resolve_`, `submit_`, and `merge_` operation as a write. Connecta classifies the maintained writes explicitly and enforces the configured pool trust policy. Unknown tools without an explicit, uncontradicted read annotation fail closed.";
305
+ : "Read-write connection: treat every `save_`, `create_`, `delete_`, `resolve_`, `submit_`, and `merge_` operation as a write. Connecta classifies the maintained writes explicitly, and a read-only endpoint refuses them. Unknown tools without an explicit, uncontradicted read annotation fail closed.";
306
306
  return remoteMcp(id, {
307
307
  url: LINEAR_MCP_ENDPOINTS[access],
308
308
  ...provider.connectorOptions,
@@ -2,7 +2,7 @@ import { type RemoteMcpAuth } from "../../connectors/remote-mcp.js";
2
2
  import type { ConnectorCallAdmissionPolicy } from "../../types.js";
3
3
  export type MixpanelRegion = "us" | "eu" | "in";
4
4
  export declare const MIXPANEL_MCP_ENDPOINTS: Readonly<Record<MixpanelRegion, string>>;
5
- export interface MixpanelOptions {
5
+ interface MixpanelCommonOptions {
6
6
  /**
7
7
  * Human-readable display name; defaults to "Mixpanel (<region>)". The region
8
8
  * rides the title because a project lives in exactly one residency and
@@ -18,25 +18,40 @@ export interface MixpanelOptions {
18
18
  * region answers empty rather than wrong, which reads as an empty project.
19
19
  */
20
20
  region?: MixpanelRegion;
21
- /**
22
- * OAuth by default; static headers support Mixpanel service accounts, and
23
- * `{ type: "credential" }` takes the same service account as an
24
- * operator-managed `username:secret` that Connecta frames for the endpoint.
25
- */
26
- auth?: RemoteMcpAuth;
27
21
  /** Account-specific conventions appended to the maintained provider guide. */
28
22
  instructions?: string;
29
23
  /** Connector-specific inline result limit; omit to inherit the deployment. */
30
24
  maxResultBytes?: number;
31
25
  /**
32
- * Optional per-runtime policy. There is no default: Mixpanel meters its MCP
33
- * server per user per hour, and a per-runtime counter over-counts one runtime
34
- * serving many users and under-counts many runtimes sharing one credential.
35
- * Only an operator who knows the account can pick a number worth enforcing.
26
+ * Optional per-runtime policy. API mode defaults to 60 calls/hour and five
27
+ * concurrent calls across all tools and projects. MCP mode has no default:
28
+ * its per-user hourly quota cannot be modeled by one per-runtime counter.
36
29
  */
37
30
  callAdmission?: ConnectorCallAdmissionPolicy;
38
31
  }
39
- /** A maintained Mixpanel hosted-MCP connection. */
32
+ /** The default hosted MCP connection. Existing auth configurations still work. */
33
+ export interface MixpanelMcpOptions extends MixpanelCommonOptions {
34
+ via?: "mcp";
35
+ /**
36
+ * OAuth by default; static headers support Mixpanel service accounts, and
37
+ * `{ type: "credential" }` takes the same service account as an
38
+ * operator-managed `username:secret` that Connecta frames for the endpoint.
39
+ */
40
+ auth?: RemoteMcpAuth;
41
+ defaultProjectId?: never;
42
+ }
43
+ /** REST APIs using an operator-managed username:secret service account. */
44
+ export interface MixpanelApiOptions extends MixpanelCommonOptions {
45
+ via: "api";
46
+ auth: {
47
+ type: "credential";
48
+ };
49
+ /** Fills an omitted project_id. Explicit ids still reach other granted projects. */
50
+ defaultProjectId?: number;
51
+ }
52
+ export type MixpanelOptions = MixpanelMcpOptions | MixpanelApiOptions;
53
+ /** Mixpanel hosted MCP by default, or service-account REST with via: "api". */
40
54
  export declare const mixpanel: import("../../provider.js").ProviderFactory<MixpanelOptions>;
41
55
  /** @deprecated Read `mixpanel.definition.classify` instead. Kept for existing imports. */
42
56
  export declare const MIXPANEL_VETTED_CATALOG: import("../../catalog-drift.js").VettedCatalog;
57
+ export {};
@@ -1,9 +1,11 @@
1
1
  import { skill } from "./skill.generated.js";
2
2
  import { remoteMcp, withCredentialDefaults } from "../../connectors/remote-mcp.js";
3
3
  import { reviewedCatalog } from "../../catalog-drift.js";
4
- import { keys, optionsOf } from "../../config-schema.js";
4
+ import { keys, optionsOf, variants } from "../../config-schema.js";
5
5
  import { PROVIDER_COMMON, REMOTE_MCP_AUTH } from "../../connectors/option-shapes.js";
6
6
  import { defineProvider } from "../../provider.js";
7
+ import { apiConnector } from "../../connectors/api-connector.js";
8
+ import { mixpanelRest, MIXPANEL_API_ADMISSION } from "./rest.js";
7
9
  export const MIXPANEL_MCP_ENDPOINTS = {
8
10
  us: "https://mcp.mixpanel.com/mcp",
9
11
  eu: "https://mcp-eu.mixpanel.com/mcp",
@@ -358,18 +360,39 @@ ${regionNote}
358
360
  Account purpose: ${purpose}${skill.fragments.guide_1}${accountInstructions ? `\n## ${skill.instructionsHeading}\n\n${accountInstructions}\n` : ""}`;
359
361
  }
360
362
  /** The closed options mixpanel() accepts; see `assertKnownOptions`. */
361
- const MIXPANEL_OPTIONS = optionsOf()({ ...PROVIDER_COMMON, ...keys("region"), auth: REMOTE_MCP_AUTH });
362
- /** A maintained Mixpanel hosted-MCP connection. */
363
+ const MIXPANEL_OPTIONS = variants("via", {
364
+ mcp: optionsOf()({
365
+ ...PROVIDER_COMMON,
366
+ ...keys("region", "via"),
367
+ auth: REMOTE_MCP_AUTH,
368
+ }).shape,
369
+ api: optionsOf()({
370
+ ...PROVIDER_COMMON,
371
+ ...keys("region", "via", "defaultProjectId"),
372
+ auth: variants("type", { credential: optionsOf()(keys("type")).shape }),
373
+ }).shape,
374
+ }, "mcp");
375
+ /** Mixpanel hosted MCP by default, or service-account REST with via: "api". */
363
376
  export const mixpanel = defineProvider({
364
377
  name: "mixpanel",
365
378
  title: "Mixpanel",
366
- kind: "mcp",
379
+ kind: "dual",
367
380
  readme: "Mixpanel",
368
- bundle: { "baselineGzip": 131378, "maxGzip": 191378 },
381
+ bundle: {
382
+ baselineGzip: 205500,
383
+ maxGzip: 265500,
384
+ note: "./providers/mixpanel (#823) adds the REST connector, guarded transport, shared operation index and value-safety engine alongside the unchanged hosted MCP client. The nine-operation reviewed subset was shrunk to depth 3 without descriptions before remeasuring: operations.ts is 1355 B gzip. The provider remeasures at about 205500 B gzip, compared with the 131378 B MCP-only baseline; the cap follows baseline + 60000 B. No new runtime dependencies.",
385
+ },
369
386
  skill,
370
387
  options: MIXPANEL_OPTIONS,
371
388
  classify: MIXPANEL_CLASSIFICATION,
372
- create: mixpanelConnector,
389
+ create: (id, options, provider) => {
390
+ const region = options.region ?? "us";
391
+ if (!Object.hasOwn(MIXPANEL_MCP_ENDPOINTS, region)) {
392
+ throw new Error('region must be "us", "eu", or "in".');
393
+ }
394
+ return options.via === "api" ? mixpanelApi(id, options, provider) : mixpanelConnector(id, options, provider);
395
+ },
373
396
  });
374
397
  function mixpanelConnector(id, options, provider) {
375
398
  const purpose = options.purpose.trim();
@@ -412,5 +435,34 @@ function mixpanelConnector(id, options, provider) {
412
435
  });
413
436
  return connector;
414
437
  }
438
+ function mixpanelApi(id, options, provider) {
439
+ if (options.auth?.type !== "credential")
440
+ throw new Error('via: "api" requires auth: { type: "credential" }.');
441
+ if (options.defaultProjectId !== undefined &&
442
+ (!Number.isSafeInteger(options.defaultProjectId) || options.defaultProjectId < 1)) {
443
+ throw new Error("defaultProjectId must be a positive safe integer.");
444
+ }
445
+ const region = options.region ?? "us";
446
+ const rest = mixpanelRest(region, options.defaultProjectId);
447
+ const instructions = options.instructions?.trim();
448
+ return apiConnector(id, {
449
+ ...provider.connectorOptions,
450
+ title: options.title ?? `Mixpanel API (${region})`,
451
+ description: `Mixpanel service-account REST APIs (${region} residency): ${options.purpose}`,
452
+ credential: {
453
+ label: "Mixpanel service account",
454
+ description: "username:secret from Mixpanel organization settings. Import requires Owner or Admin on the target project.",
455
+ placeholder: "username:secret",
456
+ },
457
+ testCredential: rest.testCredential,
458
+ callAdmission: options.callAdmission ?? MIXPANEL_API_ADMISSION,
459
+ usageGuide: {
460
+ content: `# Mixpanel API usage\n\nService-account REST, ${region} residency. Account purpose: ${options.purpose}\n\nDefault project: ${options.defaultProjectId ?? "none; supply project_id"}.${skill.fragments.api}${instructions ? `\n## ${skill.instructionsHeading}\n\n${instructions}\n` : ""}`,
461
+ summary: `${REGION_COPY[region]} residency, service-account REST. Raw export, user activity, profiles, reports and event import.`,
462
+ required: true,
463
+ },
464
+ tools: rest.tools,
465
+ });
466
+ }
415
467
  /** @deprecated Read `mixpanel.definition.classify` instead. Kept for existing imports. */
416
468
  export const MIXPANEL_VETTED_CATALOG = reviewedCatalog(mixpanel.definition.classify, 'defineProvider("mixpanel")');