@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
@@ -0,0 +1,248 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ apiKeysMatch,
5
+ createApiKeyVerifier,
6
+ createRateLimiter,
7
+ hashApiKey,
8
+ } from "../http-security.js";
9
+
10
+ describe("apiKeysMatch", () => {
11
+ it("matches identical keys", () => {
12
+ expect(apiKeysMatch({ presentedKey: "sk-abc", expectedKey: "sk-abc" })).toBe(true);
13
+ });
14
+
15
+ it("rejects different keys, including different lengths", () => {
16
+ expect(apiKeysMatch({ presentedKey: "sk-abc", expectedKey: "sk-abd" })).toBe(false);
17
+ expect(apiKeysMatch({ presentedKey: "sk-abc", expectedKey: "sk-abc-longer" })).toBe(false);
18
+ expect(apiKeysMatch({ presentedKey: "", expectedKey: "sk-abc" })).toBe(false);
19
+ });
20
+ });
21
+
22
+ describe("hashApiKey", () => {
23
+ it("never returns the raw key", () => {
24
+ expect(hashApiKey("sk-secret")).not.toContain("sk-secret");
25
+ });
26
+
27
+ it("is stable and distinct per key", () => {
28
+ expect(hashApiKey("a")).toBe(hashApiKey("a"));
29
+ expect(hashApiKey("a")).not.toBe(hashApiKey("b"));
30
+ });
31
+ });
32
+
33
+ describe("createRateLimiter", () => {
34
+ it("blocks only after the limit is exceeded", () => {
35
+ const limiter = createRateLimiter({ windowMs: 60_000, maxRequests: 3 });
36
+
37
+ expect(limiter.isBlocked("1.2.3.4")).toBe(false);
38
+ limiter.track("1.2.3.4");
39
+ limiter.track("1.2.3.4");
40
+ expect(limiter.isBlocked("1.2.3.4")).toBe(false);
41
+ limiter.track("1.2.3.4");
42
+ expect(limiter.isBlocked("1.2.3.4")).toBe(true);
43
+ });
44
+
45
+ it("tracks each IP separately", () => {
46
+ const limiter = createRateLimiter({ windowMs: 60_000, maxRequests: 1 });
47
+
48
+ limiter.track("1.1.1.1");
49
+ expect(limiter.isBlocked("1.1.1.1")).toBe(true);
50
+ expect(limiter.isBlocked("2.2.2.2")).toBe(false);
51
+ });
52
+
53
+ it("forgets an IP once its window has passed", () => {
54
+ vi.useFakeTimers();
55
+ try {
56
+ const limiter = createRateLimiter({ windowMs: 1_000, maxRequests: 1 });
57
+ limiter.track("1.1.1.1");
58
+ expect(limiter.isBlocked("1.1.1.1")).toBe(true);
59
+
60
+ vi.advanceTimersByTime(1_500);
61
+ expect(limiter.isBlocked("1.1.1.1")).toBe(false);
62
+ } finally {
63
+ vi.useRealTimers();
64
+ }
65
+ });
66
+ });
67
+
68
+ function jsonResponse(status: number): Response {
69
+ return new Response(status === 200 ? "{}" : "", { status });
70
+ }
71
+
72
+ /** A fetch double that records the URL and init it was called with. */
73
+ function fetchStub(respond: () => Promise<Response>) {
74
+ return vi.fn(async (_url: string, _init?: RequestInit) => respond());
75
+ }
76
+
77
+ describe("createApiKeyVerifier", () => {
78
+ it("asks the LangWatch identity endpoint with the key in a header", async () => {
79
+ const fetchImpl = fetchStub(async () => jsonResponse(200));
80
+ const verifier = createApiKeyVerifier({
81
+ endpoint: "https://app.langwatch.ai",
82
+ fetchImpl: fetchImpl as unknown as typeof fetch,
83
+ });
84
+
85
+ await expect(verifier.verify("sk-real")).resolves.toBe(true);
86
+
87
+ expect(fetchImpl).toHaveBeenCalledWith(
88
+ "https://app.langwatch.ai/api/me/project",
89
+ expect.objectContaining({
90
+ method: "GET",
91
+ headers: { "X-Auth-Token": "sk-real" },
92
+ })
93
+ );
94
+ });
95
+
96
+ it("never puts the key in the URL", async () => {
97
+ const fetchImpl = fetchStub(async () => jsonResponse(200));
98
+ const verifier = createApiKeyVerifier({
99
+ endpoint: "https://app.langwatch.ai",
100
+ fetchImpl: fetchImpl as unknown as typeof fetch,
101
+ });
102
+
103
+ await verifier.verify("sk-real");
104
+
105
+ expect(fetchImpl.mock.calls[0]?.[0]).not.toContain("sk-real");
106
+ });
107
+
108
+ it.each([401, 403])("rejects a key the API answers %i for", async (status) => {
109
+ const verifier = createApiKeyVerifier({
110
+ endpoint: "https://app.langwatch.ai",
111
+ fetchImpl: (async () => jsonResponse(status)) as unknown as typeof fetch,
112
+ });
113
+
114
+ await expect(verifier.verify("sk-fake")).resolves.toBe(false);
115
+ });
116
+
117
+ it("caches a positive answer instead of asking again", async () => {
118
+ const fetchImpl = vi.fn(async () => jsonResponse(200));
119
+ const verifier = createApiKeyVerifier({
120
+ endpoint: "https://app.langwatch.ai",
121
+ fetchImpl: fetchImpl as unknown as typeof fetch,
122
+ });
123
+
124
+ await verifier.verify("sk-real");
125
+ await verifier.verify("sk-real");
126
+ await verifier.verify("sk-real");
127
+
128
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
129
+ });
130
+
131
+ it("caches a negative answer so key spraying does not reach the API", async () => {
132
+ const fetchImpl = vi.fn(async () => jsonResponse(401));
133
+ const verifier = createApiKeyVerifier({
134
+ endpoint: "https://app.langwatch.ai",
135
+ fetchImpl: fetchImpl as unknown as typeof fetch,
136
+ });
137
+
138
+ await verifier.verify("sk-fake");
139
+ await verifier.verify("sk-fake");
140
+
141
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
142
+ });
143
+
144
+ it("re-asks once the cache entry has expired", async () => {
145
+ vi.useFakeTimers();
146
+ try {
147
+ const fetchImpl = fetchStub(async () => jsonResponse(200));
148
+ const verifier = createApiKeyVerifier({
149
+ endpoint: "https://app.langwatch.ai",
150
+ positiveTtlMs: 1_000,
151
+ fetchImpl: fetchImpl as unknown as typeof fetch,
152
+ });
153
+
154
+ await verifier.verify("sk-real");
155
+ vi.advanceTimersByTime(1_500);
156
+ await verifier.verify("sk-real");
157
+
158
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
159
+ } finally {
160
+ vi.useRealTimers();
161
+ }
162
+ });
163
+
164
+ it("collapses concurrent checks of the same key into one request", async () => {
165
+ const fetchImpl = vi.fn(async () => {
166
+ await new Promise((resolve) => setTimeout(resolve, 10));
167
+ return jsonResponse(200);
168
+ });
169
+ const verifier = createApiKeyVerifier({
170
+ endpoint: "https://app.langwatch.ai",
171
+ fetchImpl: fetchImpl as unknown as typeof fetch,
172
+ });
173
+
174
+ const results = await Promise.all(
175
+ Array.from({ length: 25 }, () => verifier.verify("sk-real"))
176
+ );
177
+
178
+ expect(results.every((result) => result === true)).toBe(true);
179
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
180
+ });
181
+
182
+ it("fails closed when the API cannot answer, without caching the failure", async () => {
183
+ let status = 503;
184
+ const fetchImpl = vi.fn(async () => jsonResponse(status));
185
+ const verifier = createApiKeyVerifier({
186
+ endpoint: "https://app.langwatch.ai",
187
+ fetchImpl: fetchImpl as unknown as typeof fetch,
188
+ });
189
+
190
+ await expect(verifier.verify("sk-real")).resolves.toBe(false);
191
+
192
+ status = 200;
193
+ await expect(verifier.verify("sk-real")).resolves.toBe(true);
194
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
195
+ });
196
+
197
+ it("gives up on an upstream that never answers", async () => {
198
+ // Without a timeout the in-flight promise never settles and every request
199
+ // for this key parks behind it until the socket dies.
200
+ const verifier = createApiKeyVerifier({
201
+ endpoint: "https://app.langwatch.ai",
202
+ requestTimeoutMs: 20,
203
+ fetchImpl: ((_url: string, init?: RequestInit) =>
204
+ new Promise((_resolve, reject) => {
205
+ init?.signal?.addEventListener("abort", () =>
206
+ reject(new Error("aborted"))
207
+ );
208
+ })) as unknown as typeof fetch,
209
+ });
210
+
211
+ await expect(verifier.verify("sk-real")).resolves.toBe(false);
212
+ });
213
+
214
+ it("fails closed when the request throws", async () => {
215
+ const verifier = createApiKeyVerifier({
216
+ endpoint: "https://app.langwatch.ai",
217
+ fetchImpl: (async () => {
218
+ throw new Error("connection refused");
219
+ }) as unknown as typeof fetch,
220
+ });
221
+
222
+ await expect(verifier.verify("sk-real")).resolves.toBe(false);
223
+ });
224
+
225
+ it("evicts old entries rather than growing once maxEntries is reached", async () => {
226
+ const fetchImpl = fetchStub(async () => jsonResponse(401));
227
+ const verifier = createApiKeyVerifier({
228
+ endpoint: "https://app.langwatch.ai",
229
+ maxEntries: 5,
230
+ negativeTtlMs: 60_000,
231
+ fetchImpl: fetchImpl as unknown as typeof fetch,
232
+ });
233
+
234
+ for (let i = 0; i < 20; i++) {
235
+ await verifier.verify(`sk-spray-${i}`);
236
+ }
237
+ expect(fetchImpl).toHaveBeenCalledTimes(20);
238
+
239
+ // The newest key is still cached, so it costs nothing to re-check.
240
+ await verifier.verify("sk-spray-19");
241
+ expect(fetchImpl).toHaveBeenCalledTimes(20);
242
+
243
+ // The oldest was evicted to keep the map bounded, so it has to be asked
244
+ // again even though its negative TTL has not passed.
245
+ await verifier.verify("sk-spray-0");
246
+ expect(fetchImpl).toHaveBeenCalledTimes(21);
247
+ });
248
+ });
@@ -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
+ });