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