@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/cloud.d.ts +162 -0
- package/dist/cloud.d.ts.map +1 -0
- package/dist/cloud.js +375 -0
- package/dist/cloud.js.map +1 -0
- package/dist/index.d.ts +35 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +254 -78
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
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
|
|
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
|
-
|
|
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
|
-
|
|
1313
|
-
|
|
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
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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"
|
|
1459
|
+
headers: { "Content-Type": "application/json" },
|
|
1328
1460
|
body: JSON.stringify(body),
|
|
1329
|
-
|
|
1330
|
-
|
|
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
|
|
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
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|
|
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
|
-
|
|
1355
|
-
.
|
|
1356
|
-
.int()
|
|
1357
|
-
.min(0)
|
|
1504
|
+
testId: z
|
|
1505
|
+
.string()
|
|
1358
1506
|
.optional()
|
|
1359
|
-
.describe("
|
|
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(
|
|
1365
|
-
.optional()
|
|
1366
|
-
.describe("Max events (default server: 100)"),
|
|
1367
|
-
type: z
|
|
1368
|
-
.string()
|
|
1516
|
+
.max(2000)
|
|
1369
1517
|
.optional()
|
|
1370
|
-
.describe("
|
|
1518
|
+
.describe("Max events returned (default: 200)"),
|
|
1519
|
+
...OPEN_AUTH_INPUT_SCHEMA,
|
|
1371
1520
|
},
|
|
1372
1521
|
}, async (input) => {
|
|
1373
|
-
const
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
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
|