@aiwerk/mcp-bridge 2.9.0 → 3.0.1

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 (98) hide show
  1. package/dist/src/catalog-client.d.ts +6 -0
  2. package/dist/src/config.js +14 -0
  3. package/dist/src/oauth2-credentials-file.d.ts +56 -0
  4. package/dist/src/oauth2-credentials-file.js +91 -0
  5. package/dist/src/oauth2-token-manager.d.ts +8 -0
  6. package/dist/src/oauth2-token-manager.js +10 -0
  7. package/dist/src/standalone-server.js +1 -1
  8. package/dist/src/transport-base.d.ts +25 -0
  9. package/dist/src/transport-base.js +113 -18
  10. package/dist/src/transport-stdio.d.ts +5 -1
  11. package/dist/src/transport-stdio.js +29 -2
  12. package/dist/src/types.d.ts +18 -0
  13. package/package.json +2 -2
  14. package/scripts/validate-recipes.sh +4 -0
  15. package/servers/index.json +3 -403
  16. package/servers/apify/README.md +0 -40
  17. package/servers/apify/config.json +0 -13
  18. package/servers/apify/env_vars +0 -1
  19. package/servers/apify/install.ps1 +0 -3
  20. package/servers/apify/install.sh +0 -4
  21. package/servers/apify/recipe.json +0 -69
  22. package/servers/atlassian/README.md +0 -72
  23. package/servers/atlassian/env_vars +0 -6
  24. package/servers/atlassian/install.ps1 +0 -3
  25. package/servers/atlassian/install.sh +0 -4
  26. package/servers/atlassian/recipe.json +0 -87
  27. package/servers/candidates.md +0 -13
  28. package/servers/chrome-devtools/README.md +0 -69
  29. package/servers/chrome-devtools/env_vars +0 -0
  30. package/servers/chrome-devtools/install.ps1 +0 -3
  31. package/servers/chrome-devtools/install.sh +0 -4
  32. package/servers/chrome-devtools/recipe.json +0 -74
  33. package/servers/firecrawl/recipe.json +0 -79
  34. package/servers/github/README.md +0 -40
  35. package/servers/github/env_vars +0 -1
  36. package/servers/github/install.ps1 +0 -3
  37. package/servers/github/install.sh +0 -4
  38. package/servers/github/recipe.json +0 -82
  39. package/servers/google-maps/README.md +0 -40
  40. package/servers/google-maps/config.json +0 -17
  41. package/servers/google-maps/env_vars +0 -1
  42. package/servers/google-maps/install.ps1 +0 -3
  43. package/servers/google-maps/install.sh +0 -4
  44. package/servers/google-maps/recipe.json +0 -78
  45. package/servers/hetzner/README.md +0 -41
  46. package/servers/hetzner/config.json +0 -16
  47. package/servers/hetzner/env_vars +0 -1
  48. package/servers/hetzner/install.ps1 +0 -3
  49. package/servers/hetzner/install.sh +0 -4
  50. package/servers/hetzner/recipe.json +0 -78
  51. package/servers/hostinger/README.md +0 -40
  52. package/servers/hostinger/env_vars +0 -1
  53. package/servers/hostinger/install.ps1 +0 -3
  54. package/servers/hostinger/install.sh +0 -4
  55. package/servers/hostinger/recipe.json +0 -78
  56. package/servers/imap-email/README.md +0 -37
  57. package/servers/imap-email/recipe.json +0 -103
  58. package/servers/linear/README.md +0 -40
  59. package/servers/linear/config.json +0 -16
  60. package/servers/linear/env_vars +0 -1
  61. package/servers/linear/install.ps1 +0 -3
  62. package/servers/linear/install.sh +0 -4
  63. package/servers/linear/recipe.json +0 -78
  64. package/servers/miro/README.md +0 -40
  65. package/servers/miro/config.json +0 -19
  66. package/servers/miro/env_vars +0 -1
  67. package/servers/miro/install.ps1 +0 -3
  68. package/servers/miro/install.sh +0 -4
  69. package/servers/miro/recipe.json +0 -80
  70. package/servers/notion/README.md +0 -42
  71. package/servers/notion/config.json +0 -17
  72. package/servers/notion/env_vars +0 -1
  73. package/servers/notion/install.ps1 +0 -3
  74. package/servers/notion/install.sh +0 -4
  75. package/servers/notion/recipe.json +0 -78
  76. package/servers/stripe/README.md +0 -40
  77. package/servers/stripe/config.json +0 -19
  78. package/servers/stripe/env_vars +0 -1
  79. package/servers/stripe/install.ps1 +0 -3
  80. package/servers/stripe/install.sh +0 -4
  81. package/servers/stripe/recipe.json +0 -80
  82. package/servers/tavily/README.md +0 -40
  83. package/servers/tavily/config.json +0 -17
  84. package/servers/tavily/env_vars +0 -1
  85. package/servers/tavily/install.ps1 +0 -3
  86. package/servers/tavily/install.sh +0 -4
  87. package/servers/tavily/recipe.json +0 -78
  88. package/servers/todoist/README.md +0 -40
  89. package/servers/todoist/config.json +0 -17
  90. package/servers/todoist/env_vars +0 -1
  91. package/servers/todoist/install.ps1 +0 -3
  92. package/servers/todoist/install.sh +0 -4
  93. package/servers/todoist/recipe.json +0 -78
  94. package/servers/wise/README.md +0 -41
  95. package/servers/wise/env_vars +0 -1
  96. package/servers/wise/install.ps1 +0 -3
  97. package/servers/wise/install.sh +0 -4
  98. package/servers/wise/recipe.json +0 -78
