@langwatch/mcp-server 0.10.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/.python-version +1 -0
  2. package/.release-please-shim +1 -1
  3. package/CHANGELOG.md +75 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +64 -18
  6. package/dist/{archive-project-KAZFHR4A.js → archive-project-4EQGLBJJ.js} +5 -4
  7. package/dist/{archive-scenario-6M2IB7Y2.js → archive-scenario-W6BXYDZ3.js} +5 -4
  8. package/dist/{archive-suite-NXZ3YDS3.js → archive-suite-4T6BSRS7.js} +5 -4
  9. package/dist/{assign-prompt-tag-WPUJO55U.js → assign-prompt-tag-VLXUWEKU.js} +4 -3
  10. package/dist/chunk-37SMPMQS.js +12 -0
  11. package/dist/{chunk-HRFQAFPR.js → chunk-4QC7SA2L.js} +221 -134
  12. package/dist/{chunk-XRQNXN2Z.js → chunk-5CCZFUXS.js} +2 -2
  13. package/dist/{chunk-ITN2QGHF.js → chunk-77BNWQ3U.js} +2 -2
  14. package/dist/{chunk-B6UV3JVI.js → chunk-7RHG56OH.js} +2 -2
  15. package/dist/{chunk-CAY57CKF.js → chunk-A2LEVYQ5.js} +78 -5
  16. package/dist/{chunk-U2QIW2SW.js → chunk-AQJO2EI5.js} +2 -2
  17. package/dist/{chunk-GWEW5XON.js → chunk-CDYQOY5U.js} +2 -2
  18. package/dist/{chunk-LTEJDL4V.js → chunk-J42FWRJ2.js} +2 -2
  19. package/dist/chunk-SAS5OB7F.js +58 -0
  20. package/dist/{chunk-AGGE6NPR.js → chunk-SFB4MU6H.js} +2 -2
  21. package/dist/{chunk-COCNCXVJ.js → chunk-TJCXFNDT.js} +2 -2
  22. package/dist/chunk-TOXJQDIZ.js +432 -0
  23. package/dist/chunk-UJPXFA26.js +267 -0
  24. package/dist/{chunk-KOO5733S.js → chunk-WT4PO4A3.js} +8 -2
  25. package/dist/{chunk-RDUXCCFR.js → chunk-YX3A3CFM.js} +2 -2
  26. package/dist/{chunk-UKRCZJJC.js → chunk-YZLYHLLI.js} +2 -2
  27. package/dist/{chunk-ESGZVPPC.js → chunk-ZWIL4EFL.js} +2 -2
  28. package/dist/config.js +2 -1
  29. package/dist/{create-agent-BI7UTJ6A.js → create-agent-7XHO6UIS.js} +5 -4
  30. package/dist/{create-annotation-SNM2J6S4.js → create-annotation-YQXRQC6A.js} +5 -4
  31. package/dist/{create-api-key-23MXYC2L.js → create-api-key-YUS36KBU.js} +5 -4
  32. package/dist/{create-dashboard-CPOOOIKK.js → create-dashboard-4Z5JO5LB.js} +5 -4
  33. package/dist/{create-dataset-BVXPVVY2.js → create-dataset-E6NMSVX7.js} +5 -4
  34. package/dist/{create-dataset-records-S7VFIIMR.js → create-dataset-records-SKHPRD2K.js} +5 -4
  35. package/dist/{create-evaluator-WJAAQOUE.js → create-evaluator-JXF5PF33.js} +5 -4
  36. package/dist/create-mcp-server.js +4 -3
  37. package/dist/{create-project-BF7LMZH5.js → create-project-CS2CB57Y.js} +5 -4
  38. package/dist/{create-prompt-LZ3QHSU4.js → create-prompt-F5RMX3L3.js} +4 -3
  39. package/dist/{create-prompt-tag-IF4J24QM.js → create-prompt-tag-GMRWTEFR.js} +4 -3
  40. package/dist/{create-scenario-BK4E5DEU.js → create-scenario-JDR2VB2H.js} +5 -4
  41. package/dist/{create-suite-HEQPNLND.js → create-suite-LZS6QIAF.js} +5 -4
  42. package/dist/{delete-agent-Y4T2G5PT.js → delete-agent-O3EEH4S5.js} +5 -4
  43. package/dist/{delete-annotation-KYDMQTZG.js → delete-annotation-XP6FQM7A.js} +5 -4
  44. package/dist/{delete-dashboard-3RW647WJ.js → delete-dashboard-RFUNYCNP.js} +5 -4
  45. package/dist/{delete-dataset-SZAHFHHS.js → delete-dataset-3MJFRDLP.js} +5 -4
  46. package/dist/{delete-dataset-records-ISHKGEBH.js → delete-dataset-records-WPMU5GL5.js} +5 -4
  47. package/dist/{delete-evaluator-VKIG5I3S.js → delete-evaluator-33SP2JFX.js} +5 -4
  48. package/dist/{delete-prompt-tag-VYYL4GTF.js → delete-prompt-tag-KNMZVSPB.js} +4 -3
  49. package/dist/{delete-workflow-SHNPYSWQ.js → delete-workflow-3NUXO46U.js} +5 -4
  50. package/dist/discover-evaluator-schema-XTAD4X7D.js +2191 -0
  51. package/dist/{discover-scenario-schema-MEEEVND7.js → discover-scenario-schema-FE64QK2D.js} +3 -1
  52. package/dist/{discover-schema-3T52ORPB.js → discover-schema-L6QDPON3.js} +3 -1
  53. package/dist/{get-agent-IZSP7AR5.js → get-agent-Y7MWZ65F.js} +5 -4
  54. package/dist/{get-analytics-EDFAYYIQ.js → get-analytics-TETCSOK6.js} +4 -3
  55. package/dist/{get-dashboard-WSAO6IQ4.js → get-dashboard-KHBEGZGL.js} +5 -4
  56. package/dist/{get-dataset-BEVNXQNF.js → get-dataset-RCMU5VAH.js} +5 -4
  57. package/dist/{get-evaluator-HCVXNPZR.js → get-evaluator-ADMKVWYZ.js} +5 -4
  58. package/dist/{get-project-H4V3GYRQ.js → get-project-RAK7URQJ.js} +5 -4
  59. package/dist/{get-prompt-EWRJVVGB.js → get-prompt-OY5B7TRI.js} +4 -3
  60. package/dist/{get-scenario-24YUJS5W.js → get-scenario-ZTAJJL5T.js} +5 -4
  61. package/dist/{get-simulation-run-UX2W6F6I.js → get-simulation-run-T2ZQWYH2.js} +5 -4
  62. package/dist/{get-suite-B65ZKZJA.js → get-suite-J7JVWO6O.js} +5 -4
  63. package/dist/{get-trace-ZR7MIITH.js → get-trace-K7U234Q4.js} +8 -10
  64. package/dist/{get-workflow-YROHUQJW.js → get-workflow-L5L2PSK3.js} +5 -4
  65. package/dist/http-security-EU3CHTY7.js +26 -0
  66. package/dist/http-server-YXHFPPGA.js +24706 -0
  67. package/dist/index.js +23280 -3135
  68. package/dist/{langwatch-api-annotations-OPEXNTPF.js → langwatch-api-annotations-FDAS6NQ7.js} +5 -4
  69. package/dist/{langwatch-api-dashboards-NPQM6QJI.js → langwatch-api-dashboards-JTVCS7ZJ.js} +5 -4
  70. package/dist/{langwatch-api-monitors-3L25OOOS.js → langwatch-api-monitors-3J7UIQME.js} +4 -3
  71. package/dist/{langwatch-api-secrets-ZFURUA62.js → langwatch-api-secrets-BUBSNCNO.js} +4 -3
  72. package/dist/{langwatch-api-triggers-XGXXT7DD.js → langwatch-api-triggers-EDFUCJN6.js} +4 -3
  73. package/dist/langwatch-api-workflows-NBJX5KOG.js +16 -0
  74. package/dist/{list-agents-6ZNOV7FK.js → list-agents-KFHKGPXV.js} +5 -4
  75. package/dist/{list-annotations-6P3IWILH.js → list-annotations-ZTVP4EEL.js} +5 -4
  76. package/dist/{list-api-keys-JJI62KD2.js → list-api-keys-CWD4UQT5.js} +5 -4
  77. package/dist/{list-dashboards-G2WJOXWB.js → list-dashboards-PWG6P6KR.js} +5 -4
  78. package/dist/{list-dataset-records-GWWVOCII.js → list-dataset-records-IOE7LQXS.js} +5 -4
  79. package/dist/{list-datasets-HBTH33X4.js → list-datasets-ZT4JYUDT.js} +5 -4
  80. package/dist/{list-evaluators-K5OGB6TX.js → list-evaluators-SJHC7VBH.js} +5 -4
  81. package/dist/{list-model-providers-JMOGZLSO.js → list-model-providers-PPD2WPD2.js} +5 -4
  82. package/dist/{list-projects-OXJC2NW5.js → list-projects-K7QAIX2I.js} +5 -4
  83. package/dist/{list-prompt-tags-J2HPHVAZ.js → list-prompt-tags-FOO6TNHB.js} +4 -3
  84. package/dist/{list-prompts-C7GR72XL.js → list-prompts-SC2HAUFG.js} +4 -3
  85. package/dist/{list-scenarios-JEPGIIDO.js → list-scenarios-F2WYNRIE.js} +5 -4
  86. package/dist/{list-simulation-runs-DRSAGPEC.js → list-simulation-runs-KF5THG5H.js} +5 -4
  87. package/dist/{list-suites-POTZ3ZT6.js → list-suites-CRY4VWW6.js} +5 -4
  88. package/dist/{list-workflows-QQZQIOHB.js → list-workflows-QSJWURCK.js} +5 -4
  89. package/dist/{rename-prompt-tag-4AAV2BS4.js → rename-prompt-tag-EKWU5QF3.js} +4 -3
  90. package/dist/report-issue-XYVAPAYJ.js +412 -0
  91. package/dist/{revoke-api-key-TONB4ADQ.js → revoke-api-key-FTRTZMOF.js} +5 -4
  92. package/dist/{run-agent-7QE3YEH7.js → run-agent-6SM4XNA7.js} +5 -4
  93. package/dist/{run-suite-Q66OSPO4.js → run-suite-NKZCJ2IN.js} +5 -4
  94. package/dist/{run-workflow-RWX2D752.js → run-workflow-QPXC627O.js} +5 -4
  95. package/dist/{search-traces-M5E5VB4X.js → search-traces-A6HFKPLJ.js} +10 -3
  96. package/dist/{set-model-provider-XENT5D2P.js → set-model-provider-3WKT63ZN.js} +5 -4
  97. package/dist/{update-agent-GML25EE3.js → update-agent-SMAEHLXH.js} +5 -4
  98. package/dist/{update-dataset-R4MM6NNY.js → update-dataset-BDUK7TUO.js} +5 -4
  99. package/dist/{update-dataset-record-FN4BL5P4.js → update-dataset-record-4ZSDRBSW.js} +5 -4
  100. package/dist/{update-evaluator-LH2RDDB5.js → update-evaluator-LS6TIBY7.js} +5 -4
  101. package/dist/{update-project-H53IHRBB.js → update-project-LKVYXENH.js} +5 -4
  102. package/dist/{update-prompt-M33OTTAJ.js → update-prompt-ODCRQRN6.js} +4 -3
  103. package/dist/{update-scenario-FKTQRAFQ.js → update-scenario-NEXCAQI7.js} +5 -4
  104. package/dist/{update-suite-JJTNOTXN.js → update-suite-SNO3GS7T.js} +5 -4
  105. package/package.json +20 -18
  106. package/pyproject.toml +10 -2
  107. package/src/__tests__/all-tools.integration.test.ts +56 -120
  108. package/src/__tests__/config.unit.test.ts +33 -0
  109. package/src/__tests__/docker.integration.test.ts +91 -7
  110. package/src/__tests__/documentation-fetch-security.integration.test.ts +182 -0
  111. package/src/__tests__/feature-map-tool-drift.unit.test.ts +93 -0
  112. package/src/__tests__/http-security-origin.unit.test.ts +97 -0
  113. package/src/__tests__/http-security-sessions.unit.test.ts +209 -0
  114. package/src/__tests__/http-security-verifier.unit.test.ts +248 -0
  115. package/src/__tests__/http-server-allocation.integration.test.ts +279 -0
  116. package/src/__tests__/http-server-origin.integration.test.ts +169 -0
  117. package/src/__tests__/http-server-session-auth.integration.test.ts +341 -0
  118. package/src/__tests__/http-transport.integration.test.ts +85 -87
  119. package/src/__tests__/langwatch-api.unit.test.ts +206 -0
  120. package/src/__tests__/public-http-request-security.integration.test.ts +118 -0
  121. package/src/__tests__/report-issue.integration.test.ts +147 -0
  122. package/src/__tests__/support/http-server-harness.ts +138 -0
  123. package/src/__tests__/tools.unit.test.ts +24 -0
  124. package/src/config.ts +17 -2
  125. package/src/create-mcp-server.ts +43 -29
  126. package/src/documentation-fetch.ts +111 -0
  127. package/src/http-security.ts +492 -0
  128. package/src/http-server.ts +536 -152
  129. package/src/index.ts +49 -2
  130. package/src/langwatch-api-agents.ts +2 -2
  131. package/src/langwatch-api.ts +138 -8
  132. package/src/public-http-request.ts +242 -0
  133. package/src/tools/discover-evaluator-schema.ts +2 -2
  134. package/src/tools/get-trace.ts +2 -12
  135. package/src/tools/report-issue.ts +150 -0
  136. package/src/tools/search-traces.ts +4 -0
  137. package/src/utils/format-evaluations.ts +20 -0
  138. package/tsconfig.json +8 -1
  139. package/uv.lock +261 -207
  140. package/dist/chunk-IUP5IHBD.js +0 -71
  141. package/dist/discover-evaluator-schema-H23XCLNE.js +0 -1402
  142. package/dist/http-server-GXVEDA7G.js +0 -226
  143. package/dist/langwatch-api-workflows-CXCTTCXT.js +0 -15
  144. package/pnpm-workspace.yaml +0 -17
