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 a111f25b4c6..c89b5c7bad2 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 });\"", @@ -185,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", @@ -266,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": { 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/experiments.ts b/src/experiments.ts index 909a9a89425..c4bd11b44db 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -179,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/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..b509973e814 --- /dev/null +++ b/src/mcp/apps/init/mcp-app.ts @@ -0,0 +1,274 @@ +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; + +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") { + 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 + 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 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: unknown) { + const message = err instanceof Error ? err.message : String(err); + setStatus(`Error: ${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: {}, + }); + 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: 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: 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); + } else { + setStatus("No projects returned from server.", "error"); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + setStatus(`Failed to load projects: ${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/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/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..95918b5b288 --- /dev/null +++ b/src/mcp/apps/update_environment/mcp-app.ts @@ -0,0 +1,160 @@ +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; + +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; + } +}; + +// 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 +}; + +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/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"), + }, + }, +}); diff --git a/src/mcp/resources/index.ts b/src/mcp/resources/index.ts index 5a007ceafd2..4436b6ec0d9 100644 --- a/src/mcp/resources/index.ts +++ b/src/mcp/resources/index.ts @@ -13,6 +13,8 @@ import { ServerResource, ServerResourceTemplate } from "../resource"; import { trackGA4 } from "../../track"; import { crashlytics_issues } from "./guides/crashlytics_issues"; import { crashlytics_reports } from "./guides/crashlytics_reports"; +import { update_environment_ui } from "./update_environment_ui"; +import { init_ui } from "./init_ui"; export const resources = [ app_id, @@ -25,6 +27,8 @@ export const resources = [ init_firestore_rules, init_auth, init_hosting, + update_environment_ui, + init_ui, ]; export const resourceTemplates = [docs]; diff --git a/src/mcp/resources/init_ui.ts b/src/mcp/resources/init_ui.ts new file mode 100644 index 00000000000..0834010613a --- /dev/null +++ b/src/mcp/resources/init_ui.ts @@ -0,0 +1,35 @@ +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, + }, + // 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"); + 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}`); + } + }, +); diff --git a/src/mcp/resources/update_environment_ui.ts b/src/mcp/resources/update_environment_ui.ts new file mode 100644 index 00000000000..0657d7e91a8 --- /dev/null +++ b/src/mcp/resources/update_environment_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 = "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, + }, + // 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"); + return { + contents: [ + { + uri: resourceUri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }, + ], + }; + } catch (e: any) { + throw new Error(`Failed to load Update Environment UI: ${e.message}`); + } + }, +); diff --git a/src/mcp/tools/core/deploy.ts b/src/mcp/tools/core/deploy.ts new file mode 100644 index 00000000000..db10fb3580a --- /dev/null +++ b/src/mcp/tools/core/deploy.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; +import { tool } from "../../tool"; +import { deploy as coreDeploy, TARGETS, DeployOptions } from "../../../deploy"; +import { toContent, applyAppMeta } 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, + }, + }, + async ({ only }, ctx) => { + const validTargets = Object.keys(TARGETS); + 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 = { + only: only || "", + except: "", + filteredTargets: targets, + project: ctx.projectId, + projectId: ctx.projectId, + rc: ctx.rc, + config: ctx.config, + nonInteractive: true, + onProgress: (progress: { phase: string; targets?: string[] }) => { + 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 + void (async () => { + try { + 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: unknown) { + const message = err instanceof Error ? err.message : String(err); + jobTracker.updateJob(jobId, { status: "failed", error: message }); + } + })(); + + 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/tools/core/deploy_status.ts b/src/mcp/tools/core/deploy_status.ts new file mode 100644 index 00000000000..b102bbd6a9a --- /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 }) => { + 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, + }; + }, +); diff --git a/src/mcp/util.spec.ts b/src/mcp/util.spec.ts index 1344c950725..14ba93604c5 100644 --- a/src/mcp/util.spec.ts +++ b/src/mcp/util.spec.ts @@ -1,5 +1,8 @@ import { expect } from "chai"; -import { cleanSchema } from "./util"; +import * as sinon from "sinon"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import * as experiments from "../experiments"; +import { cleanSchema, applyAppMeta } from "./util"; interface TestCase { desc: string; @@ -474,3 +477,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: CallToolResult = { content: [{ type: "text", text: "hello" }] }; + 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: 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 4d7afc2f1d9..2ed4fd06859 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,27 @@ 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: { + ...result._meta, + ui: { + resourceUri, + }, + }, + }; + } + return result; +} + /** * Returns an error message to the user. */ diff --git a/src/mcp/util/jobs.ts b/src/mcp/util/jobs.ts new file mode 100644 index 00000000000..450b858ca52 --- /dev/null +++ b/src/mcp/util/jobs.ts @@ -0,0 +1,40 @@ +export interface Job { + status: "running" | "success" | "failed"; + progress: number; + logs: string[]; + result?: any; + error?: string; + [key: string]: unknown; +} + +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();