@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
@@ -1,9 +1,8 @@
1
- import { McpGrammar, McpServerBuilder, LoopbackTransport } from "@cyanmycelium/mcp-core";
2
- import type { IMcpServer, IMessageTransport } from "@cyanmycelium/mcp-core";
1
+ import { McpServerBuilder, LoopbackTransport } from "@cyanmycelium/mcp-core";
2
+ import type { GrammarResolverOptions, IMcpServer, IMessageTransport } from "@cyanmycelium/mcp-core";
3
3
  import { BrokerInfoBehavior } from "./behaviors/broker.behavior.info.js";
4
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";
5
+ import { iterAvailableBrokerGrammars, iterBrokerGrammarsFrom } from "./broker.grammars.js";
7
6
  import type { BrokerContext } from "./broker.context.js";
8
7
 
9
8
  /**
@@ -22,32 +21,33 @@ export const BROKER_PROVIDER_NAME = "_broker";
22
21
  */
23
22
  export interface StartBrokerServerOptions {
24
23
  /**
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, ...).
24
+ * Overrides for the built-in grammar resolver from `@cyanmycelium/mcp-core`.
25
+ *
26
+ * The broker installs sensible defaults: `localeSource` reads
27
+ * `process.env.MCP_BROKER_LOCALE`, the `agents` map uses the mcp-core
28
+ * defaults (`claude`, `gpt`, `mistral`, `copilot`, `default`), the
29
+ * narrowing chain is BCP-47-style, and `fallbackKey` is `default:en`
30
+ * so the baseline grammar always matches as last resort.
31
+ *
32
+ * Pass partial overrides here to inject a custom `localeSource` (e.g.
33
+ * pull from an HTTP header proxied by your transport), enable the
34
+ * `versionFrom` dimension, or extend the `agents` map with additional
35
+ * LLM families. Anything you omit keeps the broker default.
42
36
  */
43
- localeSource?: () => string | undefined;
37
+ grammarResolverOptions?: Partial<GrammarResolverOptions>;
44
38
 
45
39
  /**
46
40
  * 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.
41
+ * files are registered **in addition to** the packaged grammars.
42
+ *
43
+ * Both packaged and local entries are registered raw against the server
44
+ * via `withGrammar(brokerGrammarKey(ua, locale), grammar)`. The
45
+ * candidate-chain resolution implemented by `McpServer.initialize` in
46
+ * mcp-core@0.3.0 then walks the chain and merges the four layers
47
+ * (behavior, adapter, static, store) for the first matching key —
48
+ * the old hand-rolled pre-merge cascade is no longer needed.
49
49
  *
50
- * When `undefined` (default), only the packaged grammars are used.
50
+ * When `undefined` (default), only the packaged grammars are loaded.
51
51
  */
52
52
  localGrammarsDir?: string;
53
53
  }
@@ -75,87 +75,45 @@ export async function startBrokerServer(
75
75
  server: IMcpServer;
76
76
  clientTransport: IMessageTransport;
77
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
78
  const [serverEnd, clientEnd] = LoopbackTransport.createPair();
83
79
 
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
- }
80
+ // Without an initializer, McpServerBuilder reports `version: "0.0.0"` in the
81
+ // `initialize` handshake. Supply the real package version from the context.
82
+ const builder = new McpServerBuilder()
83
+ .withName(BROKER_PROVIDER_NAME)
84
+ .withTransport(serverEnd)
85
+ .withInitializer({
86
+ initialize: () => ({
87
+ protocolVersion: "2024-11-05",
88
+ serverInfo: { name: BROKER_PROVIDER_NAME, version: context.version },
89
+ }),
90
+ })
91
+ .register(new BrokerInfoBehavior(context), new BrokerProvidersBehavior(context));
92
+
93
+ // Register every `(userAgent, locale)` JSON found on disk as a raw
94
+ // grammar layer. The candidate-chain resolution in
95
+ // McpServer.initialize (mcp-core@0.3.0) walks the resolver's chain
96
+ // and merges all matching layers — so partial user-agent files no
97
+ // longer need to be pre-merged with the default-locale baseline at
98
+ // boot. Local overrides come after packaged entries; identical keys
99
+ // get overlaid via the registry's last-write-wins.
100
+ for (const entry of iterAvailableBrokerGrammars()) {
101
+ builder.withGrammar(entry.key, entry.grammar);
112
102
  }
