Skip to content

Commit 49bd4ef

Browse files
authored
Add a couple of tools to fetch issue details and samples for a given issue id. (#8995)
1 parent 36e40da commit 49bd4ef

File tree

9 files changed

+331
-2
lines changed

9 files changed

+331
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
- Added prefix support for multi-instance Cloud Functions extension parameters. (#8911)
22
- Fixed a bug when `firebase deploy --only dataconnect` doesn't include GQL in nested folders (#8981)
33
- Make it possible to init a dataconnect project in non interactive mode (#8993)
4+
- Added 2 new MCP tools for crashlytics `get_sample_crash_for_issue` and `get_issue_details` (#8995)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import * as chai from "chai";
2+
import * as nock from "nock";
3+
import * as chaiAsPromised from "chai-as-promised";
4+
5+
import { getIssueDetails } from "./getIssueDetails";
6+
import { FirebaseError } from "../error";
7+
import { crashlyticsApiOrigin } from "../api";
8+
9+
chai.use(chaiAsPromised);
10+
const expect = chai.expect;
11+
12+
describe("getIssueDetails", () => {
13+
const appId = "1:1234567890:android:abcdef1234567890";
14+
const requestProjectNumber = "1234567890";
15+
const issueId = "test_issue_id";
16+
17+
afterEach(() => {
18+
nock.cleanAll();
19+
});
20+
21+
it("should resolve with the response body on success", async () => {
22+
const mockResponse = { id: issueId, title: "Crash" };
23+
24+
nock(crashlyticsApiOrigin())
25+
.get(`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}`)
26+
.reply(200, mockResponse);
27+
28+
const result = await getIssueDetails(appId, issueId);
29+
30+
expect(result).to.deep.equal(mockResponse);
31+
expect(nock.isDone()).to.be.true;
32+
});
33+
34+
it("should throw a FirebaseError if the API call fails", async () => {
35+
nock(crashlyticsApiOrigin())
36+
.get(`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}`)
37+
.reply(500, { error: "Internal Server Error" });
38+
39+
await expect(getIssueDetails(appId, issueId)).to.be.rejectedWith(
40+
FirebaseError,
41+
/Failed to fetch the issue details/,
42+
);
43+
});
44+
45+
it("should throw a FirebaseError if the appId is invalid", async () => {
46+
const invalidAppId = "invalid-app-id";
47+
48+
await expect(getIssueDetails(invalidAppId, issueId)).to.be.rejectedWith(
49+
FirebaseError,
50+
"Unable to get the projectId from the AppId.",
51+
);
52+
});
53+
});

src/crashlytics/getIssueDetails.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Client } from "../apiv2";
2+
import { logger } from "../logger";
3+
import { FirebaseError } from "../error";
4+
import { crashlyticsApiOrigin } from "../api";
5+
6+
const TIMEOUT = 10000;
7+
8+
const apiClient = new Client({
9+
urlPrefix: crashlyticsApiOrigin(),
10+
apiVersion: "v1alpha",
11+
});
12+
13+
export async function getIssueDetails(appId: string, issueId: string): Promise<string> {
14+
try {
15+
const requestProjectNumber = parseProjectNumber(appId);
16+
if (requestProjectNumber === undefined) {
17+
throw new FirebaseError("Unable to get the projectId from the AppId.");
18+
}
19+
20+
const response = await apiClient.request<void, string>({
21+
method: "GET",
22+
headers: {
23+
"Content-Type": "application/json",
24+
},
25+
path: `/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}`,
26+
timeout: TIMEOUT,
27+
});
28+
29+
return response.body;
30+
} catch (err: any) {
31+
logger.debug(err.message);
32+
throw new FirebaseError(
33+
`Failed to fetch the issue details for the Firebase AppId ${appId}, IssueId ${issueId}. Error: ${err}.`,
34+
{ original: err },
35+
);
36+
}
37+
}
38+
39+
function parseProjectNumber(appId: string): string | undefined {
40+
const appIdParts = appId.split(":");
41+
if (appIdParts.length > 1) {
42+
return appIdParts[1];
43+
}
44+
return undefined;
45+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import * as chai from "chai";
2+
import * as nock from "nock";
3+
import * as chaiAsPromised from "chai-as-promised";
4+
5+
import { getSampleCrash } from "./getSampleCrash";
6+
import { FirebaseError } from "../error";
7+
import { crashlyticsApiOrigin } from "../api";
8+
9+
chai.use(chaiAsPromised);
10+
const expect = chai.expect;
11+
12+
describe("getSampleCrash", () => {
13+
const appId = "1:1234567890:android:abcdef1234567890";
14+
const requestProjectNumber = "1234567890";
15+
const issueId = "test_issue_id";
16+
const variantId = "test_variant_id";
17+
const sampleCount = 10;
18+
19+
afterEach(() => {
20+
nock.cleanAll();
21+
});
22+
23+
it("should resolve with the response body on success", async () => {
24+
const mockResponse = { events: [{ event_id: "1" }] };
25+
26+
nock(crashlyticsApiOrigin())
27+
.get(`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/events`)
28+
.query({
29+
"filter.issue.id": issueId,
30+
page_size: String(sampleCount),
31+
})
32+
.reply(200, mockResponse);
33+
34+
const result = await getSampleCrash(appId, issueId, sampleCount, undefined);
35+
36+
expect(result).to.deep.equal(mockResponse);
37+
expect(nock.isDone()).to.be.true;
38+
});
39+
40+
it("should resolve with the response body on success with variantId", async () => {
41+
const mockResponse = { events: [{ event_id: "1" }] };
42+
43+
nock(crashlyticsApiOrigin())
44+
.get(`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/events`)
45+
.query({
46+
"filter.issue.id": issueId,
47+
"filter.issue.variant_id": variantId,
48+
page_size: String(sampleCount),
49+
})
50+
.reply(200, mockResponse);
51+
52+
const result = await getSampleCrash(appId, issueId, sampleCount, variantId);
53+
54+
expect(result).to.deep.equal(mockResponse);
55+
expect(nock.isDone()).to.be.true;
56+
});
57+
58+
it("should throw a FirebaseError if the API call fails", async () => {
59+
nock(crashlyticsApiOrigin())
60+
.get(`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/events`)
61+
.query({
62+
"filter.issue.id": issueId,
63+
page_size: String(sampleCount),
64+
})
65+
.reply(500, { error: "Internal Server Error" });
66+
67+
await expect(getSampleCrash(appId, issueId, sampleCount, undefined)).to.be.rejectedWith(
68+
FirebaseError,
69+
/Failed to fetch the same crash/,
70+
);
71+
});
72+
73+
it("should throw a FirebaseError if the appId is invalid", async () => {
74+
const invalidAppId = "invalid-app-id";
75+
76+
await expect(getSampleCrash(invalidAppId, issueId, sampleCount, undefined)).to.be.rejectedWith(
77+
FirebaseError,
78+
"Unable to get the projectId from the AppId.",
79+
);
80+
});
81+
});

src/crashlytics/getSampleCrash.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { Client } from "../apiv2";
2+
import { logger } from "../logger";
3+
import { FirebaseError } from "../error";
4+
import { crashlyticsApiOrigin } from "../api";
5+
6+
const TIMEOUT = 10000;
7+
8+
const apiClient = new Client({
9+
urlPrefix: crashlyticsApiOrigin(),
10+
apiVersion: "v1alpha",
11+
});
12+
13+
export async function getSampleCrash(
14+
appId: string,
15+
issueId: string,
16+
sampleCount: number,
17+
variantId?: string,
18+
): Promise<string> {
19+
try {
20+
const queryParams = new URLSearchParams();
21+
queryParams.set("filter.issue.id", issueId);
22+
queryParams.set("page_size", String(sampleCount));
23+
if (variantId) {
24+
queryParams.set("filter.issue.variant_id", variantId);
25+
}
26+
27+
const requestProjectNumber = parseProjectNumber(appId);
28+
if (requestProjectNumber === undefined) {
29+
throw new FirebaseError("Unable to get the projectId from the AppId.");
30+
}
31+
32+
const response = await apiClient.request<void, string>({
33+
method: "GET",
34+
headers: {
35+
"Content-Type": "application/json",
36+
},
37+
path: `/projects/${requestProjectNumber}/apps/${appId}/events`,
38+
queryParams: queryParams,
39+
timeout: TIMEOUT,
40+
});
41+
42+
return response.body;
43+
} catch (err: any) {
44+
logger.debug(err.message);
45+
throw new FirebaseError(
46+
`Failed to fetch the same crash for the Firebase AppId ${appId}, IssueId ${issueId}. Error: ${err}.`,
47+
{ original: err },
48+
);
49+
}
50+
}
51+
52+
function parseProjectNumber(appId: string): string | undefined {
53+
const appIdParts = appId.split(":");
54+
if (appIdParts.length > 1) {
55+
return appIdParts[1];
56+
}
57+
return undefined;
58+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { z } from "zod";
2+
import { tool } from "../../tool";
3+
import { mcpError, toContent } from "../../util";
4+
import { getIssueDetails } from "../../../crashlytics/getIssueDetails";
5+
6+
export const get_issue_details = tool(
7+
{
8+
name: "get_issue_details",
9+
description: "Gets the details about a specific crashlytics issue.",
10+
inputSchema: z.object({
11+
app_id: z
12+
.string()
13+
.describe(
14+
"The AppID for which the issues list should be fetched. For an Android application, read the mobilesdk_app_id value specified in the google-services.json file for the current package name. For an iOS Application, read the GOOGLE_APP_ID from GoogleService-Info.plist. If neither is available, use the `firebase_list_apps` tool to find an app_id to pass to this tool.",
15+
),
16+
issue_id: z
17+
.string()
18+
.describe(
19+
"The issue ID for which the details needs to be fetched. This is the value of the field `id` in the list of issues. Defaults to the first id in the list of issues.",
20+
),
21+
}),
22+
annotations: {
23+
title: "Gets the details of a specific issue.",
24+
readOnlyHint: true,
25+
},
26+
_meta: {
27+
requiresAuth: true,
28+
requiresProject: false,
29+
},
30+
},
31+
async ({ app_id, issue_id }) => {
32+
if (!app_id) return mcpError(`Must specify 'app_id' parameter.`);
33+
if (!issue_id) return mcpError(`Must specify 'issue_id' parameter.`);
34+
35+
return toContent(await getIssueDetails(app_id, issue_id));
36+
},
37+
);
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { z } from "zod";
2+
import { tool } from "../../tool";
3+
import { mcpError, toContent } from "../../util";
4+
import { getSampleCrash } from "../../../crashlytics/getSampleCrash";
5+
6+
export const get_sample_crash = tool(
7+
{
8+
name: "get_sample_crash_for_issue",
9+
description: "Gets the sample crash for an issue.",
10+
inputSchema: z.object({
11+
app_id: z
12+
.string()
13+
.describe(
14+
"AppId for which the issues list should be fetched. For an Android application, read the mobilesdk_app_id value specified in the google-services.json file for the current package name. For an iOS Application, read the GOOGLE_APP_ID from GoogleService-Info.plist. If neither is available, use the `firebase_list_apps` tool to find an app_id to pass to this tool.",
15+
),
16+
issue_id: z
17+
.string()
18+
.describe(
19+
"The issue Id for which the sample crash needs to be fetched. This is the value of the field `id` in the list of issues. Defaults to the first id in the list of issues.",
20+
),
21+
variant_id: z
22+
.string()
23+
.optional()
24+
.describe("The issue variant Id used as a filter to get sample issues."),
25+
sample_count: z
26+
.number()
27+
.describe("Number of samples that needs to be fetched. Maximum value is 3. Defaults to 1.")
28+
.default(1),
29+
}),
30+
annotations: {
31+
title: "Gets a sample of a crash for a specific issue.",
32+
readOnlyHint: true,
33+
},
34+
_meta: {
35+
requiresAuth: true,
36+
requiresProject: false,
37+
},
38+
},
39+
async ({ app_id, issue_id, variant_id, sample_count }) => {
40+
if (!app_id) return mcpError(`Must specify 'app_id' parameter.`);
41+
if (!issue_id) return mcpError(`Must specify 'issue_id' parameter.`);
42+
43+
if (sample_count > 3) sample_count = 3;
44+
45+
return toContent(await getSampleCrash(app_id, issue_id, sample_count, variant_id));
46+
},
47+
);

src/mcp/tools/crashlytics/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import type { ServerTool } from "../../tool";
2+
import { get_issue_details } from "./get_issue_details";
23
import { list_top_issues } from "./list_top_issues";
4+
import { get_sample_crash } from "./get_sample_crash";
35

4-
export const crashlyticsTools: ServerTool[] = [list_top_issues];
6+
export const crashlyticsTools: ServerTool[] = [
7+
list_top_issues,
8+
get_issue_details,
9+
get_sample_crash,
10+
];

src/mcp/tools/crashlytics/list_top_issues.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ export const list_top_issues = tool(
1717
issue_count: z
1818
.number()
1919
.optional()
20-
.describe("Number of issues that needs to be fetched. Defaults to 10 if unspecified."),
20+
.describe("Number of issues that needs to be fetched. Defaults to 10 if unspecified.")
21+
.default(10),
2122
issue_type: z
2223
.enum(["FATAL", "NON-FATAL", "ANR"])
2324
.optional()

0 commit comments

Comments
 (0)