@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.
Files changed (101) hide show
  1. package/README.md +16 -19
  2. package/dist/bin/mcp-bridge.js +154 -2
  3. package/dist/src/catalog-client.d.ts +57 -23
  4. package/dist/src/catalog-client.js +119 -52
  5. package/dist/src/config.d.ts +5 -1
  6. package/dist/src/config.js +79 -0
  7. package/dist/src/oauth2-credentials-file.d.ts +43 -0
  8. package/dist/src/oauth2-credentials-file.js +52 -0
  9. package/dist/src/standalone-server.js +1 -1
  10. package/dist/src/transport-base.d.ts +8 -0
  11. package/dist/src/transport-base.js +44 -18
  12. package/dist/src/transport-stdio.d.ts +5 -1
  13. package/dist/src/transport-stdio.js +19 -2
  14. package/dist/src/types.d.ts +18 -0
  15. package/dist/src/validate-recipe.js +62 -0
  16. package/package.json +2 -2
  17. package/scripts/validate-recipes.sh +4 -0
  18. package/servers/index.json +3 -403
  19. package/servers/apify/README.md +0 -40
  20. package/servers/apify/config.json +0 -13
  21. package/servers/apify/env_vars +0 -1
  22. package/servers/apify/install.ps1 +0 -3
  23. package/servers/apify/install.sh +0 -4
  24. package/servers/apify/recipe.json +0 -69
  25. package/servers/atlassian/README.md +0 -72
  26. package/servers/atlassian/env_vars +0 -6
  27. package/servers/atlassian/install.ps1 +0 -3
  28. package/servers/atlassian/install.sh +0 -4
  29. package/servers/atlassian/recipe.json +0 -87
  30. package/servers/candidates.md +0 -13
  31. package/servers/chrome-devtools/README.md +0 -69
  32. package/servers/chrome-devtools/env_vars +0 -0
  33. package/servers/chrome-devtools/install.ps1 +0 -3
  34. package/servers/chrome-devtools/install.sh +0 -4
  35. package/servers/chrome-devtools/recipe.json +0 -74
  36. package/servers/firecrawl/recipe.json +0 -79
  37. package/servers/github/README.md +0 -40
  38. package/servers/github/env_vars +0 -1
  39. package/servers/github/install.ps1 +0 -3
  40. package/servers/github/install.sh +0 -4
  41. package/servers/github/recipe.json +0 -82
  42. package/servers/google-maps/README.md +0 -40
  43. package/servers/google-maps/config.json +0 -17
  44. package/servers/google-maps/env_vars +0 -1
  45. package/servers/google-maps/install.ps1 +0 -3
  46. package/servers/google-maps/install.sh +0 -4
  47. package/servers/google-maps/recipe.json +0 -78
  48. package/servers/hetzner/README.md +0 -41
  49. package/servers/hetzner/config.json +0 -16
  50. package/servers/hetzner/env_vars +0 -1
  51. package/servers/hetzner/install.ps1 +0 -3
  52. package/servers/hetzner/install.sh +0 -4
  53. package/servers/hetzner/recipe.json +0 -78
  54. package/servers/hostinger/README.md +0 -40
  55. package/servers/hostinger/env_vars +0 -1
  56. package/servers/hostinger/install.ps1 +0 -3
  57. package/servers/hostinger/install.sh +0 -4
  58. package/servers/hostinger/recipe.json +0 -78
  59. package/servers/imap-email/README.md +0 -37
  60. package/servers/imap-email/recipe.json +0 -103
  61. package/servers/linear/README.md +0 -40
  62. package/servers/linear/config.json +0 -16
  63. package/servers/linear/env_vars +0 -1
  64. package/servers/linear/install.ps1 +0 -3
  65. package/servers/linear/install.sh +0 -4
  66. package/servers/linear/recipe.json +0 -78
  67. package/servers/miro/README.md +0 -40
  68. package/servers/miro/config.json +0 -19
  69. package/servers/miro/env_vars +0 -1
  70. package/servers/miro/install.ps1 +0 -3
  71. package/servers/miro/install.sh +0 -4
  72. package/servers/miro/recipe.json +0 -80
  73. package/servers/notion/README.md +0 -42
  74. package/servers/notion/config.json +0 -17
  75. package/servers/notion/env_vars +0 -1
  76. package/servers/notion/install.ps1 +0 -3
  77. package/servers/notion/install.sh +0 -4
  78. package/servers/notion/recipe.json +0 -78
  79. package/servers/stripe/README.md +0 -40
  80. package/servers/stripe/config.json +0 -19
  81. package/servers/stripe/env_vars +0 -1
  82. package/servers/stripe/install.ps1 +0 -3
  83. package/servers/stripe/install.sh +0 -4
  84. package/servers/stripe/recipe.json +0 -80
  85. package/servers/tavily/README.md +0 -40
  86. package/servers/tavily/config.json +0 -17
  87. package/servers/tavily/env_vars +0 -1
  88. package/servers/tavily/install.ps1 +0 -3
  89. package/servers/tavily/install.sh +0 -4
  90. package/servers/tavily/recipe.json +0 -78
  91. package/servers/todoist/README.md +0 -40
  92. package/servers/todoist/config.json +0 -17
  93. package/servers/todoist/env_vars +0 -1
  94. package/servers/todoist/install.ps1 +0 -3
  95. package/servers/todoist/install.sh +0 -4
  96. package/servers/todoist/recipe.json +0 -78
  97. package/servers/wise/README.md +0 -41
  98. package/servers/wise/env_vars +0 -1
  99. package/servers/wise/install.ps1 +0 -3
  100. package/servers/wise/install.sh +0 -4
  101. package/servers/wise/recipe.json +0 -78
