From a8ef4766339d4f8aefa38028dc85caa493ef2f2a Mon Sep 17 00:00:00 2001 From: nikkikapadia Date: Tue, 3 Nov 2026 16:10:17 -0500 Subject: [PATCH 1/6] feat(search-attributes): add attribute list as default behaviour --- .../tools/catalog/search-attributes.test.ts | 267 +++++++++++++++--- .../src/tools/catalog/search-attributes.ts | 29 +- .../tools/support/search-attributes/format.ts | 118 ++++++++ .../tools/support/search-attributes/search.ts | 71 ++++- ...s-attributes-logs-number-with-context.json | 2 + ...s-attributes-logs-string-with-context.json | 2 + ...-attributes-spans-number-with-context.json | 4 + ...-attributes-spans-string-with-context.json | 2 + packages/mcp-server-mocks/src/index.ts | 8 +- 9 files changed, 428 insertions(+), 75 deletions(-) create mode 100644 packages/mcp-core/src/tools/support/search-attributes/format.ts diff --git a/packages/mcp-core/src/tools/catalog/search-attributes.test.ts b/packages/mcp-core/src/tools/catalog/search-attributes.test.ts index 88a0aa166..fbe461857 100644 --- a/packages/mcp-core/src/tools/catalog/search-attributes.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-attributes.test.ts @@ -1,8 +1,47 @@ -import { describe, expect, it } from "vitest"; +import { mswServer } from "@sentry/mcp-server-mocks"; +import { APICallError, generateText } from "ai"; +import { http, HttpResponse } from "msw"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; +import { ApiNotFoundError } from "../../api-client"; import { getServerContext } from "../../test-setup.js"; import searchAttributes from "./search-attributes.js"; +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(), + Output: { object: vi.fn(() => ({})) }, + }; +}); + +const ATTRIBUTES_URL = + "https://sentry.io/api/0/organizations/sentry-mcp-evals/trace-items/attributes/"; + +const mockGenerateText = vi.mocked(generateText); + +function mockAgentResponse(output: { + best: string | null; + candidates: string[]; +}) { + return { + text: JSON.stringify(output), + experimental_output: output, + finishReason: "stop" as const, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + warnings: [] as const, + } as any; +} + function search(params: Record) { return searchAttributes.handler( z.object(searchAttributes.inputSchema).parse({ @@ -14,58 +53,200 @@ function search(params: Record) { } 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 - " - `); + beforeEach(() => { + vi.clearAllMocks(); + // Pin the provider so the local .env can't change which path runs. + vi.stubEnv("EMBEDDED_AGENT_PROVIDER", "openai"); + vi.stubEnv("OPENAI_API_KEY", "test-key"); + vi.stubEnv("ANTHROPIC_API_KEY", ""); + vi.stubEnv("OPENROUTER_API_KEY", ""); }); - it("resolves the project and passes the remaining inputs through", async () => { + afterEach(() => { + vi.unstubAllEnvs(); + mswServer.resetHandlers(); + }); + + it("asks for a dataset when none is given", async () => { + await expect(search({})).rejects.toThrowErrorMatchingInlineSnapshot( + `[UserInputError: Choose a dataset: \`spans\` (requests, latency, database and AI calls), \`logs\` (log lines), or \`metrics\` (counters, gauges, distributions).]`, + ); + }); + + it("lists the dataset's attributes when there's a query because searching is not supported yet", async () => { expect( - await search({ - projectSlug: "cloudflare-mcp", - query: " payment provider ", - dataset: "metrics", - period: "7d", - limit: 50, - }), + await search({ dataset: "spans", query: "endpoint status response" }), ).toMatchInlineSnapshot(` - "# Attribute Search in **sentry-mcp-evals** + "Matching by \`query\` is not available yet. Here are all of the dataset's attributes instead. + # Span Attributes in **sentry-mcp-evals** (last 14d) + + | Attribute | Type | Description | Added by Sentry | Deprecated | Replacement Attribute | + | --- | --- | --- | --- | --- | --- | + | \`browser.name\` | string | | yes | | | + | \`custom.count\` | number | | | | | + | \`custom.db.pool_size\` | number | | | | | + | \`custom.feature_flag\` | string | | | | | + | \`custom.latency_ms\` | number | | | | | + | \`custom.score\` | number | | | | | + | \`custom.tier\` | string | | | | | + | \`device\` | string | | yes | | | + | \`device.memory\` | number | The estimated total memory capacity of the device. | yes | | | + | \`deviceMemory\` | number | | yes | | | + | \`environment\` | string | The sentry environment. | yes | yes | sentry.environment | + | \`geo.city\` | string | The city of the user's location. | yes | | | + | \`geo.country_code\` | string | The country code of the user's location. | yes | | | + | \`geo.region\` | string | The region of the user's location. | yes | yes | user.geo.region | + | \`http.method\` | string | The HTTP method used. | yes | yes | http.request.method | + | \`http.request.method\` | string | The HTTP method used. | yes | | | + | \`http.status_code\` | number | The status code of the HTTP response. | yes | yes | http.response.status_code | + | \`http.url\` | string | The URL of the resource that was fetched. | yes | yes | url.full | + | \`kiosk.firmware\` | string | The firmware revision currently installed and running on the kiosk. | yes | | | + | \`kiosk.firmware_revision\` | string | The firmware revision downloaded and staged for the kiosk's next update. It is not installed until the kiosk restarts. | yes | | | + | \`os.name\` | string | Human readable operating system name. | yes | | | + | \`platform\` | string | The sdk platform that generated the event. | yes | | | + | \`project\` | string | The name of the project. In some pages of sentry.io, you can also filter on project using a dropdown. | yes | | | + | \`release\` | string | The sentry release. | yes | yes | sentry.release | + | \`sdk.name\` | string | The sentry sdk name. | yes | | | + | \`sdk.version\` | string | The sentry sdk version. | yes | | | + | \`span.description\` | string | Description of the span's operation. | yes | | | + | \`span.duration\` | number | The total time taken by the span. | yes | | | + | \`span.op\` | string | The operation of a span. | yes | | | + | \`span.status\` | string | The span's status (either "ok" or "error"). Older SDKs may set this to a more specific error, but this behaviour is dep… | yes | | | + | \`transaction\` | string | The sentry transaction (segment name). | yes | yes | sentry.segment.name | + | \`transaction.duration\` | number | | yes | | | + | \`transaction.op\` | string | Operation of the containing transaction. | yes | | | + | \`transaction.status\` | string | | yes | | | + | \`user.email\` | string | | yes | | | + | \`user.id\` | string | | yes | | | + | \`user.username\` | string | | yes | | | - Not implemented yet. Parsed inputs: + ## Response Notes - - Dataset: metrics (API item type: tracemetrics) - - Project: cloudflare-mcp (ID 4509109104082945) - - Query: payment provider - - Period: 7d - - Limit: 50 - " + - Use a key with \`search_traces\` to query its values." `); + + expect(mockGenerateText).not.toHaveBeenCalled(); }); - 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("lists the dataset's attributes when there's no query", async () => { + expect(await search({ dataset: "metrics" })).toMatchInlineSnapshot(` + + "# Metric Attributes in **sentry-mcp-evals** (last 14d) + + | Attribute | Type | Description | Added by Sentry | Deprecated | Replacement Attribute | + | --- | --- | --- | --- | --- | --- | + | \`environment\` | string | The sentry environment. | yes | yes | sentry.environment | + | \`fleet.deployment_region\` | string | The region the server is scheduled to move to in the next fleet deployment. It is not where the server runs now. | yes | | | + | \`fleet.region\` | string | The region the server is currently running in. | yes | | | + | \`http.method\` | string | The HTTP method used. | yes | yes | http.request.method | + | \`metric.name\` | string | The name of the metric. | yes | | | + | \`metric.type\` | string | The type of the metric (counter, gauge, distribution). | yes | | | + | \`metric.unit\` | string | The unit of the metric value. | yes | | | + | \`observed_timestamp\` | number | When Sentry received the metric sample. | yes | | | + | \`payload_size\` | number | | yes | | | + | \`project\` | string | The name of the project. In some pages of sentry.io, you can also filter on project using a dropdown. | yes | | | + | \`timestamp_precise\` | number | When the metric sample was recorded, with nanosecond precision. | yes | | | + | \`transaction\` | string | The sentry transaction (segment name). | yes | yes | sentry.segment.name | + | \`value\` | number | The recorded value of the metric sample. | yes | | | + + ## Response Notes + + - Use a key with \`search_metrics\` to query its values." `); + + expect(mockGenerateText).not.toHaveBeenCalled(); + }); + + it("says how many are shown when the list is cut off", async () => { + const output = await search({ dataset: "spans", limit: 5 }); + + expect(output).toContain( + "- Showing 5 of 37. Pass a `query` to find specific attributes.", + ); + }); + + it("treats a blank query as no query", async () => { + const output = await search({ query: " ", dataset: "logs" }); + + expect(output).toMatch(/^# Log Attributes in \*\*sentry-mcp-evals\*\*/); + expect(mockGenerateText).not.toHaveBeenCalled(); + }); + + it("lists attributes with a note when no AI provider is set up", async () => { + vi.stubEnv("OPENAI_API_KEY", ""); + + const output = await search({ + query: "HTTP status code", + dataset: "spans", + }); + + expect(output).toMatch( + /^Matching by `query` needs an AI provider, so these are the dataset's attributes instead./, + ); + expect(output).toContain("# Span Attributes in **sentry-mcp-evals**"); + expect(mockGenerateText).not.toHaveBeenCalled(); + }); + + // This isn't quite a case yet but i'm leaving it here so we don't forget about this case + it("lists attributes with a note when the AI provider fails", async () => { + mockGenerateText.mockRejectedValue( + new APICallError({ + message: "Workspace budget exceeded", + url: "https://api.openai.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 402, + isRetryable: false, + }), + ); + + const output = await search({ + query: "HTTP status code", + dataset: "spans", + }); + + expect(output).toMatch( + /^Matching by `query` is not available yet. Here are all of the dataset's attributes instead./, + ); + expect(output).toContain("# Span Attributes in **sentry-mcp-evals**"); + }); + + it("passes the project ID, period, and context flag to the attributes API", async () => { + let requestUrl: URL | undefined; + mswServer.use( + http.get(ATTRIBUTES_URL, ({ request }) => { + requestUrl = new URL(request.url); + return HttpResponse.json([]); + }), + ); + + const output = await search({ + projectSlug: "cloudflare-mcp", + query: "payment provider", + dataset: "metrics", + period: "7d", + }); + + expect(Object.fromEntries(requestUrl?.searchParams ?? [])).toEqual({ + itemType: "tracemetrics", + project: "4509109104082945", + statsPeriod: "7d", + expand: "context", + }); + expect(output).toContain("No metric attributes were seen in the last 7d."); + // Nothing to match, so the agent isn't called. + expect(mockGenerateText).not.toHaveBeenCalled(); + }); + + it("surfaces a 404 from the attributes API", async () => { + mswServer.use( + http.get(ATTRIBUTES_URL, () => + HttpResponse.json({ detail: "Not found" }, { status: 404 }), + ), + ); + + await expect(search({ dataset: "spans" })).rejects.toThrow( + ApiNotFoundError, + ); }); it("rejects datasets other than spans, logs, and metrics", () => { diff --git a/packages/mcp-core/src/tools/catalog/search-attributes.ts b/packages/mcp-core/src/tools/catalog/search-attributes.ts index ec17941d5..ce9897404 100644 --- a/packages/mcp-core/src/tools/catalog/search-attributes.ts +++ b/packages/mcp-core/src/tools/catalog/search-attributes.ts @@ -20,30 +20,25 @@ export default defineTool({ 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.", + "Find span, log, or metric attribute keys in Sentry: which attributes exist, their types, short descriptions, and whether they're deprecated.", "", - // 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.", + "Use before search_traces, search_logs, or search_metrics when you don't know the exact attribute key, or to check whether an attribute is still being sent.", "", - "`query` is natural language (preferred) or part of an attribute name; a configured agent turns it into an attribute-name search.", + "`query` is plain English or part of a key. Omit it to list the dataset's attributes.", "", "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')", + "search_attributes(organizationSlug='my-org', query='HTTP status code', dataset='spans')", + "search_attributes(organizationSlug='my-org', query='are we still sending the deprecated HTTP method attribute', dataset='spans')", + "search_attributes(organizationSlug='my-org', projectSlug='backend', query='request ID that ties log lines to a trace', dataset='logs')", + "search_attributes(organizationSlug='my-org', query='payment provider', dataset='metrics', period='7d')", + "search_attributes(organizationSlug='my-org', dataset='spans')", "", "", "", "- 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"), @@ -55,7 +50,7 @@ export default defineTool({ .trim() .optional() .describe( - "What attribute to find, in natural language or as part of an attribute name. Omit to list attributes in the dataset.", + "What attribute to find, in plain English or as part of a key. Omit to list the dataset's attributes.", ), period: ParamPeriod.optional(), regionUrl: ParamRegionUrl.nullable().default(null), @@ -63,8 +58,10 @@ export default defineTool({ .number() .min(1) .max(100) - .default(10) - .describe("Maximum number of attributes to return (1-100)"), + .optional() + .describe( + "Maximum number of attributes to return (1-100). Defaults to 10 with a `query` and 50 when listing.", + ), dataset: z .enum(SEARCH_ATTRIBUTES_DATASETS) .optional() diff --git a/packages/mcp-core/src/tools/support/search-attributes/format.ts b/packages/mcp-core/src/tools/support/search-attributes/format.ts new file mode 100644 index 000000000..231fed4d6 --- /dev/null +++ b/packages/mcp-core/src/tools/support/search-attributes/format.ts @@ -0,0 +1,118 @@ +import type { TraceItemAttribute } from "../../../api-client"; +import type { SearchAttributesDataset } from "./search"; + +const DATASET_LABELS: Record< + SearchAttributesDataset, + { title: string; associatedTool: string } +> = { + spans: { + title: "Span Attributes", + associatedTool: "search_traces", + }, + logs: { + title: "Log Attributes", + associatedTool: "search_logs", + }, + metrics: { + title: "Metric Attributes", + associatedTool: "search_metrics", + }, +}; + +const MAX_DESCRIPTION_LENGTH = 120; + +interface QuerySettings { + organizationSlug: string; + dataset: SearchAttributesDataset; + period: string; +} + +export function formatListResult({ + organizationSlug, + dataset, + period, + attributes, + limit, + notes = [], + importantInfo = [], +}: QuerySettings & { + attributes: TraceItemAttribute[]; + limit: number; + notes?: string[]; + importantInfo?: string[]; +}): string { + const labels = DATASET_LABELS[dataset]; + const lines: string[] = []; + + if (importantInfo) { + importantInfo.map((info) => lines.push(info)); + } + + lines.push( + `# ${labels.title} in **${organizationSlug}** (last ${period})`, + "", + ); + if (attributes.length === 0) { + return formatNoneSeen(lines, labels.title.toLowerCase(), period); + } + + // Sort by key so related attributes (http.*, db.*) stay together. + const sorted = [...attributes].sort((a, b) => a.key.localeCompare(b.key)); + const shown = sorted.slice(0, limit); + + lines.push(...formatAttributeTable(shown), "", "## Response Notes", ""); + if (shown.length < sorted.length) { + lines.push( + `- Showing ${shown.length} of ${sorted.length}. Pass a \`query\` to find specific attributes.`, + ); + } + lines.push( + `- Use a key with \`${labels.associatedTool}\` to query its values.`, + ...notes.map((note) => `- ${note}`), + ); + return lines.join("\n"); +} + +function formatNoneSeen(lines: string[], noun: string, period: string): string { + lines.push( + `No ${noun} were seen in the last ${period}.`, + "", + "## Response Notes", + "", + "- Try a longer `period` or a different `dataset`.", + ); + return lines.join("\n"); +} + +function formatAttributeTable(attributes: TraceItemAttribute[]): string[] { + return [ + "| Attribute | Type | Description | Added by Sentry | Deprecated | Replacement Attribute |", + "| --- | --- | --- | --- | --- | --- |", + ...attributes.map( + (attribute) => + `| \`${escapeCell(attribute.key)}\` | ${attribute.type} | ${escapeCell(describeCell(attribute))} | ${attribute.attributeSource.source_type === "sentry" ? "yes" : ""} | ${attribute.context?.isDeprecated ? "yes" : ""} | ${escapeCell(attribute.context?.replacementAttribute ?? "")} |`, + ), + ]; +} + +/** Blank when there's no description */ +function describeCell({ context }: TraceItemAttribute): string { + const brief = flatten(context?.brief); + if (brief) { + return truncate(brief, MAX_DESCRIPTION_LENGTH); + } else return ""; +} + +function flatten(text: string | undefined): string { + return text?.replace(/\s+/g, " ").trim() ?? ""; +} + +function truncate(text: string, maxLength: number): string { + return text.length <= maxLength + ? text + : `${text.slice(0, maxLength - 1).trimEnd()}…`; +} + +function escapeCell(text: string): string { + return text.replaceAll("|", "\\|"); +} diff --git a/packages/mcp-core/src/tools/support/search-attributes/search.ts b/packages/mcp-core/src/tools/support/search-attributes/search.ts index 42fc333d7..b530ff7ae 100644 --- a/packages/mcp-core/src/tools/support/search-attributes/search.ts +++ b/packages/mcp-core/src/tools/support/search-attributes/search.ts @@ -1,7 +1,12 @@ -import type { TraceItemType } from "../../../api-client"; +import type { Tag, TraceItemAttribute } from "../../../api-client"; +import { UserInputError } from "../../../errors"; +import { hasAgentProvider } from "../../../internal/agents/provider-factory"; +import { withProviderFallback } from "../../../internal/agents/provider-fallback"; import { apiServiceFromContext } from "../../../internal/tool-helpers/api"; import { setTargetTagsAndAttributes } from "../../../telem/scope"; import type { ServerContext } from "../../../types"; +import { fetchCustomAttributes } from "../search-events/utils"; +import { formatListResult } from "./format"; export const SEARCH_ATTRIBUTES_DATASETS = ["spans", "logs", "metrics"] as const; @@ -14,10 +19,13 @@ export interface SearchAttributesParams { query?: string; period?: string; regionUrl: string | null; - limit: number; + limit?: number; dataset?: SearchAttributesDataset; } +const DEFAULT_PERIOD = "14d"; +const LIST_LIMIT = 50; + /** * Handler for search_attributes. */ @@ -32,6 +40,12 @@ export async function runSearchAttributes( setTargetTagsAndAttributes(params); + if (!params.dataset) { + throw new UserInputError( + "Choose a dataset: `spans` (requests, latency, database and AI calls), `logs` (log lines), or `metrics` (counters, gauges, distributions).", + ); + } + // The attributes API filters by numeric project ID, not slug. let projectId: string | undefined; if (params.projectSlug) { @@ -42,19 +56,48 @@ export async function runSearchAttributes( 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; + const period = params.period ?? DEFAULT_PERIOD; + + const { attributes } = await fetchCustomAttributes( + apiService, + organizationSlug, + params.dataset, + projectId, + { statsPeriod: period }, + { context: true }, + ); + const rows = Object.values(attributes) as TraceItemAttribute[]; + + const dataset = params.dataset; + const listAttributes = (importantInfo?: string[], notes?: string[]) => + formatListResult({ + organizationSlug, + dataset, + period, + attributes: rows, + limit: params.limit ?? LIST_LIMIT, + notes, + importantInfo, + }); - // 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`; + if (!hasAgentProvider()) { + return listAttributes( + [ + "Matching by `query` needs an AI provider, so these are the dataset's attributes instead.", + ], + [], + ); + } + + if (query) { + return listAttributes( + [ + "Matching by `query` is not available yet. Here are all of the dataset's attributes instead.", + ], + [], + ); + } - return output; + return listAttributes(); } diff --git a/packages/mcp-server-mocks/src/fixtures/trace-items-attributes-logs-number-with-context.json b/packages/mcp-server-mocks/src/fixtures/trace-items-attributes-logs-number-with-context.json index fc0967f3f..2fea7ca81 100644 --- a/packages/mcp-server-mocks/src/fixtures/trace-items-attributes-logs-number-with-context.json +++ b/packages/mcp-server-mocks/src/fixtures/trace-items-attributes-logs-number-with-context.json @@ -30,11 +30,13 @@ { "key": "custom.duration", "name": "custom.duration", + "attributeSource": { "source_type": "user" }, "context": {} }, { "key": "custom.bytes", "name": "custom.bytes", + "attributeSource": { "source_type": "user" }, "context": {} }, { diff --git a/packages/mcp-server-mocks/src/fixtures/trace-items-attributes-logs-string-with-context.json b/packages/mcp-server-mocks/src/fixtures/trace-items-attributes-logs-string-with-context.json index 7cb422d0e..ca0604748 100644 --- a/packages/mcp-server-mocks/src/fixtures/trace-items-attributes-logs-string-with-context.json +++ b/packages/mcp-server-mocks/src/fixtures/trace-items-attributes-logs-string-with-context.json @@ -85,11 +85,13 @@ { "key": "custom.service", "name": "custom.service", + "attributeSource": { "source_type": "user" }, "context": {} }, { "key": "custom.component", "name": "custom.component", + "attributeSource": { "source_type": "user" }, "context": {} }, { diff --git a/packages/mcp-server-mocks/src/fixtures/trace-items-attributes-spans-number-with-context.json b/packages/mcp-server-mocks/src/fixtures/trace-items-attributes-spans-number-with-context.json index 3dbc9f34d..c67f6253c 100644 --- a/packages/mcp-server-mocks/src/fixtures/trace-items-attributes-spans-number-with-context.json +++ b/packages/mcp-server-mocks/src/fixtures/trace-items-attributes-spans-number-with-context.json @@ -27,21 +27,25 @@ { "key": "custom.count", "name": "custom.count", + "attributeSource": { "source_type": "user" }, "context": {} }, { "key": "custom.score", "name": "custom.score", + "attributeSource": { "source_type": "user" }, "context": {} }, { "key": "custom.latency_ms", "name": "custom.latency_ms", + "attributeSource": { "source_type": "user" }, "context": {} }, { "key": "custom.db.pool_size", "name": "custom.db.pool_size", + "attributeSource": { "source_type": "user" }, "context": {} }, { diff --git a/packages/mcp-server-mocks/src/fixtures/trace-items-attributes-spans-string-with-context.json b/packages/mcp-server-mocks/src/fixtures/trace-items-attributes-spans-string-with-context.json index 7a0282a9c..a2cb1d84b 100644 --- a/packages/mcp-server-mocks/src/fixtures/trace-items-attributes-spans-string-with-context.json +++ b/packages/mcp-server-mocks/src/fixtures/trace-items-attributes-spans-string-with-context.json @@ -249,11 +249,13 @@ { "key": "custom.tier", "name": "custom.tier", + "attributeSource": {"source_type": "user"}, "context": {} }, { "key": "custom.feature_flag", "name": "custom.feature_flag", + "attributeSource": {"source_type": "user"}, "context": {} }, { diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index ceff40cbe..578de6bed 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -142,13 +142,17 @@ function buildHandlers( } function withTraceItemAttributeMetadata( - attributes: readonly { key: string; name: string }[], + attributes: readonly { + key: string; + name: string; + attributeSource?: { source_type: string }; + }[], attributeType: "string" | "number" | "boolean", ) { return attributes.map((attribute) => ({ + attributeSource: { source_type: "sentry" as const }, ...attribute, attributeType, - attributeSource: { source_type: "sentry" as const }, })); } From 895cb0910d2399431b461b692e99d246940168bf Mon Sep 17 00:00:00 2001 From: nikkikapadia Date: Thu, 5 Nov 2026 11:11:25 -0500 Subject: [PATCH 2/6] address bot comments and make styling adjustments to mcp return --- .../tools/catalog/search-attributes.test.ts | 129 ++++++++++++------ .../src/tools/catalog/search-attributes.ts | 53 ++++--- .../tools/support/search-attributes/format.ts | 45 +++++- .../tools/support/search-attributes/search.ts | 17 ++- 4 files changed, 157 insertions(+), 87 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/search-attributes.test.ts b/packages/mcp-core/src/tools/catalog/search-attributes.test.ts index fbe461857..8b804b7e6 100644 --- a/packages/mcp-core/src/tools/catalog/search-attributes.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-attributes.test.ts @@ -80,8 +80,31 @@ describe("search_attributes", () => { "Matching by \`query\` is not available yet. Here are all of the dataset's attributes instead. # Span Attributes in **sentry-mcp-evals** (last 14d) + **Suggested presentation:** A table works well here, with each attribute's type, description, deprecation status, replacement attributes, and whether it's added by Sentry. + + Please tell the user each attribute's type, description, examples, whether they were added by sentry, and which attributes are deprecated and what replaces them. + | Attribute | Type | Description | Added by Sentry | Deprecated | Replacement Attribute | | --- | --- | --- | --- | --- | --- | + | \`environment\` | string | The sentry environment.. EXAMPLE VALUES: production | yes | yes | sentry.environment | + | \`http.method\` | string | The HTTP method used.. EXAMPLE VALUES: GET | yes | yes | http.request.method | + | \`http.status_code\` | number | The status code of the HTTP response.. EXAMPLE VALUES: 404 | yes | yes | http.response.status_code | + | \`http.url\` | string | The URL of the resource that was fetched.. EXAMPLE VALUES: https://example.com/test?foo=bar#buzz | yes | yes | url.full | + | \`platform\` | string | The sdk platform that generated the event.. EXAMPLE VALUES: php | yes | | | + | \`project\` | string | The name of the project. In some pages of sentry.io, you can also filter on project using a dropdown.. | yes | | | + | \`release\` | string | The sentry release.. EXAMPLE VALUES: production | yes | yes | sentry.release | + | \`sdk.name\` | string | The sentry sdk name.. EXAMPLE VALUES: @sentry/react | yes | | | + | \`sdk.version\` | string | The sentry sdk version.. EXAMPLE VALUES: 7.0.0 | yes | | | + | \`span.description\` | string | Description of the span's operation.. | yes | | | + | \`span.duration\` | number | The total time taken by the span.. | yes | | | + | \`span.op\` | string | The operation of a span.. EXAMPLE VALUES: http.client | yes | | | + | \`span.status\` | string | The span's status (either "ok" or "error"). Older SDKs may set this to a more specific error, but this behaviour is deprecated.. EXAMPLE VALUES: ok | yes | | | + | \`transaction\` | string | The sentry transaction (segment name).. EXAMPLE VALUES: GET / | yes | yes | sentry.segment.name | + | \`transaction.duration\` | number | | yes | | | + | \`transaction.op\` | string | Operation of the containing transaction.. | yes | | | + | \`transaction.status\` | string | | yes | | | + | \`user.email\` | string | | yes | | | + | \`user.id\` | string | | yes | | | | \`browser.name\` | string | | yes | | | | \`custom.count\` | number | | | | | | \`custom.db.pool_size\` | number | | | | | @@ -90,34 +113,15 @@ describe("search_attributes", () => { | \`custom.score\` | number | | | | | | \`custom.tier\` | string | | | | | | \`device\` | string | | yes | | | - | \`device.memory\` | number | The estimated total memory capacity of the device. | yes | | | + | \`device.memory\` | number | The estimated total memory capacity of the device.. | yes | | | | \`deviceMemory\` | number | | yes | | | - | \`environment\` | string | The sentry environment. | yes | yes | sentry.environment | - | \`geo.city\` | string | The city of the user's location. | yes | | | - | \`geo.country_code\` | string | The country code of the user's location. | yes | | | - | \`geo.region\` | string | The region of the user's location. | yes | yes | user.geo.region | - | \`http.method\` | string | The HTTP method used. | yes | yes | http.request.method | - | \`http.request.method\` | string | The HTTP method used. | yes | | | - | \`http.status_code\` | number | The status code of the HTTP response. | yes | yes | http.response.status_code | - | \`http.url\` | string | The URL of the resource that was fetched. | yes | yes | url.full | - | \`kiosk.firmware\` | string | The firmware revision currently installed and running on the kiosk. | yes | | | - | \`kiosk.firmware_revision\` | string | The firmware revision downloaded and staged for the kiosk's next update. It is not installed until the kiosk restarts. | yes | | | - | \`os.name\` | string | Human readable operating system name. | yes | | | - | \`platform\` | string | The sdk platform that generated the event. | yes | | | - | \`project\` | string | The name of the project. In some pages of sentry.io, you can also filter on project using a dropdown. | yes | | | - | \`release\` | string | The sentry release. | yes | yes | sentry.release | - | \`sdk.name\` | string | The sentry sdk name. | yes | | | - | \`sdk.version\` | string | The sentry sdk version. | yes | | | - | \`span.description\` | string | Description of the span's operation. | yes | | | - | \`span.duration\` | number | The total time taken by the span. | yes | | | - | \`span.op\` | string | The operation of a span. | yes | | | - | \`span.status\` | string | The span's status (either "ok" or "error"). Older SDKs may set this to a more specific error, but this behaviour is dep… | yes | | | - | \`transaction\` | string | The sentry transaction (segment name). | yes | yes | sentry.segment.name | - | \`transaction.duration\` | number | | yes | | | - | \`transaction.op\` | string | Operation of the containing transaction. | yes | | | - | \`transaction.status\` | string | | yes | | | - | \`user.email\` | string | | yes | | | - | \`user.id\` | string | | yes | | | + | \`geo.city\` | string | The city of the user's location.. EXAMPLE VALUES: Toronto | yes | | | + | \`geo.country_code\` | string | The country code of the user's location.. EXAMPLE VALUES: US | yes | | | + | \`geo.region\` | string | The region of the user's location.. EXAMPLE VALUES: Canada | yes | yes | user.geo.region | + | \`http.request.method\` | string | The HTTP method used.. EXAMPLE VALUES: GET | yes | | | + | \`kiosk.firmware\` | string | The firmware revision currently installed and running on the kiosk.. EXAMPLE VALUES: 4.1.3 | yes | | | + | \`kiosk.firmware_revision\` | string | The firmware revision downloaded and staged for the kiosk's next update. It is not installed until the kiosk restarts.. EXAMPLE VALUES: 4.2.0 | yes | | | + | \`os.name\` | string | Human readable operating system name.. EXAMPLE VALUES: Ubuntu | yes | | | | \`user.username\` | string | | yes | | | ## Response Notes @@ -130,29 +134,32 @@ describe("search_attributes", () => { it("lists the dataset's attributes when there's no query", async () => { expect(await search({ dataset: "metrics" })).toMatchInlineSnapshot(` - "# Metric Attributes in **sentry-mcp-evals** (last 14d) + **Suggested presentation:** A table works well here, with each attribute's type, description, deprecation status, replacement attributes, and whether it's added by Sentry. + + Please tell the user each attribute's type, description, examples, whether they were added by sentry, and which attributes are deprecated and what replaces them. + | Attribute | Type | Description | Added by Sentry | Deprecated | Replacement Attribute | | --- | --- | --- | --- | --- | --- | - | \`environment\` | string | The sentry environment. | yes | yes | sentry.environment | - | \`fleet.deployment_region\` | string | The region the server is scheduled to move to in the next fleet deployment. It is not where the server runs now. | yes | | | - | \`fleet.region\` | string | The region the server is currently running in. | yes | | | - | \`http.method\` | string | The HTTP method used. | yes | yes | http.request.method | - | \`metric.name\` | string | The name of the metric. | yes | | | - | \`metric.type\` | string | The type of the metric (counter, gauge, distribution). | yes | | | - | \`metric.unit\` | string | The unit of the metric value. | yes | | | - | \`observed_timestamp\` | number | When Sentry received the metric sample. | yes | | | + | \`environment\` | string | The sentry environment.. EXAMPLE VALUES: production | yes | yes | sentry.environment | + | \`metric.name\` | string | The name of the metric.. EXAMPLE VALUES: http.request.duration | yes | | | + | \`metric.type\` | string | The type of the metric (counter, gauge, distribution).. EXAMPLE VALUES: distribution | yes | | | + | \`metric.unit\` | string | The unit of the metric value.. EXAMPLE VALUES: millisecond | yes | | | + | \`observed_timestamp\` | number | When Sentry received the metric sample.. | yes | | | + | \`project\` | string | The name of the project. In some pages of sentry.io, you can also filter on project using a dropdown.. | yes | | | + | \`timestamp_precise\` | number | When the metric sample was recorded, with nanosecond precision.. | yes | | | + | \`value\` | number | The recorded value of the metric sample.. | yes | | | + | \`fleet.deployment_region\` | string | The region the server is scheduled to move to in the next fleet deployment. It is not where the server runs now.. EXAMPLE VALUES: eu-west-1 | yes | | | + | \`fleet.region\` | string | The region the server is currently running in.. EXAMPLE VALUES: us-east-1 | yes | | | + | \`http.method\` | string | The HTTP method used.. EXAMPLE VALUES: GET | yes | yes | http.request.method | | \`payload_size\` | number | | yes | | | - | \`project\` | string | The name of the project. In some pages of sentry.io, you can also filter on project using a dropdown. | yes | | | - | \`timestamp_precise\` | number | When the metric sample was recorded, with nanosecond precision. | yes | | | - | \`transaction\` | string | The sentry transaction (segment name). | yes | yes | sentry.segment.name | - | \`value\` | number | The recorded value of the metric sample. | yes | | | + | \`transaction\` | string | The sentry transaction (segment name).. EXAMPLE VALUES: GET / | yes | yes | sentry.segment.name | ## Response Notes - Use a key with \`search_metrics\` to query its values." - `); + `); expect(mockGenerateText).not.toHaveBeenCalled(); }); @@ -160,9 +167,45 @@ describe("search_attributes", () => { it("says how many are shown when the list is cut off", async () => { const output = await search({ dataset: "spans", limit: 5 }); - expect(output).toContain( - "- Showing 5 of 37. Pass a `query` to find specific attributes.", + expect(output).toContain("- Showing 5 of 37."); + }); + + it("lists recommended keys first", async () => { + const attribute = (key: string, source: "sentry" | "user" = "sentry") => ({ + key, + name: key, + attributeType: "string", + attributeSource: { source_type: source }, + }); + mswServer.use( + http.get(ATTRIBUTES_URL, () => + HttpResponse.json([ + attribute("app.tier", "user"), + attribute("environment"), + attribute("http.method"), + attribute("span.op"), + attribute("browser.name"), + attribute("project"), + ]), + ), ); + + const output = await search({ dataset: "spans" }); + const keys = [...output.matchAll(/^\| `([^`]+)` \|/gm)].map( + ([, key]) => key, + ); + + expect(keys).toEqual([ + // Search events' fields: environment is a common field, http.method a + // span field, and project and span.op are recommended. + "environment", + "http.method", + "project", + "span.op", + // Everything else + "app.tier", + "browser.name", + ]); }); it("treats a blank query as no query", async () => { diff --git a/packages/mcp-core/src/tools/catalog/search-attributes.ts b/packages/mcp-core/src/tools/catalog/search-attributes.ts index ce9897404..1c7206379 100644 --- a/packages/mcp-core/src/tools/catalog/search-attributes.ts +++ b/packages/mcp-core/src/tools/catalog/search-attributes.ts @@ -20,54 +20,51 @@ export default defineTool({ 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.", + // TODO(nikki): "Find span, log, or metric attribute keys in Sentry: which attributes exist, their types, short descriptions, and whether they're deprecated.", + "List span, log, or metric attribute keys in Sentry: which attributes exist, their types, short descriptions, examples, and whether they're deprecated.", "", "Use before search_traces, search_logs, or search_metrics when you don't know the exact attribute key, or to check whether an attribute is still being sent.", "", - "`query` is plain English or part of a key. Omit it to list the dataset's attributes.", + // TODO(nikki): "`query` is plain English or part of a key. Omit it to list the dataset's attributes.", + "Lists the dataset's attributes seen in `period`, with Sentry's common search fields first. Searching by `query` is not available yet, so a `query` returns the same list.", "", "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='HTTP status code', dataset='spans')", - "search_attributes(organizationSlug='my-org', query='are we still sending the deprecated HTTP method attribute', dataset='spans')", - "search_attributes(organizationSlug='my-org', projectSlug='backend', query='request ID that ties log lines to a trace', dataset='logs')", - "search_attributes(organizationSlug='my-org', query='payment provider', dataset='metrics', period='7d')", + // TODO(nikki): Restore these query examples when search lands: + // "search_attributes(organizationSlug='my-org', query='HTTP status code', dataset='spans')", + // "search_attributes(organizationSlug='my-org', query='are we still sending the deprecated HTTP method attribute', dataset='spans')", + // "search_attributes(organizationSlug='my-org', projectSlug='backend', query='request ID that ties log lines to a trace', dataset='logs')", + // "search_attributes(organizationSlug='my-org', query='payment provider', dataset='metrics', period='7d')", "search_attributes(organizationSlug='my-org', dataset='spans')", + "search_attributes(organizationSlug='my-org', projectSlug='backend', dataset='logs')", + "search_attributes(organizationSlug='my-org', 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.", - "- Pass a returned key to the matching search tool to query its values.", + // TODO(nikki): "- Pass a returned key to the matching search tool to query its values.", + "- Show the user each returned attribute's type, description, examples, deprecation status and if they were added by Sentry. Don't reduce the result to a list of names.", "", ].join("\n"), inputSchema: { organizationSlug: ParamOrganizationSlug, projectSlug: ParamProjectSlug.nullable().default(null), - query: z - .string() - .trim() - .optional() - .describe( - "What attribute to find, in plain English or as part of a key. Omit to list the dataset's attributes.", - ), + query: z.string().trim().optional().describe( + // TODO(nikki): "What attribute to find, in plain English or as part of a key. Omit to list the dataset's attributes.", + "Not used yet. Searching by query is not available, so the dataset's attributes are listed either way. Omit it.", + ), period: ParamPeriod.optional(), regionUrl: ParamRegionUrl.nullable().default(null), - limit: z - .number() - .min(1) - .max(100) - .optional() - .describe( - "Maximum number of attributes to return (1-100). Defaults to 10 with a `query` and 50 when listing.", - ), - 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.", - ), + limit: z.number().min(1).max(100).optional().describe( + // TODO(nikki): "Maximum number of attributes to return (1-100). Defaults to 10 with a `query` and 50 when listing.", + "Maximum number of attributes to list (1-100). Defaults to 50.", + ), + dataset: z.enum(SEARCH_ATTRIBUTES_DATASETS).optional().describe( + // TODO(nikki): "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.", + "Which dataset's attributes to list: 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/format.ts b/packages/mcp-core/src/tools/support/search-attributes/format.ts index 231fed4d6..6b69e45d2 100644 --- a/packages/mcp-core/src/tools/support/search-attributes/format.ts +++ b/packages/mcp-core/src/tools/support/search-attributes/format.ts @@ -1,4 +1,10 @@ import type { TraceItemAttribute } from "../../../api-client"; +import { normalizeEventsDataset } from "../../../utils/events-datasets"; +import { + BASE_COMMON_FIELDS, + DATASET_FIELDS, + RECOMMENDED_FIELDS, +} from "../search-events/config"; import type { SearchAttributesDataset } from "./search"; const DATASET_LABELS: Record< @@ -19,7 +25,7 @@ const DATASET_LABELS: Record< }, }; -const MAX_DESCRIPTION_LENGTH = 120; +const MAX_DESCRIPTION_LENGTH = 255; interface QuerySettings { organizationSlug: string; @@ -56,15 +62,19 @@ export function formatListResult({ return formatNoneSeen(lines, labels.title.toLowerCase(), period); } - // Sort by key so related attributes (http.*, db.*) stay together. - const sorted = [...attributes].sort((a, b) => a.key.localeCompare(b.key)); + lines.push( + "**Suggested presentation:** A table works well here, with each attribute's type, description, deprecation status, replacement attributes, and whether it's added by Sentry.", + "", + "Please tell the user each attribute's type, description, examples, whether they were added by sentry, and which attributes are deprecated and what replaces them.", + "", + ); + + const sorted = sortAttributes(attributes, dataset); const shown = sorted.slice(0, limit); lines.push(...formatAttributeTable(shown), "", "## Response Notes", ""); if (shown.length < sorted.length) { - lines.push( - `- Showing ${shown.length} of ${sorted.length}. Pass a \`query\` to find specific attributes.`, - ); + lines.push(`- Showing ${shown.length} of ${sorted.length}.`); } lines.push( `- Use a key with \`${labels.associatedTool}\` to query its values.`, @@ -73,6 +83,25 @@ export function formatListResult({ return lines.join("\n"); } +/** + * Search events' recommended fields first, then the rest. Both sort by key so + * related attributes (http.*, db.*) stay together. + */ +function sortAttributes( + attributes: TraceItemAttribute[], + dataset: SearchAttributesDataset, +): TraceItemAttribute[] { + const recommended = new Set([ + ...RECOMMENDED_FIELDS[normalizeEventsDataset(dataset)].basic, + ...Object.keys(DATASET_FIELDS[normalizeEventsDataset(dataset)]), + ...Object.keys(BASE_COMMON_FIELDS), + ]); + const group = ({ key }: TraceItemAttribute) => (recommended.has(key) ? 0 : 1); + return [...attributes].sort( + (a, b) => group(a) - group(b) || a.key.localeCompare(b.key), + ); +} + function formatNoneSeen(lines: string[], noun: string, period: string): string { lines.push( `No ${noun} were seen in the last ${period}.`, @@ -99,7 +128,9 @@ function formatAttributeTable(attributes: TraceItemAttribute[]): string[] { function describeCell({ context }: TraceItemAttribute): string { const brief = flatten(context?.brief); if (brief) { - return truncate(brief, MAX_DESCRIPTION_LENGTH); + const description = truncate(brief, MAX_DESCRIPTION_LENGTH); + const examples = `EXAMPLE VALUES: ${context?.examples?.join(", ")}`; + return `${description}. ${context?.examples ? examples : ""}`; } else return ""; } diff --git a/packages/mcp-core/src/tools/support/search-attributes/search.ts b/packages/mcp-core/src/tools/support/search-attributes/search.ts index b530ff7ae..8ce577c3c 100644 --- a/packages/mcp-core/src/tools/support/search-attributes/search.ts +++ b/packages/mcp-core/src/tools/support/search-attributes/search.ts @@ -81,16 +81,15 @@ export async function runSearchAttributes( importantInfo, }); - if (!hasAgentProvider()) { - return listAttributes( - [ - "Matching by `query` needs an AI provider, so these are the dataset's attributes instead.", - ], - [], - ); - } - if (query) { + if (!hasAgentProvider()) { + return listAttributes( + [ + "Matching by `query` needs an AI provider, so these are the dataset's attributes instead.", + ], + [], + ); + } return listAttributes( [ "Matching by `query` is not available yet. Here are all of the dataset's attributes instead.", From 8a50a92e4c04976d92d689579e7b9ade51ea7918 Mon Sep 17 00:00:00 2001 From: nikkikapadia Date: Thu, 5 Nov 2026 11:20:34 -0500 Subject: [PATCH 3/6] one more bot comment --- packages/mcp-core/src/tools/support/search-attributes/format.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-core/src/tools/support/search-attributes/format.ts b/packages/mcp-core/src/tools/support/search-attributes/format.ts index 6b69e45d2..6780a97bf 100644 --- a/packages/mcp-core/src/tools/support/search-attributes/format.ts +++ b/packages/mcp-core/src/tools/support/search-attributes/format.ts @@ -130,7 +130,7 @@ function describeCell({ context }: TraceItemAttribute): string { if (brief) { const description = truncate(brief, MAX_DESCRIPTION_LENGTH); const examples = `EXAMPLE VALUES: ${context?.examples?.join(", ")}`; - return `${description}. ${context?.examples ? examples : ""}`; + return `${description} ${context?.examples ? examples : ""}`; } else return ""; } From 4ad25a3b9ec889bf8c91644cc77ba702cceb48f0 Mon Sep 17 00:00:00 2001 From: nikkikapadia Date: Thu, 5 Nov 2026 12:05:14 -0500 Subject: [PATCH 4/6] fix test --- .../tools/catalog/search-attributes.test.ts | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/search-attributes.test.ts b/packages/mcp-core/src/tools/catalog/search-attributes.test.ts index 8b804b7e6..a8c5d9bc4 100644 --- a/packages/mcp-core/src/tools/catalog/search-attributes.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-attributes.test.ts @@ -86,22 +86,22 @@ describe("search_attributes", () => { | Attribute | Type | Description | Added by Sentry | Deprecated | Replacement Attribute | | --- | --- | --- | --- | --- | --- | - | \`environment\` | string | The sentry environment.. EXAMPLE VALUES: production | yes | yes | sentry.environment | - | \`http.method\` | string | The HTTP method used.. EXAMPLE VALUES: GET | yes | yes | http.request.method | - | \`http.status_code\` | number | The status code of the HTTP response.. EXAMPLE VALUES: 404 | yes | yes | http.response.status_code | - | \`http.url\` | string | The URL of the resource that was fetched.. EXAMPLE VALUES: https://example.com/test?foo=bar#buzz | yes | yes | url.full | - | \`platform\` | string | The sdk platform that generated the event.. EXAMPLE VALUES: php | yes | | | - | \`project\` | string | The name of the project. In some pages of sentry.io, you can also filter on project using a dropdown.. | yes | | | - | \`release\` | string | The sentry release.. EXAMPLE VALUES: production | yes | yes | sentry.release | - | \`sdk.name\` | string | The sentry sdk name.. EXAMPLE VALUES: @sentry/react | yes | | | - | \`sdk.version\` | string | The sentry sdk version.. EXAMPLE VALUES: 7.0.0 | yes | | | - | \`span.description\` | string | Description of the span's operation.. | yes | | | - | \`span.duration\` | number | The total time taken by the span.. | yes | | | - | \`span.op\` | string | The operation of a span.. EXAMPLE VALUES: http.client | yes | | | - | \`span.status\` | string | The span's status (either "ok" or "error"). Older SDKs may set this to a more specific error, but this behaviour is deprecated.. EXAMPLE VALUES: ok | yes | | | - | \`transaction\` | string | The sentry transaction (segment name).. EXAMPLE VALUES: GET / | yes | yes | sentry.segment.name | + | \`environment\` | string | The sentry environment. EXAMPLE VALUES: production | yes | yes | sentry.environment | + | \`http.method\` | string | The HTTP method used. EXAMPLE VALUES: GET | yes | yes | http.request.method | + | \`http.status_code\` | number | The status code of the HTTP response. EXAMPLE VALUES: 404 | yes | yes | http.response.status_code | + | \`http.url\` | string | The URL of the resource that was fetched. EXAMPLE VALUES: https://example.com/test?foo=bar#buzz | yes | yes | url.full | + | \`platform\` | string | The sdk platform that generated the event. EXAMPLE VALUES: php | yes | | | + | \`project\` | string | The name of the project. In some pages of sentry.io, you can also filter on project using a dropdown. | yes | | | + | \`release\` | string | The sentry release. EXAMPLE VALUES: production | yes | yes | sentry.release | + | \`sdk.name\` | string | The sentry sdk name. EXAMPLE VALUES: @sentry/react | yes | | | + | \`sdk.version\` | string | The sentry sdk version. EXAMPLE VALUES: 7.0.0 | yes | | | + | \`span.description\` | string | Description of the span's operation. | yes | | | + | \`span.duration\` | number | The total time taken by the span. | yes | | | + | \`span.op\` | string | The operation of a span. EXAMPLE VALUES: http.client | yes | | | + | \`span.status\` | string | The span's status (either "ok" or "error"). Older SDKs may set this to a more specific error, but this behaviour is deprecated. EXAMPLE VALUES: ok | yes | | | + | \`transaction\` | string | The sentry transaction (segment name). EXAMPLE VALUES: GET / | yes | yes | sentry.segment.name | | \`transaction.duration\` | number | | yes | | | - | \`transaction.op\` | string | Operation of the containing transaction.. | yes | | | + | \`transaction.op\` | string | Operation of the containing transaction. | yes | | | | \`transaction.status\` | string | | yes | | | | \`user.email\` | string | | yes | | | | \`user.id\` | string | | yes | | | @@ -113,15 +113,15 @@ describe("search_attributes", () => { | \`custom.score\` | number | | | | | | \`custom.tier\` | string | | | | | | \`device\` | string | | yes | | | - | \`device.memory\` | number | The estimated total memory capacity of the device.. | yes | | | + | \`device.memory\` | number | The estimated total memory capacity of the device. | yes | | | | \`deviceMemory\` | number | | yes | | | - | \`geo.city\` | string | The city of the user's location.. EXAMPLE VALUES: Toronto | yes | | | - | \`geo.country_code\` | string | The country code of the user's location.. EXAMPLE VALUES: US | yes | | | - | \`geo.region\` | string | The region of the user's location.. EXAMPLE VALUES: Canada | yes | yes | user.geo.region | - | \`http.request.method\` | string | The HTTP method used.. EXAMPLE VALUES: GET | yes | | | - | \`kiosk.firmware\` | string | The firmware revision currently installed and running on the kiosk.. EXAMPLE VALUES: 4.1.3 | yes | | | - | \`kiosk.firmware_revision\` | string | The firmware revision downloaded and staged for the kiosk's next update. It is not installed until the kiosk restarts.. EXAMPLE VALUES: 4.2.0 | yes | | | - | \`os.name\` | string | Human readable operating system name.. EXAMPLE VALUES: Ubuntu | yes | | | + | \`geo.city\` | string | The city of the user's location. EXAMPLE VALUES: Toronto | yes | | | + | \`geo.country_code\` | string | The country code of the user's location. EXAMPLE VALUES: US | yes | | | + | \`geo.region\` | string | The region of the user's location. EXAMPLE VALUES: Canada | yes | yes | user.geo.region | + | \`http.request.method\` | string | The HTTP method used. EXAMPLE VALUES: GET | yes | | | + | \`kiosk.firmware\` | string | The firmware revision currently installed and running on the kiosk. EXAMPLE VALUES: 4.1.3 | yes | | | + | \`kiosk.firmware_revision\` | string | The firmware revision downloaded and staged for the kiosk's next update. It is not installed until the kiosk restarts. EXAMPLE VALUES: 4.2.0 | yes | | | + | \`os.name\` | string | Human readable operating system name. EXAMPLE VALUES: Ubuntu | yes | | | | \`user.username\` | string | | yes | | | ## Response Notes @@ -142,19 +142,19 @@ describe("search_attributes", () => { | Attribute | Type | Description | Added by Sentry | Deprecated | Replacement Attribute | | --- | --- | --- | --- | --- | --- | - | \`environment\` | string | The sentry environment.. EXAMPLE VALUES: production | yes | yes | sentry.environment | - | \`metric.name\` | string | The name of the metric.. EXAMPLE VALUES: http.request.duration | yes | | | - | \`metric.type\` | string | The type of the metric (counter, gauge, distribution).. EXAMPLE VALUES: distribution | yes | | | - | \`metric.unit\` | string | The unit of the metric value.. EXAMPLE VALUES: millisecond | yes | | | - | \`observed_timestamp\` | number | When Sentry received the metric sample.. | yes | | | - | \`project\` | string | The name of the project. In some pages of sentry.io, you can also filter on project using a dropdown.. | yes | | | - | \`timestamp_precise\` | number | When the metric sample was recorded, with nanosecond precision.. | yes | | | - | \`value\` | number | The recorded value of the metric sample.. | yes | | | - | \`fleet.deployment_region\` | string | The region the server is scheduled to move to in the next fleet deployment. It is not where the server runs now.. EXAMPLE VALUES: eu-west-1 | yes | | | - | \`fleet.region\` | string | The region the server is currently running in.. EXAMPLE VALUES: us-east-1 | yes | | | - | \`http.method\` | string | The HTTP method used.. EXAMPLE VALUES: GET | yes | yes | http.request.method | + | \`environment\` | string | The sentry environment. EXAMPLE VALUES: production | yes | yes | sentry.environment | + | \`metric.name\` | string | The name of the metric. EXAMPLE VALUES: http.request.duration | yes | | | + | \`metric.type\` | string | The type of the metric (counter, gauge, distribution). EXAMPLE VALUES: distribution | yes | | | + | \`metric.unit\` | string | The unit of the metric value. EXAMPLE VALUES: millisecond | yes | | | + | \`observed_timestamp\` | number | When Sentry received the metric sample. | yes | | | + | \`project\` | string | The name of the project. In some pages of sentry.io, you can also filter on project using a dropdown. | yes | | | + | \`timestamp_precise\` | number | When the metric sample was recorded, with nanosecond precision. | yes | | | + | \`value\` | number | The recorded value of the metric sample. | yes | | | + | \`fleet.deployment_region\` | string | The region the server is scheduled to move to in the next fleet deployment. It is not where the server runs now. EXAMPLE VALUES: eu-west-1 | yes | | | + | \`fleet.region\` | string | The region the server is currently running in. EXAMPLE VALUES: us-east-1 | yes | | | + | \`http.method\` | string | The HTTP method used. EXAMPLE VALUES: GET | yes | yes | http.request.method | | \`payload_size\` | number | | yes | | | - | \`transaction\` | string | The sentry transaction (segment name).. EXAMPLE VALUES: GET / | yes | yes | sentry.segment.name | + | \`transaction\` | string | The sentry transaction (segment name). EXAMPLE VALUES: GET / | yes | yes | sentry.segment.name | ## Response Notes From ab6f52a1e7402a6d8a05f5524fdb491f005cefed Mon Sep 17 00:00:00 2001 From: nikkikapadia Date: Thu, 5 Nov 2026 14:01:20 -0500 Subject: [PATCH 5/6] jr fixes --- .../tools/catalog/search-attributes.test.ts | 10 ----- .../src/tools/catalog/search-attributes.ts | 43 ++++++++++--------- .../tools/support/search-attributes/format.ts | 10 +++-- 3 files changed, 29 insertions(+), 34 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/search-attributes.test.ts b/packages/mcp-core/src/tools/catalog/search-attributes.test.ts index a8c5d9bc4..bccee882c 100644 --- a/packages/mcp-core/src/tools/catalog/search-attributes.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-attributes.test.ts @@ -232,16 +232,6 @@ describe("search_attributes", () => { // This isn't quite a case yet but i'm leaving it here so we don't forget about this case it("lists attributes with a note when the AI provider fails", async () => { - mockGenerateText.mockRejectedValue( - new APICallError({ - message: "Workspace budget exceeded", - url: "https://api.openai.com/v1/chat/completions", - requestBodyValues: {}, - statusCode: 402, - isRetryable: false, - }), - ); - const output = await search({ query: "HTTP status code", dataset: "spans", diff --git a/packages/mcp-core/src/tools/catalog/search-attributes.ts b/packages/mcp-core/src/tools/catalog/search-attributes.ts index 1c7206379..3820d3d8f 100644 --- a/packages/mcp-core/src/tools/catalog/search-attributes.ts +++ b/packages/mcp-core/src/tools/catalog/search-attributes.ts @@ -18,24 +18,17 @@ export default defineTool({ experimental: true, includeInSkillDefinitions: false, name: "search_attributes", - // TODO: Edit this description, figure out what we want it to do + // TODO: Edit this description to incoporate searching description: [ - // TODO(nikki): "Find span, log, or metric attribute keys in Sentry: which attributes exist, their types, short descriptions, and whether they're deprecated.", "List span, log, or metric attribute keys in Sentry: which attributes exist, their types, short descriptions, examples, and whether they're deprecated.", "", "Use before search_traces, search_logs, or search_metrics when you don't know the exact attribute key, or to check whether an attribute is still being sent.", "", - // TODO(nikki): "`query` is plain English or part of a key. Omit it to list the dataset's attributes.", "Lists the dataset's attributes seen in `period`, with Sentry's common search fields first. Searching by `query` is not available yet, so a `query` returns the same list.", "", "NOT for attribute values or event data (use search_traces, search_logs, or search_metrics), or issue tag values (use get_issue_tag_values).", "", "", - // TODO(nikki): Restore these query examples when search lands: - // "search_attributes(organizationSlug='my-org', query='HTTP status code', dataset='spans')", - // "search_attributes(organizationSlug='my-org', query='are we still sending the deprecated HTTP method attribute', dataset='spans')", - // "search_attributes(organizationSlug='my-org', projectSlug='backend', query='request ID that ties log lines to a trace', dataset='logs')", - // "search_attributes(organizationSlug='my-org', query='payment provider', dataset='metrics', period='7d')", "search_attributes(organizationSlug='my-org', dataset='spans')", "search_attributes(organizationSlug='my-org', projectSlug='backend', dataset='logs')", "search_attributes(organizationSlug='my-org', dataset='metrics', period='7d')", @@ -44,27 +37,35 @@ export default defineTool({ "", "- 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.", - // TODO(nikki): "- Pass a returned key to the matching search tool to query its values.", "- Show the user each returned attribute's type, description, examples, deprecation status and if they were added by Sentry. Don't reduce the result to a list of names.", "", ].join("\n"), inputSchema: { organizationSlug: ParamOrganizationSlug, projectSlug: ParamProjectSlug.nullable().default(null), - query: z.string().trim().optional().describe( - // TODO(nikki): "What attribute to find, in plain English or as part of a key. Omit to list the dataset's attributes.", - "Not used yet. Searching by query is not available, so the dataset's attributes are listed either way. Omit it.", - ), + query: z + .string() + .trim() + .optional() + .describe( + "Not used yet. Searching by query is not available, so the dataset's attributes are listed either way. Omit it.", + ), period: ParamPeriod.optional(), regionUrl: ParamRegionUrl.nullable().default(null), - limit: z.number().min(1).max(100).optional().describe( - // TODO(nikki): "Maximum number of attributes to return (1-100). Defaults to 10 with a `query` and 50 when listing.", - "Maximum number of attributes to list (1-100). Defaults to 50.", - ), - dataset: z.enum(SEARCH_ATTRIBUTES_DATASETS).optional().describe( - // TODO(nikki): "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.", - "Which dataset's attributes to list: spans, logs, or metrics. Infer it from the question; ask the user to clarify if it cannot be inferred.", - ), + limit: z + .number() + .min(1) + .max(100) + .optional() + .describe( + "Maximum number of attributes to list (1-100). Defaults to 50.", + ), + dataset: z + .enum(SEARCH_ATTRIBUTES_DATASETS) + .optional() + .describe( + "Which dataset's attributes to list: 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/format.ts b/packages/mcp-core/src/tools/support/search-attributes/format.ts index 6780a97bf..05c786af1 100644 --- a/packages/mcp-core/src/tools/support/search-attributes/format.ts +++ b/packages/mcp-core/src/tools/support/search-attributes/format.ts @@ -128,9 +128,13 @@ function formatAttributeTable(attributes: TraceItemAttribute[]): string[] { function describeCell({ context }: TraceItemAttribute): string { const brief = flatten(context?.brief); if (brief) { - const description = truncate(brief, MAX_DESCRIPTION_LENGTH); - const examples = `EXAMPLE VALUES: ${context?.examples?.join(", ")}`; - return `${description} ${context?.examples ? examples : ""}`; + const examples = flatten( + `EXAMPLE VALUES: ${context?.examples?.join(", ")}`, + ); + return truncate( + `${brief} ${context?.examples ? examples : ""}`, + MAX_DESCRIPTION_LENGTH, + ); } else return ""; } From 9b3eedacdc2f837c343167c71b4c216db68cf1d2 Mon Sep 17 00:00:00 2001 From: nikkikapadia Date: Mon, 9 Nov 2026 15:54:25 -0500 Subject: [PATCH 6/6] implement suggestions --- .../mcp-core/src/tools/support/search-attributes/format.ts | 5 +---- .../mcp-core/src/tools/support/search-attributes/search.ts | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/mcp-core/src/tools/support/search-attributes/format.ts b/packages/mcp-core/src/tools/support/search-attributes/format.ts index 05c786af1..add2e90ba 100644 --- a/packages/mcp-core/src/tools/support/search-attributes/format.ts +++ b/packages/mcp-core/src/tools/support/search-attributes/format.ts @@ -50,10 +50,7 @@ export function formatListResult({ const labels = DATASET_LABELS[dataset]; const lines: string[] = []; - if (importantInfo) { - importantInfo.map((info) => lines.push(info)); - } - + lines.push(...importantInfo); lines.push( `# ${labels.title} in **${organizationSlug}** (last ${period})`, "", diff --git a/packages/mcp-core/src/tools/support/search-attributes/search.ts b/packages/mcp-core/src/tools/support/search-attributes/search.ts index 8ce577c3c..4a35e93f4 100644 --- a/packages/mcp-core/src/tools/support/search-attributes/search.ts +++ b/packages/mcp-core/src/tools/support/search-attributes/search.ts @@ -1,7 +1,6 @@ import type { Tag, TraceItemAttribute } from "../../../api-client"; import { UserInputError } from "../../../errors"; import { hasAgentProvider } from "../../../internal/agents/provider-factory"; -import { withProviderFallback } from "../../../internal/agents/provider-fallback"; import { apiServiceFromContext } from "../../../internal/tool-helpers/api"; import { setTargetTagsAndAttributes } from "../../../telem/scope"; import type { ServerContext } from "../../../types";