113
-
114
- for (const entry of iterAvailableBrokerGrammars()) ingest(entry, false);
115
103
  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);
104
+ for (const entry of iterBrokerGrammarsFrom(options.localGrammarsDir)) {
105
+ builder.withGrammar(entry.key, entry.grammar);
137
106
  }
138
107
  }
139
108
 
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 "";
109
+ // Wire the resolver via mcp-core's declarative helper. The broker
110
+ // ships sensible defaults (locale from MCP_BROKER_LOCALE env, agents
111
+ // from mcp-core's catalogue, BCP-47 narrowing, default:en fallback);
112
+ // anything the host application sets via `grammarResolverOptions`
113
+ // takes precedence.
114
+ builder.withGrammarResolver({
115
+ localeSource: () => process.env["MCP_BROKER_LOCALE"],
116
+ ...(options.grammarResolverOptions ?? {}),
159
117
  });
160
118
 
161
119
  // No reconnect policy → the McpServer does not attempt to reopen the loopback
@@ -13,12 +13,10 @@ export {
13
13
  brokerBaselineResourceTemplateName,
14
14
  brokerBaselineToolDescription,
15
15
  brokerGrammarKey,
16
- defaultBrokerLocaleResolver,
17
- defaultBrokerUserAgentResolver,
18
16
  iterAvailableBrokerGrammars,
17
+ iterBrokerGrammarsFrom,
19
18
  loadBrokerGrammar,
20
- resolveBrokerLocale,
21
- resolveBrokerUserAgent,
19
+ parseBrokerGrammarStem,
22
20
  } from "./broker.grammars.js";
23
- export type { BrokerLocale, BrokerLocaleResolver, BrokerUserAgent, BrokerUserAgentResolver } from "./broker.grammars.js";
21
+ export type { BrokerGrammarEntry, BrokerLocale, BrokerUserAgent } from "./broker.grammars.js";
24
22
  export type { BrokerContext, BrokerProviderInfo, BrokerProviderTransport } from "./broker.context.js";
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,20 +3,20 @@ 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";
9
17
  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";
18
+ export { brokerGrammarKey, iterAvailableBrokerGrammars, iterBrokerGrammarsFrom, loadBrokerGrammar } from "./broker/index.js";
19
+ export type { BrokerContext, BrokerProviderInfo, BrokerProviderTransport, BrokerLocale, BrokerUserAgent } from "./broker/index.js";
20
20
 
21
21
  export { VERSION, PACKAGE_NAME } from "./version.js";
22
22
 
