@glubean/mcp 0.8.1 → 0.9.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/dist/index.js CHANGED
@@ -4,7 +4,8 @@
4
4
  * Purpose:
5
5
  * - Let AI agents (Cursor, etc.) run verification-as-code locally
6
6
  * - Fetch structured failures (assertions/logs/traces) for automatic fixing
7
- * - Optionally trigger/tail remote runs via Glubean Open Platform APIs
7
+ * - Optionally report runs to Glubean Cloud via the `/v1/*` ingest contract
8
+ * (the same contract `glubean run --upload` uses — see ./cloud.ts)
8
9
  *
9
10
  * IMPORTANT (stdio transport):
10
11
  * - Never write to stdout. Use stderr for logs.
@@ -15,7 +16,7 @@ import { z } from "zod";
15
16
  import { basename, dirname, resolve } from "node:path";
16
17
  import { readFile, stat } from "node:fs/promises";
17
18
  import { parse as parseYaml } from "yaml";
18
- import { createHash } from "node:crypto";
19
+ import { createHash, randomUUID } from "node:crypto";
19
20
  import { pathToFileURL } from "node:url";
20
21
  import { applyEnvTemplating, bootstrap, loadProjectEnv, LOCAL_RUN_DEFAULTS, ProjectRunner, TestExecutor } from "@glubean/runner";
21
22
  import { renderArtifact, openapiArtifact } from "@glubean/sdk";
@@ -24,6 +25,7 @@ import { extractContractCases } from "@glubean/scanner/static";
24
25
  import { extractContractFromFile as sharedExtractFromFile, extractContractsFromProject as sharedExtractFromProject, } from "@glubean/scanner";
25
26
  import { MCP_PACKAGE_VERSION, DEFAULT_GENERATED_BY } from "./version.js";
26
27
  import { checkSdkCompat } from "./version-compat.js";
28
+ import { buildRunIngestBody, cloudFetchJson, envLabelFromEnvFile, loadUploadRedaction, MISSING_AUTH_MESSAGES, resolveCloudAuth, resolveDefaultTargetId, runIngestUrl, runTestEventsUrl, runTestResultsUrl, runUrl, } from "./cloud.js";
27
29
  const METADATA_SCHEMA_VERSION = "1";
28
30
  function toLegacyHttpContract(c) {
29
31
  // Support BOTH shapes during transitional P4:
@@ -189,15 +191,51 @@ async function readActiveEnv(projectRoot) {
189
191
  return undefined;
190
192
  }
191
193
  }
194
+ // GLU-88: mirrors packages/cli/src/lib/active_env.ts's SENSITIVE_ENV_NAMES
195
+ // guard. `.glubean/active-env` is a persistent, un-TTL'd, un-warned sticky
196
+ // file — an agent (or a human) that once ran `glubean env use prod` here
197
+ // leaves every subsequent MCP tool call silently pointed at prod until
198
+ // someone runs `glubean env reset`. Kept as a small local duplicate (not a
199
+ // cross-package import) because @glubean/mcp does not depend on
200
+ // @glubean/cli and shouldn't gain that dependency just for this helper.
201
+ const SENSITIVE_ENV_NAMES = new Set(["prod", "production"]);
202
+ function isSensitiveEnvName(name) {
203
+ return SENSITIVE_ENV_NAMES.has(name.trim().toLowerCase());
204
+ }
205
+ /**
206
+ * Thrown by `resolveEnvPath` when the active env resolves to a sensitive
207
+ * (prod-like) name and no explicit `envFile` was given. Callers should let
208
+ * this propagate — the MCP SDK surfaces thrown errors as a tool error
209
+ * response, which is exactly the "loud failure instead of silent prod
210
+ * upload" behavior GLU-88 requires.
211
+ */
212
+ export class SensitiveActiveEnvError extends Error {
213
+ envName;
214
+ constructor(envName) {
215
+ super(`Active environment is "${envName}" (set via \`glubean env use ${envName}\`, ` +
216
+ `recorded in .glubean/active-env), which looks like a production ` +
217
+ `environment. Refusing to load it implicitly — pass an explicit envFile ` +
218
+ `(.env.${envName}) to use it, or run \`glubean env reset\` to clear the ` +
219
+ `active environment and fall back to .env.`);
220
+ this.envName = envName;
221
+ this.name = "SensitiveActiveEnvError";
222
+ }
223
+ }
192
224
  /**
193
225
  * Resolve the env file path, checking `.glubean/active-env` when no explicit envFile is given.
226
+ * Throws `SensitiveActiveEnvError` instead of silently resolving a prod-like
227
+ * active-env (GLU-88) — explicit `envFile` always bypasses this check.
194
228
  */
