()),
+ * runSearchEvents: vi.fn(async () => "ok"),
+ * }));
+ */
+export async function inspectDatasetSearchTool(tool: {
+ inputSchema: object;
+ handler(params: P, context: ServerContext): Promise;
+}) {
+ const run = vi.mocked(runSearchEvents);
+ run.mockClear();
+ await tool.handler(params as P, createTestContext());
+ const [handlerParams, , options] = run.mock.calls[0] ?? [];
+ return {
+ dataset: handlerParams?.dataset,
+ options,
+ inputParams: Object.keys(tool.inputSchema),
+ };
+}
diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json
index cab05bc0e..9fa64538c 100644
--- a/packages/mcp-core/src/toolDefinitions.json
+++ b/packages/mcp-core/src/toolDefinitions.json
@@ -5181,7 +5181,7 @@
},
{
"name": "get_latest_base_snapshot",
- "description": "Get the latest UI screenshots/images for an app from the preprod snapshot system.\n\nThis is the primary tool for retrieving app screenshots — not search_replays or search_issues.\n\nUse this tool when you need to:\n- Get screenshots, screens, golden images, or reference images for an app\n- Find what the current UI looks like (latest screenshots from the main/default branch)\n- List available snapshots or browse images before requesting specific ones\n- Look up dark mode, light mode, or other variant screenshots\n- Understand what baseline images exist when investigating snapshot test or visual regression CI failures\n\nThe appId parameter is the app identifier (e.g. 'sentry-frontend', 'com.emergetools.hackernews').\nReturns compact image metadata (display_name, image_file_name, group, description) for every image.\n\n\n### Get the latest screenshots for an app\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\")\n```\n\n### Get the latest screenshots for a specific branch\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\", branch=\"main\")\n```\n\n\n\n- The response includes compact metadata per image. Scan the list to find images matching what you need (e.g. filter by group or name containing 'button').\n- To view a specific image, use get_sentry_resource(url='?selectedSnapshot=').\n- If you need to investigate a specific snapshot comparison, use get_sentry_resource with the snapshot URL.\n",
+ "description": "Get the latest UI screenshots/images for an app from the preprod snapshot system.\n\nThis is the primary tool for retrieving app screenshots.\n\nUse this tool when you need to:\n- Get screenshots, screens, golden images, or reference images for an app\n- Find what the current UI looks like (latest screenshots from the main/default branch)\n- List available snapshots or browse images before requesting specific ones\n- Look up dark mode, light mode, or other variant screenshots\n- Understand what baseline images exist when investigating snapshot test or visual regression CI failures\n\nThe appId parameter is the app identifier (e.g. 'sentry-frontend', 'com.emergetools.hackernews').\nReturns compact image metadata (display_name, image_file_name, group, description) for every image.\n\n\n### Get the latest screenshots for an app\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\")\n```\n\n### Get the latest screenshots for a specific branch\n\n```\nget_latest_base_snapshot(organizationSlug=\"sentry\", appId=\"sentry-frontend\", project=\"frontend\", branch=\"main\")\n```\n\n\n\n- The response includes compact metadata per image. Scan the list to find images matching what you need (e.g. filter by group or name containing 'button').\n- To view a specific image, use get_sentry_resource(url='?selectedSnapshot=').\n- If you need to investigate a specific snapshot comparison, use get_sentry_resource with the snapshot URL.\n",
"inputSchema": {
"type": "object",
"properties": {
diff --git a/packages/mcp-core/src/tools/catalog/get-latest-base-snapshot.ts b/packages/mcp-core/src/tools/catalog/get-latest-base-snapshot.ts
index 8c8a884b1..19b02d7db 100644
--- a/packages/mcp-core/src/tools/catalog/get-latest-base-snapshot.ts
+++ b/packages/mcp-core/src/tools/catalog/get-latest-base-snapshot.ts
@@ -15,7 +15,7 @@ export default defineTool({
description: [
"Get the latest UI screenshots/images for an app from the preprod snapshot system.",
"",
- "This is the primary tool for retrieving app screenshots — not search_replays or search_issues.",
+ "This is the primary tool for retrieving app screenshots.",
"",
"Use this tool when you need to:",
"- Get screenshots, screens, golden images, or reference images for an app",
diff --git a/packages/mcp-core/src/tools/catalog/search-errors.test.ts b/packages/mcp-core/src/tools/catalog/search-errors.test.ts
index 891a10894..bf26747da 100644
--- a/packages/mcp-core/src/tools/catalog/search-errors.test.ts
+++ b/packages/mcp-core/src/tools/catalog/search-errors.test.ts
@@ -1,78 +1,18 @@
-import { mswServer } from "@sentry/mcp-server-mocks";
-import { generateText } from "ai";
-import { HttpResponse, http } from "msw";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { expect, it, vi } from "vitest";
+import { inspectDatasetSearchTool } from "../../test-utils/dataset-search-tool";
import searchErrors from "./search-errors";
-vi.mock("@ai-sdk/openai", () => {
- const mockModel = vi.fn(() => "mocked-model");
- return {
- openai: mockModel,
- createOpenAI: vi.fn(() => mockModel),
- };
-});
-
-vi.mock("ai", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- generateText: vi.fn(),
- tool: vi.fn(() => ({ execute: vi.fn() })),
- Output: { object: vi.fn(() => ({})) },
- };
-});
-
-const context = {
- constraints: {
- organizationSlug: null,
- regionUrl: null,
- projectSlug: null,
- },
- accessToken: "test-token",
- userId: "1",
-};
-
-function agentResponse(output: Record) {
- return {
- text: JSON.stringify(output),
- experimental_output: output,
- finishReason: "stop" as const,
- usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
- warnings: [] as const,
- } as any;
-}
-
-describe("search_errors", () => {
- const mockGenerateText = vi.mocked(generateText);
-
- beforeEach(() => {
- vi.clearAllMocks();
- process.env.OPENAI_API_KEY = "test-key";
- process.env.OPENROUTER_API_KEY = "";
- mswServer.use(
- http.get(
- "https://sentry.io/api/0/organizations/:orgSlug/environments/",
- () => HttpResponse.json([]),
- ),
- http.get(
- "https://sentry.io/api/0/organizations/:orgSlug/events/validate/",
- () =>
- HttpResponse.json({
- valid: true,
- projects: [],
- dataset: [],
- environment: [],
- field: [],
- query: { valid: true, error: null, fields: [] },
- orderby: [],
- }),
- ),
- );
- });
-
- it("takes its dataset from the tool instead of a parameter", () => {
- expect(Object.keys(searchErrors.inputSchema)).toMatchInlineSnapshot(`
- [
+// The shared handler is covered by search-events.test.ts.
+vi.mock("../support/search-events/search", async (importOriginal) => ({
+ ...(await importOriginal()),
+ runSearchEvents: vi.fn(async () => "ok"),
+}));
+
+it("search_errors runs the shared handler locked to errors", async () => {
+ expect(await inspectDatasetSearchTool(searchErrors)).toMatchInlineSnapshot(`
+ {
+ "dataset": "errors",
+ "inputParams": [
"organizationSlug",
"query",
"fields",
@@ -82,50 +22,10 @@ describe("search_errors", () => {
"regionUrl",
"limit",
"includeExplanation",
- ]
- `);
- });
-
- it("keeps the errors dataset when the agent suggests another", async () => {
- mockGenerateText.mockResolvedValue(
- agentResponse({
- dataset: "logs",
- query: "level:error",
- fields: ["timestamp", "message"],
- sort: "-timestamp",
- environment: null,
- timeRange: { statsPeriod: "24h" },
- explanation: "Test query translation",
- }),
- );
- const requestedDatasets: Array = [];
- mswServer.use(
- http.get(
- "https://sentry.io/api/0/organizations/test-org/events/",
- ({ request }) => {
- requestedDatasets.push(
- new URL(request.url).searchParams.get("dataset"),
- );
- return HttpResponse.json({ data: [] });
- },
- ),
- );
-
- await searchErrors.handler(
- {
- organizationSlug: "test-org",
- regionUrl: null,
- projectSlug: null,
- query: "how many errors today",
- limit: 10,
- includeExplanation: false,
+ ],
+ "options": {
+ "lockDataset": true,
},
- context,
- );
-
- expect(requestedDatasets).toEqual(["errors"]);
- expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain(
- "The dataset is fixed to errors",
- );
- });
+ }
+ `);
});
diff --git a/packages/mcp-core/src/tools/catalog/search-events.test.ts b/packages/mcp-core/src/tools/catalog/search-events.test.ts
index b3cfb3f40..7de937048 100644
--- a/packages/mcp-core/src/tools/catalog/search-events.test.ts
+++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts
@@ -3,6 +3,7 @@ import { APICallError, generateText } from "ai";
import { HttpResponse, http } from "msw";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UserInputError } from "../../errors";
+import { runSearchEvents } from "../support/search-events/search";
import searchEvents from "./search-events";
// Mock the AI SDK
@@ -3339,6 +3340,51 @@ describe("search_events", () => {
expect(mockGenerateText).not.toHaveBeenCalled();
});
+ it("keeps the caller's dataset when lockDataset is set", async () => {
+ mockGenerateText.mockResolvedValueOnce(
+ mockAIResponse("logs", "level:error"),
+ );
+ const requestedDatasets: Array = [];
+ mswServer.use(
+ http.get(
+ "https://sentry.io/api/0/organizations/test-org/events/",
+ ({ request }) => {
+ requestedDatasets.push(
+ new URL(request.url).searchParams.get("dataset"),
+ );
+ return HttpResponse.json({ data: [] });
+ },
+ ),
+ );
+
+ await runSearchEvents(
+ {
+ organizationSlug: "test-org",
+ regionUrl: null,
+ projectSlug: null,
+ dataset: "errors",
+ query: "how many errors today",
+ limit: 10,
+ includeExplanation: false,
+ },
+ {
+ constraints: {
+ organizationSlug: null,
+ regionUrl: null,
+ projectSlug: null,
+ },
+ accessToken: "test-token",
+ userId: "1",
+ },
+ { lockDataset: true },
+ );
+
+ expect(requestedDatasets).toEqual(["errors"]);
+ expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain(
+ "The dataset is fixed to errors",
+ );
+ });
+
describe("with Seer", () => {
const seerParams = {
organizationSlug: "test-org",
diff --git a/packages/mcp-core/src/tools/catalog/search-logs.test.ts b/packages/mcp-core/src/tools/catalog/search-logs.test.ts
index 69145964a..64a4aee3d 100644
--- a/packages/mcp-core/src/tools/catalog/search-logs.test.ts
+++ b/packages/mcp-core/src/tools/catalog/search-logs.test.ts
@@ -1,78 +1,18 @@
-import { mswServer } from "@sentry/mcp-server-mocks";
-import { generateText } from "ai";
-import { HttpResponse, http } from "msw";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { expect, it, vi } from "vitest";
+import { inspectDatasetSearchTool } from "../../test-utils/dataset-search-tool";
import searchLogs from "./search-logs";
-vi.mock("@ai-sdk/openai", () => {
- const mockModel = vi.fn(() => "mocked-model");
- return {
- openai: mockModel,
- createOpenAI: vi.fn(() => mockModel),
- };
-});
-
-vi.mock("ai", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- generateText: vi.fn(),
- tool: vi.fn(() => ({ execute: vi.fn() })),
- Output: { object: vi.fn(() => ({})) },
- };
-});
-
-const context = {
- constraints: {
- organizationSlug: null,
- regionUrl: null,
- projectSlug: null,
- },
- accessToken: "test-token",
- userId: "1",
-};
-
-function agentResponse(output: Record) {
- return {
- text: JSON.stringify(output),
- experimental_output: output,
- finishReason: "stop" as const,
- usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
- warnings: [] as const,
- } as any;
-}
-
-describe("search_logs", () => {
- const mockGenerateText = vi.mocked(generateText);
-
- beforeEach(() => {
- vi.clearAllMocks();
- process.env.OPENAI_API_KEY = "test-key";
- process.env.OPENROUTER_API_KEY = "";
- mswServer.use(
- http.get(
- "https://sentry.io/api/0/organizations/:orgSlug/environments/",
- () => HttpResponse.json([]),
- ),
- http.get(
- "https://sentry.io/api/0/organizations/:orgSlug/events/validate/",
- () =>
- HttpResponse.json({
- valid: true,
- projects: [],
- dataset: [],
- environment: [],
- field: [],
- query: { valid: true, error: null, fields: [] },
- orderby: [],
- }),
- ),
- );
- });
-
- it("takes its dataset from the tool instead of a parameter", () => {
- expect(Object.keys(searchLogs.inputSchema)).toMatchInlineSnapshot(`
- [
+// The shared handler is covered by search-events.test.ts.
+vi.mock("../support/search-events/search", async (importOriginal) => ({
+ ...(await importOriginal()),
+ runSearchEvents: vi.fn(async () => "ok"),
+}));
+
+it("search_logs runs the shared handler locked to logs", async () => {
+ expect(await inspectDatasetSearchTool(searchLogs)).toMatchInlineSnapshot(`
+ {
+ "dataset": "logs",
+ "inputParams": [
"organizationSlug",
"query",
"fields",
@@ -82,50 +22,10 @@ describe("search_logs", () => {
"regionUrl",
"limit",
"includeExplanation",
- ]
- `);
- });
-
- it("keeps the logs dataset when the agent suggests another", async () => {
- mockGenerateText.mockResolvedValue(
- agentResponse({
- dataset: "errors",
- query: "severity:error",
- fields: ["timestamp", "message", "severity"],
- sort: "-timestamp",
- environment: null,
- timeRange: { statsPeriod: "24h" },
- explanation: "Test query translation",
- }),
- );
- const requestedDatasets: Array = [];
- mswServer.use(
- http.get(
- "https://sentry.io/api/0/organizations/test-org/events/",
- ({ request }) => {
- requestedDatasets.push(
- new URL(request.url).searchParams.get("dataset"),
- );
- return HttpResponse.json({ data: [] });
- },
- ),
- );
-
- await searchLogs.handler(
- {
- organizationSlug: "test-org",
- regionUrl: null,
- projectSlug: null,
- query: "error logs from the last hour",
- limit: 10,
- includeExplanation: false,
+ ],
+ "options": {
+ "lockDataset": true,
},
- context,
- );
-
- expect(requestedDatasets).toEqual(["logs"]);
- expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain(
- "The dataset is fixed to logs",
- );
- });
+ }
+ `);
});
diff --git a/packages/mcp-core/src/tools/catalog/search-metrics.test.ts b/packages/mcp-core/src/tools/catalog/search-metrics.test.ts
index 0a7e9fd4a..6e6774974 100644
--- a/packages/mcp-core/src/tools/catalog/search-metrics.test.ts
+++ b/packages/mcp-core/src/tools/catalog/search-metrics.test.ts
@@ -1,78 +1,18 @@
-import { mswServer } from "@sentry/mcp-server-mocks";
-import { generateText } from "ai";
-import { HttpResponse, http } from "msw";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { expect, it, vi } from "vitest";
+import { inspectDatasetSearchTool } from "../../test-utils/dataset-search-tool";
import searchMetrics from "./search-metrics";
-vi.mock("@ai-sdk/openai", () => {
- const mockModel = vi.fn(() => "mocked-model");
- return {
- openai: mockModel,
- createOpenAI: vi.fn(() => mockModel),
- };
-});
-
-vi.mock("ai", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- generateText: vi.fn(),
- tool: vi.fn(() => ({ execute: vi.fn() })),
- Output: { object: vi.fn(() => ({})) },
- };
-});
-
-const context = {
- constraints: {
- organizationSlug: null,
- regionUrl: null,
- projectSlug: null,
- },
- accessToken: "test-token",
- userId: "1",
-};
-
-function agentResponse(output: Record) {
- return {
- text: JSON.stringify(output),
- experimental_output: output,
- finishReason: "stop" as const,
- usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
- warnings: [] as const,
- } as any;
-}
-
-describe("search_metrics", () => {
- const mockGenerateText = vi.mocked(generateText);
-
- beforeEach(() => {
- vi.clearAllMocks();
- process.env.OPENAI_API_KEY = "test-key";
- process.env.OPENROUTER_API_KEY = "";
- mswServer.use(
- http.get(
- "https://sentry.io/api/0/organizations/:orgSlug/environments/",
- () => HttpResponse.json([]),
- ),
- http.get(
- "https://sentry.io/api/0/organizations/:orgSlug/events/validate/",
- () =>
- HttpResponse.json({
- valid: true,
- projects: [],
- dataset: [],
- environment: [],
- field: [],
- query: { valid: true, error: null, fields: [] },
- orderby: [],
- }),
- ),
- );
- });
-
- it("takes its dataset from the tool instead of a parameter", () => {
- expect(Object.keys(searchMetrics.inputSchema)).toMatchInlineSnapshot(`
- [
+// The shared handler is covered by search-events.test.ts.
+vi.mock("../support/search-events/search", async (importOriginal) => ({
+ ...(await importOriginal()),
+ runSearchEvents: vi.fn(async () => "ok"),
+}));
+
+it("search_metrics runs the shared handler locked to metrics", async () => {
+ expect(await inspectDatasetSearchTool(searchMetrics)).toMatchInlineSnapshot(`
+ {
+ "dataset": "metrics",
+ "inputParams": [
"organizationSlug",
"query",
"fields",
@@ -82,50 +22,10 @@ describe("search_metrics", () => {
"regionUrl",
"limit",
"includeExplanation",
- ]
- `);
- });
-
- it("keeps the metrics dataset when the agent suggests another", async () => {
- mockGenerateText.mockResolvedValue(
- agentResponse({
- dataset: "spans",
- query: "metric.name:http.request.duration",
- fields: ["timestamp", "metric.name", "value"],
- sort: "-timestamp",
- environment: null,
- timeRange: { statsPeriod: "24h" },
- explanation: "Test query translation",
- }),
- );
- const requestedDatasets: Array = [];
- mswServer.use(
- http.get(
- "https://sentry.io/api/0/organizations/test-org/events/",
- ({ request }) => {
- requestedDatasets.push(
- new URL(request.url).searchParams.get("dataset"),
- );
- return HttpResponse.json({ data: [] });
- },
- ),
- );
-
- await searchMetrics.handler(
- {
- organizationSlug: "test-org",
- regionUrl: null,
- projectSlug: null,
- query: "recent request duration metrics",
- limit: 10,
- includeExplanation: false,
+ ],
+ "options": {
+ "lockDataset": true,
},
- context,
- );
-
- expect(requestedDatasets).toEqual(["tracemetrics"]);
- expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain(
- "The dataset is fixed to metrics",
- );
- });
+ }
+ `);
});
diff --git a/packages/mcp-core/src/tools/catalog/search-profiles.test.ts b/packages/mcp-core/src/tools/catalog/search-profiles.test.ts
index f3219e916..91e6827b5 100644
--- a/packages/mcp-core/src/tools/catalog/search-profiles.test.ts
+++ b/packages/mcp-core/src/tools/catalog/search-profiles.test.ts
@@ -1,78 +1,20 @@
-import { mswServer } from "@sentry/mcp-server-mocks";
-import { generateText } from "ai";
-import { HttpResponse, http } from "msw";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { expect, it, vi } from "vitest";
+import { inspectDatasetSearchTool } from "../../test-utils/dataset-search-tool";
import searchProfiles from "./search-profiles";
-vi.mock("@ai-sdk/openai", () => {
- const mockModel = vi.fn(() => "mocked-model");
- return {
- openai: mockModel,
- createOpenAI: vi.fn(() => mockModel),
- };
-});
-
-vi.mock("ai", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- generateText: vi.fn(),
- tool: vi.fn(() => ({ execute: vi.fn() })),
- Output: { object: vi.fn(() => ({})) },
- };
-});
-
-const context = {
- constraints: {
- organizationSlug: null,
- regionUrl: null,
- projectSlug: null,
- },
- accessToken: "test-token",
- userId: "1",
-};
-
-function agentResponse(output: Record) {
- return {
- text: JSON.stringify(output),
- experimental_output: output,
- finishReason: "stop" as const,
- usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
- warnings: [] as const,
- } as any;
-}
-
-describe("search_profiles", () => {
- const mockGenerateText = vi.mocked(generateText);
-
- beforeEach(() => {
- vi.clearAllMocks();
- process.env.OPENAI_API_KEY = "test-key";
- process.env.OPENROUTER_API_KEY = "";
- mswServer.use(
- http.get(
- "https://sentry.io/api/0/organizations/:orgSlug/environments/",
- () => HttpResponse.json([]),
- ),
- http.get(
- "https://sentry.io/api/0/organizations/:orgSlug/events/validate/",
- () =>
- HttpResponse.json({
- valid: true,
- projects: [],
- dataset: [],
- environment: [],
- field: [],
- query: { valid: true, error: null, fields: [] },
- orderby: [],
- }),
- ),
- );
- });
-
- it("takes its dataset from the tool instead of a parameter", () => {
- expect(Object.keys(searchProfiles.inputSchema)).toMatchInlineSnapshot(`
- [
+// The shared handler is covered by search-events.test.ts.
+vi.mock("../support/search-events/search", async (importOriginal) => ({
+ ...(await importOriginal()),
+ runSearchEvents: vi.fn(async () => "ok"),
+}));
+
+it("search_profiles runs the shared handler locked to profiles", async () => {
+ expect(
+ await inspectDatasetSearchTool(searchProfiles),
+ ).toMatchInlineSnapshot(`
+ {
+ "dataset": "profiles",
+ "inputParams": [
"organizationSlug",
"query",
"fields",
@@ -82,50 +24,10 @@ describe("search_profiles", () => {
"regionUrl",
"limit",
"includeExplanation",
- ]
- `);
- });
-
- it("keeps the profiles dataset when the agent suggests another", async () => {
- mockGenerateText.mockResolvedValue(
- agentResponse({
- dataset: "spans",
- query: "transaction:/checkout",
- fields: ["profile.id", "transaction", "timestamp"],
- sort: "-timestamp",
- environment: null,
- timeRange: { statsPeriod: "24h" },
- explanation: "Test query translation",
- }),
- );
- const requestedDatasets: Array = [];
- mswServer.use(
- http.get(
- "https://sentry.io/api/0/organizations/test-org/events/",
- ({ request }) => {
- requestedDatasets.push(
- new URL(request.url).searchParams.get("dataset"),
- );
- return HttpResponse.json({ data: [] });
- },
- ),
- );
-
- await searchProfiles.handler(
- {
- organizationSlug: "test-org",
- regionUrl: null,
- projectSlug: null,
- query: "recent checkout profiles",
- limit: 10,
- includeExplanation: false,
+ ],
+ "options": {
+ "lockDataset": true,
},
- context,
- );
-
- expect(requestedDatasets).toEqual(["profiles"]);
- expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain(
- "The dataset is fixed to profiles",
- );
- });
+ }
+ `);
});
diff --git a/packages/mcp-core/src/tools/catalog/search-replays.test.ts b/packages/mcp-core/src/tools/catalog/search-replays.test.ts
index 3f3b998b3..e14b017f4 100644
--- a/packages/mcp-core/src/tools/catalog/search-replays.test.ts
+++ b/packages/mcp-core/src/tools/catalog/search-replays.test.ts
@@ -1,78 +1,18 @@
-import { mswServer } from "@sentry/mcp-server-mocks";
-import { generateText } from "ai";
-import { HttpResponse, http } from "msw";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { expect, it, vi } from "vitest";
+import { inspectDatasetSearchTool } from "../../test-utils/dataset-search-tool";
import searchReplays from "./search-replays";
-vi.mock("@ai-sdk/openai", () => {
- const mockModel = vi.fn(() => "mocked-model");
- return {
- openai: mockModel,
- createOpenAI: vi.fn(() => mockModel),
- };
-});
-
-vi.mock("ai", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- generateText: vi.fn(),
- tool: vi.fn(() => ({ execute: vi.fn() })),
- Output: { object: vi.fn(() => ({})) },
- };
-});
-
-const context = {
- constraints: {
- organizationSlug: null,
- regionUrl: null,
- projectSlug: null,
- },
- accessToken: "test-token",
- userId: "1",
-};
-
-function agentResponse(output: Record) {
- return {
- text: JSON.stringify(output),
- experimental_output: output,
- finishReason: "stop" as const,
- usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
- warnings: [] as const,
- } as any;
-}
-
-describe("search_replays", () => {
- const mockGenerateText = vi.mocked(generateText);
-
- beforeEach(() => {
- vi.clearAllMocks();
- process.env.OPENAI_API_KEY = "test-key";
- process.env.OPENROUTER_API_KEY = "";
- mswServer.use(
- http.get(
- "https://sentry.io/api/0/organizations/:orgSlug/environments/",
- () => HttpResponse.json([]),
- ),
- http.get(
- "https://sentry.io/api/0/organizations/:orgSlug/events/validate/",
- () =>
- HttpResponse.json({
- valid: true,
- projects: [],
- dataset: [],
- environment: [],
- field: [],
- query: { valid: true, error: null, fields: [] },
- orderby: [],
- }),
- ),
- );
- });
-
- it("takes its dataset from the tool instead of a parameter", () => {
- expect(Object.keys(searchReplays.inputSchema)).toMatchInlineSnapshot(`
- [
+// The shared handler is covered by search-events.test.ts.
+vi.mock("../support/search-events/search", async (importOriginal) => ({
+ ...(await importOriginal()),
+ runSearchEvents: vi.fn(async () => "ok"),
+}));
+
+it("search_replays runs the shared handler locked to replays", async () => {
+ expect(await inspectDatasetSearchTool(searchReplays)).toMatchInlineSnapshot(`
+ {
+ "dataset": "replays",
+ "inputParams": [
"organizationSlug",
"query",
"sort",
@@ -82,55 +22,10 @@ describe("search_replays", () => {
"regionUrl",
"limit",
"includeExplanation",
- ]
- `);
- });
-
- it("keeps the replays dataset when the agent suggests another", async () => {
- mockGenerateText.mockResolvedValue(
- agentResponse({
- dataset: "errors",
- query: "count_errors:>0",
- fields: [],
- sort: "-count_errors",
- environment: null,
- timeRange: { statsPeriod: "24h" },
- explanation: "Test query translation",
- }),
- );
- const requestedPaths: string[] = [];
- mswServer.use(
- http.get(
- "https://sentry.io/api/0/organizations/test-org/replays/",
- ({ request }) => {
- requestedPaths.push(new URL(request.url).pathname);
- return HttpResponse.json({ data: [] });
- },
- ),
- http.get(
- "https://sentry.io/api/0/organizations/test-org/events/",
- ({ request }) => {
- requestedPaths.push(new URL(request.url).pathname);
- return HttpResponse.json({ data: [] });
- },
- ),
- );
-
- await searchReplays.handler(
- {
- organizationSlug: "test-org",
- regionUrl: null,
- projectSlug: null,
- query: "replays with errors in the last day",
- limit: 10,
- includeExplanation: false,
+ ],
+ "options": {
+ "lockDataset": true,
},
- context,
- );
-
- expect(requestedPaths).toEqual(["/api/0/organizations/test-org/replays/"]);
- expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain(
- "The dataset is fixed to replays",
- );
- });
+ }
+ `);
});
diff --git a/packages/mcp-core/src/tools/catalog/search-traces.test.ts b/packages/mcp-core/src/tools/catalog/search-traces.test.ts
index 9da35968e..c90403b86 100644
--- a/packages/mcp-core/src/tools/catalog/search-traces.test.ts
+++ b/packages/mcp-core/src/tools/catalog/search-traces.test.ts
@@ -1,78 +1,18 @@
-import { mswServer } from "@sentry/mcp-server-mocks";
-import { generateText } from "ai";
-import { HttpResponse, http } from "msw";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { expect, it, vi } from "vitest";
+import { inspectDatasetSearchTool } from "../../test-utils/dataset-search-tool";
import searchTraces from "./search-traces";
-vi.mock("@ai-sdk/openai", () => {
- const mockModel = vi.fn(() => "mocked-model");
- return {
- openai: mockModel,
- createOpenAI: vi.fn(() => mockModel),
- };
-});
-
-vi.mock("ai", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- generateText: vi.fn(),
- tool: vi.fn(() => ({ execute: vi.fn() })),
- Output: { object: vi.fn(() => ({})) },
- };
-});
-
-const context = {
- constraints: {
- organizationSlug: null,
- regionUrl: null,
- projectSlug: null,
- },
- accessToken: "test-token",
- userId: "1",
-};
-
-function agentResponse(output: Record) {
- return {
- text: JSON.stringify(output),
- experimental_output: output,
- finishReason: "stop" as const,
- usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
- warnings: [] as const,
- } as any;
-}
-
-describe("search_traces", () => {
- const mockGenerateText = vi.mocked(generateText);
-
- beforeEach(() => {
- vi.clearAllMocks();
- process.env.OPENAI_API_KEY = "test-key";
- process.env.OPENROUTER_API_KEY = "";
- mswServer.use(
- http.get(
- "https://sentry.io/api/0/organizations/:orgSlug/environments/",
- () => HttpResponse.json([]),
- ),
- http.get(
- "https://sentry.io/api/0/organizations/:orgSlug/events/validate/",
- () =>
- HttpResponse.json({
- valid: true,
- projects: [],
- dataset: [],
- environment: [],
- field: [],
- query: { valid: true, error: null, fields: [] },
- orderby: [],
- }),
- ),
- );
- });
-
- it("takes its dataset from the tool instead of a parameter", () => {
- expect(Object.keys(searchTraces.inputSchema)).toMatchInlineSnapshot(`
- [
+// The shared handler is covered by search-events.test.ts.
+vi.mock("../support/search-events/search", async (importOriginal) => ({
+ ...(await importOriginal()),
+ runSearchEvents: vi.fn(async () => "ok"),
+}));
+
+it("search_traces runs the shared handler locked to spans", async () => {
+ expect(await inspectDatasetSearchTool(searchTraces)).toMatchInlineSnapshot(`
+ {
+ "dataset": "spans",
+ "inputParams": [
"organizationSlug",
"query",
"fields",
@@ -82,128 +22,10 @@ describe("search_traces", () => {
"regionUrl",
"limit",
"includeExplanation",
- ]
- `);
- });
-
- it("keeps the spans dataset when the agent suggests another", async () => {
- mockGenerateText.mockResolvedValue(
- agentResponse({
- dataset: "logs",
- query: "span.op:db",
- fields: ["span.op", "span.duration", "timestamp"],
- sort: "-timestamp",
- environment: null,
- timeRange: { statsPeriod: "24h" },
- explanation: "Test query translation",
- }),
- );
- const requestedDatasets: Array = [];
- mswServer.use(
- http.get(
- "https://sentry.io/api/0/organizations/test-org/events/",
- ({ request }) => {
- requestedDatasets.push(
- new URL(request.url).searchParams.get("dataset"),
- );
- return HttpResponse.json({ data: [] });
- },
- ),
- );
-
- await searchTraces.handler(
- {
- organizationSlug: "test-org",
- regionUrl: null,
- projectSlug: null,
- query: "slow db queries",
- limit: 10,
- includeExplanation: false,
- },
- context,
- );
-
- expect(requestedDatasets).toEqual(["spans"]);
- expect(JSON.stringify(mockGenerateText.mock.calls[0])).toContain(
- "The dataset is fixed to spans",
- );
- });
-
- it("sends natural language queries to Seer's Traces strategy", async () => {
- const seerStartBodies: unknown[] = [];
- mswServer.use(
- http.get("https://sentry.io/api/0/organizations/test-org/", () =>
- HttpResponse.json({
- id: "1",
- slug: "test-org",
- name: "Test Org",
- features: ["gen-ai-search-agent-translate"],
- hideAiFeatures: false,
- }),
- ),
- http.post(
- "https://sentry.io/api/0/organizations/test-org/search-agent/start/",
- async ({ request }) => {
- seerStartBodies.push(await request.json());
- return HttpResponse.json({ run_id: 1, sentry_run_id: "run-uuid" });
- },
- ),
- http.get(
- "https://sentry.io/api/0/organizations/test-org/search-agent/state/run-uuid/",
- () =>
- HttpResponse.json({
- sentry_run_id: "run-uuid",
- session: {
- status: "completed",
- final_response: {
- responses: [
- {
- query: "span.op:http.client",
- group_by: [],
- visualization: [],
- sort: "-span.duration",
- stats_period: "24h",
- start: null,
- end: null,
- mode: "samples",
- },
- ],
- unsupported_reason: null,
- },
- },
- }),
- ),
- http.get(
- "https://sentry.io/api/0/organizations/test-org/events/",
- ({ request }) => {
- const url = new URL(request.url);
- expect(url.searchParams.get("dataset")).toBe("spans");
- expect(url.searchParams.get("query")).toBe("span.op:http.client");
- return HttpResponse.json({ data: [] });
- },
- ),
- );
-
- const result = await searchTraces.handler(
- {
- organizationSlug: "test-org",
- regionUrl: null,
- projectSlug: null,
- query: "slowest api calls in the last 24 hours",
- limit: 10,
- includeExplanation: true,
- },
- { ...context, experimentalMode: true },
- );
-
- expect(seerStartBodies).toEqual([
- {
- project_ids: [-1],
- natural_language_query: "slowest api calls in the last 24 hours",
- strategy: "Traces",
+ ],
+ "options": {
+ "lockDataset": true,
},
- ]);
- expect(mockGenerateText).not.toHaveBeenCalled();
- expect(result).toContain("Translated by Seer's search agent.");
- });
+ }
+ `);
});