@aiwerk/mcp-bridge 2.8.0 → 2.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,7 +27,7 @@ Most AI agents connect to MCP servers one-by-one. With 10+ servers, that's 10+ c
27
27
  - **Graceful shutdown**: clean process termination and connection cleanup
28
28
  - **Direct mode**: all tools registered individually with automatic prefixing
29
29
  - **3 transports**: stdio, SSE, streamable-http
30
- - **Built-in catalog**: 14 pre-configured servers, install with one command
30
+ - **Built-in catalog**: 14 pre-configured servers, install with one command (bundled servers deprecated — use [MCP Catalog](https://catalog.aiwerk.ch) with 104+ recipes instead)
31
31
  - **Zero config secrets in files**: `${ENV_VAR}` resolution from `.env`
32
32
 
33
33
  ## Install
@@ -109,6 +109,56 @@ const recipe = await client.resolve('todoist');
109
109
  const results = await client.search('email');
110
110
  ```
111
111
 
112
+ ### Catalog & Auto-Merge Options
113
+
114
+ Two config options control catalog behavior:
115
+
116
+ | Option | Type | Default | Description |
117
+ |--------|------|---------|-------------|
118
+ | `catalog` | `boolean` | `true` | Whether `bootstrapCatalog()` fetches recipes from the remote catalog |
119
+ | `autoMerge` | `boolean` | `false` | Whether `mergeRecipesIntoConfig()` auto-merges cached recipes into your config |
120
+
121
+ ```json
122
+ {
123
+ "catalog": true,
124
+ "autoMerge": true,
125
+ "servers": { ... }
126
+ }
127
+ ```
128
+
129
+ - **`autoMerge` defaults to `false`** (opt-in) — cached recipes are **not** automatically added to your server list unless you explicitly enable it. This prevents servers without required credentials from being silently activated.
130
+ - **`catalog` defaults to `true`** — recipe discovery from [catalog.aiwerk.ch](https://catalog.aiwerk.ch) is enabled by default. Set to `false` to skip all remote fetching.
131
+
132
+ > **Breaking change (v2.9.0):** Previously, all cached recipes whose env vars were present were auto-merged. Now you must set `"autoMerge": true` to restore that behavior.
133
+
134
+ ### Multiple instances of the same server
135
+
136
+ Auto-discovery uses the recipe name as the config key (e.g., `gohighlevel`). If you need **multiple instances** of the same server with different credentials (e.g., two GoHighLevel subaccounts), configure them manually:
137
+
138
+ ```json
139
+ // config.json or openclaw.json
140
+ {
141
+ "ghl-client-a": {
142
+ "transport": "streamable-http",
143
+ "url": "https://services.leadconnectorhq.com/mcp/",
144
+ "headers": {
145
+ "Authorization": "Bearer ${GHL_TOKEN_A}",
146
+ "locationId": "${GHL_LOCATION_A}"
147
+ }
148
+ },
149
+ "ghl-client-b": {
150
+ "transport": "streamable-http",
151
+ "url": "https://services.leadconnectorhq.com/mcp/",
152
+ "headers": {
153
+ "Authorization": "Bearer ${GHL_TOKEN_B}",
154
+ "locationId": "${GHL_LOCATION_B}"
155
+ }
156
+ }
157
+ }
158
+ ```
159
+
160
+ Use **unique env var names** (e.g., `GHL_TOKEN_A` instead of `GHL_PIT_TOKEN`) to prevent auto-discovery from adding a duplicate third entry. Manual config always takes priority over auto-discovered recipes.
161
+
112
162
  > **Note**: The bundled `servers/` directory is deprecated and will be removed in v3.0.0.
113
163
 
114
164
  ## Use with Cursor / Windsurf
@@ -4,7 +4,7 @@ import { join, dirname, resolve, extname } from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  import { platform, homedir } from "os";
6
6
  import { execFileSync } from "child_process";
7
- import { loadConfig, initConfigDir } from "../src/config.js";
7
+ import { loadConfig, initConfigDir, warnDeprecatedBundledRecipes } from "../src/config.js";
8
8
  import { StandaloneServer } from "../src/standalone-server.js";
9
9
  import { PACKAGE_VERSION } from "../src/protocol.js";
10
10
  import { checkForUpdate, runUpdate } from "../src/update-checker.js";
@@ -554,6 +554,8 @@ async function cmdServe(args, logger) {
554
554
  logger.error("HTTP auth not configured. Set http.auth in config or use stdio mode.");
555
555
  process.exit(1);
556
556
  }
557
+ // Warn about deprecated bundled recipes (v2.8.0+)
558
+ warnDeprecatedBundledRecipes(logger);
557
559
  const server = new StandaloneServer(config, logger);
558
560
  // Graceful shutdown
559
561
  const shutdown = async () => {
@@ -78,6 +78,7 @@ export declare class CatalogClient {
78
78
  limit?: number;
79
79
  category?: string;
80
80
  sort?: string;
81
+ hostedSafe?: boolean;
81
82
  }): Promise<{
82
83
  results: CatalogSearchResult[];
83
84
  total: number;
@@ -93,7 +94,7 @@ export declare class CatalogClient {
93
94
  * Bootstrap by downloading the top N most popular recipes.
94
95
  * Skips already-cached recipes unless they are stale.
95
96
  */
96
- bootstrap(limit?: number): Promise<string[]>;
97
+ bootstrap(limit?: number, hostedSafe?: boolean): Promise<string[]>;
97
98
  /** Synchronously read a recipe from local cache. Returns null if not cached. */
98
99
  getCached(name: string): CatalogRecipe | null;
99
100
  /** List all recipe names in the local cache. */
@@ -110,6 +110,8 @@ export class CatalogClient {
110
110
  params.set("category", opts.category);
111
111
  if (opts?.sort)
112
112
  params.set("sort", opts.sort);
113
+ if (opts?.hostedSafe)
114
+ params.set("hostedSafe", "true");
113
115
  const qs = params.toString();
114
116
  return this.fetchJson(`/api/recipes${qs ? `?${qs}` : ""}`);
115
117
  }
@@ -147,8 +149,8 @@ export class CatalogClient {
147
149
  * Bootstrap by downloading the top N most popular recipes.
148
150
  * Skips already-cached recipes unless they are stale.
149
151
  */
150
- async bootstrap(limit = 15) {
151
- const { results } = await this.list({ limit, sort: "popular" });
152
+ async bootstrap(limit = 15, hostedSafe = false) {
153
+ const { results } = await this.list({ limit, sort: "popular", hostedSafe });
152
154
  const names = [];
153
155
  const toDownload = [];
154
156
  for (const entry of results) {
@@ -29,7 +29,7 @@ export declare function loadConfig(options?: LoadConfigOptions): BridgeConfig;
29
29
  * In v2.8.0, bundled servers/ recipes are deprecated in favor of catalog.
30
30
  * They will be removed in v3.0.0.
31
31
  */
32
- export declare function warnDeprecatedBundledRecipes(config: BridgeConfig, logger: Logger): void;
32
+ export declare function warnDeprecatedBundledRecipes(logger: Logger): void;
33
33
  /** Get the default config directory path. */
34
34
  export declare function getConfigDir(configPath?: string): string;
35
35
  /** Initialize the config directory with template files. */
@@ -45,6 +45,8 @@ export declare function bootstrapCatalog(options?: {
45
45
  catalogUrl?: string;
46
46
  limit?: number;
47
47
  force?: boolean;
48
+ requireCleanAudit?: boolean;
49
+ catalog?: boolean;
48
50
  }): Promise<string[]>;
49
51
  /**
50
52
  * Merge cached catalog recipes into a BridgeConfig.
@@ -159,9 +159,7 @@ export function loadConfig(options = {}) {
159
159
  * In v2.8.0, bundled servers/ recipes are deprecated in favor of catalog.
160
160
  * They will be removed in v3.0.0.
161
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
162
+ export function warnDeprecatedBundledRecipes(logger) {
165
163
  const catalogClient = new CatalogClient({ logger });
166
164
  const cached = catalogClient.listCached();
167
165
  if (cached.length === 0) {
@@ -227,14 +225,31 @@ const noopLogger = {
227
225
  error: () => { },
228
226
  debug: () => { },
229
227
  };
228
+ /**
229
+ * Extract the depAudit value from a catalog recipe's metadata.verification field.
230
+ * Returns null if not present.
231
+ */
232
+ function getDepAudit(recipe) {
233
+ const meta = recipe.metadata;
234
+ const verification = meta?.verification;
235
+ const depAudit = verification?.depAudit;
236
+ return typeof depAudit === "string" ? depAudit : null;
237
+ }
230
238
  /**
231
239
  * Bootstrap the local recipe cache from the catalog.
232
240
  * Downloads top N popular recipes if cache is empty or force=true.
233
241
  * Returns array of recipe names now cached. Never throws on network errors.
234
242
  */
235
243
  export async function bootstrapCatalog(options) {
244
+ // If catalog is explicitly disabled, skip fetching
245
+ if (options?.catalog === false) {
246
+ const logger = options?.logger ?? noopLogger;
247
+ logger.info("[mcp-bridge] Catalog discovery disabled (catalog: false), skipping bootstrap");
248
+ return [];
249
+ }
236
250
  const logger = options?.logger ?? noopLogger;
237
251
  const cacheDir = options?.cacheDir ?? join(homedir(), ".mcp-bridge", "recipes");
252
+ const requireCleanAudit = options?.requireCleanAudit ?? false;
238
253
  const client = new CatalogClient({
239
254
  baseUrl: options?.catalogUrl,
240
255
  cacheDir,
@@ -249,7 +264,7 @@ export async function bootstrapCatalog(options) {
249
264
  }
250
265
  }
251
266
  try {
252
- return await client.bootstrap(options?.limit ?? 15);
267
+ return await client.bootstrap(options?.limit ?? 15, requireCleanAudit);
253
268
  }
254
269
  catch (err) {
255
270
  logger.warn(`Catalog unreachable during bootstrap: ${err instanceof Error ? err.message : err}`);
@@ -266,12 +281,18 @@ export async function bootstrapCatalog(options) {
266
281
  */
267
282
  export function mergeRecipesIntoConfig(config, options) {
268
283
  const logger = options?.logger ?? noopLogger;
284
+ // autoMerge defaults to false (opt-in) — only merge when explicitly enabled
285
+ if (config.autoMerge !== true) {
286
+ logger.debug("[mcp-bridge] Auto-merge disabled (autoMerge is not true), skipping recipe merge");
287
+ return config;
288
+ }
269
289
  const cacheDir = options?.cacheDir ?? join(homedir(), ".mcp-bridge", "recipes");
270
290
  const client = new CatalogClient({ cacheDir, logger });
271
291
  const names = client.listCached();
272
292
  if (names.length === 0)
273
293
  return config;
274
294
  const servers = { ...config.servers };
295
+ const requireCleanAudit = config.security?.requireCleanAudit ?? false;
275
296
  for (const name of names) {
276
297
  // Never overwrite manually configured servers
277
298
  if (servers[name])
@@ -279,6 +300,18 @@ export function mergeRecipesIntoConfig(config, options) {
279
300
  const recipe = client.getCached(name);
280
301
  if (!recipe)
281
302
  continue;
303
+ // Check depAudit security policy
304
+ const depAudit = getDepAudit(recipe);
305
+ const auditOk = depAudit === null || depAudit === "clean" || depAudit === "not-applicable";
306
+ if (!auditOk) {
307
+ if (requireCleanAudit) {
308
+ logger.warn(`⚠️ Skipping server "${name}": has known security advisories (depAudit: ${depAudit}). Set security.requireCleanAudit=false to allow.`);
309
+ continue;
310
+ }
311
+ else {
312
+ logger.info(`ℹ️ Server "${name}" has known advisories (depAudit: ${depAudit}). Set security.requireCleanAudit=true to block.`);
313
+ }
314
+ }
282
315
  const converted = recipeToServerConfig(recipe);
283
316
  if (!converted) {
284
317
  logger.debug(`Skipping recipe "${name}": unsupported format`);
@@ -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, bootstrapCatalog, mergeRecipesIntoConfig } from "./config.js";
21
+ export { loadConfig, parseEnvFile, initConfigDir, getConfigDir, bootstrapCatalog, mergeRecipesIntoConfig, warnDeprecatedBundledRecipes } from "./config.js";
22
22
  export type { Logger, McpServerConfig, McpClientConfig, HttpAuthConfig, RetryConfig, McpTool, McpRequest, McpCallRequest, McpResponse, JsonRpcMessage, McpTransport, McpServerConnection, BridgeConfig, RequestIdState, RequestIdGenerator, } from "./types.js";
23
23
  export { nextRequestId } from "./types.js";
24
24
  export { pickRegisteredToolName } from "./tool-naming.js";
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, bootstrapCatalog, mergeRecipesIntoConfig } from "./config.js";
23
+ export { loadConfig, parseEnvFile, initConfigDir, getConfigDir, bootstrapCatalog, mergeRecipesIntoConfig, warnDeprecatedBundledRecipes } from "./config.js";
24
24
  export { nextRequestId } from "./types.js";
25
25
  // Tool naming
26
26
  export { pickRegisteredToolName } from "./tool-naming.js";
@@ -132,20 +132,15 @@ export class StdioTransport extends BaseTransport {
132
132
  reject(error);
133
133
  };
134
134
  const onFirstData = (chunk) => {
135
- // Validate that first data looks like JSON-RPC (starts with { or Content-Length).
136
- // Some servers write banner text to stdout instead of stderr, which would
137
- // cause a false-positive connect (we'd think the transport is ready).
135
+ // Some MCP servers print banner text to stdout before they start speaking JSON-RPC.
136
+ // We already parse and ignore non-JSON lines later in processStdoutBuffer(), so any
137
+ // stdout activity means the process is alive and its pipes are working. Resolve here
138
+ // and let initializeProtocol() validate the connection with an actual initialize request.
138
139
  const text = chunk.toString().trim();
139
- // Accept empty/whitespace readiness signals (common in lightweight stdio MCP servers),
140
- // JSON messages, or LSP framing headers.
141
- if (text === "" || text.startsWith("{") || text.startsWith("Content-Length")) {
142
- settleResolve();
143
- }
144
- else {
145
- this.logger.warn(`[mcp-bridge] Stdio process sent non-JSON data on stdout: ${text.substring(0, 80)}`);
146
- // Still listen for valid data — don't reject yet, the next chunk might be valid
147
- this.process?.stdout?.once("data", onFirstData);
140
+ if (text && !text.startsWith("{") && !text.startsWith("Content-Length")) {
141
+ this.logger.warn(`[mcp-bridge] Stdio process sent banner/non-JSON data on stdout before ready: ${text.substring(0, 80)}`);
148
142
  }
143
+ settleResolve();
149
144
  };
150
145
  const onProcessError = (error) => settleReject(error);
151
146
  const onProcessExit = () => settleReject(new Error("MCP server exited before stdout became ready"));
@@ -165,4 +165,22 @@ export interface BridgeConfig extends McpClientConfig {
165
165
  http?: {
166
166
  auth?: HttpAuthConfig;
167
167
  };
168
+ security?: {
169
+ /**
170
+ * When true, only load MCP servers whose depAudit is "clean" or "not-applicable".
171
+ * Servers with "has-advisories", "skip", or other values are blocked at startup.
172
+ * Default: false (advisories are logged as info, not blocked).
173
+ */
174
+ requireCleanAudit?: boolean;
175
+ };
176
+ /**
177
+ * Whether bootstrapCatalog() fetches recipes from the remote catalog.
178
+ * Default: true (catalog discovery is enabled).
179
+ */
180
+ catalog?: boolean;
181
+ /**
182
+ * Whether mergeRecipesIntoConfig() auto-merges cached recipes into config.
183
+ * Default: false (opt-in). Set to true to enable automatic recipe merging.
184
+ */
185
+ autoMerge?: boolean;
168
186
  }
@@ -7,6 +7,8 @@ const KNOWN_CATEGORIES = new Set([
7
7
  "development",
8
8
  "communication",
9
9
  "data",
10
+ "database",
11
+ "crm",
10
12
  "finance",
11
13
  "infrastructure",
12
14
  "analytics",
@@ -151,6 +153,26 @@ export function validateRecipe(recipe) {
151
153
  }
152
154
  }
153
155
  // ── §7.2 Warnings ──────────────────────────────────────────────────────────
156
+ // Warning: metadata.verification field validation
157
+ const verification = recipe.metadata?.verification;
158
+ if (verification && typeof verification === 'object') {
159
+ if ('tier1' in verification && verification.tier1 !== 'pass') {
160
+ warnings.push(`metadata.verification.tier1 should be "pass", got: "${verification.tier1}"`);
161
+ }
162
+ if ('tier2' in verification) {
163
+ const tier2 = verification.tier2;
164
+ if (tier2 !== 'pass' && tier2 !== 'skip') {
165
+ warnings.push(`metadata.verification.tier2 should be "pass" or "skip", got: "${tier2}"`);
166
+ }
167
+ }
168
+ if ('depAudit' in verification) {
169
+ const depAudit = verification.depAudit;
170
+ const validDepAudit = new Set(['clean', 'has-advisories', 'not-applicable', 'skip']);
171
+ if (!validDepAudit.has(depAudit)) {
172
+ warnings.push(`metadata.verification.depAudit should be "clean", "has-advisories", "not-applicable", or "skip", got: "${depAudit}"`);
173
+ }
174
+ }
175
+ }
154
176
  // Warning: metadata.lastVerified older than 90 days
155
177
  if (typeof recipe.metadata?.lastVerified === "string") {
156
178
  const lastVerified = new Date(recipe.metadata.lastVerified);
@@ -187,6 +209,71 @@ export function validateRecipe(recipe) {
187
209
  if (recipe.metadata?.maturity === "deprecated") {
188
210
  warnings.push("metadata.maturity is set to 'deprecated'");
189
211
  }
212
+ // ── §2.9 Origin cross-check ────────────────────────────────────────────
213
+ // If origin is "official", verify that metadata.author matches the npm package scope/maintainer hint.
214
+ // This is a heuristic — not all packages have matching names — so it's a warning, not an error.
215
+ const origin = recipe.metadata?.origin;
216
+ const author = recipe.metadata?.author;
217
+ const installPkg = recipe.install?.package;
218
+ if (origin === "official" && typeof author === "string" && typeof installPkg === "string") {
219
+ // Extract npm scope (e.g., "@cloudflare/mcp-server-cloudflare" → "cloudflare")
220
+ const scopeMatch = installPkg.match(/^@([^/]+)\//);
221
+ const scope = scopeMatch ? scopeMatch[1].toLowerCase() : null;
222
+ const authorLower = author.toLowerCase();
223
+ if (scope) {
224
+ // Check if the scope relates to the author (heuristic: scope contains author or vice versa)
225
+ // Known mappings for official packages where scope ≠ author name
226
+ const knownOfficialMappings = {
227
+ "playwright": ["microsoft"],
228
+ "browserbasehq": ["browserbase"],
229
+ "anthropic-ai": ["anthropic"],
230
+ "twilio-alpha": ["twilio"],
231
+ "perplexity-ai": ["perplexity"],
232
+ "pinecone-database": ["pinecone"],
233
+ "neondatabase": ["neon"],
234
+ "doist": ["todoist"],
235
+ "webflow-bot": ["webflow"],
236
+ };
237
+ const knownAliases = knownOfficialMappings[scope] ?? [];
238
+ const scopeMatchesAuthor = authorLower.includes(scope) ||
239
+ scope.includes(authorLower.replace(/[^a-z0-9]/g, "")) ||
240
+ knownAliases.some(alias => authorLower.includes(alias));
241
+ if (!scopeMatchesAuthor) {
242
+ // Also check common patterns: "modelcontextprotocol" scope = Anthropic community, not official
243
+ const communityScopes = new Set([
244
+ "modelcontextprotocol",
245
+ ]);
246
+ if (communityScopes.has(scope)) {
247
+ warnings.push(`origin is "official" but package scope @${scope} is a community/ecosystem scope — verify that ${author} actually maintains this package`);
248
+ }
249
+ else {
250
+ warnings.push(`origin is "official" but npm scope @${scope} does not obviously match author "${author}" — verify npm maintainers`);
251
+ }
252
+ }
253
+ }
254
+ else {
255
+ // No scope — unscoped packages are harder to verify, just note it
256
+ warnings.push(`origin is "official" but package "${installPkg}" is unscoped — verify npm maintainers match "${author}"`);
257
+ }
258
+ }
259
+ if (origin === "community" && typeof installPkg === "string") {
260
+ // If community but package scope matches a well-known official org, warn
261
+ const scopeMatch = installPkg.match(/^@([^/]+)\//);
262
+ if (scopeMatch) {
263
+ const scope = scopeMatch[1].toLowerCase();
264
+ const officialScopes = new Set([
265
+ "cloudflare", "stripe", "mongodb", "sentry", "datadog",
266
+ "supabase", "notion-email", "slack", "linear", "playwright",
267
+ "brave", "hubspot", "twilio-alpha", "perplexity-ai", "upstash",
268
+ "pinecone-database", "grafana", "e2b", "browserbasehq", "brightdata",
269
+ "letta-ai", "sanity", "contentful", "neondatabase",
270
+ "shortcut", "webflow-bot", "doist", "replicate",
271
+ ]);
272
+ if (officialScopes.has(scope)) {
273
+ warnings.push(`origin is "community" but package scope @${scope} looks like an official org — verify origin`);
274
+ }
275
+ }
276
+ }
190
277
  // ── Build result ───────────────────────────────────────────────────────────
191
278
  const valid = errors.length === 0;
192
279
  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.0",
3
+ "version": "2.8.3",
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",
@@ -58,10 +58,15 @@ if [[ -f "$RECIPE_CACHE_DIR/recipe.json" ]]; then
58
58
  elif command -v curl &>/dev/null; then
59
59
  echo "[mcp-bridge] Fetching recipe from catalog..."
60
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
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
62
66
  echo "[mcp-bridge] ✓ Recipe downloaded from catalog"
63
67
  SERVER_DIR="$RECIPE_CACHE_DIR"
64
68
  else
69
+ rm -f "$RECIPE_CACHE_DIR/recipe.json"
65
70
  echo "[mcp-bridge] Catalog unavailable, using bundled recipe"
66
71
  # Fall through to existing SERVER_DIR
67
72
  fi
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Verify Ed25519 signatures on all recipes in servers/.
4
+ * Usage: node scripts/verify-signatures.mjs [--ci]
5
+ *
6
+ * --ci: exit with code 1 if any signature is invalid (for CI/pre-commit)
7
+ *
8
+ * Requires: public key in keys/aiwerk-public.pem (or set AIWERK_PUBLIC_KEY env var)
9
+ */
10
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
11
+ import { join, dirname } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { verify, createPublicKey } from 'node:crypto';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const ROOT = join(__dirname, '..');
17
+ const SERVERS_DIR = join(ROOT, 'servers');
18
+
19
+ const SIGNED_FIELDS = ['id', 'name', 'description', 'transports', 'auth', 'install', 'metadata'];
20
+
21
+ function stableStringify(obj) {
22
+ if (obj === null || obj === undefined) return JSON.stringify(obj);
23
+ if (typeof obj !== 'object') return JSON.stringify(obj);
24
+ if (Array.isArray(obj)) {
25
+ return '[' + obj.map(item => stableStringify(item)).join(',') + ']';
26
+ }
27
+ const sorted = Object.keys(obj).sort();
28
+ const entries = sorted.map(key => JSON.stringify(key) + ':' + stableStringify(obj[key]));
29
+ return '{' + entries.join(',') + '}';
30
+ }
31
+
32
+ function canonicalPayload(recipe) {
33
+ const subset = {};
34
+ for (const field of SIGNED_FIELDS) {
35
+ if (field in recipe) {
36
+ subset[field] = recipe[field];
37
+ }
38
+ }
39
+ return stableStringify(subset);
40
+ }
41
+
42
+ function loadPublicKey() {
43
+ // Try env var first
44
+ if (process.env.AIWERK_PUBLIC_KEY) {
45
+ const pem = Buffer.from(process.env.AIWERK_PUBLIC_KEY, 'base64').toString('utf-8');
46
+ return createPublicKey(pem);
47
+ }
48
+ // Try keys/ dir
49
+ const keyPath = join(ROOT, 'keys', 'aiwerk-public.pem');
50
+ if (existsSync(keyPath)) {
51
+ return createPublicKey(readFileSync(keyPath, 'utf-8'));
52
+ }
53
+ // Try catalog repo
54
+ const catalogKeyPath = join(ROOT, '..', 'mcp-catalog', 'keys', 'aiwerk-public.pem');
55
+ if (existsSync(catalogKeyPath)) {
56
+ return createPublicKey(readFileSync(catalogKeyPath, 'utf-8'));
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function verifySignature(recipe, signature, publicKey) {
62
+ try {
63
+ const payload = canonicalPayload(recipe);
64
+ return verify(null, Buffer.from(payload, 'utf-8'), publicKey, Buffer.from(signature.value, 'base64'));
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ const ciMode = process.argv.includes('--ci');
71
+ const publicKey = loadPublicKey();
72
+
73
+ if (!publicKey) {
74
+ console.error('⚠️ No public key found. Skipping signature verification.');
75
+ console.error(' Set AIWERK_PUBLIC_KEY env var or place keys/aiwerk-public.pem');
76
+ process.exit(ciMode ? 1 : 0);
77
+ }
78
+
79
+ const entries = readdirSync(SERVERS_DIR, { withFileTypes: true })
80
+ .filter(e => e.isDirectory())
81
+ .sort((a, b) => a.name.localeCompare(b.name));
82
+
83
+ let total = 0;
84
+ let valid = 0;
85
+ let invalid = 0;
86
+ let unsigned = 0;
87
+
88
+ for (const entry of entries) {
89
+ const recipePath = join(SERVERS_DIR, entry.name, 'recipe.json');
90
+ if (!existsSync(recipePath)) continue;
91
+
92
+ total++;
93
+ const recipe = JSON.parse(readFileSync(recipePath, 'utf-8'));
94
+
95
+ if (!recipe.signature) {
96
+ unsigned++;
97
+ console.log(`⚠️ ${entry.name}: UNSIGNED`);
98
+ continue;
99
+ }
100
+
101
+ const isValid = verifySignature(recipe, recipe.signature, publicKey);
102
+ if (isValid) {
103
+ valid++;
104
+ console.log(`✅ ${entry.name}: valid`);
105
+ } else {
106
+ invalid++;
107
+ console.log(`❌ ${entry.name}: INVALID SIGNATURE`);
108
+ }
109
+ }
110
+
111
+ console.log(`\n${total} recipes: ${valid} valid, ${unsigned} unsigned, ${invalid} invalid`);
112
+
113
+ if (invalid > 0) {
114
+ console.error(`\n🚨 ${invalid} recipe(s) have invalid signatures!`);
115
+ console.error(' Run: cd ../mcp-catalog && npx tsx scripts/sign-recipe.ts <recipe.json> --output <recipe.json>');
116
+ process.exit(1);
117
+ }
118
+
119
+ if (unsigned > 0 && ciMode) {
120
+ console.error(`\n⚠️ ${unsigned} recipe(s) are unsigned`);
121
+ }
@@ -36,7 +36,7 @@
36
36
  "pricing": "byok",
37
37
  "maturity": "stable",
38
38
  "subcategory": "scraping",
39
- "origin": "community",
39
+ "origin": "official",
40
40
  "countries": [
41
41
  "global"
42
42
  ],
@@ -54,7 +54,7 @@
54
54
  "signature": {
55
55
  "algorithm": "ed25519",
56
56
  "publisherId": "aiwerk",
57
- "value": "2eHbBy6v0MMBTdnIusU9oNReuNjg1CKKA7iknkS7FxQNTuisjZFAW2x5DFjUgN9FqjWmlwwhSLnk/qhwfV42Cw==",
57
+ "value": "00uinemhL7iVz+e7EZ1mFhYwXB3SHhFeyKQ6nop2FhCqqykSuojLriDjx/sX8M+xAAG/C4QOPfL8qJCgNxq+Bw==",
58
58
  "signedFields": [
59
59
  "id",
60
60
  "name",
@@ -64,6 +64,6 @@
64
64
  "install",
65
65
  "metadata"
66
66
  ],
67
- "signedAt": "2026-03-20T14:24:25.196Z"
67
+ "signedAt": "2026-03-24T18:32:28.206Z"
68
68
  }
69
69
  }
@@ -41,13 +41,13 @@
41
41
  "pricing": "free",
42
42
  "maturity": "stable",
43
43
  "subcategory": "monitoring",
44
- "origin": "community",
44
+ "origin": "official",
45
45
  "countries": [
46
46
  "global"
47
47
  ],
48
48
  "audience": "developer",
49
49
  "selfHosted": true,
50
- "sideEffects": "read-only",
50
+ "sideEffects": "read-write",
51
51
  "authSummary": "none"
52
52
  },
53
53
  "capabilities": {
@@ -59,7 +59,7 @@
59
59
  "signature": {
60
60
  "algorithm": "ed25519",
61
61
  "publisherId": "aiwerk",
62
- "value": "fo/w/oYgd8T4ecaOnSUq3vE5nl82RVB31Mj+2v5W/j6qM+wK17xstVMdiZuiqH+a6YDQXXe30S9enRjLK0y/CA==",
62
+ "value": "pjNR/z4SN6fffrfYbT8rFak6dyqqoQHu5FzHraRf3Lq1eTwEt6Gu82vo2Fa0lpbeTkrZ4wS6LDRaKQpWl2SXBg==",
63
63
  "signedFields": [
64
64
  "id",
65
65
  "name",
@@ -69,6 +69,6 @@
69
69
  "install",
70
70
  "metadata"
71
71
  ],
72
- "signedAt": "2026-03-20T14:24:32.876Z"
72
+ "signedAt": "2026-03-24T18:33:00.640Z"
73
73
  }
74
74
  }
@@ -51,7 +51,7 @@
51
51
  "global"
52
52
  ],
53
53
  "audience": "developer",
54
- "selfHosted": true,
54
+ "selfHosted": false,
55
55
  "sideEffects": "read-only",
56
56
  "authSummary": "api-key"
57
57
  },
@@ -64,7 +64,7 @@
64
64
  "signature": {
65
65
  "algorithm": "ed25519",
66
66
  "publisherId": "aiwerk",
67
- "value": "s2daVrlfMhaE5+Tk/Y0CnXtG/S6qIVzP0/iHobQ4edC2RmfZFOfiaBUfOL+p6745VSoiiO8nMLDhuiRrUGQrDQ==",
67
+ "value": "eQvGxgLGN6NVFD5KnjYWTN37ulE0rwTamMkLSHnK3Z2b8uDJz0gGIatCaN6qTCX707g75zY+COQqbpltf23NAw==",
68
68
  "signedFields": [
69
69
  "id",
70
70
  "name",
@@ -74,6 +74,6 @@
74
74
  "install",
75
75
  "metadata"
76
76
  ],
77
- "signedAt": "2026-03-20T14:24:35.540Z"
77
+ "signedAt": "2026-03-24T18:33:33.007Z"
78
78
  }
79
79
  }
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "id": "google-maps",
4
4
  "name": "Google Maps",
5
- "description": "Google Maps integration for places search, geocoding, directions, and distance matrix via the official MCP server",
5
+ "description": "Google Maps integration for places search, geocoding, directions, and distance matrix via the @modelcontextprotocol MCP server",
6
6
  "repository": "https://github.com/modelcontextprotocol/servers",
7
7
  "transports": [
8
8
  {
@@ -45,7 +45,7 @@
45
45
  "pricing": "byok",
46
46
  "maturity": "stable",
47
47
  "subcategory": "search-engine",
48
- "origin": "official",
48
+ "origin": "community",
49
49
  "countries": [
50
50
  "global"
51
51
  ],
@@ -63,7 +63,7 @@
63
63
  "signature": {
64
64
  "algorithm": "ed25519",
65
65
  "publisherId": "aiwerk",
66
- "value": "nc+tSwZmwP+MHs9z8VtyL5ZNf/iAsItoHdlygRezoZ5TjR2H++3nOlMmvp5JKC+Fq61P3iYFDRXTM0/VO6a3DA==",
66
+ "value": "xS+v0iMoG9mJ7RJErekW+Kww/7p18SNL5VxnxGz/haObs2hqrg85CJgTvgXKqnVkdRD9OuQRu0oRxtQgAzzOCw==",
67
67
  "signedFields": [
68
68
  "id",
69
69
  "name",
@@ -73,6 +73,6 @@
73
73
  "install",
74
74
  "metadata"
75
75
  ],
76
- "signedAt": "2026-03-20T14:24:43.129Z"
76
+ "signedAt": "2026-03-24T18:34:05.383Z"
77
77
  }
78
78
  }
@@ -44,7 +44,7 @@
44
44
  "pricing": "byok",
45
45
  "maturity": "stable",
46
46
  "subcategory": "cloud",
47
- "origin": "community",
47
+ "origin": "official",
48
48
  "countries": [
49
49
  "global"
50
50
  ],
@@ -63,7 +63,7 @@
63
63
  "signature": {
64
64
  "algorithm": "ed25519",
65
65
  "publisherId": "aiwerk",
66
- "value": "Btxn49bVCENtq5hgKDhSvYjU8KtbcxFKIAjclBfxl2W87YDFH68q2vkXzkqd1B9QHE74P0djp8KWt5hR0m7lBA==",
66
+ "value": "7OV7oq85Z/TQkt4GzU82cAUDul+MvWRAqVaNBXY8xjRRsOwBkGTQcWxFo+8ZYbmEH+D8C0wRoragBxSonhXPBQ==",
67
67
  "signedFields": [
68
68
  "id",
69
69
  "name",
@@ -73,6 +73,6 @@
73
73
  "install",
74
74
  "metadata"
75
75
  ],
76
- "signedAt": "2026-03-20T14:25:53.457Z"
76
+ "signedAt": "2026-03-24T18:34:37.697Z"
77
77
  }
78
78
  }
@@ -17,7 +17,7 @@
17
17
  "homepage": "https://github.com/AIWerk/mcp-server-imap",
18
18
  "license": "MIT",
19
19
  "pricing": "free",
20
- "maturity": "beta",
20
+ "maturity": "stable",
21
21
  "subcategory": "email",
22
22
  "origin": "aiwerk",
23
23
  "countries": [
@@ -88,7 +88,7 @@
88
88
  "signature": {
89
89
  "algorithm": "ed25519",
90
90
  "publisherId": "aiwerk",
91
- "value": "IJQAle97X6wb3x+b6tPne74fwOUhif34GLkd4QII9ziwPyYI7r4Tv6eiV8gBc06d+H2mxMcqn/LE7qxMrxpDDg==",
91
+ "value": "m6cRcAiimoEXyt9wl+69O5z0JeLWo9bUzgfo+/33H8WJamz9RTacywypQasBbBNDBjYn2QrXSnvEDOVJa1/JBg==",
92
92
  "signedFields": [
93
93
  "id",
94
94
  "name",
@@ -98,6 +98,6 @@
98
98
  "install",
99
99
  "metadata"
100
100
  ],
101
- "signedAt": "2026-03-20T15:15:39.408Z"
101
+ "signedAt": "2026-03-24T18:35:06.321Z"
102
102
  }
103
103
  }
@@ -7,7 +7,7 @@
7
7
  "description": "Web scraping and automation platform with 3000+ ready-made actors for data extraction, browser automation, and crawling",
8
8
  "category": "automation",
9
9
  "subcategory": "scraping",
10
- "origin": "community",
10
+ "origin": "official",
11
11
  "countries": [
12
12
  "global"
13
13
  ],
@@ -57,7 +57,7 @@
57
57
  "description": "Control and inspect a live Chrome browser for automation, debugging, and performance analysis via the Chrome DevTools Protocol",
58
58
  "category": "development",
59
59
  "subcategory": "monitoring",
60
- "origin": "community",
60
+ "origin": "official",
61
61
  "countries": [
62
62
  "global"
63
63
  ],
@@ -104,10 +104,10 @@
104
104
  },
105
105
  "google-maps": {
106
106
  "name": "Google Maps",
107
- "description": "Google Maps integration for places search, geocoding, directions, and distance matrix via the official MCP server",
107
+ "description": "Google Maps integration for places search, geocoding, directions, and distance matrix via the @modelcontextprotocol MCP server",
108
108
  "category": "data",
109
109
  "subcategory": "search-engine",
110
- "origin": "official",
110
+ "origin": "community",
111
111
  "countries": [
112
112
  "global"
113
113
  ],
@@ -157,7 +157,7 @@
157
157
  "description": "Web hosting management via Hostinger API — manage domains, hosting plans, email accounts, and website settings",
158
158
  "category": "infrastructure",
159
159
  "subcategory": "cloud",
160
- "origin": "community",
160
+ "origin": "official",
161
161
  "countries": [
162
162
  "global"
163
163
  ],
@@ -206,7 +206,7 @@
206
206
  "description": "Linear project management — create, update, and search issues, projects, and teams via the Linear API",
207
207
  "category": "productivity",
208
208
  "subcategory": "project-management",
209
- "origin": "official",
209
+ "origin": "community",
210
210
  "countries": [
211
211
  "global"
212
212
  ],
@@ -375,6 +375,32 @@
375
375
  "maturity": "stable",
376
376
  "toolCount": null,
377
377
  "authSummary": "none"
378
+ },
379
+ "firecrawl": {
380
+ "name": "Firecrawl",
381
+ "description": "Web scraping, crawling, search, and structured data extraction — turn any website into clean markdown or structured data for AI agents",
382
+ "category": "data",
383
+ "subcategory": "web-scraping",
384
+ "origin": "official",
385
+ "countries": [
386
+ "global"
387
+ ],
388
+ "tags": [
389
+ "scraping",
390
+ "crawling",
391
+ "web",
392
+ "extraction",
393
+ "search",
394
+ "markdown"
395
+ ],
396
+ "authType": "api-key",
397
+ "transports": [
398
+ "stdio"
399
+ ],
400
+ "installMethod": "npx",
401
+ "maturity": "stable",
402
+ "toolCount": null,
403
+ "authSummary": "api-key"
378
404
  }
379
405
  }
380
406
  }
@@ -45,7 +45,7 @@
45
45
  "pricing": "byok",
46
46
  "maturity": "stable",
47
47
  "subcategory": "project-management",
48
- "origin": "official",
48
+ "origin": "community",
49
49
  "countries": [
50
50
  "global"
51
51
  ],
@@ -63,7 +63,7 @@
63
63
  "signature": {
64
64
  "algorithm": "ed25519",
65
65
  "publisherId": "aiwerk",
66
- "value": "Q3yXbOmJTN7V2+fUmXbxjtxv4ScPMGsQRbffDYYzsAjQGYllz8DwmuvZeHpLSGtP1Fl6g2X0iVnzlxfTzl3FCg==",
66
+ "value": "qmaBgrxT3lNFEjQ4MOBeyDC0Cs7pCG487so8PU89JU6MqHUj3foZHGH40BLVcb+nIjp5N1P503LphZCQIC0jBw==",
67
67
  "signedFields": [
68
68
  "id",
69
69
  "name",
@@ -73,6 +73,6 @@
73
73
  "install",
74
74
  "metadata"
75
75
  ],
76
- "signedAt": "2026-03-20T14:24:58.098Z"
76
+ "signedAt": "2026-03-24T18:35:38.635Z"
77
77
  }
78
78
  }