@@ -56,6 +56,12 @@ export interface CatalogRecipe {
56
56
  required?: boolean;
57
57
  envVars?: string[];
58
58
  credentialsUrl?: string;
59
+ oauth2?: {
60
+ envBinding?: string;
61
+ credentialsFileType?: "google-workspace";
62
+ [key: string]: unknown;
63
+ };
64
+ [key: string]: unknown;
59
65
  };
60
66
  signature?: RecipeSignature;
61
67
  localOnly?: boolean;
@@ -206,6 +206,16 @@ export function initConfigDir(logger) {
206
206
  logger.info(`Config directory ready: ${dir}`);
207
207
  }
208
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" };
209
219
  if (Array.isArray(recipe.transports) && recipe.transports.length > 0) {
210
220
  const t = recipe.transports[0];
211
221
  if (t.type === "stdio") {
@@ -215,6 +225,7 @@ export function recipeToServerConfig(recipe) {
215
225
  command: t.command,
216
226
  args: t.args,
217
227
  env: t.env,
228
+ ...cfgExtras,
218
229
  };
219
230
  }
220
231
  if (t.type === "sse" || t.type === "streamable-http") {
@@ -223,6 +234,7 @@ export function recipeToServerConfig(recipe) {
223
234
  description: recipe.description,
224
235
  url: t.url,
225
236
  headers: t.headers,
237
+ ...cfgExtras,
226
238
  };
227
239
  }
228
240
  return null;
@@ -234,6 +246,7 @@ export function recipeToServerConfig(recipe) {
234
246
  command: recipe.command,
235
247
  args: recipe.args,
236
248
  env: recipe.env,
249
+ ...cfgExtras,
237
250
  };
238
251
  }
239
252
  if (recipe.transport === "sse" || recipe.transport === "streamable-http") {
@@ -242,6 +255,7 @@ export function recipeToServerConfig(recipe) {
242
255
  description: recipe.description,
243
256
  url: recipe.url,
244
257
  headers: recipe.headers,
258
+ ...cfgExtras,
245
259
  };
246
260
  }
247
261
  return null;
