@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.
package/README.md CHANGED
@@ -3,6 +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: active](https://img.shields.io/badge/status-active-brightgreen.svg)](#status)
6
7
 
7
8
  **Your AI, Connected to Everything.** Multiplex multiple MCP servers into one interface. One config, one connection, all your tools.
8
9
 
@@ -10,6 +11,32 @@
10
11
 
11
12
  Works with **Claude Code**, **Codex (OpenAI)**, **Claude Desktop**, **Cursor**, **Windsurf**, **Cline**, **OpenClaw**, or any MCP client.
12
13
 
14
+ ## Status
15
+
16
+ `@aiwerk/mcp-bridge` is **actively developed** as of 2026-05-03.
17
+
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
+
24
+ It also serves:
25
+
26
+ - **OpenClaw plugin users** via [`@aiwerk/openclaw-mcp-bridge`](https://github.com/AIWerk/openclaw-mcp-bridge),
27
+ which embeds this library and ships its own recipe-install tooling.
28
+ - **Self-hosted / offline deployments** where the hosted service is not an
29
+ option.
30
+ - **Library consumers** that import `McpRouter`, transports and the OAuth2
31
+ token manager directly.
32
+
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.
39
+
13
40
  ## Why?
14
41
 
15
42
  Most AI agents connect to MCP servers one-by-one. With 10+ servers, that's 10+ connections, 200+ tools in context, and thousands of wasted tokens.
@@ -27,7 +54,7 @@ Most AI agents connect to MCP servers one-by-one. With 10+ servers, that's 10+ c
27
54
  - **Graceful shutdown**: clean process termination and connection cleanup
28
55
  - **Direct mode**: all tools registered individually with automatic prefixing
29
56
  - **3 transports**: stdio, SSE, streamable-http
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)
57
+ - **Bundled recipe examples**: 14+ reference server recipes in `servers/` copy one as a starting point for your own config, or bring your own MCP server entirely
31
58
  - **Zero config secrets in files**: `${ENV_VAR}` resolution from `.env`
32
59
 
33
60
  ## Install
@@ -565,6 +592,12 @@ NOTION_TOKEN=ntn_xxxxx
565
592
 
566
593
  Use `${VAR_NAME}` in config — resolved from `.env` + system env.
567
594
 
595
+ You can also set env vars via CLI or at runtime:
596
+ ```bash
597
+ mcp-bridge set-env TODOIST_API_TOKEN your-token-here
598
+ ```
599
+ Or via the agent: `mcp(action="set-env", params={key: "TODOIST_API_TOKEN", value: "..."})`
600
+
568
601
  ## CLI Reference
569
602
 
570
603
  ```bash
@@ -581,8 +614,9 @@ mcp-bridge init --register codex # Init + register with Codex
581
614
  mcp-bridge init --register cursor # Init + register with Cursor
582
615
  mcp-bridge init --register windsurf # Init + register with Windsurf
583
616
  mcp-bridge install <server> # Install from online catalog
617
+ mcp-bridge set-env <KEY> <value> # Set an API key in ~/.mcp-bridge/.env
584
618
  mcp-bridge catalog # Browse 100+ available servers
585
- mcp-bridge servers # List configured servers
619
+ mcp-bridge servers # List configured servers and current mode
586
620
  mcp-bridge search <query> # Search catalog by keyword
587
621
  mcp-bridge update [--check] # Check for / install updates
588
622
  mcp-bridge --version # Print version
@@ -599,6 +633,7 @@ When connected to an MCP client (Claude Code, Codex, Cursor, etc.), the bridge e
599
633
  ```
600
634
  mcp(action="search", params={query: "task management"}) # Search catalog
601
635
  mcp(action="install", params={name: "todoist"}) # Install server (persisted to config)
636
+ mcp(action="set-env", params={key: "TODOIST_API_TOKEN", value: "your-key"}) # Set API key
602
637
  mcp(action="catalog") # Browse all servers
603
638
  mcp(action="list", server="todoist") # Discover tools on a server
604
639
  mcp(action="call", server="todoist", tool="find-tasks", params={query: "today"})
@@ -4,14 +4,14 @@ 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, warnDeprecatedBundledRecipes, recipeToServerConfig, collectRequiredEnvVars } 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 } from "../src/catalog-client.js";
14
+ import { CatalogClient, CatalogError, CatalogSignatureError } from "../src/catalog-client.js";
15
15
  const __filename = fileURLToPath(import.meta.url);
16
16
  const __dirname = dirname(__filename);
