@cyanmycelium/mcp-broker 0.2.0 → 0.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 (62) 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.server.js +12 -1
  16. package/dist/broker/broker.server.js.map +1 -1
  17. package/dist/config.d.ts +35 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +4 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/mcpb.loader.d.ts +24 -0
  23. package/dist/mcpb.loader.js +161 -0
  24. package/dist/mcpb.loader.js.map +1 -0
  25. package/dist/mcpb.unzip.d.ts +6 -0
  26. package/dist/mcpb.unzip.js +95 -0
  27. package/dist/mcpb.unzip.js.map +1 -0
  28. package/dist/remote.transports.d.ts +16 -0
  29. package/dist/remote.transports.js +297 -0
  30. package/dist/remote.transports.js.map +1 -0
  31. package/dist/remote.upstream.d.ts +36 -0
  32. package/dist/remote.upstream.js +52 -0
  33. package/dist/remote.upstream.js.map +1 -0
  34. package/dist/stdio.upstream.d.ts +4 -1
  35. package/dist/stdio.upstream.js.map +1 -1
  36. package/dist/upstream.d.ts +33 -0
  37. package/dist/upstream.js +2 -0
  38. package/dist/upstream.js.map +1 -0
  39. package/dist/ws.tunnel.builder.d.ts +14 -8
  40. package/dist/ws.tunnel.builder.js +17 -9
  41. package/dist/ws.tunnel.builder.js.map +1 -1
  42. package/dist/ws.tunnel.d.ts +67 -2
  43. package/dist/ws.tunnel.js +200 -79
  44. package/dist/ws.tunnel.js.map +1 -1
  45. package/package.json +3 -2
  46. package/scripts/pack-mcpb.mjs +84 -0
  47. package/scripts/sign-bundle.mjs +61 -0
  48. package/src/bin.ts +32 -3
  49. package/src/broker/aggregate/aggregate.catalog.ts +145 -0
  50. package/src/broker/aggregate/aggregate.server.ts +178 -0
  51. package/src/broker/aggregate/provider.client.session.ts +172 -0
  52. package/src/broker/broker.server.ts +12 -1
  53. package/src/config.ts +37 -0
  54. package/src/index.ts +8 -0
  55. package/src/mcpb.loader.ts +186 -0
  56. package/src/mcpb.unzip.ts +103 -0
  57. package/src/remote.transports.ts +316 -0
  58. package/src/remote.upstream.ts +75 -0
  59. package/src/stdio.upstream.ts +4 -1
  60. package/src/upstream.ts +33 -0
  61. package/src/ws.tunnel.builder.ts +19 -9
  62. package/src/ws.tunnel.ts +239 -74
