@langwatch/mcp-server 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.python-version +1 -0
  2. package/CHANGELOG.md +21 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.md +55 -1
  5. package/dist/{archive-project-MK3673A7.js → archive-project-4EQGLBJJ.js} +5 -5
  6. package/dist/{archive-scenario-KATSXRIN.js → archive-scenario-W6BXYDZ3.js} +5 -5
  7. package/dist/{archive-suite-C3KX6IIZ.js → archive-suite-4T6BSRS7.js} +5 -5
  8. package/dist/{assign-prompt-tag-E3C5W6PI.js → assign-prompt-tag-VLXUWEKU.js} +4 -4
  9. package/dist/{chunk-VYPSKRPN.js → chunk-4QC7SA2L.js} +91 -91
  10. package/dist/{chunk-T7WJVS2N.js → chunk-5CCZFUXS.js} +2 -2
  11. package/dist/{chunk-J45SVWPR.js → chunk-77BNWQ3U.js} +2 -2
  12. package/dist/{chunk-XY4STOBE.js → chunk-7RHG56OH.js} +2 -2
  13. package/dist/{chunk-5UWY24RP.js → chunk-A2LEVYQ5.js} +2 -2
  14. package/dist/{chunk-UXXA4D6K.js → chunk-AQJO2EI5.js} +2 -2
  15. package/dist/{chunk-EU22L52K.js → chunk-CDYQOY5U.js} +2 -2
  16. package/dist/{chunk-O3LWVN67.js → chunk-J42FWRJ2.js} +2 -2
  17. package/dist/{chunk-BJ7B4R26.js → chunk-SAS5OB7F.js} +18 -2
  18. package/dist/{chunk-DAIQKS27.js → chunk-SFB4MU6H.js} +2 -2
  19. package/dist/{chunk-WWMDHZZV.js → chunk-TJCXFNDT.js} +2 -2
  20. package/dist/{chunk-CE6QMZOP.js → chunk-TOXJQDIZ.js} +4 -4
  21. package/dist/chunk-UJPXFA26.js +267 -0
  22. package/dist/{chunk-KOO5733S.js → chunk-WT4PO4A3.js} +8 -2
  23. package/dist/{chunk-RYRB2YVJ.js → chunk-YX3A3CFM.js} +2 -2
  24. package/dist/{chunk-WDH5P5RF.js → chunk-YZLYHLLI.js} +2 -2
  25. package/dist/{chunk-Y3HDZ6OW.js → chunk-ZWIL4EFL.js} +2 -2
  26. package/dist/config.js +2 -2
  27. package/dist/{create-agent-7W57J7MT.js → create-agent-7XHO6UIS.js} +5 -5
  28. package/dist/{create-annotation-W2XELBCQ.js → create-annotation-YQXRQC6A.js} +5 -5
  29. package/dist/{create-api-key-MKRXKFJM.js → create-api-key-YUS36KBU.js} +5 -5
  30. package/dist/{create-dashboard-QGL7IODC.js → create-dashboard-4Z5JO5LB.js} +5 -5
  31. package/dist/{create-dataset-3PH7A4HG.js → create-dataset-E6NMSVX7.js} +5 -5
  32. package/dist/{create-dataset-records-4DZS3VRL.js → create-dataset-records-SKHPRD2K.js} +5 -5
  33. package/dist/{create-evaluator-YWH6NC4M.js → create-evaluator-JXF5PF33.js} +5 -5
  34. package/dist/create-mcp-server.js +4 -4
  35. package/dist/{create-project-BOBQ32C5.js → create-project-CS2CB57Y.js} +5 -5
  36. package/dist/{create-prompt-FLCFGITE.js → create-prompt-F5RMX3L3.js} +4 -4
  37. package/dist/{create-prompt-tag-EMPNIKEA.js → create-prompt-tag-GMRWTEFR.js} +4 -4
  38. package/dist/{create-scenario-BSBIHUL6.js → create-scenario-JDR2VB2H.js} +5 -5
  39. package/dist/{create-suite-TWF6BBR4.js → create-suite-LZS6QIAF.js} +5 -5
  40. package/dist/{delete-agent-7MSYNY5E.js → delete-agent-O3EEH4S5.js} +5 -5
  41. package/dist/{delete-annotation-ZT5IGN24.js → delete-annotation-XP6FQM7A.js} +5 -5
  42. package/dist/{delete-dashboard-BTIY5S3L.js → delete-dashboard-RFUNYCNP.js} +5 -5
  43. package/dist/{delete-dataset-U45HQHPR.js → delete-dataset-3MJFRDLP.js} +5 -5
  44. package/dist/{delete-dataset-records-R43IMAWB.js → delete-dataset-records-WPMU5GL5.js} +5 -5
  45. package/dist/{delete-evaluator-U6JMQ6C7.js → delete-evaluator-33SP2JFX.js} +5 -5
  46. package/dist/{delete-prompt-tag-4EV4TDHB.js → delete-prompt-tag-KNMZVSPB.js} +4 -4
  47. package/dist/{delete-workflow-5QYALXEL.js → delete-workflow-3NUXO46U.js} +5 -5
  48. package/dist/{discover-evaluator-schema-MRGHVNTT.js → discover-evaluator-schema-XTAD4X7D.js} +52 -36
  49. package/dist/{discover-scenario-schema-UPBTCWPG.js → discover-scenario-schema-FE64QK2D.js} +2 -2
  50. package/dist/{discover-schema-ICGXPHUW.js → discover-schema-L6QDPON3.js} +2 -2
  51. package/dist/{get-agent-7QJVPH72.js → get-agent-Y7MWZ65F.js} +5 -5
  52. package/dist/{get-analytics-PVERWOA5.js → get-analytics-TETCSOK6.js} +4 -4
  53. package/dist/{get-dashboard-ZZPC3WMD.js → get-dashboard-KHBEGZGL.js} +5 -5
  54. package/dist/{get-dataset-25TB2LQH.js → get-dataset-RCMU5VAH.js} +5 -5
  55. package/dist/{get-evaluator-3FTRQKEC.js → get-evaluator-ADMKVWYZ.js} +5 -5
  56. package/dist/{get-project-5OAXYC57.js → get-project-RAK7URQJ.js} +5 -5
  57. package/dist/{get-prompt-I753LZKZ.js → get-prompt-OY5B7TRI.js} +4 -4
  58. package/dist/{get-scenario-DSSTR6FI.js → get-scenario-ZTAJJL5T.js} +5 -5
  59. package/dist/{get-simulation-run-RQPMS2MC.js → get-simulation-run-T2ZQWYH2.js} +5 -5
  60. package/dist/{get-suite-GTOCY5PL.js → get-suite-J7JVWO6O.js} +5 -5
  61. package/dist/{get-trace-O5VT3NAC.js → get-trace-K7U234Q4.js} +4 -4
  62. package/dist/{get-workflow-3EZDOEIF.js → get-workflow-L5L2PSK3.js} +5 -5
  63. package/dist/http-security-EU3CHTY7.js +26 -0
  64. package/dist/{http-server-2ACJ3CXW.js → http-server-YXHFPPGA.js} +1033 -693
  65. package/dist/index.js +3066 -2105
  66. package/dist/{langwatch-api-annotations-E65MI3WZ.js → langwatch-api-annotations-FDAS6NQ7.js} +5 -5
  67. package/dist/{langwatch-api-dashboards-QCDECRCY.js → langwatch-api-dashboards-JTVCS7ZJ.js} +5 -5
  68. package/dist/{langwatch-api-monitors-UIO2KTDV.js → langwatch-api-monitors-3J7UIQME.js} +4 -4
  69. package/dist/{langwatch-api-secrets-4T2EKSJ7.js → langwatch-api-secrets-BUBSNCNO.js} +4 -4
  70. package/dist/{langwatch-api-triggers-MPOEKPUY.js → langwatch-api-triggers-EDFUCJN6.js} +4 -4
  71. package/dist/langwatch-api-workflows-NBJX5KOG.js +16 -0
  72. package/dist/{list-agents-TIJTP566.js → list-agents-KFHKGPXV.js} +5 -5
  73. package/dist/{list-annotations-CGX5BT7E.js → list-annotations-ZTVP4EEL.js} +5 -5
  74. package/dist/{list-api-keys-JLWJPFRX.js → list-api-keys-CWD4UQT5.js} +5 -5
  75. package/dist/{list-dashboards-RNIWX3TX.js → list-dashboards-PWG6P6KR.js} +5 -5
  76. package/dist/{list-dataset-records-B5MHZR66.js → list-dataset-records-IOE7LQXS.js} +5 -5
  77. package/dist/{list-datasets-BVCAMQCP.js → list-datasets-ZT4JYUDT.js} +5 -5
  78. package/dist/{list-evaluators-65FBTL2U.js → list-evaluators-SJHC7VBH.js} +5 -5
  79. package/dist/{list-model-providers-GRK3I3VV.js → list-model-providers-PPD2WPD2.js} +5 -5
  80. package/dist/{list-projects-JLMBFMW3.js → list-projects-K7QAIX2I.js} +5 -5
  81. package/dist/{list-prompt-tags-UHNWDGQI.js → list-prompt-tags-FOO6TNHB.js} +4 -4
  82. package/dist/{list-prompts-7254B3U7.js → list-prompts-SC2HAUFG.js} +4 -4
  83. package/dist/{list-scenarios-46YNICKM.js → list-scenarios-F2WYNRIE.js} +5 -5
  84. package/dist/{list-simulation-runs-LGNEFNPN.js → list-simulation-runs-KF5THG5H.js} +5 -5
  85. package/dist/{list-suites-5VK4HXEK.js → list-suites-CRY4VWW6.js} +5 -5
  86. package/dist/{list-workflows-MLQDM6CP.js → list-workflows-QSJWURCK.js} +5 -5
  87. package/dist/{rename-prompt-tag-RHQMNEQL.js → rename-prompt-tag-EKWU5QF3.js} +4 -4
  88. package/dist/{report-issue-DXJ6TPTC.js → report-issue-XYVAPAYJ.js} +7 -7
  89. package/dist/{revoke-api-key-3Y35EMBT.js → revoke-api-key-FTRTZMOF.js} +5 -5
  90. package/dist/{run-agent-RBSIFKRT.js → run-agent-6SM4XNA7.js} +5 -5
  91. package/dist/{run-suite-K3DNQ3SE.js → run-suite-NKZCJ2IN.js} +5 -5
  92. package/dist/{run-workflow-5LVBZYLS.js → run-workflow-QPXC627O.js} +5 -5
  93. package/dist/{search-traces-U4HLOCZ2.js → search-traces-A6HFKPLJ.js} +4 -4
  94. package/dist/{set-model-provider-V3M5CDIY.js → set-model-provider-3WKT63ZN.js} +5 -5
  95. package/dist/{update-agent-47RZIHS7.js → update-agent-SMAEHLXH.js} +5 -5
  96. package/dist/{update-dataset-G6QJJGS5.js → update-dataset-BDUK7TUO.js} +5 -5
  97. package/dist/{update-dataset-record-RMWLSWCS.js → update-dataset-record-4ZSDRBSW.js} +5 -5
  98. package/dist/{update-evaluator-SFRRI5Q3.js → update-evaluator-LS6TIBY7.js} +5 -5
  99. package/dist/{update-project-PI5DWBVK.js → update-project-LKVYXENH.js} +5 -5
  100. package/dist/{update-prompt-RQFTEO7Y.js → update-prompt-ODCRQRN6.js} +4 -4
  101. package/dist/{update-scenario-G3IU2UIB.js → update-scenario-NEXCAQI7.js} +5 -5
  102. package/dist/{update-suite-TRGGLNR7.js → update-suite-SNO3GS7T.js} +5 -5
  103. package/package.json +3 -3
  104. package/pyproject.toml +1 -1
  105. package/src/__tests__/config.unit.test.ts +33 -0
  106. package/src/__tests__/docker.integration.test.ts +91 -7
  107. package/src/__tests__/documentation-fetch-security.integration.test.ts +8 -2
  108. package/src/__tests__/feature-map-tool-drift.unit.test.ts +93 -0
  109. package/src/__tests__/http-security-origin.unit.test.ts +97 -0
  110. package/src/__tests__/http-security-sessions.unit.test.ts +209 -0
  111. package/src/__tests__/http-security-verifier.unit.test.ts +248 -0
  112. package/src/__tests__/http-server-allocation.integration.test.ts +279 -0
  113. package/src/__tests__/http-server-origin.integration.test.ts +169 -0
  114. package/src/__tests__/http-server-session-auth.integration.test.ts +341 -0
  115. package/src/__tests__/http-transport.integration.test.ts +85 -87
  116. package/src/__tests__/support/http-server-harness.ts +138 -0
  117. package/src/config.ts +17 -2
  118. package/src/http-security.ts +492 -0
  119. package/src/http-server.ts +536 -152
  120. package/src/index.ts +49 -2
  121. package/src/tools/discover-evaluator-schema.ts +2 -2
  122. package/tsconfig.json +2 -2
  123. package/uv.lock +2 -2
  124. package/dist/langwatch-api-workflows-5HRZ7HR4.js +0 -16
  125. package/pnpm-workspace.yaml +0 -34
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  updateDatasetRecord
3
- } from "./chunk-J45SVWPR.js";
4
- import "./chunk-5UWY24RP.js";
5
- import "./chunk-KOO5733S.js";
6
- import "./chunk-BJ7B4R26.js";
3
+ } from "./chunk-77BNWQ3U.js";
4
+ import "./chunk-A2LEVYQ5.js";
5
+ import "./chunk-WT4PO4A3.js";
6
+ import "./chunk-SAS5OB7F.js";
7
7
 
