@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.
Files changed (69) hide show
  1. package/README.md +62 -17
  2. package/dist/host/agent.d.ts +22 -0
  3. package/dist/host/agent.js +314 -0
  4. package/dist/host/cli.d.ts +1 -0
  5. package/dist/host/cli.js +83 -0
  6. package/dist/host/constants.d.ts +4 -0
  7. package/dist/host/constants.js +16 -0
  8. package/dist/host/session.d.ts +21 -0
  9. package/dist/host/session.js +204 -0
  10. package/dist/host/tunnel.d.ts +5 -0
  11. package/dist/host/tunnel.js +82 -0
  12. package/dist/host.js +8 -0
  13. package/dist/proxy/core/constants.d.ts +13 -0
  14. package/dist/proxy/core/constants.js +39 -0
  15. package/dist/proxy/core/fetch-timeout.d.ts +1 -0
  16. package/dist/proxy/core/fetch-timeout.js +15 -0
  17. package/dist/proxy/core/state.d.ts +25 -0
  18. package/dist/proxy/core/state.js +90 -0
  19. package/dist/proxy/core/types.d.ts +57 -0
  20. package/dist/proxy/core/types.js +5 -0
  21. package/dist/proxy/discovery/client.d.ts +42 -0
  22. package/dist/proxy/discovery/client.js +283 -0
  23. package/dist/proxy/discovery/runner.d.ts +21 -0
  24. package/dist/proxy/discovery/runner.js +319 -0
  25. package/dist/proxy/pairing/config.d.ts +9 -0
  26. package/dist/proxy/pairing/config.js +130 -0
  27. package/dist/proxy/pairing/controller.d.ts +19 -0
  28. package/dist/proxy/pairing/controller.js +327 -0
  29. package/dist/proxy/pairing/http.d.ts +70 -0
  30. package/dist/proxy/pairing/http.js +155 -0
  31. package/dist/proxy/pairing/static-assets.d.ts +4 -0
  32. package/dist/proxy/pairing/static-assets.js +13 -0
  33. package/dist/proxy/pairing/tunnel.d.ts +13 -0
  34. package/dist/proxy/pairing/tunnel.js +130 -0
  35. package/dist/proxy/pairing/validation.d.ts +2 -0
  36. package/dist/proxy/pairing/validation.js +62 -0
  37. package/dist/proxy/routing/filtering.d.ts +13 -0
  38. package/dist/proxy/routing/filtering.js +116 -0
  39. package/dist/proxy/routing/router.d.ts +17 -0
  40. package/dist/proxy/routing/router.js +74 -0
  41. package/dist/proxy/routing/uri.d.ts +7 -0
  42. package/dist/proxy/routing/uri.js +39 -0
  43. package/dist/proxy/runtime/forwarder.d.ts +15 -0
  44. package/dist/proxy/runtime/forwarder.js +265 -0
  45. package/dist/proxy/runtime/handlers.d.ts +48 -0
  46. package/dist/proxy/runtime/handlers.js +329 -0
  47. package/dist/proxy/runtime/sse.d.ts +19 -0
  48. package/dist/proxy/runtime/sse.js +169 -0
  49. package/dist/proxy/runtime/upstream-bridge.d.ts +27 -0
  50. package/dist/proxy/runtime/upstream-bridge.js +133 -0
  51. package/dist/proxy/server.d.ts +15 -0
  52. package/dist/proxy/server.js +167 -0
  53. package/dist/proxy.js +5 -0
  54. package/{mcp/dist → dist}/shared/protocol.d.ts +15 -3
  55. package/dist/shared/protocol.js +183 -0
  56. package/dist/wrapper.d.ts +2 -0
  57. package/dist/wrapper.js +72 -0
  58. package/package.json +15 -7
  59. package/static/setup.css +233 -0
  60. package/static/setup.html +57 -0
  61. package/static/setup.js +711 -0
  62. package/static/style.css +208 -0
  63. package/mcp/dist/host.js +0 -307
  64. package/mcp/dist/proxy.js +0 -374
  65. package/mcp/dist/shared/generated.d.ts +0 -2
  66. package/mcp/dist/shared/generated.js +0 -5
  67. package/mcp/dist/shared/protocol.js +0 -79
  68. /package/{mcp/dist → dist}/host.d.ts +0 -0
  69. /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
+ }