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/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..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/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];
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}`);
+ }
+ },
+);
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.
*/