@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.
- package/CHANGELOG.md +34 -0
- package/README.md +15 -13
- package/bin/connecta.mjs +1 -0
- package/bin/version.mjs +1 -1
- package/dist/auth-elicitation.js +1 -1
- package/dist/catalog-service.js +7 -2
- package/dist/config-value-sources.js +6 -3
- package/dist/config.d.ts +26 -8
- package/dist/config.js +38 -16
- package/dist/connector-access.d.ts +2 -1
- package/dist/connectors/guarded-fetch.d.ts +9 -0
- package/dist/connectors/guarded-fetch.js +7 -0
- package/dist/describe-config.d.ts +6 -3
- package/dist/describe-config.js +3 -2
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -0
- package/dist/execute.js +16 -14
- package/dist/index.js +4 -3
- package/dist/invocation.js +7 -4
- package/dist/meta-tools.js +17 -19
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +2 -0
- package/dist/operator-ui/contract.d.ts +5 -4
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +1 -1
- package/dist/operator-ui/view.js +1 -0
- package/dist/program-writes.js +2 -2
- package/dist/provider.d.ts +2 -3
- package/dist/providers/_shared/google/workspace.js +1 -1
- package/dist/providers/breeze/skill.generated.js +2 -2
- package/dist/providers/cloudflare/skill.generated.js +1 -1
- package/dist/providers/docs/index.d.ts +1 -1
- package/dist/providers/docs/index.js +2 -2
- package/dist/providers/drive/index.d.ts +1 -1
- package/dist/providers/drive/index.js +1 -1
- package/dist/providers/forms/index.d.ts +1 -1
- package/dist/providers/forms/index.js +2 -2
- package/dist/providers/github/skill.generated.js +2 -2
- package/dist/providers/gmail/index.d.ts +1 -1
- package/dist/providers/gmail/index.js +274 -23
- package/dist/providers/gmail/skill.generated.js +2 -2
- package/dist/providers/linear/index.js +1 -1
- package/dist/providers/mixpanel/index.d.ts +27 -12
- package/dist/providers/mixpanel/index.js +58 -6
- package/dist/providers/mixpanel/operations.js +20 -0
- package/dist/providers/mixpanel/rest.js +603 -0
- package/dist/providers/mixpanel/skill.generated.js +3 -2
- package/dist/providers/mixpanel/value-safety.js +16 -0
- package/dist/providers/notion/index.js +16 -0
- package/dist/providers/notion/rest.js +138 -19
- package/dist/providers/notion/skill.generated.js +4 -4
- package/dist/providers/planning-center/index.d.ts +27 -0
- package/dist/providers/planning-center/index.js +286 -24
- package/dist/providers/planning-center/skill.generated.js +24 -4
- package/dist/providers/sheets/index.d.ts +1 -1
- package/dist/providers/sheets/index.js +3 -3
- package/dist/providers/slides/index.d.ts +1 -1
- package/dist/providers/slides/index.js +3 -3
- package/dist/providers/stripe/skill.generated.js +2 -2
- package/dist/providers/vercel/index.js +2 -2
- package/dist/providers/vercel/skill.generated.js +2 -2
- package/dist/providers/wispr-flow/index.d.ts +14 -0
- package/dist/providers/wispr-flow/index.js +117 -0
- package/dist/providers/wispr-flow/skill.generated.js +8 -0
- package/dist/routes/mcp.js +30 -10
- package/dist/routes/operator.js +14 -7
- package/dist/routes/ui-config.js +2 -2
- package/dist/server.js +3 -0
- package/dist/skills.js +5 -4
- package/dist/tool-safety.js +13 -5
- package/dist/types.d.ts +1 -1
- package/dist/ui.d.ts +1 -1
- package/dist/ui.js +1 -1
- package/dist/usage-guide.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +38 -23
- package/documentation/auth.md +86 -27
- package/documentation/code-mode.md +81 -72
- package/documentation/deploying.md +10 -5
- package/documentation/integrating.md +56 -4
- package/documentation/meta-tools.md +44 -37
- package/documentation/operating.md +11 -8
- package/documentation/operator-tests.md +2 -3
- package/documentation/operator-ui.md +9 -3
- package/documentation/provider-auth-migration.md +5 -4
- package/documentation/provider-migration-0.29.md +1 -1
- package/examples/worker/AGENTS.md +5 -0
- package/examples/worker/src/connecta.config.ts +5 -0
- package/package.json +5 -1
- package/templates/node/AGENTS.md +6 -4
- package/templates/node/README.md +4 -2
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +5 -0
- 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
|
-
|
|
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,
|
|
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(
|
|
150
|
+
return { text: new TextDecoder(charset).decode(bytes) };
|
|
134
151
|
}
|
|
135
152
|
catch {
|
|
136
|
-
return
|
|
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
|
|
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
|
|
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:
|
|
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({
|
|
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 >
|
|
434
|
-
throw new ConnectorCallError("connector_call_failed", `Gmail's answer to ${tool} is ${size} bytes even after cutting, past connecta's ${
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
/**
|
|
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 =
|
|
362
|
-
|
|
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: "
|
|
379
|
+
kind: "dual",
|
|
367
380
|
readme: "Mixpanel",
|
|
368
|
-
bundle: {
|
|
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:
|
|
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")');
|