@cyanmycelium/mcp-broker 0.2.0 → 0.4.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 (71) hide show
  1. package/.mcp-broker.example/README.md +23 -0
  2. package/.mcp-broker.example/config.json +11 -0
  3. package/README.md +1 -16
  4. package/dist/bin.js +30 -3
  5. package/dist/bin.js.map +1 -1
  6. package/dist/broker/aggregate/aggregate.catalog.d.ts +54 -0
  7. package/dist/broker/aggregate/aggregate.catalog.js +105 -0
  8. package/dist/broker/aggregate/aggregate.catalog.js.map +1 -0
  9. package/dist/broker/aggregate/aggregate.server.d.ts +47 -0
  10. package/dist/broker/aggregate/aggregate.server.js +151 -0
  11. package/dist/broker/aggregate/aggregate.server.js.map +1 -0
  12. package/dist/broker/aggregate/provider.client.session.d.ts +52 -0
  13. package/dist/broker/aggregate/provider.client.session.js +140 -0
  14. package/dist/broker/aggregate/provider.client.session.js.map +1 -0
  15. package/dist/broker/broker.grammars.d.ts +50 -86
  16. package/dist/broker/broker.grammars.js +55 -84
  17. package/dist/broker/broker.grammars.js.map +1 -1
  18. package/dist/broker/broker.server.d.ts +23 -21
  19. package/dist/broker/broker.server.js +33 -71
  20. package/dist/broker/broker.server.js.map +1 -1
  21. package/dist/broker/index.d.ts +2 -2
  22. package/dist/broker/index.js +1 -1
  23. package/dist/broker/index.js.map +1 -1
  24. package/dist/config.d.ts +35 -0
  25. package/dist/config.js.map +1 -1
  26. package/dist/index.d.ts +8 -2
  27. package/dist/index.js +5 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/mcpb.loader.d.ts +24 -0
  30. package/dist/mcpb.loader.js +161 -0
  31. package/dist/mcpb.loader.js.map +1 -0
  32. package/dist/mcpb.unzip.d.ts +6 -0
  33. package/dist/mcpb.unzip.js +95 -0
  34. package/dist/mcpb.unzip.js.map +1 -0
  35. package/dist/remote.transports.d.ts +16 -0
  36. package/dist/remote.transports.js +297 -0
  37. package/dist/remote.transports.js.map +1 -0
  38. package/dist/remote.upstream.d.ts +36 -0
  39. package/dist/remote.upstream.js +52 -0
  40. package/dist/remote.upstream.js.map +1 -0
  41. package/dist/stdio.upstream.d.ts +4 -1
  42. package/dist/stdio.upstream.js.map +1 -1
  43. package/dist/upstream.d.ts +33 -0
  44. package/dist/upstream.js +2 -0
  45. package/dist/upstream.js.map +1 -0
  46. package/dist/ws.tunnel.builder.d.ts +14 -8
  47. package/dist/ws.tunnel.builder.js +17 -9
  48. package/dist/ws.tunnel.builder.js.map +1 -1
  49. package/dist/ws.tunnel.d.ts +85 -22
  50. package/dist/ws.tunnel.js +201 -82
  51. package/dist/ws.tunnel.js.map +1 -1
  52. package/package.json +3 -2
  53. package/scripts/pack-mcpb.mjs +84 -0
  54. package/scripts/sign-bundle.mjs +61 -0
  55. package/src/bin.ts +32 -3
  56. package/src/broker/aggregate/aggregate.catalog.ts +145 -0
  57. package/src/broker/aggregate/aggregate.server.ts +178 -0
  58. package/src/broker/aggregate/provider.client.session.ts +172 -0
  59. package/src/broker/broker.grammars.ts +74 -122
  60. package/src/broker/broker.server.ts +57 -99
  61. package/src/broker/index.ts +3 -5
  62. package/src/config.ts +37 -0
  63. package/src/index.ts +10 -10
  64. package/src/mcpb.loader.ts +186 -0
  65. package/src/mcpb.unzip.ts +103 -0
  66. package/src/remote.transports.ts +316 -0
  67. package/src/remote.upstream.ts +75 -0
  68. package/src/stdio.upstream.ts +4 -1
  69. package/src/upstream.ts +33 -0
  70. package/src/ws.tunnel.builder.ts +19 -9
  71. package/src/ws.tunnel.ts +258 -99
