Skip to content
Open
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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
"author": "Sentry",
"description": "Sentry MCP Server",
"homepage": "https://github.com/getsentry/sentry-mcp",
"keywords": ["sentry"],
"keywords": [
"sentry"
],
"bugs": {
"url": "https://github.com/getsentry/sentry-mcp/issues"
},
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"private": true,
"type": "module",
"license": "FSL-1.1-ALv2",
"files": ["./dist/*"],
"files": [
"./dist/*"
],
Comment on lines +7 to +9

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Biome did its thing 🤷

"exports": {
".": {
"types": "./dist/index.d.ts",
Expand Down
3 changes: 1 addition & 2 deletions packages/mcp-cloudflare/src/server/lib/mcp-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,6 @@ async function handleAuthenticatedMcpRequest(
const organizationSlug = groups?.org || null;
const projectSlug = groups?.project || null;


// Check for experimental mode query parameter
const isExperimentalMode = url.searchParams.get("experimental") === "1";

Expand Down Expand Up @@ -451,7 +450,7 @@ async function handleAuthenticatedMcpRequest(
() =>
buildServer({
context: serverContext,
experimentalMode: isExperimentalMode,
experimentalMode: isExperimentalMode,
sdkVersion: "v2",
}),
{
Expand Down
8 changes: 6 additions & 2 deletions packages/mcp-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,19 @@
"author": "Sentry",
"description": "Sentry MCP Core - Shared code for MCP transports",
"homepage": "https://github.com/getsentry/sentry-mcp",
"keywords": ["sentry"],
"keywords": [
"sentry"
],
"bugs": {
"url": "https://github.com/getsentry/sentry-mcp/issues"
},
"repository": {
"type": "git",
"url": "git@github.com:getsentry/sentry-mcp.git"
},
"files": ["./dist/*"],
"files": [
"./dist/*"
],
"exports": {
"./api-client": {
"types": "./dist/api-client/index.d.ts",
Expand Down
85 changes: 14 additions & 71 deletions packages/mcp-core/src/api-client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -818,36 +818,19 @@ describe("listOrganizations", () => {
globalThis.fetch = originalFetch;
});

it("should fetch from regions endpoint for SaaS", async () => {
const mockRegionsResponse = {
regions: [
{ name: "US", url: "https://us.sentry.io" },
{ name: "EU", url: "https://eu.sentry.io" },
],
};

const mockOrgsUs = [{ id: "1", slug: "org-us", name: "Org US" }];
const mockOrgsEu = [{ id: "2", slug: "org-eu", name: "Org EU" }];
it("should fetch from the organizations endpoint on the root host for SaaS", async () => {
const mockOrgs = [
{ id: "1", slug: "org-us", name: "Org US" },
{ id: "2", slug: "org-eu", name: "Org EU" },
];

let callCount = 0;
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
callCount++;
if (url.includes("/users/me/regions/")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockRegionsResponse),
});
}
if (url.includes("us.sentry.io")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockOrgsUs),
});
}
if (url.includes("eu.sentry.io")) {
if (url.includes("/organizations/")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockOrgsEu),
json: () => Promise.resolve(mockOrgs),
});
}
return Promise.reject(new Error("Unexpected URL"));
Expand All @@ -860,10 +843,15 @@ describe("listOrganizations", () => {

const result = await apiService.listOrganizations();
Comment thread
sentry-warden[bot] marked this conversation as resolved.

expect(callCount).toBe(3); // 1 regions call + 2 org calls
expect(callCount).toBe(1); // Single call, no region fanout
expect(result).toHaveLength(2);
expect(result).toContainEqual(expect.objectContaining({ slug: "org-us" }));
expect(result).toContainEqual(expect.objectContaining({ slug: "org-eu" }));
// Region fanout is no longer used
expect(globalThis.fetch).not.toHaveBeenCalledWith(
expect.stringContaining("/users/me/regions/"),
expect.any(Object),
);
});

it("should fetch directly from organizations endpoint for self-hosted", async () => {
Expand Down Expand Up @@ -900,51 +888,6 @@ describe("listOrganizations", () => {
expect.any(Object),
);
});

it("should fall back to direct organizations endpoint when regions endpoint returns 404 on SaaS", async () => {
const mockOrgs = [
{ id: "1", slug: "org-1", name: "Organization 1" },
{ id: "2", slug: "org-2", name: "Organization 2" },
];

globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (url.includes("/users/me/regions/")) {
return Promise.resolve({
ok: false,
status: 404,
statusText: "Not Found",
text: () => Promise.resolve(JSON.stringify({ detail: "Not found" })),
});
}
if (url.includes("/organizations/")) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockOrgs),
});
}
return Promise.reject(new Error("Unexpected URL"));
});

const apiService = new SentryApiService({
host: "sentry.io",
accessToken: "test-token",
});

const result = await apiService.listOrganizations();

expect(result).toHaveLength(2);
expect(result).toEqual(mockOrgs);

// Verify it tried regions first, then fell back to organizations
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining("/users/me/regions/"),
expect.any(Object),
);
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining("/organizations/"),
expect.any(Object),
);
});
});

