@aiwerk/mcp-bridge 2.7.6 → 2.8.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/README.md CHANGED
@@ -87,6 +87,30 @@ 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
+ > **Note**: The bundled `servers/` directory is deprecated and will be removed in v3.0.0.
113
+
90
114
  ## Use with Cursor / Windsurf
91
115
 
92
116
  Add to your MCP config:
@@ -595,7 +619,7 @@ For production deployments with high security requirements, consider adding an e
595
619
  | ✅ | OAuth2 Device Code flow (headless) | 2.6.0 |
596
620
  | 🔜 | Auto-discovery (zero-config server registration) | planned |
597
621
  | 🔜 | Hosted bridge (bridge.aiwerk.ch) | planned |
598
- | 🔜 | Remote catalog integration | planned |
622
+ | | Remote catalog integration | 2.8.0 |
599
623
  | 🔜 | OpenTelemetry / Prometheus metrics | planned |
600
624
  | 🔜 | PII redaction | planned |
601
625
 
@@ -0,0 +1,101 @@
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
+ }): Promise<{
82
+ results: CatalogSearchResult[];
83
+ total: number;
84
+ }>;
85
+ /** Download a recipe from the catalog and cache it locally. */
86
+ download(name: string): Promise<CatalogRecipe>;
87
+ /**
88
+ * Resolve a recipe — returns cached if available, otherwise fetches from catalog.
89
+ * Falls back to cache when the catalog is unreachable (offline mode).
90
+ */
91
+ resolve(name: string): Promise<CatalogRecipe>;
92
+ /**
93
+ * Bootstrap by downloading the top N most popular recipes.
94
+ * Skips already-cached recipes unless they are stale.
95
+ */
96
+ bootstrap(limit?: number): Promise<string[]>;
97
+ /** Synchronously read a recipe from local cache. Returns null if not cached. */
98
+ getCached(name: string): CatalogRecipe | null;
99
+ /** List all recipe names in the local cache. */
100
+ listCached(): string[];
101
+ }
@@ -0,0 +1,192 @@
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
+ const qs = params.toString();
114
+ return this.fetchJson(`/api/recipes${qs ? `?${qs}` : ""}`);
115
+ }
116
+ /** Download a recipe from the catalog and cache it locally. */
117
+ async download(name) {
118
+ const recipe = await this.fetchJson(`/api/recipes/${encodeURIComponent(name)}/download`);
119
+ this.writeCache(name, recipe);
120
+ return recipe;
121
+ }
122
+ /**
123
+ * Resolve a recipe — returns cached if available, otherwise fetches from catalog.
124
+ * Falls back to cache when the catalog is unreachable (offline mode).
125
+ */
126
+ async resolve(name) {
127
+ const cached = this.readCache(name);
128
+ if (cached && !this.isCacheStale(name))
129
+ return cached;
130
+ try {
131
+ const recipe = await this.fetchJson(`/api/recipes/${encodeURIComponent(name)}/download`);
132
+ this.writeCache(name, recipe);
133
+ return recipe;
134
+ }
135
+ catch (err) {
136
+ if (err instanceof CatalogError && err.message.startsWith("Recipe not found:")) {
137
+ throw err;
138
+ }
139
+ if (cached) {
140
+ this.logger.warn(`Catalog unreachable for "${name}", using cached version`);
141
+ return cached;
142
+ }
143
+ throw new CatalogError(`Cannot resolve recipe "${name}": catalog unreachable and no local cache`);
144
+ }
145
+ }
146
+ /**
147
+ * Bootstrap by downloading the top N most popular recipes.
148
+ * Skips already-cached recipes unless they are stale.
149
+ */
150
+ async bootstrap(limit = 15) {
151
+ const { results } = await this.list({ limit, sort: "popular" });
152
+ const names = [];
153
+ const toDownload = [];
154
+ for (const entry of results) {
155
+ const name = entry.name;
156
+ if (!this.isCacheStale(name)) {
157
+ names.push(name);
158
+ continue;
159
+ }
160
+ toDownload.push(name);
161
+ }
162
+ const BATCH_SIZE = 5;
163
+ for (let i = 0; i < toDownload.length; i += BATCH_SIZE) {
164
+ const batch = toDownload.slice(i, i + BATCH_SIZE);
165
+ const results = await Promise.allSettled(batch.map(async (name) => {
166
+ await this.download(name);
167
+ return name;
168
+ }));
169
+ for (const r of results) {
170
+ if (r.status === "fulfilled") {
171
+ names.push(r.value);
172
+ }
173
+ else {
174
+ this.logger.warn(`Failed to download recipe: ${r.reason}`);
175
+ }
176
+ }
177
+ }
178
+ return names;
179
+ }
180
+ /** Synchronously read a recipe from local cache. Returns null if not cached. */
181
+ getCached(name) {
182
+ return this.readCache(name);
183
+ }
184
+ /** List all recipe names in the local cache. */
185
+ listCached() {
186
+ if (!existsSync(this.cacheDir))
187
+ return [];
188
+ return readdirSync(this.cacheDir, { withFileTypes: true })
189
+ .filter((d) => d.isDirectory() && existsSync(join(this.cacheDir, d.name, "recipe.json")))
190
+ .map((d) => d.name);
191
+ }
192
+ }
@@ -24,7 +24,37 @@ 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(config: BridgeConfig, 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
+ }): Promise<string[]>;
49
+ /**
50
+ * Merge cached catalog recipes into a BridgeConfig.
51
+ * Only adds recipes whose required env vars are all present in process.env.
52
+ * Never overwrites manually configured servers.
53
+ *
54
+ * IMPORTANT: Must be called AFTER loadConfig() / dotenv, since env var
55
+ * checks rely on process.env being fully populated.
56
+ */
57
+ export declare function mergeRecipesIntoConfig(config: BridgeConfig, options?: {
58
+ cacheDir?: string;
59
+ logger?: Logger;
60
+ }): 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,20 @@ 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(config, logger) {
163
+ // Check if install-server.sh was used (it writes source: 'local' or references servers/ path)
164
+ // For now, just log a general deprecation notice on first run
165
+ const catalogClient = new CatalogClient({ logger });
166
+ const cached = catalogClient.listCached();
167
+ if (cached.length === 0) {
168
+ logger.info('[mcp-bridge] Tip: Run bootstrapCatalog() to fetch recipes from catalog.aiwerk.ch (replaces bundled servers/)');
169
+ }
170
+ }
156
171
  /** Get the default config directory path. */
