diff --git a/firebase-vscode/src/core/env.ts b/firebase-vscode/src/core/env.ts index 2d9701be7d2..f8489d6be7a 100644 --- a/firebase-vscode/src/core/env.ts +++ b/firebase-vscode/src/core/env.ts @@ -14,7 +14,7 @@ export const env = globalSignal({ 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", { diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 00000000000..637dfb7084d --- /dev/null +++ b/src/env.ts @@ -0,0 +1,13 @@ +import { dirExistsSync } from "./fsutils"; + +let googleIdxFolderExists: boolean | undefined; +export function isFirebaseStudio() { + if (googleIdxFolderExists === true || process.env.MONOSPACE_ENV) return true; + if (googleIdxFolderExists === false) return false; + googleIdxFolderExists = dirExistsSync("/google/idx"); + return googleIdxFolderExists; +} + +export function isFirebaseMcp() { + return !!process.env.IS_FIREBASE_MCP; +} diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 4a32b27588e..bfc9e09beba 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -27,10 +27,13 @@ import { Emulators } from "../emulator/types.js"; 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", @@ -56,7 +59,7 @@ export class FirebaseMcpServer { // 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) => [ @@ -65,6 +68,20 @@ export class FirebaseMcpServer { ]), ) as Record Promise>; + /** Create a special tracking function to avoid blocking everything on initialization notification. */ + private async trackGA4( + event: Parameters[0], + params: Parameters[1] = {}, + ): Promise { + // 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 || "", + mcp_client_version: this.clientInfo?.version || "", + }; + trackGA4(event, { ...params, ...clientInfoParams }); + } + constructor(options: { activeFeatures?: ServerFeature[]; projectRoot?: string }) { this.activeFeatures = options.activeFeatures; this.startupRoot = options.projectRoot || process.env.PROJECT_ROOT; @@ -76,10 +93,7 @@ export class FirebaseMcpServer { 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"); } if (!this.clientInfo?.name) this.clientInfo = { name: "" }; @@ -106,8 +120,12 @@ export class FirebaseMcpServer { }); } + get clientName(): string { + return this.clientInfo?.name ?? (isFirebaseStudio() ? "Firebase Studio" : ""); + } + private get clientConfigKey() { - return `mcp.clientConfigs.${this.clientInfo?.name || ""}:${this.startupRoot || process.cwd()}`; + return `mcp.clientConfigs.${this.clientName}:${this.startupRoot || process.cwd()}`; } getStoredClientConfig(): ClientConfig { @@ -122,15 +140,17 @@ export class FirebaseMcpServer { } async detectProjectRoot(): Promise { - 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 { 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( @@ -140,6 +160,10 @@ export class FirebaseMcpServer { }), ); this.detectedFeatures = detected.filter((f) => !!f) as ServerFeature[]; + this.log( + "debug", + "detected features of Firebase MCP server: " + (this.detectedFeatures.join(", ") || ""), + ); return this.detectedFeatures; } @@ -204,11 +228,14 @@ export class FirebaseMcpServer { return getProjectId(await this.resolveOptions()); } - async getAuthenticatedUser(): Promise { + async getAuthenticatedUser(skipAutoAuth: boolean = false): Promise { 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 || ""}`); + return email ?? skipAutoAuth ? null : "Application Default Credentials"; } catch (e) { + this.log("debug", `error in requireAuth: ${e}`); return null; } } @@ -216,16 +243,15 @@ export class FirebaseMcpServer { async mcpListTools(): Promise { 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, }, @@ -283,26 +309,24 @@ export class FirebaseMcpServer { }; 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 { - 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); } @@ -323,6 +347,6 @@ export class FirebaseMcpServer { return; } - await this.server.sendLoggingMessage({ level, data }); + if (this._ready) await this.server.sendLoggingMessage({ level, data }); } } diff --git a/src/mcp/logging-transport.ts b/src/mcp/logging-transport.ts new file mode 100644 index 00000000000..749a34a1a80 --- /dev/null +++ b/src/mcp/logging-transport.ts @@ -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"); + } +} diff --git a/src/mcp/util.ts b/src/mcp/util.ts index 256e139a91d..0f44cf9cddd 100644 --- a/src/mcp/util.ts +++ b/src/mcp/util.ts @@ -14,6 +14,7 @@ import { crashlyticsApiOrigin, } from "../api"; import { check } from "../ensureApiEnabled"; +import { timeoutFallback } from "../timeout"; /** * Converts data to a CallToolResult. @@ -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; diff --git a/src/requireAuth.ts b/src/requireAuth.ts index a4f7795ba88..e212f6c2aab 100644 --- a/src/requireAuth.ts +++ b/src/requireAuth.ts @@ -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", @@ -44,13 +46,20 @@ async function autoAuth(options: Options, authScopes: string[]): Promise { * if the user is not authenticated * @param options CLI options. */ -export async function requireAuth(options: any): Promise { +export async function requireAuth( + options: any, + skipAutoAuth: boolean = false, +): Promise { lastOptions = options; api.setScopes([scopes.CLOUD_PLATFORM, scopes.FIREBASE_PLATFORM]); options.authScopes = api.getScopes(); @@ -104,6 +116,8 @@ export async function requireAuth(options: any): Promise { ); } else if (user && (!isExpired(tokens) || tokens?.refresh_token)) { logger.debug(`> authorizing via signed-in user (${user.email})`); + } else if (skipAutoAuth) { + return null; } else { try { return await autoAuth(options, options.authScopes); diff --git a/src/timeout.ts b/src/timeout.ts new file mode 100644 index 00000000000..acf3e172c68 --- /dev/null +++ b/src/timeout.ts @@ -0,0 +1,27 @@ +/** + * Races a promise against a timer, returns a fallback value (without rejecting) when time expires. + */ +export async function timeoutFallback( + promise: Promise, + value: V, + timeoutMillis = 2000, +): Promise { + return Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve(value), timeoutMillis)), + ]); +} + +export async function timeoutError( + promise: Promise, + error?: string | Error, + timeoutMillis = 5000, +): Promise { + if (typeof error === "string") error = new Error(error); + return Promise.race([ + promise, + new Promise((resolve, reject) => { + setTimeout(() => reject(error || new Error("Operation timed out.")), timeoutMillis); + }), + ]); +} diff --git a/src/track.ts b/src/track.ts index 7b58973e23e..55c7dbbb498 100644 --- a/src/track.ts +++ b/src/track.ts @@ -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 = @@ -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(), }, };