@langwatch/mcp-server 0.10.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/.python-version +1 -0
  2. package/.release-please-shim +1 -1
  3. package/CHANGELOG.md +75 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +64 -18
  6. package/dist/{archive-project-KAZFHR4A.js → archive-project-4EQGLBJJ.js} +5 -4
  7. package/dist/{archive-scenario-6M2IB7Y2.js → archive-scenario-W6BXYDZ3.js} +5 -4
  8. package/dist/{archive-suite-NXZ3YDS3.js → archive-suite-4T6BSRS7.js} +5 -4
  9. package/dist/{assign-prompt-tag-WPUJO55U.js → assign-prompt-tag-VLXUWEKU.js} +4 -3
  10. package/dist/chunk-37SMPMQS.js +12 -0
  11. package/dist/{chunk-HRFQAFPR.js → chunk-4QC7SA2L.js} +221 -134
  12. package/dist/{chunk-XRQNXN2Z.js → chunk-5CCZFUXS.js} +2 -2
  13. package/dist/{chunk-ITN2QGHF.js → chunk-77BNWQ3U.js} +2 -2
  14. package/dist/{chunk-B6UV3JVI.js → chunk-7RHG56OH.js} +2 -2
  15. package/dist/{chunk-CAY57CKF.js → chunk-A2LEVYQ5.js} +78 -5
  16. package/dist/{chunk-U2QIW2SW.js → chunk-AQJO2EI5.js} +2 -2
  17. package/dist/{chunk-GWEW5XON.js → chunk-CDYQOY5U.js} +2 -2
  18. package/dist/{chunk-LTEJDL4V.js → chunk-J42FWRJ2.js} +2 -2
  19. package/dist/chunk-SAS5OB7F.js +58 -0
  20. package/dist/{chunk-AGGE6NPR.js → chunk-SFB4MU6H.js} +2 -2
  21. package/dist/{chunk-COCNCXVJ.js → chunk-TJCXFNDT.js} +2 -2
  22. package/dist/chunk-TOXJQDIZ.js +432 -0
  23. package/dist/chunk-UJPXFA26.js +267 -0
  24. package/dist/{chunk-KOO5733S.js → chunk-WT4PO4A3.js} +8 -2
  25. package/dist/{chunk-RDUXCCFR.js → chunk-YX3A3CFM.js} +2 -2
  26. package/dist/{chunk-UKRCZJJC.js → chunk-YZLYHLLI.js} +2 -2
  27. package/dist/{chunk-ESGZVPPC.js → chunk-ZWIL4EFL.js} +2 -2
  28. package/dist/config.js +2 -1
  29. package/dist/{create-agent-BI7UTJ6A.js → create-agent-7XHO6UIS.js} +5 -4
  30. package/dist/{create-annotation-SNM2J6S4.js → create-annotation-YQXRQC6A.js} +5 -4
  31. package/dist/{create-api-key-23MXYC2L.js → create-api-key-YUS36KBU.js} +5 -4
  32. package/dist/{create-dashboard-CPOOOIKK.js → create-dashboard-4Z5JO5LB.js} +5 -4
  33. package/dist/{create-dataset-BVXPVVY2.js → create-dataset-E6NMSVX7.js} +5 -4
  34. package/dist/{create-dataset-records-S7VFIIMR.js → create-dataset-records-SKHPRD2K.js} +5 -4
  35. package/dist/{create-evaluator-WJAAQOUE.js → create-evaluator-JXF5PF33.js} +5 -4
  36. package/dist/create-mcp-server.js +4 -3
  37. package/dist/{create-project-BF7LMZH5.js → create-project-CS2CB57Y.js} +5 -4
  38. package/dist/{create-prompt-LZ3QHSU4.js → create-prompt-F5RMX3L3.js} +4 -3
  39. package/dist/{create-prompt-tag-IF4J24QM.js → create-prompt-tag-GMRWTEFR.js} +4 -3
  40. package/dist/{create-scenario-BK4E5DEU.js → create-scenario-JDR2VB2H.js} +5 -4
  41. package/dist/{create-suite-HEQPNLND.js → create-suite-LZS6QIAF.js} +5 -4
  42. package/dist/{delete-agent-Y4T2G5PT.js → delete-agent-O3EEH4S5.js} +5 -4
  43. package/dist/{delete-annotation-KYDMQTZG.js → delete-annotation-XP6FQM7A.js} +5 -4
  44. package/dist/{delete-dashboard-3RW647WJ.js → delete-dashboard-RFUNYCNP.js} +5 -4
  45. package/dist/{delete-dataset-SZAHFHHS.js → delete-dataset-3MJFRDLP.js} +5 -4
  46. package/dist/{delete-dataset-records-ISHKGEBH.js → delete-dataset-records-WPMU5GL5.js} +5 -4
  47. package/dist/{delete-evaluator-VKIG5I3S.js → delete-evaluator-33SP2JFX.js} +5 -4
  48. package/dist/{delete-prompt-tag-VYYL4GTF.js → delete-prompt-tag-KNMZVSPB.js} +4 -3
  49. package/dist/{delete-workflow-SHNPYSWQ.js → delete-workflow-3NUXO46U.js} +5 -4
  50. package/dist/discover-evaluator-schema-XTAD4X7D.js +2191 -0
  51. package/dist/{discover-scenario-schema-MEEEVND7.js → discover-scenario-schema-FE64QK2D.js} +3 -1
  52. package/dist/{discover-schema-3T52ORPB.js → discover-schema-L6QDPON3.js} +3 -1
  53. package/dist/{get-agent-IZSP7AR5.js → get-agent-Y7MWZ65F.js} +5 -4
  54. package/dist/{get-analytics-EDFAYYIQ.js → get-analytics-TETCSOK6.js} +4 -3
  55. package/dist/{get-dashboard-WSAO6IQ4.js → get-dashboard-KHBEGZGL.js} +5 -4
  56. package/dist/{get-dataset-BEVNXQNF.js → get-dataset-RCMU5VAH.js} +5 -4
  57. package/dist/{get-evaluator-HCVXNPZR.js → get-evaluator-ADMKVWYZ.js} +5 -4
  58. package/dist/{get-project-H4V3GYRQ.js → get-project-RAK7URQJ.js} +5 -4
  59. package/dist/{get-prompt-EWRJVVGB.js → get-prompt-OY5B7TRI.js} +4 -3
  60. package/dist/{get-scenario-24YUJS5W.js → get-scenario-ZTAJJL5T.js} +5 -4
  61. package/dist/{get-simulation-run-UX2W6F6I.js → get-simulation-run-T2ZQWYH2.js} +5 -4
  62. package/dist/{get-suite-B65ZKZJA.js → get-suite-J7JVWO6O.js} +5 -4
  63. package/dist/{get-trace-ZR7MIITH.js → get-trace-K7U234Q4.js} +8 -10
  64. package/dist/{get-workflow-YROHUQJW.js → get-workflow-L5L2PSK3.js} +5 -4
  65. package/dist/http-security-EU3CHTY7.js +26 -0
  66. package/dist/http-server-YXHFPPGA.js +24706 -0
  67. package/dist/index.js +23280 -3135
  68. package/dist/{langwatch-api-annotations-OPEXNTPF.js → langwatch-api-annotations-FDAS6NQ7.js} +5 -4
  69. package/dist/{langwatch-api-dashboards-NPQM6QJI.js → langwatch-api-dashboards-JTVCS7ZJ.js} +5 -4
  70. package/dist/{langwatch-api-monitors-3L25OOOS.js → langwatch-api-monitors-3J7UIQME.js} +4 -3
  71. package/dist/{langwatch-api-secrets-ZFURUA62.js → langwatch-api-secrets-BUBSNCNO.js} +4 -3
  72. package/dist/{langwatch-api-triggers-XGXXT7DD.js → langwatch-api-triggers-EDFUCJN6.js} +4 -3
  73. package/dist/langwatch-api-workflows-NBJX5KOG.js +16 -0
  74. package/dist/{list-agents-6ZNOV7FK.js → list-agents-KFHKGPXV.js} +5 -4
  75. package/dist/{list-annotations-6P3IWILH.js → list-annotations-ZTVP4EEL.js} +5 -4
  76. package/dist/{list-api-keys-JJI62KD2.js → list-api-keys-CWD4UQT5.js} +5 -4
  77. package/dist/{list-dashboards-G2WJOXWB.js → list-dashboards-PWG6P6KR.js} +5 -4
  78. package/dist/{list-dataset-records-GWWVOCII.js → list-dataset-records-IOE7LQXS.js} +5 -4
  79. package/dist/{list-datasets-HBTH33X4.js → list-datasets-ZT4JYUDT.js} +5 -4
  80. package/dist/{list-evaluators-K5OGB6TX.js → list-evaluators-SJHC7VBH.js} +5 -4
  81. package/dist/{list-model-providers-JMOGZLSO.js → list-model-providers-PPD2WPD2.js} +5 -4
  82. package/dist/{list-projects-OXJC2NW5.js → list-projects-K7QAIX2I.js} +5 -4
  83. package/dist/{list-prompt-tags-J2HPHVAZ.js → list-prompt-tags-FOO6TNHB.js} +4 -3
  84. package/dist/{list-prompts-C7GR72XL.js → list-prompts-SC2HAUFG.js} +4 -3
  85. package/dist/{list-scenarios-JEPGIIDO.js → list-scenarios-F2WYNRIE.js} +5 -4
  86. package/dist/{list-simulation-runs-DRSAGPEC.js → list-simulation-runs-KF5THG5H.js} +5 -4
  87. package/dist/{list-suites-POTZ3ZT6.js → list-suites-CRY4VWW6.js} +5 -4
  88. package/dist/{list-workflows-QQZQIOHB.js → list-workflows-QSJWURCK.js} +5 -4
  89. package/dist/{rename-prompt-tag-4AAV2BS4.js → rename-prompt-tag-EKWU5QF3.js} +4 -3
  90. package/dist/report-issue-XYVAPAYJ.js +412 -0
  91. package/dist/{revoke-api-key-TONB4ADQ.js → revoke-api-key-FTRTZMOF.js} +5 -4
  92. package/dist/{run-agent-7QE3YEH7.js → run-agent-6SM4XNA7.js} +5 -4
  93. package/dist/{run-suite-Q66OSPO4.js → run-suite-NKZCJ2IN.js} +5 -4
  94. package/dist/{run-workflow-RWX2D752.js → run-workflow-QPXC627O.js} +5 -4
  95. package/dist/{search-traces-M5E5VB4X.js → search-traces-A6HFKPLJ.js} +10 -3
  96. package/dist/{set-model-provider-XENT5D2P.js → set-model-provider-3WKT63ZN.js} +5 -4
  97. package/dist/{update-agent-GML25EE3.js → update-agent-SMAEHLXH.js} +5 -4
  98. package/dist/{update-dataset-R4MM6NNY.js → update-dataset-BDUK7TUO.js} +5 -4
  99. package/dist/{update-dataset-record-FN4BL5P4.js → update-dataset-record-4ZSDRBSW.js} +5 -4
  100. package/dist/{update-evaluator-LH2RDDB5.js → update-evaluator-LS6TIBY7.js} +5 -4
  101. package/dist/{update-project-H53IHRBB.js → update-project-LKVYXENH.js} +5 -4
  102. package/dist/{update-prompt-M33OTTAJ.js → update-prompt-ODCRQRN6.js} +4 -3
  103. package/dist/{update-scenario-FKTQRAFQ.js → update-scenario-NEXCAQI7.js} +5 -4
  104. package/dist/{update-suite-JJTNOTXN.js → update-suite-SNO3GS7T.js} +5 -4
  105. package/package.json +20 -18
  106. package/pyproject.toml +10 -2
  107. package/src/__tests__/all-tools.integration.test.ts +56 -120
  108. package/src/__tests__/config.unit.test.ts +33 -0
  109. package/src/__tests__/docker.integration.test.ts +91 -7
  110. package/src/__tests__/documentation-fetch-security.integration.test.ts +182 -0
  111. package/src/__tests__/feature-map-tool-drift.unit.test.ts +93 -0
  112. package/src/__tests__/http-security-origin.unit.test.ts +97 -0
  113. package/src/__tests__/http-security-sessions.unit.test.ts +209 -0
  114. package/src/__tests__/http-security-verifier.unit.test.ts +248 -0
  115. package/src/__tests__/http-server-allocation.integration.test.ts +279 -0
  116. package/src/__tests__/http-server-origin.integration.test.ts +169 -0
  117. package/src/__tests__/http-server-session-auth.integration.test.ts +341 -0
  118. package/src/__tests__/http-transport.integration.test.ts +85 -87
  119. package/src/__tests__/langwatch-api.unit.test.ts +206 -0
  120. package/src/__tests__/public-http-request-security.integration.test.ts +118 -0
  121. package/src/__tests__/report-issue.integration.test.ts +147 -0
  122. package/src/__tests__/support/http-server-harness.ts +138 -0
  123. package/src/__tests__/tools.unit.test.ts +24 -0
  124. package/src/config.ts +17 -2
  125. package/src/create-mcp-server.ts +43 -29
  126. package/src/documentation-fetch.ts +111 -0
  127. package/src/http-security.ts +492 -0
  128. package/src/http-server.ts +536 -152
  129. package/src/index.ts +49 -2
  130. package/src/langwatch-api-agents.ts +2 -2
  131. package/src/langwatch-api.ts +138 -8
  132. package/src/public-http-request.ts +242 -0
  133. package/src/tools/discover-evaluator-schema.ts +2 -2
  134. package/src/tools/get-trace.ts +2 -12
  135. package/src/tools/report-issue.ts +150 -0
  136. package/src/tools/search-traces.ts +4 -0
  137. package/src/utils/format-evaluations.ts +20 -0
  138. package/tsconfig.json +8 -1
  139. package/uv.lock +261 -207
  140. package/dist/chunk-IUP5IHBD.js +0 -71
  141. package/dist/discover-evaluator-schema-H23XCLNE.js +0 -1402
  142. package/dist/http-server-GXVEDA7G.js +0 -226
  143. package/dist/langwatch-api-workflows-CXCTTCXT.js +0 -15
  144. package/pnpm-workspace.yaml +0 -17