@@ -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
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Minimal ZIP extractor for `.mcpb` bundles, built on `node:zlib` only.
3
+ *
4
+ * A `.mcpb` bundle is an ordinary ZIP archive. The broker deliberately avoids
5
+ * the `@anthropic-ai/mcpb` package so it stays compatible with the format
6
+ * without being coupled to Anthropic's tooling — hence this small reader.
7
+ *
8
+ * Supports the two compression methods used in practice: stored (0) and
9
+ * deflate (8). ZIP64 archives are rejected with a clear error (`.mcpb` bundles
10
+ * are small and never need it). Any trailing bytes after the End Of Central
11
+ * Directory record — e.g. a native PKCS#7 signature block — are ignored, since
12
+ * extraction is driven entirely by the central directory.
13
+ */
14
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
16
+ import { inflateRawSync } from "node:zlib";
17
+
18
+ const EOCD_SIGNATURE = 0x06054b50;
19
+ const CENTRAL_HEADER_SIGNATURE = 0x02014b50;
20
+ const LOCAL_HEADER_SIGNATURE = 0x04034b50;
21
+
22
+ /** Locates the End Of Central Directory record by scanning backwards. */
23
+ function findEocd(buf: Buffer): number {
24
+ // The EOCD is 22 bytes plus a comment of up to 65535 bytes.
25
+ const minPos = Math.max(0, buf.length - 22 - 0xffff);
26
+ for (let pos = buf.length - 22; pos >= minPos; pos--) {
27
+ if (buf.readUInt32LE(pos) === EOCD_SIGNATURE) return pos;
28
+ }
29
+ throw new Error("not a ZIP archive (no End Of Central Directory record found)");
30
+ }
31
+
32
+ /**
33
+ * Extracts every entry of the `.mcpb` archive at `mcpbPath` into `destDir`.
34
+ * `destDir` is created if missing. Entries whose resolved path would escape
35
+ * `destDir` (zip-slip) are rejected.
36
+ */
37
+ export function unzipMcpb(mcpbPath: string, destDir: string): void {
38
+ const buf = readFileSync(mcpbPath);
39
+ const eocd = findEocd(buf);
40
+
41
+ const totalEntries = buf.readUInt16LE(eocd + 10);
42
+ const centralDirOffset = buf.readUInt32LE(eocd + 16);
43
+ if (centralDirOffset === 0xffffffff || totalEntries === 0xffff) {
44
+ throw new Error("ZIP64 archives are not supported");
45
+ }
46
+
47
+ const absDest = resolve(destDir);
48
+ mkdirSync(absDest, { recursive: true });
49
+
50
+ let pos = centralDirOffset;
51
+ for (let i = 0; i < totalEntries; i++) {
52
+ if (buf.readUInt32LE(pos) !== CENTRAL_HEADER_SIGNATURE) {
53
+ throw new Error(`corrupt ZIP: bad central directory header at offset ${pos}`);
54
+ }
55
+ const method = buf.readUInt16LE(pos + 10);
56
+ const compressedSize = buf.readUInt32LE(pos + 20);
57
+ const uncompressedSize = buf.readUInt32LE(pos + 24);
58
+ const nameLen = buf.readUInt16LE(pos + 28);
59
+ const extraLen = buf.readUInt16LE(pos + 30);
60
+ const commentLen = buf.readUInt16LE(pos + 32);
61
+ const localHeaderOffset = buf.readUInt32LE(pos + 42);
62
+ const name = buf.toString("utf8", pos + 46, pos + 46 + nameLen);
63
+ pos += 46 + nameLen + extraLen + commentLen;
64
+
65
+ if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localHeaderOffset === 0xffffffff) {
66
+ throw new Error("ZIP64 archives are not supported");
67
+ }
68
+
69
+ // Resolve the destination path and reject zip-slip escapes.
70
+ const target = resolve(absDest, name);
71
+ const rel = relative(absDest, target);
72
+ if (rel.startsWith("..") || isAbsolute(rel)) {
73
+ throw new Error(`unsafe ZIP entry path (zip-slip): ${name}`);
74
+ }
75
+
76
+ // Directory entry.
77
+ if (name.endsWith("/")) {
78
+ mkdirSync(target, { recursive: true });
79
+ continue;
80
+ }
81
+
82
+ // Parse the local header to locate the entry's data.
83
+ if (buf.readUInt32LE(localHeaderOffset) !== LOCAL_HEADER_SIGNATURE) {
84
+ throw new Error(`corrupt ZIP: bad local header for "${name}"`);
85
+ }
86
+ const localNameLen = buf.readUInt16LE(localHeaderOffset + 26);
87
+ const localExtraLen = buf.readUInt16LE(localHeaderOffset + 28);
88
+ const dataStart = localHeaderOffset + 30 + localNameLen + localExtraLen;
89
+ const compressed = buf.subarray(dataStart, dataStart + compressedSize);
90
+
91
+ let data: Buffer;
92
+ if (method === 0) {
93
+ data = compressed;
94
+ } else if (method === 8) {
95
+ data = inflateRawSync(compressed);
96
+ } else {
97
+ throw new Error(`unsupported ZIP compression method ${method} for "${name}"`);
98
+ }
99
+
100
+ mkdirSync(dirname(target), { recursive: true });
101
+ writeFileSync(target, data);
102
+ }
103
+ }