@langwatch/mcp-server 0.10.1 → 1.0.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.
Files changed (122) hide show
  1. package/.release-please-shim +1 -1
  2. package/CHANGELOG.md +54 -0
  3. package/README.md +9 -17
  4. package/dist/{archive-project-KAZFHR4A.js → archive-project-MK3673A7.js} +4 -3
  5. package/dist/{archive-scenario-6M2IB7Y2.js → archive-scenario-KATSXRIN.js} +4 -3
  6. package/dist/{archive-suite-NXZ3YDS3.js → archive-suite-C3KX6IIZ.js} +4 -3
  7. package/dist/{assign-prompt-tag-WPUJO55U.js → assign-prompt-tag-E3C5W6PI.js} +3 -2
  8. package/dist/chunk-37SMPMQS.js +12 -0
  9. package/dist/{chunk-CAY57CKF.js → chunk-5UWY24RP.js} +77 -4
  10. package/dist/chunk-BJ7B4R26.js +42 -0
  11. package/dist/chunk-CE6QMZOP.js +432 -0
  12. package/dist/{chunk-AGGE6NPR.js → chunk-DAIQKS27.js} +2 -2
  13. package/dist/{chunk-GWEW5XON.js → chunk-EU22L52K.js} +2 -2
  14. package/dist/{chunk-ITN2QGHF.js → chunk-J45SVWPR.js} +2 -2
  15. package/dist/{chunk-LTEJDL4V.js → chunk-O3LWVN67.js} +2 -2
  16. package/dist/{chunk-RDUXCCFR.js → chunk-RYRB2YVJ.js} +2 -2
  17. package/dist/{chunk-XRQNXN2Z.js → chunk-T7WJVS2N.js} +2 -2
  18. package/dist/{chunk-U2QIW2SW.js → chunk-UXXA4D6K.js} +2 -2
  19. package/dist/{chunk-HRFQAFPR.js → chunk-VYPSKRPN.js} +220 -133
  20. package/dist/{chunk-UKRCZJJC.js → chunk-WDH5P5RF.js} +2 -2
  21. package/dist/{chunk-COCNCXVJ.js → chunk-WWMDHZZV.js} +2 -2
  22. package/dist/{chunk-B6UV3JVI.js → chunk-XY4STOBE.js} +2 -2
  23. package/dist/{chunk-ESGZVPPC.js → chunk-Y3HDZ6OW.js} +2 -2
  24. package/dist/config.js +1 -0
  25. package/dist/{create-agent-BI7UTJ6A.js → create-agent-7W57J7MT.js} +4 -3
  26. package/dist/{create-annotation-SNM2J6S4.js → create-annotation-W2XELBCQ.js} +4 -3
  27. package/dist/{create-api-key-23MXYC2L.js → create-api-key-MKRXKFJM.js} +4 -3
  28. package/dist/{create-dashboard-CPOOOIKK.js → create-dashboard-QGL7IODC.js} +4 -3
  29. package/dist/{create-dataset-BVXPVVY2.js → create-dataset-3PH7A4HG.js} +4 -3
  30. package/dist/{create-dataset-records-S7VFIIMR.js → create-dataset-records-4DZS3VRL.js} +4 -3
  31. package/dist/{create-evaluator-WJAAQOUE.js → create-evaluator-YWH6NC4M.js} +4 -3
  32. package/dist/create-mcp-server.js +3 -2
  33. package/dist/{create-project-BF7LMZH5.js → create-project-BOBQ32C5.js} +4 -3
  34. package/dist/{create-prompt-LZ3QHSU4.js → create-prompt-FLCFGITE.js} +3 -2
  35. package/dist/{create-prompt-tag-IF4J24QM.js → create-prompt-tag-EMPNIKEA.js} +3 -2
  36. package/dist/{create-scenario-BK4E5DEU.js → create-scenario-BSBIHUL6.js} +4 -3
  37. package/dist/{create-suite-HEQPNLND.js → create-suite-TWF6BBR4.js} +4 -3
  38. package/dist/{delete-agent-Y4T2G5PT.js → delete-agent-7MSYNY5E.js} +4 -3
  39. package/dist/{delete-annotation-KYDMQTZG.js → delete-annotation-ZT5IGN24.js} +4 -3
  40. package/dist/{delete-dashboard-3RW647WJ.js → delete-dashboard-BTIY5S3L.js} +4 -3
  41. package/dist/{delete-dataset-SZAHFHHS.js → delete-dataset-U45HQHPR.js} +4 -3
  42. package/dist/{delete-dataset-records-ISHKGEBH.js → delete-dataset-records-R43IMAWB.js} +4 -3
  43. package/dist/{delete-evaluator-VKIG5I3S.js → delete-evaluator-U6JMQ6C7.js} +4 -3
  44. package/dist/{delete-prompt-tag-VYYL4GTF.js → delete-prompt-tag-4EV4TDHB.js} +3 -2
  45. package/dist/{delete-workflow-SHNPYSWQ.js → delete-workflow-5QYALXEL.js} +4 -3
  46. package/dist/discover-evaluator-schema-MRGHVNTT.js +2175 -0
  47. package/dist/{discover-scenario-schema-MEEEVND7.js → discover-scenario-schema-UPBTCWPG.js} +3 -1
  48. package/dist/{discover-schema-3T52ORPB.js → discover-schema-ICGXPHUW.js} +3 -1
  49. package/dist/{get-agent-IZSP7AR5.js → get-agent-7QJVPH72.js} +4 -3
  50. package/dist/{get-analytics-EDFAYYIQ.js → get-analytics-PVERWOA5.js} +3 -2
  51. package/dist/{get-dashboard-WSAO6IQ4.js → get-dashboard-ZZPC3WMD.js} +4 -3
  52. package/dist/{get-dataset-BEVNXQNF.js → get-dataset-25TB2LQH.js} +4 -3
  53. package/dist/{get-evaluator-HCVXNPZR.js → get-evaluator-3FTRQKEC.js} +4 -3
  54. package/dist/{get-project-H4V3GYRQ.js → get-project-5OAXYC57.js} +4 -3
  55. package/dist/{get-prompt-EWRJVVGB.js → get-prompt-I753LZKZ.js} +3 -2
  56. package/dist/{get-scenario-24YUJS5W.js → get-scenario-DSSTR6FI.js} +4 -3
  57. package/dist/{get-simulation-run-UX2W6F6I.js → get-simulation-run-RQPMS2MC.js} +4 -3
  58. package/dist/{get-suite-B65ZKZJA.js → get-suite-GTOCY5PL.js} +4 -3
  59. package/dist/{get-trace-ZR7MIITH.js → get-trace-O5VT3NAC.js} +7 -9
  60. package/dist/{get-workflow-YROHUQJW.js → get-workflow-3EZDOEIF.js} +4 -3
  61. package/dist/http-server-2ACJ3CXW.js +24366 -0
  62. package/dist/index.js +20935 -1751
  63. package/dist/{langwatch-api-annotations-OPEXNTPF.js → langwatch-api-annotations-E65MI3WZ.js} +4 -3
  64. package/dist/{langwatch-api-dashboards-NPQM6QJI.js → langwatch-api-dashboards-QCDECRCY.js} +4 -3
  65. package/dist/{langwatch-api-monitors-3L25OOOS.js → langwatch-api-monitors-UIO2KTDV.js} +3 -2
  66. package/dist/{langwatch-api-secrets-ZFURUA62.js → langwatch-api-secrets-4T2EKSJ7.js} +3 -2
  67. package/dist/{langwatch-api-triggers-XGXXT7DD.js → langwatch-api-triggers-MPOEKPUY.js} +3 -2
  68. package/dist/{langwatch-api-workflows-CXCTTCXT.js → langwatch-api-workflows-5HRZ7HR4.js} +4 -3
  69. package/dist/{list-agents-6ZNOV7FK.js → list-agents-TIJTP566.js} +4 -3
  70. package/dist/{list-annotations-6P3IWILH.js → list-annotations-CGX5BT7E.js} +4 -3
  71. package/dist/{list-api-keys-JJI62KD2.js → list-api-keys-JLWJPFRX.js} +4 -3
  72. package/dist/{list-dashboards-G2WJOXWB.js → list-dashboards-RNIWX3TX.js} +4 -3
  73. package/dist/{list-dataset-records-GWWVOCII.js → list-dataset-records-B5MHZR66.js} +4 -3
  74. package/dist/{list-datasets-HBTH33X4.js → list-datasets-BVCAMQCP.js} +4 -3
  75. package/dist/{list-evaluators-K5OGB6TX.js → list-evaluators-65FBTL2U.js} +4 -3
  76. package/dist/{list-model-providers-JMOGZLSO.js → list-model-providers-GRK3I3VV.js} +4 -3
  77. package/dist/{list-projects-OXJC2NW5.js → list-projects-JLMBFMW3.js} +4 -3
  78. package/dist/{list-prompt-tags-J2HPHVAZ.js → list-prompt-tags-UHNWDGQI.js} +3 -2
  79. package/dist/{list-prompts-C7GR72XL.js → list-prompts-7254B3U7.js} +3 -2
  80. package/dist/{list-scenarios-JEPGIIDO.js → list-scenarios-46YNICKM.js} +4 -3
  81. package/dist/{list-simulation-runs-DRSAGPEC.js → list-simulation-runs-LGNEFNPN.js} +4 -3
  82. package/dist/{list-suites-POTZ3ZT6.js → list-suites-5VK4HXEK.js} +4 -3
  83. package/dist/{list-workflows-QQZQIOHB.js → list-workflows-MLQDM6CP.js} +4 -3
  84. package/dist/{rename-prompt-tag-4AAV2BS4.js → rename-prompt-tag-RHQMNEQL.js} +3 -2
  85. package/dist/report-issue-DXJ6TPTC.js +412 -0
  86. package/dist/{revoke-api-key-TONB4ADQ.js → revoke-api-key-3Y35EMBT.js} +4 -3
  87. package/dist/{run-agent-7QE3YEH7.js → run-agent-RBSIFKRT.js} +4 -3
  88. package/dist/{run-suite-Q66OSPO4.js → run-suite-K3DNQ3SE.js} +4 -3
  89. package/dist/{run-workflow-RWX2D752.js → run-workflow-5LVBZYLS.js} +4 -3
  90. package/dist/{search-traces-M5E5VB4X.js → search-traces-U4HLOCZ2.js} +9 -2
  91. package/dist/{set-model-provider-XENT5D2P.js → set-model-provider-V3M5CDIY.js} +4 -3
  92. package/dist/{update-agent-GML25EE3.js → update-agent-47RZIHS7.js} +4 -3
  93. package/dist/{update-dataset-R4MM6NNY.js → update-dataset-G6QJJGS5.js} +4 -3
  94. package/dist/{update-dataset-record-FN4BL5P4.js → update-dataset-record-RMWLSWCS.js} +4 -3
  95. package/dist/{update-evaluator-LH2RDDB5.js → update-evaluator-SFRRI5Q3.js} +4 -3
  96. package/dist/{update-project-H53IHRBB.js → update-project-PI5DWBVK.js} +4 -3
  97. package/dist/{update-prompt-M33OTTAJ.js → update-prompt-RQFTEO7Y.js} +3 -2
  98. package/dist/{update-scenario-FKTQRAFQ.js → update-scenario-G3IU2UIB.js} +4 -3
  99. package/dist/{update-suite-JJTNOTXN.js → update-suite-TRGGLNR7.js} +4 -3
  100. package/package.json +20 -18
  101. package/pnpm-workspace.yaml +20 -3
  102. package/pyproject.toml +9 -1
  103. package/src/__tests__/all-tools.integration.test.ts +56 -120
  104. package/src/__tests__/documentation-fetch-security.integration.test.ts +176 -0
  105. package/src/__tests__/langwatch-api.unit.test.ts +206 -0
  106. package/src/__tests__/public-http-request-security.integration.test.ts +118 -0
  107. package/src/__tests__/report-issue.integration.test.ts +147 -0
  108. package/src/__tests__/tools.unit.test.ts +24 -0
  109. package/src/create-mcp-server.ts +43 -29
  110. package/src/documentation-fetch.ts +111 -0
  111. package/src/langwatch-api-agents.ts +2 -2
  112. package/src/langwatch-api.ts +138 -8
  113. package/src/public-http-request.ts +242 -0
  114. package/src/tools/get-trace.ts +2 -12
  115. package/src/tools/report-issue.ts +150 -0
  116. package/src/tools/search-traces.ts +4 -0
  117. package/src/utils/format-evaluations.ts +20 -0
  118. package/tsconfig.json +8 -1
  119. package/uv.lock +259 -205
  120. package/dist/chunk-IUP5IHBD.js +0 -71
  121. package/dist/discover-evaluator-schema-H23XCLNE.js +0 -1402
  122. package/dist/http-server-GXVEDA7G.js +0 -226
