Skip to content

Commit ed572b1

Browse files
dcramercodex
andauthored
fix(core): Preserve mixed-case slug parameters (#1142)
Preserve caller casing for Sentry resource slug parameters instead of lowercasing them during MCP argument parsing. Sentry slug lookups can be exact on legacy self-hosted instances, so mixed-case organization, project, and team slugs need to reach Sentry API paths unchanged. This restores compatibility for installs that still have mixed-case slugs. The change keeps trimming and slug validation, leaves non-slug normalization in place, and updates the generated tool definition copy for the exact lowercase `all` sentinel. Regression coverage now asserts mixed-case parser output and API request paths/bodies for the affected tools. Fixes #1138 Co-authored-by: GPT-5 Codex <noreply@openai.com>
1 parent e0071ff commit ed572b1

15 files changed

Lines changed: 366 additions & 58 deletions

‎docs/contributing/documentation-style-guide.md‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@ Link to reusable patterns: See "Error Handling" in [Common Patterns](common-patt
8181
// Tool parameter pattern used throughout the codebase
8282
export const ParamOrganizationSlug = z
8383
.string()
84-
.toLowerCase()
8584
.trim()
8685
.describe("The organization's slug. Find using `find_organizations()` tool.");
8786
```
@@ -93,10 +92,9 @@ import { z } from "zod";
9392

9493
// Define a schema for the organization slug parameter
9594
// This schema will validate that the input is a string
96-
// It will also convert to lowercase and trim whitespace
95+
// It will also trim whitespace
9796
export const ParamOrganizationSlug = z
9897
.string() // Ensures the value is a string
99-
.toLowerCase() // Converts to lowercase
10098
.trim() // Removes whitespace
10199
.describe("The organization's slug..."); // Adds description
102100
```

‎packages/mcp-core/src/schema.ts‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@
22
* Reusable Zod parameter schemas for MCP tools.
33
*
44
* Shared validation schemas used across tool definitions to ensure consistent
5-
* parameter handling and validation. Each schema includes transformation
6-
* (e.g., toLowerCase, trim) and LLM-friendly descriptions.
5+
* parameter handling and validation. Schemas apply minimal normalization
6+
* (e.g., trim) and LLM-friendly descriptions.
77
*/
88
import { z } from "zod";
99
import { SENTRY_GUIDES } from "./constants";
1010
import { validateSlug } from "./utils/slug-validation";
1111

12+
// Sentry slug lookups can be exact and case-sensitive on legacy instances.
13+
// Preserve caller casing for resource slugs; only trim and validate shape.
1214
export const ParamOrganizationSlug = z
1315
.string()
14-
.toLowerCase()
1516
.trim()
1617
.superRefine(validateSlug)
1718
.describe(
@@ -20,7 +21,6 @@ export const ParamOrganizationSlug = z
2021

2122
export const ParamTeamSlug = z
2223
.string()
23-
.toLowerCase()
2424
.trim()
2525
.superRefine(validateSlug)
2626
.describe(
@@ -29,7 +29,6 @@ export const ParamTeamSlug = z
2929

3030
export const ParamProjectSlug = z
3131
.string()
32-
.toLowerCase()
3332
.trim()
3433
.superRefine(validateSlug)
3534
.describe(
@@ -38,11 +37,10 @@ export const ParamProjectSlug = z
3837

3938
export const ParamProjectSlugOrAll = z
4039
.string()
41-
.toLowerCase()
4240
.trim()
4341
.superRefine(validateSlug)
4442
.describe(
45-
"The project's slug. This will default to all projects you have access to. It is encouraged to specify this when possible.",
43+
"The project's slug, or exact lowercase `all` when a tool supports all-projects scope. Other casing is treated as a project slug.",
4644
);
4745

4846
export const ParamSearchQuery = z

‎packages/mcp-core/src/toolDefinitions.json‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -290,13 +290,13 @@
290290
"anyOf": [
291291
{
292292
"type": "string",
293-
"description": "The project's slug. This will default to all projects you have access to. It is encouraged to specify this when possible."
293+
"description": "The project's slug, or exact lowercase `all` when a tool supports all-projects scope. Other casing is treated as a project slug."
294294
},
295295
{
296296
"type": "null"
297297
}
298298
],
299-
"description": "The project's slug. This will default to all projects you have access to. It is encouraged to specify this when possible.",
299+
"description": "The project's slug, or exact lowercase `all` when a tool supports all-projects scope. Other casing is treated as a project slug.",
300300
"default": null
301301
},
302302
"query": {
@@ -475,13 +475,13 @@
475475
"anyOf": [
476476
{
477477
"type": "string",
478-
"description": "The project's slug. This will default to all projects you have access to. It is encouraged to specify this when possible."
478+
"description": "The project's slug, or exact lowercase `all` when a tool supports all-projects scope. Other casing is treated as a project slug."
479479
},
480480
{
481481
"type": "null"
482482
}
483483
],
484-
"description": "The project's slug. This will default to all projects you have access to. It is encouraged to specify this when possible.",
484+
"description": "The project's slug, or exact lowercase `all` when a tool supports all-projects scope. Other casing is treated as a project slug.",
485485
"default": null
486486
},
487487
"environment": {
@@ -638,13 +638,13 @@
638638
"anyOf": [
639639
{
640640
"type": "string",
641-
"description": "The project's slug. This will default to all projects you have access to. It is encouraged to specify this when possible."
641+
"description": "The project's slug, or exact lowercase `all` when a tool supports all-projects scope. Other casing is treated as a project slug."
642642
},
643643
{
644644
"type": "null"
645645
}
646646
],
647-
"description": "The project's slug. This will default to all projects you have access to. It is encouraged to specify this when possible.",
647+
"description": "The project's slug, or exact lowercase `all` when a tool supports all-projects scope. Other casing is treated as a project slug.",
648648
"default": null
649649
},
650650
"query": {
@@ -1197,13 +1197,13 @@
11971197
"anyOf": [
11981198
{
11991199
"type": "string",
1200-
"description": "The project's slug. This will default to all projects you have access to. It is encouraged to specify this when possible."
1200+
"description": "The project's slug, or exact lowercase `all` when a tool supports all-projects scope. Other casing is treated as a project slug."
12011201
},
12021202
{
12031203
"type": "null"
12041204
}
12051205
],
1206-
"description": "The project's slug. This will default to all projects you have access to. It is encouraged to specify this when possible.",
1206+
"description": "The project's slug, or exact lowercase `all` when a tool supports all-projects scope. Other casing is treated as a project slug.",
12071207
"default": null
12081208
},
12091209
"ruleIdOrName": {

‎packages/mcp-core/src/tools/catalog/find-projects.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import { mswServer } from "@sentry/mcp-server-mocks";
2+
import { http, HttpResponse } from "msw";
13
import { describe, it, expect } from "vitest";
24
import findProjects from "./find-projects.js";
5+
import { prepareToolParams } from "../catalog-runtime/availability";
36
import { getServerContext } from "../../test-setup.js";
47

58
describe("find_projects", () => {
@@ -19,4 +22,38 @@ describe("find_projects", () => {
1922
"
2023
`);
2124
});
25+
26+
it("preserves mixed-case organization slug in the API path", async () => {
27+
const context = getServerContext();
28+
29+
mswServer.use(
30+
http.get("*/api/0/organizations/*/projects/", ({ request }) => {
31+
expect(new URL(request.url).pathname).toBe(
32+
"/api/0/organizations/MyOrg/projects/",
33+
);
34+
return HttpResponse.json([
35+
{
36+
id: "1",
37+
slug: "MyProject",
38+
name: "My Project",
39+
},
40+
]);
41+
}),
42+
);
43+
44+
const params = prepareToolParams({
45+
tool: findProjects,
46+
params: {
47+
organizationSlug: " MyOrg ",
48+
regionUrl: null,
49+
query: null,
50+
},
51+
context,
52+
}) as Parameters<typeof findProjects.handler>[0];
53+
54+
const result = await findProjects.handler(params, context);
55+
56+
expect(result).toContain("# Projects in **MyOrg**");
57+
expect(result).toContain("- **MyProject**");
58+
});
2259
});

‎packages/mcp-core/src/tools/catalog/get-monitor-details.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { mswServer } from "@sentry/mcp-server-mocks";
22
import { http, HttpResponse } from "msw";
33
import { describe, expect, it } from "vitest";
44
import getMonitorDetails from "./get-monitor-details.js";
5+
import { prepareToolParams } from "../catalog-runtime/availability";
56

67
const context = {
78
constraints: {
@@ -183,6 +184,69 @@ describe("get_monitor_details", () => {
183184
]);
184185
});
185186

187+
it("preserves mixed-case project slug in monitor detail endpoints", async () => {
188+
const paths: string[] = [];
189+
const monitorResponse = {
190+
id: "4509100000000001",
191+
slug: "nightly-import",
192+
name: "Nightly Import",
193+
status: "ok",
194+
owner: null,
195+
project: {
196+
id: "4509109104082945",
197+
slug: "MyProject",
198+
name: "My Project",
199+
},
200+
config: {
201+
schedule_type: "crontab",
202+
schedule: ["crontab", "0 2 * * *"],
203+
},
204+
environments: [],
205+
};
206+
mswServer.use(
207+
http.get(
208+
"*/api/0/projects/*/*/monitors/nightly-import/",
209+
({ request }) => {
210+
paths.push(new URL(request.url).pathname);
211+
return HttpResponse.json(monitorResponse);
212+
},
213+
),
214+
http.get(
215+
"*/api/0/projects/*/*/monitors/nightly-import/checkins/",
216+
({ request }) => {
217+
paths.push(new URL(request.url).pathname);
218+
return HttpResponse.json([]);
219+
},
220+
),
221+
);
222+
223+
const params = prepareToolParams({
224+
tool: getMonitorDetails,
225+
params: {
226+
organizationSlug: " MyOrg ",
227+
regionUrl: null,
228+
projectSlugOrId: " MyProject ",
229+
monitorSlug: "nightly-import",
230+
environment: null,
231+
period: "24h",
232+
start: null,
233+
end: null,
234+
checkInLimit: 10,
235+
includeStats: false,
236+
rollupSeconds: null,
237+
},
238+
context,
239+
}) as Parameters<typeof getMonitorDetails.handler>[0];
240+
241+
const result = await getMonitorDetails.handler(params, context);
242+
243+
expect(result).toContain("# Monitor Nightly Import in **MyOrg**");
244+
expect(paths).toEqual([
245+
"/api/0/projects/MyOrg/MyProject/monitors/nightly-import/",
246+
"/api/0/projects/MyOrg/MyProject/monitors/nightly-import/checkins/",
247+
]);
248+
});
249+
186250
it("uses StatsMixin time parameters for monitor stats", async () => {
187251
let checkInsRequestUrl: string | null = null;
188252
let statsRequestUrl: string | null = null;

‎packages/mcp-core/src/tools/catalog/get-monitor-details.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ export default defineTool({
9393
regionUrl: ParamRegionUrl.nullable().default(null),
9494
projectSlugOrId: z
9595
.string()
96-
.toLowerCase()
9796
.trim()
9897
.superRefine(validateSlugOrId)
9998
.nullable()

‎packages/mcp-core/src/tools/catalog/get-release-details.test.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { mswServer, releaseFixture } from "@sentry/mcp-server-mocks";
22
import { http, HttpResponse } from "msw";
33
import { describe, expect, it } from "vitest";
44
import getReleaseDetails from "./get-release-details.js";
5+
import { prepareToolParams } from "../catalog-runtime/availability";
56

67
const context = {
78
constraints: {
@@ -245,6 +246,80 @@ describe("get_release_details", () => {
245246
);
246247
});
247248

249+
it("preserves mixed-case project slug in release detail endpoints", async () => {
250+
const requests: Array<{
251+
kind: "details" | "deploys" | "commits";
252+
url: string;
253+
}> = [];
254+
const releaseVersion = "8ce89484-0fec-4913-a2cd-e8e2d41dee36";
255+
mswServer.use(
256+
http.get(
257+
`https://sentry.io/api/0/projects/MyOrg/MyProject/releases/${releaseVersion}/`,
258+
({ request }) => {
259+
requests.push({ kind: "details", url: request.url });
260+
return HttpResponse.json(releaseFixture);
261+
},
262+
),
263+
http.get(
264+
`https://sentry.io/api/0/organizations/MyOrg/releases/${releaseVersion}/deploys/`,
265+
({ request }) => {
266+
requests.push({ kind: "deploys", url: request.url });
267+
return HttpResponse.json([]);
268+
},
269+
),
270+
http.get(
271+
`https://sentry.io/api/0/projects/MyOrg/MyProject/releases/${releaseVersion}/commits/`,
272+
({ request }) => {
273+
requests.push({ kind: "commits", url: request.url });
274+
return HttpResponse.json([]);
275+
},
276+
),
277+
);
278+
279+
const params = prepareToolParams({
280+
tool: getReleaseDetails,
281+
params: {
282+
organizationSlug: " MyOrg ",
283+
regionUrl: null,
284+
releaseVersion,
285+
projectSlugOrId: " MyProject ",
286+
includeHealth: false,
287+
includeDeploys: true,
288+
includeCommits: true,
289+
limit: 10,
290+
},
291+
context,
292+
}) as Parameters<typeof getReleaseDetails.handler>[0];
293+
294+
await getReleaseDetails.handler(params, context);
295+
296+
expect(requests).toHaveLength(3);
297+
const detailsRequest = requests.find(
298+
(request) => request.kind === "details",
299+
);
300+
const deploysRequest = requests.find(
301+
(request) => request.kind === "deploys",
302+
);
303+
const commitsRequest = requests.find(
304+
(request) => request.kind === "commits",
305+
);
306+
expect(detailsRequest).toBeDefined();
307+
expect(deploysRequest).toBeDefined();
308+
expect(commitsRequest).toBeDefined();
309+
expect(new URL(detailsRequest!.url).pathname).toBe(
310+
`/api/0/projects/MyOrg/MyProject/releases/${releaseVersion}/`,
311+
);
312+
expect(new URL(deploysRequest!.url).pathname).toBe(
313+
`/api/0/organizations/MyOrg/releases/${releaseVersion}/deploys/`,
314+
);
315+
expect(new URL(deploysRequest!.url).searchParams.get("projectSlug")).toBe(
316+
"MyProject",
317+
);
318+
expect(new URL(commitsRequest!.url).pathname).toBe(
319+
`/api/0/projects/MyOrg/MyProject/releases/${releaseVersion}/commits/`,
320+
);
321+
});
322+
248323
it("hides health metadata when includeHealth is false", async () => {
249324
const releaseVersion = "8ce89484-0fec-4913-a2cd-e8e2d41dee36";
250325
mswServer.use(

‎packages/mcp-core/src/tools/catalog/get-release-details.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ export default defineTool({
101101
releaseVersion: z.string().trim().min(1).describe("Exact release version."),
102102
projectSlugOrId: z
103103
.string()
104-
.toLowerCase()
105104
.trim()
106105
.superRefine(validateSlugOrId)
107106
.describe(

0 commit comments

Comments
 (0)