Skip to content

Commit fc1bafe

Browse files
hifi-philclaude
andcommitted
feat: add withCursorPagination to withStandardDecorators
Moves cursor pagination into the standard decorator chain so all hosts (stdio, hosted, any future) get it automatically. The return type uses CursorPaginatedArgs<Args> to correctly reflect the schema transformation (skip/take → optional cursor) at the TypeScript level. Updates template list-examples test to use cursor-based pagination. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c2e80ad commit fc1bafe

7 files changed

Lines changed: 88 additions & 61 deletions

File tree

.github/workflows/test.yml

Lines changed: 41 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -247,51 +247,49 @@ jobs:
247247
env:
248248
KEEP_E2E_ASSETS: 'true'
249249

250-
# Temporarily disabled — API credit balance exhausted
251-
# - name: Skill E2E (build tool + integration test)
252-
# if: >-
253-
# github.event.pull_request.base.ref == 'main' &&
254-
# startsWith(github.event.pull_request.head.ref, 'release/')
255-
# run: npm run test:e2e:skills -w packages/create-mcp-server
256-
# timeout-minutes: 10
257-
# env:
258-
# ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
259-
# CLAUDECODE: ''
250+
- name: Skill E2E (build tool + integration test)
251+
if: >-
252+
github.event.pull_request.base.ref == 'main' &&
253+
startsWith(github.event.pull_request.head.ref, 'release/')
254+
run: npm run test:e2e:skills -w packages/create-mcp-server
255+
timeout-minutes: 10
256+
env:
257+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
258+
CLAUDECODE: ''
260259

261260
- name: Clean up E2E assets
262261
if: always()
263262
run: npm run test:e2e:cleanup -w packages/create-mcp-server || true
264263

265-
# Temporarily disabled — API credit balance exhausted
266-
# evals:
267-
# name: LLM Eval Tests
268-
# if: >-
269-
# github.event.pull_request.base.ref == 'main' &&
270-
# startsWith(github.event.pull_request.head.ref, 'release/')
271-
# runs-on: ubuntu-latest
272-
#
273-
# env:
274-
# NODE_TLS_REJECT_UNAUTHORIZED: '0'
275-
# ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
276-
#
277-
# steps:
278-
# - name: Checkout
279-
# uses: actions/checkout@v4
280-
#
281-
# - name: Setup Node.js
282-
# uses: actions/setup-node@v4
283-
# with:
284-
# node-version: '22'
285-
# cache: 'npm'
286-
#
287-
# - name: Install npm dependencies
288-
# run: npm ci
289-
#
290-
# - name: Build SDK
291-
# run: npm run build
292-
#
293-
# - name: Build template
294-
# run: npm run build -w template
295-
#
296-
# - name: Run CLI eval tests
297-
# run: npm run test:cli:evals
264+
evals:
265+
name: LLM Eval Tests
266+
if: >-
267+
github.event.pull_request.base.ref == 'main' &&
268+
startsWith(github.event.pull_request.head.ref, 'release/')
269+
runs-on: ubuntu-latest
270+
271+
env:
272+
NODE_TLS_REJECT_UNAUTHORIZED: '0'
273+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
274+
275+
steps:
276+
- name: Checkout
277+
uses: actions/checkout@v4
278+
279+
- name: Setup Node.js
280+
uses: actions/setup-node@v4
281+
with:
282+
node-version: '22'
283+
cache: 'npm'
284+
285+
- name: Install npm dependencies
286+
run: npm ci
287+
288+
- name: Build SDK
289+
run: npm run build
290+
291+
- name: Build template
292+
run: npm run build -w template
293+
294+
- name: Run CLI eval tests
295+
run: npm run test:cli:evals

packages/mcp-server-sdk/src/helpers/cursor-pagination.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,15 @@ export interface CursorPaginationOptions {
121121
defaultPageSize?: number;
122122
}
123123

