From fdd4c3766bd5a12f918e9b11a78006d8695df881 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Fri, 3 Apr 2026 14:15:38 -0700 Subject: [PATCH 1/3] feat: add Deploy MCP App Adds the Deploy MCP App UI and resources. Also includes a small fix for firestore deploy to avoid errors on empty indexes. - Verified build and file changes. --- src/deploy/firestore/deploy.ts | 3 + src/mcp/apps/deploy/mcp-app.html | 258 +++++++++++++++++++++++++++++++ src/mcp/apps/deploy/mcp-app.ts | 129 ++++++++++++++++ src/mcp/resources/deploy_ui.ts | 33 ++++ 4 files changed, 423 insertions(+) create mode 100644 src/mcp/apps/deploy/mcp-app.html create mode 100644 src/mcp/apps/deploy/mcp-app.ts create mode 100644 src/mcp/resources/deploy_ui.ts diff --git a/src/deploy/firestore/deploy.ts b/src/deploy/firestore/deploy.ts index e44d5fff02b..ba90f72d9a8 100644 --- a/src/deploy/firestore/deploy.ts +++ b/src/deploy/firestore/deploy.ts @@ -30,6 +30,9 @@ async function deployIndexes(context: any, options: any): Promise { return; } const indexesContext: IndexContext[] = context?.firestore?.indexes; + if (!indexesContext || indexesContext.length === 0) { + return; + } utils.logBullet(clc.bold(clc.cyan("firestore: ")) + "deploying indexes..."); const firestoreIndexes = new FirestoreApi(); diff --git a/src/mcp/apps/deploy/mcp-app.html b/src/mcp/apps/deploy/mcp-app.html new file mode 100644 index 00000000000..b7341d1fde1 --- /dev/null +++ b/src/mcp/apps/deploy/mcp-app.html @@ -0,0 +1,258 @@ + + + + + + Firebase Deploy + + + +
+
+

Deploy Firebase

+

Select services and trigger deployment.

+
+ +
+

Services to Deploy

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+ +
+

Deployment Progress