@@ -0,0 +1,178 @@
1
+ import type { IMessageTransport } from "@cyanmycelium/mcp-core";
2
+ import type { InternalClient } from "../../ws.tunnel.js";
3
+ import { AggregateCatalog } from "./aggregate.catalog.js";
4
+ import { ProviderClientSession } from "./provider.client.session.js";
5
+
6
+ /** MCP protocol version advertised by the aggregate server. */
7
+ const PROTOCOL_VERSION = "2024-11-05";
8
+
9
+ /** Opens an in-process client to a named provider slot. Supplied by WsTunnel. */
10
+ export type InternalClientFactory = (providerName: string) => InternalClient;
11
+
12
+ interface ClientMessage {
13
+ id?: string | number | null;
14
+ method?: string;
15
+ params?: unknown;
16
+ }
17
+
18
+ interface CallParams {
19
+ name?: string;
20
+ arguments?: Record<string, unknown>;
21
+ }
22
+
23
+ /**
24
+ * The `_all` aggregate MCP server. Presents the union of every opted-in
25
+ * provider's tools and prompts as a single MCP server, reachable on the
26
+ * reserved `_all` slot.
27
+ *
28
+ * It implements {@link IMessageTransport} so it can be registered on the
29
+ * WsTunnel as a loopback provider: `send` receives client requests, `onMessage`
30
+ * (assigned by the tunnel) carries responses and notifications back to clients.
31
+ */
32
+ export class AggregateServer implements IMessageTransport {
33
+ /** Reserved provider slot the aggregate is published on. */
34
+ static readonly SLOT = "_all";
35
+
36
+ private readonly _catalog = new AggregateCatalog();
37
+ private readonly _sessions = new Map<string, ProviderClientSession>();
38
+ private readonly _openClient: InternalClientFactory;
39
+ private _running = false;
40
+
41
+ onMessage: ((data: string) => void) | null = null;
42
+ onOpen: (() => void) | null = null;
43
+ onClose: (() => void) | null = null;
44
+ onError: ((error: Error) => void) | null = null;
45
+
46
+ constructor(openClient: InternalClientFactory) {
47
+ this._openClient = openClient;
48
+ }
49
+
50
+ get isOpen(): boolean {
51
+ return this._running;
52
+ }
53
+
54
+ /** Number of providers currently in the aggregate. */
55
+ get providerCount(): number {
56
+ return this._sessions.size;
57
+ }
58
+
59
+ /** Marks the aggregate transport open. Call before registering it. */
60
+ start(): void {
61
+ this._running = true;
62
+ }
63
+
64
+ /** WsTunnel hands a client request for the `_all` slot here. */
65
+ send(data: string): void {
66
+ void this._handleClientMessage(data);
67
+ }
68
+
69
+ /** Closes every provider session and the aggregate transport. */
70
+ close(): void {
71
+ if (!this._running) return;
72
+ this._running = false;
73
+ for (const session of this._sessions.values()) session.close();
74
+ this._sessions.clear();
75
+ this.onClose?.();
76
+ }
77
+
78
+ /**
79
+ * Adds a provider to the aggregate: opens an internal client, runs the
80
+ * session handshake, and merges the provider's catalog. A no-op for an
81
+ * already-aggregated provider or the reserved `_all` slot itself.
82
+ */
83
+ async addProvider(name: string): Promise<void> {
84
+ if (name === AggregateServer.SLOT || this._sessions.has(name)) return;
85
+
86
+ const session = new ProviderClientSession(name, this._openClient(name));
87
+ this._sessions.set(name, session);
88
+
89
+ session.onCatalogChanged = (): void => {
90
+ this._catalog.setProvider(name, { tools: session.tools, prompts: session.prompts });
91
+ this._emitListChanged();
92
+ };
93
+ session.onClosed = (): void => this.removeProvider(name);
94
+
95
+ try {
96
+ await session.initialize();
97
+ } catch {
98
+ this.removeProvider(name);
99
+ }
100
+ }
101
+
102
+ /** Removes a provider from the aggregate. */
103
+ removeProvider(name: string): void {
104
+ const session = this._sessions.get(name);
105
+ if (!session) return;
106
+ this._sessions.delete(name);
107
+ session.close();
108
+ this._catalog.removeProvider(name);
109
+ this._emitListChanged();
110
+ }
111
+
112
+ private async _handleClientMessage(data: string): Promise<void> {
113
+ let msg: ClientMessage;
114
+ try {
115
+ msg = JSON.parse(data) as ClientMessage;
116
+ } catch {
117
+ return;
118
+ }
119
+ const id = msg.id;
120
+ if (id == null) return; // client notification — nothing to answer
121
+
122
+ switch (msg.method) {
123
+ case "initialize":
124
+ this._reply(id, {
125
+ result: {
126
+ protocolVersion: PROTOCOL_VERSION,
127
+ serverInfo: { name: AggregateServer.SLOT, version: "0" },
128
+ capabilities: { tools: { listChanged: true }, prompts: { listChanged: true } },
129
+ },
130
+ });
131
+ break;
132
+ case "ping":
133
+ this._reply(id, { result: {} });
134
+ break;
135
+ case "tools/list":
136
+ this._reply(id, { result: { tools: this._catalog.tools } });
137
+ break;
138
+ case "prompts/list":
139
+ this._reply(id, { result: { prompts: this._catalog.prompts } });
140
+ break;
141
+ case "tools/call":
142
+ await this._route(id, msg.params, "tool");
143
+ break;
144
+ case "prompts/get":
145
+ await this._route(id, msg.params, "prompt");
146
+ break;
147
+ default:
148
+ this._reply(id, { error: { code: -32601, message: `Method not found: ${msg.method ?? "(none)"}` } });
149
+ }
150
+ }
151
+
152
+ private async _route(id: string | number, params: unknown, kind: "tool" | "prompt"): Promise<void> {
153
+ const p = (params ?? {}) as CallParams;
154
+ const route = p.name ? (kind === "tool" ? this._catalog.resolveTool(p.name) : this._catalog.resolvePrompt(p.name)) : undefined;
155
+ if (!route) {
156
+ this._reply(id, { error: { code: -32602, message: `Unknown aggregated ${kind}: ${p.name ?? "(none)"}` } });
157
+ return;
158
+ }
159
+ const session = this._sessions.get(route.provider);
160
+ if (!session) {
161
+ this._reply(id, { error: { code: -32000, message: `Provider "${route.provider}" is no longer connected` } });
162
+ return;
163
+ }
164
+ const args = p.arguments ?? {};
165
+ const outcome = kind === "tool" ? await session.callTool(route.original, args) : await session.getPrompt(route.original, args);
166
+ this._reply(id, outcome.error !== undefined ? { error: outcome.error } : { result: outcome.result });
167
+ }
168
+
169
+ private _reply(id: string | number, body: { result?: unknown; error?: unknown }): void {
170
+ this.onMessage?.(JSON.stringify({ jsonrpc: "2.0", id, ...body }));
171
+ }
172
+
173
+ private _emitListChanged(): void {
174
+ if (!this._running) return;
175
+ this.onMessage?.(JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" }));
176
+ this.onMessage?.(JSON.stringify({ jsonrpc: "2.0", method: "notifications/prompts/list_changed" }));
177
+ }
178
+ }
@@ -0,0 +1,172 @@
1
+ import type { InternalClient } from "../../ws.tunnel.js";
2
+ import type { CatalogTool, CatalogPrompt } from "./aggregate.catalog.js";
3
+
4
+ /** MCP protocol version the aggregate sessions negotiate with sub-providers. */
5
+ const PROTOCOL_VERSION = "2024-11-05";
6
+
7
+ /** Per-request timeout for sub-provider calls. */
8
+ const REQUEST_TIMEOUT_MS = 30_000;
9
+
10
+ /** Outcome of a JSON-RPC request: exactly one of `result` / `error` is set. */
11
+ interface RpcOutcome {
12
+ result?: unknown;
13
+ error?: unknown;
14
+ }
15
+
16
+ interface PendingRequest {
17
+ resolve: (outcome: RpcOutcome) => void;
18
+ timer: ReturnType<typeof setTimeout>;
19
+ }
20
+
21
+ interface IncomingMessage {
22
+ id?: string | number | null;
23
+ method?: string;
24
+ result?: unknown;
25
+ error?: unknown;
26
+ }
27
+
28
+ /**
29
+ * A hand-rolled JSON-RPC client session to one aggregated provider, running
30
+ * over an in-process {@link InternalClient}.
31
+ *
32
+ * Performs the MCP `initialize` handshake, caches the provider's `tools/list`
33
+ * and `prompts/list`, and re-fetches them when the provider emits a
34
+ * `list_changed` notification. `tools/call` and `prompts/get` are forwarded and
35
+ * their raw result or error relayed back unchanged.
36
+ */
37
+ export class ProviderClientSession {
38
+ readonly provider: string;
39
+
40
+ private readonly _client: InternalClient;
41
+ private readonly _idPrefix: string;
42
+ private readonly _pending = new Map<string, PendingRequest>();
43
+ private _nextId = 0;
44
+ private _tools: CatalogTool[] = [];
45
+ private _prompts: CatalogPrompt[] = [];
46
+ private _closed = false;
47
+
48
+ /** Fires after the cached catalog changes (initial load or `list_changed`). */
49
+ onCatalogChanged: (() => void) | null = null;
50
+
51
+ /** Fires when the underlying provider slot disconnects. */
52
+ onClosed: (() => void) | null = null;
53
+
54
+ constructor(provider: string, client: InternalClient) {
55
+ this.provider = provider;
56
+ this._client = client;
57
+ this._idPrefix = `agg-${provider}-`;
58
+ client.onMessage = (data: string): void => this._handleMessage(data);
59
+ client.onClose = (): void => {
60
+ if (this._closed) return;
61
+ this._rejectAll("provider disconnected");
62
+ this.onClosed?.();
63
+ };
64
+ }
65
+
66
+ get tools(): CatalogTool[] {
67
+ return this._tools;
68
+ }
69
+
70
+ get prompts(): CatalogPrompt[] {
71
+ return this._prompts;
72
+ }
73
+
74
+ /** Runs the `initialize` handshake and the first catalog fetch. */
75
+ async initialize(): Promise<void> {
76
+ await this._request("initialize", {
77
+ protocolVersion: PROTOCOL_VERSION,
78
+ capabilities: {},
79
+ clientInfo: { name: "mcp-broker-aggregate", version: "0" },
80
+ });
81
+ this._client.send(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }));
82
+ await this._refresh();
83
+ }
84
+
85
+ /** Forwards a `tools/call` to the provider, relaying the raw outcome. */
86
+ callTool(name: string, args: Record<string, unknown>): Promise<RpcOutcome> {
87
+ return this._request("tools/call", { name, arguments: args });
88
+ }
89
+
90
+ /** Forwards a `prompts/get` to the provider, relaying the raw outcome. */
91
+ getPrompt(name: string, args: Record<string, unknown>): Promise<RpcOutcome> {
92
+ return this._request("prompts/get", { name, arguments: args });
93
+ }
94
+
95
+ /** Detaches the session and its internal client. */
96
+ close(): void {
97
+ if (this._closed) return;
98
+ this._closed = true;
99
+ this._rejectAll("session closed");
100
+ this._client.close();
101
+ }
102
+
103
+ private async _refresh(): Promise<void> {
104
+ this._tools = await this._listAll<CatalogTool>("tools/list", "tools");
105
+ this._prompts = await this._listAll<CatalogPrompt>("prompts/list", "prompts");
106
+ this.onCatalogChanged?.();
107
+ }
108
+
109
+ /**
110
+ * Calls a list method, following `nextCursor` pagination. A provider that
111
+ * does not implement the primitive answers with an error, which is treated
112
+ * as an empty list.
113
+ */
114
+ private async _listAll<T>(method: string, key: string): Promise<T[]> {
115
+ const items: T[] = [];
116
+ let cursor: string | undefined;
117
+ do {
118
+ const { result, error } = await this._request(method, cursor ? { cursor } : {});
119
+ if (error) return [];
120
+ const page = (result ?? {}) as Record<string, unknown>;
121
+ const list = page[key];
122
+ if (Array.isArray(list)) items.push(...(list as T[]));
123
+ cursor = typeof page.nextCursor === "string" ? page.nextCursor : undefined;
124
+ } while (cursor);
125
+ return items;
126
+ }
127
+
128
+ private _request(method: string, params: unknown): Promise<RpcOutcome> {
129
+ return new Promise<RpcOutcome>((resolve) => {
130
+ if (this._closed) {
131
+ resolve({ error: { code: -32000, message: "session closed" } });
132
+ return;
133
+ }
134
+ const id = this._idPrefix + String(++this._nextId);
135
+ const timer = setTimeout(() => {
136
+ this._pending.delete(id);
137
+ resolve({ error: { code: -32000, message: `request "${method}" timed out` } });
138
+ }, REQUEST_TIMEOUT_MS);
139
+ this._pending.set(id, { resolve, timer });
140
+ this._client.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
141
+ });
142
+ }
143
+
144
+ private _handleMessage(data: string): void {
145
+ let msg: IncomingMessage;
146
+ try {
147
+ msg = JSON.parse(data) as IncomingMessage;
148
+ } catch {
149
+ return;
150
+ }
151
+ if (typeof msg.id === "string") {
152
+ const pending = this._pending.get(msg.id);
153
+ if (pending) {
154
+ this._pending.delete(msg.id);
155
+ clearTimeout(pending.timer);
156
+ pending.resolve({ result: msg.result, error: msg.error });
157
+ }
158
+ return;
159
+ }
160
+ if (msg.id == null && (msg.method === "notifications/tools/list_changed" || msg.method === "notifications/prompts/list_changed")) {
161
+ void this._refresh();
162
+ }
163
+ }
164
+
165
+ private _rejectAll(reason: string): void {
166
+ for (const pending of this._pending.values()) {
167
+ clearTimeout(pending.timer);
168
+ pending.resolve({ error: { code: -32000, message: reason } });
169
+ }
170
+ this._pending.clear();
171
+ }
172
+ }
@@ -1,7 +1,6 @@
1
1
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import type { McpClientInfo } from "@cyanmycelium/mcp-core";
5
4
  import { McpGrammar } from "@cyanmycelium/mcp-core";
