Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/mcp-core/src/tools/catalog/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
78 changes: 78 additions & 0 deletions packages/mcp-core/src/tools/catalog/search-attributes.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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();
});
});
78 changes: 78 additions & 0 deletions packages/mcp-core/src/tools/catalog/search-attributes.ts
Original file line number Diff line number Diff line change
@@ -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).",
"",
"<examples>",
"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')",
"</examples>",
"",
"<hints>",
"- name/otherName notation means <organizationSlug>/<projectSlug>; 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.",
"</hints>",
].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);
},
});
60 changes: 60 additions & 0 deletions packages/mcp-core/src/tools/support/search-attributes/search.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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;
}
Loading