17
17
  // After tsc, this file lives at dist/bin/mcp-bridge.js.
@@ -136,6 +136,12 @@ function parseArgs(argv) {
136
136
  case "install":
137
137
  args.command = "install";
138
138
  break;
139
+ case "search":
140
+ args.command = "search";
141
+ break;
142
+ case "catalog":
143
+ args.command = "catalog";
144
+ break;
139
145
  case "remove":
140
146
  case "uninstall":
141
147
  args.command = "remove";
@@ -143,15 +149,9 @@ function parseArgs(argv) {
143
149
  case "set-env":
144
150
  args.command = "set-env";
145
151
  break;
146
- case "catalog":
147
- args.command = "catalog";
148
- break;
149
152
  case "servers":
150
153
  args.command = "servers";
151
154
  break;
152
- case "search":
153
- args.command = "search";
154
- break;
155
155
  case "update":
156
156
  args.command = "update";
157
157
  break;
@@ -195,12 +195,12 @@ Usage:
195
195
  mcp-bridge --sse --port 3000 Start as SSE server
196
196
  mcp-bridge --http --port 3000 Start as streamable-http server
197
197
  mcp-bridge init [--register <client>] [--mode router|direct] Create config + optionally register
198
- mcp-bridge install <server> Install a server from the catalog
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
199
201
  mcp-bridge remove <server> Remove a configured server
200
202
  mcp-bridge set-env <KEY> <value> Set an API key in ~/.mcp-bridge/.env
201
- mcp-bridge catalog [--offline] List available servers
202
- mcp-bridge servers List configured servers
203
- mcp-bridge search <query> Search catalog by keyword
203
+ mcp-bridge servers List configured servers and current mode
204
204
  mcp-bridge usage Show current per-server call usage
205
205
  mcp-bridge limit <server> [--daily N] [--monthly N]
206
206
  Set per-server rate limits (0 = unlimited)
@@ -362,28 +362,6 @@ function registerClient(client, bridgeCmd, bridgeArgs, cmd) {
362
362
  }
363
363
  }
364
364
  }
