Skip to content
Merged
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
28 changes: 28 additions & 0 deletions docs/contributing/tool-responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,34 @@ 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. In Markdown,
render package names and versions as code spans with `inlineCode` from
`@sentry/toolkit-core/markdown`, which keeps them inside
their list entries; 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 @@ -1146,3 +1147,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 @@ -1028,6 +1028,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
57 changes: 57 additions & 0 deletions packages/mcp-core/src/internal/event-packages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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),
}),
]),
),
Comment on lines +6 to +21

@semgrep-code-getsentry semgrep-code-getsentry Bot Nov 3, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High severity issue identified in your code:

Risk: Affected versions of zod are vulnerable to Allocation of Resources Without Limits or Throttling. Zod parses every element of an array, set or map schema and records one validation issue per invalid element, with no cap or early stop. Upper size bounds (.max(), .length(), .size(), or z.maxLength()/z.length()/z.maxSize()/z.size() checks) only ran after all elements had been parsed, so they did not limit the work. An attacker who submits a very large collection (e.g. a million invalid items for z.array(z.string().uuid())) forces the allocation of millions of issue objects, which can exhaust memory and crash the process. Upgrade to zod 4.6.6 or later, which rejects oversized input before parsing its elements when the schema has an upper bound, and add an upper bound (e.g. .max(n)) to every collection schema that parses untrusted input: unbounded schemas stay exposed even after the upgrade.

Fix: Upgrade this library to at least version 4.6.6 at toolkit/pnpm-lock.yaml:9619.

Reference(s): GHSA-mrhx-c7xc-m3w5, CVE-2023-54404

🍰 Fixed in commit d622ab8 🍰

});

/** 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;
}
const packageVersions = new Map(Object.entries(packages ?? {}));
if (packageVersions.size === 0) {
return { metadataAvailable: false, packages: [] };
}

return {
metadataAvailable: true,
packages: Array.from(new Set(packageNames), (name) => {
const version = packageVersions.get(name);
const notListed = version === undefined;
return version?.trim()
? {
name,
status: "recorded",
version: version.slice(0, 256),
truncated: version.length > 256,
}
: {
name,
status: notListed ? "not_listed" : "version_not_recorded",
version: null,
truncated: false,
};
}),
};
}
42 changes: 42 additions & 0 deletions packages/mcp-core/src/internal/formatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
TraceSpan,
} from "../api-client/types";
import { logIssue } from "../telem/logging";
import { selectEventPackages } from "./event-packages";
import { formatUnknown } from "./unknown-formatting";
import {
type CodeLocation,
Expand Down Expand Up @@ -2207,6 +2208,37 @@ 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) {
let value: string;
switch (entry.status) {
case "recorded":
value = inlineCode(entry.version);
if (entry.truncated) {
value += " (truncated)";
}
break;
case "not_listed":
value = "Not listed in this event's package metadata";
break;
case "version_not_recorded":
value = "Version not recorded";
break;
}
output += `- ${inlineCode(entry.name)}: ${value}\n`;
}
return `${output}\n`;
}

/** 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 @@ -2231,6 +2263,7 @@ export function getSuspectCommit(committers: CommitterList | undefined) {
* @returns Formatted markdown string with complete issue information
*/
export function formatIssueOutput({
packageNames,
organizationSlug,
issue,
event,
Expand All @@ -2246,6 +2279,7 @@ export function formatIssueOutput({
availableToolNames,
directToolNames,
}: {
packageNames?: string[];
organizationSlug: string;
issue: Issue;
event: Event;
Expand Down Expand Up @@ -2396,6 +2430,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 @@ -2437,6 +2475,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 @@ -79,6 +79,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))
Comment on lines +82 to +83

@semgrep-code-getsentry semgrep-code-getsentry Bot Nov 3, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High severity issue identified in your code:

Risk: Affected versions of zod are vulnerable to Allocation of Resources Without Limits or Throttling. Zod parses every element of an array, set or map schema and records one validation issue per invalid element, with no cap or early stop. Upper size bounds (.max(), .length(), .size(), or z.maxLength()/z.length()/z.maxSize()/z.size() checks) only ran after all elements had been parsed, so they did not limit the work. An attacker who submits a very large collection (e.g. a million invalid items for z.array(z.string().uuid())) forces the allocation of millions of issue objects, which can exhaust memory and crash the process. Upgrade to zod 4.6.6 or later, which rejects oversized input before parsing its elements when the schema has an upper bound, and add an upper bound (e.g. .max(n)) to every collection schema that parses untrusted input: unbounded schemas stay exposed even after the upgrade.

Fix: Upgrade this library to at least version 4.6.6 at toolkit/pnpm-lock.yaml:9619.

Reference(s): GHSA-mrhx-c7xc-m3w5, CVE-2023-54404

🍰 Fixed in commit d622ab8 🍰

.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 } from "@modelcontextprotocol/server";
import { type Span, setUser, startSpan } from "@sentry/core";
import {
createDefaultEvent,
eventFixture,
issueFixture,
mswServer,
Expand All @@ -24,6 +25,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 { CredentialRouter, ServerContext } from "./types";
Expand Down Expand Up @@ -2038,6 +2040,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