@cyanmycelium/mcp-broker 0.4.0 → 1.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/.mcp-broker.example/CONFIGURATION-EN.md +861 -0
- package/.mcp-broker.example/CONFIGURATION-FR.md +871 -0
- package/.mcp-broker.example/README.md +8 -3
- package/.mcp-broker.example/config.json +86 -0
- package/README.md +122 -3
- package/dist/bin.d.ts +0 -1
- package/dist/bin.js +180 -208
- package/dist/bin.js.map +1 -1
- package/dist/chunk-FTDKH2C4.js +3670 -0
- package/dist/chunk-FTDKH2C4.js.map +1 -0
- package/dist/{broker/grammars → grammars}/claude/en.json +1 -1
- package/dist/{broker/grammars → grammars}/claude/fr.json +1 -1
- package/dist/index.d.ts +1790 -18
- package/dist/index.js +2 -14
- package/dist/index.js.map +1 -1
- package/package.json +14 -8
- package/scripts/copy-assets.mjs +16 -10
- package/scripts/gen-cert.mjs +5 -5
- package/scripts/pack-mcpb.mjs +5 -5
- package/scripts/sign-bundle.mjs +6 -6
- package/src/auth/auth.config.ts +98 -0
- package/src/auth/auth.types.ts +120 -0
- package/src/auth/http.auth.ts +128 -0
- package/src/auth/index.ts +34 -0
- package/src/auth/jwt.validator.ts +63 -0
- package/src/auth/provider.auth.ts +114 -0
- package/src/auth/resource.metadata.ts +34 -0
- package/src/authorization/audit.ts +35 -0
- package/src/authorization/capability.classifier.ts +114 -0
- package/src/authorization/index.ts +37 -0
- package/src/authorization/policy.engine.ts +212 -0
- package/src/authorization/policy.types.ts +105 -0
- package/src/authorization/resource.path.ts +126 -0
- package/src/authorization/runtime.ts +56 -0
- package/src/authorization/slot.resource.ts +50 -0
- package/src/authorization/subject.mapper.ts +81 -0
- package/src/bin.ts +90 -7
- package/src/broker/adapters/broker.adapter.info.ts +3 -3
- package/src/broker/adapters/broker.adapter.providers.ts +3 -3
- package/src/broker/aggregate/aggregate.catalog.ts +49 -21
- package/src/broker/aggregate/aggregate.server.ts +149 -19
- package/src/broker/aggregate/provider.client.session.ts +22 -22
- package/src/broker/behaviors/broker.behavior.info.ts +4 -4
- package/src/broker/behaviors/broker.behavior.providers.ts +6 -6
- package/src/broker/broker.context.ts +10 -4
- package/src/broker/broker.grammars.ts +16 -13
- package/src/broker/broker.server.ts +12 -9
- package/src/broker/grammars/claude/en.json +1 -1
- package/src/broker/grammars/claude/fr.json +1 -1
- package/src/broker/index.ts +9 -9
- package/src/config.ts +81 -8
- package/src/index.ts +121 -20
- package/src/{mcpb.loader.ts → mcpb/mcpb.loader.ts} +24 -21
- package/src/{mcpb.unzip.ts → mcpb/mcpb.unzip.ts} +2 -2
- package/src/remote.transports.ts +11 -8
- package/src/remote.upstream.ts +11 -8
- package/src/stdio.upstream.ts +9 -6
- package/src/upstream.ts +6 -3
- package/src/ws/ws.interfaces.ts +357 -0
- package/src/{ws.tunnel.builder.ts → ws/ws.tunnel.builder.ts} +112 -9
- package/src/{ws.tunnel.ts → ws/ws.tunnel.ts} +646 -457
- package/web/README.md +94 -0
- package/web/assets/logo.png +0 -0
- package/web/broker-self-mcp.html +338 -0
- package/web/css/styles.css +580 -0
- package/web/demos/DemoPlaceholder.html +258 -0
- package/web/demos/broker-explorer/css/app.css +393 -0
- package/web/demos/broker-explorer/index.html +94 -0
- package/web/demos/broker-explorer/js/app.js +271 -0
- package/web/demos/broker-explorer/js/mcp-ws-client.js +132 -0
- package/web/demos/oauth-lab/README.md +94 -0
- package/web/demos/oauth-lab/config.json +117 -0
- package/web/demos/oauth-lab/css/app.css +1097 -0
- package/web/demos/oauth-lab/index.html +323 -0
- package/web/demos/oauth-lab/js/app.js +654 -0
- package/web/demos/oauth-lab/server/auth-server.mjs +426 -0
- package/web/demos/oauth-lab/server/factory-provider.mjs +269 -0
- package/web/demos/oauth-lab/server/smoke-test.mjs +308 -0
- package/web/demos/oauth-lab/server/start.mjs +106 -0
- package/web/demos/provider-tunnel/css/app.css +384 -0
- package/web/demos/provider-tunnel/index.html +99 -0
- package/web/demos/provider-tunnel/js/app.js +226 -0
- package/web/demos/provider-tunnel/js/toolbox-server.js +186 -0
- package/web/index.html +558 -0
- package/web/js/lib/broker-tunnel.js +173 -0
- package/dist/broker/adapters/broker.adapter.info.d.ts +0 -16
- package/dist/broker/adapters/broker.adapter.info.js +0 -43
- package/dist/broker/adapters/broker.adapter.info.js.map +0 -1
- package/dist/broker/adapters/broker.adapter.providers.d.ts +0 -18
- package/dist/broker/adapters/broker.adapter.providers.js +0 -61
- package/dist/broker/adapters/broker.adapter.providers.js.map +0 -1
- package/dist/broker/aggregate/aggregate.catalog.d.ts +0 -54
- package/dist/broker/aggregate/aggregate.catalog.js +0 -105
- package/dist/broker/aggregate/aggregate.catalog.js.map +0 -1
- package/dist/broker/aggregate/aggregate.server.d.ts +0 -47
- package/dist/broker/aggregate/aggregate.server.js +0 -151
- package/dist/broker/aggregate/aggregate.server.js.map +0 -1
- package/dist/broker/aggregate/provider.client.session.d.ts +0 -52
- package/dist/broker/aggregate/provider.client.session.js +0 -140
- package/dist/broker/aggregate/provider.client.session.js.map +0 -1
- package/dist/broker/behaviors/broker.behavior.info.d.ts +0 -15
- package/dist/broker/behaviors/broker.behavior.info.js +0 -41
- package/dist/broker/behaviors/broker.behavior.info.js.map +0 -1
- package/dist/broker/behaviors/broker.behavior.providers.d.ts +0 -19
- package/dist/broker/behaviors/broker.behavior.providers.js +0 -69
- package/dist/broker/behaviors/broker.behavior.providers.js.map +0 -1
- package/dist/broker/broker.context.d.ts +0 -59
- package/dist/broker/broker.context.js +0 -2
- package/dist/broker/broker.context.js.map +0 -1
- package/dist/broker/broker.grammars.d.ts +0 -130
- package/dist/broker/broker.grammars.js +0 -229
- package/dist/broker/broker.grammars.js.map +0 -1
- package/dist/broker/broker.server.d.ts +0 -66
- package/dist/broker/broker.server.js +0 -73
- package/dist/broker/broker.server.js.map +0 -1
- package/dist/broker/index.d.ts +0 -9
- package/dist/broker/index.js +0 -7
- package/dist/broker/index.js.map +0 -1
- package/dist/config.d.ts +0 -136
- package/dist/config.js +0 -61
- package/dist/config.js.map +0 -1
- package/dist/mcpb.loader.d.ts +0 -24
- package/dist/mcpb.loader.js +0 -161
- package/dist/mcpb.loader.js.map +0 -1
- package/dist/mcpb.unzip.d.ts +0 -6
- package/dist/mcpb.unzip.js +0 -95
- package/dist/mcpb.unzip.js.map +0 -1
- package/dist/remote.transports.d.ts +0 -16
- package/dist/remote.transports.js +0 -297
- package/dist/remote.transports.js.map +0 -1
- package/dist/remote.upstream.d.ts +0 -36
- package/dist/remote.upstream.js +0 -52
- package/dist/remote.upstream.js.map +0 -1
- package/dist/stdio.upstream.d.ts +0 -45
- package/dist/stdio.upstream.js +0 -85
- package/dist/stdio.upstream.js.map +0 -1
- package/dist/upstream.d.ts +0 -33
- package/dist/upstream.js +0 -2
- package/dist/upstream.js.map +0 -1
- package/dist/version.d.ts +0 -2
- package/dist/version.js +0 -9
- package/dist/version.js.map +0 -1
- package/dist/ws.tunnel.builder.d.ts +0 -139
- package/dist/ws.tunnel.builder.js +0 -205
- package/dist/ws.tunnel.builder.js.map +0 -1
- package/dist/ws.tunnel.d.ts +0 -373
- package/dist/ws.tunnel.js +0 -1090
- package/dist/ws.tunnel.js.map +0 -1
- /package/dist/{broker/grammars → grammars}/default/en.json +0 -0
- /package/dist/{broker/grammars → grammars}/default/fr.json +0 -0
- /package/dist/{broker/grammars → grammars}/default/zh.json +0 -0
|
@@ -4,15 +4,41 @@ import * as https from "https";
|
|
|
4
4
|
import * as nodePath from "path";
|
|
5
5
|
import { randomUUID } from "crypto";
|
|
6
6
|
import type { IncomingMessage, ServerResponse } from "http";
|
|
7
|
-
import { WebSocket, WebSocketServer } from "ws";
|
|
8
|
-
import type {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
7
|
+
import { WebSocket, WebSocketServer, type VerifyClientCallbackAsync } from "ws";
|
|
8
|
+
import type { IMessageTransport, IMcpServer } from "@cyanmycelium/mcp-core";
|
|
9
|
+
import { StdioTransport, StreamableHttpEndpoint } from "@cyanmycelium/mcp-core/node";
|
|
10
|
+
// The broker is itself a provider: it publishes its own `_broker` and `_all`
|
|
11
|
+
// slots: so sharing the provider package's wire contract is the natural way to
|
|
12
|
+
// keep one definition of the envelope rather than two that drift.
|
|
13
|
+
import { decodeEnvelope, encodeEnvelope, encodeErrorEnvelope, envelopeFrame, TunnelErrorCodes } from "@cyanmycelium/mcp-broker-provider/protocol";
|
|
14
|
+
import { StdioUpstream } from "../stdio.upstream";
|
|
15
|
+
import { RemoteUpstream } from "../remote.upstream";
|
|
16
|
+
import type { IUpstream } from "../upstream";
|
|
17
|
+
import { startBrokerServer, BROKER_PROVIDER_NAME } from "../broker/index";
|
|
18
|
+
import type { IBrokerContext, IBrokerProviderInfo, BrokerProviderTransport } from "../broker/index";
|
|
19
|
+
import { AggregateServer } from "../broker/aggregate/aggregate.server";
|
|
20
|
+
import {
|
|
21
|
+
HttpAuthGuard,
|
|
22
|
+
AuthError,
|
|
23
|
+
normalizeProviderAuthentication,
|
|
24
|
+
providerMayPublish,
|
|
25
|
+
type IProviderAuthenticator,
|
|
26
|
+
type IPrincipal,
|
|
27
|
+
type IProviderPrincipal,
|
|
28
|
+
} from "../auth/index";
|
|
29
|
+
import {
|
|
30
|
+
DefaultSlotResourceResolver,
|
|
31
|
+
SubjectMappingError,
|
|
32
|
+
makeAuthorizationAuditEvent,
|
|
33
|
+
writeAuthorizationAuditEvent,
|
|
34
|
+
type IAuthorizationDecision,
|
|
35
|
+
type IAuthorizationSubject,
|
|
36
|
+
type IPolicyAuthorization,
|
|
37
|
+
type ResourcePath,
|
|
38
|
+
type ISlotResourceResolver,
|
|
39
|
+
} from "../authorization/index";
|
|
40
|
+
import { VERSION, PACKAGE_NAME } from "../version";
|
|
41
|
+
import type { AllowedOrigins, IHttpSession, IInternalClient, IProviderState, IWsTunnelOptions, McpEndpointKind } from "./ws.interfaces";
|
|
16
42
|
|
|
17
43
|
// ---------------------------------------------------------------------------
|
|
18
44
|
// Static-file helpers
|
|
@@ -34,251 +60,61 @@ const MIME: Readonly<Record<string, string>> = {
|
|
|
34
60
|
};
|
|
35
61
|
|
|
36
62
|
// ---------------------------------------------------------------------------
|
|
37
|
-
//
|
|
63
|
+
// JSON-RPC helpers
|
|
38
64
|
// ---------------------------------------------------------------------------
|
|
39
65
|
|
|
40
66
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* a held-open HTTP response (Streamable HTTP transport, MCP 2025-03-26),
|
|
44
|
-
* the process stdout (stdio transport for Claude Desktop), or an in-process
|
|
45
|
-
* internal client (e.g. the aggregate server).
|
|
46
|
-
*/
|
|
47
|
-
type ResponseSink =
|
|
48
|
-
| { type: "ws"; socket: WebSocket }
|
|
49
|
-
| { type: "sse"; sessionId: string }
|
|
50
|
-
| { type: "http"; res: ServerResponse }
|
|
51
|
-
| { type: "stdio" }
|
|
52
|
-
| { type: "internal"; client: InternalClient };
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* All mutable state for one named provider slot.
|
|
56
|
-
* Created lazily on first client connection; the WebSocket field is set when
|
|
57
|
-
* the provider actually connects (and cleared on disconnect).
|
|
58
|
-
*/
|
|
59
|
-
interface ProviderState {
|
|
60
|
-
/** The active provider WebSocket, or `null` when the provider is not connected. */
|
|
61
|
-
ws: WebSocket | null;
|
|
62
|
-
/** Pending JSON-RPC request ids → response sinks waiting for a reply. */
|
|
63
|
-
readonly pending: Map<string | number, ResponseSink>;
|
|
64
|
-
/** Active legacy SSE sessions (Claude), keyed by session id. */
|
|
65
|
-
readonly sseSessions: Map<string, ServerResponse>;
|
|
66
|
-
/** Active Streamable HTTP GET streams (MCP Inspector), keyed by session id. */
|
|
67
|
-
readonly mcpGetSessions: Map<string, ServerResponse>;
|
|
68
|
-
/** Raw WebSocket MCP clients connected to this provider. */
|
|
69
|
-
readonly wsClients: Set<WebSocket>;
|
|
70
|
-
/** In-process clients (e.g. the aggregate server) attached to this slot. */
|
|
71
|
-
readonly internalClients: Set<InternalClient>;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
75
|
-
// Public types
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* A single static-file mount: serves the contents of `dir` under `urlPrefix`.
|
|
67
|
+
* The JSON-RPC id a frame is waiting on, or `undefined` when nothing will come
|
|
68
|
+
* back for it: a notification, or something that did not parse.
|
|
80
69
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* { urlPrefix: "/bundle", dir: "/absolute/path/to/bundle" }
|
|
70
|
+
* `null` is a legal id in an error response, so absence cannot be signalled
|
|
71
|
+
* with it, hence `undefined`.
|
|
84
72
|
*/
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
73
|
+
function requestIdOf(frame: string): string | number | undefined {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(frame) as { id?: unknown };
|
|
76
|
+
if (typeof parsed?.id === "string" || typeof parsed?.id === "number") return parsed.id;
|
|
77
|
+
} catch {
|
|
78
|
+
/* not JSON: nothing to route by */
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
90
81
|
}
|
|
91
82
|
|
|
92
83
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
84
|
+
* Turns the three shapes an operator may configure into the single predicate
|
|
85
|
+
* the Streamable HTTP endpoint expects.
|
|
86
|
+
*
|
|
87
|
+
* Absent means closed, not open: a browser origin is refused unless it was
|
|
88
|
+
* named. A `RegExp` is reset before each test, because one carrying the `g`
|
|
89
|
+
* flag keeps `lastIndex` between calls and would otherwise accept and refuse
|
|
90
|
+
* the same origin in turn.
|
|
98
91
|
*/
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
/** Detaches this internal client; pending requests are dropped. */
|
|
111
|
-
close(): void;
|
|
92
|
+
function originPredicate(allowed: AllowedOrigins | undefined): (origin: string) => boolean {
|
|
93
|
+
if (!allowed) return () => false;
|
|
94
|
+
if (typeof allowed === "function") return allowed;
|
|
95
|
+
if (allowed instanceof RegExp) {
|
|
96
|
+
return (origin) => {
|
|
97
|
+
allowed.lastIndex = 0;
|
|
98
|
+
return allowed.test(origin);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const exact = new Set(allowed);
|
|
102
|
+
return (origin) => exact.has(origin);
|
|
112
103
|
}
|
|
113
104
|
|
|
114
105
|
/**
|
|
115
|
-
*
|
|
106
|
+
* Opens a transport that was handed over closed.
|
|
107
|
+
*
|
|
108
|
+
* `connect` is not part of `IMessageTransport`, because some transports arrive
|
|
109
|
+
* already open, so it is probed for rather than required. This is the same
|
|
110
|
+
* check `McpServer` makes on the transport it is given, and for the same
|
|
111
|
+
* reason: testing for a concrete class would tie the caller to one
|
|
112
|
+
* implementation.
|
|
116
113
|
*/
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Host/interface to bind to.
|
|
123
|
-
* @default "0.0.0.0"
|
|
124
|
-
*/
|
|
125
|
-
host?: string;
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* URL path **prefix** the MCP provider connects to via WebSocket.
|
|
129
|
-
* Each provider appends its name: `<providerPath>/<encodedName>`.
|
|
130
|
-
* @default "/provider"
|
|
131
|
-
*/
|
|
132
|
-
providerPath?: string;
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* URL path for **multiplexed** provider connections.
|
|
136
|
-
* A single WebSocket carries traffic for multiple providers using the
|
|
137
|
-
* envelope protocol `{ provider: string, payload: object }`.
|
|
138
|
-
* @default "/providers"
|
|
139
|
-
*/
|
|
140
|
-
providersPath?: string;
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* URL path raw WebSocket MCP clients connect to.
|
|
144
|
-
* @default "/"
|
|
145
|
-
*/
|
|
146
|
-
clientPath?: string;
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* **Suffix** appended to a provider name for the SSE endpoint.
|
|
150
|
-
* Full URL: `/<providerName>/sse`
|
|
151
|
-
* @default "/sse"
|
|
152
|
-
*/
|
|
153
|
-
ssePath?: string;
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* **Suffix** appended to a provider name for the legacy SSE POST endpoint.
|
|
157
|
-
* Full URL: `/<providerName>/messages`
|
|
158
|
-
* @default "/messages"
|
|
159
|
-
*/
|
|
160
|
-
messagesPath?: string;
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* **Suffix** appended to a provider name for the Streamable HTTP endpoint (MCP 2025-03-26).
|
|
164
|
-
* Full URL: `/<providerName>/mcp`
|
|
165
|
-
* MCP Inspector connects here.
|
|
166
|
-
* @default "/mcp"
|
|
167
|
-
*/
|
|
168
|
-
mcpPath?: string;
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* URL path that returns a `{ files: string[] }` JSON listing of every file
|
|
172
|
-
* inside the `samples/` subdirectory of the root static mount.
|
|
173
|
-
* @default "/__samples_index__"
|
|
174
|
-
*/
|
|
175
|
-
samplesIndexPath?: string;
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Optional static-file mounts served over plain HTTP.
|
|
179
|
-
* Matched by longest URL prefix; directory requests fall back to `index.html`.
|
|
180
|
-
*/
|
|
181
|
-
staticMounts?: StaticMount[];
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Stdio upstream providers. Each entry spawns a child process and wires its
|
|
185
|
-
* stdin/stdout as an MCP transport. Clients reach the process using its `name`
|
|
186
|
-
* directly.
|
|
187
|
-
*
|
|
188
|
-
* If a WebSocket provider connects with the same name as a stdio upstream, the
|
|
189
|
-
* connection is rejected and a warning is logged — stdio takes priority.
|
|
190
|
-
* @default undefined — no stdio providers
|
|
191
|
-
*/
|
|
192
|
-
stdioUpstreams?: StdioUpstreamConfig[];
|
|
193
|
-
|
|
194
|
-
/** Remote MCP servers reached by URL, exposed as provider slots. */
|
|
195
|
-
remoteUpstreams?: RemoteUpstreamConfig[];
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Stdio client transport. When set, the broker reads JSON-RPC from
|
|
199
|
-
* `process.stdin` and writes responses to `process.stdout`, bridging an
|
|
200
|
-
* external MCP client (e.g. Claude Desktop) to the named provider.
|
|
201
|
-
*
|
|
202
|
-
* In this mode ALL logging is redirected to stderr so stdout stays clean
|
|
203
|
-
* for the JSON-RPC stream.
|
|
204
|
-
*
|
|
205
|
-
* Claude Desktop config example:
|
|
206
|
-
* ```json
|
|
207
|
-
* {
|
|
208
|
-
* "command": "npx",
|
|
209
|
-
* "args": ["-y", "@cyanmycelium/mcp-broker"],
|
|
210
|
-
* "env": { "MCP_BROKER_STDIO_PROVIDER": "my-provider" }
|
|
211
|
-
* }
|
|
212
|
-
* ```
|
|
213
|
-
* @default undefined — stdio client transport disabled
|
|
214
|
-
*/
|
|
215
|
-
stdioClient?: { providerName: string };
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* TLS configuration. When provided, the server uses HTTPS and WSS instead of HTTP and WS.
|
|
219
|
-
* Both `cert` and `key` must be PEM-encoded strings (file contents, not file paths).
|
|
220
|
-
* Use {@link WsTunnelBuilder.withTlsFiles} to load from disk paths.
|
|
221
|
-
* @default undefined — plain HTTP/WS
|
|
222
|
-
*/
|
|
223
|
-
tls?: {
|
|
224
|
-
/** PEM-encoded TLS certificate. */
|
|
225
|
-
cert: string;
|
|
226
|
-
/** PEM-encoded private key. */
|
|
227
|
-
key: string;
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* When `true` (default), the broker exposes itself as an MCP server under the
|
|
232
|
-
* reserved slot `_broker`. Tier-1 behaviors (`broker_info`, `providers_list`,
|
|
233
|
-
* `provider_status`) become callable at `<host>/_broker/mcp`.
|
|
234
|
-
*
|
|
235
|
-
* Set to `false` to keep the broker invisible to MCP clients.
|
|
236
|
-
* @default true
|
|
237
|
-
*/
|
|
238
|
-
enableBrokerProvider?: boolean;
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* When `true` (default), the broker exposes the reserved slot `_all` — an
|
|
242
|
-
* aggregate MCP server that unions the tools and prompts of every provider
|
|
243
|
-
* that opted in via the registration handshake. Reachable like any other
|
|
244
|
-
* slot (`<host>/_all/mcp`, etc.).
|
|
245
|
-
*
|
|
246
|
-
* Set to `false` to disable aggregation entirely.
|
|
247
|
-
* @default true
|
|
248
|
-
*/
|
|
249
|
-
enableAggregateProvider?: boolean;
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Logical name reported by `broker_info`. Useful when running multiple
|
|
253
|
-
* broker instances and you want to tell them apart from the agent side
|
|
254
|
-
* (e.g. `"broker-eu-west"`).
|
|
255
|
-
* @default PACKAGE_NAME — `@cyanmycelium/mcp-broker`
|
|
256
|
-
*/
|
|
257
|
-
brokerName?: string;
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Overrides for the embedded broker server's grammar resolver — passed
|
|
261
|
-
* straight through to `mcp-core`'s `grammarResolverFromOptions`. The
|
|
262
|
-
* broker installs its own default `localeSource` (reads
|
|
263
|
-
* `process.env.MCP_BROKER_LOCALE`); anything you set here wins.
|
|
264
|
-
*
|
|
265
|
-
* Use this to inject a custom `localeSource` (e.g. read from an HTTP
|
|
266
|
-
* header proxied by your transport), enable the optional `versionFrom`
|
|
267
|
-
* dimension, or extend the `agents` map with additional LLM families.
|
|
268
|
-
*/
|
|
269
|
-
brokerGrammarResolverOptions?: Partial<GrammarResolverOptions>;
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Path to a user-supplied grammars directory whose `<userAgent>/<locale>.json`
|
|
273
|
-
* files are registered alongside the packaged grammars used by the
|
|
274
|
-
* embedded broker server. Typically pointed at `.mcp-broker/grammars/`.
|
|
275
|
-
*
|
|
276
|
-
* The candidate-chain resolution in `McpServer.initialize`
|
|
277
|
-
* (mcp-core@0.3.0) handles cascade across user-agent and locale
|
|
278
|
-
* dimensions, so partial files no longer need to be pre-merged with a
|
|
279
|
-
* baseline.
|
|
280
|
-
*/
|
|
281
|
-
brokerLocalGrammarsDir?: string;
|
|
114
|
+
function openTransport(transport: IMessageTransport): void {
|
|
115
|
+
if ("connect" in transport && typeof (transport as { connect: unknown }).connect === "function") {
|
|
116
|
+
(transport as { connect(): void }).connect();
|
|
117
|
+
}
|
|
282
118
|
}
|
|
283
119
|
|
|
284
120
|
// ---------------------------------------------------------------------------
|
|
@@ -306,8 +142,8 @@ export interface WsTunnelOptions {
|
|
|
306
142
|
* Each provider gets its own isolated set of sessions, pending requests, and
|
|
307
143
|
* notification streams. Multiple providers can be connected simultaneously.
|
|
308
144
|
*/
|
|
309
|
-
export class WsTunnel implements
|
|
310
|
-
private readonly _options:
|
|
145
|
+
export class WsTunnel implements IBrokerContext {
|
|
146
|
+
private readonly _options: IWsTunnelOptions;
|
|
311
147
|
private _httpServer: http.Server | https.Server | null = null;
|
|
312
148
|
private _wss: WebSocketServer | null = null;
|
|
313
149
|
|
|
@@ -316,13 +152,13 @@ export class WsTunnel implements BrokerContext {
|
|
|
316
152
|
* Created lazily: a slot is allocated the first time any client references
|
|
317
153
|
* a provider name, even before the provider WebSocket connects.
|
|
318
154
|
*/
|
|
319
|
-
private readonly _providers = new Map<string,
|
|
155
|
+
private readonly _providers = new Map<string, IProviderState>();
|
|
320
156
|
|
|
321
157
|
/** Maps a multiplexed WebSocket to the set of provider names it feeds. */
|
|
322
158
|
private readonly _multiplexSockets = new Map<WebSocket, Set<string>>();
|
|
323
159
|
|
|
324
160
|
/** Upstream providers (stdio child processes and remote URL servers), keyed by name. */
|
|
325
|
-
private readonly _upstreams = new Map<string,
|
|
161
|
+
private readonly _upstreams = new Map<string, IUpstream>();
|
|
326
162
|
|
|
327
163
|
/**
|
|
328
164
|
* In-process loopback transports registered as provider slots.
|
|
@@ -331,27 +167,61 @@ export class WsTunnel implements BrokerContext {
|
|
|
331
167
|
*/
|
|
332
168
|
private readonly _loopbackProviders = new Map<string, IMessageTransport>();
|
|
333
169
|
|
|
334
|
-
/** The embedded broker MCP server, when {@link
|
|
170
|
+
/** The embedded broker MCP server, when {@link IWsTunnelOptions.enableBrokerProvider} is on. */
|
|
335
171
|
private _brokerServer: IMcpServer | null = null;
|
|
336
172
|
|
|
337
|
-
/** The aggregate MCP server (`_all` slot), when {@link
|
|
173
|
+
/** The aggregate MCP server (`_all` slot), when {@link IWsTunnelOptions.enableAggregateProvider} is on. */
|
|
338
174
|
private _aggregateServer: AggregateServer | null = null;
|
|
339
175
|
|
|
340
176
|
/** Provider name that the stdio client transport is bridged to, or null when disabled. */
|
|
341
177
|
private _stdioClientProvider: string | null = null;
|
|
342
178
|
|
|
343
|
-
/**
|
|
344
|
-
private
|
|
179
|
+
/** mcp-core transport connected to the broker process stdin/stdout. */
|
|
180
|
+
private _stdioClientTransport: StdioTransport | null = null;
|
|
345
181
|
|
|
346
182
|
/** Timestamp of the most recent successful `start()`. */
|
|
347
183
|
private _startedAt: Date | null = null;
|
|
348
184
|
|
|
349
|
-
|
|
185
|
+
/** HTTP resource-server enforcement point, or `null` when auth is disabled. */
|
|
186
|
+
private readonly _authGuard: HttpAuthGuard | null;
|
|
187
|
+
|
|
188
|
+
/** Provider (engine) authenticator, or `null` when provider auth is disabled. */
|
|
189
|
+
private readonly _providerAuth: IProviderAuthenticator | null;
|
|
190
|
+
|
|
191
|
+
/** Compiled hierarchical authorization, or `null` for legacy behavior. */
|
|
192
|
+
private readonly _authorization: IPolicyAuthorization | null;
|
|
193
|
+
|
|
194
|
+
/** Stable technical-slot to hierarchical-resource mapping. */
|
|
195
|
+
private readonly _slotResourceResolver: ISlotResourceResolver;
|
|
196
|
+
|
|
197
|
+
/** Origin check applied by every slot's Streamable HTTP endpoint. */
|
|
198
|
+
private readonly _allowedOrigins: (origin: string) => boolean;
|
|
199
|
+
|
|
200
|
+
/** Principal captured at a client's WS upgrade, keyed by the upgrade request. */
|
|
201
|
+
private readonly _pendingClientPrincipals = new WeakMap<IncomingMessage, IPrincipal>();
|
|
202
|
+
|
|
203
|
+
/** Authenticated principal per raw WS client socket, for `_all` scope filtering. */
|
|
204
|
+
private readonly _clientPrincipals = new WeakMap<WebSocket, IPrincipal>();
|
|
205
|
+
|
|
206
|
+
/** Authenticated principal attached to long-lived HTTP/SSE streams. */
|
|
207
|
+
private readonly _streamPrincipals = new WeakMap<ServerResponse, IPrincipal>();
|
|
208
|
+
|
|
209
|
+
/** Provider principals captured during successful WebSocket upgrades. */
|
|
210
|
+
private readonly _pendingProviderPrincipals = new WeakMap<IncomingMessage, IProviderPrincipal>();
|
|
211
|
+
private readonly _providerPrincipals = new WeakMap<WebSocket, IProviderPrincipal>();
|
|
212
|
+
|
|
213
|
+
constructor(options: IWsTunnelOptions) {
|
|
350
214
|
this._options = options;
|
|
215
|
+
this._authGuard = options.auth ? new HttpAuthGuard(options.auth, options.mcpPath ?? "/mcp") : null;
|
|
216
|
+
this._providerAuth = options.providerAuth ?? null;
|
|
217
|
+
this._allowedOrigins = originPredicate(options.allowedOrigins);
|
|
218
|
+
this._authorization = options.authorization ?? options.auth?.authorization ?? null;
|
|
219
|
+
this._slotResourceResolver =
|
|
220
|
+
options.slotResourceResolver ?? this._authorization?.slotResourceResolver ?? options.auth?.slotResourceResolver ?? new DefaultSlotResourceResolver();
|
|
351
221
|
}
|
|
352
222
|
|
|
353
223
|
// -------------------------------------------------------------------------
|
|
354
|
-
//
|
|
224
|
+
// IBrokerContext implementation
|
|
355
225
|
// -------------------------------------------------------------------------
|
|
356
226
|
|
|
357
227
|
get version(): string {
|
|
@@ -383,7 +253,7 @@ export class WsTunnel implements BrokerContext {
|
|
|
383
253
|
return !!this._options.tls;
|
|
384
254
|
}
|
|
385
255
|
|
|
386
|
-
get paths():
|
|
256
|
+
get paths(): IBrokerContext["paths"] {
|
|
387
257
|
const o = this._options;
|
|
388
258
|
return {
|
|
389
259
|
provider: o.providerPath ?? "/provider",
|
|
@@ -395,21 +265,21 @@ export class WsTunnel implements BrokerContext {
|
|
|
395
265
|
};
|
|
396
266
|
}
|
|
397
267
|
|
|
398
|
-
public getProvidersInfo():
|
|
399
|
-
const out:
|
|
268
|
+
public getProvidersInfo(): IBrokerProviderInfo[] {
|
|
269
|
+
const out: IBrokerProviderInfo[] = [];
|
|
400
270
|
for (const [name, state] of this._providers) {
|
|
401
271
|
out.push(this._buildProviderInfo(name, state));
|
|
402
272
|
}
|
|
403
273
|
return out;
|
|
404
274
|
}
|
|
405
275
|
|
|
406
|
-
public getProviderInfo(name: string):
|
|
276
|
+
public getProviderInfo(name: string): IBrokerProviderInfo | undefined {
|
|
407
277
|
const state = this._providers.get(name);
|
|
408
278
|
if (!state) return undefined;
|
|
409
279
|
return this._buildProviderInfo(name, state);
|
|
410
280
|
}
|
|
411
281
|
|
|
412
|
-
private _buildProviderInfo(name: string, state:
|
|
282
|
+
private _buildProviderInfo(name: string, state: IProviderState): IBrokerProviderInfo {
|
|
413
283
|
let transport: BrokerProviderTransport;
|
|
414
284
|
let connected: boolean;
|
|
415
285
|
|
|
@@ -432,7 +302,7 @@ export class WsTunnel implements BrokerContext {
|
|
|
432
302
|
transport,
|
|
433
303
|
connected,
|
|
434
304
|
clientCount: state.wsClients.size,
|
|
435
|
-
sessionCount: state.sseSessions.size + state.
|
|
305
|
+
sessionCount: state.sseSessions.size + state.httpSessions.size,
|
|
436
306
|
pendingCount: state.pending.size,
|
|
437
307
|
};
|
|
438
308
|
}
|
|
@@ -459,7 +329,7 @@ export class WsTunnel implements BrokerContext {
|
|
|
459
329
|
const state = this._getOrCreateProviderState(name);
|
|
460
330
|
this._loopbackProviders.set(name, transport);
|
|
461
331
|
|
|
462
|
-
transport.onMessage = (data: string) => this._routeFromProvider(state, data);
|
|
332
|
+
transport.onMessage = (data: string) => this._routeFromProvider(state, name, data);
|
|
463
333
|
transport.onClose = () => {
|
|
464
334
|
this._loopbackProviders.delete(name);
|
|
465
335
|
this._failProviderDisconnected(state, name);
|
|
@@ -472,14 +342,14 @@ export class WsTunnel implements BrokerContext {
|
|
|
472
342
|
* broadcast notifications. Used by the aggregate server to fan a single
|
|
473
343
|
* in-process client out to every aggregated provider.
|
|
474
344
|
*
|
|
475
|
-
* The slot does not need a provider attached yet
|
|
345
|
+
* The slot does not need a provider attached yet, `send` returns a
|
|
476
346
|
* JSON-RPC error while the provider is disconnected.
|
|
477
347
|
*/
|
|
478
|
-
public openInternalClient(providerName: string):
|
|
348
|
+
public openInternalClient(providerName: string): IInternalClient {
|
|
479
349
|
const state = this._getOrCreateProviderState(providerName);
|
|
480
350
|
let closed = false;
|
|
481
351
|
|
|
482
|
-
const client:
|
|
352
|
+
const client: IInternalClient = {
|
|
483
353
|
onMessage: null,
|
|
484
354
|
onClose: null,
|
|
485
355
|
send: (message: string): void => {
|
|
@@ -530,7 +400,7 @@ export class WsTunnel implements BrokerContext {
|
|
|
530
400
|
get clientCount(): number {
|
|
531
401
|
let n = 0;
|
|
532
402
|
for (const s of this._providers.values()) {
|
|
533
|
-
n += s.wsClients.size + s.sseSessions.size + s.
|
|
403
|
+
n += s.wsClients.size + s.sseSessions.size + s.httpSessions.size;
|
|
534
404
|
}
|
|
535
405
|
return n;
|
|
536
406
|
}
|
|
@@ -559,7 +429,7 @@ export class WsTunnel implements BrokerContext {
|
|
|
559
429
|
// Disable perMessageDeflate: payloads may be large base64-encoded blobs
|
|
560
430
|
// (snapshots, images) that are already compressed. Deflating them wastes
|
|
561
431
|
// CPU without reducing size, and caused multi-second stalls in practice.
|
|
562
|
-
this._wss = new WebSocketServer({ server: this._httpServer, perMessageDeflate: false });
|
|
432
|
+
this._wss = new WebSocketServer({ server: this._httpServer, perMessageDeflate: false, verifyClient: this._makeVerifyClient() });
|
|
563
433
|
|
|
564
434
|
this._wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
|
|
565
435
|
const url = req.url ?? "/";
|
|
@@ -568,17 +438,17 @@ export class WsTunnel implements BrokerContext {
|
|
|
568
438
|
|
|
569
439
|
if (url === providersPath || url.startsWith(providersPath + "?")) {
|
|
570
440
|
// Multiplexed provider: one WebSocket carries N providers via envelopes.
|
|
571
|
-
this._onMultiplexProviderConnect(ws);
|
|
441
|
+
this._onMultiplexProviderConnect(ws, req);
|
|
572
442
|
} else if (url.startsWith(providerPath + "/") || url === providerPath) {
|
|
573
443
|
// Extract name: everything after "<providerPath>/"
|
|
574
444
|
const raw = url.slice(providerPath.length).replace(/^\//, "");
|
|
575
445
|
const name = decodeURIComponent(raw.split("?")[0]) || "(unnamed)";
|
|
576
|
-
this._onProviderConnect(ws, name);
|
|
446
|
+
this._onProviderConnect(ws, name, req);
|
|
577
447
|
} else {
|
|
578
448
|
// Raw WS MCP client: URL is "/<providerName>" or "/"
|
|
579
449
|
const raw = url.replace(/^\//, "").split("?")[0];
|
|
580
450
|
const name = decodeURIComponent(raw) || "";
|
|
581
|
-
this._onClientConnect(ws, name);
|
|
451
|
+
this._onClientConnect(ws, name, req);
|
|
582
452
|
}
|
|
583
453
|
});
|
|
584
454
|
|
|
@@ -590,10 +460,10 @@ export class WsTunnel implements BrokerContext {
|
|
|
590
460
|
// Attach configured upstreams (stdio child processes + remote URL
|
|
591
461
|
// servers). Both implement the Upstream contract, so the wiring
|
|
592
462
|
// into a provider slot is identical.
|
|
593
|
-
const wireUpstream = (cfg: { name: string; aggregate?: boolean }, upstream:
|
|
463
|
+
const wireUpstream = (cfg: { name: string; aggregate?: boolean }, upstream: IUpstream): void => {
|
|
594
464
|
upstream.onMessage = (data) => {
|
|
595
465
|
const state = this._getOrCreateProviderState(cfg.name);
|
|
596
|
-
this._routeFromProvider(state, data);
|
|
466
|
+
this._routeFromProvider(state, cfg.name, data);
|
|
597
467
|
};
|
|
598
468
|
upstream.onError = (err) => {
|
|
599
469
|
console.error(`[broker] ${err.message}`);
|
|
@@ -615,22 +485,19 @@ export class WsTunnel implements BrokerContext {
|
|
|
615
485
|
// stdin carries Claude Desktop's JSON-RPC requests; stdout carries responses.
|
|
616
486
|
if (this._options.stdioClient) {
|
|
617
487
|
this._stdioClientProvider = this._options.stdioClient.providerName;
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
process.stdin.on("end", () => {
|
|
632
|
-
// Client disconnected — nothing to clean up; pending sinks will time out.
|
|
633
|
-
});
|
|
488
|
+
const transport = new StdioTransport();
|
|
489
|
+
this._stdioClientTransport = transport;
|
|
490
|
+
transport.onMessage = (data: string): void => {
|
|
491
|
+
const state = this._getOrCreateProviderState(this._stdioClientProvider!);
|
|
492
|
+
this._routeFromStdioClient(state, data);
|
|
493
|
+
};
|
|
494
|
+
transport.onError = (err: Error): void => {
|
|
495
|
+
console.error(`[broker] stdio client transport: ${err.message}`);
|
|
496
|
+
};
|
|
497
|
+
transport.onClose = (): void => {
|
|
498
|
+
// Client disconnected: nothing to clean up; pending sinks will time out.
|
|
499
|
+
};
|
|
500
|
+
transport.connect();
|
|
634
501
|
}
|
|
635
502
|
|
|
636
503
|
this._startedAt = new Date();
|
|
@@ -652,7 +519,7 @@ export class WsTunnel implements BrokerContext {
|
|
|
652
519
|
/**
|
|
653
520
|
* Starts the in-process MCP server that exposes the broker's own behaviors
|
|
654
521
|
* (`broker_info`, `providers_list`, `provider_status`) under the reserved
|
|
655
|
-
* provider slot `_broker`. No-op when {@link
|
|
522
|
+
* provider slot `_broker`. No-op when {@link IWsTunnelOptions.enableBrokerProvider}
|
|
656
523
|
* is `false`.
|
|
657
524
|
*/
|
|
658
525
|
private async _maybeStartBrokerServer(): Promise<void> {
|
|
@@ -672,12 +539,14 @@ export class WsTunnel implements BrokerContext {
|
|
|
672
539
|
|
|
673
540
|
/**
|
|
674
541
|
* Starts the aggregate MCP server and registers it on the reserved `_all`
|
|
675
|
-
* slot. No-op when {@link
|
|
542
|
+
* slot. No-op when {@link IWsTunnelOptions.enableAggregateProvider} is `false`.
|
|
676
543
|
*/
|
|
677
544
|
private _maybeStartAggregateServer(): void {
|
|
678
545
|
if (this._options.enableAggregateProvider === false) return;
|
|
679
546
|
try {
|
|
680
547
|
const server = new AggregateServer((providerName) => this.openInternalClient(providerName));
|
|
548
|
+
server.setScopeFilter(this._options.auth?.aggregateScopeFilter ?? null);
|
|
549
|
+
server.setPolicyAuthorization(this._authorization);
|
|
681
550
|
server.start();
|
|
682
551
|
this.registerLoopbackProvider(AggregateServer.SLOT, server);
|
|
683
552
|
this._aggregateServer = server;
|
|
@@ -714,12 +583,21 @@ export class WsTunnel implements BrokerContext {
|
|
|
714
583
|
}
|
|
715
584
|
}
|
|
716
585
|
|
|
586
|
+
this._stdioClientTransport?.close();
|
|
587
|
+
this._stdioClientTransport = null;
|
|
588
|
+
this._stdioClientProvider = null;
|
|
589
|
+
|
|
590
|
+
// Streamable HTTP sessions are torn down by the endpoint that owns them,
|
|
591
|
+
// not by ending their responses here: `closeAll` runs each session's
|
|
592
|
+
// `stop`, which is what empties `httpSessions`.
|
|
593
|
+
await Promise.all([...this._providers.values()].map((state) => state.httpEndpoint?.closeAll() ?? Promise.resolve()));
|
|
594
|
+
|
|
717
595
|
return new Promise((resolve, reject) => {
|
|
718
596
|
for (const state of this._providers.values()) {
|
|
719
597
|
for (const res of state.sseSessions.values()) res.end();
|
|
720
598
|
state.sseSessions.clear();
|
|
721
|
-
|
|
722
|
-
state.
|
|
599
|
+
state.httpSessions.clear();
|
|
600
|
+
state.httpEndpoint = null;
|
|
723
601
|
for (const client of state.wsClients) client.close();
|
|
724
602
|
state.wsClients.clear();
|
|
725
603
|
state.ws?.close();
|
|
@@ -736,6 +614,123 @@ export class WsTunnel implements BrokerContext {
|
|
|
736
614
|
});
|
|
737
615
|
}
|
|
738
616
|
|
|
617
|
+
private _authorizationSubject(principal: IPrincipal | null): IAuthorizationSubject {
|
|
618
|
+
if (principal?.subject) return principal.subject;
|
|
619
|
+
if (!principal) return { ids: [] };
|
|
620
|
+
try {
|
|
621
|
+
return (
|
|
622
|
+
this._authorization?.subjectMapper.map(principal.claims) ?? {
|
|
623
|
+
ids: [],
|
|
624
|
+
claims: principal.claims,
|
|
625
|
+
}
|
|
626
|
+
);
|
|
627
|
+
} catch (error) {
|
|
628
|
+
if (!(error instanceof SubjectMappingError)) {
|
|
629
|
+
console.error("[broker] authorization subject mapping failed.");
|
|
630
|
+
}
|
|
631
|
+
return { ids: [], claims: principal.claims };
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private _auditDecision(
|
|
636
|
+
subject: IAuthorizationSubject,
|
|
637
|
+
slot: string,
|
|
638
|
+
resource: ResourcePath | undefined,
|
|
639
|
+
capability: string | undefined,
|
|
640
|
+
tool: string | undefined,
|
|
641
|
+
decision: IAuthorizationDecision
|
|
642
|
+
): void {
|
|
643
|
+
const authorization = this._authorization;
|
|
644
|
+
if (!authorization || (decision.allowed && !authorization.audit.logAllowed)) return;
|
|
645
|
+
writeAuthorizationAuditEvent(
|
|
646
|
+
makeAuthorizationAuditEvent(
|
|
647
|
+
{
|
|
648
|
+
subject,
|
|
649
|
+
slot,
|
|
650
|
+
resource,
|
|
651
|
+
capability,
|
|
652
|
+
provider: slot,
|
|
653
|
+
tool,
|
|
654
|
+
},
|
|
655
|
+
decision
|
|
656
|
+
)
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Applies hierarchical policy to the MCP operations carried in one
|
|
662
|
+
* JSON-RPC frame. `_all` performs provider-specific checks internally.
|
|
663
|
+
*/
|
|
664
|
+
private _authorizeMcpFrame(providerName: string, data: string, principal: IPrincipal | null): boolean {
|
|
665
|
+
const authorization = this._authorization;
|
|
666
|
+
if (!authorization || providerName === AggregateServer.SLOT) return true;
|
|
667
|
+
|
|
668
|
+
const subject = this._authorizationSubject(principal);
|
|
669
|
+
const resource = this._slotResourceResolver.resolve(providerName);
|
|
670
|
+
if (!resource) {
|
|
671
|
+
const decision: IAuthorizationDecision = { allowed: false, reason: "unknown-resource" };
|
|
672
|
+
this._auditDecision(subject, providerName, undefined, undefined, undefined, decision);
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
let parsed: unknown;
|
|
677
|
+
try {
|
|
678
|
+
parsed = JSON.parse(data) as unknown;
|
|
679
|
+
} catch {
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
const operations = Array.isArray(parsed) ? parsed : [parsed];
|
|
683
|
+
for (const value of operations) {
|
|
684
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
685
|
+
const operation = value as Readonly<{ method?: string; params?: unknown }>;
|
|
686
|
+
try {
|
|
687
|
+
const classified = authorization.capabilityClassifier.classify(operation, resource, providerName);
|
|
688
|
+
if (!classified) continue;
|
|
689
|
+
const decision = authorization.engine.authorize({
|
|
690
|
+
subject,
|
|
691
|
+
capability: classified.capability,
|
|
692
|
+
resource,
|
|
693
|
+
provider: providerName,
|
|
694
|
+
tool: classified.tool,
|
|
695
|
+
});
|
|
696
|
+
this._auditDecision(subject, providerName, resource, classified.capability, classified.tool, decision);
|
|
697
|
+
if (!decision.allowed) return false;
|
|
698
|
+
} catch {
|
|
699
|
+
console.error("[broker] policy evaluation failed.");
|
|
700
|
+
const decision: IAuthorizationDecision = { allowed: false, reason: "no-matching-grant" };
|
|
701
|
+
this._auditDecision(subject, providerName, resource, undefined, undefined, decision);
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private _policyDeniedPayload(data: string): string {
|
|
709
|
+
let id: string | number | null = null;
|
|
710
|
+
try {
|
|
711
|
+
const parsed = JSON.parse(data) as { id?: unknown };
|
|
712
|
+
if (typeof parsed.id === "string" || typeof parsed.id === "number" || parsed.id === null) {
|
|
713
|
+
id = parsed.id;
|
|
714
|
+
}
|
|
715
|
+
} catch {
|
|
716
|
+
// A malformed frame has no usable request id.
|
|
717
|
+
}
|
|
718
|
+
return JSON.stringify({
|
|
719
|
+
jsonrpc: "2.0",
|
|
720
|
+
id,
|
|
721
|
+
error: { code: -32001, message: "Forbidden" },
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** The JSON-RPC error returned when the slot has nobody behind it. */
|
|
726
|
+
private _notConnectedPayload(providerName: string, frame: string): string {
|
|
727
|
+
return JSON.stringify({
|
|
728
|
+
jsonrpc: "2.0",
|
|
729
|
+
id: requestIdOf(frame) ?? null,
|
|
730
|
+
error: { code: -32000, message: `Provider "${providerName}" not connected` },
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
739
734
|
// -------------------------------------------------------------------------
|
|
740
735
|
// HTTP dispatcher
|
|
741
736
|
// -------------------------------------------------------------------------
|
|
@@ -763,30 +758,34 @@ export class WsTunnel implements BrokerContext {
|
|
|
763
758
|
return;
|
|
764
759
|
}
|
|
765
760
|
|
|
761
|
+
// Protected Resource Metadata (RFC 9728), public discovery data, served
|
|
762
|
+
// unauthenticated so a client can find the authorization server after a 401.
|
|
763
|
+
if (this._authGuard && method === "GET") {
|
|
764
|
+
const metaSlot = this._authGuard.matchMetadataRequest(rawUrl);
|
|
765
|
+
if (metaSlot) {
|
|
766
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
767
|
+
res.end(JSON.stringify(this._authGuard.metadataFor(metaSlot)));
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
766
772
|
// Route /<providerName>/<endpoint>
|
|
767
773
|
const route = this._parseProviderRoute(rawUrl);
|
|
768
774
|
if (route) {
|
|
769
775
|
const { providerName, endpoint } = route;
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
776
|
+
const kind = this._mcpEndpointKind(endpoint, method);
|
|
777
|
+
if (kind) {
|
|
778
|
+
if (this._authGuard) {
|
|
779
|
+
// Gate on a valid bearer token issued for this slot before touching
|
|
780
|
+
// the provider. On failure, emit an RFC 9728 challenge / 500.
|
|
781
|
+
const guard = this._authGuard;
|
|
782
|
+
void guard.authorize(req, providerName).then(
|
|
783
|
+
(principal) => this._dispatchMcpEndpoint(kind, req, res, providerName, principal),
|
|
784
|
+
(err: unknown) => this._handleAuthFailure(guard, res, providerName, err)
|
|
785
|
+
);
|
|
786
|
+
} else {
|
|
787
|
+
this._dispatchMcpEndpoint(kind, req, res, providerName, null);
|
|
782
788
|
}
|
|
783
|
-
}
|
|
784
|
-
if (endpoint === sseSuffix && method === "GET") {
|
|
785
|
-
this._handleSseConnect(req, res, providerName);
|
|
786
|
-
return;
|
|
787
|
-
}
|
|
788
|
-
if (endpoint === messagesSuffix && method === "POST") {
|
|
789
|
-
this._handleSseMessage(req, res, providerName);
|
|
790
789
|
return;
|
|
791
790
|
}
|
|
792
791
|
}
|
|
@@ -813,15 +812,142 @@ export class WsTunnel implements BrokerContext {
|
|
|
813
812
|
return { providerName, endpoint };
|
|
814
813
|
}
|
|
815
814
|
|
|
815
|
+
/**
|
|
816
|
+
* Classifies a `(endpoint, method)` pair as one of the four MCP/SSE client
|
|
817
|
+
* handlers, or `null` when it is not a client transport request (the caller
|
|
818
|
+
* then falls through to static-file serving, preserving prior behavior).
|
|
819
|
+
*/
|
|
820
|
+
private _mcpEndpointKind(endpoint: string, method: string): McpEndpointKind | null {
|
|
821
|
+
const mcpSuffix = (this._options.mcpPath ?? "/mcp").replace(/^\//, "");
|
|
822
|
+
const sseSuffix = (this._options.ssePath ?? "/sse").replace(/^\//, "");
|
|
823
|
+
const messagesSuffix = (this._options.messagesPath ?? "/messages").replace(/^\//, "");
|
|
824
|
+
// GET, POST and DELETE all belong to the Streamable HTTP state machine.
|
|
825
|
+
if (endpoint === mcpSuffix) return "mcp";
|
|
826
|
+
if (endpoint === sseSuffix && method === "GET") return "sse-connect";
|
|
827
|
+
if (endpoint === messagesSuffix && method === "POST") return "sse-message";
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Routes an already-authorized (or auth-disabled) request to its handler. */
|
|
832
|
+
private _dispatchMcpEndpoint(kind: McpEndpointKind, req: IncomingMessage, res: ServerResponse, providerName: string, principal: IPrincipal | null): void {
|
|
833
|
+
switch (kind) {
|
|
834
|
+
case "mcp":
|
|
835
|
+
this._handleStreamableHttp(req, res, providerName, principal);
|
|
836
|
+
return;
|
|
837
|
+
case "sse-connect":
|
|
838
|
+
this._handleSseConnect(req, res, providerName, principal);
|
|
839
|
+
return;
|
|
840
|
+
case "sse-message":
|
|
841
|
+
this._handleSseMessage(req, res, providerName, principal);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Turns a rejected {@link HttpAuthGuard.authorize} into an HTTP response. */
|
|
847
|
+
private _handleAuthFailure(guard: HttpAuthGuard, res: ServerResponse, providerName: string, err: unknown): void {
|
|
848
|
+
if (err instanceof AuthError) {
|
|
849
|
+
guard.writeChallenge(res, providerName, err);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
// Not a token failure (e.g. the JWKS endpoint is unreachable), surface a
|
|
853
|
+
// 500 rather than a misleading 401.
|
|
854
|
+
console.error(`[broker] token validation error for "${providerName}":`, err);
|
|
855
|
+
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
|
856
|
+
res.end(JSON.stringify({ error: "server_error" }));
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Builds the `ws` verifyClient hook that authenticates every WebSocket
|
|
861
|
+
* upgrade before the connection is accepted, returning a real `401`/`403`
|
|
862
|
+
* during the handshake rather than a post-handshake close:
|
|
863
|
+
*
|
|
864
|
+
* - **Raw MCP clients** (`/<slot>`) are gated by the OAuth 2.1 resource
|
|
865
|
+
* server ({@link _authGuard}) with the RFC 9728 `WWW-Authenticate` challenge.
|
|
866
|
+
* - **Providers** (`/provider/<slot>`, `/providers`) are gated by the
|
|
867
|
+
* {@link _providerAuth} shared-secret / custom authenticator.
|
|
868
|
+
*
|
|
869
|
+
* Each side is independent: a branch with no authenticator configured is let
|
|
870
|
+
* through unchanged. Returns `undefined` (no hook) when neither is set.
|
|
871
|
+
*/
|
|
872
|
+
private _makeVerifyClient(): VerifyClientCallbackAsync | undefined {
|
|
873
|
+
const guard = this._authGuard;
|
|
874
|
+
const providerAuth = this._providerAuth;
|
|
875
|
+
if (!guard && !providerAuth) return undefined;
|
|
876
|
+
|
|
877
|
+
const providerPath = this._options.providerPath ?? "/provider";
|
|
878
|
+
const providersPath = this._options.providersPath ?? "/providers";
|
|
879
|
+
|
|
880
|
+
return (info, cb) => {
|
|
881
|
+
const url = info.req.url ?? "/";
|
|
882
|
+
const isMultiplex = url === providersPath || url.startsWith(providersPath + "?");
|
|
883
|
+
const isDedicated = url.startsWith(providerPath + "/") || url === providerPath;
|
|
884
|
+
|
|
885
|
+
if (isMultiplex || isDedicated) {
|
|
886
|
+
// Provider (engine) upgrade, shared-secret / custom authenticator.
|
|
887
|
+
if (!providerAuth) {
|
|
888
|
+
cb(true);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
const slot = isDedicated ? decodeURIComponent(url.slice(providerPath.length).replace(/^\//, "").split("?")[0]) || undefined : undefined;
|
|
892
|
+
Promise.resolve(providerAuth.authenticate(info.req, slot)).then(
|
|
893
|
+
(rawResult) => {
|
|
894
|
+
const result = normalizeProviderAuthentication(rawResult);
|
|
895
|
+
if (!result.authenticated) {
|
|
896
|
+
cb(false, 401, "Unauthorized", { "WWW-Authenticate": 'Bearer realm="provider"' });
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (slot !== undefined) {
|
|
900
|
+
const resource = this._slotResourceResolver.resolve(slot);
|
|
901
|
+
if (!resource || !providerMayPublish(result.principal, resource)) {
|
|
902
|
+
this._logProviderRegistration(result.principal, slot, resource, false);
|
|
903
|
+
cb(false, 403, "Forbidden");
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
this._pendingProviderPrincipals.set(info.req, result.principal);
|
|
908
|
+
cb(true);
|
|
909
|
+
},
|
|
910
|
+
() => {
|
|
911
|
+
console.error(`[broker] provider authentication error for "${slot ?? "(multiplex)"}".`);
|
|
912
|
+
cb(false, 500, "server_error");
|
|
913
|
+
}
|
|
914
|
+
);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Raw WS MCP client upgrade, OAuth 2.1 resource server.
|
|
919
|
+
if (!guard) {
|
|
920
|
+
cb(true);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const slot = decodeURIComponent(url.replace(/^\//, "").split("?")[0]);
|
|
924
|
+
void guard.authorize(info.req, slot).then(
|
|
925
|
+
(principal) => {
|
|
926
|
+
// Stash the principal so _onClientConnect can bind it to the socket.
|
|
927
|
+
this._pendingClientPrincipals.set(info.req, principal);
|
|
928
|
+
cb(true);
|
|
929
|
+
},
|
|
930
|
+
(err: unknown) => {
|
|
931
|
+
if (err instanceof AuthError) {
|
|
932
|
+
cb(false, err.status, err.code, { "WWW-Authenticate": guard.challengeHeader(slot, err) });
|
|
933
|
+
} else {
|
|
934
|
+
console.error(`[broker] token validation error for WS client "${slot}":`, err);
|
|
935
|
+
cb(false, 500, "server_error");
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
);
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
816
942
|
// -------------------------------------------------------------------------
|
|
817
943
|
// MCP / SSE transport (per provider)
|
|
818
944
|
// -------------------------------------------------------------------------
|
|
819
945
|
|
|
820
946
|
/**
|
|
821
|
-
* Handles `GET /<providerName>/sse
|
|
947
|
+
* Handles `GET /<providerName>/sse`, opens a long-lived SSE stream for Claude.
|
|
822
948
|
* Sends an `endpoint` event so Claude knows where to POST its requests.
|
|
823
949
|
*/
|
|
824
|
-
private _handleSseConnect(req: IncomingMessage, res: ServerResponse, providerName: string): void {
|
|
950
|
+
private _handleSseConnect(req: IncomingMessage, res: ServerResponse, providerName: string, principal: IPrincipal | null): void {
|
|
825
951
|
const sessionId = randomUUID();
|
|
826
952
|
const messagesSuffix = (this._options.messagesPath ?? "/messages").replace(/^\//, "");
|
|
827
953
|
const messagesUrl = `/${encodeURIComponent(providerName)}/${messagesSuffix}`;
|
|
@@ -835,6 +961,7 @@ export class WsTunnel implements BrokerContext {
|
|
|
835
961
|
|
|
836
962
|
const state = this._getOrCreateProviderState(providerName);
|
|
837
963
|
state.sseSessions.set(sessionId, res);
|
|
964
|
+
if (principal) this._streamPrincipals.set(res, principal);
|
|
838
965
|
|
|
839
966
|
req.on("close", () => {
|
|
840
967
|
state.sseSessions.delete(sessionId);
|
|
@@ -845,11 +972,11 @@ export class WsTunnel implements BrokerContext {
|
|
|
845
972
|
}
|
|
846
973
|
|
|
847
974
|
/**
|
|
848
|
-
* Handles `POST /<providerName>/messages?sessionId
|
|
975
|
+
* Handles `POST /<providerName>/messages?sessionId=…`, receives a JSON-RPC
|
|
849
976
|
* request from Claude and forwards it to the provider.
|
|
850
977
|
* Always responds 202 Accepted; the real response arrives over SSE.
|
|
851
978
|
*/
|
|
852
|
-
private _handleSseMessage(req: IncomingMessage, res: ServerResponse, providerName: string): void {
|
|
979
|
+
private _handleSseMessage(req: IncomingMessage, res: ServerResponse, providerName: string, principal: IPrincipal | null): void {
|
|
853
980
|
const params = new URL(req.url ?? "", "http://localhost").searchParams;
|
|
854
981
|
const sessionId = params.get("sessionId") ?? "";
|
|
855
982
|
const state = this._getOrCreateProviderState(providerName);
|
|
@@ -865,15 +992,20 @@ export class WsTunnel implements BrokerContext {
|
|
|
865
992
|
body += chunk.toString();
|
|
866
993
|
});
|
|
867
994
|
req.on("end", () => {
|
|
995
|
+
if (!this._authorizeMcpFrame(providerName, body, principal)) {
|
|
996
|
+
res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
|
|
997
|
+
res.end(this._policyDeniedPayload(body));
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
868
1000
|
try {
|
|
869
1001
|
const msg = JSON.parse(body) as { id?: string | number };
|
|
870
1002
|
if (msg.id != null) state.pending.set(msg.id, { type: "sse", sessionId });
|
|
871
1003
|
} catch {
|
|
872
|
-
/* malformed
|
|
1004
|
+
/* malformed, forward anyway */
|
|
873
1005
|
}
|
|
874
1006
|
|
|
875
1007
|
if (this._isProviderConnected(providerName, state)) {
|
|
876
|
-
this._sendToProvider(state, providerName, body);
|
|
1008
|
+
this._sendToProvider(state, providerName, body, principal);
|
|
877
1009
|
} else {
|
|
878
1010
|
const sseRes = state.sseSessions.get(sessionId);
|
|
879
1011
|
if (sseRes) {
|
|
@@ -900,79 +1032,94 @@ export class WsTunnel implements BrokerContext {
|
|
|
900
1032
|
}
|
|
901
1033
|
|
|
902
1034
|
/**
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
*
|
|
1035
|
+
* Serves `/<providerName>/mcp` by handing the request to the slot's
|
|
1036
|
+
* Streamable HTTP endpoint.
|
|
1037
|
+
*
|
|
1038
|
+
* Everything protocol-shaped, sessions, `Mcp-Session-Id`, `DELETE`, the
|
|
1039
|
+
* `404` on a terminated session, `Origin` and `MCP-Protocol-Version`
|
|
1040
|
+
* validation, `202` on a notification, belongs to `mcp-core` and is no
|
|
1041
|
+
* longer reimplemented here. What stays is the broker's own business:
|
|
1042
|
+
* deciding who may speak (already done upstream by the auth guard) and
|
|
1043
|
+
* relaying frames to a provider that lives somewhere else entirely.
|
|
906
1044
|
*/
|
|
907
|
-
private
|
|
908
|
-
|
|
909
|
-
req.on("data", (chunk: Buffer) => {
|
|
910
|
-
body += chunk.toString();
|
|
911
|
-
});
|
|
912
|
-
req.on("end", () => {
|
|
913
|
-
let msg: { id?: string | number } = {};
|
|
914
|
-
try {
|
|
915
|
-
msg = JSON.parse(body) as { id?: string | number };
|
|
916
|
-
} catch {
|
|
917
|
-
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
918
|
-
res.end("Invalid JSON");
|
|
919
|
-
return;
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
const state = this._getOrCreateProviderState(providerName);
|
|
923
|
-
|
|
924
|
-
if (!this._isProviderConnected(providerName, state)) {
|
|
925
|
-
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
926
|
-
res.end(
|
|
927
|
-
JSON.stringify({
|
|
928
|
-
jsonrpc: "2.0",
|
|
929
|
-
id: msg.id ?? null,
|
|
930
|
-
error: { code: -32000, message: `Provider "${providerName}" not connected` },
|
|
931
|
-
})
|
|
932
|
-
);
|
|
933
|
-
return;
|
|
934
|
-
}
|
|
1045
|
+
private _handleStreamableHttp(req: IncomingMessage, res: ServerResponse, providerName: string, principal: IPrincipal | null): void {
|
|
1046
|
+
const state = this._getOrCreateProviderState(providerName);
|
|
935
1047
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1048
|
+
// The endpoint authenticates nothing: this broker has its own guard,
|
|
1049
|
+
// richer than the spec's (per-slot scopes, subject mapping into the
|
|
1050
|
+
// policy engine). So the principal is attached to the session here,
|
|
1051
|
+
// refreshed on every request, and read back when a frame is checked.
|
|
1052
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
1053
|
+
if (typeof sessionId === "string") {
|
|
1054
|
+
const session = state.httpSessions.get(sessionId);
|
|
1055
|
+
if (session) session.principal = principal;
|
|
1056
|
+
}
|
|
944
1057
|
|
|
945
|
-
|
|
946
|
-
});
|
|
1058
|
+
void this._endpointFor(providerName, state).handleRequest(req, res);
|
|
947
1059
|
}
|
|
948
1060
|
|
|
949
1061
|
/**
|
|
950
|
-
*
|
|
951
|
-
*
|
|
952
|
-
* server
|
|
1062
|
+
* The slot's Streamable HTTP endpoint, built on first use.
|
|
1063
|
+
*
|
|
1064
|
+
* Its factory does not create an MCP server: the server is the provider,
|
|
1065
|
+
* reachable only through the tunnel. It creates a bridge instead: frames the
|
|
1066
|
+
* client sends go out to the provider, and frames coming back are addressed
|
|
1067
|
+
* to this session by id.
|
|
953
1068
|
*/
|
|
954
|
-
private
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1069
|
+
private _endpointFor(providerName: string, state: IProviderState): StreamableHttpEndpoint {
|
|
1070
|
+
if (state.httpEndpoint) return state.httpEndpoint;
|
|
1071
|
+
|
|
1072
|
+
state.httpEndpoint = new StreamableHttpEndpoint({
|
|
1073
|
+
allowedOrigins: this._allowedOrigins,
|
|
1074
|
+
|
|
1075
|
+
createServer: (transport, sessionId, _principal) => {
|
|
1076
|
+
const session: IHttpSession = { transport, principal: null };
|
|
1077
|
+
state.httpSessions.set(sessionId, session);
|
|
1078
|
+
|
|
1079
|
+
transport.onMessage = (frame: string) => this._fromHttpSession(state, providerName, sessionId, session, frame);
|
|
1080
|
+
|
|
1081
|
+
return {
|
|
1082
|
+
// No server to launch: the one serving this session is the
|
|
1083
|
+
// provider, at the far end of the tunnel. What `start` still
|
|
1084
|
+
// owes is opening the transport, since a closed one drops
|
|
1085
|
+
// every frame it is handed.
|
|
1086
|
+
start: () => openTransport(transport),
|
|
1087
|
+
stop: () => {
|
|
1088
|
+
state.httpSessions.delete(sessionId);
|
|
1089
|
+
// Nothing can answer the requests this session had in
|
|
1090
|
+
// flight; leaving them would pin the ids forever.
|
|
1091
|
+
for (const [id, sink] of state.pending) {
|
|
1092
|
+
if (sink.type === "http-session" && sink.sessionId === sessionId) state.pending.delete(id);
|
|
1093
|
+
}
|
|
1094
|
+
},
|
|
1095
|
+
};
|
|
1096
|
+
},
|
|
962
1097
|
});
|
|
963
|
-
res.write(": stream open\n\n");
|
|
964
1098
|
|
|
965
|
-
|
|
966
|
-
|
|
1099
|
+
return state.httpEndpoint;
|
|
1100
|
+
}
|
|
967
1101
|
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1102
|
+
/** Relays one frame from an HTTP session to the provider behind the slot. */
|
|
1103
|
+
private _fromHttpSession(state: IProviderState, providerName: string, sessionId: string, session: IHttpSession, frame: string): void {
|
|
1104
|
+
if (!this._authorizeMcpFrame(providerName, frame, session.principal)) {
|
|
1105
|
+
session.transport.send(this._policyDeniedPayload(frame));
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (!this._isProviderConnected(providerName, state)) {
|
|
1110
|
+
session.transport.send(this._notConnectedPayload(providerName, frame));
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const id = requestIdOf(frame);
|
|
1115
|
+
if (id !== undefined) state.pending.set(id, { type: "http-session", sessionId });
|
|
1116
|
+
|
|
1117
|
+
this._sendToProvider(state, providerName, frame, session.principal);
|
|
971
1118
|
}
|
|
972
1119
|
|
|
973
1120
|
/** Writes one JSON-RPC message as an SSE `message` event. */
|
|
974
1121
|
private _sendSseEvent(res: ServerResponse, data: string): void {
|
|
975
|
-
// data is already a compact JSON string
|
|
1122
|
+
// data is already a compact JSON string: no need to parse+re-serialize.
|
|
976
1123
|
res.write(`event: message\ndata: ${data}\n\n`);
|
|
977
1124
|
}
|
|
978
1125
|
|
|
@@ -980,18 +1127,35 @@ export class WsTunnel implements BrokerContext {
|
|
|
980
1127
|
// WebSocket connection handlers
|
|
981
1128
|
// -------------------------------------------------------------------------
|
|
982
1129
|
|
|
983
|
-
private
|
|
1130
|
+
private _logProviderRegistration(principal: IProviderPrincipal, slot: string, resource: ResourcePath | undefined, allowed: boolean): void {
|
|
1131
|
+
const event = {
|
|
1132
|
+
timestamp: new Date().toISOString(),
|
|
1133
|
+
providerId: principal.id,
|
|
1134
|
+
slot,
|
|
1135
|
+
resource: resource?.value,
|
|
1136
|
+
allowed,
|
|
1137
|
+
};
|
|
1138
|
+
const line = `[broker] provider-registration ${JSON.stringify(event)}`;
|
|
1139
|
+
if (allowed) console.info(line);
|
|
1140
|
+
else console.warn(line);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
private _onProviderConnect(ws: WebSocket, name: string, req: IncomingMessage): void {
|
|
1144
|
+
const providerPrincipal = this._pendingProviderPrincipals.get(req);
|
|
1145
|
+
if (providerPrincipal) {
|
|
1146
|
+
this._pendingProviderPrincipals.delete(req);
|
|
1147
|
+
this._providerPrincipals.set(ws, providerPrincipal);
|
|
1148
|
+
}
|
|
984
1149
|
if (this._upstreams.has(name)) {
|
|
985
1150
|
console.warn(
|
|
986
|
-
`[broker] WARNING: WebSocket provider "${name}" rejected
|
|
987
|
-
`Rename one of them to avoid the conflict.`
|
|
1151
|
+
`[broker] WARNING: WebSocket provider "${name}" rejected: a stdio upstream with the same name is already configured. ` + `Rename one of them to avoid the conflict.`
|
|
988
1152
|
);
|
|
989
1153
|
ws.close(1008, `Provider "${name}" is managed by a stdio upstream`);
|
|
990
1154
|
return;
|
|
991
1155
|
}
|
|
992
1156
|
|
|
993
1157
|
if (this._loopbackProviders.has(name)) {
|
|
994
|
-
console.warn(`[broker] WARNING: WebSocket provider "${name}" rejected
|
|
1158
|
+
console.warn(`[broker] WARNING: WebSocket provider "${name}" rejected: the slot is held by an in-process loopback (reserved system slot).`);
|
|
995
1159
|
ws.close(1008, `Provider "${name}" is reserved by the broker`);
|
|
996
1160
|
return;
|
|
997
1161
|
}
|
|
@@ -1004,10 +1168,13 @@ export class WsTunnel implements BrokerContext {
|
|
|
1004
1168
|
|
|
1005
1169
|
const state = this._getOrCreateProviderState(name);
|
|
1006
1170
|
state.ws = ws;
|
|
1171
|
+
if (providerPrincipal) {
|
|
1172
|
+
this._logProviderRegistration(providerPrincipal, name, this._slotResourceResolver.resolve(name), true);
|
|
1173
|
+
}
|
|
1007
1174
|
|
|
1008
1175
|
// A provider MAY send a registration control frame as its very first
|
|
1009
|
-
// message (see _tryHandleRegistration). Any other first message
|
|
1010
|
-
// including a normal MCP frame
|
|
1176
|
+
// message (see _tryHandleRegistration). Any other first message ,
|
|
1177
|
+
// including a normal MCP frame, is routed and leaves the provider
|
|
1011
1178
|
// non-aggregated, so every pre-existing provider keeps working.
|
|
1012
1179
|
let registrationChecked = false;
|
|
1013
1180
|
ws.on("message", (data: Buffer) => {
|
|
@@ -1016,7 +1183,7 @@ export class WsTunnel implements BrokerContext {
|
|
|
1016
1183
|
registrationChecked = true;
|
|
1017
1184
|
if (this._tryHandleRegistration(name, text)) return;
|
|
1018
1185
|
}
|
|
1019
|
-
this._routeFromProvider(state, text);
|
|
1186
|
+
this._routeFromProvider(state, name, text);
|
|
1020
1187
|
});
|
|
1021
1188
|
|
|
1022
1189
|
ws.on("close", () => {
|
|
@@ -1028,7 +1195,7 @@ export class WsTunnel implements BrokerContext {
|
|
|
1028
1195
|
/**
|
|
1029
1196
|
* Inspects a provider's first WebSocket message for an optional registration
|
|
1030
1197
|
* control frame `{ "type": "register", "aggregate": boolean }`. Returns
|
|
1031
|
-
* `true` when the message was a registration frame
|
|
1198
|
+
* `true` when the message was a registration frame: and thus consumed, not
|
|
1032
1199
|
* routed as MCP traffic. A normal MCP frame always carries `jsonrpc`, so it
|
|
1033
1200
|
* returns `false` and the provider stays non-aggregated.
|
|
1034
1201
|
*/
|
|
@@ -1046,10 +1213,18 @@ export class WsTunnel implements BrokerContext {
|
|
|
1046
1213
|
return true;
|
|
1047
1214
|
}
|
|
1048
1215
|
|
|
1049
|
-
private _onClientConnect(ws: WebSocket, providerName: string): void {
|
|
1216
|
+
private _onClientConnect(ws: WebSocket, providerName: string, req: IncomingMessage): void {
|
|
1050
1217
|
const state = this._getOrCreateProviderState(providerName);
|
|
1051
1218
|
state.wsClients.add(ws);
|
|
1052
1219
|
|
|
1220
|
+
// Carry the principal captured at the upgrade onto the socket, so requests
|
|
1221
|
+
// this client makes to `_all` can be scope-filtered.
|
|
1222
|
+
const principal = this._pendingClientPrincipals.get(req);
|
|
1223
|
+
if (principal) {
|
|
1224
|
+
this._clientPrincipals.set(ws, principal);
|
|
1225
|
+
this._pendingClientPrincipals.delete(req);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1053
1228
|
ws.on("message", (data: Buffer) => this._routeFromClient(ws, state, providerName, data.toString()));
|
|
1054
1229
|
|
|
1055
1230
|
ws.on("close", () => {
|
|
@@ -1066,77 +1241,61 @@ export class WsTunnel implements BrokerContext {
|
|
|
1066
1241
|
* envelope format `{ provider: string, payload: object }`.
|
|
1067
1242
|
* Provider names are registered lazily on first message.
|
|
1068
1243
|
*/
|
|
1069
|
-
private _onMultiplexProviderConnect(ws: WebSocket): void {
|
|
1244
|
+
private _onMultiplexProviderConnect(ws: WebSocket, req: IncomingMessage): void {
|
|
1070
1245
|
const providerNames = new Set<string>();
|
|
1071
1246
|
this._multiplexSockets.set(ws, providerNames);
|
|
1247
|
+
const providerPrincipal = this._pendingProviderPrincipals.get(req);
|
|
1248
|
+
if (providerPrincipal) {
|
|
1249
|
+
this._pendingProviderPrincipals.delete(req);
|
|
1250
|
+
this._providerPrincipals.set(ws, providerPrincipal);
|
|
1251
|
+
}
|
|
1072
1252
|
|
|
1073
1253
|
ws.on("message", (data: Buffer) => {
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
envelope = JSON.parse(data.toString()) as { provider?: string; payload?: unknown };
|
|
1077
|
-
} catch {
|
|
1078
|
-
return; // malformed — drop
|
|
1079
|
-
}
|
|
1254
|
+
const envelope = decodeEnvelope(data.toString());
|
|
1255
|
+
if (!envelope) return; // malformed, drop
|
|
1080
1256
|
|
|
1081
1257
|
const name = envelope.provider;
|
|
1082
|
-
if (!name || envelope.payload === undefined) return;
|
|
1083
1258
|
|
|
1084
1259
|
// Register provider name lazily on first encounter.
|
|
1085
1260
|
if (!providerNames.has(name)) {
|
|
1261
|
+
if (providerPrincipal) {
|
|
1262
|
+
const resource = this._slotResourceResolver.resolve(name);
|
|
1263
|
+
if (!resource || !providerMayPublish(providerPrincipal, resource)) {
|
|
1264
|
+
this._logProviderRegistration(providerPrincipal, name, resource, false);
|
|
1265
|
+
ws.send(encodeErrorEnvelope(name, TunnelErrorCodes.RegistrationForbidden, "Provider registration forbidden"));
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1086
1269
|
if (this._upstreams.has(name)) {
|
|
1087
1270
|
console.warn(
|
|
1088
|
-
`[broker] WARNING: Multiplexed WebSocket provider "${name}" rejected
|
|
1271
|
+
`[broker] WARNING: Multiplexed WebSocket provider "${name}" rejected: a stdio upstream with the same name is already configured. ` +
|
|
1089
1272
|
`Rename one of them to avoid the conflict.`
|
|
1090
1273
|
);
|
|
1091
|
-
ws.send(
|
|
1092
|
-
JSON.stringify({
|
|
1093
|
-
provider: name,
|
|
1094
|
-
payload: {
|
|
1095
|
-
jsonrpc: "2.0",
|
|
1096
|
-
id: null,
|
|
1097
|
-
error: { code: -32000, message: `Provider "${name}" is managed by a stdio upstream` },
|
|
1098
|
-
},
|
|
1099
|
-
})
|
|
1100
|
-
);
|
|
1274
|
+
ws.send(encodeErrorEnvelope(name, TunnelErrorCodes.ProviderUnavailable, `Provider "${name}" is managed by a stdio upstream`));
|
|
1101
1275
|
return;
|
|
1102
1276
|
}
|
|
1103
1277
|
|
|
1104
1278
|
if (this._loopbackProviders.has(name)) {
|
|
1105
|
-
ws.send(
|
|
1106
|
-
JSON.stringify({
|
|
1107
|
-
provider: name,
|
|
1108
|
-
payload: {
|
|
1109
|
-
jsonrpc: "2.0",
|
|
1110
|
-
id: null,
|
|
1111
|
-
error: { code: -32000, message: `Provider "${name}" is reserved by the broker` },
|
|
1112
|
-
},
|
|
1113
|
-
})
|
|
1114
|
-
);
|
|
1279
|
+
ws.send(encodeErrorEnvelope(name, TunnelErrorCodes.ProviderUnavailable, `Provider "${name}" is reserved by the broker`));
|
|
1115
1280
|
return;
|
|
1116
1281
|
}
|
|
1117
1282
|
|
|
1118
1283
|
const existing = this._providers.get(name);
|
|
1119
1284
|
if (existing?.ws?.readyState === WebSocket.OPEN) {
|
|
1120
|
-
// Provider already connected via another socket
|
|
1121
|
-
ws.send(
|
|
1122
|
-
JSON.stringify({
|
|
1123
|
-
provider: name,
|
|
1124
|
-
payload: {
|
|
1125
|
-
jsonrpc: "2.0",
|
|
1126
|
-
id: null,
|
|
1127
|
-
error: { code: -32000, message: `Provider "${name}" is already connected` },
|
|
1128
|
-
},
|
|
1129
|
-
})
|
|
1130
|
-
);
|
|
1285
|
+
// Provider already connected via another socket, reject this name.
|
|
1286
|
+
ws.send(encodeErrorEnvelope(name, TunnelErrorCodes.ProviderUnavailable, `Provider "${name}" is already connected`));
|
|
1131
1287
|
return;
|
|
1132
1288
|
}
|
|
1133
1289
|
providerNames.add(name);
|
|
1134
1290
|
const state = this._getOrCreateProviderState(name);
|
|
1135
1291
|
state.ws = ws;
|
|
1292
|
+
if (providerPrincipal) {
|
|
1293
|
+
this._logProviderRegistration(providerPrincipal, name, this._slotResourceResolver.resolve(name), true);
|
|
1294
|
+
}
|
|
1136
1295
|
}
|
|
1137
1296
|
|
|
1138
1297
|
const state = this._providers.get(name)!;
|
|
1139
|
-
this._routeFromProvider(state,
|
|
1298
|
+
this._routeFromProvider(state, name, envelopeFrame(envelope));
|
|
1140
1299
|
});
|
|
1141
1300
|
|
|
1142
1301
|
ws.on("close", () => {
|
|
@@ -1159,7 +1318,15 @@ export class WsTunnel implements BrokerContext {
|
|
|
1159
1318
|
* Sends a raw JSON-RPC message to a provider, wrapping it in a multiplex
|
|
1160
1319
|
* envelope when the provider's WebSocket is a multiplexed connection.
|
|
1161
1320
|
*/
|
|
1162
|
-
private _sendToProvider(state:
|
|
1321
|
+
private _sendToProvider(state: IProviderState, providerName: string, data: string, principal: IPrincipal | null = null): void {
|
|
1322
|
+
// The `_all` aggregate is a loopback, but it needs the caller's principal
|
|
1323
|
+
// to scope its catalog/routing, hand it off directly rather than through
|
|
1324
|
+
// the generic transport, which would drop the context.
|
|
1325
|
+
if (providerName === AggregateServer.SLOT && this._aggregateServer) {
|
|
1326
|
+
this._aggregateServer.sendAs(data, principal);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1163
1330
|
// Upstreams (stdio child processes and remote URL servers) take priority.
|
|
1164
1331
|
const upstream = this._upstreams.get(providerName);
|
|
1165
1332
|
if (upstream?.isOpen) {
|
|
@@ -1178,14 +1345,13 @@ export class WsTunnel implements BrokerContext {
|
|
|
1178
1345
|
|
|
1179
1346
|
if (this._multiplexSockets.has(state.ws)) {
|
|
1180
1347
|
// Wrap in envelope for the multiplexed socket.
|
|
1181
|
-
|
|
1182
|
-
state.ws.send(JSON.stringify({ provider: providerName, payload }));
|
|
1348
|
+
state.ws.send(encodeEnvelope(providerName, data));
|
|
1183
1349
|
} else {
|
|
1184
1350
|
state.ws.send(data);
|
|
1185
1351
|
}
|
|
1186
1352
|
}
|
|
1187
1353
|
|
|
1188
|
-
private _routeFromStdioClient(state:
|
|
1354
|
+
private _routeFromStdioClient(state: IProviderState, data: string): void {
|
|
1189
1355
|
try {
|
|
1190
1356
|
const msg = JSON.parse(data) as { id?: string | number };
|
|
1191
1357
|
if (msg?.id != null) state.pending.set(msg.id, { type: "stdio" });
|
|
@@ -1203,18 +1369,24 @@ export class WsTunnel implements BrokerContext {
|
|
|
1203
1369
|
/* */
|
|
1204
1370
|
}
|
|
1205
1371
|
if (errId != null) {
|
|
1206
|
-
|
|
1372
|
+
this._stdioClientTransport?.send(
|
|
1207
1373
|
JSON.stringify({
|
|
1208
1374
|
jsonrpc: "2.0",
|
|
1209
1375
|
id: errId,
|
|
1210
1376
|
error: { code: -32000, message: `Provider "${this._stdioClientProvider}" not connected` },
|
|
1211
|
-
})
|
|
1377
|
+
})
|
|
1212
1378
|
);
|
|
1213
1379
|
}
|
|
1214
1380
|
}
|
|
1215
1381
|
}
|
|
1216
1382
|
|
|
1217
|
-
private _routeFromClient(client: WebSocket, state:
|
|
1383
|
+
private _routeFromClient(client: WebSocket, state: IProviderState, providerName: string, data: string): void {
|
|
1384
|
+
const principal = this._clientPrincipals.get(client) ?? null;
|
|
1385
|
+
if (!this._authorizeMcpFrame(providerName, data, principal)) {
|
|
1386
|
+
client.send(this._policyDeniedPayload(data));
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1218
1390
|
try {
|
|
1219
1391
|
const msg = JSON.parse(data) as { id?: string | number };
|
|
1220
1392
|
if (msg?.id != null) state.pending.set(msg.id, { type: "ws", socket: client });
|
|
@@ -1223,7 +1395,7 @@ export class WsTunnel implements BrokerContext {
|
|
|
1223
1395
|
}
|
|
1224
1396
|
|
|
1225
1397
|
if (this._isProviderConnected(providerName, state)) {
|
|
1226
|
-
this._sendToProvider(state, providerName, data);
|
|
1398
|
+
this._sendToProvider(state, providerName, data, principal);
|
|
1227
1399
|
} else {
|
|
1228
1400
|
client.send(
|
|
1229
1401
|
JSON.stringify({
|
|
@@ -1235,7 +1407,7 @@ export class WsTunnel implements BrokerContext {
|
|
|
1235
1407
|
}
|
|
1236
1408
|
}
|
|
1237
1409
|
|
|
1238
|
-
private _routeFromProvider(state:
|
|
1410
|
+
private _routeFromProvider(state: IProviderState, providerName: string, data: string): void {
|
|
1239
1411
|
try {
|
|
1240
1412
|
const msg = JSON.parse(data) as { id?: string | number };
|
|
1241
1413
|
|
|
@@ -1247,41 +1419,53 @@ export class WsTunnel implements BrokerContext {
|
|
|
1247
1419
|
} else if (sink?.type === "sse") {
|
|
1248
1420
|
const sseRes = state.sseSessions.get(sink.sessionId);
|
|
1249
1421
|
if (sseRes) this._sendSseEvent(sseRes, data);
|
|
1250
|
-
} else if (sink?.type === "http") {
|
|
1251
|
-
|
|
1252
|
-
|
|
1422
|
+
} else if (sink?.type === "http-session") {
|
|
1423
|
+
// The session transport decides whether this answers a held-open
|
|
1424
|
+
// POST or travels on the GET stream: it correlates by id.
|
|
1425
|
+
state.httpSessions.get(sink.sessionId)?.transport.send(data);
|
|
1253
1426
|
} else if (sink?.type === "stdio") {
|
|
1254
|
-
|
|
1427
|
+
this._stdioClientTransport?.send(data);
|
|
1255
1428
|
} else if (sink?.type === "internal") {
|
|
1256
1429
|
sink.client.onMessage?.(data);
|
|
1257
1430
|
}
|
|
1258
1431
|
state.pending.delete(msg.id);
|
|
1259
1432
|
} else {
|
|
1260
1433
|
// Notification (no id): broadcast to all clients of this provider.
|
|
1261
|
-
this._broadcast(state, data);
|
|
1434
|
+
this._broadcast(state, providerName, data);
|
|
1262
1435
|
}
|
|
1263
1436
|
} catch {
|
|
1264
|
-
this._broadcast(state, data);
|
|
1437
|
+
this._broadcast(state, providerName, data);
|
|
1265
1438
|
}
|
|
1266
1439
|
}
|
|
1267
1440
|
|
|
1268
1441
|
/** Sends a message to all clients connected to one provider. */
|
|
1269
|
-
private _broadcast(state:
|
|
1442
|
+
private _broadcast(state: IProviderState, providerName: string, data: string): void {
|
|
1270
1443
|
for (const client of state.wsClients) {
|
|
1271
|
-
|
|
1444
|
+
const principal = this._clientPrincipals.get(client) ?? null;
|
|
1445
|
+
if (client.readyState === WebSocket.OPEN && this._authorizeMcpFrame(providerName, data, principal)) {
|
|
1446
|
+
client.send(data);
|
|
1447
|
+
}
|
|
1272
1448
|
}
|
|
1273
1449
|
for (const sseRes of state.sseSessions.values()) {
|
|
1274
|
-
this.
|
|
1450
|
+
const principal = this._streamPrincipals.get(sseRes) ?? null;
|
|
1451
|
+
if (this._authorizeMcpFrame(providerName, data, principal)) {
|
|
1452
|
+
this._sendSseEvent(sseRes, data);
|
|
1453
|
+
}
|
|
1275
1454
|
}
|
|
1276
|
-
for (const
|
|
1277
|
-
|
|
1455
|
+
for (const session of state.httpSessions.values()) {
|
|
1456
|
+
// The principal rides on the session here rather than in a WeakMap
|
|
1457
|
+
// keyed by the response: a Streamable HTTP session spans many HTTP
|
|
1458
|
+
// exchanges, so there is no single response to hang it on.
|
|
1459
|
+
if (this._authorizeMcpFrame(providerName, data, session.principal)) {
|
|
1460
|
+
session.transport.send(data);
|
|
1461
|
+
}
|
|
1278
1462
|
}
|
|
1279
1463
|
for (const ic of state.internalClients) {
|
|
1280
1464
|
ic.onMessage?.(data);
|
|
1281
1465
|
}
|
|
1282
1466
|
// Forward notifications to the stdio client if it is watching this provider.
|
|
1283
1467
|
if (this._stdioClientProvider && this._providers.get(this._stdioClientProvider) === state) {
|
|
1284
|
-
|
|
1468
|
+
this._stdioClientTransport?.send(data);
|
|
1285
1469
|
}
|
|
1286
1470
|
}
|
|
1287
1471
|
|
|
@@ -1290,21 +1474,25 @@ export class WsTunnel implements BrokerContext {
|
|
|
1290
1474
|
* has disconnected, then clears the pending map. Shared by all provider
|
|
1291
1475
|
* close handlers (dedicated WS, multiplexed WS, loopback).
|
|
1292
1476
|
*/
|
|
1293
|
-
private _failProviderDisconnected(state:
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1477
|
+
private _failProviderDisconnected(state: IProviderState, name: string): void {
|
|
1478
|
+
// Echoing the pending id rather than `null`: a Streamable HTTP session
|
|
1479
|
+
// matches the answer to its held-open POST by id, so an unaddressed
|
|
1480
|
+
// error would leave that request hanging until it times out.
|
|
1481
|
+
for (const [id, sink] of state.pending) {
|
|
1482
|
+
const error = JSON.stringify({
|
|
1483
|
+
jsonrpc: "2.0",
|
|
1484
|
+
id,
|
|
1485
|
+
error: { code: -32000, message: `Provider "${name}" disconnected` },
|
|
1486
|
+
});
|
|
1300
1487
|
if (sink.type === "ws" && sink.socket.readyState === WebSocket.OPEN) {
|
|
1301
1488
|
sink.socket.send(error);
|
|
1302
1489
|
} else if (sink.type === "sse") {
|
|
1303
1490
|
const sseRes = state.sseSessions.get(sink.sessionId);
|
|
1304
1491
|
if (sseRes) this._sendSseEvent(sseRes, error);
|
|
1305
|
-
} else if (sink.type === "http") {
|
|
1306
|
-
sink.
|
|
1307
|
-
|
|
1492
|
+
} else if (sink.type === "http-session") {
|
|
1493
|
+
state.httpSessions.get(sink.sessionId)?.transport.send(error);
|
|
1494
|
+
} else if (sink.type === "stdio") {
|
|
1495
|
+
this._stdioClientTransport?.send(error);
|
|
1308
1496
|
} else if (sink.type === "internal") {
|
|
1309
1497
|
sink.client.onMessage?.(error);
|
|
1310
1498
|
}
|
|
@@ -1318,10 +1506,10 @@ export class WsTunnel implements BrokerContext {
|
|
|
1318
1506
|
// -------------------------------------------------------------------------
|
|
1319
1507
|
|
|
1320
1508
|
/**
|
|
1321
|
-
* Returns `true` if the provider is reachable
|
|
1509
|
+
* Returns `true` if the provider is reachable, via a WebSocket connection,
|
|
1322
1510
|
* a stdio upstream, or an in-process loopback transport.
|
|
1323
1511
|
*/
|
|
1324
|
-
private _isProviderConnected(providerName: string, state:
|
|
1512
|
+
private _isProviderConnected(providerName: string, state: IProviderState): boolean {
|
|
1325
1513
|
if (this._upstreams.get(providerName)?.isOpen) return true;
|
|
1326
1514
|
if (this._loopbackProviders.get(providerName)?.isOpen) return true;
|
|
1327
1515
|
if (state.ws?.readyState === WebSocket.OPEN) return true;
|
|
@@ -1329,16 +1517,17 @@ export class WsTunnel implements BrokerContext {
|
|
|
1329
1517
|
}
|
|
1330
1518
|
|
|
1331
1519
|
/** Returns the state for `name`, creating it lazily if it doesn't exist yet. */
|
|
1332
|
-
private _getOrCreateProviderState(name: string):
|
|
1520
|
+
private _getOrCreateProviderState(name: string): IProviderState {
|
|
1333
1521
|
let state = this._providers.get(name);
|
|
1334
1522
|
if (!state) {
|
|
1335
1523
|
state = {
|
|
1336
1524
|
ws: null,
|
|
1337
1525
|
pending: new Map(),
|
|
1338
1526
|
sseSessions: new Map(),
|
|
1339
|
-
|
|
1527
|
+
httpSessions: new Map(),
|
|
1340
1528
|
wsClients: new Set(),
|
|
1341
1529
|
internalClients: new Set(),
|
|
1530
|
+
httpEndpoint: null,
|
|
1342
1531
|
};
|
|
1343
1532
|
this._providers.set(name, state);
|
|
1344
1533
|
}
|