@@ -0,0 +1,182 @@
1
+ import { createServer, type Server } from "node:http";
2
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
3
+
4
+ import { initConfig } from "../config.js";
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";
9
+
10
+ const MCP_HEADERS = {
11
+ Accept: "application/json, text/event-stream",
12
+ Authorization: `Bearer ${TEST_API_KEY}`,
13
+ "Content-Type": "application/json",
14
+ };
15
+
16
+ describe("MCP documentation fetch security", () => {
17
+ describe("URL validation", () => {
18
+ /** @scenario A documentation tool accepts its own trusted HTTPS pages */
19
+ it.each([
20
+ ["langwatch" as const, undefined, "https://langwatch.ai/docs/llms.txt"],
21
+ ["scenario" as const, undefined, "https://langwatch.ai/scenario/llms.txt"],
22
+ ["langwatch" as const, "observability/tracing", "https://langwatch.ai/docs/observability/tracing.md"],
23
+ ["scenario" as const, "/scenario/guides/quickstart", "https://langwatch.ai/scenario/guides/quickstart.md"],
24
+ [
25
+ "langwatch" as const,
26
+ "https://langwatch.ai/docs/llms.txt?format=raw",
27
+ "https://langwatch.ai/docs/llms.txt?format=raw",
28
+ ],
29
+ ])("resolves trusted %s documentation URL %s", (kind, input, expected) => {
30
+ expect(resolveDocumentationUrl(kind, input).toString()).toBe(expected);
31
+ });
32
+
33
+ /** @scenario A documentation tool rejects non-HTTPS and untrusted hosts */
34
+ /** @scenario A documentation tool rejects a trusted host outside its namespace */
35
+ it.each([
36
+ ["langwatch" as const, "http://169.254.169.254/latest/meta-data/"],
37
+ ["langwatch" as const, "https://langwatch.ai.attacker.example/docs/x"],
38
+ ["langwatch" as const, "https://langwatch.ai@127.0.0.1/docs/x"],
39
+ ["langwatch" as const, "https://langwatch.ai:444/docs/x"],
40
+ ["langwatch" as const, "file:///etc/passwd"],
41
+ ["langwatch" as const, "https://langwatch.ai/scenario/llms.txt"],
42
+ ["langwatch" as const, "https://langwatch.ai/docs-evil/page.md"],
43
+ ["scenario" as const, "https://langwatch.ai/docs/llms.txt"],
44
+ ["scenario" as const, "https://langwatch.ai/scenario-evil/page.md"],
45
+ ["langwatch" as const, "https://langwatch.ai/docs/../scenario/llms.txt"],
46
+ ])("rejects untrusted %s documentation URL %s", (kind, input) => {
47
+ expect(() => resolveDocumentationUrl(kind, input)).toThrow(/trusted LangWatch documentation URL/);
48
+ });
49
+ });
50
+
51
+ /** @scenario Documentation fetches do not follow redirects */
52
+ it("disables redirects and bounds trusted documentation fetches", async () => {
53
+ let receivedRedirectMode: RequestRedirect | undefined;
54
+ let receivedSignal: AbortSignal | null | undefined;
55
+ const fetchForTest: typeof fetch = async (_input, init) => {
56
+ receivedRedirectMode = init?.redirect;
57
+ receivedSignal = init?.signal;
58
+ return new Response("# documentation");
59
+ };
60
+
61
+ const text = await fetchDocumentation("langwatch", "https://langwatch.ai/docs/llms.txt", fetchForTest);
62
+
63
+ expect(text).toBe("# documentation");
64
+ expect(receivedRedirectMode).toBe("error");
65
+ expect(receivedSignal).toBeInstanceOf(AbortSignal);
66
+ });
67
+
68
+ it("rejects non-document responses from the trusted origin", async () => {
69
+ const fetchForTest: typeof fetch = async () =>
70
+ new Response("<html>unexpected</html>", {
71
+ headers: { "Content-Type": "text/html" },
72
+ });
73
+
74
+ await expect(fetchDocumentation("langwatch", "https://langwatch.ai/docs/llms.txt", fetchForTest)).rejects.toThrow(
75
+ /unexpected content type/
76
+ );
77
+ });
78
+
79
+ describe("HTTP tool transport", () => {
80
+ let mcpServer: Server;
81
+ let targetServer: Server;
82
+ let mcpPort: number;
83
+ let targetPort: number;
84
+ let sessionId: string;
85
+ let targetHits = 0;
86
+ let rpcId = 10;
87
+
88
+ beforeAll(async () => {
89
+ targetServer = createServer((_request, response) => {
90
+ targetHits += 1;
91
+ response.end("internal-only response");
92
+ });
93
+ await new Promise<void>((resolve) => {
94
+ targetServer.listen(0, "127.0.0.1", resolve);
95
+ });
96
+ const targetAddress = targetServer.address();
97
+ targetPort = typeof targetAddress === "object" && targetAddress ? targetAddress.port : 0;
98
+
99
+ initConfig({ endpoint: "https://app.langwatch.ai" });
100
+ const { startHttpServer } = await import("../http-server.js");
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 });
105
+ mcpServer = started.server;
106
+ mcpPort = started.port;
107
+
108
+ const initializeResponse = await fetch(`http://127.0.0.1:${mcpPort}/mcp`, {
109
+ method: "POST",
110
+ headers: MCP_HEADERS,
111
+ body: JSON.stringify({
112
+ jsonrpc: "2.0",
113
+ id: 1,
114
+ method: "initialize",
115
+ params: {
116
+ protocolVersion: "2025-03-26",
117
+ capabilities: {},
118
+ clientInfo: { name: "security-test", version: "1.0.0" },
119
+ },
120
+ }),
121
+ });
122
+ sessionId = initializeResponse.headers.get("mcp-session-id") ?? "";
123
+ expect(sessionId).not.toBe("");
124
+
125
+ await fetch(`http://127.0.0.1:${mcpPort}/mcp`, {
126
+ method: "POST",
127
+ headers: { ...MCP_HEADERS, "mcp-session-id": sessionId },
128
+ body: JSON.stringify({
129
+ jsonrpc: "2.0",
130
+ method: "notifications/initialized",
131
+ }),
132
+ });
133
+ });
134
+
135
+ afterAll(async () => {
136
+ await Promise.all([
137
+ new Promise<void>((resolve, reject) => {
138
+ mcpServer.close((error) => (error ? reject(error) : resolve()));
139
+ }),
140
+ new Promise<void>((resolve, reject) => {
141
+ targetServer.close((error) => (error ? reject(error) : resolve()));
142
+ }),
143
+ ]);
144
+ });
145
+
146
+ async function callDocumentationTool(
147
+ name: "fetch_langwatch_docs" | "fetch_scenario_docs",
148
+ url: string
149
+ ): Promise<string> {
150
+ const response = await fetch(`http://127.0.0.1:${mcpPort}/mcp`, {
151
+ method: "POST",
152
+ headers: { ...MCP_HEADERS, "mcp-session-id": sessionId },
153
+ body: JSON.stringify({
154
+ jsonrpc: "2.0",
155
+ id: rpcId++,
156
+ method: "tools/call",
157
+ params: { name, arguments: { url } },
158
+ }),
159
+ });
160
+
161
+ expect(response.status).toBe(200);
162
+ return response.text();
163
+ }
164
+
165
+ it.each(["fetch_langwatch_docs" as const, "fetch_scenario_docs" as const])(
166
+ "blocks %s from reaching a loopback HTTP server",
167
+ async (tool) => {
168
+ const hitsBefore = targetHits;
169
+ const body = await callDocumentationTool(tool, `http://127.0.0.1:${targetPort}/secrets`);
170
+
171
+ expect(body).toContain("trusted LangWatch documentation URL");
172
+ expect(targetHits).toBe(hitsBefore);
173
+ }
174
+ );
175
+
176
+ it("blocks cross-namespace fetches through the real MCP handler", async () => {
177
+ const body = await callDocumentationTool("fetch_langwatch_docs", "https://langwatch.ai/scenario/llms.txt");
178
+
179
+ expect(body).toContain("trusted LangWatch documentation URL");
180
+ });
181
+ });
182
+ });
@@ -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
+ });