@@ -0,0 +1,111 @@
1
+ export type DocumentationKind = "langwatch" | "scenario";
2
+
3
+ const TRUSTED_DOCUMENTATION_ORIGIN = "https://langwatch.ai";
4
+ const MAX_DOCUMENTATION_BYTES = 2 * 1024 * 1024;
5
+ const DOCUMENTATION_TIMEOUT_MS = 30_000;
6
+ const DOCUMENTATION_CONTENT_TYPES = new Set(["text/markdown", "text/plain"]);
7
+
8
+ const DOCUMENTATION_CONFIG: Record<DocumentationKind, { defaultPath: string; namespace: string }> = {
9
+ langwatch: {
10
+ defaultPath: "/docs/llms.txt",
11
+ namespace: "/docs",
12
+ },
13
+ scenario: {
14
+ defaultPath: "/scenario/llms.txt",
15
+ namespace: "/scenario",
16
+ },
17
+ };
18
+
19
+ function documentationUrlError(namespace: string): Error {
20
+ return new Error(
21
+ `Only a trusted LangWatch documentation URL under ${TRUSTED_DOCUMENTATION_ORIGIN}${namespace}/ may be fetched`
22
+ );
23
+ }
24
+
25
+ export function resolveDocumentationUrl(kind: DocumentationKind, input?: string): URL {
26
+ const { defaultPath, namespace } = DOCUMENTATION_CONFIG[kind];
27
+ const value = input?.trim();
28
+ let url: URL;
29
+
30
+ try {
31
+ if (!value) {
32
+ url = new URL(defaultPath, TRUSTED_DOCUMENTATION_ORIGIN);
33
+ } else if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value) || value.startsWith("//")) {
34
+ url = new URL(value, TRUSTED_DOCUMENTATION_ORIGIN);
35
+ } else {
36
+ const relativePath = value.replace(/^\/+/, "");
37
+ const namespacePath = namespace.slice(1);
38
+ const path =
39
+ relativePath === namespacePath || relativePath.startsWith(`${namespacePath}/`)
40
+ ? `/${relativePath}`
41
+ : `${namespace}/${relativePath}`;
42
+ url = new URL(path, TRUSTED_DOCUMENTATION_ORIGIN);
43
+ }
44
+ } catch {
45
+ throw documentationUrlError(namespace);
46
+ }
47
+
48
+ if (
49
+ url.protocol !== "https:" ||
50
+ url.hostname !== "langwatch.ai" ||
51
+ url.port !== "" ||
52
+ url.username !== "" ||
53
+ url.password !== "" ||
54
+ !url.pathname.startsWith(`${namespace}/`)
55
+ ) {
56
+ throw documentationUrlError(namespace);
57
+ }
58
+
59
+ const lowerPath = url.pathname.toLowerCase();
60
+ if (!lowerPath.endsWith(".md") && !lowerPath.endsWith(".txt")) {
61
+ url.pathname += ".md";
62
+ }
63
+
64
+ return url;
65
+ }
66
+
67
+ export async function fetchDocumentation(
68
+ kind: DocumentationKind,
69
+ input?: string,
70
+ fetchImplementation: typeof fetch = fetch
71
+ ): Promise<string> {
72
+ const url = resolveDocumentationUrl(kind, input);
73
+ const response = await fetchImplementation(url, {
74
+ redirect: "error",
75
+ signal: AbortSignal.timeout(DOCUMENTATION_TIMEOUT_MS),
76
+ });
77
+ if (!response.ok) {
78
+ throw new Error(`Documentation request failed with status ${response.status}`);
79
+ }
80
+
81
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim() ?? "";
82
+ if (!DOCUMENTATION_CONTENT_TYPES.has(contentType)) {
83
+ throw new Error("Documentation response has an unexpected content type");
84
+ }
85
+
86
+ const contentLength = Number(response.headers.get("content-length"));
87
+ if (Number.isFinite(contentLength) && contentLength > MAX_DOCUMENTATION_BYTES) {
88
+ throw new Error("Documentation response is too large");
89
+ }
90
+
91
+ if (!response.body) {
92
+ return "";
93
+ }
94
+
95
+ const reader = response.body.getReader();
96
+ const decoder = new TextDecoder();
97
+ let receivedBytes = 0;
98
+ let text = "";
99
+ for (;;) {
100
+ const { done, value } = await reader.read();
101
+ if (done) {
102
+ return text + decoder.decode();
103
+ }
104
+ receivedBytes += value.byteLength;
105
+ if (receivedBytes > MAX_DOCUMENTATION_BYTES) {
106
+ await reader.cancel();
107
+ throw new Error("Documentation response is too large");
108
+ }
109
+ text += decoder.decode(value, { stream: true });
110
+ }
111
+ }
@@ -1,4 +1,5 @@
1
1
  import { makeRequest } from "./langwatch-api.js";
