diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a74a8c500f..c0d14388d5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Adds MCP tools for App Hosting (#8605) - Fixed crash when starting the App Hosting emulator in certain applications (#8624) - Fixed issue where, with `webframeworks` enabled, `firebase init hosting` re-prompts users for source. (#8587) - Update typescript version in functions template to avoid build issue with @google-cloud/storage depedency (#8194) diff --git a/scripts/mcp-tests/gemini-smoke-test.ts b/scripts/mcp-tests/gemini-smoke-test.ts index d0c894167ba..fcb268b59e2 100644 --- a/scripts/mcp-tests/gemini-smoke-test.ts +++ b/scripts/mcp-tests/gemini-smoke-test.ts @@ -17,7 +17,7 @@ await client.connect( args: [ "experimental:mcp", "--only", - "firestore,dataconnect,messaging,remoteconfig,crashlytics,auth,storage", + "firestore,dataconnect,messaging,remoteconfig,crashlytics,auth,storage,apphosting", ], }), ); diff --git a/src/gcp/apphosting.ts b/src/gcp/apphosting.ts index 8d734426fc6..0f711abc2db 100644 --- a/src/gcp/apphosting.ts +++ b/src/gcp/apphosting.ts @@ -42,6 +42,11 @@ export interface Backend { uri: string; serviceAccount?: string; appId?: string; + managedResources?: ManagedResource[]; +} + +export interface ManagedResource { + runService: { service: string }; } export type BackendOutputOnlyFields = "name" | "createTime" | "updateTime" | "uri"; @@ -333,6 +338,19 @@ export async function getBackend( return res.body; } +/** + * Gets traffic details. + */ +export async function getTraffic( + projectId: string, + location: string, + backendId: string, +): Promise { + const name = `projects/${projectId}/locations/${location}/backends/${backendId}/traffic`; + const res = await client.get(name); + return res.body; +} + /** * List all backends present in a project and location. */ diff --git a/src/gcp/run.ts b/src/gcp/run.ts index 286f12e82f9..3c075f3b220 100644 --- a/src/gcp/run.ts +++ b/src/gcp/run.ts @@ -5,6 +5,7 @@ import * as proto from "./proto"; import * as iam from "./iam"; import { backoff } from "../throttler/throttler"; import { logger } from "../logger"; +import { listEntries, LogEntry } from "./cloudlogging"; const API_VERSION = "v1"; @@ -344,3 +345,25 @@ export async function setInvokerUpdate( }; await setIamPolicy(serviceName, policy, httpClient); } + +/** + * Fetches recent logs for a given Cloud Run service using the Cloud Logging API. + * @param projectId The Google Cloud project ID. + * @param serviceId The resource name of the Cloud Run service. + * @return A promise that resolves with the log entries. + */ +export async function fetchServiceLogs(projectId: string, serviceId: string): Promise { + const filter = `resource.type="cloud_run_revision" AND resource.labels.service_name="${serviceId}"`; + const pageSize = 100; + const order = "desc"; + + try { + const entries = await listEntries(projectId, filter, pageSize, order); + return entries || []; + } catch (err: any) { + throw new FirebaseError(`Failed to fetch logs for Cloud Run service ${serviceId}`, { + original: err, + status: (err as any)?.context?.response?.statusCode, + }); + } +} diff --git a/src/mcp/tools/apphosting/fetch_logs.ts b/src/mcp/tools/apphosting/fetch_logs.ts new file mode 100644 index 00000000000..99554b84a60 --- /dev/null +++ b/src/mcp/tools/apphosting/fetch_logs.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; +import { tool } from "../../tool.js"; +import { toContent } from "../../util.js"; +import { Backend, getBackend, getTraffic, listBuilds, Traffic } from "../../../gcp/apphosting.js"; +import { last } from "../../../utils.js"; +import { FirebaseError } from "../../../error.js"; +import { fetchServiceLogs } from "../../../gcp/run.js"; +import { listEntries } from "../../../gcp/cloudlogging.js"; + +export const fetch_logs = tool( + { + name: "fetch_logs", + description: + "Fetches the most recent logs for a specified App Hosting backend. If `buildLogs` is specified, the logs from the build process for the latest build are returned. The most recent logs are listed first.", + inputSchema: z.object({ + buildLogs: z + .boolean() + .default(false) + .describe( + "If specified, the logs for the most recent build will be returned instead of the logs for the service. The build logs are returned 'in order', to be read from top to bottom.", + ), + backendId: z.string().describe("The ID of the backend for which to fetch logs."), + location: z + .string() + .describe( + "The specific region for the backend. By default, if a backend is uniquely named across all locations, that one will be used.", + ), + }), + annotations: { + title: "Fetch logs for App Hosting backends and builds.", + readOnlyHint: true, + }, + _meta: { + requiresAuth: true, + requiresProject: true, + }, + }, + async ({ buildLogs, backendId, location } = {}, { projectId }) => { + projectId ||= ""; + location ||= ""; + if (!backendId) { + return toContent(`backendId must be specified.`); + } + const backend = await getBackend(projectId, location, backendId); + const traffic = await getTraffic(projectId, location, backendId); + const data: Backend & { traffic: Traffic } = { ...backend, traffic }; + + if (buildLogs) { + const builds = await listBuilds(projectId, location, backendId); + builds.builds.sort( + (a, b) => new Date(a.createTime).getTime() - new Date(b.createTime).getTime(), + ); + const build = last(builds.builds); + const r = new RegExp(`region=${location}/([0-9a-f-]+)?`); + const match = r.exec(build.buildLogsUri ?? ""); + if (!match) { + throw new FirebaseError("Unable to determine the build ID."); + } + const buildId = match[1]; + // Thirty days ago makes sure we get any saved data within the default retention period. + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + const timestampFilter = `timestamp >= "${thirtyDaysAgo.toISOString()}"`; + const filter = `resource.type="build" resource.labels.build_id="${buildId}" ${timestampFilter}`; + const entries = await listEntries(projectId, filter, 100, "asc"); + if (!Array.isArray(entries) || !entries.length) { + return toContent("No logs found."); + } + return toContent(entries); + } + + const serviceName = last(data.managedResources)?.runService.service; + if (!serviceName) { + throw new FirebaseError("Unable to get service name from managedResources."); + } + const serviceId = last(serviceName.split("/")); + const logs = await fetchServiceLogs(projectId, serviceId); + return toContent(logs); + }, +); diff --git a/src/mcp/tools/apphosting/index.ts b/src/mcp/tools/apphosting/index.ts new file mode 100644 index 00000000000..0c9c3919894 --- /dev/null +++ b/src/mcp/tools/apphosting/index.ts @@ -0,0 +1,5 @@ +import { ServerTool } from "../../tool"; +import { fetch_logs } from "./fetch_logs"; +import { list_backends } from "./list_backends"; + +export const appHostingTools: ServerTool[] = [fetch_logs, list_backends]; diff --git a/src/mcp/tools/apphosting/list_backends.ts b/src/mcp/tools/apphosting/list_backends.ts new file mode 100644 index 00000000000..e1aa1aaf7fa --- /dev/null +++ b/src/mcp/tools/apphosting/list_backends.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import { tool } from "../../tool.js"; +import { toContent } from "../../util.js"; +import { + Backend, + getTraffic, + listBackends, + parseBackendName, + Traffic, +} from "../../../gcp/apphosting.js"; + +export const list_backends = tool( + { + name: "list_backends", + description: + "Retrieves a list of App Hosting backends in the current project. An empty list means that there are no backends. " + + "The `uri` is the public URL of the backend. " + + "A working backend will have a `managed_resources` array that will contain a `run_service` entry. That `run_service.service` " + + "is the resource name of the Cloud Run service serving the App Hosting backend. The last segment of that name is the service ID.", + inputSchema: z.object({ + location: z + .string() + .optional() + .default("-") + .describe( + "Limit the listed backends to this region. By default, it will list all backends across all regions.", + ), + }), + annotations: { + title: "List App Hosting backends.", + readOnlyHint: true, + }, + _meta: { + requiresAuth: true, + requiresProject: true, + }, + }, + async ({ location } = {}, { projectId }) => { + projectId = projectId || ""; + if (!location) location = "-"; + const data: (Backend & { traffic: Traffic })[] = []; + const backends = await listBackends(projectId, location); + for (const backend of backends.backends) { + const { location, id } = parseBackendName(backend.name); + const traffic = await getTraffic(projectId, location, id); + data.push({ ...backend, traffic: traffic }); + } + if (!data.length) { + return toContent( + `No backends exist for project ${projectId}${location !== "-" ? ` in ${location}` : ""}.`, + ); + } + return toContent(data); + }, +); diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts index f650b493eaf..332a08838a6 100644 --- a/src/mcp/tools/index.ts +++ b/src/mcp/tools/index.ts @@ -8,6 +8,7 @@ import { storageTools } from "./storage/index.js"; import { messagingTools } from "./messaging/index.js"; import { remoteConfigTools } from "./remoteconfig/index.js"; import { crashlyticsTools } from "./crashlytics/index.js"; +import { appHostingTools } from "./apphosting/index.js"; /** availableTools returns the list of MCP tools available given the server flags */ export function availableTools(activeFeatures?: ServerFeature[]): ServerTool[] { @@ -30,6 +31,7 @@ const tools: Record = { messaging: addFeaturePrefix("messaging", messagingTools), remoteconfig: addFeaturePrefix("remoteconfig", remoteConfigTools), crashlytics: addFeaturePrefix("crashlytics", crashlyticsTools), + apphosting: addFeaturePrefix("apphosting", appHostingTools), }; function addFeaturePrefix(feature: string, tools: ServerTool[]): ServerTool[] { diff --git a/src/mcp/types.ts b/src/mcp/types.ts index c709886437e..784137b4ab4 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -6,6 +6,7 @@ export const SERVER_FEATURES = [ "messaging", "remoteconfig", "crashlytics", + "apphosting", ] as const; export type ServerFeature = (typeof SERVER_FEATURES)[number]; diff --git a/src/mcp/util.ts b/src/mcp/util.ts index 7ad9cada55e..256e139a91d 100644 --- a/src/mcp/util.ts +++ b/src/mcp/util.ts @@ -4,6 +4,7 @@ import { dump } from "js-yaml"; import { platform } from "os"; import { ServerFeature } from "./types"; import { + apphostingOrigin, authManagementOrigin, dataconnectOrigin, firestoreOrigin, @@ -87,6 +88,7 @@ const SERVER_FEATURE_APIS: Record = { messaging: messagingApiOrigin(), remoteconfig: remoteConfigApiOrigin(), crashlytics: crashlyticsApiOrigin(), + apphosting: apphostingOrigin(), }; /**