@aiwerk/mcp-bridge 3.0.0 → 3.0.2
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 -1
- package/dist/src/oauth2-credentials-file.d.ts +13 -0
- package/dist/src/oauth2-credentials-file.js +40 -1
- package/dist/src/oauth2-token-manager.d.ts +8 -0
- package/dist/src/oauth2-token-manager.js +10 -0
- package/dist/src/transport-base.d.ts +17 -0
- package/dist/src/transport-base.js +69 -0
- package/dist/src/transport-stdio.js +12 -2
- package/dist/src/validate-recipe.js +24 -4
- package/package.json +1 -1
|
@@ -64,7 +64,12 @@ export interface CatalogRecipe {
|
|
|
64
64
|
[key: string]: unknown;
|
|
65
65
|
};
|
|
66
66
|
signature?: RecipeSignature;
|
|
67
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Local-only marker. Boolean form (true) blocks hosted install entirely;
|
|
69
|
+
* string[] form lists tool names the hosted bridge hides from tools/list
|
|
70
|
+
* (standalone ignores the array — all tools run locally).
|
|
71
|
+
*/
|
|
72
|
+
localOnly?: boolean | string[];
|
|
68
73
|
[key: string]: unknown;
|
|
69
74
|
}
|
|
70
75
|
export declare class CatalogError extends Error {
|
|
@@ -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
|
-
|
|
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(" ")}`);
|
|
@@ -280,10 +280,30 @@ export function validateRecipe(recipe) {
|
|
|
280
280
|
// hosted bridge use them; the standalone runtime mostly informs the user
|
|
281
281
|
// and does not enforce hosted-only semantics (e.g. localOnly is not a
|
|
282
282
|
// gate locally — every recipe runs on the user's machine anyway).
|
|
283
|
-
// localOnly:
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
283
|
+
// localOnly: two accepted forms.
|
|
284
|
+
// - boolean true → hosted bridge refuses install entirely (chrome-devtools);
|
|
285
|
+
// standalone accepts and spawns locally
|
|
286
|
+
// - string[] → per-tool hosted-bridge filter (the listed names are
|
|
287
|
+
// stripped from tools/list on hosted); standalone IGNORES
|
|
288
|
+
// the array — every tool runs locally regardless.
|
|
289
|
+
// Standalone runtime accepts both forms; we only validate the shape here so
|
|
290
|
+
// a typo (`"yes"`, number, object) is caught at recipe-load time.
|
|
291
|
+
if (recipe.localOnly !== undefined) {
|
|
292
|
+
const lo = recipe.localOnly;
|
|
293
|
+
if (typeof lo === "boolean") {
|
|
294
|
+
// OK
|
|
295
|
+
}
|
|
296
|
+
else if (Array.isArray(lo)) {
|
|
297
|
+
if (lo.length === 0) {
|
|
298
|
+
errors.push("localOnly array must be non-empty (use false or omit instead)");
|
|
299
|
+
}
|
|
300
|
+
else if (lo.some((t) => typeof t !== "string" || t.trim().length === 0)) {
|
|
301
|
+
errors.push("localOnly array must contain only non-empty tool-name strings");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
errors.push(`localOnly must be boolean or string[], got ${typeof lo}`);
|
|
306
|
+
}
|
|
287
307
|
}
|
|
288
308
|
// multiInstance + instanceNameHint: hosted-only semantics today. Standalone
|
|
289
309
|
// accepts the fields without enforcing multi-instance install behavior.
|