@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.
- package/dist/__tests__/config.test.js +140 -2
- package/dist/__tests__/config.test.js.map +1 -1
- package/dist/__tests__/plugin-discovery.test.js +5 -5
- package/dist/__tests__/plugin-discovery.test.js.map +1 -1
- package/dist/cli.js +32 -2
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +110 -24
- package/dist/config.js.map +1 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +147 -44
- package/dist/gateway.js.map +1 -1
- package/dist/http-proxy.d.ts +85 -35
- package/dist/http-proxy.d.ts.map +1 -1
- package/dist/http-proxy.js +766 -207
- package/dist/http-proxy.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/http-proxy.js
CHANGED
|
@@ -1,34 +1,94 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP Proxy Server for MCP Gateway
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
90
|
-
if (!
|
|
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(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
158
|
-
const
|
|
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
|
|
170
|
-
//
|
|
171
|
-
|
|
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(
|
|
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
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
return { tools: result.tools };
|
|
477
|
+
clientMetadata = JSON.parse(body.toString());
|
|
208
478
|
}
|
|
209
|
-
catch
|
|
210
|
-
|
|
211
|
-
return { tools: [] };
|
|
479
|
+
catch {
|
|
480
|
+
// Use defaults
|
|
212
481
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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: `
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|