Skip to main content
Glama

Sentry MCP

Official
by getsentry
search.test.ts21.6 kB
import { describe, it, expect, vi, beforeEach } from "vitest"; import { Hono } from "hono"; import searchRoute from "./search"; import type { Env } from "../types"; import type { Ai, AutoRagSearchResponse } from "@cloudflare/workers-types"; // Create mock AutoRAG instance interface MockAutoRAG { search: ReturnType<typeof vi.fn>; } // Create mock AI binding that matches Cloudflare's Ai interface const mockAutorag: MockAutoRAG = { search: vi.fn(), }; const mockAIBinding = { autorag: vi.fn(() => mockAutorag), } as unknown as Ai; // Create test app with mocked environment function createTestApp() { const app = new Hono<{ Bindings: Env }>(); app.route("/api/search", searchRoute); return app; } describe("search route", () => { let app: ReturnType<typeof createTestApp>; beforeEach(() => { app = createTestApp(); vi.clearAllMocks(); // Setup default mock behavior const defaultResponse: AutoRagSearchResponse = { object: "vector_store.search_results.page", search_query: "test query", data: [ { file_id: "40d26845-75f9-478c-ab2e-30d30b1b049b", filename: "platforms/javascript/guides/react.md", score: 0.95, attributes: { timestamp: 1750952340000, folder: "platforms/javascript/guides/", filename: "react.md", }, content: [ { type: "text", text: "This is test documentation content about React setup and configuration.", }, ], }, ], has_more: false, next_page: null, }; mockAutorag.search.mockResolvedValue(defaultResponse); }); describe("POST /api/search", () => { it("should return 400 when query is missing", async () => { const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({}), }, { AI: mockAIBinding, }, ); expect(res.status).toBe(400); const json = await res.json(); expect(json).toHaveProperty("error", "Invalid request"); expect(json).toHaveProperty("details"); }); it("should return 400 when query is empty", async () => { const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "" }), }, { AI: mockAIBinding, }, ); expect(res.status).toBe(400); const json = await res.json(); expect(json).toHaveProperty("error", "Invalid request"); }); it("should return 400 when maxResults is out of range", async () => { const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "test", maxResults: 15 }), }, { AI: mockAIBinding, }, ); expect(res.status).toBe(400); const json = await res.json(); expect(json).toHaveProperty("error", "Invalid request"); }); it("should return 503 when AI binding is not available", async () => { const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "test" }), }, { AI: null as unknown as Ai, }, ); expect(res.status).toBe(503); const json = await res.json(); expect(json).toEqual({ error: "AI service not available", name: "AI_SERVICE_UNAVAILABLE", }); }); it("should successfully search with default parameters", async () => { const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "rate limiting" }), }, { AI: mockAIBinding, }, ); expect(res.status).toBe(200); const json = await res.json(); expect(mockAIBinding.autorag).toHaveBeenCalledWith("sentry-docs"); expect(mockAutorag.search).toHaveBeenCalledWith({ query: "rate limiting", max_num_results: 10, ranking_options: { score_threshold: 0.2, }, }); expect(json).toMatchObject({ query: "rate limiting", results: [ { id: "platforms/javascript/guides/react.md", url: "https://docs.sentry.io/platforms/javascript/guides/react", snippet: "This is test documentation content about React setup and configuration.", relevance: 0.95, }, ], }); }); it("should filter by platform for platform/guide combination", async () => { const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "setup configuration", guide: "javascript/nextjs", }), }, { AI: mockAIBinding, }, ); expect(res.status).toBe(200); expect(mockAutorag.search).toHaveBeenCalledWith({ query: "setup configuration", max_num_results: 10, ranking_options: { score_threshold: 0.2, }, filters: { type: "and", filters: [ { type: "gte", key: "folder", value: "platforms/javascript/guides/nextjs/", }, { type: "lte", key: "folder", value: "platforms/javascript/guides/nextjs/z", }, ], }, }); }); it("should filter by platform for platform only", async () => { const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "setup configuration", guide: "python", }), }, { AI: mockAIBinding, }, ); expect(res.status).toBe(200); expect(mockAutorag.search).toHaveBeenCalledWith({ query: "setup configuration", max_num_results: 10, ranking_options: { score_threshold: 0.2, }, filters: { type: "and", filters: [ { type: "gte", key: "folder", value: "platforms/python/", }, { type: "lte", key: "folder", value: "platforms/python/z", }, ], }, }); }); it("should handle custom maxResults parameter", async () => { const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "error handling", maxResults: 5 }), }, { AI: mockAIBinding, }, ); expect(res.status).toBe(200); expect(mockAutorag.search).toHaveBeenCalledWith({ query: "error handling", max_num_results: 5, ranking_options: { score_threshold: 0.2, }, }); }); it("should handle empty search results", async () => { mockAutorag.search.mockResolvedValue({ object: "vector_store.search_results.page", search_query: "test query", data: [], has_more: false, next_page: null, }); const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "nonexistent topic" }), }, { AI: mockAIBinding, }, ); expect(res.status).toBe(200); const json = await res.json(); expect(json).toMatchObject({ query: "nonexistent topic", results: [], }); }); it("should handle AutoRAG search errors gracefully", async () => { mockAutorag.search.mockRejectedValue(new Error("AutoRAG API error")); const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "test" }), }, { AI: mockAIBinding, }, ); expect(res.status).toBe(500); const json = await res.json(); expect(json).toMatchObject({ error: "Failed to search documentation. Please try again later.", name: "SEARCH_FAILED", }); }); it("should extract documentation paths correctly", async () => { mockAutorag.search.mockResolvedValue({ object: "vector_store.search_results.page", search_query: "test query", data: [ { file_id: "id-1", filename: "platforms/javascript/index.md", score: 0.9, attributes: { timestamp: 1750952340000, folder: "platforms/javascript/", filename: "index.md", }, content: [ { type: "text", text: "Content 1", }, ], }, { file_id: "id-2", filename: "product/issues.md", score: 0.8, attributes: { timestamp: 1750952340000, folder: "product/", filename: "issues.md", }, content: [ { type: "text", text: "Content 2", }, ], }, ], has_more: false, next_page: null, }); const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "test" }), }, { AI: mockAIBinding, }, ); expect(res.status).toBe(200); const json = (await res.json()) as { results: Array<{ id: string; url: string }>; }; expect(json.results[0]).toMatchInlineSnapshot( { id: "platforms/javascript/index.md", url: "https://docs.sentry.io/platforms/javascript/index", }, ` { "id": "platforms/javascript/index.md", "relevance": 0.9, "snippet": "Content 1", "url": "https://docs.sentry.io/platforms/javascript/index", } `, ); expect(json.results[1]).toMatchInlineSnapshot(` { "id": "product/issues.md", "relevance": 0.8, "snippet": "Content 2", "url": "https://docs.sentry.io/product/issues", } `); }); it("should handle index.md files correctly", async () => { mockAutorag.search.mockResolvedValue({ object: "vector_store.search_results.page", search_query: "test query", data: [ { file_id: "root-id", filename: "index.md", score: 0.9, attributes: { timestamp: 1750952340000, folder: "", filename: "index.md", }, content: [ { type: "text", text: "Root documentation content", }, ], }, ], has_more: false, next_page: null, }); const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "test" }), }, { AI: mockAIBinding, }, ); expect(res.status).toBe(200); const json = (await res.json()) as { results: Array<{ id: string; url: string }>; }; expect(json.results[0].id).toBe("index.md"); expect(json.results[0].url).toBe("https://docs.sentry.io/index"); }); it("should handle missing metadata fields", async () => { mockAutorag.search.mockResolvedValue({ object: "vector_store.search_results.page", search_query: "test query", data: [ { // Missing filename, should use index.md as fallback file_id: "some-id", score: 0.5, attributes: {}, content: [ { type: "text", text: "Content without metadata", }, ], }, ], has_more: false, next_page: null, }); const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "test" }), }, { AI: mockAIBinding, }, ); expect(res.status).toBe(200); const json = (await res.json()) as { results: Array<{ id: string; url: string }>; }; expect(json.results[0]).toMatchInlineSnapshot(` { "id": "", "relevance": 0.5, "snippet": "Content without metadata", "url": "", } `); }); it("should handle unexpected response structure", async () => { mockAutorag.search.mockResolvedValue({ // Missing expected fields unexpected: "response", }); const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "test" }), }, { AI: mockAIBinding, }, ); expect(res.status).toBe(200); const json = await res.json(); expect(json).toMatchInlineSnapshot(` { "query": "test", "results": [], } `); }); }); describe("rate limiting", () => { it("should allow requests when rate limiter is not configured", async () => { const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "test query" }), }, { AI: mockAIBinding, // No SEARCH_RATE_LIMITER binding }, ); expect(res.status).toBe(200); }); it("should allow requests when rate limit is not exceeded", async () => { const mockRateLimiter = { limit: vi.fn().mockResolvedValue({ success: true }), }; const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "test query" }), }, { AI: mockAIBinding, SEARCH_RATE_LIMITER: mockRateLimiter, }, ); expect(res.status).toBe(200); expect(mockRateLimiter.limit).toHaveBeenCalledWith({ key: expect.stringMatching(/^search:ip:[a-f0-9]{16}$/), }); }); it("should reject requests when rate limit is exceeded", async () => { const mockRateLimiter = { limit: vi.fn().mockResolvedValue({ success: false }), }; const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "test query" }), }, { AI: mockAIBinding, SEARCH_RATE_LIMITER: mockRateLimiter, }, ); expect(res.status).toBe(429); const json = await res.json(); expect(json).toMatchInlineSnapshot(` { "error": "Rate limit exceeded. You can perform up to 20 documentation searches per minute. Please wait before searching again.", "name": "RATE_LIMIT_EXCEEDED", } `); }); it("should handle rate limiter errors gracefully", async () => { const mockRateLimiter = { limit: vi .fn() .mockRejectedValue(new Error("Rate limiter connection failed")), }; const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "test query" }), }, { AI: mockAIBinding, SEARCH_RATE_LIMITER: mockRateLimiter, }, ); expect(res.status).toBe(500); const json = await res.json(); expect(json).toMatchObject({ error: "There was an error communicating with the rate limiter.", name: "RATE_LIMITER_ERROR", }); }); it("should use different rate limit keys for different IPs", async () => { const mockRateLimiter = { limit: vi.fn().mockResolvedValue({ success: true }), }; // First request from IP 192.0.2.1 await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "test query" }), }, { AI: mockAIBinding, SEARCH_RATE_LIMITER: mockRateLimiter, }, ); const firstKey = mockRateLimiter.limit.mock.calls[0][0].key; // Second request from IP 192.0.2.2 await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.2", }, body: JSON.stringify({ query: "test query" }), }, { AI: mockAIBinding, SEARCH_RATE_LIMITER: mockRateLimiter, }, ); const secondKey = mockRateLimiter.limit.mock.calls[1][0].key; expect(firstKey).not.toBe(secondKey); expect(firstKey).toMatch(/^search:ip:[a-f0-9]{16}$/); expect(secondKey).toMatch(/^search:ip:[a-f0-9]{16}$/); }); }); describe("configurable index name", () => { it("should use default index name when AUTORAG_INDEX_NAME is not set", async () => { const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "test query" }), }, { AI: mockAIBinding, // No AUTORAG_INDEX_NAME environment variable }, ); expect(res.status).toBe(200); expect(mockAIBinding.autorag).toHaveBeenCalledWith("sentry-docs"); }); it("should use custom index name when AUTORAG_INDEX_NAME is set", async () => { const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "test query" }), }, { AI: mockAIBinding, AUTORAG_INDEX_NAME: "custom-docs-index", }, ); expect(res.status).toBe(200); expect(mockAIBinding.autorag).toHaveBeenCalledWith("custom-docs-index"); }); it("should use default index name when AUTORAG_INDEX_NAME is empty", async () => { const res = await app.request( "/api/search", { method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "192.0.2.1", }, body: JSON.stringify({ query: "test query" }), }, { AI: mockAIBinding, AUTORAG_INDEX_NAME: "", }, ); expect(res.status).toBe(200); expect(mockAIBinding.autorag).toHaveBeenCalledWith("sentry-docs"); }); }); });

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