Skip to content

Commit f1e82ed

Browse files
betegoncodex
andauthored
fix(api): preserve single-tenant identity and web hosts (#1341)
For single-tenant hosts under `*.my.sentry.io`, `whoami` still routed user tokens to `sentry.io`, and generated web links pointed to the public organization subdomain. Keep identity requests and web links on the configured tenant host, with the organization slug in the URL path. Share the public SaaS routing check with `find_organizations`, preserving cross-region discovery on `sentry.io`. The broader Sentry-host check still governs HTTPS enforcement and capabilities. Regression coverage checks identity request hosts, tenant issue/search/dashboard/alert/explorer links, URL parsing, and existing public SaaS, self-hosted, and host-validation behavior. Typecheck, lint, build, generated definitions, documentation checks, and all 2,201 tests passed (20 skipped). Built stdio and Claude Code checks passed against a mocked Sentry upstream. The real single-tenant `/api/0/auth/` endpoint returns the expected 400 without credentials and 401 (`Invalid token`) with a deliberately invalid Bearer token, without redirecting. Backend identity tests also pass in CONTROL and MONOLITH modes (2 tests each), including 200 with a valid user token. An authenticated 200 using the customer token remains unverified; existing restrictions on organization-scoped tokens are unchanged. Co-authored-by: GPT-6 (Codex) <noreply@openai.com>
1 parent a2544d8 commit f1e82ed

8 files changed

Lines changed: 198 additions & 47 deletions

File tree

‎docs/contributing/api-patterns.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@ SaaS hosts use `sentry.io` to list organizations across regions. Single-tenant
7575
hosts under `*.my.sentry.io` and self-hosted instances keep their configured
7676
host for this request.
7777

78+
User identity (`/api/0/auth/`, used by `whoami`) follows the same control-host
79+
routing. Organization-scoped requests continue to use the configured host or
80+
a validated `regionUrl`.
81+
82+
Web links use `<organization>.sentry.io` for public SaaS. Single-tenant and
83+
self-hosted links keep the configured host and `/organizations/<organization>`
84+
path prefix. Use `isPublicSentryHost` for these routing decisions;
85+
`isSentryHost` also recognizes single-tenant deployments and remains the broader
86+
check for HTTPS enforcement and capabilities.
87+
7888
Sentry uses region-specific URLs:
7989

8090
```typescript

‎packages/mcp-core/src/api-client/client.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,56 @@ import {
66
import { HttpResponse, http } from "msw";
77
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
88
import { ConfigurationError } from "../errors";
9+
import { parseSentryUrl } from "../internal/url-helpers";
910
import { SentryApiService } from "./client";
1011
import { ApiNotFoundError, ApiServerError } from "./errors";
1112

13+
describe("single-tenant web URLs", () => {
14+
const api = new SentryApiService({ host: "tenant.my.sentry.io" });
15+
const baseUrl = "https://tenant.my.sentry.io/organizations/product-org";
16+
17+
it("keeps the tenant host and the organization from the path", () => {
18+
const issueUrl = api.getIssueUrl("product-org", "WEB-123");
19+
expect(issueUrl).toBe(`${baseUrl}/issues/WEB-123`);
20+
expect(parseSentryUrl(issueUrl)).toEqual({
21+
type: "issue",
22+
organizationSlug: "product-org",
23+
issueId: "WEB-123",
24+
});
25+
expect(api.getDashboardUrl("product-org", "42")).toBe(
26+
`${baseUrl}/dashboard/42/`,
27+
);
28+
});
29+
30+
it("keeps alert links on the tenant", () => {
31+
expect(api.getIssueAlertRuleUrl("product-org", "42")).toBe(
32+
`${baseUrl}/monitors/alerts/42/`,
33+
);
34+
expect(api.getMetricAlertRuleUrl("product-org", "42")).toBe(
35+
`${baseUrl}/issues/alerts/rules/details/42/`,
36+
);
37+
});
38+
39+
it.each([
40+
["errors", "discover/homepage", "query"],
41+
["spans", "traces", "query"],
42+
["logs", "logs", "logsQuery"],
43+
] as const)(
44+
"keeps %s explorer links and filters on the tenant",
45+
(dataset, page, queryParam) => {
46+
const url = new URL(
47+
api.getEventsExplorerUrl("product-org", "level:error", "123", dataset),
48+
);
49+
expect(`${url.origin}${url.pathname}`).toBe(
50+
`${baseUrl}/explore/${page}/`,
51+
);
52+
expect(url.searchParams.get(queryParam)).toBe("level:error");
53+
expect(url.searchParams.get("project")).toBe("123");
54+
expect(url.searchParams.get("statsPeriod")).toBe("24h");
55+
},
56+
);
57+
});
58+
1259
describe("getIssueUrl", () => {
1360
it("should work with sentry.io", () => {
1461
const apiService = new SentryApiService({ host: "sentry.io" });
@@ -66,6 +113,12 @@ describe("getIssueUrl", () => {
66113
// Should use sentry.io, not eu.sentry.io for web UI
67114
expect(result).toEqual("https://myorg.sentry.io/issues/PROJECT-456");
68115
});
116+
it("keeps public organization hosts with multiple labels on public web URLs", () => {
117+
const apiService = new SentryApiService({ host: "example.us.sentry.io" });
118+
expect(apiService.getIssueUrl("product-org", "WEB-123")).toBe(
119+
"https://product-org.sentry.io/issues/WEB-123",
120+
);
121+
});
69122
});
70123

71124
describe("getPreprodSnapshotUrl", () => {

‎packages/mcp-core/src/api-client/client.ts‎

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
getTraceMetricsExploreUrl,
2929
getTraceUrl as getTraceUrlUtil,
3030
getUptimeMonitorUrl as getUptimeMonitorUrlUtil,
31-
isSentryHost,
31+
isPublicSentryHost,
3232
type TraceMetricIdentifier,
3333
} from "../utils/url-utils";
3434
import { USER_AGENT } from "../version";
@@ -592,15 +592,10 @@ export class SentryApiService {
592592
}
593593

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

606601
/**
@@ -1187,7 +1182,7 @@ export class SentryApiService {
11871182
ruleId: string | number,
11881183
): string {
11891184
const encodedRuleId = encodeURIComponent(String(ruleId));
1190-
if (this.isSaas()) {
1185+
if (this.isPublicSaas()) {
11911186
return `${this.protocol}://${organizationSlug}.sentry.io/monitors/alerts/${encodedRuleId}/`;
11921187
}
11931188
return `${this.protocol}://${this.host}/organizations/${organizationSlug}/monitors/alerts/${encodedRuleId}/`;
@@ -1198,7 +1193,7 @@ export class SentryApiService {
11981193
ruleId: string | number,
11991194
): string {
12001195
const encodedRuleId = encodeURIComponent(String(ruleId));
1201-
if (this.isSaas()) {
1196+
if (this.isPublicSaas()) {
12021197
return `${this.protocol}://${organizationSlug}.sentry.io/issues/alerts/rules/details/${encodedRuleId}/`;
12031198
}
12041199
return `${this.protocol}://${this.host}/organizations/${organizationSlug}/issues/alerts/rules/details/${encodedRuleId}/`;
@@ -1289,10 +1284,10 @@ export class SentryApiService {
12891284
urlParams.set("yAxis", "count()");
12901285
}
12911286

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

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

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

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

‎packages/mcp-core/src/internal/tool-helpers/validate-region-url.test.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,12 @@ describe("validateRegionUrl", () => {
99
expect(result).toBe("sentry.io");
1010
});
1111

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

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

64+
it("rejects a different single-tenant host", () => {
65+
expect(() =>
66+
validateRegionUrl("https://other.my.sentry.io", "example.my.sentry.io"),
67+
).toThrow("The domain 'other.my.sentry.io' is not allowed");
68+
});
69+
6570
it("rejects subdomains of self-hosted that aren't base host", () => {
6671
expect(() =>
6772
validateRegionUrl("https://eu.mycompany.com", "mycompany.com"),

‎packages/mcp-core/src/tools/catalog/search-issues.test.ts‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mswServer } from "@sentry/mcp-server-mocks";
1+
import { issueFixture, mswServer } from "@sentry/mcp-server-mocks";
22
import { APICallError, generateText, RetryError } from "ai";
33
import { http, HttpResponse } from "msw";
44
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -72,6 +72,47 @@ describe("search_issues", () => {
7272
mockGenerateText.mockResolvedValue(mockAIResponse());
7373
});
7474

75+
it.each([null, "https://tenant.my.sentry.io"])(
76+
"returns tenant issue and search links with regionUrl %s",
77+
async (regionUrl) => {
78+
mockGenerateText.mockResolvedValue(
79+
mockAIResponse("is:unresolved", "date"),
80+
);
81+
const requests: string[] = [];
82+
mswServer.use(
83+
http.get("*/api/0/organizations/product-org/issues/", ({ request }) => {
84+
requests.push(request.url);
85+
return HttpResponse.json([{ ...issueFixture, shortId: "WEB-123" }]);
86+
}),
87+
);
88+
89+
const result = await searchIssues.handler(
90+
{
91+
organizationSlug: "product-org",
92+
query: "is:unresolved",
93+
sort: "date",
94+
projectSlugOrId: "123",
95+
regionUrl,
96+
limit: 1,
97+
period: "24h",
98+
includeExplanation: false,
99+
},
100+
{ ...mockContext, sentryHost: "tenant.my.sentry.io" },
101+
);
102+
103+
expect(requests).toEqual([
104+
"https://tenant.my.sentry.io/api/0/organizations/product-org/issues/?limit=1&sort=date&statsPeriod=24h&query=is%3Aunresolved&project=123&collapse=unhandled",
105+
]);
106+
expect(result).toContain(
107+
"https://tenant.my.sentry.io/organizations/product-org/issues/?project=123&query=is%3Aunresolved",
108+
);
109+
expect(result).toContain(
110+
"[WEB-123](https://tenant.my.sentry.io/organizations/product-org/issues/WEB-123)",
111+
);
112+
expect(result).not.toContain("https://product-org.sentry.io");
113+
},
114+
);
115+
75116
it("should search issues with natural language query", async () => {
76117
mockGenerateText.mockResolvedValue(mockAIResponse("is:unresolved", "date"));
77118

@@ -674,9 +715,8 @@ describe("search_issues", () => {
674715
});
675716

676717
it("should handle all sort options", async () => {
677-
const sortOptions: Array<
678-
"date" | "freq" | "new" | "user" | "recommended"
679-
> = ["date", "freq", "new", "user", "recommended"];
718+
const sortOptions: Array<"date" | "freq" | "new" | "user" | "recommended"> =
719+
["date", "freq", "new", "user", "recommended"];
680720

681721
for (const sortOption of sortOptions) {
682722
mockGenerateText.mockResolvedValue(mockAIResponse("", sortOption));

‎packages/mcp-core/src/tools/catalog/whoami.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,50 @@ import {
1212
} from "../../test-utils/structured-content.js";
1313

1414
describe("whoami", () => {
15+
it.each([
16+
["sentry.io", "sentry.io"],
17+
["us.sentry.io", "sentry.io"],
18+
["de.sentry.io", "sentry.io"],
19+
["example.us.sentry.io", "sentry.io"],
20+
["example.my.sentry.io", "example.my.sentry.io"],
21+
["sentry.example.com", "sentry.example.com"],
22+
])(
23+
"queries %s identity through %s despite regional constraints",
24+
async (host, expectedHost) => {
25+
const requests: { url: string; authorization: string | null }[] = [];
26+
const user = { id: 123456, name: "Test User", email: "test@example.com" };
27+
const constraints = { regionUrl: "https://de.sentry.io" };
28+
mswServer.use(
29+
http.get("*", ({ request }) => {
30+
requests.push({
31+
url: request.url,
32+
authorization: request.headers.get("authorization"),
33+
});
34+
return HttpResponse.json(user);
35+
}),
36+
);
37+
38+
const result = await whoami.handler(
39+
{},
40+
createTestContextWithConstraints(constraints, {
41+
sentryHost: host,
42+
accessToken: "test-token",
43+
}),
44+
);
45+
46+
expect(requests).toEqual([
47+
{
48+
url: `https://${expectedHost}/api/0/auth/`,
49+
authorization: "Bearer test-token",
50+
},
51+
]);
52+
expect(getStructuredContent(result)).toEqual({
53+
user: { ...user, id: String(user.id) },
54+
sessionConstraints: constraints,
55+
});
56+
},
57+
);
58+
1559
it("serializes without constraints", async () => {
1660
mswServer.use(
1761
http.get("https://sentry.io/api/0/auth/", () =>

‎packages/mcp-core/src/utils/url-utils.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@ import {
66
} from "./events-datasets";
77

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

15+
/** Hosts that use the public SaaS control host and organization web subdomains. */
16+
export function isPublicSentryHost(host: string): boolean {
17+
return isSentryHost(host) && !host.endsWith(".my.sentry.io");
18+
}
19+
1720
export interface TraceMetricIdentifier {
1821
name: string;
1922
type: string;
@@ -69,7 +72,7 @@ function getSentryWebBaseUrl(
6972
path: string,
7073
protocol: SentryProtocol = "https",
7174
): string {
72-
const isSaas = isSentryHost(host);
75+
const isSaas = isPublicSentryHost(host);
7376
const webHost = isSaas ? "sentry.io" : host;
7477
return isSaas
7578
? `${protocol}://${organizationSlug}.${webHost}${path}`

‎packages/mcp-server/src/cli/resolve.test.ts‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,16 +123,19 @@ describe("cli/finalize", () => {
123123
).toThrow(/cannot be used with --url or SENTRY_URL/);
124124
});
125125

126-
it("throws when --insecure-http targets sentry.io", () => {
127-
expect(() =>
128-
finalize({
129-
accessToken: "tok",
130-
host: "sentry.io",
131-
insecureHttp: true,
132-
unknownArgs: [],
133-
}),
134-
).toThrow(/only supported for self-hosted Sentry hosts/);
135-
});
126+
it.each(["sentry.io", "example.my.sentry.io"])(
127+
"throws when --insecure-http targets %s",
128+
(host) => {
129+
expect(() =>
130+
finalize({
131+
accessToken: "tok",
132+
host,
133+
insecureHttp: true,
134+
unknownArgs: [],
135+
}),
136+
).toThrow(/only supported for self-hosted Sentry hosts/);
137+
},
138+
);
136139

137140
// Skills tests
138141
it("throws on invalid skills", () => {

0 commit comments

Comments
 (0)