@langwatch/mcp-server 0.10.1 → 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 (144) hide show
  1. package/.python-version +1 -0
  2. package/.release-please-shim +1 -1
  3. package/CHANGELOG.md +75 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +64 -18
  6. package/dist/{archive-project-KAZFHR4A.js → archive-project-4EQGLBJJ.js} +5 -4
  7. package/dist/{archive-scenario-6M2IB7Y2.js → archive-scenario-W6BXYDZ3.js} +5 -4
  8. package/dist/{archive-suite-NXZ3YDS3.js → archive-suite-4T6BSRS7.js} +5 -4
  9. package/dist/{assign-prompt-tag-WPUJO55U.js → assign-prompt-tag-VLXUWEKU.js} +4 -3
  10. package/dist/chunk-37SMPMQS.js +12 -0
  11. package/dist/{chunk-HRFQAFPR.js → chunk-4QC7SA2L.js} +221 -134
  12. package/dist/{chunk-XRQNXN2Z.js → chunk-5CCZFUXS.js} +2 -2
  13. package/dist/{chunk-ITN2QGHF.js → chunk-77BNWQ3U.js} +2 -2
  14. package/dist/{chunk-B6UV3JVI.js → chunk-7RHG56OH.js} +2 -2
  15. package/dist/{chunk-CAY57CKF.js → chunk-A2LEVYQ5.js} +78 -5
  16. package/dist/{chunk-U2QIW2SW.js → chunk-AQJO2EI5.js} +2 -2
  17. package/dist/{chunk-GWEW5XON.js → chunk-CDYQOY5U.js} +2 -2
  18. package/dist/{chunk-LTEJDL4V.js → chunk-J42FWRJ2.js} +2 -2
  19. package/dist/chunk-SAS5OB7F.js +58 -0
  20. package/dist/{chunk-AGGE6NPR.js → chunk-SFB4MU6H.js} +2 -2
  21. package/dist/{chunk-COCNCXVJ.js → chunk-TJCXFNDT.js} +2 -2
  22. package/dist/chunk-TOXJQDIZ.js +432 -0
  23. package/dist/chunk-UJPXFA26.js +267 -0
  24. package/dist/{chunk-KOO5733S.js → chunk-WT4PO4A3.js} +8 -2
  25. package/dist/{chunk-RDUXCCFR.js → chunk-YX3A3CFM.js} +2 -2
  26. package/dist/{chunk-UKRCZJJC.js → chunk-YZLYHLLI.js} +2 -2
  27. package/dist/{chunk-ESGZVPPC.js → chunk-ZWIL4EFL.js} +2 -2
  28. package/dist/config.js +2 -1
  29. package/dist/{create-agent-BI7UTJ6A.js → create-agent-7XHO6UIS.js} +5 -4
  30. package/dist/{create-annotation-SNM2J6S4.js → create-annotation-YQXRQC6A.js} +5 -4
  31. package/dist/{create-api-key-23MXYC2L.js → create-api-key-YUS36KBU.js} +5 -4
  32. package/dist/{create-dashboard-CPOOOIKK.js → create-dashboard-4Z5JO5LB.js} +5 -4
  33. package/dist/{create-dataset-BVXPVVY2.js → create-dataset-E6NMSVX7.js} +5 -4
  34. package/dist/{create-dataset-records-S7VFIIMR.js → create-dataset-records-SKHPRD2K.js} +5 -4
  35. package/dist/{create-evaluator-WJAAQOUE.js → create-evaluator-JXF5PF33.js} +5 -4
  36. package/dist/create-mcp-server.js +4 -3
  37. package/dist/{create-project-BF7LMZH5.js → create-project-CS2CB57Y.js} +5 -4
  38. package/dist/{create-prompt-LZ3QHSU4.js → create-prompt-F5RMX3L3.js} +4 -3
  39. package/dist/{create-prompt-tag-IF4J24QM.js → create-prompt-tag-GMRWTEFR.js} +4 -3
  40. package/dist/{create-scenario-BK4E5DEU.js → create-scenario-JDR2VB2H.js} +5 -4
  41. package/dist/{create-suite-HEQPNLND.js → create-suite-LZS6QIAF.js} +5 -4
  42. package/dist/{delete-agent-Y4T2G5PT.js → delete-agent-O3EEH4S5.js} +5 -4
  43. package/dist/{delete-annotation-KYDMQTZG.js → delete-annotation-XP6FQM7A.js} +5 -4
  44. package/dist/{delete-dashboard-3RW647WJ.js → delete-dashboard-RFUNYCNP.js} +5 -4
  45. package/dist/{delete-dataset-SZAHFHHS.js → delete-dataset-3MJFRDLP.js} +5 -4
  46. package/dist/{delete-dataset-records-ISHKGEBH.js → delete-dataset-records-WPMU5GL5.js} +5 -4
  47. package/dist/{delete-evaluator-VKIG5I3S.js → delete-evaluator-33SP2JFX.js} +5 -4
  48. package/dist/{delete-prompt-tag-VYYL4GTF.js → delete-prompt-tag-KNMZVSPB.js} +4 -3
  49. package/dist/{delete-workflow-SHNPYSWQ.js → delete-workflow-3NUXO46U.js} +5 -4
  50. package/dist/discover-evaluator-schema-XTAD4X7D.js +2191 -0
  51. package/dist/{discover-scenario-schema-MEEEVND7.js → discover-scenario-schema-FE64QK2D.js} +3 -1
  52. package/dist/{discover-schema-3T52ORPB.js → discover-schema-L6QDPON3.js} +3 -1
  53. package/dist/{get-agent-IZSP7AR5.js → get-agent-Y7MWZ65F.js} +5 -4
  54. package/dist/{get-analytics-EDFAYYIQ.js → get-analytics-TETCSOK6.js} +4 -3
  55. package/dist/{get-dashboard-WSAO6IQ4.js → get-dashboard-KHBEGZGL.js} +5 -4
  56. package/dist/{get-dataset-BEVNXQNF.js → get-dataset-RCMU5VAH.js} +5 -4
  57. package/dist/{get-evaluator-HCVXNPZR.js → get-evaluator-ADMKVWYZ.js} +5 -4
  58. package/dist/{get-project-H4V3GYRQ.js → get-project-RAK7URQJ.js} +5 -4
  59. package/dist/{get-prompt-EWRJVVGB.js → get-prompt-OY5B7TRI.js} +4 -3
  60. package/dist/{get-scenario-24YUJS5W.js → get-scenario-ZTAJJL5T.js} +5 -4
  61. package/dist/{get-simulation-run-UX2W6F6I.js → get-simulation-run-T2ZQWYH2.js} +5 -4
  62. package/dist/{get-suite-B65ZKZJA.js → get-suite-J7JVWO6O.js} +5 -4
  63. package/dist/{get-trace-ZR7MIITH.js → get-trace-K7U234Q4.js} +8 -10
  64. package/dist/{get-workflow-YROHUQJW.js → get-workflow-L5L2PSK3.js} +5 -4
  65. package/dist/http-security-EU3CHTY7.js +26 -0
  66. package/dist/http-server-YXHFPPGA.js +24706 -0
  67. package/dist/index.js +23280 -3135
  68. package/dist/{langwatch-api-annotations-OPEXNTPF.js → langwatch-api-annotations-FDAS6NQ7.js} +5 -4
  69. package/dist/{langwatch-api-dashboards-NPQM6QJI.js → langwatch-api-dashboards-JTVCS7ZJ.js} +5 -4
  70. package/dist/{langwatch-api-monitors-3L25OOOS.js → langwatch-api-monitors-3J7UIQME.js} +4 -3
  71. package/dist/{langwatch-api-secrets-ZFURUA62.js → langwatch-api-secrets-BUBSNCNO.js} +4 -3
  72. package/dist/{langwatch-api-triggers-XGXXT7DD.js → langwatch-api-triggers-EDFUCJN6.js} +4 -3
  73. package/dist/langwatch-api-workflows-NBJX5KOG.js +16 -0
  74. package/dist/{list-agents-6ZNOV7FK.js → list-agents-KFHKGPXV.js} +5 -4
  75. package/dist/{list-annotations-6P3IWILH.js → list-annotations-ZTVP4EEL.js} +5 -4
  76. package/dist/{list-api-keys-JJI62KD2.js → list-api-keys-CWD4UQT5.js} +5 -4
  77. package/dist/{list-dashboards-G2WJOXWB.js → list-dashboards-PWG6P6KR.js} +5 -4
  78. package/dist/{list-dataset-records-GWWVOCII.js → list-dataset-records-IOE7LQXS.js} +5 -4
  79. package/dist/{list-datasets-HBTH33X4.js → list-datasets-ZT4JYUDT.js} +5 -4
  80. package/dist/{list-evaluators-K5OGB6TX.js → list-evaluators-SJHC7VBH.js} +5 -4
  81. package/dist/{list-model-providers-JMOGZLSO.js → list-model-providers-PPD2WPD2.js} +5 -4
  82. package/dist/{list-projects-OXJC2NW5.js → list-projects-K7QAIX2I.js} +5 -4
  83. package/dist/{list-prompt-tags-J2HPHVAZ.js → list-prompt-tags-FOO6TNHB.js} +4 -3
  84. package/dist/{list-prompts-C7GR72XL.js → list-prompts-SC2HAUFG.js} +4 -3
  85. package/dist/{list-scenarios-JEPGIIDO.js → list-scenarios-F2WYNRIE.js} +5 -4
  86. package/dist/{list-simulation-runs-DRSAGPEC.js → list-simulation-runs-KF5THG5H.js} +5 -4
  87. package/dist/{list-suites-POTZ3ZT6.js → list-suites-CRY4VWW6.js} +5 -4
  88. package/dist/{list-workflows-QQZQIOHB.js → list-workflows-QSJWURCK.js} +5 -4
  89. package/dist/{rename-prompt-tag-4AAV2BS4.js → rename-prompt-tag-EKWU5QF3.js} +4 -3
  90. package/dist/report-issue-XYVAPAYJ.js +412 -0
  91. package/dist/{revoke-api-key-TONB4ADQ.js → revoke-api-key-FTRTZMOF.js} +5 -4
  92. package/dist/{run-agent-7QE3YEH7.js → run-agent-6SM4XNA7.js} +5 -4
  93. package/dist/{run-suite-Q66OSPO4.js → run-suite-NKZCJ2IN.js} +5 -4
  94. package/dist/{run-workflow-RWX2D752.js → run-workflow-QPXC627O.js} +5 -4
  95. package/dist/{search-traces-M5E5VB4X.js → search-traces-A6HFKPLJ.js} +10 -3
  96. package/dist/{set-model-provider-XENT5D2P.js → set-model-provider-3WKT63ZN.js} +5 -4
  97. package/dist/{update-agent-GML25EE3.js → update-agent-SMAEHLXH.js} +5 -4
  98. package/dist/{update-dataset-R4MM6NNY.js → update-dataset-BDUK7TUO.js} +5 -4
  99. package/dist/{update-dataset-record-FN4BL5P4.js → update-dataset-record-4ZSDRBSW.js} +5 -4
  100. package/dist/{update-evaluator-LH2RDDB5.js → update-evaluator-LS6TIBY7.js} +5 -4
  101. package/dist/{update-project-H53IHRBB.js → update-project-LKVYXENH.js} +5 -4
  102. package/dist/{update-prompt-M33OTTAJ.js → update-prompt-ODCRQRN6.js} +4 -3
  103. package/dist/{update-scenario-FKTQRAFQ.js → update-scenario-NEXCAQI7.js} +5 -4
  104. package/dist/{update-suite-JJTNOTXN.js → update-suite-SNO3GS7T.js} +5 -4
  105. package/package.json +20 -18
  106. package/pyproject.toml +10 -2
  107. package/src/__tests__/all-tools.integration.test.ts +56 -120
  108. package/src/__tests__/config.unit.test.ts +33 -0
  109. package/src/__tests__/docker.integration.test.ts +91 -7
  110. package/src/__tests__/documentation-fetch-security.integration.test.ts +182 -0
  111. package/src/__tests__/feature-map-tool-drift.unit.test.ts +93 -0
  112. package/src/__tests__/http-security-origin.unit.test.ts +97 -0
  113. package/src/__tests__/http-security-sessions.unit.test.ts +209 -0
  114. package/src/__tests__/http-security-verifier.unit.test.ts +248 -0
  115. package/src/__tests__/http-server-allocation.integration.test.ts +279 -0
  116. package/src/__tests__/http-server-origin.integration.test.ts +169 -0
  117. package/src/__tests__/http-server-session-auth.integration.test.ts +341 -0
  118. package/src/__tests__/http-transport.integration.test.ts +85 -87
  119. package/src/__tests__/langwatch-api.unit.test.ts +206 -0
  120. package/src/__tests__/public-http-request-security.integration.test.ts +118 -0
  121. package/src/__tests__/report-issue.integration.test.ts +147 -0
  122. package/src/__tests__/support/http-server-harness.ts +138 -0
  123. package/src/__tests__/tools.unit.test.ts +24 -0
  124. package/src/config.ts +17 -2
  125. package/src/create-mcp-server.ts +43 -29
  126. package/src/documentation-fetch.ts +111 -0
  127. package/src/http-security.ts +492 -0
  128. package/src/http-server.ts +536 -152
  129. package/src/index.ts +49 -2
  130. package/src/langwatch-api-agents.ts +2 -2
  131. package/src/langwatch-api.ts +138 -8
  132. package/src/public-http-request.ts +242 -0
  133. package/src/tools/discover-evaluator-schema.ts +2 -2
  134. package/src/tools/get-trace.ts +2 -12
  135. package/src/tools/report-issue.ts +150 -0
  136. package/src/tools/search-traces.ts +4 -0
  137. package/src/utils/format-evaluations.ts +20 -0
  138. package/tsconfig.json +8 -1
  139. package/uv.lock +261 -207
  140. package/dist/chunk-IUP5IHBD.js +0 -71
  141. package/dist/discover-evaluator-schema-H23XCLNE.js +0 -1402
  142. package/dist/http-server-GXVEDA7G.js +0 -226
  143. package/dist/langwatch-api-workflows-CXCTTCXT.js +0 -15
  144. package/pnpm-workspace.yaml +0 -17