8
8
  // src/tools/update-dataset-record.ts
9
9
  async function handleUpdateDatasetRecord(params) {
@@ -17,4 +17,4 @@ async function handleUpdateDatasetRecord(params) {
17
17
  export {
18
18
  handleUpdateDatasetRecord
19
19
  };
20
- //# sourceMappingURL=update-dataset-record-RMWLSWCS.js.map
20
+ //# sourceMappingURL=update-dataset-record-4ZSDRBSW.js.map
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  getEvaluatorType,
3
3
  updateEvaluator
4
- } from "./chunk-EU22L52K.js";
5
- import "./chunk-5UWY24RP.js";
6
- import "./chunk-KOO5733S.js";
7
- import "./chunk-BJ7B4R26.js";
4
+ } from "./chunk-CDYQOY5U.js";
5
+ import "./chunk-A2LEVYQ5.js";
6
+ import "./chunk-WT4PO4A3.js";
7
+ import "./chunk-SAS5OB7F.js";
8
8
 
9
9
  // src/tools/update-evaluator.ts
10
10
  async function handleUpdateEvaluator(params) {
@@ -22,4 +22,4 @@ async function handleUpdateEvaluator(params) {
22
22
  export {
23
23
  handleUpdateEvaluator
24
24
  };
25
- //# sourceMappingURL=update-evaluator-SFRRI5Q3.js.map
25
+ //# sourceMappingURL=update-evaluator-LS6TIBY7.js.map
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  updateProject
3
- } from "./chunk-XY4STOBE.js";
4
- import "./chunk-5UWY24RP.js";
5
- import "./chunk-KOO5733S.js";
6
- import "./chunk-BJ7B4R26.js";
3
+ } from "./chunk-7RHG56OH.js";
4
+ import "./chunk-A2LEVYQ5.js";
5
+ import "./chunk-WT4PO4A3.js";
6
+ import "./chunk-SAS5OB7F.js";
7
7
 