6
5
 
7
6
  // ---------------------------------------------------------------------------
@@ -10,131 +9,67 @@ import { McpGrammar } from "@cyanmycelium/mcp-core";
10
9
 
11
10
  /**
12
11
  * Locale identifier used to look up a grammar JSON file under
13
- * `<userAgent>/<locale>.json`. Open string: an application can introduce any
14
- * value its custom resolver and JSON resources support.
12
+ * `<userAgent>/<locale>.json`. Open string: a host application can use any
13
+ * value its grammar resources support.
15
14
  *
16
- * The {@link defaultBrokerLocaleResolver} returns the ISO 639-1 prefix of a
17
- * BCP-47 tag (`"fr-CA"` → `"fr"`, `"zh-Hans"` → `"zh"`).
15
+ * The broker registers each `(userAgent, locale)` pair found on disk as a
16
+ * separate `McpGrammar` keyed by {@link brokerGrammarKey}. The actual
17
+ * resolution of "which key to use for this session" is delegated to
18
+ * `@cyanmycelium/mcp-core@0.3.0`'s `grammarResolverFromOptions`, which
19
+ * handles BCP-47 narrowing (`fr-CA` → `fr` → `en`), agent-family fallback,
20
+ * and the optional version dimension natively.
18
21
  */
19
22
  export type BrokerLocale = string;
