@aiwerk/mcp-bridge 2.7.6 → 2.8.2
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 +76 -2
- package/dist/bin/mcp-bridge.js +3 -1
- package/dist/src/catalog-client.d.ts +102 -0
- package/dist/src/catalog-client.js +194 -0
- package/dist/src/config.d.ts +32 -0
- package/dist/src/config.js +196 -0
- package/dist/src/index.d.ts +5 -1
- package/dist/src/index.js +5 -1
- package/dist/src/recipe-cache.d.ts +28 -0
- package/dist/src/recipe-cache.js +64 -0
- package/dist/src/types.d.ts +18 -0
- package/dist/src/validate-recipe.js +87 -0
- package/package.json +1 -1
- package/scripts/install-server.sh +23 -0
- package/scripts/verify-signatures.mjs +121 -0
- package/servers/apify/recipe.json +3 -3
- package/servers/chrome-devtools/recipe.json +4 -4
- package/servers/firecrawl/recipe.json +3 -3
- package/servers/google-maps/recipe.json +4 -4
- package/servers/hostinger/recipe.json +3 -3
- package/servers/imap-email/recipe.json +3 -3
- package/servers/index.json +32 -6
- package/servers/linear/recipe.json +3 -3
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
|
|
@@ -87,6 +87,80 @@ npx @aiwerk/mcp-bridge validate-recipe ./recipe.json
|
|
|
87
87
|
|
|
88
88
|
`config.json` (v1) remains supported, but `recipe.json` (v2) is the recommended format going forward.
|
|
89
89
|
|
|
90
|
+
## Catalog Integration (v2.8.0+)
|
|
91
|
+
|
|
92
|
+
mcp-bridge now fetches recipes from [catalog.aiwerk.ch](https://catalog.aiwerk.ch) instead of relying on bundled recipe files.
|
|
93
|
+
|
|
94
|
+
### How it works
|
|
95
|
+
1. **First run**: Automatically downloads the top 15 most popular recipes
|
|
96
|
+
2. **On-demand**: When you install a server, it checks the catalog first
|
|
97
|
+
3. **Offline**: Falls back to local cache if catalog is unreachable
|
|
98
|
+
|
|
99
|
+
### API
|
|
100
|
+
```typescript
|
|
101
|
+
import { CatalogClient, bootstrapCatalog, mergeRecipesIntoConfig } from '@aiwerk/mcp-bridge';
|
|
102
|
+
|
|
103
|
+
// Bootstrap: download top recipes
|
|
104
|
+
await bootstrapCatalog();
|
|
105
|
+
|
|
106
|
+
// Or use the client directly
|
|
107
|
+
const client = new CatalogClient();
|
|
108
|
+
const recipe = await client.resolve('todoist');
|
|
109
|
+
const results = await client.search('email');
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 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
|
+
|
|
162
|
+
> **Note**: The bundled `servers/` directory is deprecated and will be removed in v3.0.0.
|
|
163
|
+
|
|
90
164
|
## Use with Cursor / Windsurf
|
|
91
165
|
|
|
92
166
|
Add to your MCP config:
|
|
@@ -595,7 +669,7 @@ For production deployments with high security requirements, consider adding an e
|
|
|
595
669
|
| ✅ | OAuth2 Device Code flow (headless) | 2.6.0 |
|
|
596
670
|
| 🔜 | Auto-discovery (zero-config server registration) | planned |
|
|
597
671
|
| 🔜 | Hosted bridge (bridge.aiwerk.ch) | planned |
|
|
598
|
-
|
|
|
672
|
+
| ✅ | Remote catalog integration | 2.8.0 |
|
|
599
673
|
| 🔜 | OpenTelemetry / Prometheus metrics | planned |
|
|
600
674
|
| 🔜 | PII redaction | planned |
|
|
601
675
|
|
package/dist/bin/mcp-bridge.js
CHANGED
|
@@ -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 () => {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CatalogClient — REST client for the AIWerk MCP Catalog API.
|
|
3
|
+
*
|
|
4
|
+
* Default endpoint: https://catalog.aiwerk.ch
|
|
5
|
+
* Supports local file caching with offline fallback.
|
|
6
|
+
*/
|
|
7
|
+
import type { Logger } from "./types.js";
|
|
8
|
+
export interface CatalogSearchResult {
|
|
9
|
+
name: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
category?: string;
|
|
12
|
+
quality?: number;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface CatalogRecipe {
|
|
16
|
+
name: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
transport?: "stdio" | "sse" | "streamable-http";
|
|
19
|
+
command?: string;
|
|
20
|
+
args?: string[];
|
|
21
|
+
env?: Record<string, string>;
|
|
22
|
+
url?: string;
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
transports?: Array<{
|
|
25
|
+
type: "stdio" | "sse" | "streamable-http";
|
|
26
|
+
command?: string;
|
|
27
|
+
args?: string[];
|
|
28
|
+
env?: Record<string, string>;
|
|
29
|
+
url?: string;
|
|
30
|
+
headers?: Record<string, string>;
|
|
31
|
+
}>;
|
|
32
|
+
install?: {
|
|
33
|
+
npm?: {
|
|
34
|
+
package: string;
|
|
35
|
+
version?: string;
|
|
36
|
+
};
|
|
37
|
+
docker?: {
|
|
38
|
+
image: string;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
auth?: {
|
|
42
|
+
type: string;
|
|
43
|
+
required?: boolean;
|
|
44
|
+
envVars?: string[];
|
|
45
|
+
};
|
|
46
|
+
[key: string]: unknown;
|
|
47
|
+
}
|
|
48
|
+
export declare class CatalogError extends Error {
|
|
49
|
+
constructor(message: string);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* CatalogClient — REST client for the AIWerk MCP Catalog API.
|
|
53
|
+
*
|
|
54
|
+
* NOTE: File I/O operations (readCache, writeCache, etc.) are intentionally
|
|
55
|
+
* synchronous. This is acceptable for CLI tools and bridge startup, but
|
|
56
|
+
* should be converted to async if used in hot paths (e.g., per-request).
|
|
57
|
+
*/
|
|
58
|
+
export declare class CatalogClient {
|
|
59
|
+
private baseUrl;
|
|
60
|
+
private cacheDir;
|
|
61
|
+
private logger;
|
|
62
|
+
private staleMs;
|
|
63
|
+
constructor(opts?: {
|
|
64
|
+
baseUrl?: string;
|
|
65
|
+
cacheDir?: string;
|
|
66
|
+
logger?: Logger;
|
|
67
|
+
staleDays?: number;
|
|
68
|
+
});
|
|
69
|
+
private fetchJson;
|
|
70
|
+
private cachePath;
|
|
71
|
+
private readCache;
|
|
72
|
+
private writeCache;
|
|
73
|
+
private isCacheStale;
|
|
74
|
+
/** Search for recipes by keyword. */
|
|
75
|
+
search(query: string): Promise<CatalogSearchResult[]>;
|
|
76
|
+
/** List recipes with optional filtering. */
|
|
77
|
+
list(opts?: {
|
|
78
|
+
limit?: number;
|
|
79
|
+
category?: string;
|
|
80
|
+
sort?: string;
|
|
81
|
+
hostedSafe?: boolean;
|
|
82
|
+
}): Promise<{
|
|
83
|
+
results: CatalogSearchResult[];
|
|
84
|
+
total: number;
|
|
85
|
+
}>;
|
|
86
|
+
/** Download a recipe from the catalog and cache it locally. */
|
|
87
|
+
download(name: string): Promise<CatalogRecipe>;
|
|
88
|
+
/**
|
|
89
|
+
* Resolve a recipe — returns cached if available, otherwise fetches from catalog.
|
|
90
|
+
* Falls back to cache when the catalog is unreachable (offline mode).
|
|
91
|
+
*/
|
|
92
|
+
resolve(name: string): Promise<CatalogRecipe>;
|
|
93
|
+
/**
|
|
94
|
+
* Bootstrap by downloading the top N most popular recipes.
|
|
95
|
+
* Skips already-cached recipes unless they are stale.
|
|
96
|
+
*/
|
|
97
|
+
bootstrap(limit?: number, hostedSafe?: boolean): Promise<string[]>;
|
|
98
|
+
/** Synchronously read a recipe from local cache. Returns null if not cached. */
|
|
99
|
+
getCached(name: string): CatalogRecipe | null;
|
|
100
|
+
/** List all recipe names in the local cache. */
|
|
101
|
+
listCached(): string[];
|
|
102
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CatalogClient — REST client for the AIWerk MCP Catalog API.
|
|
3
|
+
*
|
|
4
|
+
* Default endpoint: https://catalog.aiwerk.ch
|
|
5
|
+
* Supports local file caching with offline fallback.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
// ── Error ────────────────────────────────────────────────────────────────────
|
|
11
|
+
export class CatalogError extends Error {
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "CatalogError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
18
|
+
const TIMEOUT_MS = 5_000;
|
|
19
|
+
const noop = {
|
|
20
|
+
info: () => { },
|
|
21
|
+
warn: () => { },
|
|
22
|
+
error: () => { },
|
|
23
|
+
debug: () => { },
|
|
24
|
+
};
|
|
25
|
+
// ── CatalogClient ────────────────────────────────────────────────────────────
|
|
26
|
+
/**
|
|
27
|
+
* CatalogClient — REST client for the AIWerk MCP Catalog API.
|
|
28
|
+
*
|
|
29
|
+
* NOTE: File I/O operations (readCache, writeCache, etc.) are intentionally
|
|
30
|
+
* synchronous. This is acceptable for CLI tools and bridge startup, but
|
|
31
|
+
* should be converted to async if used in hot paths (e.g., per-request).
|
|
32
|
+
*/
|
|
33
|
+
export class CatalogClient {
|
|
34
|
+
baseUrl;
|
|
35
|
+
cacheDir;
|
|
36
|
+
logger;
|
|
37
|
+
staleMs;
|
|
38
|
+
constructor(opts) {
|
|
39
|
+
this.baseUrl = (opts?.baseUrl ?? "https://catalog.aiwerk.ch").replace(/\/+$/, "");
|
|
40
|
+
this.cacheDir = opts?.cacheDir ?? join(homedir(), ".mcp-bridge", "recipes");
|
|
41
|
+
this.logger = opts?.logger ?? noop;
|
|
42
|
+
this.staleMs = (opts?.staleDays ?? 7) * 24 * 60 * 60 * 1000;
|
|
43
|
+
}
|
|
44
|
+
// ── Private helpers ──────────────────────────────────────────────────────
|
|
45
|
+
async fetchJson(path) {
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
50
|
+
headers: { accept: "application/json" },
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
});
|
|
53
|
+
if (res.status === 404) {
|
|
54
|
+
const name = path.split("/").filter(Boolean).pop() ?? path;
|
|
55
|
+
throw new CatalogError(`Recipe not found: ${name}`);
|
|
56
|
+
}
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
throw new CatalogError(`Catalog HTTP ${res.status}: ${await res.text().catch(() => "")}`);
|
|
59
|
+
}
|
|
60
|
+
return (await res.json());
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
cachePath(name) {
|
|
67
|
+
return join(this.cacheDir, name, "recipe.json");
|
|
68
|
+
}
|
|
69
|
+
readCache(name) {
|
|
70
|
+
const p = this.cachePath(name);
|
|
71
|
+
if (!existsSync(p))
|
|
72
|
+
return null;
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
writeCache(name, data) {
|
|
81
|
+
const dir = join(this.cacheDir, name);
|
|
82
|
+
if (!existsSync(dir))
|
|
83
|
+
mkdirSync(dir, { recursive: true });
|
|
84
|
+
writeFileSync(this.cachePath(name), JSON.stringify(data, null, 2), "utf-8");
|
|
85
|
+
}
|
|
86
|
+
isCacheStale(name) {
|
|
87
|
+
const p = this.cachePath(name);
|
|
88
|
+
if (!existsSync(p))
|
|
89
|
+
return true;
|
|
90
|
+
try {
|
|
91
|
+
const stat = statSync(p);
|
|
92
|
+
return Date.now() - stat.mtimeMs > this.staleMs;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// ── Public API ───────────────────────────────────────────────────────────
|
|
99
|
+
/** Search for recipes by keyword. */
|
|
100
|
+
async search(query) {
|
|
101
|
+
const encoded = encodeURIComponent(query);
|
|
102
|
+
return this.fetchJson(`/api/search?q=${encoded}`);
|
|
103
|
+
}
|
|
104
|
+
/** List recipes with optional filtering. */
|
|
105
|
+
async list(opts) {
|
|
106
|
+
const params = new URLSearchParams();
|
|
107
|
+
if (opts?.limit != null)
|
|
108
|
+
params.set("limit", String(opts.limit));
|
|
109
|
+
if (opts?.category)
|
|
110
|
+
params.set("category", opts.category);
|
|
111
|
+
if (opts?.sort)
|
|
112
|
+
params.set("sort", opts.sort);
|
|
113
|
+
if (opts?.hostedSafe)
|
|
114
|
+
params.set("hostedSafe", "true");
|
|
115
|
+
const qs = params.toString();
|
|
116
|
+
return this.fetchJson(`/api/recipes${qs ? `?${qs}` : ""}`);
|
|
117
|
+
}
|
|
118
|
+
/** Download a recipe from the catalog and cache it locally. */
|
|
119
|
+
async download(name) {
|
|
120
|
+
const recipe = await this.fetchJson(`/api/recipes/${encodeURIComponent(name)}/download`);
|
|
121
|
+
this.writeCache(name, recipe);
|
|
122
|
+
return recipe;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Resolve a recipe — returns cached if available, otherwise fetches from catalog.
|
|
126
|
+
* Falls back to cache when the catalog is unreachable (offline mode).
|
|
127
|
+
*/
|
|
128
|
+
async resolve(name) {
|
|
129
|
+
const cached = this.readCache(name);
|
|
130
|
+
if (cached && !this.isCacheStale(name))
|
|
131
|
+
return cached;
|
|
132
|
+
try {
|
|
133
|
+
const recipe = await this.fetchJson(`/api/recipes/${encodeURIComponent(name)}/download`);
|
|
134
|
+
this.writeCache(name, recipe);
|
|
135
|
+
return recipe;
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
if (err instanceof CatalogError && err.message.startsWith("Recipe not found:")) {
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
if (cached) {
|
|
142
|
+
this.logger.warn(`Catalog unreachable for "${name}", using cached version`);
|
|
143
|
+
return cached;
|
|
144
|
+
}
|
|
145
|
+
throw new CatalogError(`Cannot resolve recipe "${name}": catalog unreachable and no local cache`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
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
|
+
/** Synchronously read a recipe from local cache. Returns null if not cached. */
|
|
183
|
+
getCached(name) {
|
|
184
|
+
return this.readCache(name);
|
|
185
|
+
}
|
|
186
|
+
/** List all recipe names in the local cache. */
|
|
187
|
+
listCached() {
|
|
188
|
+
if (!existsSync(this.cacheDir))
|
|
189
|
+
return [];
|
|
190
|
+
return readdirSync(this.cacheDir, { withFileTypes: true })
|
|
191
|
+
.filter((d) => d.isDirectory() && existsSync(join(this.cacheDir, d.name, "recipe.json")))
|
|
192
|
+
.map((d) => d.name);
|
|
193
|
+
}
|
|
194
|
+
}
|
package/dist/src/config.d.ts
CHANGED
|
@@ -24,7 +24,39 @@ export interface LoadConfigOptions {
|
|
|
24
24
|
* 4. Validate required fields
|
|
25
25
|
*/
|
|
26
26
|
export declare function loadConfig(options?: LoadConfigOptions): BridgeConfig;
|
|
27
|
+
/**
|
|
28
|
+
* Warn about deprecated bundled recipes.
|
|
29
|
+
* In v2.8.0, bundled servers/ recipes are deprecated in favor of catalog.
|
|
30
|
+
* They will be removed in v3.0.0.
|
|
31
|
+
*/
|
|
32
|
+
export declare function warnDeprecatedBundledRecipes(logger: Logger): void;
|
|
27
33
|
/** Get the default config directory path. */
|
|
28
34
|
export declare function getConfigDir(configPath?: string): string;
|
|
29
35
|
/** Initialize the config directory with template files. */
|
|
30
36
|
export declare function initConfigDir(logger: Logger): void;
|
|
37
|
+
/**
|
|
38
|
+
* Bootstrap the local recipe cache from the catalog.
|
|
39
|
+
* Downloads top N popular recipes if cache is empty or force=true.
|
|
40
|
+
* Returns array of recipe names now cached. Never throws on network errors.
|
|
41
|
+
*/
|
|
42
|
+
export declare function bootstrapCatalog(options?: {
|
|
43
|
+
logger?: Logger;
|
|
44
|
+
cacheDir?: string;
|
|
45
|
+
catalogUrl?: string;
|
|
46
|
+
limit?: number;
|
|
47
|
+
force?: boolean;
|
|
48
|
+
requireCleanAudit?: boolean;
|
|
49
|
+
catalog?: boolean;
|
|
50
|
+
}): Promise<string[]>;
|
|
51
|
+
/**
|
|
52
|
+
* Merge cached catalog recipes into a BridgeConfig.
|
|
53
|
+
* Only adds recipes whose required env vars are all present in process.env.
|
|
54
|
+
* Never overwrites manually configured servers.
|
|
55
|
+
*
|
|
56
|
+
* IMPORTANT: Must be called AFTER loadConfig() / dotenv, since env var
|
|
57
|
+
* checks rely on process.env being fully populated.
|
|
58
|
+
*/
|
|
59
|
+
export declare function mergeRecipesIntoConfig(config: BridgeConfig, options?: {
|
|
60
|
+
cacheDir?: string;
|
|
61
|
+
logger?: Logger;
|
|
62
|
+
}): BridgeConfig;
|
package/dist/src/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join, extname } from "path";
|
|
|
3
3
|
import { homedir } from "os";
|
|
4
4
|
import { resolveEnvVars } from "./transport-base.js";
|
|
5
5
|
import { randomBytes } from "crypto";
|
|
6
|
+
import { CatalogClient } from "./catalog-client.js";
|
|
6
7
|
const DEFAULT_CONFIG_DIR = join(homedir(), ".mcp-bridge");
|
|
7
8
|
const DEFAULT_CONFIG_FILE = "config.json";
|
|
8
9
|
const DEFAULT_ENV_FILE = ".env";
|
|
@@ -153,6 +154,18 @@ export function loadConfig(options = {}) {
|
|
|
153
154
|
}
|
|
154
155
|
return config;
|
|
155
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Warn about deprecated bundled recipes.
|
|
159
|
+
* In v2.8.0, bundled servers/ recipes are deprecated in favor of catalog.
|
|
160
|
+
* They will be removed in v3.0.0.
|
|
161
|
+
*/
|
|
162
|
+
export function warnDeprecatedBundledRecipes(logger) {
|
|
163
|
+
const catalogClient = new CatalogClient({ logger });
|
|
164
|
+
const cached = catalogClient.listCached();
|
|
165
|
+
if (cached.length === 0) {
|
|
166
|
+
logger.info('[mcp-bridge] Tip: Run bootstrapCatalog() to fetch recipes from catalog.aiwerk.ch (replaces bundled servers/)');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
156
169
|
/** Get the default config directory path. */
|
|
157
170
|
export function getConfigDir(configPath) {
|
|
158
171
|
if (!configPath)
|
|
@@ -205,3 +218,186 @@ export function initConfigDir(logger) {
|
|
|
205
218
|
}
|
|
206
219
|
logger.info(`Config directory ready: ${dir}`);
|
|
207
220
|
}
|
|
221
|
+
// ── Catalog bootstrap ─────────────────────────────────────────────────────────
|
|
222
|
+
const noopLogger = {
|
|
223
|
+
info: () => { },
|
|
224
|
+
warn: () => { },
|
|
225
|
+
error: () => { },
|
|
226
|
+
debug: () => { },
|
|
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
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Bootstrap the local recipe cache from the catalog.
|
|
240
|
+
* Downloads top N popular recipes if cache is empty or force=true.
|
|
241
|
+
* Returns array of recipe names now cached. Never throws on network errors.
|
|
242
|
+
*/
|
|
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
|
+
}
|
|
250
|
+
const logger = options?.logger ?? noopLogger;
|
|
251
|
+
const cacheDir = options?.cacheDir ?? join(homedir(), ".mcp-bridge", "recipes");
|
|
252
|
+
const requireCleanAudit = options?.requireCleanAudit ?? false;
|
|
253
|
+
const client = new CatalogClient({
|
|
254
|
+
baseUrl: options?.catalogUrl,
|
|
255
|
+
cacheDir,
|
|
256
|
+
logger,
|
|
257
|
+
});
|
|
258
|
+
// Check if cache already has recipes
|
|
259
|
+
if (!options?.force) {
|
|
260
|
+
const cached = client.listCached();
|
|
261
|
+
if (cached.length > 0) {
|
|
262
|
+
logger.debug(`Recipe cache already has ${cached.length} recipes, skipping bootstrap`);
|
|
263
|
+
return cached;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
return await client.bootstrap(options?.limit ?? 15, requireCleanAudit);
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
logger.warn(`Catalog unreachable during bootstrap: ${err instanceof Error ? err.message : err}`);
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Merge cached catalog recipes into a BridgeConfig.
|
|
276
|
+
* Only adds recipes whose required env vars are all present in process.env.
|
|
277
|
+
* Never overwrites manually configured servers.
|
|
278
|
+
*
|
|
279
|
+
* IMPORTANT: Must be called AFTER loadConfig() / dotenv, since env var
|
|
280
|
+
* checks rely on process.env being fully populated.
|
|
281
|
+
*/
|
|
282
|
+
export function mergeRecipesIntoConfig(config, options) {
|
|
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
|
+
}
|
|
289
|
+
const cacheDir = options?.cacheDir ?? join(homedir(), ".mcp-bridge", "recipes");
|
|
290
|
+
const client = new CatalogClient({ cacheDir, logger });
|
|
291
|
+
const names = client.listCached();
|
|
292
|
+
if (names.length === 0)
|
|
293
|
+
return config;
|
|
294
|
+
const servers = { ...config.servers };
|
|
295
|
+
const requireCleanAudit = config.security?.requireCleanAudit ?? false;
|
|
296
|
+
for (const name of names) {
|
|
297
|
+
// Never overwrite manually configured servers
|
|
298
|
+
if (servers[name])
|
|
299
|
+
continue;
|
|
300
|
+
const recipe = client.getCached(name);
|
|
301
|
+
if (!recipe)
|
|
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
|
+
}
|
|
315
|
+
const converted = recipeToServerConfig(recipe);
|
|
316
|
+
if (!converted) {
|
|
317
|
+
logger.debug(`Skipping recipe "${name}": unsupported format`);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
// Check that all required env vars are available
|
|
321
|
+
const requiredVars = collectRequiredEnvVars(recipe);
|
|
322
|
+
const missing = requiredVars.filter((v) => !process.env[v]);
|
|
323
|
+
if (missing.length > 0) {
|
|
324
|
+
logger.debug(`Skipping recipe "${name}": missing env vars: ${missing.join(", ")}`);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
servers[name] = converted;
|
|
328
|
+
logger.debug(`Added catalog recipe "${name}" to config`);
|
|
329
|
+
}
|
|
330
|
+
return { ...config, servers };
|
|
331
|
+
}
|
|
332
|
+
/** Convert a catalog recipe JSON to McpServerConfig, or null if unsupported. */
|
|
333
|
+
function recipeToServerConfig(recipe) {
|
|
334
|
+
// v2 recipe: has transports array
|
|
335
|
+
if (Array.isArray(recipe.transports) && recipe.transports.length > 0) {
|
|
336
|
+
const t = recipe.transports[0];
|
|
337
|
+
if (t.type === "stdio") {
|
|
338
|
+
return {
|
|
339
|
+
transport: "stdio",
|
|
340
|
+
description: recipe.description,
|
|
341
|
+
command: t.command,
|
|
342
|
+
args: t.args,
|
|
343
|
+
env: t.env,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
if (t.type === "sse" || t.type === "streamable-http") {
|
|
347
|
+
return {
|
|
348
|
+
transport: t.type,
|
|
349
|
+
description: recipe.description,
|
|
350
|
+
url: t.url,
|
|
351
|
+
headers: t.headers,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
// v1 recipe: has transport string
|
|
357
|
+
if (recipe.transport === "stdio") {
|
|
358
|
+
return {
|
|
359
|
+
transport: "stdio",
|
|
360
|
+
description: recipe.description,
|
|
361
|
+
command: recipe.command,
|
|
362
|
+
args: recipe.args,
|
|
363
|
+
env: recipe.env,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
if (recipe.transport === "sse" || recipe.transport === "streamable-http") {
|
|
367
|
+
return {
|
|
368
|
+
transport: recipe.transport,
|
|
369
|
+
description: recipe.description,
|
|
370
|
+
url: recipe.url,
|
|
371
|
+
headers: recipe.headers,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
/** Collect all env var names required by a recipe. */
|
|
377
|
+
function collectRequiredEnvVars(recipe) {
|
|
378
|
+
const vars = new Set();
|
|
379
|
+
// From auth.envVars
|
|
380
|
+
if (Array.isArray(recipe.auth?.envVars)) {
|
|
381
|
+
for (const v of recipe.auth.envVars)
|
|
382
|
+
vars.add(v);
|
|
383
|
+
}
|
|
384
|
+
// From env object: extract ${VAR} references
|
|
385
|
+
const envObj = Array.isArray(recipe.transports)
|
|
386
|
+
? recipe.transports[0]?.env
|
|
387
|
+
: recipe.env;
|
|
388
|
+
if (envObj && typeof envObj === "object") {
|
|
389
|
+
for (const val of Object.values(envObj)) {
|
|
390
|
+
if (typeof val === "string") {
|
|
391
|
+
const matches = val.matchAll(/\$\{([^}]+)\}/g);
|
|
392
|
+
for (const m of matches)
|
|
393
|
+
vars.add(m[1]);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// If auth is explicitly required but no env vars were found,
|
|
398
|
+
// return a placeholder to prevent auto-registration without credentials
|
|
399
|
+
if (recipe.auth?.required === true && vars.size === 0) {
|
|
400
|
+
vars.add("__AUTH_REQUIRED__");
|
|
401
|
+
}
|
|
402
|
+
return [...vars];
|
|
403
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export { ToolResolver } from "./tool-resolution.js";
|
|
|
18
18
|
export type { ToolResolutionResult, ToolResolutionCandidate } from "./tool-resolution.js";
|
|
19
19
|
export { convertJsonSchemaToTypeBox, createToolParameters, setTypeBoxLoader, setSchemaLogger } from "./schema-convert.js";
|
|
20
20
|
export { initializeProtocol, fetchToolsList, PACKAGE_VERSION } from "./protocol.js";
|
|
21
|
-
export { loadConfig, parseEnvFile, initConfigDir, getConfigDir } from "./config.js";
|
|
21
|
+
export { loadConfig, parseEnvFile, initConfigDir, getConfigDir, bootstrapCatalog, mergeRecipesIntoConfig, 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";
|
|
@@ -26,3 +26,7 @@ export { StandaloneServer } from "./standalone-server.js";
|
|
|
26
26
|
export { checkForUpdate, checkPluginUpdate, getUpdateNotice, runUpdate, resetNoticeFlag } from "./update-checker.js";
|
|
27
27
|
export type { UpdateInfo, PluginUpdateInfo } from "./update-checker.js";
|
|
28
28
|
export { filterServers, buildFilteredDescription } from "./smart-filter.js";
|
|
29
|
+
export { CatalogClient, CatalogError } from "./catalog-client.js";
|
|
30
|
+
export type { CatalogRecipe, CatalogSearchResult } from "./catalog-client.js";
|
|
31
|
+
export { RecipeCache } from "./recipe-cache.js";
|
|
32
|
+
export type { CachedRecipe } from "./recipe-cache.js";
|