@@ -205,3 +205,82 @@ export function initConfigDir(logger) {
205
205
  }
206
206
  logger.info(`Config directory ready: ${dir}`);
207
207
  }
208
+ export function recipeToServerConfig(recipe) {
209
+ // Extract optional v2 spec fields. The validator (validate-recipe.ts) accepts
210
+ // these on every recipe; this is the runtime plumb-through.
211
+ const oauth2 = recipe.auth?.oauth2;
212
+ const envBinding = oauth2?.envBinding;
213
+ const credFileType = oauth2?.credentialsFileType;
214
+ const cfgExtras = {};
215
+ if (typeof envBinding === "string" && envBinding)
216
+ cfgExtras.oauth2EnvBinding = envBinding;
217
+ if (credFileType === "google-workspace")
218
+ cfgExtras.oauth2CredentialsFile = { format: "google-workspace" };
219
+ if (Array.isArray(recipe.transports) && recipe.transports.length > 0) {
220
+ const t = recipe.transports[0];
221
+ if (t.type === "stdio") {
222
+ return {
223
+ transport: "stdio",
224
+ description: recipe.description,
225
+ command: t.command,
226
+ args: t.args,
227
+ env: t.env,
228
+ ...cfgExtras,
229
+ };
230
+ }
231
+ if (t.type === "sse" || t.type === "streamable-http") {
232
+ return {
233
+ transport: t.type,
234
+ description: recipe.description,
235
+ url: t.url,
236
+ headers: t.headers,
237
+ ...cfgExtras,
238
+ };
239
+ }
240
+ return null;
241
+ }
242
+ if (recipe.transport === "stdio") {
243
+ return {
244
+ transport: "stdio",
245
+ description: recipe.description,
246
+ command: recipe.command,
247
+ args: recipe.args,
248
+ env: recipe.env,
249
+ ...cfgExtras,
250
+ };
251
+ }
252
+ if (recipe.transport === "sse" || recipe.transport === "streamable-http") {
253
+ return {
254
+ transport: recipe.transport,
255
+ description: recipe.description,
256
+ url: recipe.url,
257
+ headers: recipe.headers,
258
+ ...cfgExtras,
259
+ };
260
+ }
261
+ return null;
262
+ }
263
+ /** Collect all env var names required by a recipe (auth.envVars + ${VAR} refs). */
264
+ export function collectRequiredEnvVars(recipe) {
265
+ const vars = new Set();
266
+ if (Array.isArray(recipe.auth?.envVars)) {
267
+ for (const v of recipe.auth.envVars)
268
+ vars.add(v);
269
+ }
270
+ const envObj = Array.isArray(recipe.transports)
271
+ ? recipe.transports[0]?.env
272
+ : recipe.env;
273
+ if (envObj && typeof envObj === "object") {
274
+ for (const val of Object.values(envObj)) {
275
+ if (typeof val === "string") {
276
+ const matches = val.matchAll(/\$\{([^}]+)\}/g);
277
+ for (const m of matches)
278
+ vars.add(m[1]);
279
+ }
280
+ }
281
+ }
282
+ if (recipe.auth?.required === true && vars.size === 0) {
283
+ vars.add("__AUTH_REQUIRED__");
284
+ }
285
+ return Array.from(vars);
286
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Vendor-specific OAuth2 credentials file emitters.
3
+ *
4
+ * Some upstream MCP servers (e.g. workspace-mcp) read OAuth credentials from
5
+ * a JSON file rather than an Authorization header or env var. The standalone
6
+ * bridge writes that file at spawn time so the server can refresh its own
7
+ * tokens via the upstream library.
8
+ *
9
+ * Currently supports the "google-workspace" format used by
10
+ * taylorwilsdon/google_workspace_mcp.
11
+ */
12
+ import type { StoredToken } from "./token-store.js";
13
+ export interface GoogleWorkspaceCredentialOpts {
14
+ serverName: string;
15
+ /** Result of OAuth2TokenManager.getStoredToken(serverName). */
16
+ stored: StoredToken;
17
+ /**
18
+ * The Google account email. Required so workspace-mcp finds the file at
19
+ * <dir>/<email>.json. Pre-fetched by the caller (we don't bundle a userinfo
20
+ * fetch here to keep the standalone bridge offline-friendly).
21
+ */
22
+ email: string;
23
+ /** OAuth2 client_id used during the install OAuth flow. */
24
+ clientId: string;
25
+ /** OAuth2 client_secret used during the install OAuth flow. */
26
+ clientSecret: string;
27
+ /** Override the base directory (testing). Defaults to ~/.mcp-bridge/google-credentials. */
28
+ baseDirOverride?: string;
29
+ }
30
+ /** Resolve the base dir for google-workspace credentials files (per-server). */
31
+ export declare function getGoogleCredentialsBaseDir(): string;
32
+ /** Per-server dir where workspace-mcp finds <email>.json. */
33
+ export declare function getGoogleCredentialsServerDir(serverName: string, baseOverride?: string): string;
34
+ /**
35
+ * Write the google-workspace credentials file in the shape expected by
36
+ * workspace-mcp's LocalDirectoryCredentialStore (Google Auth Library JSON
37
+ * with tz-naive ISO expiry). Returns the absolute file path so the caller
38
+ * can log/audit. Permissions: dir 0700, file 0600.
39
+ */
40
+ export declare function writeGoogleWorkspaceCredentials(opts: GoogleWorkspaceCredentialOpts): {
41
+ dir: string;
42
+ filePath: string;
43
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Vendor-specific OAuth2 credentials file emitters.
3
+ *
4
+ * Some upstream MCP servers (e.g. workspace-mcp) read OAuth credentials from
5
+ * a JSON file rather than an Authorization header or env var. The standalone
6
+ * bridge writes that file at spawn time so the server can refresh its own
7
+ * tokens via the upstream library.
8
+ *
9
+ * Currently supports the "google-workspace" format used by
10
+ * taylorwilsdon/google_workspace_mcp.
11
+ */
12
+ import { mkdirSync, writeFileSync, chmodSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ /** Resolve the base dir for google-workspace credentials files (per-server). */
16
+ export function getGoogleCredentialsBaseDir() {
17
+ if (process.env.MCP_BRIDGE_GOOGLE_CREDENTIALS_DIR) {
18
+ return process.env.MCP_BRIDGE_GOOGLE_CREDENTIALS_DIR;
19
+ }
20
+ return join(homedir(), ".mcp-bridge", "google-credentials");
21
+ }
22
+ /** Per-server dir where workspace-mcp finds <email>.json. */
23
+ export function getGoogleCredentialsServerDir(serverName, baseOverride) {
24
+ const base = baseOverride ?? getGoogleCredentialsBaseDir();
25
+ return join(base, serverName);
26
+ }
27
+ /**
28
+ * Write the google-workspace credentials file in the shape expected by
29
+ * workspace-mcp's LocalDirectoryCredentialStore (Google Auth Library JSON
30
+ * with tz-naive ISO expiry). Returns the absolute file path so the caller
31
+ * can log/audit. Permissions: dir 0700, file 0600.
32
+ */
33
+ export function writeGoogleWorkspaceCredentials(opts) {
34
+ const dir = getGoogleCredentialsServerDir(opts.serverName, opts.baseDirOverride);
35
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
36
+ // workspace-mcp / google-auth-library expects expiry as a tz-naive
37
+ // ISO datetime (Python datetime.fromisoformat). Strip the trailing Z.
38
+ const expiry = new Date(opts.stored.expiresAt).toISOString().replace(/Z$/, "");
39
+ const credential = {
40
+ token: opts.stored.accessToken,
41
+ refresh_token: opts.stored.refreshToken ?? "",
42
+ token_uri: "https://oauth2.googleapis.com/token",
43
+ client_id: opts.clientId,
44
+ client_secret: opts.clientSecret,
45
+ scopes: opts.stored.scopes ?? [],
46
+ expiry,
47
+ };
48
+ const filePath = join(dir, `${opts.email}.json`);
49
+ writeFileSync(filePath, JSON.stringify(credential, null, 2), { mode: 0o600 });
50
+ chmodSync(filePath, 0o600);
51
+ return { dir, filePath };
52
+ }
@@ -738,7 +738,7 @@ export class StandaloneServer {
738
738
  case "sse":
739
739
  return new SseTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId(), serverName);
740
740
  case "stdio":
741
- return new StdioTransport(serverConfig, this.config, this.logger, onReconnected, () => this.nextRequestId());
741
+ return new StdioTransport(serverConfig, this.config, this.logger, onReconnected, () => this.nextRequestId(), this.tokenManager, serverName);
742
742
  case "streamable-http":
743
743
  return new StreamableHttpTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager, () => this.nextRequestId(), serverName);
744
744
  default:
@@ -92,6 +92,14 @@ export declare function resolveOAuth2Config(config: McpServerConfig, extraEnv?:
92
92
  export declare function resolveAuthCodeOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): AuthCodeOAuth2Config;
93
93
  export declare function resolveDeviceCodeOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): DeviceCodeOAuth2Config;