124+
/**
125+
* Conditional type that reflects the schema transformation.
126+
* If Args has both `skip` and `take`, replaces them with optional `cursor`.
127+
* Otherwise returns Args unchanged.
128+
*/
129+
export type CursorPaginatedArgs<Args> = Args extends { skip: any; take: any }
130+
? Omit<Args, "skip" | "take"> & { cursor?: z.ZodOptional<z.ZodString> }
131+
: Args;
132+
124133
// ============================================================================
125134
// Decorator
126135
// ============================================================================
@@ -141,19 +150,19 @@ export interface CursorPaginationOptions {
141150
* @returns Transformed tool definition (or original if not paginated)
142151
*/
143152
export function withCursorPagination<
144-
InputArgs extends ZodRawShape,
145-
OutputArgs extends undefined | ZodRawShape | ZodType,
153+
Args extends undefined | ZodRawShape,
154+
OutputArgs extends undefined | ZodRawShape | ZodType = undefined,
146155
>(
147-
tool: ToolDefinition<InputArgs, OutputArgs>,
156+
tool: ToolDefinition<Args, OutputArgs>,
148157
options?: CursorPaginationOptions
149-
): ToolDefinition<ZodRawShape, OutputArgs> {
158+
): ToolDefinition<CursorPaginatedArgs<Args>, OutputArgs> {
150159
// Detection: only apply if inputSchema has both skip and take
151160
if (
152161
!tool.inputSchema ||
153162
!("skip" in tool.inputSchema) ||
154163
!("take" in tool.inputSchema)
155164
) {
156-
return tool;
165+
return tool as ToolDefinition<CursorPaginatedArgs<Args>, OutputArgs>;
157166
}
158167

159168
const defaultPageSize = options?.defaultPageSize ?? DEFAULT_PAGE_SIZE;
@@ -205,7 +214,7 @@ export function withCursorPagination<
205214
...tool,
206215
inputSchema: newInputSchema,
207216
outputSchema: newOutputSchema,
208-
handler: async (args: any, extra: any) => {
217+
handler: (async (args: any, extra: any) => {
209218
// Decode cursor or use defaults
210219
const { cursor, ...restArgs } = args;
211220
let skipVal = 0;
@@ -276,6 +285,6 @@ export function withCursorPagination<
276285
}
277286

278287
return result;
279-
},
280-
};
288+
}),
289+
} as ToolDefinition<CursorPaginatedArgs<Args>, OutputArgs>;
281290
}

packages/mcp-server-sdk/src/helpers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,5 @@ export {
103103
decodeCursor,
104104
computeNextCursor,
105105
type CursorPaginationOptions,
106+
type CursorPaginatedArgs,
106107
} from "./cursor-pagination.js";

packages/mcp-server-sdk/src/helpers/tool-decorators.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { UmbracoApiError } from "./api-call-helpers.js";
1313
import { ToolValidationError } from "./tool-validation-error.js";
1414
import { withInputSanitization } from "./input-sanitizer.js";
1515
import { withDryRun } from "./dry-run.js";
16+
import { withCursorPagination, type CursorPaginatedArgs } from "./cursor-pagination.js";
1617

