Skip to content

Commit 0d06113

Browse files
sentry-junior[bot]Junior
andauthored
feat(update-issue): add optional reason parameter that posts as a comment (#988)
## Summary Adds an optional `reason` parameter to the `update_issue` tool. When provided, the reason is posted as a comment on the issue's activity feed via the Sentry notes API (`POST /issues/{id}/notes/`). This enables AI agents (like Claude routines doing triage) to explain their rationale before or alongside taking actions like archiving noisy issues. ## Changes - **`schema.ts`** — New `ParamReason` zod schema (trimmed string, min 1 char) - **`update-issue.ts`** — Added `reason` to `inputSchema` (optional), description examples/hints, centralized `tryPostReasonComment` helper with try/catch and `formatReasonCommentLine` for consistent output - **`client.ts`** — New `createIssueComment()` method on `SentryApiService` - **`mcp-server-mocks/index.ts`** — Mock handler for the notes endpoint - **`update-issue.test.ts`** — 4 new tests: reason posting, omission, no-op state, and comment failure graceful degradation ## Behavior - After a successful status/assignment update → posts reason as a comment, shows it in output - When no state changes are needed (already in requested state) → still posts the reason comment and shows it in output - When reason is omitted → no comment is posted (backward compatible) - If the comment API call fails → the update result is still returned; output shows `**Comment not posted**: <error>` instead of throwing - `idempotentHint` changed to `false` since the reason comment is non-idempotent Requested-By: David Cramer --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Junior <junior@sentry.io>
1 parent eed2240 commit 0d06113

7 files changed

Lines changed: 323 additions & 9 deletions

File tree

‎packages/mcp-core/src/api-client/client.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2364,6 +2364,28 @@ export class SentryApiService {
23642364
return IssueSchema.parse(body);
23652365
}
23662366

2367+
async createIssueComment(
2368+
{
2369+
organizationSlug,
2370+
issueId,
2371+
text,
2372+
}: {
2373+
organizationSlug: string;
2374+
issueId: string;
2375+
text: string;
2376+
},
2377+
opts?: RequestOptions,
2378+
): Promise<void> {
2379+
await this.requestJSON(
2380+
`/organizations/${organizationSlug}/issues/${issueId}/notes/`,
2381+
{
2382+
method: "POST",
2383+
body: JSON.stringify({ text }),
2384+
},
2385+
opts,
2386+
);
2387+
}
2388+
23672389
// TODO: Sentry is not yet exposing a reasonable API to fetch trace data
23682390
// async getTrace({
23692391
// organizationSlug,

‎packages/mcp-core/src/schema.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,14 @@ export const ParamIgnoreUserWindowMinutes = z
204204
"Optional time window in minutes for ignoreUserCount. If omitted, Sentry counts all future affected users.",
205205
);
206206

