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
22 changes: 6 additions & 16 deletions packages/cli/src/lib/api/infrastructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { promisify } from "node:util";
import { zstdCompress as zstdCompressCb } from "node:zlib";
import { parseSentryLinkHeader } from "@sentry/api";
import { buildSentryApiUrl } from "@sentry/toolkit-core/api-request";
// oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node-core/light";
import { type GenericSchema, safeParse } from "valibot";
Expand Down Expand Up @@ -592,12 +593,8 @@ export async function apiRequestToRegion<T>(
} = options;
const config = getSdkConfig(regionUrl, { credential, validatedRedirects });

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

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

const searchParams = buildSearchParams(params);
const normalizedEndpoint = endpoint.startsWith("/")
? endpoint.slice(1)
: endpoint;
const queryString = searchParams ? `?${searchParams.toString()}` : "";
const url = `${config.baseUrl}/api/0/${normalizedEndpoint}${queryString}`;
const url = buildSentryApiUrl(config.baseUrl, `${endpoint}${queryString}`);

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

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

// Build request headers and body.
// String bodies: no Content-Type unless the caller explicitly provides one.
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/lib/auth-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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

/** Normalize and validate a credential before constructing its Authorization value. */
export function formatAuthHeader(token: string): string {
return `Bearer ${normalizeAuthToken(token)}`;
const header = sentryBearerHeader(token);
if (header === null) {
throw new MalformedAuthTokenError();
}
return header;
}
22 changes: 22 additions & 0 deletions packages/cli/test/lib/api/infrastructure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,28 @@ describe("rawApiRequest binary handling", () => {
expect(fetchSpy).not.toHaveBeenCalled();
});

test("keeps a trusted self-hosted path and bearer credential on raw requests", async () => {
setAuthToken(" \tvalid-token\x7f ", undefined, undefined, {
host: "https://sentry.example.com",
});
globalThis.fetch = mockFetch(async (input, init) => {
expect(input).toBe(
"https://sentry.example.com/sentry/api/0/organizations/acme/?cursor=a%3Ab",
);
expect(new Headers(init?.headers).get("Authorization")).toBe(
"Bearer valid-token",
);
return new Response("{}", {
headers: { "content-type": "application/json" },
});
});

await rawApiRequest("/organizations/acme/", {
baseUrl: "https://sentry.example.com/sentry",
params: { cursor: "a:b" },
});
});

