Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/contributing/api-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ rg -n "class OrganizationAIConversations|ai-conversations" ~/src/sentry/src ~/sr
Use the verified source contract to define Zod schemas, query parameters,
pagination behavior, feature-gate handling, and test fixtures.

### Issue Activity Attribution

`source` identifies the channel or client, such as `mcp:claude`; `user` and
`sentry_app` identify the actor. `commentId` is the reference accepted by the
notes endpoints and can differ from the activity `id`. For comment creation
responses, use `commentId ?? id`. Both `source` and `commentId` may be absent or null.

### Client Creation

```typescript
Expand Down
23 changes: 23 additions & 0 deletions packages/mcp-core/src/api-client/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
EventSchema,
FlamegraphSchema,
IssueSchema,
IssueActivitySchema,
IssueTagValuesSchema,
ProfileChunkResponseSchema,
ProfileChunkSampleSchema,
Expand All @@ -28,6 +29,28 @@ import {
TransactionProfileSchema,
} from "./schema";

describe("IssueActivitySchema", () => {
it("types source metadata and the separate comment reference", () => {
const activity = {
id: "activity-1",
commentId: "comment-1",
source: "mcp:claude",
};
expect(IssueActivitySchema.parse(activity)).toEqual(activity);
expect(
IssueActivitySchema.safeParse({ ...activity, source: 123 }).success,
).toBe(false);
});

it("accepts missing or null metadata from older and system activity", () => {
expect(IssueActivitySchema.parse({ id: "activity-1" })).toEqual({
id: "activity-1",
});
const activity = { id: "activity-1", commentId: null, source: null };
expect(IssueActivitySchema.parse(activity)).toEqual(activity);
});
});

describe("IssueSchema", () => {
it("should parse a standard error issue", () => {
const errorIssue = {
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp-core/src/api-client/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,8 @@ export const CommittersResponseSchema = z.object({
export const IssueActivitySchema = z
.object({
id: ApiResourceIdSchema,
commentId: ApiResourceIdSchema.nullable().optional(),
source: z.string().nullable().optional(),
type: z.string().nullable().optional(),
dateCreated: z.string().datetime().nullable().optional(),
user: ApiActorSchema.nullable().optional(),
Expand Down
44 changes: 44 additions & 0 deletions packages/mcp-core/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1513,6 +1513,50 @@ describe("buildServer", () => {
);
});

it.each([
["get_issue_activity", {}],
["add_issue_note", { text: "Investigating after the latest deploy." }],
] as const)(
"execute_sentry_tool preserves attribution for %s",
async (name, args) => {
mswServer.use(
http.post(
"*/api/0/organizations/:org/issues/:issueId/notes/",
({ params }) => {
expect(params.org).toBe("sentry-mcp-evals");
return HttpResponse.json({
id: "activity-1",
commentId: "comment-1",
type: "note",
source: "mcp:claude",
user: { name: "Jane Developer" },
data: { text: "Investigating after the latest deploy." },
});
},
),
);
const server = buildServer({
context: {
...baseContext,
constraints: {
organizationSlug: "sentry-mcp-evals",
projectSlug: null,
},
},
});

const result = await callRegisteredTool(server, "execute_sentry_tool", {
name,
arguments: { issueId: "CLOUDFLARE-MCP-41", ...args },
});

expect(result.isError).not.toBe(true);
expect(getTextContent(result)).toContain(
"Jane Developer (Claude via MCP)",
);
},
);

it("execute_sentry_tool dispatches to catalog-only event stacktrace", async () => {
const server = buildServer({
context: baseContext,
Expand Down
49 changes: 48 additions & 1 deletion packages/mcp-core/src/tools/catalog/add-issue-note.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { mswServer } from "@sentry/mcp-server-mocks";
import { http, HttpResponse } from "msw";
import addIssueNote from "./add-issue-note.js";

const context = {
Expand All @@ -9,7 +11,52 @@ const context = {
userId: "1",
};

afterEach(() => {
mswServer.resetHandlers();
});

describe("add_issue_note", () => {
it("includes MCP attribution and the comment reference returned by Sentry", async () => {
mswServer.use(
http.post("*/api/0/organizations/:org/issues/:issueId/notes/", () =>
HttpResponse.json({
id: "activity-1",
commentId: "comment-1",
type: "note",
source: "mcp:claude",
user: { name: "Jane Developer" },
dateCreated: "2025-04-14T12:34:56.000Z",
data: { text: "Investigating after the latest deploy." },
}),
),
);

const result = await addIssueNote.handler(
{
organizationSlug: "sentry-mcp-evals",
regionUrl: null,
issueId: "CLOUDFLARE-MCP-41",
text: "Investigating after the latest deploy.",
},
context,
);

expect(result).toMatchInlineSnapshot(`
"# Added Note to Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals**

**Comment ID**: comment-1
**Type**: note
**Author**: Jane Developer (Claude via MCP)
**Created**: 2025-04-14T12:34:56.000Z
**Text**: Investigating after the latest deploy.

## Response Notes

