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
10 changes: 10 additions & 0 deletions docs/contributing/api-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ SaaS hosts use `sentry.io` to list organizations across regions. Single-tenant
hosts under `*.my.sentry.io` and self-hosted instances keep their configured
host for this request.

User identity (`/api/0/auth/`, used by `whoami`) follows the same control-host
routing. Organization-scoped requests continue to use the configured host or
a validated `regionUrl`.

Web links use `<organization>.sentry.io` for public SaaS. Single-tenant and
self-hosted links keep the configured host and `/organizations/<organization>`
path prefix. Use `isPublicSentryHost` for these routing decisions;
`isSentryHost` also recognizes single-tenant deployments and remains the broader
check for HTTPS enforcement and capabilities.

Sentry uses region-specific URLs:

```typescript
Expand Down
53 changes: 53 additions & 0 deletions packages/mcp-core/src/api-client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,56 @@ import {
import { HttpResponse, http } from "msw";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConfigurationError } from "../errors";
import { parseSentryUrl } from "../internal/url-helpers";
import { SentryApiService } from "./client";
import { ApiNotFoundError, ApiServerError } from "./errors";

describe("single-tenant web URLs", () => {
const api = new SentryApiService({ host: "tenant.my.sentry.io" });
const baseUrl = "https://tenant.my.sentry.io/organizations/product-org";

it("keeps the tenant host and the organization from the path", () => {
const issueUrl = api.getIssueUrl("product-org", "WEB-123");
expect(issueUrl).toBe(`${baseUrl}/issues/WEB-123`);
expect(parseSentryUrl(issueUrl)).toEqual({
type: "issue",
organizationSlug: "product-org",
issueId: "WEB-123",
});
expect(api.getDashboardUrl("product-org", "42")).toBe(
`${baseUrl}/dashboard/42/`,
);
});

it("keeps alert links on the tenant", () => {
expect(api.getIssueAlertRuleUrl("product-org", "42")).toBe(
`${baseUrl}/monitors/alerts/42/`,
);
expect(api.getMetricAlertRuleUrl("product-org", "42")).toBe(
`${baseUrl}/issues/alerts/rules/details/42/`,
);
});

it.each([
["errors", "discover/homepage", "query"],
["spans", "traces", "query"],
["logs", "logs", "logsQuery"],
] as const)(
"keeps %s explorer links and filters on the tenant",
(dataset, page, queryParam) => {
const url = new URL(
api.getEventsExplorerUrl("product-org", "level:error", "123", dataset),
);
expect(`${url.origin}${url.pathname}`).toBe(
`${baseUrl}/explore/${page}/`,
);
expect(url.searchParams.get(queryParam)).toBe("level:error");
expect(url.searchParams.get("project")).toBe("123");
expect(url.searchParams.get("statsPeriod")).toBe("24h");
},
);
});

describe("getIssueUrl", () => {
it("should work with sentry.io", () => {
const apiService = new SentryApiService({ host: "sentry.io" });
Expand Down Expand Up @@ -66,6 +113,12 @@ describe("getIssueUrl", () => {
// Should use sentry.io, not eu.sentry.io for web UI
expect(result).toEqual("https://myorg.sentry.io/issues/PROJECT-456");
});
it("keeps public organization hosts with multiple labels on public web URLs", () => {
const apiService = new SentryApiService({ host: "example.us.sentry.io" });
expect(apiService.getIssueUrl("product-org", "WEB-123")).toBe(
"https://product-org.sentry.io/issues/WEB-123",
);
});
});

describe("getPreprodSnapshotUrl", () => {
Expand Down
37 changes: 15 additions & 22 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
getTraceMetricsExploreUrl,
getTraceUrl as getTraceUrlUtil,
getUptimeMonitorUrl as getUptimeMonitorUrlUtil,
isSentryHost,
isPublicSentryHost,
type TraceMetricIdentifier,
} from "../utils/url-utils";
import { USER_AGENT } from "../version";
Expand Down Expand Up @@ -592,15 +592,10 @@ export class SentryApiService {
}

/**
* Checks if the current host is Sentry SaaS (sentry.io).
*
* Used to determine API endpoint availability and URL formats.
* Self-hosted instances may not have all endpoints available.
*
* @returns True if using Sentry SaaS, false for self-hosted instances
* Whether API control requests and web URLs use public SaaS routing.
*/
private isSaas(): boolean {
return isSentryHost(this.host);
private isPublicSaas(): boolean {
return isPublicSentryHost(this.host);
}

/**
Expand Down Expand Up @@ -1187,7 +1182,7 @@ export class SentryApiService {
ruleId: string | number,
): string {
const encodedRuleId = encodeURIComponent(String(ruleId));
if (this.isSaas()) {
if (this.isPublicSaas()) {
return `${this.protocol}://${organizationSlug}.sentry.io/monitors/alerts/${encodedRuleId}/`;
}
return `${this.protocol}://${this.host}/organizations/${organizationSlug}/monitors/alerts/${encodedRuleId}/`;
Expand All @@ -1198,7 +1193,7 @@ export class SentryApiService {
ruleId: string | number,
): string {
const encodedRuleId = encodeURIComponent(String(ruleId));
if (this.isSaas()) {
if (this.isPublicSaas()) {
return `${this.protocol}://${organizationSlug}.sentry.io/issues/alerts/rules/details/${encodedRuleId}/`;
}
return `${this.protocol}://${this.host}/organizations/${organizationSlug}/issues/alerts/rules/details/${encodedRuleId}/`;
Expand Down Expand Up @@ -1289,10 +1284,10 @@ export class SentryApiService {
urlParams.set("yAxis", "count()");
}

// For SaaS instances, always use sentry.io for web UI URLs regardless of region
// Public SaaS uses sentry.io for web UI URLs regardless of region.
// Regional subdomains (e.g., us.sentry.io) are only for API endpoints
const webHost = this.isSaas() ? "sentry.io" : this.host;
const path = this.isSaas()
const webHost = this.isPublicSaas() ? "sentry.io" : this.host;
const path = this.isPublicSaas()
? `${this.protocol}://${organizationSlug}.${webHost}/explore/discover/homepage/`
: `${this.protocol}://${this.host}/organizations/${organizationSlug}/explore/discover/homepage/`;

Expand Down Expand Up @@ -1373,9 +1368,9 @@ export class SentryApiService {
organizationSlug: string,
page: "logs" | "traces",
): string {
// For SaaS instances, always use sentry.io for web UI URLs regardless of region.
// Public SaaS uses sentry.io for web UI URLs regardless of region.
// Regional subdomains (e.g., us.sentry.io) are only for API endpoints.
if (this.isSaas()) {
if (this.isPublicSaas()) {
return `${this.protocol}://${organizationSlug}.sentry.io/explore/${page}/`;
}
return `${this.protocol}://${this.host}/organizations/${organizationSlug}/explore/${page}/`;
Expand Down Expand Up @@ -1589,15 +1584,13 @@ export class SentryApiService {
* @throws {ApiError} If authentication fails or user not found
*/
async getAuthenticatedUser(opts?: RequestOptions): Promise<User> {
// Auth endpoints only exist on the main API server, never on regional endpoints
// User identity belongs to the deployment's control host.
let authHost: string | undefined;

if (this.isSaas()) {
// For SaaS, always use the main sentry.io host, not regional hosts
// This handles cases like us.sentry.io, eu.sentry.io, etc.
if (this.isPublicSaas()) {
authHost = "sentry.io";
}
// For self-hosted, use the configured host (authHost remains undefined)
// Single-tenant and self-hosted deployments keep their configured host.

const body = await this.requestJSON("/auth/", undefined, {
...opts,
Expand Down Expand Up @@ -1643,7 +1636,7 @@ export class SentryApiService {
let host = undefined;
// Public SaaS lists across regions on sentry.io; single-tenant instances
// must keep organization discovery on their configured host.
if (this.isSaas() && !this.host.endsWith(".my.sentry.io")) {
if (this.isPublicSaas()) {
host = "sentry.io";
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@ describe("validateRegionUrl", () => {
expect(result).toBe("sentry.io");
});

it("allows exact match for self-hosted", () => {
const result = validateRegionUrl(
"https://sentry.company.com",
"sentry.company.com",
);
expect(result).toBe("sentry.company.com");
});
it.each(["sentry.company.com", "example.my.sentry.io"])(
"allows exact match for configured host %s",
(host) => {
expect(validateRegionUrl(`https://${host}`, host)).toBe(host);
},
);

it("allows exact match for any base host", () => {
const result = validateRegionUrl("https://example.com", "example.com");
Expand Down Expand Up @@ -62,6 +61,12 @@ describe("validateRegionUrl", () => {
);
});

it("rejects a different single-tenant host", () => {
expect(() =>
validateRegionUrl("https://other.my.sentry.io", "example.my.sentry.io"),
).toThrow("The domain 'other.my.sentry.io' is not allowed");
});

it("rejects subdomains of self-hosted that aren't base host", () => {
expect(() =>
validateRegionUrl("https://eu.mycompany.com", "mycompany.com"),
Expand Down
48 changes: 44 additions & 4 deletions packages/mcp-core/src/tools/catalog/search-issues.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mswServer } from "@sentry/mcp-server-mocks";
import { issueFixture, mswServer } from "@sentry/mcp-server-mocks";
import { APICallError, generateText, RetryError } from "ai";
import { http, HttpResponse } from "msw";
import { beforeEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -72,6 +72,47 @@ describe("search_issues", () => {
mockGenerateText.mockResolvedValue(mockAIResponse());
});

it.each([null, "https://tenant.my.sentry.io"])(
"returns tenant issue and search links with regionUrl %s",
async (regionUrl) => {
mockGenerateText.mockResolvedValue(
mockAIResponse("is:unresolved", "date"),
);
const requests: string[] = [];
mswServer.use(
http.get("*/api/0/organizations/product-org/issues/", ({ request }) => {
requests.push(request.url);
return HttpResponse.json([{ ...issueFixture, shortId: "WEB-123" }]);
}),
);

const result = await searchIssues.handler(
{
organizationSlug: "product-org",
query: "is:unresolved",
sort: "date",
projectSlugOrId: "123",
regionUrl,
limit: 1,
period: "24h",
includeExplanation: false,
},
{ ...mockContext, sentryHost: "tenant.my.sentry.io" },
);

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(
"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).not.toContain("https://product-org.sentry.io");
},
);

it("should search issues with natural language query", async () => {
mockGenerateText.mockResolvedValue(mockAIResponse("is:unresolved", "date"));

Expand Down Expand Up @@ -674,9 +715,8 @@ describe("search_issues", () => {
});

it("should handle all sort options", async () => {
const sortOptions: Array<
"date" | "freq" | "new" | "user" | "recommended"
> = ["date", "freq", "new", "user", "recommended"];
const sortOptions: Array<"date" | "freq" | "new" | "user" | "recommended"> =
["date", "freq", "new", "user", "recommended"];

for (const sortOption of sortOptions) {
mockGenerateText.mockResolvedValue(mockAIResponse("", sortOption));
Expand Down
44 changes: 44 additions & 0 deletions packages/mcp-core/src/tools/catalog/whoami.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,50 @@ import {
} from "../../test-utils/structured-content.js";

describe("whoami", () => {
it.each([
["sentry.io", "sentry.io"],
["us.sentry.io", "sentry.io"],
["de.sentry.io", "sentry.io"],
["example.us.sentry.io", "sentry.io"],
["example.my.sentry.io", "example.my.sentry.io"],
["sentry.example.com", "sentry.example.com"],
])(
"queries %s identity through %s despite regional constraints",
async (host, expectedHost) => {
const requests: { url: string; authorization: string | null }[] = [];
const user = { id: 123456, name: "Test User", email: "test@example.com" };
const constraints = { regionUrl: "https://de.sentry.io" };
mswServer.use(
http.get("*", ({ request }) => {
requests.push({
url: request.url,
authorization: request.headers.get("authorization"),
});
return HttpResponse.json(user);
}),
);

const result = await whoami.handler(
{},
createTestContextWithConstraints(constraints, {
sentryHost: host,
accessToken: "test-token",
}),
);

expect(requests).toEqual([
{
url: `https://${expectedHost}/api/0/auth/`,
authorization: "Bearer test-token",
},
]);
expect(getStructuredContent(result)).toEqual({
user: { ...user, id: String(user.id) },
sessionConstraints: constraints,
});
},
);

it("serializes without constraints", async () => {
mswServer.use(
http.get("https://sentry.io/api/0/auth/", () =>
Expand Down
11 changes: 7 additions & 4 deletions packages/mcp-core/src/utils/url-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import {
} from "./events-datasets";

/**
* Determines if a Sentry instance is SaaS or self-hosted based on the host.
* @param host The Sentry host (e.g., "sentry.io" or "sentry.company.com")
* @returns true if SaaS instance, false if self-hosted
* Recognizes Sentry-owned hosts, including single-tenant deployments.
*/
export function isSentryHost(host: string): boolean {
return host === "sentry.io" || host.endsWith(".sentry.io");
}

/** Hosts that use the public SaaS control host and organization web subdomains. */
export function isPublicSentryHost(host: string): boolean {
return isSentryHost(host) && !host.endsWith(".my.sentry.io");
}

export interface TraceMetricIdentifier {
name: string;
type: string;
Expand Down Expand Up @@ -69,7 +72,7 @@ function getSentryWebBaseUrl(
path: string,
protocol: SentryProtocol = "https",
): string {
const isSaas = isSentryHost(host);
const isSaas = isPublicSentryHost(host);
const webHost = isSaas ? "sentry.io" : host;
return isSaas
? `${protocol}://${organizationSlug}.${webHost}${path}`
Expand Down
23 changes: 13 additions & 10 deletions packages/mcp-server/src/cli/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,16 +123,19 @@ describe("cli/finalize", () => {
).toThrow(/cannot be used with --url or SENTRY_URL/);
});

it("throws when --insecure-http targets sentry.io", () => {
expect(() =>
finalize({
accessToken: "tok",
host: "sentry.io",
insecureHttp: true,
unknownArgs: [],
}),
).toThrow(/only supported for self-hosted Sentry hosts/);
});
it.each(["sentry.io", "example.my.sentry.io"])(
"throws when --insecure-http targets %s",
(host) => {
expect(() =>
finalize({
accessToken: "tok",
host,
insecureHttp: true,
unknownArgs: [],
}),
).toThrow(/only supported for self-hosted Sentry hosts/);
},
);

// Skills tests
it("throws on invalid skills", () => {
Expand Down
Loading