@silver886/mcp-proxy 0.1.4 → 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 -377
- 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,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validatePairingConfig = validatePairingConfig;
|
|
4
|
+
const protocol_js_1 = require("../../shared/protocol.js");
|
|
5
|
+
const constants_js_1 = require("../core/constants.js");
|
|
6
|
+
const validation_js_1 = require("./validation.js");
|
|
7
|
+
// Validate a PairingConfig submitted by the setup page. Pure: returns the
|
|
8
|
+
// canonicalised host list (tunnelUrl reduced to its origin so query strings
|
|
9
|
+
// /paths can't smuggle through) or an error string. Centralised here rather
|
|
10
|
+
// than inlined in handleComplete so the rules are reviewable independently
|
|
11
|
+
// of the orchestrator. Every failure mode produces a human-readable error
|
|
12
|
+
// the setup page surfaces back to the user.
|
|
13
|
+
function validatePairingConfig(cfg) {
|
|
14
|
+
if (!cfg || cfg.sealed !== true)
|
|
15
|
+
return { ok: false, error: "config must be sealed" };
|
|
16
|
+
if (!Array.isArray(cfg.hosts) || cfg.hosts.length === 0) {
|
|
17
|
+
return { ok: false, error: "hosts must be a non-empty array" };
|
|
18
|
+
}
|
|
19
|
+
if (cfg.selectedServers !== undefined) {
|
|
20
|
+
if (!Array.isArray(cfg.selectedServers)) {
|
|
21
|
+
return { ok: false, error: "selectedServers must be an array if provided" };
|
|
22
|
+
}
|
|
23
|
+
// `undefined` means "allow all", which is a valid distinct shape. An
|
|
24
|
+
// explicit empty array would seal a config that exposes zero servers —
|
|
25
|
+
// exactly what the UI's "must select at least one" rule prevents on the
|
|
26
|
+
// client. Reject it here so a direct caller of /pair/complete can't
|
|
27
|
+
// bypass that gate and persist a paired-but-empty proxy.
|
|
28
|
+
if (cfg.selectedServers.length === 0) {
|
|
29
|
+
return { ok: false, error: "selectedServers must not be empty if provided" };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (cfg.selectedTools !== undefined && !Array.isArray(cfg.selectedTools)) {
|
|
33
|
+
return { ok: false, error: "selectedTools must be an array if provided" };
|
|
34
|
+
}
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
const validatedHosts = [];
|
|
37
|
+
for (const h of cfg.hosts) {
|
|
38
|
+
if (!h || typeof h !== "object")
|
|
39
|
+
return { ok: false, error: "each host must be an object" };
|
|
40
|
+
if (typeof h.id !== "string" || !h.id)
|
|
41
|
+
return { ok: false, error: "host.id is required" };
|
|
42
|
+
const reason = (0, protocol_js_1.validateServerName)(h.id);
|
|
43
|
+
if (reason)
|
|
44
|
+
return { ok: false, error: `host.id "${h.id}" ${reason}` };
|
|
45
|
+
if (seen.has(h.id))
|
|
46
|
+
return { ok: false, error: `duplicate host.id "${h.id}"` };
|
|
47
|
+
seen.add(h.id);
|
|
48
|
+
if (typeof h.tunnelUrl !== "string" || !h.tunnelUrl) {
|
|
49
|
+
return { ok: false, error: `host "${h.id}".tunnelUrl is required` };
|
|
50
|
+
}
|
|
51
|
+
if (typeof h.authToken !== "string" || !h.authToken) {
|
|
52
|
+
return { ok: false, error: `host "${h.id}".authToken is required` };
|
|
53
|
+
}
|
|
54
|
+
const validatedUrl = (0, validation_js_1.allowedTunnelUrl)(h.tunnelUrl);
|
|
55
|
+
if (!validatedUrl) {
|
|
56
|
+
return { ok: false, error: `host "${h.id}".tunnelUrl is not on the allowlist (must be https on a Cloudflare tunnel domain)` };
|
|
57
|
+
}
|
|
58
|
+
validatedHosts.push({
|
|
59
|
+
id: h.id,
|
|
60
|
+
tunnelUrl: validatedUrl.origin,
|
|
61
|
+
authToken: h.authToken,
|
|
62
|
+
...(typeof h.label === "string" && h.label ? { label: h.label } : {}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// selectedServers entries must reference hosts we actually know about,
|
|
66
|
+
// otherwise an attacker (or a stale UI) could shape the config so the
|
|
67
|
+
// server-level allow list never triggered. Catching it here keeps the
|
|
68
|
+
// invariant on the way IN to ProxyServer state.
|
|
69
|
+
//
|
|
70
|
+
// Server names may themselves contain `__`, so we don't `split` — we
|
|
71
|
+
// take the prefix before the FIRST `__` as the hostId and require the
|
|
72
|
+
// remainder (the serverName) to be non-empty so a malformed entry like
|
|
73
|
+
// `host__` doesn't sneak through as a valid-looking allowlist line that
|
|
74
|
+
// can never match any real server.
|
|
75
|
+
const validHostIds = new Set(validatedHosts.map((h) => h.id));
|
|
76
|
+
if (cfg.selectedServers) {
|
|
77
|
+
for (const entry of cfg.selectedServers) {
|
|
78
|
+
if (typeof entry !== "string") {
|
|
79
|
+
return { ok: false, error: `selectedServers entries must look like "<hostId>__<serverName>"` };
|
|
80
|
+
}
|
|
81
|
+
const sep = entry.indexOf(constants_js_1.TOOL_SEPARATOR);
|
|
82
|
+
if (sep <= 0 || sep + constants_js_1.TOOL_SEPARATOR.length >= entry.length) {
|
|
83
|
+
return { ok: false, error: `selectedServers entry "${entry}" must look like "<hostId>__<serverName>"` };
|
|
84
|
+
}
|
|
85
|
+
const hostId = entry.slice(0, sep);
|
|
86
|
+
if (!validHostIds.has(hostId)) {
|
|
87
|
+
return { ok: false, error: `selectedServers references unknown host "${hostId}"` };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// selectedTools entries are full prefixed tool names
|
|
92
|
+
// `<hostId>__<serverName>__<toolName>`. We can't validate <toolName>
|
|
93
|
+
// here (tools are discovered post-pair), but the prefix MUST point at
|
|
94
|
+
// an exposed server AND the entry must contain enough segments to
|
|
95
|
+
// actually carry a tool name: an entry whose server prefix isn't in
|
|
96
|
+
// selectedServers (when defined) — or whose hostId isn't a known host
|
|
97
|
+
// (when selectedServers is omitted) — is unreachable noise that hides
|
|
98
|
+
// bugs in the UI or stale configs. Tool/server names may themselves
|
|
99
|
+
// contain `__`, so we prefix-match the allowed scope rather than
|
|
100
|
+
// splitting on the separator. Shape is enforced separately by requiring
|
|
101
|
+
// at least two `__` occurrences — the prefix-match alone degraded to a
|
|
102
|
+
// hostId-only check when `selectedServers` was omitted, letting entries
|
|
103
|
+
// like `host__bogus` survive without a tool segment.
|
|
104
|
+
if (cfg.selectedTools) {
|
|
105
|
+
const allowedServerPrefixes = cfg.selectedServers
|
|
106
|
+
? cfg.selectedServers.map((s) => `${s}${constants_js_1.TOOL_SEPARATOR}`)
|
|
107
|
+
: Array.from(validHostIds).map((id) => `${id}${constants_js_1.TOOL_SEPARATOR}`);
|
|
108
|
+
const scopeLabel = cfg.selectedServers ? "selectedServers" : "any known host";
|
|
109
|
+
for (const entry of cfg.selectedTools) {
|
|
110
|
+
if (typeof entry !== "string" || !entry) {
|
|
111
|
+
return { ok: false, error: "selectedTools entries must be non-empty strings" };
|
|
112
|
+
}
|
|
113
|
+
const firstSep = entry.indexOf(constants_js_1.TOOL_SEPARATOR);
|
|
114
|
+
const secondSep = firstSep < 0 ? -1 : entry.indexOf(constants_js_1.TOOL_SEPARATOR, firstSep + constants_js_1.TOOL_SEPARATOR.length);
|
|
115
|
+
if (firstSep < 0 || secondSep < 0) {
|
|
116
|
+
return { ok: false, error: `selectedTools entry "${entry}" must look like "<hostId>__<serverName>__<toolName>"` };
|
|
117
|
+
}
|
|
118
|
+
const matchedPrefix = allowedServerPrefixes.find((p) => entry.startsWith(p));
|
|
119
|
+
if (!matchedPrefix) {
|
|
120
|
+
return { ok: false, error: `selectedTools entry "${entry}" does not reference a server in ${scopeLabel}` };
|
|
121
|
+
}
|
|
122
|
+
// Must have at least one character after the matched server prefix
|
|
123
|
+
// — i.e. an actual tool segment, not the prefix on its own.
|
|
124
|
+
if (entry.length <= matchedPrefix.length) {
|
|
125
|
+
return { ok: false, error: `selectedTools entry "${entry}" is missing the tool name` };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { ok: true, hosts: validatedHosts };
|
|
130
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ProxyState } from "../core/state.js";
|
|
2
|
+
import type { DiscoveryRunner } from "../discovery/runner.js";
|
|
3
|
+
import type { UpstreamBridge } from "../runtime/upstream-bridge.js";
|
|
4
|
+
export declare class PairingController {
|
|
5
|
+
private readonly state;
|
|
6
|
+
private readonly runner;
|
|
7
|
+
private readonly bridge;
|
|
8
|
+
private readonly log;
|
|
9
|
+
private readonly sendNotification;
|
|
10
|
+
private pairing;
|
|
11
|
+
constructor(state: ProxyState, runner: DiscoveryRunner, bridge: UpstreamBridge, log: (line: string) => void, sendNotification: (method: string) => void);
|
|
12
|
+
handleConfigure(): Promise<string>;
|
|
13
|
+
teardownPairing(): void;
|
|
14
|
+
private validateHostCreds;
|
|
15
|
+
private handleListServers;
|
|
16
|
+
private handleDiscover;
|
|
17
|
+
private handleComplete;
|
|
18
|
+
closeAllSessions(): Promise<void>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PairingController = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const protocol_js_1 = require("../../shared/protocol.js");
|
|
6
|
+
const constants_js_1 = require("../core/constants.js");
|
|
7
|
+
const client_js_1 = require("../discovery/client.js");
|
|
8
|
+
const config_js_1 = require("./config.js");
|
|
9
|
+
const http_js_1 = require("./http.js");
|
|
10
|
+
const tunnel_js_1 = require("./tunnel.js");
|
|
11
|
+
const validation_js_1 = require("./validation.js");
|
|
12
|
+
// Owns the pairing flow end-to-end: brings up the temporary HTTP server +
|
|
13
|
+
// cloudflared tunnel, serves the setup page, mediates discovery on the
|
|
14
|
+
// browser's behalf (so pairing-time and runtime use the same client
|
|
15
|
+
// capabilities/clientInfo), validates the submitted config, and atomically
|
|
16
|
+
// installs it into ProxyState. Also closes any prior pairing's sessions
|
|
17
|
+
// before the next pairing's discovery starts.
|
|
18
|
+
class PairingController {
|
|
19
|
+
state;
|
|
20
|
+
runner;
|
|
21
|
+
bridge;
|
|
22
|
+
log;
|
|
23
|
+
sendNotification;
|
|
24
|
+
pairing = null;
|
|
25
|
+
constructor(state, runner, bridge, log, sendNotification) {
|
|
26
|
+
this.state = state;
|
|
27
|
+
this.runner = runner;
|
|
28
|
+
this.bridge = bridge;
|
|
29
|
+
this.log = log;
|
|
30
|
+
this.sendNotification = sendNotification;
|
|
31
|
+
}
|
|
32
|
+
async handleConfigure() {
|
|
33
|
+
this.teardownPairing();
|
|
34
|
+
const bearer = (0, node_crypto_1.randomBytes)(32).toString("base64url");
|
|
35
|
+
const http = new http_js_1.PairingHttpServer(bearer, {
|
|
36
|
+
listServers: (req) => this.handleListServers(req),
|
|
37
|
+
discover: (req) => this.handleDiscover(req),
|
|
38
|
+
complete: (cfg) => this.handleComplete(cfg),
|
|
39
|
+
// Surface the existing PairingConfig so a reconfigure flow can
|
|
40
|
+
// pre-fill the setup page. The endpoint is already gated by the
|
|
41
|
+
// pairing bearer token, so disclosing the auth tokens here is
|
|
42
|
+
// bounded by the same trust boundary as a fresh pairing.
|
|
43
|
+
info: () => ({
|
|
44
|
+
name: protocol_js_1.PACKAGE_NAME,
|
|
45
|
+
version: protocol_js_1.PACKAGE_VERSION,
|
|
46
|
+
...(this.state.config ? {
|
|
47
|
+
current: {
|
|
48
|
+
hosts: this.state.config.hosts.map((h) => ({
|
|
49
|
+
id: h.id,
|
|
50
|
+
tunnelUrl: h.tunnelUrl,
|
|
51
|
+
authToken: h.authToken,
|
|
52
|
+
...(h.label ? { label: h.label } : {}),
|
|
53
|
+
})),
|
|
54
|
+
...(this.state.config.selectedServers ? { selectedServers: [...this.state.config.selectedServers] } : {}),
|
|
55
|
+
...(this.state.config.selectedTools ? { selectedTools: [...this.state.config.selectedTools] } : {}),
|
|
56
|
+
},
|
|
57
|
+
} : {}),
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
const port = await http.listen();
|
|
61
|
+
const tunnel = new tunnel_js_1.PairingTunnel();
|
|
62
|
+
let tunnelUrl;
|
|
63
|
+
try {
|
|
64
|
+
tunnelUrl = await tunnel.start(port, (reason) => {
|
|
65
|
+
// cloudflared/wrapper died after advertising a URL. The setup link
|
|
66
|
+
// we already returned points at a dead tunnel; tear pairing down so
|
|
67
|
+
// a subsequent `configure` call can re-bootstrap instead of waiting
|
|
68
|
+
// out PAIRING_WINDOW_MS. The reason carries whatever cloudflared
|
|
69
|
+
// last reported so the operator knows whether to retry or
|
|
70
|
+
// investigate.
|
|
71
|
+
this.log(` Pairing tunnel exited unexpectedly (${reason}); setup URL invalidated`);
|
|
72
|
+
this.teardownPairing();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
http.close();
|
|
77
|
+
tunnel.stop();
|
|
78
|
+
throw new Error(`Failed to start pairing tunnel: ${err.message}`);
|
|
79
|
+
}
|
|
80
|
+
// Setup page is served from the pairing tunnel itself — same origin as
|
|
81
|
+
// the /pair/* endpoints, so no CORS is involved. Token rides in the
|
|
82
|
+
// URL fragment so it never leaves the browser as Referer / origin log.
|
|
83
|
+
const setupUrl = `${tunnelUrl.replace(/\/+$/, "")}/#token=${bearer}`;
|
|
84
|
+
const expiryTimer = setTimeout(() => {
|
|
85
|
+
this.log(` Pairing window expired (${constants_js_1.PAIRING_WINDOW_MS / 1000}s); tunnel closed`);
|
|
86
|
+
this.teardownPairing();
|
|
87
|
+
}, constants_js_1.PAIRING_WINDOW_MS);
|
|
88
|
+
expiryTimer.unref();
|
|
89
|
+
this.pairing = { tunnel, http, setupUrl, expiryTimer };
|
|
90
|
+
this.log(`\n Configure at: ${setupUrl}\n`);
|
|
91
|
+
return `Open this URL in your browser to set up the MCP Proxy:\n\n${setupUrl}\n\nThe proxy will connect automatically once setup is complete.`;
|
|
92
|
+
}
|
|
93
|
+
teardownPairing() {
|
|
94
|
+
if (!this.pairing)
|
|
95
|
+
return;
|
|
96
|
+
const { tunnel, http, expiryTimer } = this.pairing;
|
|
97
|
+
this.pairing = null;
|
|
98
|
+
clearTimeout(expiryTimer);
|
|
99
|
+
try {
|
|
100
|
+
http.close();
|
|
101
|
+
}
|
|
102
|
+
catch { /* already closed */ }
|
|
103
|
+
try {
|
|
104
|
+
tunnel.stop();
|
|
105
|
+
}
|
|
106
|
+
catch { /* already stopped */ }
|
|
107
|
+
}
|
|
108
|
+
// Validate the host credentials the browser submitted on a /pair/* call.
|
|
109
|
+
// Single source of truth so list-servers and discover share the same
|
|
110
|
+
// gating rules — without this the two endpoints could disagree about
|
|
111
|
+
// what counts as a valid tunnel URL or what the canonicalised origin is.
|
|
112
|
+
validateHostCreds(req) {
|
|
113
|
+
if (!req.tunnelUrl || !req.authToken) {
|
|
114
|
+
return { ok: false, error: "tunnelUrl and authToken are required" };
|
|
115
|
+
}
|
|
116
|
+
const url = (0, validation_js_1.allowedTunnelUrl)(req.tunnelUrl);
|
|
117
|
+
if (!url) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: "tunnelUrl is not on the allowlist. Use an https URL on a Cloudflare tunnel domain (.trycloudflare.com / .cfargotunnel.com), or extend MCP_TUNNEL_HOST_SUFFIXES / MCP_ALLOW_LOCAL on the proxy environment.",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
origin: url.origin,
|
|
126
|
+
headers: {
|
|
127
|
+
"Content-Type": "application/json",
|
|
128
|
+
Accept: "application/json, text/event-stream",
|
|
129
|
+
Authorization: `Bearer ${req.authToken}`,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// GET / on the host agent — list of advertised server names. Surfaces
|
|
134
|
+
// an explicit 401 status to the browser so the setup page can show
|
|
135
|
+
// "invalid auth token" rather than a generic upstream error.
|
|
136
|
+
async handleListServers(req) {
|
|
137
|
+
const v = this.validateHostCreds(req);
|
|
138
|
+
if (!v.ok)
|
|
139
|
+
return { ok: false, error: v.error, status: 400 };
|
|
140
|
+
try {
|
|
141
|
+
const servers = await (0, client_js_1.listHostServers)(v.origin, v.headers);
|
|
142
|
+
return { ok: true, servers };
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
const msg = err.message;
|
|
146
|
+
if (msg.startsWith("list HTTP 401")) {
|
|
147
|
+
return { ok: false, error: "invalid auth token", status: 401 };
|
|
148
|
+
}
|
|
149
|
+
return { ok: false, error: msg };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Per-server discovery on behalf of the setup page. Uses the captured
|
|
153
|
+
// clientCapabilities/clientInfo from the live MCP session — same values
|
|
154
|
+
// the runtime path will use — so capability-gated upstreams cannot look
|
|
155
|
+
// empty here and rich at runtime (or vice versa). Always closes the
|
|
156
|
+
// host-side session afterwards: pairing is read-only inspection, not
|
|
157
|
+
// an active session.
|
|
158
|
+
async handleDiscover(req) {
|
|
159
|
+
// Local validation failures must surface as HTTP 400, matching
|
|
160
|
+
// handleListServers — without `status` the http layer defaults to 502,
|
|
161
|
+
// which mislabels caller input errors as upstream failures and makes
|
|
162
|
+
// the two pairing endpoints disagree about the same class of problem.
|
|
163
|
+
const v = this.validateHostCreds(req);
|
|
164
|
+
if (!v.ok)
|
|
165
|
+
return { ok: false, error: v.error, status: 400 };
|
|
166
|
+
if (!req.serverName)
|
|
167
|
+
return { ok: false, error: "serverName is required", status: 400 };
|
|
168
|
+
const reason = (0, protocol_js_1.validateServerName)(req.serverName);
|
|
169
|
+
if (reason)
|
|
170
|
+
return { ok: false, error: `serverName ${reason}`, status: 400 };
|
|
171
|
+
const targetUrl = `${v.origin}/servers/${req.serverName}`;
|
|
172
|
+
let sessionId;
|
|
173
|
+
try {
|
|
174
|
+
const result = await (0, client_js_1.discoverServerCapabilities)(targetUrl, v.headers, req.serverName, this.state.clientCapabilities, this.state.clientInfo);
|
|
175
|
+
sessionId = result.sessionId;
|
|
176
|
+
const out = {
|
|
177
|
+
ok: true,
|
|
178
|
+
tools: result.tools,
|
|
179
|
+
prompts: result.prompts,
|
|
180
|
+
resources: result.resources,
|
|
181
|
+
resourceTemplates: result.resourceTemplates,
|
|
182
|
+
};
|
|
183
|
+
if (Object.keys(result.capErrors).length > 0)
|
|
184
|
+
out.capErrors = result.capErrors;
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
const dErr = err;
|
|
189
|
+
sessionId = dErr.sessionId;
|
|
190
|
+
// Propagate auth failures the same way list-servers does so the UI
|
|
191
|
+
// can show "invalid auth token" instead of a generic 502. Discovery
|
|
192
|
+
// errors come from initialize / tools/list / prompts/list / etc and
|
|
193
|
+
// all spell their HTTP status as "<method> HTTP <status>: …".
|
|
194
|
+
if (/HTTP 401\b/.test(dErr.message ?? "")) {
|
|
195
|
+
return { ok: false, error: "invalid auth token", status: 401 };
|
|
196
|
+
}
|
|
197
|
+
return { ok: false, error: dErr.message };
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
if (sessionId) {
|
|
201
|
+
void (0, client_js_1.deleteSession)(targetUrl, { ...v.headers, "Mcp-Session-Id": sessionId });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async handleComplete(cfg) {
|
|
206
|
+
const validation = (0, config_js_1.validatePairingConfig)(cfg);
|
|
207
|
+
if (!validation.ok)
|
|
208
|
+
return { ok: false, error: validation.error };
|
|
209
|
+
// Snapshot the active pairing before tearing it down so we can roll
|
|
210
|
+
// back if the new pairing fails to land any server. closeAllSessions
|
|
211
|
+
// is unconditional, so without this snapshot a bad submit would leave
|
|
212
|
+
// the proxy paired-but-empty with no path back to the prior config.
|
|
213
|
+
const previousConfig = this.state.config;
|
|
214
|
+
const previousHosts = previousConfig?.hosts ?? null;
|
|
215
|
+
await this.closeAllSessions();
|
|
216
|
+
this.state.installConfig({
|
|
217
|
+
hosts: validation.hosts,
|
|
218
|
+
selectedServers: cfg.selectedServers,
|
|
219
|
+
selectedTools: cfg.selectedTools,
|
|
220
|
+
sealed: true,
|
|
221
|
+
}, validation.hosts);
|
|
222
|
+
// Wait for discovery to settle before responding so the operator
|
|
223
|
+
// gets a real success/failure signal instead of a cheerful ok on a
|
|
224
|
+
// paired-but-empty proxy. Discovery is bounded by per-host fetch
|
|
225
|
+
// timeouts; the browser is fine to wait that long.
|
|
226
|
+
await this.runner.discoverServers();
|
|
227
|
+
// Strict completion check — same rule the UI enforces at submit:
|
|
228
|
+
// every selectedServers entry must have ended up in state.hosts after
|
|
229
|
+
// discovery, otherwise we'd persist a partially broken pairing that
|
|
230
|
+
// the browser path would have rejected. Without this, a direct caller
|
|
231
|
+
// of the bearer-auth endpoint (UI gates client-side, but /pair/complete
|
|
232
|
+
// is callable directly) ends up paired with missing servers and the
|
|
233
|
+
// proxy reports `ok: true`. Mirror it server-side so behaviour is the
|
|
234
|
+
// same whichever path lands the config.
|
|
235
|
+
//
|
|
236
|
+
// selectedServers is optional in PairingConfig (allow-all). When it's
|
|
237
|
+
// omitted we can't enumerate which servers were "expected" — fall back
|
|
238
|
+
// to "every advertised host must land at least one server".
|
|
239
|
+
const missing = [];
|
|
240
|
+
if (cfg.selectedServers) {
|
|
241
|
+
// Server names may contain `__`, so prefix-match on the FIRST `__`
|
|
242
|
+
// — same parsing rule used in validatePairingConfig.
|
|
243
|
+
for (const key of cfg.selectedServers) {
|
|
244
|
+
const sep = key.indexOf("__");
|
|
245
|
+
if (sep <= 0)
|
|
246
|
+
continue; // shape already gated by validatePairingConfig
|
|
247
|
+
const hostId = key.slice(0, sep);
|
|
248
|
+
const serverName = key.slice(sep + 2);
|
|
249
|
+
if (!this.state.hosts.get(hostId)?.servers.has(serverName)) {
|
|
250
|
+
missing.push(key);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
for (const host of this.state.hosts.values()) {
|
|
256
|
+
if (host.servers.size === 0)
|
|
257
|
+
missing.push(`${host.config.id} (no servers)`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (missing.length > 0) {
|
|
261
|
+
// Cap the detail string so a wildly broken submit doesn't produce a
|
|
262
|
+
// multi-kilobyte error body; the operator only needs a few names to
|
|
263
|
+
// know which host to investigate.
|
|
264
|
+
const detail = missing.length > 5
|
|
265
|
+
? `${missing.slice(0, 5).join(", ")}, and ${missing.length - 5} more`
|
|
266
|
+
: missing.join(", ");
|
|
267
|
+
await this.closeAllSessions();
|
|
268
|
+
if (previousConfig && previousHosts) {
|
|
269
|
+
this.log(` New pairing missing servers (${detail}); restoring previous pairing`);
|
|
270
|
+
this.state.installConfig(previousConfig, previousHosts);
|
|
271
|
+
await this.runner.discoverServers();
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
error: `Discovery did not complete for: ${detail}. The previous pairing has been restored — verify host reachability and retry.`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
// First-time pairing missed servers: drop back to unconfigured
|
|
278
|
+
// (closeAllSessions cleared hosts/routes already; null the config
|
|
279
|
+
// so the next configure call starts fresh).
|
|
280
|
+
this.log(` New pairing missing servers (${detail}) and no prior config to restore; reverting to unconfigured`);
|
|
281
|
+
this.state.config = null;
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
error: `Discovery did not complete for: ${detail}. Verify host reachability and retry.`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const summary = validation.hosts.map((h) => `${h.id}=${h.tunnelUrl}`).join(", ");
|
|
288
|
+
this.log(` Paired! hosts: ${summary}`);
|
|
289
|
+
// Discovery already populated the aggregated lists. Notify the agent
|
|
290
|
+
// so it re-fetches tools/prompts/resources instead of trusting any
|
|
291
|
+
// cached empty lists from before pairing.
|
|
292
|
+
this.sendNotification("notifications/tools/list_changed");
|
|
293
|
+
this.sendNotification("notifications/prompts/list_changed");
|
|
294
|
+
this.sendNotification("notifications/resources/list_changed");
|
|
295
|
+
// Defer pairing teardown until /pair/complete's response body has
|
|
296
|
+
// actually drained to the client — a fixed timer races slow clients
|
|
297
|
+
// (mobile data, congested tunnel) and they see a dropped connection
|
|
298
|
+
// on a successful pair.
|
|
299
|
+
return { ok: true, afterFlush: () => this.teardownPairing() };
|
|
300
|
+
}
|
|
301
|
+
async closeAllSessions() {
|
|
302
|
+
// Tear down SSE listeners first so loops don't reconnect after DELETE.
|
|
303
|
+
for (const host of this.state.hosts.values()) {
|
|
304
|
+
for (const ctrl of host.sseControllers.values())
|
|
305
|
+
ctrl.abort();
|
|
306
|
+
host.sseControllers.clear();
|
|
307
|
+
}
|
|
308
|
+
this.state.inflight.clear();
|
|
309
|
+
this.state.progressTokens.clear();
|
|
310
|
+
// Drain bridged requests BEFORE the DELETEs below so each upstream
|
|
311
|
+
// child gets a JSON-RPC error answer. The DELETE will then reap the
|
|
312
|
+
// session, but the upstream side has already stopped waiting.
|
|
313
|
+
await this.bridge.clear(this.state.hosts);
|
|
314
|
+
const closes = [];
|
|
315
|
+
for (const host of this.state.hosts.values()) {
|
|
316
|
+
for (const [serverName, state] of host.servers) {
|
|
317
|
+
if (!state.sessionId)
|
|
318
|
+
continue;
|
|
319
|
+
const headers = { ...this.state.hostHeaders(host.config), "Mcp-Session-Id": state.sessionId };
|
|
320
|
+
closes.push((0, client_js_1.deleteSession)(`${host.config.tunnelUrl}/servers/${serverName}`, headers));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
await Promise.allSettled(closes);
|
|
324
|
+
this.state.resetAfterClose();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
exports.PairingController = PairingController;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { PairingConfig, Prompt, Resource, ResourceTemplate, Tool } from "../core/types.js";
|
|
2
|
+
export interface ListServersRequest {
|
|
3
|
+
tunnelUrl: string;
|
|
4
|
+
authToken: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ListServersResult {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
servers?: string[];
|
|
9
|
+
error?: string;
|
|
10
|
+
status?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface DiscoverServerRequest {
|
|
13
|
+
tunnelUrl: string;
|
|
14
|
+
authToken: string;
|
|
15
|
+
serverName: string;
|
|
16
|
+
}
|
|
17
|
+
export interface DiscoverServerResult {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
tools?: Tool[];
|
|
20
|
+
prompts?: Prompt[];
|
|
21
|
+
resources?: Resource[];
|
|
22
|
+
resourceTemplates?: ResourceTemplate[];
|
|
23
|
+
capErrors?: {
|
|
24
|
+
prompts?: string;
|
|
25
|
+
resources?: string;
|
|
26
|
+
resourceTemplates?: string;
|
|
27
|
+
};
|
|
28
|
+
error?: string;
|
|
29
|
+
status?: number;
|
|
30
|
+
}
|
|
31
|
+
export type CompleteResult = {
|
|
32
|
+
ok: true;
|
|
33
|
+
afterFlush?: () => void;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
error: string;
|
|
37
|
+
};
|
|
38
|
+
export interface PairingHandlers {
|
|
39
|
+
listServers: (req: ListServersRequest) => Promise<ListServersResult>;
|
|
40
|
+
discover: (req: DiscoverServerRequest) => Promise<DiscoverServerResult>;
|
|
41
|
+
complete: (cfg: PairingConfig) => Promise<CompleteResult>;
|
|
42
|
+
info: () => {
|
|
43
|
+
name: string;
|
|
44
|
+
version: string;
|
|
45
|
+
current?: {
|
|
46
|
+
hosts: Array<{
|
|
47
|
+
id: string;
|
|
48
|
+
tunnelUrl: string;
|
|
49
|
+
authToken: string;
|
|
50
|
+
label?: string;
|
|
51
|
+
}>;
|
|
52
|
+
selectedServers?: string[];
|
|
53
|
+
selectedTools?: string[];
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export declare class PairingHttpServer {
|
|
58
|
+
private readonly bearer;
|
|
59
|
+
private readonly handlers;
|
|
60
|
+
private server;
|
|
61
|
+
private bearerExpected;
|
|
62
|
+
constructor(bearer: string, handlers: PairingHandlers);
|
|
63
|
+
listen(): Promise<number>;
|
|
64
|
+
close(): void;
|
|
65
|
+
private authorized;
|
|
66
|
+
private sendJson;
|
|
67
|
+
private sendStatic;
|
|
68
|
+
private readJson;
|
|
69
|
+
private handle;
|
|
70
|
+
}
|