@cyanmycelium/mcp-broker 0.1.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 (78) hide show
  1. package/.mcp-broker.example/README.md +53 -0
  2. package/.mcp-broker.example/config.json +32 -0
  3. package/.mcp-broker.example/grammars/claude/fr.json +7 -0
  4. package/LICENSE +201 -0
  5. package/README.md +253 -0
  6. package/dist/bin.d.ts +2 -0
  7. package/dist/bin.js +208 -0
  8. package/dist/bin.js.map +1 -0
  9. package/dist/broker/adapters/broker.adapter.info.d.ts +16 -0
  10. package/dist/broker/adapters/broker.adapter.info.js +43 -0
  11. package/dist/broker/adapters/broker.adapter.info.js.map +1 -0
  12. package/dist/broker/adapters/broker.adapter.providers.d.ts +18 -0
  13. package/dist/broker/adapters/broker.adapter.providers.js +61 -0
  14. package/dist/broker/adapters/broker.adapter.providers.js.map +1 -0
  15. package/dist/broker/behaviors/broker.behavior.info.d.ts +15 -0
  16. package/dist/broker/behaviors/broker.behavior.info.js +41 -0
  17. package/dist/broker/behaviors/broker.behavior.info.js.map +1 -0
  18. package/dist/broker/behaviors/broker.behavior.providers.d.ts +19 -0
  19. package/dist/broker/behaviors/broker.behavior.providers.js +69 -0
  20. package/dist/broker/behaviors/broker.behavior.providers.js.map +1 -0
  21. package/dist/broker/broker.context.d.ts +59 -0
  22. package/dist/broker/broker.context.js +2 -0
  23. package/dist/broker/broker.context.js.map +1 -0
  24. package/dist/broker/broker.grammars.d.ts +166 -0
  25. package/dist/broker/broker.grammars.js +258 -0
  26. package/dist/broker/broker.grammars.js.map +1 -0
  27. package/dist/broker/broker.server.d.ts +64 -0
  28. package/dist/broker/broker.server.js +111 -0
  29. package/dist/broker/broker.server.js.map +1 -0
  30. package/dist/broker/grammars/claude/en.json +16 -0
  31. package/dist/broker/grammars/claude/fr.json +16 -0
  32. package/dist/broker/grammars/default/en.json +32 -0
  33. package/dist/broker/grammars/default/fr.json +32 -0
  34. package/dist/broker/grammars/default/zh.json +32 -0
  35. package/dist/broker/index.d.ts +9 -0
  36. package/dist/broker/index.js +7 -0
  37. package/dist/broker/index.js.map +1 -0
  38. package/dist/config.d.ts +101 -0
  39. package/dist/config.js +61 -0
  40. package/dist/config.js.map +1 -0
  41. package/dist/index.d.ts +12 -0
  42. package/dist/index.js +11 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/stdio.upstream.d.ts +42 -0
  45. package/dist/stdio.upstream.js +85 -0
  46. package/dist/stdio.upstream.js.map +1 -0
  47. package/dist/version.d.ts +2 -0
  48. package/dist/version.js +9 -0
  49. package/dist/version.js.map +1 -0
  50. package/dist/ws.tunnel.builder.d.ts +133 -0
  51. package/dist/ws.tunnel.builder.js +197 -0
  52. package/dist/ws.tunnel.builder.js.map +1 -0
  53. package/dist/ws.tunnel.d.ts +310 -0
  54. package/dist/ws.tunnel.js +971 -0
  55. package/dist/ws.tunnel.js.map +1 -0
  56. package/package.json +86 -0
  57. package/scripts/copy-assets.mjs +34 -0
  58. package/scripts/gen-cert.mjs +94 -0
  59. package/src/bin.ts +231 -0
  60. package/src/broker/adapters/broker.adapter.info.ts +46 -0
  61. package/src/broker/adapters/broker.adapter.providers.ts +67 -0
  62. package/src/broker/behaviors/broker.behavior.info.ts +46 -0
  63. package/src/broker/behaviors/broker.behavior.providers.ts +82 -0
  64. package/src/broker/broker.context.ts +75 -0
  65. package/src/broker/broker.grammars.ts +336 -0
  66. package/src/broker/broker.server.ts +168 -0
  67. package/src/broker/grammars/claude/en.json +16 -0
  68. package/src/broker/grammars/claude/fr.json +16 -0
  69. package/src/broker/grammars/default/en.json +32 -0
  70. package/src/broker/grammars/default/fr.json +32 -0
  71. package/src/broker/grammars/default/zh.json +32 -0
  72. package/src/broker/index.ts +24 -0
  73. package/src/config.ts +155 -0
  74. package/src/index.ts +26 -0
  75. package/src/stdio.upstream.ts +114 -0
  76. package/src/version.ts +10 -0
  77. package/src/ws.tunnel.builder.ts +214 -0
  78. package/src/ws.tunnel.ts +1269 -0
