@aiwerk/mcp-bridge 1.7.1 → 1.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 +20 -0
- package/dist/bin/mcp-bridge.js +2 -2
- package/dist/bin/validate-recipe.d.ts +6 -0
- package/dist/bin/validate-recipe.js +33 -0
- package/dist/src/config.js +5 -1
- package/dist/src/embeddings.js +7 -1
- package/dist/src/mcp-router.d.ts +1 -1
- package/dist/src/security.d.ts +2 -1
- package/dist/src/security.js +2 -1
- package/dist/src/standalone-server.d.ts +1 -0
- package/dist/src/standalone-server.js +16 -4
- package/dist/src/transport-sse.d.ts +1 -1
- package/dist/src/transport-sse.js +9 -15
- package/dist/src/types.js +5 -2
- package/dist/src/validate-recipe.d.ts +76 -0
- package/dist/src/validate-recipe.js +241 -0
- package/package.json +3 -2
- package/scripts/install-server.sh +96 -14
- package/servers/apify/recipe.json +41 -0
- package/servers/atlassian/recipe.json +60 -0
- package/servers/chrome-devtools/recipe.json +45 -0
- package/servers/github/recipe.json +55 -0
- package/servers/google-maps/recipe.json +48 -0
- package/servers/hetzner/recipe.json +49 -0
- package/servers/hostinger/recipe.json +47 -0
- package/servers/index.json +130 -120
- package/servers/linear/recipe.json +49 -0
- package/servers/miro/recipe.json +48 -0
- package/servers/notion/recipe.json +48 -0
- package/servers/stripe/recipe.json +48 -0
- package/servers/tavily/recipe.json +48 -0
- package/servers/todoist/recipe.json +48 -0
- package/servers/wise/recipe.json +49 -0
package/README.md
CHANGED
|
@@ -59,6 +59,26 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
|
59
59
|
}
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
## Recipe Spec v2
|
|
63
|
+
|
|
64
|
+
Bundled servers now ship with `recipe.json` using **Universal Recipe Spec v2.0**.
|
|
65
|
+
During install, MCP Bridge prefers `recipe.json` when present and falls back to legacy `config.json` (v1) for backwards compatibility.
|
|
66
|
+
|
|
67
|
+
- Spec: [`docs/universal-recipe-spec.md`](docs/universal-recipe-spec.md)
|
|
68
|
+
- Runtime compatibility: v1 and v2 are both supported
|
|
69
|
+
- Existing v1-only servers continue to work unchanged
|
|
70
|
+
|
|
71
|
+
For third-party recipe authors:
|
|
72
|
+
|
|
73
|
+
1. Author `recipe.json` per the spec above.
|
|
74
|
+
2. Validate your recipe before publishing:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npx @aiwerk/mcp-bridge validate-recipe ./recipe.json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`config.json` (v1) remains supported, but `recipe.json` (v2) is the recommended format going forward.
|
|
81
|
+
|
|
62
82
|
## Use with Cursor / Windsurf
|
|
63
83
|
|
|
64
84
|
Add to your MCP config:
|
package/dist/bin/mcp-bridge.js
CHANGED
|
@@ -153,7 +153,7 @@ All logs go to stderr. Stdout is reserved for the MCP protocol (stdio mode).
|
|
|
153
153
|
function cmdInit(logger) {
|
|
154
154
|
initConfigDir(logger);
|
|
155
155
|
}
|
|
156
|
-
function cmdCatalog(logger
|
|
156
|
+
function cmdCatalog(logger) {
|
|
157
157
|
const catalogPath = join(PACKAGE_ROOT, "servers", "index.json");
|
|
158
158
|
if (!existsSync(catalogPath)) {
|
|
159
159
|
logger.error("Server catalog not found");
|
|
@@ -303,7 +303,7 @@ async function main() {
|
|
|
303
303
|
cmdInit(logger);
|
|
304
304
|
break;
|
|
305
305
|
case "catalog":
|
|
306
|
-
cmdCatalog(logger
|
|
306
|
+
cmdCatalog(logger);
|
|
307
307
|
break;
|
|
308
308
|
case "servers":
|
|
309
309
|
cmdServers(logger, args.configPath);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry point for the Universal MCP Recipe Validator.
|
|
4
|
+
* Usage: npx tsx bin/validate-recipe.ts <path-to-recipe.json>
|
|
5
|
+
*/
|
|
6
|
+
import { validateRecipeFile, formatValidationResult } from "../src/validate-recipe.js";
|
|
7
|
+
async function main() {
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
10
|
+
console.log("Usage: validate-recipe <path-to-recipe.json>");
|
|
11
|
+
console.log("");
|
|
12
|
+
console.log("Validates a Universal MCP Recipe against spec v2.0.");
|
|
13
|
+
console.log("Exits 0 if valid, 1 if invalid.");
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
|
16
|
+
const filePath = args[0];
|
|
17
|
+
let result;
|
|
18
|
+
try {
|
|
19
|
+
result = await validateRecipeFile(filePath);
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
console.error(`❌ Could not read file: ${filePath}`);
|
|
23
|
+
console.error(` ${e.message}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
const output = formatValidationResult(filePath, result);
|
|
27
|
+
console.log(output);
|
|
28
|
+
process.exit(result.valid ? 0 : 1);
|
|
29
|
+
}
|
|
30
|
+
main().catch((e) => {
|
|
31
|
+
console.error("Unexpected error:", e);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
});
|
package/dist/src/config.js
CHANGED
|
@@ -108,7 +108,11 @@ export function loadConfig(options = {}) {
|
|
|
108
108
|
}
|
|
109
109
|
// Read and parse config
|
|
110
110
|
const rawConfig = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
111
|
-
//
|
|
111
|
+
// Merge order: .env file values take priority over process.env for config resolution.
|
|
112
|
+
// This is intentional: .env is the user-controlled secrets file, process.env may have
|
|
113
|
+
// stale or system-level values. Note: dotenv loads .env INTO process.env without
|
|
114
|
+
// overwriting (opposite direction), but our config resolver uses this merged map
|
|
115
|
+
// where .env wins.
|
|
112
116
|
const mergedEnv = { ...dotEnv };
|
|
113
117
|
for (const [k, v] of Object.entries(process.env)) {
|
|
114
118
|
if (mergedEnv[k] === undefined)
|
package/dist/src/embeddings.js
CHANGED
|
@@ -84,7 +84,13 @@ export class OpenAIEmbedding {
|
|
|
84
84
|
.map((item) => item.embedding);
|
|
85
85
|
}
|
|
86
86
|
dimensions() {
|
|
87
|
-
|
|
87
|
+
if (this.model.includes("3-large"))
|
|
88
|
+
return 3072;
|
|
89
|
+
if (this.model.includes("3-small"))
|
|
90
|
+
return 1536;
|
|
91
|
+
if (this.model.includes("ada-002"))
|
|
92
|
+
return 1536;
|
|
93
|
+
return 1536; // safe default
|
|
88
94
|
}
|
|
89
95
|
}
|
|
90
96
|
export class OllamaEmbedding {
|
package/dist/src/mcp-router.d.ts
CHANGED
|
@@ -81,7 +81,7 @@ export declare class McpRouter {
|
|
|
81
81
|
private readonly maxConcurrent;
|
|
82
82
|
private readonly states;
|
|
83
83
|
private intentRouter;
|
|
84
|
-
private
|
|
84
|
+
private promotion;
|
|
85
85
|
constructor(servers: Record<string, McpServerConfig>, clientConfig: McpClientConfig, logger: Logger, transportRefs?: Partial<RouterTransportRefs>);
|
|
86
86
|
static generateDescription(servers: Record<string, McpServerConfig>): string;
|
|
87
87
|
dispatch(server?: string, action?: string, tool?: string, params?: any): Promise<RouterDispatchResponse>;
|
package/dist/src/security.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export declare function applyMaxResultSize(result: any, serverConfig: McpServerC
|
|
|
23
23
|
*/
|
|
24
24
|
export declare function applyTrustLevel(result: any, serverName: string, serverConfig: McpServerConfig): any;
|
|
25
25
|
/**
|
|
26
|
-
* Full security pipeline: truncate → sanitize
|
|
26
|
+
* Full security pipeline: truncate → trust-tag (which includes sanitize for trust="sanitize")
|
|
27
|
+
* Note: sanitization only runs when trust="sanitize". trust="untrusted" wraps without sanitizing.
|
|
27
28
|
*/
|
|
28
29
|
export declare function processResult(result: any, serverName: string, serverConfig: McpServerConfig, clientConfig: McpClientConfig): any;
|
package/dist/src/security.js
CHANGED
|
@@ -117,7 +117,8 @@ export function applyTrustLevel(result, serverName, serverConfig) {
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
/**
|
|
120
|
-
* Full security pipeline: truncate → sanitize
|
|
120
|
+
* Full security pipeline: truncate → trust-tag (which includes sanitize for trust="sanitize")
|
|
121
|
+
* Note: sanitization only runs when trust="sanitize". trust="untrusted" wraps without sanitizing.
|
|
121
122
|
*/
|
|
122
123
|
export function processResult(result, serverName, serverConfig, clientConfig) {
|
|
123
124
|
let processed = applyMaxResultSize(result, serverConfig, clientConfig);
|
|
@@ -14,6 +14,7 @@ export class StandaloneServer {
|
|
|
14
14
|
logger;
|
|
15
15
|
router = null;
|
|
16
16
|
initialized = false;
|
|
17
|
+
lspMode = false;
|
|
17
18
|
// Direct mode state
|
|
18
19
|
directTools = [];
|
|
19
20
|
directConnections = new Map();
|
|
@@ -45,9 +46,12 @@ export class StandaloneServer {
|
|
|
45
46
|
progress = false;
|
|
46
47
|
// If we're reading an LSP body, check if we have enough bytes
|
|
47
48
|
if (lspContentLength >= 0 && lspHeadersDone) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
const bufferBytes = Buffer.byteLength(buffer, "utf8");
|
|
50
|
+
if (bufferBytes >= lspContentLength) {
|
|
51
|
+
// Extract exactly lspContentLength bytes (LSP spec defines Content-Length in bytes)
|
|
52
|
+
const bodyBuffer = Buffer.from(buffer, "utf8").slice(0, lspContentLength);
|
|
53
|
+
const body = bodyBuffer.toString("utf8");
|
|
54
|
+
buffer = buffer.substring(body.length);
|
|
51
55
|
lspContentLength = -1;
|
|
52
56
|
lspHeadersDone = false;
|
|
53
57
|
const trimmed = body.trim();
|
|
@@ -80,6 +84,7 @@ export class StandaloneServer {
|
|
|
80
84
|
}
|
|
81
85
|
if (trimmed.startsWith("Content-Length:")) {
|
|
82
86
|
// Start of LSP-framed message
|
|
87
|
+
this.lspMode = true;
|
|
83
88
|
const lengthStr = trimmed.slice("Content-Length:".length).trim();
|
|
84
89
|
const length = parseInt(lengthStr, 10);
|
|
85
90
|
if (!isNaN(length) && length > 0) {
|
|
@@ -135,7 +140,14 @@ export class StandaloneServer {
|
|
|
135
140
|
});
|
|
136
141
|
}
|
|
137
142
|
writeResponse(stdout, response) {
|
|
138
|
-
|
|
143
|
+
const json = JSON.stringify(response);
|
|
144
|
+
if (this.lspMode) {
|
|
145
|
+
const byteLength = Buffer.byteLength(json, "utf8");
|
|
146
|
+
stdout.write(`Content-Length: ${byteLength}\r\n\r\n${json}`);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
stdout.write(json + "\n");
|
|
150
|
+
}
|
|
139
151
|
}
|
|
140
152
|
/** Handle a single MCP JSON-RPC request. */
|
|
141
153
|
async handleRequest(request) {
|
|
@@ -3,7 +3,7 @@ import { BaseTransport } from "./transport-base.js";
|
|
|
3
3
|
export declare class SseTransport extends BaseTransport {
|
|
4
4
|
private endpointUrl;
|
|
5
5
|
private sseAbortController;
|
|
6
|
-
private
|
|
6
|
+
private resolvedHeaders;
|
|
7
7
|
protected get transportName(): string;
|
|
8
8
|
connect(): Promise<void>;
|
|
9
9
|
private _onEndpointReceived;
|
|
@@ -3,15 +3,15 @@ import { BaseTransport, resolveEnvRecord, warnIfNonTlsRemoteUrl } from "./transp
|
|
|
3
3
|
export class SseTransport extends BaseTransport {
|
|
4
4
|
endpointUrl = null;
|
|
5
5
|
sseAbortController = null;
|
|
6
|
-
|
|
6
|
+
resolvedHeaders = null;
|
|
7
7
|
get transportName() { return "SSE"; }
|
|
8
8
|
async connect() {
|
|
9
9
|
if (!this.config.url) {
|
|
10
10
|
throw new Error("SSE transport requires URL");
|
|
11
11
|
}
|
|
12
12
|
warnIfNonTlsRemoteUrl(this.config.url, this.logger);
|
|
13
|
-
//
|
|
14
|
-
resolveEnvRecord(this.config.headers || {}, "header");
|
|
13
|
+
// Resolve headers once and cache for all subsequent requests
|
|
14
|
+
this.resolvedHeaders = resolveEnvRecord(this.config.headers || {}, "header");
|
|
15
15
|
if (this.sseAbortController) {
|
|
16
16
|
this.sseAbortController.abort();
|
|
17
17
|
}
|
|
@@ -36,10 +36,8 @@ export class SseTransport extends BaseTransport {
|
|
|
36
36
|
async startEventStream() {
|
|
37
37
|
if (!this.config.url)
|
|
38
38
|
return;
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
"Accept": "text/event-stream"
|
|
42
|
-
}, "header");
|
|
39
|
+
const base = this.resolvedHeaders ?? resolveEnvRecord(this.config.headers || {}, "header");
|
|
40
|
+
const headers = { ...base, "Accept": "text/event-stream" };
|
|
43
41
|
try {
|
|
44
42
|
const response = await fetch(this.config.url, {
|
|
45
43
|
method: "GET",
|
|
@@ -132,10 +130,8 @@ export class SseTransport extends BaseTransport {
|
|
|
132
130
|
if (!this.connected || !this.endpointUrl) {
|
|
133
131
|
throw new Error("SSE transport not connected or no endpoint URL");
|
|
134
132
|
}
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
"Content-Type": "application/json"
|
|
138
|
-
}, "header");
|
|
133
|
+
const base = this.resolvedHeaders ?? resolveEnvRecord(this.config.headers || {}, "header");
|
|
134
|
+
const headers = { ...base, "Content-Type": "application/json" };
|
|
139
135
|
const response = await fetch(this.endpointUrl, {
|
|
140
136
|
method: "POST",
|
|
141
137
|
headers,
|
|
@@ -158,10 +154,8 @@ export class SseTransport extends BaseTransport {
|
|
|
158
154
|
reject(new Error(`Request timeout after ${requestTimeout}ms`));
|
|
159
155
|
}, requestTimeout);
|
|
160
156
|
this.pendingRequests.set(id, { resolve, reject, timeout });
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
"Content-Type": "application/json"
|
|
164
|
-
}, "header");
|
|
157
|
+
const base = this.resolvedHeaders ?? resolveEnvRecord(this.config.headers || {}, "header");
|
|
158
|
+
const headers = { ...base, "Content-Type": "application/json" };
|
|
165
159
|
// The response arrives via the SSE stream (handleMessage), not from this fetch.
|
|
166
160
|
// The fetch only confirms the server accepted the request (HTTP 200).
|
|
167
161
|
// If the fetch fails, we reject immediately; otherwise we wait for the SSE stream.
|
package/dist/src/types.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
let globalRequestId =
|
|
1
|
+
let globalRequestId = 0;
|
|
2
2
|
export function nextRequestId() {
|
|
3
|
-
globalRequestId
|
|
3
|
+
globalRequestId++;
|
|
4
|
+
if (globalRequestId >= Number.MAX_SAFE_INTEGER) {
|
|
5
|
+
globalRequestId = 1;
|
|
6
|
+
}
|
|
4
7
|
return globalRequestId;
|
|
5
8
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal MCP Recipe Validator (spec v2.0, §7)
|
|
3
|
+
*/
|
|
4
|
+
export interface RecipeTransport {
|
|
5
|
+
type: string;
|
|
6
|
+
command?: string;
|
|
7
|
+
args?: string[];
|
|
8
|
+
env?: Record<string, string>;
|
|
9
|
+
url?: string;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
framing?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface RecipeInstall {
|
|
14
|
+
method: string;
|
|
15
|
+
package?: string;
|
|
16
|
+
image?: string;
|
|
17
|
+
repository?: string;
|
|
18
|
+
buildCommand?: string;
|
|
19
|
+
binary?: string;
|
|
20
|
+
version?: string;
|
|
21
|
+
preInstall?: string[];
|
|
22
|
+
postInstall?: string[];
|
|
23
|
+
platforms?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
export interface RecipeAuth {
|
|
26
|
+
required?: boolean;
|
|
27
|
+
type?: string;
|
|
28
|
+
envVars?: string[];
|
|
29
|
+
credentialsUrl?: string;
|
|
30
|
+
instructions?: string;
|
|
31
|
+
scopes?: string[];
|
|
32
|
+
bootstrap?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface RecipeMetadata {
|
|
35
|
+
homepage?: string;
|
|
36
|
+
license?: string;
|
|
37
|
+
author?: string;
|
|
38
|
+
tags?: string[];
|
|
39
|
+
category?: string;
|
|
40
|
+
languages?: string[];
|
|
41
|
+
pricing?: string;
|
|
42
|
+
maturity?: string;
|
|
43
|
+
firstPublished?: string;
|
|
44
|
+
lastVerified?: string;
|
|
45
|
+
toolCount?: number;
|
|
46
|
+
toolExamples?: Array<{
|
|
47
|
+
name: string;
|
|
48
|
+
description: string;
|
|
49
|
+
}>;
|
|
50
|
+
}
|
|
51
|
+
export interface UniversalRecipe {
|
|
52
|
+
schemaVersion?: unknown;
|
|
53
|
+
id?: unknown;
|
|
54
|
+
name?: unknown;
|
|
55
|
+
description?: unknown;
|
|
56
|
+
repository?: unknown;
|
|
57
|
+
transports?: unknown;
|
|
58
|
+
auth?: RecipeAuth;
|
|
59
|
+
install?: RecipeInstall;
|
|
60
|
+
metadata?: RecipeMetadata;
|
|
61
|
+
capabilities?: unknown;
|
|
62
|
+
[key: string]: unknown;
|
|
63
|
+
}
|
|
64
|
+
export interface ValidationResult {
|
|
65
|
+
valid: boolean;
|
|
66
|
+
errors: string[];
|
|
67
|
+
warnings: string[];
|
|
68
|
+
/** Convenience fields for success output */
|
|
69
|
+
id?: string;
|
|
70
|
+
toolCount?: number;
|
|
71
|
+
primaryTransport?: string;
|
|
72
|
+
installMethod?: string;
|
|
73
|
+
}
|
|
74
|
+
export declare function validateRecipe(recipe: UniversalRecipe): ValidationResult;
|
|
75
|
+
export declare function validateRecipeFile(filePath: string): Promise<ValidationResult>;
|
|
76
|
+
export declare function formatValidationResult(filePath: string, result: ValidationResult): string;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal MCP Recipe Validator (spec v2.0, §7)
|
|
3
|
+
*/
|
|
4
|
+
// ─── Known categories (§3.1) ─────────────────────────────────────────────────
|
|
5
|
+
const KNOWN_CATEGORIES = new Set([
|
|
6
|
+
"productivity",
|
|
7
|
+
"development",
|
|
8
|
+
"communication",
|
|
9
|
+
"data",
|
|
10
|
+
"finance",
|
|
11
|
+
"infrastructure",
|
|
12
|
+
"analytics",
|
|
13
|
+
"content",
|
|
14
|
+
"search",
|
|
15
|
+
"automation",
|
|
16
|
+
"security",
|
|
17
|
+
"ai",
|
|
18
|
+
"other",
|
|
19
|
+
]);
|
|
20
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
21
|
+
/** Extract all ${VAR} references from a string */
|
|
22
|
+
function extractVarRefs(s) {
|
|
23
|
+
const matches = s.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g);
|
|
24
|
+
return [...matches].map((m) => m[1]);
|
|
25
|
+
}
|
|
26
|
+
/** Recursively collect all ${VAR} references from an object's string values */
|
|
27
|
+
function collectVarRefs(obj, refs = new Set()) {
|
|
28
|
+
if (typeof obj === "string") {
|
|
29
|
+
for (const v of extractVarRefs(obj))
|
|
30
|
+
refs.add(v);
|
|
31
|
+
}
|
|
32
|
+
else if (Array.isArray(obj)) {
|
|
33
|
+
for (const item of obj)
|
|
34
|
+
collectVarRefs(item, refs);
|
|
35
|
+
}
|
|
36
|
+
else if (obj && typeof obj === "object") {
|
|
37
|
+
for (const val of Object.values(obj)) {
|
|
38
|
+
collectVarRefs(val, refs);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return refs;
|
|
42
|
+
}
|
|
43
|
+
// ─── Core validator ───────────────────────────────────────────────────────────
|
|
44
|
+
export function validateRecipe(recipe) {
|
|
45
|
+
const errors = [];
|
|
46
|
+
const warnings = [];
|
|
47
|
+
// ── §7.1 Rule 1: schemaVersion === 2 ──────────────────────────────────────
|
|
48
|
+
if (recipe.schemaVersion !== 2) {
|
|
49
|
+
errors.push(`schemaVersion must be 2, got: ${JSON.stringify(recipe.schemaVersion)}`);
|
|
50
|
+
}
|
|
51
|
+
// ── §7.1 Rule 2: id format ─────────────────────────────────────────────────
|
|
52
|
+
const id = recipe.id;
|
|
53
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
54
|
+
errors.push("id is required and must be a non-empty string");
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
if (id.length < 2 || id.length > 64) {
|
|
58
|
+
errors.push(`id must be 2-64 characters, got ${id.length}`);
|
|
59
|
+
}
|
|
60
|
+
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
|
|
61
|
+
errors.push(`id must match ^[a-z0-9][a-z0-9-]*[a-z0-9]$ (lowercase alphanumeric with internal hyphens only), got: "${id}"`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// ── §7.1 Rule 3: name ──────────────────────────────────────────────────────
|
|
65
|
+
const name = recipe.name;
|
|
66
|
+
if (typeof name !== "string" || name.trim().length === 0) {
|
|
67
|
+
errors.push("name is required and must be a non-empty string");
|
|
68
|
+
}
|
|
69
|
+
else if (name.length > 128) {
|
|
70
|
+
errors.push(`name must be max 128 chars, got ${name.length}`);
|
|
71
|
+
}
|
|
72
|
+
// ── §7.1 Rule 4: description ───────────────────────────────────────────────
|
|
73
|
+
const desc = recipe.description;
|
|
74
|
+
if (typeof desc !== "string" || desc.trim().length === 0) {
|
|
75
|
+
errors.push("description is required and must be a non-empty string");
|
|
76
|
+
}
|
|
77
|
+
else if (desc.length > 512) {
|
|
78
|
+
errors.push(`description must be max 512 chars, got ${desc.length}`);
|
|
79
|
+
}
|
|
80
|
+
// ── §7.1 Rule 5: repository or metadata.homepage ──────────────────────────
|
|
81
|
+
const hasRepository = typeof recipe.repository === "string" && recipe.repository.trim().length > 0;
|
|
82
|
+
const hasHomepage = typeof recipe.metadata?.homepage === "string" &&
|
|
83
|
+
recipe.metadata.homepage.trim().length > 0;
|
|
84
|
+
if (!hasRepository && !hasHomepage) {
|
|
85
|
+
errors.push("At least one of repository or metadata.homepage must be present");
|
|
86
|
+
}
|
|
87
|
+
// ── §7.1 Rules 6 & 7: transports ──────────────────────────────────────────
|
|
88
|
+
const transports = recipe.transports;
|
|
89
|
+
if (!Array.isArray(transports) || transports.length === 0) {
|
|
90
|
+
errors.push("transports must be a non-empty array");
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
let hasStdio = false;
|
|
94
|
+
let allRemote = true;
|
|
95
|
+
for (let i = 0; i < transports.length; i++) {
|
|
96
|
+
const t = transports[i];
|
|
97
|
+
if (!t || typeof t !== "object") {
|
|
98
|
+
errors.push(`transports[${i}]: must be an object`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (typeof t.type !== "string" || t.type.trim().length === 0) {
|
|
102
|
+
errors.push(`transports[${i}]: type is required`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const type = t.type;
|
|
106
|
+
if (type === "stdio") {
|
|
107
|
+
hasStdio = true;
|
|
108
|
+
allRemote = false;
|
|
109
|
+
if (typeof t.command !== "string" || t.command.trim().length === 0) {
|
|
110
|
+
errors.push(`transports[${i}] (stdio): command is required`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else if (type === "sse" || type === "streamable-http") {
|
|
114
|
+
if (typeof t.url !== "string" || t.url.trim().length === 0) {
|
|
115
|
+
errors.push(`transports[${i}] (${type}): url is required`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
errors.push(`transports[${i}]: unknown type "${type}", must be "stdio", "sse", or "streamable-http"`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// ── §7.1 Rule 9 & 10: install required for stdio ───────────────────────
|
|
123
|
+
if (hasStdio) {
|
|
124
|
+
if (!recipe.install) {
|
|
125
|
+
errors.push('install is required when any transport has type "stdio"');
|
|
126
|
+
}
|
|
127
|
+
else if (typeof recipe.install.method !== "string" ||
|
|
128
|
+
recipe.install.method.trim().length === 0) {
|
|
129
|
+
errors.push("install.method is required and must be a non-empty string");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// allRemote → install is optional, nothing to check
|
|
133
|
+
// ── §7.1 Rule 8: auth.envVars covers all ${VAR} references ────────────
|
|
134
|
+
// Collect all ${VAR} refs from transports (env, headers, args, url)
|
|
135
|
+
const transportVarRefs = new Set();
|
|
136
|
+
for (const t of transports) {
|
|
137
|
+
if (t && typeof t === "object") {
|
|
138
|
+
collectVarRefs(t.env, transportVarRefs);
|
|
139
|
+
collectVarRefs(t.headers, transportVarRefs);
|
|
140
|
+
collectVarRefs(t.args, transportVarRefs);
|
|
141
|
+
if (typeof t.url === "string")
|
|
142
|
+
collectVarRefs(t.url, transportVarRefs);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (transportVarRefs.size > 0) {
|
|
146
|
+
const declaredEnvVars = new Set(recipe.auth?.envVars ?? []);
|
|
147
|
+
const missing = [...transportVarRefs].filter((v) => !declaredEnvVars.has(v));
|
|
148
|
+
if (missing.length > 0) {
|
|
149
|
+
errors.push(`auth.envVars is missing these \${VAR} references found in transports: ${missing.join(", ")}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// ── §7.2 Warnings ──────────────────────────────────────────────────────────
|
|
154
|
+
// Warning: metadata.lastVerified older than 90 days
|
|
155
|
+
if (typeof recipe.metadata?.lastVerified === "string") {
|
|
156
|
+
const lastVerified = new Date(recipe.metadata.lastVerified);
|
|
157
|
+
if (!isNaN(lastVerified.getTime())) {
|
|
158
|
+
const ageMs = Date.now() - lastVerified.getTime();
|
|
159
|
+
const ninetyDaysMs = 90 * 24 * 60 * 60 * 1000;
|
|
160
|
+
if (ageMs > ninetyDaysMs) {
|
|
161
|
+
warnings.push(`metadata.lastVerified is >90 days old (${recipe.metadata.lastVerified})`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Warning: unknown category
|
|
166
|
+
if (typeof recipe.metadata?.category === "string" &&
|
|
167
|
+
!KNOWN_CATEGORIES.has(recipe.metadata.category)) {
|
|
168
|
+
warnings.push(`metadata.category "${recipe.metadata.category}" is not in the known category list`);
|
|
169
|
+
}
|
|
170
|
+
// Warning: non-empty preInstall / postInstall
|
|
171
|
+
if (Array.isArray(recipe.install?.preInstall) &&
|
|
172
|
+
recipe.install.preInstall.length > 0) {
|
|
173
|
+
warnings.push("Recipe contains preInstall commands - review before executing");
|
|
174
|
+
}
|
|
175
|
+
if (Array.isArray(recipe.install?.postInstall) &&
|
|
176
|
+
recipe.install.postInstall.length > 0) {
|
|
177
|
+
warnings.push("Recipe contains postInstall commands - review before executing");
|
|
178
|
+
}
|
|
179
|
+
// Warning: missing metadata.homepage when repository also absent
|
|
180
|
+
if (!hasRepository && !hasHomepage) {
|
|
181
|
+
// Already an error, skip duplicate warning
|
|
182
|
+
}
|
|
183
|
+
else if (!hasHomepage) {
|
|
184
|
+
warnings.push("metadata.homepage is missing - consider adding it for better discoverability");
|
|
185
|
+
}
|
|
186
|
+
// Warning: maturity deprecated
|
|
187
|
+
if (recipe.metadata?.maturity === "deprecated") {
|
|
188
|
+
warnings.push("metadata.maturity is set to 'deprecated'");
|
|
189
|
+
}
|
|
190
|
+
// ── Build result ───────────────────────────────────────────────────────────
|
|
191
|
+
const valid = errors.length === 0;
|
|
192
|
+
const result = { valid, errors, warnings };
|
|
193
|
+
if (valid && typeof id === "string") {
|
|
194
|
+
result.id = id;
|
|
195
|
+
result.toolCount = recipe.metadata?.toolCount ?? 0;
|
|
196
|
+
// Primary transport (first one)
|
|
197
|
+
const firstTransport = Array.isArray(recipe.transports) && recipe.transports.length > 0
|
|
198
|
+
? recipe.transports[0].type
|
|
199
|
+
: undefined;
|
|
200
|
+
result.primaryTransport = firstTransport;
|
|
201
|
+
result.installMethod = recipe.install?.method;
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
// ─── File loader ─────────────────────────────────────────────────────────────
|
|
206
|
+
export async function validateRecipeFile(filePath) {
|
|
207
|
+
const { readFile } = await import("node:fs/promises");
|
|
208
|
+
const raw = await readFile(filePath, "utf-8");
|
|
209
|
+
let recipe;
|
|
210
|
+
try {
|
|
211
|
+
recipe = JSON.parse(raw);
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
return {
|
|
215
|
+
valid: false,
|
|
216
|
+
errors: [`Failed to parse JSON: ${e.message}`],
|
|
217
|
+
warnings: [],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return validateRecipe(recipe);
|
|
221
|
+
}
|
|
222
|
+
// ─── Output formatting ────────────────────────────────────────────────────────
|
|
223
|
+
export function formatValidationResult(filePath, result) {
|
|
224
|
+
const lines = [];
|
|
225
|
+
for (const w of result.warnings) {
|
|
226
|
+
lines.push(`⚠️ Warning: ${w}`);
|
|
227
|
+
}
|
|
228
|
+
if (result.valid) {
|
|
229
|
+
const toolCount = result.toolCount ?? 0;
|
|
230
|
+
const transport = result.primaryTransport ?? "unknown";
|
|
231
|
+
const method = result.installMethod ?? "remote";
|
|
232
|
+
lines.push(`✅ Valid recipe: ${result.id} (${toolCount} tools, ${transport}, ${method})`);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
lines.push(`❌ Invalid recipe: ${filePath}`);
|
|
236
|
+
for (const err of result.errors) {
|
|
237
|
+
lines.push(` - ${err}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return lines.join("\n");
|
|
241
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwerk/mcp-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Standalone MCP server that multiplexes multiple MCP servers into one interface",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/index.js",
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"build": "tsc",
|
|
45
45
|
"test": "node --import tsx --test tests/*.test.ts",
|
|
46
46
|
"typecheck": "tsc --noEmit",
|
|
47
|
-
"prepublishOnly": "npm run build"
|
|
47
|
+
"prepublishOnly": "npm run build",
|
|
48
|
+
"validate-recipe": "npx tsx bin/validate-recipe.ts"
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
50
51
|
"@sinclair/typebox": "^0.34.0"
|