@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
package/src/http-server.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
-
import type { Request, Response, NextFunction } from "express";
|
|
2
|
+
import type { Request, RequestHandler, Response, NextFunction } from "express";
|
|
3
3
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
4
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
5
5
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -8,53 +8,127 @@ import type { Server } from "node:http";
|
|
|
8
8
|
|
|
9
9
|
import { getConfig, runWithConfig } from "./config.js";
|
|
10
10
|
import { createMcpServer } from "./create-mcp-server.js";
|
|
11
|
+
import {
|
|
12
|
+
admitOAuthToken,
|
|
13
|
+
apiKeysMatch,
|
|
14
|
+
createApiKeyVerifier,
|
|
15
|
+
createRateLimiter,
|
|
16
|
+
createSessionStore,
|
|
17
|
+
DEFAULT_BIND_HOST,
|
|
18
|
+
isOriginAllowed,
|
|
19
|
+
parseAllowedOrigins,
|
|
20
|
+
type ApiKeyVerifier,
|
|
21
|
+
type OAuthTokenEntry,
|
|
22
|
+
type RateLimiter,
|
|
23
|
+
type SessionStore,
|
|
24
|
+
} from "./http-security.js";
|
|
25
|
+
|
|
26
|
+
/** Idle time after which a session is closed and forgotten. */
|
|
27
|
+
const SESSION_MAX_AGE_MS = 30 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
/** How often the reaper sweeps sessions, tokens, and rate limiter state. */
|
|
30
|
+
const REAPER_INTERVAL_MS = 60 * 1000;
|
|
31
|
+
|
|
32
|
+
/** Concurrent sessions a single API key may hold across both transports. */
|
|
33
|
+
const MAX_SESSIONS_PER_KEY = 20;
|
|
34
|
+
|
|
35
|
+
/** OAuth access token lifetime. */
|
|
36
|
+
const OAUTH_TOKEN_TTL_SECONDS = 3600;
|
|
37
|
+
const MAX_OAUTH_TOKENS_PER_KEY = 10;
|
|
38
|
+
|
|
39
|
+
/** Per-server state that the route handlers operate on. */
|
|
40
|
+
interface ServerRuntime {
|
|
41
|
+
verifier: ApiKeyVerifier;
|
|
42
|
+
oauthTokens: Map<string, OAuthTokenEntry>;
|
|
43
|
+
authFailRateLimiter: RateLimiter;
|
|
44
|
+
oauthRateLimiter: RateLimiter;
|
|
45
|
+
sessions: SessionStore<StreamableHTTPServerTransport>;
|
|
46
|
+
sseSessions: SessionStore<SSEServerTransport>;
|
|
47
|
+
}
|
|
11
48
|
|
|
12
49
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
50
|
+
* Resolves and verifies the caller's Bearer token. When `expectedApiKey` is
|
|
51
|
+
* given the token has to resolve to that same key, so possession of a session
|
|
52
|
+
* id grants nothing on its own. Sends the error response itself and resolves to
|
|
53
|
+
* null when the request must not proceed.
|
|
16
54
|
*/
|
|
17
|
-
|
|
55
|
+
type Authenticate = (args: {
|
|
56
|
+
req: Request;
|
|
57
|
+
res: Response;
|
|
58
|
+
expectedApiKey?: string;
|
|
59
|
+
}) => Promise<string | null>;
|
|
18
60
|
|
|
19
61
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* Expects the format: `Bearer <key>`.
|
|
62
|
+
* Reads the raw Bearer token from the Authorization header. RFC 7235 defines
|
|
63
|
+
* the auth-scheme as case-insensitive, and some proxies rewrite its casing.
|
|
23
64
|
*/
|
|
24
|
-
function
|
|
65
|
+
function readBearerToken(req: Request): string | null {
|
|
25
66
|
const authHeader = req.headers.authorization;
|
|
26
|
-
if (!authHeader
|
|
27
|
-
const token = authHeader.slice(7) || null;
|
|
28
|
-
if (!token) return null;
|
|
67
|
+
if (!authHeader) return null;
|
|
29
68
|
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
oauthTokens.delete(token);
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
69
|
+
// Split on the first space rather than matching a pattern. The header is
|
|
70
|
+
// attacker controlled and can be kilobytes long, and a scheme-then-token
|
|
71
|
+
// regex backtracks over runs of whitespace.
|
|
72
|
+
const header = authHeader.trim();
|
|
73
|
+
const separator = header.indexOf(" ");
|
|
74
|
+
if (separator === -1) return null;
|
|
75
|
+
if (header.slice(0, separator).toLowerCase() !== "bearer") return null;
|
|
40
76
|
|
|
41
|
-
|
|
42
|
-
return token;
|
|
77
|
+
return header.slice(separator + 1).trim() || null;
|
|
43
78
|
}
|
|
44
79
|
|
|
45
|
-
/**
|
|
46
|
-
* Generates a deterministic but opaque access token from client credentials.
|
|
47
|
-
*/
|
|
80
|
+
/** Generates an opaque access token. */
|
|
48
81
|
function generateAccessToken(): string {
|
|
49
82
|
return createHash("sha256").update(randomUUID()).digest("hex");
|
|
50
83
|
}
|
|
51
84
|
|
|
52
85
|
/**
|
|
53
|
-
*
|
|
86
|
+
* Client address used for rate limiting.
|
|
87
|
+
*
|
|
88
|
+
* Forwarded headers only mean something when a trusted proxy sets them, so this
|
|
89
|
+
* reads the socket peer unless proxy trust is turned on explicitly. Defaulting
|
|
90
|
+
* to the socket keeps the limits countable: a caller that reaches the port
|
|
91
|
+
* directly cannot rotate `X-Forwarded-For` to reset its own counter. Where that
|
|
92
|
+
* default is wrong, it is wrong in the strict direction, counting a whole proxy
|
|
93
|
+
* as one client rather than not counting at all.
|
|
54
94
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
95
|
+
function rateLimitKey(req: Request): string {
|
|
96
|
+
if (process.env.LANGWATCH_MCP_TRUST_PROXY === "true") {
|
|
97
|
+
return req.ip ?? req.socket.remoteAddress ?? "unknown";
|
|
98
|
+
}
|
|
99
|
+
return req.socket.remoteAddress ?? "unknown";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolves a Bearer token to the API key it stands for. OAuth-issued access
|
|
104
|
+
* tokens map back to the key they were minted from; anything else is treated as
|
|
105
|
+
* a direct API key. This only translates the token, it does not decide whether
|
|
106
|
+
* the key is valid.
|
|
107
|
+
*/
|
|
108
|
+
function resolveApiKey({
|
|
109
|
+
token,
|
|
110
|
+
oauthTokens,
|
|
111
|
+
}: {
|
|
112
|
+
token: string;
|
|
113
|
+
oauthTokens: Map<string, OAuthTokenEntry>;
|
|
114
|
+
}): string | null {
|
|
115
|
+
const oauthEntry = oauthTokens.get(token);
|
|
116
|
+
if (!oauthEntry) return token;
|
|
117
|
+
|
|
118
|
+
if (Date.now() < oauthEntry.expiresAt) return oauthEntry.apiKey;
|
|
119
|
+
|
|
120
|
+
oauthTokens.delete(token);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sendUnauthorized({
|
|
125
|
+
res,
|
|
126
|
+
error,
|
|
127
|
+
}: {
|
|
128
|
+
res: Response;
|
|
129
|
+
error: string;
|
|
130
|
+
}): void {
|
|
131
|
+
res.status(401).json({ error });
|
|
58
132
|
}
|
|
59
133
|
|
|
60
134
|
/**
|
|
@@ -71,57 +145,134 @@ async function handleWithSessionConfig<T>(
|
|
|
71
145
|
}
|
|
72
146
|
|
|
73
147
|
/**
|
|
74
|
-
*
|
|
75
|
-
* for the LangWatch MCP server.
|
|
76
|
-
*
|
|
77
|
-
* Each client session provides its own API key via `Authorization: Bearer <key>`.
|
|
78
|
-
* The key is captured on the initialize request and stored per-session. All
|
|
79
|
-
* subsequent requests in that session use the captured key.
|
|
148
|
+
* Origin validation and CORS.
|
|
80
149
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
150
|
+
* The MCP transport specification requires servers to validate Origin on every
|
|
151
|
+
* incoming connection, because a page on an attacker's domain can point DNS at
|
|
152
|
+
* loopback and reach a server that only checks the token. Requests with no
|
|
153
|
+
* Origin header are not browser requests, so they pass: browsers always send
|
|
154
|
+
* Origin on the cross-origin requests this guards.
|
|
86
155
|
*/
|
|
87
|
-
|
|
88
|
-
|
|
156
|
+
function createOriginMiddleware({
|
|
157
|
+
allowedOrigins,
|
|
89
158
|
}: {
|
|
90
|
-
|
|
91
|
-
}):
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
159
|
+
allowedOrigins: readonly string[];
|
|
160
|
+
}): RequestHandler {
|
|
161
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
162
|
+
const origin = req.headers.origin;
|
|
163
|
+
|
|
164
|
+
if (typeof origin === "string" && origin.length > 0) {
|
|
165
|
+
if (!isOriginAllowed({ origin, allowedOrigins })) {
|
|
166
|
+
res.status(403).json({ error: "Origin not allowed" });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
res.header("Access-Control-Allow-Origin", origin);
|
|
170
|
+
res.header("Vary", "Origin");
|
|
171
|
+
// A cross-origin client cannot read a response header unless it is
|
|
172
|
+
// exposed, and the Streamable HTTP transport reads the session id off
|
|
173
|
+
// the initialize response.
|
|
174
|
+
res.header(
|
|
175
|
+
"Access-Control-Expose-Headers",
|
|
176
|
+
"Mcp-Session-Id, MCP-Protocol-Version"
|
|
177
|
+
);
|
|
178
|
+
}
|
|
95
179
|
|
|
96
|
-
|
|
97
|
-
app.use((_req: Request, res: Response, next: NextFunction) => {
|
|
98
|
-
res.header("Access-Control-Allow-Origin", "*");
|
|
99
|
-
res.header(
|
|
100
|
-
"Access-Control-Allow-Methods",
|
|
101
|
-
"GET, POST, DELETE, OPTIONS"
|
|
102
|
-
);
|
|
180
|
+
res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
103
181
|
res.header(
|
|
104
182
|
"Access-Control-Allow-Headers",
|
|
105
183
|
"Content-Type, Authorization, mcp-session-id, MCP-Protocol-Version"
|
|
106
184
|
);
|
|
107
|
-
|
|
108
|
-
|
|
185
|
+
res.header("X-Content-Type-Options", "nosniff");
|
|
186
|
+
res.header("X-Frame-Options", "DENY");
|
|
187
|
+
|
|
188
|
+
if (req.method === "OPTIONS") {
|
|
189
|
+
res.sendStatus(204);
|
|
109
190
|
return;
|
|
110
191
|
}
|
|
111
192
|
next();
|
|
112
|
-
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
113
195
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
196
|
+
function createAuthenticator(runtime: ServerRuntime): Authenticate {
|
|
197
|
+
const { verifier, oauthTokens, authFailRateLimiter } = runtime;
|
|
198
|
+
|
|
199
|
+
return async ({ req, res, expectedApiKey }) => {
|
|
200
|
+
const ip = rateLimitKey(req);
|
|
201
|
+
if (authFailRateLimiter.isBlocked(ip)) {
|
|
202
|
+
res.status(429).json({ error: "Too many requests" });
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const token = readBearerToken(req);
|
|
207
|
+
if (!token) {
|
|
208
|
+
sendUnauthorized({ res, error: "Authorization: Bearer <LANGWATCH_API_KEY> header required" });
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const apiKey = resolveApiKey({ token, oauthTokens });
|
|
213
|
+
if (!apiKey) {
|
|
214
|
+
authFailRateLimiter.track(ip);
|
|
215
|
+
sendUnauthorized({ res, error: "Invalid or expired token" });
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (
|
|
220
|
+
expectedApiKey !== undefined &&
|
|
221
|
+
!apiKeysMatch({ presentedKey: apiKey, expectedKey: expectedApiKey })
|
|
222
|
+
) {
|
|
223
|
+
authFailRateLimiter.track(ip);
|
|
224
|
+
sendUnauthorized({ res, error: "Bearer token does not match session" });
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The key also has to still be live, so revoking a key ends its sessions
|
|
229
|
+
// within the verifier's cache window rather than at the session TTL.
|
|
230
|
+
if (!(await verifier.verify(apiKey))) {
|
|
231
|
+
authFailRateLimiter.track(ip);
|
|
232
|
+
sendUnauthorized({ res, error: "Invalid API key" });
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return apiKey;
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** True when the key already holds its maximum concurrent sessions. */
|
|
241
|
+
function overSessionLimit({
|
|
242
|
+
apiKey,
|
|
243
|
+
sessions,
|
|
244
|
+
sseSessions,
|
|
245
|
+
}: {
|
|
246
|
+
apiKey: string;
|
|
247
|
+
sessions: SessionStore<StreamableHTTPServerTransport>;
|
|
248
|
+
sseSessions: SessionStore<SSEServerTransport>;
|
|
249
|
+
}): boolean {
|
|
250
|
+
return (
|
|
251
|
+
sessions.countForKey(apiKey) + sseSessions.countForKey(apiKey) >=
|
|
252
|
+
MAX_SESSIONS_PER_KEY
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function sendSessionLimitReached(res: Response): void {
|
|
257
|
+
res.status(429).json({
|
|
258
|
+
error: `Too many concurrent sessions (max ${MAX_SESSIONS_PER_KEY})`,
|
|
117
259
|
});
|
|
260
|
+
}
|
|
118
261
|
|
|
119
|
-
|
|
262
|
+
/** OAuth 2.0 endpoints, for Claude Desktop and other OAuth-only clients. */
|
|
263
|
+
function registerOAuthRoutes({
|
|
264
|
+
app,
|
|
265
|
+
runtime,
|
|
266
|
+
}: {
|
|
267
|
+
app: express.Express;
|
|
268
|
+
runtime: ServerRuntime;
|
|
269
|
+
}): void {
|
|
270
|
+
const { verifier, oauthTokens, oauthRateLimiter } = runtime;
|
|
120
271
|
|
|
121
272
|
app.get(
|
|
122
273
|
"/.well-known/oauth-authorization-server",
|
|
123
|
-
(
|
|
124
|
-
const baseUrl = `${
|
|
274
|
+
(req: Request, res: Response) => {
|
|
275
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
125
276
|
res.json({
|
|
126
277
|
issuer: baseUrl,
|
|
127
278
|
token_endpoint: `${baseUrl}/oauth/token`,
|
|
@@ -133,27 +284,29 @@ export async function startHttpServer({
|
|
|
133
284
|
}
|
|
134
285
|
);
|
|
135
286
|
|
|
136
|
-
//
|
|
137
|
-
// application/x-www-form-urlencoded)
|
|
287
|
+
// RFC 6749 requires application/x-www-form-urlencoded on the token endpoint.
|
|
138
288
|
app.post(
|
|
139
289
|
"/oauth/token",
|
|
140
290
|
express.urlencoded({ extended: false }),
|
|
141
|
-
(req: Request, res: Response) => {
|
|
142
|
-
const
|
|
291
|
+
async (req: Request, res: Response) => {
|
|
292
|
+
const ip = rateLimitKey(req);
|
|
293
|
+
if (oauthRateLimiter.isBlocked(ip)) {
|
|
294
|
+
res.status(429).json({ error: "Too many requests" });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
oauthRateLimiter.track(ip);
|
|
143
298
|
|
|
144
|
-
if (
|
|
299
|
+
if (req.body.grant_type !== "client_credentials") {
|
|
145
300
|
res.status(400).json({
|
|
146
301
|
error: "unsupported_grant_type",
|
|
147
|
-
error_description:
|
|
148
|
-
"Only client_credentials grant type is supported",
|
|
302
|
+
error_description: "Only client_credentials grant type is supported",
|
|
149
303
|
});
|
|
150
304
|
return;
|
|
151
305
|
}
|
|
152
306
|
|
|
153
|
-
//
|
|
154
|
-
//
|
|
307
|
+
// client_secret carries the LangWatch API key. client_id is ignored,
|
|
308
|
+
// the API key identifies the project.
|
|
155
309
|
const clientSecret = req.body.client_secret;
|
|
156
|
-
|
|
157
310
|
if (!clientSecret || typeof clientSecret !== "string") {
|
|
158
311
|
res.status(400).json({
|
|
159
312
|
error: "invalid_request",
|
|
@@ -163,32 +316,65 @@ export async function startHttpServer({
|
|
|
163
316
|
return;
|
|
164
317
|
}
|
|
165
318
|
|
|
166
|
-
|
|
167
|
-
|
|
319
|
+
// Minting a token for an unverified key would let anyone fill the token
|
|
320
|
+
// map with entries that go on to allocate sessions.
|
|
321
|
+
if (!(await verifier.verify(clientSecret))) {
|
|
322
|
+
res.status(401).json({
|
|
323
|
+
error: "invalid_client",
|
|
324
|
+
error_description: "client_secret is not a valid LangWatch API key",
|
|
325
|
+
});
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
168
328
|
|
|
329
|
+
const accessToken = generateAccessToken();
|
|
330
|
+
admitOAuthToken({
|
|
331
|
+
apiKey: clientSecret,
|
|
332
|
+
tokens: oauthTokens,
|
|
333
|
+
maxPerKey: MAX_OAUTH_TOKENS_PER_KEY,
|
|
334
|
+
});
|
|
169
335
|
oauthTokens.set(accessToken, {
|
|
170
336
|
apiKey: clientSecret,
|
|
171
|
-
expiresAt: Date.now() +
|
|
337
|
+
expiresAt: Date.now() + OAUTH_TOKEN_TTL_SECONDS * 1000,
|
|
172
338
|
});
|
|
173
339
|
|
|
174
340
|
res.json({
|
|
175
341
|
access_token: accessToken,
|
|
176
342
|
token_type: "Bearer",
|
|
177
|
-
expires_in:
|
|
343
|
+
expires_in: OAUTH_TOKEN_TTL_SECONDS,
|
|
178
344
|
scope: "mcp:tools",
|
|
179
345
|
});
|
|
180
346
|
}
|
|
181
347
|
);
|
|
348
|
+
}
|
|
182
349
|
|
|
183
|
-
|
|
350
|
+
/** Streamable HTTP transport, the modern MCP transport. */
|
|
351
|
+
function registerStreamableHttpRoutes({
|
|
352
|
+
app,
|
|
353
|
+
runtime,
|
|
354
|
+
authenticate,
|
|
355
|
+
}: {
|
|
356
|
+
app: express.Express;
|
|
357
|
+
runtime: ServerRuntime;
|
|
358
|
+
authenticate: Authenticate;
|
|
359
|
+
}): void {
|
|
360
|
+
const { sessions, sseSessions } = runtime;
|
|
184
361
|
|
|
185
|
-
const
|
|
362
|
+
const sessionIdOf = (req: Request): string | undefined =>
|
|
363
|
+
req.headers["mcp-session-id"] as string | undefined;
|
|
186
364
|
|
|
187
365
|
app.post("/mcp", async (req: Request, res: Response) => {
|
|
188
|
-
const sessionId = req
|
|
366
|
+
const sessionId = sessionIdOf(req);
|
|
367
|
+
const session = sessionId ? sessions.get(sessionId) : undefined;
|
|
368
|
+
|
|
369
|
+
if (sessionId && session) {
|
|
370
|
+
const apiKey = await authenticate({
|
|
371
|
+
req,
|
|
372
|
+
res,
|
|
373
|
+
expectedApiKey: session.apiKey,
|
|
374
|
+
});
|
|
375
|
+
if (!apiKey) return;
|
|
189
376
|
|
|
190
|
-
|
|
191
|
-
const session = sessions[sessionId];
|
|
377
|
+
sessions.touch(sessionId);
|
|
192
378
|
await handleWithSessionConfig(session.apiKey, () =>
|
|
193
379
|
session.transport.handleRequest(req, res, req.body)
|
|
194
380
|
);
|
|
@@ -196,124 +382,179 @@ export async function startHttpServer({
|
|
|
196
382
|
}
|
|
197
383
|
|
|
198
384
|
if (!sessionId && isInitializeRequest(req.body)) {
|
|
199
|
-
const apiKey =
|
|
385
|
+
const apiKey = await authenticate({ req, res });
|
|
386
|
+
if (!apiKey) return;
|
|
200
387
|
|
|
201
|
-
if (
|
|
202
|
-
res
|
|
203
|
-
error:
|
|
204
|
-
"Authorization: Bearer <LANGWATCH_API_KEY> header required",
|
|
205
|
-
});
|
|
388
|
+
if (overSessionLimit({ apiKey, sessions, sseSessions })) {
|
|
389
|
+
sendSessionLimitReached(res);
|
|
206
390
|
return;
|
|
207
391
|
}
|
|
208
392
|
|
|
393
|
+
// Claimed before the first await. The session id only exists once
|
|
394
|
+
// initialize completes, so without holding the slot from here concurrent
|
|
395
|
+
// requests for one key would all pass the check above at a count of zero.
|
|
396
|
+
const reservation = sessions.reserve(apiKey);
|
|
209
397
|
const transport = new StreamableHTTPServerTransport({
|
|
210
398
|
sessionIdGenerator: () => randomUUID(),
|
|
211
399
|
onsessioninitialized: (id) => {
|
|
212
|
-
|
|
400
|
+
reservation.commit({ sessionId: id, transport });
|
|
213
401
|
},
|
|
214
402
|
});
|
|
215
403
|
transport.onclose = () => {
|
|
216
|
-
if (transport.sessionId)
|
|
217
|
-
delete sessions[transport.sessionId];
|
|
218
|
-
}
|
|
404
|
+
if (transport.sessionId) sessions.remove(transport.sessionId);
|
|
219
405
|
};
|
|
220
406
|
|
|
221
407
|
const sessionServer = createMcpServer();
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
408
|
+
try {
|
|
409
|
+
await handleWithSessionConfig(apiKey, () =>
|
|
410
|
+
sessionServer.connect(transport)
|
|
411
|
+
);
|
|
412
|
+
await handleWithSessionConfig(apiKey, () =>
|
|
413
|
+
transport.handleRequest(req, res, req.body)
|
|
414
|
+
);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (transport.sessionId) sessions.remove(transport.sessionId);
|
|
417
|
+
await transport.close().catch(() => undefined);
|
|
418
|
+
throw error;
|
|
419
|
+
} finally {
|
|
420
|
+
// A no-op once the session took the slot, so this only returns it when
|
|
421
|
+
// initialization never produced one.
|
|
422
|
+
reservation.release();
|
|
423
|
+
}
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
225
426
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
427
|
+
// An unknown session id gets the same answer as a missing token, so the
|
|
428
|
+
// response does not reveal whether the session exists.
|
|
429
|
+
if (sessionId) {
|
|
430
|
+
sendUnauthorized({ res, error: "Session expired or not found" });
|
|
229
431
|
return;
|
|
230
432
|
}
|
|
231
433
|
|
|
232
434
|
res.status(400).json({
|
|
233
|
-
error: "Invalid request
|
|
435
|
+
error: "Invalid request, no session ID or not an initialize request",
|
|
234
436
|
});
|
|
235
437
|
});
|
|
236
438
|
|
|
237
439
|
app.get("/mcp", async (req: Request, res: Response) => {
|
|
238
|
-
const sessionId = req
|
|
440
|
+
const sessionId = sessionIdOf(req);
|
|
441
|
+
const session = sessionId ? sessions.get(sessionId) : undefined;
|
|
442
|
+
|
|
443
|
+
if (sessionId && session) {
|
|
444
|
+
const apiKey = await authenticate({
|
|
445
|
+
req,
|
|
446
|
+
res,
|
|
447
|
+
expectedApiKey: session.apiKey,
|
|
448
|
+
});
|
|
449
|
+
if (!apiKey) return;
|
|
239
450
|
|
|
240
|
-
|
|
241
|
-
const session = sessions[sessionId];
|
|
451
|
+
sessions.touch(sessionId);
|
|
242
452
|
await handleWithSessionConfig(session.apiKey, () =>
|
|
243
453
|
session.transport.handleRequest(req, res)
|
|
244
454
|
);
|
|
245
|
-
|
|
246
|
-
res
|
|
247
|
-
.status(400)
|
|
248
|
-
.json({ error: "Invalid request — no valid session ID" });
|
|
455
|
+
return;
|
|
249
456
|
}
|
|
457
|
+
|
|
458
|
+
if (sessionId) {
|
|
459
|
+
sendUnauthorized({ res, error: "Session expired or not found" });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
res.status(400).json({ error: "Invalid request, no valid session ID" });
|
|
250
464
|
});
|
|
251
465
|
|
|
252
466
|
app.delete("/mcp", async (req: Request, res: Response) => {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
467
|
+
// Authenticate before looking the session up, so that a session the caller
|
|
468
|
+
// does not own is indistinguishable from one that does not exist.
|
|
469
|
+
const apiKey = await authenticate({ req, res });
|
|
470
|
+
if (!apiKey) return;
|
|
471
|
+
|
|
472
|
+
const sessionId = sessionIdOf(req);
|
|
473
|
+
const session = sessionId ? sessions.get(sessionId) : undefined;
|
|
474
|
+
const owned =
|
|
475
|
+
session !== undefined &&
|
|
476
|
+
apiKeysMatch({ presentedKey: apiKey, expectedKey: session.apiKey });
|
|
477
|
+
|
|
478
|
+
if (!sessionId || !session || !owned) {
|
|
260
479
|
res.status(404).json({ error: "Session not found" });
|
|
480
|
+
return;
|
|
261
481
|
}
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
// --- Legacy SSE transport (backwards compatibility) ---
|
|
265
482
|
|
|
266
|
-
|
|
267
|
-
transport
|
|
268
|
-
|
|
269
|
-
}
|
|
483
|
+
sessions.remove(sessionId);
|
|
484
|
+
await session.transport.close();
|
|
485
|
+
res.status(200).json({ status: "session closed" });
|
|
486
|
+
});
|
|
487
|
+
}
|
|
270
488
|
|
|
271
|
-
|
|
489
|
+
/** Legacy SSE transport, kept for backwards compatibility. */
|
|
490
|
+
function registerSseRoutes({
|
|
491
|
+
app,
|
|
492
|
+
runtime,
|
|
493
|
+
authenticate,
|
|
494
|
+
}: {
|
|
495
|
+
app: express.Express;
|
|
496
|
+
runtime: ServerRuntime;
|
|
497
|
+
authenticate: Authenticate;
|
|
498
|
+
}): void {
|
|
499
|
+
const { sessions, sseSessions } = runtime;
|
|
272
500
|
|
|
273
501
|
app.get("/sse", async (req: Request, res: Response) => {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
// auth across the MCP/CLI ecosystem. The header form is preferred and
|
|
277
|
-
// checked first; this fallback is documented at the client level.
|
|
278
|
-
const apiKey =
|
|
279
|
-
extractBearerToken(req) ||
|
|
280
|
-
// lgtm[js/sensitive-get-query]
|
|
281
|
-
(req.query["apiKey"] as string | undefined) ||
|
|
282
|
-
null;
|
|
502
|
+
const apiKey = await authenticate({ req, res });
|
|
503
|
+
if (!apiKey) return;
|
|
283
504
|
|
|
284
|
-
if (
|
|
285
|
-
res
|
|
286
|
-
error:
|
|
287
|
-
"Authorization: Bearer <LANGWATCH_API_KEY> header or ?apiKey= query parameter required",
|
|
288
|
-
});
|
|
505
|
+
if (overSessionLimit({ apiKey, sessions, sseSessions })) {
|
|
506
|
+
sendSessionLimitReached(res);
|
|
289
507
|
return;
|
|
290
508
|
}
|
|
291
509
|
|
|
292
510
|
const transport = new SSEServerTransport("/messages", res);
|
|
293
|
-
sseSessions
|
|
511
|
+
sseSessions.add({ sessionId: transport.sessionId, transport, apiKey });
|
|
294
512
|
|
|
295
513
|
const sessionServer = createMcpServer();
|
|
296
514
|
|
|
297
|
-
// Clean up when the SSE connection closes
|
|
298
515
|
res.on("close", () => {
|
|
299
|
-
|
|
516
|
+
sseSessions.remove(transport.sessionId);
|
|
300
517
|
});
|
|
301
518
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
519
|
+
try {
|
|
520
|
+
await handleWithSessionConfig(apiKey, () =>
|
|
521
|
+
sessionServer.connect(transport)
|
|
522
|
+
);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
// Without this the entry holds one of the per-key slots until the reaper
|
|
525
|
+
// sweeps it, because res "close" may never fire if the stream never
|
|
526
|
+
// opened.
|
|
527
|
+
sseSessions.remove(transport.sessionId);
|
|
528
|
+
await transport.close().catch(() => undefined);
|
|
529
|
+
if (!res.headersSent) {
|
|
530
|
+
res.status(500).json({ error: "Failed to open SSE session" });
|
|
531
|
+
}
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
305
534
|
});
|
|
306
535
|
|
|
307
|
-
//
|
|
308
|
-
//
|
|
536
|
+
// Mounted at both /messages and /sse/messages because some clients resolve
|
|
537
|
+
// the relative /messages URL differently.
|
|
538
|
+
//
|
|
539
|
+
// The session id travels in the query string because the SSE transport hands
|
|
540
|
+
// the client its POST endpoint as a URI. It identifies the session and
|
|
541
|
+
// nothing more: the Bearer token is what authorizes the request.
|
|
309
542
|
const handleSseMessage = async (req: Request, res: Response) => {
|
|
543
|
+
const apiKey = await authenticate({ req, res });
|
|
544
|
+
if (!apiKey) return;
|
|
545
|
+
|
|
310
546
|
const sessionId = req.query["sessionId"] as string | undefined;
|
|
311
|
-
|
|
547
|
+
const session = sessionId ? sseSessions.get(sessionId) : undefined;
|
|
548
|
+
const owned =
|
|
549
|
+
session !== undefined &&
|
|
550
|
+
apiKeysMatch({ presentedKey: apiKey, expectedKey: session.apiKey });
|
|
551
|
+
|
|
552
|
+
if (!sessionId || !session || !owned) {
|
|
312
553
|
res.status(400).json({ error: "Invalid or missing session ID" });
|
|
313
554
|
return;
|
|
314
555
|
}
|
|
315
556
|
|
|
316
|
-
|
|
557
|
+
sseSessions.touch(sessionId);
|
|
317
558
|
await handleWithSessionConfig(session.apiKey, () =>
|
|
318
559
|
session.transport.handlePostMessage(req, res, req.body)
|
|
319
560
|
);
|
|
@@ -321,14 +562,157 @@ export async function startHttpServer({
|
|
|
321
562
|
|
|
322
563
|
app.post("/messages", handleSseMessage);
|
|
323
564
|
app.post("/sse/messages", handleSseMessage);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Sweeps idle sessions, expired tokens, and stale rate limiter entries. */
|
|
568
|
+
function startReaper(runtime: ServerRuntime): NodeJS.Timeout {
|
|
569
|
+
const {
|
|
570
|
+
sessions,
|
|
571
|
+
sseSessions,
|
|
572
|
+
oauthTokens,
|
|
573
|
+
verifier,
|
|
574
|
+
authFailRateLimiter,
|
|
575
|
+
oauthRateLimiter,
|
|
576
|
+
} = runtime;
|
|
577
|
+
|
|
578
|
+
const reaper = setInterval(() => {
|
|
579
|
+
sessions.sweep();
|
|
580
|
+
sseSessions.sweep();
|
|
581
|
+
|
|
582
|
+
const now = Date.now();
|
|
583
|
+
for (const [token, entry] of oauthTokens) {
|
|
584
|
+
if (now >= entry.expiresAt) oauthTokens.delete(token);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
verifier.sweep();
|
|
588
|
+
authFailRateLimiter.sweep();
|
|
589
|
+
oauthRateLimiter.sweep();
|
|
590
|
+
}, REAPER_INTERVAL_MS);
|
|
591
|
+
|
|
592
|
+
// Let the process exit naturally even while the reaper is scheduled.
|
|
593
|
+
reaper.unref();
|
|
594
|
+
return reaper;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function createRuntime({
|
|
598
|
+
endpoint,
|
|
599
|
+
apiKeyVerifier,
|
|
600
|
+
}: {
|
|
601
|
+
endpoint: string;
|
|
602
|
+
apiKeyVerifier?: ApiKeyVerifier;
|
|
603
|
+
}): ServerRuntime {
|
|
604
|
+
const closeTransport = (transport: { close(): Promise<void> }) =>
|
|
605
|
+
void transport.close().catch(() => undefined);
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
verifier: apiKeyVerifier ?? createApiKeyVerifier({ endpoint }),
|
|
609
|
+
oauthTokens: new Map<string, OAuthTokenEntry>(),
|
|
610
|
+
// Failed authentication is rate limited per client address so an attacker
|
|
611
|
+
// cannot spray candidate keys or turn one flood into unbounded
|
|
612
|
+
// verification traffic upstream.
|
|
613
|
+
authFailRateLimiter: createRateLimiter({
|
|
614
|
+
windowMs: 60_000,
|
|
615
|
+
maxRequests: 20,
|
|
616
|
+
}),
|
|
617
|
+
oauthRateLimiter: createRateLimiter({ windowMs: 60_000, maxRequests: 10 }),
|
|
618
|
+
sessions: createSessionStore<StreamableHTTPServerTransport>({
|
|
619
|
+
maxAgeMs: SESSION_MAX_AGE_MS,
|
|
620
|
+
closeTransport,
|
|
621
|
+
}),
|
|
622
|
+
sseSessions: createSessionStore<SSEServerTransport>({
|
|
623
|
+
maxAgeMs: SESSION_MAX_AGE_MS,
|
|
624
|
+
closeTransport,
|
|
625
|
+
}),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export interface StartHttpServerOptions {
|
|
630
|
+
port: number;
|
|
631
|
+
/**
|
|
632
|
+
* Listen address. Defaults to loopback. Set it to `0.0.0.0` to accept
|
|
633
|
+
* connections from outside the machine, which also means listing every
|
|
634
|
+
* browser origin that should be able to reach the server.
|
|
635
|
+
*/
|
|
636
|
+
host?: string;
|
|
637
|
+
/** Browser origins accepted in addition to loopback. */
|
|
638
|
+
allowedOrigins?: string[];
|
|
639
|
+
/** Overrides the API key verifier, for tests. */
|
|
640
|
+
apiKeyVerifier?: ApiKeyVerifier;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export interface StartedHttpServer {
|
|
644
|
+
server: Server;
|
|
645
|
+
port: number;
|
|
646
|
+
host: string;
|
|
647
|
+
allowedOrigins: string[];
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Starts an Express HTTP server with Streamable HTTP and legacy SSE transports
|
|
652
|
+
* for the LangWatch MCP server.
|
|
653
|
+
*
|
|
654
|
+
* Every request carries `Authorization: Bearer <key>`. The key is verified
|
|
655
|
+
* against the LangWatch API before any per-session state is allocated, and
|
|
656
|
+
* re-checked on every subsequent request against the key the session was
|
|
657
|
+
* created with, so a session id on its own authorizes nothing.
|
|
658
|
+
*
|
|
659
|
+
* Endpoints:
|
|
660
|
+
* - GET /health - Health check for Kubernetes probes (no auth)
|
|
661
|
+
* - POST/GET/DELETE /mcp - Streamable HTTP transport (modern)
|
|
662
|
+
* - GET /sse - Legacy SSE transport (backwards compatibility)
|
|
663
|
+
* - POST /messages - Legacy SSE message endpoint
|
|
664
|
+
*/
|
|
665
|
+
export async function startHttpServer({
|
|
666
|
+
port,
|
|
667
|
+
host,
|
|
668
|
+
allowedOrigins,
|
|
669
|
+
apiKeyVerifier,
|
|
670
|
+
}: StartHttpServerOptions): Promise<StartedHttpServer> {
|
|
671
|
+
const bindHost =
|
|
672
|
+
host ?? process.env.LANGWATCH_MCP_HTTP_HOST ?? DEFAULT_BIND_HOST;
|
|
673
|
+
const originAllowlist =
|
|
674
|
+
allowedOrigins ??
|
|
675
|
+
parseAllowedOrigins(process.env.LANGWATCH_MCP_ALLOWED_ORIGINS);
|
|
676
|
+
|
|
677
|
+
const runtime = createRuntime({
|
|
678
|
+
endpoint: getConfig().endpoint,
|
|
679
|
+
apiKeyVerifier,
|
|
680
|
+
});
|
|
681
|
+
const authenticate = createAuthenticator(runtime);
|
|
682
|
+
const reaper = startReaper(runtime);
|
|
683
|
+
|
|
684
|
+
const app = express();
|
|
685
|
+
// Forwarded headers carry the external scheme that the OAuth metadata
|
|
686
|
+
// document advertises. They do not decide the rate limit key; see
|
|
687
|
+
// rateLimitKey().
|
|
688
|
+
app.set("trust proxy", true);
|
|
689
|
+
app.use(express.json());
|
|
690
|
+
app.use(createOriginMiddleware({ allowedOrigins: originAllowlist }));
|
|
691
|
+
|
|
692
|
+
app.get("/health", (_req: Request, res: Response) => {
|
|
693
|
+
res.json({ status: "ok" });
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
registerOAuthRoutes({ app, runtime });
|
|
697
|
+
registerStreamableHttpRoutes({ app, runtime, authenticate });
|
|
698
|
+
registerSseRoutes({ app, runtime, authenticate });
|
|
324
699
|
|
|
325
|
-
// Start the server
|
|
326
700
|
return new Promise((resolve) => {
|
|
327
|
-
const server = app.listen(port,
|
|
701
|
+
const server = app.listen(port, bindHost, () => {
|
|
328
702
|
const addr = server.address();
|
|
329
|
-
const resolvedPort =
|
|
330
|
-
|
|
331
|
-
|
|
703
|
+
const resolvedPort = typeof addr === "object" && addr ? addr.port : port;
|
|
704
|
+
resolve({
|
|
705
|
+
server,
|
|
706
|
+
port: resolvedPort,
|
|
707
|
+
host: bindHost,
|
|
708
|
+
allowedOrigins: originAllowlist,
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
server.on("close", () => {
|
|
713
|
+
clearInterval(reaper);
|
|
714
|
+
runtime.sessions.closeAll();
|
|
715
|
+
runtime.sseSessions.closeAll();
|
|
332
716
|
});
|
|
333
717
|
});
|
|
334
718
|
}
|