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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
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();