20
23
 
21
24
  /**
22
25
  * User-agent family identifier used to look up a grammar JSON file under
23
- * `<userAgent>/<locale>.json`. Open string. The {@link defaultBrokerUserAgentResolver}
24
- * recognizes the common LLM families and returns `"default"` for everything else.
26
+ * `<userAgent>/<locale>.json`. Open string. Conventional values follow
27
+ * the defaults emitted by `grammarResolverFromOptions`: `claude`, `gpt`,
28
+ * `mistral`, `copilot`, plus the universal `default`. Custom families
29
+ * are supported by passing a custom `agents` map in
30
+ * `StartBrokerServerOptions.grammarResolverOptions`.
25
31
  */
26
32
  export type BrokerUserAgent = string;
27
33
 
28
34
  // ---------------------------------------------------------------------------
29
- // Resolver function types
30
- // ---------------------------------------------------------------------------
31
-
32
- /**
33
- * Picks an **ordered fallback chain** of {@link BrokerLocale} values from a raw
34
- * input. Typically the raw input is `process.env.MCP_BROKER_LOCALE`, but any
35
- * string source works (HTTP header, session metadata, etc.).
36
- *
37
- * The returned array is consumed by the broker server most-specific-first, so
38
- * the resolver controls the BCP-47 narrowing policy. The default resolver
39
- * follows the standard `lang-region` → `lang` → `en` shape:
40
- *
41
- * ```
42
- * raw = "fr-CA" → ["fr-ca", "fr", "en"]
43
- * raw = "en-US" → ["en-us", "en"]
44
- * raw = "zh" → ["zh", "en"]
45
- * raw = "" → ["en"]
46
- * ```
47
- *
48
- * A custom resolver may shape the chain however it wants — e.g. inject a
49
- * project-specific dialect first, skip the bare language prefix, or pull
50
- * candidates from a session config.
51
- */
52
- export type BrokerLocaleResolver = (raw: string | undefined) => BrokerLocale[];
53
-
54
- /**
55
- * Picks a {@link BrokerUserAgent} from the connecting client's identity. Called
56
- * by the embedded broker `McpServer` once per session, during the MCP
57
- * `initialize` handshake.
58
- */
59
- export type BrokerUserAgentResolver = (clientInfo: McpClientInfo | undefined) => BrokerUserAgent;
60
-
61
- // ---------------------------------------------------------------------------
62
- // Default resolvers
35
+ // Canonical grammar key
63
36
  // ---------------------------------------------------------------------------
