Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
63 changes: 59 additions & 4 deletions src/mcp/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand Down Expand Up @@ -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([]);
});
});
66 changes: 58 additions & 8 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand All @@ -291,7 +292,56 @@ export class FirebaseMcpServer {
}

async getProjectId(): Promise<string | undefined> {
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 || "<none>"}'`,
);
}
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<boolean> {
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<string | null> {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down