Skip to main content
Glama

Sentry MCP

Official
by getsentry
client.test.ts40 kB
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; import { SentryApiService } from "./client"; import { ConfigurationError } from "../errors"; describe("getIssueUrl", () => { it("should work with sentry.io", () => { const apiService = new SentryApiService({ host: "sentry.io" }); const result = apiService.getIssueUrl("sentry-mcp", "123456"); expect(result).toMatchInlineSnapshot( `"https://sentry-mcp.sentry.io/issues/123456"`, ); }); it("should work with self-hosted", () => { const apiService = new SentryApiService({ host: "sentry.example.com" }); const result = apiService.getIssueUrl("sentry-mcp", "123456"); expect(result).toMatchInlineSnapshot( `"https://sentry.example.com/organizations/sentry-mcp/issues/123456"`, ); }); it("should work with full URL including protocol", () => { const apiService = new SentryApiService({ host: "sentry.example.com", }); const result = apiService.getIssueUrl("sentry-mcp", "123456"); expect(result).toMatchInlineSnapshot( `"https://sentry.example.com/organizations/sentry-mcp/issues/123456"`, ); }); it("should always use HTTPS protocol", () => { const apiService = new SentryApiService({ host: "localhost:8000", }); const result = apiService.getIssueUrl("sentry-mcp", "123456"); expect(result).toMatchInlineSnapshot( `"https://localhost:8000/organizations/sentry-mcp/issues/123456"`, ); }); it("should handle regional URLs correctly for SaaS", () => { const apiService = new SentryApiService({ host: "us.sentry.io" }); const result = apiService.getIssueUrl("sentry", "PROJ-THREAD-LEAKS-12"); // Should use sentry.io, not us.sentry.io for web UI expect(result).toEqual( "https://sentry.sentry.io/issues/PROJ-THREAD-LEAKS-12", ); }); it("should handle EU regional URLs correctly for SaaS", () => { const apiService = new SentryApiService({ host: "eu.sentry.io" }); const result = apiService.getIssueUrl("myorg", "PROJECT-456"); // Should use sentry.io, not eu.sentry.io for web UI expect(result).toEqual("https://myorg.sentry.io/issues/PROJECT-456"); }); }); describe("getTraceUrl", () => { it("should work with sentry.io", () => { const apiService = new SentryApiService({ host: "sentry.io" }); const result = apiService.getTraceUrl( "sentry-mcp", "6a477f5b0f31ef7b6b9b5e1dea66c91d", ); expect(result).toMatchInlineSnapshot( `"https://sentry-mcp.sentry.io/explore/traces/trace/6a477f5b0f31ef7b6b9b5e1dea66c91d"`, ); }); it("should work with self-hosted", () => { const apiService = new SentryApiService({ host: "sentry.example.com" }); const result = apiService.getTraceUrl( "sentry-mcp", "6a477f5b0f31ef7b6b9b5e1dea66c91d", ); expect(result).toMatchInlineSnapshot( `"https://sentry.example.com/organizations/sentry-mcp/explore/traces/trace/6a477f5b0f31ef7b6b9b5e1dea66c91d"`, ); }); it("should always use HTTPS protocol", () => { const apiService = new SentryApiService({ host: "localhost:8000", }); const result = apiService.getTraceUrl( "sentry-mcp", "6a477f5b0f31ef7b6b9b5e1dea66c91d", ); expect(result).toMatchInlineSnapshot( `"https://localhost:8000/organizations/sentry-mcp/explore/traces/trace/6a477f5b0f31ef7b6b9b5e1dea66c91d"`, ); }); it("should handle regional URLs correctly for SaaS", () => { const apiService = new SentryApiService({ host: "us.sentry.io" }); const result = apiService.getTraceUrl( "sentry", "6a477f5b0f31ef7b6b9b5e1dea66c91d", ); // Should use sentry.io, not us.sentry.io for web UI expect(result).toEqual( "https://sentry.sentry.io/explore/traces/trace/6a477f5b0f31ef7b6b9b5e1dea66c91d", ); }); }); describe("getEventsExplorerUrl", () => { it("should work with sentry.io", () => { const apiService = new SentryApiService({ host: "sentry.io" }); const result = apiService.getEventsExplorerUrl( "sentry-mcp", "level:error AND message:timeout", ); expect(result).toMatchInlineSnapshot( `"https://sentry-mcp.sentry.io/explore/traces/?query=level%3Aerror+AND+message%3Atimeout&statsPeriod=24h&table=span"`, ); }); it("should work with self-hosted", () => { const apiService = new SentryApiService({ host: "sentry.example.com" }); const result = apiService.getEventsExplorerUrl( "sentry-mcp", "level:error AND message:timeout", ); expect(result).toMatchInlineSnapshot( `"https://sentry.example.com/organizations/sentry-mcp/explore/traces/?query=level%3Aerror+AND+message%3Atimeout&statsPeriod=24h&table=span"`, ); }); it("should include project parameter when provided", () => { const apiService = new SentryApiService({ host: "sentry.io" }); const result = apiService.getEventsExplorerUrl( "sentry-mcp", "level:error", "backend", ); expect(result).toMatchInlineSnapshot( `"https://sentry-mcp.sentry.io/explore/traces/?query=level%3Aerror&project=backend&statsPeriod=24h&table=span"`, ); }); it("should properly encode special characters in query", () => { const apiService = new SentryApiService({ host: "sentry.io" }); const result = apiService.getEventsExplorerUrl( "sentry-mcp", 'message:"database timeout" AND level:error', ); expect(result).toMatchInlineSnapshot( `"https://sentry-mcp.sentry.io/explore/traces/?query=message%3A%22database+timeout%22+AND+level%3Aerror&statsPeriod=24h&table=span"`, ); }); it("should always use HTTPS protocol", () => { const apiService = new SentryApiService({ host: "localhost:8000", }); const result = apiService.getEventsExplorerUrl("sentry-mcp", "level:error"); expect(result).toMatchInlineSnapshot( `"https://localhost:8000/organizations/sentry-mcp/explore/traces/?query=level%3Aerror&statsPeriod=24h&table=span"`, ); }); it("should handle regional URLs correctly for SaaS", () => { const apiService = new SentryApiService({ host: "us.sentry.io" }); const result = apiService.getEventsExplorerUrl("sentry", "level:error"); // Should use sentry.io, not us.sentry.io for web UI expect(result).toEqual( "https://sentry.sentry.io/explore/traces/?query=level%3Aerror&statsPeriod=24h&table=span", ); }); describe("time parameters", () => { it("should use statsPeriod when provided for spans dataset", () => { const apiService = new SentryApiService({ host: "sentry.io" }); const result = apiService.getEventsExplorerUrl( "sentry-mcp", "level:error", undefined, // projectId "spans", // dataset undefined, // fields undefined, // sort undefined, // aggregateFunctions undefined, // groupByFields "7d", // statsPeriod ); expect(result).toContain("statsPeriod=7d"); expect(result).not.toContain("start="); expect(result).not.toContain("end="); }); it("should use start/end when provided for spans dataset", () => { const apiService = new SentryApiService({ host: "sentry.io" }); const result = apiService.getEventsExplorerUrl( "sentry-mcp", "level:error", undefined, // projectId "spans", // dataset undefined, // fields undefined, // sort undefined, // aggregateFunctions undefined, // groupByFields undefined, // statsPeriod "2025-07-29T07:00:00", // start "2025-07-31T06:59:59", // end ); expect(result).toContain("start=2025-07-29T07%3A00%3A00"); expect(result).toContain("end=2025-07-31T06%3A59%3A59"); expect(result).not.toContain("statsPeriod="); }); it("should prefer start/end over statsPeriod when both provided for spans dataset", () => { const apiService = new SentryApiService({ host: "sentry.io" }); const result = apiService.getEventsExplorerUrl( "sentry-mcp", "level:error", undefined, // projectId "spans", // dataset undefined, // fields undefined, // sort undefined, // aggregateFunctions undefined, // groupByFields "7d", // statsPeriod (should be ignored) "2025-07-29T07:00:00", // start "2025-07-31T06:59:59", // end ); expect(result).toContain("start=2025-07-29T07%3A00%3A00"); expect(result).toContain("end=2025-07-31T06%3A59%3A59"); expect(result).not.toContain("statsPeriod="); }); it("should use statsPeriod when provided for errors dataset", () => { const apiService = new SentryApiService({ host: "sentry.io" }); const result = apiService.getEventsExplorerUrl( "sentry-mcp", "level:error", undefined, // projectId "errors", // dataset undefined, // fields undefined, // sort undefined, // aggregateFunctions undefined, // groupByFields "14d", // statsPeriod ); expect(result).toContain("statsPeriod=14d"); expect(result).not.toContain("start="); expect(result).not.toContain("end="); }); it("should use start/end when provided for errors dataset", () => { const apiService = new SentryApiService({ host: "sentry.io" }); const result = apiService.getEventsExplorerUrl( "sentry-mcp", "level:error", undefined, // projectId "errors", // dataset undefined, // fields undefined, // sort undefined, // aggregateFunctions undefined, // groupByFields undefined, // statsPeriod "2025-07-29T07:00:00", // start "2025-07-31T06:59:59", // end ); expect(result).toContain("start=2025-07-29T07%3A00%3A00"); expect(result).toContain("end=2025-07-31T06%3A59%3A59"); expect(result).not.toContain("statsPeriod="); }); it("should default to 24h when no time parameters provided", () => { const apiService = new SentryApiService({ host: "sentry.io" }); const result = apiService.getEventsExplorerUrl( "sentry-mcp", "level:error", ); expect(result).toContain("statsPeriod=24h"); }); it("should handle aggregate queries with time parameters for spans dataset", () => { const apiService = new SentryApiService({ host: "sentry.io" }); const result = apiService.getEventsExplorerUrl( "sentry-mcp", "", "4509062593708032", // projectId "spans", // dataset [ "equation|sum(gen_ai.usage.input_tokens) + sum(gen_ai.usage.output_tokens)", ], // fields "-equation|sum(gen_ai.usage.input_tokens) + sum(gen_ai.usage.output_tokens)", // sort [ "equation|sum(gen_ai.usage.input_tokens) + sum(gen_ai.usage.output_tokens)", ], // aggregateFunctions [], // groupByFields "7d", // statsPeriod ); expect(result).toContain("statsPeriod=7d"); expect(result).toContain("project=4509062593708032"); expect(result).toContain("mode=aggregate"); }); }); }); describe("network error handling", () => { let originalFetch: typeof globalThis.fetch; beforeEach(() => { originalFetch = globalThis.fetch; }); afterEach(() => { globalThis.fetch = originalFetch; }); it("should handle DNS errors with EAI_AGAIN", async () => { const dnsError = new Error("fetch failed"); dnsError.cause = new Error("getaddrinfo EAI_AGAIN https"); globalThis.fetch = vi.fn().mockRejectedValue(dnsError); const apiService = new SentryApiService({ host: "sentry.io", accessToken: "test-token", }); await expect(apiService.getAuthenticatedUser()).rejects.toThrow( /DNS temporarily unavailable/, ); }); it("should handle ENOTFOUND errors", async () => { const notFoundError = new Error("fetch failed"); notFoundError.cause = new Error("getaddrinfo ENOTFOUND invalid.host"); globalThis.fetch = vi.fn().mockRejectedValue(notFoundError); const apiService = new SentryApiService({ host: "invalid.host", accessToken: "test-token", }); await expect(apiService.getAuthenticatedUser()).rejects.toThrow( /Hostname not found/, ); }); it("should handle ECONNREFUSED errors", async () => { const refusedError = new Error("fetch failed"); refusedError.cause = new Error("connect ECONNREFUSED 127.0.0.1:443"); globalThis.fetch = vi.fn().mockRejectedValue(refusedError); const apiService = new SentryApiService({ host: "localhost", accessToken: "test-token", }); await expect(apiService.getAuthenticatedUser()).rejects.toThrow( /Connection refused/, ); }); it("should handle ETIMEDOUT errors", async () => { const timeoutError = new Error("fetch failed"); timeoutError.cause = new Error("connect ETIMEDOUT 192.168.1.1:443"); globalThis.fetch = vi.fn().mockRejectedValue(timeoutError); const apiService = new SentryApiService({ host: "192.168.1.1", accessToken: "test-token", }); await expect(apiService.getAuthenticatedUser()).rejects.toThrow( /Connection timed out/, ); }); it("should handle ECONNRESET errors", async () => { const resetError = new Error("fetch failed"); resetError.cause = new Error("read ECONNRESET"); globalThis.fetch = vi.fn().mockRejectedValue(resetError); const apiService = new SentryApiService({ host: "sentry.io", accessToken: "test-token", }); await expect(apiService.getAuthenticatedUser()).rejects.toThrow( /Connection reset/, ); }); it("should handle generic network errors", async () => { const genericError = new Error("Network request failed"); globalThis.fetch = vi.fn().mockRejectedValue(genericError); const apiService = new SentryApiService({ host: "sentry.io", accessToken: "test-token", }); await expect(apiService.getAuthenticatedUser()).rejects.toThrow( /Unable to connect to .* - Network request failed/, ); }); it("should preserve the original error in the cause chain", async () => { const originalError = new Error("getaddrinfo EAI_AGAIN"); const fetchError = new Error("fetch failed"); fetchError.cause = originalError; globalThis.fetch = vi.fn().mockRejectedValue(fetchError); const apiService = new SentryApiService({ host: "sentry.io", accessToken: "test-token", }); try { await apiService.getAuthenticatedUser(); } catch (error) { expect(error).toBeInstanceOf(Error); expect((error as Error).cause).toBe(fetchError); expect(((error as Error).cause as Error).cause).toBe(originalError); } }); it("should throw ConfigurationError for DNS errors", async () => { const dnsError = new Error("fetch failed"); dnsError.cause = new Error("getaddrinfo ENOTFOUND invalid.host"); globalThis.fetch = vi.fn().mockRejectedValue(dnsError); const apiService = new SentryApiService({ host: "invalid.host", accessToken: "test-token", }); await expect(apiService.getAuthenticatedUser()).rejects.toThrow( ConfigurationError, ); }); it("should throw ConfigurationError for connection timeout errors", async () => { const timeoutError = new Error("fetch failed"); timeoutError.cause = new Error("connect ETIMEDOUT 192.168.1.1:443"); globalThis.fetch = vi.fn().mockRejectedValue(timeoutError); const apiService = new SentryApiService({ host: "192.168.1.1", accessToken: "test-token", }); await expect(apiService.getAuthenticatedUser()).rejects.toThrow( ConfigurationError, ); }); }); describe("listOrganizations", () => { let originalFetch: typeof globalThis.fetch; beforeEach(() => { originalFetch = globalThis.fetch; }); afterEach(() => { 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" }]; 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")) { return Promise.resolve({ ok: true, json: () => Promise.resolve(mockOrgsEu), }); } return Promise.reject(new Error("Unexpected URL")); }); const apiService = new SentryApiService({ host: "sentry.io", accessToken: "test-token", }); const result = await apiService.listOrganizations(); expect(callCount).toBe(3); // 1 regions call + 2 org calls expect(result).toHaveLength(2); expect(result).toContainEqual(expect.objectContaining({ slug: "org-us" })); expect(result).toContainEqual(expect.objectContaining({ slug: "org-eu" })); }); it("should fetch directly from organizations endpoint for self-hosted", async () => { const mockOrgs = [ { id: "1", slug: "org-1", name: "Organization 1" }, { id: "2", slug: "org-2", name: "Organization 2" }, ]; let callCount = 0; globalThis.fetch = vi.fn().mockImplementation((url: string) => { callCount++; 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.example.com", accessToken: "test-token", }); const result = await apiService.listOrganizations(); expect(callCount).toBe(1); // Only 1 org call, no regions call expect(result).toHaveLength(2); expect(result).toEqual(mockOrgs); // Verify that regions endpoint was not called expect(globalThis.fetch).not.toHaveBeenCalledWith( expect.stringContaining("/users/me/regions/"), 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", () => { it("should handle hostname without protocol", () => { const apiService = new SentryApiService({ host: "sentry.io" }); // @ts-expect-error - accessing private property for testing expect(apiService.host).toBe("sentry.io"); // @ts-expect-error - accessing private property for testing expect(apiService.apiPrefix).toBe("https://sentry.io/api/0"); }); it("should handle hostname with port", () => { const apiService = new SentryApiService({ host: "localhost:8000" }); // @ts-expect-error - accessing private property for testing expect(apiService.host).toBe("localhost:8000"); // @ts-expect-error - accessing private property for testing expect(apiService.apiPrefix).toBe("https://localhost:8000/api/0"); }); it("should always use HTTPS protocol", () => { const apiService = new SentryApiService({ host: "sentry.example.com", }); // @ts-expect-error - accessing private property for testing expect(apiService.host).toBe("sentry.example.com"); // @ts-expect-error - accessing private property for testing expect(apiService.apiPrefix).toBe("https://sentry.example.com/api/0"); }); it("should always use HTTPS even for localhost", () => { const apiService = new SentryApiService({ host: "localhost:8000", }); // @ts-expect-error - accessing private property for testing expect(apiService.host).toBe("localhost:8000"); // @ts-expect-error - accessing private property for testing expect(apiService.apiPrefix).toBe("https://localhost:8000/api/0"); }); it("should update host and API prefix with setHost", () => { const apiService = new SentryApiService({ host: "sentry.io" }); apiService.setHost("eu.sentry.io"); // @ts-expect-error - accessing private property for testing expect(apiService.host).toBe("eu.sentry.io"); // @ts-expect-error - accessing private property for testing expect(apiService.apiPrefix).toBe("https://eu.sentry.io/api/0"); apiService.setHost("localhost:9000"); // @ts-expect-error - accessing private property for testing expect(apiService.host).toBe("localhost:9000"); // @ts-expect-error - accessing private property for testing expect(apiService.apiPrefix).toBe("https://localhost:9000/api/0"); }); }); describe("Content-Type validation", () => { it("should throw error when receiving HTML instead of JSON", async () => { const htmlContent = `<!DOCTYPE html> <html> <head><title>Login Required</title></head> <body><h1>Please log in</h1></body> </html>`; globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK", headers: { get: (key: string) => key === "content-type" ? "text/html; charset=utf-8" : null, }, text: () => Promise.resolve(htmlContent), }); const apiService = new SentryApiService({ host: "sentry.io", accessToken: "test-token", }); await expect(apiService.getAuthenticatedUser()).rejects.toThrow( "Expected JSON response but received HTML (200 OK). This may indicate you're not authenticated, the URL is incorrect, or there's a server issue.", ); }); it("should throw error when receiving non-JSON content type", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK", headers: { get: (key: string) => (key === "content-type" ? "text/plain" : null), }, text: () => Promise.resolve("Error: Something went wrong"), }); const apiService = new SentryApiService({ host: "sentry.io", accessToken: "test-token", }); await expect(apiService.getAuthenticatedUser()).rejects.toThrow( "Expected JSON response but received text/plain (200 OK)", ); }); it("should throw error when no content-type header is present", async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK", headers: { get: () => null, }, text: () => Promise.resolve("Some non-JSON response"), }); const apiService = new SentryApiService({ host: "sentry.io", accessToken: "test-token", }); await expect(apiService.getAuthenticatedUser()).rejects.toThrow( "Expected JSON response but received unknown content type (200 OK)", ); }); it("should parse JSON successfully when content-type is application/json", async () => { const mockUser = { id: "123", name: "Test User", email: "test@example.com", }; globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK", headers: { get: (key: string) => key === "content-type" ? "application/json; charset=utf-8" : null, }, json: () => Promise.resolve(mockUser), }); const apiService = new SentryApiService({ host: "sentry.io", accessToken: "test-token", }); const result = await apiService.getAuthenticatedUser(); expect(result).toEqual(mockUser); }); it("should detect HTML content even without content-type header", async () => { const htmlContent = "<!DOCTYPE html><html><body>Error page</body></html>"; globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK", headers: { get: () => null, }, text: () => Promise.resolve(htmlContent), }); const apiService = new SentryApiService({ host: "sentry.io", accessToken: "test-token", }); await expect(apiService.getAuthenticatedUser()).rejects.toThrow( "Expected JSON response but received HTML (200 OK). This may indicate you're not authenticated, the URL is incorrect, or there's a server issue.", ); }); it("should handle HTML response from regions endpoint", async () => { const htmlContent = `<!DOCTYPE html> <html> <head><title>Login Required</title></head> <body><h1>Please log in</h1></body> </html>`; globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK", headers: { get: (key: string) => key === "content-type" ? "text/html; charset=utf-8" : null, }, text: () => Promise.resolve(htmlContent), }); const apiService = new SentryApiService({ host: "sentry.io", accessToken: "test-token", }); await expect(apiService.listOrganizations()).rejects.toThrow( "Expected JSON response but received HTML (200 OK). This may indicate you're not authenticated, the URL is incorrect, or there's a server issue.", ); }); }); describe("API query builders", () => { describe("buildDiscoverApiQuery", () => { it("should build correct query for errors dataset", () => { const apiService = new SentryApiService({ host: "sentry.io" }); // @ts-expect-error - accessing private method for testing const params = apiService.buildDiscoverApiQuery({ query: "level:error", fields: ["title", "project", "count()"], limit: 50, projectId: "backend", statsPeriod: "24h", sort: "-count()", }); expect(params.toString()).toMatchInlineSnapshot( `"per_page=50&query=level%3Aerror&dataset=errors&statsPeriod=24h&project=backend&sort=-count&field=title&field=project&field=count%28%29"`, ); }); it("should transform aggregate sort parameters correctly", () => { const apiService = new SentryApiService({ host: "sentry.io" }); // @ts-expect-error - accessing private method for testing const params = apiService.buildDiscoverApiQuery({ query: "", fields: ["error.type", "count()", "count_unique(user)"], limit: 10, sort: "-count(span.duration)", }); expect(params.get("sort")).toBe("-count_span_duration"); }); it("should handle empty aggregate functions in sort", () => { const apiService = new SentryApiService({ host: "sentry.io" }); // @ts-expect-error - accessing private method for testing const params = apiService.buildDiscoverApiQuery({ query: "", fields: ["title", "count()"], limit: 10, sort: "-count()", }); expect(params.get("sort")).toBe("-count"); }); it("should safely handle malformed sort parameters", () => { const apiService = new SentryApiService({ host: "sentry.io" }); // @ts-expect-error - accessing private method for testing const params = apiService.buildDiscoverApiQuery({ query: "", fields: ["title"], limit: 10, sort: "-count(((", }); // Should not crash and should return the original sort if malformed expect(params.get("sort")).toBe("-count((("); }); }); describe("buildEapApiQuery", () => { it("should build correct query for spans dataset with sampling", () => { const apiService = new SentryApiService({ host: "sentry.io" }); // @ts-expect-error - accessing private method for testing const params = apiService.buildEapApiQuery({ query: "span.op:db", fields: ["span.op", "span.description", "span.duration"], limit: 20, projectId: "frontend", dataset: "spans", statsPeriod: "1h", sort: "-span.duration", }); expect(params.toString()).toMatchInlineSnapshot( `"per_page=20&query=span.op%3Adb&dataset=spans&statsPeriod=1h&project=frontend&sampling=NORMAL&sort=-span.duration&field=span.op&field=span.description&field=span.duration"`, ); }); it("should build correct query for logs dataset without sampling", () => { const apiService = new SentryApiService({ host: "sentry.io" }); // @ts-expect-error - accessing private method for testing const params = apiService.buildEapApiQuery({ query: "severity:error", fields: ["timestamp", "message", "severity"], limit: 30, dataset: "ourlogs", sort: "-timestamp", }); expect(params.toString()).toMatchInlineSnapshot( `"per_page=30&query=severity%3Aerror&dataset=ourlogs&sort=-timestamp&field=timestamp&field=message&field=severity"`, ); // Verify sampling is not added for logs expect(params.has("sampling")).toBe(false); }); it("should transform complex aggregate sorts with dots", () => { const apiService = new SentryApiService({ host: "sentry.io" }); // @ts-expect-error - accessing private method for testing const params = apiService.buildEapApiQuery({ query: "", fields: ["span.op", "avg(span.self_time)"], limit: 10, dataset: "spans", sort: "-avg(span.self_time)", }); expect(params.get("sort")).toBe("-avg_span_self_time"); }); }); describe("searchEvents integration", () => { it("should route errors dataset to Discover API builder", async () => { const apiService = new SentryApiService({ host: "sentry.io", accessToken: "test-token", }); // Mock the API response globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, headers: { get: (key: string) => key === "content-type" ? "application/json" : null, }, json: () => Promise.resolve({ data: [] }), }); await apiService.searchEvents({ organizationSlug: "test-org", query: "level:error", fields: ["title", "count()"], dataset: "errors", sort: "-count()", }); // Verify the URL contains correct parameters expect(globalThis.fetch).toHaveBeenCalledWith( expect.stringContaining("dataset=errors"), expect.any(Object), ); expect(globalThis.fetch).toHaveBeenCalledWith( expect.stringContaining("sort=-count"), expect.any(Object), ); }); it("should route spans dataset to EAP API builder with sampling", async () => { const apiService = new SentryApiService({ host: "sentry.io", accessToken: "test-token", }); // Mock the API response globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, headers: { get: (key: string) => key === "content-type" ? "application/json" : null, }, json: () => Promise.resolve({ data: [] }), }); await apiService.searchEvents({ organizationSlug: "test-org", query: "span.op:http", fields: ["span.op", "span.duration"], dataset: "spans", }); // Verify the URL contains correct parameters expect(globalThis.fetch).toHaveBeenCalledWith( expect.stringContaining("dataset=spans"), expect.any(Object), ); expect(globalThis.fetch).toHaveBeenCalledWith( expect.stringContaining("sampling=NORMAL"), expect.any(Object), ); }); }); describe("Web URL builders", () => { describe("buildDiscoverUrl", () => { it("should build correct URL for errors dataset on SaaS", () => { const apiService = new SentryApiService({ host: "sentry.io" }); // @ts-expect-error - accessing private method for testing const url = apiService.buildDiscoverUrl({ organizationSlug: "my-org", query: "level:error", projectId: "backend", fields: ["title", "project", "timestamp"], sort: "-timestamp", }); expect(url).toMatchInlineSnapshot( `"https://my-org.sentry.io/explore/discover/homepage/?dataset=errors&queryDataset=error-events&query=level%3Aerror&project=backend&field=title&field=project&field=timestamp&sort=-timestamp&statsPeriod=24h&yAxis=count%28%29"`, ); }); it("should include aggregate mode and yAxis for aggregate queries", () => { const apiService = new SentryApiService({ host: "sentry.io" }); // @ts-expect-error - accessing private method for testing const url = apiService.buildDiscoverUrl({ organizationSlug: "my-org", query: "is:unresolved", fields: ["title", "count()"], sort: "-count()", aggregateFunctions: ["count()"], groupByFields: ["title"], }); expect(url).toContain("mode=aggregate"); expect(url).toContain("yAxis=count%28%29"); expect(url).toContain("field=title"); expect(url).toContain("field=count%28%29"); }); it("should build correct URL for self-hosted", () => { const apiService = new SentryApiService({ host: "sentry.example.com" }); // @ts-expect-error - accessing private method for testing const url = apiService.buildDiscoverUrl({ organizationSlug: "my-org", query: "level:error", fields: ["title", "project"], }); expect(url).toMatchInlineSnapshot( `"https://sentry.example.com/organizations/my-org/explore/discover/homepage/?dataset=errors&queryDataset=error-events&query=level%3Aerror&field=title&field=project&sort=-timestamp&statsPeriod=24h&yAxis=count%28%29"`, ); }); }); describe("buildEapUrl", () => { it("should build correct URL for spans dataset with aggregate fields", () => { const apiService = new SentryApiService({ host: "sentry.io" }); // @ts-expect-error - accessing private method for testing const url = apiService.buildEapUrl({ organizationSlug: "my-org", query: "is_transaction:True", dataset: "spans", projectId: "123456", fields: ["span.description", "count()"], sort: "-count()", aggregateFunctions: ["count()"], groupByFields: ["span.description"], }); expect(url).toContain("https://my-org.sentry.io/explore/traces/"); expect(url).toContain("mode=aggregate"); expect(url).toContain( `aggregateField=%7B%22groupBy%22%3A%22span.description%22%7D`, ); expect(url).toContain( `aggregateField=%7B%22yAxes%22%3A%5B%22count%28%29%22%5D%7D`, ); expect(url).toContain("project=123456"); expect(url).toContain("query=is_transaction%3ATrue"); expect(url).toContain("statsPeriod=24h"); }); it("should not include empty groupBy in aggregateField", () => { const apiService = new SentryApiService({ host: "sentry.io" }); // @ts-expect-error - accessing private method for testing const url = apiService.buildEapUrl({ organizationSlug: "my-org", query: "span.op:db", dataset: "spans", fields: ["count()"], sort: "-count()", aggregateFunctions: ["count()"], groupByFields: [], }); expect(url).toContain("mode=aggregate"); expect(url).toContain( `aggregateField=%7B%22yAxes%22%3A%5B%22count%28%29%22%5D%7D`, ); expect(url).not.toContain("groupBy"); }); it("should handle multiple groupBy fields", () => { const apiService = new SentryApiService({ host: "sentry.io" }); // @ts-expect-error - accessing private method for testing const url = apiService.buildEapUrl({ organizationSlug: "my-org", query: "", dataset: "spans", fields: ["span.op", "span.description", "count()"], sort: "-count()", aggregateFunctions: ["count()"], groupByFields: ["span.op", "span.description"], }); expect(url).toContain( `aggregateField=%7B%22groupBy%22%3A%22span.op%22%7D`, ); expect(url).toContain( `aggregateField=%7B%22groupBy%22%3A%22span.description%22%7D`, ); expect(url).toContain( `aggregateField=%7B%22yAxes%22%3A%5B%22count%28%29%22%5D%7D`, ); }); it("should handle non-aggregate queries", () => { const apiService = new SentryApiService({ host: "sentry.io" }); // @ts-expect-error - accessing private method for testing const url = apiService.buildEapUrl({ organizationSlug: "my-org", query: "span.op:http", dataset: "spans", fields: ["span.op", "span.description", "span.duration"], sort: "-span.duration", }); expect(url).not.toContain("mode=aggregate"); expect(url).not.toContain("aggregateField"); expect(url).toContain("field=span.op"); expect(url).toContain("field=span.description"); expect(url).toContain("field=span.duration"); expect(url).toContain("sort=-span.duration"); }); it("should use correct path for logs dataset", () => { const apiService = new SentryApiService({ host: "sentry.io" }); // @ts-expect-error - accessing private method for testing const url = apiService.buildEapUrl({ organizationSlug: "my-org", query: "severity:error", dataset: "logs", fields: ["timestamp", "message"], }); expect(url).toContain("/explore/logs/"); expect(url).not.toContain("/explore/traces/"); }); it("should handle self-hosted URLs correctly", () => { const apiService = new SentryApiService({ host: "sentry.example.com" }); // @ts-expect-error - accessing private method for testing const url = apiService.buildEapUrl({ organizationSlug: "my-org", query: "", dataset: "spans", fields: ["span.op"], }); expect(url).toMatchInlineSnapshot( `"https://sentry.example.com/organizations/my-org/explore/traces/?query=&field=span.op&statsPeriod=24h&table=span"`, ); }); }); }); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/getsentry/sentry-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server