@aiwerk/mcp-bridge 2.7.5 → 2.8.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 +25 -1
- package/dist/src/catalog-client.d.ts +101 -0
- package/dist/src/catalog-client.js +192 -0
- package/dist/src/config.d.ts +30 -0
- package/dist/src/config.js +163 -0
- package/dist/src/index.d.ts +5 -1
- package/dist/src/index.js +5 -1
- package/dist/src/protocol.js +1 -1
- package/dist/src/recipe-cache.d.ts +28 -0
- package/dist/src/recipe-cache.js +64 -0
- package/dist/src/standalone-server.js +1 -1
- package/package.json +1 -1
- package/scripts/install-server.sh +18 -0
package/README.md
CHANGED
|
@@ -87,6 +87,30 @@ 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
|
+
> **Note**: The bundled `servers/` directory is deprecated and will be removed in v3.0.0.
|
|
113
|
+
|
|
90
114
|
## Use with Cursor / Windsurf
|
|
91
115
|
|
|
92
116
|
Add to your MCP config:
|
|
@@ -595,7 +619,7 @@ For production deployments with high security requirements, consider adding an e
|
|
|
595
619
|
| ✅ | OAuth2 Device Code flow (headless) | 2.6.0 |
|
|
596
620
|
| 🔜 | Auto-discovery (zero-config server registration) | planned |
|
|
597
621
|
| 🔜 | Hosted bridge (bridge.aiwerk.ch) | planned |
|
|
598
|
-
|
|
|
622
|
+
| ✅ | Remote catalog integration | 2.8.0 |
|
|
599
623
|
| 🔜 | OpenTelemetry / Prometheus metrics | planned |
|
|
600
624
|
| 🔜 | PII redaction | planned |
|
|
601
625
|
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
}): Promise<{
|
|
82
|
+
results: CatalogSearchResult[];
|
|
83
|
+
total: number;
|
|
84
|
+
}>;
|
|
85
|
+
/** Download a recipe from the catalog and cache it locally. */
|
|
86
|
+
download(name: string): Promise<CatalogRecipe>;
|
|
87
|
+
/**
|
|
88
|
+
* Resolve a recipe — returns cached if available, otherwise fetches from catalog.
|
|
89
|
+
* Falls back to cache when the catalog is unreachable (offline mode).
|
|
90
|
+
*/
|
|
91
|
+
resolve(name: string): Promise<CatalogRecipe>;
|
|
92
|
+
/**
|
|
93
|
+
* Bootstrap by downloading the top N most popular recipes.
|
|
94
|
+
* Skips already-cached recipes unless they are stale.
|
|
95
|
+
*/
|
|
96
|
+
bootstrap(limit?: number): Promise<string[]>;
|
|
97
|
+
/** Synchronously read a recipe from local cache. Returns null if not cached. */
|
|
98
|
+
getCached(name: string): CatalogRecipe | null;
|
|
99
|
+
/** List all recipe names in the local cache. */
|
|
100
|
+
listCached(): string[];
|
|
101
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
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
|
+
const qs = params.toString();
|
|
114
|
+
return this.fetchJson(`/api/recipes${qs ? `?${qs}` : ""}`);
|
|
115
|
+
}
|
|
116
|
+
/** Download a recipe from the catalog and cache it locally. */
|
|
117
|
+
async download(name) {
|
|
118
|
+
const recipe = await this.fetchJson(`/api/recipes/${encodeURIComponent(name)}/download`);
|
|
119
|
+
this.writeCache(name, recipe);
|
|
120
|
+
return recipe;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Resolve a recipe — returns cached if available, otherwise fetches from catalog.
|
|
124
|
+
* Falls back to cache when the catalog is unreachable (offline mode).
|
|
125
|
+
*/
|
|
126
|
+
async resolve(name) {
|
|
127
|
+
const cached = this.readCache(name);
|
|
128
|
+
if (cached && !this.isCacheStale(name))
|
|
129
|
+
return cached;
|
|
130
|
+
try {
|
|
131
|
+
const recipe = await this.fetchJson(`/api/recipes/${encodeURIComponent(name)}/download`);
|
|
132
|
+
this.writeCache(name, recipe);
|
|
133
|
+
return recipe;
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (err instanceof CatalogError && err.message.startsWith("Recipe not found:")) {
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
if (cached) {
|
|
140
|
+
this.logger.warn(`Catalog unreachable for "${name}", using cached version`);
|
|
141
|
+
return cached;
|
|
142
|
+
}
|
|
143
|
+
throw new CatalogError(`Cannot resolve recipe "${name}": catalog unreachable and no local cache`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Bootstrap by downloading the top N most popular recipes.
|
|
148
|
+
* Skips already-cached recipes unless they are stale.
|
|
149
|
+
*/
|
|
150
|
+
async bootstrap(limit = 15) {
|
|
151
|
+
const { results } = await this.list({ limit, sort: "popular" });
|
|
152
|
+
const names = [];
|
|
153
|
+
const toDownload = [];
|
|
154
|
+
for (const entry of results) {
|
|
155
|
+
const name = entry.name;
|
|
156
|
+
if (!this.isCacheStale(name)) {
|
|
157
|
+
names.push(name);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
toDownload.push(name);
|
|
161
|
+
}
|
|
162
|
+
const BATCH_SIZE = 5;
|
|
163
|
+
for (let i = 0; i < toDownload.length; i += BATCH_SIZE) {
|
|
164
|
+
const batch = toDownload.slice(i, i + BATCH_SIZE);
|
|
165
|
+
const results = await Promise.allSettled(batch.map(async (name) => {
|
|
166
|
+
await this.download(name);
|
|
167
|
+
return name;
|
|
168
|
+
}));
|
|
169
|
+
for (const r of results) {
|
|
170
|
+
if (r.status === "fulfilled") {
|
|
171
|
+
names.push(r.value);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
this.logger.warn(`Failed to download recipe: ${r.reason}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return names;
|
|
179
|
+
}
|
|
180
|
+
/** Synchronously read a recipe from local cache. Returns null if not cached. */
|
|
181
|
+
getCached(name) {
|
|
182
|
+
return this.readCache(name);
|
|
183
|
+
}
|
|
184
|
+
/** List all recipe names in the local cache. */
|
|
185
|
+
listCached() {
|
|
186
|
+
if (!existsSync(this.cacheDir))
|
|
187
|
+
return [];
|
|
188
|
+
return readdirSync(this.cacheDir, { withFileTypes: true })
|
|
189
|
+
.filter((d) => d.isDirectory() && existsSync(join(this.cacheDir, d.name, "recipe.json")))
|
|
190
|
+
.map((d) => d.name);
|
|
191
|
+
}
|
|
192
|
+
}
|
package/dist/src/config.d.ts
CHANGED
|
@@ -24,7 +24,37 @@ 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(config: BridgeConfig, 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
|
+
}): Promise<string[]>;
|
|
49
|
+
/**
|
|
50
|
+
* Merge cached catalog recipes into a BridgeConfig.
|
|
51
|
+
* Only adds recipes whose required env vars are all present in process.env.
|
|
52
|
+
* Never overwrites manually configured servers.
|
|
53
|
+
*
|
|
54
|
+
* IMPORTANT: Must be called AFTER loadConfig() / dotenv, since env var
|
|
55
|
+
* checks rely on process.env being fully populated.
|
|
56
|
+
*/
|
|
57
|
+
export declare function mergeRecipesIntoConfig(config: BridgeConfig, options?: {
|
|
58
|
+
cacheDir?: string;
|
|
59
|
+
logger?: Logger;
|
|
60
|
+
}): 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,20 @@ 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(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
|
|
165
|
+
const catalogClient = new CatalogClient({ logger });
|
|
166
|
+
const cached = catalogClient.listCached();
|
|
167
|
+
if (cached.length === 0) {
|
|
168
|
+
logger.info('[mcp-bridge] Tip: Run bootstrapCatalog() to fetch recipes from catalog.aiwerk.ch (replaces bundled servers/)');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
156
171
|
/** Get the default config directory path. */
|
|
157
172
|
export function getConfigDir(configPath) {
|
|
158
173
|
if (!configPath)
|
|
@@ -205,3 +220,151 @@ export function initConfigDir(logger) {
|
|
|
205
220
|
}
|
|
206
221
|
logger.info(`Config directory ready: ${dir}`);
|
|
207
222
|
}
|
|
223
|
+
// ── Catalog bootstrap ─────────────────────────────────────────────────────────
|
|
224
|
+
const noopLogger = {
|
|
225
|
+
info: () => { },
|
|
226
|
+
warn: () => { },
|
|
227
|
+
error: () => { },
|
|
228
|
+
debug: () => { },
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* Bootstrap the local recipe cache from the catalog.
|
|
232
|
+
* Downloads top N popular recipes if cache is empty or force=true.
|
|
233
|
+
* Returns array of recipe names now cached. Never throws on network errors.
|
|
234
|
+
*/
|
|
235
|
+
export async function bootstrapCatalog(options) {
|
|
236
|
+
const logger = options?.logger ?? noopLogger;
|
|
237
|
+
const cacheDir = options?.cacheDir ?? join(homedir(), ".mcp-bridge", "recipes");
|
|
238
|
+
const client = new CatalogClient({
|
|
239
|
+
baseUrl: options?.catalogUrl,
|
|
240
|
+
cacheDir,
|
|
241
|
+
logger,
|
|
242
|
+
});
|
|
243
|
+
// Check if cache already has recipes
|
|
244
|
+
if (!options?.force) {
|
|
245
|
+
const cached = client.listCached();
|
|
246
|
+
if (cached.length > 0) {
|
|
247
|
+
logger.debug(`Recipe cache already has ${cached.length} recipes, skipping bootstrap`);
|
|
248
|
+
return cached;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
return await client.bootstrap(options?.limit ?? 15);
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
logger.warn(`Catalog unreachable during bootstrap: ${err instanceof Error ? err.message : err}`);
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Merge cached catalog recipes into a BridgeConfig.
|
|
261
|
+
* Only adds recipes whose required env vars are all present in process.env.
|
|
262
|
+
* Never overwrites manually configured servers.
|
|
263
|
+
*
|
|
264
|
+
* IMPORTANT: Must be called AFTER loadConfig() / dotenv, since env var
|
|
265
|
+
* checks rely on process.env being fully populated.
|
|
266
|
+
*/
|
|
267
|
+
export function mergeRecipesIntoConfig(config, options) {
|
|
268
|
+
const logger = options?.logger ?? noopLogger;
|
|
269
|
+
const cacheDir = options?.cacheDir ?? join(homedir(), ".mcp-bridge", "recipes");
|
|
270
|
+
const client = new CatalogClient({ cacheDir, logger });
|
|
271
|
+
const names = client.listCached();
|
|
272
|
+
if (names.length === 0)
|
|
273
|
+
return config;
|
|
274
|
+
const servers = { ...config.servers };
|
|
275
|
+
for (const name of names) {
|
|
276
|
+
// Never overwrite manually configured servers
|
|
277
|
+
if (servers[name])
|
|
278
|
+
continue;
|
|
279
|
+
const recipe = client.getCached(name);
|
|
280
|
+
if (!recipe)
|
|
281
|
+
continue;
|
|
282
|
+
const converted = recipeToServerConfig(recipe);
|
|
283
|
+
if (!converted) {
|
|
284
|
+
logger.debug(`Skipping recipe "${name}": unsupported format`);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
// Check that all required env vars are available
|
|
288
|
+
const requiredVars = collectRequiredEnvVars(recipe);
|
|
289
|
+
const missing = requiredVars.filter((v) => !process.env[v]);
|
|
290
|
+
if (missing.length > 0) {
|
|
291
|
+
logger.debug(`Skipping recipe "${name}": missing env vars: ${missing.join(", ")}`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
servers[name] = converted;
|
|
295
|
+
logger.debug(`Added catalog recipe "${name}" to config`);
|
|
296
|
+
}
|
|
297
|
+
return { ...config, servers };
|
|
298
|
+
}
|
|
299
|
+
/** Convert a catalog recipe JSON to McpServerConfig, or null if unsupported. */
|
|
300
|
+
function recipeToServerConfig(recipe) {
|
|
301
|
+
// v2 recipe: has transports array
|
|
302
|
+
if (Array.isArray(recipe.transports) && recipe.transports.length > 0) {
|
|
303
|
+
const t = recipe.transports[0];
|
|
304
|
+
if (t.type === "stdio") {
|
|
305
|
+
return {
|
|
306
|
+
transport: "stdio",
|
|
307
|
+
description: recipe.description,
|
|
308
|
+
command: t.command,
|
|
309
|
+
args: t.args,
|
|
310
|
+
env: t.env,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (t.type === "sse" || t.type === "streamable-http") {
|
|
314
|
+
return {
|
|
315
|
+
transport: t.type,
|
|
316
|
+
description: recipe.description,
|
|
317
|
+
url: t.url,
|
|
318
|
+
headers: t.headers,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
// v1 recipe: has transport string
|
|
324
|
+
if (recipe.transport === "stdio") {
|
|
325
|
+
return {
|
|
326
|
+
transport: "stdio",
|
|
327
|
+
description: recipe.description,
|
|
328
|
+
command: recipe.command,
|
|
329
|
+
args: recipe.args,
|
|
330
|
+
env: recipe.env,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (recipe.transport === "sse" || recipe.transport === "streamable-http") {
|
|
334
|
+
return {
|
|
335
|
+
transport: recipe.transport,
|
|
336
|
+
description: recipe.description,
|
|
337
|
+
url: recipe.url,
|
|
338
|
+
headers: recipe.headers,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
/** Collect all env var names required by a recipe. */
|
|
344
|
+
function collectRequiredEnvVars(recipe) {
|
|
345
|
+
const vars = new Set();
|
|
346
|
+
// From auth.envVars
|
|
347
|
+
if (Array.isArray(recipe.auth?.envVars)) {
|
|
348
|
+
for (const v of recipe.auth.envVars)
|
|
349
|
+
vars.add(v);
|
|
350
|
+
}
|
|
351
|
+
// From env object: extract ${VAR} references
|
|
352
|
+
const envObj = Array.isArray(recipe.transports)
|
|
353
|
+
? recipe.transports[0]?.env
|
|
354
|
+
: recipe.env;
|
|
355
|
+
if (envObj && typeof envObj === "object") {
|
|
356
|
+
for (const val of Object.values(envObj)) {
|
|
357
|
+
if (typeof val === "string") {
|
|
358
|
+
const matches = val.matchAll(/\$\{([^}]+)\}/g);
|
|
359
|
+
for (const m of matches)
|
|
360
|
+
vars.add(m[1]);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// If auth is explicitly required but no env vars were found,
|
|
365
|
+
// return a placeholder to prevent auto-registration without credentials
|
|
366
|
+
if (recipe.auth?.required === true && vars.size === 0) {
|
|
367
|
+
vars.add("__AUTH_REQUIRED__");
|
|
368
|
+
}
|
|
369
|
+
return [...vars];
|
|
370
|
+
}
|
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 } 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";
|
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 } from "./config.js";
|
|
23
|
+
export { loadConfig, parseEnvFile, initConfigDir, getConfigDir, bootstrapCatalog, mergeRecipesIntoConfig } from "./config.js";
|
|
24
24
|
export { nextRequestId } from "./types.js";
|
|
25
25
|
// Tool naming
|
|
26
26
|
export { pickRegisteredToolName } from "./tool-naming.js";
|
|
@@ -30,3 +30,7 @@ export { StandaloneServer } from "./standalone-server.js";
|
|
|
30
30
|
export { checkForUpdate, checkPluginUpdate, getUpdateNotice, runUpdate, resetNoticeFlag } from "./update-checker.js";
|
|
31
31
|
// Smart filter
|
|
32
32
|
export { filterServers, buildFilteredDescription } from "./smart-filter.js";
|
|
33
|
+
// Catalog client
|
|
34
|
+
export { CatalogClient, CatalogError } from "./catalog-client.js";
|
|
35
|
+
// Recipe cache
|
|
36
|
+
export { RecipeCache } from "./recipe-cache.js";
|
package/dist/src/protocol.js
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RecipeCache — local file cache for downloaded catalog recipes.
|
|
3
|
+
*
|
|
4
|
+
* Default cache dir: ~/.mcp-bridge/recipes/
|
|
5
|
+
* Each recipe is stored as recipes/<name>/recipe.json with metadata.
|
|
6
|
+
*/
|
|
7
|
+
import type { CatalogRecipe } from "./catalog-client.js";
|
|
8
|
+
export interface CachedRecipe {
|
|
9
|
+
recipe: CatalogRecipe;
|
|
10
|
+
downloadedAt: string;
|
|
11
|
+
catalogVersion?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare class RecipeCache {
|
|
14
|
+
private cacheDir;
|
|
15
|
+
constructor(cacheDir?: string);
|
|
16
|
+
private recipePath;
|
|
17
|
+
private ensureDir;
|
|
18
|
+
/** Get a cached recipe by name, or undefined if not cached. */
|
|
19
|
+
get(name: string): CachedRecipe | undefined;
|
|
20
|
+
/** Cache a recipe. */
|
|
21
|
+
put(name: string, recipe: CatalogRecipe, catalogVersion?: string): void;
|
|
22
|
+
/** List all cached recipe names. */
|
|
23
|
+
list(): string[];
|
|
24
|
+
/** Check if a recipe is cached. */
|
|
25
|
+
has(name: string): boolean;
|
|
26
|
+
/** Remove all cached recipes. */
|
|
27
|
+
clear(): void;
|
|
28
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RecipeCache — local file cache for downloaded catalog recipes.
|
|
3
|
+
*
|
|
4
|
+
* Default cache dir: ~/.mcp-bridge/recipes/
|
|
5
|
+
* Each recipe is stored as recipes/<name>/recipe.json with metadata.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
export class RecipeCache {
|
|
11
|
+
cacheDir;
|
|
12
|
+
constructor(cacheDir) {
|
|
13
|
+
this.cacheDir = cacheDir ?? join(homedir(), ".mcp-bridge", "recipes");
|
|
14
|
+
}
|
|
15
|
+
recipePath(name) {
|
|
16
|
+
return join(this.cacheDir, name, "recipe.json");
|
|
17
|
+
}
|
|
18
|
+
ensureDir(name) {
|
|
19
|
+
const dir = join(this.cacheDir, name);
|
|
20
|
+
if (!existsSync(dir)) {
|
|
21
|
+
mkdirSync(dir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Get a cached recipe by name, or undefined if not cached. */
|
|
25
|
+
get(name) {
|
|
26
|
+
const path = this.recipePath(name);
|
|
27
|
+
if (!existsSync(path))
|
|
28
|
+
return undefined;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Cache a recipe. */
|
|
37
|
+
put(name, recipe, catalogVersion) {
|
|
38
|
+
this.ensureDir(name);
|
|
39
|
+
const entry = {
|
|
40
|
+
recipe,
|
|
41
|
+
downloadedAt: new Date().toISOString(),
|
|
42
|
+
catalogVersion,
|
|
43
|
+
};
|
|
44
|
+
writeFileSync(this.recipePath(name), JSON.stringify(entry, null, 2), "utf-8");
|
|
45
|
+
}
|
|
46
|
+
/** List all cached recipe names. */
|
|
47
|
+
list() {
|
|
48
|
+
if (!existsSync(this.cacheDir))
|
|
49
|
+
return [];
|
|
50
|
+
return readdirSync(this.cacheDir, { withFileTypes: true })
|
|
51
|
+
.filter((d) => d.isDirectory() && existsSync(join(this.cacheDir, d.name, "recipe.json")))
|
|
52
|
+
.map((d) => d.name);
|
|
53
|
+
}
|
|
54
|
+
/** Check if a recipe is cached. */
|
|
55
|
+
has(name) {
|
|
56
|
+
return existsSync(this.recipePath(name));
|
|
57
|
+
}
|
|
58
|
+
/** Remove all cached recipes. */
|
|
59
|
+
clear() {
|
|
60
|
+
if (existsSync(this.cacheDir)) {
|
|
61
|
+
rmSync(this.cacheDir, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -49,6 +49,24 @@ while [[ $# -gt 0 ]]; do
|
|
|
49
49
|
done
|
|
50
50
|
|
|
51
51
|
SERVER_DIR="$SCRIPT_DIR/../servers/$SERVER_NAME"
|
|
52
|
+
|
|
53
|
+
# v2.8.0: Try catalog first, fall back to bundled servers/
|
|
54
|
+
RECIPE_CACHE_DIR="$HOME/.mcp-bridge/recipes/$SERVER_NAME"
|
|
55
|
+
if [[ -f "$RECIPE_CACHE_DIR/recipe.json" ]]; then
|
|
56
|
+
echo "[mcp-bridge] Using cached catalog recipe for $SERVER_NAME"
|
|
57
|
+
SERVER_DIR="$RECIPE_CACHE_DIR"
|
|
58
|
+
elif command -v curl &>/dev/null; then
|
|
59
|
+
echo "[mcp-bridge] Fetching recipe from catalog..."
|
|
60
|
+
mkdir -p "$RECIPE_CACHE_DIR"
|
|
61
|
+
if curl -sf "https://catalog.aiwerk.ch/api/recipes/$SERVER_NAME/download" -o "$RECIPE_CACHE_DIR/recipe.json" 2>/dev/null; then
|
|
62
|
+
echo "[mcp-bridge] ✓ Recipe downloaded from catalog"
|
|
63
|
+
SERVER_DIR="$RECIPE_CACHE_DIR"
|
|
64
|
+
else
|
|
65
|
+
echo "[mcp-bridge] Catalog unavailable, using bundled recipe"
|
|
66
|
+
# Fall through to existing SERVER_DIR
|
|
67
|
+
fi
|
|
68
|
+
fi
|
|
69
|
+
|
|
52
70
|
if [[ ! -d "$SERVER_DIR" ]]; then
|
|
53
71
|
echo "Error: Server '$SERVER_NAME' not found."
|
|
54
72
|
usage
|