@@ -0,0 +1,56 @@
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
+ };
44
+ /**
45
+ * Resolve the user's Google email by hitting the userinfo endpoint with the
46
+ * given access token. Used at spawn time when no cached credentials file
47
+ * exists yet for the server. Throws on HTTP error or missing email.
48
+ */
49
+ export declare function fetchGoogleUserEmail(accessToken: string): Promise<string>;
50
+ /**
51
+ * Look up a previously written credentials file for this server and return
52
+ * the embedded Google email (the file basename, sans .json). Returns null
53
+ * if no credentials file exists yet. Used to skip the userinfo fetch on
54
+ * subsequent spawns of the same server.
55
+ */
56
+ export declare function findCachedEmailForServer(serverName: string, baseOverride?: string): string | null;
@@ -0,0 +1,91 @@
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, readdirSync, existsSync } 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
+ }
53
+ /**
54
+ * Resolve the user's Google email by hitting the userinfo endpoint with the
55
+ * given access token. Used at spawn time when no cached credentials file
56
+ * exists yet for the server. Throws on HTTP error or missing email.
57
+ */
58
+ export async function fetchGoogleUserEmail(accessToken) {
59
+ const res = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
60
+ headers: { Authorization: `Bearer ${accessToken}` },
61
+ });
62
+ if (!res.ok) {
63
+ const text = await res.text().catch(() => "");
64
+ throw new Error(`google userinfo fetch failed: HTTP ${res.status}: ${text.slice(0, 200)}`);
65
+ }
66
+ const json = (await res.json());
67
+ if (typeof json.email !== "string" || json.email.length === 0) {
68
+ throw new Error("google userinfo response missing email");
69
+ }
70
+ return json.email;
71
+ }
72
+ /**
73
+ * Look up a previously written credentials file for this server and return
74
+ * the embedded Google email (the file basename, sans .json). Returns null
75
+ * if no credentials file exists yet. Used to skip the userinfo fetch on
76
+ * subsequent spawns of the same server.
77
+ */
78
+ export function findCachedEmailForServer(serverName, baseOverride) {
79
+ const dir = getGoogleCredentialsServerDir(serverName, baseOverride);
80
+ if (!existsSync(dir))
81
+ return null;
82
+ try {
83
+ const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
84
+ if (files.length === 0)
85
+ return null;
86
+ return files[0].slice(0, -".json".length);
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
@@ -31,6 +31,14 @@ export declare class OAuth2TokenManager {
31
31
  getToken(config: OAuth2Config): Promise<string>;
32
32
  invalidate(tokenUrl: string, clientId: string): void;
33
33
  clear(): void;
34
+ /**
35
+ * Read the persistent stored token for a server (auth_code / device_code
36
+ * flows). Returns null if no token store is configured or the server has
37
+ * never authenticated. Used by the credentialsFileType writer to populate
38
+ * a credentials file with refresh_token + scopes after `getTokenForAuthCode`
39
+ * (or device_code) has refreshed and persisted the token.
40
+ */
41
+ getStoredToken(serverName: string): import("./token-store.js").StoredToken | null;
34
42
  /**
35
43
  * Get a token for an authorization_code flow server.
36
44
  * Checks TokenStore, refreshes if expired, throws if unavailable.
@@ -41,6 +41,16 @@ export class OAuth2TokenManager {
41
41
  this.tokenCache.clear();
42
42
  this.inflight.clear();
43
43
  }
44
+ /**
45
+ * Read the persistent stored token for a server (auth_code / device_code
46
+ * flows). Returns null if no token store is configured or the server has
47
+ * never authenticated. Used by the credentialsFileType writer to populate
48
+ * a credentials file with refresh_token + scopes after `getTokenForAuthCode`
49
+ * (or device_code) has refreshed and persisted the token.
50
+ */
51
+ getStoredToken(serverName) {
52
+ return this.tokenStore?.load(serverName) ?? null;
53
+ }
44
54
  /**
45
55
  * Get a token for an authorization_code flow server.
46
56
  * Checks TokenStore, refreshes if expired, throws if unavailable.
@@ -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,31 @@ 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>>;
103
+ /**
104
+ * Resolve OAuth2 credentials into a vendor-specific credentials file and
105
+ * return the env var binding (e.g. GOOGLE_MCP_CREDENTIALS_DIR for the
106
+ * google-workspace format consumed by workspace-mcp). The bridge writes
107
+ * <base>/<server>/<email>.json with the access_token + refresh_token + scopes
108
+ * and points the spawned process at the directory.
109
+ *
110
+ * Only auth_code and device_code grants are supported (workspace-mcp's
111
+ * primary use case); client_credentials lacks the persistent refresh_token
112
+ * the file format requires.
113
+ *
114
+ * Returns an empty record if config.oauth2CredentialsFile is not set.
115
+ * Throws on missing token store, missing stored token, or userinfo fetch
116
+ * failure (the spawn surfaces a clear error rather than booting a server
117
+ * that cannot read credentials).
118
+ */
119
+ export declare function resolveOauth2CredentialsFileAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>, serverName?: string): Promise<Record<string, string>>;
95
120
  /**
96
121
  * Resolve server headers and merge auth headers (auth takes precedence).
97
122
  */
@@ -241,32 +241,127 @@ 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
+ }
296
+ /**
297
+ * Resolve OAuth2 credentials into a vendor-specific credentials file and
298
+ * return the env var binding (e.g. GOOGLE_MCP_CREDENTIALS_DIR for the
299
+ * google-workspace format consumed by workspace-mcp). The bridge writes
300
+ * <base>/<server>/<email>.json with the access_token + refresh_token + scopes
301
+ * and points the spawned process at the directory.
302
+ *
303
+ * Only auth_code and device_code grants are supported (workspace-mcp's
304
+ * primary use case); client_credentials lacks the persistent refresh_token
305
+ * the file format requires.
306
+ *
307
+ * Returns an empty record if config.oauth2CredentialsFile is not set.
308
+ * Throws on missing token store, missing stored token, or userinfo fetch
309
+ * failure (the spawn surfaces a clear error rather than booting a server
310
+ * that cannot read credentials).
311
+ */
312
+ export async function resolveOauth2CredentialsFileAsync(config, tokenManager, extraEnv, envFallback, serverName) {
313
+ const cf = config.oauth2CredentialsFile;
314
+ if (!cf)
315
+ return {};
316
+ if (cf.format !== "google-workspace") {
317
+ throw new Error(`[mcp-bridge] Unsupported oauth2CredentialsFile.format: ${cf.format}`);
318
+ }
319
+ if (!serverName) {
320
+ throw new Error("[mcp-bridge] serverName is required for oauth2CredentialsFile resolution");
321
+ }
322
+ if (!config.auth || config.auth.type !== "oauth2") {
323
+ throw new Error(`[mcp-bridge] oauth2CredentialsFile requires auth.type === "oauth2" for server ${serverName}`);
324
+ }
325
+ // Refresh + resolve clientId/clientSecret. Force the right grant flow
326
+ // through the token manager so the StoredToken is populated/up-to-date.
327
+ let resolvedClientId = "";
328
+ let resolvedClientSecret = "";
329
+ if (isAuthCodeOAuth2(config.auth)) {
330
+ const ac = resolveAuthCodeOAuth2Config(config, extraEnv, envFallback);
331
+ resolvedClientId = ac.clientId ?? "";
332
+ resolvedClientSecret = ac.clientSecret ?? "";
333
+ await tokenManager.getTokenForAuthCode(serverName, ac);
334
+ }
335
+ else if (isDeviceCodeOAuth2(config.auth)) {
336
+ const dc = resolveDeviceCodeOAuth2Config(config, extraEnv, envFallback);
337
+ resolvedClientId = dc.clientId;
338
+ resolvedClientSecret = dc.clientSecret ?? "";
339
+ await tokenManager.getTokenForDeviceCode(serverName, dc);
340
+ }
341
+ else {
342
+ throw new Error(`[mcp-bridge] oauth2CredentialsFile requires auth_code or device_code grant for server ${serverName}; client_credentials lacks a persistent refresh_token`);
343
+ }
344
+ const stored = tokenManager.getStoredToken(serverName);
345
+ if (!stored) {
346
+ throw new Error(`[mcp-bridge] No stored OAuth2 token for server "${serverName}". Run: mcp-bridge auth login ${serverName}`);
347
+ }
348
+ // Lazy import keeps the helper module out of the cold-start path for
349
+ // servers that do not use the credentials-file feature.
350
+ const credMod = await import("./oauth2-credentials-file.js");
351
+ let email = credMod.findCachedEmailForServer(serverName);
352
+ if (!email) {
353
+ email = await credMod.fetchGoogleUserEmail(stored.accessToken);
354
+ }
355
+ const { dir } = credMod.writeGoogleWorkspaceCredentials({
356
+ serverName,
357
+ stored,
358
+ email,
359
+ clientId: resolvedClientId,
360
+ clientSecret: resolvedClientSecret,
361
+ });
362
+ const envVar = cf.envVar ?? "GOOGLE_MCP_CREDENTIALS_DIR";
363
+ return { [envVar]: dir };
364
+ }
270
365
  /**
271
366
  * Resolve server headers and merge auth headers (auth takes precedence).
272
367
  */
@@ -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, resolveOauth2CredentialsFileAsync } 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,27 @@ 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
+ let credFileEnv = {};
46
+ if (this.config.oauth2CredentialsFile && this.tokenManager) {
47
+ try {
48
+ credFileEnv = await resolveOauth2CredentialsFileAsync(this.config, this.tokenManager, undefined, undefined, this.serverName);
49
+ }
50
+ catch (error) {
51
+ this.logger.error(`[mcp-bridge] Failed to resolve OAuth2 credentials file for stdio server ${this.serverName ?? "<unnamed>"}:`, error);
52
+ throw error;
53
+ }
54
+ }
55
+ const env = { ...process.env, ...configEnv, ...oauthEnv, ...credFileEnv };
29
56
  const args = resolveArgs(this.config.args || [], env);
30
57
  if (process.env.DEBUG_STDIO_ENV) {
31
58
  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>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwerk/mcp-bridge",
3
- "version": "2.9.0",
3
+ "version": "3.0.1",
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"