@langwatch/mcp-server 0.9.0 → 0.10.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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.0](https://github.com/langwatch/langwatch/compare/mcp-server@v0.9.0...mcp-server@v0.10.0) (2026-05-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * **governance:** the LangWatch AI Governance Platform ([#3524](https://github.com/langwatch/langwatch/issues/3524)) ([7dcaa89](https://github.com/langwatch/langwatch/commit/7dcaa895e29f6f2cce8d1e9160ef857520af340a))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **deps:** move mcp-server pnpm.overrides to workspace root ([#4187](https://github.com/langwatch/langwatch/issues/4187)) ([89e45ce](https://github.com/langwatch/langwatch/commit/89e45cefa3c16ef1c11e1d74c24219b182c0d716))
14
+ * **mcp:** serve partial experiment results and fix status for SDK runs ([#4276](https://github.com/langwatch/langwatch/issues/4276)) ([d21997a](https://github.com/langwatch/langwatch/commit/d21997a3c65146b0f52026dccf0907e2abeb8bb9))
15
+
16
+
17
+ ### Miscellaneous
18
+
19
+ * **deps:** bump liquidjs to >=10.26.0 (RCE GHSA-gf2q-c269-pqgc) in typescript-sdk, skills, mcp-server ([#4340](https://github.com/langwatch/langwatch/issues/4340)) ([697e07e](https://github.com/langwatch/langwatch/commit/697e07e82241dc837afb414458e67f539d23fb54))
20
+ * **deps:** bump liquidjs to >=10.26.0 in typescript-sdk, skills, mcp-server (GHSA-gf2q-c269-pqgc) ([697e07e](https://github.com/langwatch/langwatch/commit/697e07e82241dc837afb414458e67f539d23fb54))
21
+
3
22
  ## [0.9.0](https://github.com/langwatch/langwatch/compare/mcp-server@v0.8.0...mcp-server@v0.9.0) (2026-05-22)
4
23
 
5
24
 
@@ -13,7 +13,7 @@ import { z as z2 } from "zod";
13
13
  // package.json
14
14
  var package_default = {
15
15
  name: "@langwatch/mcp-server",
16
- version: "0.9.0",
16
+ version: "0.10.0",
17
17
  description: "An MCP server for Langwatch.",
18
18
  type: "module",
19
19
  main: "./dist/index.js",
@@ -91,9 +91,7 @@ var package_default = {
91
91
  },
92
92
  pnpm: {
93
93
  overrides: {
94
- protobufjs: ">=7.5.6 <8.0.0 || >=8.0.2",
95
- "langsmith@<0.6.0": "0.6.0",
96
- "fast-uri@<3.1.2": "3.1.2"
94
+ liquidjs: ">=10.26.0"
97
95
  }
98
96
  }
99
97
  };
@@ -124,6 +122,18 @@ var createDatasetSchema = z.object({
124
122
  columnTypes: z.array(datasetColumnDefinitionSchema).optional().describe("Column definitions for the dataset")
125
123
  });
126
124
 
125
+ // src/tools/experiment-run-status.ts
126
+ var INTERRUPTED_THRESHOLD_MS = 5 * 60 * 1e3;
127
+ var deriveRunStatus = (timestamps, now = Date.now()) => {
128
+ if (timestamps.stoppedAt != null) return "stopped";
129
+ if (timestamps.finishedAt != null) return "completed";
130
+ if (timestamps.updatedAt != null && now - timestamps.updatedAt > INTERRUPTED_THRESHOLD_MS) {
131
+ return "interrupted";
132
+ }
133
+ return "running";
134
+ };
135
+ var isTerminalStatus = (status) => status === "completed" || status === "stopped";
136
+
127
137
  // src/tools/get-experiment-results.ts
128
138
  var DEFAULT_ROW_CAP = 50;
129
139
  var rowKey = (index, targetId) => `${index}:${targetId ?? ""}`;
@@ -164,24 +174,26 @@ async function handleExperimentResults(params) {
164
174
  "",
165
175
  "**Status**: not found",
166
176
  "",
167
- `Could not load results for run \`${params.runId}\`. The run may still be in progress, or the id may be incorrect.`,
177
+ `Could not load results for run \`${params.runId}\`. The run id may be incorrect, or its run state may have expired (Redis keeps it for 24h).`,
168
178
  "",
169
- "> Try `platform_experiment_status` first to confirm the run id and that it is completed."
179
+ "> Pass `experimentSlug` to load results for runs older than 24h. Discover the slug with `platform_experiment_list`, then `platform_experiment_list_runs` for the run ids."
170
180
  ].join("\n");
171
181
  }
172
182
  throw error;
173
183
  }
174
- if (!results || results.timestamps.finishedAt == null && results.timestamps.stoppedAt == null) {
184
+ if (!results) {
175
185
  return [
176
186
  `# Evaluation Results: ${params.runId}`,
177
187
  "",
178
- "**Status**: results are not yet available",
188
+ "**Status**: not found",
179
189
  "",
180
- `Results for run \`${params.runId}\` are not ready yet.`,
190
+ `Could not load results for run \`${params.runId}\`.`,
181
191
  "",
182
- "> Try `platform_experiment_status` first to confirm the run id and that it is completed."
192
+ "> Pass `experimentSlug` if the run is older than 24h. Discover the slug with `platform_experiment_list`, then `platform_experiment_list_runs` to confirm the run id."
183
193
  ].join("\n");
184
194
  }
195
+ const runStatus2 = deriveRunStatus(results.timestamps);
196
+ const partial = !isTerminalStatus(runStatus2);
185
197
  const evaluationsByRow = /* @__PURE__ */ new Map();
186
198
  for (const evaluation of results.evaluations) {
187
199
  if (evaluatorFilter && evaluation.evaluator !== evaluatorFilter) continue;
@@ -232,6 +244,12 @@ async function handleExperimentResults(params) {
232
244
  lines.push(`# Evaluation Results: ${results.runId}`);
233
245
  lines.push("");
234
246
  lines.push(`**Experiment**: ${results.experimentId}`);
247
+ lines.push(`**Status**: ${runStatus2}`);
248
+ if (typeof results.total === "number" && results.total > 0) {
249
+ lines.push(
250
+ `**Progress**: ${results.progress ?? results.dataset.length}/${results.total} rows`
251
+ );
252
+ }
235
253
  lines.push(`**Total rows**: ${results.dataset.length}`);
236
254
  lines.push(`**Total evaluations**: ${results.evaluations.length}`);
237
255
  if (filter === "failed") {
@@ -240,6 +258,12 @@ async function handleExperimentResults(params) {
240
258
  if (evaluatorFilter) {
241
259
  lines.push(`**Evaluator filter**: ${evaluatorFilter}`);
242
260
  }
261
+ if (partial) {
262
+ lines.push("");
263
+ lines.push(
264
+ runStatus2 === "interrupted" ? "> These are partial results. The run never sent a finished/stopped marker and has had no updates recently, so it likely was interrupted before completing. The rows below are everything recorded so far." : "> These are partial results. The run is still in progress, so more rows may appear on a later call."
265
+ );
266
+ }
243
267
  lines.push("");
244
268
  if (evaluatorAverages.size > 0) {
245
269
  lines.push("## Evaluator Summary");
@@ -255,7 +279,17 @@ async function handleExperimentResults(params) {
255
279
  lines.push("");
256
280
  }
257
281
  if (rows.length === 0) {
258
- lines.push("_No rows matched the filter._");
282
+ if (filter === "failed") {
283
+ lines.push("_No rows matched the filter._");
284
+ } else if (runStatus2 === "running") {
285
+ lines.push(
286
+ "_No rows recorded yet. The run is still in progress; call again shortly._"
287
+ );
288
+ } else if (runStatus2 === "interrupted") {
289
+ lines.push("_No rows were recorded before the run was interrupted._");
290
+ } else {
291
+ lines.push("_No rows recorded for this run._");
292
+ }
259
293
  return lines.join("\n");
260
294
  }
261
295
  lines.push(
@@ -461,11 +495,78 @@ async function handleRunExperiment(params) {
461
495
  );
462
496
  return lines.join("\n");
463
497
  }
464
- async function handleExperimentStatus(params) {
465
- const status = await makeRequest(
466
- "GET",
467
- `/api/experiments/runs/${encodeURIComponent(params.runId)}`
498
+ async function statusFromResults(params) {
499
+ const search = new URLSearchParams();
500
+ if (params.experimentSlug) search.set("experimentSlug", params.experimentSlug);
501
+ const qs = search.toString() ? `?${search.toString()}` : "";
502
+ let results;
503
+ try {
504
+ results = await makeRequest(
505
+ "GET",
506
+ `/api/experiments/runs/${encodeURIComponent(params.runId)}/results${qs}`
507
+ );
508
+ } catch (error) {
509
+ const code = error instanceof LangWatchApiError ? error.status : void 0;
510
+ const message = error instanceof Error ? error.message : String(error);
511
+ if (code === 404 || code === void 0 && /404|not found/i.test(message)) {
512
+ return null;
513
+ }
514
+ throw error;
515
+ }
516
+ const status = deriveRunStatus(results.timestamps);
517
+ const total = results.total ?? results.dataset?.length ?? 0;
518
+ const progress = results.progress ?? results.dataset?.length ?? 0;
519
+ const lines = [];
520
+ lines.push(`# Evaluation Run ${params.runId}
521
+ `);
522
+ lines.push(`**Status**: ${status}`);
523
+ lines.push(`**Progress**: ${progress}/${total} cells`);
524
+ if (results.timestamps.createdAt) {
525
+ lines.push(
526
+ `**Started**: ${new Date(results.timestamps.createdAt).toISOString()}`
527
+ );
528
+ }
529
+ if (results.timestamps.finishedAt) {
530
+ lines.push(
531
+ `**Finished**: ${new Date(results.timestamps.finishedAt).toISOString()}`
532
+ );
533
+ }
534
+ if (results.timestamps.stoppedAt) {
535
+ lines.push(
536
+ `**Stopped**: ${new Date(results.timestamps.stoppedAt).toISOString()}`
537
+ );
538
+ }
539
+ lines.push("");
540
+ lines.push(
541
+ "> Use `platform_experiment_results` to fetch the per-row scores (partial results are available even while running)."
468
542
  );
543
+ return lines.join("\n");
544
+ }
545
+ async function handleExperimentStatus(params) {
546
+ let status;
547
+ try {
548
+ status = await makeRequest(
549
+ "GET",
550
+ `/api/experiments/runs/${encodeURIComponent(params.runId)}`
551
+ );
552
+ } catch (error) {
553
+ const code = error instanceof LangWatchApiError ? error.status : void 0;
554
+ const message = error instanceof Error ? error.message : String(error);
555
+ if (code === 404 || /404|not found/i.test(message)) {
556
+ const fallback = await statusFromResults(params);
557
+ if (fallback) return fallback;
558
+ return [
559
+ `# Evaluation Run ${params.runId}`,
560
+ "",
561
+ "**Status**: not found",
562
+ "",
563
+ `Could not find run \`${params.runId}\`. SDK-logged runs and runs older than 24h are not in the live run-state and must be resolved by experiment slug.`,
564
+ "",
565
+ "> Pass `experimentSlug`: discover it with `platform_experiment_list`, then use `platform_experiment_list_runs` for the run ids. Or fetch the rows directly with `platform_experiment_results`."
566
+ ].join("\n");
567
+ }
568
+ throw error;
569
+ }
469
570
  const lines = [];
470
571
  lines.push(`# Evaluation Run ${status.runId}
471
572
  `);
@@ -1775,9 +1876,12 @@ ${lines.join("\n")}`
1775
1876
  );
1776
1877
  server.tool(
1777
1878
  "platform_experiment_status",
1778
- "Check the status of an experiment run. Returns progress and summary when completed.",
1879
+ "Check the status of an experiment run (running, completed, stopped, or interrupted) plus its progress. Works for both platform-run and SDK-logged experiments; pass experimentSlug for SDK runs or runs older than 24h.",
1779
1880
  {
1780
- runId: z2.string().describe("The run ID returned from platform_run_experiment")
1881
+ runId: z2.string().describe("The run ID returned from platform_run_experiment"),
1882
+ experimentSlug: z2.string().optional().describe(
1883
+ "Experiment slug \u2014 required for SDK-logged runs and runs older than 24h (the Redis run-state they would otherwise be read from is absent/expired). Use platform_experiment_list_runs to find the slug."
1884
+ )
1781
1885
  },
1782
1886
  withToolLogging("platform_experiment_status", async (params) => {
1783
1887
  requireApiKey();
@@ -1821,7 +1925,7 @@ ${lines.join("\n")}`
1821
1925
  );
1822
1926
  server.tool(
1823
1927
  "platform_experiment_results",
1824
- "Fetch per-row results for a completed experiment run so you can debug evaluator scores and missed rows. Returns a markdown report: per-evaluator averages plus row-by-row scores and failure details. Output is capped at 50 rows to protect the agent's context window \u2014 narrow with `filter: 'failed'` or `evaluator` to see what matters.",
1928
+ "Fetch per-row results for an experiment run so you can debug evaluator scores and missed rows. Serves partial results for runs that are still running or were interrupted (it does not require a completed run), with the run status noted in the output. Returns a markdown report: per-evaluator averages plus row-by-row scores and failure details. Output is capped at 50 rows to protect the agent's context window \u2014 narrow with `filter: 'failed'` or `evaluator` to see what matters.",
1825
1929
  {
1826
1930
  runId: z2.string().describe("The run ID returned from platform_run_experiment"),
1827
1931
  experimentSlug: z2.string().optional().describe(
@@ -2139,4 +2243,4 @@ The token is returned once and cannot be retrieved again.`,
2139
2243
  export {
2140
2244
  createMcpServer
2141
2245
  };
2142
- //# sourceMappingURL=chunk-JVLJKQJG.js.map
2246
+ //# sourceMappingURL=chunk-FVDAZ4XG.js.map
@@ -1,8 +1,10 @@
1
1
  // Minimal type declaration — avoids pulling mcp-server's full dependency tree
2
2
  // into the langwatch app's typecheck scope. The actual return type is McpServer,
3
- // but we only expose the connect() method that handler.ts uses.
3
+ // but we only expose the connect() and tool() methods that handler.ts +
4
+ // in-app tool registrations (e.g. registerGovernanceMcpTools) use.
4
5
  declare function createMcpServer(): {
5
6
  connect(transport: unknown): Promise<void>;
7
+ tool(...args: unknown[]): unknown;
6
8
  };
7
9
 
8
10
  export { createMcpServer };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createMcpServer
3
- } from "./chunk-JVLJKQJG.js";
3
+ } from "./chunk-FVDAZ4XG.js";
4
4
  import "./chunk-42ZHHHIJ.js";
5
5
  import "./chunk-KOO5733S.js";
6
6
  export {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createMcpServer
3
- } from "./chunk-JVLJKQJG.js";
3
+ } from "./chunk-FVDAZ4XG.js";
4
4
  import "./chunk-42ZHHHIJ.js";
5
5
  import {
6
6
  getConfig,
@@ -179,7 +179,8 @@ async function startHttpServer({
179
179
  });
180
180
  const sseSessions = {};
181
181
  app.get("/sse", async (req, res) => {
182
- const apiKey = extractBearerToken(req) || req.query["apiKey"] || null;
182
+ const apiKey = extractBearerToken(req) || // lgtm[js/sensitive-get-query]
183
+ req.query["apiKey"] || null;
183
184
  if (!apiKey) {
184
185
  res.status(401).json({
185
186
  error: "Authorization: Bearer <LANGWATCH_API_KEY> header or ?apiKey= query parameter required"
@@ -222,4 +223,4 @@ async function startHttpServer({
222
223
  export {
223
224
  startHttpServer
224
225
  };
225
- //# sourceMappingURL=http-server-DEGNV5EP.js.map
226
+ //# sourceMappingURL=http-server-XUQLIFE7.js.map
package/dist/index.js CHANGED
@@ -31109,7 +31109,7 @@ var init_package = __esm({
31109
31109
  "package.json"() {
31110
31110
  package_default = {
31111
31111
  name: "@langwatch/mcp-server",
31112
- version: "0.9.0",
31112
+ version: "0.10.0",
31113
31113
  description: "An MCP server for Langwatch.",
31114
31114
  type: "module",
31115
31115
  main: "./dist/index.js",
@@ -31187,9 +31187,7 @@ var init_package = __esm({
31187
31187
  },
31188
31188
  pnpm: {
31189
31189
  overrides: {
31190
- protobufjs: ">=7.5.6 <8.0.0 || >=8.0.2",
31191
- "langsmith@<0.6.0": "0.6.0",
31192
- "fast-uri@<3.1.2": "3.1.2"
31190
+ liquidjs: ">=10.26.0"
31193
31191
  }
31194
31192
  }
31195
31193
  };
@@ -31355,6 +31353,24 @@ var init_langwatch_api = __esm({
31355
31353
  }
31356
31354
  });
31357
31355
 
31356
+ // src/tools/experiment-run-status.ts
31357
+ var INTERRUPTED_THRESHOLD_MS, deriveRunStatus, isTerminalStatus;
31358
+ var init_experiment_run_status = __esm({
31359
+ "src/tools/experiment-run-status.ts"() {
31360
+ "use strict";
31361
+ INTERRUPTED_THRESHOLD_MS = 5 * 60 * 1e3;
31362
+ deriveRunStatus = (timestamps, now = Date.now()) => {
31363
+ if (timestamps.stoppedAt != null) return "stopped";
31364
+ if (timestamps.finishedAt != null) return "completed";
31365
+ if (timestamps.updatedAt != null && now - timestamps.updatedAt > INTERRUPTED_THRESHOLD_MS) {
31366
+ return "interrupted";
31367
+ }
31368
+ return "running";
31369
+ };
31370
+ isTerminalStatus = (status) => status === "completed" || status === "stopped";
31371
+ }
31372
+ });
31373
+
31358
31374
  // src/tools/get-experiment-results.ts
31359
31375
  async function handleExperimentResults(params) {
31360
31376
  const filter = params.filter ?? "all";
@@ -31378,24 +31394,26 @@ async function handleExperimentResults(params) {
31378
31394
  "",
31379
31395
  "**Status**: not found",
31380
31396
  "",
31381
- `Could not load results for run \`${params.runId}\`. The run may still be in progress, or the id may be incorrect.`,
31397
+ `Could not load results for run \`${params.runId}\`. The run id may be incorrect, or its run state may have expired (Redis keeps it for 24h).`,
31382
31398
  "",
31383
- "> Try `platform_experiment_status` first to confirm the run id and that it is completed."
31399
+ "> Pass `experimentSlug` to load results for runs older than 24h. Discover the slug with `platform_experiment_list`, then `platform_experiment_list_runs` for the run ids."
31384
31400
  ].join("\n");
31385
31401
  }
31386
31402
  throw error48;
31387
31403
  }
31388
- if (!results || results.timestamps.finishedAt == null && results.timestamps.stoppedAt == null) {
31404
+ if (!results) {
31389
31405
  return [
31390
31406
  `# Evaluation Results: ${params.runId}`,
31391
31407
  "",
31392
- "**Status**: results are not yet available",
31408
+ "**Status**: not found",
31393
31409
  "",
31394
- `Results for run \`${params.runId}\` are not ready yet.`,
31410
+ `Could not load results for run \`${params.runId}\`.`,
31395
31411
  "",
31396
- "> Try `platform_experiment_status` first to confirm the run id and that it is completed."
31412
+ "> Pass `experimentSlug` if the run is older than 24h. Discover the slug with `platform_experiment_list`, then `platform_experiment_list_runs` to confirm the run id."
31397
31413
  ].join("\n");
31398
31414
  }
31415
+ const runStatus2 = deriveRunStatus(results.timestamps);
31416
+ const partial2 = !isTerminalStatus(runStatus2);
31399
31417
  const evaluationsByRow = /* @__PURE__ */ new Map();
31400
31418
  for (const evaluation of results.evaluations) {
31401
31419
  if (evaluatorFilter && evaluation.evaluator !== evaluatorFilter) continue;
@@ -31446,6 +31464,12 @@ async function handleExperimentResults(params) {
31446
31464
  lines.push(`# Evaluation Results: ${results.runId}`);
31447
31465
  lines.push("");
31448
31466
  lines.push(`**Experiment**: ${results.experimentId}`);
31467
+ lines.push(`**Status**: ${runStatus2}`);
31468
+ if (typeof results.total === "number" && results.total > 0) {
31469
+ lines.push(
31470
+ `**Progress**: ${results.progress ?? results.dataset.length}/${results.total} rows`
31471
+ );
31472
+ }
31449
31473
  lines.push(`**Total rows**: ${results.dataset.length}`);
31450
31474
  lines.push(`**Total evaluations**: ${results.evaluations.length}`);
31451
31475
  if (filter === "failed") {
@@ -31454,6 +31478,12 @@ async function handleExperimentResults(params) {
31454
31478
  if (evaluatorFilter) {
31455
31479
  lines.push(`**Evaluator filter**: ${evaluatorFilter}`);
31456
31480
  }
31481
+ if (partial2) {
31482
+ lines.push("");
31483
+ lines.push(
31484
+ runStatus2 === "interrupted" ? "> These are partial results. The run never sent a finished/stopped marker and has had no updates recently, so it likely was interrupted before completing. The rows below are everything recorded so far." : "> These are partial results. The run is still in progress, so more rows may appear on a later call."
31485
+ );
31486
+ }
31457
31487
  lines.push("");
31458
31488
  if (evaluatorAverages.size > 0) {
31459
31489
  lines.push("## Evaluator Summary");
@@ -31469,7 +31499,17 @@ async function handleExperimentResults(params) {
31469
31499
  lines.push("");
31470
31500
  }
31471
31501
  if (rows.length === 0) {
31472
- lines.push("_No rows matched the filter._");
31502
+ if (filter === "failed") {
31503
+ lines.push("_No rows matched the filter._");
31504
+ } else if (runStatus2 === "running") {
31505
+ lines.push(
31506
+ "_No rows recorded yet. The run is still in progress; call again shortly._"
31507
+ );
31508
+ } else if (runStatus2 === "interrupted") {
31509
+ lines.push("_No rows were recorded before the run was interrupted._");
31510
+ } else {
31511
+ lines.push("_No rows recorded for this run._");
31512
+ }
31473
31513
  return lines.join("\n");
31474
31514
  }
31475
31515
  lines.push(
@@ -31517,6 +31557,7 @@ var init_get_experiment_results = __esm({
31517
31557
  "src/tools/get-experiment-results.ts"() {
31518
31558
  "use strict";
31519
31559
  init_langwatch_api();
31560
+ init_experiment_run_status();
31520
31561
  DEFAULT_ROW_CAP = 50;
31521
31562
  rowKey = (index, targetId) => `${index}:${targetId ?? ""}`;
31522
31563
  summarizeEntry = (entry) => {
@@ -31713,11 +31754,78 @@ async function handleRunExperiment(params) {
31713
31754
  );
31714
31755
  return lines.join("\n");
31715
31756
  }
31716
- async function handleExperimentStatus(params) {
31717
- const status = await makeRequest(
31718
- "GET",
31719
- `/api/experiments/runs/${encodeURIComponent(params.runId)}`
31757
+ async function statusFromResults(params) {
31758
+ const search = new URLSearchParams();
31759
+ if (params.experimentSlug) search.set("experimentSlug", params.experimentSlug);
31760
+ const qs = search.toString() ? `?${search.toString()}` : "";
31761
+ let results;
31762
+ try {
31763
+ results = await makeRequest(
31764
+ "GET",
31765
+ `/api/experiments/runs/${encodeURIComponent(params.runId)}/results${qs}`
31766
+ );
31767
+ } catch (error48) {
31768
+ const code = error48 instanceof LangWatchApiError ? error48.status : void 0;
31769
+ const message = error48 instanceof Error ? error48.message : String(error48);
31770
+ if (code === 404 || code === void 0 && /404|not found/i.test(message)) {
31771
+ return null;
31772
+ }
31773
+ throw error48;
31774
+ }
31775
+ const status = deriveRunStatus(results.timestamps);
31776
+ const total = results.total ?? results.dataset?.length ?? 0;
31777
+ const progress = results.progress ?? results.dataset?.length ?? 0;
31778
+ const lines = [];
31779
+ lines.push(`# Evaluation Run ${params.runId}
31780
+ `);
31781
+ lines.push(`**Status**: ${status}`);
31782
+ lines.push(`**Progress**: ${progress}/${total} cells`);
31783
+ if (results.timestamps.createdAt) {
31784
+ lines.push(
31785
+ `**Started**: ${new Date(results.timestamps.createdAt).toISOString()}`
31786
+ );
31787
+ }
31788
+ if (results.timestamps.finishedAt) {
31789
+ lines.push(
31790
+ `**Finished**: ${new Date(results.timestamps.finishedAt).toISOString()}`
31791
+ );
31792
+ }
31793
+ if (results.timestamps.stoppedAt) {
31794
+ lines.push(
31795
+ `**Stopped**: ${new Date(results.timestamps.stoppedAt).toISOString()}`
31796
+ );
31797
+ }
31798
+ lines.push("");
31799
+ lines.push(
31800
+ "> Use `platform_experiment_results` to fetch the per-row scores (partial results are available even while running)."
31720
31801
  );
31802
+ return lines.join("\n");
31803
+ }
31804
+ async function handleExperimentStatus(params) {
31805
+ let status;
31806
+ try {
31807
+ status = await makeRequest(
31808
+ "GET",
31809
+ `/api/experiments/runs/${encodeURIComponent(params.runId)}`
31810
+ );
31811
+ } catch (error48) {
31812
+ const code = error48 instanceof LangWatchApiError ? error48.status : void 0;
31813
+ const message = error48 instanceof Error ? error48.message : String(error48);
31814
+ if (code === 404 || /404|not found/i.test(message)) {
31815
+ const fallback = await statusFromResults(params);
31816
+ if (fallback) return fallback;
31817
+ return [
31818
+ `# Evaluation Run ${params.runId}`,
31819
+ "",
31820
+ "**Status**: not found",
31821
+ "",
31822
+ `Could not find run \`${params.runId}\`. SDK-logged runs and runs older than 24h are not in the live run-state and must be resolved by experiment slug.`,
31823
+ "",
31824
+ "> Pass `experimentSlug`: discover it with `platform_experiment_list`, then use `platform_experiment_list_runs` for the run ids. Or fetch the rows directly with `platform_experiment_results`."
31825
+ ].join("\n");
31826
+ }
31827
+ throw error48;
31828
+ }
31721
31829
  const lines = [];
31722
31830
  lines.push(`# Evaluation Run ${status.runId}
31723
31831
  `);
@@ -31752,6 +31860,7 @@ var init_run_experiment = __esm({
31752
31860
  "src/tools/run-experiment.ts"() {
31753
31861
  "use strict";
31754
31862
  init_langwatch_api();
31863
+ init_experiment_run_status();
31755
31864
  }
31756
31865
  });
31757
31866
 
@@ -37746,9 +37855,12 @@ ${lines.join("\n")}`
37746
37855
  );
37747
37856
  server.tool(
37748
37857
  "platform_experiment_status",
37749
- "Check the status of an experiment run. Returns progress and summary when completed.",
37858
+ "Check the status of an experiment run (running, completed, stopped, or interrupted) plus its progress. Works for both platform-run and SDK-logged experiments; pass experimentSlug for SDK runs or runs older than 24h.",
37750
37859
  {
37751
- runId: external_exports.string().describe("The run ID returned from platform_run_experiment")
37860
+ runId: external_exports.string().describe("The run ID returned from platform_run_experiment"),
37861
+ experimentSlug: external_exports.string().optional().describe(
37862
+ "Experiment slug \u2014 required for SDK-logged runs and runs older than 24h (the Redis run-state they would otherwise be read from is absent/expired). Use platform_experiment_list_runs to find the slug."
37863
+ )
37752
37864
  },
37753
37865
  withToolLogging("platform_experiment_status", async (params) => {
37754
37866
  requireApiKey();
@@ -37792,7 +37904,7 @@ ${lines.join("\n")}`
37792
37904
  );
37793
37905
  server.tool(
37794
37906
  "platform_experiment_results",
37795
- "Fetch per-row results for a completed experiment run so you can debug evaluator scores and missed rows. Returns a markdown report: per-evaluator averages plus row-by-row scores and failure details. Output is capped at 50 rows to protect the agent's context window \u2014 narrow with `filter: 'failed'` or `evaluator` to see what matters.",
37907
+ "Fetch per-row results for an experiment run so you can debug evaluator scores and missed rows. Serves partial results for runs that are still running or were interrupted (it does not require a completed run), with the run status noted in the output. Returns a markdown report: per-evaluator averages plus row-by-row scores and failure details. Output is capped at 50 rows to protect the agent's context window \u2014 narrow with `filter: 'failed'` or `evaluator` to see what matters.",
37796
37908
  {
37797
37909
  runId: external_exports.string().describe("The run ID returned from platform_run_experiment"),
37798
37910
  experimentSlug: external_exports.string().optional().describe(
@@ -63405,7 +63517,8 @@ async function startHttpServer({
63405
63517
  });
63406
63518
  const sseSessions = {};
63407
63519
  app.get("/sse", async (req, res) => {
63408
- const apiKey = extractBearerToken(req) || req.query["apiKey"] || null;
63520
+ const apiKey = extractBearerToken(req) || // lgtm[js/sensitive-get-query]
63521
+ req.query["apiKey"] || null;
63409
63522
  if (!apiKey) {
63410
63523
  res.status(401).json({
63411
63524
  error: "Authorization: Bearer <LANGWATCH_API_KEY> header or ?apiKey= query parameter required"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@langwatch/mcp-server",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "An MCP server for Langwatch.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,4 +1,9 @@
1
1
  minimumReleaseAge: 10080
2
2
 
3
+ overrides:
4
+ "protobufjs": ">=7.5.6 <8.0.0 || >=8.0.2"
5
+ "langsmith@<0.6.0": "0.6.0"
6
+ "fast-uri@<3.1.2": "3.1.2"
7
+
3
8
  onlyBuiltDependencies:
4
9
  - node-pty
@@ -0,0 +1,194 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ vi.mock("../langwatch-api.js", async (importOriginal) => {
4
+ const actual = (await importOriginal()) as Record<string, unknown>;
5
+ return {
6
+ ...actual,
7
+ makeRequest: vi.fn(),
8
+ };
9
+ });
10
+
11
+ import { LangWatchApiError, makeRequest } from "../langwatch-api.js";
12
+ import { handleExperimentStatus } from "../tools/run-experiment.js";
13
+ import {
14
+ deriveRunStatus,
15
+ isTerminalStatus,
16
+ } from "../tools/experiment-run-status.js";
17
+
18
+ const mockMakeRequest = vi.mocked(makeRequest);
19
+
20
+ beforeEach(() => {
21
+ vi.clearAllMocks();
22
+ });
23
+
24
+ describe("deriveRunStatus()", () => {
25
+ describe("when stoppedAt is set", () => {
26
+ it("is stopped", () => {
27
+ expect(deriveRunStatus({ stoppedAt: 5 })).toBe("stopped");
28
+ });
29
+ });
30
+
31
+ describe("when finishedAt is set", () => {
32
+ it("is completed", () => {
33
+ expect(deriveRunStatus({ finishedAt: 5 })).toBe("completed");
34
+ });
35
+ });
36
+
37
+ describe("when no terminal marker and updates are recent", () => {
38
+ it("is running", () => {
39
+ const now = 1_000_000;
40
+ expect(deriveRunStatus({ updatedAt: now - 1000 }, now)).toBe("running");
41
+ });
42
+ });
43
+
44
+ describe("when no terminal marker and no recent updates", () => {
45
+ it("is interrupted", () => {
46
+ const now = 1_000_000;
47
+ expect(deriveRunStatus({ updatedAt: now - 6 * 60 * 1000 }, now)).toBe(
48
+ "interrupted",
49
+ );
50
+ });
51
+ });
52
+
53
+ describe("terminal classification", () => {
54
+ it("treats completed and stopped as terminal", () => {
55
+ expect(isTerminalStatus("completed")).toBe(true);
56
+ expect(isTerminalStatus("stopped")).toBe(true);
57
+ expect(isTerminalStatus("running")).toBe(false);
58
+ expect(isTerminalStatus("interrupted")).toBe(false);
59
+ });
60
+ });
61
+ });
62
+
63
+ describe("handleExperimentStatus()", () => {
64
+ describe("given the run state exists in Redis", () => {
65
+ it("reports the status straight from the status endpoint", async () => {
66
+ mockMakeRequest.mockResolvedValueOnce({
67
+ runId: "run_1",
68
+ status: "completed",
69
+ progress: 3,
70
+ total: 3,
71
+ });
72
+
73
+ const out = await handleExperimentStatus({ runId: "run_1" });
74
+
75
+ expect(mockMakeRequest).toHaveBeenCalledWith(
76
+ "GET",
77
+ "/api/experiments/runs/run_1",
78
+ );
79
+ expect(out).toContain("**Status**: completed");
80
+ expect(out).toContain("3/3 cells");
81
+ });
82
+ });
83
+
84
+ describe("given an SDK-logged run with no Redis state", () => {
85
+ it("falls back to deriving status from the results endpoint", async () => {
86
+ mockMakeRequest
87
+ .mockRejectedValueOnce(
88
+ new LangWatchApiError("missing", 404, "Run not found"),
89
+ )
90
+ .mockResolvedValueOnce({
91
+ progress: 5,
92
+ total: 5,
93
+ dataset: [1, 2, 3, 4, 5],
94
+ timestamps: {
95
+ createdAt: 1,
96
+ updatedAt: 2,
97
+ finishedAt: 3,
98
+ stoppedAt: null,
99
+ },
100
+ });
101
+
102
+ const out = await handleExperimentStatus({
103
+ runId: "sdk_run",
104
+ experimentSlug: "doc-qa",
105
+ });
106
+
107
+ expect(mockMakeRequest).toHaveBeenNthCalledWith(
108
+ 1,
109
+ "GET",
110
+ "/api/experiments/runs/sdk_run",
111
+ );
112
+ expect(mockMakeRequest).toHaveBeenNthCalledWith(
113
+ 2,
114
+ "GET",
115
+ "/api/experiments/runs/sdk_run/results?experimentSlug=doc-qa",
116
+ );
117
+ expect(out).toContain("**Status**: completed");
118
+ expect(out).toContain("5/5 cells");
119
+ expect(out).toContain("platform_experiment_results");
120
+ });
121
+
122
+ it("reports interrupted for a stale unfinished SDK run", async () => {
123
+ mockMakeRequest
124
+ .mockRejectedValueOnce(
125
+ new LangWatchApiError("missing", 404, "Run not found"),
126
+ )
127
+ .mockResolvedValueOnce({
128
+ progress: 2,
129
+ total: 5,
130
+ dataset: [1, 2],
131
+ timestamps: {
132
+ createdAt: Date.now() - 60 * 60 * 1000,
133
+ updatedAt: Date.now() - 30 * 60 * 1000,
134
+ finishedAt: null,
135
+ stoppedAt: null,
136
+ },
137
+ });
138
+
139
+ const out = await handleExperimentStatus({
140
+ runId: "sdk_run",
141
+ experimentSlug: "doc-qa",
142
+ });
143
+
144
+ expect(out).toContain("**Status**: interrupted");
145
+ expect(out).toContain("2/5 cells");
146
+ });
147
+ });
148
+
149
+ describe("given the run cannot be found anywhere", () => {
150
+ it("returns actionable guidance instead of a raw 404", async () => {
151
+ mockMakeRequest
152
+ .mockRejectedValueOnce(
153
+ new LangWatchApiError("missing", 404, "Run not found"),
154
+ )
155
+ .mockRejectedValueOnce(
156
+ new LangWatchApiError("missing", 404, "Run not found"),
157
+ );
158
+
159
+ const out = await handleExperimentStatus({
160
+ runId: "nope",
161
+ experimentSlug: "doc-qa",
162
+ });
163
+
164
+ expect(out).toContain("not found");
165
+ expect(out).toContain("experimentSlug");
166
+ expect(out).toContain("platform_experiment_results");
167
+ });
168
+
169
+ it("rethrows non-404 errors from the status endpoint", async () => {
170
+ mockMakeRequest.mockRejectedValueOnce(
171
+ new LangWatchApiError("boom", 500, "Internal error"),
172
+ );
173
+
174
+ await expect(
175
+ handleExperimentStatus({ runId: "nope" }),
176
+ ).rejects.toThrow();
177
+ });
178
+
179
+ it("propagates a real (non-404) error from the results fallback instead of faking not-found", async () => {
180
+ mockMakeRequest
181
+ .mockRejectedValueOnce(
182
+ new LangWatchApiError("missing", 404, "Run not found"),
183
+ )
184
+ .mockRejectedValueOnce(
185
+ new LangWatchApiError("boom", 500, "Internal error"),
186
+ );
187
+
188
+ // The fallback's 500 ("boom") must surface, not the original 404 ("missing").
189
+ await expect(
190
+ handleExperimentStatus({ runId: "sdk_run", experimentSlug: "doc-qa" }),
191
+ ).rejects.toThrow(/boom/);
192
+ });
193
+ });
194
+ });
@@ -197,25 +197,116 @@ describe("handleExperimentResults()", () => {
197
197
  );
198
198
  const out = await handleExperimentResults({ runId: "missing" });
199
199
  expect(out).toContain("not found");
200
- expect(out).toContain("platform_experiment_status");
200
+ expect(out).toContain("experimentSlug");
201
201
  });
202
202
  });
203
203
  });
204
204
 
205
205
  describe("given the run is still in progress", () => {
206
206
  describe("when results are requested", () => {
207
- it("returns guidance to check status instead of partial results", async () => {
207
+ it("serves the partial rows logged so far with an in-progress note", async () => {
208
208
  mockMakeRequest.mockResolvedValueOnce({
209
209
  ...sample,
210
210
  progress: 2,
211
211
  total: 3,
212
- timestamps: { createdAt: 0, updatedAt: 0, finishedAt: null },
212
+ timestamps: {
213
+ createdAt: Date.now(),
214
+ updatedAt: Date.now(),
215
+ finishedAt: null,
216
+ stoppedAt: null,
217
+ },
213
218
  });
214
219
 
215
220
  const out = await handleExperimentResults({ runId: "unfinished" });
216
221
 
217
- expect(out).toContain("results are not yet available");
218
- expect(out).toContain("platform_experiment_status");
222
+ expect(out).toContain("**Status**: running");
223
+ expect(out).toContain("still in progress");
224
+ expect(out).toContain("Row #0");
225
+ expect(out).toContain("Progress**: 2/3");
226
+ });
227
+ });
228
+ });
229
+
230
+ describe("given the run was interrupted before finishing", () => {
231
+ describe("when results are requested", () => {
232
+ it("serves the partial rows with an interrupted note", async () => {
233
+ mockMakeRequest.mockResolvedValueOnce({
234
+ ...sample,
235
+ timestamps: {
236
+ createdAt: Date.now() - 60 * 60 * 1000,
237
+ updatedAt: Date.now() - 30 * 60 * 1000,
238
+ finishedAt: null,
239
+ stoppedAt: null,
240
+ },
241
+ });
242
+
243
+ const out = await handleExperimentResults({ runId: "interrupted" });
244
+
245
+ expect(out).toContain("**Status**: interrupted");
246
+ expect(out).toContain("interrupted");
247
+ expect(out).toContain("Row #0");
248
+ });
249
+ });
250
+ });
251
+
252
+ describe("given a non-terminal run with zero rows", () => {
253
+ describe("when the run is still running", () => {
254
+ it("says the run is in progress", async () => {
255
+ mockMakeRequest.mockResolvedValueOnce({
256
+ ...sample,
257
+ dataset: [],
258
+ evaluations: [],
259
+ timestamps: {
260
+ createdAt: Date.now(),
261
+ updatedAt: Date.now(),
262
+ finishedAt: null,
263
+ stoppedAt: null,
264
+ },
265
+ });
266
+ const out = await handleExperimentResults({ runId: "empty-running" });
267
+ expect(out).toContain("still in progress");
268
+ expect(out).not.toContain("interrupted");
269
+ });
270
+ });
271
+
272
+ describe("when the run was interrupted", () => {
273
+ it("says no rows were recorded before it was interrupted, not to wait", async () => {
274
+ mockMakeRequest.mockResolvedValueOnce({
275
+ ...sample,
276
+ dataset: [],
277
+ evaluations: [],
278
+ timestamps: {
279
+ createdAt: Date.now() - 60 * 60 * 1000,
280
+ updatedAt: Date.now() - 30 * 60 * 1000,
281
+ finishedAt: null,
282
+ stoppedAt: null,
283
+ },
284
+ });
285
+ const out = await handleExperimentResults({ runId: "empty-interrupted" });
286
+ expect(out).toContain("interrupted");
287
+ expect(out).not.toContain("still in progress");
288
+ expect(out).not.toContain("Call again shortly");
289
+ });
290
+ });
291
+ });
292
+
293
+ describe("given no run state but rows exist (SDK / >24h run)", () => {
294
+ describe("when results are requested with a stopped run", () => {
295
+ it("renders results for a stopped run", async () => {
296
+ mockMakeRequest.mockResolvedValueOnce({
297
+ ...sample,
298
+ timestamps: {
299
+ createdAt: 0,
300
+ updatedAt: 10,
301
+ finishedAt: null,
302
+ stoppedAt: 20,
303
+ },
304
+ });
305
+
306
+ const out = await handleExperimentResults({ runId: "stopped" });
307
+
308
+ expect(out).toContain("**Status**: stopped");
309
+ expect(out).toContain("Row #0");
219
310
  });
220
311
  });
221
312
  });
@@ -1,6 +1,8 @@
1
1
  // Minimal type declaration — avoids pulling mcp-server's full dependency tree
2
2
  // into the langwatch app's typecheck scope. The actual return type is McpServer,
3
- // but we only expose the connect() method that handler.ts uses.
3
+ // but we only expose the connect() and tool() methods that handler.ts +
4
+ // in-app tool registrations (e.g. registerGovernanceMcpTools) use.
4
5
  export declare function createMcpServer(): {
5
6
  connect(transport: unknown): Promise<void>;
7
+ tool(...args: unknown[]): unknown;
6
8
  };
@@ -1563,9 +1563,15 @@ NOTE: Scenarios can be created two ways. Determine which approach the user needs
1563
1563
 
1564
1564
  server.tool(
1565
1565
  "platform_experiment_status",
1566
- "Check the status of an experiment run. Returns progress and summary when completed.",
1566
+ "Check the status of an experiment run (running, completed, stopped, or interrupted) plus its progress. Works for both platform-run and SDK-logged experiments; pass experimentSlug for SDK runs or runs older than 24h.",
1567
1567
  {
1568
1568
  runId: z.string().describe("The run ID returned from platform_run_experiment"),
1569
+ experimentSlug: z
1570
+ .string()
1571
+ .optional()
1572
+ .describe(
1573
+ "Experiment slug — required for SDK-logged runs and runs older than 24h (the Redis run-state they would otherwise be read from is absent/expired). Use platform_experiment_list_runs to find the slug.",
1574
+ ),
1569
1575
  },
1570
1576
  withToolLogging("platform_experiment_status", async (params) => {
1571
1577
  requireApiKey();
@@ -1626,7 +1632,7 @@ NOTE: Scenarios can be created two ways. Determine which approach the user needs
1626
1632
 
1627
1633
  server.tool(
1628
1634
  "platform_experiment_results",
1629
- "Fetch per-row results for a completed experiment run so you can debug evaluator scores and missed rows. Returns a markdown report: per-evaluator averages plus row-by-row scores and failure details. Output is capped at 50 rows to protect the agent's context window — narrow with `filter: 'failed'` or `evaluator` to see what matters.",
1635
+ "Fetch per-row results for an experiment run so you can debug evaluator scores and missed rows. Serves partial results for runs that are still running or were interrupted (it does not require a completed run), with the run status noted in the output. Returns a markdown report: per-evaluator averages plus row-by-row scores and failure details. Output is capped at 50 rows to protect the agent's context window — narrow with `filter: 'failed'` or `evaluator` to see what matters.",
1630
1636
  {
1631
1637
  runId: z
1632
1638
  .string()
@@ -271,8 +271,13 @@ export async function startHttpServer({
271
271
  const sseSessions: Record<string, SseSessionState> = {};
272
272
 
273
273
  app.get("/sse", async (req: Request, res: Response) => {
274
+ // SSE/EventSource clients cannot set custom Authorization headers, so
275
+ // the apiKey fallback via ?apiKey= is the standard pattern for SSE
276
+ // auth across the MCP/CLI ecosystem. The header form is preferred and
277
+ // checked first; this fallback is documented at the client level.
274
278
  const apiKey =
275
279
  extractBearerToken(req) ||
280
+ // lgtm[js/sensitive-get-query]
276
281
  (req.query["apiKey"] as string | undefined) ||
277
282
  null;
278
283
 
@@ -0,0 +1,36 @@
1
+ // Experiment runs are stored as a timestamp-driven state in ClickHouse: a run
2
+ // is "completed" once finishedAt is set, "stopped" once stoppedAt is set, and
3
+ // otherwise still "running". A run that stopped emitting updates a while ago
4
+ // without ever finishing is reported as "interrupted": the SDK process likely
5
+ // died before sending finished_at/stopped_at.
6
+
7
+ export const INTERRUPTED_THRESHOLD_MS = 5 * 60 * 1000;
8
+
9
+ export type RunStatus = "completed" | "stopped" | "running" | "interrupted";
10
+
11
+ export interface RunTimestamps {
12
+ createdAt?: number | null;
13
+ updatedAt?: number | null;
14
+ finishedAt?: number | null;
15
+ stoppedAt?: number | null;
16
+ }
17
+
18
+ export const deriveRunStatus = (
19
+ timestamps: RunTimestamps,
20
+ now: number = Date.now(),
21
+ ): RunStatus => {
22
+ if (timestamps.stoppedAt != null) return "stopped";
23
+ if (timestamps.finishedAt != null) return "completed";
24
+ if (
25
+ timestamps.updatedAt != null &&
26
+ now - timestamps.updatedAt > INTERRUPTED_THRESHOLD_MS
27
+ ) {
28
+ return "interrupted";
29
+ }
30
+ return "running";
31
+ };
32
+
33
+ // Whether the run is in a terminal state. Non-terminal runs (running /
34
+ // interrupted) can still be queried; their results are partial.
35
+ export const isTerminalStatus = (status: RunStatus): boolean =>
36
+ status === "completed" || status === "stopped";
@@ -1,4 +1,5 @@
1
1
  import { LangWatchApiError, makeRequest } from "../langwatch-api.js";
2
+ import { deriveRunStatus, isTerminalStatus } from "./experiment-run-status.js";
2
3
 
3
4
  interface DatasetEntry {
4
5
  index: number;
@@ -110,26 +111,34 @@ export async function handleExperimentResults(params: {
110
111
  "",
111
112
  "**Status**: not found",
112
113
  "",
113
- `Could not load results for run \`${params.runId}\`. The run may still be in progress, or the id may be incorrect.`,
114
+ `Could not load results for run \`${params.runId}\`. The run id may be incorrect, or its run state may have expired (Redis keeps it for 24h).`,
114
115
  "",
115
- "> Try \`platform_experiment_status\` first to confirm the run id and that it is completed.",
116
+ "> Pass `experimentSlug` to load results for runs older than 24h. Discover the slug with `platform_experiment_list`, then `platform_experiment_list_runs` for the run ids.",
116
117
  ].join("\n");
117
118
  }
118
119
  throw error;
119
120
  }
120
121
 
121
- if (!results || (results.timestamps.finishedAt == null && results.timestamps.stoppedAt == null)) {
122
+ if (!results) {
122
123
  return [
123
124
  `# Evaluation Results: ${params.runId}`,
124
125
  "",
125
- "**Status**: results are not yet available",
126
+ "**Status**: not found",
126
127
  "",
127
- `Results for run \`${params.runId}\` are not ready yet.`,
128
+ `Could not load results for run \`${params.runId}\`.`,
128
129
  "",
129
- "> Try `platform_experiment_status` first to confirm the run id and that it is completed.",
130
+ "> Pass `experimentSlug` if the run is older than 24h. Discover the slug with `platform_experiment_list`, then `platform_experiment_list_runs` to confirm the run id.",
130
131
  ].join("\n");
131
132
  }
132
133
 
134
+ // Partial results are served even while the run is still in progress.
135
+ // Rows land in ClickHouse incrementally, so a "running" or "interrupted"
136
+ // run can still expose every row recorded so far. A run only fails to set
137
+ // finished_at/stopped_at when the SDK process dies before flushing, but the
138
+ // rows it did log are still useful, so we never gate on a terminal status.
139
+ const runStatus = deriveRunStatus(results.timestamps);
140
+ const partial = !isTerminalStatus(runStatus);
141
+
133
142
  // Group evaluations by target-scoped row key, applying evaluator filter.
134
143
  const evaluationsByRow = new Map<string, EvaluationItem[]>();
135
144
  for (const evaluation of results.evaluations) {
@@ -196,6 +205,12 @@ export async function handleExperimentResults(params: {
196
205
  lines.push(`# Evaluation Results: ${results.runId}`);
197
206
  lines.push("");
198
207
  lines.push(`**Experiment**: ${results.experimentId}`);
208
+ lines.push(`**Status**: ${runStatus}`);
209
+ if (typeof results.total === "number" && results.total > 0) {
210
+ lines.push(
211
+ `**Progress**: ${results.progress ?? results.dataset.length}/${results.total} rows`,
212
+ );
213
+ }
199
214
  lines.push(`**Total rows**: ${results.dataset.length}`);
200
215
  lines.push(`**Total evaluations**: ${results.evaluations.length}`);
201
216
  if (filter === "failed") {
@@ -204,6 +219,14 @@ export async function handleExperimentResults(params: {
204
219
  if (evaluatorFilter) {
205
220
  lines.push(`**Evaluator filter**: ${evaluatorFilter}`);
206
221
  }
222
+ if (partial) {
223
+ lines.push("");
224
+ lines.push(
225
+ runStatus === "interrupted"
226
+ ? "> These are partial results. The run never sent a finished/stopped marker and has had no updates recently, so it likely was interrupted before completing. The rows below are everything recorded so far."
227
+ : "> These are partial results. The run is still in progress, so more rows may appear on a later call.",
228
+ );
229
+ }
207
230
  lines.push("");
208
231
 
209
232
  if (evaluatorAverages.size > 0) {
@@ -222,7 +245,17 @@ export async function handleExperimentResults(params: {
222
245
  }
223
246
 
224
247
  if (rows.length === 0) {
225
- lines.push("_No rows matched the filter._");
248
+ if (filter === "failed") {
249
+ lines.push("_No rows matched the filter._");
250
+ } else if (runStatus === "running") {
251
+ lines.push(
252
+ "_No rows recorded yet. The run is still in progress; call again shortly._",
253
+ );
254
+ } else if (runStatus === "interrupted") {
255
+ lines.push("_No rows were recorded before the run was interrupted._");
256
+ } else {
257
+ lines.push("_No rows recorded for this run._");
258
+ }
226
259
  return lines.join("\n");
227
260
  }
228
261
 
@@ -1,4 +1,5 @@
1
- import { makeRequest } from "../langwatch-api.js";
1
+ import { LangWatchApiError, makeRequest } from "../langwatch-api.js";
2
+ import { deriveRunStatus } from "./experiment-run-status.js";
2
3
 
3
4
  interface EvaluationRunResponse {
4
5
  runId: string;
@@ -45,13 +46,107 @@ export async function handleRunExperiment(params: {
45
46
  return lines.join("\n");
46
47
  }
47
48
 
49
+ interface ResultsForStatus {
50
+ progress?: number | null;
51
+ total?: number | null;
52
+ dataset?: unknown[];
53
+ timestamps: {
54
+ createdAt?: number | null;
55
+ updatedAt?: number | null;
56
+ finishedAt?: number | null;
57
+ stoppedAt?: number | null;
58
+ };
59
+ }
60
+
61
+ // SDK-logged runs (langwatch.experiment + evaluation.log) never populate the
62
+ // Redis run-state that GET /runs/:runId reads, so that endpoint 404s for them.
63
+ // Their data lives only in ClickHouse, reachable through the results endpoint,
64
+ // so we derive the status from there as a fallback. experimentSlug is required
65
+ // because runId is not unique across experiments once the Redis state expires.
66
+ async function statusFromResults(params: {
67
+ runId: string;
68
+ experimentSlug?: string;
69
+ }): Promise<string | null> {
70
+ const search = new URLSearchParams();
71
+ if (params.experimentSlug) search.set("experimentSlug", params.experimentSlug);
72
+ const qs = search.toString() ? `?${search.toString()}` : "";
73
+
74
+ let results: ResultsForStatus;
75
+ try {
76
+ results = (await makeRequest(
77
+ "GET",
78
+ `/api/experiments/runs/${encodeURIComponent(params.runId)}/results${qs}`,
79
+ )) as ResultsForStatus;
80
+ } catch (error) {
81
+ // Only a genuine "no such run" is a fallback miss (-> guidance). Real
82
+ // 5xx / auth / network errors must propagate, not be masked as not-found.
83
+ const code = error instanceof LangWatchApiError ? error.status : undefined;
84
+ const message = error instanceof Error ? error.message : String(error);
85
+ if (code === 404 || (code === undefined && /404|not found/i.test(message))) {
86
+ return null;
87
+ }
88
+ throw error;
89
+ }
90
+
91
+ const status = deriveRunStatus(results.timestamps);
92
+ const total = results.total ?? results.dataset?.length ?? 0;
93
+ const progress = results.progress ?? results.dataset?.length ?? 0;
94
+
95
+ const lines: string[] = [];
96
+ lines.push(`# Evaluation Run ${params.runId}\n`);
97
+ lines.push(`**Status**: ${status}`);
98
+ lines.push(`**Progress**: ${progress}/${total} cells`);
99
+ if (results.timestamps.createdAt) {
100
+ lines.push(
101
+ `**Started**: ${new Date(results.timestamps.createdAt).toISOString()}`,
102
+ );
103
+ }
104
+ if (results.timestamps.finishedAt) {
105
+ lines.push(
106
+ `**Finished**: ${new Date(results.timestamps.finishedAt).toISOString()}`,
107
+ );
108
+ }
109
+ if (results.timestamps.stoppedAt) {
110
+ lines.push(
111
+ `**Stopped**: ${new Date(results.timestamps.stoppedAt).toISOString()}`,
112
+ );
113
+ }
114
+ lines.push("");
115
+ lines.push(
116
+ "> Use `platform_experiment_results` to fetch the per-row scores (partial results are available even while running).",
117
+ );
118
+ return lines.join("\n");
119
+ }
120
+
48
121
  export async function handleExperimentStatus(params: {
49
122
  runId: string;
123
+ experimentSlug?: string;
50
124
  }): Promise<string> {
51
- const status = (await makeRequest(
52
- "GET",
53
- `/api/experiments/runs/${encodeURIComponent(params.runId)}`,
54
- )) as EvaluationStatusResponse;
125
+ let status: EvaluationStatusResponse;
126
+ try {
127
+ status = (await makeRequest(
128
+ "GET",
129
+ `/api/experiments/runs/${encodeURIComponent(params.runId)}`,
130
+ )) as EvaluationStatusResponse;
131
+ } catch (error) {
132
+ const code =
133
+ error instanceof LangWatchApiError ? error.status : undefined;
134
+ const message = error instanceof Error ? error.message : String(error);
135
+ if (code === 404 || /404|not found/i.test(message)) {
136
+ const fallback = await statusFromResults(params);
137
+ if (fallback) return fallback;
138
+ return [
139
+ `# Evaluation Run ${params.runId}`,
140
+ "",
141
+ "**Status**: not found",
142
+ "",
143
+ `Could not find run \`${params.runId}\`. SDK-logged runs and runs older than 24h are not in the live run-state and must be resolved by experiment slug.`,
144
+ "",
145
+ "> Pass `experimentSlug`: discover it with `platform_experiment_list`, then use `platform_experiment_list_runs` for the run ids. Or fetch the rows directly with `platform_experiment_results`.",
146
+ ].join("\n");
147
+ }
148
+ throw error;
149
+ }
55
150
 
56
151
  const lines: string[] = [];
57
152
  lines.push(`# Evaluation Run ${status.runId}\n`);