@aiwerk/mcp-bridge 3.0.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.
@@ -41,3 +41,16 @@ export declare function writeGoogleWorkspaceCredentials(opts: GoogleWorkspaceCre
41
41
  dir: string;
42
42
  filePath: string;
43
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;
@@ -9,7 +9,7 @@
9
9
  * Currently supports the "google-workspace" format used by
10
10
  * taylorwilsdon/google_workspace_mcp.
11
11
  */
12
- import { mkdirSync, writeFileSync, chmodSync } from "node:fs";
12
+ import { mkdirSync, writeFileSync, chmodSync, readdirSync, existsSync } from "node:fs";
13
13
  import { homedir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  /** Resolve the base dir for google-workspace credentials files (per-server). */
@@ -50,3 +50,42 @@ export function writeGoogleWorkspaceCredentials(opts) {
50
50
  chmodSync(filePath, 0o600);
51
51
  return { dir, filePath };
52
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.
@@ -100,6 +100,23 @@ export declare function resolveAuthHeadersAsync(config: McpServerConfig, tokenMa
100
100
  * from recipe.auth.oauth2.envBinding).
101
101
  */
102
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>>;
103
120
  /**
104
121
  * Resolve server headers and merge auth headers (auth takes precedence).
105
122
  */
@@ -293,6 +293,75 @@ export async function resolveOauth2EnvAsync(config, tokenManager, extraEnv, envF
293
293
  return {};
294
294
  return { [config.oauth2EnvBinding]: token };
295
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
+ }
296
365
  /**
297
366
  * Resolve server headers and merge auth headers (auth takes precedence).
298
367
  */
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "child_process";
2
- import { BaseTransport, resolveEnvRecord, resolveArgs, resolveOauth2EnvAsync } 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";
@@ -42,7 +42,17 @@ export class StdioTransport extends BaseTransport {
42
42
  throw error;
43
43
  }
44
44
  }
45
- const env = { ...process.env, ...configEnv, ...oauthEnv };
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 };
46
56
  const args = resolveArgs(this.config.args || [], env);
47
57
  if (process.env.DEBUG_STDIO_ENV) {
48
58
  this.logger.info(`[mcp-bridge] stdio spawn: ${this.config.command} ${args.join(" ")}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwerk/mcp-bridge",
3
- "version": "3.0.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",