Skip to content

Commit 7ee08df

Browse files
BYKGPT-6 Sol
andauthored
Share Sentry API URLs and bearer headers (#1442)
## Summary - Add pure shared API URL assembly and bearer-header formatting in toolkit-core. - Use the shared functions in CLI raw/region API requests and the MCP API client while retaining product-specific host scope, OAuth refresh, HTTP transport, retries, and error types. - Cover self-hosted path prefixes, encoded cursors, and malformed token rejection. ## Validation - `pnpm run tsc` - `pnpm run lint` - `pnpm run test` (CLI 10,533; MCP core 1,755; toolkit core 36; MCP server 84; test client 80; Cloudflare 433) - `pnpm run pre-commit:generated`, `pnpm run docs:check`, CLI `check:deps`, Oxfmt --------- Co-authored-by: GPT-6 Sol <agent@openai.com>
1 parent a97a7c6 commit 7ee08df

11 files changed

Lines changed: 161 additions & 156 deletions

File tree

‎packages/cli/src/lib/api/infrastructure.ts‎

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { promisify } from "node:util";
1010
import { zstdCompress as zstdCompressCb } from "node:zlib";
1111
import { parseSentryLinkHeader } from "@sentry/api";
12+
import { buildSentryApiUrl } from "@sentry/toolkit-core/api-request";
1213
// oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import
1314
import * as Sentry from "@sentry/node-core/light";
1415
import { type GenericSchema, safeParse } from "valibot";
@@ -592,12 +593,8 @@ export async function apiRequestToRegion<T>(
592593
} = options;
593594
const config = getSdkConfig(regionUrl, { credential, validatedRedirects });
594595

595-
const normalizedEndpoint = endpoint.startsWith("/")
596-
? endpoint.slice(1)
597-
: endpoint;
598-
const endpointWithParams = appendSearchParams(normalizedEndpoint, params);
599-
// getSdkConfig.baseUrl is the plain region URL; add /api/0/ for raw requests
600-
const url = `${config.baseUrl}/api/0/${endpointWithParams}`;
596+
const endpointWithParams = appendSearchParams(endpoint, params);
597+
const url = buildSentryApiUrl(config.baseUrl, endpointWithParams);
601598

602599
const fetchFn = config.fetch;
603600
const headers: Record<string, string> = {
@@ -749,11 +746,8 @@ export async function apiRequestToRegionNoContent(
749746
const config = getSdkConfig(regionUrl);
750747

751748
const searchParams = buildSearchParams(params);
752-
const normalizedEndpoint = endpoint.startsWith("/")
753-
? endpoint.slice(1)
754-
: endpoint;
755749
const queryString = searchParams ? `?${searchParams.toString()}` : "";
756-
const url = `${config.baseUrl}/api/0/${normalizedEndpoint}${queryString}`;
750+
const url = buildSentryApiUrl(config.baseUrl, `${endpoint}${queryString}`);
757751

758752
const fetchFn = config.fetch;
759753
const headers: Record<string, string> = {
@@ -878,12 +872,8 @@ export async function rawApiRequest(
878872
// enforces isRequestOriginTrusted() before attaching Authorization.
879873
const config = baseUrl ? getSdkConfig(baseUrl) : getDefaultSdkConfig();
880874

881-
const normalizedEndpoint = endpoint.startsWith("/")
882-
? endpoint.slice(1)
883-
: endpoint;
884-
const endpointWithParams = appendSearchParams(normalizedEndpoint, params);
885-
// getSdkConfig.baseUrl is the plain region URL; add /api/0/ for raw requests
886-
const url = `${config.baseUrl}/api/0/${endpointWithParams}`;
875+
const endpointWithParams = appendSearchParams(endpoint, params);
876+
const url = buildSentryApiUrl(config.baseUrl, endpointWithParams);
887877

888878
// Build request headers and body.
889879
// String bodies: no Content-Type unless the caller explicitly provides one.

‎packages/cli/src/lib/auth-header.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import {
44
normalizeAuthToken as parseAuthToken,
5+
sentryBearerHeader,
56
trimAuthToken as trimSharedAuthToken,
67
} from "@sentry/toolkit-core/auth-token";
78
import { MalformedAuthTokenError } from "./errors.js";
@@ -22,5 +23,9 @@ export function normalizeAuthToken(token: string): string {
2223

2324
/** Normalize and validate a credential before constructing its Authorization value. */
2425
export function formatAuthHeader(token: string): string {
25-
return `Bearer ${normalizeAuthToken(token)}`;
26+
const header = sentryBearerHeader(token);
27+
if (header === null) {
28+
throw new MalformedAuthTokenError();
29+
}
30+
return header;
2631
}

‎packages/cli/test/lib/api/infrastructure.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,28 @@ describe("rawApiRequest binary handling", () => {
606606
expect(fetchSpy).not.toHaveBeenCalled();
607607
});
608608

609+
test("keeps a trusted self-hosted path and bearer credential on raw requests", async () => {
610+
setAuthToken(" \tvalid-token\x7f ", undefined, undefined, {
611+
host: "https://sentry.example.com",
612+
});
613+
globalThis.fetch = mockFetch(async (input, init) => {
614+
expect(input).toBe(
615+
"https://sentry.example.com/sentry/api/0/organizations/acme/?cursor=a%3Ab",
616+
);
617+
expect(new Headers(init?.headers).get("Authorization")).toBe(
618+
"Bearer valid-token",
619+
);
620+
return new Response("{}", {
621+
headers: { "content-type": "application/json" },
622+
});
623+
});
624+
625+
await rawApiRequest("/organizations/acme/", {
626+
baseUrl: "https://sentry.example.com/sentry",
627+
params: { cursor: "a:b" },
628+
});
629+
});
630+
609631
test("returns Uint8Array for image/png without UTF-8 corruption", async () => {
610632
// Real PNG signature: 89 50 4e 47 0d 0a 1a 0a — the leading 0x89 is not
611633
// valid UTF-8 and would become EF BF BD if response.text() were used.

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { parseSentryLinkHeader } from "@sentry/api";
2-
import { normalizeAuthToken } from "@sentry/toolkit-core/auth-token";
2+
import { buildSentryApiUrl } from "@sentry/toolkit-core/api-request";
3+
import { sentryBearerHeader } from "@sentry/toolkit-core/auth-token";
34
import { z } from "zod";
45
import { DEFAULT_SEARCH_ISSUES_PERIOD } from "../constants";
56
import { ConfigurationError } from "../errors";
@@ -768,20 +769,21 @@ export class SentryApiService {
768769
options: RequestInit = {},
769770
{ host, allowStatuses }: { host?: string; allowStatuses?: number[] } = {},
770771
): Promise<Response> {
771-
const url = host
772-
? `${this.protocol}://${host}/api/0${path}`
773-
: `${this.apiPrefix}${path}`;
772+
const url = buildSentryApiUrl(
773+
`${this.protocol}://${host ?? this.host}`,
774+
path,
775+
);
774776

775777
const headers: Record<string, string> = {
776778
"Content-Type": "application/json",
777779
"User-Agent": USER_AGENT,
778780
};
779781
if (this.accessToken !== null) {
780-
const token = normalizeAuthToken(this.accessToken);
781-
if (token === null) {
782+
const authorization = sentryBearerHeader(this.accessToken);
783+
if (authorization === null) {
782784
throw new ConfigurationError("Malformed authentication token");
783785
}
784-
headers.Authorization = `Bearer ${token}`;
786+
headers.Authorization = authorization;
785787
}
786788
if (this.clientId) {
787789
headers["X-Sentry-MCP-Client-Id"] = this.clientId;

‎packages/mcp-server-mocks/src/index.ts‎

Lines changed: 42 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -24,142 +24,58 @@ import { HttpResponse, http } from "msw";
2424
*/
2525
import { setupServer } from "msw/node";
2626

27-
import autofixStateFixture from "./fixtures/autofix-state.json" with {
28-
type: "json",
29-
};
30-
import autofixStateExplorerFixture from "./fixtures/autofix-state-explorer.json" with {
31-
type: "json",
32-
};
27+
import autofixStateFixture from "./fixtures/autofix-state.json" with { type: "json" };
28+
import autofixStateExplorerFixture from "./fixtures/autofix-state-explorer.json" with { type: "json" };
3329
import clientKeyFixture from "./fixtures/client-key.json" with { type: "json" };
34-
import dashboardDetailsFixture from "./fixtures/dashboard-details.json" with {
35-
type: "json",
36-
};
37-
import dashboardListFixture from "./fixtures/dashboard-list.json" with {
38-
type: "json",
39-
};
30+
import dashboardDetailsFixture from "./fixtures/dashboard-details.json" with { type: "json" };
31+
import dashboardListFixture from "./fixtures/dashboard-list.json" with { type: "json" };
4032
import eventsFixture from "./fixtures/event.json" with { type: "json" };
41-
import eventAttachmentsFixture from "./fixtures/event-attachments.json" with {
42-
type: "json",
43-
};
44-
import eventsErrorsFixture from "./fixtures/events-errors.json" with {
45-
type: "json",
46-
};
47-
import eventsErrorsEmptyFixture from "./fixtures/events-errors-empty.json" with {
48-
type: "json",
49-
};
50-
import eventsSpansFixture from "./fixtures/events-spans.json" with {
51-
type: "json",
52-
};
53-
import eventsSpansEmptyFixture from "./fixtures/events-spans-empty.json" with {
54-
type: "json",
55-
};
56-
import eventsTraceMetricsFixture from "./fixtures/events-tracemetrics.json" with {
57-
type: "json",
58-
};
59-
import eventsTraceMetricsAggregateFixture from "./fixtures/events-tracemetrics-aggregate.json" with {
60-
type: "json",
61-
};
62-
import eventsTraceMetricsEmptyFixture from "./fixtures/events-tracemetrics-empty.json" with {
63-
type: "json",
64-
};
65-
import flamegraphFixture from "./fixtures/flamegraph.json" with {
66-
type: "json",
67-
};
33+
import eventAttachmentsFixture from "./fixtures/event-attachments.json" with { type: "json" };
34+
import eventsErrorsFixture from "./fixtures/events-errors.json" with { type: "json" };
35+
import eventsErrorsEmptyFixture from "./fixtures/events-errors-empty.json" with { type: "json" };
36+
import eventsSpansFixture from "./fixtures/events-spans.json" with { type: "json" };
37+
import eventsSpansEmptyFixture from "./fixtures/events-spans-empty.json" with { type: "json" };
38+
import eventsTraceMetricsFixture from "./fixtures/events-tracemetrics.json" with { type: "json" };
39+
import eventsTraceMetricsAggregateFixture from "./fixtures/events-tracemetrics-aggregate.json" with { type: "json" };
40+
import eventsTraceMetricsEmptyFixture from "./fixtures/events-tracemetrics-empty.json" with { type: "json" };
41+
import flamegraphFixture from "./fixtures/flamegraph.json" with { type: "json" };
6842
import issueFixture from "./fixtures/issue.json" with { type: "json" };
69-
import issueActivityFixture from "./fixtures/issue-activity.json" with {
70-
type: "json",
71-
};
72-
import issueCommentsFixture from "./fixtures/issue-comments.json" with {
73-
type: "json",
74-
};
75-
import issueNullCulpritFixture from "./fixtures/issue-null-culprit.json" with {
76-
type: "json",
77-
};
78-
import issueTagValuesFixture from "./fixtures/issue-tag-values.json" with {
79-
type: "json",
80-
};
81-
import issueUserReportsFixture from "./fixtures/issue-user-reports.json" with {
82-
type: "json",
83-
};
43+
import issueActivityFixture from "./fixtures/issue-activity.json" with { type: "json" };
44+
import issueCommentsFixture from "./fixtures/issue-comments.json" with { type: "json" };
45+
import issueNullCulpritFixture from "./fixtures/issue-null-culprit.json" with { type: "json" };
46+
import issueTagValuesFixture from "./fixtures/issue-tag-values.json" with { type: "json" };
47+
import issueUserReportsFixture from "./fixtures/issue-user-reports.json" with { type: "json" };
8448
import monitorFixture from "./fixtures/monitor.json" with { type: "json" };
85-
import monitorCheckInsFixture from "./fixtures/monitor-checkins.json" with {
86-
type: "json",
87-
};
88-
import monitorStatsFixture from "./fixtures/monitor-stats.json" with {
89-
type: "json",
90-
};
91-
import organizationFixture from "./fixtures/organization.json" with {
92-
type: "json",
93-
};
94-
import performanceEventFixture from "./fixtures/performance-event.json" with {
95-
type: "json",
96-
};
97-
import profileChunkFixture from "./fixtures/profile-chunk.json" with {
98-
type: "json",
99-
};
49+
import monitorCheckInsFixture from "./fixtures/monitor-checkins.json" with { type: "json" };
50+
import monitorStatsFixture from "./fixtures/monitor-stats.json" with { type: "json" };
51+
import organizationFixture from "./fixtures/organization.json" with { type: "json" };
52+
import performanceEventFixture from "./fixtures/performance-event.json" with { type: "json" };
53+
import profileChunkFixture from "./fixtures/profile-chunk.json" with { type: "json" };
10054
import projectFixture from "./fixtures/project.json" with { type: "json" };
10155
import releaseFixture from "./fixtures/release.json" with { type: "json" };
102-
import releaseCommitsFixture from "./fixtures/release-commits.json" with {
103-
type: "json",
104-
};
105-
import releaseDeploysFixture from "./fixtures/release-deploys.json" with {
106-
type: "json",
107-
};
108-
import replayDetailsFixture from "./fixtures/replay-details.json" with {
109-
type: "json",
110-
};
111-
import replayRecordingSegmentsFixture from "./fixtures/replay-recording-segments.json" with {
112-
type: "json",
113-
};
56+
import releaseCommitsFixture from "./fixtures/release-commits.json" with { type: "json" };
57+
import releaseDeploysFixture from "./fixtures/release-deploys.json" with { type: "json" };
58+
import replayDetailsFixture from "./fixtures/replay-details.json" with { type: "json" };
59+
import replayRecordingSegmentsFixture from "./fixtures/replay-recording-segments.json" with { type: "json" };
11460
import tagsFixture from "./fixtures/tags.json" with { type: "json" };
11561
import teamFixture from "./fixtures/team.json" with { type: "json" };
11662
import traceFixture from "./fixtures/trace.json" with { type: "json" };
117-
import traceEventFixture from "./fixtures/trace-event.json" with {
118-
type: "json",
119-
};
120-
import traceItemsAttributesLogsNumberFixture from "./fixtures/trace-items-attributes-logs-number.json" with {
121-
type: "json",
122-
};
123-
import traceItemsAttributesLogsStringFixture from "./fixtures/trace-items-attributes-logs-string.json" with {
124-
type: "json",
125-
};
126-
import traceItemsAttributesSpansNumberFixture from "./fixtures/trace-items-attributes-spans-number.json" with {
127-
type: "json",
128-
};
129-
import traceItemsAttributesSpansStringFixture from "./fixtures/trace-items-attributes-spans-string.json" with {
130-
type: "json",
131-
};
132-
import traceItemsAttributesSpansNumberWithContextFixture from "./fixtures/trace-items-attributes-spans-number-with-context.json" with {
133-
type: "json",
134-
};
135-
import traceItemsAttributesSpansStringWithContextFixture from "./fixtures/trace-items-attributes-spans-string-with-context.json" with {
136-
type: "json",
137-
};
138-
import traceItemsAttributesTraceMetricsNumberFixture from "./fixtures/trace-items-attributes-tracemetrics-number.json" with {
139-
type: "json",
140-
};
141-
import traceItemsAttributesTraceMetricsStringFixture from "./fixtures/trace-items-attributes-tracemetrics-string.json" with {
142-
type: "json",
143-
};
63+
import traceEventFixture from "./fixtures/trace-event.json" with { type: "json" };
64+
import traceItemsAttributesLogsNumberFixture from "./fixtures/trace-items-attributes-logs-number.json" with { type: "json" };
65+
import traceItemsAttributesLogsStringFixture from "./fixtures/trace-items-attributes-logs-string.json" with { type: "json" };
66+
import traceItemsAttributesSpansNumberFixture from "./fixtures/trace-items-attributes-spans-number.json" with { type: "json" };
67+
import traceItemsAttributesSpansStringFixture from "./fixtures/trace-items-attributes-spans-string.json" with { type: "json" };
68+
import traceItemsAttributesSpansNumberWithContextFixture from "./fixtures/trace-items-attributes-spans-number-with-context.json" with { type: "json" };
69+
import traceItemsAttributesSpansStringWithContextFixture from "./fixtures/trace-items-attributes-spans-string-with-context.json" with { type: "json" };
70+
import traceItemsAttributesTraceMetricsNumberFixture from "./fixtures/trace-items-attributes-tracemetrics-number.json" with { type: "json" };
71+
import traceItemsAttributesTraceMetricsStringFixture from "./fixtures/trace-items-attributes-tracemetrics-string.json" with { type: "json" };
14472
import traceMetaFixture from "./fixtures/trace-meta.json" with { type: "json" };
145-
import traceMetaWithNullsFixture from "./fixtures/trace-meta-with-nulls.json" with {
146-
type: "json",
147-
};
148-
import traceMixedFixture from "./fixtures/trace-mixed.json" with {
149-
type: "json",
150-
};
151-
import transactionProfileV1Fixture from "./fixtures/transaction-profile-v1.json" with {
152-
type: "json",
153-
};
154-
import transactionProfileV1MissingFunctionFixture from "./fixtures/transaction-profile-v1-missing-function.json" with {
155-
type: "json",
156-
};
157-
import uptimeChecksFixture from "./fixtures/uptime-checks.json" with {
158-
type: "json",
159-
};
160-
import uptimeMonitorFixture from "./fixtures/uptime-monitor.json" with {
161-
type: "json",
162-
};
73+
import traceMetaWithNullsFixture from "./fixtures/trace-meta-with-nulls.json" with { type: "json" };
74+
import traceMixedFixture from "./fixtures/trace-mixed.json" with { type: "json" };
75+
import transactionProfileV1Fixture from "./fixtures/transaction-profile-v1.json" with { type: "json" };
76+
import transactionProfileV1MissingFunctionFixture from "./fixtures/transaction-profile-v1-missing-function.json" with { type: "json" };
77+
import uptimeChecksFixture from "./fixtures/uptime-checks.json" with { type: "json" };
78+
import uptimeMonitorFixture from "./fixtures/uptime-monitor.json" with { type: "json" };
16379
import userFixture from "./fixtures/user.json" with { type: "json" };
16480
import { issueFixture2 } from "./payloads";
16581

‎packages/toolkit-core/README.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
Pure protocol and hostname helpers shared by the CLI and MCP. Both product
44
builds bundle this private workspace package into their artifacts.
55

6-
The shared code validates opaque bearer tokens, constructs OAuth device-flow
7-
form bodies, classifies RFC 8628 polling responses, advances retry intervals,
8-
recognizes Sentry hostnames, and encodes API path identifiers. Each product
9-
retains its own credential storage, URL and host trust checks, regional routing,
10-
polling deadline, HTTP transport, response validation, and user-facing errors.
6+
The shared code validates and formats upstream bearer tokens, assembles Sentry
7+
API URLs, constructs OAuth device-flow form bodies, classifies RFC 8628 polling
8+
responses, advances retry intervals, recognizes Sentry hostnames, and encodes
9+
API path identifiers. Each product retains its own credential storage, host
10+
trust checks, regional routing, polling deadline, HTTP transport, response
11+
validation, and user-facing errors.

‎packages/toolkit-core/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
"node": ">=22.13"
88
},
99
"exports": {
10+
"./api-request": {
11+
"types": "./src/api-request.ts",
12+
"default": "./src/api-request.ts"
13+
},
1014
"./api-path-segment": {
1115
"types": "./src/api-path-segment.ts",
1216
"default": "./src/api-path-segment.ts"
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildSentryApiUrl } from "./api-request.js";
3+
4+
describe("buildSentryApiUrl", () => {
5+
it.each([
6+
[
7+
"https://sentry.io",
8+
"/organizations/org/",
9+
"https://sentry.io/api/0/organizations/org/",
10+
],
11+
[
12+
"https://us.sentry.io/",
13+
"organizations/org/?cursor=a%3Ab",
14+
"https://us.sentry.io/api/0/organizations/org/?cursor=a%3Ab",
15+
],
16+
[
17+
"https://self-hosted.example/sentry/",
18+
"/organizations/org/",
19+
"https://self-hosted.example/sentry/api/0/organizations/org/",
20+
],
21+
[
22+
"https://self-hosted.example/sentry////",
23+
"organizations/org/",
24+
"https://self-hosted.example/sentry/api/0/organizations/org/",
25+
],
26+
])("assembles %s with %s", (baseUrl, endpoint, expected) => {
27+
expect(buildSentryApiUrl(baseUrl, endpoint)).toBe(expected);
28+
});
29+
30+
it("preserves encoded identifiers without decoding them", () => {
31+
expect(buildSentryApiUrl("https://sentry.io", "issues/a%2Fb/")).toBe(
32+
"https://sentry.io/api/0/issues/a%2Fb/",
33+
);
34+
});
35+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Assemble an API URL from a product-validated base and a relative endpoint.
3+
* This does not decide which host is trusted or which region an org uses.
4+
*/
5+
export function buildSentryApiUrl(baseUrl: string, endpoint: string): string {
6+
const path = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint;
7+
let end = baseUrl.length;
8+
while (end > 0 && baseUrl.charAt(end - 1) === "/") {
9+
end -= 1;
10+
}
11+
return `${baseUrl.slice(0, end)}/api/0/${path}`;
12+
}

0 commit comments

Comments
 (0)