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
26 changes: 26 additions & 0 deletions docs/contributing/tool-responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,32 @@ The event API's legacy `context` field (extra data) can be `null`. Issue details
must still load; omit the `Extra Data` section for null or empty values while
preserving the event's error, stacktrace, and structured `contexts` data.

## Selected Event Package Versions

Use `get_sentry_resource` with `resourceType: "event"` (or an event URL) and
`packageNames: ["example-package", "@example/client"]` to inspect dependency
versions recorded with that event. The catalog tool `get_issue_details` accepts
the same selection with an explicit `eventId`.

- Selection is optional: omitting it preserves the ordinary response.
- Accept 1–10 exact, case-sensitive names, each up to 256 characters. Duplicate
names produce one entry; package names are not normalized across ecosystems.
- Use the existing response's `packages` map without extra API calls. With Sentry's
JSON formatter, include `event.packageVersions` in `structuredContent`: a
`metadataAvailable` flag and selected `packages` entries with `name`, `status`,
`version`, and `truncated`. Status is `recorded`, `not_listed`, or
`version_not_recorded`; unavailable metadata has an empty selection. The server
generates equivalent JSON text. When returning Markdown, append the same
selection after Sentry-supplied or local event formatting.
- Missing, null, and empty maps mean metadata is unavailable. In a populated map,
distinguish a name not listed from a null or blank version. Neither proves a
dependency was absent from the application.
- Truncate versions after 256 characters with an explicit marker and escape
Markdown in package data when rendering Markdown; structured strings stay raw.
Do not render unrelated packages.
- Package selection requires an exact event; issue/latest-event and other resource
lookups reject it.

## Structured Content

MCP tools may expose `structuredContent` alongside generated text `content`.
Expand Down
13 changes: 13 additions & 0 deletions packages/mcp-core/src/api-client/schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
autofixStateExplorerFixture,
createDefaultEvent,
issueNullCulpritFixture,
profileChunkFixture,
tagsFixture,
Expand Down Expand Up @@ -1162,3 +1163,15 @@ describe("TransactionProfileSchema", () => {
expect(profile.profile.thread_metadata).toEqual({});
});
});

