Skip to content

Commit b2dae54

Browse files
taeoldjoehan
andauthored
add MCP tool to get functions log tool (#9231)
* add Functions MCP log tool with pagination support * respond to gemini comments. * nit: formaaters and simplifications. --------- Co-authored-by: Joe Hanley <[email protected]>
1 parent 7313c43 commit b2dae54

File tree

12 files changed

+254
-20
lines changed

12 files changed

+254
-20
lines changed

src/commands/functions-log.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const command = new Command("functions:log")
2828
opn(url);
2929
return;
3030
}
31-
const entries = await cloudlogging.listEntries(
31+
const { entries } = await cloudlogging.listEntries(
3232
projectId,
3333
apiFilter,
3434
options.lines || 35,

src/gcp/cloudlogging.spec.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe("cloudlogging", () => {
2525

2626
await expect(
2727
cloudlogging.listEntries("project", "filter", 10, "desc"),
28-
).to.eventually.deep.equal(entries);
28+
).to.eventually.deep.equal({ entries, nextPageToken: undefined });
2929
});
3030

3131
it("should reject if the API call fails", async () => {
@@ -36,5 +36,22 @@ describe("cloudlogging", () => {
3636
"Failed to retrieve log entries from Google Cloud.",
3737
);
3838
});
39+
40+
it("should include nextPageToken when provided", async () => {
41+
const entries = [{ logName: "log1" }];
42+
nock(cloudloggingOrigin())
43+
.post("/v2/entries:list", (body) => {
44+
expect(body.pageToken).to.equal("token");
45+
return true;
46+
})
47+
.reply(200, { entries, nextPageToken: "next" });
48+
49+
await expect(
50+
cloudlogging.listEntries("project", "filter", 10, "asc", "token"),
51+
).to.eventually.deep.equal({
52+
entries,
53+
nextPageToken: "next",
54+
});
55+
});
3956
});
4057
});

src/gcp/cloudlogging.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,27 +24,49 @@ export interface LogEntry {
2424
jsonPayload?: any;
2525
}
2626