@@ -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
+ }
@@ -81,7 +81,18 @@ export async function startBrokerServer(
81
81
 
82
82
  const [serverEnd, clientEnd] = LoopbackTransport.createPair();
83
83
 
84
- const builder = new McpServerBuilder().withName(BROKER_PROVIDER_NAME).withTransport(serverEnd).register(new BrokerInfoBehavior(context), new BrokerProvidersBehavior(context));
84
+ // Without an initializer, McpServerBuilder reports `version: "0.0.0"` in the
85
+ // `initialize` handshake. Supply the real package version from the context.
86
+ const builder = new McpServerBuilder()
87
+ .withName(BROKER_PROVIDER_NAME)
88
+ .withTransport(serverEnd)
89
+ .withInitializer({
90
+ initialize: () => ({
91
+ protocolVersion: "2024-11-05",
92
+ serverInfo: { name: BROKER_PROVIDER_NAME, version: context.version },
93
+ }),
94
+ })
95
+ .register(new BrokerInfoBehavior(context), new BrokerProvidersBehavior(context));
85
96
 
86
97
  // Discover every grammar by scanning two directories:
87
98
  // 1. The packaged grammars shipped with the broker.
package/src/config.ts CHANGED
@@ -73,6 +73,43 @@ export interface BrokerConfig {
73
73
  command: string;
74
74
  args?: string[];
75
75
  env?: Record<string, string>;
76
+ /** When `true`, the upstream joins the `_all` aggregate slot once connected. */
77
+ aggregate?: boolean;
78
+ }>;
79
+
80
+ /**
81
+ * Remote MCP servers the broker connects out to and exposes as provider
82
+ * slots. Each entry is reached by URL (Streamable HTTP / SSE / WebSocket);
83
+ * local servers should be shipped as `.mcpb` bundles instead.
84
+ */
85
+ mcpServers?: Array<{
86
+ name: string;
87
+ url: string;
88
+ transport?: "streamable-http" | "sse" | "websocket";
89
+ headers?: Record<string, string>;
90
+ /** Defaults to `true`; set to `false` to exclude this upstream from the `_all` aggregate slot. */
91
+ aggregate?: boolean;
92
+ }>;
93
+
94
+ /**
95
+ * Local `.mcpb` bundles the broker loads at startup and runs as stdio
96
+ * provider slots. A bundle is a ZIP with a `manifest.json`; the broker
97
+ * verifies a detached signature against a trusted public key before
98
+ * unpacking and spawning it.
99
+ */
100
+ mcpbBundles?: Array<{
101
+ /** Provider slot name the bundle is bound to. */
102
+ name: string;
103
+ /** Path to the `.mcpb` file (resolved against the config file's directory). */
104
+ path: string;
105
+ /** Path to the trusted public key (PEM) used to verify the detached signature. */
106
+ publicKey: string;
107
+ /** Path to the detached signature file. Defaults to `<path>.sig`. */
108
+ signature?: string;
109
+ /** Values substituted into the manifest's `${user_config.*}` placeholders. */
110
+ userConfig?: Record<string, string | number | boolean | Array<string | number>>;
111
+ /** Defaults to `true`; set to `false` to exclude this bundle from the `_all` aggregate slot. */
112
+ aggregate?: boolean;
76
113
  }>;
77
114
  }
78
115
 
package/src/index.ts CHANGED
@@ -3,6 +3,14 @@ export { WsTunnelBuilder } from "./ws.tunnel.builder.js";
3
3
  export type { WsTunnelOptions, StaticMount } from "./ws.tunnel.js";
4
4
  export { StdioUpstream } from "./stdio.upstream.js";
5
5
  export type { StdioUpstreamConfig } from "./stdio.upstream.js";
6
+ export { RemoteUpstream } from "./remote.upstream.js";
7
+ export type { RemoteUpstreamConfig } from "./remote.upstream.js";
8
+ export type { Upstream } from "./upstream.js";
9
+
10
+ // `.mcpb` bundle loading — verifies + unpacks a bundle into a stdio upstream.
11
+ export { loadMcpbBundle } from "./mcpb.loader.js";
12
+ export type { McpbBundleConfig } from "./mcpb.loader.js";
13
+ export { unzipMcpb } from "./mcpb.unzip.js";
6
14
 
7
15
  // Broker introspection — tier 1.
