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/internal/tool-helpers/ai-conversation-actions.ts b/packages/mcp-core/src/internal/tool-helpers/ai-conversation-actions.ts index 2130306c1..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,33 +1,16 @@ import { inlineCode } from "@sentry/toolkit-core/markdown"; -import { isTopLevelToolName } from "../../tools/surfaces"; import { - formatToolCall, - isToolAvailableInSession, -} from "./tool-call-formatting"; - -const MAX_SUGGESTED_ACTIONS = 3; + MAX_SUGGESTED_ACTIONS, + type SuggestedToolAction, + buildSuggestedToolAction, +} from "./suggested-actions"; +import { formatToolCall } from "./tool-call-formatting"; export interface AIConversationReference { conversationId: string; 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 +25,22 @@ export function getAIConversationSuggestedActions({ availableToolNames?: ReadonlySet; directToolNames?: ReadonlySet; }): SuggestedToolAction[] { - if ( - aiConversations.length === 0 || - !isToolAvailableInSession( - "get_agent_conversation_details", - availableToolNames, + return aiConversations + .slice(0, MAX_SUGGESTED_ACTIONS) + .map((conversation) => + buildSuggestedToolAction({ + toolName: "get_agent_conversation_details", + arguments: { + organizationSlug, + conversationId: conversation.conversationId, + }, + reason: "Fetch the full transcript for this agent conversation.", + experimentalMode, + availableToolNames, + directToolNames, + }), ) - ) { - 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.", - }; - }); + .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 new file mode 100644 index 000000000..ee84917c5 --- /dev/null +++ b/packages/mcp-core/src/internal/tool-helpers/suggested-actions.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; +import { isTopLevelToolName } from "../../tools/surfaces"; +import { isToolAvailableInSession } from "./tool-call-formatting"; + +/** See docs/contributing/tool-responses.md#suggested-actions. */ +export const MAX_SUGGESTED_ACTIONS = 3; + +export 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 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, + 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 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 ( + !executeToolIsDirect || + !isToolAvailableInSession("execute_sentry_tool", availableToolNames) + ) { + return null; + } + + return { + type: "tool_call", + toolName: "execute_sentry_tool", + arguments: { name: toolName, arguments: targetArguments }, + reason, + }; +} diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index e2598f4ca..63bffcaed 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -7804,6 +7804,181 @@ }, "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"] + }, + "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"] + }, + "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", + "assigneeEmail", + "reporter", + "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..3b9fd1041 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,53 @@ 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, + "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", + "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 +261,7 @@ describe("search_issues", () => { ), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: "is:unresolved", @@ -232,7 +275,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 +307,7 @@ describe("search_issues", () => { ), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: "is:unresolved", @@ -277,7 +321,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 +362,7 @@ describe("search_issues", () => { ), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: "is:unresolved is:unassigned", @@ -333,11 +378,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 +417,7 @@ describe("search_issues", () => { ), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: "is:unresolved", @@ -405,9 +451,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 +492,7 @@ describe("search_issues", () => { ), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: explicitQuery, @@ -460,8 +507,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 +556,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 +578,7 @@ describe("search_issues", () => { ), ); - await searchIssues.handler( + await runSearch( { organizationSlug: "test-org", query: "all issues", @@ -558,7 +608,7 @@ describe("search_issues", () => { ), ); - await searchIssues.handler( + await runSearch( { organizationSlug: "test-org", query: "most frequent errors", @@ -588,7 +638,7 @@ describe("search_issues", () => { ), ); - await searchIssues.handler( + await runSearch( { organizationSlug: "test-org", query: "all issues", @@ -618,7 +668,7 @@ describe("search_issues", () => { ), ); - await searchIssues.handler( + await runSearch( { organizationSlug: "test-org", query: "test", @@ -642,7 +692,7 @@ describe("search_issues", () => { }), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: "unresolved issues", @@ -656,8 +706,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 +724,7 @@ describe("search_issues", () => { }), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: "nonexistent issues", @@ -683,7 +738,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 +759,7 @@ describe("search_issues", () => { ), ); - await searchIssues.handler( + await runSearch( { organizationSlug: "test-org", query: "unresolved errors", @@ -737,7 +793,7 @@ describe("search_issues", () => { ), ); - await searchIssues.handler( + await runSearch( { organizationSlug: "test-org", query: "test", @@ -753,7 +809,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 +837,7 @@ describe("search_issues", () => { }), ); - const result = await searchIssues.handler( + const result = await runSearch( { organizationSlug: "test-org", query: "all issues", @@ -795,10 +851,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 +879,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 +895,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..549bb3802 --- /dev/null +++ b/packages/mcp-core/src/tools/support/search-issues/results.test.ts @@ -0,0 +1,252 @@ +import { + createFeedbackIssue, + createPerformanceIssue, +} from "@sentry/mcp-server-mocks"; +import { describe, expect, it } from "vitest"; +import { IssueSchema } from "../../../api-client/schema"; +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", + metadata: { contact_email: "reporter@example.com", name: "Reporter" }, + } as Parameters[0]); + + const structuredContent = build({ + // 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); + expect(serialized).not.toContain("must-not-leak"); + expect(structuredContent).toMatchInlineSnapshot(` + { + "executedSearch": { + "period": "30d", + "projectSlugOrId": null, + "query": "is:unresolved", + "sort": "date", + }, + "explanation": null, + "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", + "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, + assigneeEmail, + }) => ({ + shortId, + issueCategory, + occurrences, + seerActionability, + assignedTo, + assigneeEmail, + }), + ), + ).toMatchInlineSnapshot(` + [ + { + "assignedTo": "Jane Doe", + "assigneeEmail": "jane@example.com", + "issueCategory": "performance", + "occurrences": 100, + "seerActionability": "high", + "shortId": "TEST-HIGH", + }, + { + "assignedTo": null, + "assigneeEmail": null, + "issueCategory": "performance", + "occurrences": 25, + "seerActionability": null, + "shortId": "TEST-NONE", + }, + ] + `); + }); + + 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: [ + 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..35e9876c6 --- /dev/null +++ b/packages/mcp-core/src/tools/support/search-issues/results.ts @@ -0,0 +1,214 @@ +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(), + 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(), + 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; +} + +function formatAssigneeEmail(assignee: Issue["assignedTo"]): string | null { + return typeof assignee === "object" ? assignee?.email || null : 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; +} + +/** 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), + assigneeEmail: formatAssigneeEmail(issue.assignedTo), + reporter: formatReporter(issue), + 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] : []; + }); +}