@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.
Files changed (151) hide show
  1. package/.mcp-broker.example/CONFIGURATION-EN.md +861 -0
  2. package/.mcp-broker.example/CONFIGURATION-FR.md +871 -0
  3. package/.mcp-broker.example/README.md +8 -3
  4. package/.mcp-broker.example/config.json +86 -0
  5. package/README.md +122 -3
  6. package/dist/bin.d.ts +0 -1
  7. package/dist/bin.js +180 -208
  8. package/dist/bin.js.map +1 -1
  9. package/dist/chunk-FTDKH2C4.js +3670 -0
  10. package/dist/chunk-FTDKH2C4.js.map +1 -0
  11. package/dist/{broker/grammars → grammars}/claude/en.json +1 -1
  12. package/dist/{broker/grammars → grammars}/claude/fr.json +1 -1
  13. package/dist/index.d.ts +1790 -18
  14. package/dist/index.js +2 -14
  15. package/dist/index.js.map +1 -1
  16. package/package.json +14 -8
  17. package/scripts/copy-assets.mjs +16 -10
  18. package/scripts/gen-cert.mjs +5 -5
  19. package/scripts/pack-mcpb.mjs +5 -5
  20. package/scripts/sign-bundle.mjs +6 -6
  21. package/src/auth/auth.config.ts +98 -0
  22. package/src/auth/auth.types.ts +120 -0
  23. package/src/auth/http.auth.ts +128 -0
  24. package/src/auth/index.ts +34 -0
  25. package/src/auth/jwt.validator.ts +63 -0
  26. package/src/auth/provider.auth.ts +114 -0
  27. package/src/auth/resource.metadata.ts +34 -0
  28. package/src/authorization/audit.ts +35 -0
  29. package/src/authorization/capability.classifier.ts +114 -0
  30. package/src/authorization/index.ts +37 -0
  31. package/src/authorization/policy.engine.ts +212 -0
  32. package/src/authorization/policy.types.ts +105 -0
  33. package/src/authorization/resource.path.ts +126 -0
  34. package/src/authorization/runtime.ts +56 -0
  35. package/src/authorization/slot.resource.ts +50 -0
  36. package/src/authorization/subject.mapper.ts +81 -0
  37. package/src/bin.ts +90 -7
  38. package/src/broker/adapters/broker.adapter.info.ts +3 -3
  39. package/src/broker/adapters/broker.adapter.providers.ts +3 -3
  40. package/src/broker/aggregate/aggregate.catalog.ts +49 -21
  41. package/src/broker/aggregate/aggregate.server.ts +149 -19
  42. package/src/broker/aggregate/provider.client.session.ts +22 -22
  43. package/src/broker/behaviors/broker.behavior.info.ts +4 -4
  44. package/src/broker/behaviors/broker.behavior.providers.ts +6 -6
  45. package/src/broker/broker.context.ts +10 -4
  46. package/src/broker/broker.grammars.ts +16 -13
  47. package/src/broker/broker.server.ts +12 -9
  48. package/src/broker/grammars/claude/en.json +1 -1
  49. package/src/broker/grammars/claude/fr.json +1 -1
  50. package/src/broker/index.ts +9 -9
  51. package/src/config.ts +81 -8
  52. package/src/index.ts +121 -20
  53. package/src/{mcpb.loader.ts → mcpb/mcpb.loader.ts} +24 -21
  54. package/src/{mcpb.unzip.ts → mcpb/mcpb.unzip.ts} +2 -2
  55. package/src/remote.transports.ts +11 -8
  56. package/src/remote.upstream.ts +11 -8
  57. package/src/stdio.upstream.ts +9 -6
  58. package/src/upstream.ts +6 -3
  59. package/src/ws/ws.interfaces.ts +357 -0
  60. package/src/{ws.tunnel.builder.ts → ws/ws.tunnel.builder.ts} +112 -9
  61. package/src/{ws.tunnel.ts → ws/ws.tunnel.ts} +646 -457
  62. package/web/README.md +94 -0
  63. package/web/assets/logo.png +0 -0
  64. package/web/broker-self-mcp.html +338 -0
  65. package/web/css/styles.css +580 -0
  66. package/web/demos/DemoPlaceholder.html +258 -0
  67. package/web/demos/broker-explorer/css/app.css +393 -0
  68. package/web/demos/broker-explorer/index.html +94 -0
  69. package/web/demos/broker-explorer/js/app.js +271 -0
  70. package/web/demos/broker-explorer/js/mcp-ws-client.js +132 -0
  71. package/web/demos/oauth-lab/README.md +94 -0
  72. package/web/demos/oauth-lab/config.json +117 -0
  73. package/web/demos/oauth-lab/css/app.css +1097 -0
  74. package/web/demos/oauth-lab/index.html +323 -0
  75. package/web/demos/oauth-lab/js/app.js +654 -0
  76. package/web/demos/oauth-lab/server/auth-server.mjs +426 -0
  77. package/web/demos/oauth-lab/server/factory-provider.mjs +269 -0
  78. package/web/demos/oauth-lab/server/smoke-test.mjs +308 -0
  79. package/web/demos/oauth-lab/server/start.mjs +106 -0
  80. package/web/demos/provider-tunnel/css/app.css +384 -0
  81. package/web/demos/provider-tunnel/index.html +99 -0
  82. package/web/demos/provider-tunnel/js/app.js +226 -0
  83. package/web/demos/provider-tunnel/js/toolbox-server.js +186 -0
  84. package/web/index.html +558 -0
  85. package/web/js/lib/broker-tunnel.js +173 -0
  86. package/dist/broker/adapters/broker.adapter.info.d.ts +0 -16
  87. package/dist/broker/adapters/broker.adapter.info.js +0 -43
  88. package/dist/broker/adapters/broker.adapter.info.js.map +0 -1
  89. package/dist/broker/adapters/broker.adapter.providers.d.ts +0 -18
  90. package/dist/broker/adapters/broker.adapter.providers.js +0 -61
  91. package/dist/broker/adapters/broker.adapter.providers.js.map +0 -1
  92. package/dist/broker/aggregate/aggregate.catalog.d.ts +0 -54
  93. package/dist/broker/aggregate/aggregate.catalog.js +0 -105
  94. package/dist/broker/aggregate/aggregate.catalog.js.map +0 -1
  95. package/dist/broker/aggregate/aggregate.server.d.ts +0 -47
  96. package/dist/broker/aggregate/aggregate.server.js +0 -151
  97. package/dist/broker/aggregate/aggregate.server.js.map +0 -1
  98. package/dist/broker/aggregate/provider.client.session.d.ts +0 -52
  99. package/dist/broker/aggregate/provider.client.session.js +0 -140
  100. package/dist/broker/aggregate/provider.client.session.js.map +0 -1
  101. package/dist/broker/behaviors/broker.behavior.info.d.ts +0 -15
  102. package/dist/broker/behaviors/broker.behavior.info.js +0 -41
  103. package/dist/broker/behaviors/broker.behavior.info.js.map +0 -1
  104. package/dist/broker/behaviors/broker.behavior.providers.d.ts +0 -19
  105. package/dist/broker/behaviors/broker.behavior.providers.js +0 -69
  106. package/dist/broker/behaviors/broker.behavior.providers.js.map +0 -1
  107. package/dist/broker/broker.context.d.ts +0 -59
  108. package/dist/broker/broker.context.js +0 -2
  109. package/dist/broker/broker.context.js.map +0 -1
  110. package/dist/broker/broker.grammars.d.ts +0 -130
  111. package/dist/broker/broker.grammars.js +0 -229
  112. package/dist/broker/broker.grammars.js.map +0 -1
  113. package/dist/broker/broker.server.d.ts +0 -66
  114. package/dist/broker/broker.server.js +0 -73
  115. package/dist/broker/broker.server.js.map +0 -1
  116. package/dist/broker/index.d.ts +0 -9
  117. package/dist/broker/index.js +0 -7
  118. package/dist/broker/index.js.map +0 -1
  119. package/dist/config.d.ts +0 -136
  120. package/dist/config.js +0 -61
  121. package/dist/config.js.map +0 -1
  122. package/dist/mcpb.loader.d.ts +0 -24
  123. package/dist/mcpb.loader.js +0 -161
  124. package/dist/mcpb.loader.js.map +0 -1
  125. package/dist/mcpb.unzip.d.ts +0 -6
  126. package/dist/mcpb.unzip.js +0 -95
  127. package/dist/mcpb.unzip.js.map +0 -1
  128. package/dist/remote.transports.d.ts +0 -16
  129. package/dist/remote.transports.js +0 -297
  130. package/dist/remote.transports.js.map +0 -1
  131. package/dist/remote.upstream.d.ts +0 -36
  132. package/dist/remote.upstream.js +0 -52
  133. package/dist/remote.upstream.js.map +0 -1
  134. package/dist/stdio.upstream.d.ts +0 -45
  135. package/dist/stdio.upstream.js +0 -85
  136. package/dist/stdio.upstream.js.map +0 -1
  137. package/dist/upstream.d.ts +0 -33
  138. package/dist/upstream.js +0 -2
  139. package/dist/upstream.js.map +0 -1
  140. package/dist/version.d.ts +0 -2
  141. package/dist/version.js +0 -9
  142. package/dist/version.js.map +0 -1
  143. package/dist/ws.tunnel.builder.d.ts +0 -139
  144. package/dist/ws.tunnel.builder.js +0 -205
  145. package/dist/ws.tunnel.builder.js.map +0 -1
  146. package/dist/ws.tunnel.d.ts +0 -373
  147. package/dist/ws.tunnel.js +0 -1090
  148. package/dist/ws.tunnel.js.map +0 -1
  149. /package/dist/{broker/grammars → grammars}/default/en.json +0 -0
  150. /package/dist/{broker/grammars → grammars}/default/fr.json +0 -0
  151. /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 { GrammarResolverOptions, IMessageTransport, IMcpServer } from "@cyanmycelium/mcp-core";
