Skip to content

Commit 04364fa

Browse files
authored
chore(mcp): Refactor client directory config logic. (#8599)
1 parent 5e96551 commit 04364fa

File tree

6 files changed

+79
-31
lines changed

6 files changed

+79
-31
lines changed

src/bin/mcp.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { FirebaseMcpServer } from "../mcp/index";
55
import { parseArgs } from "util";
66
import { SERVER_FEATURES, ServerFeature } from "../mcp/types";
77
import { markdownDocsOfTools } from "../mcp/tools/index.js";
8+
import { resolve } from "path";
89

910
const STARTUP_MESSAGE = `
1011
This is a running process of the Firebase MCP server. This command should only be executed by an MCP client. An example MCP client configuration might be:
@@ -36,7 +37,10 @@ export async function mcp(): Promise<void> {
3637
const activeFeatures = (values.only || "")
3738
.split(",")
3839
.filter((f) => SERVER_FEATURES.includes(f as ServerFeature)) as ServerFeature[];
39-
const server = new FirebaseMcpServer({ activeFeatures, projectRoot: values.dir });
40+
const server = new FirebaseMcpServer({
41+
activeFeatures,
42+
projectRoot: values.dir ? resolve(values.dir) : undefined,
43+
});
4044
await server.start();
4145
if (process.stdin.isTTY) process.stderr.write(STARTUP_MESSAGE);
4246
}

src/mcp/index.ts

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
ListToolsResult,
99
} from "@modelcontextprotocol/sdk/types.js";
1010
import { checkFeatureActive, mcpError } from "./util.js";
11-
import { SERVER_FEATURES, ServerFeature } from "./types.js";
11+
import { ClientConfig, SERVER_FEATURES, ServerFeature } from "./types.js";
1212
import { availableTools } from "./tools/index.js";
1313
import { ServerTool, ServerToolContext } from "./tool.js";
1414
import { configstore } from "../configstore.js";
@@ -23,12 +23,14 @@ import { loadRC } from "../rc.js";
2323
import { EmulatorHubClient } from "../emulator/hubClient.js";
2424

2525
const SERVER_VERSION = "0.0.1";
26-
const PROJECT_ROOT_KEY = "mcp.projectRoot";
2726

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

3029
export class FirebaseMcpServer {
31-
projectRoot?: string;
30+
private _ready: boolean = false;
31+
private _readyPromises: { resolve: () => void; reject: (err: unknown) => void }[] = [];
32+
startupRoot?: string;
33+
cachedProjectRoot?: string;
3234
server: Server;
3335
activeFeatures?: ServerFeature[];
3436
detectedFeatures?: ServerFeature[];
@@ -37,11 +39,12 @@ export class FirebaseMcpServer {
3739

3840
constructor(options: { activeFeatures?: ServerFeature[]; projectRoot?: string }) {
3941
this.activeFeatures = options.activeFeatures;
42+
this.startupRoot = options.projectRoot || process.env.PROJECT_ROOT;
4043
this.server = new Server({ name: "firebase", version: SERVER_VERSION });
4144
this.server.registerCapabilities({ tools: { listChanged: true } });
4245
this.server.setRequestHandler(ListToolsRequestSchema, this.mcpListTools.bind(this));
4346
this.server.setRequestHandler(CallToolRequestSchema, this.mcpCallTool.bind(this));
44-
this.server.oninitialized = () => {
47+
this.server.oninitialized = async () => {
4548
const clientInfo = this.server.getClientVersion();
4649
this.clientInfo = clientInfo;
4750
if (clientInfo?.name) {
@@ -50,15 +53,48 @@ export class FirebaseMcpServer {
5053
mcp_client_version: clientInfo.version,
5154
});
5255
}
56+
if (!this.clientInfo?.name) this.clientInfo = { name: "<unknown-client>" };
57+
58+
this._ready = true;
59+
while (this._readyPromises.length) {
60+
this._readyPromises.pop()?.resolve();
61+
}
5362
};
54-
this.projectRoot =
55-
options.projectRoot ??
56-
(configstore.get(PROJECT_ROOT_KEY) as string) ??
57-
process.env.PROJECT_ROOT ??
58-
process.cwd();
63+
this.detectProjectRoot();
5964
this.detectActiveFeatures();
6065
}
6166

67+
/** Wait until initialization has finished. */
68+
ready() {
69+
if (this._ready) return Promise.resolve();
70+
return new Promise((resolve, reject) => {
71+
this._readyPromises.push({ resolve: resolve as () => void, reject });
72+
});
73+
}
74+
75+
private get clientConfigKey() {
76+
return `mcp.clientConfigs.${this.clientInfo?.name || "<unknown-client>"}:${this.startupRoot || process.cwd()}`;
77+
}
78+
79+
getStoredClientConfig(): ClientConfig {
80+
return configstore.get(this.clientConfigKey) || {};
81+
}
82+
83+
updateStoredClientConfig(update: Partial<ClientConfig>) {
84+
const config = configstore.get(this.clientConfigKey) || {};
85+
const newConfig = { ...config, ...update };
86+
configstore.set(this.clientConfigKey, newConfig);
87+
return newConfig;
88+
}
89+
90+
async detectProjectRoot(): Promise<string> {
91+
await this.ready();
92+
if (this.cachedProjectRoot) return this.cachedProjectRoot;
93+
const storedRoot = this.getStoredClientConfig().projectRoot;
94+
this.cachedProjectRoot = storedRoot || this.startupRoot || process.cwd();
95+
return this.cachedProjectRoot;
96+
}
97+
6298
async detectActiveFeatures(): Promise<ServerFeature[]> {
6399
if (this.detectedFeatures?.length) return this.detectedFeatures; // memoized
64100
const options = await this.resolveOptions();
@@ -97,21 +133,14 @@ export class FirebaseMcpServer {
97133
}
98134

99135
setProjectRoot(newRoot: string | null): void {
100-
if (newRoot === null) {
101-
configstore.delete(PROJECT_ROOT_KEY);
102-
this.projectRoot = process.env.PROJECT_ROOT || process.cwd();
103-
void this.server.sendToolListChanged();
104-
return;
105-
}
106-
107-
configstore.set(PROJECT_ROOT_KEY, newRoot);
108-
this.projectRoot = newRoot;
136+
this.updateStoredClientConfig({ projectRoot: newRoot });
137+
this.cachedProjectRoot = newRoot || undefined;
109138
this.detectedFeatures = undefined; // reset detected features
110139
void this.server.sendToolListChanged();
111140
}
112141

113142
async resolveOptions(): Promise<Partial<Options>> {
114-
const options: Partial<Options> = { cwd: this.projectRoot };
143+
const options: Partial<Options> = { cwd: this.cachedProjectRoot };
115144
await cmd.prepare(options);
116145
return options;
117146
}
@@ -129,7 +158,7 @@ export class FirebaseMcpServer {
129158
}
130159

131160
async mcpListTools(): Promise<ListToolsResult> {
132-
if (!this.activeFeatures) await this.detectActiveFeatures();
161+
await Promise.all([this.detectActiveFeatures(), this.detectProjectRoot()]);
133162
const hasActiveProject = !!(await this.getProjectId());
134163
await trackGA4("mcp_list_tools", {
135164
mcp_client_name: this.clientInfo?.name,
@@ -138,7 +167,7 @@ export class FirebaseMcpServer {
138167
return {
139168
tools: this.availableTools.map((t) => t.mcp),
140169
_meta: {
141-
projectRoot: this.projectRoot,
170+
projectRoot: this.cachedProjectRoot,
142171
projectDetected: hasActiveProject,
143172
authenticatedUser: await this.getAuthenticatedUser(),
144173
activeFeatures: this.activeFeatures,
@@ -148,6 +177,7 @@ export class FirebaseMcpServer {
148177
}
149178

150179
async mcpCallTool(request: CallToolRequest): Promise<CallToolResult> {
180+
await this.detectProjectRoot();
151181
const toolName = request.params.name;
152182
const toolArgs = request.params.arguments;
153183
const tool = this.getTool(toolName);
@@ -158,7 +188,7 @@ export class FirebaseMcpServer {
158188
if (tool.mcp._meta?.requiresAuth && !accountEmail) return mcpAuthError();
159189
if (tool.mcp._meta?.requiresProject && !projectId) return NO_PROJECT_ERROR;
160190

161-
const options = { projectDir: this.projectRoot, cwd: this.projectRoot };
191+
const options = { projectDir: this.cachedProjectRoot, cwd: this.cachedProjectRoot };
162192
const toolsCtx: ServerToolContext = {
163193
projectId: projectId,
164194
host: this,

src/mcp/tools/core/get_environment.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ export const get_environment = tool(
2424
const aliases = projectId ? getAliases({ rc }, projectId) : [];
2525
return toContent(`# Environment Information
2626
27-
Project Directory: ${host.projectRoot}}
28-
Project Config Path: ${config.path("firebase.json")}
27+
Project Directory: ${host.cachedProjectRoot}
28+
Project Config Path: ${config.projectFileExists("firebase.json") ? config.path("firebase.json") : "<NO CONFIG PRESENT>"}
2929
Active Project ID: ${
3030
projectId ? `${projectId}${aliases.length ? ` (alias: ${aliases.join(",")})` : ""}` : "<NONE>"
3131
}
@@ -38,11 +38,20 @@ ${dump(rc.projects).trim()}
3838
# Available Accounts:
3939
4040
${dump(getAllAccounts().map((account) => account.user.email)).trim()}
41-
41+
${
42+
config.projectFileExists("firebase.json")
43+
? `
4244
# firebase.json contents:
4345
4446
\`\`\`json
4547
${config.readProjectFile("firebase.json")}
46-
\`\`\``);
48+
\`\`\``
49+
: `\n# Empty Environment
50+
51+
It looks like the current directory is not initialized as a Firebase project. The user will most likely want to:
52+
53+
a) Change the project directory using the 'firebase_update_environment' tool to select a directory with a 'firebase.json' file in it, or
54+
b) Initialize a new Firebase project directory using the 'firebase_init' tool.`
55+
}`);
4756
},
4857
);

src/mcp/tools/core/update_environment.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ export const update_environment = tool(
3434
readOnlyHint: true,
3535
},
3636
_meta: {
37-
requiresAuth: true,
38-
requiresProject: true,
37+
requiresAuth: false,
38+
requiresProject: false,
3939
},
4040
},
4141
async ({ project_dir, active_project, active_user_account }, { config, rc, host }) => {
@@ -50,7 +50,7 @@ export const update_environment = tool(
5050
}
5151
if (active_user_account) {
5252
assertAccount(active_user_account, { mcp: true });
53-
setProjectAccount(host.projectRoot!, active_user_account);
53+
setProjectAccount(host.cachedProjectRoot!, active_user_account);
5454
output += `- Updated active account to '${active_user_account}'\n`;
5555
}
5656

src/mcp/tools/rules/validate_rules.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export function validateRulesTool(productName: string) {
105105
let rulesSourceContent: string;
106106
if (source_file) {
107107
try {
108-
const filePath = resolve(source_file, host.projectRoot!);
108+
const filePath = resolve(source_file, host.cachedProjectRoot!);
109109
if (filePath.includes("../"))
110110
return mcpError("Cannot read files outside of the project directory.");
111111
rulesSourceContent = config.readProjectFile(source_file);

src/mcp/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,8 @@ export const SERVER_FEATURES = [
77
"remoteconfig",
88
] as const;
99
export type ServerFeature = (typeof SERVER_FEATURES)[number];
10+
11+
export interface ClientConfig {
12+
/** The current project root directory for this client. */
13+
projectRoot?: string | null;
14+
}

0 commit comments

Comments
 (0)