@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.
- package/dist/src/catalog-client.d.ts +6 -0
- package/dist/src/config.js +14 -0
- package/dist/src/oauth2-credentials-file.d.ts +56 -0
- package/dist/src/oauth2-credentials-file.js +91 -0
- package/dist/src/oauth2-token-manager.d.ts +8 -0
- package/dist/src/oauth2-token-manager.js +10 -0
- package/dist/src/standalone-server.js +1 -1
- package/dist/src/transport-base.d.ts +25 -0
- package/dist/src/transport-base.js +113 -18
- package/dist/src/transport-stdio.d.ts +5 -1
- package/dist/src/transport-stdio.js +29 -2
- package/dist/src/types.d.ts +18 -0
- package/package.json +2 -2
- package/scripts/validate-recipes.sh +4 -0
- package/servers/index.json +3 -403
- package/servers/apify/README.md +0 -40
- package/servers/apify/config.json +0 -13
- package/servers/apify/env_vars +0 -1
- package/servers/apify/install.ps1 +0 -3
- package/servers/apify/install.sh +0 -4
- package/servers/apify/recipe.json +0 -69
- package/servers/atlassian/README.md +0 -72
- package/servers/atlassian/env_vars +0 -6
- package/servers/atlassian/install.ps1 +0 -3
- package/servers/atlassian/install.sh +0 -4
- package/servers/atlassian/recipe.json +0 -87
- package/servers/candidates.md +0 -13
- package/servers/chrome-devtools/README.md +0 -69
- package/servers/chrome-devtools/env_vars +0 -0
- package/servers/chrome-devtools/install.ps1 +0 -3
- package/servers/chrome-devtools/install.sh +0 -4
- package/servers/chrome-devtools/recipe.json +0 -74
- package/servers/firecrawl/recipe.json +0 -79
- package/servers/github/README.md +0 -40
- package/servers/github/env_vars +0 -1
- package/servers/github/install.ps1 +0 -3
- package/servers/github/install.sh +0 -4
- package/servers/github/recipe.json +0 -82
- package/servers/google-maps/README.md +0 -40
- package/servers/google-maps/config.json +0 -17
- package/servers/google-maps/env_vars +0 -1
- package/servers/google-maps/install.ps1 +0 -3
- package/servers/google-maps/install.sh +0 -4
- package/servers/google-maps/recipe.json +0 -78
- package/servers/hetzner/README.md +0 -41
- package/servers/hetzner/config.json +0 -16
- package/servers/hetzner/env_vars +0 -1
- package/servers/hetzner/install.ps1 +0 -3
- package/servers/hetzner/install.sh +0 -4
- package/servers/hetzner/recipe.json +0 -78
- package/servers/hostinger/README.md +0 -40
- package/servers/hostinger/env_vars +0 -1
- package/servers/hostinger/install.ps1 +0 -3
- package/servers/hostinger/install.sh +0 -4
- package/servers/hostinger/recipe.json +0 -78
- package/servers/imap-email/README.md +0 -37
- package/servers/imap-email/recipe.json +0 -103
- package/servers/linear/README.md +0 -40
- package/servers/linear/config.json +0 -16
- package/servers/linear/env_vars +0 -1
- package/servers/linear/install.ps1 +0 -3
- package/servers/linear/install.sh +0 -4
- package/servers/linear/recipe.json +0 -78
- package/servers/miro/README.md +0 -40
- package/servers/miro/config.json +0 -19
- package/servers/miro/env_vars +0 -1
- package/servers/miro/install.ps1 +0 -3
- package/servers/miro/install.sh +0 -4
- package/servers/miro/recipe.json +0 -80
- package/servers/notion/README.md +0 -42
- package/servers/notion/config.json +0 -17
- package/servers/notion/env_vars +0 -1
- package/servers/notion/install.ps1 +0 -3
- package/servers/notion/install.sh +0 -4
- package/servers/notion/recipe.json +0 -78
- package/servers/stripe/README.md +0 -40
- package/servers/stripe/config.json +0 -19
- package/servers/stripe/env_vars +0 -1
- package/servers/stripe/install.ps1 +0 -3
- package/servers/stripe/install.sh +0 -4
- package/servers/stripe/recipe.json +0 -80
- package/servers/tavily/README.md +0 -40
- package/servers/tavily/config.json +0 -17
- package/servers/tavily/env_vars +0 -1
- package/servers/tavily/install.ps1 +0 -3
- package/servers/tavily/install.sh +0 -4
- package/servers/tavily/recipe.json +0 -78
- package/servers/todoist/README.md +0 -40
- package/servers/todoist/config.json +0 -17
- package/servers/todoist/env_vars +0 -1
- package/servers/todoist/install.ps1 +0 -3
- package/servers/todoist/install.sh +0 -4
- package/servers/todoist/recipe.json +0 -78
- package/servers/wise/README.md +0 -41
- package/servers/wise/env_vars +0 -1
- package/servers/wise/install.ps1 +0 -3
- package/servers/wise/install.sh +0 -4
- 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;
|
package/dist/src/config.js
CHANGED
|
@@ -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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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(" ")}`);
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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": "
|
|
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"
|