8
8
  // src/tools/update-project.ts
9
9
  async function handleUpdateProject(params) {
@@ -22,4 +22,4 @@ async function handleUpdateProject(params) {
22
22
  export {
23
23
  handleUpdateProject
24
24
  };
25
- //# sourceMappingURL=update-project-PI5DWBVK.js.map
25
+ //# sourceMappingURL=update-project-LKVYXENH.js.map
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  updatePrompt
3
- } from "./chunk-5UWY24RP.js";
4
- import "./chunk-KOO5733S.js";
5
- import "./chunk-BJ7B4R26.js";
3
+ } from "./chunk-A2LEVYQ5.js";
4
+ import "./chunk-WT4PO4A3.js";
5
+ import "./chunk-SAS5OB7F.js";
6
6
 
7
7
  // src/tools/update-prompt.ts
8
8
  async function handleUpdatePrompt(params) {
@@ -22,4 +22,4 @@ async function handleUpdatePrompt(params) {
22
22
  export {
23
23
  handleUpdatePrompt
24
24
  };
25
- //# sourceMappingURL=update-prompt-RQFTEO7Y.js.map
25
+ //# sourceMappingURL=update-prompt-ODCRQRN6.js.map
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  updateScenario
3
- } from "./chunk-O3LWVN67.js";
4
- import "./chunk-5UWY24RP.js";
5
- import "./chunk-KOO5733S.js";
6
- import "./chunk-BJ7B4R26.js";
3
+ } from "./chunk-J42FWRJ2.js";
4
+ import "./chunk-A2LEVYQ5.js";
5
+ import "./chunk-WT4PO4A3.js";
6
+ import "./chunk-SAS5OB7F.js";
7
7
 
