From c80407334bf63266901b26c28c3e56d8ffa60812 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 15:28:03 -0700 Subject: [PATCH 01/13] feat: add mcpapps experiment flag and helper ### Description - Adds mcpapps experiment flag to src/experiments.ts. - Adds applyAppMeta helper function to src/mcp/util.ts to conditionally add UI metadata. - Adds unit tests for applyAppMeta in src/mcp/util.spec.ts. ### Scenarios Tested - Unit tests passed. - Build succeeds. --- src/experiments.ts | 6 ++++++ src/mcp/util.spec.ts | 34 +++++++++++++++++++++++++++++++++- src/mcp/util.ts | 21 +++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/experiments.ts b/src/experiments.ts index 909a9a89425..e68e07b8754 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -24,6 +24,12 @@ export const ALL_EXPERIMENTS = experiments({ shortDescription: "enables the experiments family of commands", }, + mcpapps: { + shortDescription: "Enables MCP Apps features", + fullDescription: "Enables MCP Apps features, including returning UI resource URIs.", + public: true, + }, + // Realtime Database experiments rtdbrules: { shortDescription: "Advanced security rules management", diff --git a/src/mcp/util.spec.ts b/src/mcp/util.spec.ts index 1344c950725..2597404cc9f 100644 --- a/src/mcp/util.spec.ts +++ b/src/mcp/util.spec.ts @@ -1,5 +1,7 @@ import { expect } from "chai"; -import { cleanSchema } from "./util"; +import * as sinon from "sinon"; +import * as experiments from "../experiments"; +import { cleanSchema, applyAppMeta } from "./util"; interface TestCase { desc: string; @@ -474,3 +476,33 @@ describe("cleanSchema", () => { }); }); }); + +describe("applyAppMeta", () => { + let experimentsStub: sinon.SinonStub; + + beforeEach(() => { + experimentsStub = sinon.stub(experiments, "isEnabled"); + }); + + afterEach(() => { + experimentsStub.restore(); + }); + + it("should add _meta if mcpapps experiment is enabled", () => { + experimentsStub.withArgs("mcpapps").returns(true); + const result = { content: [{ type: "text", text: "hello" }] } as any; + const uri = "ui://test"; + const expected = { + content: [{ type: "text", text: "hello" }], + _meta: { ui: { resourceUri: uri } }, + }; + expect(applyAppMeta(result, uri)).to.deep.equal(expected); + }); + + it("should NOT add _meta if mcpapps experiment is disabled", () => { + experimentsStub.withArgs("mcpapps").returns(false); + const result = { content: [{ type: "text", text: "hello" }] } as any; + const uri = "ui://test"; + expect(applyAppMeta(result, uri)).to.deep.equal(result); + }); +}); diff --git a/src/mcp/util.ts b/src/mcp/util.ts index 4d7afc2f1d9..e859f3fe1f0 100644 --- a/src/mcp/util.ts +++ b/src/mcp/util.ts @@ -1,5 +1,6 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { dump } from "js-yaml"; +import * as experiments from "../experiments"; import { ServerFeature } from "./types"; import { apphostingOrigin, @@ -45,6 +46,26 @@ export function toContent( } as CallToolResult & { structuredContent: any }; } +/** + * Conditionally adds MCP App metadata (_meta.ui.resourceUri) to a CallToolResult. + */ +export function applyAppMeta( + result: CallToolResult, + resourceUri: string, +): CallToolResult & { _meta?: { ui?: { resourceUri: string } } } { + if (experiments.isEnabled("mcpapps")) { + return { + ...result, + _meta: { + ui: { + resourceUri, + }, + }, + }; + } + return result; +} + /** * Returns an error message to the user. */ From 6c30145649541a089672255605bc9afc9e5f1418 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 16:41:22 -0700 Subject: [PATCH 02/13] chore: address PR comments on experiments and util ### Description - Fixes applyAppMeta to preserve existing metadata. - Moves mcpapps flag to be grouped with other MCP experiments. - Removes as any in util.spec.ts by importing CallToolResult. ### Scenarios Tested - Build succeeds. - Lint passes for modified files (ignoring pre-existing warnings). - Unit tests for applyAppMeta pass. --- src/experiments.ts | 11 +++++------ src/mcp/util.spec.ts | 5 +++-- src/mcp/util.ts | 1 + 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/experiments.ts b/src/experiments.ts index e68e07b8754..c4bd11b44db 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -24,12 +24,6 @@ export const ALL_EXPERIMENTS = experiments({ shortDescription: "enables the experiments family of commands", }, - mcpapps: { - shortDescription: "Enables MCP Apps features", - fullDescription: "Enables MCP Apps features, including returning UI resource URIs.", - public: true, - }, - // Realtime Database experiments rtdbrules: { shortDescription: "Advanced security rules management", @@ -185,6 +179,11 @@ export const ALL_EXPERIMENTS = experiments({ default: false, public: true, }, + mcpapps: { + shortDescription: "Enables MCP Apps features", + fullDescription: "Enables MCP Apps features, including returning UI resource URIs.", + public: true, + }, fdcift: { shortDescription: "Enable instrumentless trial for Data Connect", default: true, diff --git a/src/mcp/util.spec.ts b/src/mcp/util.spec.ts index 2597404cc9f..14ba93604c5 100644 --- a/src/mcp/util.spec.ts +++ b/src/mcp/util.spec.ts @@ -1,5 +1,6 @@ import { expect } from "chai"; import * as sinon from "sinon"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import * as experiments from "../experiments"; import { cleanSchema, applyAppMeta } from "./util"; @@ -490,7 +491,7 @@ describe("applyAppMeta", () => { it("should add _meta if mcpapps experiment is enabled", () => { experimentsStub.withArgs("mcpapps").returns(true); - const result = { content: [{ type: "text", text: "hello" }] } as any; + const result: CallToolResult = { content: [{ type: "text", text: "hello" }] }; const uri = "ui://test"; const expected = { content: [{ type: "text", text: "hello" }], @@ -501,7 +502,7 @@ describe("applyAppMeta", () => { it("should NOT add _meta if mcpapps experiment is disabled", () => { experimentsStub.withArgs("mcpapps").returns(false); - const result = { content: [{ type: "text", text: "hello" }] } as any; + const result: CallToolResult = { content: [{ type: "text", text: "hello" }] }; const uri = "ui://test"; expect(applyAppMeta(result, uri)).to.deep.equal(result); }); diff --git a/src/mcp/util.ts b/src/mcp/util.ts index e859f3fe1f0..2ed4fd06859 100644 --- a/src/mcp/util.ts +++ b/src/mcp/util.ts @@ -57,6 +57,7 @@ export function applyAppMeta( return { ...result, _meta: { + ...result._meta, ui: { resourceUri, }, From 7050ac618d57c9f013c5331fb2301c28d9dc25ec Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 14:10:57 -0700 Subject: [PATCH 03/13] feat: add infrastructure for MCP Apps 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. - Verified build and file changes. --- src/mcp/resources/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/mcp/resources/index.ts b/src/mcp/resources/index.ts index 5a007ceafd2..a9fa9047eff 100644 --- a/src/mcp/resources/index.ts +++ b/src/mcp/resources/index.ts @@ -13,6 +13,10 @@ import { ServerResource, ServerResourceTemplate } from "../resource"; import { trackGA4 } from "../../track"; import { crashlytics_issues } from "./guides/crashlytics_issues"; import { crashlytics_reports } from "./guides/crashlytics_reports"; +import { login_ui } from "./login_ui"; +import { update_environment_ui } from "./update_environment_ui"; +import { deploy_ui } from "./deploy_ui"; +import { init_ui } from "./init_ui"; export const resources = [ app_id, @@ -25,6 +29,10 @@ export const resources = [ init_firestore_rules, init_auth, init_hosting, + login_ui, + update_environment_ui, + deploy_ui, + init_ui, ]; export const resourceTemplates = [docs]; From a5b2a79f828a7c74af213067bfb8c6b0462ca68e Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 14:11:31 -0700 Subject: [PATCH 04/13] feat: add Update Environment MCP App ### Description Adds the Update Environment MCP App, allowing users to switch projects and directories from the UI. ### Scenarios Tested - Verified build and file changes. --- src/mcp/apps/update_environment/mcp-app.html | 299 +++++++++++++++++++ src/mcp/apps/update_environment/mcp-app.ts | 150 ++++++++++ src/mcp/resources/update_environment_ui.ts | 33 ++ 3 files changed, 482 insertions(+) create mode 100644 src/mcp/apps/update_environment/mcp-app.html create mode 100644 src/mcp/apps/update_environment/mcp-app.ts create mode 100644 src/mcp/resources/update_environment_ui.ts diff --git a/src/mcp/apps/update_environment/mcp-app.html b/src/mcp/apps/update_environment/mcp-app.html new file mode 100644 index 00000000000..d09722e1f8b --- /dev/null +++ b/src/mcp/apps/update_environment/mcp-app.html @@ -0,0 +1,299 @@ + + + + + + Update Firebase Environment + + + + + + +
+
+