@@ -0,0 +1,341 @@
1
+ /**
2
+ * A session id must authorize nothing on its own, and credentials must never
3
+ * be accepted from the query string.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+
8
+ import { initConfig } from "../config.js";
9
+ import {
10
+ countingVerifier,
11
+ MCP_POST_HEADERS,
12
+ openSession,
13
+ OTHER_VALID_KEY,
14
+ readSessionId,
15
+ revocableVerifier,
16
+ startHarness,
17
+ toolsListBody,
18
+ VALID_KEY,
19
+ initializeBody,
20
+ type Harness,
21
+ } from "./support/http-server-harness.js";
22
+
23
+ beforeEach(() => {
24
+ initConfig({ endpoint: "https://app.langwatch.ai" });
25
+ });
26
+
27
+ describe("Credentials in query parameters", () => {
28
+ let harness: Harness;
29
+
30
+ beforeEach(async () => {
31
+ const { verifier } = countingVerifier([VALID_KEY]);
32
+ harness = await startHarness({ port: 0, apiKeyVerifier: verifier });
33
+ });
34
+
35
+ afterEach(async () => {
36
+ await harness.close();
37
+ });
38
+
39
+ it("does not accept an API key from the SSE query string", async () => {
40
+ const controller = new AbortController();
41
+
42
+ const response = await fetch(`${harness.baseUrl}/sse?apiKey=${VALID_KEY}`, {
43
+ signal: controller.signal,
44
+ });
45
+
46
+ expect(response.status).toBe(401);
47
+ controller.abort();
48
+ });
49
+
50
+ it("does not accept an API key from the query string on /mcp", async () => {
51
+ const response = await fetch(`${harness.baseUrl}/mcp?apiKey=${VALID_KEY}`, {
52
+ method: "POST",
53
+ headers: MCP_POST_HEADERS,
54
+ body: initializeBody(),
55
+ });
56
+
57
+ expect(response.status).toBe(401);
58
+ });
59
+
60
+ it("rejects a message posted with only a session id in the query string", async () => {
61
+ const controller = new AbortController();
62
+ const sseResponse = await fetch(`${harness.baseUrl}/sse`, {
63
+ signal: controller.signal,
64
+ headers: { Authorization: `Bearer ${VALID_KEY}` },
65
+ });
66
+ expect(sseResponse.status).toBe(200);
67
+
68
+ const sessionId = await readSessionId(sseResponse);
69
+
70
+ const response = await fetch(
71
+ `${harness.baseUrl}/messages?sessionId=${sessionId}`,
72
+ {
73
+ method: "POST",
74
+ headers: { "Content-Type": "application/json" },
75
+ body: toolsListBody(),
76
+ }
77
+ );
78
+
79
+ expect(response.status).toBe(401);
80
+ controller.abort();
81
+ });
82
+ });
83
+
84
+ describe("Session id is not a credential", () => {
85
+ let harness: Harness;
86
+
87
+ beforeEach(async () => {
88
+ const { verifier } = countingVerifier([VALID_KEY, OTHER_VALID_KEY]);
89
+ harness = await startHarness({ port: 0, apiKeyVerifier: verifier });
90
+ });
91
+
92
+ afterEach(async () => {
93
+ await harness.close();
94
+ });
95
+
96
+ it("rejects tools/list carrying only a valid session id", async () => {
97
+ const sessionId = await openSession({ baseUrl: harness.baseUrl });
98
+
99
+ const response = await fetch(`${harness.baseUrl}/mcp`, {
100
+ method: "POST",
101
+ headers: { ...MCP_POST_HEADERS, "mcp-session-id": sessionId },
102
+ body: toolsListBody(),
103
+ });
104
+
105
+ expect(response.status).toBe(401);
106
+ const body = await response.json();
107
+ expect(body.error).toContain("Authorization");
108
+ });
109
+
110
+ it("rejects GET /mcp carrying only a valid session id", async () => {
111
+ const sessionId = await openSession({ baseUrl: harness.baseUrl });
112
+
113
+ const response = await fetch(`${harness.baseUrl}/mcp`, {
114
+ method: "GET",
115
+ headers: { Accept: "text/event-stream", "mcp-session-id": sessionId },
116
+ });
117
+
118
+ expect(response.status).toBe(401);
119
+ });
120
+
121
+ it("rejects DELETE /mcp carrying only a valid session id", async () => {
122
+ const sessionId = await openSession({ baseUrl: harness.baseUrl });
123
+
124
+ const response = await fetch(`${harness.baseUrl}/mcp`, {
125
+ method: "DELETE",
126
+ headers: { "mcp-session-id": sessionId },
127
+ });
128
+
129
+ expect(response.status).toBe(401);
130
+
131
+ // The session survived the unauthorized delete attempt.
132
+ const authorized = await fetch(`${harness.baseUrl}/mcp`, {
133
+ method: "DELETE",
134
+ headers: {
135
+ "mcp-session-id": sessionId,
136
+ Authorization: `Bearer ${VALID_KEY}`,
137
+ },
138
+ });
139
+ expect(authorized.status).toBe(200);
140
+ });
141
+
142
+ it("accepts a lowercase bearer scheme, which RFC 7235 allows", async () => {
143
+ const sessionId = await openSession({ baseUrl: harness.baseUrl });
144
+
145
+ const response = await fetch(`${harness.baseUrl}/mcp`, {
146
+ method: "POST",
147
+ headers: {
148
+ ...MCP_POST_HEADERS,
149
+ "mcp-session-id": sessionId,
150
+ Authorization: `bearer ${VALID_KEY}`,
151
+ },
152
+ body: toolsListBody(),
153
+ });
154
+
155
+ expect(response.status).toBe(200);
156
+ await response.text();
157
+ });
158
+
159
+ it("tolerates extra spaces between the scheme and the token", async () => {
160
+ const sessionId = await openSession({ baseUrl: harness.baseUrl });
161
+
162
+ const response = await fetch(`${harness.baseUrl}/mcp`, {
163
+ method: "POST",
164
+ headers: {
165
+ ...MCP_POST_HEADERS,
166
+ "mcp-session-id": sessionId,
167
+ Authorization: `Bearer ${VALID_KEY}`,
168
+ },
169
+ body: toolsListBody(),
170
+ });
171
+
172
+ expect(response.status).toBe(200);
173
+ await response.text();
174
+ });
175
+
176
+ it("rejects an Authorization header carrying no token", async () => {
177
+ const sessionId = await openSession({ baseUrl: harness.baseUrl });
178
+
179
+ for (const header of ["Bearer", `Basic ${VALID_KEY}`, `Bearer${VALID_KEY}`]) {
180
+ const response = await fetch(`${harness.baseUrl}/mcp`, {
181
+ method: "POST",
182
+ headers: {
183
+ ...MCP_POST_HEADERS,
184
+ "mcp-session-id": sessionId,
185
+ Authorization: header,
186
+ },
187
+ body: toolsListBody(),
188
+ });
189
+
190
+ expect(response.status).toBe(401);
191
+ await response.text();
192
+ }
193
+ });
194
+
195
+ it("does not reveal whether another key's session exists", async () => {
196
+ const sessionId = await openSession({ baseUrl: harness.baseUrl, apiKey: VALID_KEY });
197
+
198
+ const someoneElses = await fetch(`${harness.baseUrl}/mcp`, {
199
+ method: "DELETE",
200
+ headers: {
201
+ "mcp-session-id": sessionId,
202
+ Authorization: `Bearer ${OTHER_VALID_KEY}`,
203
+ },
204
+ });
205
+
206
+ const neverExisted = await fetch(`${harness.baseUrl}/mcp`, {
207
+ method: "DELETE",
208
+ headers: {
209
+ "mcp-session-id": "00000000-0000-0000-0000-000000000000",
210
+ Authorization: `Bearer ${OTHER_VALID_KEY}`,
211
+ },
212
+ });
213
+
214
+ // Same status for "not yours" and "does not exist", so the response is not
215
+ // an existence oracle for session ids.
216
+ expect(someoneElses.status).toBe(404);
217
+ expect(neverExisted.status).toBe(404);
218
+ expect(await someoneElses.json()).toEqual(await neverExisted.json());
219
+ });
220
+
221
+ it("rejects a different valid key reusing someone else's session", async () => {
222
+ const sessionId = await openSession({ baseUrl: harness.baseUrl, apiKey: VALID_KEY });
223
+
224
+ const response = await fetch(`${harness.baseUrl}/mcp`, {
225
+ method: "POST",
226
+ headers: {
227
+ ...MCP_POST_HEADERS,
228
+ "mcp-session-id": sessionId,
229
+ Authorization: `Bearer ${OTHER_VALID_KEY}`,
230
+ },
231
+ body: toolsListBody(),
232
+ });
233
+
234
+ expect(response.status).toBe(401);
235
+ const body = await response.json();
236
+ expect(body.error).toContain("does not match session");
237
+ });
238
+
239
+ it("still serves the session to the key that created it", async () => {
240
+ const sessionId = await openSession({ baseUrl: harness.baseUrl });
241
+
242
+ const response = await fetch(`${harness.baseUrl}/mcp`, {
243
+ method: "POST",
244
+ headers: {
245
+ ...MCP_POST_HEADERS,
246
+ "mcp-session-id": sessionId,
247
+ Authorization: `Bearer ${VALID_KEY}`,
248
+ },
249
+ body: toolsListBody(),
250
+ });
251
+
252
+ expect(response.status).toBe(200);
253
+ const text = await response.text();
254
+ expect(text).toContain("fetch_langwatch_docs");
255
+ });
256
+
257
+ it("answers 401 rather than 400 for a session id it does not know", async () => {
258
+ const response = await fetch(`${harness.baseUrl}/mcp`, {
259
+ method: "POST",
260
+ headers: {
261
+ ...MCP_POST_HEADERS,
262
+ "mcp-session-id": "00000000-0000-0000-0000-000000000000",
263
+ Authorization: `Bearer ${VALID_KEY}`,
264
+ },
265
+ body: toolsListBody(),
266
+ });
267
+
268
+ expect(response.status).toBe(401);
269
+ });
270
+ });
271
+
272
+ describe("Revoking a key ends its sessions", () => {
273
+ it("stops serving a Streamable HTTP session once its key stops verifying", async () => {
274
+ const { verifier, revoke } = revocableVerifier([VALID_KEY]);
275
+ const harness = await startHarness({ port: 0, apiKeyVerifier: verifier });
276
+
277
+ try {
278
+ const sessionId = await openSession({ baseUrl: harness.baseUrl });
279
+ const call = () =>
280
+ fetch(`${harness.baseUrl}/mcp`, {
281
+ method: "POST",
282
+ headers: {
283
+ ...MCP_POST_HEADERS,
284
+ "mcp-session-id": sessionId,
285
+ Authorization: `Bearer ${VALID_KEY}`,
286
+ },
287
+ body: toolsListBody(),
288
+ });
289
+
290
+ const before = await call();
291
+ expect(before.status).toBe(200);
292
+ await before.text();
293
+
294
+ revoke(VALID_KEY);
295
+
296
+ const after = await call();
297
+ expect(after.status).toBe(401);
298
+ await after.text();
299
+ } finally {
300
+ await harness.close();
301
+ }
302
+ });
303
+
304
+ it("stops serving an SSE session once its key stops verifying", async () => {
305
+ const { verifier, revoke } = revocableVerifier([VALID_KEY]);
306
+ const harness = await startHarness({ port: 0, apiKeyVerifier: verifier });
307
+ const controller = new AbortController();
308
+
309
+ try {
310
+ const sse = await fetch(`${harness.baseUrl}/sse`, {
311
+ signal: controller.signal,
312
+ headers: { Authorization: `Bearer ${VALID_KEY}` },
313
+ });
314
+ expect(sse.status).toBe(200);
315
+
316
+ const sessionId = await readSessionId(sse);
317
+ const post = () =>
318
+ fetch(`${harness.baseUrl}/messages?sessionId=${sessionId}`, {
319
+ method: "POST",
320
+ headers: {
321
+ "Content-Type": "application/json",
322
+ Authorization: `Bearer ${VALID_KEY}`,
323
+ },
324
+ body: toolsListBody(),
325
+ });
326
+
327
+ const before = await post();
328
+ expect(before.status).toBeLessThan(400);
329
+ await before.text();
330
+
331
+ revoke(VALID_KEY);
332
+
333
+ const after = await post();
334
+ expect(after.status).toBe(401);
335
+ await after.text();
336
+ } finally {
337
+ controller.abort();
338
+ await harness.close();
339
+ }
340
+ });
341
+ });
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
2
  import { initConfig } from "../config.js";
3
+ import type { ApiKeyVerifier } from "../http-security.js";
3
4
  import type { Server } from "http";
4
5
 
5
6
  /** Standard headers required by the MCP Streamable HTTP protocol for POST requests */