+
+
+
+
+ +
+
+
+ + + diff --git a/src/mcp/apps/deploy/mcp-app.ts b/src/mcp/apps/deploy/mcp-app.ts new file mode 100644 index 00000000000..ea18ae60827 --- /dev/null +++ b/src/mcp/apps/deploy/mcp-app.ts @@ -0,0 +1,129 @@ +import { App } from "@modelcontextprotocol/ext-apps"; + +const app = new App({ name: "firebase-deploy", version: "1.0.0" }); + +const deployBtn = document.getElementById("deploy-btn") as HTMLButtonElement; +const progressBar = document.getElementById("progress-bar") as HTMLDivElement; +const progressContainer = document.getElementById("progress-container") as HTMLDivElement; +const statusList = document.getElementById("status-list") as HTMLDivElement; + +function addLog(message: string, type: "info" | "success" | "error" = "info") { + const item = document.createElement("div"); + item.className = `status-item ${type}`; + item.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; + statusList.appendChild(item); + statusList.scrollTop = statusList.scrollHeight; // Auto-scroll +} + +function updateProgress(percentage: number) { + progressBar.style.width = `${percentage}%`; +} + +function pollStatus(jobId: string) { + const interval = setInterval(async () => { + try { + const statusRes = await app.callServerTool({ + name: "firebase_deploy_status", + arguments: { jobId }, + }); + + if (statusRes.isError) { + addLog(`Failed to poll status: ${JSON.stringify(statusRes.content)}`, "error"); + clearInterval(interval); + deployBtn.disabled = false; + deployBtn.textContent = "Deploy"; + return; + } + + const job = statusRes.structuredContent as any; + if (job) { + updateProgress(job.progress); + + // Clear and redraw logs to avoid duplication if we are reading full history + statusList.innerHTML = ""; + job.logs.forEach((log: string) => addLog(log)); + + if (job.status === "success") { + addLog("Deployment completed successfully!", "success"); + clearInterval(interval); + deployBtn.disabled = false; + deployBtn.textContent = "Deploy"; + } else if (job.status === "failed") { + addLog(`Deployment failed: ${job.error}`, "error"); + clearInterval(interval); + deployBtn.disabled = false; + deployBtn.textContent = "Deploy"; + } + } + } catch (err: any) { + addLog(`Error during polling: ${err.message}`, "error"); + clearInterval(interval); + deployBtn.disabled = false; + deployBtn.textContent = "Deploy"; + } + }, 2000); +} + +deployBtn.addEventListener("click", async () => { + // 1. Get checked targets + const targets: string[] = []; + const checkboxes = document.querySelectorAll( + '.checkbox-grid input[type="checkbox"]:checked', + ) as NodeListOf; + checkboxes.forEach((cb) => targets.push(cb.value)); + + if (targets.length === 0) { + addLog("Please select at least one service to deploy.", "error"); + return; + } + + // 2. Disable UI + deployBtn.disabled = true; + deployBtn.textContent = "Deploying..."; + progressContainer.style.display = "block"; + statusList.innerHTML = ""; // Clear old logs + updateProgress(10); + addLog(`Starting deployment for: ${targets.join(", ")}`); + + // 3. Call tool + try { + const onlyArg = targets.join(","); + addLog(`Calling firebase_deploy with only="${onlyArg}"...`); + + const result = await app.callServerTool({ + name: "firebase_deploy", + arguments: { only: onlyArg }, + }); + + if (result.isError) { + addLog(`Deployment failed to start: ${JSON.stringify(result.content)}`, "error"); + updateProgress(0); + deployBtn.disabled = false; + deployBtn.textContent = "Deploy"; + } else { + const jobId = (result.structuredContent as any)?.jobId; + if (jobId) { + addLog(`Deployment started with Job ID: ${jobId}. Polling status...`); + pollStatus(jobId); + } else { + addLog("Failed to get Job ID from server.", "error"); + deployBtn.disabled = false; + deployBtn.textContent = "Deploy"; + } + } + } catch (err: any) { + addLog(`Error calling deploy tool: ${err.message}`, "error"); + updateProgress(0); + deployBtn.disabled = false; + deployBtn.textContent = "Deploy"; + } +}); + +(async () => { + try { + await app.connect(); + addLog("Connected to host.", "info"); + } catch (err: any) { + console.error("Failed to connect app:", err); + } +})(); diff --git a/src/mcp/resources/deploy_ui.ts b/src/mcp/resources/deploy_ui.ts new file mode 100644 index 00000000000..e819d930101 --- /dev/null +++ b/src/mcp/resources/deploy_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 = "text/html;profile=mcp-app"; +const resourceUri = "ui://core/deploy/mcp-app.html"; + +export const deploy_ui = resource( + { + uri: resourceUri, + name: "Deploy UI", + description: "Visual interface for Firebase Deploy", + mimeType: RESOURCE_MIME_TYPE, + }, + async (uri: string, ctx: McpContext) => { + try { + const htmlPath = path.join(__dirname, "../apps/deploy/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 Deploy UI: ${e.message}`); + } + }, +); From 35e17748708769cabfa314e913459738464df7d3 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Mon, 6 Apr 2026 14:22:17 -0700 Subject: [PATCH 2/3] fix: resolve lint warnings and fix build for deploy app on mcp-deploy-app --- package.json | 2 +- src/mcp/apps/deploy/mcp-app.ts | 33 ++++++++++++++++++------------ src/mcp/apps/deploy/vite.config.ts | 15 ++++++++++++++ 3 files changed, 36 insertions(+), 14 deletions(-) create mode 100644 src/mcp/apps/deploy/vite.config.ts diff --git a/package.json b/package.json index c89b5c7bad2..ab6cf08e130 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "firebase": "./lib/bin/firebase.js" }, "scripts": { - "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:mcp-apps": "vite build --config src/mcp/apps/update_environment/vite.config.ts && vite build --config src/mcp/apps/init/vite.config.ts && vite build --config src/mcp/apps/deploy/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", diff --git a/src/mcp/apps/deploy/mcp-app.ts b/src/mcp/apps/deploy/mcp-app.ts index ea18ae60827..bce1b317996 100644 --- a/src/mcp/apps/deploy/mcp-app.ts +++ b/src/mcp/apps/deploy/mcp-app.ts @@ -1,4 +1,5 @@ import { App } from "@modelcontextprotocol/ext-apps"; +import { Job } from "../../util/jobs"; const app = new App({ name: "firebase-deploy", version: "1.0.0" }); @@ -7,7 +8,7 @@ const progressBar = document.getElementById("progress-bar") as HTMLDivElement; const progressContainer = document.getElementById("progress-container") as HTMLDivElement; const statusList = document.getElementById("status-list") as HTMLDivElement; -function addLog(message: string, type: "info" | "success" | "error" = "info") { +function addLog(message: string, type: "info" | "success" | "error" = "info"): void { const item = document.createElement("div"); item.className = `status-item ${type}`; item.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; @@ -15,11 +16,12 @@ function addLog(message: string, type: "info" | "success" | "error" = "info") { statusList.scrollTop = statusList.scrollHeight; // Auto-scroll } -function updateProgress(percentage: number) { +function updateProgress(percentage: number): void { progressBar.style.width = `${percentage}%`; } -function pollStatus(jobId: string) { +function pollStatus(jobId: string): void { + // eslint-disable-next-line @typescript-eslint/no-misused-promises const interval = setInterval(async () => { try { const statusRes = await app.callServerTool({ @@ -35,7 +37,7 @@ function pollStatus(jobId: string) { return; } - const job = statusRes.structuredContent as any; + const job = statusRes.structuredContent as unknown as Job; if (job) { updateProgress(job.progress); @@ -49,14 +51,15 @@ function pollStatus(jobId: string) { deployBtn.disabled = false; deployBtn.textContent = "Deploy"; } else if (job.status === "failed") { - addLog(`Deployment failed: ${job.error}`, "error"); + addLog(`Deployment failed: ${job.error || "Unknown error"}`, "error"); clearInterval(interval); deployBtn.disabled = false; deployBtn.textContent = "Deploy"; } } - } catch (err: any) { - addLog(`Error during polling: ${err.message}`, "error"); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + addLog(`Error during polling: ${message}`, "error"); clearInterval(interval); deployBtn.disabled = false; deployBtn.textContent = "Deploy"; @@ -64,9 +67,11 @@ function pollStatus(jobId: string) { }, 2000); } +// eslint-disable-next-line @typescript-eslint/no-misused-promises deployBtn.addEventListener("click", async () => { // 1. Get checked targets const targets: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion const checkboxes = document.querySelectorAll( '.checkbox-grid input[type="checkbox"]:checked', ) as NodeListOf; @@ -101,7 +106,7 @@ deployBtn.addEventListener("click", async () => { deployBtn.disabled = false; deployBtn.textContent = "Deploy"; } else { - const jobId = (result.structuredContent as any)?.jobId; + const jobId = (result.structuredContent as unknown as { jobId: string })?.jobId; if (jobId) { addLog(`Deployment started with Job ID: ${jobId}. Polling status...`); pollStatus(jobId); @@ -111,19 +116,21 @@ deployBtn.addEventListener("click", async () => { deployBtn.textContent = "Deploy"; } } - } catch (err: any) { - addLog(`Error calling deploy tool: ${err.message}`, "error"); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + addLog(`Error calling deploy tool: ${message}`, "error"); updateProgress(0); deployBtn.disabled = false; deployBtn.textContent = "Deploy"; } }); -(async () => { +void (async () => { try { await app.connect(); addLog("Connected to host.", "info"); - } catch (err: any) { - console.error("Failed to connect app:", err); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error("Failed to connect app:", message); } })(); diff --git a/src/mcp/apps/deploy/vite.config.ts b/src/mcp/apps/deploy/vite.config.ts new file mode 100644 index 00000000000..36b263efe23 --- /dev/null +++ b/src/mcp/apps/deploy/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; +import * as path from "path"; + +export default defineConfig({ + plugins: [viteSingleFile()], + root: __dirname, + build: { + outDir: path.resolve(__dirname, "../../../../lib/mcp/apps/deploy"), + emptyOutDir: true, + rollupOptions: { + input: path.resolve(__dirname, "mcp-app.html"), + }, + }, +}); From dadc628ac867b69328c457eb1d0505b5c488d7f7 Mon Sep 17 00:00:00 2001 From: Joe Hanley Date: Thu, 9 Apr 2026 16:03:27 -0700 Subject: [PATCH 3/3] fix: remove unused variables in deploy_ui.ts --- src/mcp/resources/deploy_ui.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/mcp/resources/deploy_ui.ts b/src/mcp/resources/deploy_ui.ts index e819d930101..fa08e732b59 100644 --- a/src/mcp/resources/deploy_ui.ts +++ b/src/mcp/resources/deploy_ui.ts @@ -1,5 +1,4 @@ import { resource } from "../resource"; -import { McpContext } from "../types"; import * as path from "path"; import * as fs from "fs/promises"; @@ -13,7 +12,7 @@ export const deploy_ui = resource( description: "Visual interface for Firebase Deploy", mimeType: RESOURCE_MIME_TYPE, }, - async (uri: string, ctx: McpContext) => { + async () => { try { const htmlPath = path.join(__dirname, "../apps/deploy/mcp-app.html"); const html = await fs.readFile(htmlPath, "utf-8");