diff --git a/src/bin/mcp.ts b/src/bin/mcp.ts index 448c5df3cef..b5bee2d9dfe 100644 --- a/src/bin/mcp.ts +++ b/src/bin/mcp.ts @@ -49,6 +49,8 @@ Options: If specified, auto-detection is disabled for other features. --tools Comma-separated list of specific tools to enable. Disables auto-detection entirely. + --mode Server mode: stdio, sse (defaults to stdio). + --port The port to listen on when running in SSE mode (defaults to 3000). -h, --help Show this help message. `; @@ -58,6 +60,8 @@ export async function mcp(): Promise { 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 }, @@ -85,6 +89,11 @@ export async function mcp(): Promise { } 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"); @@ -103,6 +112,9 @@ export async function mcp(): Promise { 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); } diff --git a/src/mcp/index.ts b/src/mcp/index.ts index c9aac8b48f5..ada78467d0a 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -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, @@ -381,6 +384,9 @@ export class FirebaseMcpServer { 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", { @@ -488,7 +494,71 @@ export class FirebaseMcpServer { return resolved.result; } - async start(): Promise { + async start(options?: { useSSE?: boolean; port?: number }): Promise { + if (options?.useSSE) { + const app = express(); + + app.use(cors()); + + const port = options.port || 3000; + const transports: Record = {}; // 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((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(); @@ -498,12 +568,12 @@ export class FirebaseMcpServer { private async safeCheckBillingEnabled(projectId: string): Promise { 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; } diff --git a/src/mcp/resource.ts b/src/mcp/resource.ts index c58ce85c578..a2d44313fcf 100644 --- a/src/mcp/resource.ts +++ b/src/mcp/resource.ts @@ -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; diff --git a/src/mcp/types.ts b/src/mcp/types.ts index ad5e4f86f69..79c8996edfd 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -32,4 +32,5 @@ export interface McpContext { rc: RC; firebaseCliCommand: string; isBillingEnabled: boolean; + progressToken?: string | number; } diff --git a/src/mcp/util.ts b/src/mcp/util.ts index af63b1d337c..4d7afc2f1d9 100644 --- a/src/mcp/util.ts +++ b/src/mcp/util.ts @@ -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 }; } /**