@@ -10,6 +11,18 @@ const MCP_POST_HEADERS = {
10
11
 
11
12
  const BEARER_TOKEN = "test-session-key";
12
13
 
14
+ /**
15
+ * Stands in for the LangWatch API. The standalone server has no database, so
16
+ * it asks the API whether a key is real; here one key is.
17
+ */
18
+ function stubVerifier(validKeys: string[]): ApiKeyVerifier {
19
+ return {
20
+ verify: async (apiKey: string) => validKeys.includes(apiKey),
21
+ sweep: () => undefined,
22
+ clear: () => undefined,
23
+ };
24
+ }
25
+
13
26
  /** Helper to create auth + MCP headers */
14
27
  function mcpHeaders({
15
28
  sessionId,
@@ -41,6 +54,7 @@ function initializeBody() {
41
54
  describe("HTTP transport", () => {
42
55
  let httpServer: Server;
43
56
  let port: number;
57
+ let baseUrl: string;
44
58
 
45
59
  beforeAll(async () => {
46
60
  // Initialize with no API key -- HTTP mode relies on per-session Bearer tokens
@@ -49,9 +63,13 @@ describe("HTTP transport", () => {
49
63
  });
50
64
 
51
65
  const { startHttpServer } = await import("../http-server.js");
52
- const result = await startHttpServer({ port: 0 });
66
+ const result = await startHttpServer({
67
+ port: 0,
68
+ apiKeyVerifier: stubVerifier([BEARER_TOKEN, "my-langwatch-api-key"]),
69
+ });
53
70
  httpServer = result.server;
54
71
  port = result.port;
72
+ baseUrl = `http://127.0.0.1:${port}`;
55
73
  });
56
74
 
57
75
  afterAll(async () => {
@@ -62,7 +80,7 @@ describe("HTTP transport", () => {
62
80
 
63
81
  describe("/health endpoint", () => {
64
82
  it("returns ok status without authentication", async () => {
65
- const response = await fetch(`http://localhost:${port}/health`);
83
+ const response = await fetch(`${baseUrl}/health`);
66
84
  const body = await response.json();
67
85
 
68
86
  expect(response.status).toBe(200);
@@ -71,18 +89,30 @@ describe("HTTP transport", () => {
71
89
  });
72
90
 
73
91
  describe("CORS headers", () => {
74
- it("includes Access-Control-Allow-Origin on responses", async () => {
75
- const response = await fetch(`http://localhost:${port}/health`);
92
+ it("sends no Access-Control-Allow-Origin when the request has no Origin", async () => {
93
+ const response = await fetch(`${baseUrl}/health`);
94
+
95
+ expect(response.status).toBe(200);
96
+ expect(response.headers.get("access-control-allow-origin")).toBeNull();
97
+ });
76
98
 
77
- expect(response.headers.get("access-control-allow-origin")).toBe("*");
99
+ it("reflects an allowed origin rather than answering with a wildcard", async () => {
100
+ const response = await fetch(`${baseUrl}/health`, {
101
+ headers: { Origin: "http://localhost:5173" },
102
+ });
103
+
104
+ expect(response.headers.get("access-control-allow-origin")).toBe(
105
+ "http://localhost:5173"
106
+ );
107
+ expect(response.headers.get("vary")).toContain("Origin");
78
108
  });
79
109
 
80
110
  it("responds to OPTIONS preflight requests", async () => {
81
- const response = await fetch(`http://localhost:${port}/mcp`, {
111
+ const response = await fetch(`${baseUrl}/mcp`, {
82
112
  method: "OPTIONS",
83
113
  });
84
114
 
85
- expect(response.status).toBe(200);
115
+ expect(response.status).toBe(204);
86
116
  expect(response.headers.get("access-control-allow-methods")).toContain(
87
117
  "POST"
88
118
  );
@@ -92,7 +122,7 @@ describe("HTTP transport", () => {
92
122
  });
93
123
 
94
124
  it("includes Authorization in allowed headers for CORS", async () => {
95
- const response = await fetch(`http://localhost:${port}/mcp`, {
125
+ const response = await fetch(`${baseUrl}/mcp`, {
96
126
  method: "OPTIONS",
97
127
  });
98
128
 
@@ -105,7 +135,7 @@ describe("HTTP transport", () => {
105
135
  describe("/mcp endpoint (Streamable HTTP)", () => {
106
136
  describe("when no Bearer token is provided", () => {
107
137
  it("returns 401 on initialize request", async () => {
108
- const response = await fetch(`http://localhost:${port}/mcp`, {
138
+ const response = await fetch(`${baseUrl}/mcp`, {
109
139
  method: "POST",
110
140
  headers: MCP_POST_HEADERS,
111
141
  body: initializeBody(),
@@ -118,7 +148,7 @@ describe("HTTP transport", () => {
118
148
  });
119
149
 
120
150
  it("rejects non-initialize POST without session ID", async () => {
121
- const response = await fetch(`http://localhost:${port}/mcp`, {
151
+ const response = await fetch(`${baseUrl}/mcp`, {
122
152
  method: "POST",
123
153
  headers: {
124
154
  ...MCP_POST_HEADERS,
@@ -135,7 +165,7 @@ describe("HTTP transport", () => {
135
165
  });
136
166
 
137
167
  it("accepts initialize request with Bearer token and returns session ID", async () => {
138
- const response = await fetch(`http://localhost:${port}/mcp`, {
168
+ const response = await fetch(`${baseUrl}/mcp`, {
139
169
  method: "POST",
140
170
  headers: mcpHeaders(),
141
171
  body: initializeBody(),
@@ -147,7 +177,7 @@ describe("HTTP transport", () => {
147
177
  });
148
178
 
149
179
  it("lists all tools after initialization", async () => {
150
- const initResponse = await fetch(`http://localhost:${port}/mcp`, {
180
+ const initResponse = await fetch(`${baseUrl}/mcp`, {
151
181
  method: "POST",
152
182
  headers: mcpHeaders(),
153
183
  body: initializeBody(),
@@ -156,7 +186,7 @@ describe("HTTP transport", () => {
156
186
  const sessionId = initResponse.headers.get("mcp-session-id")!;
157
187
 
158
188
  // Send initialized notification
159
- await fetch(`http://localhost:${port}/mcp`, {
189
+ await fetch(`${baseUrl}/mcp`, {
160
190
  method: "POST",
161
191
  headers: mcpHeaders({ sessionId }),
162
192
  body: JSON.stringify({
@@ -166,7 +196,7 @@ describe("HTTP transport", () => {
166
196
  });
167
197
 
168
198
  // List tools
169
- const toolsResponse = await fetch(`http://localhost:${port}/mcp`, {
199
+ const toolsResponse = await fetch(`${baseUrl}/mcp`, {
170
200
  method: "POST",
171
201
  headers: mcpHeaders({ sessionId }),
172
202
  body: JSON.stringify({
@@ -187,7 +217,7 @@ describe("HTTP transport", () => {
187
217
 
188
218
  describe("DELETE /mcp", () => {
189
219
  it("closes an existing session", async () => {
190
- const initResponse = await fetch(`http://localhost:${port}/mcp`, {
220
+ const initResponse = await fetch(`${baseUrl}/mcp`, {
191
221
  method: "POST",
192
222
  headers: mcpHeaders(),
193
223
  body: initializeBody(),
@@ -195,18 +225,24 @@ describe("HTTP transport", () => {
195
225
 
196
226
  const sessionId = initResponse.headers.get("mcp-session-id")!;
197
227
 
198
- const deleteResponse = await fetch(`http://localhost:${port}/mcp`, {
228
+ const deleteResponse = await fetch(`${baseUrl}/mcp`, {
199
229
  method: "DELETE",
200
- headers: { "mcp-session-id": sessionId },
230
+ headers: {
231
+ "mcp-session-id": sessionId,
232
+ Authorization: `Bearer ${BEARER_TOKEN}`,
233
+ },
201
234
  });
202
235
 
203
236
  expect(deleteResponse.status).toBe(200);
204
237
  });
205
238
 
206
239
  it("returns 404 for unknown session", async () => {
207
- const response = await fetch(`http://localhost:${port}/mcp`, {
240
+ const response = await fetch(`${baseUrl}/mcp`, {
208
241
  method: "DELETE",
209
- headers: { "mcp-session-id": "nonexistent-session-id" },
242
+ headers: {
243
+ "mcp-session-id": "nonexistent-session-id",
244
+ Authorization: `Bearer ${BEARER_TOKEN}`,
245
+ },
210
246
  });
211
247
 
212
248
  expect(response.status).toBe(404);
@@ -217,7 +253,7 @@ describe("HTTP transport", () => {
217
253
  describe("/.well-known/oauth-authorization-server", () => {
218
254
  it("returns OAuth metadata with token endpoint", async () => {
219
255
  const response = await fetch(
220
- `http://localhost:${port}/.well-known/oauth-authorization-server`
256
+ `${baseUrl}/.well-known/oauth-authorization-server`
221
257
  );
222
258
  const body = await response.json();
223
259
 
@@ -229,14 +265,11 @@ describe("HTTP transport", () => {
229
265
 
230
266
  describe("/oauth/token", () => {
231
267
  it("returns 400 for unsupported grant type", async () => {
232
- const response = await fetch(
233
- `http://localhost:${port}/oauth/token`,
234
- {
235
- method: "POST",
236
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
237
- body: "grant_type=authorization_code&client_secret=test-key",
238
- }
239
- );
268
+ const response = await fetch(`${baseUrl}/oauth/token`, {
269
+ method: "POST",
270
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
271
+ body: "grant_type=authorization_code&client_secret=test-key",
272
+ });
240
273
  const body = await response.json();
241
274
 
242
275
  expect(response.status).toBe(400);
@@ -244,14 +277,11 @@ describe("HTTP transport", () => {
244
277
  });
245
278
 
246
279
  it("returns 400 when client_secret is missing", async () => {
247
- const response = await fetch(
248
- `http://localhost:${port}/oauth/token`,
249
- {
250
- method: "POST",
251
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
252
- body: "grant_type=client_credentials",
253
- }
254
- );
280
+ const response = await fetch(`${baseUrl}/oauth/token`, {
281
+ method: "POST",
282
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
283
+ body: "grant_type=client_credentials",
284
+ });
255
285
  const body = await response.json();
256
286
 
257
287
  expect(response.status).toBe(400);
@@ -259,14 +289,11 @@ describe("HTTP transport", () => {
259
289
  });
260
290
 
261
291
  it("issues an access token for valid client credentials", async () => {
262
- const response = await fetch(
263
- `http://localhost:${port}/oauth/token`,
264
- {
265
- method: "POST",
266
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
267
- body: "grant_type=client_credentials&client_secret=my-langwatch-api-key",
268
- }
269
- );
292
+ const response = await fetch(`${baseUrl}/oauth/token`, {
293
+ method: "POST",
294
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
295
+ body: "grant_type=client_credentials&client_secret=my-langwatch-api-key",
296
+ });
270
297
  const body = await response.json();
271
298
 
272
299
  expect(response.status).toBe(200);
@@ -277,20 +304,17 @@ describe("HTTP transport", () => {
277
304
 
278
305
  it("issued token works for MCP initialize request", async () => {
279
306
  // Get OAuth token
280
- const tokenResponse = await fetch(
281
- `http://localhost:${port}/oauth/token`,
282
- {
283
- method: "POST",
284
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
285
- body: `grant_type=client_credentials&client_secret=${BEARER_TOKEN}`,
286
- }
287
- );
307
+ const tokenResponse = await fetch(`${baseUrl}/oauth/token`, {
308
+ method: "POST",
309
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
310
+ body: `grant_type=client_credentials&client_secret=${BEARER_TOKEN}`,
311
+ });
288
312
  expect(tokenResponse.status).toBe(200);
289
313
  const { access_token } = await tokenResponse.json();
290
314
  expect(access_token).toBeTruthy();
291
315
 
292
316
  // Use OAuth token for MCP initialize
293
- const response = await fetch(`http://localhost:${port}/mcp`, {
317
+ const response = await fetch(`${baseUrl}/mcp`, {
294
318
  method: "POST",
295
319
  headers: {
296
320
  ...MCP_POST_HEADERS,
@@ -307,20 +331,17 @@ describe("HTTP transport", () => {
307
331
  const controller = new AbortController();
308
332
 
309
333
  // Get OAuth token
310
- const tokenResponse = await fetch(
311
- `http://localhost:${port}/oauth/token`,
312
- {
313
- method: "POST",
314
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
315
- body: `grant_type=client_credentials&client_secret=${BEARER_TOKEN}`,
316
- }
317
- );
334
+ const tokenResponse = await fetch(`${baseUrl}/oauth/token`, {
335
+ method: "POST",
336
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
337
+ body: `grant_type=client_credentials&client_secret=${BEARER_TOKEN}`,
338
+ });
318
339
  expect(tokenResponse.status).toBe(200);
319
340
  const { access_token } = await tokenResponse.json();
320
341
  expect(access_token).toBeTruthy();
321
342
 
322
343
  // Use OAuth token for SSE
323
- const response = await fetch(`http://localhost:${port}/sse`, {
344
+ const response = await fetch(`${baseUrl}/sse`, {
324
345
  signal: controller.signal,
325
346
  headers: { Authorization: `Bearer ${access_token}` },
326
347
  });
@@ -339,7 +360,7 @@ describe("HTTP transport", () => {
339
360
  it("returns 401 without authorization", async () => {
340
361
  const controller = new AbortController();
341
362
 
342
- const response = await fetch(`http://localhost:${port}/sse`, {
363
+ const response = await fetch(`${baseUrl}/sse`, {
343
364
  signal: controller.signal,
344
365
  });
345
366
 
@@ -350,7 +371,7 @@ describe("HTTP transport", () => {
350
371
  it("establishes SSE connection with Bearer token in header", async () => {
351
372
  const controller = new AbortController();
352
373
 
353
- const response = await fetch(`http://localhost:${port}/sse`, {
374
+ const response = await fetch(`${baseUrl}/sse`, {
354
375
  signal: controller.signal,
355
376
  headers: { Authorization: `Bearer ${BEARER_TOKEN}` },
356
377
  });
@@ -370,28 +391,5 @@ describe("HTTP transport", () => {
370
391
 
371
392
  controller.abort();
372
393
  });
373
-
374
- it("establishes SSE connection with apiKey query parameter", async () => {
375
- const controller = new AbortController();
376
-
377
- const response = await fetch(
378
- `http://localhost:${port}/sse?apiKey=${BEARER_TOKEN}`,
379
- { signal: controller.signal }
380
- );
381
-
382
- expect(response.status).toBe(200);
383
- expect(response.headers.get("content-type")).toContain(
384
- "text/event-stream"
385
- );
386
-
387
- const reader = response.body!.getReader();
388
- const decoder = new TextDecoder();
389
- const { value } = await reader.read();
390
- const text = decoder.decode(value);
391
-
392
- expect(text).toContain("event: endpoint");
393
-
394
- controller.abort();
395
- });
396
394
  });
397
395
  });