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