test("returns Uint8Array for image/png without UTF-8 corruption", async () => {
// Real PNG signature: 89 50 4e 47 0d 0a 1a 0a — the leading 0x89 is not
// valid UTF-8 and would become EF BF BD if response.text() were used.
Expand Down
16 changes: 9 additions & 7 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { parseSentryLinkHeader } from "@sentry/api";
import { normalizeAuthToken } from "@sentry/toolkit-core/auth-token";
import { buildSentryApiUrl } from "@sentry/toolkit-core/api-request";
import { sentryBearerHeader } from "@sentry/toolkit-core/auth-token";
import { z } from "zod";
import { DEFAULT_SEARCH_ISSUES_PERIOD } from "../constants";
import { ConfigurationError } from "../errors";
Expand Down Expand Up @@ -768,20 +769,21 @@ export class SentryApiService {
options: RequestInit = {},
{ host, allowStatuses }: { host?: string; allowStatuses?: number[] } = {},
): Promise<Response> {
const url = host
? `${this.protocol}://${host}/api/0${path}`
: `${this.apiPrefix}${path}`;
const url = buildSentryApiUrl(
`${this.protocol}://${host ?? this.host}`,
path,
);

const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
};
if (this.accessToken !== null) {
const token = normalizeAuthToken(this.accessToken);
if (token === null) {
const authorization = sentryBearerHeader(this.accessToken);
if (authorization === null) {
throw new ConfigurationError("Malformed authentication token");
}
headers.Authorization = `Bearer ${token}`;
headers.Authorization = authorization;
}
if (this.clientId) {
headers["X-Sentry-MCP-Client-Id"] = this.clientId;
Expand Down
168 changes: 42 additions & 126 deletions packages/mcp-server-mocks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,142 +24,58 @@ import { HttpResponse, http } from "msw";
*/
import { setupServer } from "msw/node";

import autofixStateFixture from "./fixtures/autofix-state.json" with {
type: "json",
};
import autofixStateExplorerFixture from "./fixtures/autofix-state-explorer.json" with {
type: "json",
};
import autofixStateFixture from "./fixtures/autofix-state.json" with { type: "json" };
import autofixStateExplorerFixture from "./fixtures/autofix-state-explorer.json" with { type: "json" };
import clientKeyFixture from "./fixtures/client-key.json" with { type: "json" };
import dashboardDetailsFixture from "./fixtures/dashboard-details.json" with {
type: "json",
};
import dashboardListFixture from "./fixtures/dashboard-list.json" with {
type: "json",
};
import dashboardDetailsFixture from "./fixtures/dashboard-details.json" with { type: "json" };
import dashboardListFixture from "./fixtures/dashboard-list.json" with { type: "json" };
import eventsFixture from "./fixtures/event.json" with { type: "json" };
import eventAttachmentsFixture from "./fixtures/event-attachments.json" with {
type: "json",
};
import eventsErrorsFixture from "./fixtures/events-errors.json" with {
type: "json",
};
import eventsErrorsEmptyFixture from "./fixtures/events-errors-empty.json" with {
type: "json",
};
import eventsSpansFixture from "./fixtures/events-spans.json" with {
type: "json",
};
import eventsSpansEmptyFixture from "./fixtures/events-spans-empty.json" with {
type: "json",
};
import eventsTraceMetricsFixture from "./fixtures/events-tracemetrics.json" with {
type: "json",
};
import eventsTraceMetricsAggregateFixture from "./fixtures/events-tracemetrics-aggregate.json" with {
type: "json",
};
import eventsTraceMetricsEmptyFixture from "./fixtures/events-tracemetrics-empty.json" with {
type: "json",
};
import flamegraphFixture from "./fixtures/flamegraph.json" with {
type: "json",
};
import eventAttachmentsFixture from "./fixtures/event-attachments.json" with { type: "json" };
import eventsErrorsFixture from "./fixtures/events-errors.json" with { type: "json" };
import eventsErrorsEmptyFixture from "./fixtures/events-errors-empty.json" with { type: "json" };
import eventsSpansFixture from "./fixtures/events-spans.json" with { type: "json" };
import eventsSpansEmptyFixture from "./fixtures/events-spans-empty.json" with { type: "json" };
import eventsTraceMetricsFixture from "./fixtures/events-tracemetrics.json" with { type: "json" };
import eventsTraceMetricsAggregateFixture from "./fixtures/events-tracemetrics-aggregate.json" with { type: "json" };
import eventsTraceMetricsEmptyFixture from "./fixtures/events-tracemetrics-empty.json" with { type: "json" };
import flamegraphFixture from "./fixtures/flamegraph.json" with { type: "json" };
import issueFixture from "./fixtures/issue.json" with { type: "json" };
import issueActivityFixture from "./fixtures/issue-activity.json" with {
type: "json",
};
import issueCommentsFixture from "./fixtures/issue-comments.json" with {
type: "json",
};
import issueNullCulpritFixture from "./fixtures/issue-null-culprit.json" with {
type: "json",
};
import issueTagValuesFixture from "./fixtures/issue-tag-values.json" with {
type: "json",
};
import issueUserReportsFixture from "./fixtures/issue-user-reports.json" with {
type: "json",
};
import issueActivityFixture from "./fixtures/issue-activity.json" with { type: "json" };
import issueCommentsFixture from "./fixtures/issue-comments.json" with { type: "json" };
import issueNullCulpritFixture from "./fixtures/issue-null-culprit.json" with { type: "json" };
import issueTagValuesFixture from "./fixtures/issue-tag-values.json" with { type: "json" };
import issueUserReportsFixture from "./fixtures/issue-user-reports.json" with { type: "json" };
import monitorFixture from "./fixtures/monitor.json" with { type: "json" };
import monitorCheckInsFixture from "./fixtures/monitor-checkins.json" with {
type: "json",
};
import monitorStatsFixture from "./fixtures/monitor-stats.json" with {
type: "json",
};
import organizationFixture from "./fixtures/organization.json" with {
type: "json",
};
import performanceEventFixture from "./fixtures/performance-event.json" with {
type: "json",
};
import profileChunkFixture from "./fixtures/profile-chunk.json" with {
type: "json",
};
import monitorCheckInsFixture from "./fixtures/monitor-checkins.json" with { type: "json" };
import monitorStatsFixture from "./fixtures/monitor-stats.json" with { type: "json" };
import organizationFixture from "./fixtures/organization.json" with { type: "json" };
import performanceEventFixture from "./fixtures/performance-event.json" with { type: "json" };
import profileChunkFixture from "./fixtures/profile-chunk.json" with { type: "json" };
import projectFixture from "./fixtures/project.json" with { type: "json" };
import releaseFixture from "./fixtures/release.json" with { type: "json" };
import releaseCommitsFixture from "./fixtures/release-commits.json" with {
type: "json",
};
import releaseDeploysFixture from "./fixtures/release-deploys.json" with {
type: "json",
};
import replayDetailsFixture from "./fixtures/replay-details.json" with {
type: "json",
};
import replayRecordingSegmentsFixture from "./fixtures/replay-recording-segments.json" with {
type: "json",
};
import releaseCommitsFixture from "./fixtures/release-commits.json" with { type: "json" };
import releaseDeploysFixture from "./fixtures/release-deploys.json" with { type: "json" };
import replayDetailsFixture from "./fixtures/replay-details.json" with { type: "json" };
import replayRecordingSegmentsFixture from "./fixtures/replay-recording-segments.json" with { type: "json" };
import tagsFixture from "./fixtures/tags.json" with { type: "json" };
import teamFixture from "./fixtures/team.json" with { type: "json" };
import traceFixture from "./fixtures/trace.json" with { type: "json" };
import traceEventFixture from "./fixtures/trace-event.json" with {
type: "json",
};
import traceItemsAttributesLogsNumberFixture from "./fixtures/trace-items-attributes-logs-number.json" with {
type: "json",
};
import traceItemsAttributesLogsStringFixture from "./fixtures/trace-items-attributes-logs-string.json" with {
type: "json",
};
import traceItemsAttributesSpansNumberFixture from "./fixtures/trace-items-attributes-spans-number.json" with {
type: "json",
};
import traceItemsAttributesSpansStringFixture from "./fixtures/trace-items-attributes-spans-string.json" with {
type: "json",
};
import traceItemsAttributesSpansNumberWithContextFixture from "./fixtures/trace-items-attributes-spans-number-with-context.json" with {
type: "json",
};
import traceItemsAttributesSpansStringWithContextFixture from "./fixtures/trace-items-attributes-spans-string-with-context.json" with {
type: "json",
};
import traceItemsAttributesTraceMetricsNumberFixture from "./fixtures/trace-items-attributes-tracemetrics-number.json" with {
type: "json",
};
import traceItemsAttributesTraceMetricsStringFixture from "./fixtures/trace-items-attributes-tracemetrics-string.json" with {
type: "json",
};
import traceEventFixture from "./fixtures/trace-event.json" with { type: "json" };
import traceItemsAttributesLogsNumberFixture from "./fixtures/trace-items-attributes-logs-number.json" with { type: "json" };
import traceItemsAttributesLogsStringFixture from "./fixtures/trace-items-attributes-logs-string.json" with { type: "json" };
import traceItemsAttributesSpansNumberFixture from "./fixtures/trace-items-attributes-spans-number.json" with { type: "json" };
import traceItemsAttributesSpansStringFixture from "./fixtures/trace-items-attributes-spans-string.json" with { type: "json" };
import traceItemsAttributesSpansNumberWithContextFixture from "./fixtures/trace-items-attributes-spans-number-with-context.json" with { type: "json" };
import traceItemsAttributesSpansStringWithContextFixture from "./fixtures/trace-items-attributes-spans-string-with-context.json" with { type: "json" };
import traceItemsAttributesTraceMetricsNumberFixture from "./fixtures/trace-items-attributes-tracemetrics-number.json" with { type: "json" };
import traceItemsAttributesTraceMetricsStringFixture from "./fixtures/trace-items-attributes-tracemetrics-string.json" with { type: "json" };
import traceMetaFixture from "./fixtures/trace-meta.json" with { type: "json" };
import traceMetaWithNullsFixture from "./fixtures/trace-meta-with-nulls.json" with {
type: "json",
};
import traceMixedFixture from "./fixtures/trace-mixed.json" with {
type: "json",
};
import transactionProfileV1Fixture from "./fixtures/transaction-profile-v1.json" with {
type: "json",
};
import transactionProfileV1MissingFunctionFixture from "./fixtures/transaction-profile-v1-missing-function.json" with {
type: "json",
};
import uptimeChecksFixture from "./fixtures/uptime-checks.json" with {
type: "json",
};
import uptimeMonitorFixture from "./fixtures/uptime-monitor.json" with {
type: "json",
};
import traceMetaWithNullsFixture from "./fixtures/trace-meta-with-nulls.json" with { type: "json" };
import traceMixedFixture from "./fixtures/trace-mixed.json" with { type: "json" };
import transactionProfileV1Fixture from "./fixtures/transaction-profile-v1.json" with { type: "json" };
import transactionProfileV1MissingFunctionFixture from "./fixtures/transaction-profile-v1-missing-function.json" with { type: "json" };
import uptimeChecksFixture from "./fixtures/uptime-checks.json" with { type: "json" };
import uptimeMonitorFixture from "./fixtures/uptime-monitor.json" with { type: "json" };
import userFixture from "./fixtures/user.json" with { type: "json" };
import { issueFixture2 } from "./payloads";

Expand Down
11 changes: 6 additions & 5 deletions packages/toolkit-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
Pure protocol and hostname helpers shared by the CLI and MCP. Both product
builds bundle this private workspace package into their artifacts.

The shared code validates opaque bearer tokens, constructs OAuth device-flow
form bodies, classifies RFC 8628 polling responses, advances retry intervals,
recognizes Sentry hostnames, and encodes API path identifiers. Each product
retains its own credential storage, URL and host trust checks, regional routing,
polling deadline, HTTP transport, response validation, and user-facing errors.
The shared code validates and formats upstream bearer tokens, assembles Sentry
API URLs, constructs OAuth device-flow form bodies, classifies RFC 8628 polling
responses, advances retry intervals, recognizes Sentry hostnames, and encodes
API path identifiers. Each product retains its own credential storage, host
trust checks, regional routing, polling deadline, HTTP transport, response
validation, and user-facing errors.
4 changes: 4 additions & 0 deletions packages/toolkit-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
"node": ">=22.13"
},
"exports": {
"./api-request": {
"types": "./src/api-request.ts",
"default": "./src/api-request.ts"
},
"./api-path-segment": {
"types": "./src/api-path-segment.ts",
"default": "./src/api-path-segment.ts"
Expand Down
35 changes: 35 additions & 0 deletions packages/toolkit-core/src/api-request.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { buildSentryApiUrl } from "./api-request.js";

describe("buildSentryApiUrl", () => {
it.each([
[
"https://sentry.io",
"/organizations/org/",
"https://sentry.io/api/0/organizations/org/",
],
[
"https://us.sentry.io/",
"organizations/org/?cursor=a%3Ab",
"https://us.sentry.io/api/0/organizations/org/?cursor=a%3Ab",
],
[
"https://self-hosted.example/sentry/",
"/organizations/org/",
"https://self-hosted.example/sentry/api/0/organizations/org/",
],
[
"https://self-hosted.example/sentry////",
"organizations/org/",
"https://self-hosted.example/sentry/api/0/organizations/org/",
],
])("assembles %s with %s", (baseUrl, endpoint, expected) => {
expect(buildSentryApiUrl(baseUrl, endpoint)).toBe(expected);
});

it("preserves encoded identifiers without decoding them", () => {
expect(buildSentryApiUrl("https://sentry.io", "issues/a%2Fb/")).toBe(
"https://sentry.io/api/0/issues/a%2Fb/",
);
});
});
12 changes: 12 additions & 0 deletions packages/toolkit-core/src/api-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Assemble an API URL from a product-validated base and a relative endpoint.
* This does not decide which host is trusted or which region an org uses.
*/
export function buildSentryApiUrl(baseUrl: string, endpoint: string): string {
const path = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint;
let end = baseUrl.length;
while (end > 0 && baseUrl.charAt(end - 1) === "/") {
end -= 1;
}
return `${baseUrl.slice(0, end)}/api/0/${path}`;
}
Loading
Loading