64
37
 
65
38
  /**
66
- * Default locale resolver — emits the BCP-47 narrowing chain for a raw locale
67
- * tag, from most specific to least specific, always ending with the universal
68
- * `"en"` fallback.
69
- *
70
- * Steps for an input `raw`:
71
- * 1. Lowercase the input.
72
- * 2. Push it as the most-specific candidate (only if non-empty).
73
- * 3. If it contains a `-` separator, push its bare language prefix next.
74
- * 4. Always push `"en"` last as the universal fallback.
39
+ * Builds the canonical grammar key for the `(userAgent, locale, version?)`
40
+ * matrix the broker registers on disk.
75
41
  *
76
- * The broker server tries each candidate in turn against `<userAgent>/<locale>.json`
77
- * — so dropping a `claude/fr-ca.json` lets Canadian-French Claude clients
78
- * pick up that specific dialect, while clients with `fr` or `fr-FR` fall back
79
- * to `claude/fr.json` or `default/fr.json` automatically.
80
- *
81
- * Examples:
82
- * - `"fr-CA"` → `["fr-ca", "fr", "en"]`
83
- * - `"fr"` → `["fr", "en"]`
84
- * - `"zh-CN"` → `["zh-cn", "zh", "en"]`
85
- * - `"en-US"` → `["en-us", "en"]`
86
- * - `""` / `undefined` → `["en"]`
87
- */
88
- export const defaultBrokerLocaleResolver: BrokerLocaleResolver = (raw) => {
89
- const a = [];
90
- if (raw) {
91
- const sep = "-";
92
- raw = raw.toLowerCase();
93
- a.push(raw);
94
- if (raw.indexOf(sep) !== -1) {
95
- a.push(raw.split(sep)[0]);
96
- }
97
- }
98
- a.push("en");
99
- return a;
100
- };
101
-
102
- /**
103
- * Default user-agent resolver — substring match on `clientInfo.name` against
104
- * a list of known LLM family hints. Unknown clients fall through to
105
- * `"default"` which is the universal baseline.
42
+ * Pattern:
43
+ * - `"<userAgent>:<locale>"` (no version) — e.g. `"claude:fr"`, `"default:en"`
44
+ * - `"<userAgent>:<locale>@<version>"` (versioned) — e.g. `"claude:fr@v2"`
106
45
  *
107
- * This is intentionally a heuristic: MCP does not yet standardize an
108
- * agent-family field in `clientInfo`. Override the resolver in the broker
109
- * options if you need richer logic (header inspection, allow-list, etc.).
46
+ * The colon separator is reserved for the `<ua>:<locale>` composition; the
47
+ * `@` separator is reserved for the optional version suffix. Neither
48
+ * character is allowed inside the identifier segments. This matches the
49
+ * default `composeKey` of `grammarResolverFromOptions` exactly, so a
50
+ * broker-loaded grammar at `claude/fr@v2.json` is automatically picked up
51
+ * when a Claude session resolves to the `claude:fr@v2` candidate.
110
52
  */
