@langwatch/mcp-server 1.0.0 → 2.1.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 (204) hide show
  1. package/.python-version +1 -0
  2. package/CHANGELOG.md +110 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.md +86 -4
  5. package/dist/{archive-project-MK3673A7.js → archive-project-W3IKI7UL.js} +5 -5
  6. package/dist/archive-run-plan-474OKHHU.js +16 -0
  7. package/dist/{archive-scenario-KATSXRIN.js → archive-scenario-V5UNYK26.js} +5 -5
  8. package/dist/archive-test-suite-O7FLDMXK.js +16 -0
  9. package/dist/{assign-prompt-tag-E3C5W6PI.js → assign-prompt-tag-Q46WRB6P.js} +4 -4
  10. package/dist/chunk-56OGTS24.js +44 -0
  11. package/dist/chunk-6DJDEXET.js +63 -0
  12. package/dist/chunk-7E4FZIYJ.js +15 -0
  13. package/dist/{chunk-J45SVWPR.js → chunk-AN4OUA4D.js} +2 -2
  14. package/dist/chunk-CKNAH5TG.js +74 -0
  15. package/dist/{chunk-CE6QMZOP.js → chunk-EYXMLE5X.js} +86 -11
  16. package/dist/{chunk-XY4STOBE.js → chunk-G4ZAZTZW.js} +2 -2
  17. package/dist/{chunk-5UWY24RP.js → chunk-GELJ2MQR.js} +12 -3
  18. package/dist/chunk-HV4DWKBI.js +46 -0
  19. package/dist/chunk-JR6DJZOU.js +49 -0
  20. package/dist/{chunk-DAIQKS27.js → chunk-JTQJRNFJ.js} +2 -2
  21. package/dist/{chunk-WWMDHZZV.js → chunk-K37ZNPXK.js} +2 -2
  22. package/dist/{chunk-EU22L52K.js → chunk-OJLYYJWI.js} +2 -2
  23. package/dist/{chunk-Y3HDZ6OW.js → chunk-QAMP3CGR.js} +2 -2
  24. package/dist/{chunk-VYPSKRPN.js → chunk-QRO35SOM.js} +347 -146
  25. package/dist/{chunk-O3LWVN67.js → chunk-RVAE2MEA.js} +2 -2
  26. package/dist/{chunk-UXXA4D6K.js → chunk-S4GVX36W.js} +2 -2
  27. package/dist/{chunk-BJ7B4R26.js → chunk-SAS5OB7F.js} +18 -2
  28. package/dist/{chunk-RYRB2YVJ.js → chunk-SR6QPRIE.js} +2 -2
  29. package/dist/chunk-UF6XIGYR.js +58 -0
  30. package/dist/{chunk-T7WJVS2N.js → chunk-UFOYFYI7.js} +2 -2
  31. package/dist/chunk-UJPXFA26.js +267 -0
  32. package/dist/{chunk-KOO5733S.js → chunk-WT4PO4A3.js} +8 -2
  33. package/dist/config.js +2 -2
  34. package/dist/{create-agent-7W57J7MT.js → create-agent-JFJ2K6X3.js} +5 -5
  35. package/dist/{create-annotation-W2XELBCQ.js → create-annotation-EUXXQ6NS.js} +5 -5
  36. package/dist/{create-api-key-MKRXKFJM.js → create-api-key-BGBB76JX.js} +5 -5
  37. package/dist/{create-dashboard-QGL7IODC.js → create-dashboard-Z76AIPLS.js} +5 -5
  38. package/dist/{create-dataset-3PH7A4HG.js → create-dataset-FD5VAXU4.js} +5 -5
  39. package/dist/{create-dataset-records-4DZS3VRL.js → create-dataset-records-WQUXXOHI.js} +5 -5
  40. package/dist/{create-evaluator-YWH6NC4M.js → create-evaluator-6NV33Z52.js} +5 -5
  41. package/dist/create-mcp-server.js +9 -4
  42. package/dist/{create-project-BOBQ32C5.js → create-project-ZQ7TFIO2.js} +5 -5
  43. package/dist/{create-prompt-FLCFGITE.js → create-prompt-BS6QRCUU.js} +4 -4
  44. package/dist/{create-prompt-tag-EMPNIKEA.js → create-prompt-tag-FCQ5ELSQ.js} +4 -4
  45. package/dist/{create-scenario-BSBIHUL6.js → create-scenario-DRPTPXU3.js} +12 -5
  46. package/dist/create-test-suite-ZIZCCFJ4.js +37 -0
  47. package/dist/{delete-agent-7MSYNY5E.js → delete-agent-SA4JMUZF.js} +5 -5
  48. package/dist/{delete-annotation-ZT5IGN24.js → delete-annotation-X3KKK7QY.js} +5 -5
  49. package/dist/{delete-dashboard-BTIY5S3L.js → delete-dashboard-LYMRPF27.js} +5 -5
  50. package/dist/{delete-dataset-U45HQHPR.js → delete-dataset-W6EZ44HY.js} +5 -5
  51. package/dist/{delete-dataset-records-R43IMAWB.js → delete-dataset-records-7CNOTALB.js} +5 -5
  52. package/dist/{delete-evaluator-U6JMQ6C7.js → delete-evaluator-RKYC4TNB.js} +5 -5
  53. package/dist/{delete-prompt-tag-4EV4TDHB.js → delete-prompt-tag-QB3OCZUV.js} +4 -4
  54. package/dist/{delete-workflow-5QYALXEL.js → delete-workflow-NG2VXYYM.js} +5 -5
  55. package/dist/{discover-evaluator-schema-MRGHVNTT.js → discover-evaluator-schema-ESSHQGPY.js} +58 -321
  56. package/dist/{discover-scenario-schema-UPBTCWPG.js → discover-scenario-schema-W7KBXA4J.js} +54 -2
  57. package/dist/{discover-schema-ICGXPHUW.js → discover-schema-L6QDPON3.js} +2 -2
  58. package/dist/get-agent-7THF7HCU.js +66 -0
  59. package/dist/{get-analytics-PVERWOA5.js → get-analytics-R6AZD3AW.js} +4 -4
  60. package/dist/{get-dashboard-ZZPC3WMD.js → get-dashboard-P4WWVHFU.js} +5 -5
  61. package/dist/{get-dataset-25TB2LQH.js → get-dataset-AKTYSQDI.js} +5 -5
  62. package/dist/{get-evaluator-3FTRQKEC.js → get-evaluator-RZ44UIN6.js} +5 -5
  63. package/dist/{get-project-5OAXYC57.js → get-project-QSQEAB33.js} +5 -5
  64. package/dist/get-prompt-GVKXF5NL.js +100 -0
  65. package/dist/get-run-plan-NY7J5GRE.js +61 -0
  66. package/dist/{get-scenario-DSSTR6FI.js → get-scenario-Q3LLQE4T.js} +9 -5
  67. package/dist/{get-simulation-run-RQPMS2MC.js → get-simulation-run-WINEYDMK.js} +14 -5
  68. package/dist/get-test-suite-4HKA7OKS.js +49 -0
  69. package/dist/{get-trace-O5VT3NAC.js → get-trace-RCUV7N4O.js} +4 -4
  70. package/dist/{get-workflow-3EZDOEIF.js → get-workflow-EEOC7Y45.js} +5 -5
  71. package/dist/http-security-EU3CHTY7.js +26 -0
  72. package/dist/{http-server-2ACJ3CXW.js → http-server-WCDUARTH.js} +1038 -693
  73. package/dist/index.js +6437 -4020
  74. package/dist/{langwatch-api-annotations-E65MI3WZ.js → langwatch-api-annotations-6DHVZWUK.js} +5 -5
  75. package/dist/{langwatch-api-dashboards-QCDECRCY.js → langwatch-api-dashboards-2KJL2M42.js} +5 -5
  76. package/dist/{langwatch-api-monitors-UIO2KTDV.js → langwatch-api-monitors-6ISFJ7WX.js} +4 -4
  77. package/dist/{langwatch-api-secrets-4T2EKSJ7.js → langwatch-api-secrets-OEBD6GBU.js} +4 -4
  78. package/dist/{langwatch-api-triggers-MPOEKPUY.js → langwatch-api-triggers-5E6DDGX6.js} +4 -4
  79. package/dist/langwatch-api-workflows-6UJL5U4Z.js +16 -0
  80. package/dist/list-agents-AMVDQPQL.js +43 -0
  81. package/dist/{list-annotations-CGX5BT7E.js → list-annotations-6PR5NLIC.js} +5 -5
  82. package/dist/{list-api-keys-JLWJPFRX.js → list-api-keys-GVPNBIHQ.js} +5 -5
  83. package/dist/{list-dashboards-RNIWX3TX.js → list-dashboards-Z3BZKDGF.js} +5 -5
  84. package/dist/{list-dataset-records-B5MHZR66.js → list-dataset-records-O67HZ35Y.js} +5 -5
  85. package/dist/{list-datasets-BVCAMQCP.js → list-datasets-WXZL6UMP.js} +5 -5
  86. package/dist/{list-evaluators-65FBTL2U.js → list-evaluators-PAX72WCN.js} +5 -5
  87. package/dist/{list-model-providers-GRK3I3VV.js → list-model-providers-VVLZMLCX.js} +5 -5
  88. package/dist/{list-projects-JLMBFMW3.js → list-projects-AQVI6SUK.js} +5 -5
  89. package/dist/{list-prompt-tags-UHNWDGQI.js → list-prompt-tags-WZFBJOHG.js} +4 -4
  90. package/dist/{list-prompts-7254B3U7.js → list-prompts-BQOHRHYT.js} +4 -4
  91. package/dist/list-run-plans-UBAU2DGH.js +46 -0
  92. package/dist/{list-scenarios-46YNICKM.js → list-scenarios-MS4IUIVM.js} +10 -7
  93. package/dist/{list-simulation-runs-LGNEFNPN.js → list-simulation-runs-D6YF4ZOU.js} +13 -7
  94. package/dist/list-test-suites-L7R2O6DR.js +35 -0
  95. package/dist/{list-workflows-MLQDM6CP.js → list-workflows-6MV4XJHF.js} +5 -5
  96. package/dist/{rename-prompt-tag-RHQMNEQL.js → rename-prompt-tag-K5LQ4CY6.js} +4 -4
  97. package/dist/rename-test-suite-NTYHAO5X.js +21 -0
  98. package/dist/report-issue-RHZZ33IS.js +885 -0
  99. package/dist/rerun-run-plan-MUDWJBE4.js +22 -0
  100. package/dist/{revoke-api-key-3Y35EMBT.js → revoke-api-key-6LAZ3QZZ.js} +5 -5
  101. package/dist/run-agent-CAGWGTYT.js +72 -0
  102. package/dist/run-plan-T4ZYOZGA.js +39 -0
  103. package/dist/run-test-suite-L4MZ7G4D.js +26 -0
  104. package/dist/{run-workflow-5LVBZYLS.js → run-workflow-DFIS23AP.js} +5 -5
  105. package/dist/{search-traces-U4HLOCZ2.js → search-traces-4XTJYXIK.js} +4 -4
  106. package/dist/{set-model-provider-V3M5CDIY.js → set-model-provider-XRNMFOVR.js} +5 -5
  107. package/dist/{update-agent-47RZIHS7.js → update-agent-DUWNYBNA.js} +5 -5
  108. package/dist/{update-dataset-G6QJJGS5.js → update-dataset-YSOMDCOB.js} +5 -5
  109. package/dist/{update-dataset-record-RMWLSWCS.js → update-dataset-record-DEA2ITYR.js} +5 -5
  110. package/dist/{update-evaluator-SFRRI5Q3.js → update-evaluator-NH7Q5GWV.js} +5 -5
  111. package/dist/{update-project-PI5DWBVK.js → update-project-Q26FBSYH.js} +5 -5
  112. package/dist/update-prompt-ZQQ2C3KU.js +143 -0
  113. package/dist/{update-scenario-G3IU2UIB.js → update-scenario-WUX34JAR.js} +12 -5
  114. package/package.json +8 -9
  115. package/pyproject.toml +5 -5
  116. package/src/__tests__/agent-tools.unit.test.ts +257 -0
  117. package/src/__tests__/all-tools.integration.test.ts +321 -75
  118. package/src/__tests__/config.unit.test.ts +33 -0
  119. package/src/__tests__/docker.integration.test.ts +91 -7
  120. package/src/__tests__/documentation-fetch-security.integration.test.ts +8 -2
  121. package/src/__tests__/feature-map-tool-drift.unit.test.ts +93 -0
  122. package/src/__tests__/http-security-origin.unit.test.ts +97 -0
  123. package/src/__tests__/http-security-sessions.unit.test.ts +209 -0
  124. package/src/__tests__/http-security-verifier.unit.test.ts +248 -0
  125. package/src/__tests__/http-server-allocation.integration.test.ts +279 -0
  126. package/src/__tests__/http-server-origin.integration.test.ts +169 -0
  127. package/src/__tests__/http-server-session-auth.integration.test.ts +341 -0
  128. package/src/__tests__/http-transport.integration.test.ts +85 -87
  129. package/src/__tests__/integration.integration.test.ts +7 -11
  130. package/src/__tests__/prompt-response-types.unit.test.ts +51 -0
  131. package/src/__tests__/prompts.integration.test.ts +736 -0
  132. package/src/__tests__/run-plan-tools.unit.test.ts +565 -0
  133. package/src/__tests__/scenario-tools.unit.test.ts +19 -0
  134. package/src/__tests__/simulation-run-tools.unit.test.ts +116 -0
  135. package/src/__tests__/support/http-server-harness.ts +138 -0
  136. package/src/__tests__/test-suite-tools.unit.test.ts +615 -0
  137. package/src/__tests__/{tools.unit.test.ts → tools.integration.test.ts} +31 -59
  138. package/src/config.ts +17 -2
  139. package/src/create-mcp-server.ts +334 -55
  140. package/src/http-security.ts +492 -0
  141. package/src/http-server.ts +536 -152
  142. package/src/index.ts +49 -2
  143. package/src/langwatch-api-agents.ts +196 -12
  144. package/src/langwatch-api-run-plans.ts +157 -0
  145. package/src/langwatch-api-scenarios.ts +9 -0
  146. package/src/langwatch-api-simulation-runs.ts +5 -0
  147. package/src/langwatch-api-test-suites.ts +135 -0
  148. package/src/langwatch-api.ts +62 -12
  149. package/src/schemas/index.ts +5 -0
  150. package/src/schemas/run-plan.ts +92 -0
  151. package/src/schemas/suite-fields.ts +149 -0
  152. package/src/tools/archive-run-plan.ts +12 -0
  153. package/src/tools/archive-test-suite.ts +12 -0
  154. package/src/tools/create-scenario.ts +8 -0
  155. package/src/tools/create-test-suite.ts +43 -0
  156. package/src/tools/discover-evaluator-schema.ts +2 -2
  157. package/src/tools/discover-scenario-schema.ts +54 -0
  158. package/src/tools/format-run-plan.ts +68 -0
  159. package/src/tools/format-scenario.ts +14 -0
  160. package/src/tools/format-suite-details.ts +96 -0
  161. package/src/tools/get-agent.ts +45 -3
  162. package/src/tools/get-prompt.ts +108 -24
  163. package/src/tools/get-run-plan.ts +62 -0
  164. package/src/tools/get-scenario.ts +3 -0
  165. package/src/tools/get-simulation-run.ts +9 -0
  166. package/src/tools/get-test-suite.ts +49 -0
  167. package/src/tools/list-agents.ts +20 -3
  168. package/src/tools/list-run-plans.ts +47 -0
  169. package/src/tools/list-scenarios.ts +12 -2
  170. package/src/tools/list-simulation-runs.ts +6 -2
  171. package/src/tools/list-test-suites.ts +35 -0
  172. package/src/tools/rename-test-suite.ts +18 -0
  173. package/src/tools/rerun-run-plan.ts +21 -0
  174. package/src/tools/run-agent.ts +54 -6
  175. package/src/tools/run-plan.ts +49 -0
  176. package/src/tools/run-test-suite.ts +30 -0
  177. package/src/tools/test-agent.ts +18 -0
  178. package/src/tools/update-prompt.ts +195 -10
  179. package/src/tools/update-scenario.ts +8 -0
  180. package/src/tools/update-test-suite.ts +45 -0
  181. package/tsconfig.json +7 -2
  182. package/uv.lock +418 -298
  183. package/dist/archive-suite-C3KX6IIZ.js +0 -16
  184. package/dist/chunk-WDH5P5RF.js +0 -48
  185. package/dist/create-suite-TWF6BBR4.js +0 -39
  186. package/dist/get-agent-7QJVPH72.js +0 -29
  187. package/dist/get-prompt-I753LZKZ.js +0 -46
  188. package/dist/get-suite-GTOCY5PL.js +0 -45
  189. package/dist/langwatch-api-workflows-5HRZ7HR4.js +0 -16
  190. package/dist/list-agents-TIJTP566.js +0 -33
  191. package/dist/list-suites-5VK4HXEK.js +0 -43
  192. package/dist/report-issue-DXJ6TPTC.js +0 -412
  193. package/dist/run-agent-RBSIFKRT.js +0 -37
  194. package/dist/run-suite-K3DNQ3SE.js +0 -30
  195. package/dist/update-prompt-RQFTEO7Y.js +0 -25
  196. package/dist/update-suite-TRGGLNR7.js +0 -40
  197. package/pnpm-workspace.yaml +0 -34
  198. package/src/langwatch-api-suites.ts +0 -118
  199. package/src/tools/archive-suite.ts +0 -12
  200. package/src/tools/create-suite.ts +0 -42
  201. package/src/tools/get-suite.ts +0 -47
  202. package/src/tools/list-suites.ts +0 -43
  203. package/src/tools/run-suite.ts +0 -28
  204. package/src/tools/update-suite.ts +0 -44
