@cyanmycelium/mcp-broker 1.2.0 → 1.3.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 (49) hide show
  1. package/.mcp-broker.example/CONFIGURATION-EN.md +300 -44
  2. package/.mcp-broker.example/CONFIGURATION-FR.md +313 -44
  3. package/.mcp-broker.example/README.md +73 -2
  4. package/.mcp-broker.example/config.json +18 -9
  5. package/.mcp-broker.example/config.stdio-bridge.json +16 -0
  6. package/README.md +407 -27
  7. package/dist/bin.js +215 -20
  8. package/dist/bin.js.map +1 -1
  9. package/dist/chunk-BZUZYXVA.js +5955 -0
  10. package/dist/chunk-BZUZYXVA.js.map +1 -0
  11. package/dist/grammars/claude/en.json +12 -0
  12. package/dist/grammars/claude/fr.json +12 -0
  13. package/dist/grammars/default/en.json +40 -0
  14. package/dist/grammars/default/fr.json +40 -0
  15. package/dist/grammars/default/zh.json +40 -0
  16. package/dist/index.d.ts +991 -25
  17. package/dist/index.js +1 -1
  18. package/package.json +3 -3
  19. package/src/auth/index.ts +3 -1
  20. package/src/auth/provider.auth.ts +126 -8
  21. package/src/authorization/policy.engine.ts +11 -2
  22. package/src/authorization/policy.types.ts +25 -1
  23. package/src/bin.ts +325 -28
  24. package/src/broker/adapters/broker.adapter.diagnose.ts +45 -0
  25. package/src/broker/adapters/broker.adapter.guide.ts +108 -0
  26. package/src/broker/aggregate/aggregate.server.ts +82 -15
  27. package/src/broker/aggregate/provider.client.session.ts +85 -11
  28. package/src/broker/behaviors/broker.behavior.diagnose.ts +47 -0
  29. package/src/broker/behaviors/broker.behavior.guide.ts +79 -0
  30. package/src/broker/broker.context.ts +65 -0
  31. package/src/broker/broker.diagnostics.ts +495 -0
  32. package/src/broker/broker.guides.ts +1029 -0
  33. package/src/broker/broker.server.ts +23 -7
  34. package/src/broker/broker.slots.ts +36 -0
  35. package/src/broker/grammars/claude/en.json +12 -0
  36. package/src/broker/grammars/claude/fr.json +12 -0
  37. package/src/broker/grammars/default/en.json +40 -0
  38. package/src/broker/grammars/default/fr.json +40 -0
  39. package/src/broker/grammars/default/zh.json +40 -0
  40. package/src/config.ts +191 -4
  41. package/src/index.ts +38 -3
  42. package/src/remote.transports.ts +127 -10
  43. package/src/remote.upstream.ts +4 -1
  44. package/src/ws/ws.interfaces.ts +148 -3
  45. package/src/ws/ws.tunnel.builder.ts +63 -1
  46. package/src/ws/ws.tunnel.ts +1150 -173
  47. package/web/README.md +31 -4
  48. package/dist/chunk-FTDKH2C4.js +0 -3670
  49. package/dist/chunk-FTDKH2C4.js.map +0 -1
package/src/bin.ts CHANGED
@@ -34,12 +34,15 @@
34
34
  * | MCP_BROKER_CONFIG | (none) | Path to a JSON config file (see above). |
35
35
  * | MCP_BROKER_PORT | 3000 | |
36
36
  * | MCP_BROKER_HOST | 0.0.0.0 | |
37
- * | MCP_BROKER_PROVIDER_PATH | /provider| Prefix for provider WS |
37
+ * | MCP_BROKER_PROVIDER_PATH | /provider| Prefix for one-slot-per-socket providers (plain frames)|
38
+ * | MCP_BROKER_PROVIDERS_PATH |/providers| Exact path for multiplexed providers (envelope frames) |
38
39
  * | MCP_BROKER_CLIENT_PATH | / | Prefix for raw WS clients |
39
40
  * | MCP_BROKER_MCP_PATH | /mcp | Suffix for Streamable HTTP transport |
41
+ * | MCP_BROKER_SSE_PATH | /sse | Suffix for the legacy SSE stream (GET) |
42
+ * | MCP_BROKER_MESSAGES_PATH | /messages| Suffix for legacy SSE JSON-RPC posts |
40
43
  * | MCP_BROKER_WWW_DIR | (none) | Ergonomic shortcut: mount this directory at "/" |
41
44
  * | MCP_BROKER_BUNDLE_DIR | (none) | Ergonomic shortcut: mount this directory at "/bundle" |
42
- * | MCP_BROKER_OPEN | (unset) | "1" to auto-launch the default browser at the root URL |
45
+ * | MCP_BROKER_OPEN | (unset) | "1" for the root URL, or a path/same-origin URL to open|
43
46
  * | MCP_BROKER_TLS_CERT | (none) | Path to a PEM TLS certificate |
44
47
  * | MCP_BROKER_TLS_KEY | (none) | Path to a PEM private key |
45
48
  * | MCP_BROKER_PROTOCOL | auto | "http" to force plain, "https" to force TLS, otherwise |
@@ -48,16 +51,99 @@
48
51
  * | | | provider (Claude Desktop bridge). |
49
52
  * | MCP_BROKER_LOCALE | en | Locale used for tool descriptions on the `_broker` |
50
53
  * | | | slot. ISO 639-1 base; regional variants accepted. |