27+
interface ListEntriesRequest {
28+
resourceNames: string[];
29+
filter: string;
30+
orderBy: string;
31+
pageSize: number;
32+
pageToken?: string;
33+
}
34+
35+
interface ListEntriesResponse {
36+
entries?: LogEntry[];
37+
nextPageToken?: string;
38+
}
39+
2740
/**
28-
* GCP api call to list all log entries (https://cloud.google.com/logging/docs/reference/v2/rest/v2/entries/list)
41+
* Lists Cloud Logging entries with optional pagination support.
42+
* Ref: https://cloud.google.com/logging/docs/reference/v2/rest/v2/entries/list
2943
*/
3044
export async function listEntries(
3145
projectId: string,
3246
filter: string,
3347
pageSize: number,
3448
order: string,
35-
): Promise<LogEntry[]> {
49+
pageToken?: string,
50+
): Promise<{ entries: LogEntry[]; nextPageToken?: string }> {
3651
const client = new Client({ urlPrefix: cloudloggingOrigin(), apiVersion: API_VERSION });
52+
const body: ListEntriesRequest = {
53+
resourceNames: [`projects/${projectId}`],
54+
filter,
55+
orderBy: `timestamp ${order}`,
56+
pageSize,
57+
};
58+
if (pageToken) {
59+
body.pageToken = pageToken;
60+
}
3761
try {
38-
const result = await client.post<
39-
{ resourceNames: string[]; filter: string; orderBy: string; pageSize: number },
40-
{ entries: LogEntry[] }
41-
>("/entries:list", {
42-
resourceNames: [`projects/${projectId}`],
43-
filter: filter,
44-
orderBy: `timestamp ${order}`,
45-
pageSize: pageSize,
46-
});
47-
return result.body.entries;
62+
const result = await client.post<ListEntriesRequest, ListEntriesResponse>(
63+
"/entries:list",
64+
body,
65+
);
66+
return {
67+
entries: result.body.entries ?? [],
68+
nextPageToken: result.body.nextPageToken,
69+
};
4870
} catch (err: any) {
4971
throw new FirebaseError("Failed to retrieve log entries from Google Cloud.", {
5072
original: err,

src/gcp/run.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,8 +358,8 @@ export async function fetchServiceLogs(projectId: string, serviceId: string): Pr
358358
const order = "desc";
359359

360360
try {
361-
const entries = await listEntries(projectId, filter, pageSize, order);
362-
return entries || [];
361+
const { entries } = await listEntries(projectId, filter, pageSize, order);
362+
return entries;
363363
} catch (err: any) {
364364
throw new FirebaseError(`Failed to fetch logs for Cloud Run service ${serviceId}`, {
365365
original: err,

src/mcp/prompts/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const prompts: Record<ServerFeature, ServerPrompt[]> = {
1111
dataconnect: dataconnectPrompts,
1212
auth: [],
1313
messaging: [],
14+
functions: [],
1415
remoteconfig: [],
1516
crashlytics: crashlyticsPrompts,
1617
apphosting: [],

src/mcp/tools/apphosting/fetch_logs.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ describe("fetch_logs tool", () => {
9898
getBackendStub.resolves(backend);
9999
getTrafficStub.resolves(traffic);
100100
listBuildsStub.resolves(builds);
101-
listEntriesStub.resolves(logEntries);
101+
listEntriesStub.resolves({ entries: logEntries });
102102

103103
const result = await fetch_logs.fn({ buildLogs: true, backendId, location }, {
104104
projectId,
@@ -119,7 +119,7 @@ describe("fetch_logs tool", () => {
119119
getBackendStub.resolves(backend);
120120
getTrafficStub.resolves(traffic);
121121
listBuildsStub.resolves(builds);
122-
listEntriesStub.resolves([]);
122+
listEntriesStub.resolves({ entries: [] });
123123

124124
const result = await fetch_logs.fn({ buildLogs: true, backendId, location }, {
125125
projectId,

src/mcp/tools/apphosting/fetch_logs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ export const fetch_logs = tool(
6161
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
6262
const timestampFilter = `timestamp >= "${thirtyDaysAgo.toISOString()}"`;
6363
const filter = `resource.type="build" resource.labels.build_id="${buildId}" ${timestampFilter}`;
64-
const entries = await listEntries(projectId, filter, 100, "asc");
65-
if (!Array.isArray(entries) || !entries.length) {
64+
const { entries } = await listEntries(projectId, filter, 100, "asc");
65+
if (!entries.length) {
6666
return toContent("No logs found.");
6767
}
6868
return toContent(entries);
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { z } from "zod";
2+
3+
import { tool } from "../../tool";
4+
import { mcpError, toContent } from "../../util";
5+
import { getApiFilter } from "../../../functions/functionslog";
6+
import { listEntries } from "../../../gcp/cloudlogging";
7+
8+
const SEVERITY_LEVELS = [
9+
"DEFAULT",
10+
"DEBUG",
11+
"INFO",
12+
"NOTICE",
13+
"WARNING",
14+
"ERROR",
15+
"CRITICAL",
16+
"ALERT",
17+
"EMERGENCY",
18+
] as const;
19+
20+
// normalizeFunctionSelectors standardizes tool input into the comma-separated
21+
// list that the existing logging filter helper expects (matching CLI behaviour).
22+
function normalizeFunctionSelectors(selectors?: string | string[]): string | undefined {
23+
if (!selectors) return undefined;
24+
if (Array.isArray(selectors)) {
25+
const cleaned = selectors.map((name) => name.trim()).filter(Boolean);
26+
return cleaned.length ? cleaned.join(",") : undefined;
27+
}
28+
const cleaned = selectors
29+
.split(/[,\s]+/)
30+
.map((name) => name.trim())
31+
.filter(Boolean);
32+
return cleaned.length ? cleaned.join(",") : undefined;
33+
}
34+
35+
function validateTimestamp(label: string, value: string): string | null {
36+
const parsed = Date.parse(value);
37+
if (Number.isNaN(parsed)) {
38+
return `${label} must be an RFC3339/ISO 8601 timestamp, received '${value}'.`;
39+
}
40+
return null;
41+
}
42+
43+
export const get_logs = tool(
44+
{
45+
name: "get_logs",
46+
description:
47+
"Retrieves a page of Cloud Functions log entries using Google Cloud Logging advanced filters.",
48+
inputSchema: z.object({
49+
function_names: z
50+
.union([z.string(), z.array(z.string()).min(1)])
51+
.optional()
52+
.describe(
53+
"Optional list of deployed Cloud Function names to filter logs (string or array).",
54+
),
55+
page_size: z
56+
.number()
57+
.int()
58+
.min(1)
59+
.max(1000)
60+
.default(50)
61+
.describe("Maximum number of log entries to return."),
62+
order: z.enum(["asc", "desc"]).default("desc").describe("Sort order by timestamp"),
63+
page_token: z
64+
.string()
65+
.optional()
66+
.describe("Opaque page token returned from a previous call to continue pagination."),
67+
min_severity: z
68+
.enum(SEVERITY_LEVELS)
69+
.optional()
70+
.describe("Filters results to entries at or above the provided severity level."),
71+
start_time: z
72+
.string()
73+
.optional()
74+
.describe(
75+
"RFC3339 timestamp (YYYY-MM-DDTHH:MM:SSZ). Only entries with timestamp greater than or equal to this are returned.",
76+
),
77+
end_time: z
78+
.string()
79+
.optional()
80+
.describe(
81+
"RFC3339 timestamp (YYYY-MM-DDTHH:MM:SSZ). Only entries with timestamp less than or equal to this are returned.",
82+
),
83+
filter: z
84+
.string()
85+
.optional()
86+
.describe(
87+
"Additional Google Cloud Logging advanced filter text that will be AND'ed with the generated filter.",
88+
),
89+
}),
90+
annotations: {
91+
title: "Get Functions Logs from Cloud Logging",
92+
readOnlyHint: true,
93+
openWorldHint: true,
94+
},
95+
_meta: {
96+
requiresAuth: true,
97+
requiresProject: true,
98+
},
99+
},
100+
async (
101+
{ function_names, page_size, order, page_token, min_severity, start_time, end_time, filter },
102+
{ projectId },
103+
) => {
104+
const resolvedOrder = order;
105+
const resolvedPageSize = page_size;
106+
107+
const normalizedSelectors = normalizeFunctionSelectors(function_names);
108+
const filterParts: string[] = [getApiFilter(normalizedSelectors)];
109+
110+
if (min_severity) {
111+
filterParts.push(`severity>="${min_severity}"`);
112+
}
113+
if (start_time) {
114+
const error = validateTimestamp("start_time", start_time);
115+
if (error) return mcpError(error);
116+
filterParts.push(`timestamp>="${start_time}"`);
117+
}
118+
if (end_time) {
119+
const error = validateTimestamp("end_time", end_time);
120+
if (error) return mcpError(error);
121+
filterParts.push(`timestamp<="${end_time}"`);
122+
}
123+
if (start_time && end_time && Date.parse(start_time) > Date.parse(end_time)) {
124+
return mcpError("start_time must be less than or equal to end_time.");
125+
}
126+
if (filter) {
127+
filterParts.push(`(${filter})`);
128+
}
129+
130+
const combinedFilter = filterParts.join("\n");
131+
132+
try {
133+
const { entries, nextPageToken } = await listEntries(
134+
projectId,
135+
combinedFilter,
136+
resolvedPageSize,
137+
resolvedOrder,
138+
page_token,
139+
);
140+
141+
const formattedEntries = entries.map((entry) => {
142+
const functionName =
143+
entry.resource?.labels?.function_name ?? entry.resource?.labels?.service_name ?? null;
144+
const payload =
145+
entry.textPayload ?? entry.jsonPayload ?? entry.protoPayload ?? entry.labels ?? null;
146+
return {
147+
timestamp: entry.timestamp ?? entry.receiveTimestamp ?? null,
148+
severity: entry.severity ?? "DEFAULT",
149+
function: functionName,
150+
message:
151+
entry.textPayload ??
152+
(entry.jsonPayload ? JSON.stringify(entry.jsonPayload) : undefined) ??
153+
(entry.protoPayload ? JSON.stringify(entry.protoPayload) : undefined) ??
154+
"",
155+
payload,
156+
log_name: entry.logName,
157+
trace: entry.trace ?? null,
158+
span_id: entry.spanId ?? null,
159+
};
160+
});
161+
162+
const response = {
163+
filter: combinedFilter,
164+
order: resolvedOrder,
165+
page_size: resolvedPageSize,
166+
entries: resolvedOrder === "asc" ? formattedEntries : formattedEntries.reverse(),
167+
next_page_token: nextPageToken ?? null,
168+
has_more: Boolean(nextPageToken),
169+
};
170+
171+
if (!entries.length) {
172+
return toContent(response, {
173+
contentPrefix: "No log entries matched the provided filters.\n\n",
174+
});
175+
}
176+
177+
return toContent(response);
178+
} catch (err) {
179+
const message =
180+
err instanceof Error ? err.message : "Failed to retrieve Cloud Logging entries.";
181+
return mcpError(message);
182+
}
183+
},
184+
);

src/mcp/tools/functions/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { ServerTool } from "../../tool";
2+
3+
import { get_logs } from "./get_logs";
4+
5+
export const functionsTools: ServerTool[] = [get_logs];

src/mcp/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { remoteConfigTools } from "./remoteconfig/index";
1010
import { crashlyticsTools } from "./crashlytics/index";
1111
import { appHostingTools } from "./apphosting/index";
1212
import { realtimeDatabaseTools } from "./database/index";
13+
import { functionsTools } from "./functions/index";
1314

1415
/** availableTools returns the list of MCP tools available given the server flags */
1516
export function availableTools(activeFeatures?: ServerFeature[]): ServerTool[] {
@@ -33,6 +34,7 @@ const tools: Record<ServerFeature, ServerTool[]> = {
3334
dataconnect: addFeaturePrefix("dataconnect", dataconnectTools),
3435
storage: addFeaturePrefix("storage", storageTools),
3536
messaging: addFeaturePrefix("messaging", messagingTools),
37+
functions: addFeaturePrefix("functions", functionsTools),
3638
remoteconfig: addFeaturePrefix("remoteconfig", remoteConfigTools),
3739
crashlytics: addFeaturePrefix("crashlytics", crashlyticsTools),
3840
apphosting: addFeaturePrefix("apphosting", appHostingTools),

0 commit comments

Comments
 (0)