8
16
  export { BrokerInfoBehavior, BrokerProvidersBehavior, startBrokerServer, BROKER_PROVIDER_NAME } from "./broker/index.js";
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Loads a local `.mcpb` bundle into a {@link StdioUpstreamConfig}.
3
+ *
4
+ * A `.mcpb` bundle is a ZIP holding a `manifest.json` whose `server.mcp_config`
5
+ * describes a stdio MCP server process. The broker:
6
+ *
7
+ * 1. verifies a **detached signature** of the `.mcpb` file against a trusted
8
+ * public key (PEM) — integrity *and* provenance, using `node:crypto` only;
9
+ * 2. unpacks the archive;
10
+ * 3. reads the manifest, expands the `mcp_config` placeholders, and produces a
11
+ * `StdioUpstreamConfig` that the existing upstream wiring spawns.
12
+ *
13
+ * The broker stays compatible with the `.mcpb` format without depending on the
14
+ * `@anthropic-ai/mcpb` package: the bundle's *own* (native PKCS#7) signature is
15
+ * never parsed — the detached signature layer is the broker's trust anchor.
16
+ *
17
+ * Any failure (missing files, bad signature, malformed manifest, missing
18
+ * `user_config` value) is logged and yields `null`: the bundle is refused and
19
+ * no process is ever spawned. Never auto-runs an unverified bundle.
20
+ */
21
+ import { createPublicKey, verify, X509Certificate, type KeyObject } from "node:crypto";
22
+ import { existsSync, readFileSync, rmSync } from "node:fs";
23
+ import { homedir } from "node:os";
24
+ import { join, resolve, sep } from "node:path";
25
+ import { unzipMcpb } from "./mcpb.unzip.js";
26
+ import type { StdioUpstreamConfig } from "./stdio.upstream.js";
27
+
28
+ /** A `.mcpb` bundle entry from the broker config file. */
29
+ export interface McpbBundleConfig {
30
+ /** Provider slot name the bundle is bound to. */
31
+ name: string;
32
+ /** Path to the `.mcpb` file. */
33
+ path: string;
34
+ /** Path to the trusted public key (PEM) verifying the detached signature. */
35
+ publicKey: string;
36
+ /** Path to the detached signature file. Defaults to `<path>.sig`. */
37
+ signature?: string;
38
+ /** Values substituted into the manifest's `${user_config.*}` placeholders. */
39
+ userConfig?: Record<string, string | number | boolean | Array<string | number>>;
40
+ /** When `false`, the bundle stays out of the `_all` aggregate slot. Defaults to `true`. */
41
+ aggregate?: boolean;
42
+ }
43
+
44
+ interface McpConfig {
45
+ command?: string;
46
+ args?: string[];
47
+ env?: Record<string, string>;
48
+ platform_overrides?: Record<string, { command?: string; args?: string[]; env?: Record<string, string> }>;
49
+ }
50
+
51
+ /** Loads a PEM that holds either an X.509 certificate or a bare public key. */
52
+ function loadPublicKey(pem: string): KeyObject {
53
+ if (pem.includes("BEGIN CERTIFICATE")) {
54
+ return new X509Certificate(pem).publicKey;
55
+ }
56
+ return createPublicKey(pem);
57
+ }
58
+
59
+ /** Verifies the detached `signature` of `data` against `publicKey`. */
60
+ function verifyDetachedSignature(data: Buffer, signature: Buffer, publicKey: KeyObject): boolean {
61
+ // Ed25519/Ed448 are used without a separate hash algorithm; RSA/EC need one.
62
+ const keyType = publicKey.asymmetricKeyType;
63
+ const algorithm = keyType === "ed25519" || keyType === "ed448" ? null : "sha256";
64
+ return verify(algorithm, data, publicKey, signature);
65
+ }
66
+
67
+ /** Resolves a single `${...}` placeholder key, or `undefined` when unknown. */
68
+ function resolvePlaceholder(key: string, dirname: string, userConfig: McpbBundleConfig["userConfig"]): string | number | boolean | Array<string | number> | undefined {
69
+ if (key === "__dirname") return dirname;
70
+ if (key === "HOME") return homedir();
71
+ if (key === "DESKTOP") return join(homedir(), "Desktop");
72
+ if (key === "DOCUMENTS") return join(homedir(), "Documents");
73
+ if (key === "DOWNLOADS") return join(homedir(), "Downloads");
74
+ if (key === "pathSeparator" || key === "/") return sep;
75
+ if (key.startsWith("user_config.")) {
76
+ const name = key.slice("user_config.".length);
77
+ const value = userConfig?.[name];
78
+ if (value === undefined) throw new Error(`missing user_config value: "${name}"`);
79
+ return value;
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ /** Expands placeholders in a scalar string (command, env value). */
85
+ function expandScalar(input: string, dirname: string, userConfig: McpbBundleConfig["userConfig"]): string {
86
+ return input.replace(/\$\{([^}]+)\}/g, (match, key: string) => {
87
+ const value = resolvePlaceholder(key, dirname, userConfig);
88
+ if (value === undefined) return match; // unknown placeholder — leave verbatim
89
+ if (Array.isArray(value)) throw new Error(`placeholder "\${${key}}" is multi-valued and cannot be used here`);
90
+ return String(value);
91
+ });
92
+ }
93
+
94
+ /** Expands one manifest argument; a standalone multi-valued placeholder spreads. */
95
+ function expandArg(arg: string, dirname: string, userConfig: McpbBundleConfig["userConfig"]): string[] {
96
+ const standalone = /^\$\{(user_config\.[^}]+)\}$/.exec(arg);
97
+ if (standalone) {
98
+ const value = resolvePlaceholder(standalone[1], dirname, userConfig);
99
+ if (Array.isArray(value)) return value.map(String);
100
+ return [String(value)];
101
+ }
102
+ return [expandScalar(arg, dirname, userConfig)];
103
+ }
104
+
105
+ /**
106
+ * Verifies, unpacks and resolves a `.mcpb` bundle into a `StdioUpstreamConfig`.
107
+ *
108
+ * @param cfg The bundle entry from the broker config.
109
+ * @param baseDir Directory the bundle paths are resolved against.
110
+ * @returns A ready upstream config, or `null` when the bundle is refused.
111
+ */
112
+ export async function loadMcpbBundle(cfg: McpbBundleConfig, baseDir: string): Promise<StdioUpstreamConfig | null> {
113
+ const tag = `[mcp-broker] mcpb bundle "${cfg.name}"`;
114
+ try {
115
+ const mcpbPath = resolve(baseDir, cfg.path);
116
+ const publicKeyPath = resolve(baseDir, cfg.publicKey);
117
+ const signaturePath = cfg.signature ? resolve(baseDir, cfg.signature) : `${mcpbPath}.sig`;
118
+
119
+ for (const [label, file] of [
120
+ ["bundle", mcpbPath],
121
+ ["public key", publicKeyPath],
122
+ ["signature", signaturePath],
123
+ ] as const) {
124
+ if (!existsSync(file)) {
125
+ console.error(`${tag}: ${label} file not found at ${file} — bundle refused.`);
126
+ return null;
127
+ }
128
+ }
129
+
130
+ // ── Signature verification (mandatory) ──────────────────────────────
131
+ const bundleBytes = readFileSync(mcpbPath);
132
+ const signatureBytes = readFileSync(signaturePath);
133
+ const publicKey = loadPublicKey(readFileSync(publicKeyPath, "utf8"));
134
+ if (!verifyDetachedSignature(bundleBytes, signatureBytes, publicKey)) {
135
+ console.error(`${tag}: detached signature is invalid for the configured public key — bundle refused.`);
136
+ return null;
137
+ }
138
+
139
+ // ── Unpack ──────────────────────────────────────────────────────────
140
+ const outputDir = resolve(baseDir, ".cache", "mcpb", cfg.name);
141
+ rmSync(outputDir, { recursive: true, force: true });
142
+ unzipMcpb(mcpbPath, outputDir);
143
+
144
+ // ── Manifest → mcp_config ───────────────────────────────────────────
145
+ const manifestPath = join(outputDir, "manifest.json");
146
+ if (!existsSync(manifestPath)) {
147
+ console.error(`${tag}: manifest.json missing inside the bundle — bundle refused.`);
148
+ return null;
149
+ }
150
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { server?: { mcp_config?: McpConfig } };
151
+ const mcpConfig = manifest.server?.mcp_config;
152
+ if (!mcpConfig) {
153
+ console.error(`${tag}: manifest has no server.mcp_config — bundle refused.`);
154
+ return null;
155
+ }
156
+
157
+ // Platform-specific overrides replace the base fields when present.
158
+ const override = mcpConfig.platform_overrides?.[process.platform];
159
+ const command = override?.command ?? mcpConfig.command;
160
+ const rawArgs = override?.args ?? mcpConfig.args ?? [];
161
+ const rawEnv = { ...mcpConfig.env, ...override?.env };
162
+ if (!command) {
163
+ console.error(`${tag}: manifest mcp_config has no command — bundle refused.`);
164
+ return null;
165
+ }
166
+
167
+ // ── Placeholder expansion ───────────────────────────────────────────
168
+ const expandedCommand = expandScalar(command, outputDir, cfg.userConfig);
169
+ const expandedArgs = rawArgs.flatMap((arg) => expandArg(arg, outputDir, cfg.userConfig));
170
+ const expandedEnv: Record<string, string> = {};
171
+ for (const [key, value] of Object.entries(rawEnv)) {
172
+ expandedEnv[key] = expandScalar(value, outputDir, cfg.userConfig);
173
+ }
174
+
175
+ return {
176
+ name: cfg.name,
177
+ command: expandedCommand,
178
+ args: expandedArgs,
179
+ env: Object.keys(expandedEnv).length > 0 ? expandedEnv : undefined,
180
+ aggregate: cfg.aggregate ?? true,
181
+ };
182
+ } catch (err) {
183
+ console.error(`${tag}: ${(err as Error).message} — bundle refused.`);
184
+ return null;
185
+ }
186
+ }