Skip to content

fix(mcp): Address auth issues with Firebase Studio environment #8871

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jul 21, 2025
Merged
2 changes: 1 addition & 1 deletion firebase-vscode/src/core/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const env = globalSignal<Environment>({
export function registerEnv(broker: ExtensionBrokerImpl): Disposable {
const sub = broker.on("getInitialData", async () => {
pluginLogger.debug(
`Value of process.env.MONOSPACE_ENV: ` + `${process.env.MONOSPACE_ENV}`
`Value of process.env.MONOSPACE_ENV: ` + `${process.env.MONOSPACE_ENV}`,
);

broker.send("notifyEnv", {
Expand Down
13 changes: 13 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { dirExistsSync } from "./fsutils";

let googleIdxFolderExists: boolean | undefined;
export function isFirebaseStudio() {

Check warning on line 4 in src/env.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment

Check warning on line 4 in src/env.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
if (googleIdxFolderExists === true || process.env.MONOSPACE_ENV) return true;
if (googleIdxFolderExists === false) return false;
googleIdxFolderExists = dirExistsSync("/google/idx");
return googleIdxFolderExists;
}

export function isFirebaseMcp() {

Check warning on line 11 in src/env.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment

Check warning on line 11 in src/env.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
return !!process.env.IS_FIREBASE_MCP;
}
74 changes: 49 additions & 25 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@
import { existsSync } from "node:fs";
import { ensure, check } from "../ensureApiEnabled.js";
import * as api from "../api.js";
import { LoggingStdioServerTransport } from "./logging-transport.js";
import { isFirebaseStudio } from "../env.js";
import { timeoutFallback } from "../timeout.js";

const SERVER_VERSION = "0.1.0";
const SERVER_VERSION = "0.2.0";

const cmd = new Command("experimental:mcp").before(requireAuth);
const cmd = new Command("experimental:mcp");

const orderedLogLevels = [
"debug",
Expand All @@ -44,7 +47,7 @@
] as const;

export class FirebaseMcpServer {
private _ready: boolean = false;

Check warning on line 50 in src/mcp/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Type boolean trivially inferred from a boolean literal, remove type annotation
private _readyPromises: { resolve: () => void; reject: (err: unknown) => void }[] = [];
startupRoot?: string;
cachedProjectRoot?: string;
Expand All @@ -56,7 +59,7 @@

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

/** Create a special tracking function to avoid blocking everything on initialization notification. */
private async trackGA4(
event: Parameters<typeof trackGA4>[0],
params: Parameters<typeof trackGA4>[1] = {},
): Promise<void> {
// wait until ready or until 2s has elapsed
if (!this.clientInfo) await timeoutFallback(this.ready(), null, 2000);
const clientInfoParams = {
mcp_client_name: this.clientInfo?.name || "<unknown-client>",
mcp_client_version: this.clientInfo?.version || "<unknown-version>",
};
trackGA4(event, { ...params, ...clientInfoParams });

Check warning on line 82 in src/mcp/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
}

constructor(options: { activeFeatures?: ServerFeature[]; projectRoot?: string }) {
this.activeFeatures = options.activeFeatures;
this.startupRoot = options.projectRoot || process.env.PROJECT_ROOT;
Expand All @@ -72,14 +89,11 @@
this.server.registerCapabilities({ tools: { listChanged: true }, logging: {} });
this.server.setRequestHandler(ListToolsRequestSchema, this.mcpListTools.bind(this));
this.server.setRequestHandler(CallToolRequestSchema, this.mcpCallTool.bind(this));
this.server.oninitialized = async () => {

Check warning on line 92 in src/mcp/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Promise-returning function provided to variable where a void return was expected
const clientInfo = this.server.getClientVersion();
this.clientInfo = clientInfo;
if (clientInfo?.name) {
trackGA4("mcp_client_connected", {
mcp_client_name: clientInfo.name,
mcp_client_version: clientInfo.version,
});
this.trackGA4("mcp_client_connected");

Check warning on line 96 in src/mcp/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
}
if (!this.clientInfo?.name) this.clientInfo = { name: "<unknown-client>" };

Expand All @@ -94,8 +108,8 @@
return {};
});

this.detectProjectRoot();

Check warning on line 111 in src/mcp/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
this.detectActiveFeatures();

Check warning on line 112 in src/mcp/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
}

/** Wait until initialization has finished. */
Expand All @@ -106,8 +120,12 @@
});
}

get clientName(): string {
return this.clientInfo?.name ?? (isFirebaseStudio() ? "Firebase Studio" : "<unknown-client>");
}

private get clientConfigKey() {
return `mcp.clientConfigs.${this.clientInfo?.name || "<unknown-client>"}:${this.startupRoot || process.cwd()}`;
return `mcp.clientConfigs.${this.clientName}:${this.startupRoot || process.cwd()}`;
}

getStoredClientConfig(): ClientConfig {
Expand All @@ -122,15 +140,17 @@
}

async detectProjectRoot(): Promise<string> {
await this.ready();
await timeoutFallback(this.ready(), null, 2000);
if (this.cachedProjectRoot) return this.cachedProjectRoot;
const storedRoot = this.getStoredClientConfig().projectRoot;
this.cachedProjectRoot = storedRoot || this.startupRoot || process.cwd();
this.log("debug", "detected and cached project root: " + this.cachedProjectRoot);
return this.cachedProjectRoot;
}

async detectActiveFeatures(): Promise<ServerFeature[]> {
if (this.detectedFeatures?.length) return this.detectedFeatures; // memoized
this.log("debug", "detecting active features of Firebase MCP server...");
const options = await this.resolveOptions();
const projectId = await this.getProjectId();
const detected = await Promise.all(
Expand All @@ -140,6 +160,10 @@
}),
);
this.detectedFeatures = detected.filter((f) => !!f) as ServerFeature[];
this.log(
"debug",
"detected features of Firebase MCP server: " + (this.detectedFeatures.join(", ") || "<none>"),
);
return this.detectedFeatures;
}

Expand Down Expand Up @@ -204,28 +228,30 @@
return getProjectId(await this.resolveOptions());
}

async getAuthenticatedUser(): Promise<string | null> {
async getAuthenticatedUser(skipAutoAuth: boolean = false): Promise<string | null> {
try {
const email = await requireAuth(await this.resolveOptions());
return email ?? "Application Default Credentials";
this.log("debug", `calling requireAuth`);
const email = await requireAuth(await this.resolveOptions(), skipAutoAuth);
this.log("debug", `detected authenticated account: ${email || "<none>"}`);
return email ?? skipAutoAuth ? null : "Application Default Credentials";
} catch (e) {
this.log("debug", `error in requireAuth: ${e}`);
return null;
}
}

async mcpListTools(): Promise<ListToolsResult> {
await Promise.all([this.detectActiveFeatures(), this.detectProjectRoot()]);
const hasActiveProject = !!(await this.getProjectId());
await trackGA4("mcp_list_tools", {
mcp_client_name: this.clientInfo?.name,
mcp_client_version: this.clientInfo?.version,
});
await this.trackGA4("mcp_list_tools");
const skipAutoAuthForStudio = isFirebaseStudio();
this.log("debug", `skip auto-auth in studio environment: ${skipAutoAuthForStudio}`);
return {
tools: this.availableTools.map((t) => t.mcp),
_meta: {
projectRoot: this.cachedProjectRoot,
projectDetected: hasActiveProject,
authenticatedUser: await this.getAuthenticatedUser(),
authenticatedUser: await this.getAuthenticatedUser(skipAutoAuthForStudio),
activeFeatures: this.activeFeatures,
detectedFeatures: this.detectedFeatures,
},
Expand Down Expand Up @@ -283,26 +309,24 @@
};
try {
const res = await tool.fn(toolArgs, toolsCtx);
await trackGA4("mcp_tool_call", {
await this.trackGA4("mcp_tool_call", {
tool_name: toolName,
error: res.isError ? 1 : 0,
mcp_client_name: this.clientInfo?.name,
mcp_client_version: this.clientInfo?.version,
});
return res;
} catch (err: unknown) {
await trackGA4("mcp_tool_call", {
await this.trackGA4("mcp_tool_call", {
tool_name: toolName,
error: 1,
mcp_client_name: this.clientInfo?.name,
mcp_client_version: this.clientInfo?.version,
});
return mcpError(err);
}
}

async start(): Promise<void> {
const transport = new StdioServerTransport();
const transport = process.env.FIREBASE_MCP_DEBUG_LOG
? new LoggingStdioServerTransport(process.env.FIREBASE_MCP_DEBUG_LOG)
: new StdioServerTransport();
await this.server.connect(transport);
}

Expand All @@ -323,6 +347,6 @@
return;
}

await this.server.sendLoggingMessage({ level, data });
if (this._ready) await this.server.sendLoggingMessage({ level, data });
}
}
24 changes: 24 additions & 0 deletions src/mcp/logging-transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
import { appendFileSync } from "fs";
import { appendFile } from "fs/promises";

export class LoggingStdioServerTransport extends StdioServerTransport {
path: string;

constructor(path: string) {
super();
this.path = path;
appendFileSync(path, "--- new process start ---\n");
const origOnData = this._ondata;
this._ondata = (chunk: Buffer) => {
origOnData(chunk);
appendFileSync(path, chunk.toString(), { encoding: "utf8" });
};
}

async send(message: JSONRPCMessage) {
await super.send(message);
await appendFile(this.path, JSON.stringify(message) + "\n");
}
}
8 changes: 7 additions & 1 deletion src/mcp/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
crashlyticsApiOrigin,
} from "../api";
import { check } from "../ensureApiEnabled";
import { timeoutFallback } from "../timeout";

/**
* Converts data to a CallToolResult.
Expand Down Expand Up @@ -104,7 +105,12 @@ export async function checkFeatureActive(
if (feature in (options?.config?.data || {})) return true;
// if the feature's api is active in the project, it's active
try {
if (projectId) return await check(projectId, SERVER_FEATURE_APIS[feature], "", true);
if (projectId)
return await timeoutFallback(
check(projectId, SERVER_FEATURE_APIS[feature], "", true),
true,
3000,
);
} catch (e) {
// if we don't have network or something, better to default to on
return true;
Expand Down
20 changes: 17 additions & 3 deletions src/requireAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import * as scopes from "./scopes";
import { Tokens, TokensWithExpiration, User } from "./types/auth";
import { setRefreshToken, setActiveAccount, setGlobalDefaultAccount, isExpired } from "./auth";
import type { Options } from "./options";
import { isFirebaseMcp, isFirebaseStudio } from "./env";
import { timeoutError } from "./timeout";

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

let clientEmail;
try {
const credentials = await client.getCredentials();
const timeoutMillis = isFirebaseMcp() ? 5000 : 15000;
const credentials = await timeoutError(
client.getCredentials(),
new FirebaseError(
`Authenticating with default credentials timed out after ${timeoutMillis / 1000} seconds. Please try running \`firebase login\` instead.`,
),
timeoutMillis,
);
clientEmail = credentials.client_email;
} catch (e) {
// Make sure any error here doesn't block the CLI, but log it.
logger.debug(`Error getting account credentials.`);
}
if (process.env.MONOSPACE_ENV && token && clientEmail) {
if (isFirebaseStudio() && token && clientEmail) {
// Within monospace, this a OAuth token for the user, so we make it the active user.
const activeAccount = {
user: { email: clientEmail },
Expand Down Expand Up @@ -82,7 +91,10 @@ export async function refreshAuth(): Promise<Tokens> {
* if the user is not authenticated
* @param options CLI options.
*/
export async function requireAuth(options: any): Promise<string | null> {
export async function requireAuth(
options: any,
skipAutoAuth: boolean = false,
): Promise<string | null> {
lastOptions = options;
api.setScopes([scopes.CLOUD_PLATFORM, scopes.FIREBASE_PLATFORM]);
options.authScopes = api.getScopes();
Expand All @@ -104,6 +116,8 @@ export async function requireAuth(options: any): Promise<string | null> {
);
} else if (user && (!isExpired(tokens) || tokens?.refresh_token)) {
logger.debug(`> authorizing via signed-in user (${user.email})`);
} else if (skipAutoAuth) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have monospace specific logic in autoAuth that sets the user from the returned access token as the active user (so that it appears like the user is logged in). I'm concerned about the case where these tokens are present but expired - MCP server will tell them to login, but I think firebase login might not work as expected.

I think we probably need to clean up the auth strategy overall ASAP - what you have here is probably still an improvement over the current state tho

return null;
} else {
try {
return await autoAuth(options, options.authScopes);
Expand Down
27 changes: 27 additions & 0 deletions src/timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Races a promise against a timer, returns a fallback value (without rejecting) when time expires.
*/
export async function timeoutFallback<T, V>(
promise: Promise<T>,
value: V,
timeoutMillis = 2000,
): Promise<T | V> {
return Promise.race([
promise,
new Promise<V>((resolve) => setTimeout(() => resolve(value), timeoutMillis)),
]);
}

export async function timeoutError<T>(
promise: Promise<T>,
error?: string | Error,
timeoutMillis = 5000,
): Promise<T> {
if (typeof error === "string") error = new Error(error);
return Promise.race<T>([
promise,
new Promise((resolve, reject) => {
setTimeout(() => reject(error || new Error("Operation timed out.")), timeoutMillis);
}),
]);
}
3 changes: 2 additions & 1 deletion src/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getGlobalDefaultAccount } from "./auth";

import { configstore } from "./configstore";
import { logger } from "./logger";
import { isFirebaseStudio } from "./env";
const pkg = require("../package.json");

type cliEventNames =
Expand Down Expand Up @@ -78,7 +79,7 @@ const GA4_USER_PROPS = {
value: process.env.FIREPIT_VERSION || "none",
},
is_firebase_studio: {
value: process.env.MONOSPACE_ENV ?? "false",
value: isFirebaseStudio().toString(),
},
};

Expand Down
Loading