@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
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
37
|
+
grammarResolverOptions?: Partial<GrammarResolverOptions>;
|
|
44
38
|
|
|
45
39
|
/**
|
|
46
40
|
* Path to a user-supplied grammars directory whose `<userAgent>/<locale>.json`
|
|
47
|
-
* files are
|
|
48
|
-
*
|
|
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
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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))
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
package/src/broker/index.ts
CHANGED
|
@@ -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
|
-
|
|
21
|
-
resolveBrokerUserAgent,
|
|
19
|
+
parseBrokerGrammarStem,
|
|
22
20
|
} from "./broker.grammars.js";
|
|
23
|
-
export type {
|
|
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
|
-
|
|
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
|
+
}
|