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
38 changes: 38 additions & 0 deletions examples/clients/typescript/everything-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,44 @@ async function runBasicClient(serverUrl: string): Promise<void> {

registerScenarios(['initialize', 'tools_call', 'tools-call'], runBasicClient);

// One complete enumeration: cursors are opaque strings, including ""; the
// presence of nextCursor, not page size or cursor truthiness, controls it.
// https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination
async function runPaginationClient(serverUrl: string): Promise<void> {
const protocolVersion = process.env.MCP_CONFORMANCE_PROTOCOL_VERSION;
const stateless = protocolVersion
? (STATELESS_SPEC_VERSIONS as readonly string[]).includes(protocolVersion)
: false;
const client = new Client(
{ name: 'pagination-client', version: '1.0.0' },
{ capabilities: {} }
);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
try {
if (!stateless) await client.connect(transport);
const tools: { name: string }[] = [];
let cursor: string | undefined;
do {
const params = cursor === undefined ? {} : { cursor };
const page = stateless
? await statelessRequest(serverUrl, 'tools/list', params)
: await client.listTools(params);
tools.push(...page.tools);
cursor = page.nextCursor;
} while (cursor !== undefined);
if (!tools.some((tool) => tool.name === 'add_numbers')) {
throw new Error('Paginated discovery did not return add_numbers');
}
const params = { name: 'add_numbers', arguments: { a: 2, b: 3 } };
if (stateless) await statelessRequest(serverUrl, 'tools/call', params);
else await client.callTool(params);
} finally {
await client.close();
}
}

registerScenario('client-pagination', runPaginationClient);

// SEP-2106: json-schema-ref-no-deref advertises a tool whose inputSchema
// contains a network-URI $ref. A conformant client lists tools normally and
// simply never fetches that URI. The scenario's mock only serves tools/list,
Expand Down
91 changes: 91 additions & 0 deletions examples/clients/typescript/pagination-broken-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Deliberately broken pagination strategies for the client-pagination scenario.
* MCP_PAGINATION_DEFECT selects a control; the default reproduces the truthiness
* condition reported in conformance#577. No language-model service is used.
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { sendStatelessRequest } from '../../../src/connection/stateless.js';
import { STATELESS_SPEC_VERSIONS } from '../../../src/connection/select.js';
import { LATEST_SPEC_VERSION, type SpecVersion } from '../../../src/types.js';
import { runAsCli } from './helpers/cliRunner.js';

export type PaginationDefect =
| 'truthy-cursor'
| 'trim-cursor'
| 'recover-trim'
| 'empty-page'
| 'past-end'
| 'inert'
| 'rediscover';

export async function runClient(
serverUrl: string,
defect: PaginationDefect = (process.env.MCP_PAGINATION_DEFECT ??
'truthy-cursor') as PaginationDefect
): Promise<void> {
if (defect === 'inert') return;
const specVersion = (process.env.MCP_CONFORMANCE_PROTOCOL_VERSION ??
LATEST_SPEC_VERSION) as SpecVersion;
const stateless = (STATELESS_SPEC_VERSIONS as readonly string[]).includes(
specVersion
);
const client = new Client(
{ name: 'pagination-control-client', version: '1.0.0' },
{ capabilities: {} }
);
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
type ToolPage = { tools: { name: string }[]; nextCursor?: string };
async function request(method: string, params: Record<string, unknown>) {
const response = await sendStatelessRequest(serverUrl, method, params, {
specVersion
});
if (response.body?.error || !response.body?.result) {
throw new Error(`${method}: ${JSON.stringify(response.body)}`);
}
return response.body.result;
}
async function list(cursor?: string): Promise<ToolPage> {
const params = cursor === undefined ? {} : { cursor };
return stateless
? ((await request('tools/list', params)) as ToolPage)
: client.listTools(params);
}
try {
if (!stateless) await client.connect(transport);
const tools: { name: string }[] = [];
let cursor: string | undefined;
for (let page = 0; page < 8; page++) {
const result = await list(cursor);
tools.push(...result.tools);
// One wrong condition is enough to stop on a valid empty-string cursor.
if (defect === 'truthy-cursor' && !result.nextCursor) break;
if (defect === 'empty-page' && result.tools.length === 0) break;
if (result.nextCursor === undefined) {
// Continue the SAME traversal once after the server declared its end.
if (defect === 'past-end') await list(cursor);
break;
}
if (defect === 'recover-trim' && result.nextCursor !== '') {
try {
await list(result.nextCursor.trim());
} catch {
// Recover with the advertised cursor; the earlier violation remains.
}
}
cursor =
defect === 'trim-cursor' ? result.nextCursor.trim() : result.nextCursor;
}
if (tools.some((tool) => tool.name === 'add_numbers')) {
const params = { name: 'add_numbers', arguments: { a: 2, b: 3 } };
if (stateless) await request('tools/call', params);
else await client.callTool(params);
}
// A NEW discovery after the requested traversal and tool call is allowed.
if (defect === 'rediscover') await list();
} finally {
await client.close();
}
}

runAsCli(runClient, import.meta.url);
150 changes: 150 additions & 0 deletions src/scenarios/client/pagination.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { describe, expect, test } from 'vitest';
import { getHandler } from '../../../examples/clients/typescript/everything-client';
import {
runClient,
type PaginationDefect
} from '../../../examples/clients/typescript/pagination-broken-client';
import { getScenario, listCoreScenarios } from '../index';
import { testScenarioContext } from '../../mock-server/testing';
import { DRAFT_PROTOCOL_VERSION, LATEST_SPEC_VERSION } from '../../types';
import { PaginationScenario } from './pagination';
import {
InlineClientRunner,
runClientAgainstScenario
} from './auth/test_helpers/testClient';

const EMPTY = 'pagination-empty-cursor';
const OPAQUE = 'pagination-opaque-cursor';
const TOOL = 'pagination-tool-discovered';
const END = 'pagination-stops-at-end';
const CHECK_IDS = [EMPTY, OPAQUE, TOOL, END];

test('client-pagination is registered in the core suite', () => {
expect(getScenario('client-pagination')).toBeInstanceOf(PaginationScenario);
expect(listCoreScenarios()).toContain('client-pagination');
});

describe.each([LATEST_SPEC_VERSION, DRAFT_PROTOCOL_VERSION] as const)(
'client-pagination at %s',
(specVersion) => {
test('everything-client completes the traversal with no skipped checks', async () => {
const handler = getHandler('client-pagination');
if (!handler) throw new Error('Missing client-pagination handler');
const checks = await runClientAgainstScenario(
new InlineClientRunner(handler),
'client-pagination',
{ specVersion }
);
expect(checks.map((check) => check.id)).toEqual(CHECK_IDS);
expect(checks.every((check) => check.status === 'SUCCESS')).toBe(true);
});

const faults: Array<{
defect: PaginationDefect;
failures: string[];
successes: string[];
}> = [
{
defect: 'truthy-cursor',
failures: CHECK_IDS,
successes: []
},
{
defect: 'trim-cursor',
failures: [OPAQUE, TOOL, END],
successes: [EMPTY]
},
{
defect: 'recover-trim',
failures: [OPAQUE],
successes: [EMPTY, TOOL, END]
},
{
defect: 'empty-page',
failures: [OPAQUE, TOOL, END],
successes: [EMPTY]
},
{
defect: 'past-end',
failures: [END],
successes: [EMPTY, OPAQUE, TOOL]
}
];
for (const { defect, failures, successes } of faults) {
test(`detects the ${defect} control`, async () => {
const checks = await runClientAgainstScenario(
new InlineClientRunner((url) => runClient(url, defect)),
'client-pagination',
{
specVersion,
expectedFailureSlugs: failures,
expectedSuccessSlugs: successes
}
);
expect(
checks
.filter((check) => check.status !== 'SUCCESS')
.map((check) => check.id)
).toEqual(failures);
if (defect === 'past-end') {
expect(checks.find((check) => check.id === END)?.status).toBe(
'WARNING'
);
}
});
}

test('an inert client cannot pass any unexercised check', async () => {
const checks = await runClientAgainstScenario(
new InlineClientRunner((url) => runClient(url, 'inert')),
'client-pagination',
{ specVersion, expectedFailureSlugs: CHECK_IDS }
);
expect(checks.map((check) => check.id)).toEqual(CHECK_IDS);
expect(checks.every((check) => check.details?.untestable === true)).toBe(
true
);
expect(checks.map((check) => check.status)).toEqual([
'FAILURE',
'FAILURE',
'FAILURE',
'WARNING'
]);
});

test('allows a new discovery after the requested traversal and tool call', async () => {
await runClientAgainstScenario(
new InlineClientRunner((url) => runClient(url, 'rediscover')),
'client-pagination',
{ specVersion }
);
});
}
);

test('getChecks is idempotent and the same scenario starts without old evidence', async () => {
const scenario = new PaginationScenario();
const urls = await scenario.start(testScenarioContext());
try {
const handler = getHandler('client-pagination');
if (!handler) throw new Error('Missing client-pagination handler');
await handler(urls.serverUrl);
const first = scenario.getChecks();
const second = scenario.getChecks();
expect(first.map((check) => [check.id, check.status])).toEqual(
second.map((check) => [check.id, check.status])
);
expect(first).toHaveLength(4);
expect(first.every((check) => check.status === 'SUCCESS')).toBe(true);
} finally {
await scenario.stop();
}
await scenario.start(testScenarioContext());
try {
expect(
scenario.getChecks().every((check) => check.details?.untestable)
).toBe(true);
} finally {
await scenario.stop();
}
});
Loading