Skip to content
Merged
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
@@ -0,0 +1 @@
- `firebase_update_environment` MCP tool supports accepting Gemini in Firebase Terms of Service.
42 changes: 33 additions & 9 deletions src/mcp/errors.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,44 @@
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { mcpError } from "./util";
import { configstore } from "../configstore";
import { check, ensure } from "../ensureApiEnabled";
import { cloudAiCompanionOrigin } from "../api";

export const NO_PROJECT_ERROR = mcpError(
'No active project was found. Use the `firebase_update_environment` tool to set the project directory to an absolute folder location containing a firebase.json config file. Alternatively, change the MCP server config to add [...,"--dir","/absolute/path/to/project/directory"] in its command-line arguments.',
"This tool requires an active project. Use the `firebase_update_environment` tool to set a project ID",
"PRECONDITION_FAILED",
);

const GEMINI_TOS_ERROR = mcpError(
"This tool requires the Gemini in Firebase API, please review the terms of service and accept it using `firebase_update_environment`.\n" +
"Learn more about Gemini in Firebase and how it uses your data: https://firebase.google.com/docs/gemini-in-firebase#how-gemini-in-firebase-uses-your-data",
"PRECONDITION_FAILED",
);

/** Enable the Gemini in Firebase API or return an error to accept it */
export async function requireGeminiToS(projectId: string): Promise<CallToolResult | undefined> {
if (!projectId) {
return NO_PROJECT_ERROR;
}
if (configstore.get("gemini")) {
await ensure(projectId, cloudAiCompanionOrigin(), "");
} else {
if (!(await check(projectId, cloudAiCompanionOrigin(), ""))) {
return GEMINI_TOS_ERROR;
}
}
return undefined;
}

export function noProjectDirectory(projectRoot: string | undefined): CallToolResult {

Check warning on line 33 in src/mcp/errors.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
return mcpError(
`The current project directory '${
projectRoot || "<NO PROJECT DIRECTORY FOUND>"
}' does not exist. Please use the 'update_firebase_environment' tool to target a different project directory.`,
);
}

export function mcpAuthError(skipADC: boolean): CallToolResult {

Check warning on line 41 in src/mcp/errors.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
if (skipADC) {
return mcpError(
`The user is not currently logged into the Firebase CLI, which is required to use this tool. Please run the 'firebase_login' tool to log in.`,
Expand All @@ -15,11 +47,3 @@
return mcpError(`The user is not currently logged into the Firebase CLI, which is required to use this tool. Please run the 'firebase_login' tool to log in, or instruct the user to configure [Application Default Credentials][ADC] on their machine.
[ADC]: https://cloud.google.com/docs/authentication/application-default-credentials`);
}

export function mcpGeminiError(projectId: string) {
const consoleUrl = `https://firebase.corp.google.com/project/${projectId}/overview`;
return mcpError(
`This tool uses the Gemini in Firebase API. Visit Firebase Console to enable the Gemini in Firebase API ${consoleUrl} and try again.`,
"PRECONDITION_FAILED",
);
}
48 changes: 18 additions & 30 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,13 @@
import { requireAuth } from "../requireAuth";
import { Options } from "../options";
import { getProjectId } from "../projectUtils";
import { mcpAuthError, NO_PROJECT_ERROR, mcpGeminiError } from "./errors";
import { mcpAuthError, noProjectDirectory, NO_PROJECT_ERROR, requireGeminiToS } from "./errors";
import { trackGA4 } from "../track";
import { Config } from "../config";
import { loadRC } from "../rc";
import { EmulatorHubClient } from "../emulator/hubClient";
import { Emulators } from "../emulator/types";
import { existsSync } from "node:fs";
import { ensure, check } from "../ensureApiEnabled";
import * as api from "../api";
import { LoggingStdioServerTransport } from "./logging-transport";
import { isFirebaseStudio } from "../env";
import { timeoutFallback } from "../timeout";
Expand All @@ -54,10 +52,10 @@
] as const;

export class FirebaseMcpServer {
private _ready: boolean = false;

Check warning on line 55 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;
cachedProjectDir?: string;
server: Server;
activeFeatures?: ServerFeature[];
detectedFeatures?: ServerFeature[];
Expand Down Expand Up @@ -86,7 +84,7 @@
mcp_client_name: this.clientInfo?.name || "<unknown-client>",
mcp_client_version: this.clientInfo?.version || "<unknown-version>",
};
trackGA4(event, { ...params, ...clientInfoParams });

Check warning on line 87 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 }) {
Expand All @@ -104,11 +102,11 @@
this.server.setRequestHandler(ListPromptsRequestSchema, this.mcpListPrompts.bind(this));
this.server.setRequestHandler(GetPromptRequestSchema, this.mcpGetPrompt.bind(this));

this.server.oninitialized = async () => {

Check warning on line 105 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) {
this.trackGA4("mcp_client_connected");

Check warning on line 109 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 @@ -123,12 +121,12 @@
return {};
});