94
94
  export declare function resolveAuthHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>, serverName?: string): Promise<Record<string, string>>;
95
+ /**
96
+ * Resolve OAuth2 access token into a stdio env-var binding (for servers that
97
+ * read credentials from a specific env var, e.g. GITHUB_PERSONAL_ACCESS_TOKEN).
98
+ * Returns an empty record if no envBinding is configured or auth is not OAuth2.
99
+ * Sourced from McpServerConfig.oauth2EnvBinding (set by recipeToServerConfig
100
+ * from recipe.auth.oauth2.envBinding).
101
+ */
102
+ export declare function resolveOauth2EnvAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>, serverName?: string): Promise<Record<string, string>>;
95
103
  /**
96
104
  * Resolve server headers and merge auth headers (auth takes precedence).
97
105
  */
@@ -241,32 +241,58 @@ export function resolveDeviceCodeOAuth2Config(config, extraEnv, envFallback) {
241
241
  ...(scopes && scopes.length > 0 ? { scopes } : {}),
242
242
  };
243
243
  }
244
+ /**
245
+ * Resolve an OAuth2 access token by picking the correct flow (auth code, device
246
+ * code, or client credentials). Returns null if config.auth is not OAuth2.
247
+ * Shared between resolveAuthHeadersAsync (HTTP transports) and
248
+ * resolveOauth2EnvAsync (stdio transports with envBinding).
249
+ */
250
+ async function resolveOauth2AccessToken(config, tokenManager, extraEnv, envFallback, serverName) {
251
+ if (!config.auth || config.auth.type !== "oauth2")
252
+ return null;
253
+ if (isAuthCodeOAuth2(config.auth)) {
254
+ if (!serverName) {
255
+ throw new Error("[mcp-bridge] serverName is required for authorization_code OAuth2 flow");
256
+ }
257
+ const authCodeConfig = resolveAuthCodeOAuth2Config(config, extraEnv, envFallback);
258
+ return tokenManager.getTokenForAuthCode(serverName, authCodeConfig);
259
+ }
260
+ if (isDeviceCodeOAuth2(config.auth)) {
261
+ if (!serverName) {
262
+ throw new Error("[mcp-bridge] serverName is required for device_code OAuth2 flow");
263
+ }
264
+ const deviceCodeConfig = resolveDeviceCodeOAuth2Config(config, extraEnv, envFallback);
265
+ return tokenManager.getTokenForDeviceCode(serverName, deviceCodeConfig);
266
+ }
267
+ const oauth2Config = resolveOAuth2Config(config, extraEnv, envFallback);
268
+ return tokenManager.getToken(oauth2Config);
269
+ }
244
270
  export async function resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback, serverName) {
245
271
  if (!config.auth)
246
272
  return {};
247
273
  if (config.auth.type === "oauth2") {
248
- if (isAuthCodeOAuth2(config.auth)) {
249
- if (!serverName) {
250
- throw new Error("[mcp-bridge] serverName is required for authorization_code OAuth2 flow");
251
- }
252
- const authCodeConfig = resolveAuthCodeOAuth2Config(config, extraEnv, envFallback);
253
- const token = await tokenManager.getTokenForAuthCode(serverName, authCodeConfig);
274
+ const token = await resolveOauth2AccessToken(config, tokenManager, extraEnv, envFallback, serverName);
275
+ if (token)
254
276
  return { Authorization: `Bearer ${token}` };
255
- }
256
- if (isDeviceCodeOAuth2(config.auth)) {
257
- if (!serverName) {
258
- throw new Error("[mcp-bridge] serverName is required for device_code OAuth2 flow");
259
- }
260
- const deviceCodeConfig = resolveDeviceCodeOAuth2Config(config, extraEnv, envFallback);
261
- const token = await tokenManager.getTokenForDeviceCode(serverName, deviceCodeConfig);
262
- return { Authorization: `Bearer ${token}` };
263
- }
264
- const oauth2Config = resolveOAuth2Config(config, extraEnv, envFallback);
265
- const token = await tokenManager.getToken(oauth2Config);
266
- return { Authorization: `Bearer ${token}` };
277
+ return {};
267
278
  }
268
279
  return resolveAuthHeaders(config, extraEnv, envFallback);
269
280
  }