111
- export const defaultBrokerUserAgentResolver: BrokerUserAgentResolver = (clientInfo) => {
112
- const n = (clientInfo?.name ?? "").toLowerCase();
113
- if (n.includes("claude")) return "claude";
114
- if (n.includes("gpt") || n.includes("openai")) return "gpt";
115
- if (n.includes("mistral")) return "mistral";
116
- if (n.includes("copilot")) return "copilot";
117
- return "default";
118
- };
119
-
120
- /** @deprecated Use {@link defaultBrokerLocaleResolver}. Kept as backward-compat alias. */
121
- export const resolveBrokerLocale = defaultBrokerLocaleResolver;
122
- /** @deprecated Use {@link defaultBrokerUserAgentResolver}. Kept as backward-compat alias. */
123
- export const resolveBrokerUserAgent: (clientName: string | undefined) => BrokerUserAgent = (clientName) => defaultBrokerUserAgentResolver({ name: clientName ?? "", version: "" });
124
-
125
- // ---------------------------------------------------------------------------
126
- // Canonical grammar key
127
- // ---------------------------------------------------------------------------
53
+ export function brokerGrammarKey(userAgent: BrokerUserAgent, locale: BrokerLocale, version?: string): string {
54
+ const base = `${userAgent}:${locale}`;
55
+ return version ? `${base}@${version}` : base;
56
+ }
128
57
 
