Skip to content

Commit e89797a

Browse files
committed
Adding crashlytics add note and update issue tools
1 parent 254a69c commit e89797a

File tree

7 files changed

+322
-1
lines changed

7 files changed

+322
-1
lines changed

src/crashlytics/addNote.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import * as chai from "chai";
2+
import * as nock from "nock";
3+
import * as chaiAsPromised from "chai-as-promised";
4+
5+
import { addNote } from "./addNote";
6+
import { FirebaseError } from "../error";
7+
import { crashlyticsApiOrigin } from "../api";
8+
9+
chai.use(chaiAsPromised);
10+
const expect = chai.expect;
11+
describe("addNote", () => {
12+
const projectId = "my-project";
13+
const appId = "1:1234567890:android:abcdef1234567890";
14+
const requestProjectId = "1234567890";
15+
const issueId = "test-issue-id";
16+
const note = "This is a test note.";
17+
18+
afterEach(() => {
19+
nock.cleanAll();
20+
});
21+
22+
it("should resolve with the response body on success", async () => {
23+
const mockResponse = { name: "note1", body: note };
24+
25+
nock(crashlyticsApiOrigin())
26+
.post(`/v1alpha/projects/${requestProjectId}/apps/${appId}/issues/${issueId}/notes`, {
27+
body: note,
28+
})
29+
.reply(200, mockResponse);
30+
31+
const result = await addNote(projectId, appId, issueId, note);
32+
33+
expect(result).to.deep.equal(mockResponse);
34+
expect(nock.isDone()).to.be.true;
35+
});
36+
37+
it("should throw a FirebaseError if the API call fails", async () => {
38+
nock(crashlyticsApiOrigin())
39+
.post(`/v1alpha/projects/${requestProjectId}/apps/${appId}/issues/${issueId}/notes`)
40+
.reply(500, { error: "Internal Server Error" });
41+
42+
await expect(addNote(projectId, appId, issueId, note)).to.be.rejectedWith(
43+
FirebaseError,
44+
`Failed to add note to issue ${issueId} for app ${appId}.`,
45+
);
46+
});
47+
48+
it("should throw a FirebaseError if the appId is invalid", async () => {
49+
const invalidAppId = "invalid-app-id";
50+
51+
await expect(addNote(projectId, invalidAppId, issueId, note)).to.be.rejectedWith(
52+
FirebaseError,
53+
"Unable to get the projectId from the AppId.",
54+
);
55+
});
56+
});

src/crashlytics/addNote.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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+
type NoteRequest = {
14+
body: string;
15+
};
16+
17+
export async function addNote(
18+
projectId: string,
19+
appId: string,
20+
issueId: string,
21+
note: string,
22+
): Promise<string> {
23+
try {
24+
const requestProjectId = parseProjectId(appId);
25+
if (requestProjectId === undefined) {
26+
throw new FirebaseError("Unable to get the projectId from the AppId.");
27+
}
28+
29+
const response = await apiClient.request<NoteRequest, string>({
30+
method: "POST",
31+
headers: {
32+
"Content-Type": "application/json",
33+
},
34+
path: `/projects/${requestProjectId}/apps/${appId}/issues/${issueId}/notes`,
35+
body: { body: note },
36+
timeout: TIMEOUT,
37+
});
38+
39+
return response.body;
40+
} catch (err: any) {
41+
logger.debug(err.message);
42+
throw new FirebaseError(
43+
`Failed to add note to issue ${issueId} for app ${appId}. Error: ${err}.`,
44+
{ original: err },
45+
);
46+
}
47+
}
48+
49+
function parseProjectId(appId: string): string | undefined {
50+
const appIdParts = appId.split(":");
51+
if (appIdParts.length > 1) {
52+
return appIdParts[1];
53+
}
54+
return undefined;
55+
}

src/crashlytics/updateIssue.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as chai from "chai";
2+
import * as nock from "nock";
3+
import * as chaiAsPromised from "chai-as-promised";
4+
5+
import { updateIssue, IssueState } from "./updateIssue";
6+
import { FirebaseError } from "../error";
7+
import { crashlyticsApiOrigin } from "../api";
8+
9+
chai.use(chaiAsPromised);
10+
const expect = chai.expect;
11+
12+
describe("updateIssue", () => {
13+
const projectId = "my-project";
14+
const appId = "1:1234567890:android:abcdef1234567890";
15+
const requestProjectId = "1234567890";
16+
const issueId = "test-issue-id";
17+
18+
afterEach(() => {
19+
nock.cleanAll();
20+
});
21+
22+
it("should resolve with the updated issue on success", async () => {
23+
const state = IssueState.CLOSED;
24+
const mockResponse = {
25+
id: "1",
26+
state: state,
27+
};
28+
29+
nock(crashlyticsApiOrigin())
30+
.patch(`/v1alpha/projects/${requestProjectId}/apps/${appId}/issues/${issueId}`, {
31+
state,
32+
})
33+
.query({ updateMask: "state" })
34+
.reply(200, mockResponse);
35+
36+
const result = await updateIssue(projectId, appId, issueId, state);
37+
38+
expect(result).to.deep.equal(mockResponse);
39+
expect(nock.isDone()).to.be.true;
40+
});
41+
42+
it("should throw a FirebaseError if the API call fails", async () => {
43+
const state = IssueState.OPEN;
44+
nock(crashlyticsApiOrigin())
45+
.patch(`/v1alpha/projects/${requestProjectId}/apps/${appId}/issues/${issueId}`)
46+
.query({ updateMask: "state" })
47+
.reply(500, { error: "Internal Server Error" });
48+
49+
await expect(updateIssue(projectId, appId, issueId, state)).to.be.rejectedWith(
50+
FirebaseError,
51+
`Failed to update issue ${issueId} for app ${appId}.`,
52+
);
53+
});
54+
55+
it("should throw a FirebaseError if the appId is invalid", async () => {
56+
const invalidAppId = "invalid-app-id";
57+
await expect(
58+
updateIssue(projectId, invalidAppId, issueId, IssueState.CLOSED),
59+
).to.be.rejectedWith(FirebaseError, "Unable to get the projectId from the AppId.");
60+
});
61+
});

