@silver886/mcp-proxy 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -17
- package/dist/host/agent.d.ts +22 -0
- package/dist/host/agent.js +314 -0
- package/dist/host/cli.d.ts +1 -0
- package/dist/host/cli.js +83 -0
- package/dist/host/constants.d.ts +4 -0
- package/dist/host/constants.js +16 -0
- package/dist/host/session.d.ts +21 -0
- package/dist/host/session.js +204 -0
- package/dist/host/tunnel.d.ts +5 -0
- package/dist/host/tunnel.js +82 -0
- package/dist/host.js +8 -0
- package/dist/proxy/core/constants.d.ts +13 -0
- package/dist/proxy/core/constants.js +39 -0
- package/dist/proxy/core/fetch-timeout.d.ts +1 -0
- package/dist/proxy/core/fetch-timeout.js +15 -0
- package/dist/proxy/core/state.d.ts +25 -0
- package/dist/proxy/core/state.js +90 -0
- package/dist/proxy/core/types.d.ts +57 -0
- package/dist/proxy/core/types.js +5 -0
- package/dist/proxy/discovery/client.d.ts +42 -0
- package/dist/proxy/discovery/client.js +283 -0
- package/dist/proxy/discovery/runner.d.ts +21 -0
- package/dist/proxy/discovery/runner.js +319 -0
- package/dist/proxy/pairing/config.d.ts +9 -0
- package/dist/proxy/pairing/config.js +130 -0
- package/dist/proxy/pairing/controller.d.ts +19 -0
- package/dist/proxy/pairing/controller.js +327 -0
- package/dist/proxy/pairing/http.d.ts +70 -0
- package/dist/proxy/pairing/http.js +155 -0
- package/dist/proxy/pairing/static-assets.d.ts +4 -0
- package/dist/proxy/pairing/static-assets.js +13 -0
- package/dist/proxy/pairing/tunnel.d.ts +13 -0
- package/dist/proxy/pairing/tunnel.js +130 -0
- package/dist/proxy/pairing/validation.d.ts +2 -0
- package/dist/proxy/pairing/validation.js +62 -0
- package/dist/proxy/routing/filtering.d.ts +13 -0
- package/dist/proxy/routing/filtering.js +116 -0
- package/dist/proxy/routing/router.d.ts +17 -0
- package/dist/proxy/routing/router.js +74 -0
- package/dist/proxy/routing/uri.d.ts +7 -0
- package/dist/proxy/routing/uri.js +39 -0
- package/dist/proxy/runtime/forwarder.d.ts +15 -0
- package/dist/proxy/runtime/forwarder.js +265 -0
- package/dist/proxy/runtime/handlers.d.ts +48 -0
- package/dist/proxy/runtime/handlers.js +329 -0
- package/dist/proxy/runtime/sse.d.ts +19 -0
- package/dist/proxy/runtime/sse.js +169 -0
- package/dist/proxy/runtime/upstream-bridge.d.ts +27 -0
- package/dist/proxy/runtime/upstream-bridge.js +133 -0
- package/dist/proxy/server.d.ts +15 -0
- package/dist/proxy/server.js +167 -0
- package/dist/proxy.js +5 -0
- package/{mcp/dist → dist}/shared/protocol.d.ts +15 -3
- package/dist/shared/protocol.js +183 -0
- package/dist/wrapper.d.ts +2 -0
- package/dist/wrapper.js +72 -0
- package/package.json +15 -7
- package/static/setup.css +233 -0
- package/static/setup.html +57 -0
- package/static/setup.js +711 -0
- package/static/style.css +208 -0
- package/mcp/dist/host.js +0 -307
- package/mcp/dist/proxy.js +0 -374
- package/mcp/dist/shared/generated.d.ts +0 -2
- package/mcp/dist/shared/generated.js +0 -5
- package/mcp/dist/shared/protocol.js +0 -79
- /package/{mcp/dist → dist}/host.d.ts +0 -0
- /package/{mcp/dist → dist}/proxy.d.ts +0 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ProxyState } from "../core/state.js";
|
|
2
|
+
import type { ToolRoute } from "../core/types.js";
|
|
3
|
+
import type { DiscoveryRunner } from "../discovery/runner.js";
|
|
4
|
+
export declare class Forwarder {
|
|
5
|
+
private readonly state;
|
|
6
|
+
private readonly runner;
|
|
7
|
+
private readonly log;
|
|
8
|
+
private readonly sendError;
|
|
9
|
+
private readonly writeOut;
|
|
10
|
+
constructor(state: ProxyState, runner: DiscoveryRunner, log: (line: string) => void, sendError: (code: number, detail: string | undefined, id: string | number | null) => void, writeOut: (line: string) => void);
|
|
11
|
+
forwardRoutedRequest(id: string | number, route: ToolRoute, method: string, upstreamParams: unknown): Promise<void>;
|
|
12
|
+
private replaySubscriptions;
|
|
13
|
+
forwardNotification(route: ToolRoute, method: string, params: unknown): Promise<void>;
|
|
14
|
+
broadcastSetLogLevel(params: Record<string, unknown>): Promise<void>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Forwarder = void 0;
|
|
4
|
+
const protocol_js_1 = require("../../shared/protocol.js");
|
|
5
|
+
const constants_js_1 = require("../core/constants.js");
|
|
6
|
+
const fetch_timeout_js_1 = require("../core/fetch-timeout.js");
|
|
7
|
+
const validation_js_1 = require("../pairing/validation.js");
|
|
8
|
+
const uri_js_1 = require("../routing/uri.js");
|
|
9
|
+
// All agent→server (and proxy→server) HTTP traffic flows through this
|
|
10
|
+
// class. It owns the request/response shape, stale-session 404 retry,
|
|
11
|
+
// resources/read URI wrapping, and the per-request id bookkeeping that
|
|
12
|
+
// notifications/cancelled relies on. It does NOT own discovery/init —
|
|
13
|
+
// when a session is reaped under us it asks DiscoveryRunner to re-init.
|
|
14
|
+
class Forwarder {
|
|
15
|
+
state;
|
|
16
|
+
runner;
|
|
17
|
+
log;
|
|
18
|
+
sendError;
|
|
19
|
+
writeOut;
|
|
20
|
+
constructor(state, runner, log, sendError, writeOut) {
|
|
21
|
+
this.state = state;
|
|
22
|
+
this.runner = runner;
|
|
23
|
+
this.log = log;
|
|
24
|
+
this.sendError = sendError;
|
|
25
|
+
this.writeOut = writeOut;
|
|
26
|
+
}
|
|
27
|
+
async forwardRoutedRequest(id, route, method, upstreamParams) {
|
|
28
|
+
const host = this.state.hosts.get(route.hostId);
|
|
29
|
+
const server = host?.servers.get(route.serverName);
|
|
30
|
+
if (!host || !server) {
|
|
31
|
+
this.sendError(protocol_js_1.ErrorCode.INTERNAL, `route stale: ${route.hostId}/${route.serverName}`, id);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const targetUrl = `${host.config.tunnelUrl}/servers/${route.serverName}`;
|
|
35
|
+
const buildHeaders = (sessionId) => {
|
|
36
|
+
const h = this.state.hostHeaders(host.config);
|
|
37
|
+
if (sessionId)
|
|
38
|
+
h["Mcp-Session-Id"] = sessionId;
|
|
39
|
+
return h;
|
|
40
|
+
};
|
|
41
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id, method, params: upstreamParams });
|
|
42
|
+
this.state.inflight.set(id, route);
|
|
43
|
+
const progressToken = (upstreamParams?._meta?.progressToken);
|
|
44
|
+
if (progressToken !== undefined)
|
|
45
|
+
this.state.progressTokens.set(progressToken, route);
|
|
46
|
+
try {
|
|
47
|
+
let upstream = await fetch(targetUrl, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: buildHeaders(server.sessionId),
|
|
50
|
+
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.TOOL_FORWARD_TIMEOUT_MS),
|
|
51
|
+
body,
|
|
52
|
+
});
|
|
53
|
+
this.runner.captureSessionId(host, route.serverName, server, upstream.headers.get("mcp-session-id"));
|
|
54
|
+
let responseBody = await upstream.text();
|
|
55
|
+
// Stale session recovery: the host now refuses unknown ids with 404
|
|
56
|
+
// instead of silently spawning a fresh, uninitialized child. Re-run
|
|
57
|
+
// the MCP handshake for this one server and retry the call exactly
|
|
58
|
+
// once.
|
|
59
|
+
if (upstream.status === 404 && (0, validation_js_1.isUnknownSessionError)(responseBody)) {
|
|
60
|
+
this.log(`[${route.hostId}/${route.serverName}] session lost, re-initializing`);
|
|
61
|
+
const before = host.servers.get(route.serverName);
|
|
62
|
+
// Snapshot subscriptions BEFORE initServer overwrites the state
|
|
63
|
+
// with a fresh empty set. The new session has no record of any
|
|
64
|
+
// subscribe call the agent made on the old one, so we replay
|
|
65
|
+
// each URI after init lands.
|
|
66
|
+
const priorSubscriptions = before ? Array.from(before.subscriptions) : [];
|
|
67
|
+
await this.runner.initServer(host, route.serverName);
|
|
68
|
+
const refreshed = host.servers.get(route.serverName);
|
|
69
|
+
if (!refreshed || refreshed === before) {
|
|
70
|
+
this.sendError(protocol_js_1.ErrorCode.HOST_UNREACHABLE, `re-init failed for ${route.hostId}/${route.serverName}`, id);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (priorSubscriptions.length > 0) {
|
|
74
|
+
await this.replaySubscriptions(host, route.serverName, refreshed, priorSubscriptions);
|
|
75
|
+
}
|
|
76
|
+
upstream = await fetch(targetUrl, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: buildHeaders(refreshed.sessionId),
|
|
79
|
+
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.TOOL_FORWARD_TIMEOUT_MS),
|
|
80
|
+
body,
|
|
81
|
+
});
|
|
82
|
+
this.runner.captureSessionId(host, route.serverName, refreshed, upstream.headers.get("mcp-session-id"));
|
|
83
|
+
responseBody = await upstream.text();
|
|
84
|
+
}
|
|
85
|
+
if (!upstream.ok) {
|
|
86
|
+
this.sendError(protocol_js_1.ErrorCode.HOST_UNREACHABLE, `host returned ${upstream.status}: ${responseBody.slice(0, 200)}`, id);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(responseBody);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
this.sendError(protocol_js_1.ErrorCode.INTERNAL, `host returned non-JSON body: ${responseBody.slice(0, 200)}`, id);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const isJsonRpc = parsed.jsonrpc === "2.0" && (parsed.result !== undefined || parsed.error !== undefined);
|
|
98
|
+
if (!isJsonRpc) {
|
|
99
|
+
this.sendError(protocol_js_1.ErrorCode.INTERNAL, "host returned non-JSON-RPC body", id);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Track subscribe/unsubscribe state on success only — a JSON-RPC
|
|
103
|
+
// error means the upstream rejected the call and the subscription
|
|
104
|
+
// state didn't actually change. Use the live `host.servers.get()`
|
|
105
|
+
// result rather than the captured `server` so a re-init mid-call
|
|
106
|
+
// commits to the post-recovery state.
|
|
107
|
+
if (parsed.error === undefined && (method === "resources/subscribe" || method === "resources/unsubscribe")) {
|
|
108
|
+
const uri = upstreamParams?.uri;
|
|
109
|
+
if (typeof uri === "string") {
|
|
110
|
+
const live = host.servers.get(route.serverName);
|
|
111
|
+
if (live) {
|
|
112
|
+
if (method === "resources/subscribe")
|
|
113
|
+
live.subscriptions.add(uri);
|
|
114
|
+
else
|
|
115
|
+
live.subscriptions.delete(uri);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// resources/read response carries its own list of `contents[i].uri`,
|
|
120
|
+
// which the upstream emits in its own URI namespace (e.g., reading a
|
|
121
|
+
// directory returns concrete file URIs the agent never saw in
|
|
122
|
+
// resources/list). Wrap each so the agent sees a URI it can route
|
|
123
|
+
// back through the proxy on a subsequent read/subscribe.
|
|
124
|
+
if (method === "resources/read" && parsed.result !== undefined) {
|
|
125
|
+
const result = parsed.result;
|
|
126
|
+
if (Array.isArray(result.contents)) {
|
|
127
|
+
result.contents = result.contents.map((entry) => {
|
|
128
|
+
if (entry && typeof entry === "object" && typeof entry.uri === "string") {
|
|
129
|
+
const e = entry;
|
|
130
|
+
return { ...e, uri: (0, uri_js_1.wrapResourceUri)(route.hostId, route.serverName, e.uri) };
|
|
131
|
+
}
|
|
132
|
+
return entry;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
parsed.id = id;
|
|
137
|
+
this.writeOut(JSON.stringify(parsed));
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
this.sendError(protocol_js_1.ErrorCode.HOST_UNREACHABLE, err.message, id);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
this.state.inflight.delete(id);
|
|
144
|
+
if (progressToken !== undefined)
|
|
145
|
+
this.state.progressTokens.delete(progressToken);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Re-issue resources/subscribe for each URI the agent had subscribed on
|
|
149
|
+
// the prior session. Best-effort: a URI that fails to re-subscribe is
|
|
150
|
+
// dropped from the new set so we don't claim a subscription we don't
|
|
151
|
+
// actually hold. Logged for visibility but not surfaced to the agent —
|
|
152
|
+
// the agent never saw the session rotate.
|
|
153
|
+
async replaySubscriptions(host, serverName, refreshed, uris) {
|
|
154
|
+
const targetUrl = `${host.config.tunnelUrl}/servers/${serverName}`;
|
|
155
|
+
const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": refreshed.sessionId };
|
|
156
|
+
await Promise.allSettled(uris.map(async (uri) => {
|
|
157
|
+
try {
|
|
158
|
+
const resp = await fetch(targetUrl, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers,
|
|
161
|
+
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.TOOL_FORWARD_TIMEOUT_MS),
|
|
162
|
+
body: JSON.stringify({
|
|
163
|
+
jsonrpc: "2.0",
|
|
164
|
+
id: `resub-${host.config.id}-${serverName}-${Date.now()}-${uri}`,
|
|
165
|
+
method: "resources/subscribe",
|
|
166
|
+
params: { uri },
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
this.runner.captureSessionId(host, serverName, refreshed, resp.headers.get("mcp-session-id"));
|
|
170
|
+
if (!resp.ok) {
|
|
171
|
+
this.log(` [${host.config.id}/${serverName}] resubscribe ${uri} HTTP ${resp.status}`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const text = await resp.text();
|
|
175
|
+
try {
|
|
176
|
+
const payload = JSON.parse(text);
|
|
177
|
+
if (payload.error) {
|
|
178
|
+
this.log(` [${host.config.id}/${serverName}] resubscribe ${uri} rejected: ${payload.error.message ?? "(no message)"}`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// unparseable body — treat as best-effort success
|
|
184
|
+
}
|
|
185
|
+
refreshed.subscriptions.add(uri);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
this.log(` [${host.config.id}/${serverName}] resubscribe ${uri} failed: ${err.message}`);
|
|
189
|
+
}
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
async forwardNotification(route, method, params) {
|
|
193
|
+
const host = this.state.hosts.get(route.hostId);
|
|
194
|
+
const server = host?.servers.get(route.serverName);
|
|
195
|
+
if (!host || !server || !server.sessionId)
|
|
196
|
+
return;
|
|
197
|
+
const target = `${host.config.tunnelUrl}/servers/${route.serverName}`;
|
|
198
|
+
const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": server.sessionId };
|
|
199
|
+
const resp = await fetch(target, {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers,
|
|
202
|
+
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.TOOL_FORWARD_TIMEOUT_MS),
|
|
203
|
+
body: JSON.stringify({ jsonrpc: "2.0", method, params }),
|
|
204
|
+
});
|
|
205
|
+
this.runner.captureSessionId(host, route.serverName, server, resp.headers.get("mcp-session-id"));
|
|
206
|
+
}
|
|
207
|
+
// logging/setLevel has no per-server addressing in the protocol. Issue
|
|
208
|
+
// the same level to every paired session in parallel; aggregate failures
|
|
209
|
+
// into stderr and return success to the agent — partial setLevel is
|
|
210
|
+
// still a meaningful change.
|
|
211
|
+
async broadcastSetLogLevel(params) {
|
|
212
|
+
const targets = [];
|
|
213
|
+
for (const host of this.state.hosts.values()) {
|
|
214
|
+
for (const [serverName, server] of host.servers) {
|
|
215
|
+
if (!server.sessionId)
|
|
216
|
+
continue;
|
|
217
|
+
targets.push({ host, serverName, server });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
await Promise.allSettled(targets.map(async ({ host, serverName, server }) => {
|
|
221
|
+
const target = `${host.config.tunnelUrl}/servers/${serverName}`;
|
|
222
|
+
const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": server.sessionId };
|
|
223
|
+
try {
|
|
224
|
+
const resp = await fetch(target, {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers,
|
|
227
|
+
signal: (0, fetch_timeout_js_1.timeoutSignal)(constants_js_1.TOOL_FORWARD_TIMEOUT_MS),
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
jsonrpc: "2.0",
|
|
230
|
+
id: `loglevel-${host.config.id}-${serverName}-${Date.now()}`,
|
|
231
|
+
method: "logging/setLevel",
|
|
232
|
+
params,
|
|
233
|
+
}),
|
|
234
|
+
});
|
|
235
|
+
this.runner.captureSessionId(host, serverName, server, resp.headers.get("mcp-session-id"));
|
|
236
|
+
if (!resp.ok) {
|
|
237
|
+
this.log(` [${host.config.id}/${serverName}] logging/setLevel HTTP ${resp.status}`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// HTTP 200 can still wrap a JSON-RPC error (invalid level, server
|
|
241
|
+
// rejection). Read the body and surface it — silently ignoring an
|
|
242
|
+
// upstream's "no thanks" makes invalid levels look applied.
|
|
243
|
+
const text = await resp.text();
|
|
244
|
+
if (!text)
|
|
245
|
+
return;
|
|
246
|
+
let payload;
|
|
247
|
+
try {
|
|
248
|
+
payload = JSON.parse(text);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return; // unparseable — not our concern, treat as best-effort success
|
|
252
|
+
}
|
|
253
|
+
if (payload?.error) {
|
|
254
|
+
const code = payload.error.code ?? "?";
|
|
255
|
+
const message = payload.error.message ?? "(no message)";
|
|
256
|
+
this.log(` [${host.config.id}/${serverName}] logging/setLevel rejected: ${code} ${message}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
this.log(` [${host.config.id}/${serverName}] logging/setLevel failed: ${err.message}`);
|
|
261
|
+
}
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
exports.Forwarder = Forwarder;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ProxyState } from "../core/state.js";
|
|
2
|
+
import type { DiscoveryRunner } from "../discovery/runner.js";
|
|
3
|
+
import type { PairingController } from "../pairing/controller.js";
|
|
4
|
+
import type { Forwarder } from "./forwarder.js";
|
|
5
|
+
import type { UpstreamBridge } from "./upstream-bridge.js";
|
|
6
|
+
export declare class RequestHandlers {
|
|
7
|
+
private readonly state;
|
|
8
|
+
private readonly runner;
|
|
9
|
+
private readonly forwarder;
|
|
10
|
+
private readonly pairing;
|
|
11
|
+
private readonly bridge;
|
|
12
|
+
private readonly sendResult;
|
|
13
|
+
private readonly sendError;
|
|
14
|
+
constructor(state: ProxyState, runner: DiscoveryRunner, forwarder: Forwarder, pairing: PairingController, bridge: UpstreamBridge, sendResult: (id: string | number | null, result: unknown) => void, sendError: (code: number, detail: string | undefined, id: string | number | null) => void);
|
|
15
|
+
handleInitialize(id: string | number, params: {
|
|
16
|
+
capabilities?: Record<string, unknown>;
|
|
17
|
+
clientInfo?: {
|
|
18
|
+
name?: string;
|
|
19
|
+
version?: string;
|
|
20
|
+
};
|
|
21
|
+
} | undefined): void;
|
|
22
|
+
handleToolsList(id: string | number): Promise<void>;
|
|
23
|
+
handlePromptsList(id: string | number): Promise<void>;
|
|
24
|
+
handlePromptDispatch(id: string | number, params: {
|
|
25
|
+
name?: string;
|
|
26
|
+
arguments?: Record<string, unknown>;
|
|
27
|
+
} | undefined): Promise<void>;
|
|
28
|
+
handleResourcesList(id: string | number): Promise<void>;
|
|
29
|
+
handleResourceTemplatesList(id: string | number): Promise<void>;
|
|
30
|
+
handleResourceMethod(id: string | number, method: string, params: {
|
|
31
|
+
uri?: string;
|
|
32
|
+
} | undefined): Promise<void>;
|
|
33
|
+
handleLoggingSetLevel(id: string | number, params: Record<string, unknown>): Promise<void>;
|
|
34
|
+
handleCompletion(id: string | number, params: {
|
|
35
|
+
ref?: {
|
|
36
|
+
type?: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
uri?: string;
|
|
39
|
+
};
|
|
40
|
+
argument?: unknown;
|
|
41
|
+
}): Promise<void>;
|
|
42
|
+
handleToolDispatch(id: string | number, params: {
|
|
43
|
+
name: string;
|
|
44
|
+
arguments?: Record<string, unknown>;
|
|
45
|
+
_meta?: unknown;
|
|
46
|
+
}): Promise<void>;
|
|
47
|
+
handleClientNotification(method: string, params: Record<string, unknown>): Promise<void>;
|
|
48
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RequestHandlers = void 0;
|
|
4
|
+
const protocol_js_1 = require("../../shared/protocol.js");
|
|
5
|
+
const constants_js_1 = require("../core/constants.js");
|
|
6
|
+
const filtering_js_1 = require("../routing/filtering.js");
|
|
7
|
+
const uri_js_1 = require("../routing/uri.js");
|
|
8
|
+
// One method per JSON-RPC verb the agent can send. Each handler is
|
|
9
|
+
// responsible for: parameter validation, looking up the route from the
|
|
10
|
+
// (already discovered) state, and either answering locally or delegating
|
|
11
|
+
// to Forwarder. Lives here rather than on ProxyServer so the router file
|
|
12
|
+
// stays a thin dispatcher and the per-method logic doesn't have to share
|
|
13
|
+
// a 1k-line class with discovery, pairing, and forwarding.
|
|
14
|
+
class RequestHandlers {
|
|
15
|
+
state;
|
|
16
|
+
runner;
|
|
17
|
+
forwarder;
|
|
18
|
+
pairing;
|
|
19
|
+
bridge;
|
|
20
|
+
sendResult;
|
|
21
|
+
sendError;
|
|
22
|
+
constructor(state, runner, forwarder, pairing, bridge, sendResult, sendError) {
|
|
23
|
+
this.state = state;
|
|
24
|
+
this.runner = runner;
|
|
25
|
+
this.forwarder = forwarder;
|
|
26
|
+
this.pairing = pairing;
|
|
27
|
+
this.bridge = bridge;
|
|
28
|
+
this.sendResult = sendResult;
|
|
29
|
+
this.sendError = sendError;
|
|
30
|
+
}
|
|
31
|
+
handleInitialize(id, params) {
|
|
32
|
+
this.state.clientCapabilities = params?.capabilities ?? {};
|
|
33
|
+
if (params?.clientInfo?.name) {
|
|
34
|
+
this.state.clientInfo = {
|
|
35
|
+
name: params.clientInfo.name,
|
|
36
|
+
version: params.clientInfo.version ?? "unknown",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
this.sendResult(id, {
|
|
40
|
+
protocolVersion: protocol_js_1.MCP_PROTOCOL_VERSION,
|
|
41
|
+
// listChanged is honest in both directions: SSE listeners relay
|
|
42
|
+
// notifications/{tools,prompts,resources}/list_changed from each
|
|
43
|
+
// upstream server with a cache refresh in between, so the agent's
|
|
44
|
+
// follow-up list call sees fresh data.
|
|
45
|
+
capabilities: {
|
|
46
|
+
tools: { listChanged: true },
|
|
47
|
+
prompts: { listChanged: true },
|
|
48
|
+
resources: { listChanged: true, subscribe: true },
|
|
49
|
+
logging: {},
|
|
50
|
+
completions: {},
|
|
51
|
+
},
|
|
52
|
+
serverInfo: { name: protocol_js_1.PACKAGE_NAME, version: protocol_js_1.PACKAGE_VERSION },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async handleToolsList(id) {
|
|
56
|
+
if (!this.state.config) {
|
|
57
|
+
this.sendResult(id, { tools: [constants_js_1.CONFIGURE_TOOL] });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
await this.runner.retryDiscoveryIfNeeded();
|
|
61
|
+
this.sendResult(id, {
|
|
62
|
+
tools: [constants_js_1.CONFIGURE_TOOL, ...(0, filtering_js_1.getFilteredTools)(this.state.config, this.state.hosts, this.state.toolRoute)],
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async handlePromptsList(id) {
|
|
66
|
+
if (!this.state.config) {
|
|
67
|
+
this.sendResult(id, { prompts: [constants_js_1.CONFIGURE_PROMPT] });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await this.runner.retryDiscoveryIfNeeded();
|
|
71
|
+
// Inject CONFIGURE_PROMPT first so re-pairing is always one prompt away
|
|
72
|
+
// regardless of upstream state.
|
|
73
|
+
this.sendResult(id, {
|
|
74
|
+
prompts: [constants_js_1.CONFIGURE_PROMPT, ...(0, filtering_js_1.getAggregatedPrompts)(this.state.config, this.state.hosts, this.state.promptRoute)],
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async handlePromptDispatch(id, params) {
|
|
78
|
+
const promptName = params?.name;
|
|
79
|
+
if (promptName === "configure") {
|
|
80
|
+
let text;
|
|
81
|
+
try {
|
|
82
|
+
text = await this.pairing.handleConfigure();
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
// handleConfigure throws when the pairing tunnel can't come up
|
|
86
|
+
// (cloudflared missing, network unreachable, startup timeout, etc.).
|
|
87
|
+
// Surface the cause as a JSON-RPC error so the agent doesn't hang
|
|
88
|
+
// waiting on a response that never arrives.
|
|
89
|
+
this.sendError(protocol_js_1.ErrorCode.INTERNAL, err.message, id);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.sendResult(id, {
|
|
93
|
+
messages: [
|
|
94
|
+
{ role: "user", content: { type: "text", text: "Show the MCP Proxy setup URL. Do not add any follow-up — do not ask me to let you know or report back." } },
|
|
95
|
+
{ role: "assistant", content: { type: "text", text } },
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (!promptName) {
|
|
101
|
+
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, "name is required", id);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!this.state.config) {
|
|
105
|
+
this.sendError(protocol_js_1.ErrorCode.PROXY_NOT_CONFIGURED, "Call the `configure` tool first.", id);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const route = this.state.promptRoute.get(promptName);
|
|
109
|
+
if (!route || !(0, filtering_js_1.isServerSelected)(this.state.config, route.hostId, route.serverName)) {
|
|
110
|
+
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown prompt: ${promptName}`, id);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const upstream = { name: route.originalName };
|
|
114
|
+
if (params?.arguments !== undefined)
|
|
115
|
+
upstream.arguments = params.arguments;
|
|
116
|
+
const meta = params?._meta;
|
|
117
|
+
if (meta !== undefined)
|
|
118
|
+
upstream._meta = meta;
|
|
119
|
+
await this.forwarder.forwardRoutedRequest(id, route, "prompts/get", upstream);
|
|
120
|
+
}
|
|
121
|
+
async handleResourcesList(id) {
|
|
122
|
+
if (!this.state.config) {
|
|
123
|
+
this.sendResult(id, { resources: [] });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
await this.runner.retryDiscoveryIfNeeded();
|
|
127
|
+
this.sendResult(id, {
|
|
128
|
+
resources: (0, filtering_js_1.getAggregatedResources)(this.state.config, this.state.hosts, this.state.resources.exactEntries()),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async handleResourceTemplatesList(id) {
|
|
132
|
+
if (!this.state.config) {
|
|
133
|
+
this.sendResult(id, { resourceTemplates: [] });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
await this.runner.retryDiscoveryIfNeeded();
|
|
137
|
+
this.sendResult(id, {
|
|
138
|
+
resourceTemplates: (0, filtering_js_1.getAggregatedResourceTemplates)(this.state.config, this.state.hosts, this.state.templateRoutes),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
async handleResourceMethod(id, method, params) {
|
|
142
|
+
if (!this.state.config) {
|
|
143
|
+
this.sendError(protocol_js_1.ErrorCode.PROXY_NOT_CONFIGURED, "Call the `configure` tool first.", id);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const uri = params?.uri;
|
|
147
|
+
if (!uri) {
|
|
148
|
+
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, "uri is required", id);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const parsed = (0, uri_js_1.unwrapResourceUri)(uri);
|
|
152
|
+
if (!parsed) {
|
|
153
|
+
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Not a recognised resource URI: ${uri}`, id);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!(0, filtering_js_1.isServerSelected)(this.state.config, parsed.hostId, parsed.serverName)) {
|
|
157
|
+
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `No upstream server owns resource URI: ${uri}`, id);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!this.state.hosts.get(parsed.hostId)?.servers.has(parsed.serverName)) {
|
|
161
|
+
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `No upstream server owns resource URI: ${uri}`, id);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// Deliberately NO per-URI allowlist here. The host's authority model is
|
|
165
|
+
// "anyone holding (tunnelUrl, authToken) can call any MCP method on any
|
|
166
|
+
// child server" — there is no resource-level ACL on the host side, no
|
|
167
|
+
// `selectedResources` field on PairingConfig, and no resource picker in
|
|
168
|
+
// the setup UI. The proxy's selection gates (selectedServers,
|
|
169
|
+
// selectedTools) constrain what the agent can reach THROUGH the proxy;
|
|
170
|
+
// a credentialed attacker bypasses the proxy entirely, so adding a
|
|
171
|
+
// proxy-side resource check would not raise the privilege floor. A
|
|
172
|
+
// discovered-set check would also reject legitimate dynamic URIs
|
|
173
|
+
// returned by resources/read on directory-style resources (see
|
|
174
|
+
// forwarder.ts wrapResourceUri block) — false positives with no
|
|
175
|
+
// matching security gain. handleCompletion's ref/resource branch
|
|
176
|
+
// intentionally mirrors this.
|
|
177
|
+
const route = {
|
|
178
|
+
hostId: parsed.hostId,
|
|
179
|
+
serverName: parsed.serverName,
|
|
180
|
+
originalName: parsed.originalUri,
|
|
181
|
+
};
|
|
182
|
+
await this.forwarder.forwardRoutedRequest(id, route, method, { ...(params ?? {}), uri: parsed.originalUri });
|
|
183
|
+
}
|
|
184
|
+
async handleLoggingSetLevel(id, params) {
|
|
185
|
+
if (!this.state.config) {
|
|
186
|
+
this.sendResult(id, {});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
await this.forwarder.broadcastSetLogLevel(params);
|
|
190
|
+
this.sendResult(id, {});
|
|
191
|
+
}
|
|
192
|
+
async handleCompletion(id, params) {
|
|
193
|
+
if (!this.state.config) {
|
|
194
|
+
this.sendError(protocol_js_1.ErrorCode.PROXY_NOT_CONFIGURED, "Call the `configure` tool first.", id);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const ref = params.ref;
|
|
198
|
+
if (!ref || typeof ref.type !== "string") {
|
|
199
|
+
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, "ref.type is required", id);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
let route = null;
|
|
203
|
+
let upstreamRef = null;
|
|
204
|
+
if (ref.type === "ref/prompt") {
|
|
205
|
+
if (!ref.name) {
|
|
206
|
+
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, "ref.name is required for ref/prompt", id);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
route = this.state.promptRoute.get(ref.name) ?? null;
|
|
210
|
+
if (route)
|
|
211
|
+
upstreamRef = { type: ref.type, name: route.originalName };
|
|
212
|
+
}
|
|
213
|
+
else if (ref.type === "ref/resource") {
|
|
214
|
+
if (!ref.uri) {
|
|
215
|
+
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, "ref.uri is required for ref/resource", id);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const parsed = (0, uri_js_1.unwrapResourceUri)(ref.uri);
|
|
219
|
+
// Server-level gate only — no per-URI allowlist. See the comment in
|
|
220
|
+
// handleResourceMethod for the threat-model reasoning.
|
|
221
|
+
if (parsed && this.state.hosts.get(parsed.hostId)?.servers.has(parsed.serverName)) {
|
|
222
|
+
route = {
|
|
223
|
+
hostId: parsed.hostId,
|
|
224
|
+
serverName: parsed.serverName,
|
|
225
|
+
originalName: parsed.originalUri,
|
|
226
|
+
};
|
|
227
|
+
upstreamRef = { type: ref.type, uri: parsed.originalUri };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown ref.type: ${ref.type}`, id);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (!route || !upstreamRef || !(0, filtering_js_1.isServerSelected)(this.state.config, route.hostId, route.serverName)) {
|
|
235
|
+
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `No upstream server matches ref`, id);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
await this.forwarder.forwardRoutedRequest(id, route, "completion/complete", {
|
|
239
|
+
ref: upstreamRef,
|
|
240
|
+
...(params.argument !== undefined ? { argument: params.argument } : {}),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
async handleToolDispatch(id, params) {
|
|
244
|
+
if (params.name === "configure") {
|
|
245
|
+
let text;
|
|
246
|
+
try {
|
|
247
|
+
text = await this.pairing.handleConfigure();
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
this.sendError(protocol_js_1.ErrorCode.INTERNAL, err.message, id);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
this.sendResult(id, { content: [{ type: "text", text }] });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (!this.state.config) {
|
|
257
|
+
this.sendError(protocol_js_1.ErrorCode.PROXY_NOT_CONFIGURED, "Call the `configure` tool first.", id);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const route = this.state.toolRoute.get(params.name);
|
|
261
|
+
if (!route || !(0, filtering_js_1.isServerSelected)(this.state.config, route.hostId, route.serverName)) {
|
|
262
|
+
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown tool: ${params.name}`, id);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// selectedTools is a tool-level filter on top of the server-level gate.
|
|
266
|
+
if (this.state.config.selectedTools !== undefined && !this.state.config.selectedTools.includes(params.name)) {
|
|
267
|
+
this.sendError(protocol_js_1.ErrorCode.INVALID_PARAMS, `Unknown tool: ${params.name}`, id);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// Preserve `_meta` so the upstream server still sees the agent's
|
|
271
|
+
// progressToken and can emit notifications/progress against it.
|
|
272
|
+
const upstream = { name: route.originalName, arguments: params.arguments };
|
|
273
|
+
if (params._meta !== undefined)
|
|
274
|
+
upstream._meta = params._meta;
|
|
275
|
+
await this.forwarder.forwardRoutedRequest(id, route, "tools/call", upstream);
|
|
276
|
+
}
|
|
277
|
+
async handleClientNotification(method, params) {
|
|
278
|
+
// Sent during initServer for each upstream session — never re-broadcast.
|
|
279
|
+
if (method === "notifications/initialized")
|
|
280
|
+
return;
|
|
281
|
+
if (method === "notifications/cancelled") {
|
|
282
|
+
const reqId = params.requestId;
|
|
283
|
+
if (reqId === undefined)
|
|
284
|
+
return;
|
|
285
|
+
// Two id namespaces. `inflight` covers requests we sent upstream
|
|
286
|
+
// (tools/call, prompts/get, resources/*). `bridge` covers
|
|
287
|
+
// server→client requests we forwarded out (sampling, elicitation,
|
|
288
|
+
// roots/list, ping): the client sees our synthetic id and cancels
|
|
289
|
+
// using that, so we translate it back to the upstream's original id
|
|
290
|
+
// before forwarding. Without this branch the upstream child waited
|
|
291
|
+
// the full UPSTREAM_REQUEST_TIMEOUT_MS for a response the client had
|
|
292
|
+
// already abandoned.
|
|
293
|
+
const route = this.state.inflight.get(reqId);
|
|
294
|
+
if (route) {
|
|
295
|
+
this.forwarder.forwardNotification(route, method, params).catch(() => { });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const ctx = this.bridge.consumeForCancel(reqId);
|
|
299
|
+
if (ctx) {
|
|
300
|
+
const translated = { ...params, requestId: ctx.originalId };
|
|
301
|
+
this.forwarder.forwardNotification({ hostId: ctx.hostId, serverName: ctx.serverName, originalName: "" }, method, translated).catch(() => { });
|
|
302
|
+
}
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (method === "notifications/roots/list_changed") {
|
|
306
|
+
const targets = [];
|
|
307
|
+
for (const host of this.state.hosts.values()) {
|
|
308
|
+
for (const serverName of host.servers.keys()) {
|
|
309
|
+
if (!(0, filtering_js_1.isServerSelected)(this.state.config, host.config.id, serverName))
|
|
310
|
+
continue;
|
|
311
|
+
targets.push({ hostId: host.config.id, serverName, originalName: "" });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
await Promise.all(targets.map((t) => this.forwarder.forwardNotification(t, method, params).catch(() => { })));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (method === "notifications/progress") {
|
|
318
|
+
const progressToken = params.progressToken;
|
|
319
|
+
if (progressToken === undefined)
|
|
320
|
+
return;
|
|
321
|
+
const route = this.state.progressTokens.get(progressToken);
|
|
322
|
+
if (!route)
|
|
323
|
+
return;
|
|
324
|
+
this.forwarder.forwardNotification(route, method, params).catch(() => { });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
exports.RequestHandlers = RequestHandlers;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { HostState } from "../core/types.js";
|
|
2
|
+
export interface SseCallbacks {
|
|
3
|
+
isCurrent: (host: HostState, name: string, sessionId: string) => boolean;
|
|
4
|
+
onUpstreamRequest: (host: HostState, name: string, msg: {
|
|
5
|
+
id: string | number;
|
|
6
|
+
method: string;
|
|
7
|
+
params?: unknown;
|
|
8
|
+
}) => void;
|
|
9
|
+
onListChanged: (host: HostState, name: string, kind: "tools" | "prompts" | "resources") => Promise<void>;
|
|
10
|
+
onNotification: (msg: unknown) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare class SseReader {
|
|
13
|
+
private readonly cb;
|
|
14
|
+
constructor(cb: SseCallbacks);
|
|
15
|
+
start(host: HostState, name: string, sessionId: string): void;
|
|
16
|
+
private loop;
|
|
17
|
+
private consume;
|
|
18
|
+
private dispatchData;
|
|
19
|
+
}
|