8
8
  // src/tools/update-scenario.ts
9
9
  async function handleUpdateScenario(params) {
@@ -25,4 +25,4 @@ async function handleUpdateScenario(params) {
25
25
  export {
26
26
  handleUpdateScenario
27
27
  };
28
- //# sourceMappingURL=update-scenario-G3IU2UIB.js.map
28
+ //# sourceMappingURL=update-scenario-NEXCAQI7.js.map
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  updateSuite
3
- } from "./chunk-WDH5P5RF.js";
4
- import "./chunk-5UWY24RP.js";
5
- import "./chunk-KOO5733S.js";
6
- import "./chunk-BJ7B4R26.js";
3
+ } from "./chunk-YZLYHLLI.js";
4
+ import "./chunk-A2LEVYQ5.js";
5
+ import "./chunk-WT4PO4A3.js";
6
+ import "./chunk-SAS5OB7F.js";
7
7
 
8
8
  // src/tools/update-suite.ts
9
9
  async function handleUpdateSuite(params) {
@@ -37,4 +37,4 @@ async function handleUpdateSuite(params) {
37
37
  export {
38
38
  handleUpdateSuite
39
39
  };
40
- //# sourceMappingURL=update-suite-TRGGLNR7.js.map
40
+ //# sourceMappingURL=update-suite-SNO3GS7T.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@langwatch/mcp-server",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "An MCP server for Langwatch.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -61,9 +61,9 @@
61
61
  "vitest": "^4.1.8",
62
62
  "yargs": "^18.0.0",
63
63
  "zod-validation-error": "^5.0.0",
64
- "@langwatch/handled-error": "0.1.0",
65
64
  "@langwatch/redaction": "0.0.1",
66
- "@langwatch/ssrf": "0.0.1"
65
+ "@langwatch/ssrf": "0.0.1",
66
+ "@langwatch/handled-error": "0.1.0"
67
67
  },
68
68
  "dependencies": {
69
69
  "@modelcontextprotocol/sdk": "^1.29.0",
package/pyproject.toml CHANGED
@@ -25,4 +25,4 @@ constraint-dependencies = [
25
25
  ]
26
26
 
27
27
  [tool.uv.sources]
28
- langwatch = { path = "../python-sdk", editable = true }
28
+ langwatch = { path = "../../sdks/python", editable = true }
@@ -58,6 +58,39 @@ describe("config", () => {
58
58
  });
59
59
  });
