@aiwerk/mcp-bridge 2.8.45 → 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.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![CI](https://github.com/AIWerk/mcp-bridge/actions/workflows/ci.yml/badge.svg)](https://github.com/AIWerk/mcp-bridge/actions/workflows/ci.yml)
4
4
  [![npm version](https://img.shields.io/npm/v/@aiwerk/mcp-bridge.svg)](https://www.npmjs.com/package/@aiwerk/mcp-bridge)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
- [![Status: maintenance](https://img.shields.io/badge/status-maintenance-blue.svg)](#status)
6
+ [![Status: active](https://img.shields.io/badge/status-active-brightgreen.svg)](#status)
7
7
 
8
8
  **Your AI, Connected to Everything.** Multiplex multiple MCP servers into one interface. One config, one connection, all your tools.
9
9
 
@@ -13,32 +13,29 @@ Works with **Claude Code**, **Codex (OpenAI)**, **Claude Desktop**, **Cursor**,
13
13
 
14
14
  ## Status
15
15
 
16
- `@aiwerk/mcp-bridge` is in **maintenance mode** as of 2026-04-10.
16
+ `@aiwerk/mcp-bridge` is **actively developed** as of 2026-05-03.
17
17
 
18
- Primary development has moved to the hosted **AIWerk MCP Platform** at
19
- [aiwerkmcp.com](https://aiwerkmcp.com) a multi-tenant hosted MCP bridge
20
- with catalog, authentication, per-user isolation and OAuth2 built in.
21
- For most use cases, the hosted platform is a simpler and more featureful
22
- choice than running the standalone router yourself.
18
+ The primary use case is the **local install path for the AIWerk catalog** at
19
+ [aiwerkmcp.com](https://aiwerkmcp.com). Some recipes (those marked
20
+ `localOnly: true`, e.g. `chrome-devtools`) need a browser, display, USB
21
+ device or user-specific local path that the hosted bridge cannot reach;
22
+ this package is the recommended way to run them.
23
23
 
24
- This standalone package remains supported for:
24
+ It also serves:
25
25
 
26
26
  - **OpenClaw plugin users** via [`@aiwerk/openclaw-mcp-bridge`](https://github.com/AIWerk/openclaw-mcp-bridge),
27
27
  which embeds this library and ships its own recipe-install tooling.
28
- - **Self-hosted / offline deployments** where talking to a hosted service
29
- is not an option.
28
+ - **Self-hosted / offline deployments** where the hosted service is not an
29
+ option.
30
30
  - **Library consumers** that import `McpRouter`, transports and the OAuth2
31
31
  token manager directly.
32
32
 
33
- The published package is frozen on version `2.8.x`. Security fixes and
34
- compatibility updates (new Node.js, new MCP protocol revisions) may still
35
- ship, but no new features are planned. The bundled `servers/` directory
36
- is retained as a set of **reference recipe examples** you can copy into
37
- your own `~/.mcp-bridge/config.json` it is not a curated catalog and
38
- does not auto-update. As of this release the only remaining network
39
- coupling with AIWerk infrastructure has been removed: the install helper
40
- no longer fetches recipes over HTTP and only reads from the bundled
41
- `servers/` directory.
33
+ Active development tracks the **Universal Recipe Spec v2** alongside the
34
+ hosted bridge new fields like `localOnly`, `multiInstance`,
35
+ `auth.options[]` and `envBinding` are being ported, and the bundled
36
+ `servers/` directory is being kept in sync with the catalog. The install
37
+ helper does not fetch recipes over HTTP recipes come from the bundled
38
+ `servers/` directory only.
42
39
 
43
40
  ## Why?
44
41
 
@@ -1,16 +1,17 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, existsSync, writeFileSync, unlinkSync } from "fs";
2
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, unlinkSync } from "fs";
3
3
  import { join, dirname, resolve, extname } from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import { homedir } from "os";
6
6
  import { execFileSync, execSync } from "child_process";
7
- import { loadConfig, initConfigDir } from "../src/config.js";
7
+ import { loadConfig, initConfigDir, recipeToServerConfig, collectRequiredEnvVars } 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";
11
11
  import { FileTokenStore } from "../src/token-store.js";
12
12
  import { performAuthCodeLogin, performDeviceCodeLogin } from "../src/cli-auth.js";
13
13
  import { RateLimiter } from "../src/rate-limiter.js";
14
+ import { CatalogClient, CatalogError, CatalogSignatureError } from "../src/catalog-client.js";
14
15
  const __filename = fileURLToPath(import.meta.url);
15
16
  const __dirname = dirname(__filename);
16
17
  // After tsc, this file lives at dist/bin/mcp-bridge.js.
@@ -132,6 +133,15 @@ function parseArgs(argv) {
132
133
  case "init":
133
134
  args.command = "init";
134
135
  break;
136
+ case "install":
137
+ args.command = "install";
138
+ break;
139
+ case "search":
140
+ args.command = "search";
141
+ break;
142
+ case "catalog":
143
+ args.command = "catalog";
144
+ break;
135
145
  case "remove":
136
146
  case "uninstall":
137
147
  args.command = "remove";
@@ -185,6 +195,9 @@ Usage:
185
195
  mcp-bridge --sse --port 3000 Start as SSE server
186
196
  mcp-bridge --http --port 3000 Start as streamable-http server
187
197
  mcp-bridge init [--register <client>] [--mode router|direct] Create config + optionally register
198
+ mcp-bridge install <server> Install a server from the AIWerk catalog (signature-verified)
199
+ mcp-bridge catalog [--offline] List available servers from the catalog
200
+ mcp-bridge search <query> Search the catalog by keyword
188
201
  mcp-bridge remove <server> Remove a configured server
189
202
  mcp-bridge set-env <KEY> <value> Set an API key in ~/.mcp-bridge/.env
190
203
  mcp-bridge servers List configured servers and current mode
@@ -488,6 +501,128 @@ function cmdLimit(args, logger) {
488
501
  process.exit(1);
489
502
  }
490
503
  }
504
+ async function cmdInstall(serverName, args, logger) {
505
+ const configPath = resolveConfigPath(args.configPath);
506
+ const configDir = dirname(configPath);
507
+ if (!existsSync(configDir)) {
508
+ mkdirSync(configDir, { recursive: true });
509
+ }
510
+ if (!existsSync(configPath)) {
511
+ writeFileSync(configPath, JSON.stringify({ servers: {} }, null, 2) + "\n", "utf-8");
512
+ logger.info(`Created config: ${configPath}`);
513
+ }
514
+ const raw = JSON.parse(readFileSync(configPath, "utf-8"));
515
+ if (!raw.servers)
516
+ raw.servers = {};
517
+ if (raw.servers[serverName]) {
518
+ process.stdout.write(`Server "${serverName}" is already configured.\n`);
519
+ process.stdout.write(`Config: ${configPath}\n`);
520
+ return;
521
+ }
522
+ process.stdout.write(`Fetching recipe for ${serverName} from bridge.aiwerk.ch...\n`);
523
+ const cacheDir = join(configDir, "recipes");
524
+ const client = new CatalogClient({ cacheDir, logger });
525
+ let recipe;
526
+ try {
527
+ recipe = await client.resolve(serverName);
528
+ }
529
+ catch (err) {
530
+ if (err instanceof CatalogSignatureError) {
531
+ logger.error(err.message);
532
+ process.stderr.write(`\nThe recipe was rejected because its Ed25519 signature did not match the AIWerk catalog public key. This means either the catalog was tampered with in transit, or your local cache was modified. Nothing has been written to your config.\n`);
533
+ }
534
+ else if (err instanceof CatalogError) {
535
+ logger.error(err.message);
536
+ }
537
+ else {
538
+ logger.error(`Failed to fetch recipe: ${err instanceof Error ? err.message : String(err)}`);
539
+ }
540
+ process.exit(1);
541
+ }
542
+ if (recipe.localOnly) {
543
+ process.stdout.write(`\nNote: "${serverName}" is marked localOnly — it needs your local environment (browser, display, USB, or user-specific paths) and cannot run on the hosted bridge. You are installing it locally, which is the right place.\n\n`);
544
+ }
545
+ const serverConfig = recipeToServerConfig(recipe);
546
+ if (!serverConfig) {
547
+ logger.error(`Unsupported recipe format for "${serverName}"`);
548
+ process.exit(1);
549
+ }
550
+ const requiredVars = collectRequiredEnvVars(recipe);
551
+ const missing = requiredVars.filter((v) => v !== "__AUTH_REQUIRED__" && !process.env[v]);
552
+ raw.servers[serverName] = serverConfig;
553
+ writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
554
+ process.stdout.write(`✓ Added "${serverName}" to ${configPath}\n\n`);
555
+ if (missing.length > 0) {
556
+ process.stdout.write(`Missing environment variables:\n`);
557
+ for (const v of missing) {
558
+ process.stdout.write(` ${v}\n`);
559
+ }
560
+ const credUrl = recipe.auth?.credentialsUrl;
561
+ if (typeof credUrl === "string") {
562
+ process.stdout.write(`\nGet credentials: ${credUrl}\n`);
563
+ }
564
+ process.stdout.write(`\nSet them in your environment or ~/.mcp-bridge/.env before starting the bridge.\n`);
565
+ }
566
+ else if (requiredVars.length > 0) {
567
+ process.stdout.write(`All required environment variables are set. Ready to use.\n`);
568
+ }
569
+ else {
570
+ process.stdout.write(`No credentials required. Ready to use.\n`);
571
+ }
572
+ }
573
+ async function cmdCatalog(args, logger) {
574
+ const configPath = resolveConfigPath(args.configPath);
575
+ const cacheDir = join(dirname(configPath), "recipes");
576
+ const client = new CatalogClient({ cacheDir, logger });
577
+ if (args.offline) {
578
+ const cached = client.listCached();
579
+ if (cached.length === 0) {
580
+ process.stdout.write(`No cached recipes. Run 'mcp-bridge install <server>' to fetch one.\n`);
581
+ return;
582
+ }
583
+ process.stdout.write(`\nCached recipes (${cached.length}):\n\n`);
584
+ for (const name of cached.sort()) {
585
+ const r = client.getCached(name);
586
+ const desc = r?.description ?? "";
587
+ process.stdout.write(` ${name.padEnd(24)}${desc.slice(0, 60)}\n`);
588
+ }
589
+ return;
590
+ }
591
+ try {
592
+ const { results } = await client.list({ limit: 100 });
593
+ process.stdout.write(`\nAvailable servers (${results.length} from bridge.aiwerk.ch):\n\n`);
594
+ for (const r of results) {
595
+ const desc = (r.description ?? "").slice(0, 60);
596
+ process.stdout.write(` ${r.name.padEnd(24)}${desc}\n`);
597
+ }
598
+ process.stdout.write(`\nInstall: mcp-bridge install <name>\n`);
599
+ }
600
+ catch (err) {
601
+ logger.error(`Failed to fetch catalog: ${err instanceof Error ? err.message : String(err)}`);
602
+ process.stderr.write(`Use 'mcp-bridge catalog --offline' to list cached recipes.\n`);
603
+ process.exit(1);
604
+ }
605
+ }
606
+ async function cmdSearch(query, logger) {
607
+ const client = new CatalogClient({ logger });
608
+ try {
609
+ const results = await client.search(query);
610
+ if (results.length === 0) {
611
+ process.stdout.write(`No matches for "${query}".\n`);
612
+ return;
613
+ }
614
+ process.stdout.write(`\nMatches for "${query}" (${results.length}):\n\n`);
615
+ for (const r of results) {
616
+ const desc = (r.description ?? "").slice(0, 60);
617
+ process.stdout.write(` ${r.name.padEnd(24)}${desc}\n`);
618
+ }
619
+ process.stdout.write(`\nInstall: mcp-bridge install <name>\n`);
620
+ }
621
+ catch (err) {
622
+ logger.error(`Search failed: ${err instanceof Error ? err.message : String(err)}`);
623
+ process.exit(1);
624
+ }
625
+ }
491
626
  function cmdRemove(serverName, args, logger) {
492
627
  const configPath = resolveConfigPath(args.configPath);
493
628
  if (!existsSync(configPath)) {
@@ -798,6 +933,23 @@ async function main() {
798
933
  case "limit":
799
934
  cmdLimit(args, logger);
800
935
  break;
936
+ case "install":
937
+ if (args.positional.length === 0) {
938
+ process.stderr.write("Usage: mcp-bridge install <server>\n");
939
+ process.exit(1);
940
+ }
941
+ await cmdInstall(args.positional[0], args, logger);
942
+ break;
943
+ case "catalog":
944
+ await cmdCatalog(args, logger);
945
+ break;
946
+ case "search":
947
+ if (args.positional.length === 0) {
948
+ process.stderr.write("Usage: mcp-bridge search <query>\n");
949
+ process.exit(1);
950
+ }
951
+ await cmdSearch(args.positional.join(" "), logger);
952
+ break;
801
953
  case "remove":
802
954
  if (args.positional.length === 0) {
803
955
  process.stderr.write("Usage: mcp-bridge remove <server>\n");
@@ -1,8 +1,11 @@
1
1
  /**
2
- * CatalogClient — REST client for the AIWerk MCP Catalog API.
2
+ * CatalogClient — REST client for the AIWerk MCP catalog at bridge.aiwerk.ch.
3
3
  *
4
- * Default endpoint: https://catalog.aiwerk.ch
5
- * Supports local file caching with offline fallback.
4
+ * Restored 2026-05-03 after maintenance mode ended. Endpoint moved from the
5
+ * historical catalog.aiwerk.ch to bridge.aiwerk.ch/api/recipes/<name>/download.
6
+ * Every fetched recipe is Ed25519-verified against the bundled AIWerk public
7
+ * key before it is cached or returned. Unsigned or tampered recipes are
8
+ * refused.
6
9
  */
7
10
  import type { Logger } from "./types.js";
8
11
  export interface CatalogSearchResult {
@@ -12,6 +15,13 @@ export interface CatalogSearchResult {
12
15
  quality?: number;
13
16
  [key: string]: unknown;
14
17
  }
18
+ export interface RecipeSignature {
19
+ algorithm: string;
20
+ publisherId: string;
21
+ value: string;
22
+ signedFields: string[];
23
+ signedAt: string;
24
+ }
15
25
  export interface CatalogRecipe {
16
26
  name: string;
17
27
  description?: string;
@@ -30,6 +40,9 @@ export interface CatalogRecipe {
30
40
  headers?: Record<string, string>;
31
41
  }>;
32
42
  install?: {
43
+ method?: string;
44
+ package?: string;
45
+ version?: string;
33
46
  npm?: {
34
47
  package: string;
35
48
  version?: string;
@@ -44,34 +57,49 @@ export interface CatalogRecipe {
44
57
  envVars?: string[];
45
58
  credentialsUrl?: string;
46
59
  };
60
+ signature?: RecipeSignature;
61
+ localOnly?: boolean;
47
62
  [key: string]: unknown;
48
63
  }
49
64
  export declare class CatalogError extends Error {
50
65
  constructor(message: string);
51
66
  }
67
+ export declare class CatalogSignatureError extends CatalogError {
68
+ constructor(message: string);
69
+ }
52
70
  /**
53
- * CatalogClient REST client for the AIWerk MCP Catalog API.
54
- *
55
- * NOTE: File I/O operations (readCache, writeCache, etc.) are intentionally
56
- * synchronous. This is acceptable for CLI tools and bridge startup, but
57
- * should be converted to async if used in hot paths (e.g., per-request).
71
+ * Verify the Ed25519 signature on a recipe against the bundled AIWerk public
72
+ * key. Throws CatalogSignatureError on any failure (missing signature, wrong
73
+ * algorithm, tampered payload, key mismatch).
74
+ */
75
+ export declare function verifyRecipeSignature(recipe: CatalogRecipe): void;
76
+ export interface CatalogClientOptions {
77
+ baseUrl?: string;
78
+ cacheDir?: string;
79
+ logger?: Logger;
80
+ staleDays?: number;
81
+ /** Skip signature verification (testing only). */
82
+ skipSignatureVerify?: boolean;
83
+ }
84
+ /**
85
+ * REST client for the AIWerk MCP catalog. File I/O is intentionally
86
+ * synchronous — fine for CLI tools and bridge startup. Signature verification
87
+ * runs on every fetch (including cache reads) so a tampered cache cannot
88
+ * silently slip through.
58
89
  */
59
90
  export declare class CatalogClient {
60
91
  private baseUrl;
61
92
  private cacheDir;
62
93
  private logger;
63
94
  private staleMs;
64
- constructor(opts?: {
65
- baseUrl?: string;
66
- cacheDir?: string;
67
- logger?: Logger;
68
- staleDays?: number;
69
- });
95
+ private skipSignatureVerify;
96
+ constructor(opts?: CatalogClientOptions);
70
97
  private fetchJson;
71
98
  private cachePath;
72
99
  private readCache;
73
100
  private writeCache;
74
101
  private isCacheStale;
102
+ private verifyOrThrow;
75
103
  /** Search for recipes by keyword. */
76
104
  search(query: string): Promise<CatalogSearchResult[]>;
77
105
  /** List recipes with optional filtering. */
@@ -79,23 +107,23 @@ export declare class CatalogClient {
79
107
  limit?: number;
80
108
  category?: string;
81
109
  sort?: string;
82
- hostedSafe?: boolean;
83
110
  }): Promise<{
84
111
  results: CatalogSearchResult[];
85
112
  total: number;
86
113
  }>;
87
- /** Download a recipe from the catalog and cache it locally. */
88
- download(name: string): Promise<CatalogRecipe>;
89
114
  /**
90
- * Resolve a recipe returns cached if available, otherwise fetches from catalog.
91
- * Falls back to cache when the catalog is unreachable (offline mode).
115
+ * Download a recipe from the catalog, verify its signature, and cache it
116
+ * locally. Throws CatalogSignatureError if the recipe is unsigned or
117
+ * tampered — nothing is written to the cache in that case.
92
118
  */
93
- resolve(name: string): Promise<CatalogRecipe>;
119
+ download(name: string): Promise<CatalogRecipe>;
94
120
  /**
95
- * Bootstrap by downloading the top N most popular recipes.
96
- * Skips already-cached recipes unless they are stale.
121
+ * Resolve a recipe by name. Returns the cached copy if fresh; otherwise
122
+ * fetches from the catalog. Falls back to a stale cache if the network is
123
+ * unreachable. Signature is verified on both fresh and cached paths so a
124
+ * tampered cache cannot slip through.
97
125
  */
98
- bootstrap(limit?: number, hostedSafe?: boolean): Promise<string[]>;
126
+ resolve(name: string): Promise<CatalogRecipe>;
99
127
  /** Synchronously read a recipe from local cache. Returns null if not cached. */
100
128
  getCached(name: string): CatalogRecipe | null;
101
129
  /** List all recipe names in the local cache. */
@@ -1,45 +1,121 @@
1
1
  /**
2
- * CatalogClient — REST client for the AIWerk MCP Catalog API.
2
+ * CatalogClient — REST client for the AIWerk MCP catalog at bridge.aiwerk.ch.
3
3
  *
4
- * Default endpoint: https://catalog.aiwerk.ch
5
- * Supports local file caching with offline fallback.
4
+ * Restored 2026-05-03 after maintenance mode ended. Endpoint moved from the
5
+ * historical catalog.aiwerk.ch to bridge.aiwerk.ch/api/recipes/<name>/download.
6
+ * Every fetched recipe is Ed25519-verified against the bundled AIWerk public
7
+ * key before it is cached or returned. Unsigned or tampered recipes are
8
+ * refused.
6
9
  */
7
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
8
11
  import { join } from "node:path";
9
12
  import { homedir } from "node:os";
10
- // ── Error ────────────────────────────────────────────────────────────────────
13
+ import { createPublicKey, verify } from "node:crypto";
14
+ // ── Public key (Ed25519, AIWerk catalog signer) ──────────────────────────────
15
+ //
16
+ // The hosted bridge signs every recipe with the matching private key kept in
17
+ // pass under aiwerk/mcp-catalog-private-key. The public key is baked into
18
+ // the standalone bundle so a fresh install does not need to fetch it.
19
+ const AIWERK_CATALOG_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
20
+ MCowBQYDK2VwAyEAkHESasC8Mbf2+pGe+bhKRQkgOBSPcqGj0ZWGop4TS6k=
21
+ -----END PUBLIC KEY-----
22
+ `;
23
+ const SIGNED_FIELDS = [
24
+ "id",
25
+ "name",
26
+ "description",
27
+ "transports",
28
+ "auth",
29
+ "install",
30
+ "metadata",
31
+ "skill",
32
+ "localOnly",
33
+ ];
34
+ // ── Errors ───────────────────────────────────────────────────────────────────
11
35
  export class CatalogError extends Error {
12
36
  constructor(message) {
13
37
  super(message);
14
38
  this.name = "CatalogError";
15
39
  }
16
40
  }
41
+ export class CatalogSignatureError extends CatalogError {
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = "CatalogSignatureError";
45
+ }
46
+ }
17
47
  // ── Helpers ──────────────────────────────────────────────────────────────────
18
48
  const TIMEOUT_MS = 5_000;
49
+ const DEFAULT_BASE_URL = "https://bridge.aiwerk.ch";
19
50
  const noop = {
20
51
  info: () => { },
21
52
  warn: () => { },
22
53
  error: () => { },
23
54
  debug: () => { },
24
55
  };
25
- // ── CatalogClient ────────────────────────────────────────────────────────────
56
+ function stableStringify(value) {
57
+ if (value === null || value === undefined)
58
+ return JSON.stringify(value);
59
+ if (typeof value !== "object")
60
+ return JSON.stringify(value);
61
+ if (Array.isArray(value)) {
62
+ return "[" + value.map((v) => stableStringify(v)).join(",") + "]";
63
+ }
64
+ const sorted = Object.keys(value).sort();
65
+ const entries = sorted.map((k) => JSON.stringify(k) + ":" + stableStringify(value[k]));
66
+ return "{" + entries.join(",") + "}";
67
+ }
68
+ function canonicalSignedPayload(recipe) {
69
+ const subset = {};
70
+ for (const field of SIGNED_FIELDS) {
71
+ if (field in recipe) {
72
+ subset[field] = recipe[field];
73
+ }
74
+ }
75
+ return stableStringify(subset);
76
+ }
26
77
  /**
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).
78
+ * Verify the Ed25519 signature on a recipe against the bundled AIWerk public
79
+ * key. Throws CatalogSignatureError on any failure (missing signature, wrong
80
+ * algorithm, tampered payload, key mismatch).
81
+ */
82
+ export function verifyRecipeSignature(recipe) {
83
+ const sig = recipe.signature;
84
+ if (!sig) {
85
+ throw new CatalogSignatureError("recipe has no signature");
86
+ }
87
+ if (sig.algorithm !== "ed25519") {
88
+ throw new CatalogSignatureError(`unsupported signature algorithm: ${sig.algorithm}`);
89
+ }
90
+ if (typeof sig.value !== "string" || sig.value.length === 0) {
91
+ throw new CatalogSignatureError("recipe signature value is empty");
92
+ }
93
+ const publicKey = createPublicKey(AIWERK_CATALOG_PUBLIC_KEY_PEM);
94
+ const payload = Buffer.from(canonicalSignedPayload(recipe), "utf-8");
95
+ const signatureBytes = Buffer.from(sig.value, "base64");
96
+ const ok = verify(null, payload, publicKey, signatureBytes);
97
+ if (!ok) {
98
+ throw new CatalogSignatureError("recipe signature does not match");
99
+ }
100
+ }
101
+ /**
102
+ * REST client for the AIWerk MCP catalog. File I/O is intentionally
103
+ * synchronous — fine for CLI tools and bridge startup. Signature verification
104
+ * runs on every fetch (including cache reads) so a tampered cache cannot
105
+ * silently slip through.
32
106
  */
33
107
  export class CatalogClient {
34
108
  baseUrl;
35
109
  cacheDir;
36
110
  logger;
37
111
  staleMs;
112
+ skipSignatureVerify;
38
113
  constructor(opts) {
39
- this.baseUrl = (opts?.baseUrl ?? "https://catalog.aiwerk.ch").replace(/\/+$/, "");
114
+ this.baseUrl = (opts?.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
40
115
  this.cacheDir = opts?.cacheDir ?? join(homedir(), ".mcp-bridge", "recipes");
41
116
  this.logger = opts?.logger ?? noop;
42
117
  this.staleMs = (opts?.staleDays ?? 7) * 24 * 60 * 60 * 1000;
118
+ this.skipSignatureVerify = opts?.skipSignatureVerify ?? false;
43
119
  }
44
120
  // ── Private helpers ──────────────────────────────────────────────────────
45
121
  async fetchJson(path) {
@@ -95,11 +171,23 @@ export class CatalogClient {
95
171
  return true;
96
172
  }
97
173
  }
174
+ verifyOrThrow(recipe, name) {
175
+ if (this.skipSignatureVerify)
176
+ return;
177
+ try {
178
+ verifyRecipeSignature(recipe);
179
+ }
180
+ catch (err) {
181
+ const reason = err instanceof Error ? err.message : String(err);
182
+ throw new CatalogSignatureError(`Recipe "${name}" failed signature verification: ${reason}`);
183
+ }
184
+ }
98
185
  // ── Public API ───────────────────────────────────────────────────────────
99
186
  /** Search for recipes by keyword. */
100
187
  async search(query) {
101
188
  const encoded = encodeURIComponent(query);
102
- return this.fetchJson(`/api/search?q=${encoded}`);
189
+ const result = await this.fetchJson(`/api/search?q=${encoded}`);
190
+ return Array.isArray(result) ? result : (result.results ?? []);
103
191
  }
104
192
  /** List recipes with optional filtering. */
105
193
  async list(opts) {
@@ -110,75 +198,54 @@ export class CatalogClient {
110
198
  params.set("category", opts.category);
111
199
  if (opts?.sort)
112
200
  params.set("sort", opts.sort);
113
- if (opts?.hostedSafe)
114
- params.set("hostedSafe", "true");
115
201
  const qs = params.toString();
116
202
  return this.fetchJson(`/api/recipes${qs ? `?${qs}` : ""}`);
117
203
  }
118
- /** Download a recipe from the catalog and cache it locally. */
204
+ /**
205
+ * Download a recipe from the catalog, verify its signature, and cache it
206
+ * locally. Throws CatalogSignatureError if the recipe is unsigned or
207
+ * tampered — nothing is written to the cache in that case.
208
+ */
119
209
  async download(name) {
120
210
  const recipe = await this.fetchJson(`/api/recipes/${encodeURIComponent(name)}/download`);
211
+ this.verifyOrThrow(recipe, name);
121
212
  this.writeCache(name, recipe);
122
213
  return recipe;
123
214
  }
124
215
  /**
125
- * Resolve a recipe returns cached if available, otherwise fetches from catalog.
126
- * Falls back to cache when the catalog is unreachable (offline mode).
216
+ * Resolve a recipe by name. Returns the cached copy if fresh; otherwise
217
+ * fetches from the catalog. Falls back to a stale cache if the network is
218
+ * unreachable. Signature is verified on both fresh and cached paths so a
219
+ * tampered cache cannot slip through.
127
220
  */
128
221
  async resolve(name) {
129
222
  const cached = this.readCache(name);
130
- if (cached && !this.isCacheStale(name))
223
+ if (cached && !this.isCacheStale(name)) {
224
+ this.verifyOrThrow(cached, name);
131
225
  return cached;
226
+ }
132
227
  try {
133
228
  const recipe = await this.fetchJson(`/api/recipes/${encodeURIComponent(name)}/download`);
229
+ this.verifyOrThrow(recipe, name);
134
230
  this.writeCache(name, recipe);
135
231
  return recipe;
136
232
  }
137
233
  catch (err) {
234
+ if (err instanceof CatalogSignatureError)
235
+ throw err;
138
236
  if (err instanceof CatalogError && err.message.startsWith("Recipe not found:")) {
139
237
  throw err;
140
238
  }
141
239
  if (cached) {
240
+ // Stale cache fallback: still verify the signature so an offline user
241
+ // does not run a tampered local copy.
242
+ this.verifyOrThrow(cached, name);
142
243
  this.logger.warn(`Catalog unreachable for "${name}", using cached version`);
143
244
  return cached;
144
245
  }
145
246
  throw new CatalogError(`Cannot resolve recipe "${name}": catalog unreachable and no local cache`);
146
247
  }
147
248
  }
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
249
  /** Synchronously read a recipe from local cache. Returns null if not cached. */
183
250
  getCached(name) {
184
251
  return this.readCache(name);
@@ -1,4 +1,4 @@
1
- import { BridgeConfig, Logger } from "./types.js";
1
+ import { BridgeConfig, Logger, McpServerConfig } from "./types.js";
2
2
  /**
3
3
  * Load ~/.openclaw/.env as a fallback env source.
4
4
  *
@@ -28,3 +28,7 @@ export declare function loadConfig(options?: LoadConfigOptions): BridgeConfig;
28
28
  export declare function getConfigDir(configPath?: string): string;
29
29
  /** Initialize the config directory with template files. */
30
30
  export declare function initConfigDir(logger: Logger): void;
31
+ import type { CatalogRecipe } from "./catalog-client.js";
32
+ export declare function recipeToServerConfig(recipe: CatalogRecipe): McpServerConfig | null;
33
+ /** Collect all env var names required by a recipe (auth.envVars + ${VAR} refs). */
34
+ export declare function collectRequiredEnvVars(recipe: CatalogRecipe): string[];
@@ -205,3 +205,68 @@ export function initConfigDir(logger) {
205
205
  }
206
206
  logger.info(`Config directory ready: ${dir}`);
207
207
  }
208
+ export function recipeToServerConfig(recipe) {
209
+ if (Array.isArray(recipe.transports) && recipe.transports.length > 0) {
210
+ const t = recipe.transports[0];
211
+ if (t.type === "stdio") {
212
+ return {
213
+ transport: "stdio",
214
+ description: recipe.description,
215
+ command: t.command,
216
+ args: t.args,
217
+ env: t.env,
218
+ };
219
+ }
220
+ if (t.type === "sse" || t.type === "streamable-http") {
221
+ return {
222
+ transport: t.type,
223
+ description: recipe.description,
224
+ url: t.url,
225
+ headers: t.headers,
226
+ };
227
+ }
228
+ return null;
229
+ }
230
+ if (recipe.transport === "stdio") {
231
+ return {
232
+ transport: "stdio",
233
+ description: recipe.description,
234
+ command: recipe.command,
235
+ args: recipe.args,
236
+ env: recipe.env,
237
+ };
238
+ }
239
+ if (recipe.transport === "sse" || recipe.transport === "streamable-http") {
240
+ return {
241
+ transport: recipe.transport,
242
+ description: recipe.description,
243
+ url: recipe.url,
244
+ headers: recipe.headers,
245
+ };
246
+ }
247
+ return null;
248
+ }
249
+ /** Collect all env var names required by a recipe (auth.envVars + ${VAR} refs). */
250
+ export function collectRequiredEnvVars(recipe) {
251
+ const vars = new Set();
252
+ if (Array.isArray(recipe.auth?.envVars)) {
253
+ for (const v of recipe.auth.envVars)
254
+ vars.add(v);
255
+ }
256
+ const envObj = Array.isArray(recipe.transports)
257
+ ? recipe.transports[0]?.env
258
+ : recipe.env;
259
+ if (envObj && typeof envObj === "object") {
260
+ for (const val of Object.values(envObj)) {
261
+ if (typeof val === "string") {
262
+ const matches = val.matchAll(/\$\{([^}]+)\}/g);
263
+ for (const m of matches)
264
+ vars.add(m[1]);
265
+ }
266
+ }
267
+ }
268
+ if (recipe.auth?.required === true && vars.size === 0) {
269
+ vars.add("__AUTH_REQUIRED__");
270
+ }
271
+ return Array.from(vars);
272
+ }
@@ -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.45",
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",