@aiwerk/mcp-bridge 2.8.44 → 2.9.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.
@@ -13,8 +13,6 @@ import { ToolResolver } from "./tool-resolution.js";
13
13
  import { OAuth2TokenManager } from "./oauth2-token-manager.js";
14
14
  import { FileTokenStore } from "./token-store.js";
15
15
  import { RateLimiter } from "./rate-limiter.js";
16
- import { CatalogClient } from "./catalog-client.js";
17
- import { recipeToServerConfig, collectRequiredEnvVars } from "./config.js";
18
16
  const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
19
17
  const DEFAULT_CONNECT_ERROR_COOLDOWN_MS = 10 * 1000;
20
18
  const DEFAULT_MAX_CONCURRENT = 5;
@@ -35,7 +33,6 @@ export class McpRouter {
35
33
  tokenManager;
36
34
  rateLimiter;
37
35
  requestIdState = { value: 0 };
38
- catalogClient;
39
36
  intentRouter = null;
40
37
  promotion = null;
41
38
  constructor(servers, clientConfig, logger, transportRefs) {
@@ -61,7 +58,6 @@ export class McpRouter {
61
58
  this.toolResolver = new ToolResolver(Object.keys(servers));
62
59
  this.tokenManager = new OAuth2TokenManager(logger, new FileTokenStore());
63
60
  this.rateLimiter = new RateLimiter();
64
- this.catalogClient = new CatalogClient({ logger });
65
61
  if (clientConfig.adaptivePromotion?.enabled) {
66
62
  this.promotion = new AdaptivePromotion(clientConfig.adaptivePromotion, logger);
67
63
  }
@@ -69,7 +65,7 @@ export class McpRouter {
69
65
  static generateDescription(servers) {
70
66
  const serverNames = Object.keys(servers);
71
67
  if (serverNames.length === 0) {
72
- return "MCP server manager with no servers configured yet. To add MCP servers, always use this tool first (not npm install): action='search' to find servers in the verified catalog (100+ signed, security-audited recipes), action='install' to add a server by name, action='catalog' to browse all available servers.";
68
+ return "MCP server manager with no servers configured yet.";
73
69
  }
74
70
  const serverList = serverNames
75
71
  .map((name) => {
@@ -77,7 +73,7 @@ export class McpRouter {
77
73
  return desc ? `${name} (${desc})` : name;
78
74
  })
79
75
  .join(", ");
80
- return `MCP server manager with ${serverNames.length} connected servers: ${serverList}. Actions: 'call' to execute a tool, 'list' to discover tools on a server, 'batch' for multiple calls in one round-trip, 'status' to check connections, 'refresh' to re-discover tools. To add new MCP servers, always use this tool first (not npm install): 'search' to find servers in the verified catalog (100+ signed, security-audited recipes), 'install' to add a server by name. Use 'set-env' with params.key and params.value to configure API keys (stored in ~/.mcp-bridge/.env). If the user mentions a specific tool by name, the call action auto-connects and works without listing first.`;
76
+ return `MCP server manager with ${serverNames.length} connected servers: ${serverList}. Actions: 'call' to execute a tool, 'list' to discover tools on a server, 'batch' for multiple calls in one round-trip, 'status' to check connections, 'refresh' to re-discover tools. Use 'set-env' with params.key and params.value to configure API keys (stored in ~/.mcp-bridge/.env). If the user mentions a specific tool by name, the call action auto-connects and works without listing first.`;
81
77
  }
82
78
  async dispatch(server, action = "call", tool, params) {
83
79
  try {
@@ -98,137 +94,6 @@ export class McpRouter {
98
94
  }
99
95
  return this.resolveIntent(intent);
100
96
  }
101
- // Search catalog
102
- if (normalizedAction === "search") {
103
- const query = params?.query || server || tool;
104
- if (!query) {
105
- return this.error("invalid_params", "query is required for action=search (pass as params.query or server field)");
106
- }
107
- if (!this.catalogClient) {
108
- return this.error("mcp_error", "Catalog client not available");
109
- }
110
- try {
111
- const searchResponse = await this.catalogClient.search(query);
112
- // search() may return array or {results: [...]} depending on catalog API version
113
- const results = Array.isArray(searchResponse) ? searchResponse : searchResponse.results || [];
114
- return {
115
- action: "search",
116
- query,
117
- results: results.map((r) => ({
118
- id: r.name,
119
- name: r.name,
120
- description: r.description || "",
121
- category: r.category,
122
- auth: r.authSummary || (r.authRequired ? "required" : "none"),
123
- origin: r.origin,
124
- maturity: r.maturity,
125
- sideEffects: r.sideEffects,
126
- pricing: r.pricing,
127
- signed: Array.isArray(r.badges) ? r.badges.includes("signed") : false,
128
- }))
129
- };
130
- }
131
- catch (err) {
132
- return this.error("mcp_error", `Catalog search failed: ${err instanceof Error ? err.message : String(err)}`);
133
- }
134
- }
135
- // Browse catalog
136
- if (normalizedAction === "catalog") {
137
- if (!this.catalogClient) {
138
- return this.error("mcp_error", "Catalog client not available");
139
- }
140
- try {
141
- const recipeList = await this.catalogClient.list({ limit: 200 });
142
- return {
143
- action: "catalog",
144
- recipes: recipeList.results.map((r) => ({
145
- id: r.name,
146
- name: r.name,
147
- description: r.description || "",
148
- category: r.category,
149
- auth: r.authSummary || (r.authRequired ? "required" : "none"),
150
- origin: r.origin,
151
- maturity: r.maturity,
152
- sideEffects: r.sideEffects,
153
- pricing: r.pricing,
154
- signed: Array.isArray(r.badges) ? r.badges.includes("signed") : false,
155
- }))
156
- };
157
- }
158
- catch (err) {
159
- return this.error("mcp_error", `Catalog browse failed: ${err instanceof Error ? err.message : String(err)}`);
160
- }
161
- }
162
- // Install server from catalog (runtime + persisted to config file)
163
- if (normalizedAction === "install") {
164
- const serverName = server || params?.server || params?.name;
165
- if (!serverName) {
166
- return this.error("invalid_params", "server name is required for action=install (pass as server field or params.name)");
167
- }
168
- // Sanitize serverName: only lowercase alphanumeric + hyphens (matches recipe spec id format)
169
- if (!/^[a-z0-9][a-z0-9-]*$/.test(serverName)) {
170
- return this.error("invalid_params", `Invalid server name "${serverName}". Must match /^[a-z0-9][a-z0-9-]*$/ (lowercase alphanumeric + hyphens).`);
171
- }
172
- if (this.servers[serverName]) {
173
- return { action: "install", server: serverName, installed: true, message: `Server "${serverName}" is already configured.` };
174
- }
175
- if (!this.catalogClient) {
176
- return this.error("mcp_error", "Catalog client not available");
177
- }
178
- try {
179
- const recipe = await this.catalogClient.resolve(serverName);
180
- const serverConfig = recipeToServerConfig(recipe);
181
- if (!serverConfig) {
182
- return this.error("mcp_error", `Unsupported recipe format for "${serverName}"`);
183
- }
184
- // Check env vars
185
- const requiredVars = collectRequiredEnvVars(recipe);
186
- const missing = requiredVars.filter(v => !process.env[v]);
187
- // Add to runtime config
188
- this.servers[serverName] = serverConfig;
189
- this.clientConfig.servers[serverName] = serverConfig;
190
- // Persist to config file
191
- try {
192
- const os = await import("os");
193
- const fs = await import("fs");
194
- const path = await import("path");
195
- const configPath = path.join(os.homedir(), ".mcp-bridge", "config.json");
196
- if (fs.existsSync(configPath)) {
197
- const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
198
- if (!raw.servers)
199
- raw.servers = {};
200
- if (!raw.servers[serverName]) {
201
- raw.servers[serverName] = serverConfig;
202
- fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
203
- this.logger.info(`Persisted "${serverName}" to ${configPath}`);
204
- }
205
- }
206
- }
207
- catch (persistErr) {
208
- this.logger.warn(`Could not persist "${serverName}" to config: ${persistErr instanceof Error ? persistErr.message : String(persistErr)}`);
209
- }
210
- const credUrl = recipe.auth?.credentialsUrl;
211
- if (missing.length > 0) {
212
- return {
213
- action: "install",
214
- server: serverName,
215
- installed: true,
216
- message: `Server "${serverName}" installed. Missing env vars: ${missing.join(", ")}. Set them before calling.`,
217
- missingEnvVars: missing,
218
- ...(credUrl ? { credentialsUrl: credUrl } : {}),
219
- };
220
- }
221
- return {
222
- action: "install",
223
- server: serverName,
224
- installed: true,
225
- message: `Server "${serverName}" installed and ready to use.`
226
- };
227
- }
228
- catch (err) {
229
- return this.error("mcp_error", `Install failed: ${err instanceof Error ? err.message : String(err)}`);
230
- }
231
- }
232
97
  if (normalizedAction === "remove") {
233
98
  const serverName = server || params?.server || params?.name;
234
99
  if (!serverName) {
@@ -464,7 +329,7 @@ export class McpRouter {
464
329
  }
465
330
  }
466
331
  if (normalizedAction !== "call") {
467
- return this.error("invalid_params", `action must be one of: list, call, batch, refresh, schema, intent, status, promotions, search, catalog, install, set-mode, set-env`);
332
+ return this.error("invalid_params", `action must be one of: list, call, batch, refresh, schema, intent, status, promotions, remove, set-mode, set-env`);
468
333
  }
469
334
  if (!tool) {
470
335
  return this.error("invalid_params", "tool is required for action=call");
@@ -228,7 +228,7 @@ export class StandaloneServer {
228
228
  type: "object",
229
229
  properties: {
230
230
  server: { type: "string", description: "Server name" },
231
- action: { type: "string", description: "list | call | batch | refresh | status | intent | schema | promotions | search | catalog | install | remove | set-mode | set-env" },
231
+ action: { type: "string", description: "list | call | batch | refresh | status | intent | schema | promotions | remove | set-mode | set-env" },
232
232
  tool: { type: "string", description: "Tool name for action=call/schema" },
233
233
  params: { type: "object", description: "Tool arguments" },
234
234
  calls: {
@@ -409,15 +409,13 @@ export class StandaloneServer {
409
409
  return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: `Failed to set env var: ${err instanceof Error ? err.message : String(err)}` }] } };
410
410
  }
411
411
  }
412
- if (action === "search" || action === "install" || action === "catalog" || action === "set-mode" || action === "remove") {
412
+ if (action === "set-mode" || action === "remove") {
413
413
  // Delegate to router dispatch (create a temporary router for management actions)
414
414
  if (!this.router) {
415
415
  const { McpRouter } = await import("./mcp-router.js");
416
416
  this.router = new McpRouter(this.config.servers, this.config, this.logger);
417
417
  }
418
418
  const params = {};
419
- if (query)
420
- params.query = query;
421
419
  if (server)
422
420
  params.server = server;
423
421
  if (server)
@@ -425,13 +423,9 @@ export class StandaloneServer {
425
423
  if (toolArgs?.mode)
426
424
  params.mode = toolArgs.mode;
427
425
  const result = await this.router.dispatch(server, action, undefined, params);
428
- // If install succeeded, notify about new tools
429
- if (action === "install" && "installed" in result && result.installed) {
430
- this.sendToolsChanged();
431
- }
432
426
  return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(result) }] } };
433
427
  }
434
- return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: "Unknown action. Use: search, install, remove, catalog, status, servers, discover, set-mode" }] } };
428
+ return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: "Unknown action. Use: remove, status, servers, discover, set-mode, set-env" }] } };
435
429
  }