51
- * | MCP_BROKER_ALLOWED_ORIGINS | (none) | Comma-separated browser origins allowed on |
52
- * | | | `/<slot>/mcp`. None means no browser origin passes. |
54
+ * | MCP_BROKER_ALLOWED_ORIGINS | (none) | Comma-separated browser origins allowed on the client |
55
+ * | | | HTTP surface (`/<slot>/mcp`, `/sse`, `/messages`). |
56
+ * | | | None means no browser origin passes. |
57
+ * | MCP_BROKER_PROVIDER_HEARTBEAT_MS | 30000 | Provider ws ping interval; 0 disables liveness. |
58
+ * | MCP_BROKER_PROVIDER_REQUEST_TIMEOUT_MS| 60000 | Deadline for one provider answer; 0 disables. |
59
+ * | MCP_BROKER_PROVIDER_TAKEOVER | liveness | "reject" | "liveness" | "always" on slot contention |
53
60
  */
54
61
  import * as fs from "fs";
55
62
  import * as path from "path";
56
63
  import open from "open";
57
- import { WsTunnelBuilder } from "./index";
58
- import { loadBrokerConfig } from "./config";
64
+ import { WsTunnelBuilder, VERSION, PACKAGE_NAME, BROKER_PROVIDER_NAME, BROKER_AGGREGATE_NAME, type ProviderTakeoverMode } from "./index";
65
+ import { loadBrokerConfig, resolveOpenTarget } from "./config";
59
66
  import { loadMcpbBundle } from "./mcpb/mcpb.loader";
60
67
 
68
+ // ---------------------------------------------------------------------------
69
+ // CLI flags
70
+ // ---------------------------------------------------------------------------
71
+ // Handled before ANY other work (config load, logging, server start) so that
72
+ // `--help` / `--version` never boot a server or bind a port.
73
+
74
+ const argv = process.argv.slice(2);
75
+
76
+ if (argv.includes("--help") || argv.includes("-h")) {
77
+ printHelp();
78
+ process.exit(0);
79
+ }
80
+ if (argv.includes("--version") || argv.includes("-v")) {
81
+ process.stdout.write(`${PACKAGE_NAME} ${VERSION}\n`);
82
+ process.exit(0);
83
+ }
84
+
85
+ function printHelp(): void {
86
+ process.stdout.write(
87
+ `\n${PACKAGE_NAME} ${VERSION}\n` +
88
+ `WebSocket-based Model Context Protocol broker.\n\n` +
89
+ `USAGE\n` +
90
+ ` npx @cyanmycelium/mcp-broker Start the broker (foreground)\n` +
91
+ ` npx @cyanmycelium/mcp-broker --help Show this help\n` +
92
+ ` npx @cyanmycelium/mcp-broker --version Print the version\n\n` +
93
+ `The broker takes no positional arguments. Configure it with a\n` +
94
+ `.mcp-broker/config.json file or MCP_BROKER_* environment variables\n` +
95
+ `(env vars win over the file).\n\n` +
96
+ `COMMON ENVIRONMENT VARIABLES\n` +
97
+ ` MCP_BROKER_CONFIG Path to a JSON config file\n` +
98
+ ` MCP_BROKER_PORT TCP port (default 3000)\n` +
99
+ ` MCP_BROKER_HOST Bind interface (default 0.0.0.0)\n` +
100
+ ` MCP_BROKER_PROTOCOL "http" | "https" (default: auto from TLS)\n` +
101
+ ` MCP_BROKER_TLS_CERT PEM certificate path (enables HTTPS/WSS)\n` +
102
+ ` MCP_BROKER_TLS_KEY PEM private key path\n` +
103
+ ` MCP_BROKER_STDIO_PROVIDER Bridge stdin/stdout to this provider ("_all" is the safe target)\n` +
104
+ ` MCP_BROKER_LOCALE Locale for _broker tool descriptions (default en)\n` +
105
+ ` MCP_BROKER_ALLOWED_ORIGINS Comma-separated browser origins allowed on /<slot>/mcp,\n` +
106
+ ` /<slot>/sse and /<slot>/messages (default: none pass)\n` +
107
+ ` MCP_BROKER_WWW_DIR Serve this directory at "/"\n` +
108
+ ` MCP_BROKER_BUNDLE_DIR Serve this directory at "/bundle"\n` +
109
+ ` MCP_BROKER_OPEN "1" to open the root in a browser, or a path such as /app/\n\n` +
110
+ `ENDPOINT PATHS (change these only if every peer agrees)\n` +
111
+ ` MCP_BROKER_PROVIDER_PATH Prefix for one provider per socket (default /provider);\n` +
112
+ ` connect as <prefix>/<slot> with DirectTransport (plain frames)\n` +
113
+ ` MCP_BROKER_PROVIDERS_PATH Exact path for many providers on one socket (default /providers);\n` +
114
+ ` connect with MultiplexTransport (envelope frames)\n` +
115
+ ` MCP_BROKER_CLIENT_PATH Prefix for raw WebSocket clients (default /)\n` +
116
+ ` MCP_BROKER_MCP_PATH Streamable HTTP suffix (default /mcp)\n` +
117
+ ` MCP_BROKER_SSE_PATH Legacy SSE stream suffix (default /sse)\n` +
118
+ ` MCP_BROKER_MESSAGES_PATH Legacy SSE post suffix (default /messages)\n\n` +
119
+ `PROVIDER LIVENESS\n` +
120
+ ` MCP_BROKER_PROVIDER_HEARTBEAT_MS Ping interval per provider socket (default 30000, 0 off)\n` +
121
+ ` MCP_BROKER_PROVIDER_REQUEST_TIMEOUT_MS Deadline for one provider answer (default 60000, 0 off)\n` +
122
+ ` MCP_BROKER_PROVIDER_TAKEOVER reject | liveness | always (default liveness)\n\n` +
123
+ `AUTHORIZATION (OAuth 2.1, opt-in)\n` +
124
+ ` MCP_BROKER_AUTH_ENABLED "1" to require bearer tokens on client endpoints\n` +
125
+ ` MCP_BROKER_PUBLIC_BASE_URL Public origin, e.g. https://mcp.example.com\n` +
126
+ ` MCP_BROKER_JWKS Authorization server JWKS URL\n` +
127
+ ` MCP_BROKER_ISSUER Expected token issuer\n` +
128
+ ` MCP_BROKER_PROVIDER_SECRET Shared secret required from providers\n\n` +
129
+ `CONFIG-FILE ONLY (no env var; .mcp-broker/config.json)\n` +
130
+ ` stdioUpstreams[] Local MCP servers the broker spawns. NOT in _all unless "aggregate": true\n` +
131
+ ` mcpServers[] Remote MCP servers the broker dials out to. In _all unless "aggregate": false\n` +
132
+ ` mcpbBundles[] Signed .mcpb bundles, verified then spawned. In _all unless "aggregate": false\n` +
133
+ ` www.mounts[] Several urlPrefix -> dir mappings (the env vars above mount one each)\n` +
134
+ ` allowedOrigins Also accepts { "pattern": "<regexp>" }; the env var takes a list only\n` +
135
+ ` auth.* Roles, assignments, denies, per-slot and per-provider scopes\n` +
136
+ ` brokerName Library-only: no builder setter exists, so the CLI cannot forward it\n\n` +
137
+ `RESERVED SLOTS (always present, no provider needed)\n` +
138
+ ` _broker Introspection: broker_info, providers_list, provider_status,\n` +
139
+ ` broker_guide (integration docs), broker_diagnose (live problem report)\n` +
140
+ ` _all Live aggregate of every opted-in provider. The right target for\n` +
141
+ ` MCP_BROKER_STDIO_PROVIDER, since it exists before any provider connects.\n\n` +
142
+ `Start here: call broker_guide on the _broker slot for the integration guide.\n` +
143
+ `Full reference: https://github.com/pandaGaume/mcp-broker/tree/main/node\n\n`
144
+ );
145
+ }
146
+
61
147
  // ---------------------------------------------------------------------------