@@ -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
- * In-memory store for OAuth access tokens.
14
- * Maps token → API key so we can resolve Bearer tokens from either
15
- * direct API keys or OAuth-issued access tokens.
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
- const oauthTokens = new Map<string, { apiKey: string; expiresAt: number }>();
55
+ type Authenticate = (args: {
56
+ req: Request;
57
+ res: Response;
58
+ expectedApiKey?: string;
59
+ }) => Promise<string | null>;
18
60
 
19
61
  /**
20
- * Extracts the API key from the request's Authorization header.
21
- * Accepts both direct API keys and OAuth-issued access tokens.
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 extractBearerToken(req: Request): string | null {
65
+ function readBearerToken(req: Request): string | null {
25
66
  const authHeader = req.headers.authorization;
26
- if (!authHeader?.startsWith("Bearer ")) return null;
27
- const token = authHeader.slice(7) || null;
28
- if (!token) return null;
67
+ if (!authHeader) return null;
29
68
 
30
- // Check if this is an OAuth-issued access token
31
- const oauthEntry = oauthTokens.get(token);
32
- if (oauthEntry) {
33
- if (Date.now() < oauthEntry.expiresAt) {
34
- return oauthEntry.apiKey;
35
- }
36
- // Token expired, clean up
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
- // Otherwise treat it as a direct API key
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
- * Per-session state: the transport and the API key provided at session creation.
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
- interface SessionState {
56
- transport: StreamableHTTPServerTransport;
57
- apiKey: string;
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
- * Starts an Express HTTP server with Streamable HTTP and legacy SSE transports
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
- * Endpoints:
82
- * - GET /health - Health check for Kubernetes probes (no auth)
83
- * - POST/GET/DELETE /mcp - Streamable HTTP transport (modern)
84
- * - GET /sse - Legacy SSE transport (backwards compatibility)
85
- * - POST /messages - Legacy SSE message endpoint
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
- export async function startHttpServer({
88
- port,
156
+ function createOriginMiddleware({
157
+ allowedOrigins,
89
158
  }: {
90
- port: number;
91
- }): Promise<{ server: Server; port: number }> {
92
- const app = express();
93
- app.set("trust proxy", true);
94
- app.use(express.json());
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
- // CORS middleware for cross-origin requests (ChatGPT/Claude Chat)
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
- if (_req.method === "OPTIONS") {
108
- res.sendStatus(200);
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
- // Health check endpoint for Kubernetes probes
115
- app.get("/health", (_req: Request, res: Response) => {
116
- res.json({ status: "ok" });
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
- // --- OAuth 2.0 endpoints (for Claude Desktop and other OAuth-only clients) ---
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
- (_req: Request, res: Response) => {
124
- const baseUrl = `${_req.protocol}://${_req.get("host")}`;
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
- // URL-encoded body parser for OAuth token endpoint (RFC 6749 requires
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 grantType = req.body.grant_type;
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 (grantType !== "client_credentials") {
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
- // Accept client_secret as the LangWatch API key.
154
- // client_id is ignored — the API key identifies the project.
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
- const expiresIn = 3600; // 1 hour
167
- const accessToken = generateAccessToken();
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() + expiresIn * 1000,
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: expiresIn,
343
+ expires_in: OAUTH_TOKEN_TTL_SECONDS,
178
344
  scope: "mcp:tools",
179
345
  });
180
346
  }
181
347
  );
348
+ }
182
349
 
183
- // --- Streamable HTTP transport (modern) ---
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 sessions: Record<string, SessionState> = {};
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.headers["mcp-session-id"] as string | undefined;
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
- if (sessionId && sessions[sessionId]) {
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 = extractBearerToken(req);
385
+ const apiKey = await authenticate({ req, res });
386
+ if (!apiKey) return;
200
387
 
201
- if (!apiKey) {
202
- res.status(401).json({
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
- sessions[id] = { transport, apiKey };
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
- await handleWithSessionConfig(apiKey, () =>
223
- sessionServer.connect(transport)
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
- await handleWithSessionConfig(apiKey, () =>
227
- transport.handleRequest(req, res, req.body)
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 — no session ID or not an initialize 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.headers["mcp-session-id"] as string | undefined;
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
- if (sessionId && sessions[sessionId]) {
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
- } else {
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
- const sessionId = req.headers["mcp-session-id"] as string | undefined;
254
- if (sessionId && sessions[sessionId]) {
255
- const session = sessions[sessionId];
256
- await session.transport.close();
257
- delete sessions[sessionId];
258
- res.status(200).json({ status: "session closed" });
259
- } else {
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
- interface SseSessionState {
267
- transport: SSEServerTransport;
268
- apiKey: string;
269
- }
483
+ sessions.remove(sessionId);
484
+ await session.transport.close();
485
+ res.status(200).json({ status: "session closed" });
486
+ });
487
+ }
270
488
 
271
- const sseSessions: Record<string, SseSessionState> = {};
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
- // SSE/EventSource clients cannot set custom Authorization headers, so
275
- // the apiKey fallback via ?apiKey= is the standard pattern for SSE
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 (!apiKey) {
285
- res.status(401).json({
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[transport.sessionId] = { transport, apiKey };
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
- delete sseSessions[transport.sessionId];
516
+ sseSessions.remove(transport.sessionId);
300
517
  });
301
518
 
302
- await handleWithSessionConfig(apiKey, () =>
303
- sessionServer.connect(transport)
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
- // Handle POST messages — mount at both /messages and /sse/messages
308
- // because some clients resolve the relative /messages URL differently
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
- if (!sessionId || !sseSessions[sessionId]) {
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
- const session = sseSessions[sessionId];
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, "0.0.0.0", () => {
701
+ const server = app.listen(port, bindHost, () => {
328
702
  const addr = server.address();
329
- const resolvedPort =
330
- typeof addr === "object" && addr ? addr.port : port;
331
- resolve({ server, port: resolvedPort });
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
  }