207+
export const ParamReason = z
208+
.string()
209+
.trim()
210+
.min(1)
211+
.describe(
212+
"Optional reason for taking this action. When provided, it will be posted as a comment on the issue's activity feed.",
213+
);
214+
207215
export const ParamSentryGuide = z
208216
.enum(SENTRY_GUIDES)
209217
.describe(

‎packages/mcp-core/src/skillDefinitions.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@
199199
},
200200
{
201201
"name": "update_issue",
202-
"description": "Update a Sentry issue's status or assignment.\n\nUse this to resolve, reopen, assign, or ignore an issue.\n\n<examples>\n```\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='resolved')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo='user:123456')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='forever')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='untilOccurrenceCount', ignoreCount=100, ignoreWindowMinutes=60)\n```\n</examples>\n\n<hints>\n- Provide `issueUrl` or `organizationSlug` + `issueId`.\n- At least one of `status` or `assignedTo` is required.\n- `assignedTo` format: `user:ID` or `team:ID_OR_SLUG`.\n- Use `whoami` to find your user ID for self-assignment.\n- Status values: `resolved`, `resolvedInNextRelease`, `unresolved`, `ignored`.\n- `status='ignored'` defaults to `ignoreMode='untilEscalating'`.\n- Ignore modes: `untilEscalating`, `forever`, `forDuration`, `untilOccurrenceCount`, `untilUserCount`.\n- Matching ignore inputs are `ignoreDurationMinutes`, `ignoreCount` + optional `ignoreWindowMinutes`, or `ignoreUserCount` + optional `ignoreUserWindowMinutes`.\n- To switch an already ignored issue between `untilEscalating`, `forever`, and condition-based ignore modes, first set `status='unresolved'`, then ignore it again with the new rule.\n</hints>",
202+
"description": "Update a Sentry issue's status or assignment.\n\nUse this to resolve, reopen, assign, or ignore an issue.\n\n<examples>\n```\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='resolved')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo='user:123456')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='forever')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='untilOccurrenceCount', ignoreCount=100, ignoreWindowMinutes=60)\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', reason='Ignoring because this is expected noise from the staging deploy')\n```\n</examples>\n\n<hints>\n- Provide `issueUrl` or `organizationSlug` + `issueId`.\n- At least one of `status` or `assignedTo` is required.\n- `assignedTo` format: `user:ID` or `team:ID_OR_SLUG`.\n- Use `whoami` to find your user ID for self-assignment.\n- Status values: `resolved`, `resolvedInNextRelease`, `unresolved`, `ignored`.\n- `status='ignored'` defaults to `ignoreMode='untilEscalating'`.\n- Ignore modes: `untilEscalating`, `forever`, `forDuration`, `untilOccurrenceCount`, `untilUserCount`.\n- Matching ignore inputs are `ignoreDurationMinutes`, `ignoreCount` + optional `ignoreWindowMinutes`, or `ignoreUserCount` + optional `ignoreUserWindowMinutes`.\n- To switch an already ignored issue between `untilEscalating`, `forever`, and condition-based ignore modes, first set `status='unresolved'`, then ignore it again with the new rule.\n- `reason` is optional. When provided, it will be posted as a comment on the issue's activity feed explaining why the action was taken.\n</hints>",
203203
"requiredScopes": ["event:write"]
204204
},
205205
{

‎packages/mcp-core/src/toolDefinitions.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1098,7 +1098,7 @@
10981098
},
10991099
{
11001100
"name": "update_issue",
1101-
"description": "Update a Sentry issue's status or assignment.\n\nUse this to resolve, reopen, assign, or ignore an issue.\n\n<examples>\n```\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='resolved')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo='user:123456')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='forever')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='untilOccurrenceCount', ignoreCount=100, ignoreWindowMinutes=60)\n```\n</examples>\n\n<hints>\n- Provide `issueUrl` or `organizationSlug` + `issueId`.\n- At least one of `status` or `assignedTo` is required.\n- `assignedTo` format: `user:ID` or `team:ID_OR_SLUG`.\n- Use `whoami` to find your user ID for self-assignment.\n- Status values: `resolved`, `resolvedInNextRelease`, `unresolved`, `ignored`.\n- `status='ignored'` defaults to `ignoreMode='untilEscalating'`.\n- Ignore modes: `untilEscalating`, `forever`, `forDuration`, `untilOccurrenceCount`, `untilUserCount`.\n- Matching ignore inputs are `ignoreDurationMinutes`, `ignoreCount` + optional `ignoreWindowMinutes`, or `ignoreUserCount` + optional `ignoreUserWindowMinutes`.\n- To switch an already ignored issue between `untilEscalating`, `forever`, and condition-based ignore modes, first set `status='unresolved'`, then ignore it again with the new rule.\n</hints>",
1101+
"description": "Update a Sentry issue's status or assignment.\n\nUse this to resolve, reopen, assign, or ignore an issue.\n\n<examples>\n```\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='resolved')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', assignedTo='user:123456')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='forever')\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', ignoreMode='untilOccurrenceCount', ignoreCount=100, ignoreWindowMinutes=60)\nupdate_issue(organizationSlug='my-org', issueId='PROJECT-123', status='ignored', reason='Ignoring because this is expected noise from the staging deploy')\n```\n</examples>\n\n<hints>\n- Provide `issueUrl` or `organizationSlug` + `issueId`.\n- At least one of `status` or `assignedTo` is required.\n- `assignedTo` format: `user:ID` or `team:ID_OR_SLUG`.\n- Use `whoami` to find your user ID for self-assignment.\n- Status values: `resolved`, `resolvedInNextRelease`, `unresolved`, `ignored`.\n- `status='ignored'` defaults to `ignoreMode='untilEscalating'`.\n- Ignore modes: `untilEscalating`, `forever`, `forDuration`, `untilOccurrenceCount`, `untilUserCount`.\n- Matching ignore inputs are `ignoreDurationMinutes`, `ignoreCount` + optional `ignoreWindowMinutes`, or `ignoreUserCount` + optional `ignoreUserWindowMinutes`.\n- To switch an already ignored issue between `untilEscalating`, `forever`, and condition-based ignore modes, first set `status='unresolved'`, then ignore it again with the new rule.\n- `reason` is optional. When provided, it will be posted as a comment on the issue's activity feed explaining why the action was taken.\n</hints>",
11021102
"inputSchema": {
11031103
"type": "object",
11041104
"properties": {
@@ -1179,6 +1179,11 @@
11791179
"exclusiveMinimum": 0,
11801180
"maximum": 10080,
11811181
"description": "Optional time window in minutes for ignoreUserCount. If omitted, Sentry counts all future affected users."
1182+
},
1183+
"reason": {
1184+
"type": "string",
1185+
"minLength": 1,
1186+
"description": "Optional reason for taking this action. When provided, it will be posted as a comment on the issue's activity feed."
11821187
}
11831188
},
11841189
"additionalProperties": false,

‎packages/mcp-core/src/tools/update-issue.test.ts‎

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -794,4 +794,195 @@ describe("update_issue", () => {
794794
),
795795
).rejects.toThrow("`ignoreWindowMinutes` requires `ignoreCount`");
796796
});
797+
798+
it("posts reason as a comment when updating issue status", async () => {
799+
let commentPosted: { text: string } | undefined;
800+
const currentIssue = createIssue({
801+
status: "unresolved",
802+
statusDetails: {},
803+
});
804+
const updatedIssue = createIssue({
805+
status: "resolved",
806+
statusDetails: {},
807+
});
808+
809+
mswServer.use(
810+
http.get(
811+
"https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/",
812+
() => HttpResponse.json(currentIssue),
813+
),
814+
http.put(
815+
"https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/",
816+
() => HttpResponse.json(updatedIssue),
817+
),
818+
http.post(
819+
"https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/notes/",
820+
async ({ request }) => {
821+
commentPosted = (await request.json()) as { text: string };
822+
return HttpResponse.json({
823+
id: "12345",
824+
text: commentPosted.text,
825+
type: "note",
826+
dateCreated: new Date().toISOString(),
827+
});
828+
},
829+
),
830+
);
831+
832+
const result = await updateIssue.handler(
833+
{
834+
organizationSlug: "sentry-mcp-evals",
835+
issueId: "CLOUDFLARE-MCP-41",
836+
status: "resolved",
837+
assignedTo: undefined,
838+
issueUrl: undefined,
839+
regionUrl: null,
840+
reason: "Resolved because the root cause was fixed in PR #123",
841+
},
842+
serverContext,
843+
);
844+
845+
expect(commentPosted).toEqual({
846+
text: "Resolved because the root cause was fixed in PR #123",
847+
});
848+
expect(result).toContain(
849+
'**Comment posted**: "Resolved because the root cause was fixed in PR #123"',
850+
);
851+
});
852+
853+
it("does not post a comment when reason is not provided", async () => {
854+
let commentPosted = false;
855+
const currentIssue = createIssue({
856+
status: "unresolved",
857+
statusDetails: {},
858+
});
859+
const updatedIssue = createIssue({
860+
status: "resolved",
861+
statusDetails: {},
862+
});
863+
864+
mswServer.use(
865+
http.get(
866+
"https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/",
867+
() => HttpResponse.json(currentIssue),
868+
),
869+
http.put(
870+
"https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/",
871+
() => HttpResponse.json(updatedIssue),
872+
),
873+
http.post(
874+
"https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/notes/",
875+
() => {
876+
commentPosted = true;
877+
return HttpResponse.json({});
878+
},
879+
),
880+
);
881+
882+
await updateIssue.handler(
883+
{
884+
organizationSlug: "sentry-mcp-evals",
885+
issueId: "CLOUDFLARE-MCP-41",
886+
status: "resolved",
887+
assignedTo: undefined,
888+
issueUrl: undefined,
889+
regionUrl: null,
890+
},
891+
serverContext,
892+
);
893+
894+
expect(commentPosted).toBe(false);
895+
});
896+
897+
it("posts reason as a comment even when no state changes are needed", async () => {
898+
let commentPosted: { text: string } | undefined;
899+
const currentIssue = createIssue({
900+
status: "resolved",
901+
statusDetails: {},
902+
});
903+
904+
mswServer.use(
905+
http.get(
906+
"https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/",
907+
() => HttpResponse.json(currentIssue),
908+
),
909+
http.post(
910+
"https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/notes/",
911+
async ({ request }) => {
912+
commentPosted = (await request.json()) as { text: string };
913+
return HttpResponse.json({
914+
id: "12345",
915+
text: commentPosted.text,
916+
type: "note",
917+
dateCreated: new Date().toISOString(),
918+
});
919+
},
920+
),
921+
);
922+
923+
const result = await updateIssue.handler(
924+
{
925+
organizationSlug: "sentry-mcp-evals",
926+
issueId: "CLOUDFLARE-MCP-41",
927+
status: "resolved",
928+
assignedTo: undefined,
929+
issueUrl: undefined,
930+
regionUrl: null,
931+
reason: "Confirmed this is no longer an issue after deploy",
932+
},
933+
serverContext,
934+
);
935+
936+
expect(commentPosted).toEqual({
937+
text: "Confirmed this is no longer an issue after deploy",
938+
});
939+
expect(result).toContain("No changes were needed.");
940+
expect(result).toContain(
941+
'**Comment posted**: "Confirmed this is no longer an issue after deploy"',
942+
);
943+
});
944+
945+
it("does not throw when comment posting fails after a successful update", async () => {
946+
const currentIssue = createIssue({
947+
status: "unresolved",
948+
statusDetails: {},
949+
});
950+
const updatedIssue = createIssue({
951+
status: "resolved",
952+
statusDetails: {},
953+
});
954+
955+
mswServer.use(
956+
http.get(
957+
"https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/",
958+
() => HttpResponse.json(currentIssue),
959+
),
960+
http.put(
961+
"https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/",
962+
() => HttpResponse.json(updatedIssue),
963+
),
964+
http.post(
965+
"https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-41/notes/",
966+
() => HttpResponse.json({ detail: "Rate limited" }, { status: 429 }),
967+
),
968+
);
969+
970+
const result = await updateIssue.handler(
971+
{
972+
organizationSlug: "sentry-mcp-evals",
973+
issueId: "CLOUDFLARE-MCP-41",
974+
status: "resolved",
975+
assignedTo: undefined,
976+
issueUrl: undefined,
977+
regionUrl: null,
978+
reason: "Resolving because fix deployed",
979+
},
980+
serverContext,
981+
);
982+
983+
// Update succeeded — output should show it
984+
expect(result).toContain("**Status**: unresolved → **resolved**");
985+
// Comment failure should be reported gracefully, not thrown
986+
expect(result).toContain("**Comment not posted**");
987+
});
797988
});

0 commit comments

Comments
 (0)