@@ -0,0 +1,168 @@
1
+ import { McpGrammar, McpServerBuilder, LoopbackTransport } from "@cyanmycelium/mcp-core";
2
+ import type { IMcpServer, IMessageTransport } from "@cyanmycelium/mcp-core";
3
+ import { BrokerInfoBehavior } from "./behaviors/broker.behavior.info.js";
4
+ import { BrokerProvidersBehavior } from "./behaviors/broker.behavior.providers.js";
5
+ import { brokerGrammarKey, defaultBrokerLocaleResolver, defaultBrokerUserAgentResolver, iterAvailableBrokerGrammars, iterBrokerGrammarsFrom } from "./broker.grammars.js";
6
+ import type { BrokerLocaleResolver, BrokerUserAgent, BrokerUserAgentResolver } from "./broker.grammars.js";
7
+ import type { BrokerContext } from "./broker.context.js";
8
+
9
+ /**
10
+ * Reserved provider slot name under which the broker exposes itself as an MCP
11
+ * server. Clients reach it via `<host>/_broker/mcp` (or any other client transport).
12
+ *
13
+ * Prefixed with `_` to make it unambiguously a system slot, and to reduce the
14
+ * chance of collision with user-supplied provider names.
15
+ */
16
+ export const BROKER_PROVIDER_NAME = "_broker";
17
+
18
+ /**
19
+ * Optional knobs passed to {@link startBrokerServer}. Lets the embedder
20
+ * replace either resolver with custom logic without touching mcp-broker
21
+ * internals.
22
+ */
23
+ export interface StartBrokerServerOptions {
24
+ /**
25
+ * Picks a locale (BCP-47 base language, by convention) for the current
26
+ * session. Defaults to {@link defaultBrokerLocaleResolver} which reads
27
+ * `MCP_BROKER_LOCALE` and keeps the ISO 639-1 prefix.
28
+ */
29
+ localeResolver?: BrokerLocaleResolver;
30
+
31
+ /**
32
+ * Picks a user-agent family for the connecting client. Defaults to
33
+ * {@link defaultBrokerUserAgentResolver} which substring-matches
34
+ * `clientInfo.name` against known LLM families.
35
+ */
36
+ userAgentResolver?: BrokerUserAgentResolver;
37
+
38
+ /**
39
+ * Source of the raw locale string fed to the locale resolver. Defaults
40
+ * to `process.env.MCP_BROKER_LOCALE`. Override when the locale lives
41
+ * somewhere else (config file, session metadata, HTTP header proxy, ...).
42
+ */
43
+ localeSource?: () => string | undefined;
44
+
45
+ /**
46
+ * Path to a user-supplied grammars directory whose `<userAgent>/<locale>.json`
47
+ * files are merged **on top of** the packaged grammars. Local entries win
48
+ * on conflicts; missing entries fall through to the packaged values.
49
+ *
50
+ * When `undefined` (default), only the packaged grammars are used.
51
+ */
52
+ localGrammarsDir?: string;
53
+ }
54
+
55
+ /**
56
+ * Constructs the broker's own MCP server (the Tier-1 introspection behaviors)
57
+ * and returns the running server plus the loopback transport that must be
58
+ * registered against the {@link WsTunnel} as the `_broker` provider slot.
59
+ *
60
+ * Usage from {@link WsTunnel.start}:
61
+ * ```ts
62
+ * const { server, clientTransport } = await startBrokerServer(this, { ... });
63
+ * this._registerLoopbackProvider(BROKER_PROVIDER_NAME, clientTransport);
64
+ * ```
65
+ *
66
+ * @param context Read-only view of the broker's state.
67
+ * @param options Optional resolver overrides.
68
+ * @returns The running {@link IMcpServer} (call `.stop()` on shutdown) and the
69
+ * loopback transport to attach to the tunnel.
70
+ */
71
+ export async function startBrokerServer(
72
+ context: BrokerContext,
73
+ options: StartBrokerServerOptions = {}
74
+ ): Promise<{
75
+ server: IMcpServer;
76
+ clientTransport: IMessageTransport;
77
+ }> {
78
+ const localeResolver = options.localeResolver ?? defaultBrokerLocaleResolver;
79
+ const userAgentResolver = options.userAgentResolver ?? defaultBrokerUserAgentResolver;
80
+ const localeSource = options.localeSource ?? (() => process.env["MCP_BROKER_LOCALE"]);
81
+
82
+ const [serverEnd, clientEnd] = LoopbackTransport.createPair();
83
+
84
+ const builder = new McpServerBuilder().withName(BROKER_PROVIDER_NAME).withTransport(serverEnd).register(new BrokerInfoBehavior(context), new BrokerProvidersBehavior(context));
85
+
86
+ // Discover every grammar by scanning two directories:
87
+ // 1. The packaged grammars shipped with the broker.
88
+ // 2. Optionally, a user-supplied directory whose entries are merged
89
+ // ON TOP of the packaged ones (local wins on conflicts).
90
+ //
91
+ // Then build the (userAgent × locale) matrix. Within each user-agent,
92
+ // the grammar cascades on top of "default:<locale>" so per-user-agent
93
+ // files can stay partial.
94
+ const defaults = new Map<string, McpGrammar>(); // locale → grammar
95
+ const overrides = new Map<BrokerUserAgent, Map<string, McpGrammar>>(); // userAgent → locale → grammar
96
+
97
+ function ingest(entry: { userAgent: BrokerUserAgent; locale: string; grammar: McpGrammar }, isOverride: boolean): void {
98
+ if (entry.userAgent === "default") {
99
+ const existing = defaults.get(entry.locale);
100
+ const merged = existing && isOverride ? McpGrammar.merge(existing, entry.grammar) : entry.grammar;
101
+ defaults.set(entry.locale, merged);
102
+ } else {
103
+ let byLocale = overrides.get(entry.userAgent);
104
+ if (!byLocale) {
105
+ byLocale = new Map();
106
+ overrides.set(entry.userAgent, byLocale);
107
+ }
108
+ const existing = byLocale.get(entry.locale);
109
+ const merged = existing && isOverride ? McpGrammar.merge(existing, entry.grammar) : entry.grammar;
110
+ byLocale.set(entry.locale, merged);
111
+ }
112
+ }
113
+
114
+ for (const entry of iterAvailableBrokerGrammars()) ingest(entry, false);
115
+ if (options.localGrammarsDir) {
116
+ for (const entry of iterBrokerGrammarsFrom(options.localGrammarsDir)) ingest(entry, true);
117
+ }
118
+
119
+ const availableKeys = new Set<string>();
120
+
121
+ // Register every "default:<locale>" as-is.
122
+ for (const [locale, grammar] of defaults) {
123
+ const key = brokerGrammarKey("default", locale);
124
+ builder.withGrammar(key, grammar);
125
+ availableKeys.add(key);
126
+ }
127
+
128
+ // Register every "<userAgent>:<locale>" as merge(default:<locale>, ua:<locale>).
129
+ // The user-agent grammar wins per entry; missing entries cascade from default.
130
+ for (const [agent, byLocale] of overrides) {
131
+ for (const [locale, uaGrammar] of byLocale) {
132
+ const baseline = defaults.get(locale);
133
+ const merged = baseline ? McpGrammar.merge(baseline, uaGrammar) : uaGrammar;
134
+ const key = brokerGrammarKey(agent, locale);
135
+ builder.withGrammar(key, merged);
136
+ availableKeys.add(key);
137
+ }
138
+ }
139
+
140
+ builder.withGrammarResolver((clientInfo) => {
141
+ // localeResolver returns the full fallback chain (most specific first),
142
+ // ending with the universal "en". The user-agent axis is the broker's
143
+ // own concern: per locale, prefer the user-agent-specific grammar, then
144
+ // fall back to the "default" agent.
145
+ const locales = localeResolver(localeSource());
146
+ const userAgent = userAgentResolver(clientInfo);
147
+ const userAgents = userAgent === "default" ? ["default"] : [userAgent, "default"];
148
+
149
+ for (const locale of locales) {
150
+ for (const ua of userAgents) {
151
+ const key = brokerGrammarKey(ua, locale);
152
+ if (availableKeys.has(key)) return key;
153
+ }
154
+ }
155
+
156
+ // None of the candidates is on disk → return an empty key. McpServer
157
+ // then falls back to the behavior's baseline descriptions (English).
158
+ return "";
159
+ });
160
+
161
+ // No reconnect policy → the McpServer does not attempt to reopen the loopback
162
+ // when WsTunnel.stop() closes it. Clean shutdown.
163
+ const server = builder.build();
164
+
165
+ await server.start();
166
+
167
+ return { server, clientTransport: clientEnd };
168
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "tools": {
3
+ "broker_info": {
4
+ "description": "Get the broker's identity: name, version, uptime, host, port, TLS, and URL paths. Call this first when you need to know what you are talking to."
5
+ },
6
+ "providers_list": {
7
+ "description": "List every provider slot on this broker, connected or not. Each entry tells you the transport kind, current client count, and how many requests are in flight. Use this to discover what you can route to."
8
+ },
9
+ "provider_status": {
10
+ "description": "Get the full status of one provider slot by name. Returns an error if the slot does not exist — start with providers_list if you are not sure of the name.",
11
+ "properties": {
12
+ "name": "The exact provider slot name. Case-sensitive. Use providers_list to discover valid values."
13
+ }
14
+ }
15
+ }
16
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "tools": {
3
+ "broker_info": {
4
+ "description": "Récupère l'identité du broker : nom, version, uptime, hôte, port, TLS et chemins URL. À appeler en premier pour savoir à qui tu parles."
5
+ },
6
+ "providers_list": {
7
+ "description": "Liste tous les emplacements de providers de ce broker, connectés ou non. Chaque entrée indique le type de transport, le nombre de clients actuels et le nombre de requêtes en cours. Utilise-le pour découvrir vers qui tu peux router."
8
+ },
9
+ "provider_status": {
10
+ "description": "Récupère le statut complet d'un emplacement de provider par son nom. Retourne une erreur si l'emplacement n'existe pas — commence par providers_list si tu n'es pas sûr du nom.",
11
+ "properties": {
12
+ "name": "Le nom exact de l'emplacement provider. Sensible à la casse. Utilise providers_list pour découvrir les valeurs valides."
13
+ }
14
+ }
15
+ }
16
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "tools": {
3
+ "broker_info": {
4
+ "description": "Returns the broker's name, version, uptime, host, port, TLS status, and configured URL paths."
5
+ },
6
+ "providers_list": {
7
+ "description": "Returns every provider slot known to the broker (connected and disconnected), with transport kind, client count, and pending request count."
8
+ },
9
+ "provider_status": {
10
+ "description": "Returns the status of one provider slot identified by name. Errors if the slot does not exist.",
11
+ "properties": {
12
+ "name": "Exact provider name (case-sensitive)."
13
+ }
14
+ }
15
+ },
16
+ "resources": {
17
+ "broker://info": {
18
+ "name": "Broker info",
19
+ "description": "Broker identity, version, uptime, and listening configuration."
20
+ },
21
+ "broker://providers": {
22
+ "name": "Broker providers",
23
+ "description": "List of every provider slot known to the broker, including disconnected ones."
24
+ }
25
+ },
26
+ "templates": {
27
+ "broker://providers/{name}": {
28
+ "name": "Broker provider",
29
+ "description": "Snapshot of one specific provider slot, addressed by its name."
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "tools": {
3
+ "broker_info": {
4
+ "description": "Retourne le nom, la version, l'uptime, l'hôte, le port, l'état TLS et les chemins URL du broker."
5
+ },
6
+ "providers_list": {
7
+ "description": "Liste tous les emplacements de providers connus du broker (connectés et déconnectés), avec leur type de transport, leur nombre de clients et le nombre de requêtes en attente."
8
+ },
9
+ "provider_status": {
10
+ "description": "Retourne l'état d'un emplacement de provider identifié par son nom. Échoue si le provider n'existe pas.",
11
+ "properties": {
12
+ "name": "Nom exact du provider (sensible à la casse)."
13
+ }
14
+ }
15
+ },
16
+ "resources": {
17
+ "broker://info": {
18
+ "name": "Informations du broker",
19
+ "description": "Identité, version, uptime et configuration d'écoute du broker."
20
+ },
21
+ "broker://providers": {
22
+ "name": "Providers du broker",
23
+ "description": "Liste de tous les emplacements de providers connus du broker, y compris les déconnectés."
24
+ }
25
+ },
26
+ "templates": {
27
+ "broker://providers/{name}": {
28
+ "name": "Provider du broker",
29
+ "description": "Snapshot d'un emplacement de provider spécifique, identifié par son nom."
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "tools": {
3
+ "broker_info": {
4
+ "description": "返回 broker 的名称、版本、运行时间、主机、端口、TLS 状态和已配置的 URL 路径。"
5
+ },
6
+ "providers_list": {
7
+ "description": "列出 broker 已知的所有 provider 槽位(已连接和未连接),包括传输类型、客户端数量和待处理请求数量。"
8
+ },
9
+ "provider_status": {
10
+ "description": "返回由名称标识的某个 provider 槽位的状态。如果槽位不存在则返回错误。",
11
+ "properties": {
12
+ "name": "provider 的精确名称(区分大小写)。"
13
+ }
14
+ }
15
+ },
16
+ "resources": {
17
+ "broker://info": {
18
+ "name": "Broker 信息",
19
+ "description": "Broker 的身份、版本、运行时间和监听配置。"
20
+ },
21
+ "broker://providers": {
22
+ "name": "Broker Providers",
23
+ "description": "Broker 已知的所有 provider 槽位列表,包括未连接的。"
24
+ }
25
+ },
26
+ "templates": {
27
+ "broker://providers/{name}": {
28
+ "name": "Broker Provider",
29
+ "description": "指定名称的 provider 槽位快照。"
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,24 @@
1
+ export { BrokerInfoBehavior } from "./behaviors/broker.behavior.info.js";
2
+ export { BrokerProvidersBehavior } from "./behaviors/broker.behavior.providers.js";
3
+ export { BrokerInfoAdapter, BROKER_INFO_URI } from "./adapters/broker.adapter.info.js";
4
+ export { BrokerProvidersAdapter, PROVIDERS_URI, PROVIDER_URI_TEMPLATE } from "./adapters/broker.adapter.providers.js";
5
+ export { startBrokerServer, BROKER_PROVIDER_NAME } from "./broker.server.js";
6
+ export type { StartBrokerServerOptions } from "./broker.server.js";
7
+ export {
8
+ brokerBaselineGrammar,
9
+ brokerBaselinePropertyDescription,
10
+ brokerBaselineResourceDescription,
11
+ brokerBaselineResourceName,
12
+ brokerBaselineResourceTemplateDescription,
13
+ brokerBaselineResourceTemplateName,
14
+ brokerBaselineToolDescription,
15
+ brokerGrammarKey,
16
+ defaultBrokerLocaleResolver,
17
+ defaultBrokerUserAgentResolver,
18
+ iterAvailableBrokerGrammars,
19
+ loadBrokerGrammar,
20
+ resolveBrokerLocale,
21
+ resolveBrokerUserAgent,
22
+ } from "./broker.grammars.js";
23
+ export type { BrokerLocale, BrokerLocaleResolver, BrokerUserAgent, BrokerUserAgentResolver } from "./broker.grammars.js";
24
+ export type { BrokerContext, BrokerProviderInfo, BrokerProviderTransport } from "./broker.context.js";
package/src/config.ts ADDED
@@ -0,0 +1,155 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+
4
+ /**
5
+ * Shape of the optional JSON config file consumed by `bin.ts` at startup.
6
+ * Every field is optional. Environment variables (`MCP_BROKER_*`) always win
7
+ * over file values, and file values win over the built-in defaults.
8
+ *
9
+ * @example
10
+ * ```json
11
+ * {
12
+ * "port": 3001,
13
+ * "locale": "fr",
14
+ * "tls": { "cert": "certs/cert.pem", "key": "certs/key.pem" },
15
+ * "stdioUpstreams": [
16
+ * { "name": "fs", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"] }
17
+ * ]
18
+ * }
19
+ * ```
20
+ */
21
+ export interface BrokerConfig {
22
+ /** TCP port. Maps to `MCP_BROKER_PORT`. */
23
+ port?: number;
24
+
25
+ /** Bind host. Maps to `MCP_BROKER_HOST`. */
26
+ host?: string;
27
+
28
+ /** Force protocol (`http`/`https`) regardless of cert presence. Maps to `MCP_BROKER_PROTOCOL`. */
29
+ protocol?: "http" | "https";
30
+
31
+ /** Locale fed to the broker grammar resolver. Maps to `MCP_BROKER_LOCALE`. */
32
+ locale?: string;
33
+
34
+ /** Bridge stdin/stdout for a Claude-Desktop-style client. Maps to `MCP_BROKER_STDIO_PROVIDER`. */
35
+ stdioProvider?: string;
36
+
37
+ /** Logical broker name reported by `broker_info`. */
38
+ brokerName?: string;
39
+
40
+ /** URL paths (override the defaults). */
41
+ paths?: {
42
+ provider?: string;
43
+ providers?: string;
44
+ client?: string;
45
+ mcp?: string;
46
+ sse?: string;
47
+ messages?: string;
48
+ };
49
+
50
+ /** TLS material as paths on disk. Resolved against the config file's directory. */
51
+ tls?: {
52
+ cert: string;
53
+ key: string;
54
+ };
55
+
56
+ /**
57
+ * Static-file serving alongside the JSON-RPC endpoints. JSON-RPC routes
58
+ * always take precedence.
59
+ */
60
+ www?: {
61
+ /** Auto-launch the default browser at the root URL on startup. */
62
+ open?: boolean;
63
+ /** URL-prefix → directory mappings. Longest-prefix match wins. */
64
+ mounts?: Array<{
65
+ urlPrefix: string;
66
+ dir: string;
67
+ }>;
68
+ };
69
+
70
+ /** Stdio upstream providers spawned by the broker at startup. */
71
+ stdioUpstreams?: Array<{
72
+ name: string;
73
+ command: string;
74
+ args?: string[];
75
+ env?: Record<string, string>;
76
+ }>;
77
+ }
78
+
79
+ /**
80
+ * Returned by {@link loadBrokerConfig}. The {@link config} is the parsed JSON;
81
+ * {@link baseDir} is the directory used to resolve relative paths inside it
82
+ * (the directory containing the config file when one was found, otherwise
83
+ * `process.cwd()`).
84
+ */
85
+ export interface LoadedBrokerConfig {
86
+ config: BrokerConfig;
87
+ baseDir: string;
88
+ /** Absolute path of the config file that was loaded, or `null` if none. */
89
+ sourcePath: string | null;
90
+ }
91
+
92
+ /** Default folder name (relative to `process.cwd()`) holding broker-local files. */
93
+ export const DEFAULT_CONFIG_DIR = ".mcp-broker";
94
+
95
+ /** Default config filename inside {@link DEFAULT_CONFIG_DIR}. */
96
+ export const DEFAULT_CONFIG_FILENAME = "config.json";
97
+
98
+ /** Legacy flat config filename at the cwd root (pre-`.mcp-broker/` layout). */
99
+ export const LEGACY_CONFIG_FILENAME = "mcp-broker.config.json";
100
+
101
+ /**
102
+ * Loads the broker config from a JSON file.
103
+ *
104
+ * Discovery order:
105
+ * 1. The `path` argument when provided (explicit override).
106
+ * 2. The `MCP_BROKER_CONFIG` env var.
107
+ * 3. `./.mcp-broker/config.json` relative to `process.cwd()`.
108
+ * 4. `./mcp-broker.config.json` relative to `process.cwd()` (legacy layout —
109
+ * a deprecation warning is written to stderr).
110
+ *
111
+ * When no file is found, returns the built-in empty config with
112
+ * `baseDir = process.cwd()`. On invalid JSON, logs a warning to stderr and
113
+ * returns the same empty config — never throws.
114
+ *
115
+ * Paths inside the config file are intended to be resolved against
116
+ * {@link LoadedBrokerConfig.baseDir} by the consumer.
117
+ */
118
+ export function loadBrokerConfig(path?: string): LoadedBrokerConfig {
119
+ const cwd = process.cwd();
120
+ const envPath = process.env["MCP_BROKER_CONFIG"];
121
+
122
+ let sourcePath: string | null = null;
123
+
124
+ if (path) {
125
+ sourcePath = resolve(cwd, path);
126
+ } else if (envPath) {
127
+ sourcePath = resolve(cwd, envPath);
128
+ } else {
129
+ const modern = resolve(cwd, DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_FILENAME);
130
+ const legacy = resolve(cwd, LEGACY_CONFIG_FILENAME);
131
+ if (existsSync(modern)) {
132
+ sourcePath = modern;
133
+ } else if (existsSync(legacy)) {
134
+ sourcePath = legacy;
135
+ process.stderr.write(
136
+ `[mcp-broker] Using legacy config at ${legacy}. ` + `Move it to ${resolve(cwd, DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_FILENAME)} ` + `to silence this warning.\n`
137
+ );
138
+ }
139
+ }
140
+
141
+ if (!sourcePath || !existsSync(sourcePath)) {
142
+ return { config: {}, baseDir: cwd, sourcePath: null };
143
+ }
144
+
145
+ const baseDir = dirname(sourcePath);
146
+
147
+ try {
148
+ const raw = readFileSync(sourcePath, "utf-8");
149
+ const config = JSON.parse(raw) as BrokerConfig;
150
+ return { config, baseDir, sourcePath };
151
+ } catch (err) {
152
+ process.stderr.write(`[mcp-broker] Failed to parse config file at ${sourcePath}: ${(err as Error).message}\n`);
153
+ return { config: {}, baseDir: cwd, sourcePath: null };
154
+ }
155
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ export { WsTunnel } from "./ws.tunnel.js";
2
+ export { WsTunnelBuilder } from "./ws.tunnel.builder.js";
3
+ export type { WsTunnelOptions, StaticMount } from "./ws.tunnel.js";
4
+ export { StdioUpstream } from "./stdio.upstream.js";
5
+ export type { StdioUpstreamConfig } from "./stdio.upstream.js";
6
+
7
+ // Broker introspection — tier 1.
8
+ export { BrokerInfoBehavior, BrokerProvidersBehavior, startBrokerServer, BROKER_PROVIDER_NAME } from "./broker/index.js";
9
+ export type { StartBrokerServerOptions } from "./broker/index.js";
10
+ export {
11
+ brokerGrammarKey,
12
+ defaultBrokerLocaleResolver,
13
+ defaultBrokerUserAgentResolver,
14
+ iterAvailableBrokerGrammars,
15
+ loadBrokerGrammar,
16
+ resolveBrokerLocale,
17
+ resolveBrokerUserAgent,
18
+ } from "./broker/index.js";
19
+ export type { BrokerContext, BrokerProviderInfo, BrokerProviderTransport, BrokerLocale, BrokerLocaleResolver, BrokerUserAgent, BrokerUserAgentResolver } from "./broker/index.js";
20
+
21
+ export { VERSION, PACKAGE_NAME } from "./version.js";
22
+
23
+ // JSON config file used by `bin.ts` at startup. Exported so a programmatic
24
+ // embedder can re-use the same loader against a custom path.
25
+ export { loadBrokerConfig, DEFAULT_CONFIG_FILENAME } from "./config.js";
26
+ export type { BrokerConfig } from "./config.js";
@@ -0,0 +1,114 @@
1
+ import { spawn, ChildProcess } from "node:child_process";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Configuration
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export interface StdioUpstreamConfig {
8
+ /** Logical name of this provider (matched against incoming WebSocket provider names). */
9
+ name: string;
10
+ /** Executable to spawn (e.g. `"node"`, `"python"`, an absolute path). */
11
+ command: string;
12
+ /** Arguments passed to the command. */
13
+ args?: string[];
14
+ /** Extra environment variables merged with `process.env`. */
15
+ env?: NodeJS.ProcessEnv;
16
+ }
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // StdioUpstream
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Manages a stdio-based MCP server process.
24
+ * JSON-RPC messages are exchanged over the child process stdin/stdout using
25
+ * newline-delimited framing (matching the MCP SDK stdio transport).
26
+ *
27
+ * One instance per configured provider. The broker uses this to bridge
28
+ * WebSocket/SSE/HTTP clients to local MCP server processes.
29
+ */
30
+ export class StdioUpstream {
31
+ readonly name: string;
32
+
33
+ private readonly _config: StdioUpstreamConfig;
34
+ private _proc: ChildProcess | null = null;
35
+ private _buffer = "";
36
+ private _open = false;
37
+ private _stopped = false;
38
+
39
+ /** Called when a complete JSON-RPC line arrives from the process stdout. */
40
+ onMessage: ((data: string) => void) | null = null;
41
+
42
+ /** Called when the process has started and stdin is writable. */
43
+ onOpen: (() => void) | null = null;
44
+
45
+ /** Called when the process exits (cleanly or otherwise). */
46
+ onClose: (() => void) | null = null;
47
+
48
+ /** Called on spawn or runtime errors. */
49
+ onError: ((error: Error) => void) | null = null;
50
+
51
+ constructor(config: StdioUpstreamConfig) {
52
+ this.name = config.name;
53
+ this._config = config;
54
+ }
55
+
56
+ get isOpen(): boolean {
57
+ return this._open;
58
+ }
59
+
60
+ /** Spawns the child process and wires up stdio listeners. */
61
+ connect(): void {
62
+ this._stopped = false;
63
+ const { command, args = [], env } = this._config;
64
+
65
+ this._proc = spawn(command, args, {
66
+ env: { ...process.env, ...env },
67
+ stdio: ["pipe", "pipe", "inherit"],
68
+ });
69
+
70
+ this._proc.on("error", (err: Error) => {
71
+ this._open = false;
72
+ this.onError?.(new Error(`StdioUpstream "${this.name}": process error — ${err.message}`));
73
+ });
74
+
75
+ this._proc.on("spawn", () => {
76
+ this._open = true;
77
+ this.onOpen?.();
78
+ });
79
+
80
+ this._proc.stdout!.on("data", (chunk: Buffer) => {
81
+ this._buffer += chunk.toString("utf8");
82
+ let nl: number;
83
+ while ((nl = this._buffer.indexOf("\n")) !== -1) {
84
+ const line = this._buffer.slice(0, nl).trim();
85
+ this._buffer = this._buffer.slice(nl + 1);
86
+ if (line) this.onMessage?.(line);
87
+ }
88
+ });
89
+
90
+ this._proc.on("close", (code) => {
91
+ this._open = false;
92
+ this._proc = null;
93
+ this.onClose?.();
94
+ if (!this._stopped) {
95
+ this.onError?.(new Error(`StdioUpstream "${this.name}": process exited with code ${code ?? "null"}`));
96
+ }
97
+ });
98
+ }
99
+
100
+ /** Sends a JSON-RPC message to the process stdin (appends newline). */
101
+ send(data: string): void {
102
+ if (!this._open || !this._proc?.stdin?.writable) return;
103
+ this._proc.stdin.write(data + "\n", "utf8");
104
+ }
105
+
106
+ /** Kills the child process and prevents further reconnection attempts. */
107
+ close(): void {
108
+ this._stopped = true;
109
+ this._open = false;
110
+ this._proc?.stdin?.end();
111
+ this._proc?.kill();
112
+ this._proc = null;
113
+ }
114
+ }
package/src/version.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { createRequire } from "module";
2
+
3
+ // Resolves the package's own version from package.json without bundler help.
4
+ // Works both for the compiled dist/version.js (one level below package.json)
5
+ // and for tsc's source-level resolution from src/version.ts.
6
+ const require = createRequire(import.meta.url);
7
+ const pkg = require("../package.json") as { version: string; name: string };
8
+
9
+ export const VERSION: string = pkg.version;
10
+ export const PACKAGE_NAME: string = pkg.name;