From 67dea540517bacd1f98d3918a90294179c0431ce Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:14:46 +0000 Subject: [PATCH] fix(mcp): Map common get_sentry_resource param aliases and URL shapes Accept issueUrl/resourceUrl as url and issueId/id as resourceId, infer the issue type for issue-shaped IDs, parse project event URLs, and point list or search URLs (issues, alerts, logs, traces, API) at the right tool instead of a generic unknown-URL error. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/mcp-core/src/internal/url-helpers.ts | 20 ++++- packages/mcp-core/src/toolDefinitions.json | 12 +++ .../get-sentry-resource-resolve.test.ts | 61 +++++++++++++++ .../tools/catalog/get-sentry-resource.test.ts | 28 ++++++- .../src/tools/catalog/get-sentry-resource.ts | 75 ++++++++++++++++--- 5 files changed, 184 insertions(+), 12 deletions(-) diff --git a/packages/mcp-core/src/internal/url-helpers.ts b/packages/mcp-core/src/internal/url-helpers.ts index 164f20c1e..c95f4249e 100644 --- a/packages/mcp-core/src/internal/url-helpers.ts +++ b/packages/mcp-core/src/internal/url-helpers.ts @@ -74,7 +74,7 @@ export interface ParsedSentryUrl { * * Supported URL patterns: * - Issue: `/issues/{issueId}` or `/organizations/{org}/issues/{issueId}` - * - Event: `/issues/{issueId}/events/{eventId}` + * - Event: `/issues/{issueId}/events/{eventId}` or `/projects/{project}/events/{eventId}` * - Trace: `/explore/traces/trace/{traceId}` or `/performance/trace/{traceId}` * - Profile: `/explore/profiling/profile/{project}/{profileId}/flamegraph/` * - Continuous profile: `/explore/profiling/profile/{project}/flamegraph/` with query params @@ -403,6 +403,24 @@ function identifyResource( } } + // Project event URL: /projects/{project}/events/{eventId}/ (redirects to the + // event's issue in the Sentry UI). API URLs share this shape but not the org. + const projectsIndex = pathParts.indexOf("projects"); + if ( + projectsIndex !== -1 && + pathParts[0] !== "api" && + pathParts[projectsIndex + 2] === "events" + ) { + const eventId = pathParts[projectsIndex + 3]; + if (eventId) { + return { + type: "event", + organizationSlug, + eventId, + }; + } + } + // Dashboard URL: /dashboard/{dashboardId}/ // Dashboard IDs are always numeric integers. const dashboardIndex = pathParts.indexOf("dashboard"); diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index da1ef1fa0..5d00fb589 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -5616,6 +5616,18 @@ "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." + }, + "issueUrl": { + "type": "string" + }, + "resourceUrl": { + "type": "string" + }, + "issueId": { + "type": "string" + }, + "id": { + "type": "string" } } }, diff --git a/packages/mcp-core/src/tools/catalog/get-sentry-resource-resolve.test.ts b/packages/mcp-core/src/tools/catalog/get-sentry-resource-resolve.test.ts index e97536409..fb51d0238 100644 --- a/packages/mcp-core/src/tools/catalog/get-sentry-resource-resolve.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-sentry-resource-resolve.test.ts @@ -340,6 +340,18 @@ describe("resolveResourceParams", () => { // ─── URL mode: recognized types (replay, monitor, release) ──────────────── describe("URL mode — recognized types", () => { + it.each([ + "https://my-org.sentry.io/projects/my-project/events/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/", + "https://sentry.io/organizations/my-org/projects/my-project/events/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/", + ])("parses project event URL %s", (url) => { + expect(resolveResourceParams({ url })).toEqual({ + type: "event", + organizationSlug: "my-org", + eventId: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", + issueId: undefined, + }); + }); + it("parses replay URL", () => { expect( resolveResourceParams({ @@ -418,6 +430,31 @@ describe("resolveResourceParams", () => { // ─── URL mode: error cases ──────────────────────────────────────────────── describe("URL mode — error cases", () => { + it.each([ + [ + "https://my-org.sentry.io/issues/?query=is%3Aunresolved", + "Use `search_issues` to list issues", + ], + [ + "https://my-org.sentry.io/alerts/rules/details/123/", + "Use `find_alert_rules` to list alert rules", + ], + [ + "https://my-org.sentry.io/explore/logs/?project=1", + "Use `search_logs` to query logs", + ], + [ + "https://my-org.sentry.io/explore/traces/", + "Use `search_traces` to query traces", + ], + [ + "https://sentry.io/api/0/projects/my-org/my-project/events/abc123/", + "This is a Sentry API URL", + ], + ])("explains unsupported URL %s", (url, message) => { + expect(() => resolveResourceParams({ url })).toThrow(message); + }); + it("throws for unknown URL path (settings)", () => { expect(() => resolveResourceParams({ @@ -555,6 +592,30 @@ describe("resolveResourceParams", () => { }); // ─── By type and ID: validation errors ────────────────────────────────────── + describe("By ID without resourceType", () => { + it.each(["PROJECT-123", "MY-WEB-APP-2KD", "1234567890"])( + "infers issue for %s", + (resourceId) => { + expect( + resolveResourceParams({ organizationSlug: "my-org", resourceId }), + ).toEqual({ + type: "issue", + organizationSlug: "my-org", + issueId: resourceId, + }); + }, + ); + + it.each(["a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "my-cron-job"])( + "still requires resourceType for %s", + (resourceId) => { + expect(() => + resolveResourceParams({ organizationSlug: "my-org", resourceId }), + ).toThrow("Either `url` or `resourceType` must be provided"); + }, + ); + }); + describe("By type and ID — validation errors", () => { it("throws when neither url nor resourceType provided", () => { expect(() => resolveResourceParams({})).toThrow( diff --git a/packages/mcp-core/src/tools/catalog/get-sentry-resource.test.ts b/packages/mcp-core/src/tools/catalog/get-sentry-resource.test.ts index 20b6f4495..02f4a12e1 100644 --- a/packages/mcp-core/src/tools/catalog/get-sentry-resource.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-sentry-resource.test.ts @@ -51,6 +51,10 @@ function callHandler(params: { | "dashboard"; resourceId?: string; organizationSlug?: string; + issueUrl?: string; + resourceUrl?: string; + issueId?: string; + id?: string; }) { return getSentryResource.handler(params, baseContext); } @@ -147,6 +151,24 @@ describe("get_sentry_resource", () => { // ─── URL mode: issue URLs ────────────────────────────────────────────────── describe("URL mode — issue URLs", () => { + it.each([ + { + issueUrl: "https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41", + }, + { + resourceUrl: + "https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41", + }, + { organizationSlug: "sentry-mcp-evals", issueId: "cloudflare-mcp-41" }, + { organizationSlug: "sentry-mcp-evals", id: "CLOUDFLARE-MCP-41" }, + { organizationSlug: "sentry-mcp-evals", resourceId: "CLOUDFLARE-MCP-41" }, + ])("resolves issue from alias params %o", async (params) => { + const result = await callHandler(params); + expect(result).toContain( + "# Issue CLOUDFLARE-MCP-41 in **sentry-mcp-evals**", + ); + }); + it("resolves issue from subdomain URL (my-org.sentry.io)", async () => { const result = await callHandler({ url: "https://sentry-mcp-evals.sentry.io/issues/CLOUDFLARE-MCP-41", @@ -926,7 +948,7 @@ describe("get_sentry_resource", () => { describe("Validation errors", () => { it("throws when neither url nor resourceType provided", async () => { await expect( - callHandler({ organizationSlug: "my-org", resourceId: "X-1" }), + callHandler({ organizationSlug: "my-org", resourceId: "my-cron-job" }), ).rejects.toThrow("Either `url` or `resourceType` must be provided"); }); @@ -1060,6 +1082,10 @@ describe("get_sentry_resource", () => { "resourceType", "resourceId", "organizationSlug", + "issueUrl", + "resourceUrl", + "issueId", + "id", ]); }); diff --git a/packages/mcp-core/src/tools/catalog/get-sentry-resource.ts b/packages/mcp-core/src/tools/catalog/get-sentry-resource.ts index 91a95e89a..5a6c10ddc 100644 --- a/packages/mcp-core/src/tools/catalog/get-sentry-resource.ts +++ b/packages/mcp-core/src/tools/catalog/get-sentry-resource.ts @@ -81,6 +81,46 @@ export interface ResolvedResourceParams { dashboardIdOrTitle?: string; } +// Issue short IDs (PROJECT-123) and numeric issue IDs. Only used to infer a +// missing resourceType, so other resource IDs keep requiring an explicit type. +const ISSUE_ID_PATTERN = /^(?:\d+|[A-Z0-9_.]+(?:-[A-Z0-9_.]+)*-[A-Z0-9]+)$/; + +// Sentry pages that are lists or searches rather than one resource. +const UNSUPPORTED_URL_GUIDANCE: Array<[RegExp, string]> = [ + [ + /(^|\/)api\/0\//, + "This is a Sentry API URL. Pass the Sentry web URL for the resource instead, or use `resourceType` with `resourceId`.", + ], + [ + /(^|\/)issues\/?$/, + "This is an issue list URL, not a single issue. Use `search_issues` to list issues, or pass a URL for one issue such as https://my-org.sentry.io/issues/PROJECT-123/.", + ], + [ + /(^|\/)alerts\//, + "Alert URLs are not supported here. Use `find_alert_rules` to list alert rules, or `get_alert_rule` for one rule.", + ], + [ + /(^|\/)explore\/logs\/?$/, + "This is a logs search URL. Use `search_logs` to query logs.", + ], + [ + /(^|\/)(explore\/traces|performance|traces)\/?$/, + "This is a trace search URL. Use `search_traces` to query traces, or pass a URL for one trace.", + ], +]; + +function getUnsupportedUrlGuidance(url: string): string | undefined { + let pathname: string; + try { + pathname = new URL(url).pathname.replace(/^\/+|\/+$/g, ""); + } catch { + return undefined; + } + return UNSUPPORTED_URL_GUIDANCE.find(([pattern]) => + pattern.test(pathname), + )?.[1]; +} + export function resolveResourceParams(params: { url?: string | null; resourceType?: string | null; @@ -90,10 +130,13 @@ export function resolveResourceParams(params: { }): ResolvedResourceParams { if (params.url) { const parsed = parseSentryUrl(params.url); - return resolveFromParsedUrl(parsed, params); + return resolveFromParsedUrl(parsed, { ...params, url: params.url }); } if (!params.resourceType) { + if (params.resourceId && ISSUE_ID_PATTERN.test(params.resourceId)) { + return resolveResourceParams({ ...params, resourceType: "issue" }); + } throw new UserInputError( "Either `url` or `resourceType` must be provided. " + "Pass a `url` to auto-detect the resource type, or specify `resourceType` with `resourceId`.", @@ -191,6 +234,7 @@ export function resolveResourceParams(params: { function resolveFromParsedUrl( parsed: ParsedSentryUrl, params: { + url: string; resourceType?: string | null; organizationSlug?: string | null; projectSlug?: string | null; @@ -210,6 +254,10 @@ function resolveFromParsedUrl( `Detected a performance summary URL for transaction "${parsed.transaction}". Use \`search_traces\` to find traces and performance data for this transaction.`, ); } + const guidance = getUnsupportedUrlGuidance(params.url); + if (guidance) { + throw new UserInputError(guidance); + } throw new UserInputError( "Could not determine resource type from URL. " + "Supported URL patterns: issues, events, traces, agent conversations, profiles, replays, monitors, dashboards, and releases.", @@ -244,10 +292,8 @@ function resolveFromParsedUrl( }; case "event": - if (!parsed.issueId || !parsed.eventId) { - throw new UserInputError( - "Could not extract issue ID and event ID from URL.", - ); + if (!parsed.eventId) { + throw new UserInputError("Could not extract event ID from URL."); } return { type: "event", @@ -553,6 +599,13 @@ export default defineTool({ ), organizationSlug: ParamOrganizationSlug.optional(), + + // Undocumented aliases for keys models often send instead of + // url/resourceId. Kept without descriptions to keep the schema small. + issueUrl: z.string().trim().optional(), + resourceUrl: z.string().trim().optional(), + issueId: z.string().trim().optional(), + id: z.string().trim().optional(), }, annotations: { @@ -562,10 +615,12 @@ export default defineTool({ }, async handler(params, context: ServerContext) { + const url = params.url ?? params.issueUrl ?? params.resourceUrl; const resolved = resolveResourceParams({ - url: params.url, - resourceType: params.resourceType, - resourceId: params.resourceId, + url, + resourceType: + params.resourceType ?? (!url && params.issueId ? "issue" : undefined), + resourceId: params.resourceId ?? params.issueId ?? params.id, organizationSlug: params.organizationSlug ?? context.constraints.organizationSlug, projectSlug: context.constraints.projectSlug, @@ -654,7 +709,7 @@ export default defineTool({ case "replay": return await getReplayDetails.handler( { - replayUrl: params.url, + replayUrl: url, organizationSlug: resolved.organizationSlug, replayId: resolved.replayId, regionUrl: context.constraints.regionUrl ?? undefined, @@ -684,7 +739,7 @@ export default defineTool({ case "profile": return await getProfileDetails.handler( { - profileUrl: params.url, + profileUrl: url, organizationSlug: resolved.organizationSlug, projectSlugOrId: resolved.projectSlugOrId, profileId: resolved.profileId,