Skip to content

Commit c2cd6a2

Browse files
BYKGPT-6 Sol
andauthored
Use the Sentry Link parser for MCP pagination (#1436)
## Summary - Use the existing @sentry/api Link-header parser for MCP pagination rather than substring checks that can follow a previous-page cursor. - Cover a previous link containing an x-rel="next" extension attribute and align the API-patterns mock with Sentry's results/cursor fields. ## Validation - Regression failed with the old parser and passed with the SDK parser. - pnpm install --frozen-lockfile, pnpm run tsc, pnpm run lint, docs:check, check:generated and Oxfmt check passed. - Full workspace test run on the initial base passed (CLI 10,529; MCP-core 1,753; MCP-server 84; MCP test-client 80; MCP Cloudflare 433; toolkit-core 23). After integrating the latest main, MCP-core passed 1,755 tests, with typecheck and lint passing on the combined tree. Co-authored-by: GPT-6 Sol <agent@openai.com>
1 parent dfaa8c8 commit c2cd6a2

5 files changed

Lines changed: 86 additions & 71 deletions

File tree

‎docs/contributing/api-patterns.md‎

Lines changed: 29 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@ pagination behavior, feature-gate handling, and test fixtures.
2626
```typescript
2727
// Standard usage with context helper
2828
const apiService = apiServiceFromContext(context, {
29-
regionUrl: params.regionUrl
29+
regionUrl: params.regionUrl,
3030
});
3131

3232
// Direct instantiation
3333
const api = new SentryApiService({
3434
host: "sentry.io",
35-
accessToken: token
35+
accessToken: token,
3636
});
3737
```
3838

@@ -47,19 +47,19 @@ tools.
4747
const issues = await api.issues.list({
4848
organizationSlug: "org",
4949
query: "is:unresolved",
50-
sort: "date"
50+
sort: "date",
5151
});
5252

5353
// Get specific resource
5454
const project = await api.projects.get({
5555
organizationSlug: "org",
56-
projectIdOrSlug: "frontend"
56+
projectIdOrSlug: "frontend",
5757
});
5858

5959
// Create/update
6060
await api.issues.update({
6161
issueId: "123",
62-
status: "resolved"
62+
status: "resolved",
6363
});
6464
```
6565

@@ -94,7 +94,7 @@ const orgs = await api.organizations.list();
9494

9595
// Use region URL
9696
const api = apiServiceFromContext(context, {
97-
regionUrl: org.region_url
97+
regionUrl: org.region_url,
9898
});
9999
```
100100

@@ -105,17 +105,15 @@ const api = apiServiceFromContext(context, {
105105
```typescript
106106
// Support ID variations
107107
const IssueIdSchema = z.union([
108-
z.string(), // "PROJ-123"
109-
z.number() // 123456789
108+
z.string(), // "PROJ-123"
109+
z.number(), // 123456789
110110
]);
111111

112112
// Partial with passthrough for unknowns
113-
const FlexibleSchema = BaseSchema
114-
.partial()
115-
.passthrough();
113+
const FlexibleSchema = BaseSchema.partial().passthrough();
116114

117115
// Nullable handling
118-
z.union([DateSchema, z.null()])
116+
z.union([DateSchema, z.null()]);
119117
```
120118

121119
See Zod patterns in [common-patterns.md](common-patterns.md#zod-schema-patterns).
@@ -146,11 +144,11 @@ export const handlers = [
146144
if (!params.org) {
147145
return HttpResponse.json("Invalid org", { status: 400 });
148146
}
149-
147+
150148
// Return fixture
151149
return HttpResponse.json(issueListFixture);
152-
}
153-
}
150+
},
151+
},
154152
];
155153
```
156154

@@ -162,19 +160,17 @@ See: `packages/mcp-server-mocks/src/handlers/`
162160
fetch: async ({ request }) => {
163161
const url = new URL(request.url);
164162
const query = url.searchParams.get("query");
165-
163+
166164
// Validate query parameters
167165
if (query && !isValidQuery(query)) {
168166
return HttpResponse.json("Invalid query", { status: 400 });
169167
}
170-
168+
171169
// Filter based on query
172-
const filtered = fixtures.filter(item =>
173-
matchesQuery(item, query)
174-
);
175-
170+
const filtered = fixtures.filter((item) => matchesQuery(item, query));
171+
176172
return HttpResponse.json(filtered);
177-
}
173+
};
178174
```
179175

