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. In experimental
mode, 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 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 @@ -1080,6 +1080,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) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
packages: [...new Set(packageNames)].map((name) => {
packages: Array.from(new Set(packageNames), (name) => {

const version = Object.hasOwn(packages, name)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

packages should be a Map I guess if we are doing this?

? packages[name]
: undefined;
if (version === undefined || !version?.trim()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First check is redundant.

Suggested change
if (version === undefined || !version?.trim()) {
if (!version?.trim()) {

return {
name,
status: version === undefined ? "not_listed" : "version_not_recorded",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, do version === undefined early on and record it? Then you can probably reduce this to a single return {...} statement with 2 ternary statements in it.

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 @@ -1970,6 +1971,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`;
Comment on lines +1985 to +1993

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These nested ternaries are blowing my mind. I feel like this calls for a switch block to be clear?

}
return `${output}\n`;
}

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this doing? Don't we already have proper markdown/HTML escaping and building blocks in the repo?


/** 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 @@ -1994,6 +2027,7 @@ export function getSuspectCommit(committers: CommitterList | undefined) {
* @returns Formatted markdown string with complete issue information
*/
export function formatIssueOutput({
packageNames,
organizationSlug,
issue,
event,
Expand All @@ -2009,6 +2043,7 @@ export function formatIssueOutput({
availableToolNames,
directToolNames,
}: {
packageNames?: string[];
organizationSlug: string;
issue: Issue;
event: Event;
Expand Down Expand Up @@ -2159,6 +2194,10 @@ export function formatIssueOutput({
.join("");
}

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 @@ -2200,6 +2239,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
71 changes: 71 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 @@ -1450,6 +1452,75 @@ 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({
experimentalMode: true,
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