From dca4ae2394efc3781de25d9d8e57fbcd3040871e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:15:09 +0000 Subject: [PATCH 1/6] feat(mcp): Return structured output from search_issues search_issues now declares an outputSchema and returns structuredContent on every success path instead of markdown. Issues are mapped explicitly, using the field names get_issue_details already uses (occurrences, usersImpacted, seerActionability, url), so the passthrough issue schema cannot leak backend fields. The search URL, executed query, sort, period, and project are top-level fields. Query rewrite and empty-result guidance go in notes[]. Suggested actions open the first issue, and the first feedback issue when one is present, with get_sentry_resource. The markdown formatter had no other callers, so results.ts replaces formatters.ts. The shared suggested-action helper is brought over unchanged from the search_errors change so this branch stands alone on main. Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- .../tool-helpers/ai-conversation-actions.ts | 86 +---- .../tool-helpers/suggested-actions.ts | 78 ++++ packages/mcp-core/src/toolDefinitions.json | 149 ++++++++ .../src/tools/catalog/search-issues.test.ts | 201 ++++++---- .../src/tools/catalog/search-issues.ts | 86 ++--- .../src/tools/support/search-issues/README.md | 2 +- .../support/search-issues/formatters.test.ts | 359 ------------------ .../tools/support/search-issues/formatters.ts | 203 ---------- .../support/search-issues/results.test.ts | 221 +++++++++++ .../tools/support/search-issues/results.ts | 191 ++++++++++ 10 files changed, 817 insertions(+), 759 deletions(-) create mode 100644 packages/mcp-core/src/internal/tool-helpers/suggested-actions.ts delete mode 100644 packages/mcp-core/src/tools/support/search-issues/formatters.test.ts delete mode 100644 packages/mcp-core/src/tools/support/search-issues/formatters.ts create mode 100644 packages/mcp-core/src/tools/support/search-issues/results.test.ts create mode 100644 packages/mcp-core/src/tools/support/search-issues/results.ts diff --git a/packages/mcp-core/src/internal/tool-helpers/ai-conversation-actions.ts b/packages/mcp-core/src/internal/tool-helpers/ai-conversation-actions.ts index 2130306c1..3068b48f1 100644 --- a/packages/mcp-core/src/internal/tool-helpers/ai-conversation-actions.ts +++ b/packages/mcp-core/src/internal/tool-helpers/ai-conversation-actions.ts @@ -1,9 +1,9 @@ import { inlineCode } from "@sentry/toolkit-core/markdown"; -import { isTopLevelToolName } from "../../tools/surfaces"; import { - formatToolCall, - isToolAvailableInSession, -} from "./tool-call-formatting"; + buildSuggestedToolAction, + type SuggestedToolAction, +} from "./suggested-actions"; +import { formatToolCall } from "./tool-call-formatting"; const MAX_SUGGESTED_ACTIONS = 3; @@ -12,22 +12,6 @@ export interface AIConversationReference { spanId?: string; } -type SuggestedActionValue = - | string - | number - | boolean - | null - | SuggestedActionValue[] - | { [key: string]: SuggestedActionValue }; - -/** A concrete, session-callable MCP follow-up action. */ -interface SuggestedToolAction { - type: "tool_call"; - toolName: string; - arguments: Record; - reason: string; -} - /** Builds transcript actions that match the session's direct or catalog surface. */ export function getAIConversationSuggestedActions({ organizationSlug, @@ -42,54 +26,20 @@ export function getAIConversationSuggestedActions({ availableToolNames?: ReadonlySet; directToolNames?: ReadonlySet; }): SuggestedToolAction[] { - if ( - aiConversations.length === 0 || - !isToolAvailableInSession( - "get_agent_conversation_details", - availableToolNames, - ) - ) { - return []; - } - - const targetIsDirect = directToolNames - ? directToolNames.has("get_agent_conversation_details") - : isTopLevelToolName("get_agent_conversation_details", experimentalMode); - const executeToolIsDirect = directToolNames - ? directToolNames.has("execute_sentry_tool") - : isTopLevelToolName("execute_sentry_tool", experimentalMode); - const useCatalogGateway = - !targetIsDirect && - executeToolIsDirect && - isToolAvailableInSession("execute_sentry_tool", availableToolNames); - - if (!targetIsDirect && !useCatalogGateway) { - return []; - } - - return aiConversations.slice(0, MAX_SUGGESTED_ACTIONS).map((conversation) => { - const targetArguments = { - organizationSlug, - conversationId: conversation.conversationId, - }; - - return targetIsDirect - ? { - type: "tool_call", - toolName: "get_agent_conversation_details", - arguments: targetArguments, - reason: "Fetch the full transcript for this agent conversation.", - } - : { - type: "tool_call", - toolName: "execute_sentry_tool", - arguments: { - name: "get_agent_conversation_details", - arguments: targetArguments, - }, - reason: "Fetch the full transcript for this agent conversation.", - }; - }); + return aiConversations.slice(0, MAX_SUGGESTED_ACTIONS).flatMap( + (conversation) => + buildSuggestedToolAction({ + toolName: "get_agent_conversation_details", + arguments: { + organizationSlug, + conversationId: conversation.conversationId, + }, + reason: "Fetch the full transcript for this agent conversation.", + experimentalMode, + availableToolNames, + directToolNames, + }) ?? [], + ); } /** Returns markdown instructions that mirror the callable structured actions. */ diff --git a/packages/mcp-core/src/internal/tool-helpers/suggested-actions.ts b/packages/mcp-core/src/internal/tool-helpers/suggested-actions.ts new file mode 100644 index 000000000..f974784d6 --- /dev/null +++ b/packages/mcp-core/src/internal/tool-helpers/suggested-actions.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; +import { isTopLevelToolName } from "../../tools/surfaces"; +import { isToolAvailableInSession } from "./tool-call-formatting"; + +type SuggestedActionValue = + | string + | number + | boolean + | null + | SuggestedActionValue[] + | { [key: string]: SuggestedActionValue }; + +/** A concrete, session-callable MCP follow-up action. */ +export interface SuggestedToolAction { + type: "tool_call"; + toolName: string; + arguments: Record; + reason: string; +} + +export const suggestedToolActionSchema = z.object({ + type: z.literal("tool_call"), + toolName: z.string(), + arguments: z.record(z.string(), z.unknown()), + reason: z.string(), +}); + +/** + * Builds a follow-up call for `toolName` on the session's direct or catalog + * surface. Returns null when the session cannot call the tool. + */ +export function buildSuggestedToolAction({ + toolName, + arguments: targetArguments, + reason, + experimentalMode, + availableToolNames, + directToolNames, +}: { + toolName: string; + arguments: Record; + reason: string; + experimentalMode: boolean; + availableToolNames?: ReadonlySet; + directToolNames?: ReadonlySet; +}): SuggestedToolAction | null { + if (!isToolAvailableInSession(toolName, availableToolNames)) { + return null; + } + + const isDirect = (name: string) => + directToolNames + ? directToolNames.has(name) + : isTopLevelToolName(name, experimentalMode); + + if (isDirect(toolName)) { + return { + type: "tool_call", + toolName, + arguments: targetArguments, + reason, + }; + } + + if ( + isDirect("execute_sentry_tool") && + isToolAvailableInSession("execute_sentry_tool", availableToolNames) + ) { + return { + type: "tool_call", + toolName: "execute_sentry_tool", + arguments: { name: toolName, arguments: targetArguments }, + reason, + }; + } + + return null; +} diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index e2598f4ca..34e6ee04b 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -7804,6 +7804,155 @@ }, "required": ["organizationSlug"] }, + "outputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "explanation": { + "type": ["string", "null"] + }, + "executedSearch": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "sort": { + "type": "string", + "enum": ["date", "freq", "new", "user", "recommended"] + }, + "period": { + "type": "string" + }, + "projectSlugOrId": { + "type": ["string", "null"] + } + }, + "required": ["query", "sort", "period", "projectSlugOrId"], + "additionalProperties": false + }, + "url": { + "type": "string", + "description": "Opens these results in Sentry." + }, + "presentationHint": { + "type": "string" + }, + "notes": { + "type": "array", + "items": { + "type": "string" + } + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "shortId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "status": { + "type": "string" + }, + "substatus": { + "type": ["string", "null"] + }, + "issueCategory": { + "type": ["string", "null"] + }, + "occurrences": { + "type": "number" + }, + "usersImpacted": { + "type": "number" + }, + "assignedTo": { + "type": ["string", "null"] + }, + "firstSeen": { + "type": ["string", "null"] + }, + "lastSeen": { + "type": ["string", "null"] + }, + "culprit": { + "type": ["string", "null"] + }, + "seerActionability": { + "type": ["string", "null"] + }, + "projectSlug": { + "type": ["string", "null"] + } + }, + "required": [ + "shortId", + "title", + "url", + "status", + "substatus", + "issueCategory", + "occurrences", + "usersImpacted", + "assignedTo", + "firstSeen", + "lastSeen", + "culprit", + "seerActionability", + "projectSlug" + ], + "additionalProperties": false + } + }, + "suggestedActions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "tool_call" + }, + "toolName": { + "type": "string" + }, + "arguments": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "reason": { + "type": "string" + } + }, + "required": ["type", "toolName", "arguments", "reason"], + "additionalProperties": false + } + } + }, + "required": [ + "query", + "explanation", + "executedSearch", + "url", + "presentationHint", + "notes", + "issues", + "suggestedActions" + ], + "additionalProperties": false + }, "requiredScopes": ["event:read"], "skills": ["inspect", "triage", "seer"], "surface": "direct" diff --git a/packages/mcp-core/src/tools/catalog/search-issues.test.ts b/packages/mcp-core/src/tools/catalog/search-issues.test.ts index 59f9cd772..919e7a05e 100644 --- a/packages/mcp-core/src/tools/catalog/search-issues.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-issues.test.ts @@ -4,6 +4,14 @@ import { http, HttpResponse } from "msw"; import { beforeEach, describe, expect, it, vi } from "vitest"; import searchIssues from "./search-issues"; import { prepareToolParams } from "../catalog-runtime/availability"; +import { + type SearchIssuesStructuredContent, + searchIssuesOutputSchema, +} from "../support/search-issues/results"; +import { + assertStructuredOnlyResult, + getStructuredContent, +} from "../../test-utils/structured-content"; import type { ServerContext } from "../../types"; // Mock the AI SDK @@ -25,6 +33,20 @@ vi.mock("ai", async (importOriginal) => { }; }); +const EMPTY_NOTE = + "No issues found matching your search criteria. Try adjusting your search criteria or time range."; + +async function runSearch(...args: Parameters) { + const result = await searchIssues.handler(...args); + assertStructuredOnlyResult(result); + const structuredContent = + getStructuredContent(result); + expect(searchIssuesOutputSchema.parse(structuredContent)).toEqual( + structuredContent, + ); + return structuredContent; +} + describe("search_issues", () => { const mockGenerateText = vi.mocked(generateText); const mockContext: ServerContext = { @@ -86,7 +108,7 @@ describe("search_issues", () => { }), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "product-org", query: "is:unresolved", @@ -103,13 +125,15 @@ describe("search_issues", () => { expect(requests).toEqual([ "https://tenant.my.sentry.io/api/0/organizations/product-org/issues/?limit=1&sort=date&statsPeriod=24h&query=is%3Aunresolved&project=123&collapse=unhandled", ]); - expect(result).toContain( + expect(result.url).toBe( "https://tenant.my.sentry.io/organizations/product-org/issues/?project=123&query=is%3Aunresolved", ); - expect(result).toContain( - "[WEB-123](https://tenant.my.sentry.io/organizations/product-org/issues/WEB-123)", + expect(result.issues[0]?.url).toBe( + "https://tenant.my.sentry.io/organizations/product-org/issues/WEB-123", + ); + expect(JSON.stringify(result)).not.toContain( + "https://product-org.sentry.io", ); - expect(result).not.toContain("https://product-org.sentry.io"); }, ); @@ -150,7 +174,7 @@ describe("search_issues", () => { ), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: "unresolved issues", @@ -164,34 +188,51 @@ describe("search_issues", () => { mockContext, ); + expect(JSON.stringify(result)).not.toContain("permalink"); expect(result).toMatchInlineSnapshot(` - "# Search Results for "unresolved issues" - - **Suggested presentation:** Cards work well for these issues, with status, assignee, and issue ID links visible. - - **View these results in Sentry**: - https://test-org.sentry.io/issues/?query=is%3Aunresolved - Please tell the user this dashboard link is available if they want to open the results in Sentry. - - Found **1** issue: - - ## 1. [PROJ-123](https://test-org.sentry.io/issues/PROJ-123) - - **Test Error** - - - **Status**: unresolved - - **Users**: 50 - - **Events**: 100 - - **First seen**: 2025-01-15 - - **Last seen**: 2025-01-15 - - **Culprit**: \`test.function\` - - ## Next Steps - - - Get more details about a specific issue: Use get_sentry_resource with the issue ID or issue URL - - Update issue status: Use the Sentry tool \`update_issue\` to resolve or assign issues - - View event counts: Use search_errors for aggregated statistics - " + { + "executedSearch": { + "period": "30d", + "projectSlugOrId": null, + "query": "is:unresolved", + "sort": "date", + }, + "explanation": null, + "issues": [ + { + "assignedTo": null, + "culprit": "test.function", + "firstSeen": "2025-01-15T10:00:00Z", + "issueCategory": null, + "lastSeen": "2025-01-15T12:00:00Z", + "occurrences": 100, + "projectSlug": "test-project", + "seerActionability": null, + "shortId": "PROJ-123", + "status": "unresolved", + "substatus": null, + "title": "Test Error", + "url": "https://test-org.sentry.io/issues/PROJ-123", + "usersImpacted": 50, + }, + ], + "notes": [], + "presentationHint": "Cards work well for these issues, with status, assignee, and issue ID links visible. Please tell the user this dashboard link is available if they want to open the results in Sentry.", + "query": "unresolved issues", + "suggestedActions": [ + { + "arguments": { + "organizationSlug": "test-org", + "resourceId": "PROJ-123", + "resourceType": "issue", + }, + "reason": "Inspect the first matching issue.", + "toolName": "get_sentry_resource", + "type": "tool_call", + }, + ], + "url": "https://test-org.sentry.io/issues/?query=is%3Aunresolved", + } `); }); @@ -218,7 +259,7 @@ describe("search_issues", () => { ), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: "is:unresolved", @@ -232,7 +273,8 @@ describe("search_issues", () => { mockContext, ); - expect(result).toContain("No issues found"); + expect(result.issues).toEqual([]); + expect(result.notes).toEqual([EMPTY_NOTE]); }); it("falls back when the AI SDK wraps a provider outage in RetryError", async () => { @@ -263,7 +305,7 @@ describe("search_issues", () => { ), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: "is:unresolved", @@ -277,7 +319,8 @@ describe("search_issues", () => { mockContext, ); - expect(result).toContain("No issues found"); + expect(result.issues).toEqual([]); + expect(result.notes).toEqual([EMPTY_NOTE]); }); it("should search issues with direct query syntax (no agent)", async () => { @@ -317,7 +360,7 @@ describe("search_issues", () => { ), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: "is:unresolved is:unassigned", @@ -333,11 +376,12 @@ describe("search_issues", () => { // Should NOT have called the AI agent expect(mockGenerateText).not.toHaveBeenCalled(); - expect(result).toContain("PROJ-123"); - expect(result).toContain("Test Error"); + expect(result.issues.map(({ shortId, title }) => [shortId, title])).toEqual( + [["PROJ-123", "Test Error"]], + ); }); - it("omits update_issue guidance when update_issue is unavailable in the session", async () => { + it("suggests only follow-ups the session can call", async () => { process.env.OPENAI_API_KEY = ""; process.env.ANTHROPIC_API_KEY = ""; process.env.OPENROUTER_API_KEY = ""; @@ -371,7 +415,7 @@ describe("search_issues", () => { ), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: "is:unresolved", @@ -405,9 +449,10 @@ describe("search_issues", () => { }, ); - expect(result).toContain("PROJ-123"); - expect(result).not.toContain("Update issue status"); - expect(result).not.toContain("update_issue"); + expect(result.issues.map((issue) => issue.shortId)).toEqual(["PROJ-123"]); + expect(result.suggestedActions.map((action) => action.toolName)).toEqual([ + "get_sentry_resource", + ]); }); it("should preserve explicit query syntax through agent repair", async () => { @@ -445,7 +490,7 @@ describe("search_issues", () => { ), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: explicitQuery, @@ -460,8 +505,10 @@ describe("search_issues", () => { ); expect(mockGenerateText).toHaveBeenCalled(); - expect(result).toContain("PROJ-123"); - expect(result).toContain("Needs Review"); + expect(result.executedSearch.query).toBe(explicitQuery); + expect(result.issues.map(({ shortId, title }) => [shortId, title])).toEqual( + [["PROJ-123", "Needs Review"]], + ); }); it("should handle project slug parameter", async () => { @@ -507,10 +554,11 @@ describe("search_issues", () => { context: mockContext, }) as Parameters[0]; - const result = await searchIssues.handler(params, mockContext); + const result = await runSearch(params, mockContext); expect(mockGenerateText).not.toHaveBeenCalled(); - expect(result).toContain("No issues found"); + expect(result.issues).toEqual([]); + expect(result.notes).toEqual([EMPTY_NOTE]); }); it("should handle numeric project ID", async () => { @@ -528,7 +576,7 @@ describe("search_issues", () => { ), ); - await searchIssues.handler( + await runSearch( { organizationSlug: "test-org", query: "all issues", @@ -558,7 +606,7 @@ describe("search_issues", () => { ), ); - await searchIssues.handler( + await runSearch( { organizationSlug: "test-org", query: "most frequent errors", @@ -588,7 +636,7 @@ describe("search_issues", () => { ), ); - await searchIssues.handler( + await runSearch( { organizationSlug: "test-org", query: "all issues", @@ -618,7 +666,7 @@ describe("search_issues", () => { ), ); - await searchIssues.handler( + await runSearch( { organizationSlug: "test-org", query: "test", @@ -642,7 +690,7 @@ describe("search_issues", () => { }), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: "unresolved issues", @@ -656,8 +704,13 @@ describe("search_issues", () => { mockContext, ); - expect(result).toContain("Query Translation"); - expect(result).toContain("Test query translation"); + expect(result.explanation).toBe("Test query translation"); + expect(result.executedSearch).toEqual({ + query: "is:unresolved", + sort: "date", + period: "30d", + projectSlugOrId: null, + }); }); it("should handle empty results", async () => { @@ -669,7 +722,7 @@ describe("search_issues", () => { }), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: "nonexistent issues", @@ -683,7 +736,8 @@ describe("search_issues", () => { mockContext, ); - expect(result).toContain("No issues found"); + expect(result.issues).toEqual([]); + expect(result.notes).toEqual([EMPTY_NOTE]); }); it("should pass agent query directly to API", async () => { @@ -703,7 +757,7 @@ describe("search_issues", () => { ), ); - await searchIssues.handler( + await runSearch( { organizationSlug: "test-org", query: "unresolved errors", @@ -737,7 +791,7 @@ describe("search_issues", () => { ), ); - await searchIssues.handler( + await runSearch( { organizationSlug: "test-org", query: "test", @@ -753,7 +807,7 @@ describe("search_issues", () => { } }); - it("should format issues with proper markdown", async () => { + it("maps issue fields without the upstream passthrough", async () => { mockGenerateText.mockResolvedValue(mockAIResponse("", "date")); mswServer.use( @@ -781,7 +835,7 @@ describe("search_issues", () => { }), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: "all issues", @@ -795,10 +849,16 @@ describe("search_issues", () => { mockContext, ); - expect(result).toContain("# Search Results"); - expect(result).toContain("PROJ-123"); - expect(result).toContain("Test Error"); - expect(result).toContain("unresolved"); + expect(result.query).toBe("all issues"); + expect(result.issues).toHaveLength(1); + expect(result.issues[0]).toMatchObject({ + shortId: "PROJ-123", + title: "Test Error", + status: "unresolved", + occurrences: 100, + usersImpacted: 50, + }); + expect(result.issues[0]).not.toHaveProperty("level"); }); it("rewrites unsupported OR and parentheses before calling the API (no agent)", async () => { @@ -817,7 +877,7 @@ describe("search_issues", () => { ), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: "is:unresolved (is:regressed OR is:escalating)", @@ -833,7 +893,10 @@ describe("search_issues", () => { expect(mockGenerateText).not.toHaveBeenCalled(); expect(sentQuery).toBe("is:unresolved substatus:[regressed,escalating]"); - expect(result).toContain( + expect(result.executedSearch.query).toBe( + "is:unresolved substatus:[regressed,escalating]", + ); + expect(result.notes[0]).toContain( "Ran `is:unresolved substatus:[regressed,escalating]`.", ); }); diff --git a/packages/mcp-core/src/tools/catalog/search-issues.ts b/packages/mcp-core/src/tools/catalog/search-issues.ts index 4fe8560d5..1ebe3ebad 100644 --- a/packages/mcp-core/src/tools/catalog/search-issues.ts +++ b/packages/mcp-core/src/tools/catalog/search-issues.ts @@ -17,9 +17,10 @@ import type { ServerContext } from "../../types"; import { isNumericId, validateSlugOrId } from "../../utils/slug-validation"; import { searchIssuesAgent } from "../support/search-issues/agent"; import { - formatExplanation, - formatIssueResults, -} from "../support/search-issues/formatters"; + buildIssueResults, + type IssueSort, + searchIssuesOutputSchema, +} from "../support/search-issues/results"; const ProjectSlugOrIdSchema = z.string().trim().superRefine(validateSlugOrId); @@ -44,7 +45,7 @@ function rewriteIssueQuery(query: string): RewrittenSearchQuery { function buildIssueSearchRepairPrompt(params: { query: string; - sort: "date" | "freq" | "new" | "user" | "recommended"; + sort: IssueSort; }): string { return [ "Fix this Sentry issue search request.", @@ -138,6 +139,7 @@ export default defineTool({ "Include explanation of how the query was translated or repaired", ), }, + outputSchema: searchIssuesOutputSchema, annotations: { readOnlyHint: true, destructiveHint: false, @@ -151,7 +153,7 @@ export default defineTool({ setTargetTagsAndAttributes(params); let query: string; - let sort: "date" | "freq" | "new" | "user" | "recommended"; + let sort: IssueSort; let explanation: string | undefined; let projectId: string | undefined; @@ -222,59 +224,25 @@ export default defineTool({ issues.length, ); - // Build output with explanation first (if requested and NL was used), then results - let output = ""; - - if (params.includeExplanation && explanation) { - output += `# Search Results for "${params.query}"\n\n`; - output += `**Suggested presentation:** Cards work well for these issues, with status, assignee, and issue ID links visible.\n\n`; - - output += `## Query Translation\n`; - output += `Input query: "${params.query}"\n`; - output += `Sentry query: \`${query}\``; - output += `\nSort: ${sort}`; - output += `\n\n`; - - if (explanation) { - output += formatExplanation(explanation); - output += `\n\n`; - } - - output += formatIssueResults({ - issues, - organizationSlug: params.organizationSlug, - projectSlugOrId: params.projectSlugOrId ?? undefined, - query, - regionUrl: params.regionUrl ?? undefined, - host: context.sentryHost, - protocol: context.sentryProtocol, - inputQuery: params.query, - skipHeader: true, - experimentalMode: context.experimentalMode ?? false, - availableToolNames: context.availableToolNames, - directToolNames: context.directToolNames, - }); - } else { - output = formatIssueResults({ - issues, - organizationSlug: params.organizationSlug, - projectSlugOrId: params.projectSlugOrId ?? undefined, - query, - regionUrl: params.regionUrl ?? undefined, - host: context.sentryHost, - protocol: context.sentryProtocol, - inputQuery: params.query, - skipHeader: false, - experimentalMode: context.experimentalMode ?? false, - availableToolNames: context.availableToolNames, - directToolNames: context.directToolNames, - }); - } - - if (rewritten.notes.length > 0) { - output += `\n\n**Query rewritten:** ${rewritten.notes.join(" ")} Ran \`${query}\`.`; - } - - return output; + return buildIssueResults({ + issues, + organizationSlug: params.organizationSlug, + inputQuery: params.query, + query, + sort, + period: params.period, + projectSlugOrId: params.projectSlugOrId ?? undefined, + explanation: params.includeExplanation ? explanation : undefined, + notes: + rewritten.notes.length > 0 + ? [`Query rewritten: ${rewritten.notes.join(" ")} Ran \`${query}\`.`] + : [], + regionUrl: params.regionUrl ?? undefined, + host: context.sentryHost, + protocol: context.sentryProtocol, + experimentalMode: context.experimentalMode ?? false, + availableToolNames: context.availableToolNames, + directToolNames: context.directToolNames, + }); }, }); diff --git a/packages/mcp-core/src/tools/support/search-issues/README.md b/packages/mcp-core/src/tools/support/search-issues/README.md index 4dd476333..1e674003e 100644 --- a/packages/mcp-core/src/tools/support/search-issues/README.md +++ b/packages/mcp-core/src/tools/support/search-issues/README.md @@ -11,7 +11,7 @@ The `search_issues` tool uses an embedded AI agent to translate natural language - **Handler**: `handler.ts` - MCP tool definition and orchestration - **Agent**: `agent.ts` - AI translation logic - **Config**: `config.ts` - System prompts and settings -- **Formatters**: `formatters.ts` - Result formatting +- **Results**: `results.ts` - Structured result payload and output schema ## Agent Tools diff --git a/packages/mcp-core/src/tools/support/search-issues/formatters.test.ts b/packages/mcp-core/src/tools/support/search-issues/formatters.test.ts deleted file mode 100644 index 012d51739..000000000 --- a/packages/mcp-core/src/tools/support/search-issues/formatters.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { formatIssueResults } from "./formatters.js"; -import { - createPerformanceIssue, - createFeedbackIssue, -} from "@sentry/mcp-server-mocks"; - -describe("formatIssueResults", () => { - const baseParams = { - organizationSlug: "test-org", - regionUrl: "https://sentry.io", - }; - - describe("issueCategory display", () => { - it("displays category for feedback issues", () => { - const feedbackIssue = createFeedbackIssue({ - shortId: "TEST-FEEDBACK-1", - title: "User Feedback: Login broken", - status: "unresolved", - userCount: 1, - count: "1", - firstSeen: "2025-01-01T00:00:00Z", - lastSeen: "2025-01-01T00:00:00Z", - issueCategory: "feedback", - }); - - const result = formatIssueResults({ - ...baseParams, - issues: [feedbackIssue], - }); - - expect(result).toContain("**Category**: feedback"); - }); - - it("displays category for performance issues", () => { - const perfIssue = createPerformanceIssue({ - shortId: "TEST-PERF-1", - title: "N+1 Query", - status: "unresolved", - userCount: 10, - count: "100", - firstSeen: "2025-01-01T00:00:00Z", - lastSeen: "2025-01-01T00:00:00Z", - issueCategory: "performance", - }); - - const result = formatIssueResults({ - ...baseParams, - issues: [perfIssue], - }); - - expect(result).toContain("**Category**: performance"); - }); - - it("does not display category for error issues", () => { - const errorIssue = createFeedbackIssue({ - shortId: "TEST-ERROR-1", - title: "TypeError: Cannot read property", - status: "unresolved", - userCount: 5, - count: "50", - firstSeen: "2025-01-01T00:00:00Z", - lastSeen: "2025-01-01T00:00:00Z", - issueCategory: "error", - }); - - const result = formatIssueResults({ - ...baseParams, - issues: [errorIssue], - }); - - expect(result).not.toContain("**Category**:"); - }); - - it("does not display category when issueCategory is undefined", () => { - const issue = createFeedbackIssue({ - shortId: "TEST-1", - title: "Some Issue", - status: "unresolved", - userCount: 1, - count: "1", - firstSeen: "2025-01-01T00:00:00Z", - lastSeen: "2025-01-01T00:00:00Z", - issueCategory: undefined, - }); - - const result = formatIssueResults({ - ...baseParams, - issues: [issue], - }); - - expect(result).not.toContain("**Category**:"); - }); - }); - - describe("feedback-specific guidance", () => { - it("includes feedback guidance when results contain feedback issues", () => { - const feedbackIssue = createFeedbackIssue({ - shortId: "TEST-FEEDBACK-2", - title: "User Feedback: Page not loading", - issueCategory: "feedback", - }); - - const result = formatIssueResults({ - ...baseParams, - issues: [feedbackIssue], - }); - - expect(result).toContain( - "View feedback details: Use get_sentry_resource to see full feedback content and linked error events", - ); - }); - - it("does not include feedback guidance for non-feedback issues", () => { - const errorIssue = createPerformanceIssue({ - shortId: "TEST-ERROR-2", - title: "N+1 Query", - issueCategory: "performance", - }); - - const result = formatIssueResults({ - ...baseParams, - issues: [errorIssue], - }); - - expect(result).not.toContain("View feedback details:"); - }); - - it("includes feedback guidance when mixed issues contain at least one feedback", () => { - const errorIssue = createPerformanceIssue({ - shortId: "TEST-ERROR-3", - title: "N+1 Query", - issueCategory: "performance", - }); - const feedbackIssue = createFeedbackIssue({ - shortId: "TEST-FEEDBACK-3", - title: "User Feedback: Bug report", - issueCategory: "feedback", - }); - - const result = formatIssueResults({ - ...baseParams, - issues: [errorIssue, feedbackIssue], - }); - - expect(result).toContain("View feedback details:"); - }); - }); - - describe("availability-aware guidance", () => { - it("omits update_issue guidance when the tool is unavailable", () => { - const issue = createPerformanceIssue({ - shortId: "TEST-NO-TRIAGE", - title: "N+1 Query", - }); - - const result = formatIssueResults({ - ...baseParams, - issues: [issue], - availableToolNames: new Set([ - "get_sentry_resource", - "search_events", - "search_sentry_tools", - "execute_sentry_tool", - ]), - directToolNames: new Set([ - "get_sentry_resource", - "search_events", - "search_sentry_tools", - "execute_sentry_tool", - ]), - }); - - expect(result).not.toContain("Update issue status"); - expect(result).not.toContain("update_issue"); - }); - - it("includes update_issue guidance when the tool is available", () => { - const issue = createPerformanceIssue({ - shortId: "TEST-TRIAGE", - title: "N+1 Query", - }); - - const result = formatIssueResults({ - ...baseParams, - issues: [issue], - availableToolNames: new Set([ - "get_sentry_resource", - "search_events", - "search_sentry_tools", - "execute_sentry_tool", - "update_issue", - ]), - directToolNames: new Set([ - "get_sentry_resource", - "search_events", - "search_sentry_tools", - "execute_sentry_tool", - "update_issue", - ]), - }); - - expect(result).toContain( - "Update issue status: Use the Sentry tool `update_issue` to resolve or assign issues", - ); - }); - }); - - describe("empty results", () => { - it("handles empty issue list gracefully", () => { - const result = formatIssueResults({ - ...baseParams, - issues: [], - inputQuery: "user feedback", - }); - - expect(result).toContain("No issues found matching your search criteria"); - expect(result).not.toContain("View feedback details:"); - }); - }); - - describe("seer fixability score", () => { - it("displays fixability score when present", () => { - const issue = createPerformanceIssue({ - shortId: "TEST-1", - seerFixabilityScore: 0.8, - }); - - const result = formatIssueResults({ - ...baseParams, - issues: [issue], - }); - - expect(result).toContain("**Seer Actionability**: super_high"); - }); - - it("does not display fixability when not present", () => { - const issue = createPerformanceIssue({ - shortId: "TEST-1", - }); - - const result = formatIssueResults({ - ...baseParams, - issues: [issue], - }); - - expect(result).not.toContain("Seer Actionability"); - }); - - it("displays correct label for different score thresholds", () => { - const highIssue = createPerformanceIssue({ - shortId: "TEST-HIGH", - seerFixabilityScore: 0.7, - }); - const mediumIssue = createPerformanceIssue({ - shortId: "TEST-MED", - seerFixabilityScore: 0.5, - }); - const lowIssue = createPerformanceIssue({ - shortId: "TEST-LOW", - seerFixabilityScore: 0.3, - }); - - const highResult = formatIssueResults({ - ...baseParams, - issues: [highIssue], - }); - const mediumResult = formatIssueResults({ - ...baseParams, - issues: [mediumIssue], - }); - const lowResult = formatIssueResults({ - ...baseParams, - issues: [lowIssue], - }); - - expect(highResult).toContain("**Seer Actionability**: high"); - expect(mediumResult).toContain("**Seer Actionability**: medium"); - expect(lowResult).toContain("**Seer Actionability**: low"); - }); - }); - - describe("output format", () => { - it("formats feedback issue correctly", () => { - const feedbackIssue = createFeedbackIssue({ - shortId: "PROJ-FB-1", - title: "User Feedback: Checkout broken", - status: "unresolved", - userCount: 1, - count: "1", - firstSeen: "2025-01-01T00:00:00Z", - lastSeen: "2025-01-01T00:00:00Z", - issueCategory: "feedback", - culprit: "User Feedback Widget", - }); - - const result = formatIssueResults({ - ...baseParams, - issues: [feedbackIssue], - inputQuery: "show me user feedback", - }); - - expect(result).toMatchInlineSnapshot(` - "# Search Results for "show me user feedback" - - **Suggested presentation:** Cards work well for these issues, with status, assignee, and issue ID links visible. - - **View these results in Sentry**: - https://test-org.sentry.io/issues/ - Please tell the user this dashboard link is available if they want to open the results in Sentry. - - Found **1** issue: - - ## 1. [PROJ-FB-1](https://test-org.sentry.io/issues/PROJ-FB-1) - - **User Feedback: Checkout broken** - - - **Status**: unresolved - - **Category**: feedback - - **Users**: 1 - - **Events**: 1 - - **First seen**: 2025-01-01 - - **Last seen**: 2025-01-01 - - **Culprit**: \`User Feedback Widget\` - - ## Next Steps - - - Get more details about a specific issue: Use get_sentry_resource with the issue ID or issue URL - - Update issue status: Use the Sentry tool \`update_issue\` to resolve or assign issues - - View event counts: Use search_errors for aggregated statistics - - View feedback details: Use get_sentry_resource to see full feedback content and linked error events - " - `); - }); - - it("uses the configured self-hosted host and protocol when regionUrl is absent", () => { - const feedbackIssue = createFeedbackIssue({ - shortId: "PROJ-FB-2", - title: "User Feedback: Internal Sentry", - issueCategory: "feedback", - }); - - const result = formatIssueResults({ - organizationSlug: "test-org", - host: "sentry.internal:9000", - protocol: "http", - issues: [feedbackIssue], - inputQuery: "show me user feedback", - }); - - expect(result).toContain( - "http://sentry.internal:9000/organizations/test-org/issues/", - ); - expect(result).toContain( - "## 1. [PROJ-FB-2](http://sentry.internal:9000/organizations/test-org/issues/PROJ-FB-2)", - ); - }); - }); -}); diff --git a/packages/mcp-core/src/tools/support/search-issues/formatters.ts b/packages/mcp-core/src/tools/support/search-issues/formatters.ts deleted file mode 100644 index 6e1cea6b6..000000000 --- a/packages/mcp-core/src/tools/support/search-issues/formatters.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { Issue } from "../../../api-client"; -import { logInfo } from "../../../telem/logging"; -import { getIssueUrl, getIssuesSearchUrl } from "../../../utils/url-utils"; -import { getSeerActionabilityLabel } from "../../../internal/formatting"; -import { formatAvailableToolCallInstruction } from "../../../internal/tool-helpers/tool-call-formatting"; -import type { SentryProtocol } from "../../../types"; - -/** - * Format an explanation for how the input query was translated - */ -export function formatExplanation(explanation: string): string { - return `## How I interpreted your query\n\n${explanation}`; -} - -export interface FormatIssueResultsParams { - issues: Issue[]; - organizationSlug: string; - projectSlugOrId?: string; - query?: string | null; - regionUrl?: string; - host?: string; - protocol?: SentryProtocol; - inputQuery?: string; - skipHeader?: boolean; - experimentalMode?: boolean; - availableToolNames?: ReadonlySet; - directToolNames?: ReadonlySet; -} - -/** - * Format issue search results for display - */ -export function formatIssueResults(params: FormatIssueResultsParams): string { - const { - issues, - organizationSlug, - projectSlugOrId, - query, - regionUrl, - host, - protocol = "https", - inputQuery, - skipHeader = false, - experimentalMode = false, - availableToolNames, - directToolNames, - } = params; - - const region = regionUrl ? new URL(regionUrl) : null; - const resolvedHost = region?.host || host || "sentry.io"; - const resolvedProtocol = - (region?.protocol.replace(":", "") as SentryProtocol | undefined) || - protocol; - - let output = ""; - - // Skip header section if requested (when called from handler with includeExplanation) - if (!skipHeader) { - // Use the original input in the title if provided, otherwise fall back to org/project. - if (inputQuery) { - output = `# Search Results for "${inputQuery}"\n\n`; - } else { - output = `# Issues in **${organizationSlug}`; - if (projectSlugOrId) { - output += `/${projectSlugOrId}`; - } - output += "**\n\n"; - } - - // Add lightweight presentation guidance for clients that can render rich results. - output += `**Suggested presentation:** Cards work well for these issues, with status, assignee, and issue ID links visible.\n\n`; - } - - if (issues.length === 0) { - logInfo(`No issues found for query: ${inputQuery || query}`, { - extra: { - query, - organizationSlug, - projectSlug: projectSlugOrId, - inputQuery, - }, - }); - output += "No issues found matching your search criteria.\n\n"; - output += "Try adjusting your search criteria or time range."; - return output; - } - - // Generate search URL for viewing results - const searchUrl = getIssuesSearchUrl( - resolvedHost, - organizationSlug, - query, - projectSlugOrId, - resolvedProtocol, - ); - - // Add view link with lightweight guidance text (like the search_* event tools) - output += `**View these results in Sentry**:\n${searchUrl}\n`; - output += `Please tell the user this dashboard link is available if they want to open the results in Sentry.\n\n`; - - output += `Found **${issues.length}** issue${issues.length === 1 ? "" : "s"}:\n\n`; - - // Format each issue - issues.forEach((issue, index) => { - // Generate issue URL using the utility function - const issueUrl = getIssueUrl( - resolvedHost, - organizationSlug, - issue.shortId, - resolvedProtocol, - ); - - output += `## ${index + 1}. [${issue.shortId}](${issueUrl})\n\n`; - output += `**${issue.title}**\n\n`; - - // Issue metadata - // Issues don't have a level field in the API response - output += `- **Status**: ${issue.status}\n`; - // Display issue category for non-error types (feedback, performance, metric) - if (issue.issueCategory && issue.issueCategory !== "error") { - output += `- **Category**: ${issue.issueCategory}\n`; - } - output += `- **Users**: ${issue.userCount || 0}\n`; - output += `- **Events**: ${issue.count || 0}\n`; - - if (issue.assignedTo) { - const assignee = issue.assignedTo; - if (typeof assignee === "string") { - output += `- **Assigned to**: ${assignee}\n`; - } else if ( - assignee && - typeof assignee === "object" && - "name" in assignee - ) { - output += `- **Assigned to**: ${assignee.name}\n`; - } - } - - output += `- **First seen**: ${formatDate(issue.firstSeen)}\n`; - output += `- **Last seen**: ${formatDate(issue.lastSeen)}\n`; - - if (issue.culprit) { - output += `- **Culprit**: \`${issue.culprit}\`\n`; - } - - if (issue.seerFixabilityScore != null) { - output += `- **Seer Actionability**: ${getSeerActionabilityLabel(issue.seerFixabilityScore)}\n`; - } - - output += "\n"; - }); - - // Add next steps section (like the search_* event tools) - output += "## Next Steps\n\n"; - output += - "- Get more details about a specific issue: Use get_sentry_resource with the issue ID or issue URL\n"; - const updateIssueInstruction = formatAvailableToolCallInstruction({ - toolName: "update_issue", - experimentalMode, - availableToolNames, - directToolNames, - purpose: "to resolve or assign issues", - }); - if (updateIssueInstruction) { - output += `- Update issue status: ${updateIssueInstruction}\n`; - } - output += - "- View event counts: Use search_errors for aggregated statistics\n"; - - // Add feedback-specific guidance if results contain feedback - const hasFeedback = issues.some((i) => i.issueCategory === "feedback"); - if (hasFeedback) { - output += - "- View feedback details: Use get_sentry_resource to see full feedback content and linked error events\n"; - } - - return output; -} - -/** - * Format date for display - */ -function formatDate(dateString?: string | null): string { - if (!dateString) return "N/A"; - - const date = new Date(dateString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); - - if (diffHours < 1) { - const diffMinutes = Math.floor(diffMs / (1000 * 60)); - return `${diffMinutes} minute${diffMinutes === 1 ? "" : "s"} ago`; - } - if (diffHours < 24) { - return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`; - } - const diffDays = Math.floor(diffHours / 24); - if (diffDays < 30) { - return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; - } - return date.toISOString().split("T")[0]; -} diff --git a/packages/mcp-core/src/tools/support/search-issues/results.test.ts b/packages/mcp-core/src/tools/support/search-issues/results.test.ts new file mode 100644 index 000000000..cccb100f5 --- /dev/null +++ b/packages/mcp-core/src/tools/support/search-issues/results.test.ts @@ -0,0 +1,221 @@ +import { + createFeedbackIssue, + createPerformanceIssue, +} from "@sentry/mcp-server-mocks"; +import { describe, expect, it } from "vitest"; +import { + type BuildIssueResultsParams, + buildIssueResults, + searchIssuesOutputSchema, +} from "./results"; + +const baseParams: Omit = { + organizationSlug: "test-org", + regionUrl: "https://sentry.io", + inputQuery: "is:unresolved", + query: "is:unresolved", + sort: "date", + period: "30d", + notes: [], +}; + +function build(params: Partial) { + const { structuredContent } = buildIssueResults({ + ...baseParams, + issues: [], + ...params, + }); + expect(searchIssuesOutputSchema.parse(structuredContent)).toEqual( + structuredContent, + ); + return structuredContent; +} + +describe("buildIssueResults", () => { + it("maps a feedback issue explicitly", () => { + const feedbackIssue = createFeedbackIssue({ + shortId: "PROJ-FB-1", + title: "User Feedback: Checkout broken", + status: "unresolved", + userCount: 1, + count: "1", + firstSeen: "2025-01-01T00:00:00Z", + lastSeen: "2025-01-01T00:00:00Z", + issueCategory: "feedback", + culprit: "User Feedback Widget", + secretBackendField: "must-not-leak", + } as Parameters[0]); + + const structuredContent = build({ + issues: [feedbackIssue], + inputQuery: "show me user feedback", + }); + + expect(JSON.stringify(structuredContent)).not.toContain("must-not-leak"); + expect(structuredContent).toMatchInlineSnapshot(` + { + "executedSearch": { + "period": "30d", + "projectSlugOrId": null, + "query": "is:unresolved", + "sort": "date", + }, + "explanation": null, + "issues": [ + { + "assignedTo": null, + "culprit": "User Feedback Widget", + "firstSeen": "2025-01-01T00:00:00Z", + "issueCategory": "feedback", + "lastSeen": "2025-01-01T00:00:00Z", + "occurrences": 1, + "projectSlug": "CLOUDFLARE-MCP", + "seerActionability": null, + "shortId": "PROJ-FB-1", + "status": "unresolved", + "substatus": "new", + "title": "User Feedback: Checkout broken", + "url": "https://test-org.sentry.io/issues/PROJ-FB-1", + "usersImpacted": 1, + }, + ], + "notes": [], + "presentationHint": "Cards work well for these issues, with status, assignee, and issue ID links visible. Please tell the user this dashboard link is available if they want to open the results in Sentry.", + "query": "show me user feedback", + "suggestedActions": [ + { + "arguments": { + "organizationSlug": "test-org", + "resourceId": "PROJ-FB-1", + "resourceType": "issue", + }, + "reason": "Read the full feedback and its linked error events.", + "toolName": "get_sentry_resource", + "type": "tool_call", + }, + ], + "url": "https://test-org.sentry.io/issues/?query=is%3Aunresolved", + } + `); + }); + + it("reports the category, actionability, and numeric counts", () => { + const structuredContent = build({ + issues: [ + createPerformanceIssue({ + shortId: "TEST-HIGH", + count: "100", + userCount: 10, + seerFixabilityScore: 0.7, + assignedTo: { + type: "user", + id: "1", + name: "Jane Doe", + email: "jane@example.com", + }, + }), + createPerformanceIssue({ shortId: "TEST-NONE" }), + ], + }); + + expect( + structuredContent.issues.map( + ({ + shortId, + issueCategory, + occurrences, + seerActionability, + assignedTo, + }) => ({ + shortId, + issueCategory, + occurrences, + seerActionability, + assignedTo, + }), + ), + ).toMatchInlineSnapshot(` + [ + { + "assignedTo": "Jane Doe", + "issueCategory": "performance", + "occurrences": 100, + "seerActionability": "high", + "shortId": "TEST-HIGH", + }, + { + "assignedTo": null, + "issueCategory": "performance", + "occurrences": 25, + "seerActionability": null, + "shortId": "TEST-NONE", + }, + ] + `); + }); + + it("adds a feedback follow-up when any result is feedback", () => { + const structuredContent = build({ + issues: [ + createPerformanceIssue({ shortId: "TEST-PERF-3" }), + createFeedbackIssue({ + shortId: "TEST-FEEDBACK-3", + issueCategory: "feedback", + }), + ], + }); + + expect( + structuredContent.suggestedActions.map((action) => [ + action.arguments.resourceId, + action.reason, + ]), + ).toEqual([ + ["TEST-PERF-3", "Inspect the first matching issue."], + [ + "TEST-FEEDBACK-3", + "Read the full feedback and its linked error events.", + ], + ]); + }); + + it("omits follow-ups the session cannot call", () => { + const structuredContent = build({ + issues: [createPerformanceIssue({ shortId: "TEST-NO-RESOURCE" })], + availableToolNames: new Set(["search_issues"]), + directToolNames: new Set(["search_issues"]), + }); + + expect(structuredContent.suggestedActions).toEqual([]); + }); + + it("explains an empty result in notes", () => { + const structuredContent = build({ + issues: [], + notes: ["Query rewritten: example. Ran `is:unresolved`."], + }); + + expect(structuredContent.issues).toEqual([]); + expect(structuredContent.notes).toEqual([ + "Query rewritten: example. Ran `is:unresolved`.", + "No issues found matching your search criteria. Try adjusting your search criteria or time range.", + ]); + expect(structuredContent.suggestedActions).toEqual([]); + }); + + it("uses the configured self-hosted host and protocol when regionUrl is absent", () => { + const structuredContent = build({ + regionUrl: undefined, + host: "sentry.internal:9000", + protocol: "http", + issues: [createFeedbackIssue({ shortId: "PROJ-FB-2" })], + }); + + expect(structuredContent.url).toBe( + "http://sentry.internal:9000/organizations/test-org/issues/?query=is%3Aunresolved", + ); + expect(structuredContent.issues[0]?.url).toBe( + "http://sentry.internal:9000/organizations/test-org/issues/PROJ-FB-2", + ); + }); +}); diff --git a/packages/mcp-core/src/tools/support/search-issues/results.ts b/packages/mcp-core/src/tools/support/search-issues/results.ts new file mode 100644 index 000000000..caa7784fd --- /dev/null +++ b/packages/mcp-core/src/tools/support/search-issues/results.ts @@ -0,0 +1,191 @@ +import { z } from "zod"; +import type { Issue } from "../../../api-client"; +import { getSeerActionabilityLabel } from "../../../internal/formatting"; +import { structuredResult } from "../../../internal/tool-helpers/results"; +import { + buildSuggestedToolAction, + type SuggestedToolAction, + suggestedToolActionSchema, +} from "../../../internal/tool-helpers/suggested-actions"; +import { logInfo } from "../../../telem/logging"; +import type { SentryProtocol } from "../../../types"; +import { getIssueUrl, getIssuesSearchUrl } from "../../../utils/url-utils"; +import type { StructuredToolOutput } from "../../types"; + +const PRESENTATION_HINT = + "Cards work well for these issues, with status, assignee, and issue ID links visible. Please tell the user this dashboard link is available if they want to open the results in Sentry."; +const EMPTY_NOTE = + "No issues found matching your search criteria. Try adjusting your search criteria or time range."; + +const ISSUE_SORTS = ["date", "freq", "new", "user", "recommended"] as const; + +export type IssueSort = (typeof ISSUE_SORTS)[number]; + +/** + * The search_issues payload. Issue fields are mapped explicitly, with the + * names get_issue_details uses, so the passthrough issue schema cannot leak. + */ +export const searchIssuesOutputSchema = z.object({ + query: z.string(), + explanation: z.string().nullable(), + executedSearch: z.object({ + query: z.string(), + sort: z.enum(ISSUE_SORTS), + period: z.string(), + projectSlugOrId: z.string().nullable(), + }), + url: z.string().describe("Opens these results in Sentry."), + presentationHint: z.string(), + notes: z.array(z.string()), + issues: z.array( + z.object({ + shortId: z.string(), + title: z.string(), + url: z.string(), + status: z.string(), + substatus: z.string().nullable(), + issueCategory: z.string().nullable(), + occurrences: z.number(), + usersImpacted: z.number(), + assignedTo: z.string().nullable(), + firstSeen: z.string().nullable(), + lastSeen: z.string().nullable(), + culprit: z.string().nullable(), + seerActionability: z.string().nullable(), + projectSlug: z.string().nullable(), + }), + ), + suggestedActions: z.array(suggestedToolActionSchema), +}); + +export type SearchIssuesStructuredContent = z.infer< + typeof searchIssuesOutputSchema +>; + +export interface BuildIssueResultsParams { + issues: Issue[]; + organizationSlug: string; + inputQuery: string; + query: string; + sort: IssueSort; + period: string; + projectSlugOrId?: string; + explanation?: string; + notes: string[]; + regionUrl?: string; + host?: string; + protocol?: SentryProtocol; + experimentalMode?: boolean; + availableToolNames?: ReadonlySet; + directToolNames?: ReadonlySet; +} + +function formatAssignee(assignee: Issue["assignedTo"]): string | null { + if (typeof assignee === "string") { + return assignee; + } + return assignee?.name ?? null; +} + +/** Builds the structured search_issues result. */ +export function buildIssueResults( + params: BuildIssueResultsParams, +): StructuredToolOutput { + const { organizationSlug, projectSlugOrId, query } = params; + + const region = params.regionUrl ? new URL(params.regionUrl) : null; + const host = region?.host || params.host || "sentry.io"; + const protocol = + (region?.protocol.replace(":", "") as SentryProtocol | undefined) || + params.protocol || + "https"; + + if (params.issues.length === 0) { + logInfo(`No issues found for query: ${params.inputQuery || query}`, { + extra: { + query, + organizationSlug, + projectSlug: projectSlugOrId, + inputQuery: params.inputQuery, + }, + }); + } + + const issues = params.issues.map((issue) => ({ + shortId: issue.shortId, + title: issue.title, + url: getIssueUrl(host, organizationSlug, issue.shortId, protocol), + status: issue.status, + substatus: issue.substatus ?? null, + issueCategory: issue.issueCategory ?? null, + occurrences: Number(issue.count) || 0, + usersImpacted: Number(issue.userCount) || 0, + assignedTo: formatAssignee(issue.assignedTo), + firstSeen: issue.firstSeen, + lastSeen: issue.lastSeen, + culprit: issue.culprit || null, + seerActionability: + issue.seerFixabilityScore == null + ? null + : getSeerActionabilityLabel(issue.seerFixabilityScore), + projectSlug: issue.project?.slug ?? null, + })); + + return structuredResult({ + query: params.inputQuery, + explanation: params.explanation || null, + executedSearch: { + query, + sort: params.sort, + period: params.period, + projectSlugOrId: projectSlugOrId ?? null, + }, + url: getIssuesSearchUrl( + host, + organizationSlug, + query, + projectSlugOrId, + protocol, + ), + presentationHint: PRESENTATION_HINT, + notes: issues.length === 0 ? [...params.notes, EMPTY_NOTE] : params.notes, + issues, + suggestedActions: buildIssueActions(issues, params), + }); +} + +function buildIssueActions( + issues: SearchIssuesStructuredContent["issues"], + params: BuildIssueResultsParams, +): SuggestedToolAction[] { + const first = issues[0]; + const feedback = issues.find((issue) => issue.issueCategory === "feedback"); + const targets = [ + first && first !== feedback + ? { issue: first, reason: "Inspect the first matching issue." } + : null, + feedback + ? { + issue: feedback, + reason: "Read the full feedback and its linked error events.", + } + : null, + ]; + return targets.flatMap((target) => { + const action = target + ? buildSuggestedToolAction({ + toolName: "get_sentry_resource", + arguments: { + organizationSlug: params.organizationSlug, + resourceType: "issue", + resourceId: target.issue.shortId, + }, + reason: target.reason, + experimentalMode: params.experimentalMode ?? false, + availableToolNames: params.availableToolNames, + directToolNames: params.directToolNames, + }) + : null; + return action ? [action] : []; + }); +} From d260f7d757e086ce3c64abc4b7e0b185c4934716 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:44:07 +0000 Subject: [PATCH 2/6] test(mcp): Assert search_issues omits assignee and feedback reporter emails Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- .../src/tools/support/search-issues/results.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/mcp-core/src/tools/support/search-issues/results.test.ts b/packages/mcp-core/src/tools/support/search-issues/results.test.ts index cccb100f5..63b857ae8 100644 --- a/packages/mcp-core/src/tools/support/search-issues/results.test.ts +++ b/packages/mcp-core/src/tools/support/search-issues/results.test.ts @@ -44,6 +44,7 @@ describe("buildIssueResults", () => { issueCategory: "feedback", culprit: "User Feedback Widget", secretBackendField: "must-not-leak", + metadata: { contact_email: "reporter@example.com", name: "Reporter" }, } as Parameters[0]); const structuredContent = build({ @@ -51,7 +52,14 @@ describe("buildIssueResults", () => { inputQuery: "show me user feedback", }); - expect(JSON.stringify(structuredContent)).not.toContain("must-not-leak"); + const serialized = JSON.stringify(structuredContent); + for (const leaked of [ + "must-not-leak", + "reporter@example.com", + "Reporter", + ]) { + expect(serialized).not.toContain(leaked); + } expect(structuredContent).toMatchInlineSnapshot(` { "executedSearch": { @@ -152,6 +160,7 @@ describe("buildIssueResults", () => { }, ] `); + expect(JSON.stringify(structuredContent)).not.toContain("jane@example.com"); }); it("adds a feedback follow-up when any result is feedback", () => { From 9640c0f1e523e78e8a111d665a2b2d5434c2d0b2 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:44:09 +0000 Subject: [PATCH 3/6] ref(mcp): Align the shared suggested-action helper Copy suggested-actions.ts and ai-conversation-actions.ts byte-for-byte from the get_replay_details change, which exports MAX_SUGGESTED_ACTIONS and SuggestedActionValue, so every structured-output branch adds the same bytes and they merge cleanly in any order. Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- .../tool-helpers/ai-conversation-actions.ts | 15 +++--- .../tool-helpers/suggested-actions.ts | 47 +++++++++---------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/packages/mcp-core/src/internal/tool-helpers/ai-conversation-actions.ts b/packages/mcp-core/src/internal/tool-helpers/ai-conversation-actions.ts index 3068b48f1..63ff7bd82 100644 --- a/packages/mcp-core/src/internal/tool-helpers/ai-conversation-actions.ts +++ b/packages/mcp-core/src/internal/tool-helpers/ai-conversation-actions.ts @@ -1,12 +1,11 @@ import { inlineCode } from "@sentry/toolkit-core/markdown"; import { - buildSuggestedToolAction, + MAX_SUGGESTED_ACTIONS, type SuggestedToolAction, + buildSuggestedToolAction, } from "./suggested-actions"; import { formatToolCall } from "./tool-call-formatting"; -const MAX_SUGGESTED_ACTIONS = 3; - export interface AIConversationReference { conversationId: string; spanId?: string; @@ -26,8 +25,9 @@ export function getAIConversationSuggestedActions({ availableToolNames?: ReadonlySet; directToolNames?: ReadonlySet; }): SuggestedToolAction[] { - return aiConversations.slice(0, MAX_SUGGESTED_ACTIONS).flatMap( - (conversation) => + return aiConversations + .slice(0, MAX_SUGGESTED_ACTIONS) + .map((conversation) => buildSuggestedToolAction({ toolName: "get_agent_conversation_details", arguments: { @@ -38,8 +38,9 @@ export function getAIConversationSuggestedActions({ experimentalMode, availableToolNames, directToolNames, - }) ?? [], - ); + }), + ) + .filter((action): action is SuggestedToolAction => action !== null); } /** Returns markdown instructions that mirror the callable structured actions. */ diff --git a/packages/mcp-core/src/internal/tool-helpers/suggested-actions.ts b/packages/mcp-core/src/internal/tool-helpers/suggested-actions.ts index f974784d6..ee84917c5 100644 --- a/packages/mcp-core/src/internal/tool-helpers/suggested-actions.ts +++ b/packages/mcp-core/src/internal/tool-helpers/suggested-actions.ts @@ -2,7 +2,10 @@ import { z } from "zod"; import { isTopLevelToolName } from "../../tools/surfaces"; import { isToolAvailableInSession } from "./tool-call-formatting"; -type SuggestedActionValue = +/** See docs/contributing/tool-responses.md#suggested-actions. */ +export const MAX_SUGGESTED_ACTIONS = 3; + +export type SuggestedActionValue = | string | number | boolean @@ -26,8 +29,8 @@ export const suggestedToolActionSchema = z.object({ }); /** - * Builds a follow-up call for `toolName` on the session's direct or catalog - * surface. Returns null when the session cannot call the tool. + * Builds a follow-up call that matches the session's direct or catalog + * surface, or null when the target tool cannot be called in this session. */ export function buildSuggestedToolAction({ toolName, @@ -48,31 +51,27 @@ export function buildSuggestedToolAction({ return null; } - const isDirect = (name: string) => - directToolNames - ? directToolNames.has(name) - : isTopLevelToolName(name, experimentalMode); - - if (isDirect(toolName)) { - return { - type: "tool_call", - toolName, - arguments: targetArguments, - reason, - }; + const targetIsDirect = directToolNames + ? directToolNames.has(toolName) + : isTopLevelToolName(toolName, experimentalMode); + if (targetIsDirect) { + return { type: "tool_call", toolName, arguments: targetArguments, reason }; } + const executeToolIsDirect = directToolNames + ? directToolNames.has("execute_sentry_tool") + : isTopLevelToolName("execute_sentry_tool", experimentalMode); if ( - isDirect("execute_sentry_tool") && - isToolAvailableInSession("execute_sentry_tool", availableToolNames) + !executeToolIsDirect || + !isToolAvailableInSession("execute_sentry_tool", availableToolNames) ) { - return { - type: "tool_call", - toolName: "execute_sentry_tool", - arguments: { name: toolName, arguments: targetArguments }, - reason, - }; + return null; } - return null; + return { + type: "tool_call", + toolName: "execute_sentry_tool", + arguments: { name: toolName, arguments: targetArguments }, + reason, + }; } From 5eea6a62d85ffea7465ed2118f99b18a4bb6d18f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:58:22 +0000 Subject: [PATCH 4/6] feat(mcp): Return assignee and feedback reporter emails from search_issues Map the assignee email and the feedback reporter's name and contact email as explicit fields. Keep the issue metadata schema from stripping the reporter fields during parsing. Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- packages/mcp-core/src/api-client/schema.ts | 3 +++ packages/mcp-core/src/toolDefinitions.json | 26 +++++++++++++++++++ .../src/tools/catalog/search-issues.test.ts | 2 ++ .../support/search-issues/results.test.ts | 22 +++++++++------- .../tools/support/search-issues/results.ts | 20 ++++++++++++++ 5 files changed, 64 insertions(+), 9 deletions(-) diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index 725e3300b..2533f74de 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -875,6 +875,9 @@ export const IssueSchema = z title: z.string().nullable().optional(), location: z.string().nullable().optional(), value: z.string().nullable().optional(), + // Feedback issues carry who submitted them. + name: z.string().nullable().optional(), + contact_email: z.string().nullable().optional(), }) .optional(), seerFixabilityScore: z.number().nullable().optional(), diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 34e6ee04b..63bffcaed 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -7878,6 +7878,30 @@ "assignedTo": { "type": ["string", "null"] }, + "assigneeEmail": { + "type": ["string", "null"] + }, + "reporter": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": ["string", "null"] + }, + "email": { + "type": ["string", "null"] + } + }, + "required": ["name", "email"], + "additionalProperties": false + }, + { + "type": "null" + } + ], + "description": "Who submitted a feedback issue. Null for other issues." + }, "firstSeen": { "type": ["string", "null"] }, @@ -7904,6 +7928,8 @@ "occurrences", "usersImpacted", "assignedTo", + "assigneeEmail", + "reporter", "firstSeen", "lastSeen", "culprit", diff --git a/packages/mcp-core/src/tools/catalog/search-issues.test.ts b/packages/mcp-core/src/tools/catalog/search-issues.test.ts index 919e7a05e..3b9fd1041 100644 --- a/packages/mcp-core/src/tools/catalog/search-issues.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-issues.test.ts @@ -201,12 +201,14 @@ describe("search_issues", () => { "issues": [ { "assignedTo": null, + "assigneeEmail": null, "culprit": "test.function", "firstSeen": "2025-01-15T10:00:00Z", "issueCategory": null, "lastSeen": "2025-01-15T12:00:00Z", "occurrences": 100, "projectSlug": "test-project", + "reporter": null, "seerActionability": null, "shortId": "PROJ-123", "status": "unresolved", diff --git a/packages/mcp-core/src/tools/support/search-issues/results.test.ts b/packages/mcp-core/src/tools/support/search-issues/results.test.ts index 63b857ae8..79a81ff98 100644 --- a/packages/mcp-core/src/tools/support/search-issues/results.test.ts +++ b/packages/mcp-core/src/tools/support/search-issues/results.test.ts @@ -3,6 +3,7 @@ import { createPerformanceIssue, } from "@sentry/mcp-server-mocks"; import { describe, expect, it } from "vitest"; +import { IssueSchema } from "../../../api-client/schema"; import { type BuildIssueResultsParams, buildIssueResults, @@ -48,18 +49,13 @@ describe("buildIssueResults", () => { } as Parameters[0]); const structuredContent = build({ - issues: [feedbackIssue], + // Parse like the API client does so the reporter fields must survive it. + issues: [IssueSchema.parse(feedbackIssue)], inputQuery: "show me user feedback", }); const serialized = JSON.stringify(structuredContent); - for (const leaked of [ - "must-not-leak", - "reporter@example.com", - "Reporter", - ]) { - expect(serialized).not.toContain(leaked); - } + expect(serialized).not.toContain("must-not-leak"); expect(structuredContent).toMatchInlineSnapshot(` { "executedSearch": { @@ -72,12 +68,17 @@ describe("buildIssueResults", () => { "issues": [ { "assignedTo": null, + "assigneeEmail": null, "culprit": "User Feedback Widget", "firstSeen": "2025-01-01T00:00:00Z", "issueCategory": "feedback", "lastSeen": "2025-01-01T00:00:00Z", "occurrences": 1, "projectSlug": "CLOUDFLARE-MCP", + "reporter": { + "email": "reporter@example.com", + "name": "Reporter", + }, "seerActionability": null, "shortId": "PROJ-FB-1", "status": "unresolved", @@ -134,18 +135,21 @@ describe("buildIssueResults", () => { occurrences, seerActionability, assignedTo, + assigneeEmail, }) => ({ shortId, issueCategory, occurrences, seerActionability, assignedTo, + assigneeEmail, }), ), ).toMatchInlineSnapshot(` [ { "assignedTo": "Jane Doe", + "assigneeEmail": "jane@example.com", "issueCategory": "performance", "occurrences": 100, "seerActionability": "high", @@ -153,6 +157,7 @@ describe("buildIssueResults", () => { }, { "assignedTo": null, + "assigneeEmail": null, "issueCategory": "performance", "occurrences": 25, "seerActionability": null, @@ -160,7 +165,6 @@ describe("buildIssueResults", () => { }, ] `); - expect(JSON.stringify(structuredContent)).not.toContain("jane@example.com"); }); it("adds a feedback follow-up when any result is feedback", () => { diff --git a/packages/mcp-core/src/tools/support/search-issues/results.ts b/packages/mcp-core/src/tools/support/search-issues/results.ts index caa7784fd..34c122728 100644 --- a/packages/mcp-core/src/tools/support/search-issues/results.ts +++ b/packages/mcp-core/src/tools/support/search-issues/results.ts @@ -48,6 +48,14 @@ export const searchIssuesOutputSchema = z.object({ occurrences: z.number(), usersImpacted: z.number(), assignedTo: z.string().nullable(), + assigneeEmail: z.string().nullable(), + reporter: z + .object({ + name: z.string().nullable(), + email: z.string().nullable(), + }) + .nullable() + .describe("Who submitted a feedback issue. Null for other issues."), firstSeen: z.string().nullable(), lastSeen: z.string().nullable(), culprit: z.string().nullable(), @@ -87,6 +95,16 @@ function formatAssignee(assignee: Issue["assignedTo"]): string | null { return assignee?.name ?? null; } +function formatAssigneeEmail(assignee: Issue["assignedTo"]): string | null { + return typeof assignee === "object" ? assignee?.email || null : null; +} + +function formatReporter(issue: Issue) { + const name = issue.metadata?.name || null; + const email = issue.metadata?.contact_email || null; + return name || email ? { name, email } : null; +} + /** Builds the structured search_issues result. */ export function buildIssueResults( params: BuildIssueResultsParams, @@ -121,6 +139,8 @@ export function buildIssueResults( occurrences: Number(issue.count) || 0, usersImpacted: Number(issue.userCount) || 0, assignedTo: formatAssignee(issue.assignedTo), + assigneeEmail: formatAssigneeEmail(issue.assignedTo), + reporter: formatReporter(issue), firstSeen: issue.firstSeen, lastSeen: issue.lastSeen, culprit: issue.culprit || null, From c2a4dfa6c8819772b7f893f209193bde70ad60d5 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:18:48 +0000 Subject: [PATCH 5/6] fix(mcp): Only read the search_issues reporter from feedback issues Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- .../support/search-issues/results.test.ts | 18 ++++++++++++++++++ .../src/tools/support/search-issues/results.ts | 3 +++ 2 files changed, 21 insertions(+) diff --git a/packages/mcp-core/src/tools/support/search-issues/results.test.ts b/packages/mcp-core/src/tools/support/search-issues/results.test.ts index 79a81ff98..549bb3802 100644 --- a/packages/mcp-core/src/tools/support/search-issues/results.test.ts +++ b/packages/mcp-core/src/tools/support/search-issues/results.test.ts @@ -167,6 +167,24 @@ describe("buildIssueResults", () => { `); }); + it("only reads the reporter from feedback issues", () => { + const structuredContent = build({ + issues: [ + IssueSchema.parse( + createPerformanceIssue({ + shortId: "TEST-NOT-FEEDBACK", + metadata: { + name: "Not a reporter", + contact_email: "x@example.com", + }, + } as Parameters[0]), + ), + ], + }); + + expect(structuredContent.issues[0]?.reporter).toBeNull(); + }); + it("adds a feedback follow-up when any result is feedback", () => { const structuredContent = build({ issues: [ diff --git a/packages/mcp-core/src/tools/support/search-issues/results.ts b/packages/mcp-core/src/tools/support/search-issues/results.ts index 34c122728..7ddd9045d 100644 --- a/packages/mcp-core/src/tools/support/search-issues/results.ts +++ b/packages/mcp-core/src/tools/support/search-issues/results.ts @@ -100,6 +100,9 @@ function formatAssigneeEmail(assignee: Issue["assignedTo"]): string | null { } function formatReporter(issue: Issue) { + if (issue.issueCategory !== "feedback") { + return null; + } const name = issue.metadata?.name || null; const email = issue.metadata?.contact_email || null; return name || email ? { name, email } : null; From 63e568a3a00b869ce470b393716cbe83a0e5c9e6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:03:29 +0000 Subject: [PATCH 6/6] fix(tools): Treat an empty search_issues assignee name as null Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- packages/mcp-core/src/tools/support/search-issues/results.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-core/src/tools/support/search-issues/results.ts b/packages/mcp-core/src/tools/support/search-issues/results.ts index 7ddd9045d..35e9876c6 100644 --- a/packages/mcp-core/src/tools/support/search-issues/results.ts +++ b/packages/mcp-core/src/tools/support/search-issues/results.ts @@ -92,7 +92,7 @@ function formatAssignee(assignee: Issue["assignedTo"]): string | null { if (typeof assignee === "string") { return assignee; } - return assignee?.name ?? null; + return assignee?.name || null; } function formatAssigneeEmail(assignee: Issue["assignedTo"]): string | null {