@zackbart/connecta 0.30.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 +66 -0
- package/README.md +21 -16
- package/bin/connecta.mjs +1 -0
- package/bin/version.mjs +1 -1
- package/dist/auth-elicitation.js +1 -1
- package/dist/call-recovery.js +25 -0
- package/dist/catalog-service.js +7 -2
- package/dist/config-schema.d.ts +14 -7
- package/dist/config-schema.js +29 -10
- 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 +18 -1
- package/dist/connectors/guarded-fetch.js +21 -2
- package/dist/describe-config.d.ts +6 -3
- package/dist/describe-config.js +3 -2
- package/dist/errors.d.ts +3 -1
- package/dist/errors.js +1 -0
- package/dist/execute.js +16 -14
- package/dist/index.js +4 -3
- package/dist/invocation.js +39 -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.d.ts +9 -0
- package/dist/operator-ui/view.js +12 -0
- package/dist/program-writes.js +4 -49
- package/dist/provider.d.ts +6 -5
- package/dist/provider.js +3 -4
- package/dist/providers/_shared/google/workspace.js +1 -1
- package/dist/providers/_shared/rest/dispatch.js +41 -0
- package/dist/providers/_shared/rest/operation-index.d.ts +133 -0
- package/dist/providers/_shared/rest/operation-index.js +527 -0
- package/dist/providers/_shared/rest/tools.d.ts +141 -0
- package/dist/providers/_shared/rest/tools.js +569 -0
- package/dist/providers/_shared/rest/value-safety.d.ts +151 -0
- package/dist/providers/_shared/rest/value-safety.js +580 -0
- package/dist/providers/breeze/skill.generated.js +2 -2
- package/dist/providers/cloudflare/graphql.js +385 -0
- package/dist/providers/cloudflare/index.d.ts +47 -51
- package/dist/providers/cloudflare/index.js +188 -1997
- package/dist/providers/cloudflare/openapi.generated.js +3347 -0
- package/dist/providers/cloudflare/rest.d.ts +37 -0
- package/dist/providers/cloudflare/rest.js +1056 -0
- package/dist/providers/cloudflare/skill.generated.js +7 -4
- package/dist/providers/cloudflare/value-safety.js +1247 -0
- 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.d.ts +35 -31
- package/dist/providers/notion/index.js +76 -1499
- package/dist/providers/notion/openapi.generated.js +75 -0
- package/dist/providers/notion/rest.d.ts +24 -0
- package/dist/providers/notion/rest.js +1186 -0
- package/dist/providers/notion/skill.generated.js +5 -4
- package/dist/providers/notion/value-safety.js +31 -0
- 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/index.d.ts +26 -20
- package/dist/providers/stripe/index.js +103 -130
- package/dist/providers/stripe/openapi.generated.js +649 -0
- package/dist/providers/stripe/rest.d.ts +14 -0
- package/dist/providers/stripe/rest.js +395 -0
- package/dist/providers/stripe/skill.generated.js +7 -5
- package/dist/providers/stripe/value-safety.js +705 -0
- package/dist/providers/vercel/index.d.ts +31 -22
- package/dist/providers/vercel/index.js +96 -853
- package/dist/providers/vercel/openapi.generated.js +456 -0
- package/dist/providers/vercel/rest.d.ts +20 -0
- package/dist/providers/vercel/rest.js +1002 -0
- package/dist/providers/vercel/skill.generated.js +4 -5
- package/dist/providers/vercel/value-safety.js +328 -0
- 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/dist/write-outcome.js +47 -0
- package/documentation/README.md +1 -0
- package/documentation/architecture.md +155 -28
- package/documentation/auth.md +86 -27
- package/documentation/code-mode.md +81 -72
- package/documentation/deploying.md +10 -5
- package/documentation/integrating.md +72 -9
- package/documentation/meta-tools.md +52 -38
- 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 +308 -0
- package/documentation/provider-migration-0.29.md +18 -6
- package/examples/worker/AGENTS.md +5 -0
- package/examples/worker/README.md +2 -2
- package/examples/worker/src/connecta.config.ts +5 -0
- package/package.json +7 -2
- package/templates/node/AGENTS.md +6 -4
- package/templates/node/README.md +6 -4
- package/templates/node/package.json +1 -1
- package/templates/node/src/connecta.config.ts +5 -0
- package/dist/providers/vercel/mcp-ownership.js +0 -212
- package/dist/tool-safety.d.ts +0 -9
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { retryAfterMs } from "../../connectors/guarded-fetch.js";
|
|
2
|
+
import { ConnectorCallError } from "../../errors.js";
|
|
3
|
+
import { OperationIndex } from "../_shared/rest/operation-index.js";
|
|
4
|
+
import { callRest, restCall, restTools, restTransport } from "../_shared/rest/tools.js";
|
|
5
|
+
import { valueSafety } from "../_shared/rest/value-safety.js";
|
|
6
|
+
import { openapi } from "./operations.js";
|
|
7
|
+
import { MIXPANEL_VALUE_SAFETY } from "./value-safety.js";
|
|
8
|
+
/** Per runtime and connection, conservatively shared across its projects and API families. */
|
|
9
|
+
export const MIXPANEL_API_ADMISSION = {
|
|
10
|
+
rules: [
|
|
11
|
+
{
|
|
12
|
+
maxConcurrency: 5,
|
|
13
|
+
budget: { kind: "rolling-window", maxCalls: 60, windowMs: 3_600_000 },
|
|
14
|
+
maxQueueSize: 16,
|
|
15
|
+
queueTimeoutMs: 5_000,
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
const INDEX = new OperationIndex(openapi, { vendor: "mixpanel", title: "Mixpanel" });
|
|
20
|
+
const SAFETY = valueSafety(MIXPANEL_VALUE_SAFETY, () => INDEX);
|
|
21
|
+
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
22
|
+
const MAX_EXPORT_BYTES = 1024 * 1024;
|
|
23
|
+
const DEFAULT_EXPORT_BYTES = 64 * 1024;
|
|
24
|
+
const MAX_IMPORT_BYTES = 10_000_000;
|
|
25
|
+
const encoder = new TextEncoder();
|
|
26
|
+
const READ = { readOnlyHint: true };
|
|
27
|
+
const PROJECT = {
|
|
28
|
+
type: "integer",
|
|
29
|
+
minimum: 1,
|
|
30
|
+
maximum: Number.MAX_SAFE_INTEGER,
|
|
31
|
+
description: "Project id from list_projects or project settings; omitted only with defaultProjectId.",
|
|
32
|
+
};
|
|
33
|
+
const WORKSPACE = {
|
|
34
|
+
type: "integer",
|
|
35
|
+
minimum: 1,
|
|
36
|
+
description: "Data view workspace id when the project requires one.",
|
|
37
|
+
};
|
|
38
|
+
const DATE = {
|
|
39
|
+
type: "string",
|
|
40
|
+
pattern: "^\\d{4}-\\d{2}-\\d{2}$",
|
|
41
|
+
description: "Inclusive date, YYYY-MM-DD. Export uses the vendor's project-age timezone rules.",
|
|
42
|
+
};
|
|
43
|
+
const TEXT = { type: "string", minLength: 1, description: "Documented Mixpanel value." };
|
|
44
|
+
const RAW = {
|
|
45
|
+
type: "boolean",
|
|
46
|
+
description: "Return the redacted vendor envelope instead of the named tool's projection.",
|
|
47
|
+
};
|
|
48
|
+
const OUT = {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: {
|
|
51
|
+
status: { type: "integer" },
|
|
52
|
+
data: { description: "Redacted Mixpanel result." },
|
|
53
|
+
page: {
|
|
54
|
+
type: "object",
|
|
55
|
+
description: "When hasMore is true, repeat the same filters with the next page and session id.",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
required: ["status", "data"],
|
|
59
|
+
};
|
|
60
|
+
const EXPORT_PROPERTIES = {
|
|
61
|
+
project_id: PROJECT,
|
|
62
|
+
from_date: DATE,
|
|
63
|
+
to_date: DATE,
|
|
64
|
+
event: {
|
|
65
|
+
type: "array",
|
|
66
|
+
items: { ...TEXT, description: "Exact event name." },
|
|
67
|
+
minItems: 1,
|
|
68
|
+
description: "Optional event-name filter, encoded as JSON on the wire.",
|
|
69
|
+
},
|
|
70
|
+
where: { ...TEXT, description: "Mixpanel segmentation expression to filter events." },
|
|
71
|
+
time_in_ms: { type: "boolean", description: "Request millisecond timestamps instead of the default seconds." },
|
|
72
|
+
limit: {
|
|
73
|
+
type: "integer",
|
|
74
|
+
minimum: 1,
|
|
75
|
+
maximum: 100_000,
|
|
76
|
+
description: "Mixpanel event cap is 100000; Connecta defaults to 1000.",
|
|
77
|
+
},
|
|
78
|
+
max_bytes: {
|
|
79
|
+
type: "integer",
|
|
80
|
+
minimum: 1,
|
|
81
|
+
maximum: MAX_EXPORT_BYTES,
|
|
82
|
+
description: "Connecta's byte ceiling, default 65536 and maximum 1048576; partial trailing lines are discarded.",
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
function record(value) {
|
|
86
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
87
|
+
}
|
|
88
|
+
function invalid(message) {
|
|
89
|
+
throw new ConnectorCallError("invalid_args", message);
|
|
90
|
+
}
|
|
91
|
+
function project(value, defaultProjectId) {
|
|
92
|
+
const id = value ?? defaultProjectId;
|
|
93
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id) || id < 1)
|
|
94
|
+
invalid("Supply a positive project_id from list_projects or configure defaultProjectId; never guess one.");
|
|
95
|
+
return id;
|
|
96
|
+
}
|
|
97
|
+
function dates(input) {
|
|
98
|
+
for (const key of ["from_date", "to_date"]) {
|
|
99
|
+
const value = input[key];
|
|
100
|
+
if (typeof value !== "string" ||
|
|
101
|
+
!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
|
|
102
|
+
!Number.isFinite(Date.parse(value)) ||
|
|
103
|
+
new Date(value).toISOString().slice(0, 10) !== value)
|
|
104
|
+
invalid(`${key} must be a valid YYYY-MM-DD date.`);
|
|
105
|
+
}
|
|
106
|
+
if (String(input.from_date) > String(input.to_date))
|
|
107
|
+
invalid("from_date must be on or before to_date.");
|
|
108
|
+
}
|
|
109
|
+
function retention(query) {
|
|
110
|
+
if ((query.unit === undefined || query.unit === "day") && typeof query.interval === "number" && query.interval > 90) {
|
|
111
|
+
invalid("Day retention intervals must not exceed 90; unit defaults to day.");
|
|
112
|
+
}
|
|
113
|
+
if ((query.retention_type === undefined || query.retention_type === "birth") &&
|
|
114
|
+
(typeof query.born_event !== "string" || query.born_event.trim() === "")) {
|
|
115
|
+
invalid("birth retention, including the default, requires born_event. Use compounded for recurring retention.");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function failure(status, headers) {
|
|
119
|
+
if (status === 401)
|
|
120
|
+
return new ConnectorCallError("auth_required", "Mixpanel rejected the service account. An operator must set username:secret in this connection's operator UI.");
|
|
121
|
+
if (status === 403)
|
|
122
|
+
return new ConnectorCallError("provider_permission_denied", "Mixpanel refused access. Check the service account's project membership, data sensitivity and role; import needs Owner or Admin.", { retryable: false });
|
|
123
|
+
if (status === 429)
|
|
124
|
+
return new ConnectorCallError("rate_limited", "Mixpanel's project API budget is spent; respect the retry window.", {
|
|
125
|
+
retryAfterMs: retryAfterMs(headers) ?? 60_000,
|
|
126
|
+
});
|
|
127
|
+
const wait = retryAfterMs(headers);
|
|
128
|
+
if (status >= 500)
|
|
129
|
+
return new ConnectorCallError("unavailable", `Mixpanel returned HTTP ${status}.`, wait === undefined ? {} : { retryAfterMs: wait });
|
|
130
|
+
return new ConnectorCallError("connector_call_failed", `Mixpanel returned HTTP ${status}. Check the documented parameters, project access and region. Import failures can partially ingest valid records; inspect the target before repeating.`, { retryable: false });
|
|
131
|
+
}
|
|
132
|
+
async function authenticate(ctx) {
|
|
133
|
+
const pair = await ctx.credential?.get();
|
|
134
|
+
if (!pair)
|
|
135
|
+
throw new ConnectorCallError("auth_required", "An operator must set the Mixpanel service account as username:secret in this connection's operator UI.");
|
|
136
|
+
const colon = pair.indexOf(":");
|
|
137
|
+
if (colon < 1 || colon === pair.length - 1 || /[\r\n]/.test(pair))
|
|
138
|
+
throw new ConnectorCallError("auth_required", "The Mixpanel service account must be username:secret, with both fields non-empty.");
|
|
139
|
+
const bytes = encoder.encode(pair);
|
|
140
|
+
let binary = "";
|
|
141
|
+
for (const byte of bytes)
|
|
142
|
+
binary += String.fromCharCode(byte);
|
|
143
|
+
return { Authorization: `Basic ${btoa(binary)}` };
|
|
144
|
+
}
|
|
145
|
+
function encode(call) {
|
|
146
|
+
if (call.op.path === "/api/query/engage") {
|
|
147
|
+
const form = new URLSearchParams();
|
|
148
|
+
for (const [key, value] of Object.entries(call.body)) {
|
|
149
|
+
if (value !== undefined)
|
|
150
|
+
form.set(key, Array.isArray(value) || record(value) ? JSON.stringify(value) : String(value));
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
query: call.query,
|
|
154
|
+
rawBody: form.toString(),
|
|
155
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const query = {};
|
|
159
|
+
for (const [key, value] of Object.entries(call.query)) {
|
|
160
|
+
if (value === undefined)
|
|
161
|
+
continue;
|
|
162
|
+
if (Array.isArray(value))
|
|
163
|
+
query[key] = JSON.stringify(value);
|
|
164
|
+
else if (["string", "number", "boolean"].includes(typeof value))
|
|
165
|
+
query[key] = value;
|
|
166
|
+
else
|
|
167
|
+
invalid(`Mixpanel query ${key} must be a scalar or JSON array.`);
|
|
168
|
+
}
|
|
169
|
+
return { query, ...(call.body !== undefined ? { body: call.body } : {}) };
|
|
170
|
+
}
|
|
171
|
+
/** Every dispatch has one fixed documented operation and residency host. */
|
|
172
|
+
export function mixpanelRest(region, defaultProjectId) {
|
|
173
|
+
const app = region === "us" ? "mixpanel.com" : `${region}.mixpanel.com`;
|
|
174
|
+
const suffix = region === "us" ? "" : `-${region}`;
|
|
175
|
+
const origins = {
|
|
176
|
+
query: `https://${app}`,
|
|
177
|
+
app: `https://${app}`,
|
|
178
|
+
export: `https://data${suffix}.mixpanel.com`,
|
|
179
|
+
ingestion: `https://api${suffix}.mixpanel.com`,
|
|
180
|
+
};
|
|
181
|
+
const transports = Object.fromEntries(Object.entries(origins).map(([server, baseUrl]) => [
|
|
182
|
+
server,
|
|
183
|
+
restTransport({
|
|
184
|
+
provider: "Mixpanel",
|
|
185
|
+
baseUrl,
|
|
186
|
+
maxResponseBytes: MAX_RESPONSE_BYTES,
|
|
187
|
+
timeoutMs: 60_000,
|
|
188
|
+
authenticate,
|
|
189
|
+
}),
|
|
190
|
+
]));
|
|
191
|
+
const vendor = {
|
|
192
|
+
vendor: "mixpanel",
|
|
193
|
+
title: "Mixpanel",
|
|
194
|
+
index: INDEX,
|
|
195
|
+
valueSafety: SAFETY,
|
|
196
|
+
transport: (server) => transports[server ?? "query"] ?? "Mixpanel admits only its reviewed residency hosts.",
|
|
197
|
+
failure,
|
|
198
|
+
encode,
|
|
199
|
+
readPosts: [["POST", "/api/query/engage", "Queries existing profiles; no profile mutation or ingestion occurs."]],
|
|
200
|
+
scope: (call) => call.path === "/api/app/me"
|
|
201
|
+
? call
|
|
202
|
+
: { ...call, query: { ...call.query, project_id: project(call.query.project_id, defaultProjectId) } },
|
|
203
|
+
result: (body) => {
|
|
204
|
+
if (record(body) && (body.status === "error" || body.status === 0 || body.error))
|
|
205
|
+
throw new ConnectorCallError("connector_call_failed", "Mixpanel reported a failure in a successful HTTP response. Check parameters and project access.", { retryable: false });
|
|
206
|
+
return body;
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
const request = (ctx, method, path, query = {}, body) => callRest(vendor, restCall(vendor, method, path, { query, body }), ctx);
|
|
210
|
+
async function exportEvents(args, ctx) {
|
|
211
|
+
dates(args);
|
|
212
|
+
const maxBytes = args.max_bytes ?? DEFAULT_EXPORT_BYTES;
|
|
213
|
+
const limit = args.limit ?? 1000;
|
|
214
|
+
if (typeof maxBytes !== "number" || !Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_EXPORT_BYTES)
|
|
215
|
+
invalid("max_bytes must be 1..1048576, Connecta's export ceiling.");
|
|
216
|
+
if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 100_000)
|
|
217
|
+
invalid("limit must be 1..100000, Mixpanel's export event ceiling.");
|
|
218
|
+
const call = restCall(vendor, "GET", "/api/2.0/export", { query: { ...args, max_bytes: undefined, limit } });
|
|
219
|
+
const framing = encode(call);
|
|
220
|
+
return transports.export({ method: "GET", path: call.path, query: framing.query, prefixOnly: true }, ctx, async (response) => {
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
// Cancel the unread error stream without waiting for its first chunk.
|
|
223
|
+
// Await it so a direct connector call also owns the body's lifetime.
|
|
224
|
+
try {
|
|
225
|
+
await response.cancel();
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
/* The status still describes an unreadable error response. */
|
|
229
|
+
}
|
|
230
|
+
if (ctx.signal?.aborted)
|
|
231
|
+
throw ctx.signal.reason;
|
|
232
|
+
throw failure(response.status, response.headers);
|
|
233
|
+
}
|
|
234
|
+
const { bytes, truncated: byteTruncated } = await response.prefix(maxBytes);
|
|
235
|
+
const text = new TextDecoder().decode(bytes);
|
|
236
|
+
const complete = byteTruncated ? text.slice(0, text.lastIndexOf("\n") + 1) : text;
|
|
237
|
+
const lines = complete.split("\n").filter((line) => line.trim() !== "");
|
|
238
|
+
let events;
|
|
239
|
+
try {
|
|
240
|
+
events = lines.map((line) => JSON.parse(line));
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
throw new ConnectorCallError("connector_call_failed", "Mixpanel returned malformed JSONL.", {
|
|
244
|
+
retryable: false,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (events.some((event) => !record(event) || typeof event.event !== "string" || !record(event.properties)))
|
|
248
|
+
throw new ConnectorCallError("connector_call_failed", "Mixpanel returned an unexpected export event shape.", {
|
|
249
|
+
retryable: false,
|
|
250
|
+
});
|
|
251
|
+
const truncated = byteTruncated || events.length >= limit;
|
|
252
|
+
return {
|
|
253
|
+
events: SAFETY.redact(events.slice(0, limit), "GET", call.path),
|
|
254
|
+
bytes_read: bytes.length,
|
|
255
|
+
truncated,
|
|
256
|
+
notice: truncated
|
|
257
|
+
? "Export may omit events at the byte or event cap. There is no cursor; narrow the date range or filters. Never infer absence from this sample."
|
|
258
|
+
: "Complete response for the requested range and filters.",
|
|
259
|
+
};
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function readTool(name, description, path, properties, required, handler) {
|
|
263
|
+
return {
|
|
264
|
+
name,
|
|
265
|
+
description,
|
|
266
|
+
annotations: READ,
|
|
267
|
+
inputSchema: { type: "object", properties, required, additionalProperties: false },
|
|
268
|
+
outputSchema: OUT,
|
|
269
|
+
handler: handler ??
|
|
270
|
+
(async (args, ctx) => {
|
|
271
|
+
const { raw: _raw, ...query } = args;
|
|
272
|
+
if (query.from_date !== undefined || query.to_date !== undefined)
|
|
273
|
+
dates(query);
|
|
274
|
+
if (path === "/api/query/retention")
|
|
275
|
+
retention(query);
|
|
276
|
+
return await request(ctx, "GET", path, query);
|
|
277
|
+
}),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const generic = restTools(vendor)
|
|
281
|
+
.filter((tool) => tool.name !== "mixpanel_api_write")
|
|
282
|
+
.map((tool) => tool.name !== "mixpanel_api_read"
|
|
283
|
+
? {
|
|
284
|
+
...tool,
|
|
285
|
+
handler: async (args, ctx) => {
|
|
286
|
+
const result = (await tool.handler(args, ctx));
|
|
287
|
+
const routed = (row) => ({
|
|
288
|
+
...row,
|
|
289
|
+
tool: row.path === "/import"
|
|
290
|
+
? "import_events"
|
|
291
|
+
: row.path === "/api/query/engage"
|
|
292
|
+
? "query_profiles"
|
|
293
|
+
: "mixpanel_api_read",
|
|
294
|
+
});
|
|
295
|
+
return Array.isArray(result.operations)
|
|
296
|
+
? { ...result, operations: result.operations.map(routed) }
|
|
297
|
+
: routed(result);
|
|
298
|
+
},
|
|
299
|
+
}
|
|
300
|
+
: {
|
|
301
|
+
...tool,
|
|
302
|
+
inputSchema: {
|
|
303
|
+
...tool.inputSchema,
|
|
304
|
+
properties: {
|
|
305
|
+
path: tool.inputSchema.properties.path,
|
|
306
|
+
project_id: PROJECT,
|
|
307
|
+
query: {
|
|
308
|
+
type: "object",
|
|
309
|
+
description: "Documented query parameters; use details to resolve required names. No auth headers or body.",
|
|
310
|
+
},
|
|
311
|
+
select: tool.inputSchema.properties.select,
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
description: "GET a reviewed Mixpanel operation on this region's hosts. No arbitrary URLs, profile updates or service-account administration.",
|
|
315
|
+
handler: async (args, ctx) => {
|
|
316
|
+
if (args.method !== undefined && args.method !== "GET")
|
|
317
|
+
invalid("mixpanel_api_read permits GET only; use query_profiles for Engage and import_events for ingestion.");
|
|
318
|
+
const path = args.path;
|
|
319
|
+
const query = { ...(record(args.query) ? args.query : {}) };
|
|
320
|
+
if (args.project_id !== undefined) {
|
|
321
|
+
if (query.project_id !== undefined && query.project_id !== args.project_id)
|
|
322
|
+
invalid("Conflicting project_id values; use the top-level project_id.");
|
|
323
|
+
query.project_id = args.project_id;
|
|
324
|
+
}
|
|
325
|
+
if (path === "/api/2.0/export") {
|
|
326
|
+
const call = restCall(vendor, "GET", path, { query });
|
|
327
|
+
INDEX.check(call.op, call.query, call.body);
|
|
328
|
+
let event = query.event;
|
|
329
|
+
if (typeof event === "string") {
|
|
330
|
+
try {
|
|
331
|
+
event = JSON.parse(event);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
invalid("event must be a JSON array of names.");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (event !== undefined &&
|
|
338
|
+
(!Array.isArray(event) || event.length === 0 || !event.every((name) => typeof name === "string")))
|
|
339
|
+
invalid("event must be a JSON array of names.");
|
|
340
|
+
if (args.select !== undefined)
|
|
341
|
+
invalid("Generic export does not accept select; reduce the bounded export data in execute_code.");
|
|
342
|
+
return { status: 200, data: await exportEvents({ ...query, event }, ctx) };
|
|
343
|
+
}
|
|
344
|
+
if (query.from_date !== undefined || query.to_date !== undefined)
|
|
345
|
+
dates(query);
|
|
346
|
+
if (path === "/api/query/retention")
|
|
347
|
+
retention(query);
|
|
348
|
+
return await tool.handler({ ...args, query }, ctx);
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
const tools = [
|
|
352
|
+
...generic,
|
|
353
|
+
readTool("list_projects", "Read service-account identity and reachable project metadata. Project tokens and credentials are redacted.", "/api/app/me", {}, []),
|
|
354
|
+
{
|
|
355
|
+
name: "export_events",
|
|
356
|
+
description: "Export bounded raw JSONL events for a date range. Truncation is explicit; narrow filters to establish absence.",
|
|
357
|
+
annotations: READ,
|
|
358
|
+
inputSchema: {
|
|
359
|
+
type: "object",
|
|
360
|
+
properties: EXPORT_PROPERTIES,
|
|
361
|
+
required: ["from_date", "to_date"],
|
|
362
|
+
additionalProperties: false,
|
|
363
|
+
},
|
|
364
|
+
outputSchema: {
|
|
365
|
+
type: "object",
|
|
366
|
+
properties: {
|
|
367
|
+
events: { type: "array", items: { type: "object" } },
|
|
368
|
+
bytes_read: { type: "integer" },
|
|
369
|
+
truncated: { type: "boolean" },
|
|
370
|
+
notice: { type: "string" },
|
|
371
|
+
},
|
|
372
|
+
required: ["events", "bytes_read", "truncated", "notice"],
|
|
373
|
+
},
|
|
374
|
+
handler: exportEvents,
|
|
375
|
+
},
|
|
376
|
+
readTool("get_profile_activity", "Read one user's events, ordered by timestamp. Mixpanel's activity endpoint is in maintenance mode; use export for complete analysis.", "/api/query/stream/query", {
|
|
377
|
+
project_id: PROJECT,
|
|
378
|
+
workspace_id: WORKSPACE,
|
|
379
|
+
distinct_id: { ...TEXT, description: "Exact distinct_id of the user whose event history to read." },
|
|
380
|
+
from_date: DATE,
|
|
381
|
+
to_date: DATE,
|
|
382
|
+
raw: RAW,
|
|
383
|
+
}, ["distinct_id", "from_date", "to_date"], async (args, ctx) => {
|
|
384
|
+
dates(args);
|
|
385
|
+
const { distinct_id, raw, ...query } = args;
|
|
386
|
+
const result = await request(ctx, "GET", "/api/query/stream/query", { ...query, distinct_ids: [distinct_id] });
|
|
387
|
+
if (raw)
|
|
388
|
+
return result;
|
|
389
|
+
const body = result.data;
|
|
390
|
+
const events = record(body) && record(body.results) ? body.results.events : undefined;
|
|
391
|
+
if (!Array.isArray(events) ||
|
|
392
|
+
events.some((e) => !record(e) || !record(e.properties) || typeof e.properties.time !== "number"))
|
|
393
|
+
throw new ConnectorCallError("connector_call_failed", "Mixpanel returned an unexpected activity shape.", {
|
|
394
|
+
retryable: false,
|
|
395
|
+
});
|
|
396
|
+
return {
|
|
397
|
+
status: result.status,
|
|
398
|
+
data: {
|
|
399
|
+
events: [...events].sort((a, b) => a.properties.time - b.properties.time),
|
|
400
|
+
notice: "Maintenance endpoint; Mixpanel documents no completeness guarantee or pagination cursor.",
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
}),
|
|
404
|
+
readTool("query_profiles", "Query existing Engage profiles. This POST is read-only; continue with the returned session_id and next page, preserving filters.", "/api/query/engage", {
|
|
405
|
+
project_id: PROJECT,
|
|
406
|
+
workspace_id: WORKSPACE,
|
|
407
|
+
distinct_id: { ...TEXT, description: "Profile distinct id." },
|
|
408
|
+
distinct_ids: { type: "array", items: TEXT, description: "Exact profile ids, JSON encoded." },
|
|
409
|
+
where: { ...TEXT, description: "Mixpanel profile selector expression." },
|
|
410
|
+
output_properties: { type: "array", items: TEXT, description: "Profile property names to return." },
|
|
411
|
+
session_id: { ...TEXT, description: "Session from the previous response; repeat it unchanged." },
|
|
412
|
+
page: { type: "integer", minimum: 0, description: "Zero-based page; a nonzero page requires session_id." },
|
|
413
|
+
raw: RAW,
|
|
414
|
+
}, [], async (args, ctx) => {
|
|
415
|
+
const { project_id, workspace_id, raw, ...body } = args;
|
|
416
|
+
if (typeof body.page === "number" && body.page > 0 && !body.session_id)
|
|
417
|
+
invalid("A nonzero profile page requires session_id from the previous response.");
|
|
418
|
+
if (body.distinct_id !== undefined && body.distinct_ids !== undefined)
|
|
419
|
+
invalid("Use distinct_id or distinct_ids, not both.");
|
|
420
|
+
const result = await request(ctx, "POST", "/api/query/engage", { project_id, workspace_id }, body);
|
|
421
|
+
if (raw)
|
|
422
|
+
return result;
|
|
423
|
+
const data = result.data;
|
|
424
|
+
if (!record(data) ||
|
|
425
|
+
!Array.isArray(data.results) ||
|
|
426
|
+
typeof data.page_size !== "number" ||
|
|
427
|
+
data.page_size < 1 ||
|
|
428
|
+
typeof data.page !== "number" ||
|
|
429
|
+
typeof data.session_id !== "string")
|
|
430
|
+
throw new ConnectorCallError("connector_call_failed", "Mixpanel returned an unexpected profile page shape.", {
|
|
431
|
+
retryable: false,
|
|
432
|
+
});
|
|
433
|
+
const hasMore = data.results.length >= data.page_size;
|
|
434
|
+
return {
|
|
435
|
+
...result,
|
|
436
|
+
data: {
|
|
437
|
+
results: data.results,
|
|
438
|
+
total: data.total,
|
|
439
|
+
page: data.page,
|
|
440
|
+
session_id: data.session_id,
|
|
441
|
+
page_size: data.page_size,
|
|
442
|
+
},
|
|
443
|
+
page: { hasMore, ...(hasMore ? { next: String(data.page + 1), param: "page", in: "body" } : {}) },
|
|
444
|
+
};
|
|
445
|
+
}),
|
|
446
|
+
readTool("get_insights_report", "Read a saved Insights report by bookmark_id from its report URL. The id is project-local.", "/api/query/insights", {
|
|
447
|
+
project_id: PROJECT,
|
|
448
|
+
workspace_id: WORKSPACE,
|
|
449
|
+
bookmark_id: { ...PROJECT, description: "Saved Insights bookmark id from the report URL." },
|
|
450
|
+
}, ["bookmark_id"]),
|
|
451
|
+
readTool("get_funnel_report", "Read a saved funnel by funnel_id and date range. Use mixpanel_api_read on /api/query/funnels/list to resolve ids.", "/api/query/funnels", {
|
|
452
|
+
project_id: PROJECT,
|
|
453
|
+
workspace_id: WORKSPACE,
|
|
454
|
+
funnel_id: { ...PROJECT, description: "Saved funnel id from the vendor list or report URL." },
|
|
455
|
+
from_date: DATE,
|
|
456
|
+
to_date: DATE,
|
|
457
|
+
}, ["funnel_id", "from_date", "to_date"]),
|
|
458
|
+
readTool("query_retention", "Query retention by events and date range. Mixpanel documents no saved Retention bookmark-id endpoint.", "/api/query/retention", {
|
|
459
|
+
project_id: PROJECT,
|
|
460
|
+
workspace_id: WORKSPACE,
|
|
461
|
+
from_date: DATE,
|
|
462
|
+
to_date: DATE,
|
|
463
|
+
event: { ...TEXT, description: "Returning event name." },
|
|
464
|
+
born_event: { ...TEXT, description: "Initial event name for birth retention." },
|
|
465
|
+
retention_type: {
|
|
466
|
+
type: "string",
|
|
467
|
+
enum: ["birth", "compounded"],
|
|
468
|
+
description: "First-time or recurring retention, respectively.",
|
|
469
|
+
},
|
|
470
|
+
interval: {
|
|
471
|
+
type: "integer",
|
|
472
|
+
minimum: 1,
|
|
473
|
+
description: "Bucket interval; Mixpanel caps day intervals at 90.",
|
|
474
|
+
},
|
|
475
|
+
interval_count: {
|
|
476
|
+
type: "integer",
|
|
477
|
+
minimum: 1,
|
|
478
|
+
description: "Number of buckets, in addition to the zeroth interval.",
|
|
479
|
+
},
|
|
480
|
+
unit: { type: "string", enum: ["day", "week", "month"], description: "Interval unit, default day." },
|
|
481
|
+
}, ["from_date", "to_date"]),
|
|
482
|
+
{
|
|
483
|
+
name: "import_events",
|
|
484
|
+
description: "Import up to 2000 events and 10MB uncompressed JSON. Owner/Admin required; validation errors may still ingest valid events. Never replay automatically.",
|
|
485
|
+
annotations: { readOnlyHint: false },
|
|
486
|
+
inputSchema: {
|
|
487
|
+
type: "object",
|
|
488
|
+
properties: {
|
|
489
|
+
project_id: PROJECT,
|
|
490
|
+
events: {
|
|
491
|
+
type: "array",
|
|
492
|
+
minItems: 1,
|
|
493
|
+
maxItems: 2000,
|
|
494
|
+
description: "Mixpanel event batch; supply stable insert ids for deduplication.",
|
|
495
|
+
items: {
|
|
496
|
+
type: "object",
|
|
497
|
+
description: "One event, smaller than 1MB uncompressed.",
|
|
498
|
+
properties: {
|
|
499
|
+
event: { ...TEXT, description: "Exact event name." },
|
|
500
|
+
properties: {
|
|
501
|
+
type: "object",
|
|
502
|
+
description: "Event properties with time (integer seconds/milliseconds), distinct_id (string) and stable $insert_id (1..36 alphanumeric/hyphen bytes). Fewer than 255 keys; nested depth 3.",
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
required: ["event", "properties"],
|
|
506
|
+
additionalProperties: false,
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
required: ["events"],
|
|
511
|
+
additionalProperties: false,
|
|
512
|
+
},
|
|
513
|
+
outputSchema: OUT,
|
|
514
|
+
handler: async (args, ctx) => {
|
|
515
|
+
const events = args.events;
|
|
516
|
+
validateImport(events);
|
|
517
|
+
return await request(ctx, "POST", "/import", { project_id: args.project_id, strict: "1" }, events);
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
];
|
|
521
|
+
return {
|
|
522
|
+
tools,
|
|
523
|
+
async testCredential(value, ctx) {
|
|
524
|
+
try {
|
|
525
|
+
await request({ ...ctx, credential: { get: async () => value, getAll: async () => ({ value }) } }, "GET", "/api/app/me");
|
|
526
|
+
return {
|
|
527
|
+
ok: true,
|
|
528
|
+
message: "Authenticated Mixpanel service account; list_projects returns its reachable project metadata.",
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
return {
|
|
533
|
+
ok: false,
|
|
534
|
+
message: error instanceof ConnectorCallError ? error.message : "Mixpanel rejected the service account.",
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
function validateImport(events) {
|
|
541
|
+
if (events.length < 1 || events.length > 2000)
|
|
542
|
+
invalid("Mixpanel import requires 1..2000 events.");
|
|
543
|
+
if (encoder.encode(JSON.stringify(events)).length > MAX_IMPORT_BYTES)
|
|
544
|
+
invalid("Mixpanel import is limited to 10MB of uncompressed JSON per batch.");
|
|
545
|
+
const walk = (value, depth) => {
|
|
546
|
+
if (Array.isArray(value)) {
|
|
547
|
+
if (value.length >= 255)
|
|
548
|
+
invalid("Mixpanel array properties must have fewer than 255 elements.");
|
|
549
|
+
for (const item of value)
|
|
550
|
+
walk(item, depth);
|
|
551
|
+
}
|
|
552
|
+
else if (record(value)) {
|
|
553
|
+
if (depth > 3)
|
|
554
|
+
invalid("Mixpanel nested object properties may be at most three levels deep.");
|
|
555
|
+
if (Object.keys(value).length >= 255)
|
|
556
|
+
invalid("Mixpanel objects must have fewer than 255 keys.");
|
|
557
|
+
for (const item of Object.values(value))
|
|
558
|
+
walk(item, depth + 1);
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
for (const event of events) {
|
|
562
|
+
if (!record(event) || !record(event.properties))
|
|
563
|
+
invalid("Each Mixpanel event requires a properties object.");
|
|
564
|
+
const props = event.properties;
|
|
565
|
+
if (typeof props.time !== "number" ||
|
|
566
|
+
!Number.isSafeInteger(props.time) ||
|
|
567
|
+
typeof props.distinct_id !== "string" ||
|
|
568
|
+
typeof props.$insert_id !== "string" ||
|
|
569
|
+
!/^[A-Za-z0-9-]{1,36}$/.test(props.$insert_id))
|
|
570
|
+
invalid("Mixpanel import needs integer time, string distinct_id and a 1..36-byte alphanumeric/hyphen $insert_id.");
|
|
571
|
+
const forbidden = new Set([
|
|
572
|
+
"00000000-0000-0000-0000-000000000000",
|
|
573
|
+
"anon",
|
|
574
|
+
"anonymous",
|
|
575
|
+
"nil",
|
|
576
|
+
"none",
|
|
577
|
+
"null",
|
|
578
|
+
"n/a",
|
|
579
|
+
"na",
|
|
580
|
+
"undefined",
|
|
581
|
+
"unknown",
|
|
582
|
+
"<nil>",
|
|
583
|
+
"0",
|
|
584
|
+
"-1",
|
|
585
|
+
"true",
|
|
586
|
+
"false",
|
|
587
|
+
"[]",
|
|
588
|
+
"{}",
|
|
589
|
+
]);
|
|
590
|
+
if (forbidden.has(props.distinct_id) || forbidden.has(props.$insert_id))
|
|
591
|
+
invalid("Mixpanel refuses this distinct_id or $insert_id placeholder value.");
|
|
592
|
+
if (encoder.encode(JSON.stringify(event)).length >= 1_000_000)
|
|
593
|
+
invalid("Each Mixpanel event must be smaller than 1MB of uncompressed JSON.");
|
|
594
|
+
walk(event.properties, 0);
|
|
595
|
+
const time = event.properties.time;
|
|
596
|
+
const earliest = Date.UTC(1971, 0, 1);
|
|
597
|
+
const latest = Date.now() + 3_600_000;
|
|
598
|
+
const seconds = time >= earliest / 1000 && time <= latest / 1000;
|
|
599
|
+
const milliseconds = time >= earliest && time <= latest;
|
|
600
|
+
if (!seconds && !milliseconds)
|
|
601
|
+
invalid("Mixpanel event time must be after 1971-01-01 and no more than one hour in the future.");
|
|
602
|
+
}
|
|
603
|
+
}
|