this.detectProjectRoot();

Check warning on line 124 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 125 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. */
ready() {

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

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
if (this._ready) return Promise.resolve();
return new Promise((resolve, reject) => {
this._readyPromises.push({ resolve: resolve as () => void, reject });
Expand All @@ -139,7 +137,7 @@
return this.clientInfo?.name ?? (isFirebaseStudio() ? "Firebase Studio" : "<unknown-client>");
}

private get clientConfigKey() {

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

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
return `mcp.clientConfigs.${this.clientName}:${this.startupRoot || process.cwd()}`;
}

Expand All @@ -156,11 +154,11 @@

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

async detectActiveFeatures(): Promise<ServerFeature[]> {
Expand Down Expand Up @@ -235,14 +233,14 @@

setProjectRoot(newRoot: string | null): void {
this.updateStoredClientConfig({ projectRoot: newRoot });
this.cachedProjectRoot = newRoot || undefined;
this.cachedProjectDir = newRoot || undefined;
this.detectedFeatures = undefined; // reset detected features
void this.server.sendToolListChanged();
void this.server.sendPromptListChanged();
}

async resolveOptions(): Promise<Partial<Options>> {
const options: Partial<Options> = { cwd: this.cachedProjectRoot, isMCP: true };
const options: Partial<Options> = { cwd: this.cachedProjectDir, isMCP: true };
await cmd.prepare(options);
return options;
}
Expand Down Expand Up @@ -272,7 +270,7 @@
return {
tools: this.availableTools.map((t) => t.mcp),
_meta: {
projectRoot: this.cachedProjectRoot,
projectRoot: this.cachedProjectDir,
projectDetected: hasActiveProject,
authenticatedUser: await this.getAuthenticatedUser(skipAutoAuthForStudio),
activeFeatures: this.activeFeatures,
Expand All @@ -289,15 +287,10 @@
if (!tool) throw new Error(`Tool '${toolName}' could not be found.`);

// Check if the current project directory exists.
if (
tool.mcp.name !== "firebase_update_environment" && // allow this tool only, to fix the issue
(!this.cachedProjectRoot || !existsSync(this.cachedProjectRoot))
) {
return mcpError(
`The current project directory '${
this.cachedProjectRoot || "<NO PROJECT DIRECTORY FOUND>"
}' does not exist. Please use the 'update_firebase_environment' tool to target a different project directory.`,
);
if (!tool.mcp._meta?.optionalProjectDir) {
if (!this.cachedProjectDir || !existsSync(this.cachedProjectDir)) {
return noProjectDirectory(this.cachedProjectDir);
}
}

// Check if the project ID is set.
Expand All @@ -316,16 +309,11 @@

// Check if the tool requires Gemini in Firebase API.
if (tool.mcp._meta?.requiresGemini) {
if (configstore.get("gemini")) {
await ensure(projectId, api.cloudAiCompanionOrigin(), "");
} else {
if (!(await check(projectId, api.cloudAiCompanionOrigin(), ""))) {
return mcpGeminiError(projectId);
}
}
const err = await requireGeminiToS(projectId);
if (err) return err;
}

const options = { projectDir: this.cachedProjectRoot, cwd: this.cachedProjectRoot };
const options = { projectDir: this.cachedProjectDir, cwd: this.cachedProjectDir };
const toolsCtx: ServerToolContext = {
projectId: projectId,
host: this,
Expand Down Expand Up @@ -362,7 +350,7 @@
arguments: p.mcp.arguments,
})),
_meta: {
projectRoot: this.cachedProjectRoot,
projectRoot: this.cachedProjectDir,
projectDetected: hasActiveProject,
authenticatedUser: await this.getAuthenticatedUser(skipAutoAuthForStudio),
activeFeatures: this.activeFeatures,
Expand All @@ -386,7 +374,7 @@
const skipAutoAuthForStudio = isFirebaseStudio();
const accountEmail = await this.getAuthenticatedUser(skipAutoAuthForStudio);

const options = { projectDir: this.cachedProjectRoot, cwd: this.cachedProjectRoot };
const options = { projectDir: this.cachedProjectDir, cwd: this.cachedProjectDir };
const promptsCtx: ServerPromptContext = {
projectId: projectId,
host: this,
Expand Down
2 changes: 2 additions & 0 deletions src/mcp/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export interface ServerTool<InputSchema extends ZodTypeAny = ZodTypeAny> {
openWorldHint?: boolean;
};
_meta?: {
/** Set this on a tool if it cannot work without a Firebase project directory. */
optionalProjectDir?: boolean;
/** Set this on a tool if it *always* requires a project to work. */
requiresProject?: boolean;
/** Set this on a tool if it *always* requires a signed-in user to work. */
Expand Down
5 changes: 4 additions & 1 deletion src/mcp/tools/core/get_environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { toContent } from "../../util";
import { getAliases } from "../../../projectUtils";
import { dump } from "js-yaml";
import { getAllAccounts } from "../../../auth";
import { configstore } from "../../../configstore";

export const get_environment = tool(
{
Expand All @@ -22,14 +23,16 @@ export const get_environment = tool(
},
async (_, { projectId, host, accountEmail, rc, config }) => {
const aliases = projectId ? getAliases({ rc }, projectId) : [];
const geminiTosAccepted = !!configstore.get("gemini");
return toContent(`# Environment Information

Project Directory: ${host.cachedProjectRoot}
Project Directory: ${host.cachedProjectDir}
Project Config Path: ${config.projectFileExists("firebase.json") ? config.path("firebase.json") : "<NO CONFIG PRESENT>"}
Active Project ID: ${
projectId ? `${projectId}${aliases.length ? ` (alias: ${aliases.join(",")})` : ""}` : "<NONE>"
}
Authenticated User: ${accountEmail || "<NONE>"}
Gemini in Firebase Terms of Service: ${geminiTosAccepted ? "Accepted" : "Not Accepted"}

# Available Project Aliases (format: '[alias]: [projectId]')

Expand Down
6 changes: 6 additions & 0 deletions src/mcp/tools/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { toContent } from "../../util";
import { DEFAULT_RULES } from "../../../init/features/database";
import { actuate, Setup, SetupInfo } from "../../../init/index";
import { freeTrialTermsLink } from "../../../dataconnect/freeTrial";
import { requireGeminiToS } from "../../errors";

export const init = tool(
{
Expand Down Expand Up @@ -157,6 +158,11 @@ export const init = tool(
};
}
if (features.dataconnect) {
if (features.dataconnect.app_description) {
// If app description is provided, ensure the Gemini in Firebase API is enabled.
const err = await requireGeminiToS(projectId);
if (err) return err;
}
featuresList.push("dataconnect");
featureInfo.dataconnect = {
analyticsFlow: "mcp",
Expand Down
21 changes: 16 additions & 5 deletions src/mcp/tools/core/update_environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { mcpError, toContent } from "../../util";
import { setNewActive } from "../../../commands/use";
import { assertAccount, setProjectAccount } from "../../../auth";
import { existsSync } from "node:fs";
import { configstore } from "../../../configstore";

export const update_environment = tool(
{
name: "update_environment",
description:
"Updates Firebase environment config such as project directory, active project, active user account, and more. Use `firebase_get_environment` to see the currently configured environment.",
"Updates Firebase environment config such as project directory, active project, active user account, accept terms of service, and more. Use `firebase_get_environment` to see the currently configured environment.",
inputSchema: z.object({
project_dir: z
.string()
Expand All @@ -29,17 +30,25 @@ export const update_environment = tool(
.describe(
"The email address of the signed-in user to authenticate as when interacting with the current project directory.",
),
accept_gemini_tos: z
.boolean()
.optional()
.describe("Accept the Gemini in Firebase terms of service."),
}),
annotations: {
title: "Update Firebase Environment",
readOnlyHint: false,
},
_meta: {
optionalProjectDir: true,
requiresAuth: false,
requiresProject: false,
},
},
async ({ project_dir, active_project, active_user_account }, { config, rc, host }) => {
async (
{ project_dir, active_project, active_user_account, accept_gemini_tos },
{ config, rc, host },
) => {
let output = "";
if (project_dir) {
if (!existsSync(project_dir))
Expand All @@ -55,12 +64,14 @@ export const update_environment = tool(
}
if (active_user_account) {
assertAccount(active_user_account, { mcp: true });
setProjectAccount(host.cachedProjectRoot!, active_user_account);
setProjectAccount(host.cachedProjectDir!, active_user_account);
output += `- Updated active account to '${active_user_account}'\n`;
}

if (accept_gemini_tos) {
configstore.set("gemini", true);
output += `- Accepted the Gemini in Firebase terms of service\n`;
}
if (output === "") output = "No changes were made.";

return toContent(output);
},
);
2 changes: 1 addition & 1 deletion src/mcp/tools/rules/validate_rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export function validateRulesTool(productName: string) {
let rulesSourceContent: string;
if (source_file) {
try {
const filePath = resolve(source_file, host.cachedProjectRoot!);
const filePath = resolve(source_file, host.cachedProjectDir!);
if (filePath.includes("../"))
return mcpError("Cannot read files outside of the project directory.");
rulesSourceContent = config.readProjectFile(source_file);
Expand Down
Loading