@sonoma-security/mcp-gateway 0.1.13 → 0.1.15

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.
@@ -1,34 +1,94 @@
1
1
  /**
2
2
  * HTTP Proxy Server for MCP Gateway
3
3
  *
4
- * Exposes per-server HTTP endpoints that transparently proxy MCP requests
5
- * to upstream servers. This allows plugins to keep their skills active
6
- * while traffic flows through the gateway for policy enforcement and telemetry.
4
+ * Transparent HTTP passthrough that forwards all MCP traffic (including OAuth
5
+ * discovery, auth headers, SSE streams) to upstream servers. Parses JSON-RPC
6
+ * request/response bodies for telemetry and policy enforcement.
7
7
  *
8
8
  * Architecture:
9
- * Plugin (http://localhost:{port}/proxy/{serverName}) -> HTTP Proxy -> Upstream MCP Server
9
+ * Client (http://localhost:{port}/proxy/{serverName}/mcp)
10
+ * -> HTTP Proxy (parse JSON-RPC, enforce policy, record telemetry)
11
+ * -> Upstream MCP Server (https://mcp.example.com/mcp)
10
12
  *
11
- * Each upstream server gets its own MCP Server + StreamableHTTPServerTransport,
12
- * which delegates tools/list and tools/call to the upstream Client connection
13
- * managed by the parent McpGateway.
13
+ * Unlike the previous MCP-level proxy (which terminated MCP sessions and
14
+ * created its own Server/Transport), this is a true HTTP passthrough.
15
+ * OAuth, SSE, session headers all flow through untouched.
14
16
  */
15
17
  import { createServer } from "node:http";