365
- async function cmdCatalog(logger, offline) {
366
- const client = new CatalogClient({ logger });
367
- try {
368
- const result = await client.list({ limit: 200 });
369
- const recipes = result.results || [];
370
- process.stdout.write(`\nAvailable servers (${recipes.length} from catalog.aiwerk.ch):\n\n`);
371
- process.stdout.write(" Server Auth Category Description\n");
372
- process.stdout.write(" " + "─".repeat(90) + "\n");
373
- for (const r of recipes) {
374
- const name = (r.name || "").padEnd(22);
375
- const auth = (r.authSummary || "none").padEnd(12);
376
- const cat = (r.category || "").padEnd(17);
377
- const desc = (r.description || "").slice(0, 60);
378
- process.stdout.write(` ${name}${auth}${cat}${desc}\n`);
379
- }
380
- process.stdout.write("\n");
381
- }
382
- catch (err) {
383
- logger.error(`Failed to fetch catalog: ${err instanceof Error ? err.message : String(err)}`);
384
- process.exit(1);
385
- }
386
- }
387
365
  function cmdServers(logger, configPath) {
388
366
  try {
389
367
  const config = loadConfig({ configPath, logger });
@@ -411,27 +389,6 @@ function cmdServers(logger, configPath) {
411
389
  process.exit(1);
412
390
  }
413
391
  }
414
- async function cmdSearch(query, logger) {
415
- const client = new CatalogClient({ logger });
416
- try {
417
- const searchResponse = await client.search(query);
418
- const results = Array.isArray(searchResponse) ? searchResponse : searchResponse.results || [];
419
- if (results.length === 0) {
420
- process.stdout.write(`No servers matching "${query}"\n`);
421
- return;
422
- }
423
- process.stdout.write(`\nSearch results for "${query}" (${results.length} found):\n\n`);
424
- for (const [i, r] of results.entries()) {
425
- const auth = r.authSummary || (r.authRequired ? "required" : "none");
426
- process.stdout.write(` ${i + 1} ${(r.name || "").padEnd(22)}[${auth}] ${r.description || ""}\n`);
427
- }
428
- process.stdout.write("\n");
429
- }
430
- catch (err) {
431
- logger.error(`Search failed: ${err instanceof Error ? err.message : String(err)}`);
432
- process.exit(1);
433
- }
434
- }
435
392
  function resolveConfigPath(configPath) {
436
393
  if (!configPath) {
437
394
  return join(homedir(), ".mcp-bridge", "config.json");
@@ -547,27 +504,22 @@ function cmdLimit(args, logger) {
547
504
  async function cmdInstall(serverName, args, logger) {
548
505
  const configPath = resolveConfigPath(args.configPath);
549
506
  const configDir = dirname(configPath);
550
- // Ensure config dir exists
551
507
  if (!existsSync(configDir)) {
552
508
  mkdirSync(configDir, { recursive: true });
553
509
  }
554
- // Ensure config file exists
555
510
  if (!existsSync(configPath)) {
556
511
  writeFileSync(configPath, JSON.stringify({ servers: {} }, null, 2) + "\n", "utf-8");
557
512
  logger.info(`Created config: ${configPath}`);
558
513
  }
559
- // Read current config
560
514
  const raw = JSON.parse(readFileSync(configPath, "utf-8"));
561
515
  if (!raw.servers)
562
516
  raw.servers = {};
563
- // Check if already configured
564
517
  if (raw.servers[serverName]) {
565
518
  process.stdout.write(`Server "${serverName}" is already configured.\n`);
566
519
  process.stdout.write(`Config: ${configPath}\n`);
567
520
  return;
568
521
  }
569
- // Fetch recipe from catalog
570
- process.stdout.write(`Fetching recipe for ${serverName}...\n`);
522
+ process.stdout.write(`Fetching recipe for ${serverName} from bridge.aiwerk.ch...\n`);
571
523
  const cacheDir = join(configDir, "recipes");
572
524
  const client = new CatalogClient({ cacheDir, logger });
573
525
  let recipe;
@@ -575,74 +527,100 @@ async function cmdInstall(serverName, args, logger) {
575
527
  recipe = await client.resolve(serverName);
576
528
  }
577
529
  catch (err) {
578
- logger.error(`Recipe not found: ${serverName}`);
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
+ }
579
540
  process.exit(1);
580
541
  }
581
- // Convert recipe to server config
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
+ }
582
545
  const serverConfig = recipeToServerConfig(recipe);
583
546
  if (!serverConfig) {
584
547
  logger.error(`Unsupported recipe format for "${serverName}"`);
585
548
  process.exit(1);
586
549
  }
587
- // Check required env vars
588
550
  const requiredVars = collectRequiredEnvVars(recipe);
589
- const missing = requiredVars.filter(v => !process.env[v]);
590
- // Add to config
551
+ const missing = requiredVars.filter((v) => v !== "__AUTH_REQUIRED__" && !process.env[v]);
591
552
  raw.servers[serverName] = serverConfig;
592
553
  writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
593
- process.stdout.write(`\n✓ Added "${serverName}" to ${configPath}\n\n`);
554
+ process.stdout.write(`✓ Added "${serverName}" to ${configPath}\n\n`);
594
555
  if (missing.length > 0) {
595
- process.stdout.write(`⚠ Missing environment variables:\n`);
556
+ process.stdout.write(`Missing environment variables:\n`);
596
557
  for (const v of missing) {
597
558
  process.stdout.write(` ${v}\n`);
598
559
  }
599
- // Show credentials URL if available
600
560
  const credUrl = recipe.auth?.credentialsUrl;
601
- if (credUrl) {
561
+ if (typeof credUrl === "string") {
602
562
  process.stdout.write(`\nGet credentials: ${credUrl}\n`);
603
563
  }
604
564
  process.stdout.write(`\nSet them in your environment or ~/.mcp-bridge/.env before starting the bridge.\n`);
605
565
  }
606
- else {
566
+ else if (requiredVars.length > 0) {
607
567
  process.stdout.write(`All required environment variables are set. Ready to use.\n`);
608
568
  }
609
- // Pre-discover tools and cache them (so direct mode has tools on first start)
610
- // Try even with missing env vars — many servers respond to tools/list without auth
611
- process.stdout.write(`\nDiscovering tools...\n`);
612
- {
613
- try {
614
- const { StdioTransport } = await import("../src/transport-stdio.js");
615
- const { SseTransport } = await import("../src/transport-sse.js");
616
- const { StreamableHttpTransport } = await import("../src/transport-streamable-http.js");
617
- const { fetchToolsList, initializeProtocol, PACKAGE_VERSION } = await import("../src/protocol.js");
618
- let transport;
619
- if (serverConfig.transport === "stdio") {
620
- transport = new StdioTransport(serverConfig, { servers: {} }, logger, async () => { });
621
- }
622
- else if (serverConfig.transport === "sse") {
623
- transport = new SseTransport(serverConfig, { servers: {} }, logger, async () => { });
624
- }
625
- else if (serverConfig.transport === "streamable-http") {
626
- transport = new StreamableHttpTransport(serverConfig, { servers: {} }, logger, async () => { });
627
- }
628
- if (transport) {
629
- await transport.connect();
630
- await initializeProtocol(transport, PACKAGE_VERSION);
631
- const tools = await fetchToolsList(transport);
632
- // Save to cache
633
- const cacheToolDir = join(configDir, "cache");
634
- mkdirSync(cacheToolDir, { recursive: true });
635
- writeFileSync(join(cacheToolDir, `${serverName}-tools.json`), JSON.stringify(tools, null, 2), "utf-8");
636
- process.stdout.write(`✓ Cached ${tools.length} tools from "${serverName}"\n`);
637
- // Disconnect
638
- await transport.disconnect().catch(() => { });
639
- }
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`);
640
588
  }
641
- catch (discErr) {
642
- // Non-fatal: tool discovery is a nice-to-have, server still installed
643
- logger.info(`Tool discovery skipped: ${discErr instanceof Error ? discErr.message : String(discErr)}`);
644
- process.stdout.write(`Tool discovery skipped (server will discover tools on first use).\n`);
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`);
645
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);
646
624
  }
647
625
  }
648
626
  function cmdRemove(serverName, args, logger) {
@@ -812,23 +790,10 @@ async function cmdAuth(args, logger) {
812
790
  process.stdout.write(` ${name.padEnd(maxName)}${label.padEnd(14)}${envStatus.slice(0, 27).padEnd(28)}${status}\n`);
813
791
  // Show help for missing env vars
814
792
  if (status === "missing env vars") {
815
- try {
816
- const cacheDir = join(dirname(resolveConfigPath(args.configPath)), "recipes");
817
- const helpClient = new CatalogClient({ cacheDir, logger });
818
- const recipe = await helpClient.resolve(name);
819
- const recipeAuth = recipe?.auth;
820
- if (recipeAuth?.credentialsUrl) {
821
- process.stdout.write(` ${" ".repeat(maxName)}Get credentials: ${recipeAuth.credentialsUrl}\n`);
822
- }
823
- if (recipeAuth?.instructions) {
824
- process.stdout.write(` ${" ".repeat(maxName)}${recipeAuth.instructions}\n`);
825
- }
826
- const missingKeys = envKeys.filter(k => !envVars.has(k) && !process.env[k]);
827
- if (missingKeys.length > 0) {
828
- process.stdout.write(` ${" ".repeat(maxName)}Set with: mcp-bridge set-env ${missingKeys[0]} <your-key>\n`);
829
- }
793
+ const missingKeys = envKeys.filter(k => !envVars.has(k) && !process.env[k]);
794
+ if (missingKeys.length > 0) {
795
+ process.stdout.write(` ${" ".repeat(maxName)}Set with: mcp-bridge set-env ${missingKeys[0]} <your-key>\n`);
830
796
  }
831
- catch { /* recipe not in cache, skip help */ }
832
797
  }
833
798
  shown.add(name);
834
799
  }
@@ -928,8 +893,6 @@ async function cmdServe(args, logger) {
928
893
  logger.error("HTTP auth not configured. Set http.auth in config or use stdio mode.");
929
894
  process.exit(1);
930
895
  }
931
- // Warn about deprecated bundled recipes (v2.8.0+)
932
- warnDeprecatedBundledRecipes(logger);
933
896
  const server = new StandaloneServer(config, logger);
934
897
  // Graceful shutdown
935
898
  const shutdown = async () => {
@@ -961,19 +924,9 @@ async function main() {
961
924
  case "init":
962
925
  cmdInit(logger, args.register, args.mode);
963
926
  break;
964
- case "catalog":
965
- await cmdCatalog(logger, args.offline);
966
- break;
967
927
  case "servers":
968
928
  cmdServers(logger, args.configPath);
969
929
  break;
970
- case "search":
971
- if (args.positional.length === 0) {
972
- process.stderr.write("Usage: mcp-bridge search <query>\n");
973
- process.exit(1);
974
- }
975
- await cmdSearch(args.positional[0], logger);
976
- break;
977
930
  case "usage":
978
931
  cmdUsage(args.configPath, logger);
979
932
  break;
@@ -987,6 +940,16 @@ async function main() {
987
940
  }
988
941
  await cmdInstall(args.positional[0], args, logger);
989
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;
990
953
  case "remove":
991
954
  if (args.positional.length === 0) {
992
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. */