Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion packages/mcp-core/src/internal/url-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down
12 changes: 12 additions & 0 deletions packages/mcp-core/src/toolDefinitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -5761,6 +5761,18 @@
"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"
},
"fresh": {
"description": "Bypass cached reads.",
"type": "boolean"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResolvedResourceParams>({
type: "event",
organizationSlug: "my-org",
eventId: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
issueId: undefined,
});
});

it("parses replay URL", () => {
expect(
resolveResourceParams({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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<ResolvedResourceParams>({
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(
Expand Down
28 changes: 27 additions & 1 deletion packages/mcp-core/src/tools/catalog/get-sentry-resource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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");
});

Expand Down Expand Up @@ -1060,6 +1082,10 @@ describe("get_sentry_resource", () => {
"resourceType",
"resourceId",
"organizationSlug",
"issueUrl",
"resourceUrl",
"issueId",
"id",
]);
});

Expand Down
75 changes: 65 additions & 10 deletions packages/mcp-core/src/tools/catalog/get-sentry-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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`.",
Expand Down Expand Up @@ -191,6 +234,7 @@ export function resolveResourceParams(params: {
function resolveFromParsedUrl(
parsed: ParsedSentryUrl,
params: {
url: string;
resourceType?: string | null;
organizationSlug?: string | null;
projectSlug?: string | null;
Expand All @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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: {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading