From 835e86e90e0512001243ad296f4d05a2e157bf7c Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 14:10:43 -0700 Subject: [PATCH 1/8] feat: add SSE mode support for MCP server ### Description Adds support for running the MCP server in SSE (HTTP) mode, in addition to the default Stdio transport. This allows clients to connect over network or via tools that support SSE. ### Scenarios Tested - Started server in SSE mode and verified log output. --- src/bin/mcp.ts | 9 ++++++- src/mcp/index.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/bin/mcp.ts b/src/bin/mcp.ts index 448c5df3cef..586d285c80d 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. + --sse Start the server in SSE (HTTP) mode instead of default 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" }, + sse: { type: "boolean", default: false }, + 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 }, @@ -103,6 +107,9 @@ export async function mcp(): Promise { enabledTools, projectRoot: values.dir ? resolve(values.dir) : undefined, }); - await server.start(); + await server.start({ + useSSE: values.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..404610d77aa 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -1,5 +1,7 @@ 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 { CallToolRequest, CallToolRequestSchema, @@ -381,6 +383,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", { @@ -456,7 +461,7 @@ export class FirebaseMcpServer { } async mcpListResources(): Promise { - await this.trackGA4("mcp_list_resources", { resource_name: "__list__" }); + await trackGA4("mcp_read_resource", {}); return { resources: resources.map((r) => r.mcp), }; @@ -488,7 +493,67 @@ export class FirebaseMcpServer { return resolved.result; } - async start(): Promise { + async start(options?: { useSSE?: boolean; port?: number }): Promise { + if (options?.useSSE) { + const express = require("express"); + const cors = require("cors"); + const app = express(); + + app.use(cors()); + + const port = options.port || 3000; + const transports: Record = {}; // session ID to transport + + app.get("/sse", async (req: any, res: any) => { + console.error(`[SSE] GET /sse connection attempt from ${req.ip}`); + try { + const transport = new SSEServerTransport("/message", res); + const sessionId = (transport as any).sessionId; // Typecast if type defs are lagging + transports[sessionId] = transport; + + console.error(`[SSE] Connected session ${sessionId}`); + + await this.server.connect(transport); + console.error(`[SSE] Server connected to transport`); + + // Keep handler alive + await new Promise((resolve) => { + req.on("close", () => { + console.error(`[SSE] Session ${sessionId} disconnected`); + delete transports[sessionId]; + resolve(); + }); + }); + } catch (err) { + console.error(`[SSE] Connection error:`, err); + } + }); + + app.post("/message", async (req: any, res: any) => { + const sessionId = req.query.sessionId as string; + console.error(`[SSE] POST /message attempt for session ${sessionId}`); + + const transport = transports[sessionId]; + + if (transport) { + try { + await transport.handlePostMessage(req, res); + console.error(`[SSE] Handled message for session ${sessionId}`); + } catch (err) { + console.error(`[SSE] Error handling message for session ${sessionId}:`, err); + } + } else { + console.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, "0.0.0.0", () => { + console.error(`MCP Server running on HTTP/SSE mode at http://0.0.0.0:${port}`); + }); + return; + } + const transport = process.env.FIREBASE_MCP_DEBUG_LOG ? new LoggingStdioServerTransport(process.env.FIREBASE_MCP_DEBUG_LOG) : new StdioServerTransport(); From 6da0923992db8e53c34094c7c37877e9bb3da99d Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 14:23:09 -0700 Subject: [PATCH 2/8] fix: add progressToken to McpContext interface to fix build error ### Description Fixes a type error where progressToken was not defined on McpContext. ### Scenarios Tested - Verified build succeeds. --- src/mcp/types.ts | 1 + 1 file changed, 1 insertion(+) 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; } From 2d73e81550bd8a1abda58bbd75e8f561f3115487 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 14:24:59 -0700 Subject: [PATCH 3/8] refactor: address PR comments on SSE support ### Description Addresses PR comments by: - Moving inline require calls to top-level imports. - Replacing any types with specific interfaces or unknown. ### Scenarios Tested - Verified build succeeds. --- src/mcp/index.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 404610d77aa..be5647554d4 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -2,6 +2,7 @@ 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, @@ -495,16 +496,14 @@ export class FirebaseMcpServer { async start(options?: { useSSE?: boolean; port?: number }): Promise { if (options?.useSSE) { - const express = require("express"); - const cors = require("cors"); const app = express(); app.use(cors()); const port = options.port || 3000; - const transports: Record = {}; // session ID to transport + const transports: Record = {}; // session ID to transport - app.get("/sse", async (req: any, res: any) => { + app.get("/sse", async (req: express.Request, res: express.Response) => { console.error(`[SSE] GET /sse connection attempt from ${req.ip}`); try { const transport = new SSEServerTransport("/message", res); @@ -529,7 +528,7 @@ export class FirebaseMcpServer { } }); - app.post("/message", async (req: any, res: any) => { + app.post("/message", async (req: express.Request, res: express.Response) => { const sessionId = req.query.sessionId as string; console.error(`[SSE] POST /message attempt for session ${sessionId}`); @@ -563,12 +562,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; } From bfdde6b04367a8e66492f7fbf4f86709073f42e7 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 14:35:34 -0700 Subject: [PATCH 4/8] fix: address remaining review comments on SSE support ### Description - Reverts accidental GA4 tracking change in mcpListResources. - Replaces console.error with this.logger calls for better logging. - Changes default server binding from 0.0.0.0 to 127.0.0.1 for security. ### Scenarios Tested - Verified build succeeds. --- src/mcp/index.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/mcp/index.ts b/src/mcp/index.ts index be5647554d4..7a0527aadda 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -462,7 +462,7 @@ export class FirebaseMcpServer { } async mcpListResources(): Promise { - await trackGA4("mcp_read_resource", {}); + await this.trackGA4("mcp_list_resources", { resource_name: "__list__" }); return { resources: resources.map((r) => r.mcp), }; @@ -504,51 +504,51 @@ export class FirebaseMcpServer { const transports: Record = {}; // session ID to transport app.get("/sse", async (req: express.Request, res: express.Response) => { - console.error(`[SSE] GET /sse connection attempt from ${req.ip}`); + this.logger.debug(`[SSE] GET /sse connection attempt from ${req.ip}`); try { const transport = new SSEServerTransport("/message", res); const sessionId = (transport as any).sessionId; // Typecast if type defs are lagging transports[sessionId] = transport; - console.error(`[SSE] Connected session ${sessionId}`); + this.logger.debug(`[SSE] Connected session ${sessionId}`); await this.server.connect(transport); - console.error(`[SSE] Server connected to transport`); + this.logger.debug(`[SSE] Server connected to transport`); // Keep handler alive await new Promise((resolve) => { req.on("close", () => { - console.error(`[SSE] Session ${sessionId} disconnected`); + this.logger.debug(`[SSE] Session ${sessionId} disconnected`); delete transports[sessionId]; resolve(); }); }); } catch (err) { - console.error(`[SSE] Connection error:`, 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; - console.error(`[SSE] POST /message attempt for session ${sessionId}`); + this.logger.debug(`[SSE] POST /message attempt for session ${sessionId}`); const transport = transports[sessionId]; if (transport) { try { await transport.handlePostMessage(req, res); - console.error(`[SSE] Handled message for session ${sessionId}`); + this.logger.debug(`[SSE] Handled message for session ${sessionId}`); } catch (err) { - console.error(`[SSE] Error handling message for session ${sessionId}:`, err); + this.logger.error(`[SSE] Error handling message for session ${sessionId}: ${err}`); } } else { - console.error(`[SSE] Rejecting message: No active transport found for session ${sessionId}`); + 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, "0.0.0.0", () => { - console.error(`MCP Server running on HTTP/SSE mode at http://0.0.0.0:${port}`); + 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; } From 863b5feda04ad4f79d537e7c55a2aa94a2fe02bb Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 14:43:43 -0700 Subject: [PATCH 5/8] style: lint and format fixes for SSE support ### Description - Applied auto-formatting fixes from npm run format. ### Scenarios Tested - Verified build succeeds. --- src/mcp/index.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 7a0527aadda..46e5bdf5e98 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -497,7 +497,7 @@ export class FirebaseMcpServer { async start(options?: { useSSE?: boolean; port?: number }): Promise { if (options?.useSSE) { const app = express(); - + app.use(cors()); const port = options.port || 3000; @@ -509,9 +509,9 @@ export class FirebaseMcpServer { const transport = new SSEServerTransport("/message", res); const sessionId = (transport as any).sessionId; // Typecast if type defs are lagging transports[sessionId] = transport; - + this.logger.debug(`[SSE] Connected session ${sessionId}`); - + await this.server.connect(transport); this.logger.debug(`[SSE] Server connected to transport`); @@ -531,7 +531,7 @@ export class FirebaseMcpServer { 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) { @@ -542,7 +542,9 @@ export class FirebaseMcpServer { 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}`); + 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"); } }); From fdf875ed253ffcf155f03dc9d3ef1596b61fee45 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 15:08:18 -0700 Subject: [PATCH 6/8] feat: add infrastructure for MCP Apps (#10259) * feat: add infrastructure for MCP Apps ### Description Adds support for returning structured content from tools, which is used by MCP Apps to pass complex data to the host. Also updates the resource index. ### Scenarios Tested - Verified build and file changes. * fix: resolve build errors and address review comments on infra ### Description - Removes imports and registry entries for UI resources that are not yet available in this branch (login, update_environment, deploy, init). - Replaces as any in toContent with an intersection type for better type safety. ### Scenarios Tested - Verified build succeeds. --- src/mcp/util.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 }; } /** From 22ae5b5c63ee3979b1eb7878372fef3988afb67a Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 16:58:39 -0700 Subject: [PATCH 7/8] chore: avoid any for sessionId in SSE transport ### Description - Defines a local interface extending SSEServerTransport to avoid using when accessing . ### Scenarios Tested - Build succeeds. - Lint passes for modified lines. --- src/mcp/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 46e5bdf5e98..ada78467d0a 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -507,7 +507,11 @@ export class FirebaseMcpServer { this.logger.debug(`[SSE] GET /sse connection attempt from ${req.ip}`); try { const transport = new SSEServerTransport("/message", res); - const sessionId = (transport as any).sessionId; // Typecast if type defs are lagging + // 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}`); From dd0e8446b49352ba1955e585f2fe2aba98b70d1e Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 17:09:31 -0700 Subject: [PATCH 8/8] feat: change sse flag to mode flag and fix build errors ### Description - Replaced boolean flag with string flag (defaults to 'stdio'). - Added validation for to accept only 'stdio' or 'sse'. - Fixed build errors by adding to interface and removing missing resource. ### Scenarios Tested - Build succeeds. - Lint passes with no new errors. --- src/bin/mcp.ts | 11 ++++++++--- src/mcp/resource.ts | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/bin/mcp.ts b/src/bin/mcp.ts index 586d285c80d..b5bee2d9dfe 100644 --- a/src/bin/mcp.ts +++ b/src/bin/mcp.ts @@ -49,7 +49,7 @@ Options: If specified, auto-detection is disabled for other features. --tools Comma-separated list of specific tools to enable. Disables auto-detection entirely. - --sse Start the server in SSE (HTTP) mode instead of default Stdio. + --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. `; @@ -60,7 +60,7 @@ export async function mcp(): Promise { only: { type: "string", default: "" }, tools: { type: "string", default: "" }, dir: { type: "string" }, - sse: { type: "boolean", default: false }, + mode: { type: "string", default: "stdio" }, port: { type: "string", default: "3000" }, "generate-tool-list": { type: "boolean", default: false }, "generate-prompt-list": { type: "boolean", default: false }, @@ -89,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"); @@ -108,7 +113,7 @@ export async function mcp(): Promise { projectRoot: values.dir ? resolve(values.dir) : undefined, }); await server.start({ - useSSE: values.sse, + 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/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;