@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.
- package/.mcp-broker.example/README.md +23 -0
- package/.mcp-broker.example/config.json +11 -0
- package/README.md +1 -16
- package/dist/bin.js +30 -3
- package/dist/bin.js.map +1 -1
- package/dist/broker/aggregate/aggregate.catalog.d.ts +54 -0
- package/dist/broker/aggregate/aggregate.catalog.js +105 -0
- package/dist/broker/aggregate/aggregate.catalog.js.map +1 -0
- package/dist/broker/aggregate/aggregate.server.d.ts +47 -0
- package/dist/broker/aggregate/aggregate.server.js +151 -0
- package/dist/broker/aggregate/aggregate.server.js.map +1 -0
- package/dist/broker/aggregate/provider.client.session.d.ts +52 -0
- package/dist/broker/aggregate/provider.client.session.js +140 -0
- package/dist/broker/aggregate/provider.client.session.js.map +1 -0
- package/dist/broker/broker.grammars.d.ts +50 -86
- package/dist/broker/broker.grammars.js +55 -84
- package/dist/broker/broker.grammars.js.map +1 -1
- package/dist/broker/broker.server.d.ts +23 -21
- package/dist/broker/broker.server.js +33 -71
- package/dist/broker/broker.server.js.map +1 -1
- package/dist/broker/index.d.ts +2 -2
- package/dist/broker/index.js +1 -1
- package/dist/broker/index.js.map +1 -1
- package/dist/config.d.ts +35 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/mcpb.loader.d.ts +24 -0
- package/dist/mcpb.loader.js +161 -0
- package/dist/mcpb.loader.js.map +1 -0
- package/dist/mcpb.unzip.d.ts +6 -0
- package/dist/mcpb.unzip.js +95 -0
- package/dist/mcpb.unzip.js.map +1 -0
- package/dist/remote.transports.d.ts +16 -0
- package/dist/remote.transports.js +297 -0
- package/dist/remote.transports.js.map +1 -0
- package/dist/remote.upstream.d.ts +36 -0
- package/dist/remote.upstream.js +52 -0
- package/dist/remote.upstream.js.map +1 -0
- package/dist/stdio.upstream.d.ts +4 -1
- package/dist/stdio.upstream.js.map +1 -1
- package/dist/upstream.d.ts +33 -0
- package/dist/upstream.js +2 -0
- package/dist/upstream.js.map +1 -0
- package/dist/ws.tunnel.builder.d.ts +14 -8
- package/dist/ws.tunnel.builder.js +17 -9
- package/dist/ws.tunnel.builder.js.map +1 -1
- package/dist/ws.tunnel.d.ts +85 -22
- package/dist/ws.tunnel.js +201 -82
- package/dist/ws.tunnel.js.map +1 -1
- package/package.json +3 -2
- package/scripts/pack-mcpb.mjs +84 -0
- package/scripts/sign-bundle.mjs +61 -0
- package/src/bin.ts +32 -3
- package/src/broker/aggregate/aggregate.catalog.ts +145 -0
- package/src/broker/aggregate/aggregate.server.ts +178 -0
- package/src/broker/aggregate/provider.client.session.ts +172 -0
- package/src/broker/broker.grammars.ts +74 -122
- package/src/broker/broker.server.ts +57 -99
- package/src/broker/index.ts +3 -5
- package/src/config.ts +37 -0
- package/src/index.ts +10 -10
- package/src/mcpb.loader.ts +186 -0
- package/src/mcpb.unzip.ts +103 -0
- package/src/remote.transports.ts +316 -0
- package/src/remote.upstream.ts +75 -0
- package/src/stdio.upstream.ts +4 -1
- package/src/upstream.ts +33 -0
- package/src/ws.tunnel.builder.ts +19 -9
- 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:
|
|
14
|
-
* value its
|
|
12
|
+
* `<userAgent>/<locale>.json`. Open string: a host application can use any
|
|
13
|
+
* value its grammar resources support.
|
|
15
14
|
*
|
|
16
|
-
* The
|
|
17
|
-
*
|
|
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.
|
|
24
|
-
*
|
|
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
|
-
//
|
|
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
|
-
*
|
|
67
|
-
*
|
|
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
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
137
|
-
|
|
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)`
|
|
170
|
-
* Returns `undefined` (instead of throwing) when the resource
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|