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

Choose a Firebase Project

+

Select an active Firebase project for your workspace.

+
+ +
+

Current Context

+

Project ID: -

+

User: -

+
+ + + + + +
+ +
+
+
+ + + diff --git a/src/mcp/apps/update_environment/mcp-app.ts b/src/mcp/apps/update_environment/mcp-app.ts new file mode 100644 index 00000000000..48e6e4483c9 --- /dev/null +++ b/src/mcp/apps/update_environment/mcp-app.ts @@ -0,0 +1,150 @@ +import { App, applyDocumentTheme, applyHostStyleVariables, applyHostFonts } from "@modelcontextprotocol/ext-apps"; + +const app = new App({ name: "Update Firebase Environment", version: "1.0.0" }); + +const projectListContainer = document.getElementById("project-list") as HTMLDivElement; +const searchInput = document.getElementById("search-input") as HTMLInputElement; +const submitBtn = document.getElementById("submit-btn") as HTMLButtonElement; +const statusBox = document.getElementById("status-box") as HTMLDivElement; + +let projects: any[] = []; +let filteredProjects: any[] = []; +let selectedProjectId: string | null = null; + +const envProjectIdEl = document.getElementById("env-project-id") as HTMLSpanElement; +const envUserEl = document.getElementById("env-user") as HTMLSpanElement; +const currentEnvBox = document.getElementById("current-env") as HTMLDivElement; + +function showStatus(message: string, type: "success" | "error" | "info") { + statusBox.textContent = message; + statusBox.className = `status ${type}`; + statusBox.style.display = "block"; +} + +function renderProjects() { + projectListContainer.innerHTML = ""; + + if (filteredProjects.length === 0) { + projectListContainer.innerHTML = ` + + `; + return; + } + + filteredProjects.forEach((p) => { + const item = document.createElement("div"); + item.className = "dropdown-item"; + if (p.projectId === selectedProjectId) { + item.classList.add("selected"); + } + + const displayName = p.displayName || p.projectId; + const projectId = p.projectId; + + item.innerHTML = ` +
${displayName}
+
${projectId}
+ `; + + item.onclick = () => { + selectedProjectId = projectId; + submitBtn.disabled = false; + renderProjects(); // Re-render to show selection + }; + + projectListContainer.appendChild(item); + }); +} + +searchInput.oninput = () => { + const query = searchInput.value.toLowerCase().trim(); + if (query === "") { + filteredProjects = projects; + } else { + filteredProjects = projects.filter((p) => { + const name = (p.displayName || p.projectId).toLowerCase(); + const id = p.projectId.toLowerCase(); + return name.includes(query) || id.includes(query); + }); + } + renderProjects(); +}; + +submitBtn.onclick = async () => { + if (!selectedProjectId) return; + + submitBtn.disabled = true; + showStatus(`Updating active project to ${selectedProjectId}...`, "info"); + + try { + const result = await app.callServerTool({ + name: "firebase_update_environment", + arguments: { active_project: selectedProjectId }, + }); + + const textContent = result.content?.find((c: any) => c.type === "text"); + const text = textContent ? (textContent as any).text : "Update complete."; + + if (result.isError) { + showStatus(text, "error"); + submitBtn.disabled = false; + } else { + showStatus(text, "success"); + } + } catch (err: any) { + showStatus(`Error updating environment: ${err.message}`, "error"); + submitBtn.disabled = false; + } +}; + +app.ontoolresult = (result) => { + // We can handle tool results if needed, but we rely on manual triggers for list_projects +}; + +app.onhostcontextchanged = (ctx) => { + if (ctx.theme) applyDocumentTheme(ctx.theme); + if (ctx.styles?.variables) applyHostStyleVariables(ctx.styles.variables); + if (ctx.styles?.css?.fonts) applyHostFonts(ctx.styles.css.fonts); + if (ctx.safeAreaInsets) { + const { top, right, bottom, left } = ctx.safeAreaInsets; + document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`; + } +}; + +(async () => { + try { + await app.connect(); + showStatus("Connecting to server...", "info"); + + // Fetch current environment + try { + const envResult = await app.callServerTool({ name: "firebase_get_environment", arguments: {} }); + const envData = envResult.structuredContent as any; + if (envData) { + envProjectIdEl.textContent = envData.projectId || ""; + envUserEl.textContent = envData.authenticatedUser || ""; + } + } catch (err: any) { + console.error("Failed to fetch environment:", err); + showStatus(`Failed to fetch environment: ${err.message}`, "error"); + } + + // Fetch projects on load + const result = await app.callServerTool({ name: "firebase_list_projects", arguments: {} }); + const data = result.structuredContent as any; + + if (data && data.projects) { + projects = data.projects; + filteredProjects = projects; + renderProjects(); + showStatus("Projects loaded successfully.", "success"); + setTimeout(() => { if (statusBox.className === "status success") statusBox.style.display = "none"; }, 3000); + } else { + showStatus("No projects returned from server.", "error"); + } + } catch (err: any) { + showStatus(`Failed to load projects: ${err.message}`, "error"); + } +})(); diff --git a/src/mcp/resources/update_environment_ui.ts b/src/mcp/resources/update_environment_ui.ts new file mode 100644 index 00000000000..1b7740eb7e1 --- /dev/null +++ b/src/mcp/resources/update_environment_ui.ts @@ -0,0 +1,33 @@ +import { resource } from "../resource"; +import { McpContext } from "../types"; +import * as path from "path"; +import * as fs from "fs/promises"; + +export const RESOURCE_MIME_TYPE = "application/vnd.mcp.ext-app+html"; +const resourceUri = "ui://core/update_environment/mcp-app.html"; + +export const update_environment_ui = resource( + { + uri: resourceUri, + name: "Update Environment UI", + description: "Visual interface for selecting active Firebase project", + mimeType: RESOURCE_MIME_TYPE, + }, + async (uri: string, ctx: McpContext) => { + try { + const htmlPath = path.join(__dirname, "../apps/update_environment/mcp-app.html"); + const html = await fs.readFile(htmlPath, "utf-8"); + return { + contents: [ + { + uri: resourceUri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }, + ], + }; + } catch (e: any) { + throw new Error(`Failed to load Update Environment UI: ${e.message}`); + } + }, +);