From 4f31c6f3a6d1270a150304483b6ae9c2e853c238 Mon Sep 17 00:00:00 2001 From: Joe Becher Date: Fri, 9 Jan 2026 09:58:18 -0500 Subject: [PATCH 1/5] feat(tools): add get_issue_tag_values tool for tag distribution Add new MCP tool to expose Sentry's Tag Distributions view, allowing AI agents to get aggregate counts of unique tag values for an issue. Changes: - Add getIssueTagValues method to API client - Add IssueTagValuesSchema for response validation - Create get-issue-tag-values tool with full documentation - Add MSW mock handler and fixture for testing - Add comprehensive unit tests - Update use-sentry handler tests for new tool count Co-Authored-By: Cursor --- packages/mcp-core/src/api-client/client.ts | 47 +++++ packages/mcp-core/src/api-client/schema.ts | 27 +++ packages/mcp-core/src/api-client/types.ts | 4 + packages/mcp-core/src/skillDefinitions.json | 7 +- packages/mcp-core/src/toolDefinitions.json | 43 +++++ .../src/tools/get-issue-tag-values.test.ts | 105 +++++++++++ .../src/tools/get-issue-tag-values.ts | 175 ++++++++++++++++++ packages/mcp-core/src/tools/index.ts | 2 + .../src/tools/use-sentry/handler.test.ts | 16 +- .../src/fixtures/issue-tag-values.json | 47 +++++ packages/mcp-server-mocks/src/index.ts | 17 ++ 11 files changed, 481 insertions(+), 9 deletions(-) create mode 100644 packages/mcp-core/src/tools/get-issue-tag-values.test.ts create mode 100644 packages/mcp-core/src/tools/get-issue-tag-values.ts create mode 100644 packages/mcp-server-mocks/src/fixtures/issue-tag-values.json diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 1622d3104..adbe50466 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -15,6 +15,7 @@ import { ReleaseListSchema, IssueListSchema, IssueSchema, + IssueTagValuesSchema, EventSchema, EventAttachmentListSchema, ErrorsSearchResponseSchema, @@ -42,6 +43,7 @@ import type { EventAttachmentList, Issue, IssueList, + IssueTagValues, OrganizationList, Project, ProjectList, @@ -1546,6 +1548,51 @@ export class SentryApiService { return IssueSchema.parse(body); } + /** + * Retrieves tag value distribution for a specific issue. + * + * Returns aggregate counts of unique tag values, useful for understanding + * how an issue is distributed across different tag values (e.g., URLs, + * browsers, environments). + * + * @param params Query parameters + * @param params.organizationSlug Organization identifier + * @param params.issueId Issue identifier (short ID or numeric ID) + * @param params.tagKey Tag key to get values for (e.g., "url", "browser", "environment") + * @param opts Request options + * @returns Tag value distribution with counts and percentages + * + * @example + * ```typescript + * const tagValues = await apiService.getIssueTagValues({ + * organizationSlug: "my-org", + * issueId: "PROJECT-123", + * tagKey: "url" + * }); + * console.log(`Total unique values: ${tagValues.totalValues}`); + * tagValues.topValues.forEach(v => console.log(`${v.value}: ${v.count}`)); + * ``` + */ + async getIssueTagValues( + { + organizationSlug, + issueId, + tagKey, + }: { + organizationSlug: string; + issueId: string; + tagKey: string; + }, + opts?: RequestOptions, + ): Promise { + const body = await this.requestJSON( + `/organizations/${organizationSlug}/issues/${issueId}/tags/${tagKey}/`, + undefined, + opts, + ); + return IssueTagValuesSchema.parse(body); + } + async getEventForIssue( { organizationSlug, diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index de23c85d2..c59f58723 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -666,6 +666,33 @@ export const EventAttachmentSchema = z.object({ export const EventAttachmentListSchema = z.array(EventAttachmentSchema); +/** + * Schema for individual tag values within an issue's tag distribution. + * + * Represents a single value's occurrence count and percentage within a tag. + */ +export const IssueTagValueSchema = z.object({ + key: z.string().optional(), + name: z.string().optional(), + value: z.string(), + count: z.number(), + lastSeen: z.string().datetime().optional(), + firstSeen: z.string().datetime().optional(), +}); + +/** + * Schema for Sentry issue tag values response. + * + * Contains aggregate counts of unique tag values for an issue, + * useful for understanding the distribution of tags like URL, browser, etc. + */ +export const IssueTagValuesSchema = z.object({ + key: z.string(), + name: z.string(), + totalValues: z.number(), + topValues: z.array(IssueTagValueSchema), +}); + /** * Schema for Sentry trace metadata response. * diff --git a/packages/mcp-core/src/api-client/types.ts b/packages/mcp-core/src/api-client/types.ts index ecb5bae2d..7adced4a1 100644 --- a/packages/mcp-core/src/api-client/types.ts +++ b/packages/mcp-core/src/api-client/types.ts @@ -55,6 +55,7 @@ import type { EventAttachmentListSchema, IssueListSchema, IssueSchema, + IssueTagValuesSchema, OrganizationListSchema, OrganizationSchema, ProjectListSchema, @@ -116,3 +117,6 @@ export type TraceMeta = z.infer; export type TraceSpan = z.infer; export type TraceIssue = z.infer; export type Trace = z.infer; + +// Issue tag values +export type IssueTagValues = z.infer; diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index d222d1ffc..e1b720e8b 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -5,7 +5,7 @@ "description": "Search for errors, analyze traces, and explore event details", "defaultEnabled": true, "order": 1, - "toolCount": 11, + "toolCount": 12, "tools": [ { "name": "find_organizations", @@ -37,6 +37,11 @@ "description": "Get detailed information about a specific Sentry issue by ID.\n\nUSE THIS TOOL WHEN USERS:\n- Provide a specific issue ID (e.g., 'CLOUDFLARE-MCP-41', 'PROJECT-123')\n- Ask to 'explain [ISSUE-ID]', 'tell me about [ISSUE-ID]'\n- Want details/stacktrace/analysis for a known issue\n- Provide a Sentry issue URL\n\nDO NOT USE for:\n- General searching or listing issues (use search_issues)\n- Root cause analysis (use analyze_issue_with_seer)\n\nTRIGGER PATTERNS:\n- 'Explain ISSUE-123' → use get_issue_details\n- 'Tell me about PROJECT-456' → use get_issue_details\n- 'What happened in [issue URL]' → use get_issue_details\n\n\n### With Sentry URL (recommended - simplest approach)\n```\nget_issue_details(issueUrl='https://sentry.sentry.io/issues/6916805731/?project=4509062593708032&query=is%3Aunresolved')\n```\n\n### With issue ID and organization\n```\nget_issue_details(organizationSlug='my-organization', issueId='CLOUDFLARE-MCP-41')\n```\n\n### With event ID and organization\n```\nget_issue_details(organizationSlug='my-organization', eventId='c49541c747cb4d8aa3efb70ca5aba243')\n```\n\n\n\n- **IMPORTANT**: If user provides a Sentry URL, pass the ENTIRE URL to issueUrl parameter unchanged\n- When using issueUrl, all other parameters are automatically extracted - don't provide them separately\n- If using issueId (not URL), then organizationSlug is required\n", "requiredScopes": ["event:read"] }, + { + "name": "get_issue_tag_values", + "description": "Get tag value distribution for a specific Sentry issue.\n\nUse this tool when you need to:\n- Understand how an issue is distributed across different tag values\n- Get aggregate counts of unique tag values (e.g., 'how many unique URLs are affected')\n- Analyze which browsers, environments, or URLs are most impacted by an issue\n- View the tag distributions page data programmatically\n\nCommon tag keys:\n- `url`: Request URLs affected by the issue\n- `browser`: Browser types and versions\n- `browser.name`: Browser names only\n- `os`: Operating systems\n- `environment`: Deployment environments (production, staging, etc.)\n- `release`: Software releases\n- `device`: Device types\n- `user`: Affected users\n\n\n### Get URL distribution for an issue\n```\nget_issue_tag_values(organizationSlug='my-organization', issueId='PROJECT-123', tagKey='url')\n```\n\n### Get browser distribution using issue URL\n```\nget_issue_tag_values(issueUrl='https://sentry.io/issues/PROJECT-123/', tagKey='browser')\n```\n\n### Get environment distribution\n```\nget_issue_tag_values(organizationSlug='my-organization', issueId='PROJECT-123', tagKey='environment')\n```\n\n\n\n- If user provides a Sentry URL, pass the ENTIRE URL to issueUrl parameter unchanged\n- Common tag keys: url, browser, browser.name, os, environment, release, device, user\n- Tag keys are case-sensitive\n", + "requiredScopes": ["event:read"] + }, { "name": "get_trace_details", "description": "Get detailed information about a specific Sentry trace by ID.\n\nUSE THIS TOOL WHEN USERS:\n- Provide a specific trace ID (e.g., 'a4d1aae7216b47ff8117cf4e09ce9d0a')\n- Ask to 'show me trace [TRACE-ID]', 'explain trace [TRACE-ID]'\n- Want high-level overview and link to view trace details in Sentry\n- Need trace statistics and span breakdown\n\nDO NOT USE for:\n- General searching for traces (use search_events with trace queries)\n- Individual span details (this shows trace overview)\n\nTRIGGER PATTERNS:\n- 'Show me trace abc123' → use get_trace_details\n- 'Explain trace a4d1aae7216b47ff8117cf4e09ce9d0a' → use get_trace_details\n- 'What is trace [trace-id]' → use get_trace_details\n\n\n### Get trace overview\n```\nget_trace_details(organizationSlug='my-organization', traceId='a4d1aae7216b47ff8117cf4e09ce9d0a')\n```\n\n\n\n- Trace IDs are 32-character hexadecimal strings\n", diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index b98b18525..ebc4db2c1 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -475,6 +475,49 @@ }, "requiredScopes": ["event:read"] }, + { + "name": "get_issue_tag_values", + "description": "Get tag value distribution for a specific Sentry issue.\n\nUse this tool when you need to:\n- Understand how an issue is distributed across different tag values\n- Get aggregate counts of unique tag values (e.g., 'how many unique URLs are affected')\n- Analyze which browsers, environments, or URLs are most impacted by an issue\n- View the tag distributions page data programmatically\n\nCommon tag keys:\n- `url`: Request URLs affected by the issue\n- `browser`: Browser types and versions\n- `browser.name`: Browser names only\n- `os`: Operating systems\n- `environment`: Deployment environments (production, staging, etc.)\n- `release`: Software releases\n- `device`: Device types\n- `user`: Affected users\n\n\n### Get URL distribution for an issue\n```\nget_issue_tag_values(organizationSlug='my-organization', issueId='PROJECT-123', tagKey='url')\n```\n\n### Get browser distribution using issue URL\n```\nget_issue_tag_values(issueUrl='https://sentry.io/issues/PROJECT-123/', tagKey='browser')\n```\n\n### Get environment distribution\n```\nget_issue_tag_values(organizationSlug='my-organization', issueId='PROJECT-123', tagKey='environment')\n```\n\n\n\n- If user provides a Sentry URL, pass the ENTIRE URL to issueUrl parameter unchanged\n- Common tag keys: url, browser, browser.name, os, environment, release, device, user\n- Tag keys are case-sensitive\n", + "inputSchema": { + "type": "object", + "properties": { + "organizationSlug": { + "type": "string", + "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." + }, + "regionUrl": { + "anyOf": [ + { + "type": "string", + "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." + }, + { + "type": "null" + } + ], + "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool.", + "default": null + }, + "issueId": { + "type": "string", + "description": "The Issue ID. e.g. `PROJECT-1Z43`" + }, + "issueUrl": { + "type": "string", + "format": "uri", + "description": "The URL of the issue. e.g. https://my-organization.sentry.io/issues/PROJECT-1Z43" + }, + "tagKey": { + "type": "string", + "description": "The tag key to get values for (e.g., 'url', 'browser', 'environment', 'release')." + } + }, + "required": ["tagKey"], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "requiredScopes": ["event:read"] + }, { "name": "get_trace_details", "description": "Get detailed information about a specific Sentry trace by ID.\n\nUSE THIS TOOL WHEN USERS:\n- Provide a specific trace ID (e.g., 'a4d1aae7216b47ff8117cf4e09ce9d0a')\n- Ask to 'show me trace [TRACE-ID]', 'explain trace [TRACE-ID]'\n- Want high-level overview and link to view trace details in Sentry\n- Need trace statistics and span breakdown\n\nDO NOT USE for:\n- General searching for traces (use search_events with trace queries)\n- Individual span details (this shows trace overview)\n\nTRIGGER PATTERNS:\n- 'Show me trace abc123' → use get_trace_details\n- 'Explain trace a4d1aae7216b47ff8117cf4e09ce9d0a' → use get_trace_details\n- 'What is trace [trace-id]' → use get_trace_details\n\n\n### Get trace overview\n```\nget_trace_details(organizationSlug='my-organization', traceId='a4d1aae7216b47ff8117cf4e09ce9d0a')\n```\n\n\n\n- Trace IDs are 32-character hexadecimal strings\n", diff --git a/packages/mcp-core/src/tools/get-issue-tag-values.test.ts b/packages/mcp-core/src/tools/get-issue-tag-values.test.ts new file mode 100644 index 000000000..5250f1e2f --- /dev/null +++ b/packages/mcp-core/src/tools/get-issue-tag-values.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import getIssueTagValues from "./get-issue-tag-values.js"; +import { getServerContext } from "../test-setup.js"; +import { UserInputError } from "../errors.js"; + +describe("get_issue_tag_values", () => { + it("returns tag value distribution for an issue", async () => { + const result = await getIssueTagValues.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + tagKey: "url", + regionUrl: null, + issueUrl: undefined, + }, + getServerContext(), + ); + expect(result).toMatchInlineSnapshot(` + "# Tag Distribution: Url + + **Issue**: CLOUDFLARE-MCP-41 + **Tag Key**: \`url\` + **Total Unique Values**: 156 + + ## Top Values + + | Value | Count | First Seen | Last Seen | + |-------|-------|------------|----------| + | \`/upload/github/org/repo/commit/abc123\` | 45 | 2024-01-10 | 2024-01-15 | + | \`/api/v1/users/profile\` | 32 | 2024-01-11 | 2024-01-15 | + | \`/dashboard/overview\` | 28 | 2024-01-12 | 2024-01-15 | + | \`/settings/notifications\` | 21 | 2024-01-13 | 2024-01-14 | + | \`/checkout/payment\` | 15 | 2024-01-14 | 2024-01-14 | + + *Showing top 5 of 156 unique values* + + ## Using this information + + - Use \`get_issue_details(issueId='CLOUDFLARE-MCP-41')\` to see the full issue details + - Try other tag keys like: url, browser, environment, release, os, device, user + " + `); + }); + + it("works with issue URL parameter", async () => { + const result = await getIssueTagValues.handler( + { + organizationSlug: undefined, + issueId: undefined, + tagKey: "browser", + regionUrl: null, + issueUrl: + "https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41/", + }, + getServerContext(), + ); + expect(result).toContain("# Tag Distribution: Browser"); + expect(result).toContain("**Tag Key**: `browser`"); + }); + + it("throws error when neither issueId nor issueUrl provided", async () => { + await expect( + getIssueTagValues.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: undefined, + tagKey: "url", + regionUrl: null, + issueUrl: undefined, + }, + getServerContext(), + ), + ).rejects.toThrow(UserInputError); + }); + + it("throws error when organizationSlug missing with issueId", async () => { + await expect( + getIssueTagValues.handler( + { + organizationSlug: undefined, + issueId: "CLOUDFLARE-MCP-41", + tagKey: "url", + regionUrl: null, + issueUrl: undefined, + }, + getServerContext(), + ), + ).rejects.toThrow(UserInputError); + }); + + it("throws error when tagKey is missing", async () => { + await expect( + getIssueTagValues.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + tagKey: "", + regionUrl: null, + issueUrl: undefined, + }, + getServerContext(), + ), + ).rejects.toThrow(UserInputError); + }); +}); diff --git a/packages/mcp-core/src/tools/get-issue-tag-values.ts b/packages/mcp-core/src/tools/get-issue-tag-values.ts new file mode 100644 index 000000000..f8073a888 --- /dev/null +++ b/packages/mcp-core/src/tools/get-issue-tag-values.ts @@ -0,0 +1,175 @@ +import { z } from "zod"; +import { setTag } from "@sentry/core"; +import { defineTool } from "../internal/tool-helpers/define"; +import { apiServiceFromContext } from "../internal/tool-helpers/api"; +import { parseIssueParams } from "../internal/tool-helpers/issue"; +import { enhanceNotFoundError } from "../internal/tool-helpers/enhance-error"; +import { ApiNotFoundError } from "../api-client"; +import { UserInputError } from "../errors"; +import type { ServerContext } from "../types"; +import { + ParamOrganizationSlug, + ParamRegionUrl, + ParamIssueShortId, + ParamIssueUrl, +} from "../schema"; + +export default defineTool({ + name: "get_issue_tag_values", + skills: ["inspect"], // Available in inspect skill for understanding issue distribution + requiredScopes: ["event:read"], + description: [ + "Get tag value distribution for a specific Sentry issue.", + "", + "Use this tool when you need to:", + "- Understand how an issue is distributed across different tag values", + "- Get aggregate counts of unique tag values (e.g., 'how many unique URLs are affected')", + "- Analyze which browsers, environments, or URLs are most impacted by an issue", + "- View the tag distributions page data programmatically", + "", + "Common tag keys:", + "- `url`: Request URLs affected by the issue", + "- `browser`: Browser types and versions", + "- `browser.name`: Browser names only", + "- `os`: Operating systems", + "- `environment`: Deployment environments (production, staging, etc.)", + "- `release`: Software releases", + "- `device`: Device types", + "- `user`: Affected users", + "", + "", + "### Get URL distribution for an issue", + "```", + "get_issue_tag_values(organizationSlug='my-organization', issueId='PROJECT-123', tagKey='url')", + "```", + "", + "### Get browser distribution using issue URL", + "```", + "get_issue_tag_values(issueUrl='https://sentry.io/issues/PROJECT-123/', tagKey='browser')", + "```", + "", + "### Get environment distribution", + "```", + "get_issue_tag_values(organizationSlug='my-organization', issueId='PROJECT-123', tagKey='environment')", + "```", + "", + "", + "", + "- If user provides a Sentry URL, pass the ENTIRE URL to issueUrl parameter unchanged", + "- Common tag keys: url, browser, browser.name, os, environment, release, device, user", + "- Tag keys are case-sensitive", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug.optional(), + regionUrl: ParamRegionUrl.nullable().default(null), + issueId: ParamIssueShortId.optional(), + issueUrl: ParamIssueUrl.optional(), + tagKey: z + .string() + .trim() + .describe( + "The tag key to get values for (e.g., 'url', 'browser', 'environment', 'release').", + ), + }, + annotations: { + readOnlyHint: true, + openWorldHint: true, + }, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl ?? undefined, + }); + + // Validate that we have the minimum required parameters + if (!params.issueUrl && !params.issueId) { + throw new UserInputError( + "Either `issueId` or `issueUrl` must be provided", + ); + } + + if (!params.issueUrl && !params.organizationSlug) { + throw new UserInputError( + "`organizationSlug` is required when providing `issueId`", + ); + } + + if (!params.tagKey) { + throw new UserInputError( + "`tagKey` is required. Common values: url, browser, environment, release, os, device, user", + ); + } + + const { organizationSlug: orgSlug, issueId: parsedIssueId } = + parseIssueParams({ + organizationSlug: params.organizationSlug, + issueId: params.issueId, + issueUrl: params.issueUrl, + }); + + setTag("organization.slug", orgSlug); + + // Fetch the tag values for the issue + let tagValues: Awaited>; + try { + tagValues = await apiService.getIssueTagValues({ + organizationSlug: orgSlug, + issueId: parsedIssueId!, + tagKey: params.tagKey, + }); + } catch (error) { + if (error instanceof ApiNotFoundError) { + throw enhanceNotFoundError(error, { + organizationSlug: orgSlug, + issueId: parsedIssueId, + tagKey: params.tagKey, + }); + } + throw error; + } + + // Format the output + let output = `# Tag Distribution: ${tagValues.name}\n\n`; + output += `**Issue**: ${parsedIssueId}\n`; + output += `**Tag Key**: \`${tagValues.key}\`\n`; + output += `**Total Unique Values**: ${tagValues.totalValues}\n\n`; + + if (tagValues.topValues.length === 0) { + output += "No values found for this tag.\n"; + return output; + } + + output += "## Top Values\n\n"; + output += "| Value | Count | First Seen | Last Seen |\n"; + output += "|-------|-------|------------|----------|\n"; + + for (const value of tagValues.topValues) { + const firstSeen = value.firstSeen + ? new Date(value.firstSeen).toISOString().split("T")[0] + : "-"; + const lastSeen = value.lastSeen + ? new Date(value.lastSeen).toISOString().split("T")[0] + : "-"; + // Truncate long values for readability + const displayValue = + value.value.length > 60 + ? `${value.value.substring(0, 57)}...` + : value.value; + output += `| \`${displayValue}\` | ${value.count} | ${firstSeen} | ${lastSeen} |\n`; + } + + // Calculate percentage for context + if (tagValues.topValues.length > 0 && tagValues.totalValues > 0) { + const topTotal = tagValues.topValues.reduce((sum, v) => sum + v.count, 0); + const shownCount = tagValues.topValues.length; + output += `\n*Showing top ${shownCount} of ${tagValues.totalValues} unique values*\n`; + } + + // Add usage hints + output += "\n## Using this information\n\n"; + output += `- Use \`get_issue_details(issueId='${parsedIssueId}')\` to see the full issue details\n`; + output += `- Try other tag keys like: url, browser, environment, release, os, device, user\n`; + + return output; + }, +}); diff --git a/packages/mcp-core/src/tools/index.ts b/packages/mcp-core/src/tools/index.ts index 6d1e97805..4e7e45f45 100644 --- a/packages/mcp-core/src/tools/index.ts +++ b/packages/mcp-core/src/tools/index.ts @@ -4,6 +4,7 @@ import findTeams from "./find-teams"; import findProjects from "./find-projects"; import findReleases from "./find-releases"; import getIssueDetails from "./get-issue-details"; +import getIssueTagValues from "./get-issue-tag-values"; import getTraceDetails from "./get-trace-details"; import getEventAttachment from "./get-event-attachment"; import updateIssue from "./update-issue"; @@ -28,6 +29,7 @@ export default { find_projects: findProjects, find_releases: findReleases, get_issue_details: getIssueDetails, + get_issue_tag_values: getIssueTagValues, get_trace_details: getTraceDetails, get_event_attachment: getEventAttachment, update_issue: updateIssue, diff --git a/packages/mcp-core/src/tools/use-sentry/handler.test.ts b/packages/mcp-core/src/tools/use-sentry/handler.test.ts index e6162c6fc..fe23f3789 100644 --- a/packages/mcp-core/src/tools/use-sentry/handler.test.ts +++ b/packages/mcp-core/src/tools/use-sentry/handler.test.ts @@ -58,9 +58,9 @@ describe("use_sentry handler", () => { }), }); - // Verify all 20 tools were provided (21 total - use_sentry itself) + // Verify all 21 tools were provided (22 total - use_sentry itself) const toolsArg = mockUseSentryAgent.mock.calls[0][0].tools; - expect(Object.keys(toolsArg)).toHaveLength(20); + expect(Object.keys(toolsArg)).toHaveLength(21); // Verify result is returned expect(result).toBe("Agent executed tools successfully"); @@ -107,8 +107,8 @@ describe("use_sentry handler", () => { // Verify use_sentry is NOT in the list expect(toolNames).not.toContain("use_sentry"); - // Verify we have exactly 20 tools (21 total - 1 use_sentry) - expect(toolNames).toHaveLength(20); + // Verify we have exactly 21 tools (22 total - 1 use_sentry) + expect(toolNames).toHaveLength(21); }); it("filters find_organizations when organizationSlug constraint is set", async () => { @@ -134,8 +134,8 @@ describe("use_sentry handler", () => { const toolsArg = mockUseSentryAgent.mock.calls[0][0].tools; expect(toolsArg).toBeDefined(); - // With only org constraint, find_organizations is filtered (20 - 1 = 19) - expect(Object.keys(toolsArg)).toHaveLength(19); + // With only org constraint, find_organizations is filtered (21 - 1 = 20) + expect(Object.keys(toolsArg)).toHaveLength(20); // Verify find_organizations is filtered but find_projects remains expect(toolsArg.find_organizations).toBeUndefined(); @@ -167,8 +167,8 @@ describe("use_sentry handler", () => { expect(toolsArg).toBeDefined(); // When both org and project constraints are present, - // find_organizations and find_projects are filtered out (20 - 2 = 18) - expect(Object.keys(toolsArg)).toHaveLength(18); + // find_organizations and find_projects are filtered out (21 - 2 = 19) + expect(Object.keys(toolsArg)).toHaveLength(19); // Verify both find tools are filtered expect(toolsArg.find_organizations).toBeUndefined(); diff --git a/packages/mcp-server-mocks/src/fixtures/issue-tag-values.json b/packages/mcp-server-mocks/src/fixtures/issue-tag-values.json new file mode 100644 index 000000000..2c2925f6c --- /dev/null +++ b/packages/mcp-server-mocks/src/fixtures/issue-tag-values.json @@ -0,0 +1,47 @@ +{ + "key": "url", + "name": "URL", + "totalValues": 156, + "topValues": [ + { + "key": "url", + "name": "/upload/github/org/repo/commit/abc123", + "value": "/upload/github/org/repo/commit/abc123", + "count": 45, + "lastSeen": "2024-01-15T10:30:00.000Z", + "firstSeen": "2024-01-10T08:00:00.000Z" + }, + { + "key": "url", + "name": "/api/v1/users/profile", + "value": "/api/v1/users/profile", + "count": 32, + "lastSeen": "2024-01-15T09:45:00.000Z", + "firstSeen": "2024-01-11T14:20:00.000Z" + }, + { + "key": "url", + "name": "/dashboard/overview", + "value": "/dashboard/overview", + "count": 28, + "lastSeen": "2024-01-15T08:15:00.000Z", + "firstSeen": "2024-01-12T11:30:00.000Z" + }, + { + "key": "url", + "name": "/settings/notifications", + "value": "/settings/notifications", + "count": 21, + "lastSeen": "2024-01-14T22:00:00.000Z", + "firstSeen": "2024-01-13T16:45:00.000Z" + }, + { + "key": "url", + "name": "/checkout/payment", + "value": "/checkout/payment", + "count": 15, + "lastSeen": "2024-01-14T18:30:00.000Z", + "firstSeen": "2024-01-14T10:00:00.000Z" + } + ] +} diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index 9447e64a3..0fb7e5e31 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -64,6 +64,9 @@ import traceMixedFixture from "./fixtures/trace-mixed.json" with { import traceEventFixture from "./fixtures/trace-event.json" with { type: "json", }; +import issueTagValuesFixture from "./fixtures/issue-tag-values.json" with { + type: "json", +}; /** * Standard organization payload for mock responses. @@ -1040,6 +1043,20 @@ export const restHandlers = buildHandlers([ }, }), }, + // Issue tag values endpoints + { + method: "get", + path: "/api/0/organizations/:org/issues/:issueId/tags/:tagKey/", + fetch: ({ params }) => { + const tagKey = params.tagKey as string; + // Return fixture with the requested tag key + return HttpResponse.json({ + ...issueTagValuesFixture, + key: tagKey, + name: tagKey.charAt(0).toUpperCase() + tagKey.slice(1), + }); + }, + }, { method: "post", path: "/api/0/projects/sentry-mcp-evals/cloudflare-mcp/teams/:teamSlug/", From 28f1ec861d06900ba81d48e9c58b0bc657c9b074 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 11 Jan 2026 12:52:58 -0800 Subject: [PATCH 2/5] fix(tools): add path-safe validation to tagKey parameter Add regex validation to tagKey in get_issue_tag_values tool to prevent path traversal attacks. The tagKey is now restricted to alphanumeric characters, dots, hyphens, and underscores, matching the pattern used for other URL path parameters like organizationSlug. Also removes unused topTotal variable from output formatting. Co-Authored-By: Claude Opus 4.5 --- packages/mcp-core/src/toolDefinitions.json | 1 + .../src/tools/get-issue-tag-values.test.ts | 30 +++++++++++++++++++ .../src/tools/get-issue-tag-values.ts | 6 ++-- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index ebc4db2c1..dcc345e2a 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -509,6 +509,7 @@ }, "tagKey": { "type": "string", + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$", "description": "The tag key to get values for (e.g., 'url', 'browser', 'environment', 'release')." } }, diff --git a/packages/mcp-core/src/tools/get-issue-tag-values.test.ts b/packages/mcp-core/src/tools/get-issue-tag-values.test.ts index 5250f1e2f..d26b6e6b0 100644 --- a/packages/mcp-core/src/tools/get-issue-tag-values.test.ts +++ b/packages/mcp-core/src/tools/get-issue-tag-values.test.ts @@ -102,4 +102,34 @@ describe("get_issue_tag_values", () => { ), ).rejects.toThrow(UserInputError); }); + + it("throws error when tagKey contains path traversal characters", async () => { + await expect( + getIssueTagValues.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + tagKey: "../../../admin", + regionUrl: null, + issueUrl: undefined, + }, + getServerContext(), + ), + ).rejects.toThrow(); + }); + + it("throws error when tagKey contains slashes", async () => { + await expect( + getIssueTagValues.handler( + { + organizationSlug: "sentry-mcp-evals", + issueId: "CLOUDFLARE-MCP-41", + tagKey: "url/path", + regionUrl: null, + issueUrl: undefined, + }, + getServerContext(), + ), + ).rejects.toThrow(); + }); }); diff --git a/packages/mcp-core/src/tools/get-issue-tag-values.ts b/packages/mcp-core/src/tools/get-issue-tag-values.ts index f8073a888..4ed9eb020 100644 --- a/packages/mcp-core/src/tools/get-issue-tag-values.ts +++ b/packages/mcp-core/src/tools/get-issue-tag-values.ts @@ -68,6 +68,10 @@ export default defineTool({ tagKey: z .string() .trim() + .regex( + /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, + "Tag key must contain only alphanumeric characters, dots, hyphens, and underscores, and must start with an alphanumeric character", + ) .describe( "The tag key to get values for (e.g., 'url', 'browser', 'environment', 'release').", ), @@ -158,9 +162,7 @@ export default defineTool({ output += `| \`${displayValue}\` | ${value.count} | ${firstSeen} | ${lastSeen} |\n`; } - // Calculate percentage for context if (tagValues.topValues.length > 0 && tagValues.totalValues > 0) { - const topTotal = tagValues.topValues.reduce((sum, v) => sum + v.count, 0); const shownCount = tagValues.topValues.length; output += `\n*Showing top ${shownCount} of ${tagValues.totalValues} unique values*\n`; } From 9021a9718dac135a767ae6ac21eaf3b47d019c72 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 11 Jan 2026 13:21:27 -0800 Subject: [PATCH 3/5] fix(tools): address cursor bot review feedback - Make firstSeen/lastSeen nullable in IssueTagValueSchema to handle API responses that return null instead of omitting the field - Escape pipe, backtick, and newline characters in markdown table output to prevent table formatting issues Co-Authored-By: Claude Opus 4.5 --- packages/mcp-core/src/api-client/schema.ts | 4 ++-- packages/mcp-core/src/tools/get-issue-tag-values.ts | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index c59f58723..a35a6a0cc 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -676,8 +676,8 @@ export const IssueTagValueSchema = z.object({ name: z.string().optional(), value: z.string(), count: z.number(), - lastSeen: z.string().datetime().optional(), - firstSeen: z.string().datetime().optional(), + lastSeen: z.string().datetime().nullable().optional(), + firstSeen: z.string().datetime().nullable().optional(), }); /** diff --git a/packages/mcp-core/src/tools/get-issue-tag-values.ts b/packages/mcp-core/src/tools/get-issue-tag-values.ts index 4ed9eb020..a383a42a0 100644 --- a/packages/mcp-core/src/tools/get-issue-tag-values.ts +++ b/packages/mcp-core/src/tools/get-issue-tag-values.ts @@ -155,10 +155,15 @@ export default defineTool({ ? new Date(value.lastSeen).toISOString().split("T")[0] : "-"; // Truncate long values for readability - const displayValue = + let displayValue = value.value.length > 60 ? `${value.value.substring(0, 57)}...` : value.value; + // Escape markdown table special characters + displayValue = displayValue + .replace(/\|/g, "\\|") + .replace(/`/g, "\\`") + .replace(/\n/g, " "); output += `| \`${displayValue}\` | ${value.count} | ${firstSeen} | ${lastSeen} |\n`; } From 049c7e65f04074137140a994d63cc17c7fdd26fb Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 11 Jan 2026 14:37:20 -0800 Subject: [PATCH 4/5] fix(tools): escape backslash characters in markdown output Address CodeQL security alert by escaping backslash characters before escaping other special markdown characters. This prevents incomplete escaping when input contains backslashes. Co-Authored-By: Claude Opus 4.5 --- packages/mcp-core/src/tools/get-issue-tag-values.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/mcp-core/src/tools/get-issue-tag-values.ts b/packages/mcp-core/src/tools/get-issue-tag-values.ts index a383a42a0..a474b0eb1 100644 --- a/packages/mcp-core/src/tools/get-issue-tag-values.ts +++ b/packages/mcp-core/src/tools/get-issue-tag-values.ts @@ -159,8 +159,9 @@ export default defineTool({ value.value.length > 60 ? `${value.value.substring(0, 57)}...` : value.value; - // Escape markdown table special characters + // Escape markdown table special characters (backslashes first) displayValue = displayValue + .replace(/\\/g, "\\\\") .replace(/\|/g, "\\|") .replace(/`/g, "\\`") .replace(/\n/g, " "); From 1da0b33cb3e985d13351b0729249a7e3503cb29a Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sun, 11 Jan 2026 14:47:12 -0800 Subject: [PATCH 5/5] fix(tools): include organizationSlug in usage hint Address Cursor Bot feedback - the get_issue_details suggestion now includes the required organizationSlug parameter. Co-Authored-By: Claude Opus 4.5 --- packages/mcp-core/src/tools/get-issue-tag-values.test.ts | 2 +- packages/mcp-core/src/tools/get-issue-tag-values.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mcp-core/src/tools/get-issue-tag-values.test.ts b/packages/mcp-core/src/tools/get-issue-tag-values.test.ts index d26b6e6b0..197170568 100644 --- a/packages/mcp-core/src/tools/get-issue-tag-values.test.ts +++ b/packages/mcp-core/src/tools/get-issue-tag-values.test.ts @@ -36,7 +36,7 @@ describe("get_issue_tag_values", () => { ## Using this information - - Use \`get_issue_details(issueId='CLOUDFLARE-MCP-41')\` to see the full issue details + - Use \`get_issue_details(organizationSlug='sentry-mcp-evals', issueId='CLOUDFLARE-MCP-41')\` to see the full issue details - Try other tag keys like: url, browser, environment, release, os, device, user " `); diff --git a/packages/mcp-core/src/tools/get-issue-tag-values.ts b/packages/mcp-core/src/tools/get-issue-tag-values.ts index a474b0eb1..1a866b7fc 100644 --- a/packages/mcp-core/src/tools/get-issue-tag-values.ts +++ b/packages/mcp-core/src/tools/get-issue-tag-values.ts @@ -175,7 +175,7 @@ export default defineTool({ // Add usage hints output += "\n## Using this information\n\n"; - output += `- Use \`get_issue_details(issueId='${parsedIssueId}')\` to see the full issue details\n`; + output += `- Use \`get_issue_details(organizationSlug='${orgSlug}', issueId='${parsedIssueId}')\` to see the full issue details\n`; output += `- Try other tag keys like: url, browser, environment, release, os, device, user\n`; return output;