Choose a Firebase Project

+

Select an active Firebase project for your workspace.

+
+ +
+

Current Context

+

Project ID: -

+

User: -

+
+ + + + + +
+ +
+
+
+ + + diff --git a/src/mcp/apps/update_environment/mcp-app.ts b/src/mcp/apps/update_environment/mcp-app.ts new file mode 100644 index 00000000000..48e6e4483c9 --- /dev/null +++ b/src/mcp/apps/update_environment/mcp-app.ts @@ -0,0 +1,150 @@ +import { App, applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps"; + +const app = new App({ name: "Update Firebase Environment", version: "1.0.0" }); + +const projectListContainer = document.getElementById("project-list") as HTMLDivElement; +const searchInput = document.getElementById("search-input") as HTMLInputElement; +const submitBtn = document.getElementById("submit-btn") as HTMLButtonElement; +const statusBox = document.getElementById("status-box") as HTMLDivElement; + +let projects: any[] = []; +let filteredProjects: any[] = []; +let selectedProjectId: string | null = null; + +const envProjectIdEl = document.getElementById("env-project-id") as HTMLSpanElement; +const envUserEl = document.getElementById("env-user") as HTMLSpanElement; +const currentEnvBox = document.getElementById("current-env") as HTMLDivElement; + +function showStatus(message: string, type: "success" | "error" | "info") { + statusBox.textContent = message; + statusBox.className = `status ${type}`; + statusBox.style.display = "block"; +} + +function renderProjects() { + projectListContainer.innerHTML = ""; + + if (filteredProjects.length === 0) { + projectListContainer.innerHTML = ` + + `; + return; + } + + filteredProjects.forEach((p) => { + const item = document.createElement("div"); + item.className = "dropdown-item"; + if (p.projectId === selectedProjectId) { + item.classList.add("selected"); + } + + const displayName = p.displayName || p.projectId; + const projectId = p.projectId; + + item.innerHTML = ` +
${displayName}
+
${projectId}
+ `; + + item.onclick = () => { + selectedProjectId = projectId; + submitBtn.disabled = false; + renderProjects(); // Re-render to show selection + }; + + projectListContainer.appendChild(item); + }); +} + +searchInput.oninput = () => { + const query = searchInput.value.toLowerCase().trim(); + if (query === "") { + filteredProjects = projects; + } else { + filteredProjects = projects.filter((p) => { + const name = (p.displayName || p.projectId).toLowerCase(); + const id = p.projectId.toLowerCase(); + return name.includes(query) || id.includes(query); + }); + } + renderProjects(); +}; + +submitBtn.onclick = async () => { + if (!selectedProjectId) return; + + submitBtn.disabled = true; + showStatus(`Updating active project to ${selectedProjectId}...`, "info"); + + try { + const result = await app.callServerTool({ + name: "firebase_update_environment", + arguments: { active_project: selectedProjectId }, + }); + + const textContent = result.content?.find((c: any) => c.type === "text"); + const text = textContent ? (textContent as any).text : "Update complete."; + + if (result.isError) { + showStatus(text, "error"); + submitBtn.disabled = false; + } else { + showStatus(text, "success"); + } + } catch (err: any) { + showStatus(`Error updating environment: ${err.message}`, "error"); + submitBtn.disabled = false; + } +}; + +app.ontoolresult = (result) => { + // We can handle tool results if needed, but we rely on manual triggers for list_projects +}; + +app.onhostcontextchanged = (ctx) => { + if (ctx.theme) applyDocumentTheme(ctx.theme); + if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables); + if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts); + if (ctx.safeAreaInsets) { + const { top, right, bottom, left } = ctx.safeAreaInsets; + document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`; + } +}; + +(async () => { + try { + await app.connect(); + showStatus("Connecting to server...", "info"); + + // Fetch current environment + try { + const envResult = await app.callServerTool({ name: "firebase_get_environment", arguments: {} }); + const envData = envResult.structuredContent as any; + if (envData) { + envProjectIdEl.textContent = envData.projectId || ""; + envUserEl.textContent = envData.authenticatedUser || ""; + } + } catch (err: any) { + console.error("Failed to fetch environment:", err); + showStatus(`Failed to fetch environment: ${err.message}`, "error"); + } + + // Fetch projects on load + const result = await app.callServerTool({ name: "firebase_list_projects", arguments: {} }); + const data = result.structuredContent as any; + + if (data && data.projects) { + projects = data.projects; + filteredProjects = projects; + renderProjects(); + showStatus("Projects loaded successfully.", "success"); + setTimeout(() => { if (statusBox.className === "status success") statusBox.style.display = "none"; }, 3000); + } else { + showStatus("No projects returned from server.", "error"); + } + } catch (err: any) { + showStatus(`Failed to load projects: ${err.message}`, "error"); + } +})(); diff --git a/src/mcp/resources/update_environment_ui.ts b/src/mcp/resources/update_environment_ui.ts new file mode 100644 index 00000000000..1b7740eb7e1 --- /dev/null +++ b/src/mcp/resources/update_environment_ui.ts @@ -0,0 +1,33 @@ +import { resource } from "../resource"; +import { McpContext } from "../types"; +import * as path from "path"; +import * as fs from "fs/promises"; + +export const RESOURCE_MIME_TYPE = "application/vnd.mcp.ext-app+html"; +const resourceUri = "ui://core/update_environment/mcp-app.html"; + +export const update_environment_ui = resource( + { + uri: resourceUri, + name: "Update Environment UI", + description: "Visual interface for selecting active Firebase project", + mimeType: RESOURCE_MIME_TYPE, + }, + async (uri: string, ctx: McpContext) => { + try { + const htmlPath = path.join(__dirname, "../apps/update_environment/mcp-app.html"); + const html = await fs.readFile(htmlPath, "utf-8"); + return { + contents: [ + { + uri: resourceUri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }, + ], + }; + } catch (e: any) { + throw new Error(`Failed to load Update Environment UI: ${e.message}`); + } + }, +); From 69d436871135d8e6759d07ab88565803de578fb1 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 14:11:57 -0700 Subject: [PATCH 05/13] feat: add Init MCP App ### Description Adds the Init MCP App for interactive initialization of Firestore and Auth products. Includes support for Google Sign-In configuration and project selection. ### Scenarios Tested - Verified build and file changes. --- package.json | 3 +- src/mcp/apps/init/mcp-app.html | 341 +++++++++++++++++++++++++++++++ src/mcp/apps/init/mcp-app.ts | 231 +++++++++++++++++++++ src/mcp/apps/init/vite.config.ts | 15 ++ src/mcp/resources/init_ui.ts | 34 +++ 5 files changed, 623 insertions(+), 1 deletion(-) create mode 100644 src/mcp/apps/init/mcp-app.html create mode 100644 src/mcp/apps/init/mcp-app.ts create mode 100644 src/mcp/apps/init/vite.config.ts create mode 100644 src/mcp/resources/init_ui.ts diff --git a/package.json b/package.json index a111f25b4c6..cf8796f17ad 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "firebase": "./lib/bin/firebase.js" }, "scripts": { - "build": "tsc && npm run copyfiles", + "build:mcp-apps": "vite build --config src/mcp/apps/update_environment/vite.config.ts && vite build --config src/mcp/apps/init/vite.config.ts", + "build": "npm run build:mcp-apps && tsc && npm run copyfiles", "build:publish": "tsc --build tsconfig.publish.json && npm run copyfiles", "build:watch": "npm run build && tsc --watch", "clean": "node -e \"fs.rmSync('lib', { recursive: true, force: true }); fs.rmSync('dev', { recursive: true, force: true });\"", diff --git a/src/mcp/apps/init/mcp-app.html b/src/mcp/apps/init/mcp-app.html new file mode 100644 index 00000000000..e477aae0e0c --- /dev/null +++ b/src/mcp/apps/init/mcp-app.html @@ -0,0 +1,341 @@ + + + + + + Firebase Init + + + +
+
+

Initialize Firebase Product

+

Choose a product to initialize in your workspace.

+
+ Directory: Loading... +
+
+ + + + + + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + +
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+ +
+
+ + + diff --git a/src/mcp/apps/init/mcp-app.ts b/src/mcp/apps/init/mcp-app.ts new file mode 100644 index 00000000000..d5f9907f6ca --- /dev/null +++ b/src/mcp/apps/init/mcp-app.ts @@ -0,0 +1,231 @@ +import { App, applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps"; + +const app = new App({ name: "firebase-init", version: "1.0.0" }); + +const initBtn = document.getElementById("init-btn") as HTMLButtonElement; +const statusBox = document.getElementById("status-box") as HTMLDivElement; +const productRadios = document.getElementsByName("product") as NodeListOf; +const firestoreSection = document.getElementById("firestore-section") as HTMLDivElement; +const authSection = document.getElementById("auth-section") as HTMLDivElement; + +const googleCheckbox = document.getElementById("auth-google") as HTMLInputElement; +const googleFields = document.getElementById("google-fields") as HTMLDivElement; + +const searchInput = document.getElementById("search-input") as HTMLInputElement; +const projectListContainer = document.getElementById("project-list") as HTMLDivElement; + +let projects: any[] = []; +let filteredProjects: any[] = []; +let selectedProjectId: string | null = null; + +function setStatus(message: string, type: "info" | "success" | "error" = "info") { + statusBox.className = `status ${type}`; + statusBox.textContent = message; + statusBox.style.display = "block"; +} + +function renderProjects() { + projectListContainer.innerHTML = ""; + + if (filteredProjects.length === 0) { + const empty = document.createElement("div"); + empty.className = "dropdown-item"; + empty.style.cursor = "default"; + empty.innerHTML = `
No projects found
`; + projectListContainer.appendChild(empty); + return; + } + + filteredProjects.forEach((project) => { + const item = document.createElement("div"); + item.className = "dropdown-item"; + if (project.projectId === selectedProjectId) { + item.classList.add("selected"); + } + + const displayName = project.displayName || project.projectId; + const projectId = project.projectId; + + item.innerHTML = ` +
${displayName}
+
${projectId}
+ `; + + item.onclick = () => { + selectedProjectId = projectId; + initBtn.disabled = false; // Enable init button when project is selected + renderProjects(); // Re-render to show selection + }; + + projectListContainer.appendChild(item); + }); +} + +searchInput.oninput = () => { + const query = searchInput.value.toLowerCase().trim(); + if (query === "") { + filteredProjects = projects; + } else { + filteredProjects = projects.filter((p) => { + const name = (p.displayName || p.projectId).toLowerCase(); + const id = p.projectId.toLowerCase(); + return name.includes(query) || id.includes(query); + }); + } + renderProjects(); +}; + +// Handle product switching +productRadios.forEach((radio) => { + radio.addEventListener("change", (e) => { + const target = e.target as HTMLInputElement; + if (target.checked) { + if (target.value === "firestore") { + firestoreSection.classList.add("active"); + authSection.classList.remove("active"); + } else if (target.value === "auth") { + authSection.classList.add("active"); + firestoreSection.classList.remove("active"); + } + } + }); +}); + +// Handle Google Sign-In toggle +googleCheckbox.addEventListener("change", (e) => { + const target = e.target as HTMLInputElement; + if (target.checked) { + googleFields.classList.add("active"); + } else { + googleFields.classList.remove("active"); + } +}); + +initBtn.addEventListener("click", async () => { + const selectedProduct = Array.from(productRadios).find((r) => r.checked)?.value; + + if (!selectedProjectId) { + setStatus("Please select a project first.", "error"); + return; + } + + initBtn.disabled = true; + initBtn.textContent = "Initializing..."; + setStatus("Setting active project...", "info"); + + try { + // 1. Set active project + const updateResult = await app.callServerTool({ + name: "firebase_update_environment", + arguments: { active_project: selectedProjectId }, + }); + + if (updateResult.isError) { + setStatus(`Failed to set active project: ${JSON.stringify(updateResult.content)}`, "error"); + initBtn.disabled = false; + initBtn.textContent = "Initialize"; + return; + } + + setStatus("Initializing product...", "info"); + + // 2. Call init + const args: any = { features: {} }; + + if (selectedProduct === "firestore") { + const dbId = (document.getElementById("firestore-db-id") as HTMLInputElement).value; + const rulesFile = (document.getElementById("firestore-rules-file") as HTMLInputElement).value; + + args.features.firestore = { + database_id: dbId, + rules_filename: rulesFile, + }; + } else if (selectedProduct === "auth") { + const emailEnabled = (document.getElementById("auth-email") as HTMLInputElement).checked; + const anonymousEnabled = (document.getElementById("auth-anonymous") as HTMLInputElement).checked; + const googleEnabled = googleCheckbox.checked; + + args.features.auth = { + providers: { + emailPassword: emailEnabled, + anonymous: anonymousEnabled, + } + }; + + if (googleEnabled) { + const displayName = (document.getElementById("google-display-name") as HTMLInputElement).value; + const supportEmail = (document.getElementById("google-support-email") as HTMLInputElement).value; + args.features.auth.providers.googleSignIn = { + oAuthBrandDisplayName: displayName, + supportEmail: supportEmail, + }; + } + } + + const res = await app.callServerTool({ + name: "firebase_init", + arguments: args, + }); + + if (res.isError) { + setStatus(`Failed to initialize: ${JSON.stringify(res.content)}`, "error"); + } else { + setStatus(`Successfully initialized ${selectedProduct}!`, "success"); + } + } catch (err: any) { + setStatus(`Error: ${err.message}`, "error"); + } finally { + initBtn.disabled = false; + initBtn.textContent = "Initialize"; + } +}); + +app.onhostcontextchanged = (ctx) => { + if (ctx.theme) applyDocumentTheme(ctx.theme); + if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables); + if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts); + if (ctx.safeAreaInsets) { + const { top, right, bottom, left } = ctx.safeAreaInsets; + document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`; + } +}; + +(async () => { + const envDirEl = document.getElementById("env-dir") as HTMLSpanElement; + try { + await app.connect(); + setStatus("Connecting to server...", "info"); + + try { + const envResult = await app.callServerTool({ name: "firebase_get_environment", arguments: {} }); + const envData = envResult.structuredContent as any; + if (envData) { + envDirEl.textContent = envData.projectDir || ""; + } + } catch (err: any) { + console.error("Failed to fetch environment:", err); + envDirEl.textContent = "Error loading"; + } + + // Fetch projects on load + try { + const result = await app.callServerTool({ name: "firebase_list_projects", arguments: { page_size: 10000 } }); + const data = result.structuredContent as any; + + if (data && data.projects) { + projects = data.projects; + filteredProjects = projects; + renderProjects(); + setStatus("Projects loaded.", "success"); + setTimeout(() => { if (statusBox.className === "status success") statusBox.style.display = "none"; }, 2000); + } else { + setStatus("No projects returned from server.", "error"); + } + } catch (err: any) { + setStatus(`Failed to load projects: ${err.message}`, "error"); + } + } catch (err: any) { + setStatus(`Failed to connect: ${err.message}`, "error"); + if (envDirEl) envDirEl.textContent = "Error loading"; + } +})(); diff --git a/src/mcp/apps/init/vite.config.ts b/src/mcp/apps/init/vite.config.ts new file mode 100644 index 00000000000..983a7570c38 --- /dev/null +++ b/src/mcp/apps/init/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; +import path from "path"; + +export default defineConfig({ + plugins: [viteSingleFile()], + root: __dirname, + build: { + outDir: path.resolve(__dirname, "../../../../lib/mcp/apps/init"), + emptyOutDir: true, + rollupOptions: { + input: path.resolve(__dirname, "mcp-app.html"), + }, + }, +}); diff --git a/src/mcp/resources/init_ui.ts b/src/mcp/resources/init_ui.ts new file mode 100644 index 00000000000..41ec7f41e17 --- /dev/null +++ b/src/mcp/resources/init_ui.ts @@ -0,0 +1,34 @@ +import { resource } from "../resource"; +import { McpContext } from "../types"; +import * as path from "path"; +import * as fs from "fs/promises"; + +export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; +const resourceUri = "ui://core/init/mcp-app.html"; + +export const init_ui = resource( + { + uri: resourceUri, + name: "Init UI", + description: "Visual interface for Firebase Init", + mimeType: RESOURCE_MIME_TYPE, + }, + async (uri: string, ctx: McpContext) => { + try { + // The built HTML will be in lib/mcp/apps/init/mcp-app.html + const htmlPath = path.join(__dirname, "../apps/init/mcp-app.html"); + const html = await fs.readFile(htmlPath, "utf-8"); + return { + contents: [ + { + uri: resourceUri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }, + ], + }; + } catch (e: any) { + throw new Error(`Failed to load Init UI: ${e.message}`); + } + }, +); From 7004ba76f3d3c681e7082d45e110cb5c71a85a45 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 17:16:15 -0700 Subject: [PATCH 06/13] chore: address PR comments and fix build on mcp-init-app - Remove non-existent login_ui and deploy_ui from resources index to fix build. - Avoid using any in mcp-app.ts, use specific interfaces. - Check isError on tool results. - Reduce page_size to 1000. - Fix lint warnings for catch blocks. --- src/mcp/apps/init/mcp-app.ts | 89 ++++++++++++++++++++++++++---------- src/mcp/resources/index.ts | 4 -- 2 files changed, 66 insertions(+), 27 deletions(-) diff --git a/src/mcp/apps/init/mcp-app.ts b/src/mcp/apps/init/mcp-app.ts index d5f9907f6ca..b509973e814 100644 --- a/src/mcp/apps/init/mcp-app.ts +++ b/src/mcp/apps/init/mcp-app.ts @@ -1,4 +1,9 @@ -import { App, applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps"; +import { + App, + applyDocumentTheme, + applyHostStyleVariables, + applyHostFonts, +} from "@modelcontextprotocol/ext-apps"; const app = new App({ name: "firebase-init", version: "1.0.0" }); @@ -14,8 +19,13 @@ const googleFields = document.getElementById("google-fields") as HTMLDivElement; const searchInput = document.getElementById("search-input") as HTMLInputElement; const projectListContainer = document.getElementById("project-list") as HTMLDivElement; -let projects: any[] = []; -let filteredProjects: any[] = []; +interface Project { + projectId: string; + displayName?: string; +} + +let projects: Project[] = []; +let filteredProjects: Project[] = []; let selectedProjectId: string | null = null; function setStatus(message: string, type: "info" | "success" | "error" = "info") { @@ -103,7 +113,7 @@ googleCheckbox.addEventListener("change", (e) => { initBtn.addEventListener("click", async () => { const selectedProduct = Array.from(productRadios).find((r) => r.checked)?.value; - + if (!selectedProjectId) { setStatus("Please select a project first.", "error"); return; @@ -130,31 +140,47 @@ initBtn.addEventListener("click", async () => { setStatus("Initializing product...", "info"); // 2. Call init - const args: any = { features: {} }; + interface InitArgs { + features: { + firestore?: { database_id: string; rules_filename: string }; + auth?: { + providers: { + emailPassword?: boolean; + anonymous?: boolean; + googleSignIn?: { oAuthBrandDisplayName: string; supportEmail: string }; + }; + }; + }; + [key: string]: unknown; + } + const args: InitArgs = { features: {} }; if (selectedProduct === "firestore") { const dbId = (document.getElementById("firestore-db-id") as HTMLInputElement).value; const rulesFile = (document.getElementById("firestore-rules-file") as HTMLInputElement).value; - + args.features.firestore = { database_id: dbId, rules_filename: rulesFile, }; } else if (selectedProduct === "auth") { const emailEnabled = (document.getElementById("auth-email") as HTMLInputElement).checked; - const anonymousEnabled = (document.getElementById("auth-anonymous") as HTMLInputElement).checked; + const anonymousEnabled = (document.getElementById("auth-anonymous") as HTMLInputElement) + .checked; const googleEnabled = googleCheckbox.checked; - + args.features.auth = { providers: { emailPassword: emailEnabled, anonymous: anonymousEnabled, - } + }, }; if (googleEnabled) { - const displayName = (document.getElementById("google-display-name") as HTMLInputElement).value; - const supportEmail = (document.getElementById("google-support-email") as HTMLInputElement).value; + const displayName = (document.getElementById("google-display-name") as HTMLInputElement) + .value; + const supportEmail = (document.getElementById("google-support-email") as HTMLInputElement) + .value; args.features.auth.providers.googleSignIn = { oAuthBrandDisplayName: displayName, supportEmail: supportEmail, @@ -172,8 +198,9 @@ initBtn.addEventListener("click", async () => { } else { setStatus(`Successfully initialized ${selectedProduct}!`, "success"); } - } catch (err: any) { - setStatus(`Error: ${err.message}`, "error"); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + setStatus(`Error: ${message}`, "error"); } finally { initBtn.disabled = false; initBtn.textContent = "Initialize"; @@ -197,35 +224,51 @@ app.onhostcontextchanged = (ctx) => { setStatus("Connecting to server...", "info"); try { - const envResult = await app.callServerTool({ name: "firebase_get_environment", arguments: {} }); - const envData = envResult.structuredContent as any; + const envResult = await app.callServerTool({ + name: "firebase_get_environment", + arguments: {}, + }); + if (envResult.isError) { + throw new Error(`Failed to fetch environment: ${JSON.stringify(envResult.content)}`); + } + const envData = envResult.structuredContent as { projectDir?: string }; if (envData) { envDirEl.textContent = envData.projectDir || ""; } - } catch (err: any) { + } catch (err: unknown) { console.error("Failed to fetch environment:", err); envDirEl.textContent = "Error loading"; } // Fetch projects on load try { - const result = await app.callServerTool({ name: "firebase_list_projects", arguments: { page_size: 10000 } }); - const data = result.structuredContent as any; + const result = await app.callServerTool({ + name: "firebase_list_projects", + arguments: { page_size: 1000 }, + }); + if (result.isError) { + throw new Error(`Failed to load projects: ${JSON.stringify(result.content)}`); + } + const data = result.structuredContent as { projects?: Project[] }; if (data && data.projects) { projects = data.projects; filteredProjects = projects; renderProjects(); setStatus("Projects loaded.", "success"); - setTimeout(() => { if (statusBox.className === "status success") statusBox.style.display = "none"; }, 2000); + setTimeout(() => { + if (statusBox.className === "status success") statusBox.style.display = "none"; + }, 2000); } else { setStatus("No projects returned from server.", "error"); } - } catch (err: any) { - setStatus(`Failed to load projects: ${err.message}`, "error"); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + setStatus(`Failed to load projects: ${message}`, "error"); } - } catch (err: any) { - setStatus(`Failed to connect: ${err.message}`, "error"); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + setStatus(`Failed to connect: ${message}`, "error"); if (envDirEl) envDirEl.textContent = "Error loading"; } })(); diff --git a/src/mcp/resources/index.ts b/src/mcp/resources/index.ts index a9fa9047eff..4436b6ec0d9 100644 --- a/src/mcp/resources/index.ts +++ b/src/mcp/resources/index.ts @@ -13,9 +13,7 @@ import { ServerResource, ServerResourceTemplate } from "../resource"; import { trackGA4 } from "../../track"; import { crashlytics_issues } from "./guides/crashlytics_issues"; import { crashlytics_reports } from "./guides/crashlytics_reports"; -import { login_ui } from "./login_ui"; import { update_environment_ui } from "./update_environment_ui"; -import { deploy_ui } from "./deploy_ui"; import { init_ui } from "./init_ui"; export const resources = [ @@ -29,9 +27,7 @@ export const resources = [ init_firestore_rules, init_auth, init_hosting, - login_ui, update_environment_ui, - deploy_ui, init_ui, ]; From 50f0bff25ab6de5b95b1d383f5e8ce66e8bf1844 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Mon, 6 Apr 2026 09:53:43 -0700 Subject: [PATCH 07/13] fix: add missing vite.config.ts for update_environment MCP app --- src/mcp/apps/update_environment/vite.config.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/mcp/apps/update_environment/vite.config.ts diff --git a/src/mcp/apps/update_environment/vite.config.ts b/src/mcp/apps/update_environment/vite.config.ts new file mode 100644 index 00000000000..924b047c9bb --- /dev/null +++ b/src/mcp/apps/update_environment/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; +import path from "path"; + +export default defineConfig({ + plugins: [viteSingleFile()], + root: __dirname, + build: { + outDir: path.resolve(__dirname, "../../../../lib/mcp/apps/update_environment"), + emptyOutDir: true, + rollupOptions: { + input: path.resolve(__dirname, "mcp-app.html"), + }, + }, +}); From 341de9f90f88e6f91635c16cf73c55954f79d251 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Mon, 6 Apr 2026 13:08:17 -0700 Subject: [PATCH 08/13] chore: add missing MCP app dependencies and update shrinkwrap on mcp-init-app --- npm-shrinkwrap.json | 173 ++++++++++++++++++++++++++++++++------------ package.json | 4 +- 2 files changed, 131 insertions(+), 46 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index d93ac9def70..aefe1954175 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -93,6 +93,7 @@ "@angular-devkit/architect": "^0.1402.2", "@angular-devkit/core": "^14.2.2", "@google/events": "^5.1.1", + "@modelcontextprotocol/ext-apps": "^1.3.2", "@types/archiver": "^6.0.0", "@types/async-lock": "^1.4.2", "@types/body-parser": "^1.17.0", @@ -174,7 +175,8 @@ "ts-node": "^10.4.0", "typescript": "^5.3.3", "typescript-json-schema": "^0.65.1", - "vite": "^4.2.1" + "vite": "^4.2.1", + "vite-plugin-singlefile": "^0.13.5" }, "engines": { "node": ">=20.0.0 || >=22.0.0 || >=24.0.0" @@ -3023,9 +3025,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.7", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", - "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", + "version": "1.19.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz", + "integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -3764,13 +3766,40 @@ "integrity": "sha512-4/RWEeXDO6bocPONheFe6gX/oQdP/bEpv0oL4HqjPP5DCenBSt0mHgahppY49N0CpsaqffdwPq+TlX9CYOq2Dw==", "dev": true }, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.5.0.tgz", + "integrity": "sha512-q4fut89TOoP2LEPHSGfZErIf1K1xOTTzV+41h/bB2BqKw2gKb0uLKbHusOy1UtbY0puS16zBho/vFp3f5XMVbQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", - "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.7", + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -3778,14 +3807,15 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" + "zod-to-json-schema": "^3.25.1" }, "engines": { "node": ">=18" @@ -10140,10 +10170,13 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", - "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, "engines": { "node": ">= 16" }, @@ -10151,7 +10184,16 @@ "url": "https://github.com/sponsors/express-rate-limit" }, "peerDependencies": { - "express": "^4.11 || 5 || ^5.0.0-beta.1" + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" } }, "node_modules/express/node_modules/cookie": { @@ -12392,11 +12434,10 @@ } }, "node_modules/hono": { - "version": "4.11.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", - "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "version": "4.12.11", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.11.tgz", + "integrity": "sha512-r4xbIa3mGGGoH9nN4A14DOg2wx7y2oQyJEb5O57C/xzETG/qx4c7CVDQ5WMeKHZ7ORk2W0hZ/sQKXTav3cmYBA==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -21698,6 +21739,23 @@ } } }, + "node_modules/vite-plugin-singlefile": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-0.13.5.tgz", + "integrity": "sha512-y/aRGh8qHmw2f1IhaI/C6PJAaov47ESYDvUv1am1YHMhpY+19B5k5Odp8P+tgs+zhfvak6QB1ykrALQErEAo7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromatch": "^4.0.5" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "rollup": ">=2.79.0", + "vite": ">=3.2.0" + } + }, "node_modules/vitefu": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.4.tgz", @@ -22351,9 +22409,9 @@ } }, "node_modules/zod": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", - "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -24557,9 +24615,9 @@ } }, "@hono/node-server": { - "version": "1.19.7", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz", - "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==", + "version": "1.19.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz", + "integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==", "requires": {} }, "@humanwhocodes/config-array": { @@ -25004,12 +25062,19 @@ "integrity": "sha512-4/RWEeXDO6bocPONheFe6gX/oQdP/bEpv0oL4HqjPP5DCenBSt0mHgahppY49N0CpsaqffdwPq+TlX9CYOq2Dw==", "dev": true }, + "@modelcontextprotocol/ext-apps": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.5.0.tgz", + "integrity": "sha512-q4fut89TOoP2LEPHSGfZErIf1K1xOTTzV+41h/bB2BqKw2gKb0uLKbHusOy1UtbY0puS16zBho/vFp3f5XMVbQ==", + "dev": true, + "requires": {} + }, "@modelcontextprotocol/sdk": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", - "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "requires": { - "@hono/node-server": "^1.19.7", + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -25017,14 +25082,15 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" + "zod-to-json-schema": "^3.25.1" }, "dependencies": { "accepts": { @@ -29745,10 +29811,19 @@ } }, "express-rate-limit": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", - "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", - "requires": {} + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "requires": { + "ip-address": "10.1.0" + }, + "dependencies": { + "ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==" + } + } }, "extend": { "version": "3.0.2", @@ -31346,10 +31421,9 @@ "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==" }, "hono": { - "version": "4.11.9", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", - "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", - "peer": true + "version": "4.12.11", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.11.tgz", + "integrity": "sha512-r4xbIa3mGGGoH9nN4A14DOg2wx7y2oQyJEb5O57C/xzETG/qx4c7CVDQ5WMeKHZ7ORk2W0hZ/sQKXTav3cmYBA==" }, "hosted-git-info": { "version": "2.8.9", @@ -38158,6 +38232,15 @@ "rollup": "^3.18.0" } }, + "vite-plugin-singlefile": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-0.13.5.tgz", + "integrity": "sha512-y/aRGh8qHmw2f1IhaI/C6PJAaov47ESYDvUv1am1YHMhpY+19B5k5Odp8P+tgs+zhfvak6QB1ykrALQErEAo7g==", + "dev": true, + "requires": { + "micromatch": "^4.0.5" + } + }, "vitefu": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.4.tgz", @@ -38637,9 +38720,9 @@ } }, "zod": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", - "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==" + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" }, "zod-to-json-schema": { "version": "3.24.5", diff --git a/package.json b/package.json index cf8796f17ad..c89b5c7bad2 100644 --- a/package.json +++ b/package.json @@ -186,6 +186,7 @@ "@angular-devkit/architect": "^0.1402.2", "@angular-devkit/core": "^14.2.2", "@google/events": "^5.1.1", + "@modelcontextprotocol/ext-apps": "^1.3.2", "@types/archiver": "^6.0.0", "@types/async-lock": "^1.4.2", "@types/body-parser": "^1.17.0", @@ -267,7 +268,8 @@ "ts-node": "^10.4.0", "typescript": "^5.3.3", "typescript-json-schema": "^0.65.1", - "vite": "^4.2.1" + "vite": "^4.2.1", + "vite-plugin-singlefile": "^0.13.5" }, "overrides": { "@angular-devkit/core": { From b5de2c9f286ee910b680aabc83523bc9f8475968 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Mon, 6 Apr 2026 13:30:55 -0700 Subject: [PATCH 09/13] fix: resolve lint errors for unused variables on mcp-init-app --- src/mcp/apps/update_environment/mcp-app.ts | 4 ++-- src/mcp/resources/init_ui.ts | 3 ++- src/mcp/resources/update_environment_ui.ts | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/mcp/apps/update_environment/mcp-app.ts b/src/mcp/apps/update_environment/mcp-app.ts index 48e6e4483c9..c1110651df7 100644 --- a/src/mcp/apps/update_environment/mcp-app.ts +++ b/src/mcp/apps/update_environment/mcp-app.ts @@ -13,7 +13,6 @@ let selectedProjectId: string | null = null; const envProjectIdEl = document.getElementById("env-project-id") as HTMLSpanElement; const envUserEl = document.getElementById("env-user") as HTMLSpanElement; -const currentEnvBox = document.getElementById("current-env") as HTMLDivElement; function showStatus(message: string, type: "success" | "error" | "info") { statusBox.textContent = message; @@ -99,7 +98,8 @@ submitBtn.onclick = async () => { } }; -app.ontoolresult = (result) => { +// eslint-disable-next-line @typescript-eslint/no-unused-vars +app.ontoolresult = (_result) => { // We can handle tool results if needed, but we rely on manual triggers for list_projects }; diff --git a/src/mcp/resources/init_ui.ts b/src/mcp/resources/init_ui.ts index 41ec7f41e17..0834010613a 100644 --- a/src/mcp/resources/init_ui.ts +++ b/src/mcp/resources/init_ui.ts @@ -13,7 +13,8 @@ export const init_ui = resource( description: "Visual interface for Firebase Init", mimeType: RESOURCE_MIME_TYPE, }, - async (uri: string, ctx: McpContext) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async (_uri: string, _ctx: McpContext) => { try { // The built HTML will be in lib/mcp/apps/init/mcp-app.html const htmlPath = path.join(__dirname, "../apps/init/mcp-app.html"); diff --git a/src/mcp/resources/update_environment_ui.ts b/src/mcp/resources/update_environment_ui.ts index 1b7740eb7e1..0657d7e91a8 100644 --- a/src/mcp/resources/update_environment_ui.ts +++ b/src/mcp/resources/update_environment_ui.ts @@ -13,7 +13,8 @@ export const update_environment_ui = resource( description: "Visual interface for selecting active Firebase project", mimeType: RESOURCE_MIME_TYPE, }, - async (uri: string, ctx: McpContext) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async (_uri: string, _ctx: McpContext) => { try { const htmlPath = path.join(__dirname, "../apps/update_environment/mcp-app.html"); const html = await fs.readFile(htmlPath, "utf-8"); From c3794d8634bbc46ad31f17b17a191ad9dbe59448 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 14:15:20 -0700 Subject: [PATCH 10/13] feat: add deploy and deploy_status tools ### Description Adds firebase_deploy and deploy_status tools to support asynchronous deployment and status polling. ### Scenarios Tested - Verified file changes. --- src/mcp/tools/core/deploy.ts | 98 +++++++++++++++++++++++++++++ src/mcp/tools/core/deploy_status.ts | 37 +++++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/mcp/tools/core/deploy.ts create mode 100644 src/mcp/tools/core/deploy_status.ts diff --git a/src/mcp/tools/core/deploy.ts b/src/mcp/tools/core/deploy.ts new file mode 100644 index 00000000000..0b185b7f482 --- /dev/null +++ b/src/mcp/tools/core/deploy.ts @@ -0,0 +1,98 @@ +import { z } from "zod"; +import { tool } from "../../tool"; +import { deploy as coreDeploy } from "../../../deploy"; +import { toContent } from "../../util"; +import { jobTracker } from "../../util/jobs"; + +export const deploy = tool( + "core", + { + name: "deploy", + description: + "Deploy resources to your Firebase project, based on the contents of firebase.json.", + inputSchema: z.object({ + only: z + .string() + .optional() + .describe( + 'Comma-separated list of services to deploy. Valid targets are: database, storage, firestore, functions, hosting, remoteconfig, extensions, dataconnect, apphosting, auth.', + ), + }), + annotations: { + title: "Deploy Firebase Services", + readOnlyHint: false, + }, + _meta: { + requiresAuth: true, + requiresProject: true, + ui: { + resourceUri: "ui://core/deploy/mcp-app.html", + }, + }, + }, + async ({ only }, ctx) => { + const validTargets = [ + "database", + "storage", + "firestore", + "functions", + "hosting", + "remoteconfig", + "extensions", + "dataconnect", + "apphosting", + "auth", + ]; + let targets = validTargets; + if (only) { + const parts = only.split(",").map((p) => p.trim()); + targets = parts.filter((p) => validTargets.includes(p)); + } + + const jobId = Date.now().toString(); + jobTracker.createJob(jobId); + + const options: any = { + only: only || "", + except: "", + filteredTargets: targets, + project: ctx.projectId, + projectId: ctx.projectId, + rc: ctx.rc, + config: ctx.config, + nonInteractive: true, + onProgress: (progress: any) => { + const phaseNumbers: Record = { + predeploy: 10, + prepare: 30, + deploy: 60, + release: 80, + postdeploy: 100, + }; + const percentage = phaseNumbers[progress.phase] || 0; + jobTracker.updateJob(jobId, { progress: percentage }); + jobTracker.addLog( + jobId, + `Deploy [${progress.phase}]: Complete for targets ${progress.targets?.join(",")}`, + ); + }, + }; + + // Run in background + (async () => { + try { + const typedTargets = targets as any; // Cast or specify exact enum + const res = await coreDeploy(typedTargets, options); + jobTracker.updateJob(jobId, { status: "success", progress: 100, result: res }); + } catch (err: any) { + jobTracker.updateJob(jobId, { status: "failed", error: err.message }); + } + })(); + + const contentRes = toContent(`Deployment started with Job ID: ${jobId}. Use deploy_status tool to track.`); + return { + ...contentRes, + structuredContent: { jobId, message: "Deployment started" }, + }; + }, +); diff --git a/src/mcp/tools/core/deploy_status.ts b/src/mcp/tools/core/deploy_status.ts new file mode 100644 index 00000000000..7d5871cd891 --- /dev/null +++ b/src/mcp/tools/core/deploy_status.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { tool } from "../../tool"; +import { toContent, mcpError } from "../../util"; +import { jobTracker } from "../../util/jobs"; + +export const deploy_status = tool( + "core", + { + name: "deploy_status", + description: "Check the status of a background deployment job using its Job ID.", + inputSchema: z.object({ + jobId: z.string().describe("The Job ID returned by the deploy tool"), + }), + annotations: { + title: "Check Deployment Status", + readOnlyHint: true, + }, + _meta: { + requiresAuth: true, + requiresProject: true, + }, + }, + async ({ jobId }, ctx) => { + const job = jobTracker.getJob(jobId); + if (!job) { + return mcpError(`Job not found: ${jobId}`); + } + + const contentRes = toContent( + `Job ID: ${jobId}\nStatus: ${job.status}\nProgress: ${job.progress}%\n\nLogs:\n${job.logs.join("\n")}`, + ); + return { + ...contentRes, + structuredContent: job as any, + }; + }, +); From 84916f24d8aa5cce7dad248af575cd037bb44441 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 15:54:29 -0700 Subject: [PATCH 11/13] feat: use applyAppMeta in deploy tool and recover jobs.ts ### Description - Updates deploy tool to use applyAppMeta to conditionally return UI resource URI. - Recovers missing src/mcp/util/jobs.ts from compiled version. ### Scenarios Tested - Build succeeds. - Lint passes for these files. --- src/mcp/tools/core/deploy.ts | 22 +++++++++++--------- src/mcp/util/jobs.ts | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 src/mcp/util/jobs.ts diff --git a/src/mcp/tools/core/deploy.ts b/src/mcp/tools/core/deploy.ts index 0b185b7f482..37f93fa778a 100644 --- a/src/mcp/tools/core/deploy.ts +++ b/src/mcp/tools/core/deploy.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { tool } from "../../tool"; import { deploy as coreDeploy } from "../../../deploy"; -import { toContent } from "../../util"; +import { toContent, applyAppMeta } from "../../util"; import { jobTracker } from "../../util/jobs"; export const deploy = tool( @@ -15,7 +15,7 @@ export const deploy = tool( .string() .optional() .describe( - 'Comma-separated list of services to deploy. Valid targets are: database, storage, firestore, functions, hosting, remoteconfig, extensions, dataconnect, apphosting, auth.', + "Comma-separated list of services to deploy. Valid targets are: database, storage, firestore, functions, hosting, remoteconfig, extensions, dataconnect, apphosting, auth.", ), }), annotations: { @@ -25,9 +25,6 @@ export const deploy = tool( _meta: { requiresAuth: true, requiresProject: true, - ui: { - resourceUri: "ui://core/deploy/mcp-app.html", - }, }, }, async ({ only }, ctx) => { @@ -89,10 +86,15 @@ export const deploy = tool( } })(); - const contentRes = toContent(`Deployment started with Job ID: ${jobId}. Use deploy_status tool to track.`); - return { - ...contentRes, - structuredContent: { jobId, message: "Deployment started" }, - }; + const contentRes = toContent( + `Deployment started with Job ID: ${jobId}. Use deploy_status tool to track.`, + ); + return applyAppMeta( + { + ...contentRes, + structuredContent: { jobId, message: "Deployment started" }, + }, + "ui://core/deploy/mcp-app.html", + ); }, ); diff --git a/src/mcp/util/jobs.ts b/src/mcp/util/jobs.ts new file mode 100644 index 00000000000..0c06a5fad22 --- /dev/null +++ b/src/mcp/util/jobs.ts @@ -0,0 +1,39 @@ +export interface Job { + status: "running" | "success" | "failed"; + progress: number; + logs: string[]; + result?: any; + error?: string; +} + +class JobTracker { + private jobs = new Map(); + + createJob(id: string): void { + this.jobs.set(id, { + status: "running", + progress: 0, + logs: [], + }); + } + + updateJob(id: string, updates: Partial): void { + const job = this.jobs.get(id); + if (job) { + Object.assign(job, updates); + } + } + + addLog(id: string, log: string): void { + const job = this.jobs.get(id); + if (job) { + job.logs.push(log); + } + } + + getJob(id: string): Job | undefined { + return this.jobs.get(id); + } +} + +export const jobTracker = new JobTracker(); From 330dfd32e120217b5086189c2098631ee0f174ff Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Mon, 6 Apr 2026 09:51:29 -0700 Subject: [PATCH 12/13] chore: address PR comments and fix build on mcp-deploy-tools - Export TARGETS from src/deploy/index.ts to avoid duplication. - Fix any usages in deploy.ts and deploy_status.ts. - Add index signature to Job interface in jobs.ts. - Fix lint warnings and errors. --- src/deploy/index.ts | 2 +- src/mcp/tools/core/deploy.ts | 34 +++++++++++------------------ src/mcp/tools/core/deploy_status.ts | 4 ++-- src/mcp/util/jobs.ts | 1 + 4 files changed, 17 insertions(+), 24 deletions(-) diff --git a/src/deploy/index.ts b/src/deploy/index.ts index f0c28f4a5ed..35791163b04 100644 --- a/src/deploy/index.ts +++ b/src/deploy/index.ts @@ -33,7 +33,7 @@ import { deployStatsParams, } from "./dataconnect/context"; -const TARGETS = { +export const TARGETS = { hosting: HostingTarget, database: DatabaseTarget, firestore: FirestoreTarget, diff --git a/src/mcp/tools/core/deploy.ts b/src/mcp/tools/core/deploy.ts index 37f93fa778a..db10fb3580a 100644 --- a/src/mcp/tools/core/deploy.ts +++ b/src/mcp/tools/core/deploy.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { tool } from "../../tool"; -import { deploy as coreDeploy } from "../../../deploy"; +import { deploy as coreDeploy, TARGETS, DeployOptions } from "../../../deploy"; import { toContent, applyAppMeta } from "../../util"; import { jobTracker } from "../../util/jobs"; @@ -28,18 +28,7 @@ export const deploy = tool( }, }, async ({ only }, ctx) => { - const validTargets = [ - "database", - "storage", - "firestore", - "functions", - "hosting", - "remoteconfig", - "extensions", - "dataconnect", - "apphosting", - "auth", - ]; + const validTargets = Object.keys(TARGETS); let targets = validTargets; if (only) { const parts = only.split(",").map((p) => p.trim()); @@ -49,7 +38,7 @@ export const deploy = tool( const jobId = Date.now().toString(); jobTracker.createJob(jobId); - const options: any = { + const options = { only: only || "", except: "", filteredTargets: targets, @@ -58,7 +47,7 @@ export const deploy = tool( rc: ctx.rc, config: ctx.config, nonInteractive: true, - onProgress: (progress: any) => { + onProgress: (progress: { phase: string; targets?: string[] }) => { const phaseNumbers: Record = { predeploy: 10, prepare: 30, @@ -70,19 +59,22 @@ export const deploy = tool( jobTracker.updateJob(jobId, { progress: percentage }); jobTracker.addLog( jobId, - `Deploy [${progress.phase}]: Complete for targets ${progress.targets?.join(",")}`, + `Deploy [${progress.phase}]: Complete for targets ${(progress.targets || []).join(",")}`, ); }, }; // Run in background - (async () => { + void (async () => { try { - const typedTargets = targets as any; // Cast or specify exact enum - const res = await coreDeploy(typedTargets, options); + const res = await coreDeploy( + targets as (keyof typeof TARGETS)[], + options as unknown as DeployOptions, + ); jobTracker.updateJob(jobId, { status: "success", progress: 100, result: res }); - } catch (err: any) { - jobTracker.updateJob(jobId, { status: "failed", error: err.message }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + jobTracker.updateJob(jobId, { status: "failed", error: message }); } })(); diff --git a/src/mcp/tools/core/deploy_status.ts b/src/mcp/tools/core/deploy_status.ts index 7d5871cd891..b102bbd6a9a 100644 --- a/src/mcp/tools/core/deploy_status.ts +++ b/src/mcp/tools/core/deploy_status.ts @@ -20,7 +20,7 @@ export const deploy_status = tool( requiresProject: true, }, }, - async ({ jobId }, ctx) => { + async ({ jobId }) => { const job = jobTracker.getJob(jobId); if (!job) { return mcpError(`Job not found: ${jobId}`); @@ -31,7 +31,7 @@ export const deploy_status = tool( ); return { ...contentRes, - structuredContent: job as any, + structuredContent: job, }; }, ); diff --git a/src/mcp/util/jobs.ts b/src/mcp/util/jobs.ts index 0c06a5fad22..450b858ca52 100644 --- a/src/mcp/util/jobs.ts +++ b/src/mcp/util/jobs.ts @@ -4,6 +4,7 @@ export interface Job { logs: string[]; result?: any; error?: string; + [key: string]: unknown; } class JobTracker { From df9b936f90cd6811ec50e5ed22d8d9c96e0e0c4c Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Mon, 6 Apr 2026 12:46:00 -0700 Subject: [PATCH 13/13] fix: resolve lint errors and missing dependencies on mcp-deploy-tools --- src/mcp/apps/update_environment/mcp-app.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/mcp/apps/update_environment/mcp-app.ts b/src/mcp/apps/update_environment/mcp-app.ts index c1110651df7..95918b5b288 100644 --- a/src/mcp/apps/update_environment/mcp-app.ts +++ b/src/mcp/apps/update_environment/mcp-app.ts @@ -1,4 +1,9 @@ -import { App, applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps"; +import { + App, + applyDocumentTheme, + applyHostStyleVariables, + applyHostFonts, +} from "@modelcontextprotocol/ext-apps"; const app = new App({ name: "Update Firebase Environment", version: "1.0.0" }); @@ -120,7 +125,10 @@ app.onhostcontextchanged = (ctx) => { // Fetch current environment try { - const envResult = await app.callServerTool({ name: "firebase_get_environment", arguments: {} }); + const envResult = await app.callServerTool({ + name: "firebase_get_environment", + arguments: {}, + }); const envData = envResult.structuredContent as any; if (envData) { envProjectIdEl.textContent = envData.projectId || ""; @@ -140,7 +148,9 @@ app.onhostcontextchanged = (ctx) => { filteredProjects = projects; renderProjects(); showStatus("Projects loaded successfully.", "success"); - setTimeout(() => { if (statusBox.className === "status success") statusBox.style.display = "none"; }, 3000); + setTimeout(() => { + if (statusBox.className === "status success") statusBox.style.display = "none"; + }, 3000); } else { showStatus("No projects returned from server.", "error"); }