@langwatch/mcp-server 0.10.0 → 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.
- package/.release-please-shim +1 -1
- package/CHANGELOG.md +62 -0
- package/README.md +9 -17
- package/dist/{archive-project-HZBRPR2S.js → archive-project-MK3673A7.js} +4 -3
- package/dist/{archive-scenario-P5RRITRM.js → archive-scenario-KATSXRIN.js} +4 -3
- package/dist/{archive-suite-NUUFD2EM.js → archive-suite-C3KX6IIZ.js} +4 -3
- package/dist/{assign-prompt-tag-FKCCZPML.js → assign-prompt-tag-E3C5W6PI.js} +3 -2
- package/dist/chunk-37SMPMQS.js +12 -0
- package/dist/{chunk-42ZHHHIJ.js → chunk-5UWY24RP.js} +79 -4
- package/dist/chunk-BJ7B4R26.js +42 -0
- package/dist/chunk-CE6QMZOP.js +432 -0
- package/dist/{chunk-X2SEURT2.js → chunk-DAIQKS27.js} +2 -2
- package/dist/{chunk-R7C7OUVG.js → chunk-EU22L52K.js} +2 -2
- package/dist/{chunk-TROIWSEQ.js → chunk-J45SVWPR.js} +2 -2
- package/dist/{chunk-CCM4ZFGF.js → chunk-O3LWVN67.js} +2 -2
- package/dist/{chunk-BB7EOKHR.js → chunk-RYRB2YVJ.js} +2 -2
- package/dist/{chunk-HKY3GW45.js → chunk-T7WJVS2N.js} +2 -2
- package/dist/{chunk-JHOSVOHT.js → chunk-UXXA4D6K.js} +2 -2
- package/dist/{chunk-FVDAZ4XG.js → chunk-VYPSKRPN.js} +219 -137
- package/dist/{chunk-CHKCEZBP.js → chunk-WDH5P5RF.js} +2 -2
- package/dist/{chunk-BBJ5WAJB.js → chunk-WWMDHZZV.js} +2 -2
- package/dist/{chunk-ADSJ2FDA.js → chunk-XY4STOBE.js} +2 -2
- package/dist/{chunk-BYWYM2RP.js → chunk-Y3HDZ6OW.js} +2 -2
- package/dist/config.js +1 -0
- package/dist/{create-agent-K5Q7BFGZ.js → create-agent-7W57J7MT.js} +4 -3
- package/dist/{create-annotation-KN3GE3DK.js → create-annotation-W2XELBCQ.js} +4 -3
- package/dist/{create-api-key-OQI5PUNQ.js → create-api-key-MKRXKFJM.js} +4 -3
- package/dist/{create-dashboard-G4QA5AXP.js → create-dashboard-QGL7IODC.js} +4 -3
- package/dist/{create-dataset-WQSC37N7.js → create-dataset-3PH7A4HG.js} +4 -3
- package/dist/{create-dataset-records-B4RL3FLC.js → create-dataset-records-4DZS3VRL.js} +4 -3
- package/dist/{create-evaluator-YFFQIR7E.js → create-evaluator-YWH6NC4M.js} +4 -3
- package/dist/create-mcp-server.js +3 -2
- package/dist/{create-project-CUVQ67VN.js → create-project-BOBQ32C5.js} +4 -3
- package/dist/{create-prompt-NGXKSP4X.js → create-prompt-FLCFGITE.js} +3 -2
- package/dist/{create-prompt-tag-IUKYPVHH.js → create-prompt-tag-EMPNIKEA.js} +3 -2
- package/dist/{create-scenario-FNBXALH3.js → create-scenario-BSBIHUL6.js} +4 -3
- package/dist/{create-suite-DJEPD4ZB.js → create-suite-TWF6BBR4.js} +4 -3
- package/dist/{delete-agent-TJI5XBBM.js → delete-agent-7MSYNY5E.js} +4 -3
- package/dist/{delete-annotation-KRP53TUU.js → delete-annotation-ZT5IGN24.js} +4 -3
- package/dist/{delete-dashboard-N5SZJIR3.js → delete-dashboard-BTIY5S3L.js} +4 -3
- package/dist/{delete-dataset-KDUOHL2P.js → delete-dataset-U45HQHPR.js} +4 -3
- package/dist/{delete-dataset-records-DM4W3PAW.js → delete-dataset-records-R43IMAWB.js} +4 -3
- package/dist/{delete-evaluator-7ZATLJOA.js → delete-evaluator-U6JMQ6C7.js} +4 -3
- package/dist/{delete-prompt-tag-I6HNMSVJ.js → delete-prompt-tag-4EV4TDHB.js} +3 -2
- package/dist/{delete-workflow-QEO74JQR.js → delete-workflow-5QYALXEL.js} +4 -3
- package/dist/discover-evaluator-schema-MRGHVNTT.js +2175 -0
- package/dist/{discover-scenario-schema-MEEEVND7.js → discover-scenario-schema-UPBTCWPG.js} +3 -1
- package/dist/{discover-schema-3T52ORPB.js → discover-schema-ICGXPHUW.js} +3 -1
- package/dist/{get-agent-3KDWCP5N.js → get-agent-7QJVPH72.js} +4 -3
- package/dist/{get-analytics-GPVJX7SF.js → get-analytics-PVERWOA5.js} +3 -2
- package/dist/{get-dashboard-YPTIE5YY.js → get-dashboard-ZZPC3WMD.js} +4 -3
- package/dist/{get-dataset-6DQFYITM.js → get-dataset-25TB2LQH.js} +4 -3
- package/dist/{get-evaluator-PCM6XQAU.js → get-evaluator-3FTRQKEC.js} +4 -3
- package/dist/{get-project-JCMWXVLU.js → get-project-5OAXYC57.js} +4 -3
- package/dist/{get-prompt-5XAGWWBG.js → get-prompt-I753LZKZ.js} +3 -2
- package/dist/{get-scenario-7XBCDZWX.js → get-scenario-DSSTR6FI.js} +4 -3
- package/dist/{get-simulation-run-AV24FJWI.js → get-simulation-run-RQPMS2MC.js} +4 -3
- package/dist/{get-suite-4DBVHFEM.js → get-suite-GTOCY5PL.js} +4 -3
- package/dist/{get-trace-MRBDS3HJ.js → get-trace-O5VT3NAC.js} +7 -9
- package/dist/{get-workflow-UR4Y6UFX.js → get-workflow-3EZDOEIF.js} +4 -3
- package/dist/http-server-2ACJ3CXW.js +24366 -0
- package/dist/index.js +23374 -3140
- package/dist/{langwatch-api-annotations-ZJDRB4JH.js → langwatch-api-annotations-E65MI3WZ.js} +4 -3
- package/dist/{langwatch-api-dashboards-WARLI6NF.js → langwatch-api-dashboards-QCDECRCY.js} +4 -3
- package/dist/{langwatch-api-monitors-4XKXHYCG.js → langwatch-api-monitors-UIO2KTDV.js} +3 -2
- package/dist/{langwatch-api-secrets-6SHK5G7Q.js → langwatch-api-secrets-4T2EKSJ7.js} +3 -2
- package/dist/{langwatch-api-triggers-5SX6R2WU.js → langwatch-api-triggers-MPOEKPUY.js} +3 -2
- package/dist/{langwatch-api-workflows-NLJENNXE.js → langwatch-api-workflows-5HRZ7HR4.js} +4 -3
- package/dist/{list-agents-NECO7MPI.js → list-agents-TIJTP566.js} +4 -3
- package/dist/{list-annotations-3OJEO774.js → list-annotations-CGX5BT7E.js} +4 -3
- package/dist/{list-api-keys-AWW7ROLB.js → list-api-keys-JLWJPFRX.js} +4 -3
- package/dist/{list-dashboards-DVPVVVKW.js → list-dashboards-RNIWX3TX.js} +4 -3
- package/dist/{list-dataset-records-I4BYORF5.js → list-dataset-records-B5MHZR66.js} +4 -3
- package/dist/{list-datasets-WUAE5HGX.js → list-datasets-BVCAMQCP.js} +4 -3
- package/dist/{list-evaluators-B6OSVAM2.js → list-evaluators-65FBTL2U.js} +4 -3
- package/dist/{list-model-providers-NL6IP4UZ.js → list-model-providers-GRK3I3VV.js} +4 -3
- package/dist/{list-projects-5FHMJ4PQ.js → list-projects-JLMBFMW3.js} +4 -3
- package/dist/{list-prompt-tags-Z2DD6FLQ.js → list-prompt-tags-UHNWDGQI.js} +3 -2
- package/dist/{list-prompts-VNXKHK7T.js → list-prompts-7254B3U7.js} +3 -2
- package/dist/{list-scenarios-CFZHXL76.js → list-scenarios-46YNICKM.js} +4 -3
- package/dist/{list-simulation-runs-AM2BXLBW.js → list-simulation-runs-LGNEFNPN.js} +4 -3
- package/dist/{list-suites-ZDOHAAXF.js → list-suites-5VK4HXEK.js} +4 -3
- package/dist/{list-workflows-WBRBKLIT.js → list-workflows-MLQDM6CP.js} +4 -3
- package/dist/{rename-prompt-tag-A4RJUK7Z.js → rename-prompt-tag-RHQMNEQL.js} +3 -2
- package/dist/report-issue-DXJ6TPTC.js +412 -0
- package/dist/{revoke-api-key-ZSPSPF5G.js → revoke-api-key-3Y35EMBT.js} +4 -3
- package/dist/{run-agent-JFNDY7MR.js → run-agent-RBSIFKRT.js} +4 -3
- package/dist/{run-suite-TH7LO5TJ.js → run-suite-K3DNQ3SE.js} +4 -3
- package/dist/{run-workflow-CK3JP5U2.js → run-workflow-5LVBZYLS.js} +4 -3
- package/dist/{search-traces-3BBVYPC3.js → search-traces-U4HLOCZ2.js} +9 -2
- package/dist/{set-model-provider-WOC5FNMV.js → set-model-provider-V3M5CDIY.js} +4 -3
- package/dist/{update-agent-YMEV5MTL.js → update-agent-47RZIHS7.js} +4 -3
- package/dist/{update-dataset-EYT5POFM.js → update-dataset-G6QJJGS5.js} +4 -3
- package/dist/{update-dataset-record-VFF5JTX4.js → update-dataset-record-RMWLSWCS.js} +4 -3
- package/dist/{update-evaluator-6IJHFG2E.js → update-evaluator-SFRRI5Q3.js} +4 -3
- package/dist/{update-project-OIT4WPFI.js → update-project-PI5DWBVK.js} +4 -3
- package/dist/{update-prompt-ZU2DD6KM.js → update-prompt-RQFTEO7Y.js} +3 -2
- package/dist/{update-scenario-3VUTPDRU.js → update-scenario-G3IU2UIB.js} +4 -3
- package/dist/{update-suite-C6JFDQHL.js → update-suite-TRGGLNR7.js} +4 -3
- package/package.json +20 -18
- package/pnpm-workspace.yaml +27 -2
- package/pyproject.toml +9 -1
- package/src/__tests__/all-tools.integration.test.ts +56 -120
- package/src/__tests__/documentation-fetch-security.integration.test.ts +176 -0
- package/src/__tests__/langwatch-api.unit.test.ts +206 -0
- package/src/__tests__/public-http-request-security.integration.test.ts +118 -0
- package/src/__tests__/report-issue.integration.test.ts +147 -0
- package/src/__tests__/tools.unit.test.ts +24 -0
- package/src/create-mcp-server.ts +43 -29
- package/src/documentation-fetch.ts +111 -0
- package/src/langwatch-api-agents.ts +2 -2
- package/src/langwatch-api.ts +138 -8
- package/src/public-http-request.ts +242 -0
- package/src/tools/get-trace.ts +2 -12
- package/src/tools/report-issue.ts +150 -0
- package/src/tools/search-traces.ts +4 -0
- package/src/utils/format-evaluations.ts +20 -0
- package/tsconfig.json +8 -1
- package/uv.lock +259 -205
- package/dist/chunk-XC62KJIT.js +0 -71
- package/dist/discover-evaluator-schema-H23XCLNE.js +0 -1402
- package/dist/http-server-XUQLIFE7.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
|
|
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
|
|
package/src/langwatch-api.ts
CHANGED
|
@@ -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?:
|
|
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
|
-
|
|
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
|
+
}
|
package/src/tools/get-trace.ts
CHANGED
|
@@ -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) {
|