describe("host configuration", () => {
Expand Down Expand Up @@ -1136,7 +1079,7 @@ describe("Content-Type validation", () => {
);
});

it("should handle HTML response from regions endpoint", async () => {
it("should handle HTML response from organizations endpoint", async () => {
const htmlContent = `<!DOCTYPE html>
<html>
<head><title>Login Required</title></head>
Expand Down
61 changes: 11 additions & 50 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ import {
TraceMetaSchema,
TraceSchema,
UserSchema,
UserRegionsSchema,
IssueAlertRuleListSchema,
MetricAlertRuleListSchema,
MetricAlertRuleSchema,
Expand Down Expand Up @@ -1498,13 +1497,11 @@ export class SentryApiService {
/**
* Lists all organizations accessible to the authenticated user.
*
* Automatically handles multi-region queries by fetching from all
* available regions and combining results.
* Queries the `/organizations/` endpoint on the root host.
*
* @param params Query parameters
* @param params.query Search query to filter organizations by name/slug
* @param params.limit Maximum number of organizations to return (defaults to 25)
* @param opts Request options
* @returns Array of organizations across all accessible regions
*
* @example
Expand All @@ -1516,10 +1513,10 @@ export class SentryApiService {
* });
* ```
*/
async listOrganizations(
params?: { query?: string; limit?: number },
opts?: RequestOptions,
): Promise<OrganizationList> {
async listOrganizations(params?: {
query?: string;
limit?: number;
}): Promise<OrganizationList> {
const limit = params?.limit ?? 25;

// Build query parameters
Expand All @@ -1531,50 +1528,14 @@ export class SentryApiService {
const queryString = queryParams.toString();
const path = `/organizations/?${queryString}`;

// For self-hosted instances, the regions endpoint doesn't exist
if (!this.isSaas()) {
const body = await this.requestJSON(path, undefined, opts);
return OrganizationListSchema.parse(body);
let host = undefined;
// For SaaS, always use the main sentry.io host, not regional hosts
if (this.isSaas()) {
host = "sentry.io";
}

// For SaaS, try to use regions endpoint first
try {
// TODO: Sentry is currently not returning all orgs without hitting region endpoints
// The regions endpoint only exists on the main API server, not on regional endpoints
const regionsBody = await this.requestJSON(
"/users/me/regions/",
undefined,
{}, // Don't pass opts to ensure we use the main host
);
const regionData = UserRegionsSchema.parse(regionsBody);

const allOrganizations = (
await Promise.all(
regionData.regions.map(async (region) =>
this.requestJSON(path, undefined, {
...opts,
host: new URL(region.url).host,
}),
),
)
)
.map((data) => OrganizationListSchema.parse(data))
.reduce((acc, curr) => acc.concat(curr), []);

// Apply the limit after combining results from all regions
return allOrganizations.slice(0, limit);
} catch (error) {
// If regions endpoint fails (e.g., older self-hosted versions identifying as sentry.io),
// fall back to direct organizations endpoint
if (error instanceof ApiNotFoundError) {
// logger.info("Regions endpoint not found, falling back to direct organizations endpoint");
const body = await this.requestJSON(path, undefined, opts);
return OrganizationListSchema.parse(body);
}

// Re-throw other errors
throw error;
}
const body = await this.requestJSON(path, undefined, { host });
return OrganizationListSchema.parse(body);
}

/**
Expand Down
9 changes: 0 additions & 9 deletions packages/mcp-core/src/api-client/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,6 @@ export const UserSchema = z
})
.passthrough();

export const UserRegionsSchema = z.object({
regions: z.array(
z.object({
name: z.string(),
url: z.string().url(),
}),
),
});

/**
* Schema for Sentry organization API responses.
*
Expand Down
2 changes: 0 additions & 2 deletions packages/mcp-core/src/tools/catalog-runtime/availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ function isAllowedBySkills({
tool: ToolConfig<any>;
context: ServerContext;
}): boolean {

const grantedSkills: Set<Skill> | undefined = context.grantedSkills
? new Set<Skill>(context.grantedSkills)
: undefined;
Expand Down Expand Up @@ -202,7 +201,6 @@ export function getToolsForMcpRegistration({
useDefaultSurfacePolicy,
});


return availableTools.filter(({ isTopLevel }) => isTopLevel);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,7 @@ import findOrganizations from "./find-organizations.js";

function mockOrganizations(organizations: unknown[]) {
mswServer.use(
http.get("https://sentry.io/api/0/users/me/regions/", () =>
HttpResponse.json({
regions: [{ name: "us", url: "https://us.sentry.io" }],
}),
),
http.get("https://us.sentry.io/api/0/organizations/", ({ request }) => {
http.get("https://sentry.io/api/0/organizations/", ({ request }) => {
expect(new URL(request.url).searchParams.get("per_page")).toBe("26");
return HttpResponse.json(organizations);
}),
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp-core/src/tools/catalog/find-organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ export default defineTool({
},
outputSchema: findOrganizationsOutputSchema,
async handler(params, context: ServerContext) {
// User data endpoints (like /users/me/regions/) should never use regionUrl
// as they must always query the main API server, not region-specific servers
// Organizations are listed from the root host, which returns orgs across
// all regions, so no regionUrl is passed here.
const apiService = apiServiceFromContext(context);
const organizations = await apiService.listOrganizations({
query: params.query ?? undefined,
Expand Down
1 change: 0 additions & 1 deletion packages/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,6 @@ async function main() {
(process.env.NODE_ENV !== "production" ? "development" : "production"),
});


// Log experimental mode status
if (cli.experimental) {
console.warn(
Expand Down
1 change: 0 additions & 1 deletion packages/mcp-test-client/src/mcp-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export function applyProtectedResourceFlags(
useExperimental?: boolean;
},
): URL {

if (options.useExperimental) {
protectedResourceUrl.searchParams.set("experimental", "1");
}
Expand Down
Loading