@@ -4,7 +4,16 @@ import { execSync } from "child_process";
4
4
  const IMAGE_NAME = "langwatch-mcp-server-test";
5
5
  const CONTAINER_NAME = "langwatch-mcp-server-test-container";
6
6
  const HOST_PORT = 13099; // unlikely to conflict
7
- const BEARER_TOKEN = "test-docker-api-key";
7
+
8
+ /**
9
+ * HTTP mode verifies the bearer against the LangWatch API before creating a
10
+ * session, so the authenticated paths need a key the API actually accepts.
11
+ * Without one, this file still covers startup, CORS, and rejection.
12
+ */
13
+ const BEARER_TOKEN = process.env.LANGWATCH_API_KEY;
14
+
15
+ /** A syntactically fine token that the LangWatch API does not know. */
16
+ const BOGUS_TOKEN = "totally-not-a-real-key";
8
17
 
9
18
  /** Standard headers required by the MCP Streamable HTTP protocol */
10
19
  const MCP_POST_HEADERS = {
@@ -20,7 +29,7 @@ describe("Docker container", () => {
20
29
  // Build from repo root (mcp-server needs langevals/ for build)
21
30
  const repoRoot = process.cwd().replace(/\/mcp-server.*$/, "");
22
31
  execSync(
23
- `docker build -t ${IMAGE_NAME} -f mcp-server/Dockerfile .`,
32
+ `docker build -t ${IMAGE_NAME} -f mcp/typescript/Dockerfile .`,
24
33
  {
25
34
  cwd: repoRoot,
26
35
  stdio: "pipe",
@@ -85,11 +94,86 @@ describe("Docker container", () => {
85
94
  expect(body.status).toBe("ok");
86
95
  });
87
96
 
88
- it("CORS headers are present", async () => {
97
+ it("does not answer with a wildcard CORS origin", async () => {
89
98
  if (!containerRunning) return;
90
99
 
91
100
  const res = await fetch(`http://localhost:${HOST_PORT}/health`);
92
- expect(res.headers.get("access-control-allow-origin")).toBe("*");
101
+ expect(res.headers.get("access-control-allow-origin")).not.toBe("*");
102
+ });
103
+
104
+ it("reflects an allowed origin and rejects an unlisted one", async () => {
105
+ if (!containerRunning) return;
106
+
107
+ const allowed = await fetch(`http://localhost:${HOST_PORT}/health`, {
108
+ headers: { Origin: "http://localhost:5173" },
109
+ });
110
+ expect(allowed.status).toBe(200);
111
+ expect(allowed.headers.get("access-control-allow-origin")).toBe(
112
+ "http://localhost:5173"
113
+ );
114
+
115
+ const rejected = await fetch(`http://localhost:${HOST_PORT}/health`, {
116
+ headers: { Origin: "https://attacker.example" },
117
+ });
118
+ expect(rejected.status).toBe(403);
119
+ });
120
+
121
+ it("rejects a bearer the LangWatch API does not recognise", async () => {
122
+ if (!containerRunning) return;
123
+
124
+ const res = await fetch(`http://localhost:${HOST_PORT}/mcp`, {
125
+ method: "POST",
126
+ headers: {
127
+ ...MCP_POST_HEADERS,
128
+ Authorization: `Bearer ${BOGUS_TOKEN}`,
129
+ },
130
+ body: JSON.stringify({
131
+ jsonrpc: "2.0",
132
+ id: 1,
133
+ method: "initialize",
134
+ params: {
135
+ protocolVersion: "2025-03-26",
136
+ capabilities: {},
137
+ clientInfo: { name: "docker-test", version: "1.0.0" },
138
+ },
139
+ }),
140
+ });
141
+
142
+ expect(res.status).toBe(401);
143
+ expect(res.headers.get("mcp-session-id")).toBeNull();
144
+ });
145
+
146
+ it("rejects a session id presented without a bearer token", async () => {
147
+ if (!containerRunning || !BEARER_TOKEN) return;
148
+
149
+ const initRes = await fetch(`http://localhost:${HOST_PORT}/mcp`, {
150
+ method: "POST",
151
+ headers: {
152
+ ...MCP_POST_HEADERS,
153
+ Authorization: `Bearer ${BEARER_TOKEN}`,
154
+ },
155
+ body: JSON.stringify({
156
+ jsonrpc: "2.0",
157
+ id: 1,
158
+ method: "initialize",
159
+ params: {
160
+ protocolVersion: "2025-03-26",
161
+ capabilities: {},
162
+ clientInfo: { name: "docker-test", version: "1.0.0" },
163
+ },
164
+ }),
165
+ });
166
+ const sessionId = initRes.headers.get("mcp-session-id");
167
+ expect(sessionId).toBeTruthy();
168
+ await initRes.text();
169
+
170
+ const res = await fetch(`http://localhost:${HOST_PORT}/mcp`, {
171
+ method: "POST",
172
+ headers: { ...MCP_POST_HEADERS, "mcp-session-id": sessionId! },
173
+ body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }),
174
+ });
175
+
176
+ expect(res.status).toBe(401);
93
177
  });
94
178
 
95
179
  it("returns 401 on initialize without Bearer token", async () => {
@@ -116,7 +200,7 @@ describe("Docker container", () => {
116
200
  });
117
201
 
118
202
  it("MCP initialize works with Bearer token", async () => {
119
- if (!containerRunning) return;
203
+ if (!containerRunning || !BEARER_TOKEN) return;
120
204
 
121
205
  const res = await fetch(`http://localhost:${HOST_PORT}/mcp`, {
122
206
  method: "POST",
@@ -146,7 +230,7 @@ describe("Docker container", () => {
146
230
  });
147
231
 
148
232
  it("lists tools after initialization with Bearer token", async () => {
149
- if (!containerRunning) return;
233
+ if (!containerRunning || !BEARER_TOKEN) return;
150
234
 
151
235
  // Initialize a session
152
236
  const initRes = await fetch(`http://localhost:${HOST_PORT}/mcp`, {
@@ -205,7 +289,7 @@ describe("Docker container", () => {
205
289
  });
206
290
 
207
291
  it("legacy SSE endpoint responds with Bearer token", async () => {
208
- if (!containerRunning) return;
292
+ if (!containerRunning || !BEARER_TOKEN) return;
209
293
 
210
294
  const controller = new AbortController();
211
295
  const timeout = setTimeout(() => controller.abort(), 5000);
@@ -3,10 +3,13 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
3
3
 
4
4
  import { initConfig } from "../config.js";
5
5
  import { fetchDocumentation, resolveDocumentationUrl } from "../documentation-fetch.js";
6
+ import { countingVerifier } from "./support/http-server-harness.js";
7
+
8
+ const TEST_API_KEY = "security-regression-test";
6
9
 
7
10
  const MCP_HEADERS = {
8
11
  Accept: "application/json, text/event-stream",
9
- Authorization: "Bearer security-regression-test",
12
+ Authorization: `Bearer ${TEST_API_KEY}`,
10
13
  "Content-Type": "application/json",
11
14
  };
12
15
 
@@ -95,7 +98,10 @@ describe("MCP documentation fetch security", () => {
95
98
 
96
99
  initConfig({ endpoint: "https://app.langwatch.ai" });
97
100
  const { startHttpServer } = await import("../http-server.js");
98
- const started = await startHttpServer({ port: 0 });
101
+ // These cases are about SSRF in the documentation tools, so the key check
102
+ // is stubbed to keep the test on its own subject.
103
+ const { verifier } = countingVerifier([TEST_API_KEY]);
104
+ const started = await startHttpServer({ port: 0, apiKeyVerifier: verifier });
99
105
  mcpServer = started.server;
100
106
  mcpPort = started.port;
101
107
 
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Drift guard between the MCP tools this server registers and the names
3
+ * `feature-map.json` tells agents to call.
4
+ *
5
+ * The feature map is the canonical information architecture: it is embedded
6
+ * into the CLI at codegen time and shipped inside the npx server package, so a
7
+ * name in it is a name an agent will try. Two entries pointed at
8
+ * `platform_run_evaluation` and `platform_evaluation_status`, which have never
9
+ * existed — the tools are `platform_run_experiment` and
10
+ * `platform_experiment_status`. An agent following the map for the experiments
11
+ * feature called two tools that were not there.
12
+ *
13
+ * Nothing compared the two lists, which is why it went unnoticed. This does.
14
+ *
15
+ * @see .claude/skills/feature-map/SKILL.md
16
+ */
17
+ import { readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { describe, expect, it } from "vitest";
20
+
21
+ const REPO_ROOT = join(__dirname, "../../../..");
22
+
23
+ /**
24
+ * Every tool name the feature map hands to an agent, from any depth: `mcp`
25
+ * arrays hang off surfaces nested several levels into the tree.
26
+ */
27
+ function toolNamesInFeatureMap(): Set<string> {
28
+ const map: unknown = JSON.parse(
29
+ readFileSync(join(REPO_ROOT, "feature-map.json"), "utf-8")
30
+ );
31
+ const names = new Set<string>();
32
+
33
+ const walk = (node: unknown): void => {
34
+ if (Array.isArray(node)) {
35
+ for (const item of node) walk(item);
36
+ return;
37
+ }
38
+ if (node === null || typeof node !== "object") return;
39
+
40
+ const entry = node as Record<string, unknown>;
41
+ if (Array.isArray(entry.mcp)) {
42
+ for (const name of entry.mcp) {
43
+ if (typeof name === "string") names.add(name);
44
+ }
45
+ }
46
+ for (const value of Object.values(entry)) walk(value);
47
+ };
48
+
49
+ walk(map);
50
+ return names;
51
+ }
52
+
53
+ /**
54
+ * Every name passed as the first argument of a `server.tool(` registration.
55
+ *
56
+ * Read from the source rather than by constructing a server: building one
57
+ * needs config and credentials this test has no business holding, and the
58
+ * registration is a string literal in every case.
59
+ */
60
+ function registeredToolNames(): Set<string> {
61
+ const source = readFileSync(
62
+ join(__dirname, "../create-mcp-server.ts"),
63
+ "utf-8"
64
+ );
65
+ const names = new Set<string>();
66
+ for (const match of source.matchAll(/server\.tool\(\s*"([^"]+)"/g)) {
67
+ names.add(match[1]!);
68
+ }
69
+ return names;
70
+ }
71
+
72
+ describe("feature map MCP tool names", () => {
73
+ describe("given the tools this server registers", () => {
74
+ it("names only tools that exist", () => {
75
+ const registered = registeredToolNames();
76
+ const missing = [...toolNamesInFeatureMap()]
77
+ .filter((name) => !registered.has(name))
78
+ .sort();
79
+
80
+ // Listed, not registered: an agent following the map calls a tool that
81
+ // is not there. Either rename the entry, or register the tool.
82
+ expect(missing).toEqual([]);
83
+ });
84
+
85
+ it("finds the registrations it is reading", () => {
86
+ // A regex that silently matched nothing would make the check above pass
87
+ // for the wrong reason.
88
+ const registered = registeredToolNames();
89
+ expect(registered.size).toBeGreaterThan(50);
90
+ expect(registered).toContain("platform_run_experiment");
91
+ });
92
+ });
93
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ DEFAULT_BIND_HOST,
5
+ isLoopbackHost,
6
+ isOriginAllowed,
7
+ parseAllowedOrigins,
8
+ } from "../http-security.js";
9
+
10
+ describe("bind host", () => {
11
+ it("defaults to loopback", () => {
12
+ expect(DEFAULT_BIND_HOST).toBe("127.0.0.1");
13
+ expect(isLoopbackHost(DEFAULT_BIND_HOST)).toBe(true);
14
+ });
15
+
16
+ it.each(["127.0.0.1", "localhost", "::1", "[::1]", " LOCALHOST "])(
17
+ "recognises %s as loopback",
18
+ (host) => {
19
+ expect(isLoopbackHost(host)).toBe(true);
20
+ }
21
+ );
22
+
23
+ it.each(["0.0.0.0", "::", "192.168.1.10", "10.0.0.1", "example.com"])(
24
+ "does not treat %s as loopback",
25
+ (host) => {
26
+ expect(isLoopbackHost(host)).toBe(false);
27
+ }
28
+ );
29
+ });
30
+
31
+ describe("parseAllowedOrigins", () => {
32
+ it("returns an empty list when nothing is configured", () => {
33
+ expect(parseAllowedOrigins(undefined)).toEqual([]);
34
+ expect(parseAllowedOrigins("")).toEqual([]);
35
+ });
36
+
37
+ it("splits, trims, and normalizes entries", () => {
38
+ expect(
39
+ parseAllowedOrigins(" https://Example.com/ , https://app.test:8443 ")
40
+ ).toEqual(["https://example.com", "https://app.test:8443"]);
41
+ });
42
+
43
+ it("drops entries that are not usable origins", () => {
44
+ expect(parseAllowedOrigins("not-an-origin, https://ok.test")).toEqual([
45
+ "https://ok.test",
46
+ ]);
47
+ });
48
+
49
+ it("deduplicates equivalent origins", () => {
50
+ expect(parseAllowedOrigins("https://a.test,https://a.test/")).toEqual([
51
+ "https://a.test",
52
+ ]);
53
+ });
54
+ });
55
+
56
+ describe("isOriginAllowed", () => {
57
+ it.each([
58
+ "http://localhost:5173",
59
+ "http://127.0.0.1:3000",
60
+ "http://[::1]:3000",
61
+ "https://localhost",
62
+ ])("always allows the loopback origin %s", (origin) => {
63
+ expect(isOriginAllowed({ origin, allowedOrigins: [] })).toBe(true);
64
+ });
65
+
66
+ it("rejects an unlisted remote origin", () => {
67
+ expect(isOriginAllowed({ origin: "https://evil.test", allowedOrigins: [] })).toBe(false);
68
+ });
69
+
70
+ it("allows a configured remote origin", () => {
71
+ expect(isOriginAllowed({ origin: "https://app.test", allowedOrigins: ["https://app.test"] })).toBe(
72
+ true
73
+ );
74
+ });
75
+
76
+ it("matches on scheme, host and port together", () => {
77
+ const allowlist = ["https://app.test"];
78
+ expect(isOriginAllowed({ origin: "http://app.test", allowedOrigins: allowlist })).toBe(false);
79
+ expect(isOriginAllowed({ origin: "https://app.test:8443", allowedOrigins: allowlist })).toBe(false);
80
+ expect(isOriginAllowed({ origin: "https://other.test", allowedOrigins: allowlist })).toBe(false);
81
+ });
82
+
83
+ it("does not let a hostname that merely ends in localhost through", () => {
84
+ expect(isOriginAllowed({ origin: "https://notlocalhost", allowedOrigins: [] })).toBe(false);
85
+ expect(isOriginAllowed({ origin: "https://evil-localhost.test", allowedOrigins: [] })).toBe(false);
86
+ });
87
+
88
+ it("rejects the opaque null origin", () => {
89
+ expect(isOriginAllowed({ origin: "null", allowedOrigins: [] })).toBe(false);
90
+ });
91
+
92
+ it("treats a rebound attacker hostname as unlisted", () => {
93
+ // DNS rebinding points an attacker hostname at loopback, but the browser
94
+ // still sends the attacker hostname as the origin.
95
+ expect(isOriginAllowed({ origin: "http://rebind.attacker.test", allowedOrigins: [] })).toBe(false);
96
+ });
97
+ });
@@ -0,0 +1,209 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { admitOAuthToken, createSessionStore } from "../http-security.js";
4
+
5
+ describe("createSessionStore", () => {
6
+ function makeStore(maxAgeMs = 60_000) {
7
+ const closed: string[] = [];
8
+ const store = createSessionStore<{ id: string }>({
9
+ maxAgeMs,
10
+ closeTransport: (transport) => closed.push(transport.id),
11
+ });
12
+ return { store, closed };
13
+ }
14
+
15
+ it("stores and returns sessions bound to their key", () => {
16
+ const { store } = makeStore();
17
+ store.add({ sessionId: "s1", transport: { id: "s1" }, apiKey: "sk-a" });
18
+
19
+ expect(store.get("s1")?.apiKey).toBe("sk-a");
20
+ expect(store.size).toBe(1);
21
+ });
22
+
23
+ it("counts live sessions per key without holding the raw key", () => {
24
+ const { store } = makeStore();
25
+ store.add({ sessionId: "s1", transport: { id: "s1" }, apiKey: "sk-a" });
26
+ store.add({ sessionId: "s2", transport: { id: "s2" }, apiKey: "sk-a" });
27
+ store.add({ sessionId: "s3", transport: { id: "s3" }, apiKey: "sk-b" });
28
+
29
+ expect(store.countForKey("sk-a")).toBe(2);
30
+ expect(store.countForKey("sk-b")).toBe(1);
31
+ expect(store.countForKey("sk-unknown")).toBe(0);
32
+ });
33
+
34
+ it("decrements the per-key count when a session is removed", () => {
35
+ const { store } = makeStore();
36
+ store.add({ sessionId: "s1", transport: { id: "s1" }, apiKey: "sk-a" });
37
+ store.add({ sessionId: "s2", transport: { id: "s2" }, apiKey: "sk-a" });
38
+
39
+ store.remove("s1");
40
+ expect(store.countForKey("sk-a")).toBe(1);
41
+
42
+ store.remove("s2");
43
+ expect(store.countForKey("sk-a")).toBe(0);
44
+ });
45
+
46
+ it("ignores removal of a session it does not hold", () => {
47
+ const { store } = makeStore();
48
+ store.add({ sessionId: "s1", transport: { id: "s1" }, apiKey: "sk-a" });
49
+
50
+ store.remove("never-existed");
51
+ store.remove("never-existed");
52
+
53
+ expect(store.countForKey("sk-a")).toBe(1);
54
+ });
55
+
56
+ it("closes and forgets sessions that have gone idle", () => {
57
+ vi.useFakeTimers();
58
+ try {
59
+ const { store, closed } = makeStore(1_000);
60
+ store.add({ sessionId: "idle", transport: { id: "idle" }, apiKey: "sk-a" });
61
+ store.add({ sessionId: "busy", transport: { id: "busy" }, apiKey: "sk-a" });
62
+
63
+ vi.advanceTimersByTime(900);
64
+ store.touch("busy");
65
+ vi.advanceTimersByTime(900);
66
+ store.sweep();
67
+
68
+ expect(closed).toEqual(["idle"]);
69
+ expect(store.get("idle")).toBeUndefined();
70
+ expect(store.get("busy")).toBeDefined();
71
+ expect(store.countForKey("sk-a")).toBe(1);
72
+ } finally {
73
+ vi.useRealTimers();
74
+ }
75
+ });
76
+
77
+ it("closes everything on shutdown", () => {
78
+ const { store, closed } = makeStore();
79
+ store.add({ sessionId: "s1", transport: { id: "s1" }, apiKey: "sk-a" });
80
+ store.add({ sessionId: "s2", transport: { id: "s2" }, apiKey: "sk-b" });
81
+
82
+ store.closeAll();
83
+
84
+ expect(closed.sort()).toEqual(["s1", "s2"]);
85
+ expect(store.size).toBe(0);
86
+ expect(store.countForKey("sk-a")).toBe(0);
87
+ });
88
+
89
+ it("does not let a prototype-shaped session id reach the prototype chain", () => {
90
+ const { store } = makeStore();
91
+
92
+ expect(store.get("__proto__")).toBeUndefined();
93
+ expect(store.get("constructor")).toBeUndefined();
94
+
95
+ store.add({ sessionId: "__proto__", transport: { id: "weird" }, apiKey: "sk-a" });
96
+ expect(store.get("__proto__")?.transport.id).toBe("weird");
97
+ expect(store.size).toBe(1);
98
+ });
99
+ });
100
+
101
+ describe("session reservations", () => {
102
+ function makeStore() {
103
+ return createSessionStore<{ id: string }>({
104
+ maxAgeMs: 60_000,
105
+ closeTransport: () => undefined,
106
+ });
107
+ }
108
+
109
+ it("counts a reserved slot before any session exists", () => {
110
+ const store = makeStore();
111
+ store.reserve("sk-a");
112
+
113
+ // The point of the reservation: the cap sees the slot while the session is
114
+ // still being set up.
115
+ expect(store.countForKey("sk-a")).toBe(1);
116
+ expect(store.size).toBe(0);
117
+ });
118
+
119
+ it("hands the reserved slot to the session rather than counting twice", () => {
120
+ const store = makeStore();
121
+ store.reserve("sk-a").commit({ sessionId: "s1", transport: { id: "s1" } });
122
+
123
+ expect(store.countForKey("sk-a")).toBe(1);
124
+ expect(store.get("s1")?.apiKey).toBe("sk-a");
125
+ });
126
+
127
+ it("returns the slot when the session never came up", () => {
128
+ const store = makeStore();
129
+ const reservation = store.reserve("sk-a");
130
+ reservation.release();
131
+
132
+ expect(store.countForKey("sk-a")).toBe(0);
133
+ });
134
+
135
+ it("ignores a release once the slot was committed", () => {
136
+ const store = makeStore();
137
+ const reservation = store.reserve("sk-a");
138
+ reservation.commit({ sessionId: "s1", transport: { id: "s1" } });
139
+ reservation.release();
140
+
141
+ expect(store.countForKey("sk-a")).toBe(1);
142
+ expect(store.get("s1")).toBeDefined();
143
+ });
144
+
145
+ it("frees the slot when the committed session is removed", () => {
146
+ const store = makeStore();
147
+ store.reserve("sk-a").commit({ sessionId: "s1", transport: { id: "s1" } });
148
+ store.remove("s1");
149
+
150
+ expect(store.countForKey("sk-a")).toBe(0);
151
+ });
152
+ });
153
+
154
+ describe("admitOAuthToken", () => {
155
+ function entry(apiKey: string, expiresAt: number) {
156
+ return { apiKey, expiresAt };
157
+ }
158
+
159
+ it("evicts the oldest token for a key that is at the cap", () => {
160
+ const tokens = new Map([
161
+ ["t1", entry("sk-a", Date.now() + 1_000)],
162
+ ["t2", entry("sk-a", Date.now() + 2_000)],
163
+ ["t3", entry("sk-a", Date.now() + 3_000)],
164
+ ]);
165
+
166
+ admitOAuthToken({ apiKey: "sk-a", tokens, maxPerKey: 3 });
167
+
168
+ // Room for the caller's new token, and the longest-standing one went.
169
+ expect(tokens.has("t1")).toBe(false);
170
+ expect([...tokens.keys()]).toEqual(["t2", "t3"]);
171
+ });
172
+
173
+ it("leaves other keys alone", () => {
174
+ const tokens = new Map([
175
+ ["t1", entry("sk-a", Date.now() + 1_000)],
176
+ ["t2", entry("sk-b", Date.now() + 1_000)],
177
+ ["t3", entry("sk-b", Date.now() + 2_000)],
178
+ ]);
179
+
180
+ admitOAuthToken({ apiKey: "sk-a", tokens, maxPerKey: 1 });
181
+
182
+ expect(tokens.has("t1")).toBe(false);
183
+ expect(tokens.has("t2")).toBe(true);
184
+ expect(tokens.has("t3")).toBe(true);
185
+ });
186
+
187
+ it("keeps a key from growing the map without bound", () => {
188
+ const tokens = new Map<string, { apiKey: string; expiresAt: number }>();
189
+
190
+ for (let i = 0; i < 50; i += 1) {
191
+ admitOAuthToken({ apiKey: "sk-a", tokens, maxPerKey: 10 });
192
+ tokens.set(`t${i}`, entry("sk-a", Date.now() + 60_000 + i));
193
+ }
194
+
195
+ expect(tokens.size).toBe(10);
196
+ });
197
+
198
+ it("drops expired tokens for any key on the way past", () => {
199
+ const tokens = new Map([
200
+ ["stale", entry("sk-b", Date.now() - 1)],
201
+ ["live", entry("sk-b", Date.now() + 60_000)],
202
+ ]);
203
+
204
+ admitOAuthToken({ apiKey: "sk-a", tokens, maxPerKey: 10 });
205
+
206
+ expect(tokens.has("stale")).toBe(false);
207
+ expect(tokens.has("live")).toBe(true);
208
+ });
209
+ });