157
172
  export function getConfigDir(configPath) {
158
173
  if (!configPath)
@@ -205,3 +220,151 @@ export function initConfigDir(logger) {
205
220
  }
206
221
  logger.info(`Config directory ready: ${dir}`);
207
222
  }
223
+ // ── Catalog bootstrap ─────────────────────────────────────────────────────────
224
+ const noopLogger = {
225
+ info: () => { },
226
+ warn: () => { },
227
+ error: () => { },
228
+ debug: () => { },
229
+ };
230
+ /**
231
+ * Bootstrap the local recipe cache from the catalog.
232
+ * Downloads top N popular recipes if cache is empty or force=true.
233
+ * Returns array of recipe names now cached. Never throws on network errors.
234
+ */
235
+ export async function bootstrapCatalog(options) {
236
+ const logger = options?.logger ?? noopLogger;
237
+ const cacheDir = options?.cacheDir ?? join(homedir(), ".mcp-bridge", "recipes");
238
+ const client = new CatalogClient({
239
+ baseUrl: options?.catalogUrl,
240
+ cacheDir,
241
+ logger,
242
+ });
243
+ // Check if cache already has recipes
244
+ if (!options?.force) {
245
+ const cached = client.listCached();
246
+ if (cached.length > 0) {
247
+ logger.debug(`Recipe cache already has ${cached.length} recipes, skipping bootstrap`);
248
+ return cached;
249
+ }
250
+ }
251
+ try {
252
+ return await client.bootstrap(options?.limit ?? 15);
253
+ }
254
+ catch (err) {
255
+ logger.warn(`Catalog unreachable during bootstrap: ${err instanceof Error ? err.message : err}`);
256
+ return [];
257
+ }
258
+ }
259
+ /**
260
+ * Merge cached catalog recipes into a BridgeConfig.
261
+ * Only adds recipes whose required env vars are all present in process.env.
262
+ * Never overwrites manually configured servers.
263
+ *
264
+ * IMPORTANT: Must be called AFTER loadConfig() / dotenv, since env var
265
+ * checks rely on process.env being fully populated.
266
+ */
267
+ export function mergeRecipesIntoConfig(config, options) {
268
+ const logger = options?.logger ?? noopLogger;
269
+ const cacheDir = options?.cacheDir ?? join(homedir(), ".mcp-bridge", "recipes");
270
+ const client = new CatalogClient({ cacheDir, logger });
271
+ const names = client.listCached();
272
+ if (names.length === 0)
273
+ return config;
274
+ const servers = { ...config.servers };
275
+ for (const name of names) {
276
+ // Never overwrite manually configured servers
277
+ if (servers[name])
278
+ continue;
279
+ const recipe = client.getCached(name);
280
+ if (!recipe)
281
+ continue;
282
+ const converted = recipeToServerConfig(recipe);
283
+ if (!converted) {
284
+ logger.debug(`Skipping recipe "${name}": unsupported format`);
285
+ continue;
286
+ }
287
+ // Check that all required env vars are available
288
+ const requiredVars = collectRequiredEnvVars(recipe);
289
+ const missing = requiredVars.filter((v) => !process.env[v]);
290
+ if (missing.length > 0) {
291
+ logger.debug(`Skipping recipe "${name}": missing env vars: ${missing.join(", ")}`);
292
+ continue;
293
+ }
294
+ servers[name] = converted;
295
+ logger.debug(`Added catalog recipe "${name}" to config`);
296
+ }
297
+ return { ...config, servers };
298
+ }
299
+ /** Convert a catalog recipe JSON to McpServerConfig, or null if unsupported. */
300
+ function recipeToServerConfig(recipe) {
301
+ // v2 recipe: has transports array
302
+ if (Array.isArray(recipe.transports) && recipe.transports.length > 0) {
303
+ const t = recipe.transports[0];
304
+ if (t.type === "stdio") {
305
+ return {
306
+ transport: "stdio",
307
+ description: recipe.description,
308
+ command: t.command,
309
+ args: t.args,
310
+ env: t.env,
311
+ };
312
+ }
313
+ if (t.type === "sse" || t.type === "streamable-http") {
314
+ return {
315
+ transport: t.type,
316
+ description: recipe.description,
317
+ url: t.url,
318
+ headers: t.headers,
319
+ };
320
+ }
321
+ return null;
322
+ }
323
+ // v1 recipe: has transport string
324
+ if (recipe.transport === "stdio") {
325
+ return {
326
+ transport: "stdio",
327
+ description: recipe.description,
328
+ command: recipe.command,
329
+ args: recipe.args,
330
+ env: recipe.env,
331
+ };
332
+ }
333
+ if (recipe.transport === "sse" || recipe.transport === "streamable-http") {
334
+ return {
335
+ transport: recipe.transport,
336
+ description: recipe.description,
337
+ url: recipe.url,
338
+ headers: recipe.headers,
339
+ };
340
+ }
341
+ return null;
342
+ }
343
+ /** Collect all env var names required by a recipe. */
344
+ function collectRequiredEnvVars(recipe) {
345
+ const vars = new Set();
346
+ // From auth.envVars
347
+ if (Array.isArray(recipe.auth?.envVars)) {
348
+ for (const v of recipe.auth.envVars)
349
+ vars.add(v);
350
+ }
351
+ // From env object: extract ${VAR} references
352
+ const envObj = Array.isArray(recipe.transports)
353
+ ? recipe.transports[0]?.env
354
+ : recipe.env;
355
+ if (envObj && typeof envObj === "object") {
356
+ for (const val of Object.values(envObj)) {
357
+ if (typeof val === "string") {
358
+ const matches = val.matchAll(/\$\{([^}]+)\}/g);
359
+ for (const m of matches)
360
+ vars.add(m[1]);
361
+ }
362
+ }
363
+ }
364
+ // If auth is explicitly required but no env vars were found,
365
+ // return a placeholder to prevent auto-registration without credentials
366
+ if (recipe.auth?.required === true && vars.size === 0) {
367
+ vars.add("__AUTH_REQUIRED__");
368
+ }
369
+ return [...vars];
370
+ }
@@ -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 } 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";
package/dist/src/index.js CHANGED
@@ -20,7 +20,7 @@ export { convertJsonSchemaToTypeBox, createToolParameters, setTypeBoxLoader, set
20
20
  // Protocol helpers
21
21
  export { initializeProtocol, fetchToolsList, PACKAGE_VERSION } from "./protocol.js";
22
22
  // Config
23
- export { loadConfig, parseEnvFile, initConfigDir, getConfigDir } from "./config.js";
23
+ export { loadConfig, parseEnvFile, initConfigDir, getConfigDir, bootstrapCatalog, mergeRecipesIntoConfig } from "./config.js";
24
24
  export { nextRequestId } from "./types.js";
25
25
  // Tool naming
26
26
  export { pickRegisteredToolName } from "./tool-naming.js";
@@ -30,3 +30,7 @@ export { StandaloneServer } from "./standalone-server.js";
30
30
  export { checkForUpdate, checkPluginUpdate, getUpdateNotice, runUpdate, resetNoticeFlag } from "./update-checker.js";
31
31
  // Smart filter
32
32
  export { filterServers, buildFilteredDescription } from "./smart-filter.js";
33
+ // Catalog client
34
+ export { CatalogClient, CatalogError } from "./catalog-client.js";
35
+ // Recipe cache
36
+ export { RecipeCache } from "./recipe-cache.js";
@@ -0,0 +1,28 @@
1
+ /**
2
+ * RecipeCache — local file cache for downloaded catalog recipes.
3
+ *
4
+ * Default cache dir: ~/.mcp-bridge/recipes/
5
+ * Each recipe is stored as recipes/<name>/recipe.json with metadata.
6
+ */
7
+ import type { CatalogRecipe } from "./catalog-client.js";
8
+ export interface CachedRecipe {
9
+ recipe: CatalogRecipe;
10
+ downloadedAt: string;
11
+ catalogVersion?: string;
12
+ }
13
+ export declare class RecipeCache {
14
+ private cacheDir;
15
+ constructor(cacheDir?: string);
16
+ private recipePath;
17
+ private ensureDir;
18
+ /** Get a cached recipe by name, or undefined if not cached. */
19
+ get(name: string): CachedRecipe | undefined;
20
+ /** Cache a recipe. */
21
+ put(name: string, recipe: CatalogRecipe, catalogVersion?: string): void;
22
+ /** List all cached recipe names. */
23
+ list(): string[];
24
+ /** Check if a recipe is cached. */
25
+ has(name: string): boolean;
26
+ /** Remove all cached recipes. */
27
+ clear(): void;
28
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * RecipeCache — local file cache for downloaded catalog recipes.
3
+ *
4
+ * Default cache dir: ~/.mcp-bridge/recipes/
5
+ * Each recipe is stored as recipes/<name>/recipe.json with metadata.
6
+ */
7
+ import { existsSync, mkdirSync, readdirSync, rmSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ export class RecipeCache {
11
+ cacheDir;
12
+ constructor(cacheDir) {
13
+ this.cacheDir = cacheDir ?? join(homedir(), ".mcp-bridge", "recipes");
14
+ }
15
+ recipePath(name) {
16
+ return join(this.cacheDir, name, "recipe.json");
17
+ }
18
+ ensureDir(name) {
19
+ const dir = join(this.cacheDir, name);
20
+ if (!existsSync(dir)) {
21
+ mkdirSync(dir, { recursive: true });
22
+ }
23
+ }
24
+ /** Get a cached recipe by name, or undefined if not cached. */
25
+ get(name) {
26
+ const path = this.recipePath(name);
27
+ if (!existsSync(path))
28
+ return undefined;
29
+ try {
30
+ return JSON.parse(readFileSync(path, "utf-8"));
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ }
36
+ /** Cache a recipe. */
37
+ put(name, recipe, catalogVersion) {
38
+ this.ensureDir(name);
39
+ const entry = {
40
+ recipe,
41
+ downloadedAt: new Date().toISOString(),
42
+ catalogVersion,
43
+ };
44
+ writeFileSync(this.recipePath(name), JSON.stringify(entry, null, 2), "utf-8");
45
+ }
46
+ /** List all cached recipe names. */
47
+ list() {
48
+ if (!existsSync(this.cacheDir))
49
+ return [];
50
+ return readdirSync(this.cacheDir, { withFileTypes: true })
51
+ .filter((d) => d.isDirectory() && existsSync(join(this.cacheDir, d.name, "recipe.json")))
52
+ .map((d) => d.name);
53
+ }
54
+ /** Check if a recipe is cached. */
55
+ has(name) {
56
+ return existsSync(this.recipePath(name));
57
+ }
58
+ /** Remove all cached recipes. */
59
+ clear() {
60
+ if (existsSync(this.cacheDir)) {
61
+ rmSync(this.cacheDir, { recursive: true, force: true });
62
+ }
63
+ }
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwerk/mcp-bridge",
3
- "version": "2.7.6",
3
+ "version": "2.8.0",
4
4
  "description": "Standalone MCP server that multiplexes multiple MCP servers into one interface",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -49,6 +49,24 @@ while [[ $# -gt 0 ]]; do
49
49
  done
50
50
 
51
51
  SERVER_DIR="$SCRIPT_DIR/../servers/$SERVER_NAME"
52
+
53
+ # v2.8.0: Try catalog first, fall back to bundled servers/
54
+ RECIPE_CACHE_DIR="$HOME/.mcp-bridge/recipes/$SERVER_NAME"
55
+ if [[ -f "$RECIPE_CACHE_DIR/recipe.json" ]]; then
56
+ echo "[mcp-bridge] Using cached catalog recipe for $SERVER_NAME"
57
+ SERVER_DIR="$RECIPE_CACHE_DIR"
58
+ elif command -v curl &>/dev/null; then
59
+ echo "[mcp-bridge] Fetching recipe from catalog..."
60
+ mkdir -p "$RECIPE_CACHE_DIR"
61
+ if curl -sf "https://catalog.aiwerk.ch/api/recipes/$SERVER_NAME/download" -o "$RECIPE_CACHE_DIR/recipe.json" 2>/dev/null; then
62
+ echo "[mcp-bridge] ✓ Recipe downloaded from catalog"
63
+ SERVER_DIR="$RECIPE_CACHE_DIR"
64
+ else
65
+ echo "[mcp-bridge] Catalog unavailable, using bundled recipe"
66
+ # Fall through to existing SERVER_DIR
67
+ fi
68
+ fi
69
+
52
70
  if [[ ! -d "$SERVER_DIR" ]]; then
53
71
  echo "Error: Server '$SERVER_NAME' not found."
54
72
  usage