@langwatch/mcp-server 1.0.0 → 2.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 (125) hide show
  1. package/.python-version +1 -0
  2. package/CHANGELOG.md +21 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.md +55 -1
  5. package/dist/{archive-project-MK3673A7.js → archive-project-4EQGLBJJ.js} +5 -5
  6. package/dist/{archive-scenario-KATSXRIN.js → archive-scenario-W6BXYDZ3.js} +5 -5
  7. package/dist/{archive-suite-C3KX6IIZ.js → archive-suite-4T6BSRS7.js} +5 -5
  8. package/dist/{assign-prompt-tag-E3C5W6PI.js → assign-prompt-tag-VLXUWEKU.js} +4 -4
  9. package/dist/{chunk-VYPSKRPN.js → chunk-4QC7SA2L.js} +91 -91
  10. package/dist/{chunk-T7WJVS2N.js → chunk-5CCZFUXS.js} +2 -2
  11. package/dist/{chunk-J45SVWPR.js → chunk-77BNWQ3U.js} +2 -2
  12. package/dist/{chunk-XY4STOBE.js → chunk-7RHG56OH.js} +2 -2
  13. package/dist/{chunk-5UWY24RP.js → chunk-A2LEVYQ5.js} +2 -2
  14. package/dist/{chunk-UXXA4D6K.js → chunk-AQJO2EI5.js} +2 -2
  15. package/dist/{chunk-EU22L52K.js → chunk-CDYQOY5U.js} +2 -2
  16. package/dist/{chunk-O3LWVN67.js → chunk-J42FWRJ2.js} +2 -2
  17. package/dist/{chunk-BJ7B4R26.js → chunk-SAS5OB7F.js} +18 -2
  18. package/dist/{chunk-DAIQKS27.js → chunk-SFB4MU6H.js} +2 -2
  19. package/dist/{chunk-WWMDHZZV.js → chunk-TJCXFNDT.js} +2 -2
  20. package/dist/{chunk-CE6QMZOP.js → chunk-TOXJQDIZ.js} +4 -4
  21. package/dist/chunk-UJPXFA26.js +267 -0
  22. package/dist/{chunk-KOO5733S.js → chunk-WT4PO4A3.js} +8 -2
  23. package/dist/{chunk-RYRB2YVJ.js → chunk-YX3A3CFM.js} +2 -2
  24. package/dist/{chunk-WDH5P5RF.js → chunk-YZLYHLLI.js} +2 -2
  25. package/dist/{chunk-Y3HDZ6OW.js → chunk-ZWIL4EFL.js} +2 -2
  26. package/dist/config.js +2 -2
  27. package/dist/{create-agent-7W57J7MT.js → create-agent-7XHO6UIS.js} +5 -5
  28. package/dist/{create-annotation-W2XELBCQ.js → create-annotation-YQXRQC6A.js} +5 -5
  29. package/dist/{create-api-key-MKRXKFJM.js → create-api-key-YUS36KBU.js} +5 -5
  30. package/dist/{create-dashboard-QGL7IODC.js → create-dashboard-4Z5JO5LB.js} +5 -5
  31. package/dist/{create-dataset-3PH7A4HG.js → create-dataset-E6NMSVX7.js} +5 -5
  32. package/dist/{create-dataset-records-4DZS3VRL.js → create-dataset-records-SKHPRD2K.js} +5 -5
  33. package/dist/{create-evaluator-YWH6NC4M.js → create-evaluator-JXF5PF33.js} +5 -5
  34. package/dist/create-mcp-server.js +4 -4
  35. package/dist/{create-project-BOBQ32C5.js → create-project-CS2CB57Y.js} +5 -5
  36. package/dist/{create-prompt-FLCFGITE.js → create-prompt-F5RMX3L3.js} +4 -4
  37. package/dist/{create-prompt-tag-EMPNIKEA.js → create-prompt-tag-GMRWTEFR.js} +4 -4
  38. package/dist/{create-scenario-BSBIHUL6.js → create-scenario-JDR2VB2H.js} +5 -5
  39. package/dist/{create-suite-TWF6BBR4.js → create-suite-LZS6QIAF.js} +5 -5
  40. package/dist/{delete-agent-7MSYNY5E.js → delete-agent-O3EEH4S5.js} +5 -5
  41. package/dist/{delete-annotation-ZT5IGN24.js → delete-annotation-XP6FQM7A.js} +5 -5
  42. package/dist/{delete-dashboard-BTIY5S3L.js → delete-dashboard-RFUNYCNP.js} +5 -5
  43. package/dist/{delete-dataset-U45HQHPR.js → delete-dataset-3MJFRDLP.js} +5 -5
  44. package/dist/{delete-dataset-records-R43IMAWB.js → delete-dataset-records-WPMU5GL5.js} +5 -5
  45. package/dist/{delete-evaluator-U6JMQ6C7.js → delete-evaluator-33SP2JFX.js} +5 -5
  46. package/dist/{delete-prompt-tag-4EV4TDHB.js → delete-prompt-tag-KNMZVSPB.js} +4 -4
  47. package/dist/{delete-workflow-5QYALXEL.js → delete-workflow-3NUXO46U.js} +5 -5
  48. package/dist/{discover-evaluator-schema-MRGHVNTT.js → discover-evaluator-schema-XTAD4X7D.js} +52 -36
  49. package/dist/{discover-scenario-schema-UPBTCWPG.js → discover-scenario-schema-FE64QK2D.js} +2 -2
  50. package/dist/{discover-schema-ICGXPHUW.js → discover-schema-L6QDPON3.js} +2 -2
  51. package/dist/{get-agent-7QJVPH72.js → get-agent-Y7MWZ65F.js} +5 -5
  52. package/dist/{get-analytics-PVERWOA5.js → get-analytics-TETCSOK6.js} +4 -4
  53. package/dist/{get-dashboard-ZZPC3WMD.js → get-dashboard-KHBEGZGL.js} +5 -5
  54. package/dist/{get-dataset-25TB2LQH.js → get-dataset-RCMU5VAH.js} +5 -5
  55. package/dist/{get-evaluator-3FTRQKEC.js → get-evaluator-ADMKVWYZ.js} +5 -5
  56. package/dist/{get-project-5OAXYC57.js → get-project-RAK7URQJ.js} +5 -5
  57. package/dist/{get-prompt-I753LZKZ.js → get-prompt-OY5B7TRI.js} +4 -4
  58. package/dist/{get-scenario-DSSTR6FI.js → get-scenario-ZTAJJL5T.js} +5 -5
  59. package/dist/{get-simulation-run-RQPMS2MC.js → get-simulation-run-T2ZQWYH2.js} +5 -5
  60. package/dist/{get-suite-GTOCY5PL.js → get-suite-J7JVWO6O.js} +5 -5
  61. package/dist/{get-trace-O5VT3NAC.js → get-trace-K7U234Q4.js} +4 -4
  62. package/dist/{get-workflow-3EZDOEIF.js → get-workflow-L5L2PSK3.js} +5 -5
  63. package/dist/http-security-EU3CHTY7.js +26 -0
  64. package/dist/{http-server-2ACJ3CXW.js → http-server-YXHFPPGA.js} +1033 -693
  65. package/dist/index.js +3066 -2105
  66. package/dist/{langwatch-api-annotations-E65MI3WZ.js → langwatch-api-annotations-FDAS6NQ7.js} +5 -5
  67. package/dist/{langwatch-api-dashboards-QCDECRCY.js → langwatch-api-dashboards-JTVCS7ZJ.js} +5 -5
  68. package/dist/{langwatch-api-monitors-UIO2KTDV.js → langwatch-api-monitors-3J7UIQME.js} +4 -4
  69. package/dist/{langwatch-api-secrets-4T2EKSJ7.js → langwatch-api-secrets-BUBSNCNO.js} +4 -4
  70. package/dist/{langwatch-api-triggers-MPOEKPUY.js → langwatch-api-triggers-EDFUCJN6.js} +4 -4
  71. package/dist/langwatch-api-workflows-NBJX5KOG.js +16 -0
  72. package/dist/{list-agents-TIJTP566.js → list-agents-KFHKGPXV.js} +5 -5
  73. package/dist/{list-annotations-CGX5BT7E.js → list-annotations-ZTVP4EEL.js} +5 -5
  74. package/dist/{list-api-keys-JLWJPFRX.js → list-api-keys-CWD4UQT5.js} +5 -5
  75. package/dist/{list-dashboards-RNIWX3TX.js → list-dashboards-PWG6P6KR.js} +5 -5
  76. package/dist/{list-dataset-records-B5MHZR66.js → list-dataset-records-IOE7LQXS.js} +5 -5
  77. package/dist/{list-datasets-BVCAMQCP.js → list-datasets-ZT4JYUDT.js} +5 -5
  78. package/dist/{list-evaluators-65FBTL2U.js → list-evaluators-SJHC7VBH.js} +5 -5
  79. package/dist/{list-model-providers-GRK3I3VV.js → list-model-providers-PPD2WPD2.js} +5 -5
  80. package/dist/{list-projects-JLMBFMW3.js → list-projects-K7QAIX2I.js} +5 -5
  81. package/dist/{list-prompt-tags-UHNWDGQI.js → list-prompt-tags-FOO6TNHB.js} +4 -4
  82. package/dist/{list-prompts-7254B3U7.js → list-prompts-SC2HAUFG.js} +4 -4
  83. package/dist/{list-scenarios-46YNICKM.js → list-scenarios-F2WYNRIE.js} +5 -5
  84. package/dist/{list-simulation-runs-LGNEFNPN.js → list-simulation-runs-KF5THG5H.js} +5 -5
  85. package/dist/{list-suites-5VK4HXEK.js → list-suites-CRY4VWW6.js} +5 -5
  86. package/dist/{list-workflows-MLQDM6CP.js → list-workflows-QSJWURCK.js} +5 -5
  87. package/dist/{rename-prompt-tag-RHQMNEQL.js → rename-prompt-tag-EKWU5QF3.js} +4 -4
  88. package/dist/{report-issue-DXJ6TPTC.js → report-issue-XYVAPAYJ.js} +7 -7
  89. package/dist/{revoke-api-key-3Y35EMBT.js → revoke-api-key-FTRTZMOF.js} +5 -5
  90. package/dist/{run-agent-RBSIFKRT.js → run-agent-6SM4XNA7.js} +5 -5
  91. package/dist/{run-suite-K3DNQ3SE.js → run-suite-NKZCJ2IN.js} +5 -5
  92. package/dist/{run-workflow-5LVBZYLS.js → run-workflow-QPXC627O.js} +5 -5
  93. package/dist/{search-traces-U4HLOCZ2.js → search-traces-A6HFKPLJ.js} +4 -4
  94. package/dist/{set-model-provider-V3M5CDIY.js → set-model-provider-3WKT63ZN.js} +5 -5
  95. package/dist/{update-agent-47RZIHS7.js → update-agent-SMAEHLXH.js} +5 -5
  96. package/dist/{update-dataset-G6QJJGS5.js → update-dataset-BDUK7TUO.js} +5 -5
  97. package/dist/{update-dataset-record-RMWLSWCS.js → update-dataset-record-4ZSDRBSW.js} +5 -5
  98. package/dist/{update-evaluator-SFRRI5Q3.js → update-evaluator-LS6TIBY7.js} +5 -5
  99. package/dist/{update-project-PI5DWBVK.js → update-project-LKVYXENH.js} +5 -5
  100. package/dist/{update-prompt-RQFTEO7Y.js → update-prompt-ODCRQRN6.js} +4 -4
  101. package/dist/{update-scenario-G3IU2UIB.js → update-scenario-NEXCAQI7.js} +5 -5
  102. package/dist/{update-suite-TRGGLNR7.js → update-suite-SNO3GS7T.js} +5 -5
  103. package/package.json +3 -3
  104. package/pyproject.toml +1 -1
  105. package/src/__tests__/config.unit.test.ts +33 -0
  106. package/src/__tests__/docker.integration.test.ts +91 -7
  107. package/src/__tests__/documentation-fetch-security.integration.test.ts +8 -2
  108. package/src/__tests__/feature-map-tool-drift.unit.test.ts +93 -0
  109. package/src/__tests__/http-security-origin.unit.test.ts +97 -0
  110. package/src/__tests__/http-security-sessions.unit.test.ts +209 -0
  111. package/src/__tests__/http-security-verifier.unit.test.ts +248 -0
  112. package/src/__tests__/http-server-allocation.integration.test.ts +279 -0
  113. package/src/__tests__/http-server-origin.integration.test.ts +169 -0
  114. package/src/__tests__/http-server-session-auth.integration.test.ts +341 -0
  115. package/src/__tests__/http-transport.integration.test.ts +85 -87
  116. package/src/__tests__/support/http-server-harness.ts +138 -0
  117. package/src/config.ts +17 -2
  118. package/src/http-security.ts +492 -0
  119. package/src/http-server.ts +536 -152
  120. package/src/index.ts +49 -2
  121. package/src/tools/discover-evaluator-schema.ts +2 -2
  122. package/tsconfig.json +2 -2
  123. package/uv.lock +2 -2
  124. package/dist/langwatch-api-workflows-5HRZ7HR4.js +0 -16
  125. package/pnpm-workspace.yaml +0 -34
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Nothing is allocated for a key the LangWatch API has not confirmed, and the
3
+ * per-key and per-address limits bound what one caller can consume.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+ import type { AddressInfo } from "node:net";
8
+ import { createServer, type Server as HttpServer } from "node:http";
9
+
10
+ import { initConfig } from "../config.js";
11
+ import { createApiKeyVerifier } from "../http-security.js";
12
+ import {
13
+ countingVerifier,
14
+ initializeBody,
15
+ MCP_POST_HEADERS,
16
+ openSession,
17
+ requestUntilThrottled,
18
+ startHarness,
19
+ VALID_KEY,
20
+ } from "./support/http-server-harness.js";
21
+
22
+ /** Mirrors MAX_SESSIONS_PER_KEY in http-server.ts. */
23
+ const MAX_SESSIONS_PER_KEY = 20;
24
+
25
+ beforeEach(() => {
26
+ initConfig({ endpoint: "https://app.langwatch.ai" });
27
+ });
28
+
29
+ describe("Key verification before allocation", () => {
30
+ it("rejects an unverified bearer without creating a session", async () => {
31
+ const { verifier, verify } = countingVerifier([VALID_KEY]);
32
+ const harness = await startHarness({ port: 0, apiKeyVerifier: verifier });
33
+
34
+ try {
35
+ const response = await fetch(`${harness.baseUrl}/mcp`, {
36
+ method: "POST",
37
+ headers: {
38
+ ...MCP_POST_HEADERS,
39
+ Authorization: "Bearer totally-not-a-real-key",
40
+ },
41
+ body: initializeBody(),
42
+ });
43
+
44
+ expect(response.status).toBe(401);
45
+ expect(response.headers.get("mcp-session-id")).toBeNull();
46
+ expect(verify).toHaveBeenCalledWith("totally-not-a-real-key");
47
+ } finally {
48
+ await harness.close();
49
+ }
50
+ });
51
+
52
+ it("verifies the key before the first byte of session state exists", async () => {
53
+ const { verifier, verify } = countingVerifier([VALID_KEY]);
54
+ const harness = await startHarness({ port: 0, apiKeyVerifier: verifier });
55
+
56
+ try {
57
+ await fetch(`${harness.baseUrl}/mcp`, {
58
+ method: "POST",
59
+ headers: { ...MCP_POST_HEADERS, Authorization: "Bearer nope" },
60
+ body: initializeBody(),
61
+ });
62
+
63
+ // A rejected initialize leaves nothing behind, so a later valid
64
+ // initialize is the first session this key gets.
65
+ const sessionId = await openSession({ baseUrl: harness.baseUrl });
66
+ expect(sessionId).toBeTruthy();
67
+ expect(verify).toHaveBeenCalledTimes(2);
68
+ } finally {
69
+ await harness.close();
70
+ }
71
+ });
72
+
73
+ it("refuses to mint an OAuth token for an unverified client_secret", async () => {
74
+ const { verifier } = countingVerifier([VALID_KEY]);
75
+ const harness = await startHarness({ port: 0, apiKeyVerifier: verifier });
76
+
77
+ try {
78
+ const response = await fetch(`${harness.baseUrl}/oauth/token`, {
79
+ method: "POST",
80
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
81
+ body: "grant_type=client_credentials&client_secret=totally-not-a-real-key",
82
+ });
83
+
84
+ expect(response.status).toBe(401);
85
+ const body = await response.json();
86
+ expect(body.error).toBe("invalid_client");
87
+ } finally {
88
+ await harness.close();
89
+ }
90
+ });
91
+ });
92
+
93
+ describe("Limits", () => {
94
+ it("rate limits repeated authentication failures from one address", async () => {
95
+ const { verifier } = countingVerifier([VALID_KEY]);
96
+ const harness = await startHarness({ port: 0, apiKeyVerifier: verifier });
97
+
98
+ try {
99
+ const { rejected } = await requestUntilThrottled({
100
+ request: (attempt) =>
101
+ fetch(`${harness.baseUrl}/mcp`, {
102
+ method: "POST",
103
+ headers: {
104
+ ...MCP_POST_HEADERS,
105
+ Authorization: `Bearer wrong-key-${attempt}`,
106
+ },
107
+ body: initializeBody(),
108
+ }),
109
+ });
110
+
111
+ expect(rejected).toBeDefined();
112
+ await expect(rejected!.json()).resolves.toEqual({
113
+ error: "Too many requests",
114
+ });
115
+ } finally {
116
+ await harness.close();
117
+ }
118
+ });
119
+
120
+ it("ignores forwarded client addresses when the proxy is not trusted", async () => {
121
+ const previous = process.env.LANGWATCH_MCP_TRUST_PROXY;
122
+ delete process.env.LANGWATCH_MCP_TRUST_PROXY;
123
+
124
+ const { verifier } = countingVerifier([VALID_KEY]);
125
+ const harness = await startHarness({ port: 0, apiKeyVerifier: verifier });
126
+
127
+ try {
128
+ // Every attempt claims a different client address. Untrusted, these are
129
+ // ignored, so they all count against the one real address.
130
+ const { rejected } = await requestUntilThrottled({
131
+ request: (attempt) =>
132
+ fetch(`${harness.baseUrl}/mcp`, {
133
+ method: "POST",
134
+ headers: {
135
+ ...MCP_POST_HEADERS,
136
+ Authorization: `Bearer wrong-key-${attempt}`,
137
+ "X-Forwarded-For": `203.0.113.${attempt}`,
138
+ },
139
+ body: initializeBody(),
140
+ }),
141
+ });
142
+
143
+ expect(rejected).toBeDefined();
144
+ } finally {
145
+ await harness.close();
146
+ if (previous !== undefined) {
147
+ process.env.LANGWATCH_MCP_TRUST_PROXY = previous;
148
+ }
149
+ }
150
+ });
151
+
152
+ it("holds the cap when initialize requests arrive together", async () => {
153
+ const { verifier } = countingVerifier([VALID_KEY]);
154
+ const harness = await startHarness({ port: 0, apiKeyVerifier: verifier });
155
+
156
+ try {
157
+ // The cap has to hold when the requests overlap, not just in sequence.
158
+ // Admission reserves the slot before initialize is awaited, so this does
159
+ // not depend on when the transport reports the session as initialized.
160
+ const responses = await Promise.all(
161
+ Array.from({ length: 40 }, () =>
162
+ fetch(`${harness.baseUrl}/mcp`, {
163
+ method: "POST",
164
+ headers: {
165
+ ...MCP_POST_HEADERS,
166
+ Authorization: `Bearer ${VALID_KEY}`,
167
+ },
168
+ body: initializeBody(),
169
+ })
170
+ )
171
+ );
172
+
173
+ const opened = responses.filter((response) => response.status === 200);
174
+ const refused = responses.filter((response) => response.status === 429);
175
+ await Promise.all(responses.map((response) => response.text()));
176
+
177
+ expect(opened.length).toBeLessThanOrEqual(MAX_SESSIONS_PER_KEY);
178
+ expect(opened.length).toBeGreaterThan(0);
179
+ expect(refused.length).toBe(responses.length - opened.length);
180
+ } finally {
181
+ await harness.close();
182
+ }
183
+ });
184
+
185
+ it("caps concurrent sessions for a single key", async () => {
186
+ const { verifier } = countingVerifier([VALID_KEY]);
187
+ const harness = await startHarness({ port: 0, apiKeyVerifier: verifier });
188
+
189
+ try {
190
+ const { rejected } = await requestUntilThrottled({
191
+ request: () =>
192
+ fetch(`${harness.baseUrl}/mcp`, {
193
+ method: "POST",
194
+ headers: {
195
+ ...MCP_POST_HEADERS,
196
+ Authorization: `Bearer ${VALID_KEY}`,
197
+ },
198
+ body: initializeBody(),
199
+ }),
200
+ });
201
+
202
+ expect(rejected).toBeDefined();
203
+ // Asserting the body, not just the status, so this pins the session cap
204
+ // rather than any 429 the server might grow later.
205
+ const body = await rejected!.json();
206
+ expect(body.error).toContain("Too many concurrent sessions");
207
+ } finally {
208
+ await harness.close();
209
+ }
210
+ });
211
+ });
212
+
213
+ describe("Verification against the LangWatch API", () => {
214
+ let upstream: HttpServer;
215
+ let upstreamUrl: string;
216
+ let seenAuthHeaders: (string | undefined)[];
217
+ let seenUrls: string[];
218
+
219
+ beforeEach(async () => {
220
+ seenAuthHeaders = [];
221
+ seenUrls = [];
222
+ upstream = createServer((req, res) => {
223
+ seenUrls.push(req.url ?? "");
224
+ const token = req.headers["x-auth-token"];
225
+ seenAuthHeaders.push(typeof token === "string" ? token : undefined);
226
+
227
+ if (token === VALID_KEY) {
228
+ res.writeHead(200, { "Content-Type": "application/json" });
229
+ res.end(JSON.stringify({ id: "p1", name: "Test", slug: "test" }));
230
+ return;
231
+ }
232
+ res.writeHead(401).end();
233
+ });
234
+
235
+ await new Promise<void>((resolve) =>
236
+ upstream.listen(0, "127.0.0.1", resolve)
237
+ );
238
+ upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`;
239
+ });
240
+
241
+ afterEach(async () => {
242
+ await new Promise<void>((resolve) => upstream.close(() => resolve()));
243
+ });
244
+
245
+ it("checks a key against GET /api/me/project with the key in a header", async () => {
246
+ const verifier = createApiKeyVerifier({ endpoint: upstreamUrl });
247
+
248
+ await expect(verifier.verify(VALID_KEY)).resolves.toBe(true);
249
+ await expect(verifier.verify("sk-lw-fake")).resolves.toBe(false);
250
+
251
+ expect(seenUrls).toEqual(["/api/me/project", "/api/me/project"]);
252
+ expect(seenAuthHeaders).toEqual([VALID_KEY, "sk-lw-fake"]);
253
+ });
254
+
255
+ it("end to end, only the key the API recognises gets a session", async () => {
256
+ initConfig({ endpoint: upstreamUrl });
257
+ const harness = await startHarness({ port: 0 });
258
+
259
+ try {
260
+ const rejected = await fetch(`${harness.baseUrl}/mcp`, {
261
+ method: "POST",
262
+ headers: { ...MCP_POST_HEADERS, Authorization: "Bearer sk-lw-fake" },
263
+ body: initializeBody(),
264
+ });
265
+ expect(rejected.status).toBe(401);
266
+
267
+ const accepted = await fetch(`${harness.baseUrl}/mcp`, {
268
+ method: "POST",
269
+ headers: { ...MCP_POST_HEADERS, Authorization: `Bearer ${VALID_KEY}` },
270
+ body: initializeBody(),
271
+ });
272
+ expect(accepted.status).toBe(200);
273
+ expect(accepted.headers.get("mcp-session-id")).toBeTruthy();
274
+ await accepted.text();
275
+ } finally {
276
+ await harness.close();
277
+ }
278
+ });
279
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Origin validation and bind address for the standalone MCP HTTP server.
3
+ *
4
+ * The MCP transport specification requires servers to validate Origin on every
5
+ * incoming connection and to bind loopback when running locally.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
9
+ import type { AddressInfo } from "node:net";
10
+
11
+ import { initConfig } from "../config.js";
12
+ import { startHttpServer } from "../http-server.js";
13
+ import {
14
+ countingVerifier,
15
+ initializeBody,
16
+ MCP_POST_HEADERS,
17
+ startHarness,
18
+ VALID_KEY,
19
+ type Harness,
20
+ } from "./support/http-server-harness.js";
21
+
22
+ beforeEach(() => {
23
+ initConfig({ endpoint: "https://app.langwatch.ai" });
24
+ });
25
+
26
+ describe("Origin validation", () => {
27
+ let harness: Harness;
28
+
29
+ beforeEach(async () => {
30
+ const { verifier } = countingVerifier([VALID_KEY]);
31
+ harness = await startHarness({
32
+ port: 0,
33
+ allowedOrigins: ["https://console.example.com"],
34
+ apiKeyVerifier: verifier,
35
+ });
36
+ });
37
+
38
+ afterEach(async () => {
39
+ await harness.close();
40
+ });
41
+
42
+ it("rejects a request from an origin that is not on the allowlist", async () => {
43
+ const response = await fetch(`${harness.baseUrl}/mcp`, {
44
+ method: "POST",
45
+ headers: {
46
+ ...MCP_POST_HEADERS,
47
+ Authorization: `Bearer ${VALID_KEY}`,
48
+ Origin: "https://attacker.example",
49
+ },
50
+ body: initializeBody(),
51
+ });
52
+
53
+ expect(response.status).toBe(403);
54
+ await expect(response.json()).resolves.toEqual({
55
+ error: "Origin not allowed",
56
+ });
57
+ });
58
+
59
+ it("rejects an unlisted origin even on the unauthenticated health endpoint", async () => {
60
+ const response = await fetch(`${harness.baseUrl}/health`, {
61
+ headers: { Origin: "https://attacker.example" },
62
+ });
63
+
64
+ expect(response.status).toBe(403);
65
+ });
66
+
67
+ it("rejects a rebound hostname pointing at loopback", async () => {
68
+ // The Origin is what the browser sends, and DNS rebinding cannot change it.
69
+ const response = await fetch(`${harness.baseUrl}/health`, {
70
+ headers: { Origin: "http://rebind.attacker.example" },
71
+ });
72
+
73
+ expect(response.status).toBe(403);
74
+ });
75
+
76
+ it("accepts a loopback origin without configuration", async () => {
77
+ const response = await fetch(`${harness.baseUrl}/health`, {
78
+ headers: { Origin: "http://localhost:5173" },
79
+ });
80
+
81
+ expect(response.status).toBe(200);
82
+ expect(response.headers.get("access-control-allow-origin")).toBe(
83
+ "http://localhost:5173"
84
+ );
85
+ });
86
+
87
+ it("accepts a configured origin", async () => {
88
+ const response = await fetch(`${harness.baseUrl}/health`, {
89
+ headers: { Origin: "https://console.example.com" },
90
+ });
91
+
92
+ expect(response.status).toBe(200);
93
+ expect(response.headers.get("access-control-allow-origin")).toBe(
94
+ "https://console.example.com"
95
+ );
96
+ });
97
+
98
+ it("never answers with a wildcard origin", async () => {
99
+ for (const origin of [
100
+ undefined,
101
+ "http://localhost:5173",
102
+ "https://console.example.com",
103
+ ]) {
104
+ const response = await fetch(`${harness.baseUrl}/health`, {
105
+ headers: origin ? { Origin: origin } : {},
106
+ });
107
+ expect(response.headers.get("access-control-allow-origin")).not.toBe("*");
108
+ }
109
+ });
110
+
111
+ it("exposes the session id header so an allowed browser origin can read it", async () => {
112
+ const response = await fetch(`${harness.baseUrl}/health`, {
113
+ headers: { Origin: "https://console.example.com" },
114
+ });
115
+
116
+ expect(
117
+ response.headers.get("access-control-expose-headers")?.toLowerCase()
118
+ ).toContain("mcp-session-id");
119
+ });
120
+
121
+ it("sets the sniffing and framing headers a browser-reachable server needs", async () => {
122
+ const response = await fetch(`${harness.baseUrl}/health`);
123
+
124
+ expect(response.headers.get("x-content-type-options")).toBe("nosniff");
125
+ expect(response.headers.get("x-frame-options")).toBe("DENY");
126
+ });
127
+
128
+ it("rejects the preflight for an unlisted origin", async () => {
129
+ const response = await fetch(`${harness.baseUrl}/mcp`, {
130
+ method: "OPTIONS",
131
+ headers: { Origin: "https://attacker.example" },
132
+ });
133
+
134
+ expect(response.status).toBe(403);
135
+ });
136
+ });
137
+
138
+ describe("Bind address", () => {
139
+ it("binds loopback by default", async () => {
140
+ const { verifier } = countingVerifier([VALID_KEY]);
141
+ const { server, host } = await startHttpServer({
142
+ port: 0,
143
+ apiKeyVerifier: verifier,
144
+ });
145
+
146
+ try {
147
+ expect(host).toBe("127.0.0.1");
148
+ expect((server.address() as AddressInfo).address).toBe("127.0.0.1");
149
+ } finally {
150
+ await new Promise<void>((resolve) => server.close(() => resolve()));
151
+ }
152
+ });
153
+
154
+ it("binds every interface only when asked to", async () => {
155
+ const { verifier } = countingVerifier([VALID_KEY]);
156
+ const { server, host } = await startHttpServer({
157
+ port: 0,
158
+ host: "0.0.0.0",
159
+ apiKeyVerifier: verifier,
160
+ });
161
+
162
+ try {
163
+ expect(host).toBe("0.0.0.0");
164
+ expect((server.address() as AddressInfo).address).toBe("0.0.0.0");
165
+ } finally {
166
+ await new Promise<void>((resolve) => server.close(() => resolve()));
167
+ }
168
+ });
169
+ });