1718
// Re-export everything from split modules for convenience
1819
export {
@@ -211,15 +212,28 @@ export function createToolAnnotations(tool: ToolDefinition<any, any>): ToolAnnot
211212

212213
/**
213214
* Standard decorator composition for all tools.
214-
* Applies: withErrorHandling → withInputSanitization → withDryRun → withPreExecutionCheck → handler
215+
* Applies: withErrorHandling → withCursorPagination → withInputSanitization → withDryRun → withPreExecutionCheck → handler
215216
*
216-
* Input sanitization runs before dry-run so agents get validation feedback even in dry-run mode.
217+
* Cursor pagination is applied after the inner decorators but before error handling,
218+
* so cursor decode errors are caught. Only affects tools with skip/take in their
219+
* inputSchema — all others pass through unchanged.
220+
*
221+
* The return type reflects the schema transformation: tools with skip/take get
222+
* CursorPaginatedArgs (skip/take replaced with optional cursor).
217223
*
218224
* @example
219225
* export default withStandardDecorators(myTool);
220226
*/
221227
export function withStandardDecorators<Args extends undefined | ZodRawShape, OutputArgs extends undefined | ZodRawShape | ZodType = undefined>(
222228
tool: ToolDefinition<Args, OutputArgs>
223-
): ToolDefinition<Args, OutputArgs> {
224-
return compose<Args, OutputArgs>(withErrorHandling, withInputSanitization, withDryRun, withPreExecutionCheck)(tool);
229+
): ToolDefinition<CursorPaginatedArgs<Args>, OutputArgs> {
230+
// Applied in three steps rather than a single compose() because
231+
// withCursorPagination changes the type signature (skip/take → cursor),
232+
// which compose() can't express since it requires uniform types throughout.
233+
//
234+
// Execution order (innermost → outermost):
235+
// 1. preExecutionCheck → 2. dryRun → 3. inputSanitization → 4. cursorPagination → 5. errorHandling
236+
const decorated = compose<Args, OutputArgs>(withInputSanitization, withDryRun, withPreExecutionCheck)(tool);
237+
const paginated = withCursorPagination(decorated);
238+
return withErrorHandling(paginated);
225239
}

packages/mcp-server-sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export {
107107
decodeCursor,
108108
computeNextCursor,
109109
type CursorPaginationOptions,
110+
type CursorPaginatedArgs,
110111
} from "./helpers/cursor-pagination.js";
111112

112113
// CLI Introspection & Context Generation

template/src/umbraco-api/tools/example/__tests__/list-examples.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ExampleBuilder,
99
ExampleTestHelper,
1010
} from "./setup.js";
11+
import { encodeCursor } from "@umbraco-cms/mcp-server-sdk";
1112
import listExamplesTool from "../get/list-examples.js";
1213

1314
describe("list-examples", () => {
@@ -26,7 +27,7 @@ describe("list-examples", () => {
2627
it("should return paginated list", async () => {
2728
const context = createMockRequestHandlerExtra();
2829

29-
const result = await listExamplesTool.handler({ skip: 0, take: 100 }, context);
30+
const result = await listExamplesTool.handler({}, context);
3031

3132
expect(result.structuredContent).toBeDefined();
3233
const content = result.structuredContent as any;
@@ -35,15 +36,18 @@ describe("list-examples", () => {
3536
expect(Array.isArray(content.items)).toBe(true);
3637
});
3738

38-
it("should support pagination parameters", async () => {
39+
it("should support cursor-based pagination", async () => {
3940
const context = createMockRequestHandlerExtra();
4041

42+
// Request 2 items via cursor encoding
43+
const cursor = encodeCursor({ s: 0, t: 2 });
4144
const result = await listExamplesTool.handler(
42-
{ skip: 0, take: 2 },
45+
{ cursor },
4346
context
4447
);
4548

4649
const content = result.structuredContent as any;
4750
expect(content.items.length).toBe(2);
51+
expect(content.nextCursor).toBeDefined();
4852
});
4953
});

template/src/umbraco-api/tools/example/__tests__/search-examples.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe("search-examples", () => {
3636
const context = createMockRequestHandlerExtra();
3737

3838
const result = await searchExamplesTool.handler(
39-
{ query: "Search", skip: 0, take: 100 },
39+
{ query: "Search" },
4040
context
4141
);
4242

@@ -50,7 +50,7 @@ describe("search-examples", () => {
5050
const context = createMockRequestHandlerExtra();
5151

5252
const result = await searchExamplesTool.handler(
53-
{ query: "First item", skip: 0, take: 100 },
53+
{ query: "First item" },
5454
context
5555
);
5656

@@ -62,7 +62,7 @@ describe("search-examples", () => {
6262
const context = createMockRequestHandlerExtra();
6363

6464
const result = await searchExamplesTool.handler(
65-
{ query: "NonExistentQuery12345", skip: 0, take: 100 },
65+
{ query: "NonExistentQuery12345" },
6666
context
6767
);
6868

0 commit comments

Comments
 (0)