diff --git a/src/gcp/apphosting.ts b/src/gcp/apphosting.ts index 8d734426fc6..832dced595e 100644 --- a/src/gcp/apphosting.ts +++ b/src/gcp/apphosting.ts @@ -333,6 +333,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..c9e3921aed0 100644 --- a/src/gcp/run.ts +++ b/src/gcp/run.ts @@ -1,6 +1,6 @@ import { Client } from "../apiv2"; import { FirebaseError } from "../error"; -import { runOrigin } from "../api"; +import { cloudloggingOrigin, runOrigin } from "../api"; import * as proto from "./proto"; import * as iam from "./iam"; import { backoff } from "../throttler/throttler"; @@ -344,3 +344,72 @@ export async function setInvokerUpdate( }; await setIamPolicy(serviceName, policy, httpClient); } + +interface EntriesListRequest { + resourceNames: string[]; + filter?: string; + orderBy?: string; + pageSize?: number; + pageToken?: string; +} + +interface EntriesListResponse { + entries?: LogEntry[]; + nextPageToken?: string; +} + +interface LogEntry { + logName: string; + resource: unknown; + timestamp: string; + receiveTimestamp: string; + httpRequest?: unknown; + + protoPayload?: unknown; + textPayload?: string; + jsonPayload?: unknown; + + severity: + | "DEFAULT" + | "DEBUG" + | "INFO" + | "NOTICE" + | "WARNING" + | "ERROR" + | "CRITICAL" + | "ALERT" + | "EMERGENCY"; +} + +/** + * 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 loggingClient = new Client({ + urlPrefix: cloudloggingOrigin(), + apiVersion: "v2", + }); + + const requestBody: EntriesListRequest = { + resourceNames: [`projects/${projectId}`], + filter: `resource.type="cloud_run_revision" AND resource.labels.service_name="${serviceId}"`, + orderBy: "timestamp desc", + pageSize: 100, + }; + + try { + const response = await loggingClient.post( + "/entries:list", + requestBody, + ); + return response.body.entries || []; + } catch (err: any) { + throw new FirebaseError(`Failed to fetch logs for Cloud Run service ${serviceId}`, { + original: err, + status: err?.context?.response?.statusCode, + }); + } +} diff --git a/src/mcp/tools/apphosting/index.ts b/src/mcp/tools/apphosting/index.ts new file mode 100644 index 00000000000..d036475d488 --- /dev/null +++ b/src/mcp/tools/apphosting/index.ts @@ -0,0 +1,4 @@ +import { ServerTool } from "../../tool"; +import { list_backends } from "./list_backends"; + +export const appHostingTools: ServerTool[] = [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..7b83dcb9eb5 100644 --- a/src/mcp/tools/index.ts +++ b/src/mcp/tools/index.ts @@ -8,6 +8,8 @@ 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"; +import { runTools } from "./run/index.js"; /** availableTools returns the list of MCP tools available given the server flags */ export function availableTools(activeFeatures?: ServerFeature[]): ServerTool[] { @@ -30,6 +32,8 @@ const tools: Record = { messaging: addFeaturePrefix("messaging", messagingTools), remoteconfig: addFeaturePrefix("remoteconfig", remoteConfigTools), crashlytics: addFeaturePrefix("crashlytics", crashlyticsTools), + apphosting: addFeaturePrefix("apphosting", appHostingTools), + run: addFeaturePrefix("run", runTools), }; function addFeaturePrefix(feature: string, tools: ServerTool[]): ServerTool[] { diff --git a/src/mcp/tools/run/fetch_logs.ts b/src/mcp/tools/run/fetch_logs.ts new file mode 100644 index 00000000000..3890e7bc39e --- /dev/null +++ b/src/mcp/tools/run/fetch_logs.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; +import { tool } from "../../tool.js"; +import { toContent } from "../../util.js"; +import { NO_PROJECT_ERROR } from "../../errors.js"; +import { fetchServiceLogs } from "../../../gcp/run.js"; + +export const fetch_logs = tool( + { + name: "fetch_logs", + description: + "Fetches recent logs for a Cloud Run service. Includes details such as the message, severity, and timestamp.", + inputSchema: z.object({ + serviceId: z.string().describe("The Cloud Run service ID."), + }), + annotations: { + title: "Fetch recent Cloud Run service logs.", + readOnlyHint: true, + }, + _meta: { + requiresAuth: true, + requiresProject: true, + }, + }, + async ({ serviceId } = {}, { projectId }) => { + if (!projectId) return NO_PROJECT_ERROR; + if (!serviceId) return toContent("A Cloud Run service ID must be provided."); + const data = await fetchServiceLogs(projectId, serviceId); + return toContent(data); + }, +); diff --git a/src/mcp/tools/run/index.ts b/src/mcp/tools/run/index.ts new file mode 100644 index 00000000000..0dac7c3f910 --- /dev/null +++ b/src/mcp/tools/run/index.ts @@ -0,0 +1,4 @@ +import { ServerTool } from "../../tool"; +import { fetch_logs } from "./fetch_logs"; + +export const runTools: ServerTool[] = [fetch_logs]; diff --git a/src/mcp/types.ts b/src/mcp/types.ts index c709886437e..7db4fcedd4f 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -6,6 +6,8 @@ export const SERVER_FEATURES = [ "messaging", "remoteconfig", "crashlytics", + "apphosting", + "run", ] as const; export type ServerFeature = (typeof SERVER_FEATURES)[number]; diff --git a/src/mcp/util.ts b/src/mcp/util.ts index c4be462bf01..501df37471a 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, @@ -11,6 +12,7 @@ import { remoteConfigApiOrigin, storageOrigin, crashlyticsApiOrigin, + cloudRunApiOrigin, } from "../api"; import { check } from "../ensureApiEnabled"; @@ -87,6 +89,8 @@ const SERVER_FEATURE_APIS: Record = { messaging: messagingApiOrigin(), remoteconfig: remoteConfigApiOrigin(), crashlytics: crashlyticsApiOrigin(), + apphosting: apphostingOrigin(), + run: cloudRunApiOrigin(), }; /**