Skip to content

Commit b26987f

Browse files
authored
fix(mcp): Address auth issues with Firebase Studio environment (#8871)
1 parent 8713fbd commit b26987f

File tree

8 files changed

+140
-31
lines changed

8 files changed

+140
-31
lines changed

firebase-vscode/src/core/env.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const env = globalSignal<Environment>({
1414
export function registerEnv(broker: ExtensionBrokerImpl): Disposable {
1515
const sub = broker.on("getInitialData", async () => {
1616
pluginLogger.debug(
17-
`Value of process.env.MONOSPACE_ENV: ` + `${process.env.MONOSPACE_ENV}`
17+
`Value of process.env.MONOSPACE_ENV: ` + `${process.env.MONOSPACE_ENV}`,
1818
);
1919

2020
broker.send("notifyEnv", {

src/env.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { dirExistsSync } from "./fsutils";
2+
3+
let googleIdxFolderExists: boolean | undefined;
4+
export function isFirebaseStudio() {
5+
if (googleIdxFolderExists === true || process.env.MONOSPACE_ENV) return true;
6+
if (googleIdxFolderExists === false) return false;
7+
googleIdxFolderExists = dirExistsSync("/google/idx");
8+
return googleIdxFolderExists;
9+
}
10+
11+
export function isFirebaseMcp() {
12+
return !!process.env.IS_FIREBASE_MCP;
13+
}

src/mcp/index.ts

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,13 @@ import { Emulators } from "../emulator/types.js";
2727
import { existsSync } from "node:fs";
2828
import { ensure, check } from "../ensureApiEnabled.js";
2929
import * as api from "../api.js";
30+
import { LoggingStdioServerTransport } from "./logging-transport.js";
31+
import { isFirebaseStudio } from "../env.js";
32+
import { timeoutFallback } from "../timeout.js";
3033

31-
const SERVER_VERSION = "0.1.0";
34+
const SERVER_VERSION = "0.2.0";
3235

33-
const cmd = new Command("experimental:mcp").before(requireAuth);
36+
const cmd = new Command("experimental:mcp");
3437

3538
const orderedLogLevels = [
3639
"debug",
@@ -56,7 +59,7 @@ export class FirebaseMcpServer {
5659

5760
// logging spec:
5861
// https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/logging
59-
currentLogLevel?: LoggingLevel;
62+
currentLogLevel?: LoggingLevel = process.env.FIREBASE_MCP_DEBUG_LOG ? "debug" : undefined;
6063
// the api of logging from a consumers perspective looks like `server.logger.warn("my warning")`.
6164
public readonly logger = Object.fromEntries(
6265
orderedLogLevels.map((logLevel) => [
@@ -65,6 +68,20 @@ export class FirebaseMcpServer {
6568
]),
6669
) as Record<LoggingLevel, (message: unknown) => Promise<void>>;
6770

71+
/** Create a special tracking function to avoid blocking everything on initialization notification. */
72+
private async trackGA4(
73+
event: Parameters<typeof trackGA4>[0],
74+
params: Parameters<typeof trackGA4>[1] = {},
75+
): Promise<void> {
76+
// wait until ready or until 2s has elapsed
77+
if (!this.clientInfo) await timeoutFallback(this.ready(), null, 2000);
78+
const clientInfoParams = {
79+
mcp_client_name: this.clientInfo?.name || "<unknown-client>",
80+
mcp_client_version: this.clientInfo?.version || "<unknown-version>",
81+
};
82+
trackGA4(event, { ...params, ...clientInfoParams });
83+
}
84+
6885
constructor(options: { activeFeatures?: ServerFeature[]; projectRoot?: string }) {
6986
this.activeFeatures = options.activeFeatures;
7087
this.startupRoot = options.projectRoot || process.env.PROJECT_ROOT;
@@ -76,10 +93,7 @@ export class FirebaseMcpServer {
7693
const clientInfo = this.server.getClientVersion();
7794
this.clientInfo = clientInfo;
7895
if (clientInfo?.name) {
79-
trackGA4("mcp_client_connected", {
80-
mcp_client_name: clientInfo.name,
81-
mcp_client_version: clientInfo.version,
82-
});
96+
this.trackGA4("mcp_client_connected");
8397
}
8498
if (!this.clientInfo?.name) this.clientInfo = { name: "<unknown-client>" };
8599

@@ -106,8 +120,12 @@ export class FirebaseMcpServer {
106120
});
107121
}
108122

123+
get clientName(): string {
124+
return this.clientInfo?.name ?? (isFirebaseStudio() ? "Firebase Studio" : "<unknown-client>");
125+
}
126+
109127
private get clientConfigKey() {
110-
return `mcp.clientConfigs.${this.clientInfo?.name || "<unknown-client>"}:${this.startupRoot || process.cwd()}`;
128+
return `mcp.clientConfigs.${this.clientName}:${this.startupRoot || process.cwd()}`;
111129
}
112130

113131
getStoredClientConfig(): ClientConfig {
@@ -122,15 +140,17 @@ export class FirebaseMcpServer {
122140
}
123141

124142
async detectProjectRoot(): Promise<string> {
125-
await this.ready();
143+
await timeoutFallback(this.ready(), null, 2000);
126144
if (this.cachedProjectRoot) return this.cachedProjectRoot;
127145
const storedRoot = this.getStoredClientConfig().projectRoot;
128146
this.cachedProjectRoot = storedRoot || this.startupRoot || process.cwd();
147+
this.log("debug", "detected and cached project root: " + this.cachedProjectRoot);
129148
return this.cachedProjectRoot;
130149
}
131150

132151
async detectActiveFeatures(): Promise<ServerFeature[]> {
133152
if (this.detectedFeatures?.length) return this.detectedFeatures; // memoized
153+
this.log("debug", "detecting active features of Firebase MCP server...");
134154
const options = await this.resolveOptions();
135155
const projectId = await this.getProjectId();
136156
const detected = await Promise.all(
@@ -140,6 +160,10 @@ export class FirebaseMcpServer {
140160
}),
141161
);
142162
this.detectedFeatures = detected.filter((f) => !!f) as ServerFeature[];
163+
this.log(
164+
"debug",
165+
"detected features of Firebase MCP server: " + (this.detectedFeatures.join(", ") || "<none>"),
166+
);
143167
return this.detectedFeatures;
144168
}
145169

@@ -204,28 +228,30 @@ export class FirebaseMcpServer {
204228
return getProjectId(await this.resolveOptions());
205229
}
206230

207-
async getAuthenticatedUser(): Promise<string | null> {
231+
async getAuthenticatedUser(skipAutoAuth: boolean = false): Promise<string | null> {
208232
try {
209-
const email = await requireAuth(await this.resolveOptions());
210-
return email ?? "Application Default Credentials";
233+
this.log("debug", `calling requireAuth`);
234+
const email = await requireAuth(await this.resolveOptions(), skipAutoAuth);
235+
this.log("debug", `detected authenticated account: ${email || "<none>"}`);
236+
return email ?? skipAutoAuth ? null : "Application Default Credentials";
211237
} catch (e) {
238+
this.log("debug", `error in requireAuth: ${e}`);
212239
return null;
213240
}
214241
}
215242

216243
async mcpListTools(): Promise<ListToolsResult> {
217244
await Promise.all([this.detectActiveFeatures(), this.detectProjectRoot()]);
218245
const hasActiveProject = !!(await this.getProjectId());
219-
await trackGA4("mcp_list_tools", {
220-
mcp_client_name: this.clientInfo?.name,
221-
mcp_client_version: this.clientInfo?.version,
222-
});
246+
await this.trackGA4("mcp_list_tools");
247+
const skipAutoAuthForStudio = isFirebaseStudio();
248+
this.log("debug", `skip auto-auth in studio environment: ${skipAutoAuthForStudio}`);
223249
return {
224250
tools: this.availableTools.map((t) => t.mcp),
225251
_meta: {
226252
projectRoot: this.cachedProjectRoot,
227253
projectDetected: hasActiveProject,
228-
authenticatedUser: await this.getAuthenticatedUser(),
254+
authenticatedUser: await this.getAuthenticatedUser(skipAutoAuthForStudio),
229255
activeFeatures: this.activeFeatures,
230256
detectedFeatures: this.detectedFeatures,
231257
},
@@ -283,26 +309,24 @@ export class FirebaseMcpServer {
283309
};
284310
try {
285311
const res = await tool.fn(toolArgs, toolsCtx);
286-
await trackGA4("mcp_tool_call", {
312+
await this.trackGA4("mcp_tool_call", {
287313
tool_name: toolName,
288314
error: res.isError ? 1 : 0,
289-
mcp_client_name: this.clientInfo?.name,
290-
mcp_client_version: this.clientInfo?.version,
291315
});
292316
return res;
293317
} catch (err: unknown) {
294-
await trackGA4("mcp_tool_call", {
318+
await this.trackGA4("mcp_tool_call", {
295319
tool_name: toolName,
296320
error: 1,
297-
mcp_client_name: this.clientInfo?.name,
298-
mcp_client_version: this.clientInfo?.version,
299321
});
300322
return mcpError(err);
301323
}
302324
}
303325

304326
async start(): Promise<void> {
305-
const transport = new StdioServerTransport();
327+
const transport = process.env.FIREBASE_MCP_DEBUG_LOG
328+
? new LoggingStdioServerTransport(process.env.FIREBASE_MCP_DEBUG_LOG)
329+
: new StdioServerTransport();
306330
await this.server.connect(transport);
307331
}
308332

@@ -323,6 +347,6 @@ export class FirebaseMcpServer {
323347
return;
324348
}
325349

326-
await this.server.sendLoggingMessage({ level, data });
350+
if (this._ready) await this.server.sendLoggingMessage({ level, data });
327351
}
328352
}

src/mcp/logging-transport.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2+
import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
3+
import { appendFileSync } from "fs";
4+
import { appendFile } from "fs/promises";
5+
6+
export class LoggingStdioServerTransport extends StdioServerTransport {
7+
path: string;
8+
9+
constructor(path: string) {
10+
super();
11+
this.path = path;
12+
appendFileSync(path, "--- new process start ---\n");
13+
const origOnData = this._ondata;
14+
this._ondata = (chunk: Buffer) => {
15+
origOnData(chunk);
16+
appendFileSync(path, chunk.toString(), { encoding: "utf8" });
17+
};
18+
}
19+
20+
async send(message: JSONRPCMessage) {
21+
await super.send(message);
22+
await appendFile(this.path, JSON.stringify(message) + "\n");
23+
}
24+
}

src/mcp/util.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
crashlyticsApiOrigin,
1515
} from "../api";
1616
import { check } from "../ensureApiEnabled";
17+
import { timeoutFallback } from "../timeout";
1718

1819
/**
1920
* Converts data to a CallToolResult.
@@ -104,7 +105,12 @@ export async function checkFeatureActive(
104105
if (feature in (options?.config?.data || {})) return true;
105106
// if the feature's api is active in the project, it's active
106107
try {
107-
if (projectId) return await check(projectId, SERVER_FEATURE_APIS[feature], "", true);
108+
if (projectId)
109+
return await timeoutFallback(
110+
check(projectId, SERVER_FEATURE_APIS[feature], "", true),
111+
true,
112+
3000,
113+
);
108114
} catch (e) {
109115
// if we don't have network or something, better to default to on
110116
return true;

src/requireAuth.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import * as scopes from "./scopes";
1010
import { Tokens, TokensWithExpiration, User } from "./types/auth";
1111
import { setRefreshToken, setActiveAccount, setGlobalDefaultAccount, isExpired } from "./auth";
1212
import type { Options } from "./options";
13+
import { isFirebaseMcp, isFirebaseStudio } from "./env";
14+
import { timeoutError } from "./timeout";
1315

1416
const AUTH_ERROR_MESSAGE = `Command requires authentication, please run ${clc.bold(
1517
"firebase login",
@@ -44,13 +46,20 @@ async function autoAuth(options: Options, authScopes: string[]): Promise<null |
4446

4547
let clientEmail;
4648
try {
47-
const credentials = await client.getCredentials();
49+
const timeoutMillis = isFirebaseMcp() ? 5000 : 15000;
50+
const credentials = await timeoutError(
51+
client.getCredentials(),
52+
new FirebaseError(
53+
`Authenticating with default credentials timed out after ${timeoutMillis / 1000} seconds. Please try running \`firebase login\` instead.`,
54+
),
55+
timeoutMillis,
56+
);
4857
clientEmail = credentials.client_email;
4958
} catch (e) {
5059
// Make sure any error here doesn't block the CLI, but log it.
5160
logger.debug(`Error getting account credentials.`);
5261
}
53-
if (process.env.MONOSPACE_ENV && token && clientEmail) {
62+
if (isFirebaseStudio() && token && clientEmail) {
5463
// Within monospace, this a OAuth token for the user, so we make it the active user.
5564
const activeAccount = {
5665
user: { email: clientEmail },
@@ -82,7 +91,10 @@ export async function refreshAuth(): Promise<Tokens> {
8291
* if the user is not authenticated
8392
* @param options CLI options.
8493
*/
85-
export async function requireAuth(options: any): Promise<string | null> {
94+
export async function requireAuth(
95+
options: any,
96+
skipAutoAuth: boolean = false,
97+
): Promise<string | null> {
8698
lastOptions = options;
8799
api.setScopes([scopes.CLOUD_PLATFORM, scopes.FIREBASE_PLATFORM]);
88100
options.authScopes = api.getScopes();
@@ -104,6 +116,8 @@ export async function requireAuth(options: any): Promise<string | null> {
104116
);
105117
} else if (user && (!isExpired(tokens) || tokens?.refresh_token)) {
106118
logger.debug(`> authorizing via signed-in user (${user.email})`);
119+
} else if (skipAutoAuth) {
120+
return null;
107121
} else {
108122
try {
109123
return await autoAuth(options, options.authScopes);

src/timeout.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Races a promise against a timer, returns a fallback value (without rejecting) when time expires.
3+
*/
4+
export async function timeoutFallback<T, V>(
5+
promise: Promise<T>,
6+
value: V,
7+
timeoutMillis = 2000,
8+
): Promise<T | V> {
9+
return Promise.race([
10+
promise,
11+
new Promise<V>((resolve) => setTimeout(() => resolve(value), timeoutMillis)),
12+
]);
13+
}
14+
15+
export async function timeoutError<T>(
16+
promise: Promise<T>,
17+
error?: string | Error,
18+
timeoutMillis = 5000,
19+
): Promise<T> {
20+
if (typeof error === "string") error = new Error(error);
21+
return Promise.race<T>([
22+
promise,
23+
new Promise((resolve, reject) => {
24+
setTimeout(() => reject(error || new Error("Operation timed out.")), timeoutMillis);
25+
}),
26+
]);
27+
}

src/track.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getGlobalDefaultAccount } from "./auth";
44

55
import { configstore } from "./configstore";
66
import { logger } from "./logger";
7+
import { isFirebaseStudio } from "./env";
78
const pkg = require("../package.json");
89

910
type cliEventNames =
@@ -78,7 +79,7 @@ const GA4_USER_PROPS = {
7879
value: process.env.FIREPIT_VERSION || "none",
7980
},
8081
is_firebase_studio: {
81-
value: process.env.MONOSPACE_ENV ?? "false",
82+
value: isFirebaseStudio().toString(),
8283
},
8384
};
8485

0 commit comments

Comments
 (0)