- The note is visible in the issue activity feed."
`);
expect(result).not.toContain("activity-1");
});

it("serializes the created note", async () => {
const result = await addIssueNote.handler(
{
Expand Down
12 changes: 4 additions & 8 deletions packages/mcp-core/src/tools/catalog/add-issue-note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,15 @@ import {
} from "../../schema";
import { setTargetTagsAndAttributes } from "../../telem/scope";
import type { ServerContext } from "../../types";
import {
formatActor,
formatDate,
formatId,
readString,
} from "./support/api-formatting";
import { formatDate, formatId, readString } from "./support/api-formatting";
import { formatActivityAuthor } from "./support/activity-formatting";

function formatComment(comment: IssueComment): string {
const actor = comment.user ? formatActor(comment.user) : "current user";
const actor = formatActivityAuthor(comment, "current user");
const text = comment.data ? readString(comment.data, "text") : null;

return [
`**Comment ID**: ${formatId(comment.id)}`,
`**Comment ID**: ${formatId(comment.commentId ?? comment.id)}`,
`**Type**: ${comment.type ?? "note"}`,
`**Author**: ${actor}`,
formatDate(comment.dateCreated)
Expand Down
128 changes: 99 additions & 29 deletions packages/mcp-core/src/tools/catalog/get-issue-activity.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { mswServer } from "@sentry/mcp-server-mocks";
import { afterEach, describe, expect, it } from "vitest";
import { http, HttpResponse } from "msw";
import type { IssueActivity } from "../../api-client/types";
import getIssueActivity from "./get-issue-activity.js";

const context = {
Expand All @@ -10,22 +12,32 @@ const context = {
userId: "1",
};

const params = {
organizationSlug: "sentry-mcp-evals",
regionUrl: null,
issueId: "CLOUDFLARE-MCP-41",
includeComments: true,
limit: 25,
};

function mockActivity(activity: IssueActivity) {
mswServer.use(
http.get("*/api/0/organizations/:org/issues/:issueId/activities/", () =>
HttpResponse.json({ activity: [activity] }),
),
http.get("*/api/0/organizations/:org/issues/:issueId/notes/", () =>
HttpResponse.json([activity]),
),
);
}

afterEach(() => {
mswServer.resetHandlers();
});

describe("get_issue_activity", () => {
it("serializes issue activity and comments", async () => {
const result = await getIssueActivity.handler(
{
organizationSlug: "sentry-mcp-evals",
regionUrl: null,
issueId: "CLOUDFLARE-MCP-41",
includeComments: true,
limit: 25,
},
context,
);
const result = await getIssueActivity.handler(params, context);

expect(result).toMatchInlineSnapshot(`
"# Activity for Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals**
Expand All @@ -34,39 +46,97 @@ describe("get_issue_activity", () => {

- 2025-04-10T22:55:22.411Z: auto_set_ongoing by system (4633815464)
- Data: {"after_days":7}
- 2025-04-14T12:00:00.000Z: note by Jane Developer (4633816000)
- 2025-04-14T12:00:00.000Z: note by Jane Developer (Claude via MCP) (4633816000)
- Investigating after the latest deploy.

## Comments

- 2025-04-14T12:00:00.000Z: note by Jane Developer (Claude via MCP) (4633816000)
- Investigating after the latest deploy.

## Response Notes

- Use the Sentry tool \`add_issue_note\` to add a new human-visible issue comment.
"
`);
});

it.each([
["mcp:claude-code", "Claude via MCP"],
["mcp:cursor", "Cursor via MCP"],
["mcp", "via MCP"],
["mcp:new-client", "via MCP"],
["api", "via API"],
["sentry-cli", "via Sentry CLI"],
["web", null],
[null, null],
[undefined, null],
])(
"formats source %s in both activity and comments",
async (source, label) => {
mockActivity({
id: "activity-1",
type: "note",
source,
user: { name: "Jane Developer" },
});
const result = await getIssueActivity.handler(params, context);
const attribution = label
? `Jane Developer (${label})`
: "Jane Developer";
const [activity, comments] = result.split("## Comments");
expect(activity).toContain(`by ${attribution} (activity-1)`);
expect(comments).toContain(`by ${attribution} (activity-1)`);
expect(result).not.toContain("Comment ID:");
},
);

it("attributes Sentry Apps by name and separates comment IDs from activity IDs", async () => {
mockActivity({
id: "activity-1",
commentId: "comment-1",
type: "note",
dateCreated: "2025-04-14T12:00:00.000Z",
source: "api",
user: null,
sentry_app: { name: "Example App", slug: "example-app" },
data: { text: "Investigating after the latest deploy." },
});

const result = await getIssueActivity.handler(params, context);
expect(result).toMatchInlineSnapshot(`
"# Activity for Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals**

## Activity

- 2025-04-14T12:00:00.000Z: note by Example App (activity-1)
- Comment ID: comment-1
- Investigating after the latest deploy.

## Comments

- 2025-04-14T12:00:00.000Z: note by Jane Developer (4633816000)
- 2025-04-14T12:00:00.000Z: note by Example App (activity-1)
- Comment ID: comment-1
- Investigating after the latest deploy.

## Response Notes

- Use the Sentry tool \`add_issue_note\` to add a new human-visible issue comment.
"
`);
expect(result).not.toContain("by system");
expect(result).not.toContain("via API");
});

it("omits add_issue_note guidance when the tool is unavailable", async () => {
const result = await getIssueActivity.handler(
{
organizationSlug: "sentry-mcp-evals",
regionUrl: null,
issueId: "CLOUDFLARE-MCP-41",
includeComments: true,
limit: 25,
},
{
...context,
availableToolNames: new Set([
"get_issue_activity",
"execute_sentry_tool",
]),
directToolNames: new Set(["execute_sentry_tool"]),
},
);
const result = await getIssueActivity.handler(params, {
...context,
availableToolNames: new Set([
"get_issue_activity",
"execute_sentry_tool",
]),
directToolNames: new Set(["execute_sentry_tool"]),
});

expect(result).not.toContain("Response Notes");
expect(result).not.toContain("add_issue_note");
Expand Down
Loading
Loading