62
148
  // Configuration
63
149
  // ---------------------------------------------------------------------------
@@ -83,9 +169,18 @@ envFromConfig("MCP_BROKER_PROTOCOL", config.protocol);
83
169
  envFromConfig("MCP_BROKER_LOCALE", config.locale);
84
170
  envFromConfig("MCP_BROKER_STDIO_PROVIDER", config.stdioProvider);
85
171
  envFromConfig("MCP_BROKER_PROVIDER_PATH", config.paths?.provider);
172
+ envFromConfig("MCP_BROKER_PROVIDERS_PATH", config.paths?.providers);
86
173
  envFromConfig("MCP_BROKER_CLIENT_PATH", config.paths?.client);
87
174
  envFromConfig("MCP_BROKER_MCP_PATH", config.paths?.mcp);
88
- envFromConfig("MCP_BROKER_OPEN", config.www?.open === true ? "1" : undefined);
175
+ envFromConfig("MCP_BROKER_SSE_PATH", config.paths?.sse);
176
+ envFromConfig("MCP_BROKER_MESSAGES_PATH", config.paths?.messages);
177
+ envFromConfig("MCP_BROKER_PROVIDER_HEARTBEAT_MS", config.providerHeartbeatIntervalMs);
178
+ envFromConfig("MCP_BROKER_PROVIDER_REQUEST_TIMEOUT_MS", config.providerRequestTimeoutMs);
179
+ envFromConfig("MCP_BROKER_PROVIDER_TAKEOVER", config.providerTakeover);
180
+ // `www.open` is `boolean | string`: `true` means the root, a string is a path
181
+ // or a same-origin URL. Both travel as the env string and are resolved once,
182
+ // by `resolveOpenTarget`, so the file and the env var cannot diverge.
183
+ envFromConfig("MCP_BROKER_OPEN", config.www?.open === true ? "1" : typeof config.www?.open === "string" ? config.www.open : undefined);
89
184
  envFromConfig("MCP_BROKER_AUTH_ENABLED", config.auth?.enabled === true ? "1" : undefined);
90
185
  envFromConfig("MCP_BROKER_PUBLIC_BASE_URL", config.auth?.publicBaseUrl);
91
186
  envFromConfig("MCP_BROKER_JWKS", config.auth?.jwks);
