@aiwerk/mcp-bridge 2.7.6 → 2.8.2

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/README.md CHANGED
@@ -27,7 +27,7 @@ Most AI agents connect to MCP servers one-by-one. With 10+ servers, that's 10+ c
27
27
  - **Graceful shutdown**: clean process termination and connection cleanup
28
28
  - **Direct mode**: all tools registered individually with automatic prefixing
29
29
  - **3 transports**: stdio, SSE, streamable-http
30
- - **Built-in catalog**: 14 pre-configured servers, install with one command
30
+ - **Built-in catalog**: 14 pre-configured servers, install with one command (bundled servers deprecated — use [MCP Catalog](https://catalog.aiwerk.ch) with 104+ recipes instead)
31
31
  - **Zero config secrets in files**: `${ENV_VAR}` resolution from `.env`
32
32
 
33
33
  ## Install
@@ -87,6 +87,80 @@ npx @aiwerk/mcp-bridge validate-recipe ./recipe.json
87
87
 
88
88
  `config.json` (v1) remains supported, but `recipe.json` (v2) is the recommended format going forward.
89
89
 
90
+ ## Catalog Integration (v2.8.0+)
91
+
92
+ mcp-bridge now fetches recipes from [catalog.aiwerk.ch](https://catalog.aiwerk.ch) instead of relying on bundled recipe files.
93
+
94
+ ### How it works
95
+ 1. **First run**: Automatically downloads the top 15 most popular recipes
96
+ 2. **On-demand**: When you install a server, it checks the catalog first
97
+ 3. **Offline**: Falls back to local cache if catalog is unreachable
98
+
99
+ ### API
100
+ ```typescript
101
+ import { CatalogClient, bootstrapCatalog, mergeRecipesIntoConfig } from '@aiwerk/mcp-bridge';
102
+
103
+ // Bootstrap: download top recipes
104
+ await bootstrapCatalog();
105
+
106
+ // Or use the client directly
107
+ const client = new CatalogClient();
108
+ const recipe = await client.resolve('todoist');
109
+ const results = await client.search('email');
110
+ ```
111
+
112
+ ### Catalog & Auto-Merge Options
113
+
114
+ Two config options control catalog behavior:
115
+
116
+ | Option | Type | Default | Description |
117
+ |--------|------|---------|-------------|
118
+ | `catalog` | `boolean` | `true` | Whether `bootstrapCatalog()` fetches recipes from the remote catalog |
119
+ | `autoMerge` | `boolean` | `false` | Whether `mergeRecipesIntoConfig()` auto-merges cached recipes into your config |
120
+
121
+ ```json
122
+ {
123
+ "catalog": true,
124
+ "autoMerge": true,
125
+ "servers": { ... }
126
+ }
127
+ ```
128
+
129
+ - **`autoMerge` defaults to `false`** (opt-in) — cached recipes are **not** automatically added to your server list unless you explicitly enable it. This prevents servers without required credentials from being silently activated.
130
+ - **`catalog` defaults to `true`** — recipe discovery from [catalog.aiwerk.ch](https://catalog.aiwerk.ch) is enabled by default. Set to `false` to skip all remote fetching.
131
+
132
+ > **Breaking change (v2.9.0):** Previously, all cached recipes whose env vars were present were auto-merged. Now you must set `"autoMerge": true` to restore that behavior.
133
+
134
+ ### Multiple instances of the same server
135
+
136
+ Auto-discovery uses the recipe name as the config key (e.g., `gohighlevel`). If you need **multiple instances** of the same server with different credentials (e.g., two GoHighLevel subaccounts), configure them manually:
137
+
138
+ ```json
139
+ // config.json or openclaw.json
140
+ {
141
+ "ghl-client-a": {
142
+ "transport": "streamable-http",
143
+ "url": "https://services.leadconnectorhq.com/mcp/",
144
+ "headers": {
145
+ "Authorization": "Bearer ${GHL_TOKEN_A}",
146
+ "locationId": "${GHL_LOCATION_A}"
147
+ }
148
+ },
149
+ "ghl-client-b": {
150
+ "transport": "streamable-http",
151
+ "url": "https://services.leadconnectorhq.com/mcp/",
152
+ "headers": {
153
+ "Authorization": "Bearer ${GHL_TOKEN_B}",
154
+ "locationId": "${GHL_LOCATION_B}"
155
+ }
156
+ }
157
+ }
158
+ ```
159
+
160
+ Use **unique env var names** (e.g., `GHL_TOKEN_A` instead of `GHL_PIT_TOKEN`) to prevent auto-discovery from adding a duplicate third entry. Manual config always takes priority over auto-discovered recipes.
161
+
162
+ > **Note**: The bundled `servers/` directory is deprecated and will be removed in v3.0.0.
163
+
90
164
  ## Use with Cursor / Windsurf
91
165
 
92
166
  Add to your MCP config:
@@ -595,7 +669,7 @@ For production deployments with high security requirements, consider adding an e
595
669
  | ✅ | OAuth2 Device Code flow (headless) | 2.6.0 |
596
670
  | 🔜 | Auto-discovery (zero-config server registration) | planned |
597
671
  | 🔜 | Hosted bridge (bridge.aiwerk.ch) | planned |
598
- | 🔜 | Remote catalog integration | planned |
672
+ | | Remote catalog integration | 2.8.0 |
599
673
  | 🔜 | OpenTelemetry / Prometheus metrics | planned |
600
674
  | 🔜 | PII redaction | planned |
601
675
 
@@ -4,7 +4,7 @@ import { join, dirname, resolve, extname } from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import { platform, homedir } from "os";
6
6
  import { execFileSync } from "child_process";
7
- import { loadConfig, initConfigDir } from "../src/config.js";
7
+ import { loadConfig, initConfigDir, warnDeprecatedBundledRecipes } from "../src/config.js";
8
8
  import { StandaloneServer } from "../src/standalone-server.js";
9
9
  import { PACKAGE_VERSION } from "../src/protocol.js";
10
10
  import { checkForUpdate, runUpdate } from "../src/update-checker.js";
@@ -554,6 +554,8 @@ async function cmdServe(args, logger) {
554
554
  logger.error("HTTP auth not configured. Set http.auth in config or use stdio mode.");
555
555
  process.exit(1);
556
556
  }
557
+ // Warn about deprecated bundled recipes (v2.8.0+)
558
+ warnDeprecatedBundledRecipes(logger);
557
559
  const server = new StandaloneServer(config, logger);
558
560
  // Graceful shutdown
559
561
  const shutdown = async () => {
@@ -0,0 +1,102 @@
1
+ /**
2
+ * CatalogClient — REST client for the AIWerk MCP Catalog API.
3
+ *
4
+ * Default endpoint: https://catalog.aiwerk.ch
5
+ * Supports local file caching with offline fallback.
6
+ */
7
+ import type { Logger } from "./types.js";
8
+ export interface CatalogSearchResult {
9
+ name: string;
10
+ description?: string;
11
+ category?: string;
12
+ quality?: number;
13
+ [key: string]: unknown;
14
+ }
15
+ export interface CatalogRecipe {
16
+ name: string;
17
+ description?: string;
18
+ transport?: "stdio" | "sse" | "streamable-http";
19
+ command?: string;
20
+ args?: string[];
21
+ env?: Record<string, string>;
22
+ url?: string;
23
+ headers?: Record<string, string>;
24
+ transports?: Array<{
25
+ type: "stdio" | "sse" | "streamable-http";
26
+ command?: string;
27
+ args?: string[];
28
+ env?: Record<string, string>;
29
+ url?: string;
30
+ headers?: Record<string, string>;
31
+ }>;
32
+ install?: {
33
+ npm?: {
34
+ package: string;
35
+ version?: string;
36
+ };
37
+ docker?: {
38
+ image: string;
39
+ };
40
+ };
41
+ auth?: {
42
+ type: string;
43
+ required?: boolean;
44
+ envVars?: string[];
45
+ };
46
+ [key: string]: unknown;
47
+ }
48
+ export declare class CatalogError extends Error {
49
+ constructor(message: string);
50
+ }
51
+ /**
52
+ * CatalogClient — REST client for the AIWerk MCP Catalog API.
53
+ *
54
+ * NOTE: File I/O operations (readCache, writeCache, etc.) are intentionally
55
+ * synchronous. This is acceptable for CLI tools and bridge startup, but
56
+ * should be converted to async if used in hot paths (e.g., per-request).
57
+ */
58
+ export declare class CatalogClient {
59
+ private baseUrl;
60
+ private cacheDir;
61
+ private logger;
62
+ private staleMs;
63
+ constructor(opts?: {
64
+ baseUrl?: string;
65
+ cacheDir?: string;
66
+ logger?: Logger;
67
+ staleDays?: number;
68
+ });
69
+ private fetchJson;
70
+ private cachePath;
71
+ private readCache;
72
+ private writeCache;
73
+ private isCacheStale;
74
+ /** Search for recipes by keyword. */
75
+ search(query: string): Promise<CatalogSearchResult[]>;
76
+ /** List recipes with optional filtering. */
77
+ list(opts?: {
78
+ limit?: number;
79
+ category?: string;
80
+ sort?: string;
81
+ hostedSafe?: boolean;
82
+ }): Promise<{
83
+ results: CatalogSearchResult[];
84
+ total: number;
85
+ }>;
86
+ /** Download a recipe from the catalog and cache it locally. */
87
+ download(name: string): Promise<CatalogRecipe>;
88
+ /**
89
+ * Resolve a recipe — returns cached if available, otherwise fetches from catalog.
90
+ * Falls back to cache when the catalog is unreachable (offline mode).
91
+ */
92
+ resolve(name: string): Promise<CatalogRecipe>;
93
+ /**
94
+ * Bootstrap by downloading the top N most popular recipes.
95
+ * Skips already-cached recipes unless they are stale.
96
+ */
97
+ bootstrap(limit?: number, hostedSafe?: boolean): Promise<string[]>;
98
+ /** Synchronously read a recipe from local cache. Returns null if not cached. */
99
+ getCached(name: string): CatalogRecipe | null;
100
+ /** List all recipe names in the local cache. */
101
+ listCached(): string[];
102
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * CatalogClient — REST client for the AIWerk MCP Catalog API.
3
+ *
4
+ * Default endpoint: https://catalog.aiwerk.ch
5
+ * Supports local file caching with offline fallback.
6
+ */
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ // ── Error ────────────────────────────────────────────────────────────────────
11
+ export class CatalogError extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = "CatalogError";
15
+ }
16
+ }
17
+ // ── Helpers ──────────────────────────────────────────────────────────────────
18
+ const TIMEOUT_MS = 5_000;
19
+ const noop = {
20
+ info: () => { },
21
+ warn: () => { },
22
+ error: () => { },
23
+ debug: () => { },
24
+ };
25
+ // ── CatalogClient ────────────────────────────────────────────────────────────
26
+ /**
27
+ * CatalogClient — REST client for the AIWerk MCP Catalog API.
28
+ *
29
+ * NOTE: File I/O operations (readCache, writeCache, etc.) are intentionally
30
+ * synchronous. This is acceptable for CLI tools and bridge startup, but
31
+ * should be converted to async if used in hot paths (e.g., per-request).
32
+ */
33
+ export class CatalogClient {
34
+ baseUrl;
35
+ cacheDir;
36
+ logger;
37
+ staleMs;
38
+ constructor(opts) {
39
+ this.baseUrl = (opts?.baseUrl ?? "https://catalog.aiwerk.ch").replace(/\/+$/, "");
40
+ this.cacheDir = opts?.cacheDir ?? join(homedir(), ".mcp-bridge", "recipes");
41
+ this.logger = opts?.logger ?? noop;
42
+ this.staleMs = (opts?.staleDays ?? 7) * 24 * 60 * 60 * 1000;
43
+ }
44
+ // ── Private helpers ──────────────────────────────────────────────────────
45
+ async fetchJson(path) {
46
+ const controller = new AbortController();
47
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
48
+ try {
49
+ const res = await fetch(`${this.baseUrl}${path}`, {
50
+ headers: { accept: "application/json" },
51
+ signal: controller.signal,
52
+ });
53
+ if (res.status === 404) {
54
+ const name = path.split("/").filter(Boolean).pop() ?? path;
55
+ throw new CatalogError(`Recipe not found: ${name}`);
56
+ }
57
+ if (!res.ok) {
58
+ throw new CatalogError(`Catalog HTTP ${res.status}: ${await res.text().catch(() => "")}`);
59
+ }
60
+ return (await res.json());
61
+ }
62
+ finally {
63
+ clearTimeout(timer);
64
+ }
65
+ }
66
+ cachePath(name) {
67
+ return join(this.cacheDir, name, "recipe.json");
68
+ }
69
+ readCache(name) {
70
+ const p = this.cachePath(name);
71
+ if (!existsSync(p))
72
+ return null;
73
+ try {
74
+ return JSON.parse(readFileSync(p, "utf-8"));
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ writeCache(name, data) {
81
+ const dir = join(this.cacheDir, name);
82
+ if (!existsSync(dir))
83
+ mkdirSync(dir, { recursive: true });
84
+ writeFileSync(this.cachePath(name), JSON.stringify(data, null, 2), "utf-8");
85
+ }
86
+ isCacheStale(name) {
87
+ const p = this.cachePath(name);
88
+ if (!existsSync(p))
89
+ return true;
90
+ try {
91
+ const stat = statSync(p);
92
+ return Date.now() - stat.mtimeMs > this.staleMs;
93
+ }
94
+ catch {
95
+ return true;
96
+ }
97
+ }
98
+ // ── Public API ───────────────────────────────────────────────────────────
99
+ /** Search for recipes by keyword. */
100
+ async search(query) {
101
+ const encoded = encodeURIComponent(query);
102
+ return this.fetchJson(`/api/search?q=${encoded}`);
103
+ }
104
+ /** List recipes with optional filtering. */
105
+ async list(opts) {
106
+ const params = new URLSearchParams();
107
+ if (opts?.limit != null)
108
+ params.set("limit", String(opts.limit));
109
+ if (opts?.category)
110
+ params.set("category", opts.category);
111
+ if (opts?.sort)
112
+ params.set("sort", opts.sort);
113
+ if (opts?.hostedSafe)
114
+ params.set("hostedSafe", "true");
115
+ const qs = params.toString();
116
+ return this.fetchJson(`/api/recipes${qs ? `?${qs}` : ""}`);
117
+ }
118
+ /** Download a recipe from the catalog and cache it locally. */
119
+ async download(name) {
120
+ const recipe = await this.fetchJson(`/api/recipes/${encodeURIComponent(name)}/download`);
121
+ this.writeCache(name, recipe);
122
+ return recipe;
123
+ }
124
+ /**
125
+ * Resolve a recipe — returns cached if available, otherwise fetches from catalog.
126
+ * Falls back to cache when the catalog is unreachable (offline mode).
127
+ */
128
+ async resolve(name) {
129
+ const cached = this.readCache(name);
130
+ if (cached && !this.isCacheStale(name))
131
+ return cached;
132
+ try {
133
+ const recipe = await this.fetchJson(`/api/recipes/${encodeURIComponent(name)}/download`);
134
+ this.writeCache(name, recipe);
135
+ return recipe;
136
+ }
137
+ catch (err) {
138
+ if (err instanceof CatalogError && err.message.startsWith("Recipe not found:")) {
139
+ throw err;
140
+ }
141
+ if (cached) {
142
+ this.logger.warn(`Catalog unreachable for "${name}", using cached version`);
143
+ return cached;
144
+ }
145
+ throw new CatalogError(`Cannot resolve recipe "${name}": catalog unreachable and no local cache`);
146
+ }
147
+ }
148
+ /**
149
+ * Bootstrap by downloading the top N most popular recipes.
150
+ * Skips already-cached recipes unless they are stale.
151
+ */
152
+ async bootstrap(limit = 15, hostedSafe = false) {
153
+ const { results } = await this.list({ limit, sort: "popular", hostedSafe });
154
+ const names = [];
155
+ const toDownload = [];
156
+ for (const entry of results) {
157
+ const name = entry.name;
158
+ if (!this.isCacheStale(name)) {
159
+ names.push(name);
160
+ continue;
161
+ }
162
+ toDownload.push(name);
163
+ }
164
+ const BATCH_SIZE = 5;
165
+ for (let i = 0; i < toDownload.length; i += BATCH_SIZE) {
166
+ const batch = toDownload.slice(i, i + BATCH_SIZE);
167
+ const results = await Promise.allSettled(batch.map(async (name) => {
168
+ await this.download(name);
169
+ return name;
170
+ }));
171
+ for (const r of results) {
172
+ if (r.status === "fulfilled") {
173
+ names.push(r.value);
174
+ }
175
+ else {
176
+ this.logger.warn(`Failed to download recipe: ${r.reason}`);
177
+ }
178
+ }
179
+ }
180
+ return names;
181
+ }
182
+ /** Synchronously read a recipe from local cache. Returns null if not cached. */
183
+ getCached(name) {
184
+ return this.readCache(name);
185
+ }
186
+ /** List all recipe names in the local cache. */
187
+ listCached() {
188
+ if (!existsSync(this.cacheDir))
189
+ return [];
190
+ return readdirSync(this.cacheDir, { withFileTypes: true })
191
+ .filter((d) => d.isDirectory() && existsSync(join(this.cacheDir, d.name, "recipe.json")))
192
+ .map((d) => d.name);
193
+ }
194
+ }
@@ -24,7 +24,39 @@ export interface LoadConfigOptions {
24
24
  * 4. Validate required fields
25
25
  */
26
26
  export declare function loadConfig(options?: LoadConfigOptions): BridgeConfig;
27
+ /**
28
+ * Warn about deprecated bundled recipes.
29
+ * In v2.8.0, bundled servers/ recipes are deprecated in favor of catalog.
30
+ * They will be removed in v3.0.0.
31
+ */
32
+ export declare function warnDeprecatedBundledRecipes(logger: Logger): void;
27
33
  /** Get the default config directory path. */
28
34
  export declare function getConfigDir(configPath?: string): string;
29
35
  /** Initialize the config directory with template files. */
30
36
  export declare function initConfigDir(logger: Logger): void;
37
+ /**
38
+ * Bootstrap the local recipe cache from the catalog.
39
+ * Downloads top N popular recipes if cache is empty or force=true.
40
+ * Returns array of recipe names now cached. Never throws on network errors.
41
+ */
42
+ export declare function bootstrapCatalog(options?: {
43
+ logger?: Logger;
44
+ cacheDir?: string;
45
+ catalogUrl?: string;
46
+ limit?: number;
47
+ force?: boolean;
48
+ requireCleanAudit?: boolean;
49
+ catalog?: boolean;
50
+ }): Promise<string[]>;
51
+ /**
52
+ * Merge cached catalog recipes into a BridgeConfig.
53
+ * Only adds recipes whose required env vars are all present in process.env.
54
+ * Never overwrites manually configured servers.
55
+ *
56
+ * IMPORTANT: Must be called AFTER loadConfig() / dotenv, since env var
57
+ * checks rely on process.env being fully populated.
58
+ */
59
+ export declare function mergeRecipesIntoConfig(config: BridgeConfig, options?: {
60
+ cacheDir?: string;
61
+ logger?: Logger;
62
+ }): BridgeConfig;
@@ -3,6 +3,7 @@ import { join, extname } from "path";
3
3
  import { homedir } from "os";
4
4
  import { resolveEnvVars } from "./transport-base.js";
5
5
  import { randomBytes } from "crypto";
6
+ import { CatalogClient } from "./catalog-client.js";
6
7
  const DEFAULT_CONFIG_DIR = join(homedir(), ".mcp-bridge");
7
8
  const DEFAULT_CONFIG_FILE = "config.json";
8
9
  const DEFAULT_ENV_FILE = ".env";
@@ -153,6 +154,18 @@ export function loadConfig(options = {}) {
153
154
  }
154
155
  return config;
155
156
  }
157
+ /**
158
+ * Warn about deprecated bundled recipes.
159
+ * In v2.8.0, bundled servers/ recipes are deprecated in favor of catalog.
160
+ * They will be removed in v3.0.0.
161
+ */
162
+ export function warnDeprecatedBundledRecipes(logger) {
163
+ const catalogClient = new CatalogClient({ logger });
164
+ const cached = catalogClient.listCached();
165
+ if (cached.length === 0) {
166
+ logger.info('[mcp-bridge] Tip: Run bootstrapCatalog() to fetch recipes from catalog.aiwerk.ch (replaces bundled servers/)');
167
+ }
168
+ }
156
169
  /** Get the default config directory path. */
157
170
  export function getConfigDir(configPath) {
158
171
  if (!configPath)
@@ -205,3 +218,186 @@ export function initConfigDir(logger) {
205
218
  }
206
219
  logger.info(`Config directory ready: ${dir}`);
207
220
  }
221
+ // ── Catalog bootstrap ─────────────────────────────────────────────────────────
222
+ const noopLogger = {
223
+ info: () => { },
224
+ warn: () => { },
225
+ error: () => { },
226
+ debug: () => { },
227
+ };
228
+ /**
229
+ * Extract the depAudit value from a catalog recipe's metadata.verification field.
230
+ * Returns null if not present.
231
+ */
232
+ function getDepAudit(recipe) {
233
+ const meta = recipe.metadata;
234
+ const verification = meta?.verification;
235
+ const depAudit = verification?.depAudit;
236
+ return typeof depAudit === "string" ? depAudit : null;
237
+ }
238
+ /**
239
+ * Bootstrap the local recipe cache from the catalog.
240
+ * Downloads top N popular recipes if cache is empty or force=true.
241
+ * Returns array of recipe names now cached. Never throws on network errors.
242
+ */
243
+ export async function bootstrapCatalog(options) {
244
+ // If catalog is explicitly disabled, skip fetching
245
+ if (options?.catalog === false) {
246
+ const logger = options?.logger ?? noopLogger;
247
+ logger.info("[mcp-bridge] Catalog discovery disabled (catalog: false), skipping bootstrap");
248
+ return [];
249
+ }
250
+ const logger = options?.logger ?? noopLogger;
251
+ const cacheDir = options?.cacheDir ?? join(homedir(), ".mcp-bridge", "recipes");
252
+ const requireCleanAudit = options?.requireCleanAudit ?? false;
253
+ const client = new CatalogClient({
254
+ baseUrl: options?.catalogUrl,
255
+ cacheDir,
256
+ logger,
257
+ });
258
+ // Check if cache already has recipes
259
+ if (!options?.force) {
260
+ const cached = client.listCached();
261
+ if (cached.length > 0) {
262
+ logger.debug(`Recipe cache already has ${cached.length} recipes, skipping bootstrap`);
263
+ return cached;
264
+ }
265
+ }
266
+ try {
267
+ return await client.bootstrap(options?.limit ?? 15, requireCleanAudit);
268
+ }
269
+ catch (err) {
270
+ logger.warn(`Catalog unreachable during bootstrap: ${err instanceof Error ? err.message : err}`);
271
+ return [];
272
+ }
273
+ }
274
+ /**
275
+ * Merge cached catalog recipes into a BridgeConfig.
276
+ * Only adds recipes whose required env vars are all present in process.env.
277
+ * Never overwrites manually configured servers.
278
+ *
279
+ * IMPORTANT: Must be called AFTER loadConfig() / dotenv, since env var
280
+ * checks rely on process.env being fully populated.
281
+ */
282
+ export function mergeRecipesIntoConfig(config, options) {
283
+ const logger = options?.logger ?? noopLogger;
284
+ // autoMerge defaults to false (opt-in) — only merge when explicitly enabled
285
+ if (config.autoMerge !== true) {
286
+ logger.debug("[mcp-bridge] Auto-merge disabled (autoMerge is not true), skipping recipe merge");
287
+ return config;
288
+ }
289
+ const cacheDir = options?.cacheDir ?? join(homedir(), ".mcp-bridge", "recipes");
290
+ const client = new CatalogClient({ cacheDir, logger });
291
+ const names = client.listCached();
292
+ if (names.length === 0)
293
+ return config;
294
+ const servers = { ...config.servers };
295
+ const requireCleanAudit = config.security?.requireCleanAudit ?? false;
296
+ for (const name of names) {
297
+ // Never overwrite manually configured servers
298
+ if (servers[name])
299
+ continue;
300
+ const recipe = client.getCached(name);
301
+ if (!recipe)
302
+ continue;
303
+ // Check depAudit security policy
304
+ const depAudit = getDepAudit(recipe);
305
+ const auditOk = depAudit === null || depAudit === "clean" || depAudit === "not-applicable";
306
+ if (!auditOk) {
307
+ if (requireCleanAudit) {
308
+ logger.warn(`⚠️ Skipping server "${name}": has known security advisories (depAudit: ${depAudit}). Set security.requireCleanAudit=false to allow.`);
309
+ continue;
310
+ }
311
+ else {
312
+ logger.info(`ℹ️ Server "${name}" has known advisories (depAudit: ${depAudit}). Set security.requireCleanAudit=true to block.`);
313
+ }
314
+ }
315
+ const converted = recipeToServerConfig(recipe);
316
+ if (!converted) {
317
+ logger.debug(`Skipping recipe "${name}": unsupported format`);
318
+ continue;
319
+ }
320
+ // Check that all required env vars are available
321
+ const requiredVars = collectRequiredEnvVars(recipe);
322
+ const missing = requiredVars.filter((v) => !process.env[v]);
323
+ if (missing.length > 0) {
324
+ logger.debug(`Skipping recipe "${name}": missing env vars: ${missing.join(", ")}`);
325
+ continue;
326
+ }
327
+ servers[name] = converted;
328
+ logger.debug(`Added catalog recipe "${name}" to config`);
329
+ }
330
+ return { ...config, servers };
331
+ }
332
+ /** Convert a catalog recipe JSON to McpServerConfig, or null if unsupported. */
333
+ function recipeToServerConfig(recipe) {
334
+ // v2 recipe: has transports array
335
+ if (Array.isArray(recipe.transports) && recipe.transports.length > 0) {
336
+ const t = recipe.transports[0];
337
+ if (t.type === "stdio") {
338
+ return {
339
+ transport: "stdio",
340
+ description: recipe.description,
341
+ command: t.command,
342
+ args: t.args,
343
+ env: t.env,
344
+ };
345
+ }
346
+ if (t.type === "sse" || t.type === "streamable-http") {
347
+ return {
348
+ transport: t.type,
349
+ description: recipe.description,
350
+ url: t.url,
351
+ headers: t.headers,
352
+ };
353
+ }
354
+ return null;
355
+ }
356
+ // v1 recipe: has transport string
357
+ if (recipe.transport === "stdio") {
358
+ return {
359
+ transport: "stdio",
360
+ description: recipe.description,
361
+ command: recipe.command,
362
+ args: recipe.args,
363
+ env: recipe.env,
364
+ };
365
+ }
366
+ if (recipe.transport === "sse" || recipe.transport === "streamable-http") {
367
+ return {
368
+ transport: recipe.transport,
369
+ description: recipe.description,
370
+ url: recipe.url,
371
+ headers: recipe.headers,
372
+ };
373
+ }
374
+ return null;
375
+ }
376
+ /** Collect all env var names required by a recipe. */
377
+ function collectRequiredEnvVars(recipe) {
378
+ const vars = new Set();
379
+ // From auth.envVars
380
+ if (Array.isArray(recipe.auth?.envVars)) {
381
+ for (const v of recipe.auth.envVars)
382
+ vars.add(v);
383
+ }
384
+ // From env object: extract ${VAR} references
385
+ const envObj = Array.isArray(recipe.transports)
386
+ ? recipe.transports[0]?.env
387
+ : recipe.env;
388
+ if (envObj && typeof envObj === "object") {
389
+ for (const val of Object.values(envObj)) {
390
+ if (typeof val === "string") {
391
+ const matches = val.matchAll(/\$\{([^}]+)\}/g);
392
+ for (const m of matches)
393
+ vars.add(m[1]);
394
+ }
395
+ }
396
+ }
397
+ // If auth is explicitly required but no env vars were found,
398
+ // return a placeholder to prevent auto-registration without credentials
399
+ if (recipe.auth?.required === true && vars.size === 0) {
400
+ vars.add("__AUTH_REQUIRED__");
401
+ }
402
+ return [...vars];
403
+ }
@@ -18,7 +18,7 @@ export { ToolResolver } from "./tool-resolution.js";
18
18
  export type { ToolResolutionResult, ToolResolutionCandidate } from "./tool-resolution.js";
19
19
  export { convertJsonSchemaToTypeBox, createToolParameters, setTypeBoxLoader, setSchemaLogger } from "./schema-convert.js";
20
20
  export { initializeProtocol, fetchToolsList, PACKAGE_VERSION } from "./protocol.js";
21
- export { loadConfig, parseEnvFile, initConfigDir, getConfigDir } from "./config.js";
21
+ export { loadConfig, parseEnvFile, initConfigDir, getConfigDir, bootstrapCatalog, mergeRecipesIntoConfig, warnDeprecatedBundledRecipes } from "./config.js";
22
22
  export type { Logger, McpServerConfig, McpClientConfig, HttpAuthConfig, RetryConfig, McpTool, McpRequest, McpCallRequest, McpResponse, JsonRpcMessage, McpTransport, McpServerConnection, BridgeConfig, RequestIdState, RequestIdGenerator, } from "./types.js";
23
23
  export { nextRequestId } from "./types.js";
24
24
  export { pickRegisteredToolName } from "./tool-naming.js";
@@ -26,3 +26,7 @@ export { StandaloneServer } from "./standalone-server.js";
26
26
  export { checkForUpdate, checkPluginUpdate, getUpdateNotice, runUpdate, resetNoticeFlag } from "./update-checker.js";
27
27
  export type { UpdateInfo, PluginUpdateInfo } from "./update-checker.js";
28
28
  export { filterServers, buildFilteredDescription } from "./smart-filter.js";
29
+ export { CatalogClient, CatalogError } from "./catalog-client.js";
30
+ export type { CatalogRecipe, CatalogSearchResult } from "./catalog-client.js";
31
+ export { RecipeCache } from "./recipe-cache.js";
32
+ export type { CachedRecipe } from "./recipe-cache.js";