@aiwerk/mcp-bridge 1.7.2 → 1.9.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 +69 -0
- package/dist/bin/validate-recipe.d.ts +6 -0
- package/dist/bin/validate-recipe.js +33 -0
- package/dist/src/config.js +1 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +3 -0
- package/dist/src/mcp-router.d.ts +27 -0
- package/dist/src/mcp-router.js +126 -13
- package/dist/src/result-cache.d.ts +29 -0
- package/dist/src/result-cache.js +120 -0
- package/dist/src/standalone-server.js +21 -5
- package/dist/src/tool-resolution.d.ts +32 -0
- package/dist/src/tool-resolution.js +130 -0
- package/dist/src/types.d.ts +7 -0
- 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
|
@@ -207,11 +207,24 @@ export class StandaloneServer {
|
|
|
207
207
|
type: "object",
|
|
208
208
|
properties: {
|
|
209
209
|
server: { type: "string", description: "Server name" },
|
|
210
|
-
action: { type: "string", description: "list | call | refresh | status" },
|
|
211
|
-
tool: { type: "string", description: "Tool name for action=call" },
|
|
212
|
-
params: { type: "object", description: "Tool arguments" }
|
|
210
|
+
action: { type: "string", description: "list | call | batch | refresh | status | intent | schema | promotions" },
|
|
211
|
+
tool: { type: "string", description: "Tool name for action=call/schema" },
|
|
212
|
+
params: { type: "object", description: "Tool arguments" },
|
|
213
|
+
calls: {
|
|
214
|
+
type: "array",
|
|
215
|
+
description: "Batch calls for action=batch",
|
|
216
|
+
items: {
|
|
217
|
+
type: "object",
|
|
218
|
+
properties: {
|
|
219
|
+
server: { type: "string" },
|
|
220
|
+
tool: { type: "string" },
|
|
221
|
+
params: { type: "object" }
|
|
222
|
+
},
|
|
223
|
+
required: ["server", "tool"]
|
|
224
|
+
}
|
|
225
|
+
}
|
|
213
226
|
},
|
|
214
|
-
required: [
|
|
227
|
+
required: []
|
|
215
228
|
}
|
|
216
229
|
}]
|
|
217
230
|
}
|
|
@@ -244,7 +257,10 @@ export class StandaloneServer {
|
|
|
244
257
|
error: { code: -32004, message: `Unknown tool: ${toolName}. In router mode, use the 'mcp' tool.` }
|
|
245
258
|
};
|
|
246
259
|
}
|
|
247
|
-
const
|
|
260
|
+
const dispatchParams = toolArgs.action === "batch"
|
|
261
|
+
? { ...(toolArgs.params ?? {}), calls: toolArgs.calls }
|
|
262
|
+
: toolArgs.params;
|
|
263
|
+
const result = await this.router.dispatch(toolArgs.server, toolArgs.action, toolArgs.tool, dispatchParams);
|
|
248
264
|
// Check if result is an error
|
|
249
265
|
if ("error" in result) {
|
|
250
266
|
return {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface ToolResolutionCandidate {
|
|
2
|
+
server: string;
|
|
3
|
+
tool: string;
|
|
4
|
+
score: number;
|
|
5
|
+
suggested?: true;
|
|
6
|
+
}
|
|
7
|
+
export type ToolResolutionResult = {
|
|
8
|
+
server: string;
|
|
9
|
+
tool: string;
|
|
10
|
+
} | {
|
|
11
|
+
ambiguous: true;
|
|
12
|
+
message: string;
|
|
13
|
+
candidates: ToolResolutionCandidate[];
|
|
14
|
+
} | null;
|
|
15
|
+
export declare class ToolResolver {
|
|
16
|
+
private readonly basePriority;
|
|
17
|
+
private readonly toolsByName;
|
|
18
|
+
private readonly toolNamesByServer;
|
|
19
|
+
private readonly recentCalls;
|
|
20
|
+
constructor(serverOrder: string[]);
|
|
21
|
+
registerServerTools(server: string, tools: Array<{
|
|
22
|
+
name: string;
|
|
23
|
+
inputSchema: any;
|
|
24
|
+
}>): void;
|
|
25
|
+
removeServer(server: string): void;
|
|
26
|
+
resolve(toolName: string, params?: Record<string, unknown>, serverHint?: string): ToolResolutionResult;
|
|
27
|
+
recordCall(server: string, tool: string): void;
|
|
28
|
+
getKnownToolNames(): string[];
|
|
29
|
+
private scoreCandidate;
|
|
30
|
+
private wasUsedRecently;
|
|
31
|
+
private computeParamMatch;
|
|
32
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const RECENT_CALL_LIMIT = 5;
|
|
2
|
+
const BASE_PRIORITY_STEP = 0.1;
|
|
3
|
+
const BASE_PRIORITY_MIN = 0.1;
|
|
4
|
+
const RECENCY_BOOST = 0.3;
|
|
5
|
+
const PARAM_MATCH_WEIGHT = 0.2;
|
|
6
|
+
const AUTO_RESOLVE_DELTA = 0.15;
|
|
7
|
+
export class ToolResolver {
|
|
8
|
+
basePriority = new Map();
|
|
9
|
+
toolsByName = new Map();
|
|
10
|
+
toolNamesByServer = new Map();
|
|
11
|
+
recentCalls = [];
|
|
12
|
+
constructor(serverOrder) {
|
|
13
|
+
const reversed = [...serverOrder].reverse();
|
|
14
|
+
reversed.forEach((server, index) => {
|
|
15
|
+
const score = Math.max(1.0 - (index * BASE_PRIORITY_STEP), BASE_PRIORITY_MIN);
|
|
16
|
+
this.basePriority.set(server, score);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
registerServerTools(server, tools) {
|
|
20
|
+
this.removeServer(server);
|
|
21
|
+
const names = new Set();
|
|
22
|
+
for (const tool of tools) {
|
|
23
|
+
if (!tool?.name)
|
|
24
|
+
continue;
|
|
25
|
+
const registered = {
|
|
26
|
+
server,
|
|
27
|
+
tool: tool.name,
|
|
28
|
+
inputSchema: tool.inputSchema
|
|
29
|
+
};
|
|
30
|
+
const existing = this.toolsByName.get(tool.name) ?? [];
|
|
31
|
+
existing.push(registered);
|
|
32
|
+
this.toolsByName.set(tool.name, existing);
|
|
33
|
+
names.add(tool.name);
|
|
34
|
+
}
|
|
35
|
+
this.toolNamesByServer.set(server, names);
|
|
36
|
+
}
|
|
37
|
+
removeServer(server) {
|
|
38
|
+
const previousNames = this.toolNamesByServer.get(server);
|
|
39
|
+
if (previousNames) {
|
|
40
|
+
for (const toolName of previousNames) {
|
|
41
|
+
const filtered = (this.toolsByName.get(toolName) ?? []).filter((entry) => entry.server !== server);
|
|
42
|
+
if (filtered.length === 0) {
|
|
43
|
+
this.toolsByName.delete(toolName);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
this.toolsByName.set(toolName, filtered);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
this.toolNamesByServer.delete(server);
|
|
50
|
+
}
|
|
51
|
+
resolve(toolName, params, serverHint) {
|
|
52
|
+
const candidates = this.toolsByName.get(toolName) ?? [];
|
|
53
|
+
if (candidates.length === 0) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (serverHint) {
|
|
57
|
+
const explicit = candidates.find((candidate) => candidate.server === serverHint);
|
|
58
|
+
if (!explicit) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return { server: explicit.server, tool: explicit.tool };
|
|
62
|
+
}
|
|
63
|
+
if (candidates.length === 1) {
|
|
64
|
+
return { server: candidates[0].server, tool: candidates[0].tool };
|
|
65
|
+
}
|
|
66
|
+
const scored = candidates
|
|
67
|
+
.map((candidate) => ({
|
|
68
|
+
...candidate,
|
|
69
|
+
score: this.scoreCandidate(candidate.server, candidate.inputSchema, params)
|
|
70
|
+
}))
|
|
71
|
+
.sort((a, b) => {
|
|
72
|
+
if (b.score !== a.score) {
|
|
73
|
+
return b.score - a.score;
|
|
74
|
+
}
|
|
75
|
+
return (this.basePriority.get(b.server) ?? BASE_PRIORITY_MIN) - (this.basePriority.get(a.server) ?? BASE_PRIORITY_MIN);
|
|
76
|
+
});
|
|
77
|
+
const first = scored[0];
|
|
78
|
+
const second = scored[1];
|
|
79
|
+
if (!second || (first.score - second.score) >= AUTO_RESOLVE_DELTA) {
|
|
80
|
+
return { server: first.server, tool: first.tool };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
ambiguous: true,
|
|
84
|
+
message: `Multiple servers provide '${toolName}'. Please specify server=`,
|
|
85
|
+
candidates: scored.map((candidate, index) => ({
|
|
86
|
+
server: candidate.server,
|
|
87
|
+
tool: candidate.tool,
|
|
88
|
+
score: Number(candidate.score.toFixed(2)),
|
|
89
|
+
...(index === 0 ? { suggested: true } : {})
|
|
90
|
+
}))
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
recordCall(server, tool) {
|
|
94
|
+
this.recentCalls.push({ server, tool });
|
|
95
|
+
if (this.recentCalls.length > RECENT_CALL_LIMIT) {
|
|
96
|
+
this.recentCalls.shift();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
getKnownToolNames() {
|
|
100
|
+
return [...this.toolsByName.keys()];
|
|
101
|
+
}
|
|
102
|
+
scoreCandidate(server, inputSchema, params) {
|
|
103
|
+
const base = this.basePriority.get(server) ?? BASE_PRIORITY_MIN;
|
|
104
|
+
const recency = this.wasUsedRecently(server) ? RECENCY_BOOST : 0;
|
|
105
|
+
const paramMatch = this.computeParamMatch(inputSchema, params) * PARAM_MATCH_WEIGHT;
|
|
106
|
+
return base + recency + paramMatch;
|
|
107
|
+
}
|
|
108
|
+
wasUsedRecently(server) {
|
|
109
|
+
return this.recentCalls.some((call) => call.server === server);
|
|
110
|
+
}
|
|
111
|
+
computeParamMatch(inputSchema, params) {
|
|
112
|
+
if (!params || typeof params !== "object") {
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
const paramNames = Object.keys(params);
|
|
116
|
+
if (paramNames.length === 0) {
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
const schemaProperties = inputSchema?.properties;
|
|
120
|
+
if (!schemaProperties || typeof schemaProperties !== "object") {
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
const propertyNames = new Set(Object.keys(schemaProperties));
|
|
124
|
+
if (propertyNames.size === 0) {
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
const matching = paramNames.filter((paramName) => propertyNames.has(paramName)).length;
|
|
128
|
+
return matching / paramNames.length;
|
|
129
|
+
}
|
|
130
|
+
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -30,6 +30,7 @@ export interface McpClientConfig {
|
|
|
30
30
|
requestTimeoutMs?: number;
|
|
31
31
|
routerIdleTimeoutMs?: number;
|
|
32
32
|
routerMaxConcurrent?: number;
|
|
33
|
+
maxBatchSize?: number;
|
|
33
34
|
schemaCompression?: {
|
|
34
35
|
enabled?: boolean;
|
|
35
36
|
maxDescriptionLength?: number;
|
|
@@ -48,6 +49,12 @@ export interface McpClientConfig {
|
|
|
48
49
|
minCalls?: number;
|
|
49
50
|
decayMs?: number;
|
|
50
51
|
};
|
|
52
|
+
resultCache?: {
|
|
53
|
+
enabled?: boolean;
|
|
54
|
+
maxEntries?: number;
|
|
55
|
+
defaultTtlMs?: number;
|
|
56
|
+
cacheTtl?: Record<string, number>;
|
|
57
|
+
};
|
|
51
58
|
}
|
|
52
59
|
export interface McpTool {
|
|
53
60
|
name: string;
|
|
@@ -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.9.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"
|