@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.
- package/.python-version +1 -0
- package/CHANGELOG.md +21 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +55 -1
- package/dist/{archive-project-MK3673A7.js → archive-project-4EQGLBJJ.js} +5 -5
- package/dist/{archive-scenario-KATSXRIN.js → archive-scenario-W6BXYDZ3.js} +5 -5
- package/dist/{archive-suite-C3KX6IIZ.js → archive-suite-4T6BSRS7.js} +5 -5
- package/dist/{assign-prompt-tag-E3C5W6PI.js → assign-prompt-tag-VLXUWEKU.js} +4 -4
- package/dist/{chunk-VYPSKRPN.js → chunk-4QC7SA2L.js} +91 -91
- package/dist/{chunk-T7WJVS2N.js → chunk-5CCZFUXS.js} +2 -2
- package/dist/{chunk-J45SVWPR.js → chunk-77BNWQ3U.js} +2 -2
- package/dist/{chunk-XY4STOBE.js → chunk-7RHG56OH.js} +2 -2
- package/dist/{chunk-5UWY24RP.js → chunk-A2LEVYQ5.js} +2 -2
- package/dist/{chunk-UXXA4D6K.js → chunk-AQJO2EI5.js} +2 -2
- package/dist/{chunk-EU22L52K.js → chunk-CDYQOY5U.js} +2 -2
- package/dist/{chunk-O3LWVN67.js → chunk-J42FWRJ2.js} +2 -2
- package/dist/{chunk-BJ7B4R26.js → chunk-SAS5OB7F.js} +18 -2
- package/dist/{chunk-DAIQKS27.js → chunk-SFB4MU6H.js} +2 -2
- package/dist/{chunk-WWMDHZZV.js → chunk-TJCXFNDT.js} +2 -2
- package/dist/{chunk-CE6QMZOP.js → chunk-TOXJQDIZ.js} +4 -4
- package/dist/chunk-UJPXFA26.js +267 -0
- package/dist/{chunk-KOO5733S.js → chunk-WT4PO4A3.js} +8 -2
- package/dist/{chunk-RYRB2YVJ.js → chunk-YX3A3CFM.js} +2 -2
- package/dist/{chunk-WDH5P5RF.js → chunk-YZLYHLLI.js} +2 -2
- package/dist/{chunk-Y3HDZ6OW.js → chunk-ZWIL4EFL.js} +2 -2
- package/dist/config.js +2 -2
- package/dist/{create-agent-7W57J7MT.js → create-agent-7XHO6UIS.js} +5 -5
- package/dist/{create-annotation-W2XELBCQ.js → create-annotation-YQXRQC6A.js} +5 -5
- package/dist/{create-api-key-MKRXKFJM.js → create-api-key-YUS36KBU.js} +5 -5
- package/dist/{create-dashboard-QGL7IODC.js → create-dashboard-4Z5JO5LB.js} +5 -5
- package/dist/{create-dataset-3PH7A4HG.js → create-dataset-E6NMSVX7.js} +5 -5
- package/dist/{create-dataset-records-4DZS3VRL.js → create-dataset-records-SKHPRD2K.js} +5 -5
- package/dist/{create-evaluator-YWH6NC4M.js → create-evaluator-JXF5PF33.js} +5 -5
- package/dist/create-mcp-server.js +4 -4
- package/dist/{create-project-BOBQ32C5.js → create-project-CS2CB57Y.js} +5 -5
- package/dist/{create-prompt-FLCFGITE.js → create-prompt-F5RMX3L3.js} +4 -4
- package/dist/{create-prompt-tag-EMPNIKEA.js → create-prompt-tag-GMRWTEFR.js} +4 -4
- package/dist/{create-scenario-BSBIHUL6.js → create-scenario-JDR2VB2H.js} +5 -5
- package/dist/{create-suite-TWF6BBR4.js → create-suite-LZS6QIAF.js} +5 -5
- package/dist/{delete-agent-7MSYNY5E.js → delete-agent-O3EEH4S5.js} +5 -5
- package/dist/{delete-annotation-ZT5IGN24.js → delete-annotation-XP6FQM7A.js} +5 -5
- package/dist/{delete-dashboard-BTIY5S3L.js → delete-dashboard-RFUNYCNP.js} +5 -5
- package/dist/{delete-dataset-U45HQHPR.js → delete-dataset-3MJFRDLP.js} +5 -5
- package/dist/{delete-dataset-records-R43IMAWB.js → delete-dataset-records-WPMU5GL5.js} +5 -5
- package/dist/{delete-evaluator-U6JMQ6C7.js → delete-evaluator-33SP2JFX.js} +5 -5
- package/dist/{delete-prompt-tag-4EV4TDHB.js → delete-prompt-tag-KNMZVSPB.js} +4 -4
- package/dist/{delete-workflow-5QYALXEL.js → delete-workflow-3NUXO46U.js} +5 -5
- package/dist/{discover-evaluator-schema-MRGHVNTT.js → discover-evaluator-schema-XTAD4X7D.js} +52 -36
- package/dist/{discover-scenario-schema-UPBTCWPG.js → discover-scenario-schema-FE64QK2D.js} +2 -2
- package/dist/{discover-schema-ICGXPHUW.js → discover-schema-L6QDPON3.js} +2 -2
- package/dist/{get-agent-7QJVPH72.js → get-agent-Y7MWZ65F.js} +5 -5
- package/dist/{get-analytics-PVERWOA5.js → get-analytics-TETCSOK6.js} +4 -4
- package/dist/{get-dashboard-ZZPC3WMD.js → get-dashboard-KHBEGZGL.js} +5 -5
- package/dist/{get-dataset-25TB2LQH.js → get-dataset-RCMU5VAH.js} +5 -5
- package/dist/{get-evaluator-3FTRQKEC.js → get-evaluator-ADMKVWYZ.js} +5 -5
- package/dist/{get-project-5OAXYC57.js → get-project-RAK7URQJ.js} +5 -5
- package/dist/{get-prompt-I753LZKZ.js → get-prompt-OY5B7TRI.js} +4 -4
- package/dist/{get-scenario-DSSTR6FI.js → get-scenario-ZTAJJL5T.js} +5 -5
- package/dist/{get-simulation-run-RQPMS2MC.js → get-simulation-run-T2ZQWYH2.js} +5 -5
- package/dist/{get-suite-GTOCY5PL.js → get-suite-J7JVWO6O.js} +5 -5
- package/dist/{get-trace-O5VT3NAC.js → get-trace-K7U234Q4.js} +4 -4
- package/dist/{get-workflow-3EZDOEIF.js → get-workflow-L5L2PSK3.js} +5 -5
- package/dist/http-security-EU3CHTY7.js +26 -0
- package/dist/{http-server-2ACJ3CXW.js → http-server-YXHFPPGA.js} +1033 -693
- package/dist/index.js +3066 -2105
- package/dist/{langwatch-api-annotations-E65MI3WZ.js → langwatch-api-annotations-FDAS6NQ7.js} +5 -5
- package/dist/{langwatch-api-dashboards-QCDECRCY.js → langwatch-api-dashboards-JTVCS7ZJ.js} +5 -5
- package/dist/{langwatch-api-monitors-UIO2KTDV.js → langwatch-api-monitors-3J7UIQME.js} +4 -4
- package/dist/{langwatch-api-secrets-4T2EKSJ7.js → langwatch-api-secrets-BUBSNCNO.js} +4 -4
- package/dist/{langwatch-api-triggers-MPOEKPUY.js → langwatch-api-triggers-EDFUCJN6.js} +4 -4
- package/dist/langwatch-api-workflows-NBJX5KOG.js +16 -0
- package/dist/{list-agents-TIJTP566.js → list-agents-KFHKGPXV.js} +5 -5
- package/dist/{list-annotations-CGX5BT7E.js → list-annotations-ZTVP4EEL.js} +5 -5
- package/dist/{list-api-keys-JLWJPFRX.js → list-api-keys-CWD4UQT5.js} +5 -5
- package/dist/{list-dashboards-RNIWX3TX.js → list-dashboards-PWG6P6KR.js} +5 -5
- package/dist/{list-dataset-records-B5MHZR66.js → list-dataset-records-IOE7LQXS.js} +5 -5
- package/dist/{list-datasets-BVCAMQCP.js → list-datasets-ZT4JYUDT.js} +5 -5
- package/dist/{list-evaluators-65FBTL2U.js → list-evaluators-SJHC7VBH.js} +5 -5
- package/dist/{list-model-providers-GRK3I3VV.js → list-model-providers-PPD2WPD2.js} +5 -5
- package/dist/{list-projects-JLMBFMW3.js → list-projects-K7QAIX2I.js} +5 -5
- package/dist/{list-prompt-tags-UHNWDGQI.js → list-prompt-tags-FOO6TNHB.js} +4 -4
- package/dist/{list-prompts-7254B3U7.js → list-prompts-SC2HAUFG.js} +4 -4
- package/dist/{list-scenarios-46YNICKM.js → list-scenarios-F2WYNRIE.js} +5 -5
- package/dist/{list-simulation-runs-LGNEFNPN.js → list-simulation-runs-KF5THG5H.js} +5 -5
- package/dist/{list-suites-5VK4HXEK.js → list-suites-CRY4VWW6.js} +5 -5
- package/dist/{list-workflows-MLQDM6CP.js → list-workflows-QSJWURCK.js} +5 -5
- package/dist/{rename-prompt-tag-RHQMNEQL.js → rename-prompt-tag-EKWU5QF3.js} +4 -4
- package/dist/{report-issue-DXJ6TPTC.js → report-issue-XYVAPAYJ.js} +7 -7
- package/dist/{revoke-api-key-3Y35EMBT.js → revoke-api-key-FTRTZMOF.js} +5 -5
- package/dist/{run-agent-RBSIFKRT.js → run-agent-6SM4XNA7.js} +5 -5
- package/dist/{run-suite-K3DNQ3SE.js → run-suite-NKZCJ2IN.js} +5 -5
- package/dist/{run-workflow-5LVBZYLS.js → run-workflow-QPXC627O.js} +5 -5
- package/dist/{search-traces-U4HLOCZ2.js → search-traces-A6HFKPLJ.js} +4 -4
- package/dist/{set-model-provider-V3M5CDIY.js → set-model-provider-3WKT63ZN.js} +5 -5
- package/dist/{update-agent-47RZIHS7.js → update-agent-SMAEHLXH.js} +5 -5
- package/dist/{update-dataset-G6QJJGS5.js → update-dataset-BDUK7TUO.js} +5 -5
- package/dist/{update-dataset-record-RMWLSWCS.js → update-dataset-record-4ZSDRBSW.js} +5 -5
- package/dist/{update-evaluator-SFRRI5Q3.js → update-evaluator-LS6TIBY7.js} +5 -5
- package/dist/{update-project-PI5DWBVK.js → update-project-LKVYXENH.js} +5 -5
- package/dist/{update-prompt-RQFTEO7Y.js → update-prompt-ODCRQRN6.js} +4 -4
- package/dist/{update-scenario-G3IU2UIB.js → update-scenario-NEXCAQI7.js} +5 -5
- package/dist/{update-suite-TRGGLNR7.js → update-suite-SNO3GS7T.js} +5 -5
- package/package.json +3 -3
- package/pyproject.toml +1 -1
- 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__/support/http-server-harness.ts +138 -0
- package/src/config.ts +17 -2
- package/src/http-security.ts +492 -0
- package/src/http-server.ts +536 -152
- package/src/index.ts +49 -2
- package/src/tools/discover-evaluator-schema.ts +2 -2
- package/tsconfig.json +2 -2
- package/uv.lock +2 -2
- package/dist/langwatch-api-workflows-5HRZ7HR4.js +0 -16
- package/pnpm-workspace.yaml +0 -34
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
updateDatasetRecord
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
5
|
-
import "./chunk-
|
|
6
|
-
import "./chunk-
|
|
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-
|
|
20
|
+
//# sourceMappingURL=update-dataset-record-4ZSDRBSW.js.map
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getEvaluatorType,
|
|
3
3
|
updateEvaluator
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
6
|
-
import "./chunk-
|
|
7
|
-
import "./chunk-
|
|
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-
|
|
25
|
+
//# sourceMappingURL=update-evaluator-LS6TIBY7.js.map
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
updateProject
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
5
|
-
import "./chunk-
|
|
6
|
-
import "./chunk-
|
|
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-
|
|
25
|
+
//# sourceMappingURL=update-project-LKVYXENH.js.map
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
updatePrompt
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
5
|
-
import "./chunk-
|
|
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-
|
|
25
|
+
//# sourceMappingURL=update-prompt-ODCRQRN6.js.map
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
updateScenario
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
5
|
-
import "./chunk-
|
|
6
|
-
import "./chunk-
|
|
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-
|
|
28
|
+
//# sourceMappingURL=update-scenario-NEXCAQI7.js.map
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
updateSuite
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
5
|
-
import "./chunk-
|
|
6
|
-
import "./chunk-
|
|
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-
|
|
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": "
|
|
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
|
@@ -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
|
-
|
|
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
|
+
});
|