@sonoma-security/mcp-gateway 0.1.12 → 0.1.14
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/dist/__tests__/config.test.js +140 -2
- package/dist/__tests__/config.test.js.map +1 -1
- package/dist/__tests__/plugin-discovery.test.d.ts +12 -0
- package/dist/__tests__/plugin-discovery.test.d.ts.map +1 -0
- package/dist/__tests__/plugin-discovery.test.js +367 -0
- package/dist/__tests__/plugin-discovery.test.js.map +1 -0
- package/dist/__tests__/tool-blocking.test.d.ts +2 -0
- package/dist/__tests__/tool-blocking.test.d.ts.map +1 -0
- package/dist/__tests__/tool-blocking.test.js +256 -0
- package/dist/__tests__/tool-blocking.test.js.map +1 -0
- package/dist/auth/keychain.d.ts +34 -0
- package/dist/auth/keychain.d.ts.map +1 -0
- package/dist/auth/keychain.js +305 -0
- package/dist/auth/keychain.js.map +1 -0
- package/dist/auth/storage.d.ts +5 -6
- package/dist/auth/storage.d.ts.map +1 -1
- package/dist/auth/storage.js +72 -21
- package/dist/auth/storage.js.map +1 -1
- package/dist/auth/upstream-oauth.d.ts.map +1 -1
- package/dist/auth/upstream-oauth.js +8 -5
- package/dist/auth/upstream-oauth.js.map +1 -1
- package/dist/auth/upstream-token-store.d.ts +18 -6
- package/dist/auth/upstream-token-store.d.ts.map +1 -1
- package/dist/auth/upstream-token-store.js +127 -35
- package/dist/auth/upstream-token-store.js.map +1 -1
- package/dist/cli.js +48 -2
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +122 -27
- package/dist/config.js.map +1 -1
- package/dist/gateway.d.ts +15 -0
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +302 -68
- package/dist/gateway.js.map +1 -1
- package/dist/http-proxy.d.ts +126 -0
- package/dist/http-proxy.d.ts.map +1 -0
- package/dist/http-proxy.js +875 -0
- package/dist/http-proxy.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/pattern-matcher.d.ts +25 -0
- package/dist/pattern-matcher.d.ts.map +1 -1
- package/dist/pattern-matcher.js +65 -0
- package/dist/pattern-matcher.js.map +1 -1
- package/dist/sonoma-client.d.ts +17 -1
- package/dist/sonoma-client.d.ts.map +1 -1
- package/dist/sonoma-client.js +67 -43
- package/dist/sonoma-client.js.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Proxy Server for MCP Gateway
|
|
3
|
+
*
|
|
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
|
+
*
|
|
8
|
+
* Architecture:
|
|
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)
|
|
12
|
+
*
|
|
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.
|
|
16
|
+
*/
|
|
17
|
+
import { createServer } from "node:http";
|
|
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
|
+
}
|
|
59
|
+
export class HttpProxyServer {
|
|
60
|
+
httpServer;
|
|
61
|
+
callbacks;
|
|
62
|
+
port;
|
|
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) {
|
|
74
|
+
this.port = port;
|
|
75
|
+
this.callbacks = callbacks;
|
|
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
|
+
}
|
|
85
|
+
}
|
|
86
|
+
preboundAdopted = false;
|
|
87
|
+
async start() {
|
|
88
|
+
if (this.preboundAdopted) {
|
|
89
|
+
this.callbacks.log(`HTTP proxy handler attached on port ${this.port} (prebound)`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
this.httpServer.on("error", reject);
|
|
94
|
+
this.httpServer.listen(this.port, () => {
|
|
95
|
+
this.callbacks.log(`HTTP proxy server listening on port ${this.port}`);
|
|
96
|
+
resolve();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
async stop() {
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
this.httpServer.close(() => resolve());
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Get the proxy URL for a specific server.
|
|
107
|
+
* Plugins should rewrite their URLs to this endpoint.
|
|
108
|
+
*/
|
|
109
|
+
getProxyUrl(serverName) {
|
|
110
|
+
return `http://localhost:${this.port}/proxy/${encodeURIComponent(serverName)}/mcp`;
|
|
111
|
+
}
|
|
112
|
+
async handleRequest(req, res) {
|
|
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}`);
|
|
118
|
+
// CORS headers for local access
|
|
119
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
120
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
121
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, Last-Event-ID, Accept, MCP-Protocol-Version");
|
|
122
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
123
|
+
if (req.method === "OPTIONS") {
|
|
124
|
+
res.writeHead(204);
|
|
125
|
+
res.end();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
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);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
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" }));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// Parse /proxy/{serverName}/mcp (with optional trailing paths for future MCP extensions)
|
|
217
|
+
const proxyMatch = url.pathname.match(/^\/proxy\/([^/]+)\/mcp(\/.*)?$/);
|
|
218
|
+
if (!proxyMatch) {
|
|
219
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
220
|
+
res.end(JSON.stringify({ error: "Not found. Use /proxy/{serverName}/mcp" }));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
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);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
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}` }));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
await this.forwardMcpRequest(serverName, upstreamUrl, extraPath, req, res);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
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.
|
|
245
|
+
*/
|
|
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" }));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
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}`;
|
|
288
|
+
const response = await fetch(discoveryUrl, {
|
|
289
|
+
headers: { Accept: "application/json" },
|
|
290
|
+
redirect: "follow",
|
|
291
|
+
});
|
|
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
|
+
}
|
|
311
|
+
res.writeHead(response.status);
|
|
312
|
+
res.end();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const metadata = await response.json();
|
|
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);
|
|
351
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
352
|
+
res.end(body);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
res.writeHead(502);
|
|
356
|
+
res.end();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
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.
|
|
364
|
+
*/
|
|
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
|
+
}
|
|
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 = {};
|
|
476
|
+
try {
|
|
477
|
+
clientMetadata = JSON.parse(body.toString());
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
// Use defaults
|
|
481
|
+
}
|
|
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;
|
|
530
|
+
}
|
|
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` }],
|
|
612
|
+
},
|
|
613
|
+
});
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const startTime = Date.now();
|
|
618
|
+
try {
|
|
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
|
+
}
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
const durationMs = Date.now() - startTime;
|
|
642
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
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 },
|
|
658
|
+
});
|
|
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: {
|
|
724
|
+
isError: true,
|
|
725
|
+
content: [
|
|
726
|
+
{
|
|
727
|
+
type: "text",
|
|
728
|
+
text: `Tool ${serverName}.${toolCallInfo.toolName} is blocked by organization policy`,
|
|
729
|
+
},
|
|
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();
|
|
821
|
+
};
|
|
822
|
+
// If client disconnects, cancel the upstream stream
|
|
823
|
+
req.on("close", () => {
|
|
824
|
+
reader.cancel().catch(() => { });
|
|
825
|
+
});
|
|
826
|
+
await pump();
|
|
827
|
+
}
|
|
828
|
+
else if (upstreamResponse.body) {
|
|
829
|
+
// Buffer non-streaming responses
|
|
830
|
+
const body = new Uint8Array(await upstreamResponse.arrayBuffer());
|
|
831
|
+
res.end(body);
|
|
832
|
+
}
|
|
833
|
+
else {
|
|
834
|
+
res.end();
|
|
835
|
+
}
|
|
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;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
//# sourceMappingURL=http-proxy.js.map
|