src/crashlytics/updateIssue.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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 enum IssueState {
14+
OPEN = "OPEN",
15+
CLOSED = "CLOSED",
16+
}
17+
18+
type UpdateIssueRequest = {
19+
state: IssueState;
20+
};
21+
22+
// Based on https://cloud.google.com/firebase/docs/reference/crashlytics/rest/v1/projects.apps.issues#resource:-issue
23+
type Issue = {
24+
name: string;
25+
issueId: string;
26+
state: IssueState;
27+
};
28+
29+
export async function updateIssue(
30+
projectId: string,
31+
appId: string,
32+
issueId: string,
33+
state: IssueState,
34+
): Promise<Issue> {
35+
try {
36+
const requestProjectId = parseProjectId(appId);
37+
if (requestProjectId === undefined) {
38+
throw new FirebaseError("Unable to get the projectId from the AppId.");
39+
}
40+
41+
const response = await apiClient.request<UpdateIssueRequest, Issue>({
42+
method: "PATCH",
43+
headers: {
44+
"Content-Type": "application/json",
45+
},
46+
path: `/projects/${requestProjectId}/apps/${appId}/issues/${issueId}`,
47+
queryParams: { updateMask: "state" },
48+
body: { state },
49+
timeout: TIMEOUT,
50+
});
51+
52+
return response.body;
53+
} catch (err: any) {
54+
logger.debug(err.message);
55+
throw new FirebaseError(`Failed to update issue ${issueId} for app ${appId}. Error: ${err}.`, {
56+
original: err,
57+
});
58+
}
59+
}
60+
61+
function parseProjectId(appId: string): string | undefined {
62+
const appIdParts = appId.split(":");
63+
if (appIdParts.length > 1) {
64+
return appIdParts[1];
65+
}
66+
return undefined;
67+
}

src/mcp/tools/crashlytics/add_note.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { z } from "zod";
2+
import { tool } from "../../tool";
3+
import { mcpError, toContent } from "../../util";
4+
import { addNote } from "../../../crashlytics/addNote";
5+
6+
export const add_note = tool(
7+
{
8+
name: "add_note",
9+
description: "Add a note to an issue from crashlytics.",
10+
inputSchema: z.object({
11+
app_id: z
12+
.string()
13+
.optional()
14+
.describe(
15+
"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.",
16+
),
17+
issue_id: z.string().optional().describe("The issue id to add the note to."),
18+
note: z.string().optional().describe("The note to add to the issue."),
19+
}),
20+
annotations: {
21+
title: "Add note to Crashlytics issue.",
22+
readOnlyHint: true,
23+
},
24+
_meta: {
25+
requiresAuth: true,
26+
requiresProject: true,
27+
},
28+
},
29+
async ({ app_id, issue_id, note }, { projectId }) => {
30+
if (!app_id) return mcpError(`Must specify 'app_id' parameter.`);
31+
if (!issue_id) return mcpError(`Must specify 'issue_id' parameter.`);
32+
if (!note) return mcpError(`Must specify 'note' parameter.`);
33+
34+
return toContent(await addNote(projectId, app_id, issue_id, note));
35+
},
36+
);

src/mcp/tools/crashlytics/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { ServerTool } from "../../tool";
22
import { list_top_issues } from "./list_top_issues";
3+
import { add_note } from "./add_note";
4+
import { update_issue } from "./update_issue";
35

4-
export const crashlyticsTools: ServerTool[] = [list_top_issues];
6+
export const crashlyticsTools: ServerTool[] = [list_top_issues, add_note, update_issue];
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { z } from "zod";
2+
import { tool } from "../../tool";
3+
import { mcpError, toContent } from "../../util";
4+
import { updateIssue, IssueState } from "../../../crashlytics/updateIssue";
5+
6+
export const update_issue = tool(
7+
{
8+
name: "update_issue",
9+
description: "Update the state of an issue in Crashlytics.",
10+
inputSchema: z.object({
11+
app_id: z
12+
.string()
13+
.optional()
14+
.describe(
15+
"AppId for which the issue should be updated. 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.",
16+
),
17+
issue_id: z.string().optional().describe("The issue id to update."),
18+
state: z
19+
.nativeEnum(IssueState)
20+
.optional()
21+
.describe("The new state for the issue. Can be 'OPEN' or 'CLOSED'."),
22+
}),
23+
annotations: {
24+
title: "Update Crashlytics issue state.",
25+
},
26+
_meta: {
27+
requiresAuth: true,
28+
requiresProject: true,
29+
},
30+
},
31+
async ({ app_id, issue_id, state }, { projectId }) => {
32+
if (!app_id) {
33+
return mcpError(`Must specify 'app_id' parameter.`);
34+
}
35+
if (!issue_id) {
36+
return mcpError(`Must specify 'issue_id' parameter.`);
37+
}
38+
if (!state) {
39+
return mcpError(`Must specify 'state' parameter.`);
40+
}
41+
42+
return toContent(await updateIssue(projectId, app_id, issue_id, state));
43+
},
44+
);

0 commit comments

Comments
 (0)