9
- import { StdioUpstream, type StdioUpstreamConfig } from "./stdio.upstream.js";
10
- import { RemoteUpstream, type RemoteUpstreamConfig } from "./remote.upstream.js";
11
- import type { Upstream } from "./upstream.js";
12
- import { startBrokerServer, BROKER_PROVIDER_NAME } from "./broker/index.js";
13
- import type { BrokerContext, BrokerProviderInfo, BrokerProviderTransport } from "./broker/index.js";
14
- import { AggregateServer } from "./broker/aggregate/aggregate.server.js";
15
- import { VERSION, PACKAGE_NAME } from "./version.js";
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
- // Internal types
63
+ // JSON-RPC helpers
38
64
  // ---------------------------------------------------------------------------
39
65
 
40
66
  /**
41
- * Where a JSON-RPC response should be delivered.
42
- * Either a WebSocket socket (raw WS client), an SSE session (legacy MCP/HTTP),
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
- * @example
82
- * { urlPrefix: "/", dir: "/absolute/path/to/www" }
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
- export interface StaticMount {
86
- /** URL prefix that triggers this mount (e.g. `"/"` or `"/bundle"`). */
87
- urlPrefix: string;
88
- /** Absolute path to the directory to serve. */
89
- dir: string;
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
- * In-process client handle for a provider slot — the symmetric counterpart of
94
- * {@link WsTunnel.registerLoopbackProvider}. Lets a component inside the broker
95
- * process (e.g. the aggregate server) issue MCP requests to a provider slot and
96
- * receive both the responses and the provider's broadcast notifications,
97
- * without opening a real network connection.
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
- export interface InternalClient {
100
- /**
101
- * Sends a JSON-RPC message to the provider slot. When the message carries an
102
- * `id`, the matching response is delivered to {@link onMessage}. When the
103
- * provider is not connected, a JSON-RPC error is delivered synchronously.
104
- */
105
- send(message: string): void;
106
- /** Receives responses to this client's requests and the provider's notifications. */
107
- onMessage: ((data: string) => void) | null;
108
- /** Fires when the provider slot loses its connection. */
109
- onClose: (() => void) | null;
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
- * Configuration options for a {@link WsTunnel} instance.
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
- export interface WsTunnelOptions {
118
- /** TCP port to listen on. */
119
- port: number;
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 BrokerContext {
310
- private readonly _options: WsTunnelOptions;
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, ProviderState>();
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, Upstream>();
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 WsTunnelOptions.enableBrokerProvider} is on. */
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 WsTunnelOptions.enableAggregateProvider} is on. */
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
- /** Buffered partial line from stdin (stdio client transport). */
344
- private _stdioClientBuffer = "";
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
- constructor(options: WsTunnelOptions) {
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
- // BrokerContext implementation
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(): BrokerContext["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(): BrokerProviderInfo[] {
399
- const out: BrokerProviderInfo[] = [];
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): BrokerProviderInfo | undefined {
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: ProviderState): BrokerProviderInfo {
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.mcpGetSessions.size,
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 — `send` returns a
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): InternalClient {
348
+ public openInternalClient(providerName: string): IInternalClient {
479
349
  const state = this._getOrCreateProviderState(providerName);
480
350
  let closed = false;
481
351
 
482
- const client: InternalClient = {
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.mcpGetSessions.size;
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: Upstream): void => {
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
- process.stdin.setEncoding("utf8");
619
- process.stdin.on("data", (chunk: string) => {
620
- this._stdioClientBuffer += chunk;
621
- let nl: number;
622
- while ((nl = this._stdioClientBuffer.indexOf("\n")) !== -1) {
623
- const line = this._stdioClientBuffer.slice(0, nl).trim();
624
- this._stdioClientBuffer = this._stdioClientBuffer.slice(nl + 1);
625
- if (line) {
626
- const state = this._getOrCreateProviderState(this._stdioClientProvider!);
627
- this._routeFromStdioClient(state, line);
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 WsTunnelOptions.enableBrokerProvider}
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 WsTunnelOptions.enableAggregateProvider} is `false`.
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
- for (const res of state.mcpGetSessions.values()) res.end();
722
- state.mcpGetSessions.clear();
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 mcpSuffix = (this._options.mcpPath ?? "/mcp").replace(/^\//, "");
771
- const sseSuffix = (this._options.ssePath ?? "/sse").replace(/^\//, "");
772
- const messagesSuffix = (this._options.messagesPath ?? "/messages").replace(/^\//, "");
773
-
774
- if (endpoint === mcpSuffix) {
775
- if (method === "GET") {
776
- this._handleMcpGetStream(req, res, providerName);
777
- return;
778
- }
779
- if (method === "POST") {
780
- this._handleMcpPost(req, res, providerName);
781
- return;
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` — opens a long-lived SSE stream for Claude.
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=…` — receives a JSON-RPC
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 — forward anyway */
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
- * Handles `POST /<providerName>/mcp` — Streamable HTTP transport (MCP 2025-03-26).
904
- * Forwards the JSON-RPC request to the provider and holds the HTTP response
905
- * open until the reply arrives, then writes it as `application/json`.
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 _handleMcpPost(req: IncomingMessage, res: ServerResponse, providerName: string): void {
908
- let body = "";
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
- if (msg.id != null) {
937
- // Request: hold the response open; reply arrives in _routeFromProvider.
938
- state.pending.set(msg.id, { type: "http", res });
939
- } else {
940
- // Notification: forward and acknowledge immediately.
941
- res.writeHead(202);
942
- res.end();
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
- this._sendToProvider(state, providerName, body);
946
- });
1058
+ void this._endpointFor(providerName, state).handleRequest(req, res);
947
1059
  }
948
1060
 
949
1061
  /**
950
- * Handles `GET /<providerName>/mcp` — opens a persistent SSE stream per MCP 2025-03-26.
951
- * Streamable HTTP clients (e.g. MCP Inspector) use this to receive
952
- * server-initiated notifications without re-polling.
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 _handleMcpGetStream(req: IncomingMessage, res: ServerResponse, providerName: string): void {
955
- const sessionId = (req.headers["mcp-session-id"] as string | undefined) ?? randomUUID();
956
-
957
- res.writeHead(200, {
958
- "Content-Type": "text/event-stream",
959
- "Cache-Control": "no-cache, no-transform",
960
- Connection: "keep-alive",
961
- "Mcp-Session-Id": sessionId,
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
- const state = this._getOrCreateProviderState(providerName);
966
- state.mcpGetSessions.set(sessionId, res);
1099
+ return state.httpEndpoint;
1100
+ }
967
1101
 
968
- req.on("close", () => {
969
- state.mcpGetSessions.delete(sessionId);
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 — no need to parse+re-serialize.
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 _onProviderConnect(ws: WebSocket, name: string): void {
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 — a stdio upstream with the same name is already configured. ` +
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 — the slot is held by an in-process loopback (reserved system slot).`);
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 — is routed and leaves the provider
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 — and thus consumed, not
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
- let envelope: { provider?: string; payload?: unknown };
1075
- try {
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 — a stdio upstream with the same name is already configured. ` +
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 — reject this name.
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, JSON.stringify(envelope.payload));
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: ProviderState, providerName: string, data: string): void {
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
- const payload = JSON.parse(data) as unknown;
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: ProviderState, data: string): void {
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
- process.stdout.write(
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
- }) + "\n"
1377
+ })
1212
1378
  );
1213
1379
  }
1214
1380
  }
1215
1381
  }
1216
1382
 
1217
- private _routeFromClient(client: WebSocket, state: ProviderState, providerName: string, data: string): void {
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: ProviderState, data: string): void {
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
- sink.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
1252
- sink.res.end(data);
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
- process.stdout.write(data + "\n");
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: ProviderState, data: string): void {
1442
+ private _broadcast(state: IProviderState, providerName: string, data: string): void {
1270
1443
  for (const client of state.wsClients) {
1271
- if (client.readyState === WebSocket.OPEN) client.send(data);
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._sendSseEvent(sseRes, data);
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 mcpRes of state.mcpGetSessions.values()) {
1277
- this._sendSseEvent(mcpRes, data);
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
- process.stdout.write(data + "\n");
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: ProviderState, name: string): void {
1294
- const error = JSON.stringify({
1295
- jsonrpc: "2.0",
1296
- id: null,
1297
- error: { code: -32000, message: `Provider "${name}" disconnected` },
1298
- });
1299
- for (const sink of state.pending.values()) {
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.res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
1307
- sink.res.end(error);
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 — via a WebSocket connection,
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: ProviderState): boolean {
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): ProviderState {
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
- mcpGetSessions: new Map(),
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
  }