281
+ /**
282
+ * Resolve OAuth2 access token into a stdio env-var binding (for servers that
283
+ * read credentials from a specific env var, e.g. GITHUB_PERSONAL_ACCESS_TOKEN).
284
+ * Returns an empty record if no envBinding is configured or auth is not OAuth2.
285
+ * Sourced from McpServerConfig.oauth2EnvBinding (set by recipeToServerConfig
286
+ * from recipe.auth.oauth2.envBinding).
287
+ */
288
+ export async function resolveOauth2EnvAsync(config, tokenManager, extraEnv, envFallback, serverName) {
289
+ if (!config.oauth2EnvBinding)
290
+ return {};
291
+ const token = await resolveOauth2AccessToken(config, tokenManager, extraEnv, envFallback, serverName);
292
+ if (!token)
293
+ return {};
294
+ return { [config.oauth2EnvBinding]: token };
295
+ }
270
296
  /**
271
297
  * Resolve server headers and merge auth headers (auth takes precedence).
272
298
  */
@@ -1,10 +1,14 @@
1
- import { McpRequest, McpResponse } from "./types.js";
1
+ import { McpRequest, McpResponse, McpServerConfig, McpClientConfig, Logger, RequestIdGenerator } from "./types.js";
2
2
  import { BaseTransport } from "./transport-base.js";
