@aiwerk/mcp-bridge 1.7.2 → 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/validate-recipe.d.ts +6 -0
- package/dist/bin/validate-recipe.js +33 -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
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:
|
|
@@ -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
|
+
});
|
|
@@ -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"
|
|
@@ -37,9 +37,19 @@ if [[ ! -d "$SERVER_DIR" ]]; then
|
|
|
37
37
|
fi
|
|
38
38
|
|
|
39
39
|
SERVER_TITLE="$(tr '-' ' ' <<<"$SERVER_NAME" | awk '{for(i=1;i<=NF;i++){$i=toupper(substr($i,1,1))substr($i,2)};print}')"
|
|
40
|
-
SERVER_CONFIG_FILE="$SERVER_DIR/config.json"
|
|
41
40
|
ENV_VARS_FILE="$SERVER_DIR/env_vars"
|
|
42
41
|
|
|
42
|
+
# Prefer recipe.json (v2) over config.json (v1) when both exist
|
|
43
|
+
RECIPE_FILE="$SERVER_DIR/recipe.json"
|
|
44
|
+
SERVER_CONFIG_FILE="$SERVER_DIR/config.json"
|
|
45
|
+
RECIPE_FORMAT="v1"
|
|
46
|
+
if [[ -f "$RECIPE_FILE" ]]; then
|
|
47
|
+
RECIPE_FORMAT="v2"
|
|
48
|
+
elif [[ ! -f "$SERVER_CONFIG_FILE" ]]; then
|
|
49
|
+
echo "Error: No recipe.json or config.json found in $SERVER_DIR"
|
|
50
|
+
exit 1
|
|
51
|
+
fi
|
|
52
|
+
|
|
43
53
|
require_cmd() {
|
|
44
54
|
if ! command -v "$1" >/dev/null 2>&1; then
|
|
45
55
|
echo "❌ Missing required command: $1"
|
|
@@ -48,6 +58,18 @@ require_cmd() {
|
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
get_token_url() {
|
|
61
|
+
# For v2 recipes, prefer credentialsUrl from auth block
|
|
62
|
+
if [[ "$RECIPE_FORMAT" == "v2" ]]; then
|
|
63
|
+
local url
|
|
64
|
+
url=$(python3 -c "
|
|
65
|
+
import json, sys
|
|
66
|
+
with open(sys.argv[1]) as f:
|
|
67
|
+
r = json.load(f)
|
|
68
|
+
print(r.get('auth', {}).get('credentialsUrl', ''))
|
|
69
|
+
" "$RECIPE_FILE" 2>/dev/null)
|
|
70
|
+
[[ -n "$url" ]] && echo "$url" && return
|
|
71
|
+
fi
|
|
72
|
+
# v1 fallback: hardcoded URLs
|
|
51
73
|
case "$SERVER_NAME" in
|
|
52
74
|
apify) echo "https://console.apify.com/settings/integrations" ;;
|
|
53
75
|
github) echo "https://github.com/settings/tokens" ;;
|
|
@@ -171,11 +193,22 @@ print(f'ℹ️ Server recipe kept in servers/{server_name}/ (reinstall anytime)
|
|
|
171
193
|
" "$SERVER_NAME" "$MCP_BRIDGE_JSON" 2>/dev/null
|
|
172
194
|
|
|
173
195
|
# Remove env var from .env if exists
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
196
|
+
REMOVE_ENV_VAR=""
|
|
197
|
+
if [[ "$RECIPE_FORMAT" == "v2" ]]; then
|
|
198
|
+
REMOVE_ENV_VAR=$(python3 -c "
|
|
199
|
+
import json, sys
|
|
200
|
+
with open(sys.argv[1]) as f:
|
|
201
|
+
r = json.load(f)
|
|
202
|
+
env_vars = r.get('auth', {}).get('envVars', [])
|
|
203
|
+
print(env_vars[0] if env_vars else '')
|
|
204
|
+
" "$RECIPE_FILE" 2>/dev/null)
|
|
205
|
+
elif [[ -f "$ENV_VARS_FILE" ]] && [[ -s "$ENV_VARS_FILE" ]]; then
|
|
206
|
+
REMOVE_ENV_VAR="$(head -n 1 "$ENV_VARS_FILE" | tr -d '[:space:]')"
|
|
207
|
+
fi
|
|
208
|
+
if [[ -n "$REMOVE_ENV_VAR" ]] && [[ -f "$ENV_FILE" ]]; then
|
|
209
|
+
if grep -q "^${REMOVE_ENV_VAR}=" "$ENV_FILE" 2>/dev/null; then
|
|
210
|
+
sed -i "/^${REMOVE_ENV_VAR}=/d" "$ENV_FILE"
|
|
211
|
+
echo "🔑 Removed ${REMOVE_ENV_VAR} from ${ENV_FILE}"
|
|
179
212
|
fi
|
|
180
213
|
fi
|
|
181
214
|
|
|
@@ -214,8 +247,20 @@ echo "========================================"
|
|
|
214
247
|
|
|
215
248
|
if [[ "$DRY_RUN" == "true" ]]; then
|
|
216
249
|
echo "[DRY RUN] Server: $SERVER_NAME"
|
|
217
|
-
|
|
218
|
-
|
|
250
|
+
echo "[DRY RUN] Recipe format: $RECIPE_FORMAT"
|
|
251
|
+
if [[ "$RECIPE_FORMAT" == "v2" ]]; then
|
|
252
|
+
ENV_VAR_LIST=$(python3 -c "
|
|
253
|
+
import json, sys
|
|
254
|
+
with open(sys.argv[1]) as f:
|
|
255
|
+
r = json.load(f)
|
|
256
|
+
print(', '.join(r.get('auth', {}).get('envVars', [])))
|
|
257
|
+
" "$RECIPE_FILE" 2>/dev/null)
|
|
258
|
+
[[ -n "$ENV_VAR_LIST" ]] && echo "[DRY RUN] Env vars (from auth.envVars): $ENV_VAR_LIST"
|
|
259
|
+
echo "[DRY RUN] Recipe (v2):"; cat "$RECIPE_FILE"
|
|
260
|
+
else
|
|
261
|
+
[[ -f "$ENV_VARS_FILE" ]] && echo "[DRY RUN] Env var: $(cat "$ENV_VARS_FILE")"
|
|
262
|
+
echo "[DRY RUN] Config (v1):"; cat "$SERVER_CONFIG_FILE"
|
|
263
|
+
fi
|
|
219
264
|
exit 0
|
|
220
265
|
fi
|
|
221
266
|
|
|
@@ -226,9 +271,21 @@ check_prerequisites
|
|
|
226
271
|
install_dependencies
|
|
227
272
|
|
|
228
273
|
# 3. Get API token
|
|
229
|
-
|
|
274
|
+
# Determine env var name: v2 uses auth.envVars[], v1 uses env_vars file
|
|
275
|
+
ENV_VAR_NAME=""
|
|
276
|
+
if [[ "$RECIPE_FORMAT" == "v2" ]]; then
|
|
277
|
+
ENV_VAR_NAME=$(python3 -c "
|
|
278
|
+
import json, sys
|
|
279
|
+
with open(sys.argv[1]) as f:
|
|
280
|
+
r = json.load(f)
|
|
281
|
+
env_vars = r.get('auth', {}).get('envVars', [])
|
|
282
|
+
print(env_vars[0] if env_vars else '')
|
|
283
|
+
" "$RECIPE_FILE" 2>/dev/null)
|
|
284
|
+
elif [[ -f "$ENV_VARS_FILE" ]] && [[ -s "$ENV_VARS_FILE" ]]; then
|
|
230
285
|
ENV_VAR_NAME="$(head -n 1 "$ENV_VARS_FILE" | tr -d '[:space:]')"
|
|
286
|
+
fi
|
|
231
287
|
|
|
288
|
+
if [[ -n "$ENV_VAR_NAME" ]]; then
|
|
232
289
|
TOKEN_URL="$(get_token_url)"
|
|
233
290
|
[[ -n "$TOKEN_URL" ]] && echo "Get your API token here: ${TOKEN_URL}"
|
|
234
291
|
|
|
@@ -268,18 +325,43 @@ cp "$MCP_BRIDGE_JSON" "$BACKUP_FILE"
|
|
|
268
325
|
echo "Backup: ${BACKUP_FILE}"
|
|
269
326
|
|
|
270
327
|
PATH_OVERRIDE="$(resolve_path_override)"
|
|
328
|
+
ACTIVE_RECIPE_FILE="$RECIPE_FILE"
|
|
329
|
+
ACTIVE_RECIPE_FORMAT="$RECIPE_FORMAT"
|
|
271
330
|
|
|
272
|
-
python3 - "$MCP_BRIDGE_JSON" "$SERVER_CONFIG_FILE" "$SERVER_NAME" "$PATH_OVERRIDE" <<'PY'
|
|
331
|
+
python3 - "$MCP_BRIDGE_JSON" "$SERVER_CONFIG_FILE" "$SERVER_NAME" "$PATH_OVERRIDE" "$ACTIVE_RECIPE_FILE" "$ACTIVE_RECIPE_FORMAT" <<'PY'
|
|
273
332
|
import json, sys
|
|
274
333
|
|
|
275
|
-
config_path, server_cfg_path, server_name, path_override = sys.argv[1:
|
|
334
|
+
config_path, server_cfg_path, server_name, path_override, recipe_file, recipe_format = sys.argv[1:7]
|
|
276
335
|
|
|
277
336
|
with open(config_path, "r", encoding="utf-8") as f:
|
|
278
337
|
raw = f.read().strip()
|
|
279
338
|
cfg = json.loads(raw) if raw else {}
|
|
280
339
|
|
|
281
|
-
|
|
282
|
-
|
|
340
|
+
if recipe_format == "v2":
|
|
341
|
+
# Parse v2 recipe and build v1-compatible server config for runtime
|
|
342
|
+
with open(recipe_file, "r", encoding="utf-8") as f:
|
|
343
|
+
recipe = json.load(f)
|
|
344
|
+
transport = recipe["transports"][0]
|
|
345
|
+
server_cfg = {
|
|
346
|
+
"schemaVersion": 1,
|
|
347
|
+
"name": server_name,
|
|
348
|
+
"transport": transport.get("type", "stdio"),
|
|
349
|
+
"command": transport.get("command", ""),
|
|
350
|
+
"args": transport.get("args", []),
|
|
351
|
+
"env": transport.get("env", {}),
|
|
352
|
+
}
|
|
353
|
+
# Carry over optional v1-compatible fields from recipe if present
|
|
354
|
+
auth = recipe.get("auth", {})
|
|
355
|
+
if auth.get("required"):
|
|
356
|
+
server_cfg["authRequired"] = True
|
|
357
|
+
if auth.get("credentialsUrl"):
|
|
358
|
+
server_cfg["credentialsUrl"] = auth["credentialsUrl"]
|
|
359
|
+
meta = recipe.get("metadata", {})
|
|
360
|
+
if meta.get("homepage"):
|
|
361
|
+
server_cfg["homepage"] = meta["homepage"]
|
|
362
|
+
else:
|
|
363
|
+
with open(server_cfg_path, "r", encoding="utf-8") as f:
|
|
364
|
+
server_cfg = json.load(f)
|
|
283
365
|
|
|
284
366
|
if path_override:
|
|
285
367
|
args = server_cfg.get("args")
|
|
@@ -299,7 +381,7 @@ with open(config_path, "w", encoding="utf-8") as f:
|
|
|
299
381
|
json.dump(cfg, f, indent=2)
|
|
300
382
|
f.write("\n")
|
|
301
383
|
|
|
302
|
-
print(f"✅ Configuration merged for: {server_name}")
|
|
384
|
+
print(f"✅ Configuration merged for: {server_name} (recipe {recipe_format})")
|
|
303
385
|
PY
|
|
304
386
|
|
|
305
387
|
# 5. Gateway restart
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 2,
|
|
3
|
+
"id": "apify",
|
|
4
|
+
"name": "Apify",
|
|
5
|
+
"description": "Web scraping and automation platform with 3000+ ready-made actors for data extraction, browser automation, and crawling",
|
|
6
|
+
"repository": "https://github.com/apify/apify-mcp-server",
|
|
7
|
+
|
|
8
|
+
"transports": [
|
|
9
|
+
{
|
|
10
|
+
"type": "streamable-http",
|
|
11
|
+
"url": "https://mcp.apify.com/mcp",
|
|
12
|
+
"headers": {
|
|
13
|
+
"Authorization": "Bearer ${APIFY_TOKEN}"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
|
|
18
|
+
"auth": {
|
|
19
|
+
"required": true,
|
|
20
|
+
"type": "bearer",
|
|
21
|
+
"envVars": ["APIFY_TOKEN"],
|
|
22
|
+
"credentialsUrl": "https://console.apify.com/settings/integrations",
|
|
23
|
+
"instructions": "Copy your API token from Apify Console > Settings > Integrations."
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
"metadata": {
|
|
27
|
+
"homepage": "https://apify.com/",
|
|
28
|
+
"author": "Apify",
|
|
29
|
+
"tags": ["scraping", "automation", "web", "crawling", "actors"],
|
|
30
|
+
"category": "automation",
|
|
31
|
+
"pricing": "byok",
|
|
32
|
+
"maturity": "stable"
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
"capabilities": {
|
|
36
|
+
"tools": true,
|
|
37
|
+
"resources": false,
|
|
38
|
+
"prompts": false,
|
|
39
|
+
"sampling": false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 2,
|
|
3
|
+
"id": "atlassian",
|
|
4
|
+
"name": "Atlassian (Confluence + Jira)",
|
|
5
|
+
"description": "Confluence wiki and Jira project management — search, create, and update pages and issues",
|
|
6
|
+
"repository": "https://github.com/sooperset/mcp-atlassian",
|
|
7
|
+
|
|
8
|
+
"transports": [
|
|
9
|
+
{
|
|
10
|
+
"type": "stdio",
|
|
11
|
+
"command": "uvx",
|
|
12
|
+
"args": ["mcp-atlassian"],
|
|
13
|
+
"env": {
|
|
14
|
+
"CONFLUENCE_URL": "${CONFLUENCE_URL}",
|
|
15
|
+
"CONFLUENCE_USERNAME": "${CONFLUENCE_USERNAME}",
|
|
16
|
+
"CONFLUENCE_API_TOKEN": "${CONFLUENCE_API_TOKEN}",
|
|
17
|
+
"JIRA_URL": "${JIRA_URL}",
|
|
18
|
+
"JIRA_USERNAME": "${JIRA_USERNAME}",
|
|
19
|
+
"JIRA_API_TOKEN": "${JIRA_API_TOKEN}"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
|
|
24
|
+
"auth": {
|
|
25
|
+
"required": true,
|
|
26
|
+
"type": "api-key",
|
|
27
|
+
"envVars": [
|
|
28
|
+
"CONFLUENCE_URL",
|
|
29
|
+
"CONFLUENCE_USERNAME",
|
|
30
|
+
"CONFLUENCE_API_TOKEN",
|
|
31
|
+
"JIRA_URL",
|
|
32
|
+
"JIRA_USERNAME",
|
|
33
|
+
"JIRA_API_TOKEN"
|
|
34
|
+
],
|
|
35
|
+
"credentialsUrl": "https://id.atlassian.com/manage-profile/security/api-tokens",
|
|
36
|
+
"instructions": "Create an API token at Atlassian account settings. Set CONFLUENCE_URL and JIRA_URL to your instance URLs (e.g., https://yourcompany.atlassian.net/wiki and https://yourcompany.atlassian.net)."
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
"install": {
|
|
40
|
+
"method": "uvx",
|
|
41
|
+
"package": "mcp-atlassian",
|
|
42
|
+
"version": "latest"
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
"metadata": {
|
|
46
|
+
"homepage": "https://github.com/sooperset/mcp-atlassian",
|
|
47
|
+
"author": "sooperset",
|
|
48
|
+
"tags": ["wiki", "project-management", "confluence", "jira", "atlassian"],
|
|
49
|
+
"category": "productivity",
|
|
50
|
+
"pricing": "byok",
|
|
51
|
+
"maturity": "stable"
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
"capabilities": {
|
|
55
|
+
"tools": true,
|
|
56
|
+
"resources": false,
|
|
57
|
+
"prompts": false,
|
|
58
|
+
"sampling": false
|
|
59
|
+
}
|
|
60
|
+
}
|