diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a9de9e9842..d7ed5ebbf70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,3 @@ +- MCP now prefers the project from Application Default Credentials and no longer fails startup when billing checks are unauthorized (#9983) - Updated the functions.config deprecation notice from March 2026 to March 2027 (#9941) - Detects when App Hosting fails to deploy, returning an error. (#8866) diff --git a/src/mcp/index.spec.ts b/src/mcp/index.spec.ts index f219f643ec6..c5b38cc8be0 100644 --- a/src/mcp/index.spec.ts +++ b/src/mcp/index.spec.ts @@ -2,17 +2,16 @@ import { expect } from "chai"; import * as sinon from "sinon"; import { FirebaseMcpServer } from "./index"; import * as requireAuthModule from "../requireAuth"; +import * as cloudbilling from "../gcp/cloudbilling"; +import * as availability from "./util/availability"; describe("FirebaseMcpServer.getAuthenticatedUser", () => { let server: FirebaseMcpServer; let requireAuthStub: sinon.SinonStub; beforeEach(() => { - // Mock the methods that may cause hanging BEFORE creating the instance sinon.stub(FirebaseMcpServer.prototype, "detectProjectRoot").resolves("/test/project"); - sinon.stub(FirebaseMcpServer.prototype, "detectActiveFeatures").resolves([]); - - server = new FirebaseMcpServer({}); + server = new FirebaseMcpServer({ activeFeatures: ["core"] }); // Mock the resolveOptions method to avoid dependency issues sinon.stub(server, "resolveOptions").resolves({}); @@ -61,3 +60,59 @@ describe("FirebaseMcpServer.getAuthenticatedUser", () => { expect(requireAuthStub.calledOnce).to.be.true; }); }); + +describe("FirebaseMcpServer.getProjectId", () => { + let server: FirebaseMcpServer; + + beforeEach(() => { + sinon.stub(FirebaseMcpServer.prototype, "detectProjectRoot").resolves("/test/project"); + server = new FirebaseMcpServer({ activeFeatures: ["core"] }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should prefer credential project over configured default project", async () => { + sinon.stub(server, "resolveOptions").resolves({ project: "project-a", projectId: "project-a" }); + sinon.stub(server as any, "getProjectIdFromCredentials").returns("project-b"); + + const projectId = await server.getProjectId(); + + expect(projectId).to.equal("project-b"); + }); + + it("should use configured project when no credential project is available", async () => { + sinon.stub(server, "resolveOptions").resolves({ project: "project-a", projectId: "project-a" }); + sinon.stub(server as any, "getProjectIdFromCredentials").returns(undefined); + + const projectId = await server.getProjectId(); + + expect(projectId).to.equal("project-a"); + }); +}); + +describe("FirebaseMcpServer.detectActiveFeatures", () => { + let server: FirebaseMcpServer; + + beforeEach(() => { + sinon.stub(FirebaseMcpServer.prototype, "detectProjectRoot").resolves("/test/project"); + server = new FirebaseMcpServer({ activeFeatures: ["core"] }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should continue feature detection when billing check fails", async () => { + sinon.stub(server, "getProjectId").resolves("project-a"); + sinon.stub(server, "getAuthenticatedUser").resolves("adc@example.com"); + sinon.stub(cloudbilling, "checkBillingEnabled").rejects(new Error("permission denied")); + sinon.stub(server as any, "_createMcpContext").returns({} as any); + sinon.stub(availability, "getDefaultFeatureAvailabilityCheck").returns(async () => false); + + const features = await server.detectActiveFeatures(); + + expect(features).to.deep.equal([]); + }); +}); diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 118792cbb4e..22a6220ce38 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -24,7 +24,7 @@ import { SetLevelRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import * as crossSpawn from "cross-spawn"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { Command } from "../command"; import { Config } from "../config"; import { configstore } from "../configstore"; @@ -77,6 +77,7 @@ export class FirebaseMcpServer { clientInfo?: { name?: string; version?: string }; emulatorHubClient?: EmulatorHubClient; private cliCommand?: string; + private cachedCredentialProjectId?: string | null; // logging spec: // https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/logging @@ -201,7 +202,7 @@ export class FirebaseMcpServer { this.logger.debug("detecting active features of Firebase MCP server..."); const projectId = (await this.getProjectId()) || ""; const accountEmail = await this.getAuthenticatedUser(); - const isBillingEnabled = projectId ? await checkBillingEnabled(projectId) : false; + const isBillingEnabled = await this.getBillingEnabled(projectId); const ctx = this._createMcpContext(projectId, accountEmail, isBillingEnabled); const detected = await Promise.all( SERVER_FEATURES.map(async (f) => { @@ -252,7 +253,7 @@ export class FirebaseMcpServer { // We need a project ID and user for the context, but it's ok if they're empty. const projectId = (await this.getProjectId()) || ""; const accountEmail = await this.getAuthenticatedUser(); - const isBillingEnabled = projectId ? await checkBillingEnabled(projectId) : false; + const isBillingEnabled = await this.getBillingEnabled(projectId); const ctx = this._createMcpContext(projectId, accountEmail, isBillingEnabled); return availableTools(ctx, this.activeFeatures, this.detectedFeatures, this.enabledTools); } @@ -266,7 +267,7 @@ export class FirebaseMcpServer { // We need a project ID and user for the context, but it's ok if they're empty. const projectId = (await this.getProjectId()) || ""; const accountEmail = await this.getAuthenticatedUser(); - const isBillingEnabled = projectId ? await checkBillingEnabled(projectId) : false; + const isBillingEnabled = await this.getBillingEnabled(projectId); const ctx = this._createMcpContext(projectId, accountEmail, isBillingEnabled); return availablePrompts(ctx, this.activeFeatures, this.detectedFeatures); } @@ -291,7 +292,56 @@ export class FirebaseMcpServer { } async getProjectId(): Promise { - return getProjectId(await this.resolveOptions()); + const options = await this.resolveOptions(); + const configuredProjectId = getProjectId(options); + const credentialProjectId = this.getProjectIdFromCredentials(); + if (credentialProjectId && credentialProjectId !== configuredProjectId) { + this.logger.debug( + `using project '${credentialProjectId}' from GOOGLE_APPLICATION_CREDENTIALS instead of '${configuredProjectId || ""}'`, + ); + } + return credentialProjectId || configuredProjectId; + } + + private getProjectIdFromCredentials(): string | undefined { + if (this.cachedCredentialProjectId !== undefined) { + return this.cachedCredentialProjectId || undefined; + } + + const credentialPath = process.env.GOOGLE_APPLICATION_CREDENTIALS; + if (!credentialPath) { + this.cachedCredentialProjectId = null; + return undefined; + } + + if (!existsSync(credentialPath)) { + this.logger.debug( + `GOOGLE_APPLICATION_CREDENTIALS points to a missing file: ${credentialPath}`, + ); + this.cachedCredentialProjectId = null; + return undefined; + } + + try { + const rawCreds = readFileSync(credentialPath, "utf8"); + const creds = JSON.parse(rawCreds) as { project_id?: string; quota_project_id?: string }; + this.cachedCredentialProjectId = creds.project_id || creds.quota_project_id || null; + } catch (err: unknown) { + this.logger.debug(`unable to parse GOOGLE_APPLICATION_CREDENTIALS file: ${err}`); + this.cachedCredentialProjectId = null; + } + + return this.cachedCredentialProjectId || undefined; + } + + private async getBillingEnabled(projectId: string): Promise { + if (!projectId) return false; + try { + return await checkBillingEnabled(projectId); + } catch (err: unknown) { + this.logger.debug(`billing check failed for project '${projectId}': ${err}`); + return false; + } } async getAuthenticatedUser(skipAutoAuth: boolean = false): Promise { @@ -379,7 +429,7 @@ export class FirebaseMcpServer { return mcpAuthError(skipAutoAuthForStudio); } - const isBillingEnabled = projectId ? await checkBillingEnabled(projectId) : false; + const isBillingEnabled = await this.getBillingEnabled(projectId); const toolsCtx = this._createMcpContext(projectId, accountEmail, isBillingEnabled); try { const res = await tool.fn(toolArgs, toolsCtx); @@ -434,7 +484,7 @@ export class FirebaseMcpServer { const skipAutoAuthForStudio = isFirebaseStudio(); const accountEmail = await this.getAuthenticatedUser(skipAutoAuthForStudio); - const isBillingEnabled = projectId ? await checkBillingEnabled(projectId) : false; + const isBillingEnabled = await this.getBillingEnabled(projectId); const promptsCtx = this._createMcpContext(projectId, accountEmail, isBillingEnabled); try { @@ -475,7 +525,7 @@ export class FirebaseMcpServer { const skipAutoAuthForStudio = isFirebaseStudio(); const accountEmail = await this.getAuthenticatedUser(skipAutoAuthForStudio); - const isBillingEnabled = projectId ? await checkBillingEnabled(projectId) : false; + const isBillingEnabled = await this.getBillingEnabled(projectId); const resourceCtx = this._createMcpContext(projectId, accountEmail, isBillingEnabled); const resolved = await resolveResource(req.params.uri, resourceCtx);