Skip to content

Commit 32cd290

Browse files
authored
[MCP] Flexible directory selection and command line arguments. (#8468)
1 parent 46ac525 commit 32cd290

File tree

13 files changed

+238
-30
lines changed

13 files changed

+238
-30
lines changed

npm-shrinkwrap.json

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
"description": "Command-Line Interface for Firebase",
55
"main": "./lib/index.js",
66
"bin": {
7-
"firebase": "./lib/bin/firebase.js",
8-
"firebase-mcp": "./lib/bin/firebase-mcp.js"
7+
"firebase": "./lib/bin/firebase.js"
98
},
109
"scripts": {
1110
"build": "tsc && npm run copyfiles",

src/bin/mcp.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,37 @@
11
#!/usr/bin/env node
22

3-
import { Command } from "../command";
4-
import { requireAuth } from "../requireAuth";
5-
63
import { silenceStdout } from "../logger";
74
silenceStdout();
85

96
import { FirebaseMcpServer } from "../mcp/index";
7+
import { parseArgs } from "util";
8+
import { SERVER_FEATURES, ServerFeature } from "../mcp/types";
9+
10+
const STARTUP_MESSAGE = `
11+
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:
1012
11-
const cmd = new Command("mcp").before(requireAuth);
13+
{
14+
"mcpServers": {
15+
"firebase": {
16+
"command": "firebase",
17+
"args": ["experimental:mcp", "--dir", "/path/to/firebase/project"]
18+
}
19+
}
20+
}
21+
`;
1222

13-
export async function mcp() {
14-
const options: any = {};
15-
options.cwd = process.env.PROJECT_ROOT || process.env.CWD;
16-
await cmd.prepare(options);
17-
const server = new FirebaseMcpServer({ cliOptions: options });
23+
export async function mcp(): Promise<void> {
24+
const { values } = parseArgs({
25+
options: {
26+
only: { type: "string", default: "" },
27+
dir: { type: "string" },
28+
},
29+
allowPositionals: true,
30+
});
31+
const activeFeatures = (values.only || "")
32+
.split(",")
33+
.filter((f) => SERVER_FEATURES.includes(f as ServerFeature)) as ServerFeature[];
34+
const server = new FirebaseMcpServer({ activeFeatures, projectRoot: values.dir });
1835
await server.start();
36+
if (process.stdin.isTTY) process.stderr.write(STARTUP_MESSAGE);
1937
}

src/mcp/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Firebase MCP Server
2+
3+
The Firebase MCP Server provides assistive utilities to compatible AI-assistant
4+
development tools.
5+
6+
## Usage
7+
8+
The Firebase MCP server uses the Firebase CLI for authentication and project
9+
selection. You will usually want to start the server with a specific directory
10+
as an argument to operate against the project of your working directory.
11+
12+
For clients that don't operate within a specific workspace, the Firebase MCP
13+
server makes tools available to read and write a project directory.
14+
15+
### Example: Cursor
16+
17+
In `.cursor/mcp.json` in your workspace directory, add the Firebase MCP server:
18+
19+
```json
20+
{
21+
"mcpServers": {
22+
"firebase": {
23+
"command": "npx",
24+
"args": ["-y", "firebase-tools", "experimental:mcp", "--dir", "<your_absolute_workspace_dir>"]
25+
}
26+
}
27+
}
28+
```
29+
30+
### Command Line Options
31+
32+
- `--dir <absolute_dir_path>`: The absolute path of a directory containing `firebase.json` to set a project context for the MCP server. If unspecified, the `{get|set}_firebase_directory` tools will become available and the default directory will be the working directory where the MCP server was started.
33+
- `--only <feature1,feature2>`: A comma-separated list of feature groups to activate. Use this to limit the tools exposed to only features you are actively using.
34+
35+
## Tools
36+
37+
| Tool Name | Feature Group | Description |
38+
| ------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------- |
39+
| `get_firebase_directory` | `core` | When running without the `--dir` command, retrieves the current directory (defaults to current working directory). |
40+
| `set_firebase_directory` | `core` | When running without the `--dir` command, sets the current project directory (i.e. one with `firebase.json` in it). |
41+
| `get_project` | `project` | Get basic information about the active project in the current Firebase directory. |
42+
| `list_apps` | `project` | List registered apps for the currently active project. |
43+
| `get_sdk_config` | `project` | Get an Firebase client SDK config for a specific platform. |

src/mcp/errors.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { mcpError } from "./util";
2+
3+
export const NO_PROJECT_ERROR = mcpError(
4+
"No active project was found. Use the `set_firebase_directory` command to set the project directory (containing firebase.json).",
5+
"PRECONDITION_FAILED",
6+
);

src/mcp/index.ts

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,55 +10,104 @@ import {
1010
import { ServerFeature } from "./types.js";
1111
import { tools } from "./tools/index.js";
1212
import { ServerTool } from "./tool.js";
13+
import { configstore } from "../configstore.js";
14+
import { coreTools } from "./tools/core/index.js";
15+
import { Command } from "../command.js";
16+
import { requireAuth } from "../requireAuth.js";
17+
import { Options } from "../options.js";
18+
import { getProjectId } from "../projectUtils.js";
1319

1420
const SERVER_VERSION = "0.0.1";
21+
const PROJECT_ROOT_KEY = "mcp.projectRoot";
22+
23+
const cmd = new Command("experimental:mcp").before(requireAuth);
1524

1625
export class FirebaseMcpServer {
17-
projectId?: string;
26+
projectRoot?: string;
1827
server: Server;
1928
cliOptions: any;
2029
activeFeatures?: ServerFeature[];
30+
fixedRoot?: boolean;
2131

22-
constructor(options: { activeFeatures?: ServerFeature[]; cliOptions: any }) {
32+
constructor(options: { activeFeatures?: ServerFeature[]; projectRoot?: string }) {
2333
this.activeFeatures = options.activeFeatures;
24-
this.cliOptions = options.cliOptions;
2534
this.server = new Server({ name: "firebase", version: SERVER_VERSION });
26-
this.server.registerCapabilities({ tools: { listChanged: false } });
35+
this.server.registerCapabilities({ tools: { listChanged: true } });
2736
this.server.setRequestHandler(ListToolsRequestSchema, this.mcpListTools.bind(this));
2837
this.server.setRequestHandler(CallToolRequestSchema, this.mcpCallTool.bind(this));
38+
this.projectRoot =
39+
options.projectRoot ??
40+
(configstore.get(PROJECT_ROOT_KEY) as string) ??
41+
process.env.PROJECT_ROOT ??
42+
process.cwd();
43+
if (options.projectRoot) this.fixedRoot = true;
2944
}
3045

31-
get activeTools(): ServerTool[] {
32-
const toolDefs: ServerTool[] = [];
33-
const activeFeatures = this.activeFeatures || (Object.keys(tools) as ServerFeature[]);
46+
get availableTools(): ServerTool[] {
47+
const toolDefs: ServerTool[] = this.fixedRoot ? [] : [...coreTools];
48+
const activeFeatures = this.activeFeatures?.length
49+
? this.activeFeatures
50+
: (Object.keys(tools) as ServerFeature[]);
3451
for (const key of activeFeatures || []) {
3552
toolDefs.push(...tools[key]);
3653
}
3754
return toolDefs;
3855
}
3956

57+
async activeTools(): Promise<ServerTool[]> {
58+
const hasActiveProject = !!(await this.getProjectId());
59+
return this.availableTools.filter((t) => hasActiveProject || !t.mcp._meta?.requiresProject);
60+
}
61+
4062
getTool(name: string): ServerTool | null {
41-
return this.activeTools.find((t) => t.mcp.name === name) || null;
63+
return this.availableTools.find((t) => t.mcp.name === name) || null;
4264
}
4365

44-
mcpListTools(): Promise<ListToolsResult> {
45-
return Promise.resolve({
46-
tools: this.activeTools.map((t) => t.mcp),
66+
async mcpListTools(): Promise<ListToolsResult> {
67+
const hasActiveProject = !!(await this.getProjectId());
68+
return {
69+
tools: (await this.activeTools()).map((t) => t.mcp),
4770
_meta: {
71+
projectRoot: this.projectRoot,
72+
projectDetected: hasActiveProject,
4873
activeFeatures: this.activeFeatures,
4974
},
50-
});
75+
};
76+
}
77+
78+
setProjectRoot(newRoot: string | null): void {
79+
if (newRoot === null) {
80+
configstore.delete(PROJECT_ROOT_KEY);
81+
this.projectRoot = process.env.PROJECT_ROOT || process.cwd();
82+
void this.server.sendToolListChanged();
83+
return;
84+
}
85+
86+
configstore.set(PROJECT_ROOT_KEY, newRoot);
87+
this.projectRoot = newRoot;
88+
void this.server.sendToolListChanged();
89+
}
90+
91+
async resolveOptions(): Promise<Partial<Options>> {
92+
const options: Partial<Options> = { cwd: this.projectRoot };
93+
await cmd.prepare(options);
94+
return options;
95+
}
96+
97+
async getProjectId(): Promise<string | undefined> {
98+
return getProjectId(await this.resolveOptions());
5199
}
52100

53101
async mcpCallTool(request: CallToolRequest): Promise<CallToolResult> {
54102
const toolName = request.params.name;
55103
const toolArgs = request.params.arguments;
56104
const tool = this.getTool(toolName);
57105
if (!tool) throw new Error(`Tool '${toolName}' could not be found.`);
58-
return tool.fn(toolArgs, { projectId: this.cliOptions.project });
106+
107+
return tool.fn(toolArgs, { projectId: await this.getProjectId(), host: this });
59108
}
60109

61-
async start() {
110+
async start(): Promise<void> {
62111
const transport = new StdioServerTransport();
63112
await this.server.connect(transport);
64113
}

src/mcp/tool.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { CallToolResult } from "@modelcontextprotocol/sdk/types";
22
import { z, ZodTypeAny } from "zod";
33
import { zodToJsonSchema } from "zod-to-json-schema";
4+
import type { FirebaseMcpServer } from "./index";
5+
6+
export interface ServerToolContext {
7+
projectId?: string;
8+
host: FirebaseMcpServer;
9+
}
410

511
export interface ServerTool<InputSchema extends ZodTypeAny = ZodTypeAny> {
612
mcp: {
@@ -14,8 +20,11 @@ export interface ServerTool<InputSchema extends ZodTypeAny = ZodTypeAny> {
1420
idempotentHint?: boolean;
1521
openWorldHint?: boolean;
1622
};
23+
_meta?: {
24+
requiresProject?: boolean;
25+
};
1726
};
18-
fn: (input: z.infer<InputSchema>, ctx: { projectId?: string }) => Promise<CallToolResult>;
27+
fn: (input: z.infer<InputSchema>, ctx: ServerToolContext) => Promise<CallToolResult>;
1928
}
2029

2130
export function tool<InputSchema extends ZodTypeAny>(
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { z } from "zod";
2+
import { tool } from "../../tool.js";
3+
import { mcpError, toContent } from "../../util.js";
4+
import { detectProjectRoot } from "../../../detectProjectRoot.js";
5+
6+
export const get_firebase_directory = tool(
7+
{
8+
name: "get_firebase_directory",
9+
description:
10+
"Gets the current Firebase project directory. If this has been set using the `set_firebase_directory` tool it will return that, otherwise it will look for a PROJECT_ROOT environment variable or the current working directory of the running Firebase MCP server.",
11+
inputSchema: z.object({}),
12+
annotations: {
13+
title: "Get Firebase Project Directory",
14+
readOnlyHint: true,
15+
},
16+
},
17+
(_, { host }) => {
18+
if (!detectProjectRoot({ cwd: host.projectRoot }))
19+
return Promise.resolve(
20+
mcpError(
21+
`There is no detected 'firebase.json' in directory '${host.projectRoot}'. Please use the 'set_firebase_directory' tool to activate a Firebase project directory.`,
22+
),
23+
);
24+
return Promise.resolve(
25+
toContent(`The current Firebase project directory is '${host.projectRoot}'.`),
26+
);
27+
},
28+
);

src/mcp/tools/core/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { get_firebase_directory } from "./get_firebase_directory";
2+
import { set_firebase_directory } from "./set_firebase_directory";
3+
4+
export const coreTools = [get_firebase_directory, set_firebase_directory];
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { z } from "zod";
2+
import { tool } from "../../tool.js";
3+
import { mcpError, toContent } from "../../util.js";
4+
import { existsSync } from "fs";
5+
import { join } from "path";
6+
7+
export const set_firebase_directory = tool(
8+
{
9+
name: "set_firebase_directory",
10+
description:
11+
"Sets the project directory for the Firebase MCP server to utilize for project detection and authentication. This should be a directory with a `firebase.json` file in it. This information is persisted between sessions.",
12+
inputSchema: z.object({
13+
dir: z
14+
.string()
15+
.nullable()
16+
.describe(
17+
"the absolute path of the directory. set to null to 'unset' the value and fall back to the working directory",
18+
),
19+
}),
20+
annotations: {
21+
title: "Set Firebase Project Directory",
22+
idempotentHint: true,
23+
},
24+
},
25+
({ dir }, { host }) => {
26+
if (dir === null) {
27+
host.setProjectRoot(null);
28+
return Promise.resolve(
29+
toContent(
30+
`Firebase MCP project directory setting deleted. New project root is: ${host.projectRoot || "unset"}`,
31+
),
32+
);
33+
}
34+
35+
if (!existsSync(dir)) return Promise.resolve(mcpError(`Directory '${dir}' does not exist.`));
36+
if (!existsSync(join(dir, "firebase.json")))
37+
return Promise.resolve(
38+
mcpError(`Directory '${dir}' does not contain a 'firebase.json' file.`),
39+
);
40+
host.setProjectRoot(dir);
41+
return Promise.resolve(toContent(`Firebase MCP project directory set to '${dir}'.`));
42+
},
43+
);

0 commit comments

Comments
 (0)