16
- import { randomUUID } from "node:crypto";
17
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
18
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
19
- import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
20
- const TOOL_CALL_TIMEOUT_MS = 30_000;
18
+ /**
19
+ * Bind an HTTP server to the given port immediately, returning both the server
20
+ * and a promise that resolves once it's listening. The server responds to any
21
+ * request with 503 Service Unavailable until `adoptHttpServer` swaps in a real
22
+ * handler. This lets the caller hold the port during slow startup work (auth,
23
+ * policy fetch, upstream connections) so clients probing the port get
24
+ * "service starting" instead of ECONNREFUSED.
25
+ *
26
+ * On EADDRINUSE, the bind is retried for up to ~10s. The launchd/Scheduled
27
+ * Task supervisor races its own bootout against the new process, and the
28
+ * old owner can hold the socket briefly during teardown. Without the retry
29
+ * the fresh daemon crashes immediately, and each crash-loop wipes any
30
+ * in-memory MCP session state, stranding clients with stale session IDs.
31
+ */
32
+ export function preListenHttpServer(port) {
33
+ const server = createServer((_req, res) => {
34
+ res.setHeader("Access-Control-Allow-Origin", "*");
35
+ res.setHeader("Retry-After", "1");
36
+ res.writeHead(503, { "Content-Type": "application/json" });
37
+ res.end(JSON.stringify({ error: "gateway_starting" }));
38
+ });
39
+ const listening = new Promise((resolve, reject) => {
40
+ const deadline = Date.now() + 10_000;
41
+ const tryListen = () => {
42
+ const onError = (err) => {
43
+ if (err.code === "EADDRINUSE" && Date.now() < deadline) {
44
+ setTimeout(tryListen, 500);
45
+ return;
46
+ }
47
+ reject(err);
48
+ };
49
+ server.once("error", onError);
50
+ server.listen(port, () => {
51
+ server.off("error", onError);
52
+ resolve();
53
+ });
54
+ };
55
+ tryListen();
56
+ });
57
+ return { server, listening };
58
+ }
21
59
  export class HttpProxyServer {
22
60
  httpServer;
23
61
  callbacks;
24
- sessions = new Map();
25
62
  port;
26
- constructor(port, callbacks) {
63
+ // Track the last server that triggered OAuth discovery so we can route
64
+ // root-level auth requests (the SDK strips the path from the server URL
65
+ // when constructing the authorization server URL, so root-level requests
66
+ // like /.well-known/oauth-authorization-server and /register lose the
67
+ // server name context).
68
+ lastOAuthServerName;
69
+ // Cache of upstream AS metadata per server so we can route /authorize,
70
+ // /token, /register to the real upstream endpoints (which may live at
71
+ // non-root paths like /oauth/authorize).
72
+ upstreamAsEndpoints = new Map();
73
+ constructor(port, callbacks, preboundServer) {
27
74
  this.port = port;
28
75
  this.callbacks = callbacks;
29
- this.httpServer = createServer(this.handleRequest.bind(this));
76
+ if (preboundServer) {
77
+ this.httpServer = preboundServer;
78
+ this.preboundAdopted = true;
79
+ this.httpServer.removeAllListeners("request");
80
+ this.httpServer.on("request", this.handleRequest.bind(this));
81
+ }
82
+ else {
83
+ this.httpServer = createServer(this.handleRequest.bind(this));
84
+ }
30
85
  }
86
+ preboundAdopted = false;
31
87
  async start() {
88
+ if (this.preboundAdopted) {
89
+ this.callbacks.log(`HTTP proxy handler attached on port ${this.port} (prebound)`);
90
+ return;
91
+ }
32
92
  return new Promise((resolve, reject) => {
33
93
  this.httpServer.on("error", reject);
34
94
  this.httpServer.listen(this.port, () => {
@@ -38,17 +98,6 @@ export class HttpProxyServer {
38
98
  });
39
99
  }
40
100
  async stop() {
41
- // Close all sessions
42
- for (const [sessionId, session] of this.sessions) {
43
- try {
44
- await session.transport.close();
45
- await session.server.close();
46
- }
47
- catch {
48
- // Ignore close errors
49
- }
50
- this.sessions.delete(sessionId);
51
- }
52
101
  return new Promise((resolve) => {
53
102
  this.httpServer.close(() => resolve());
54
103
  });
@@ -62,115 +111,245 @@ export class HttpProxyServer {
62
111
  }
63
112
  async handleRequest(req, res) {
64
113
  const url = new URL(req.url ?? "/", `http://localhost:${this.port}`);
114
+ const accept = req.headers["accept"] ?? "";
115
+ const contentType = req.headers["content-type"] ?? "";
116
+ const mcpSessionId = req.headers["mcp-session-id"] ?? "";
117
+ this.callbacks.log(`[http-proxy] ${req.method} ${url.pathname} accept=${accept} ct=${contentType} session=${mcpSessionId}`);
65
118
  // CORS headers for local access
66
119
  res.setHeader("Access-Control-Allow-Origin", "*");
67
120
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
68
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Last-Event-ID");
121
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, Last-Event-ID, Accept, MCP-Protocol-Version");
69
122
  res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
70
123
  if (req.method === "OPTIONS") {
71
124
  res.writeHead(204);
72
125
  res.end();
73
126
  return;
74
127
  }
75
- // Forward OAuth protected resource discovery to the upstream server so the
76
- // MCP SDK learns where the real authorization endpoint is (e.g., slack.com)
77
- // instead of constructing localhost:19880/authorize which doesn't exist.
78
- if (url.pathname.startsWith("/.well-known/oauth-protected-resource")) {
79
- await this.handleOAuthDiscovery(url, res);
128
+ // OAuth discovery: rewrite path and forward to upstream
129
+ // e.g., /.well-known/oauth-protected-resource/proxy/sentry/mcp
130
+ // -> https://mcp.sentry.dev/.well-known/oauth-protected-resource
131
+ const wellKnownMatch = url.pathname.match(/^\/\.well-known\/([^/]+)\/proxy\/([^/]+)\/mcp$/);
132
+ if (wellKnownMatch) {
133
+ const wellKnownType = wellKnownMatch[1];
134
+ const serverName = decodeURIComponent(wellKnownMatch[2]);
135
+ this.lastOAuthServerName = serverName;
136
+ await this.forwardOAuthDiscovery(wellKnownType, serverName, req, res);
80
137
  return;
81
138
  }
82
- // Return 404 for /authorize and other .well-known endpoints.
83
- if (url.pathname.startsWith("/.well-known/") || url.pathname === "/authorize") {
84
- res.writeHead(404);
85
- res.end();
139
+ // In-path .well-known: /proxy/{server}/mcp/.well-known/{type}
140
+ // Claude Code also probes this pattern for OpenID discovery.
141
+ const inPathWellKnown = url.pathname.match(/^\/proxy\/([^/]+)\/mcp\/\.well-known\/(.+)$/);
142
+ if (inPathWellKnown) {
143
+ const serverName = decodeURIComponent(inPathWellKnown[1]);
144
+ this.lastOAuthServerName = serverName;
145
+ await this.forwardOAuthDiscovery(inPathWellKnown[2], serverName, req, res);
146
+ return;
147
+ }
148
+ // Root-level OAuth requests: the MCP SDK constructs the authorization
149
+ // server URL as `new URL('/', serverUrl)` which strips the path, losing
150
+ // the server name. Forward these to the last server that triggered
151
+ // path-aware OAuth discovery.
152
+ const isRootOAuth = url.pathname.startsWith("/.well-known/")
153
+ || url.pathname === "/authorize"
154
+ || url.pathname === "/register"
155
+ || url.pathname === "/token"
156
+ || url.pathname.startsWith("/oauth/");
157
+ if (isRootOAuth) {
158
+ // For stdio servers, synthesize OAuth responses so Claude Code doesn't
159
+ // get stuck on "needs authentication". The proxy acts as a no-op OAuth
160
+ // server: it issues a dummy token that the SDK includes in POST headers,
161
+ // and the bridge ignores the Authorization header entirely.
162
+ if (this.lastOAuthServerName && this.callbacks.hasStdioUpstream(this.lastOAuthServerName)) {
163
+ await this.handleStdioOAuth(url, req, res);
164
+ return;
165
+ }
166
+ if (this.lastOAuthServerName) {
167
+ const upstreamUrl = this.callbacks.getUpstreamUrl(this.lastOAuthServerName);
168
+ if (upstreamUrl) {
169
+ const upstreamOrigin = new URL(upstreamUrl).origin;
170
+ const serverName = this.lastOAuthServerName;
171
+ this.callbacks.log(`Root OAuth request ${url.pathname} -> forwarding to ${upstreamOrigin} (server: ${serverName})`);
172
+ // /.well-known/oauth-authorization-server at root: fetch upstream
173
+ // metadata and rewrite endpoints through forwardOAuthDiscovery.
174
+ const rootWellKnown = url.pathname.match(/^\/\.well-known\/(.+)$/);
175
+ if (rootWellKnown) {
176
+ await this.forwardOAuthDiscovery(rootWellKnown[1], serverName, req, res);
177
+ return;
178
+ }
179
+ // Look up the real upstream endpoint for this pathname. The upstream's
180
+ // AS metadata may place /authorize, /token, /register at non-root
181
+ // paths (e.g., betterstack serves /oauth/token).
182
+ const endpoints = this.upstreamAsEndpoints.get(serverName);
183
+ const upstreamEndpoint = endpoints
184
+ ? (url.pathname === "/authorize" ? endpoints.authorization_endpoint
185
+ : url.pathname === "/token" ? endpoints.token_endpoint
186
+ : url.pathname === "/register" ? endpoints.registration_endpoint
187
+ : url.pathname === "/revoke" ? endpoints.revocation_endpoint
188
+ : url.pathname === "/introspect" ? endpoints.introspection_endpoint
189
+ : url.pathname === "/userinfo" ? endpoints.userinfo_endpoint
190
+ : undefined)
191
+ : undefined;
192
+ const target = upstreamEndpoint
193
+ ? new URL(upstreamEndpoint)
194
+ : new URL(`${upstreamOrigin}${url.pathname}`);
195
+ // /authorize is browser-navigated; 302-redirect the browser to the
196
+ // upstream's authorize endpoint with the `resource` param rewritten
197
+ // from our proxy URL to the upstream's canonical resource URI.
198
+ if (url.pathname === "/authorize") {
199
+ const proxyResource = `http://localhost:${this.port}/proxy/${encodeURIComponent(serverName)}/mcp`;
200
+ for (const [k, v] of url.searchParams) {
201
+ target.searchParams.append(k, k === "resource" && v === proxyResource ? upstreamUrl : v);
202
+ }
203
+ res.writeHead(302, { Location: target.toString() });
204
+ res.end();
205
+ return;
206
+ }
207
+ await this.forwardRootOAuth(target.origin, target.pathname, url.search, req, res, serverName, upstreamUrl);
208
+ return;
209
+ }
210
+ }
211
+ this.callbacks.log(`Root OAuth request ${url.pathname} -> no server context, returning 404`);
212
+ res.writeHead(404, { "Content-Type": "application/json" });
213
+ res.end(JSON.stringify({ error: "not_found" }));
86
214
  return;
87
215
  }
88
- // Parse /proxy/{serverName}/mcp
89
- const match = url.pathname.match(/^\/proxy\/([^/]+)\/mcp$/);
90
- if (!match) {
216
+ // Parse /proxy/{serverName}/mcp (with optional trailing paths for future MCP extensions)
217
+ const proxyMatch = url.pathname.match(/^\/proxy\/([^/]+)\/mcp(\/.*)?$/);
218
+ if (!proxyMatch) {
91
219
  res.writeHead(404, { "Content-Type": "application/json" });
92
220
  res.end(JSON.stringify({ error: "Not found. Use /proxy/{serverName}/mcp" }));
93
221
  return;
94
222
  }
95
- const serverName = decodeURIComponent(match[1]);
96
- // Get or create session
97
- const sessionId = req.headers["mcp-session-id"];
98
- if (req.method === "GET" || req.method === "DELETE") {
99
- // SSE or session termination: route to existing session
100
- if (sessionId && this.sessions.has(sessionId)) {
101
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by this.sessions.has(sessionId) above
102
- const session = this.sessions.get(sessionId);
103
- await session.transport.handleRequest(req, res);
104
- if (req.method === "DELETE") {
105
- this.sessions.delete(sessionId);
106
- }
107
- return;
108
- }
109
- if (req.method === "DELETE") {
110
- res.writeHead(200);
111
- res.end();
112
- return;
113
- }
114
- // GET without session: not valid
115
- res.writeHead(400, { "Content-Type": "application/json" });
116
- res.end(JSON.stringify({ error: "Session required for GET requests" }));
223
+ const serverName = decodeURIComponent(proxyMatch[1]);
224
+ const extraPath = proxyMatch[2] ?? "";
225
+ // Prefer the stdio bridge when the gateway has an active connection to
226
+ // the server. This bypasses OAuth discovery issues: the gateway already
227
+ // authenticated with the upstream, so the bridge reuses that connection.
228
+ // HTTP passthrough is only used when there's no gateway-level connection.
229
+ if (this.callbacks.hasStdioUpstream(serverName)) {
230
+ await this.bridgeToStdio(serverName, req, res);
117
231
  return;
118
232
  }
119
- // POST: create new session or route to existing
120
- if (sessionId && this.sessions.has(sessionId)) {
121
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by this.sessions.has(sessionId) above
122
- await this.sessions.get(sessionId).transport.handleRequest(req, res);
233
+ const upstreamUrl = this.callbacks.getUpstreamUrl(serverName);
234
+ if (!upstreamUrl) {
235
+ res.writeHead(502, { "Content-Type": "application/json" });
236
+ res.end(JSON.stringify({ error: `No upstream URL configured for server: ${serverName}` }));
123
237
  return;
124
238
  }
125
- // New session: create a proxy MCP server for this server name.
126
- // The session is created immediately (even if the upstream isn't connected
127
- // yet) so the MCP handshake completes and Claude Code marks it as
128
- // "connected". tools/list will return empty until the upstream connects,
129
- // then we send toolListChanged.
130
- const session = await this.createProxySession(serverName);
131
- await session.transport.handleRequest(req, res);
239
+ await this.forwardMcpRequest(serverName, upstreamUrl, extraPath, req, res);
132
240
  }
133
241
  /**
134
- * Forward OAuth protected resource metadata from the upstream server.
135
- *
136
- * The MCP SDK queries /.well-known/oauth-protected-resource{path} to discover
137
- * where to send the user for authentication. We forward this from the real
138
- * upstream (e.g., mcp.slack.com) but rewrite the `resource` field to match
139
- * the proxy URL so the SDK's validation passes.
242
+ * Forward OAuth discovery requests to the upstream server.
243
+ * Rewrites the `resource` field in oauth-protected-resource responses
244
+ * to match the proxy URL so the SDK's validation passes.
140
245
  */
141
- async handleOAuthDiscovery(url, res) {
142
- // Extract server name from path suffix: /.well-known/oauth-protected-resource/proxy/{name}/mcp
143
- const pathMatch = url.pathname.match(/^\/\.well-known\/oauth-protected-resource\/proxy\/([^/]+)\/mcp$/);
144
- if (!pathMatch) {
145
- res.writeHead(404);
146
- res.end();
147
- return;
148
- }
149
- const serverName = decodeURIComponent(pathMatch[1]);
150
- const upstreamUrl = this.callbacks.getUpstreamUrl?.(serverName);
151
- if (!upstreamUrl) {
152
- res.writeHead(404);
153
- res.end();
246
+ async forwardOAuthDiscovery(wellKnownType, serverName, _req, res) {
247
+ const upstreamUrl = this.callbacks.getUpstreamUrl(serverName);
248
+ const hasGatewayConnection = this.callbacks.hasStdioUpstream(serverName);
249
+ this.callbacks.log(`OAuth discovery: type=${wellKnownType}, server=${serverName}, upstream=${upstreamUrl ?? "STDIO"}, gatewayConnected=${hasGatewayConnection}`);
250
+ if (!upstreamUrl || hasGatewayConnection) {
251
+ // Server is bridged through the gateway (either stdio-only or HTTP with
252
+ // an active gateway connection). Synthesize OAuth responses so Claude
253
+ // Code's proactive auth flow succeeds and proceeds to the MCP POST,
254
+ // which the bridge handles using the gateway's own authenticated session.
255
+ if (wellKnownType === "oauth-protected-resource") {
256
+ const proxyUrl = `http://localhost:${this.port}/proxy/${encodeURIComponent(serverName)}/mcp`;
257
+ const synthesized = {
258
+ resource: proxyUrl,
259
+ authorization_servers: [`http://localhost:${this.port}`],
260
+ };
261
+ this.callbacks.log(`OAuth discovery: synthesized oauth-protected-resource for stdio server ${serverName}`);
262
+ res.writeHead(200, { "Content-Type": "application/json" });
263
+ res.end(JSON.stringify(synthesized));
264
+ return;
265
+ }
266
+ if (wellKnownType === "oauth-authorization-server" || wellKnownType === "openid-configuration") {
267
+ const metadata = this.buildStdioAuthServerMetadata();
268
+ this.callbacks.log(`OAuth discovery: synthesized ${wellKnownType} for stdio server ${serverName}`);
269
+ res.writeHead(200, { "Content-Type": "application/json" });
270
+ res.end(JSON.stringify(metadata));
271
+ return;
272
+ }
273
+ res.writeHead(404, { "Content-Type": "application/json" });
274
+ res.end(JSON.stringify({ error: "not_found" }));
154
275
  return;
155
276
  }
156
277
  try {
157
- const upstreamOrigin = new URL(upstreamUrl).origin;
158
- const discoveryUrl = `${upstreamOrigin}/.well-known/oauth-protected-resource`;
278
+ const upstream = new URL(upstreamUrl);
279
+ const upstreamOrigin = upstream.origin;
280
+ // RFC 9728: oauth-protected-resource is served at
281
+ // /.well-known/oauth-protected-resource{path}, where {path} is the
282
+ // resource's path (e.g., "/mcp" for https://mcp.sentry.dev/mcp).
283
+ // RFC 8414 oauth-authorization-server lives at origin root.
284
+ const pathSuffix = wellKnownType === "oauth-protected-resource"
285
+ ? upstream.pathname.replace(/\/$/, "")
286
+ : "";
287
+ const discoveryUrl = `${upstreamOrigin}/.well-known/${wellKnownType}${pathSuffix}`;
159
288
  const response = await fetch(discoveryUrl, {
160
289
  headers: { Accept: "application/json" },
161
290
  redirect: "follow",
162
291
  });
163
292
  if (!response.ok) {
293
+ // If the upstream doesn't support oauth-protected-resource, synthesize
294
+ // one. Without this, the SDK falls back to root-level discovery at
295
+ // http://localhost:{port}/.well-known/oauth-authorization-server which
296
+ // has no server name in the path and can't be routed.
297
+ //
298
+ // The synthesized response tells the SDK:
299
+ // 1. The resource is our proxy URL (passes checkResourceAllowed)
300
+ // 2. The authorization server is the upstream origin (direct auth)
301
+ if (wellKnownType === "oauth-protected-resource" && response.status === 404) {
302
+ const synthesized = {
303
+ resource: `http://localhost:${this.port}/proxy/${encodeURIComponent(serverName)}/mcp`,
304
+ authorization_servers: [upstreamOrigin],
305
+ };
306
+ this.callbacks.log(`OAuth discovery: synthesized oauth-protected-resource for ${serverName}`);
307
+ res.writeHead(200, { "Content-Type": "application/json" });
308
+ res.end(JSON.stringify(synthesized));
309
+ return;
310
+ }
164
311
  res.writeHead(response.status);
165
312
  res.end();
166
313
  return;
167
314
  }
168
315
  const metadata = await response.json();
169
- // Rewrite the resource field to match the proxy URL so the SDK's
170
- // "protected resource does not match" validation passes
171
- metadata.resource = `http://localhost:${this.port}/proxy/${encodeURIComponent(serverName)}/mcp`;
316
+ // Rewrite discovery so the client treats our proxy as both the resource
317
+ // and the authorization server. This lets the SDK's checkResourceAllowed
318
+ // pass (resource == server URL), while /authorize, /token, /register
319
+ // route through our proxy so we can rewrite `resource` to the upstream's
320
+ // canonical URI (RFC 8707) before forwarding.
321
+ if (wellKnownType === "oauth-protected-resource") {
322
+ metadata.resource = `http://localhost:${this.port}/proxy/${encodeURIComponent(serverName)}/mcp`;
323
+ metadata.authorization_servers = [`http://localhost:${this.port}`];
324
+ }
325
+ else if (wellKnownType === "oauth-authorization-server"
326
+ || wellKnownType === "openid-configuration") {
327
+ const proxyOrigin = `http://localhost:${this.port}`;
328
+ // Cache the real upstream endpoints so /authorize, /token, /register
329
+ // on our proxy can forward to their true locations (which may be at
330
+ // non-root paths like /oauth/token).
331
+ this.upstreamAsEndpoints.set(serverName, {
332
+ authorization_endpoint: typeof metadata.authorization_endpoint === "string" ? metadata.authorization_endpoint : undefined,
333
+ token_endpoint: typeof metadata.token_endpoint === "string" ? metadata.token_endpoint : undefined,
334
+ registration_endpoint: typeof metadata.registration_endpoint === "string" ? metadata.registration_endpoint : undefined,
335
+ revocation_endpoint: typeof metadata.revocation_endpoint === "string" ? metadata.revocation_endpoint : undefined,
336
+ introspection_endpoint: typeof metadata.introspection_endpoint === "string" ? metadata.introspection_endpoint : undefined,
337
+ userinfo_endpoint: typeof metadata.userinfo_endpoint === "string" ? metadata.userinfo_endpoint : undefined,
338
+ });
339
+ metadata.issuer = proxyOrigin;
340
+ metadata.authorization_endpoint = `${proxyOrigin}/authorize`;
341
+ metadata.token_endpoint = `${proxyOrigin}/token`;
342
+ metadata.registration_endpoint = `${proxyOrigin}/register`;
343
+ if (metadata.revocation_endpoint)
344
+ metadata.revocation_endpoint = `${proxyOrigin}/revoke`;
345
+ if (metadata.introspection_endpoint)
346
+ metadata.introspection_endpoint = `${proxyOrigin}/introspect`;
347
+ if (metadata.userinfo_endpoint)
348
+ metadata.userinfo_endpoint = `${proxyOrigin}/userinfo`;
349
+ }
350
+ const body = JSON.stringify(metadata);
172
351
  res.writeHead(200, { "Content-Type": "application/json" });
173
- res.end(JSON.stringify(metadata));
352
+ res.end(body);
174
353
  }
175
354
  catch {
176
355
  res.writeHead(502);
@@ -178,139 +357,519 @@ export class HttpProxyServer {
178
357
  }
179
358
  }
180
359
  /**
181
- * Create a new MCP Server that proxies requests to an upstream Client.
182
- * Each session gets its own Server + Transport pair.
183
- *
184
- * The session is created immediately even if the upstream isn't connected yet.
185
- * tools/list returns empty tools in that case, and tool calls return errors.
186
- * When the upstream connects, the gateway sends toolListChanged which triggers
187
- * the client to re-fetch tools.
360
+ * Forward root-level OAuth requests (/token, /register) to the upstream.
361
+ * Rewrites the `resource` parameter from the proxy URL to the upstream's
362
+ * canonical resource URI (RFC 8707) so the upstream AS accepts the token
363
+ * request bound to the resource it knows about.
188
364
  */
189
- async createProxySession(serverName) {
190
- const callbacks = this.callbacks;
191
- const server = new Server({
192
- name: `sonoma-proxy-${serverName}`,
193
- version: "0.1.0",
194
- }, {
195
- capabilities: {
196
- tools: { listChanged: true },
197
- },
198
- });
199
- // Proxy tools/list: forward to upstream client if connected, empty otherwise
200
- server.setRequestHandler(ListToolsRequestSchema, async () => {
201
- const upstream = callbacks.getUpstreams().get(serverName);
202
- if (!upstream?.connected) {
203
- return { tools: [] };
365
+ async forwardRootOAuth(upstreamOrigin, pathname, search, req, res, serverName, upstreamUrl) {
366
+ let targetSearch = search;
367
+ let body;
368
+ if (req.method === "POST") {
369
+ body = await readBody(req);
370
+ }
371
+ if (serverName && upstreamUrl) {
372
+ const proxyResource = `http://localhost:${this.port}/proxy/${encodeURIComponent(serverName)}/mcp`;
373
+ // Rewrite `resource` in query string.
374
+ if (targetSearch.includes("resource=")) {
375
+ const params = new URLSearchParams(targetSearch);
376
+ if (params.get("resource") === proxyResource) {
377
+ params.set("resource", upstreamUrl);
378
+ targetSearch = `?${params.toString()}`;
379
+ }
380
+ }
381
+ // Rewrite `resource` in form-urlencoded body (e.g., /token exchange).
382
+ if (body) {
383
+ const contentType = String(req.headers["content-type"] ?? "");
384
+ if (contentType.includes("application/x-www-form-urlencoded")) {
385
+ const params = new URLSearchParams(body.toString());
386
+ if (params.get("resource") === proxyResource) {
387
+ params.set("resource", upstreamUrl);
388
+ body = Buffer.from(params.toString());
389
+ }
390
+ }
204
391
  }
392
+ }
393
+ const targetUrl = `${upstreamOrigin}${pathname}${targetSearch}`;
394
+ const forwardHeaders = {};
395
+ for (const [key, value] of Object.entries(req.headers)) {
396
+ if (key === "host" || key === "connection" || key === "transfer-encoding" || key === "content-length")
397
+ continue;
398
+ if (value) {
399
+ forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
400
+ }
401
+ }
402
+ // Prevent compressed responses (e.g., zstd from Cloudflare) that the
403
+ // SDK's fetch may not decompress, causing JSON parse failures
404
+ forwardHeaders["accept-encoding"] = "identity";
405
+ try {
406
+ const response = await fetch(targetUrl, {
407
+ method: req.method ?? "GET",
408
+ headers: forwardHeaders,
409
+ body: body ? new Uint8Array(body) : undefined,
410
+ redirect: "follow",
411
+ });
412
+ const responseHeaders = {};
413
+ response.headers.forEach((value, key) => {
414
+ if (key === "transfer-encoding" || key === "connection")
415
+ return;
416
+ if (key === "content-encoding")
417
+ return;
418
+ responseHeaders[key] = value;
419
+ });
420
+ const responseBody = new Uint8Array(await response.arrayBuffer());
421
+ res.writeHead(response.status, responseHeaders);
422
+ res.end(responseBody);
423
+ }
424
+ catch {
425
+ res.writeHead(502);
426
+ res.end();
427
+ }
428
+ }
429
+ /**
430
+ * Build synthetic OAuth authorization server metadata for stdio-bridged servers.
431
+ * Points all OAuth endpoints back to this proxy so the full flow completes locally.
432
+ */
433
+ buildStdioAuthServerMetadata() {
434
+ const issuer = `http://localhost:${this.port}`;
435
+ return {
436
+ issuer,
437
+ authorization_endpoint: `${issuer}/authorize`,
438
+ token_endpoint: `${issuer}/token`,
439
+ registration_endpoint: `${issuer}/register`,
440
+ response_types_supported: ["code"],
441
+ grant_types_supported: ["authorization_code", "client_credentials", "refresh_token"],
442
+ token_endpoint_auth_methods_supported: ["client_secret_post", "none"],
443
+ code_challenge_methods_supported: ["S256"],
444
+ };
445
+ }
446
+ /**
447
+ * Handle OAuth endpoints for stdio-bridged servers.
448
+ * Synthesizes valid responses so Claude Code's proactive auth flow succeeds
449
+ * without requiring actual user authentication. The bridge ignores the
450
+ * Authorization header, so the dummy token is harmless.
451
+ */
452
+ async handleStdioOAuth(url, req, res) {
453
+ this.callbacks.log(`Stdio OAuth: ${req.method} ${url.pathname}`);
454
+ // .well-known discovery at root level
455
+ if (url.pathname === "/.well-known/oauth-authorization-server"
456
+ || url.pathname === "/.well-known/openid-configuration") {
457
+ const metadata = this.buildStdioAuthServerMetadata();
458
+ res.writeHead(200, { "Content-Type": "application/json" });
459
+ res.end(JSON.stringify(metadata));
460
+ return;
461
+ }
462
+ if (url.pathname === "/.well-known/oauth-protected-resource") {
463
+ // Return protected resource metadata pointing to this proxy
464
+ const synthesized = {
465
+ resource: `http://localhost:${this.port}`,
466
+ authorization_servers: [`http://localhost:${this.port}`],
467
+ };
468
+ res.writeHead(200, { "Content-Type": "application/json" });
469
+ res.end(JSON.stringify(synthesized));
470
+ return;
471
+ }
472
+ // Dynamic client registration: return a valid client_id
473
+ if (url.pathname === "/register" && req.method === "POST") {
474
+ const body = await readBody(req);
475
+ let clientMetadata = {};
205
476
  try {
206
- const result = await upstream.client.listTools();
207
- return { tools: result.tools };
477
+ clientMetadata = JSON.parse(body.toString());
208
478
  }
209
- catch (error) {
210
- callbacks.log(`Failed to list tools from ${serverName}: ${error}`);
211
- return { tools: [] };
479
+ catch {
480
+ // Use defaults
212
481
  }
213
- });
214
- // Proxy tools/call: check policy, forward to upstream, record telemetry
215
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
216
- const { name: toolName, arguments: args } = request.params;
217
- const startTime = Date.now();
218
- // Check policy before forwarding
219
- if (callbacks.isToolBlocked(serverName, toolName)) {
220
- callbacks.recordToolCall({
221
- serverName,
222
- toolName,
223
- durationMs: Date.now() - startTime,
224
- status: "blocked",
225
- argumentKeys: args ? Object.keys(args) : [],
226
- });
227
- return {
228
- isError: true,
229
- content: [
230
- {
231
- type: "text",
232
- text: `Tool ${serverName}.${toolName} is blocked by organization policy`,
233
- },
234
- ],
235
- };
482
+ const clientId = `stdio-bridge-${Date.now()}`;
483
+ const registration = {
484
+ client_id: clientId,
485
+ client_name: clientMetadata.client_name ?? "stdio-bridge-client",
486
+ redirect_uris: clientMetadata.redirect_uris ?? [],
487
+ grant_types: ["authorization_code", "client_credentials", "refresh_token"],
488
+ response_types: ["code"],
489
+ token_endpoint_auth_method: "none",
490
+ };
491
+ this.callbacks.log(`Stdio OAuth: registered client ${clientId}`);
492
+ res.writeHead(200, { "Content-Type": "application/json" });
493
+ res.end(JSON.stringify(registration));
494
+ return;
495
+ }
496
+ // Token endpoint: issue a long-lived dummy token with a refresh token.
497
+ // Real auth is handled by the gateway's upstream OAuth; these tokens are
498
+ // placeholders so the MCP SDK has something to send in Authorization
499
+ // headers. The bridge ignores them. A refresh_token is included so
500
+ // Claude Code's UI treats the session as durably authenticated and
501
+ // doesn't flip to "not authenticated" when the access token expires.
502
+ if (url.pathname === "/token" && req.method === "POST") {
503
+ const tokenResponse = {
504
+ access_token: `stdio-bridge-token-${Date.now()}`,
505
+ token_type: "Bearer",
506
+ expires_in: 315_360_000,
507
+ refresh_token: `stdio-bridge-refresh-${Date.now()}`,
508
+ scope: "mcp",
509
+ };
510
+ this.callbacks.log("Stdio OAuth: issued dummy token");
511
+ res.writeHead(200, { "Content-Type": "application/json" });
512
+ res.end(JSON.stringify(tokenResponse));
513
+ return;
514
+ }
515
+ // Authorization endpoint: redirect back with an auth code immediately.
516
+ // Claude Code opens this in a browser; we redirect to the callback with
517
+ // a synthetic code that the token endpoint will accept.
518
+ if (url.pathname === "/authorize") {
519
+ const redirectUri = url.searchParams.get("redirect_uri");
520
+ const state = url.searchParams.get("state");
521
+ if (redirectUri) {
522
+ const callbackUrl = new URL(redirectUri);
523
+ callbackUrl.searchParams.set("code", `stdio-auth-code-${Date.now()}`);
524
+ if (state)
525
+ callbackUrl.searchParams.set("state", state);
526
+ this.callbacks.log(`Stdio OAuth: redirecting to ${callbackUrl.origin}${callbackUrl.pathname}`);
527
+ res.writeHead(302, { Location: callbackUrl.toString() });
528
+ res.end();
529
+ return;
236
530
  }
237
- const upstream = callbacks.getUpstreams().get(serverName);
238
- if (!upstream?.connected) {
239
- return {
240
- isError: true,
241
- content: [
242
- {
243
- type: "text",
244
- text: `Server ${serverName} is not connected yet. Please wait for the connection to be established.`,
531
+ }
532
+ // Fallback
533
+ res.writeHead(404, { "Content-Type": "application/json" });
534
+ res.end(JSON.stringify({ error: "not_found" }));
535
+ }
536
+ /**
537
+ * Bridge an HTTP MCP request to a stdio upstream via JSON-RPC.
538
+ *
539
+ * For command-based (stdio) servers that have no HTTP URL, we parse the
540
+ * incoming JSON-RPC request, forward it through the gateway's MCP Client
541
+ * connection, and return the response as HTTP.
542
+ *
543
+ * Handles: initialize, tools/list, tools/call, resources/list, etc.
544
+ * Also synthesizes MCP server metadata for the initial handshake.
545
+ */
546
+ async bridgeToStdio(serverName, req, res) {
547
+ // GET requests: the SDK sends GET with Accept: text/event-stream to open
548
+ // an SSE stream for server-initiated messages. Stdio servers don't push
549
+ // server-initiated messages, but some clients (Claude Code's /mcp status
550
+ // UI) treat a 405 response as a failed connection. Return an empty,
551
+ // long-lived SSE stream instead: 200 with Content-Type text/event-stream,
552
+ // keep-alive comments, and close when the client disconnects.
553
+ if (req.method === "GET") {
554
+ res.writeHead(200, {
555
+ "Content-Type": "text/event-stream",
556
+ "Cache-Control": "no-cache, no-transform",
557
+ "Connection": "keep-alive",
558
+ });
559
+ res.write(":ok\n\n");
560
+ const keepAlive = setInterval(() => {
561
+ if (!res.writableEnded)
562
+ res.write(":keepalive\n\n");
563
+ }, 30_000);
564
+ const cleanup = () => {
565
+ clearInterval(keepAlive);
566
+ if (!res.writableEnded)
567
+ res.end();
568
+ };
569
+ req.on("close", cleanup);
570
+ req.on("error", cleanup);
571
+ return;
572
+ }
573
+ if (req.method === "DELETE") {
574
+ // Session cleanup: no-op for stdio
575
+ res.writeHead(200);
576
+ res.end();
577
+ return;
578
+ }
579
+ // POST: parse JSON-RPC body
580
+ const body = await readBody(req);
581
+ let jsonRpc;
582
+ try {
583
+ jsonRpc = JSON.parse(body.toString());
584
+ }
585
+ catch {
586
+ res.writeHead(400, { "Content-Type": "application/json" });
587
+ res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null }));
588
+ return;
589
+ }
590
+ const requests = Array.isArray(jsonRpc) ? jsonRpc : [jsonRpc];
591
+ const responses = [];
592
+ for (const rpcReq of requests) {
593
+ const { method, params, id } = rpcReq;
594
+ // Policy enforcement for tools/call
595
+ if (method === "tools/call" && params) {
596
+ const p = params;
597
+ const toolName = p.name ?? "unknown";
598
+ if (this.callbacks.isToolBlocked(serverName, toolName)) {
599
+ this.callbacks.recordToolCall({
600
+ serverName,
601
+ toolName,
602
+ durationMs: 0,
603
+ status: "blocked",
604
+ argumentKeys: p.arguments ? Object.keys(p.arguments) : [],
605
+ });
606
+ responses.push({
607
+ jsonrpc: "2.0",
608
+ id: id ?? null,
609
+ result: {
610
+ isError: true,
611
+ content: [{ type: "text", text: `Tool ${serverName}.${toolName} is blocked by organization policy` }],
245
612
  },
246
- ],
247
- };
613
+ });
614
+ continue;
615
+ }
248
616
  }
617
+ const startTime = Date.now();
249
618
  try {
250
- const callPromise = upstream.client.callTool({
251
- name: toolName,
252
- arguments: args,
253
- });
254
- const callTimeout = new Promise((_, reject) => {
255
- setTimeout(() => reject(new Error(`Tool call timeout after ${TOOL_CALL_TIMEOUT_MS}ms`)), TOOL_CALL_TIMEOUT_MS);
256
- });
257
- const result = await Promise.race([callPromise, callTimeout]);
258
- callbacks.recordToolCall({
259
- serverName,
260
- toolName,
261
- durationMs: Date.now() - startTime,
262
- status: "success",
263
- argumentKeys: args ? Object.keys(args) : [],
264
- });
265
- return result;
619
+ const result = await this.callbacks.forwardJsonRpc(serverName, method, params);
620
+ const durationMs = Date.now() - startTime;
621
+ // Record telemetry for tools/call
622
+ if (method === "tools/call" && params) {
623
+ const p = params;
624
+ this.callbacks.recordToolCall({
625
+ serverName,
626
+ toolName: p.name ?? "unknown",
627
+ durationMs,
628
+ status: result.error ? "error" : "success",
629
+ argumentKeys: p.arguments ? Object.keys(p.arguments) : [],
630
+ ...(result.error ? { errorMessage: result.error.message } : {}),
631
+ });
632
+ }
633
+ if (result.error) {
634
+ responses.push({ jsonrpc: "2.0", id: id ?? null, error: result.error });
635
+ }
636
+ else {
637
+ responses.push({ jsonrpc: "2.0", id: id ?? null, result: result.result });
638
+ }
266
639
  }
267
640
  catch (error) {
641
+ const durationMs = Date.now() - startTime;
268
642
  const errorMessage = error instanceof Error ? error.message : String(error);
269
- callbacks.recordToolCall({
270
- serverName,
271
- toolName,
272
- durationMs: Date.now() - startTime,
273
- status: "error",
274
- errorMessage,
275
- argumentKeys: args ? Object.keys(args) : [],
643
+ if (method === "tools/call" && params) {
644
+ const p = params;
645
+ this.callbacks.recordToolCall({
646
+ serverName,
647
+ toolName: p.name ?? "unknown",
648
+ durationMs,
649
+ status: "error",
650
+ errorMessage,
651
+ argumentKeys: p.arguments ? Object.keys(p.arguments) : [],
652
+ });
653
+ }
654
+ responses.push({
655
+ jsonrpc: "2.0",
656
+ id: id ?? null,
657
+ error: { code: -32603, message: errorMessage },
276
658
  });
277
- return {
659
+ }
660
+ }
661
+ // If all messages are notifications (no id), return 202 Accepted (no body).
662
+ // The SDK expects 202 for notifications and only processes JSON for requests.
663
+ const allNotifications = requests.every((r) => r.id === undefined || r.id === null);
664
+ if (allNotifications) {
665
+ res.writeHead(202);
666
+ res.end();
667
+ return;
668
+ }
669
+ const responseBody = Array.isArray(jsonRpc) ? responses : responses[0];
670
+ res.writeHead(200, { "Content-Type": "application/json" });
671
+ res.end(JSON.stringify(responseBody));
672
+ }
673
+ /**
674
+ * Forward an MCP request to the upstream server.
675
+ * For POST requests, parses the JSON-RPC body to detect tools/call for
676
+ * policy enforcement and telemetry. All other requests pass through as-is.
677
+ */
678
+ async forwardMcpRequest(serverName, upstreamUrl, extraPath, req, res) {
679
+ // Build upstream target URL
680
+ const targetUrl = extraPath ? `${upstreamUrl}${extraPath}` : upstreamUrl;
681
+ // Read request body for POST (needed for JSON-RPC parsing)
682
+ let requestBody;
683
+ if (req.method === "POST") {
684
+ requestBody = await readBody(req);
685
+ }
686
+ // Parse JSON-RPC to detect tools/call for policy and telemetry
687
+ let toolCallInfo;
688
+ let blocked = false;
689
+ if (requestBody) {
690
+ try {
691
+ const jsonRpc = JSON.parse(requestBody.toString());
692
+ // Handle both single requests and batches
693
+ const requests = Array.isArray(jsonRpc) ? jsonRpc : [jsonRpc];
694
+ for (const rpcReq of requests) {
695
+ if (rpcReq.method === "tools/call" && rpcReq.params) {
696
+ const params = rpcReq.params;
697
+ const toolName = params.name ?? "unknown";
698
+ const argumentKeys = params.arguments ? Object.keys(params.arguments) : [];
699
+ toolCallInfo = { toolName, argumentKeys };
700
+ // Check policy before forwarding
701
+ if (this.callbacks.isToolBlocked(serverName, toolName)) {
702
+ blocked = true;
703
+ this.callbacks.recordToolCall({
704
+ serverName,
705
+ toolName,
706
+ durationMs: 0,
707
+ status: "blocked",
708
+ argumentKeys,
709
+ });
710
+ }
711
+ }
712
+ }
713
+ }
714
+ catch {
715
+ // Not valid JSON or not JSON-RPC; forward as-is
716
+ }
717
+ }
718
+ // If tool is blocked by policy, return an MCP error response without forwarding
719
+ if (blocked && toolCallInfo) {
720
+ const errorResponse = {
721
+ jsonrpc: "2.0",
722
+ id: extractRequestId(requestBody),
723
+ result: {
278
724
  isError: true,
279
725
  content: [
280
726
  {
281
727
  type: "text",
282
- text: `Error calling ${serverName}.${toolName}: ${errorMessage}`,
728
+ text: `Tool ${serverName}.${toolCallInfo.toolName} is blocked by organization policy`,
283
729
  },
284
730
  ],
731
+ },
732
+ };
733
+ res.writeHead(200, { "Content-Type": "application/json" });
734
+ res.end(JSON.stringify(errorResponse));
735
+ return;
736
+ }
737
+ // Forward the request to upstream
738
+ const startTime = Date.now();
739
+ // Build headers for upstream request
740
+ const forwardHeaders = {};
741
+ for (const [key, value] of Object.entries(req.headers)) {
742
+ // Skip hop-by-hop headers and host
743
+ if (key === "host" || key === "connection" || key === "transfer-encoding")
744
+ continue;
745
+ if (value) {
746
+ forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
747
+ }
748
+ }
749
+ // Prevent compressed responses (e.g., Brotli from Cloudflare) that fetch()
750
+ // auto-decompresses but whose content-encoding header would confuse the client
751
+ forwardHeaders["accept-encoding"] = "identity";
752
+ try {
753
+ const upstreamResponse = await fetch(targetUrl, {
754
+ method: req.method ?? "GET",
755
+ headers: forwardHeaders,
756
+ body: requestBody ? new Uint8Array(requestBody) : undefined,
757
+ redirect: "follow",
758
+ });
759
+ // Record telemetry for tool calls
760
+ if (toolCallInfo) {
761
+ const durationMs = Date.now() - startTime;
762
+ const status = upstreamResponse.ok ? "success" : "error";
763
+ this.callbacks.recordToolCall({
764
+ serverName,
765
+ toolName: toolCallInfo.toolName,
766
+ durationMs,
767
+ status,
768
+ argumentKeys: toolCallInfo.argumentKeys,
769
+ ...(status === "error" ? { errorMessage: `HTTP ${upstreamResponse.status}` } : {}),
770
+ });
771
+ }
772
+ // Forward response status and headers
773
+ const responseHeaders = {};
774
+ upstreamResponse.headers.forEach((value, key) => {
775
+ // Skip hop-by-hop headers
776
+ if (key === "transfer-encoding" || key === "connection")
777
+ return;
778
+ // Strip content-encoding since we requested identity (fetch auto-decompresses)
779
+ if (key === "content-encoding")
780
+ return;
781
+ responseHeaders[key] = value;
782
+ });
783
+ // On 401, rewrite resource_metadata in WWW-Authenticate to point to our
784
+ // path-aware discovery endpoint. Without this, the SDK uses the upstream's
785
+ // resource_metadata URL, discovers auth at the upstream origin, then falls
786
+ // back to root-level /.well-known/ on our proxy (which has no server name
787
+ // and can't be routed).
788
+ if (upstreamResponse.status === 401) {
789
+ const proxyResourceMetadata = `http://localhost:${this.port}/.well-known/oauth-protected-resource/proxy/${encodeURIComponent(serverName)}/mcp`;
790
+ const existingAuth = responseHeaders["www-authenticate"] ?? "";
791
+ if (existingAuth.toLowerCase().startsWith("bearer")) {
792
+ // Replace any existing resource_metadata with our proxy URL
793
+ const rewritten = existingAuth.replace(/resource_metadata="[^"]*"/g, `resource_metadata="${proxyResourceMetadata}"`);
794
+ // If there was no resource_metadata to replace, append it
795
+ responseHeaders["www-authenticate"] = rewritten.includes("resource_metadata=")
796
+ ? rewritten
797
+ : `${existingAuth}, resource_metadata="${proxyResourceMetadata}"`;
798
+ }
799
+ else {
800
+ responseHeaders["www-authenticate"] = `Bearer resource_metadata="${proxyResourceMetadata}"`;
801
+ }
802
+ }
803
+ // Check if this is an SSE response (streaming)
804
+ const contentType = upstreamResponse.headers.get("content-type") ?? "";
805
+ const isSSE = contentType.includes("text/event-stream");
806
+ res.writeHead(upstreamResponse.status, responseHeaders);
807
+ if (isSSE && upstreamResponse.body) {
808
+ // Stream SSE responses through as-is
809
+ const reader = upstreamResponse.body.getReader();
810
+ const pump = async () => {
811
+ while (true) {
812
+ const { done, value } = await reader.read();
813
+ if (done)
814
+ break;
815
+ const canContinue = res.write(value);
816
+ if (!canContinue) {
817
+ await new Promise((resolve) => res.once("drain", resolve));
818
+ }
819
+ }
820
+ res.end();
285
821
  };
822
+ // If client disconnects, cancel the upstream stream
823
+ req.on("close", () => {
824
+ reader.cancel().catch(() => { });
825
+ });
826
+ await pump();
286
827
  }
287
- });
288
- const transport = new StreamableHTTPServerTransport({
289
- sessionIdGenerator: () => randomUUID(),
290
- });
291
- // Track session by ID once the transport assigns one
292
- transport.onclose = () => {
293
- if (transport.sessionId) {
294
- this.sessions.delete(transport.sessionId);
828
+ else if (upstreamResponse.body) {
829
+ // Buffer non-streaming responses
830
+ const body = new Uint8Array(await upstreamResponse.arrayBuffer());
831
+ res.end(body);
295
832
  }
296
- };
297
- await server.connect(transport);
298
- const session = { server, transport, serverName };
299
- // Store session after transport is connected (sessionId is set during handleRequest)
300
- // We'll store it by the transport's sessionId after the first request
301
- if (transport.sessionId) {
302
- this.sessions.set(transport.sessionId, session);
303
- }
304
- // Also set up a hook to store the session once the ID is assigned
305
- const origHandleRequest = transport.handleRequest.bind(transport);
306
- transport.handleRequest = async (req, res, body) => {
307
- await origHandleRequest(req, res, body);
308
- if (transport.sessionId && !this.sessions.has(transport.sessionId)) {
309
- this.sessions.set(transport.sessionId, session);
310
- callbacks.log(`HTTP proxy session created: ${transport.sessionId} -> ${serverName}`);
833
+ else {
834
+ res.end();
311
835
  }
312
- };
313
- return session;
836
+ }
837
+ catch (error) {
838
+ // Record telemetry for failed tool calls
839
+ if (toolCallInfo) {
840
+ this.callbacks.recordToolCall({
841
+ serverName,
842
+ toolName: toolCallInfo.toolName,
843
+ durationMs: Date.now() - startTime,
844
+ status: "error",
845
+ errorMessage: error instanceof Error ? error.message : String(error),
846
+ argumentKeys: toolCallInfo.argumentKeys,
847
+ });
848
+ }
849
+ this.callbacks.log(`Proxy error for ${serverName}: ${error instanceof Error ? error.message : error}`);
850
+ res.writeHead(502, { "Content-Type": "application/json" });
851
+ res.end(JSON.stringify({ error: "Failed to reach upstream server" }));
852
+ }
853
+ }
854
+ }
855
+ function readBody(req) {
856
+ return new Promise((resolve, reject) => {
857
+ const chunks = [];
858
+ req.on("data", (chunk) => chunks.push(chunk));
859
+ req.on("end", () => resolve(Buffer.concat(chunks)));
860
+ req.on("error", reject);
861
+ });
862
+ }
863
+ function extractRequestId(body) {
864
+ if (!body)
865
+ return null;
866
+ try {
867
+ const parsed = JSON.parse(body.toString());
868
+ const first = Array.isArray(parsed) ? parsed[0] : parsed;
869
+ return first?.id ?? null;
870
+ }
871
+ catch {
872
+ return null;
314
873
  }
315
874
  }
316
875
  //# sourceMappingURL=http-proxy.js.map