3
+ import type { OAuth2TokenManager } from "./oauth2-token-manager.js";
3
4
  export declare class StdioTransport extends BaseTransport {
4
5
  private process;
5
6
  private framingMode;
6
7
  private stdoutBuffer;
7
8
  private isShuttingDown;
9
+ private readonly tokenManager?;
10
+ private readonly serverName?;
11
+ constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, requestIdGenerator?: RequestIdGenerator, tokenManager?: OAuth2TokenManager, serverName?: string);
8
12
  protected get transportName(): string;
9
13
  connect(): Promise<void>;
10
14
  private startProcess;
@@ -1,10 +1,17 @@
1
1
  import { spawn } from "child_process";
2
- import { BaseTransport, resolveEnvRecord, resolveArgs } from "./transport-base.js";
2
+ import { BaseTransport, resolveEnvRecord, resolveArgs, resolveOauth2EnvAsync } from "./transport-base.js";
3
3
  export class StdioTransport extends BaseTransport {
4
4
  process = null;
5
5
  framingMode = "auto";
6
6
  stdoutBuffer = Buffer.alloc(0);
7
7
  isShuttingDown = false;
8
+ tokenManager;
9
+ serverName;
10
+ constructor(config, clientConfig, logger, onReconnected, requestIdGenerator, tokenManager, serverName) {
11
+ super(config, clientConfig, logger, onReconnected, requestIdGenerator);
12
+ this.tokenManager = tokenManager;
13
+ this.serverName = serverName;
14
+ }
8
15
  get transportName() { return "stdio"; }
9
16
  async connect() {
10
17
  if (!this.config.command) {
@@ -25,7 +32,17 @@ export class StdioTransport extends BaseTransport {
25
32
  if (!this.config.command)
26
33
  return;
27
34
  const configEnv = resolveEnvRecord(this.config.env || {}, "env key");
28
- const env = { ...process.env, ...configEnv };
35
+ let oauthEnv = {};
36
+ if (this.config.oauth2EnvBinding && this.tokenManager) {
37
+ try {
38
+ oauthEnv = await resolveOauth2EnvAsync(this.config, this.tokenManager, undefined, undefined, this.serverName);
39
+ }
40
+ catch (error) {
41
+ this.logger.error(`[mcp-bridge] Failed to resolve OAuth2 envBinding for stdio server ${this.serverName ?? "<unnamed>"}:`, error);
42
+ throw error;
43
+ }
44
+ }
45
+ const env = { ...process.env, ...configEnv, ...oauthEnv };
29
46
  const args = resolveArgs(this.config.args || [], env);
30
47
  if (process.env.DEBUG_STDIO_ENV) {
31
48
  this.logger.info(`[mcp-bridge] stdio spawn: ${this.config.command} ${args.join(" ")}`);
@@ -48,6 +48,24 @@ export interface McpServerConfig {
48
48
  url?: string;
49
49
  headers?: Record<string, string>;
50
50
  auth?: HttpAuthConfig;
51
+ /**
52
+ * If set, the OAuth2 access token is injected into the spawned process
53
+ * (or HTTP request env) under this name instead of (or in addition to) the
54
+ * default Authorization header. Used by stdio servers that read their
55
+ * credentials from a specific env var (e.g. GITHUB_PERSONAL_ACCESS_TOKEN).
56
+ * Sourced from recipe.auth.oauth2.envBinding.
57
+ */
58
+ oauth2EnvBinding?: string;
59
+ /**
60
+ * If set, the OAuth2 token + refresh token are written to a credentials
61
+ * file in a vendor-specific format, and the file path is injected into
62
+ * the spawned process via env var. Sourced from recipe.auth.oauth2.credentialsFileType.
63
+ * Format dictates the file shape (e.g. "google-workspace" follows Google's tz-naive expiry).
64
+ */
65
+ oauth2CredentialsFile?: {
66
+ format: "google-workspace";
67
+ envVar?: string;
68
+ };
51
69
  command?: string;
52
70
  args?: string[];
53
71
  env?: Record<string, string>;
@@ -274,6 +274,68 @@ export function validateRecipe(recipe) {
274
274
  }
275
275
  }
276
276
  }
277
+ // ── v2 spec field acceptance (Universal Recipe Spec v2, fields added since 2.8.x) ──
278
+ //
279
+ // These are typo-guarded boolean/string checks. Recipes shipped from the
280
+ // hosted bridge use them; the standalone runtime mostly informs the user
281
+ // and does not enforce hosted-only semantics (e.g. localOnly is not a
282
+ // gate locally — every recipe runs on the user's machine anyway).
283
+ // localOnly: top-level boolean. Hosted bridge refuses these recipes;
284
+ // standalone accepts them and lets the user spawn locally.
285
+ if (recipe.localOnly !== undefined && typeof recipe.localOnly !== "boolean") {
286
+ errors.push(`localOnly must be boolean, got ${typeof recipe.localOnly}`);
287
+ }
288
+ // multiInstance + instanceNameHint: hosted-only semantics today. Standalone
289
+ // accepts the fields without enforcing multi-instance install behavior.
290
+ if (recipe.multiInstance !== undefined && typeof recipe.multiInstance !== "boolean") {
291
+ errors.push(`multiInstance must be boolean, got ${typeof recipe.multiInstance}`);
292
+ }
293
+ if (recipe.instanceNameHint !== undefined && typeof recipe.instanceNameHint !== "string") {
294
+ errors.push(`instanceNameHint must be string, got ${typeof recipe.instanceNameHint}`);
295
+ }
296
+ // auth.options[]: multi-auth picker. If present, must be an array of objects
297
+ // with an id (string), label (string), and type (string).
298
+ const authOptions = recipe.auth?.options;
299
+ if (authOptions !== undefined) {
300
+ if (!Array.isArray(authOptions)) {
301
+ errors.push("auth.options must be an array");
302
+ }
303
+ else {
304
+ for (let i = 0; i < authOptions.length; i++) {
305
+ const opt = authOptions[i];
306
+ if (!opt || typeof opt !== "object") {
307
+ errors.push(`auth.options[${i}]: must be an object`);
308
+ continue;
309
+ }
310
+ if (typeof opt.id !== "string" || opt.id.trim().length === 0) {
311
+ errors.push(`auth.options[${i}]: id is required (non-empty string)`);
312
+ }
313
+ if (typeof opt.label !== "string" || opt.label.trim().length === 0) {
314
+ errors.push(`auth.options[${i}]: label is required (non-empty string)`);
315
+ }
316
+ if (typeof opt.type !== "string" || opt.type.trim().length === 0) {
317
+ errors.push(`auth.options[${i}]: type is required (non-empty string)`);
318
+ }
319
+ if (opt.recommended !== undefined && typeof opt.recommended !== "boolean") {
320
+ errors.push(`auth.options[${i}]: recommended must be boolean if present`);
321
+ }
322
+ }
323
+ }
324
+ }
325
+ // oauth2.envBinding: env var name to bind the OAuth access_token at spawn time.
326
+ // oauth2.credentialsFileType: marks recipes that need a credentials file
327
+ // written to disk (e.g. workspace-mcp). Standalone reads but does not yet
328
+ // write the file; will be plumbed through OAuth2 token manager when the
329
+ // feature is needed locally.
330
+ const oauth2 = recipe.auth?.oauth2;
331
+ if (oauth2 && typeof oauth2 === "object") {
332
+ if (oauth2.envBinding !== undefined && typeof oauth2.envBinding !== "string") {
333
+ errors.push(`auth.oauth2.envBinding must be string, got ${typeof oauth2.envBinding}`);
334
+ }
335
+ if (oauth2.credentialsFileType !== undefined && typeof oauth2.credentialsFileType !== "string") {
336
+ errors.push(`auth.oauth2.credentialsFileType must be string, got ${typeof oauth2.credentialsFileType}`);
337
+ }
338
+ }
277
339
  // ── Build result ───────────────────────────────────────────────────────────
278
340
  const valid = errors.length === 0;
279
341
  const result = { valid, errors, warnings };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwerk/mcp-bridge",
3
- "version": "2.8.45",
3
+ "version": "3.0.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",
@@ -45,7 +45,7 @@
45
45
  "test": "node --import tsx --test tests/*.test.ts",
46
46
  "typecheck": "tsc --noEmit",
47
47
  "postinstall": "node scripts/postinstall.cjs",
48
- "prepublishOnly": "tsc && bash scripts/validate-recipes.sh && node -e \"const v=require('./package.json').version;const fs=require('fs');const cl=fs.readFileSync('CHANGELOG.md','utf8');if(!cl.includes('['+v+']')){console.error('ERROR: CHANGELOG.md missing entry for v'+v);process.exit(1)}\"",
48
+ "prepublishOnly": "bash $HOME/agent-memory/commons/scripts/prepublish-safety.sh && tsc && bash scripts/validate-recipes.sh && node -e \"const v=require('./package.json').version;const fs=require('fs');const cl=fs.readFileSync('CHANGELOG.md','utf8');if(!cl.includes('['+v+']')){console.error('ERROR: CHANGELOG.md missing entry for v'+v);process.exit(1)}\"",
49
49
  "validate-recipe": "npx tsx bin/validate-recipe.ts",
50
50
  "lint": "eslint src/",
51
51
  "format": "prettier --write src/",
@@ -4,6 +4,10 @@
4
4
  # Exit code 1 if any URL is broken.
5
5
 
6
6
  set -uo pipefail
7
+ # nullglob: an unmatched glob expands to nothing, not the literal pattern.
8
+ # Without this, an empty `servers/` (as of v3.0.0) would make the loop
9
+ # try to read `servers/*/recipe.json` literally and fail.
10
+ shopt -s nullglob
7
11
 
8
12
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
13
  SERVERS_DIR="$SCRIPT_DIR/../servers"