195
- async function resolveEnvPath(projectRoot, envFile) {
229
+ export async function resolveEnvPath(projectRoot, envFile) {
196
230
  if (envFile)
197
231
  return resolve(envFile);
198
232
  const activeEnv = await readActiveEnv(projectRoot);
199
- if (activeEnv)
233
+ if (activeEnv) {
234
+ if (isSensitiveEnvName(activeEnv)) {
235
+ throw new SensitiveActiveEnvError(activeEnv);
236
+ }
200
237
  return resolve(projectRoot, `.env.${activeEnv}`);
238
+ }
201
239
  return resolve(projectRoot, ".env");
202
240
  }
203
241
  function normalizeFilePath(path) {
@@ -603,6 +641,7 @@ export async function runLocalTestsFromFile(args) {
603
641
  secrets,
604
642
  results: [],
605
643
  summary: { total: 0, passed: 0, failed: 0, skipped: 0 },
644
+ envPath,
606
645
  error: tests.length === 0
607
646
  ? "No tests discovered in file. Check that exports use test() or contract.http.with() from @glubean/sdk."
608
647
  : `No tests matched filter "${args.filter}". Available: ${tests.map((t) => t.id).join(", ")}`,
@@ -662,6 +701,7 @@ export async function runLocalTestsFromFile(args) {
662
701
  secrets,
663
702
  results: [],
664
703
  summary: { total: 0, passed: 0, failed: 0, skipped: 0 },
704
+ envPath,
665
705
  error: "inputJson and bootstrapInput are mutually exclusive. " +
666
706
  "Per attachment-model §5.1: explicit input bypasses the overlay, so bootstrap params would be ignored. Pick one channel per run.",
667
707
  versionInfo,
@@ -675,6 +715,7 @@ export async function runLocalTestsFromFile(args) {
675
715
  secrets,
676
716
  results: [],
677
717
  summary: { total: 0, passed: 0, failed: 0, skipped: 0 },
718
+ envPath,
678
719
  error: `inputJson / bootstrapInput / forceStandalone require \`filter\` ` +
679
720
  `to match exactly one testId. Matched ${selected.length} tests` +
680
721
  (selected.length > 1
@@ -899,31 +940,12 @@ export async function runLocalTestsFromFile(args) {
899
940
  secrets,
900
941
  results,
901
942
  summary: { total: results.length, passed, failed, skipped: skippedCount },
943
+ envPath,
902
944
  ...(orchestrationError !== undefined && { error: orchestrationError }),
903
945
  ...(warningsArr.length > 0 && { warnings: warningsArr }),
904
946
  versionInfo,
905
947
  };
906
948
  }
907
- function bearerHeaders(token) {
908
- if (!token)
909
- return {};
910
- return { Authorization: `Bearer ${token}` };
911
- }
912
- async function fetchJson(url, init) {
913
- const res = await fetch(url, init);
914
- const text = await res.text();
915
- if (!res.ok) {
916
- throw new Error(`HTTP ${res.status} ${res.statusText}: ${text.slice(0, 2000)}`);
917
- }
918
- if (!text)
919
- return null;
920
- try {
921
- return JSON.parse(text);
922
- }
923
- catch {
924
- return text;
925
- }
926
- }
927
949
  const server = new McpServer({
928
950
  name: "glubean",
929
951
  version: MCP_PACKAGE_VERSION,
@@ -939,7 +961,7 @@ export const MCP_TOOL_NAMES = {
939
961
  openapi: "glubean_openapi",
940
962
  diagnoseConfig: "glubean_diagnose_config",
941
963
  getMetadata: "glubean_get_metadata",
942
- openTriggerRun: "glubean_open_trigger_run",
964
+ openUploadRun: "glubean_open_upload_run",
943
965
  openGetRun: "glubean_open_get_run",
944
966
  openGetRunEvents: "glubean_open_get_run_events",
945
967
  };
@@ -1010,6 +1032,9 @@ server.registerTool(MCP_TOOL_NAMES.runLocalFile, {
1010
1032
  .describe("DEBUG: bypass `runnability.requireAttachment` for the filtered case. Author-debug only; runtime emits a warning."),
1011
1033
  },
1012
1034
  }, async (input) => {
1035
+ // Recorded BEFORE execution — the snapshot's honest startedAt for Cloud
1036
+ // upload (createdAt below is the run's END; codex GLU-77 R1 P2).
1037
+ const runStartedAt = new Date().toISOString();
1013
1038
  const result = await runLocalTestsFromFile({
1014
1039
  filePath: input.filePath,
1015
1040
  filter: input.filter,
@@ -1047,6 +1072,8 @@ server.registerTool(MCP_TOOL_NAMES.runLocalFile, {
1047
1072
  }
1048
1073
  lastLocalRunSnapshot = {
1049
1074
  createdAt: new Date().toISOString(),
1075
+ startedAt: runStartedAt,
1076
+ clientRunId: randomUUID(),
1050
1077
  fileUrl: result.fileUrl,
1051
1078
  projectRoot: result.projectRoot,
1052
1079
  summary: result.summary,
@@ -1054,6 +1081,13 @@ server.registerTool(MCP_TOOL_NAMES.runLocalFile, {
1054
1081
  includeLogs: input.includeLogs ?? true,
1055
1082
  includeTraces: input.includeTraces ?? false,
1056
1083
  filter: input.filter,
1084
+ // The RESOLVED env path the run used (handles .glubean/active-env) —
1085
+ // NOT the raw input, so a later upload can't re-resolve to a DIFFERENT
1086
+ // active env than the run's (codex GLU-77 R3 P2). Falls back to the raw
1087
+ // input only on the version-skew early return (no run happened).
1088
+ ...(result.envPath ?? input.envFile
1089
+ ? { envFile: result.envPath ?? input.envFile }
1090
+ : {}),
1057
1091
  };
1058
1092
  return {
1059
1093
  content: [{ type: "text", text: JSON.stringify(safe) }],
@@ -1309,83 +1343,225 @@ server.registerTool(MCP_TOOL_NAMES.getMetadata, {
1309
1343
  ],
1310
1344
  };
1311
1345
  });
1312
- server.registerTool(MCP_TOOL_NAMES.openTriggerRun, {
1313
- description: "Trigger a remote run via Glubean Open Platform API (POST /open/v1/runs).",
1346
+ // =============================================================================
1347
+ // Cloud open* tools /v1 ingest contract (GLU-77)
1348
+ //
1349
+ // The legacy Open Platform (`/open/v1/*` — server-side bundle execution) was
1350
+ // retired with the old stack. These tools speak the new platform API: runs
1351
+ // execute LOCALLY (glubean_run_local_file), then results are reported via
1352
+ // `POST /v1/projects/{projectId}/targets/{targetId}/runs` — the same ingest
1353
+ // contract and credential conventions as `glubean run --upload`.
1354
+ // =============================================================================
1355
+ const OPEN_AUTH_INPUT_SCHEMA = {
1356
+ apiUrl: z
1357
+ .string()
1358
+ .optional()
1359
+ .describe("Platform API base URL (default: GLUBEAN_API_URL, or https://api.glubean.com)"),
1360
+ token: z
1361
+ .string()
1362
+ .optional()
1363
+ .describe("API token (default: GLUBEAN_TOKEN from process env or .env.secrets, else ~/.glubean/credentials.json)"),
1364
+ projectId: z
1365
+ .string()
1366
+ .optional()
1367
+ .describe("Project id (default: GLUBEAN_PROJECT_ID from process env or .env, else ~/.glubean/credentials.json)"),
1368
+ targetId: z
1369
+ .string()
1370
+ .optional()
1371
+ .describe("Target id within the project (default: GLUBEAN_TARGET_ID, else the project's default target)"),
1372
+ dir: z
1373
+ .string()
1374
+ .optional()
1375
+ .describe("Project root for .env/.env.secrets credential resolution (default: the last run snapshot's project root, else cwd)"),
1376
+ envFile: z
1377
+ .string()
1378
+ .optional()
1379
+ .describe("Path to .env file (default: <projectRoot>/.env)"),
1380
+ };
1381
+ /**
1382
+ * Resolve Cloud credentials for the open* tools with the CLI's precedence
1383
+ * (explicit arg > process env > project .env/.env.secrets >
1384
+ * ~/.glubean/credentials.json), then require the full token/project/target
1385
+ * set — resolving the project's DEFAULT target when none is configured.
1386
+ * Returns a human-actionable error message when a piece is missing.
1387
+ */
1388
+ async function requireOpenToolAuth(input, fallbackRoot) {
1389
+ const projectRoot = input.dir ? resolve(input.dir) : (fallbackRoot ?? process.cwd());
1390
+ const envPath = await resolveEnvPath(projectRoot, input.envFile);
1391
+ const { vars, secrets } = await loadProjectEnv(projectRoot, basename(envPath));
1392
+ const auth = await resolveCloudAuth(input, {
1393
+ envFileVars: { ...vars, ...secrets },
1394
+ });
1395
+ if (!auth.token)
1396
+ return { ok: false, error: MISSING_AUTH_MESSAGES.token };
1397
+ if (!auth.projectId)
1398
+ return { ok: false, error: MISSING_AUTH_MESSAGES.projectId };
1399
+ const targetId = auth.targetId ??
1400
+ (await resolveDefaultTargetId(auth.apiUrl, auth.projectId, auth.token));
1401
+ if (!targetId)
1402
+ return { ok: false, error: MISSING_AUTH_MESSAGES.targetId };
1403
+ return {
1404
+ ok: true,
1405
+ auth: { apiUrl: auth.apiUrl, token: auth.token, projectId: auth.projectId, targetId },
1406
+ };
1407
+ }
1408
+ function errorContent(error, extra) {
1409
+ return {
1410
+ content: [
1411
+ { type: "text", text: JSON.stringify({ error, ...extra }) },
1412
+ ],
1413
+ };
1414
+ }
1415
+ server.registerTool(MCP_TOOL_NAMES.openUploadRun, {
1416
+ description: "Upload the most recent glubean_run_local_file results to Glubean Cloud " +
1417
+ "(POST /v1/projects/{projectId}/targets/{targetId}/runs — the same ingest contract as `glubean run --upload`). " +
1418
+ "Replaces the retired glubean_open_trigger_run (/open/v1): the platform ingests locally-executed runs; there is no remote trigger. " +
1419
+ "Credentials resolve like the CLI: explicit args > GLUBEAN_TOKEN / GLUBEAN_PROJECT_ID / GLUBEAN_TARGET_ID / GLUBEAN_API_URL " +
1420
+ "(process env or project .env/.env.secrets) > ~/.glubean/credentials.json.",
1314
1421
  inputSchema: {
1315
- apiUrl: z.string().describe("Base API URL, e.g. https://api.glubean.com"),
1316
- token: z.string().describe("Project token with runs:write scope"),
1317
- projectId: z.string().describe("Project ID (short id)"),
1318
- bundleId: z.string().describe("Bundle ID (short id)"),
1319
- jobId: z.string().optional().describe("Optional job ID"),
1422
+ ...OPEN_AUTH_INPUT_SCHEMA,
1423
+ environment: z
1424
+ .string()
1425
+ .optional()
1426
+ .describe("Environment label recorded on the run (default: GLUBEAN_ENV, else 'default')"),
1320
1427
  },
1321
1428
  }, async (input) => {
1322
- const { apiUrl, token, projectId, bundleId, jobId } = input;
1323
- const url = `${apiUrl.replace(/\/$/, "")}/open/v1/runs`;
1324
- const body = { projectId, bundleId, jobId };
1325
- const json = await fetchJson(url, {
1429
+ if (!lastLocalRunSnapshot) {
1430
+ return errorContent("No local run snapshot to upload. Run glubean_run_local_file first, then call this tool.");
1431
+ }
1432
+ if (lastLocalRunSnapshot.results.length === 0) {
1433
+ return errorContent("The last local run produced no results — nothing to upload.");
1434
+ }
1435
+ // Credential resolution AND the environment label default to the env file
1436
+ // the RUN was executed with — sourcing upload credentials from a different
1437
+ // env than the run (e.g. run with `.env.staging`, upload with `.env`)
1438
+ // could misroute the upload (codex GLU-77 R2 P2). An explicit `envFile`
1439
+ // argument overrides BOTH consistently.
1440
+ const effectiveEnvFile = input.envFile ?? lastLocalRunSnapshot.envFile;
1441
+ const check = await requireOpenToolAuth({ ...input, envFile: effectiveEnvFile }, lastLocalRunSnapshot.projectRoot);
1442
+ if (!check.ok)
1443
+ return errorContent(check.error);
1444
+ const { apiUrl, token, projectId, targetId } = check.auth;
1445
+ // Environment label — same chain as the CLI's resolveUploadEnvironment:
1446
+ // explicit arg > GLUBEAN_ENV > derived from the env file the RUN used
1447
+ // (`.env.staging` → "staging"; resolveEnvPath honors .glubean/active-env).
1448
+ const snapshotRoot = lastLocalRunSnapshot.projectRoot;
1449
+ const runEnvPath = await resolveEnvPath(snapshotRoot, effectiveEnvFile);
1450
+ const environment = input.environment ||
1451
+ process.env.GLUBEAN_ENV?.trim() ||
1452
+ envLabelFromEnvFile(runEnvPath);
1453
+ // Project redaction rules (glubean.yaml defaults.redaction) — additive on
1454
+ // the built-in baseline, matching the CLI's upload-path scrub.
1455
+ const redaction = await loadUploadRedaction(snapshotRoot);
1456
+ const body = buildRunIngestBody(lastLocalRunSnapshot, { environment, redaction });
1457
+ const json = (await cloudFetchJson(runIngestUrl(apiUrl, projectId, targetId), {
1326
1458
  method: "POST",
1327
- headers: { "Content-Type": "application/json", ...bearerHeaders(token) },
1459
+ headers: { "Content-Type": "application/json" },
1328
1460
  body: JSON.stringify(body),
1329
- });
1330
- return { content: [{ type: "text", text: JSON.stringify(json) }] };
1461
+ token,
1462
+ }));
1463
+ const runId = typeof json?.id === "string" ? json.id : undefined;
1464
+ if (!runId) {
1465
+ return errorContent("Cloud accepted the upload but the response was missing the run id.", { response: json });
1466
+ }
1467
+ return {
1468
+ content: [
1469
+ {
1470
+ type: "text",
1471
+ text: JSON.stringify({
1472
+ runId,
1473
+ url: runUrl(apiUrl, projectId, targetId, runId),
1474
+ projectId,
1475
+ targetId,
1476
+ environment,
1477
+ summary: lastLocalRunSnapshot.summary,
1478
+ }),
1479
+ },
1480
+ ],
1481
+ };
1331
1482
  });
1332
1483
  server.registerTool(MCP_TOOL_NAMES.openGetRun, {
1333
- description: "Get run status via Glubean Open Platform API (GET /open/v1/runs/:runId).",
1484
+ description: "Get a run's status + metadata from Glubean Cloud " +
1485
+ "(GET /v1/projects/{projectId}/targets/{targetId}/runs/{runId}).",
1334
1486
  inputSchema: {
1335
- apiUrl: z.string().describe("Base API URL, e.g. https://api.glubean.com"),
1336
- token: z.string().describe("Project token with runs:read scope"),
1337
1487
  runId: z.string().describe("Run ID"),
1488
+ ...OPEN_AUTH_INPUT_SCHEMA,
1338
1489
  },
1339
1490
  }, async (input) => {
1340
- const { apiUrl, token, runId } = input;
1341
- const url = `${apiUrl.replace(/\/$/, "")}/open/v1/runs/${encodeURIComponent(runId)}`;
1342
- const json = await fetchJson(url, {
1343
- method: "GET",
1344
- headers: bearerHeaders(token),
1345
- });
1491
+ const check = await requireOpenToolAuth(input, lastLocalRunSnapshot?.projectRoot);
1492
+ if (!check.ok)
1493
+ return errorContent(check.error);
1494
+ const { apiUrl, token, projectId, targetId } = check.auth;
1495
+ const json = await cloudFetchJson(runUrl(apiUrl, projectId, targetId, input.runId), { method: "GET", token });
1346
1496
  return { content: [{ type: "text", text: JSON.stringify(json) }] };
1347
1497
  });
1348
1498
  server.registerTool(MCP_TOOL_NAMES.openGetRunEvents, {
1349
- description: "Fetch a page of run events via Glubean Open Platform API (GET /open/v1/runs/:runId/events).",
1499
+ description: "Fetch a test's events for a Cloud run " +
1500
+ "(GET /v1/projects/{projectId}/targets/{targetId}/runs/{runId}/tests/{testId}/events). " +
1501
+ "The /v1 contract stores events per test — omit testId to list the run's tests (with their testIds) instead.",
1350
1502
  inputSchema: {
1351
- apiUrl: z.string().describe("Base API URL, e.g. https://api.glubean.com"),
1352
- token: z.string().describe("Project token with runs:read scope"),
1353
1503
  runId: z.string().describe("Run ID"),
1354
- afterSeq: z
1355
- .number()
1356
- .int()
1357
- .min(0)
1504
+ testId: z
1505
+ .string()
1358
1506
  .optional()
1359
- .describe("Cursor: return events after this seq"),
1507
+ .describe("Test ID within the run. Omit to list the run's tests instead."),
1508
+ type: z
1509
+ .string()
1510
+ .optional()
1511
+ .describe("Filter by event type (assertion/log/error/status) — applied client-side"),
1360
1512
  limit: z
1361
1513
  .number()
1362
1514
  .int()
1363
1515
  .min(1)
1364
- .max(1000)
1365
- .optional()
1366
- .describe("Max events (default server: 100)"),
1367
- type: z
1368
- .string()
1516
+ .max(2000)
1369
1517
  .optional()
1370
- .describe("Filter by event type (log/assert/trace/result)"),
1518
+ .describe("Max events returned (default: 200)"),
1519
+ ...OPEN_AUTH_INPUT_SCHEMA,
1371
1520
  },
1372
1521
  }, async (input) => {
1373
- const { apiUrl, token, runId, afterSeq, limit, type } = input;
1374
- const base = `${apiUrl.replace(/\/$/, "")}/open/v1/runs/${encodeURIComponent(runId)}/events`;
1375
- const params = new URLSearchParams();
1376
- if (afterSeq !== undefined)
1377
- params.set("afterSeq", String(afterSeq));
1378
- if (limit !== undefined)
1379
- params.set("limit", String(limit));
1380
- if (type)
1381
- params.set("type", type);
1382
- const qs = params.toString();
1383
- const url = qs ? `${base}?${qs}` : base;
1384
- const json = await fetchJson(url, {
1385
- method: "GET",
1386
- headers: bearerHeaders(token),
1387
- });
1388
- return { content: [{ type: "text", text: JSON.stringify(json) }] };
1522
+ const check = await requireOpenToolAuth(input, lastLocalRunSnapshot?.projectRoot);
1523
+ if (!check.ok)
1524
+ return errorContent(check.error);
1525
+ const { apiUrl, token, projectId, targetId } = check.auth;
1526
+ if (!input.testId) {
1527
+ const rows = await cloudFetchJson(runTestResultsUrl(apiUrl, projectId, targetId, input.runId), { method: "GET", token });
1528
+ const empty = Array.isArray(rows) && rows.length === 0;
1529
+ return {
1530
+ content: [
1531
+ {
1532
+ type: "text",
1533
+ text: JSON.stringify({
1534
+ message: empty
1535
+ ? "No per-test rows yet — the run may still be deriving " +
1536
+ "(check the run's `derivation` field via glubean_open_get_run and retry in a few seconds)."
1537
+ : "The /v1 contract stores run events per test — pass one of these testIds to fetch its events.",
1538
+ runId: input.runId,
1539
+ tests: rows,
1540
+ }),
1541
+ },
1542
+ ],
1543
+ };
1544
+ }
1545
+ const events = await cloudFetchJson(runTestEventsUrl(apiUrl, projectId, targetId, input.runId, input.testId), { method: "GET", token });
1546
+ const all = Array.isArray(events) ? events : [];
1547
+ const filtered = input.type
1548
+ ? all.filter((e) => e?.type === input.type)
1549
+ : all;
1550
+ const limit = Math.max(1, Math.min(input.limit ?? 200, 2000));
1551
+ return {
1552
+ content: [
1553
+ {
1554
+ type: "text",
1555
+ text: JSON.stringify({
1556
+ runId: input.runId,
1557
+ testId: input.testId,
1558
+ availableTotal: all.length,
1559
+ returned: Math.min(filtered.length, limit),
1560
+ events: filtered.slice(0, limit),
1561
+ }),
1562
+ },
1563
+ ],
1564
+ };
1389
1565
  });
1390
1566
  // =============================================================================
1391
1567
  // Runtime contract extraction — delegated to @glubean/scanner