@zackbart/connecta 0.12.1 → 0.13.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 +173 -0
- package/README.md +4 -1
- package/dist/apps-shell.d.ts +13 -11
- package/dist/apps-shell.d.ts.map +1 -1
- package/dist/apps-shell.js +221 -30
- package/dist/apps-shell.js.map +1 -1
- package/dist/catalog-service.d.ts +41 -0
- package/dist/catalog-service.d.ts.map +1 -1
- package/dist/catalog-service.js +94 -5
- package/dist/catalog-service.js.map +1 -1
- package/dist/connectors/api.d.ts +5 -4
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/api.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +5 -4
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/execute.d.ts +12 -4
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +142 -20
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +14 -4
- package/dist/meta-tools.js.map +1 -1
- package/dist/providers/mixpanel.d.ts +21 -0
- package/dist/providers/mixpanel.d.ts.map +1 -0
- package/dist/providers/mixpanel.js +183 -0
- package/dist/providers/mixpanel.js.map +1 -0
- package/dist/skills.d.ts +7 -9
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +60 -25
- package/dist/skills.js.map +1 -1
- package/dist/types.d.ts +26 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/code-mode.md +32 -20
- package/documentation/connectors.md +116 -4
- package/documentation/mcp-ui-design.md +8 -8
- package/documentation/meta-tools.md +80 -8
- package/documentation/mixpanel.md +72 -0
- package/documentation/program-ui-read-calls.md +213 -0
- package/ethos.md +10 -4
- package/package.json +5 -1
- package/src/apps-shell.ts +221 -30
- package/src/catalog-service.ts +139 -4
- package/src/connectors/api.ts +5 -3
- package/src/connectors/remote-mcp.ts +5 -3
- package/src/execute.ts +215 -21
- package/src/index.ts +1 -0
- package/src/meta-tools.ts +19 -4
- package/src/providers/mixpanel.ts +220 -0
- package/src/skills.ts +66 -24
- package/src/types.ts +27 -6
- package/src/version.ts +1 -1
- package/templates/node/package.json +1 -1
package/src/execute.ts
CHANGED
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
InvocationService,
|
|
29
29
|
} from "./invocation.js";
|
|
30
30
|
import type { RegistryView } from "./registry.js";
|
|
31
|
+
import { hasConnectorGuides } from "./skills.js";
|
|
32
|
+
import { isExplicitlyReadOnly } from "./tool-safety.js";
|
|
31
33
|
import type {
|
|
32
34
|
Executor,
|
|
33
35
|
ExecutorProvider,
|
|
@@ -249,7 +251,23 @@ function requireEmittedBlock(raw: unknown): EmittedBlock {
|
|
|
249
251
|
}
|
|
250
252
|
|
|
251
253
|
const UI_SHAPE_HINT =
|
|
252
|
-
"connecta.ui accepts exactly one argument
|
|
254
|
+
"connecta.ui accepts exactly one HTML argument and, optionally, one read-binding options object";
|
|
255
|
+
|
|
256
|
+
const MAX_UI_READ_BINDINGS = 32;
|
|
257
|
+
const MAX_UI_VIEW_ARGS = 32;
|
|
258
|
+
const UI_READ_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
259
|
+
const FORBIDDEN_UI_KEY = new Set(["__proto__", "constructor", "prototype"]);
|
|
260
|
+
|
|
261
|
+
interface UiReadBinding {
|
|
262
|
+
address: string;
|
|
263
|
+
fixedArgs: Record<string, unknown>;
|
|
264
|
+
viewArgs: string[];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
interface UiPayload {
|
|
268
|
+
html: string;
|
|
269
|
+
reads?: Record<string, UiReadBinding>;
|
|
270
|
+
}
|
|
253
271
|
|
|
254
272
|
/** What the argument was, named the way the emit validator names a bad field. */
|
|
255
273
|
function describeUiArgument(raw: unknown): string {
|
|
@@ -262,9 +280,9 @@ function describeUiArgument(raw: unknown): string {
|
|
|
262
280
|
}
|
|
263
281
|
|
|
264
282
|
/**
|
|
265
|
-
* Strict U1 validation.
|
|
266
|
-
*
|
|
267
|
-
*
|
|
283
|
+
* Strict U1/V1 validation. The first argument remains HTML; the only second
|
|
284
|
+
* argument is one read-binding manifest. There are no alternate object or MCP
|
|
285
|
+
* block forms.
|
|
268
286
|
*/
|
|
269
287
|
function requireUiHtml(raw: unknown): string {
|
|
270
288
|
if (typeof raw !== "string" || raw.length === 0) {
|
|
@@ -273,6 +291,125 @@ function requireUiHtml(raw: unknown): string {
|
|
|
273
291
|
return raw;
|
|
274
292
|
}
|
|
275
293
|
|
|
294
|
+
function requireRecord(raw: unknown, label: string): Record<string, unknown> {
|
|
295
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
296
|
+
throw new Error(`${label} must be an object`);
|
|
297
|
+
}
|
|
298
|
+
return raw as Record<string, unknown>;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function requireExactKeys(
|
|
302
|
+
value: Record<string, unknown>,
|
|
303
|
+
allowed: readonly string[],
|
|
304
|
+
label: string,
|
|
305
|
+
): void {
|
|
306
|
+
const extras = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
307
|
+
if (extras.length > 0) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`${label} carries unsupported field(s) ${extras.map((key) => JSON.stringify(key)).join(", ")}`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function requireUiReadKey(raw: unknown, label: string): string {
|
|
315
|
+
if (
|
|
316
|
+
typeof raw !== "string" ||
|
|
317
|
+
raw.length === 0 ||
|
|
318
|
+
raw.length > 128 ||
|
|
319
|
+
FORBIDDEN_UI_KEY.has(raw)
|
|
320
|
+
) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`${label} must be a non-empty string of at most 128 characters and cannot be __proto__, constructor, or prototype`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
return raw;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function requireUiReads(raw: unknown): Record<string, UiReadBinding> {
|
|
329
|
+
const record = requireRecord(raw, "connecta.ui options.reads");
|
|
330
|
+
const names = Object.keys(record);
|
|
331
|
+
if (names.length === 0 || names.length > MAX_UI_READ_BINDINGS) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`connecta.ui options.reads must contain from 1 through ${MAX_UI_READ_BINDINGS} named bindings`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
const reads = Object.create(null) as Record<string, UiReadBinding>;
|
|
337
|
+
for (const name of names) {
|
|
338
|
+
if (!UI_READ_NAME.test(name) || FORBIDDEN_UI_KEY.has(name)) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`connecta.ui read binding name ${JSON.stringify(name)} must match ${UI_READ_NAME}`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const value = requireRecord(
|
|
344
|
+
record[name],
|
|
345
|
+
`connecta.ui read binding ${JSON.stringify(name)}`,
|
|
346
|
+
);
|
|
347
|
+
requireExactKeys(
|
|
348
|
+
value,
|
|
349
|
+
["address", "fixedArgs", "viewArgs"],
|
|
350
|
+
`connecta.ui read binding ${JSON.stringify(name)}`,
|
|
351
|
+
);
|
|
352
|
+
if (typeof value.address !== "string" || value.address.length === 0) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`connecta.ui read binding ${JSON.stringify(name)} address must be a non-empty string`,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
const fixedArgs =
|
|
358
|
+
value.fixedArgs === undefined
|
|
359
|
+
? {}
|
|
360
|
+
: requireRecord(
|
|
361
|
+
value.fixedArgs,
|
|
362
|
+
`connecta.ui read binding ${JSON.stringify(name)} fixedArgs`,
|
|
363
|
+
);
|
|
364
|
+
const rawViewArgs = value.viewArgs ?? [];
|
|
365
|
+
if (!Array.isArray(rawViewArgs) || rawViewArgs.length > MAX_UI_VIEW_ARGS) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`connecta.ui read binding ${JSON.stringify(name)} viewArgs must be an array of at most ${MAX_UI_VIEW_ARGS} strings`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
const viewArgs = rawViewArgs.map((key) =>
|
|
371
|
+
requireUiReadKey(
|
|
372
|
+
key,
|
|
373
|
+
`connecta.ui read binding ${JSON.stringify(name)} viewArgs entry`,
|
|
374
|
+
)
|
|
375
|
+
);
|
|
376
|
+
if (new Set(viewArgs).size !== viewArgs.length) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`connecta.ui read binding ${JSON.stringify(name)} viewArgs must not repeat a key`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
for (const key of viewArgs) {
|
|
382
|
+
if (Object.prototype.hasOwnProperty.call(fixedArgs, key)) {
|
|
383
|
+
throw new Error(
|
|
384
|
+
`connecta.ui read binding ${JSON.stringify(name)} view argument ${JSON.stringify(key)} cannot override a fixed argument`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
reads[name] = {
|
|
389
|
+
address: value.address,
|
|
390
|
+
fixedArgs,
|
|
391
|
+
viewArgs,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
return reads;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function requireUiPayload(values: unknown[]): UiPayload {
|
|
398
|
+
if (values.length !== 1 && values.length !== 2) {
|
|
399
|
+
throw new Error(
|
|
400
|
+
`${UI_SHAPE_HINT}; got ${values.length} arguments`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
const html = requireUiHtml(values[0]);
|
|
404
|
+
if (values.length === 1) return { html };
|
|
405
|
+
const options = requireRecord(values[1], "connecta.ui options");
|
|
406
|
+
requireExactKeys(options, ["reads"], "connecta.ui options");
|
|
407
|
+
if (!Object.prototype.hasOwnProperty.call(options, "reads")) {
|
|
408
|
+
throw new Error("connecta.ui options must contain reads");
|
|
409
|
+
}
|
|
410
|
+
return { html, reads: requireUiReads(options.reads) };
|
|
411
|
+
}
|
|
412
|
+
|
|
276
413
|
/**
|
|
277
414
|
* Request-local collection for `connecta.emit` and `connecta.ui`. Budgets fail
|
|
278
415
|
* loudly at the crossing call — nothing is partially accepted and prior blocks
|
|
@@ -288,7 +425,7 @@ export class EmitCollector {
|
|
|
288
425
|
/** The shared transport aggregate: emitted blocks plus the UI payload. */
|
|
289
426
|
bytes = 0;
|
|
290
427
|
/** The one accepted UI payload (U2), delivered in result `_meta` on success. */
|
|
291
|
-
ui?:
|
|
428
|
+
ui?: UiPayload;
|
|
292
429
|
/** What the blocks alone cost, so the `emitted` aggregate stays a true pair. */
|
|
293
430
|
private blockBytes = 0;
|
|
294
431
|
constructor(
|
|
@@ -328,14 +465,28 @@ export class EmitCollector {
|
|
|
328
465
|
* problem worth naming — that there is a second payload at all — and a
|
|
329
466
|
* complaint about its type would send the author to fix the wrong thing.
|
|
330
467
|
*/
|
|
331
|
-
acceptUi(
|
|
468
|
+
acceptUi(...values: unknown[]): void {
|
|
469
|
+
if (this.ui) {
|
|
470
|
+
throw new Error(
|
|
471
|
+
"connecta.ui accepts at most one payload per run: a view was already accepted and stands",
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
this.acceptUiPayload(requireUiPayload(values));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
acceptUiPayload(payload: UiPayload): void {
|
|
332
478
|
if (this.ui) {
|
|
333
479
|
throw new Error(
|
|
334
480
|
"connecta.ui accepts at most one payload per run: a view was already accepted and stands",
|
|
335
481
|
);
|
|
336
482
|
}
|
|
337
|
-
|
|
338
|
-
|
|
483
|
+
let serialized: string;
|
|
484
|
+
try {
|
|
485
|
+
serialized = JSON.stringify(payload);
|
|
486
|
+
} catch {
|
|
487
|
+
throw new Error("connecta.ui payload must be JSON-serializable");
|
|
488
|
+
}
|
|
489
|
+
const size = diagnosticsEncoder.encode(serialized).byteLength;
|
|
339
490
|
if (this.bytes + size > this.maxBytes) {
|
|
340
491
|
throw new Error(
|
|
341
492
|
`connecta.ui byte budget exceeded: payload is ${size} serialized bytes with ${this.maxBytes - this.bytes} of ${this.maxBytes} remaining`,
|
|
@@ -619,6 +770,41 @@ export async function buildSandboxProviders(
|
|
|
619
770
|
return outcome.value;
|
|
620
771
|
};
|
|
621
772
|
|
|
773
|
+
/**
|
|
774
|
+
* A read binding is admitted while the program still owns the request. The
|
|
775
|
+
* shell later calls the ordinary `call_tool`, which repeats this same
|
|
776
|
+
* fail-closed check against the then-current catalog; validating here keeps
|
|
777
|
+
* a typo or destructive address from producing a view whose controls can
|
|
778
|
+
* never work, while validation at use keeps a stale view from retaining old
|
|
779
|
+
* authority.
|
|
780
|
+
*/
|
|
781
|
+
const validateUiReads = async (payload: UiPayload): Promise<UiPayload> => {
|
|
782
|
+
if (!payload.reads) return payload;
|
|
783
|
+
const reads = Object.create(null) as Record<string, UiReadBinding>;
|
|
784
|
+
for (const [name, binding] of Object.entries(payload.reads)) {
|
|
785
|
+
const resolution = await catalog.resolveTool(
|
|
786
|
+
binding.address,
|
|
787
|
+
limits.signal !== undefined ? { signal: limits.signal } : {},
|
|
788
|
+
);
|
|
789
|
+
if (!resolution.ok) {
|
|
790
|
+
throw new Error(
|
|
791
|
+
`connecta.ui read binding ${JSON.stringify(name)} could not resolve ${JSON.stringify(binding.address)}: ${resolution.error.message}`,
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
if (!isExplicitlyReadOnly(resolution.resolved.definition)) {
|
|
795
|
+
throw new Error(
|
|
796
|
+
`connecta.ui read binding ${JSON.stringify(name)} refuses ${JSON.stringify(binding.address)}: the tool is not explicitly read-only`,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
reads[name] = {
|
|
800
|
+
...binding,
|
|
801
|
+
address:
|
|
802
|
+
`${resolution.resolved.connector.id}.${resolution.resolved.toolName}`,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
return { html: payload.html, reads };
|
|
806
|
+
};
|
|
807
|
+
|
|
622
808
|
return [
|
|
623
809
|
{
|
|
624
810
|
name: "connecta",
|
|
@@ -643,13 +829,14 @@ export async function buildSandboxProviders(
|
|
|
643
829
|
// reason (U7): one more provider fn, no change to ExecuteResult or
|
|
644
830
|
// the Executor contract. Delivery is the handler's job, not the
|
|
645
831
|
// guest's — nothing here becomes addressable.
|
|
646
|
-
ui: async (
|
|
832
|
+
ui: async (...values: unknown[]) => {
|
|
647
833
|
if (!limits.emitCollector) {
|
|
648
834
|
throw new Error(
|
|
649
835
|
"connecta.ui is unavailable: no emission collector was configured for this execution",
|
|
650
836
|
);
|
|
651
837
|
}
|
|
652
|
-
|
|
838
|
+
const payload = await validateUiReads(requireUiPayload(values));
|
|
839
|
+
limits.emitCollector.acceptUiPayload(payload);
|
|
653
840
|
},
|
|
654
841
|
batch: async (calls: unknown) => {
|
|
655
842
|
const started = Date.now();
|
|
@@ -1043,7 +1230,7 @@ export function createExecuteTool(
|
|
|
1043
1230
|
// _meta is where the Apps spec's best practices put data "not intended
|
|
1044
1231
|
// for model context", and how shipped hosts behave. The shell reads
|
|
1045
1232
|
// exactly this key out of the tool result the host delivers to it.
|
|
1046
|
-
response._meta = { [PROGRAM_UI_META_KEY]:
|
|
1233
|
+
response._meta = { [PROGRAM_UI_META_KEY]: emitted.ui };
|
|
1047
1234
|
}
|
|
1048
1235
|
if (emitted.blocks.length > 0) {
|
|
1049
1236
|
// Emitted image/audio blocks are valid MCP content that ToolResult's
|
|
@@ -1086,20 +1273,22 @@ function discardedEmitsText(emitted: EmitCollector): string {
|
|
|
1086
1273
|
|
|
1087
1274
|
const executeDescription = (
|
|
1088
1275
|
emitBudgets: { maxBytes: number; maxBlocks: number },
|
|
1089
|
-
|
|
1276
|
+
connectorGuides: boolean,
|
|
1277
|
+
) => `Choose the route before discovery. Exactly one unknown-address read uses top-level search_tools then call_tool; a known address uses call_tool directly. This is the primary surface for everything wider. If any result will be reduced — even from one connector call — or work has dependent/multiple calls, loops, joins, or branches, make exactly one execute_code call that searches, selects, calls, and reduces before returning. A discovery-only program wastes its round trip: finish here, don't return catalog matches for a later call. Only readOnlyHint: true tools are available. Limits: ${EXECUTE_MAX_HOST_CALLS} host calls per run, ${EXECUTE_MAX_BATCH_CALLS} per batch, ${EXECUTE_HOST_CALL_TIMEOUT_MS / 1_000}-second host deadline.
|
|
1090
1278
|
|
|
1091
1279
|
Write an async arrow function. It runs with NO network, filesystem, timers, or imports — the only capabilities are:
|
|
1092
|
-
-
|
|
1280
|
+
- Connector globals call <connectorId>.<toolName>(args) with one schema-matching args object. Sanitization: non-[A-Za-z0-9_$] → "_" (my-service.get.thing → my_service.get_thing), leading digit → "_" prefix, reserved word → "_" suffix.
|
|
1093
1281
|
- connecta.call(address, args) and connecta.batch(calls) — call raw addresses. Every batch entry is { address, ok: true, data } or { address, ok: false, error, errorDetails: { code, retryable } }; destructure that, not a bare result.
|
|
1094
|
-
- connecta.search(args) and connecta.describe
|
|
1095
|
-
- connecta.emit(block) —
|
|
1096
|
-
- connecta.ui(html) —
|
|
1282
|
+
- connecta.search(args) loads catalogs and must be followed by selection and calls in this program; set connector to the obvious id to load one, otherwise it loads all. For distinct operations, make separate short searches here. Require address/description to match the operation, then check requiredInputKeys, truncation, safety, and outputs; never take the first lexical or merely input-compatible match. Choose the best compatible match; do not require it to be the only match. Missing outputKeys means inspect outputSchema, not discard the candidate. Compatible means every required key has a task/prior-result value; do not prefer zero required keys. Put every requiredInputKey in call args. For dependencies, match an earlier outputKey to the later requiredInputKey. Use displayed names; [] means no required keys, not permission to invent args. Describe only a truncated/insufficient compact shape. Reducers use declared outputKeys, never guessed items/results roots. connecta.describe takes { address: "<connectorId>.<toolName>" } or { addresses: [...] }. Use safety: "readOnly" to avoid advertising calls this sandbox cannot execute; it changes results, not authority. A missing key list means a non-object shape, not no fields — read the schema.${connectorGuides ? " A match with guideRequired: true is a hard stop: do not call it; describing the exact schema clears only a schema_truncated reason, so for any other reason return the exact guide name, fetch that guide with the top-level skills tool, then write the informed call." : ""}
|
|
1283
|
+
- connecta.emit(block) — emit exactly { type: "text", text } or { type: "image" | "audio", data (base64), mimeType }. Success-only, no host call, ${emitBudgets.maxBlocks} blocks/${emitBudgets.maxBytes} bytes; invalid or over-budget throws before accepting.
|
|
1284
|
+
- connecta.ui(html, options?) — one success-only view. One arg is display-only; live reads use { reads: { name: { address, fixedArgs?, viewArgs? } } }, then markup calls connecta.read(name, args). Read admission is enforced; fixed keys cannot be overridden and undeclared keys fail. It shares the ${emitBudgets.maxBytes}-byte emit budget — one budget, not two; a second, over-budget, or invalid call throws catchably. Bytes stay out of context, so the model reads the return value, not the view: return the initial summary from its variables; later reads update only the view.
|
|
1097
1285
|
- console.log(...) — captured and returned with the result.
|
|
1098
1286
|
|
|
1099
|
-
|
|
1287
|
+
Dependent example (only when the second call requires a value returned by the first): async () => { const { tools } = await connecta.search({ query: "pipeline run job logs", safety: "readOnly", includeSchemas: "compact" }); const pick = (suffix) => { const match = tools.find((t) => t.address.endsWith(suffix)); if (!match) throw new Error("no tool for " + suffix); return match.address; }; const run = await connecta.call(pick(".get_run"), { runId: 42 }); const logs = await connecta.call(pick(".get_job_logs"), { jobId: run.failedJobId }); return [run, logs]; }
|
|
1288
|
+
|
|
1289
|
+
Calls return plain values (JSON-parsing MCP text when possible) and throw; catch errors. A thrown error is only a message; connecta.batch tells a policy refusal from a transient failure. Never retry retryable: false or rate_limited immediately — there are no timers. Return JSON; large results truncate, so reduce instead of returning raw payloads.
|
|
1100
1290
|
|
|
1101
|
-
Plain
|
|
1102
|
-
Dependent example (only when the second call requires a value returned by the first): async () => { const { tools } = await connecta.search({ query: "pipeline run job logs", safety: "readOnly", includeSchemas: "compact" }); const pick = (suffix) => { const match = tools.find((t) => t.address.endsWith(suffix)); if (!match) throw new Error("no tool for " + suffix); return match.address; }; const run = await connecta.call(pick(".get_run"), { runId: 42 }); const logs = await connecta.call(pick(".get_job_logs"), { jobId: run.failedJobId }); return [run, logs]; }`;
|
|
1291
|
+
Plain JS, no TypeScript. Compact schemas are TypeScript-like, not JSON Schema: write the property names they display; never guess positions or aliases.`;
|
|
1103
1292
|
|
|
1104
1293
|
/** Register the execute_code meta-tool. Only called when an executor is configured. */
|
|
1105
1294
|
export function registerExecuteTool(
|
|
@@ -1154,11 +1343,16 @@ export function registerExecuteTool(
|
|
|
1154
1343
|
server.registerTool(
|
|
1155
1344
|
"execute_code",
|
|
1156
1345
|
{
|
|
1157
|
-
description: executeDescription(
|
|
1346
|
+
description: executeDescription(
|
|
1347
|
+
emitBudgets,
|
|
1348
|
+
hasConnectorGuides(registry.listConnectors()),
|
|
1349
|
+
),
|
|
1158
1350
|
inputSchema: z.object({
|
|
1159
1351
|
code: z
|
|
1160
1352
|
.string()
|
|
1161
|
-
.describe(
|
|
1353
|
+
.describe(
|
|
1354
|
+
"One complete JavaScript async arrow function. Consume search/describe results and finish the task inside it; returning catalog data for a later call spends a round trip and buys nothing.",
|
|
1355
|
+
),
|
|
1162
1356
|
diagnostics: z
|
|
1163
1357
|
.boolean()
|
|
1164
1358
|
.optional()
|
package/src/index.ts
CHANGED
package/src/meta-tools.ts
CHANGED
|
@@ -1372,7 +1372,7 @@ export function createMetaTools(
|
|
|
1372
1372
|
};
|
|
1373
1373
|
}
|
|
1374
1374
|
|
|
1375
|
-
const SEARCH_DESC = `
|
|
1375
|
+
const SEARCH_DESC = `Use top-level search only for exactly one unreduced read, then call_tool, or for write-capable work, then call_destructive_tool. For read-only reduction, dependent or multiple calls, never search here: make one execute_code program that searches and calls. Use 2–4 distinctive action/object terms, not the full request; set connector to the obvious integration id to load one catalog instead of all; omit limit initially (default ${DEFAULT_SEARCH_LIMIT}), page to ${MAX_SEARCH_LIMIT} if needed. safety="readOnly" returns only calls available to call_tool/code; "approvalRequired" returns the rest; omitted/"all" returns all. This filters results, not authority. includeSchemas="compact" adds the input and any declared output shape, bounded; plain objects expose inputKeys, requiredInputKeys, and outputKeys; truncation flags mark incomplete shapes; matches also carry declared annotations. Require purpose/address fit plus compatible inputs, truncation, safety, and outputs — never the first lexical match. Empty query browses all.`;
|
|
1376
1376
|
const CALL_DESC =
|
|
1377
1377
|
'Use for ONE tool explicitly annotated readOnlyHint: true — the cheapest path for a single cold call. For two or more calls, dependent steps, loops, joins, or data reduction use execute_code, whose connecta.call and connecta.batch reach the same tools. Unannotated, write-capable, and destructive tools are refused and require call_destructive_tool. fields selects JSON dot-paths; traverse arrays with [] (for example results[].id). Misses return data plus `$connecta` feedback. resultMode "value" unwraps results, timeoutMs sets a deadline, safe maxRetries are annotation-gated, diagnostics adds timing, and large results page through get_result.';
|
|
1378
1378
|
const CALL_DESTRUCTIVE_DESC =
|
|
@@ -1396,9 +1396,11 @@ const SEARCH_WITH_DESCRIBE_DESC = `${SEARCH_DESC} Expand an ambiguous compact sh
|
|
|
1396
1396
|
*/
|
|
1397
1397
|
const GUIDE_NOTES = {
|
|
1398
1398
|
skills:
|
|
1399
|
-
|
|
1399
|
+
" skills({}) also lists this deployment's scoped connector guides; fetch only an exact name listed there or carried by discovery, never one inferred from a connector id.",
|
|
1400
1400
|
search:
|
|
1401
|
-
" A
|
|
1401
|
+
" A result carrying `guide` also carries a bounded `guideSummary`. `guideRequired: true` is a hard stop: fetch that exact guide before calling. `guideRequiredReasons` explains why — `connector_required` and `approval_required` stand however you expand the schema; `schema_truncated` clears once describe returns the exact one. Otherwise fetch only when the summary names a connector convention relevant to the task. A complete, unambiguous read-only schema needs no otherwise-irrelevant guide fetch.",
|
|
1402
|
+
destructive:
|
|
1403
|
+
" Before a consequential call, inspect the address through discovery or describe and fetch any connector guide it names.",
|
|
1402
1404
|
} as const;
|
|
1403
1405
|
|
|
1404
1406
|
/** `base`, plus its guide note when any VISIBLE connector carries a guide. */
|
|
@@ -1484,6 +1486,7 @@ export function registerMetaTools(
|
|
|
1484
1486
|
description: describedFor(registry, SKILLS_DESC, "skills"),
|
|
1485
1487
|
inputSchema: z.object({ name: z.string().optional() }),
|
|
1486
1488
|
annotations: READ_ONLY_LOCAL,
|
|
1489
|
+
_meta: { ui: { visibility: ["model"] } },
|
|
1487
1490
|
},
|
|
1488
1491
|
async (args) => mt.skills(args as SkillArgs),
|
|
1489
1492
|
);
|
|
@@ -1508,6 +1511,7 @@ export function registerMetaTools(
|
|
|
1508
1511
|
includeSchemas: z.enum(["compact", "json"]).optional(),
|
|
1509
1512
|
}),
|
|
1510
1513
|
annotations: READ_ONLY_REMOTE,
|
|
1514
|
+
_meta: { ui: { visibility: ["model"] } },
|
|
1511
1515
|
},
|
|
1512
1516
|
async (args) => mt.searchTools(args as SearchArgs),
|
|
1513
1517
|
);
|
|
@@ -1520,6 +1524,10 @@ export function registerMetaTools(
|
|
|
1520
1524
|
// call_tool admits only tools that are themselves explicitly read-only;
|
|
1521
1525
|
// anything else is refused and routed to call_destructive_tool.
|
|
1522
1526
|
annotations: READ_ONLY_REMOTE,
|
|
1527
|
+
// The trusted program-view shell delegates bounded named reads here.
|
|
1528
|
+
// It is already one of the seven model tools; app visibility adds no
|
|
1529
|
+
// tool and this handler repeats ordinary fail-closed read admission.
|
|
1530
|
+
_meta: { ui: { visibility: ["model", "app"] } },
|
|
1523
1531
|
},
|
|
1524
1532
|
async (args) => mt.callTool(args as CallArgs),
|
|
1525
1533
|
);
|
|
@@ -1527,7 +1535,11 @@ export function registerMetaTools(
|
|
|
1527
1535
|
server.registerTool(
|
|
1528
1536
|
"call_destructive_tool",
|
|
1529
1537
|
{
|
|
1530
|
-
description:
|
|
1538
|
+
description: describedFor(
|
|
1539
|
+
registry,
|
|
1540
|
+
CALL_DESTRUCTIVE_DESC,
|
|
1541
|
+
"destructive",
|
|
1542
|
+
),
|
|
1531
1543
|
inputSchema: z.object({
|
|
1532
1544
|
...CALL_INPUT_SCHEMA,
|
|
1533
1545
|
// Bounded above, but with no lower bound: a model that sends `""` or
|
|
@@ -1541,6 +1553,7 @@ export function registerMetaTools(
|
|
|
1541
1553
|
readOnlyHint: false,
|
|
1542
1554
|
openWorldHint: true,
|
|
1543
1555
|
},
|
|
1556
|
+
_meta: { ui: { visibility: ["model"] } },
|
|
1544
1557
|
},
|
|
1545
1558
|
async (args) => {
|
|
1546
1559
|
// `reason` is the host's to display and connecta's to keep out of the
|
|
@@ -1568,6 +1581,7 @@ export function registerMetaTools(
|
|
|
1568
1581
|
destructiveHint: false,
|
|
1569
1582
|
openWorldHint: true,
|
|
1570
1583
|
},
|
|
1584
|
+
_meta: { ui: { visibility: ["model"] } },
|
|
1571
1585
|
},
|
|
1572
1586
|
async (args) => mt.authorizeConnector(args as AuthorizeArgs),
|
|
1573
1587
|
);
|
|
@@ -1586,6 +1600,7 @@ export function registerMetaTools(
|
|
|
1586
1600
|
maxBytes: z.number().int().min(MIN_MAX_RESULT_BYTES).optional(),
|
|
1587
1601
|
}),
|
|
1588
1602
|
annotations: READ_ONLY_LOCAL,
|
|
1603
|
+
_meta: { ui: { visibility: ["model"] } },
|
|
1589
1604
|
},
|
|
1590
1605
|
async (args) => mt.getResult(args as GetResultArgs),
|
|
1591
1606
|
);
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import {
|
|
2
|
+
remoteMcp,
|
|
3
|
+
type RemoteMcpAuth,
|
|
4
|
+
} from "../connectors/remote-mcp.js";
|
|
5
|
+
import type {
|
|
6
|
+
Connector,
|
|
7
|
+
ConnectorCallAdmissionPolicy,
|
|
8
|
+
ToolDef,
|
|
9
|
+
} from "../types.js";
|
|
10
|
+
|
|
11
|
+
export type MixpanelRegion = "us" | "eu" | "in";
|
|
12
|
+
|
|
13
|
+
export const MIXPANEL_MCP_ENDPOINTS: Readonly<
|
|
14
|
+
Record<MixpanelRegion, string>
|
|
15
|
+
> = {
|
|
16
|
+
us: "https://mcp.mixpanel.com/mcp",
|
|
17
|
+
eu: "https://mcp-eu.mixpanel.com/mcp",
|
|
18
|
+
in: "https://mcp-in.mixpanel.com/mcp",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface MixpanelOptions {
|
|
22
|
+
/** Human-readable display name; defaults to "Mixpanel". */
|
|
23
|
+
title?: string;
|
|
24
|
+
/** Who should use this account and for what decisions. */
|
|
25
|
+
purpose: string;
|
|
26
|
+
/** Mixpanel data residency region. Defaults to "us". */
|
|
27
|
+
region?: MixpanelRegion;
|
|
28
|
+
/** OAuth by default; static headers support Mixpanel service accounts. */
|
|
29
|
+
auth?: RemoteMcpAuth;
|
|
30
|
+
/** Account-specific conventions appended to the maintained provider guide. */
|
|
31
|
+
instructions?: string;
|
|
32
|
+
/** Connector-specific inline result limit; omit to inherit the deployment. */
|
|
33
|
+
maxResultBytes?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Budget-only: a rejection computes its own retry-after from the window, and
|
|
37
|
+
// declaring `retryAfterMs` here would be a queue setting without a queue —
|
|
38
|
+
// which the admission controller refuses at construction.
|
|
39
|
+
const MIXPANEL_ADMISSION: ConnectorCallAdmissionPolicy = {
|
|
40
|
+
rules: [
|
|
41
|
+
{
|
|
42
|
+
budget: {
|
|
43
|
+
kind: "rolling-window",
|
|
44
|
+
maxCalls: 600,
|
|
45
|
+
windowMs: 3_600_000,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Tools whose official contract is observational rather than mutating. */
|
|
52
|
+
const READ_ONLY_TOOLS = new Set([
|
|
53
|
+
"Run-Query",
|
|
54
|
+
"Get-Query-Schema",
|
|
55
|
+
"Get-Report",
|
|
56
|
+
"Display-Query",
|
|
57
|
+
"List-Dashboards",
|
|
58
|
+
"Get-Dashboard",
|
|
59
|
+
"Get-Business-Context",
|
|
60
|
+
"Get-Projects",
|
|
61
|
+
"List-Organizations",
|
|
62
|
+
"Get-Events",
|
|
63
|
+
"List-Properties",
|
|
64
|
+
"Get-Property-Values",
|
|
65
|
+
"Search-Entities",
|
|
66
|
+
"Get-Issues",
|
|
67
|
+
"Get-Lexicon-URL",
|
|
68
|
+
"Find-Duplicate-Groups",
|
|
69
|
+
"Get-Custom-Property",
|
|
70
|
+
"Get-Cohort",
|
|
71
|
+
"List-Cohorts",
|
|
72
|
+
"Describe-Cohort-Schema",
|
|
73
|
+
"Get-Lookup-Table",
|
|
74
|
+
"Get-Metric",
|
|
75
|
+
"List-Metrics",
|
|
76
|
+
"Get-User-Replays-Data",
|
|
77
|
+
"List-Experiments",
|
|
78
|
+
"Get-Experiment",
|
|
79
|
+
"Get-Experiment-Setup-Guidance",
|
|
80
|
+
"Get-Experiment-Results-Interpretation-Guidance",
|
|
81
|
+
"Explain-Experiment-Health-Check",
|
|
82
|
+
"Run-Experiment-Pre-Launch-Checks",
|
|
83
|
+
"Search-Prior-Experiments",
|
|
84
|
+
"List-Feature-Flags",
|
|
85
|
+
"Get-Feature-Flag",
|
|
86
|
+
"Get-Feature-Flag-Setup-Guidance",
|
|
87
|
+
"Get-Feature-Flag-Lifecycle-Guidance",
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The maintained write catalog. `"destructive"` tools modify or remove state
|
|
92
|
+
* that already exists; `"additive"` ones only bring something new into being.
|
|
93
|
+
* Both leave the read-only path — the distinction only decides whether the
|
|
94
|
+
* connection asserts `destructiveHint`, which shapes the host's approval copy.
|
|
95
|
+
*/
|
|
96
|
+
const WRITE_TOOLS: ReadonlyMap<string, "additive" | "destructive"> = new Map([
|
|
97
|
+
["Create-Dashboard", "additive"],
|
|
98
|
+
["Update-Dashboard", "destructive"],
|
|
99
|
+
["Duplicate-Dashboard", "additive"],
|
|
100
|
+
["Delete-Dashboard", "destructive"],
|
|
101
|
+
["Edit-Event", "destructive"],
|
|
102
|
+
["Edit-Property", "destructive"],
|
|
103
|
+
["Bulk-Edit-Events", "destructive"],
|
|
104
|
+
["Bulk-Edit-Properties", "destructive"],
|
|
105
|
+
["Create-Tag", "additive"],
|
|
106
|
+
["Rename-Tag", "destructive"],
|
|
107
|
+
["Delete-Tag", "destructive"],
|
|
108
|
+
["Dismiss-Issues", "destructive"],
|
|
109
|
+
["Update-Business-Context", "destructive"],
|
|
110
|
+
["Dismiss-Duplicate-Group", "destructive"],
|
|
111
|
+
["Merge-Group", "destructive"],
|
|
112
|
+
["Create-Custom-Property", "additive"],
|
|
113
|
+
["Update-Custom-Property", "destructive"],
|
|
114
|
+
["Create-Cohort", "additive"],
|
|
115
|
+
["Update-Cohort", "destructive"],
|
|
116
|
+
["Delete-Cohort", "destructive"],
|
|
117
|
+
["Create-Lookup-Table", "additive"],
|
|
118
|
+
["Update-Lookup-Table", "destructive"],
|
|
119
|
+
["Create-Metric", "additive"],
|
|
120
|
+
["Update-Metric", "destructive"],
|
|
121
|
+
["Create-Experiment", "additive"],
|
|
122
|
+
["Update-Experiment", "destructive"],
|
|
123
|
+
["Create-Feature-Flag", "additive"],
|
|
124
|
+
["Update-Feature-Flag", "destructive"],
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Fill in what the downstream leaves unsaid; never argue with what it says.
|
|
129
|
+
*
|
|
130
|
+
* A vetted classification may always tighten — that direction only ever routes
|
|
131
|
+
* more calls through `call_destructive_tool`. Loosening is the direction that
|
|
132
|
+
* needs the downstream's silence: an explicit `destructiveHint: true` or
|
|
133
|
+
* `readOnlyHint: false` on an allowlisted read name is the downstream telling
|
|
134
|
+
* us this release's allowlist is wrong, and it wins.
|
|
135
|
+
*/
|
|
136
|
+
function vettedSafety(definition: ToolDef): ToolDef {
|
|
137
|
+
const downstream = definition.annotations ?? {};
|
|
138
|
+
if (READ_ONLY_TOOLS.has(definition.name)) {
|
|
139
|
+
if (
|
|
140
|
+
downstream.destructiveHint === true ||
|
|
141
|
+
downstream.readOnlyHint === false
|
|
142
|
+
) {
|
|
143
|
+
return definition;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
...definition,
|
|
147
|
+
annotations: {
|
|
148
|
+
...downstream,
|
|
149
|
+
readOnlyHint: true,
|
|
150
|
+
destructiveHint: downstream.destructiveHint ?? false,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (WRITE_TOOLS.get(definition.name) === "destructive") {
|
|
155
|
+
return {
|
|
156
|
+
...definition,
|
|
157
|
+
annotations: {
|
|
158
|
+
...downstream,
|
|
159
|
+
readOnlyHint: false,
|
|
160
|
+
destructiveHint: true,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// Maintained additive creates and tools this release has never seen land
|
|
165
|
+
// here alike: not read-only, so the ordinary fail-closed path keeps them
|
|
166
|
+
// approval-visible, without claiming a create destroys anything.
|
|
167
|
+
return {
|
|
168
|
+
...definition,
|
|
169
|
+
annotations: {
|
|
170
|
+
...downstream,
|
|
171
|
+
readOnlyHint: false,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function usageGuide(purpose: string, instructions: string | undefined): string {
|
|
177
|
+
const accountInstructions = instructions?.trim();
|
|
178
|
+
return `# Mixpanel usage
|
|
179
|
+
|
|
180
|
+
Account purpose: ${purpose}
|
|
181
|
+
|
|
182
|
+
- Start with \`Get-Projects\`, then use \`Get-Business-Context\` for the selected project before interpreting its events or metrics.
|
|
183
|
+
- Discover names with \`Get-Events\`, \`List-Properties\`, and \`Get-Property-Values\`; do not guess event or property spelling.
|
|
184
|
+
- For a new analysis, fetch \`Get-Query-Schema\` before \`Run-Query\`. Reduce query results inside \`execute_code\` before returning them.
|
|
185
|
+
- Use \`Get-Report\` when the request names an existing saved report. Use \`Run-Query\` for a new question.
|
|
186
|
+
- Mixpanel limits MCP traffic to 600 requests per user per hour. Reuse discovery results within a run and avoid speculative fan-out.
|
|
187
|
+
- Treat every create, update, edit, merge, dismiss, duplicate, or delete operation as a write. Connecta routes the maintained write catalog through \`call_destructive_tool\`; newly added tools also fail closed until classified.
|
|
188
|
+
${
|
|
189
|
+
accountInstructions
|
|
190
|
+
? `\n## Account instructions\n\n${accountInstructions}\n`
|
|
191
|
+
: ""
|
|
192
|
+
}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A maintained Mixpanel hosted-MCP connection. */
|
|
196
|
+
export function mixpanel(id: string, options: MixpanelOptions): Connector {
|
|
197
|
+
const purpose = options.purpose.trim();
|
|
198
|
+
if (!purpose) {
|
|
199
|
+
throw new Error("mixpanel() requires a non-empty account purpose.");
|
|
200
|
+
}
|
|
201
|
+
const region = options.region ?? "us";
|
|
202
|
+
const connector = remoteMcp(id, {
|
|
203
|
+
url: MIXPANEL_MCP_ENDPOINTS[region],
|
|
204
|
+
title: options.title ?? "Mixpanel",
|
|
205
|
+
description: `Mixpanel product analytics — ${purpose}`,
|
|
206
|
+
auth: options.auth ?? { type: "oauth" },
|
|
207
|
+
requireHttps: true,
|
|
208
|
+
callAdmission: MIXPANEL_ADMISSION,
|
|
209
|
+
usageGuide: usageGuide(purpose, options.instructions),
|
|
210
|
+
...(options.maxResultBytes !== undefined
|
|
211
|
+
? { maxResultBytes: options.maxResultBytes }
|
|
212
|
+
: {}),
|
|
213
|
+
});
|
|
214
|
+
return {
|
|
215
|
+
...connector,
|
|
216
|
+
async listTools(ctx) {
|
|
217
|
+
return (await connector.listTools(ctx)).map(vettedSafety);
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|