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
14 changes: 13 additions & 1 deletion src/bin/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,19 @@
If specified, auto-detection is disabled for other features.
--tools <tools> Comma-separated list of specific tools to enable. Disables
auto-detection entirely.
--mode <mode> Server mode: stdio, sse (defaults to stdio).
--port <port> The port to listen on when running in SSE mode (defaults to 3000).
-h, --help Show this help message.
`;

export async function mcp(): Promise<void> {

Check warning on line 57 in src/bin/mcp.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
const { values } = parseArgs({
options: {
only: { type: "string", default: "" },
tools: { type: "string", default: "" },
dir: { type: "string" },
mode: { type: "string", default: "stdio" },
port: { type: "string", default: "3000" },
"generate-tool-list": { type: "boolean", default: false },
"generate-prompt-list": { type: "boolean", default: false },
"generate-resource-list": { type: "boolean", default: false },
Expand Down Expand Up @@ -85,6 +89,11 @@
}
if (earlyExit) return;

if (values.mode !== "stdio" && values.mode !== "sse") {
console.error("Error: --mode must be either 'stdio' or 'sse'");
process.exit(1);
}

setFirebaseMcp(true);
// Write debug logs to ~/.cache/firebase to avoid polluting the user's project directory.
const mcpLogDir = join(homedir(), ".cache", "firebase");
Expand All @@ -103,6 +112,9 @@
enabledTools,
projectRoot: values.dir ? resolve(values.dir) : undefined,
});
await server.start();
await server.start({
useSSE: values.mode === "sse",
port: values.port ? parseInt(values.port, 10) : undefined,
});
if (process.stdin.isTTY) process.stderr.write(STARTUP_MESSAGE);
}
76 changes: 73 additions & 3 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";
import cors from "cors";
import {
CallToolRequest,
CallToolRequestSchema,
Expand Down Expand Up @@ -154,7 +157,7 @@
}

/** Wait until initialization has finished. */
ready() {

Check warning on line 160 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 @@ -165,19 +168,19 @@
return this.clientInfo?.name ?? (isFirebaseStudio() ? "Firebase Studio" : "<unknown-client>");
}

private get clientConfigKey() {

Check warning on line 171 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()}`;
}

getStoredClientConfig(): ClientConfig {
return configstore.get(this.clientConfigKey) || {};

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe return of an `any` typed value
}

updateStoredClientConfig(update: Partial<ClientConfig>) {

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

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
const config = configstore.get(this.clientConfigKey) || {};

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
const newConfig = { ...config, ...update };

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
configstore.set(this.clientConfigKey, newConfig);
return newConfig;

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

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe return of an `any` typed value
}

async detectProjectSetup(): Promise<void> {
Expand All @@ -192,13 +195,13 @@
if (this.cachedProjectDir) return this.cachedProjectDir;
const storedRoot = this.getStoredClientConfig().projectRoot;
this.cachedProjectDir = storedRoot || this.startupRoot || process.cwd();
this.logger.debug(`detected and cached project root: ${this.cachedProjectDir}`);

Check warning on line 198 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
return this.cachedProjectDir;
}

async detectActiveFeatures(): Promise<ServerFeature[]> {
if (this.detectedFeatures?.length) return this.detectedFeatures; // memoized
this.logger.debug("detecting active features of Firebase MCP server...");

Check warning on line 204 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
const projectId = (await this.getProjectId()) || "";
const accountEmail = await this.getAuthenticatedUser();
const isBillingEnabled = projectId ? await this.safeCheckBillingEnabled(projectId) : false;
Expand Down Expand Up @@ -381,6 +384,9 @@

const isBillingEnabled = projectId ? await this.safeCheckBillingEnabled(projectId) : false;
const toolsCtx = this._createMcpContext(projectId, accountEmail, isBillingEnabled);
if (request.params._meta?.progressToken) {
toolsCtx.progressToken = request.params._meta.progressToken;
}
try {
const res = await tool.fn(toolArgs, toolsCtx);
await this.trackGA4("mcp_tool_call", {
Expand Down Expand Up @@ -488,7 +494,71 @@
return resolved.result;
}

async start(): Promise<void> {
async start(options?: { useSSE?: boolean; port?: number }): Promise<void> {
if (options?.useSSE) {
const app = express();

app.use(cors());

const port = options.port || 3000;
const transports: Record<string, SSEServerTransport> = {}; // session ID to transport

app.get("/sse", async (req: express.Request, res: express.Response) => {
this.logger.debug(`[SSE] GET /sse connection attempt from ${req.ip}`);
try {
const transport = new SSEServerTransport("/message", res);
// SSEServerTransport has sessionId but it might not be in the typings
interface SSEServerTransportWithSessionId extends SSEServerTransport {
sessionId: string;
}
const sessionId = (transport as SSEServerTransportWithSessionId).sessionId;
transports[sessionId] = transport;

this.logger.debug(`[SSE] Connected session ${sessionId}`);

await this.server.connect(transport);
this.logger.debug(`[SSE] Server connected to transport`);

// Keep handler alive
await new Promise<void>((resolve) => {
req.on("close", () => {
this.logger.debug(`[SSE] Session ${sessionId} disconnected`);
delete transports[sessionId];
resolve();
});
});
} catch (err) {
this.logger.error(`[SSE] Connection error: ${err}`);
}
});

app.post("/message", async (req: express.Request, res: express.Response) => {
const sessionId = req.query.sessionId as string;
this.logger.debug(`[SSE] POST /message attempt for session ${sessionId}`);

const transport = transports[sessionId];

if (transport) {
try {
await transport.handlePostMessage(req, res);
this.logger.debug(`[SSE] Handled message for session ${sessionId}`);
} catch (err) {
this.logger.error(`[SSE] Error handling message for session ${sessionId}: ${err}`);
}
} else {
this.logger.error(
`[SSE] Rejecting message: No active transport found for session ${sessionId}`,
);
res.status(400).send("No active SSE transport connection found for this session");
}
});

app.listen(port, "127.0.0.1", () => {
this.logger.info(`MCP Server running on HTTP/SSE mode at http://127.0.0.1:${port}`);
});
return;
}

const transport = process.env.FIREBASE_MCP_DEBUG_LOG
? new LoggingStdioServerTransport(process.env.FIREBASE_MCP_DEBUG_LOG)
: new StdioServerTransport();
Expand All @@ -498,12 +568,12 @@
private async safeCheckBillingEnabled(projectId: string): Promise<boolean> {
try {
return await checkBillingEnabled(projectId);
} catch (e: any) {
} catch (e: unknown) {
this.logger.debug(
"[mcp] Error on billingInfo for " +
projectId +
", failing open (assuming false): " +
(e.message || e),
(e instanceof Error ? e.message : String(e)),
);
return false;
}
Expand Down
1 change: 1 addition & 0 deletions src/mcp/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface ServerResource {
name: string;
description?: string;
title?: string;
mimeType?: string;
_meta?: {
/** Set this on a resource if it *always* requires a signed-in user to work. */
requiresAuth?: boolean;
Expand Down
1 change: 1 addition & 0 deletions src/mcp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@ export interface McpContext {
rc: RC;
firebaseCliCommand: string;
isBillingEnabled: boolean;
progressToken?: string | number;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is progressToken? Is it a common concept for MCP server? If not, some comment here can be helpful

}
3 changes: 2 additions & 1 deletion src/mcp/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export function toContent(
const suffix = options?.contentSuffix || "";
return {
content: [{ type: "text", text: `${prefix}${text}${suffix}` }],
};
structuredContent: data,
} as CallToolResult & { structuredContent: any };
}

/**
Expand Down
Loading