129
58
  /**
130
- * Builds the canonical grammar key for the `(userAgent, locale)` matrix.
59
+ * Parses a grammar JSON filename of the form `<locale>.json` or
60
+ * `<locale>@<version>.json` (without the `.json` suffix) into its
61
+ * components. The first `@` (if any) separates locale from version; any
62
+ * additional `@` is folded into the version string.
131
63
  *
132
- * Pattern: `"<userAgent>:<locale>"` — e.g. `"claude:fr"`, `"default:en"`.
133
- * The colon separator is reserved for this composition and never appears in
134
- * user-agent or locale identifiers.
64
+ * Returns `null` when the input cannot be split into a usable locale.
135
65
  */
136
- export function brokerGrammarKey(userAgent: BrokerUserAgent, locale: BrokerLocale): string {
137
- return `${userAgent}:${locale}`;
66
+ export function parseBrokerGrammarStem(stem: string): { locale: BrokerLocale; version?: string } | null {
67
+ const at = stem.indexOf("@");
68
+ if (at < 0) return stem.length > 0 ? { locale: stem } : null;
69
+ const locale = stem.slice(0, at);
70
+ const version = stem.slice(at + 1);
71
+ if (locale.length === 0 || version.length === 0) return null;
72
+ return { locale, version };
138
73
  }
139
74
 
140
75
  // ---------------------------------------------------------------------------
@@ -166,16 +101,21 @@ const GRAMMARS_DIR = join(dirname(fileURLToPath(import.meta.url)), "grammars");
166
101
  const _cache = new Map<string, McpGrammar>();
167
102
 
168
103
  /**
169
- * Loads and caches the grammar for a given `(userAgent, locale)` combination.
170
- * Returns `undefined` (instead of throwing) when the resource file is missing,
171
- * so the caller can implement a fallback chain.
104
+ * Loads and caches the grammar for a given `(userAgent, locale, version?)`
105
+ * combination. Returns `undefined` (instead of throwing) when the resource
106
+ * file is missing, so the caller can implement a fallback chain.
107
+ *
108
+ * Filename convention on disk:
109
+ * - `<userAgent>/<locale>.json` (no version)
110
+ * - `<userAgent>/<locale>@<version>.json` (versioned)
172
111
  */
173
- export function loadBrokerGrammar(userAgent: BrokerUserAgent, locale: BrokerLocale): McpGrammar | undefined {
174
- const key = brokerGrammarKey(userAgent, locale);
112
+ export function loadBrokerGrammar(userAgent: BrokerUserAgent, locale: BrokerLocale, version?: string): McpGrammar | undefined {
113
+ const key = brokerGrammarKey(userAgent, locale, version);
175
114
  const cached = _cache.get(key);
176
115
  if (cached) return cached;
177
116
 
178
- const path = join(GRAMMARS_DIR, userAgent, `${locale}.json`);
117
+ const filename = version ? `${locale}@${version}.json` : `${locale}.json`;
118
+ const path = join(GRAMMARS_DIR, userAgent, filename);
179
119
  if (!existsSync(path)) return undefined;
180
120
 
181
121
  const raw = readFileSync(path, "utf-8");
@@ -194,12 +134,17 @@ export function loadBrokerGrammar(userAgent: BrokerUserAgent, locale: BrokerLoca
194
134
  * grammars and any local overrides. No hard-coded list of supported
195
135
  * user-agents or locales — adding a new grammar is dropping a JSON file.
196
136
  */
197
- export function* iterBrokerGrammarsFrom(grammarsDir: string): Generator<{
137
+ export interface BrokerGrammarEntry {
198
138
  userAgent: BrokerUserAgent;
199
139
  locale: BrokerLocale;
140
+ /** Set only for filenames carrying an `@<version>` suffix. */
141
+ version?: string;
142
+ /** Composed via {@link brokerGrammarKey} from the three segments above. */
200
143
  key: string;
201
144
  grammar: McpGrammar;
202
- }> {
145
+ }
146
+
147
+ export function* iterBrokerGrammarsFrom(grammarsDir: string): Generator<BrokerGrammarEntry> {
203
148
  if (!existsSync(grammarsDir)) return;
204
149
 
205
150
  const userAgents = readdirSync(grammarsDir).sort();
@@ -210,13 +155,25 @@ export function* iterBrokerGrammarsFrom(grammarsDir: string): Generator<{
210
155
  const files = readdirSync(uaDir).sort();
211
156
  for (const file of files) {
212
157
  if (!file.endsWith(".json")) continue;
213
- const locale = file.slice(0, -".json".length);
158
+ const stem = file.slice(0, -".json".length);
159
+ const parsed = parseBrokerGrammarStem(stem);
160
+ if (!parsed) {
161
+ process.stderr.write(`[mcp-broker] Skipping unparseable grammar filename ${file} in ${uaDir}\n`);
162
+ continue;
163
+ }
164
+ const { locale, version } = parsed;
214
165
  const path = join(uaDir, file);
215
166
  try {
216
167
  const raw = readFileSync(path, "utf-8");
217
168
  const data = JSON.parse(raw);
218
169
  const grammar = McpGrammar.fromJSON(data);
219
- yield { userAgent, locale, key: brokerGrammarKey(userAgent, locale), grammar };
170
+ yield {
171
+ userAgent,
172
+ locale,
173
+ version,
174
+ key: brokerGrammarKey(userAgent, locale, version),
175
+ grammar,
176
+ };
220
177
  } catch (err) {
221
178
  process.stderr.write(`[mcp-broker] Failed to load grammar ${path}: ${(err as Error).message}\n`);
222
179
  }
@@ -231,12 +188,7 @@ export function* iterBrokerGrammarsFrom(grammarsDir: string): Generator<{
231
188
  * For local user overrides, see {@link iterBrokerGrammarsFrom} with a custom
232
189
  * directory — typically `.mcp-broker/grammars/` next to the config file.
233
190
  */
234
- export function* iterAvailableBrokerGrammars(): Generator<{
235
- userAgent: BrokerUserAgent;
236
- locale: BrokerLocale;
237
- key: string;
238
- grammar: McpGrammar;
239
- }> {
191
+ export function* iterAvailableBrokerGrammars(): Generator<BrokerGrammarEntry> {
240
192
  yield* iterBrokerGrammarsFrom(GRAMMARS_DIR);
241
193
  }
242
194