describe("event packages", () => {
it.each([
{ label: "missing", packages: undefined },
{ label: "null", packages: null },
{ label: "empty", packages: {} },
{ label: "recorded", packages: { example: "1.2.3", redacted: null } },
])("retains $label package metadata during event parsing", ({ packages }) => {
const event = EventSchema.parse({ ...createDefaultEvent(), packages });
expect(event.packages).toEqual(packages);
});
});
1 change: 1 addition & 0 deletions packages/mcp-core/src/api-client/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,7 @@ const EventTagsSchema = z.preprocess((value) => {

const BaseEventSchema = z.object({
id: z.string(),
packages: z.record(z.string(), z.string().nullable()).nullish(),
groupID: z.string().nullable().optional(),
title: z.string(),
message: z.string().nullable(),
Expand Down
58 changes: 58 additions & 0 deletions packages/mcp-core/src/internal/event-packages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { z } from "zod";
import type { Event } from "../api-client/types";

export const SelectedEventPackagesSchema = z.object({
metadataAvailable: z.boolean(),
packages: z.array(
z.discriminatedUnion("status", [
z.object({
name: z.string(),
status: z.literal("recorded"),
version: z.string(),
truncated: z.boolean(),
}),
z.object({
name: z.string(),
status: z.enum(["not_listed", "version_not_recorded"]),
version: z.null(),
truncated: z.literal(false),
}),
]),
),
});

/** Select once for both structured and Markdown output, without exposing the full map. */
export function selectEventPackages(
packages: Event["packages"],
packageNames?: string[],
): z.infer<typeof SelectedEventPackagesSchema> | undefined {
if (!packageNames?.length) {
return undefined;
}
if (!packages || Object.keys(packages).length === 0) {
return { metadataAvailable: false, packages: [] };
}

return {
metadataAvailable: true,
packages: [...new Set(packageNames)].map((name) => {
const version = Object.hasOwn(packages, name)
? packages[name]
: undefined;
if (version === undefined || !version?.trim()) {
return {
name,
status: version === undefined ? "not_listed" : "version_not_recorded",
version: null,
truncated: false,
};
}
return {
name,
status: "recorded",
version: version.slice(0, 256),
truncated: version.length > 256,
};
}),
};
}
43 changes: 43 additions & 0 deletions packages/mcp-core/src/internal/formatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type {
TraceSpan,
} from "../api-client/types";
import { logIssue } from "../telem/logging";
import { selectEventPackages } from "./event-packages";
import {
type CodeLocation,
findMostRelevantInAppFrame,
Expand Down Expand Up @@ -1985,6 +1986,38 @@ function formatSeerSummary(autofixState: AutofixRunState | undefined): string {
return `${parts.join("\n")}\n\n`;
}

function formatEventPackages(event: Event, packageNames: string[]): string {
const selection = selectEventPackages(event.packages, packageNames);
if (!selection) {
return "";
}
let output = "\n### Selected Package Versions\n\n";
if (!selection.metadataAvailable) {
return `${output}Package metadata is unavailable for this event.\n\n`;
}

for (const entry of selection.packages) {
const value =
entry.status === "recorded"
? entry.truncated
? `${entry.version}… (truncated)`
: entry.version
: entry.status === "not_listed"
? "Not listed in this event's package metadata"
: "Version not recorded";
output += `- ${formatPackageText(entry.name)}: ${formatPackageText(value)}\n`;
}
return `${output}\n`;
}

function formatPackageText(value: string): string {
return value
.replace(/[\r\n\t]/g, " ")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/([\\`*_\[\]|>])/g, "\\$1");
}

/** Projects the suspect commit consistently for structured and markdown issue details. */
export function getSuspectCommit(committers: CommitterList | undefined) {
// The endpoint currently returns the issue's latest suspect commit, grouped by author.
Expand All @@ -2009,6 +2042,7 @@ export function getSuspectCommit(committers: CommitterList | undefined) {
* @returns Formatted markdown string with complete issue information
*/
export function formatIssueOutput({
packageNames,
organizationSlug,
issue,
event,
Expand All @@ -2024,6 +2058,7 @@ export function formatIssueOutput({
availableToolNames,
directToolNames,
}: {
packageNames?: string[];
organizationSlug: string;
issue: Issue;
event: Event;
Expand Down Expand Up @@ -2179,6 +2214,10 @@ export function formatIssueOutput({
});
}

if (packageNames?.length) {
output += formatEventPackages(event, packageNames);
}

// For unsupported event types, return early without trying to render event details
return output;
}
Expand Down Expand Up @@ -2235,6 +2274,10 @@ export function formatIssueOutput({
});
}

if (packageNames?.length) {
output += formatEventPackages(event, packageNames);
}

// Add Seer context if available
if (autofixState) {
output += formatSeerSummary(autofixState);
Expand Down
8 changes: 8 additions & 0 deletions packages/mcp-core/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ export const ParamIssueShortId = z
.trim()
.describe("The Issue ID. e.g. `PROJECT-1Z43`");

export const ParamPackageNames = z
.array(z.string().min(1).max(256))
.min(1)
.max(10)
.describe(
"Exact, case-sensitive package names recorded with a specific event (1–10 names, up to 256 characters each). Requires an event lookup. Only selected package versions are added; versions longer than 256 characters are truncated.",
);

export const ParamIssueUrl = z
.string()
.url()
Expand Down
70 changes: 70 additions & 0 deletions packages/mcp-core/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { McpServer as ModernMcpServer } from "@modelcontextprotocol/server";
import { type Span, setUser, startSpan } from "@sentry/core";
import {
createDefaultEvent,
issueFixture,
mswServer,
projectFixture,
Expand All @@ -20,6 +21,7 @@ import {
getStructuredContent,
getTextContent,
} from "./test-utils/structured-content";
import type { GetIssueDetailsPayload } from "./tools/catalog/get-issue-details";
import { createExecuteTool } from "./tools/special/execute-tool";
import type { ToolConfig } from "./tools/types";
import type { ServerContext } from "./types";
Expand Down Expand Up @@ -1449,6 +1451,74 @@ describe("buildServer", () => {
);
});

it.each(["get_sentry_resource", "get_issue_details"])(
"dispatches package selection through %s",
async (toolName) => {
const eventId = "7ca573c0f4814912aaa9bdc77d1a7d51";
mswServer.use(
http.get(
`https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/6507376925/events/${eventId}/`,
() =>
HttpResponse.json({
...createDefaultEvent({ id: eventId, contexts: {} }),
packages: { example: "1.2.3", unrelated: "9.9.9" },
formatted: {
format: "json",
content: JSON.stringify({ message: "Synthetic event" }),
},
}),
),
);
const server = buildServer({
context: {
...baseContext,
constraints: {
organizationSlug: "sentry-mcp-evals",
projectSlug: "CLOUDFLARE-MCP",
},
},
});
const result = await callRegisteredTool(server, "execute_sentry_tool", {
name: toolName,
arguments: {
...(toolName === "get_sentry_resource"
? { resourceType: "event", resourceId: eventId }
: { eventId }),
packageNames: ["example"],
},
});
const payload = getStructuredContent<GetIssueDetailsPayload>(result);
expect(payload.event.packageVersions).toEqual({
metadataAvailable: true,
packages: [
{
name: "example",
status: "recorded",
version: "1.2.3",
truncated: false,
},
],
});
expect(getTextContent(result)).toBe(JSON.stringify(payload, null, 2));
expect(result.isError).not.toBe(true);
expect(getTextContent(result)).not.toContain("unrelated");
},
);

it("rejects excessive package selection at the MCP boundary", async () => {
const result = await callRegisteredTool(
buildServer({ context: baseContext }),
"get_sentry_resource",
{
resourceType: "event",
resourceId: "7ca573c0f4814912aaa9bdc77d1a7d51",
organizationSlug: "sentry-mcp-evals",
packageNames: Array.from({ length: 11 }, (_, i) => `package${i}`),
},
);
expect(result.isError).toBe(true);
});

it("execute_sentry_tool dispatches to catalog-only event stacktrace", async () => {
const server = buildServer({
context: baseContext,
Expand Down
Loading
Loading