180176
### Dynamic Responses
@@ -189,11 +185,15 @@ const page = fixtures.slice(start, start + limit);
189185

190186
return HttpResponse.json(page, {
191187
headers: {
192-
"Link": `<...?cursor=${start + limit}>; rel="next"`
193-
}
188+
Link: `<https://sentry.io/api/0/issues/?cursor=${start + limit}>; rel="next"; results="true"; cursor="${start + limit}"`,
189+
},
194190
});
195191
```
196192

193+
Use `@sentry/api`'s `parseSentryLinkHeader` to read the next cursor. Sentry's
194+
`Link` header reports whether the next page has results; a URL query alone does
195+
not establish that another page exists.
196+
197197
## Testing with Mocks
198198

199199
### Setup Pattern
@@ -213,13 +213,12 @@ afterAll(() => server.close());
213213
```typescript
214214
it("handles errors", async () => {
215215
server.use(
216-
http.get("*/issues/", () =>
217-
HttpResponse.json({ error: "Server error" }, { status: 500 })
218-
)
216+
http.get("*/issues/", () =>
217+
HttpResponse.json({ error: "Server error" }, { status: 500 }),
218+
),
219219
);
220-
221-
await expect(api.issues.list(params))
222-
.rejects.toThrow(ApiError);
220+
221+
await expect(api.issues.list(params)).rejects.toThrow(ApiError);
223222
});
224223
```
225224

‎packages/mcp-core/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@
173173
"@logtape/sentry": "^1.1.1",
174174
"@modelcontextprotocol/sdk": "catalog:",
175175
"@modelcontextprotocol/server": "catalog:",
176+
"@sentry/api": "0.337.0",
176177
"@sentry/core": "catalog:",
177178
"@sentry/toolkit-core": "workspace:*",
178179
"ai": "catalog:",

‎packages/mcp-core/src/api-client/client.test.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ describe("API bearer token validation", () => {
4444
accessToken: "valid\nsecret",
4545
});
4646

47-
const error = await api.listOrganizations().catch((cause: unknown) => cause);
47+
const error = await api
48+
.listOrganizations()
49+
.catch((cause: unknown) => cause);
4850
expect(error).toBeInstanceOf(ConfigurationError);
4951
expect(String(error)).toContain("Malformed authentication token");
5052
expect(String(error)).not.toContain("valid\nsecret");
@@ -54,7 +56,9 @@ describe("API bearer token validation", () => {
5456
host: "sentry.example.com",
5557
accessToken: "",
5658
});
57-
await expect(emptyTokenApi.listOrganizations()).rejects.toThrow(ConfigurationError);
59+
await expect(emptyTokenApi.listOrganizations()).rejects.toThrow(
60+
ConfigurationError,
61+
);
5862
expect(requests).toBe(0);
5963
});
6064
});
@@ -303,6 +307,29 @@ describe("external issue linking API methods", () => {
303307
expect(pages).toEqual([null, "next-page"]);
304308
});
305309

310+
it("uses the actual next link rather than a previous link's extension attribute", async () => {
311+
const pages: (string | null)[] = [];
312+
mswServer.use(
313+
http.get(
314+
"https://us.sentry.io/api/0/organizations/test-org/issues/123/integrations/",
315+
({ request }) => {
316+
const cursor = new URL(request.url).searchParams.get("cursor");
317+
pages.push(cursor);
318+
return HttpResponse.json([], {
319+
headers: cursor
320+
? {}
321+
: {
322+
Link: '<https://us.sentry.io/>; x-rel="next"; rel="previous"; results="true"; cursor="previous-page", <https://us.sentry.io/>; rel="next"; results="true"; cursor="next-page"',
323+
},
324+
});
325+
},
326+
),
327+
);
328+
329+
await api.listIssueIntegrations({ organizationSlug, issueId });
330+
expect(pages).toEqual([null, "next-page"]);
331+
});
332+
306333
it("finds App associations beyond the first page", async () => {
307334
mswServer.use(
308335
http.get(

‎packages/mcp-core/src/api-client/client.ts‎

Lines changed: 19 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { parseSentryLinkHeader } from "@sentry/api";
12
import { normalizeAuthToken } from "@sentry/toolkit-core/auth-token";
23
import { z } from "zod";
34
import { DEFAULT_SEARCH_ISSUES_PERIOD } from "../constants";
@@ -260,22 +261,7 @@ function parseStatsPeriod(statsPeriod: string): {
260261
}
261262

262263
function getNextCursor(linkHeader: string | null): string | null {
263-
if (!linkHeader) {
264-
return null;
265-
}
266-
267-
for (const link of linkHeader.split(",")) {
268-
if (!link.includes('rel="next"') || !link.includes('results="true"')) {
269-
continue;
270-
}
271-
272-
const cursorMatch = link.match(/cursor="([^"]+)"/);
273-
if (cursorMatch?.[1]) {
274-
return cursorMatch[1];
275-
}
276-
}
277-
278-
return null;
264+
return parseSentryLinkHeader(linkHeader).nextCursor ?? null;
279265
}
280266

281267
/**
@@ -424,26 +410,22 @@ const EventsValidationIssueSchema = z
424410
valid: z.boolean(),
425411
error: ValidationErrorSchema,
426412
})
427-
.transform(
428-
({ valid, error }): EventsValidationIssue => ({
429-
valid,
430-
...(error ? { error } : {}),
431-
}),
432-
);
413+
.transform(({ valid, error }): EventsValidationIssue => ({
414+
valid,
415+
...(error ? { error } : {}),
416+
}));
433417

434418
const EventsNamedValidationIssueSchema = z
435419
.object({
436420
name: z.string(),
437421
valid: z.boolean(),
438422
error: ValidationErrorSchema,
439423
})
440-
.transform(
441-
({ name, valid, error }): EventsNamedValidationIssue => ({
442-
name,
443-
valid,
444-
...(error ? { error } : {}),
445-
}),
446-
);
424+
.transform(({ name, valid, error }): EventsNamedValidationIssue => ({
425+
name,
426+
valid,
427+
...(error ? { error } : {}),
428+
}));
447429

448430
const EventsAttributeValidationSchema = z
449431
.object({
@@ -474,13 +456,11 @@ const EventsQueryValidationSchema = z
474456
error: ValidationErrorSchema,
475457
fields: EventsAttributeValidationListSchema,
476458
})
477-
.transform(
478-
({ valid, error, fields }): EventsQueryValidation => ({
479-
valid,
480-
fields,
481-
...(error ? { error } : {}),
482-
}),
483-
);
459+
.transform(({ valid, error, fields }): EventsQueryValidation => ({
460+
valid,
461+
fields,
462+
...(error ? { error } : {}),
463+
}));
484464

485465
const EventsValidationResponseSchema = z
486466
.object({
@@ -1331,9 +1311,9 @@ export class SentryApiService {
13311311
private isAggregateExplorerQuery(params: ExplorerAggregateParams): boolean {
13321312
return Boolean(
13331313
params.aggregateFunctions?.length ||
1334-
params.fields?.some(
1335-
(field) => field.includes("(") && field.includes(")"),
1336-
),
1314+
params.fields?.some(
1315+
(field) => field.includes("(") && field.includes(")"),
1316+
),
13371317
);
13381318
}
13391319

‎pnpm-lock.yaml‎

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)