2
+ import { requestPublicJson } from "./public-http-request.js";
2
3
 
3
4
  export interface AgentSummary {
4
5
  id: string;
@@ -76,13 +77,12 @@ export async function runAgent(
76
77
  throw new Error("HTTP agent has no URL configured");
77
78
  }
78
79
 
79
- const response = await fetch(url, {
80
+ const result = await requestPublicJson(url, {
80
81
  method: "POST",
81
82
  headers: { "Content-Type": "application/json" },
82
83
  body: JSON.stringify(input ?? {}),
83
84
  });
84
85
 
85
- const result = (await response.json()) as Record<string, unknown>;
86
86
  return { agentType: "http", result };
87
87
  }
88
88
 
@@ -1,4 +1,9 @@
1
+ import type {
2
+ HandledErrorFault,
3
+ SerializedReason,
4
+ } from "@langwatch/handled-error";
1
5
  import { getConfig, requireApiKey } from "./config.js";
6
+ import type { EvaluationSummary } from "./utils/format-evaluations.js";
2
7
 
3
8
  // --- Response types ---
4
9
 
@@ -10,6 +15,7 @@ export interface TraceSearchResult {
10
15
  timestamps?: { started_at?: string | number };
11
16
  metadata?: Record<string, unknown>;
12
17
  error?: Record<string, unknown>;
18
+ evaluations?: EvaluationSummary[];
13
19
  }
14
20
 
15
21
  export interface SearchTracesResponse {
@@ -39,13 +45,7 @@ export interface TraceDetailResponse {
39
45
  };
40
46
  error?: Record<string, unknown>;
41
47
  ascii_tree?: string;
42
- evaluations?: Array<{
43
- evaluator_id?: string;
44
- name?: string;
45
- score?: number;
46
- passed?: boolean;
47
- label?: string;
48
- }>;
48
+ evaluations?: EvaluationSummary[];
49
49
  spans?: Array<{
50
50
  span_id: string;
51
51
  name?: string;
@@ -106,14 +106,121 @@ export interface PromptMutationResponse {
106
106
  // --- HTTP client ---
107
107
 
108
108
  export class LangWatchApiError extends Error {
109
+ readonly code?: string;
110
+ readonly tips?: string[];
111
+ readonly docsUrl?: string;
112
+ readonly fault?: HandledErrorFault;
113
+ /**
114
+ * The per-field failures behind this error, verbatim from the envelope.
115
+ * A validation failure carries the offending field and the values it would
116
+ * have accepted here — the difference between an agent correcting its own
117
+ * request and an agent guessing again.
118
+ */
119
+ readonly reasons?: SerializedReason[];
120
+
109
121
  constructor(
110
122
  message: string,
111
123
  public readonly status: number,
112
124
  public readonly responseBody: string,
125
+ options: {
126
+ code?: string;
127
+ tips?: string[];
128
+ docsUrl?: string;
129
+ fault?: HandledErrorFault;
130
+ reasons?: SerializedReason[];
131
+ } = {},
113
132
  ) {
114
133
  super(message);
115
134
  this.name = "LangWatchApiError";
135
+ this.code = options.code;
136
+ this.tips = options.tips;
137
+ this.docsUrl = options.docsUrl;
138
+ this.fault = options.fault;
139
+ this.reasons = options.reasons;
140
+ }
141
+ }
142
+
143
+ /** Structured fields extracted from a handled-error JSON response body. */
144
+ interface ParsedErrorBody {
145
+ code?: string;
146
+ message?: string;
147
+ tips?: string[];
148
+ docsUrl?: string;
149
+ fault?: HandledErrorFault;
150
+ reasons?: SerializedReason[];
151
+ }
152
+
153
+ const VALID_FAULTS: readonly HandledErrorFault[] = ["customer", "platform", "provider"];
154
+
155
+ /**
156
+ * Parses an error response body as a handled-error envelope. Accepts both the
157
+ * REST shape (`{ error: "<code>", message, tips?, docsUrl?, fault? }`) and the
158
+ * serialized tRPC shape (`{ code, message?, tips?, docsUrl?, fault?, ... }`).
159
+ * Returns an empty object when the body is not a recognizable error envelope.
160
+ */
161
+ function parseErrorBody(responseBody: string): ParsedErrorBody {
162
+ try {
163
+ const parsed: unknown = JSON.parse(responseBody);
164
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
165
+ return {};
166
+ }
167
+ const body = parsed as Record<string, unknown>;
168
+ // Prefer `code` (the domain discriminant) over `error` — the
169
+ // packages/api unversioned envelope uses `error` for the HTTP status
170
+ // text ("Not Found") while `code` holds the real code.
171
+ const code =
172
+ typeof body.code === "string"
173
+ ? body.code
174
+ : typeof body.error === "string"
175
+ ? body.error
176
+ : undefined;
177
+ const message = typeof body.message === "string" ? body.message : undefined;
178
+ if (code === undefined && message === undefined) {
179
+ return {};
180
+ }
181
+ const tips =
182
+ Array.isArray(body.tips) && body.tips.every((t) => typeof t === "string")
183
+ ? (body.tips as string[])
184
+ : undefined;
185
+ const docsUrl = typeof body.docsUrl === "string" ? body.docsUrl : undefined;
186
+ const fault = VALID_FAULTS.includes(body.fault as HandledErrorFault)
187
+ ? (body.fault as HandledErrorFault)
188
+ : undefined;
189
+ const reasons =
190
+ Array.isArray(body.reasons) &&
191
+ body.reasons.every((r) => !!r && typeof r === "object")
192
+ ? (body.reasons as SerializedReason[])
193
+ : undefined;
194
+ return { code, message, tips, docsUrl, fault, reasons };
195
+ } catch {
196
+ return {};
197
+ }
198
+ }
199
+
200
+ /**
201
+ * The human line for one per-field failure: the field, what it would have
202
+ * accepted, and what it got. Returns null when a reason names no field, so a
203
+ * generic nested error adds no noise to the message.
204
+ *
205
+ * This exists because the MCP transport's only channel to the caller is the
206
+ * error MESSAGE — an agent never sees the `reasons` array itself, so a
207
+ * rejection whose remedy lives only there is unfollowable.
208
+ */
209
+ function describeReason(reason: SerializedReason): string | null {
210
+ const meta = reason.meta;
211
+ if (!meta || typeof meta !== "object") return null;
212
+
213
+ const field = typeof meta.field === "string" ? meta.field : null;
214
+ if (!field) return null;
215
+
216
+ const parts = [`- ${field}`];
217
+ if (meta.received !== undefined) {
218
+ parts.push(`received ${JSON.stringify(meta.received)}`);
219
+ }
220
+ if (Array.isArray(meta.expected) && meta.expected.length > 0) {
221
+ parts.push(`expected one of: ${meta.expected.join(", ")}`);
116
222
  }
223
+ return parts.join(" — ");
117
224
  }
118
225
 
119
226
  /**
@@ -151,10 +258,33 @@ export async function makeRequest(
151
258
 
152
259
  if (!response.ok) {
153
260
  const responseBody = await response.text();
261
+ const parsed = parseErrorBody(responseBody);
262
+ const lines = [
263
+ `LangWatch API error ${response.status}: ${parsed.message ?? responseBody}`,
264
+ ];
265
+ const reasonLines = (parsed.reasons ?? [])
266
+ .map(describeReason)
267
+ .filter((line): line is string => line !== null);
268
+ if (reasonLines.length > 0) {
269
+ lines.push("Rejected fields:", ...reasonLines);
270
+ }
271
+ if (parsed.tips && parsed.tips.length > 0) {
272
+ lines.push("Tips:", ...parsed.tips.map((tip) => `- ${tip}`));
273
+ }
274
+ if (parsed.docsUrl) {
275
+ lines.push(`Docs: ${parsed.docsUrl}`);
276
+ }
154
277
  throw new LangWatchApiError(
155
- `LangWatch API error ${response.status}: ${responseBody}`,
278
+ lines.join("\n"),
156
279
  response.status,
157
280
  responseBody,
281
+ {
282
+ code: parsed.code,
283
+ tips: parsed.tips,
284
+ docsUrl: parsed.docsUrl,
285
+ fault: parsed.fault,
286
+ reasons: parsed.reasons,
287
+ },
158
288
  );
159
289
  }
160
290
 
@@ -0,0 +1,242 @@
1
+ import { lookup as dnsLookup } from "node:dns/promises";
2
+ import { request as httpRequest, type RequestOptions } from "node:http";
3
+ import { request as httpsRequest } from "node:https";
4
+ import { isIP } from "node:net";
5
+
6
+ import { classify } from "@langwatch/ssrf";
7
+
8
+ const MAX_REDIRECTS = 5;
9
+ const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
10
+ const REQUEST_TIMEOUT_MS = 30_000;
11
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
12
+ const SENSITIVE_REDIRECT_HEADERS = new Set([
13
+ "authorization",
14
+ "cookie",
15
+ "cookie2",
16
+ "proxy-authorization",
17
+ "x-api-key",
18
+ "api-key",
19
+ "x-auth-token",
20
+ "x-access-token",
21
+ ]);
22
+
23
+ type DnsResolver = (hostname: string, options: { all: true; verbatim: true }) => Promise<ResolvedAddress[]>;
24
+
25
+ interface ResolvedAddress {
26
+ address: string;
27
+ family: number;
28
+ }
29
+
30
+ export interface PublicDestination {
31
+ address: string;
32
+ family: number;
33
+ url: URL;
34
+ }
35
+
36
+ function unsafeDestinationError(): Error {
37
+ return new Error("HTTP agent destinations must resolve only to globally routable public addresses");
38
+ }
39
+
40
+ function hostnameWithoutBrackets(hostname: string): string {
41
+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
42
+ }
43
+
44
+ function assertPublicAddress(address: string): void {
45
+ if (classify(address) !== "global") {
46
+ throw unsafeDestinationError();
47
+ }
48
+ }
49
+
50
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
51
+ let timeout: ReturnType<typeof setTimeout> | undefined;
52
+ try {
53
+ return await Promise.race([
54
+ promise,
55
+ new Promise<never>((_resolve, reject) => {
56
+ timeout = setTimeout(() => reject(new Error("request timed out")), timeoutMs);
57
+ timeout.unref();
58
+ }),
59
+ ]);
60
+ } finally {
61
+ if (timeout) {
62
+ clearTimeout(timeout);
63
+ }
64
+ }
65
+ }
66
+
67
+ export function headersForRedirect(
68
+ headers: Record<string, string> | undefined,
69
+ crossOrigin: boolean
70
+ ): Record<string, string> | undefined {
71
+ if (!headers || !crossOrigin) {
72
+ return headers;
73
+ }
74
+
75
+ const safeHeaders = Object.fromEntries(
76
+ Object.entries(headers).filter(([name]) => !SENSITIVE_REDIRECT_HEADERS.has(name.toLowerCase()))
77
+ );
78
+ return Object.keys(safeHeaders).length > 0 ? safeHeaders : undefined;
79
+ }
80
+
81
+ export async function resolvePublicDestination(
82
+ input: string,
83
+ resolver: DnsResolver = dnsLookup
84
+ ): Promise<PublicDestination> {
85
+ let url: URL;
86
+ try {
87
+ url = new URL(input);
88
+ } catch {
89
+ throw new Error("HTTP agent URL is invalid");
90
+ }
91
+
92
+ if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username !== "" || url.password !== "") {
93
+ throw unsafeDestinationError();
94
+ }
95
+
96
+ const hostname = hostnameWithoutBrackets(url.hostname);
97
+ const literalFamily = isIP(hostname);
98
+ if (literalFamily !== 0) {
99
+ assertPublicAddress(hostname);
100
+ return { address: hostname, family: literalFamily, url };
101
+ }
102
+
103
+ let addresses: ResolvedAddress[];
104
+ try {
105
+ addresses = await withTimeout(
106
+ resolver(hostname, { all: true, verbatim: true }),
107
+ REQUEST_TIMEOUT_MS
108
+ );
109
+ } catch {
110
+ throw new Error("HTTP agent destination could not be resolved");
111
+ }
112
+
113
+ if (addresses.length === 0) {
114
+ throw new Error("HTTP agent destination could not be resolved");
115
+ }
116
+
117
+ for (const { address } of addresses) {
118
+ assertPublicAddress(address);
119
+ }
120
+
121
+ const selected = addresses.find(({ family }) => family === 4) ?? addresses[0]!;
122
+ return {
123
+ address: selected.address,
124
+ family: selected.family,
125
+ url,
126
+ };
127
+ }
128
+
129
+ interface PublicJsonRequestOptions {
130
+ body?: string;
131
+ headers?: Record<string, string>;
132
+ method?: "GET" | "POST";
133
+ signal?: AbortSignal;
134
+ }
135
+
136
+ function requestBody(
137
+ destination: PublicDestination,
138
+ options: PublicJsonRequestOptions
139
+ ): Promise<{ body: string; location?: string; statusCode: number }> {
140
+ return new Promise((resolve, reject) => {
141
+ const hostname = hostnameWithoutBrackets(destination.url.hostname);
142
+ const requestOptions: RequestOptions = {
143
+ protocol: destination.url.protocol,
144
+ hostname,
145
+ port: destination.url.port || undefined,
146
+ path: `${destination.url.pathname}${destination.url.search}`,
147
+ method: options.method ?? "GET",
148
+ headers: options.headers,
149
+ family: destination.family,
150
+ signal: options.signal,
151
+ lookup: (_hostname, _options, callback) => {
152
+ callback(null, destination.address, destination.family);
153
+ },
154
+ };
155
+ const transport = destination.url.protocol === "https:" ? httpsRequest : httpRequest;
156
+ const request = transport(requestOptions, (response) => {
157
+ const chunks: Buffer[] = [];
158
+ let receivedBytes = 0;
159
+ response.once("aborted", () => {
160
+ reject(new Error("HTTP agent response was interrupted"));
161
+ });
162
+ response.once("error", reject);
163
+
164
+ const contentLength = Number(response.headers["content-length"]);
165
+ if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) {
166
+ response.destroy(new Error("HTTP agent response is too large"));
167
+ return;
168
+ }
169
+
170
+ response.on("data", (chunk: Buffer) => {
171
+ receivedBytes += chunk.length;
172
+ if (receivedBytes > MAX_RESPONSE_BYTES) {
173
+ response.destroy(new Error("HTTP agent response is too large"));
174
+ return;
175
+ }
176
+ chunks.push(chunk);
177
+ });
178
+ response.once("end", () => {
179
+ resolve({
180
+ body: Buffer.concat(chunks).toString("utf8"),
181
+ location: response.headers.location,
182
+ statusCode: response.statusCode ?? 0,
183
+ });
184
+ });
185
+ });
186
+
187
+ request.once("error", reject);
188
+ request.setTimeout(REQUEST_TIMEOUT_MS, () => {
189
+ request.destroy(new Error("HTTP agent request timed out"));
190
+ });
191
+ if (options.body !== undefined) {
192
+ request.write(options.body);
193
+ }
194
+ request.end();
195
+ });
196
+ }
197
+
198
+ export async function requestPublicJson(
199
+ input: string,
200
+ options: PublicJsonRequestOptions = {},
201
+ redirectCount = 0
202
+ ): Promise<Record<string, unknown>> {
203
+ const destination = await resolvePublicDestination(input);
204
+ const response = await requestBody(destination, options);
205
+
206
+ if (REDIRECT_STATUSES.has(response.statusCode) && response.location) {
207
+ if (redirectCount >= MAX_REDIRECTS) {
208
+ throw new Error(`HTTP agent returned too many redirects`);
209
+ }
210
+ const redirectUrl = new URL(response.location, destination.url);
211
+ const redirectHeaders = headersForRedirect(
212
+ options.headers,
213
+ redirectUrl.origin !== destination.url.origin
214
+ );
215
+ const preserveBody = response.statusCode === 307 || response.statusCode === 308;
216
+ return requestPublicJson(
217
+ redirectUrl.toString(),
218
+ preserveBody
219
+ ? { ...options, headers: redirectHeaders }
220
+ : {
221
+ ...options,
222
+ body: undefined,
223
+ headers: redirectHeaders,
224
+ method: "GET",
225
+ },
226
+ redirectCount + 1
227
+ );
228
+ }
229
+
230
+ if (response.statusCode < 200 || response.statusCode >= 300) {
231
+ throw new Error(`HTTP agent returned an unsuccessful status: ${response.statusCode}`);
232
+ }
233
+
234
+ let parsed: unknown;
235
+ try {
236
+ parsed = JSON.parse(response.body);
237
+ } catch {
238
+ throw new Error("HTTP agent returned invalid JSON");
239
+ }
240
+
241
+ return parsed as Record<string, unknown>;
242
+ }
@@ -1,4 +1,5 @@
1
1
  import { getTraceById as apiGetTraceById } from "../langwatch-api.js";
2
+ import { formatEvaluationLines } from "../utils/format-evaluations.js";
2
3
 
3
4
  /**
4
5
  * Handles the get_trace MCP tool invocation.
@@ -35,18 +36,7 @@ export async function handleGetTrace(params: {
35
36
  }
36
37
 
37
38
  if (result.evaluations && result.evaluations.length > 0) {
38
- lines.push("\n## Evaluations");
39
- for (const evaluation of result.evaluations) {
40
- const status =
41
- evaluation.passed === true
42
- ? "PASSED"
43
- : evaluation.passed === false
44
- ? "FAILED"
45
- : "N/A";
46
- lines.push(
47
- `- **${evaluation.name || evaluation.evaluator_id}**: ${status}${evaluation.score != null ? ` (score: ${evaluation.score})` : ""}${evaluation.label ? ` [${evaluation.label}]` : ""}`
48
- );
49
- }
39
+ lines.push("\n## Evaluations", ...formatEvaluationLines(result.evaluations));
50
40
  }
51
41
 
52
42
  if (result.formatted_trace) {