@@ -105,10 +200,17 @@ if (stdioProvider) {
105
200
 
106
201
  const port = parseInt(process.env["MCP_BROKER_PORT"] ?? "3000", 10);
107
202
  const host = process.env["MCP_BROKER_HOST"];
203
+ // ── Endpoint paths ───────────────────────────────────────────────────────────
204
+ // All six resolve the same way (env, then config file, then the default) and all
205
+ // six are handed to the builder. Before this, `paths.providers` and
206
+ // `paths.messages` were read nowhere and `paths.sse` reached only the banner, so
207
+ // setting one advertised an endpoint the router did not serve.
108
208
  const providerPath = process.env["MCP_BROKER_PROVIDER_PATH"] ?? "/provider";
209
+ const providersPath = process.env["MCP_BROKER_PROVIDERS_PATH"] ?? "/providers";
109
210
  const clientPath = process.env["MCP_BROKER_CLIENT_PATH"] ?? "/";
110
211
  const mcpPath = process.env["MCP_BROKER_MCP_PATH"] ?? "/mcp";
111
- const ssePath = config.paths?.sse ?? "/sse";
212
+ const ssePath = process.env["MCP_BROKER_SSE_PATH"] ?? "/sse";
213
+ const messagesPath = process.env["MCP_BROKER_MESSAGES_PATH"] ?? "/messages";
112
214
 
113
215
  // ── TLS material ─────────────────────────────────────────────────────────────
114
216
  // Env var (relative to cwd) wins over config (relative to baseDir).
@@ -127,7 +229,41 @@ if (protocolOverride === "https" && (!tlsCertPath || !tlsKeyPath)) {
127
229
 
128
230
  const useTls = protocolOverride === "http" ? false : protocolOverride === "https" ? true : !!(tlsCertPath && tlsKeyPath);
129
231
 
130
- // ── Allowed browser origins for /<slot>/mcp ──────────────────────────────────
232
+ // ── Provider liveness knobs ──────────────────────────────────────────────────
233
+ // The defaults are correct for nearly everyone; these exist for the operator who
234
+ // has to tune them (long-running tools, a flaky link). An unusable value is
235
+ // reported and ignored rather than silently coerced, so nobody believes a
236
+ // setting is in force when it is not.
237
+
238
+ /**
239
+ * Parses a millisecond count from an env var. Returns `undefined` (keep the
240
+ * built-in default) when unset or unusable, after saying why.
241
+ */
242
+ function millisFromEnv(envName: string): number | undefined {
243
+ const raw = process.env[envName];
244
+ if (raw === undefined || raw.trim() === "") return undefined;
245
+ const value = Number(raw);
246
+ if (!Number.isFinite(value) || value < 0) {
247
+ console.warn(`[mcp-broker] Ignoring ${envName}="${raw}": expected a whole number of milliseconds (0 disables). Using the default.`);
248
+ return undefined;
249
+ }
250
+ return Math.floor(value);
251
+ }
252
+
253
+ const providerHeartbeatIntervalMs = millisFromEnv("MCP_BROKER_PROVIDER_HEARTBEAT_MS");
254
+ const providerRequestTimeoutMs = millisFromEnv("MCP_BROKER_PROVIDER_REQUEST_TIMEOUT_MS");
255
+
256
+ const takeoverRaw = process.env["MCP_BROKER_PROVIDER_TAKEOVER"]?.trim().toLowerCase();
257
+ let providerTakeover: ProviderTakeoverMode | undefined;
258
+ if (takeoverRaw) {
259
+ if (takeoverRaw === "reject" || takeoverRaw === "liveness" || takeoverRaw === "always") {
260
+ providerTakeover = takeoverRaw;
261
+ } else {
262
+ console.warn(`[mcp-broker] Ignoring MCP_BROKER_PROVIDER_TAKEOVER="${takeoverRaw}": expected "reject", "liveness" or "always". Using "liveness".`);
263
+ }
264
+ }
265
+
266
+ // ── Allowed browser origins for the client HTTP surface ──────────────────────
131
267
  // Absent leaves the endpoint closed to browsers, which is the safe default.
132
268
  // The env var only carries the list form; a pattern needs the config file,
133
269
  // since a regular expression cannot survive comma-splitting.
@@ -151,10 +287,57 @@ if (envOrigins?.length) {
151
287
  }
152
288
  }
153
289
 
290
+ /**
291
+ * One-line rendering of the resolved origin policy for the startup banner.
292
+ *
293
+ * Worth a banner row because the closed default is invisible otherwise: a page
294
+ * served by this very broker still gets a 403 on `/<slot>/mcp` unless its origin
295
+ * is listed, and nothing else says so until the request fails.
296
+ */
297
+ function describeAllowedOrigins(): string {
298
+ if (allowedOrigins instanceof RegExp) return `pattern ${String(allowedOrigins)}`;
299
+ if (allowedOrigins && allowedOrigins.length > 0) return allowedOrigins.join(", ");
300
+ return "none (set MCP_BROKER_ALLOWED_ORIGINS, or allowedOrigins in the config file). Requests carrying no Origin header (Claude Desktop, MCP Inspector, any server-side SDK) still pass";
301
+ }
302
+
154
303
  // ── Env-var static mount shortcuts (relative to cwd) ─────────────────────────
155
304
  const envWwwDir = process.env["MCP_BROKER_WWW_DIR"] ? path.resolve(cwd, process.env["MCP_BROKER_WWW_DIR"]) : null;
156
305
  const envBundleDir = process.env["MCP_BROKER_BUNDLE_DIR"] ? path.resolve(cwd, process.env["MCP_BROKER_BUNDLE_DIR"]) : null;
157
306
 
307
+ /**
308
+ * Warns when the stdio bridge is pinned to a slot this broker does not host.
309
+ *
310
+ * The bridge gates every frame, `initialize` included, on the slot being
311
+ * occupied, so an MCP host that starts before the slot is claimed fails the
312
+ * handshake and gives up: with a browser-hosted provider, which cannot be
313
+ * connected before the host launches, this fails every single time. Naming the
314
+ * slots the broker does have turns that into a one-line fix.
315
+ *
316
+ * Deliberately a warning, not an exit: a WebSocket provider may legitimately
317
+ * claim the slot later, and a broker that refuses to start would be worse.
318
+ */
319
+ function warnIfStdioTargetUnknown(target: string): void {
320
+ const known = new Set<string>([
321
+ BROKER_AGGREGATE_NAME,
322
+ BROKER_PROVIDER_NAME,
323
+ ...(config.stdioUpstreams ?? []).map((u) => u.name),
324
+ ...(config.mcpServers ?? []).map((s) => s.name),
325
+ ...(config.mcpbBundles ?? []).map((b) => b.name),
326
+ ]);
327
+ if (known.has(target)) return;
328
+
329
+ const hosted = [...known].filter((name) => name !== BROKER_AGGREGATE_NAME && name !== BROKER_PROVIDER_NAME);
330
+ console.warn(
331
+ `[mcp-broker] MCP_BROKER_STDIO_PROVIDER="${target}" names a slot this broker does not host. ` +
332
+ `Nothing answers on it until a WebSocket provider connects to ${providerPath}/${target} (or announces "${target}" on ${providersPath}), ` +
333
+ `and until then every request from the MCP host, including the initial handshake, fails with 'Provider "${target}" not connected'. ` +
334
+ `An MCP host that starts before that provider is up, which is always the case for a provider hosted in a browser page, will therefore never connect. ` +
335
+ `Prefer MCP_BROKER_STDIO_PROVIDER="${BROKER_AGGREGATE_NAME}": it exists from startup, answers the handshake itself, unions every opted-in provider, ` +
336
+ `and pushes notifications/tools/list_changed as providers join, so a page opened later appears live. ` +
337
+ `Slots this broker hosts right now: ${hosted.length > 0 ? hosted.map((n) => `"${n}"`).join(", ") + `, plus the reserved "${BROKER_AGGREGATE_NAME}" and "${BROKER_PROVIDER_NAME}"` : `the reserved "${BROKER_AGGREGATE_NAME}" and "${BROKER_PROVIDER_NAME}" only`}.`
338
+ );
339
+ }
340
+
158
341
  // ---------------------------------------------------------------------------
159
342
  // Server bootstrap
160
343
  // ---------------------------------------------------------------------------
@@ -165,14 +348,46 @@ async function main(): Promise<void> {
165
348
  const localGrammarsDir = path.join(baseDir, "grammars");
166
349
  const hasLocalGrammars = fs.existsSync(localGrammarsDir);
167
350
 
168
- const builder = new WsTunnelBuilder().withPort(port).withProviderPath(providerPath).withClientPath(clientPath).withMcpPath(mcpPath);
351
+ const builder = new WsTunnelBuilder()
352
+ .withPort(port)
353
+ .withProviderPath(providerPath)
354
+ .withProvidersPath(providersPath)
355
+ .withClientPath(clientPath)
356
+ .withMcpPath(mcpPath)
357
+ .withSsePath(ssePath)
358
+ .withMessagesPath(messagesPath);
169
359
 
170
360
  if (host) {
171
361
  builder.withHost(host);
172
362
  }
173
363
 
364
+ if (providerHeartbeatIntervalMs !== undefined) {
365
+ builder.withProviderHeartbeat(providerHeartbeatIntervalMs);
366
+ }
367
+ if (providerRequestTimeoutMs !== undefined) {
368
+ builder.withProviderRequestTimeout(providerRequestTimeoutMs);
369
+ }
370
+ if (providerTakeover) {
371
+ builder.withProviderTakeover(providerTakeover);
372
+ }
373
+
174
374
  if (useTls) {
175
- builder.withTlsFiles(tlsCertPath!, tlsKeyPath!);
375
+ // `withTlsFiles` reads both files synchronously and would otherwise
376
+ // surface a bare ENOENT with no hint that TLS was on because a config
377
+ // file mentioned it. That is the first wall a copied config template
378
+ // hits, since the template ships `tls.cert`/`tls.key` but no `certs/`.
379
+ try {
380
+ builder.withTlsFiles(tlsCertPath!, tlsKeyPath!);
381
+ } catch (err) {
382
+ throw new Error(
383
+ `TLS is enabled but the material could not be read: ${(err as Error).message}. ` +
384
+ `Certificate: ${tlsCertPath}, key: ${tlsKeyPath}. ` +
385
+ `Config-file paths are resolved against the config file's own directory, env-var paths against the current directory. ` +
386
+ `Generate a development pair, point tls.cert/tls.key (or MCP_BROKER_TLS_CERT/MCP_BROKER_TLS_KEY) at real files, ` +
387
+ `or run without TLS by removing the tls block and setting MCP_BROKER_PROTOCOL=http.`,
388
+ { cause: err }
389
+ );
390
+ }
176
391
  }
177
392
 
178
393
  if (allowedOrigins) {
@@ -184,19 +399,29 @@ async function main(): Promise<void> {
184
399
  // (relative to baseDir). Order matters: /bundle before / so the prefix router
185
400
  // can distinguish them. Subsequent registrations contribute additional mounts;
186
401
  // longest-prefix match wins at runtime.
402
+ // Every prefix that actually got mounted, in registration order. The
403
+ // browser-open check below tests the resolved path against this list rather
404
+ // than against `config.www.mounts`, which would miss both env shortcuts and
405
+ // would false-warn on a perfectly valid `open: "/bundle/"`.
406
+ const mountedPrefixes: string[] = [];
407
+ const mount = (urlPrefix: string, dir: string): void => {
408
+ builder.withStaticMount(urlPrefix, dir);
409
+ mountedPrefixes.push(urlPrefix);
410
+ };
411
+
187
412
  if (envBundleDir && fs.existsSync(envBundleDir)) {
188
- builder.withStaticMount("/bundle", envBundleDir);
413
+ mount("/bundle", envBundleDir);
189
414
  }
190
415
  if (envWwwDir && fs.existsSync(envWwwDir)) {
191
- builder.withStaticMount("/", envWwwDir);
416
+ mount("/", envWwwDir);
192
417
  }
193
418
  if (config.www?.mounts) {
194
- for (const mount of config.www.mounts) {
195
- const abs = path.resolve(baseDir, mount.dir);
419
+ for (const entry of config.www.mounts) {
420
+ const abs = path.resolve(baseDir, entry.dir);
196
421
  if (fs.existsSync(abs)) {
197
- builder.withStaticMount(mount.urlPrefix, abs);
422
+ mount(entry.urlPrefix, abs);
198
423
  } else {
199
- console.warn(`[mcp-broker] www.mounts entry "${mount.urlPrefix}" → ${abs} skipped (directory not found).`);
424
+ console.warn(`[mcp-broker] www.mounts entry "${entry.urlPrefix}" → ${abs} skipped (directory not found).`);
200
425
  }
201
426
  }
202
427
  }
@@ -238,6 +463,7 @@ async function main(): Promise<void> {
238
463
 
239
464
  if (stdioProvider) {
240
465
  builder.withStdioClient(stdioProvider);
466
+ warnIfStdioTargetUnknown(stdioProvider);
241
467
  }
242
468
 
243
469
  if (hasLocalGrammars) {
@@ -300,30 +526,74 @@ async function main(): Promise<void> {
300
526
  const localhost = `${httpScheme}://localhost:${port}`;
301
527
  const mcpSuffix = mcpPath.replace(/^\//, "");
302
528
  const sseSuffix = ssePath.replace(/^\//, "");
303
- const hasWwwRoot = !!(envWwwDir && fs.existsSync(envWwwDir)) || (config.www?.mounts ?? []).some((m) => m.urlPrefix === "/");
529
+ const messagesSuffix = messagesPath.replace(/^\//, "");
304
530
 
531
+ // The two provider rows are the point of the banner: the endpoint chosen
532
+ // decides the framing, and connecting the wrong transport to either one is
533
+ // the single most common way an integration fails. So each row names the
534
+ // transport class it requires, right there.
305
535
  console.log();
306
536
  console.log(`⚙️ mcp-broker started${useTls ? " (TLS)" : ""}`);
307
537
  console.log(hr);
308
- console.log(`📡 Provider WebSocket ${wsScheme}://localhost:${port}${providerPath}/<name>`);
538
+ console.log(`📡 Provider WS direct ${wsScheme}://localhost:${port}${providerPath}/<name>`);
539
+ console.log(` one provider per socket, plain JSON-RPC frames: use DirectTransport`);
540
+ console.log(`🧵 Provider WS multiplex ${wsScheme}://localhost:${port}${providersPath}`);
541
+ console.log(` many providers per socket, envelope frames: use MultiplexTransport`);
309
542
  console.log(`🔌 MCP (Streamable HTTP) ${localhost}/<name>/${mcpSuffix}`);
310
- console.log(`📺 Legacy SSE ${localhost}/<name>/${sseSuffix}`);
543
+ console.log(`📺 Legacy SSE ${localhost}/<name>/${sseSuffix} + POST ${localhost}/<name>/${messagesSuffix}`);
544
+ // Raw WS clients are a real, configurable route. Left off the banner it read
545
+ // as provider-only, and `clientPath` reached the router and nothing else,
546
+ // which is the same defect `paths.sse` had.
547
+ console.log(`🔗 Client WS (raw) ${wsScheme}://localhost:${port}${clientPath === "/" ? "/" : clientPath}<name>`);
548
+ console.log(` bare JSON-RPC frames, no session layer; not origin-checked`);
549
+ console.log(`🧩 Reserved slots ${BROKER_AGGREGATE_NAME}: live aggregate of every opted-in provider`);
550
+ console.log(` ${BROKER_PROVIDER_NAME}: broker_info, providers_list, provider_status, broker_guide, broker_diagnose`);
551
+ if (mountedPrefixes.length > 0) {
552
+ console.log(`📁 Static mounts ${mountedPrefixes.map((prefix) => `${localhost}${prefix === "/" ? "/" : prefix + "/"}`).join(" ")}`);
553
+ }
311
554
  if (hasLocalGrammars) {
312
555
  console.log(`🌐 Local grammars ${localGrammarsDir}`);
313
556
  }
314
557
  console.log(`🔐 Authorization ${authEnabled ? "OAuth 2.1 (Bearer required)" : "disabled (trusted network only)"}`);
315
558
  console.log(`🛡️ Provider auth ${providerSecret ? "shared secret required" : "disabled"}`);
559
+ console.log(`🌍 Browser origins ${describeAllowedOrigins()}`);
560
+ console.log(` enforced on /<name>/${mcpSuffix}, /<name>/${sseSuffix} and /<name>/${messagesSuffix}`);
561
+ if (!allowedOrigins) {
562
+ console.log(` a page this broker serves is refused too, list its origin to admit it`);
563
+ }
316
564
  console.log(hr);
565
+ console.log(` New here? Call broker_guide on the ${BROKER_PROVIDER_NAME} slot: ${localhost}/${BROKER_PROVIDER_NAME}/${mcpSuffix}`);
317
566
  console.log(` Press Ctrl+C to stop.`);
318
567
  console.log();
319
568
 
320
- // Auto-launch browser only if explicitly opted in via MCP_BROKER_OPEN=1
321
- // AND a mount at "/" is configured.
322
- if (hasWwwRoot && process.env["MCP_BROKER_OPEN"] === "1") {
323
- const url = `${localhost}/`;
324
- console.log(`🚀 Opening browser: ${url}`);
325
- console.log();
326
- await open(url);
569
+ // ── Auto-launch the browser ─────────────────────────────────────────────
570
+ // Opt-in through MCP_BROKER_OPEN / www.open, and only onto a path a mount
571
+ // actually serves: launching a browser at a 404 is worse than not launching.
572
+ const openTarget = resolveOpenTarget(process.env["MCP_BROKER_OPEN"], localhost);
573
+ if (openTarget.error) {
574
+ console.warn(`[mcp-broker] ${openTarget.error}`);
575
+ } else if (openTarget.url && openTarget.path) {
576
+ const openUrl = openTarget.url;
577
+ const openPath = openTarget.path;
578
+ const covered = mountedPrefixes.some((prefix) => prefix === "/" || openPath === prefix || openPath.startsWith(prefix.endsWith("/") ? prefix : prefix + "/"));
579
+ if (!covered) {
580
+ console.warn(
581
+ `[mcp-broker] Not opening ${openUrl}: no static mount serves "${openPath}", so it would 404. ` +
582
+ (mountedPrefixes.length > 0
583
+ ? `Mounted prefixes: ${mountedPrefixes.map((p) => `"${p}"`).join(", ")}.`
584
+ : `No directory is mounted. Add one with MCP_BROKER_WWW_DIR=<dir> (serves it at "/") or a www.mounts entry in the config file.`)
585
+ );
586
+ } else {
587
+ console.log(`🚀 Opening browser: ${openUrl}`);
588
+ console.log();
589
+ try {
590
+ await open(openUrl);
591
+ } catch (err) {
592
+ // The broker is listening and healthy; failing to spawn a browser
593
+ // (a headless box, no default handler) must not take it down.
594
+ console.warn(`[mcp-broker] Could not launch a browser for ${openUrl}: ${(err as Error).message}. The broker is running; open the URL yourself.`);
595
+ }
596
+ }
327
597
  }
328
598
 
329
599
  // ── Signal handlers ─────────────────────────────────────────────────────
@@ -338,6 +608,33 @@ async function main(): Promise<void> {
338
608
  }
339
609
 
340
610
  main().catch((err: unknown) => {
341
- console.error("[mcp-broker] Fatal error:", err);
611
+ // `tunnel.start()` now rejects on a listen failure instead of dying as an
612
+ // uncaught EventEmitter error, so this path is live where it used to be
613
+ // unreachable, and the rejection carries a fully worded diagnosis (for
614
+ // EADDRINUSE: the address, the URL to attach to the broker that already
615
+ // holds the port, and the MCP_BROKER_PORT escape). Print the sentence, not
616
+ // the object: `console.error("…", err)` renders a stack dump and buries it.
617
+ //
618
+ // In stdio mode console is redirected to stderr above, so this cannot
619
+ // corrupt the JSON-RPC stream on stdout.
620
+ const message = err instanceof Error ? err.message : String(err);
621
+ console.error(`[mcp-broker] Cannot start: ${message}`);
622
+
623
+ // A wrapped cause keeps the original errno for anyone reading a host log.
624
+ const cause = err instanceof Error ? err.cause : undefined;
625
+ if (cause instanceof Error) {
626
+ const code = (cause as NodeJS.ErrnoException).code;
627
+ console.error(`[mcp-broker] Underlying error: ${code ? code + " " : ""}${cause.message}`);
628
+ }
629
+
630
+ // A bare Error with no diagnosis of its own is still worth grounding: say
631
+ // where the broker was trying to listen, since that is the one fact the
632
+ // reader needs to check next.
633
+ if (!(err instanceof Error) || !message.includes(String(port))) {
634
+ console.error(
635
+ `[mcp-broker] Configuration in effect: host=${host ?? "0.0.0.0"} port=${port}. Set MCP_BROKER_PORT / MCP_BROKER_HOST, or "port" / "host" in .mcp-broker/config.json.`
636
+ );
637
+ }
638
+
342
639
  process.exit(1);
343
640
  });
@@ -0,0 +1,45 @@
1
+ import { McpAdapterBase, McpToolResults } from "@cyanmycelium/mcp-core";
2
+ import type { McpResourceContent, McpToolResult } from "@cyanmycelium/mcp-core";
3
+ import { diagnoseBroker } from "../broker.diagnostics";
4
+ import type { IBrokerContext } from "../broker.context";
5
+
6
+ /**
7
+ * Runs the broker's self-diagnosis on demand.
8
+ *
9
+ * Deliberately tool-only, with no backing resource: the answer is a snapshot
10
+ * of live state, and a resource read can be served from the behavior's content
11
+ * cache, which would hand a caller a stale diagnosis at exactly the moment it
12
+ * matters. `broker://providers` is the resource for state you want cached;
13
+ * this is the one you want fresh.
14
+ */
15
+ export class BrokerDiagnoseAdapter extends McpAdapterBase {
16
+ constructor(private readonly _context: IBrokerContext) {
17
+ super("broker");
18
+ }
19
+
20
+ public async readResourceAsync(_uri: string): Promise<McpResourceContent | undefined> {
21
+ return undefined;
22
+ }
23
+
24
+ public async executeToolAsync(_uri: string, toolName: string, args: Record<string, unknown>): Promise<McpToolResult> {
25
+ if (toolName !== "broker_diagnose") {
26
+ return McpToolResults.error(`Unknown tool: ${toolName}`);
27
+ }
28
+
29
+ const raw = args["slot"];
30
+ if (raw !== undefined && raw !== null && typeof raw !== "string") {
31
+ return McpToolResults.error(`Invalid argument "slot": expected a string, got ${typeof raw}. Omit it to diagnose the whole broker.`);
32
+ }
33
+
34
+ const slot = typeof raw === "string" && raw !== "" ? raw : undefined;
35
+ const diagnosis = diagnoseBroker(this._context, slot);
36
+ if (!diagnosis) {
37
+ return McpToolResults.error(
38
+ `Unknown slot: "${slot}". The broker has never seen that name. ` +
39
+ `Call providers_list to see the slots that exist, and note that slot names are case-sensitive. ` +
40
+ `Omit the argument to diagnose the whole broker.`
41
+ );
42
+ }
43
+ return McpToolResults.json(diagnosis);
44
+ }
45
+ }
@@ -0,0 +1,108 @@
1
+ import { McpAdapterBase, McpToolResults } from "@cyanmycelium/mcp-core";
2
+ import type { McpResourceContent, McpToolResult } from "@cyanmycelium/mcp-core";
3
+ import { BROKER_GUIDES, BROKER_GUIDE_MIME_TYPE, BROKER_GUIDE_TOPICS, brokerGuide, brokerGuideIndex, brokerGuideTopicFromUri } from "../broker.guides";
4
+ import type { IBrokerContext } from "../broker.context";
5
+
6
+ /**
7
+ * Serves the broker's own integration documentation, so an agent that can
8
+ * reach this slot never has to find a README.
9
+ *
10
+ * The prose lives in `broker.guides.ts` and is static, which is what makes it
11
+ * safe to cache and to re-export to the markdown docs. What is NOT static is
12
+ * the deployment it describes: port, scheme and URL paths are per-instance.
13
+ * Rather than templating the guides, the adapter appends one short block of
14
+ * effective values to every page, so a reader never has to guess whether the
15
+ * defaults in the prose are the values in force here.
16
+ */
17
+ export class BrokerGuideAdapter extends McpAdapterBase {
18
+ constructor(private readonly _context: IBrokerContext) {
19
+ super("broker");
20
+ }
21
+
22
+ public async readResourceAsync(uri: string): Promise<McpResourceContent | undefined> {
23
+ const topic = brokerGuideTopicFromUri(uri);
24
+ if (!topic) return undefined;
25
+ const guide = brokerGuide(topic);
26
+ if (!guide) return undefined;
27
+ return {
28
+ uri: guide.uri,
29
+ mimeType: BROKER_GUIDE_MIME_TYPE,
30
+ text: this._render(guide.content),
31
+ };
32
+ }
33
+
34
+ public async executeToolAsync(_uri: string, toolName: string, args: Record<string, unknown>): Promise<McpToolResult> {
35
+ if (toolName !== "broker_guide") {
36
+ return McpToolResults.error(`Unknown tool: ${toolName}`);
37
+ }
38
+
39
+ const raw = args["topic"];
40
+ if (raw === undefined || raw === null || raw === "") {
41
+ // No topic is the discovery call: hand back the index page itself,
42
+ // not a list of links, so a single call is already useful.
43
+ const index = brokerGuide("index");
44
+ return McpToolResults.text(this._render(index ? index.content : BROKER_GUIDES.map((g) => `- ${g.topic}: ${g.summary}`).join("\n")));
45
+ }
46
+ if (typeof raw !== "string") {
47
+ return McpToolResults.error(`Invalid argument "topic": expected a string, got ${typeof raw}. Valid topics: ${BROKER_GUIDE_TOPICS.join(", ")}.`);
48
+ }
49
+
50
+ const guide = brokerGuide(raw.trim());
51
+ if (!guide) {
52
+ return McpToolResults.error(
53
+ `Unknown guide topic "${raw}". Valid topics: ${BROKER_GUIDE_TOPICS.join(", ")}. ` +
54
+ `Call broker_guide with no argument for the index, which says which one to read.`
55
+ );
56
+ }
57
+ return McpToolResults.text(this._render(guide.content));
58
+ }
59
+
60
+ /**
61
+ * Machine-readable form of the index, for a caller that wants to plan its
62
+ * reads without parsing a Markdown table. Exposed for the behavior's tool
63
+ * schema documentation and for tests.
64
+ */
65
+ public index(): ReturnType<typeof brokerGuideIndex> {
66
+ return brokerGuideIndex();
67
+ }
68
+
69
+ /**
70
+ * Appends the effective configuration of THIS broker to a guide page.
71
+ *
72
+ * Everything in the block is read live from the context, so a deployment
73
+ * that moved a path or enabled TLS does not silently contradict the prose
74
+ * above it.
75
+ */
76
+ private _render(content: string): string {
77
+ const c = this._context;
78
+ const paths = c.paths;
79
+ const scheme = c.tls ? "https" : "http";
80
+ const wsScheme = c.tls ? "wss" : "ws";
81
+ const authority = `${c.host && c.host !== "0.0.0.0" ? c.host : "localhost"}:${c.port}`;
82
+
83
+ const block = [
84
+ "",
85
+ "---",
86
+ "",
87
+ "## Effective configuration of this broker",
88
+ "",
89
+ "The prose above uses the default paths. These are the values actually in",
90
+ "force on the instance you are talking to.",
91
+ "",
92
+ "| what | value |",
93
+ "|---|---|",
94
+ `| broker | \`${c.name}\` ${c.version} |`,
95
+ `| listening | \`${scheme}://${authority}\`${c.tls ? " (TLS on: providers use `wss://`, browser origins are `https://`)" : ""} |`,
96
+ `| provider, dedicated socket | \`${wsScheme}://${authority}${paths.provider}/<slot>\` |`,
97
+ `| provider, shared socket | \`${wsScheme}://${authority}${paths.providers}\` |`,
98
+ `| client, Streamable HTTP | \`${scheme}://${authority}/<slot>${paths.mcp}\` |`,
99
+ `| client, legacy SSE | \`${scheme}://${authority}/<slot>${paths.sse}\` + \`${scheme}://${authority}/<slot>${paths.messages}\` |`,
100
+ `| client, raw WebSocket | \`${wsScheme}://${authority}/<slot>\` |`,
101
+ "",
102
+ "Call `broker_diagnose()` for the live state and the problems this broker can",
103
+ "detect about its own wiring.",
104
+ ].join("\n");
105
+
106
+ return content + "\n" + block + "\n";
107
+ }
108
+ }