diff --git a/packages/mcp-core/src/tools/catalog/index.ts b/packages/mcp-core/src/tools/catalog/index.ts index a08aec24a..a156b888e 100644 --- a/packages/mcp-core/src/tools/catalog/index.ts +++ b/packages/mcp-core/src/tools/catalog/index.ts @@ -70,6 +70,7 @@ import getAgentConversationDetails from "./get-agent-conversation-details"; import searchAgentConversations from "./search-agent-conversations"; import addIssueNote from "./add-issue-note"; import onboardingStatusUpdate from "./onboarding-status-update"; +import searchAttributes from "./search-attributes"; import type { ToolConfig } from "../types"; const legacyGetAIConversationDetails = { @@ -144,6 +145,7 @@ const catalogTools = { update_issue: updateIssue, link_issue: linkIssue, unlink_issue: unlinkIssue, + search_attributes: searchAttributes, search_errors: searchErrors, search_logs: searchLogs, search_traces: searchTraces, diff --git a/packages/mcp-core/src/tools/catalog/search-attributes.test.ts b/packages/mcp-core/src/tools/catalog/search-attributes.test.ts new file mode 100644 index 000000000..88a0aa166 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/search-attributes.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { getServerContext } from "../../test-setup.js"; +import searchAttributes from "./search-attributes.js"; + +function search(params: Record) { + return searchAttributes.handler( + z.object(searchAttributes.inputSchema).parse({ + organizationSlug: "sentry-mcp-evals", + ...params, + }), + getServerContext(), + ); +} + +describe("search_attributes", () => { + it("applies defaults and leaves dataset unset when only the organization is given", async () => { + expect(await search({})).toMatchInlineSnapshot(` + "# Attribute Search in **sentry-mcp-evals** + + Not implemented yet. Parsed inputs: + + - Dataset: undefined (API item type: undefined) + - Project: all projects + - Query: none (list all attributes) + - Period: not set + - Limit: 10 + " + `); + }); + + it("resolves the project and passes the remaining inputs through", async () => { + expect( + await search({ + projectSlug: "cloudflare-mcp", + query: " payment provider ", + dataset: "metrics", + period: "7d", + limit: 50, + }), + ).toMatchInlineSnapshot(` + "# Attribute Search in **sentry-mcp-evals** + + Not implemented yet. Parsed inputs: + + - Dataset: metrics (API item type: tracemetrics) + - Project: cloudflare-mcp (ID 4509109104082945) + - Query: payment provider + - Period: 7d + - Limit: 50 + " + `); + }); + + it("treats a blank query as no query", async () => { + expect(await search({ query: " ", dataset: "logs" })) + .toMatchInlineSnapshot(` + "# Attribute Search in **sentry-mcp-evals** + + Not implemented yet. Parsed inputs: + + - Dataset: logs (API item type: logs) + - Project: all projects + - Query: none (list all attributes) + - Period: not set + - Limit: 10 + " + `); + }); + + it("rejects datasets other than spans, logs, and metrics", () => { + expect(() => + z + .object(searchAttributes.inputSchema) + .parse({ organizationSlug: "sentry-mcp-evals", dataset: "errors" }), + ).toThrow(); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/search-attributes.ts b/packages/mcp-core/src/tools/catalog/search-attributes.ts new file mode 100644 index 000000000..ec17941d5 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/search-attributes.ts @@ -0,0 +1,78 @@ +import z from "zod"; +import { defineTool } from "../../internal/tool-helpers/define"; +import { + ParamOrganizationSlug, + ParamPeriod, + ParamProjectSlug, + ParamRegionUrl, +} from "../../schema"; +import type { ServerContext } from "../../types"; +import { + SEARCH_ATTRIBUTES_DATASETS, + runSearchAttributes, +} from "../support/search-attributes/search"; +import { searchToolBase } from "../support/search-events/search"; + +export default defineTool({ + ...searchToolBase("search_attributes"), + experimental: true, + includeInSkillDefinitions: false, + name: "search_attributes", + // TODO: Edit this description, figure out what we want it to do + description: [ + "Find span, log, or metric attribute keys in Sentry: which attributes exist, their types, short descriptions, and whether they're deprecated or follow semantic conventions.", + "", + // TODO: put this in after "Use before search_traces, search_logs, or search_metrics" + "Use when you don't know the exact attribute key, or to check whether an attribute is being sent at all.", + "", + "`query` is natural language (preferred) or part of an attribute name; a configured agent turns it into an attribute-name search.", + "", + "NOT for attribute values or event data (use search_traces, search_logs, or search_metrics), or issue tag values (use get_issue_tag_values).", + "", + "", + "search_attributes(organizationSlug='my-org', query='which attribute holds the HTTP status code, so I can group failed requests by it', dataset='spans')", + "search_attributes(organizationSlug='my-org', query='database query text', dataset='spans')", + "search_attributes(organizationSlug='my-org', query='gen_ai.usage', dataset='spans', limit=50)", + "search_attributes(organizationSlug='my-org', query='are we still sending the deprecated HTTP method attribute, and what replaced it', dataset='spans')", + "search_attributes(organizationSlug='my-org', query='user.id', dataset='spans', period='24h')", + "search_attributes(organizationSlug='my-org', projectSlug='backend', query='which attribute carries the request ID that ties log lines back to a trace', dataset='logs')", + "search_attributes(organizationSlug='my-org', query='log level', dataset='logs')", + "search_attributes(organizationSlug='my-org', projectSlug='frontend', query='which attributes identify payment provider and region, to break down revenue by them', dataset='metrics', period='7d')", + "", + "", + "", + "- name/otherName notation means /; parse it directly, don't call find_organizations/find_projects.", + "- Infer dataset from the question: spans for requests, latency, DB, or AI calls; logs for log lines; metrics for counters, gauges, and distributions.", + "- Omit `query` to list all attributes in the dataset.", + "- Pass a returned key to the matching search tool to query its values.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + projectSlug: ParamProjectSlug.nullable().default(null), + query: z + .string() + .trim() + .optional() + .describe( + "What attribute to find, in natural language or as part of an attribute name. Omit to list attributes in the dataset.", + ), + period: ParamPeriod.optional(), + regionUrl: ParamRegionUrl.nullable().default(null), + limit: z + .number() + .min(1) + .max(100) + .default(10) + .describe("Maximum number of attributes to return (1-100)"), + dataset: z + .enum(SEARCH_ATTRIBUTES_DATASETS) + .optional() + .describe( + "Which dataset's attributes to search: spans, logs, or metrics. Infer it from the question; ask the user to clarify if it cannot be inferred.", + ), + }, + async handler(params, context: ServerContext) { + return await runSearchAttributes(params, context); + }, +}); diff --git a/packages/mcp-core/src/tools/support/search-attributes/search.ts b/packages/mcp-core/src/tools/support/search-attributes/search.ts new file mode 100644 index 000000000..42fc333d7 --- /dev/null +++ b/packages/mcp-core/src/tools/support/search-attributes/search.ts @@ -0,0 +1,60 @@ +import type { TraceItemType } from "../../../api-client"; +import { apiServiceFromContext } from "../../../internal/tool-helpers/api"; +import { setTargetTagsAndAttributes } from "../../../telem/scope"; +import type { ServerContext } from "../../../types"; + +export const SEARCH_ATTRIBUTES_DATASETS = ["spans", "logs", "metrics"] as const; + +export type SearchAttributesDataset = + (typeof SEARCH_ATTRIBUTES_DATASETS)[number]; + +export interface SearchAttributesParams { + organizationSlug: string; + projectSlug: string | null; + query?: string; + period?: string; + regionUrl: string | null; + limit: number; + dataset?: SearchAttributesDataset; +} + +/** + * Handler for search_attributes. + */ +export async function runSearchAttributes( + params: SearchAttributesParams, + context: ServerContext, +): Promise { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl ?? undefined, + }); + const organizationSlug = params.organizationSlug; + + setTargetTagsAndAttributes(params); + + // The attributes API filters by numeric project ID, not slug. + let projectId: string | undefined; + if (params.projectSlug) { + const project = await apiService.getProject({ + organizationSlug, + projectSlugOrId: params.projectSlug, + }); + projectId = String(project.id); + } + + // The attributes API still calls the metrics dataset "tracemetrics". + const itemType = + params.dataset === "metrics" ? "tracemetrics" : params.dataset; + const query = params.query || undefined; + + // TODO: search attributes with these inputs and format the results. + let output = `# Attribute Search in **${organizationSlug}**\n\n`; + output += "Not implemented yet. Parsed inputs:\n\n"; + output += `- Dataset: ${params.dataset} (API item type: ${itemType})\n`; + output += `- Project: ${projectId ? `${params.projectSlug} (ID ${projectId})` : "all projects"}\n`; + output += `- Query: ${query ?? "none (list all attributes)"}\n`; + output += `- Period: ${params.period ?? "not set"}\n`; + output += `- Limit: ${params.limit}\n`; + + return output; +}