436
430
  // Handle mcp_discover tool (legacy, kept for backward compatibility)
437
431
  if (toolName === "mcp_discover") {
@@ -607,13 +601,12 @@ export class StandaloneServer {
607
601
  const serverNames = Object.keys(this.config.servers);
608
602
  return {
609
603
  name: "mcp_manage",
610
- description: `MCP server manager. Actions: 'search' to find servers in the verified catalog (100+ signed recipes), 'install' to add a server by name, 'remove' to remove a server, 'catalog' to browse all, 'status' to check connections, 'servers' to list configured servers, 'discover' to connect a server and discover its tools, 'set-mode' to switch between router/direct mode, 'set-env' to configure API keys in ~/.mcp-bridge/.env. Connected servers: ${serverNames.join(", ") || "none"}.`,
604
+ description: `MCP server manager. Actions: 'remove' to remove a server, 'status' to check connections, 'servers' to list configured servers, 'discover' to connect a server and discover its tools, 'set-mode' to switch between router/direct mode, 'set-env' to configure API keys in ~/.mcp-bridge/.env. Connected servers: ${serverNames.join(", ") || "none"}.`,
611
605
  inputSchema: {
612
606
  type: "object",
613
607
  properties: {
614
- action: { type: "string", description: "search | install | remove | catalog | status | servers | discover | set-mode | set-env", enum: ["search", "install", "remove", "catalog", "status", "servers", "discover", "set-mode", "set-env"] },
615
- server: { type: "string", description: "Server name (for install, remove, discover)" },
616
- query: { type: "string", description: "Search query (for action=search)" },
608
+ action: { type: "string", description: "remove | status | servers | discover | set-mode | set-env", enum: ["remove", "status", "servers", "discover", "set-mode", "set-env"] },
609
+ server: { type: "string", description: "Server name (for remove, discover)" },
617
610
  mode: { type: "string", description: "Mode (for action=set-mode): router or direct", enum: ["router", "direct"] },
618
611
  key: { type: "string", description: "Environment variable name for action=set-env (e.g. TODOIST_API_TOKEN)" },
619
612
  value: { type: "string", description: "Environment variable value for action=set-env" }
@@ -175,14 +175,4 @@ export interface BridgeConfig extends McpClientConfig {
175
175
  */
176
176
  requireCleanAudit?: boolean;
177
177
  };
178
- /**
179
- * Whether bootstrapCatalog() fetches recipes from the remote catalog.
180
- * Default: true (catalog discovery is enabled).
181
- */
182
- catalog?: boolean;
183
- /**
184
- * Whether mergeRecipesIntoConfig() auto-merges cached recipes into config.
185
- * Default: false (opt-in). Set to true to enable automatic recipe merging.
186
- */
187
- autoMerge?: boolean;
188
178
  }
@@ -274,6 +274,68 @@ export function validateRecipe(recipe) {
274
274
  }
275
275
  }
276
276
  }
277
+ // ── v2 spec field acceptance (Universal Recipe Spec v2, fields added since 2.8.x) ──
278
+ //
279
+ // These are typo-guarded boolean/string checks. Recipes shipped from the
280
+ // hosted bridge use them; the standalone runtime mostly informs the user
281
+ // and does not enforce hosted-only semantics (e.g. localOnly is not a
282
+ // gate locally — every recipe runs on the user's machine anyway).
283
+ // localOnly: top-level boolean. Hosted bridge refuses these recipes;
284
+ // standalone accepts them and lets the user spawn locally.
285
+ if (recipe.localOnly !== undefined && typeof recipe.localOnly !== "boolean") {
286
+ errors.push(`localOnly must be boolean, got ${typeof recipe.localOnly}`);
287
+ }
288
+ // multiInstance + instanceNameHint: hosted-only semantics today. Standalone
289
+ // accepts the fields without enforcing multi-instance install behavior.
290
+ if (recipe.multiInstance !== undefined && typeof recipe.multiInstance !== "boolean") {
291
+ errors.push(`multiInstance must be boolean, got ${typeof recipe.multiInstance}`);
292
+ }
293
+ if (recipe.instanceNameHint !== undefined && typeof recipe.instanceNameHint !== "string") {
294
+ errors.push(`instanceNameHint must be string, got ${typeof recipe.instanceNameHint}`);
295
+ }
296
+ // auth.options[]: multi-auth picker. If present, must be an array of objects
297
+ // with an id (string), label (string), and type (string).
298
+ const authOptions = recipe.auth?.options;
299
+ if (authOptions !== undefined) {
300
+ if (!Array.isArray(authOptions)) {
301
+ errors.push("auth.options must be an array");
302
+ }
303
+ else {
304
+ for (let i = 0; i < authOptions.length; i++) {
305
+ const opt = authOptions[i];
306
+ if (!opt || typeof opt !== "object") {
307
+ errors.push(`auth.options[${i}]: must be an object`);
308
+ continue;
309
+ }
310
+ if (typeof opt.id !== "string" || opt.id.trim().length === 0) {
311
+ errors.push(`auth.options[${i}]: id is required (non-empty string)`);
312
+ }
313
+ if (typeof opt.label !== "string" || opt.label.trim().length === 0) {
314
+ errors.push(`auth.options[${i}]: label is required (non-empty string)`);
315
+ }
316
+ if (typeof opt.type !== "string" || opt.type.trim().length === 0) {
317
+ errors.push(`auth.options[${i}]: type is required (non-empty string)`);
318
+ }
319
+ if (opt.recommended !== undefined && typeof opt.recommended !== "boolean") {
320
+ errors.push(`auth.options[${i}]: recommended must be boolean if present`);
321
+ }
322
+ }
323
+ }
324
+ }
325
+ // oauth2.envBinding: env var name to bind the OAuth access_token at spawn time.
326
+ // oauth2.credentialsFileType: marks recipes that need a credentials file
327
+ // written to disk (e.g. workspace-mcp). Standalone reads but does not yet
328
+ // write the file; will be plumbed through OAuth2 token manager when the
329
+ // feature is needed locally.
330
+ const oauth2 = recipe.auth?.oauth2;
331
+ if (oauth2 && typeof oauth2 === "object") {
332
+ if (oauth2.envBinding !== undefined && typeof oauth2.envBinding !== "string") {
333
+ errors.push(`auth.oauth2.envBinding must be string, got ${typeof oauth2.envBinding}`);
334
+ }
335
+ if (oauth2.credentialsFileType !== undefined && typeof oauth2.credentialsFileType !== "string") {
336
+ errors.push(`auth.oauth2.credentialsFileType must be string, got ${typeof oauth2.credentialsFileType}`);
337
+ }
338
+ }
277
339
  // ── Build result ───────────────────────────────────────────────────────────
278
340
  const valid = errors.length === 0;
279
341
  const result = { valid, errors, warnings };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwerk/mcp-bridge",
3
- "version": "2.8.44",
3
+ "version": "2.9.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",
@@ -50,30 +50,17 @@ done
50
50
 
51
51
  SERVER_DIR="$SCRIPT_DIR/../servers/$SERVER_NAME"
52
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
- ENCODED_NAME="$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$SERVER_NAME" 2>/dev/null || echo "$SERVER_NAME")"
62
- if curl -sf --connect-timeout 5 --max-time 15 \
63
- "https://catalog.aiwerk.ch/api/recipes/$ENCODED_NAME/download" \
64
- -o "$RECIPE_CACHE_DIR/recipe.json" 2>/dev/null \
65
- && [[ -s "$RECIPE_CACHE_DIR/recipe.json" ]]; then
66
- echo "[mcp-bridge] ✓ Recipe downloaded from catalog"
67
- SERVER_DIR="$RECIPE_CACHE_DIR"
68
- else
69
- rm -f "$RECIPE_CACHE_DIR/recipe.json"
70
- echo "[mcp-bridge] Catalog unavailable, using bundled recipe"
71
- # Fall through to existing SERVER_DIR
72
- fi
73
- fi
53
+ # Historical note (2026-04-10): earlier versions of this script fetched
54
+ # recipes over HTTP from catalog.aiwerk.ch (and briefly from
55
+ # bridge.aiwerk.ch) before falling back to the bundled servers/ directory.
56
+ # That coupling has been removed — mcp-bridge is a pure router and the
57
+ # bundled servers/ tree is the only recipe source. Users who want to add
58
+ # their own server should either copy a bundled recipe into servers/ as a
59
+ # starting point, or write their own ~/.mcp-bridge/config.json entry
60
+ # directly and point it to any MCP server they want.
74
61
 
75
62
  if [[ ! -d "$SERVER_DIR" ]]; then
76
- echo "Error: Server '$SERVER_NAME' not found."
63
+ echo "Error: Server '$SERVER_NAME' not found in $SCRIPT_DIR/../servers/"
77
64
  usage
78
65
  fi
79
66