@langwatch/mcp-server 0.9.0 → 0.10.1

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.
Files changed (101) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/{archive-project-HZBRPR2S.js → archive-project-KAZFHR4A.js} +3 -3
  3. package/dist/{archive-scenario-P5RRITRM.js → archive-scenario-6M2IB7Y2.js} +3 -3
  4. package/dist/{archive-suite-NUUFD2EM.js → archive-suite-NXZ3YDS3.js} +3 -3
  5. package/dist/{assign-prompt-tag-FKCCZPML.js → assign-prompt-tag-WPUJO55U.js} +2 -2
  6. package/dist/{chunk-X2SEURT2.js → chunk-AGGE6NPR.js} +2 -2
  7. package/dist/{chunk-ADSJ2FDA.js → chunk-B6UV3JVI.js} +2 -2
  8. package/dist/{chunk-42ZHHHIJ.js → chunk-CAY57CKF.js} +3 -1
  9. package/dist/{chunk-BBJ5WAJB.js → chunk-COCNCXVJ.js} +2 -2
  10. package/dist/{chunk-BYWYM2RP.js → chunk-ESGZVPPC.js} +2 -2
  11. package/dist/{chunk-R7C7OUVG.js → chunk-GWEW5XON.js} +2 -2
  12. package/dist/{chunk-JVLJKQJG.js → chunk-HRFQAFPR.js} +204 -105
  13. package/dist/{chunk-TROIWSEQ.js → chunk-ITN2QGHF.js} +2 -2
  14. package/dist/{chunk-XC62KJIT.js → chunk-IUP5IHBD.js} +3 -3
  15. package/dist/{chunk-CCM4ZFGF.js → chunk-LTEJDL4V.js} +2 -2
  16. package/dist/{chunk-BB7EOKHR.js → chunk-RDUXCCFR.js} +2 -2
  17. package/dist/{chunk-JHOSVOHT.js → chunk-U2QIW2SW.js} +2 -2
  18. package/dist/{chunk-CHKCEZBP.js → chunk-UKRCZJJC.js} +2 -2
  19. package/dist/{chunk-HKY3GW45.js → chunk-XRQNXN2Z.js} +2 -2
  20. package/dist/{create-agent-K5Q7BFGZ.js → create-agent-BI7UTJ6A.js} +3 -3
  21. package/dist/{create-annotation-KN3GE3DK.js → create-annotation-SNM2J6S4.js} +3 -3
  22. package/dist/{create-api-key-OQI5PUNQ.js → create-api-key-23MXYC2L.js} +3 -3
  23. package/dist/{create-dashboard-G4QA5AXP.js → create-dashboard-CPOOOIKK.js} +3 -3
  24. package/dist/{create-dataset-WQSC37N7.js → create-dataset-BVXPVVY2.js} +3 -3
  25. package/dist/{create-dataset-records-B4RL3FLC.js → create-dataset-records-S7VFIIMR.js} +3 -3
  26. package/dist/{create-evaluator-YFFQIR7E.js → create-evaluator-WJAAQOUE.js} +3 -3
  27. package/dist/create-mcp-server.d.ts +3 -1
  28. package/dist/create-mcp-server.js +2 -2
  29. package/dist/{create-project-CUVQ67VN.js → create-project-BF7LMZH5.js} +3 -3
  30. package/dist/{create-prompt-NGXKSP4X.js → create-prompt-LZ3QHSU4.js} +2 -2
  31. package/dist/{create-prompt-tag-IUKYPVHH.js → create-prompt-tag-IF4J24QM.js} +2 -2
  32. package/dist/{create-scenario-FNBXALH3.js → create-scenario-BK4E5DEU.js} +3 -3
  33. package/dist/{create-suite-DJEPD4ZB.js → create-suite-HEQPNLND.js} +3 -3
  34. package/dist/{delete-agent-TJI5XBBM.js → delete-agent-Y4T2G5PT.js} +3 -3
  35. package/dist/{delete-annotation-KRP53TUU.js → delete-annotation-KYDMQTZG.js} +3 -3
  36. package/dist/{delete-dashboard-N5SZJIR3.js → delete-dashboard-3RW647WJ.js} +3 -3
  37. package/dist/{delete-dataset-KDUOHL2P.js → delete-dataset-SZAHFHHS.js} +3 -3
  38. package/dist/{delete-dataset-records-DM4W3PAW.js → delete-dataset-records-ISHKGEBH.js} +3 -3
  39. package/dist/{delete-evaluator-7ZATLJOA.js → delete-evaluator-VKIG5I3S.js} +3 -3
  40. package/dist/{delete-prompt-tag-I6HNMSVJ.js → delete-prompt-tag-VYYL4GTF.js} +2 -2
  41. package/dist/{delete-workflow-QEO74JQR.js → delete-workflow-SHNPYSWQ.js} +3 -3
  42. package/dist/{get-agent-3KDWCP5N.js → get-agent-IZSP7AR5.js} +3 -3
  43. package/dist/{get-analytics-GPVJX7SF.js → get-analytics-EDFAYYIQ.js} +2 -2
  44. package/dist/{get-dashboard-YPTIE5YY.js → get-dashboard-WSAO6IQ4.js} +3 -3
  45. package/dist/{get-dataset-6DQFYITM.js → get-dataset-BEVNXQNF.js} +3 -3
  46. package/dist/{get-evaluator-PCM6XQAU.js → get-evaluator-HCVXNPZR.js} +3 -3
  47. package/dist/{get-project-JCMWXVLU.js → get-project-H4V3GYRQ.js} +3 -3
  48. package/dist/{get-prompt-5XAGWWBG.js → get-prompt-EWRJVVGB.js} +2 -2
  49. package/dist/{get-scenario-7XBCDZWX.js → get-scenario-24YUJS5W.js} +3 -3
  50. package/dist/{get-simulation-run-AV24FJWI.js → get-simulation-run-UX2W6F6I.js} +3 -3
  51. package/dist/{get-suite-4DBVHFEM.js → get-suite-B65ZKZJA.js} +3 -3
  52. package/dist/{get-trace-MRBDS3HJ.js → get-trace-ZR7MIITH.js} +2 -2
  53. package/dist/{get-workflow-UR4Y6UFX.js → get-workflow-YROHUQJW.js} +3 -3
  54. package/dist/{http-server-DEGNV5EP.js → http-server-GXVEDA7G.js} +5 -4
  55. package/dist/index.js +2660 -1497
  56. package/dist/{langwatch-api-annotations-ZJDRB4JH.js → langwatch-api-annotations-OPEXNTPF.js} +3 -3
  57. package/dist/{langwatch-api-dashboards-WARLI6NF.js → langwatch-api-dashboards-NPQM6QJI.js} +3 -3
  58. package/dist/{langwatch-api-monitors-4XKXHYCG.js → langwatch-api-monitors-3L25OOOS.js} +2 -2
  59. package/dist/{langwatch-api-secrets-6SHK5G7Q.js → langwatch-api-secrets-ZFURUA62.js} +2 -2
  60. package/dist/{langwatch-api-triggers-5SX6R2WU.js → langwatch-api-triggers-XGXXT7DD.js} +2 -2
  61. package/dist/{langwatch-api-workflows-NLJENNXE.js → langwatch-api-workflows-CXCTTCXT.js} +3 -3
  62. package/dist/{list-agents-NECO7MPI.js → list-agents-6ZNOV7FK.js} +3 -3
  63. package/dist/{list-annotations-3OJEO774.js → list-annotations-6P3IWILH.js} +3 -3
  64. package/dist/{list-api-keys-AWW7ROLB.js → list-api-keys-JJI62KD2.js} +3 -3
  65. package/dist/{list-dashboards-DVPVVVKW.js → list-dashboards-G2WJOXWB.js} +3 -3
  66. package/dist/{list-dataset-records-I4BYORF5.js → list-dataset-records-GWWVOCII.js} +3 -3
  67. package/dist/{list-datasets-WUAE5HGX.js → list-datasets-HBTH33X4.js} +3 -3
  68. package/dist/{list-evaluators-B6OSVAM2.js → list-evaluators-K5OGB6TX.js} +3 -3
  69. package/dist/{list-model-providers-NL6IP4UZ.js → list-model-providers-JMOGZLSO.js} +3 -3
  70. package/dist/{list-projects-5FHMJ4PQ.js → list-projects-OXJC2NW5.js} +3 -3
  71. package/dist/{list-prompt-tags-Z2DD6FLQ.js → list-prompt-tags-J2HPHVAZ.js} +2 -2
  72. package/dist/{list-prompts-VNXKHK7T.js → list-prompts-C7GR72XL.js} +2 -2
  73. package/dist/{list-scenarios-CFZHXL76.js → list-scenarios-JEPGIIDO.js} +3 -3
  74. package/dist/{list-simulation-runs-AM2BXLBW.js → list-simulation-runs-DRSAGPEC.js} +3 -3
  75. package/dist/{list-suites-ZDOHAAXF.js → list-suites-POTZ3ZT6.js} +3 -3
  76. package/dist/{list-workflows-WBRBKLIT.js → list-workflows-QQZQIOHB.js} +3 -3
  77. package/dist/{rename-prompt-tag-A4RJUK7Z.js → rename-prompt-tag-4AAV2BS4.js} +2 -2
  78. package/dist/{revoke-api-key-ZSPSPF5G.js → revoke-api-key-TONB4ADQ.js} +3 -3
  79. package/dist/{run-agent-JFNDY7MR.js → run-agent-7QE3YEH7.js} +3 -3
  80. package/dist/{run-suite-TH7LO5TJ.js → run-suite-Q66OSPO4.js} +3 -3
  81. package/dist/{run-workflow-CK3JP5U2.js → run-workflow-RWX2D752.js} +3 -3
  82. package/dist/{search-traces-3BBVYPC3.js → search-traces-M5E5VB4X.js} +2 -2
  83. package/dist/{set-model-provider-WOC5FNMV.js → set-model-provider-XENT5D2P.js} +3 -3
  84. package/dist/{update-agent-YMEV5MTL.js → update-agent-GML25EE3.js} +3 -3
  85. package/dist/{update-dataset-EYT5POFM.js → update-dataset-R4MM6NNY.js} +3 -3
  86. package/dist/{update-dataset-record-VFF5JTX4.js → update-dataset-record-FN4BL5P4.js} +3 -3
  87. package/dist/{update-evaluator-6IJHFG2E.js → update-evaluator-LH2RDDB5.js} +3 -3
  88. package/dist/{update-project-OIT4WPFI.js → update-project-H53IHRBB.js} +3 -3
  89. package/dist/{update-prompt-ZU2DD6KM.js → update-prompt-M33OTTAJ.js} +2 -2
  90. package/dist/{update-scenario-3VUTPDRU.js → update-scenario-FKTQRAFQ.js} +3 -3
  91. package/dist/{update-suite-C6JFDQHL.js → update-suite-JJTNOTXN.js} +3 -3
  92. package/package.json +1 -1
  93. package/pnpm-workspace.yaml +13 -0
  94. package/src/__tests__/experiment-status.unit.test.ts +194 -0
  95. package/src/__tests__/get-experiment-results.unit.test.ts +96 -5
  96. package/src/create-mcp-server.d.ts +3 -1
  97. package/src/create-mcp-server.ts +8 -2
  98. package/src/http-server.ts +5 -0
  99. package/src/tools/experiment-run-status.ts +36 -0
  100. package/src/tools/get-experiment-results.ts +40 -7
  101. package/src/tools/run-experiment.ts +100 -5
@@ -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`);