60
60
 
61
+ describe("when the endpoint carries a trailing slash", () => {
62
+ it("strips it from a CLI endpoint", () => {
63
+ initConfig({ endpoint: "https://cli.example.com/" });
64
+ expect(getConfig().endpoint).toBe("https://cli.example.com");
65
+ });
66
+
67
+ it("trims whitespace around a CLI endpoint", () => {
68
+ initConfig({ endpoint: " https://cli.example.com/// " });
69
+ expect(getConfig().endpoint).toBe("https://cli.example.com");
70
+ });
71
+
72
+ it("strips repeated slashes from the env var", () => {
73
+ process.env.LANGWATCH_ENDPOINT = "http://localhost:5560///";
74
+ initConfig({});
75
+ expect(getConfig().endpoint).toBe("http://localhost:5560");
76
+ });
77
+
78
+ it("keeps the built request path free of a double slash", () => {
79
+ initConfig({ endpoint: "https://app.langwatch.ai/" });
80
+ expect(`${getConfig().endpoint}/api/bug-reports`).toBe(
81
+ "https://app.langwatch.ai/api/bug-reports",
82
+ );
83
+ });
84
+ });
85
+
86
+ describe("when the endpoint is blank", () => {
87
+ it("falls back to the default rather than an empty base url", () => {
88
+ process.env.LANGWATCH_ENDPOINT = " ";
89
+ initConfig({});
90
+ expect(getConfig().endpoint).toBe("https://app.langwatch.ai");
91
+ });
92
+ });
93
+
61
94
  describe("when no args or env vars are provided", () => {
62
95
  it("defaults endpoint to https://app.langwatch.ai", () => {
63
96
  initConfig({});
@@ -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
+ });