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
76 changes: 76 additions & 0 deletions examples/servers/typescript/tools-list-rotated-order.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env node

/**
* tools/list ordering negative test server.
*
* Speaks the sessionless 2026-07-28 wire (SEP-2575) and advertises the same
* four tools on every tools/list request, but rotates the list by one
* position each time (it keeps a call counter for that, nothing else). The set never changes, only the order, which violates the
* 2026-07-28 SHOULD "Servers SHOULD return tools in a deterministic order".
* The tools-list scenario should emit WARNING for
* tools-list-deterministic-order against this server while tools-list itself
* still passes, since every response is structurally valid.
*/

import express from 'express';

const app = express();
app.use(express.json());

const TOOLS = ['alpha', 'bravo', 'charlie', 'delta'].map((name) => ({
name,
description: `Fixture tool ${name}`,
inputSchema: { type: 'object', properties: {} }
}));

let listCalls = 0;

app.post('/mcp', (req, res) => {
const body = req.body || {};
const id = body.id ?? null;
const method = body.method;

switch (method) {
case 'server/discover':
return res.json({
jsonrpc: '2.0',
id,
result: {
resultType: 'complete',
ttlMs: 0,
cacheScope: 'private',
supportedVersions: ['2026-07-28'],
capabilities: { tools: {} },
serverInfo: { name: 'tools-list-rotated-order', version: '1.0.0' }
}
});
case 'tools/list': {
// Rotate by one position per call: the same set, never the same order.
const offset = listCalls++ % TOOLS.length;
const tools = [...TOOLS.slice(offset), ...TOOLS.slice(0, offset)];
return res.json({
jsonrpc: '2.0',
id,
result: {
resultType: 'complete',
ttlMs: 0,
cacheScope: 'private',
tools
}
});
}
default:
return res.status(404).json({
jsonrpc: '2.0',
id,
error: { code: -32601, message: 'Method not found' }
});
}
});

const PORT = parseInt(process.env.PORT || '3008', 10);
app.listen(PORT, '127.0.0.1', () => {
console.log(
`tools/list rotated-order negative test server running on http://localhost:${PORT}/mcp`
);
});
38 changes: 38 additions & 0 deletions src/scenarios/server/negative.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'path';
import { DNSRebindingProtectionScenario } from './dns-rebinding';
import { ResourcesNotFoundErrorScenario } from './resources';
import { CachingScenario } from './caching';
import { ToolsListScenario } from './tools';
import {
JsonSchema2020_12Scenario,
sep2106KeywordCheckStatus
Expand Down Expand Up @@ -216,6 +217,43 @@ describe('Server scenario negative tests', () => {
}, 10000);
});

describe('tools-list-deterministic-order', () => {
let serverProcess: ChildProcess | null = null;
const PORT = 3008;

beforeAll(async () => {
serverProcess = await startServer(
path.join(
process.cwd(),
'examples/servers/typescript/tools-list-rotated-order.ts'
),
PORT
);
}, 35000);

afterAll(async () => {
await stopServer(serverProcess);
});

it('emits WARNING for deterministic-order while tools-list still passes against a server that rotates its tool list', async () => {
const scenario = new ToolsListScenario();
const checks = await scenario.run(
testContext(`http://localhost:${PORT}/mcp`, DRAFT_PROTOCOL_VERSION)
);

const list = checks.find((c) => c.id === 'tools-list');
expect(list?.status).toBe('SUCCESS');

const order = checks.find(
(c) => c.id === 'tools-list-deterministic-order'
);
expect(order?.status).toBe('WARNING');
expect(order?.errorMessage).toMatch(/different order/);
expect(order?.details).toMatchObject({ toolCount: 4, probes: 3 });
expect(order?.details?.untestable).toBeUndefined();
}, 10000);
});

describe('sep2106KeywordCheckStatus (soft version gate)', () => {
it('passes preserved keywords at any target version', () => {
expect(sep2106KeywordCheckStatus(true, DRAFT_PROTOCOL_VERSION)).toBe(
Expand Down
146 changes: 145 additions & 1 deletion src/scenarios/server/tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest';
import { buildToolsNameFormatCheck, validateToolNameFormat } from './tools.js';
import {
buildToolsListDeterministicOrderCheck,
buildToolsNameFormatCheck,
validateToolNameFormat
} from './tools.js';

describe('validateToolNameFormat', () => {
it('accepts a typical snake_case name', () => {
Expand Down Expand Up @@ -105,3 +109,143 @@ describe('buildToolsNameFormatCheck', () => {
expect(ids).toEqual(['MCP-Tools-List', 'SEP-986']);
});
});

describe('buildToolsListDeterministicOrderCheck', () => {
const names = (...ns: string[]) => ns.map((name) => ({ name }));

it('returns SUCCESS when every probe lists the same tools in the same order', () => {
const check = buildToolsListDeterministicOrderCheck([
names('a', 'b', 'c'),
names('a', 'b', 'c'),
names('a', 'b', 'c')
]);
expect(check.id).toBe('tools-list-deterministic-order');
expect(check.status).toBe('SUCCESS');
expect(check.errorMessage).toBeUndefined();
expect(check.details).toEqual({
toolCount: 3,
probes: 3,
orders: [
['a', 'b', 'c'],
['a', 'b', 'c'],
['a', 'b', 'c']
]
});
expect(check.specReferences?.[0]?.url).toContain('2026-07-28/server/tools');
});

it('returns WARNING when the same tools come back in a different order', () => {
const check = buildToolsListDeterministicOrderCheck([
names('a', 'b', 'c'),
names('b', 'c', 'a'),
names('c', 'a', 'b')
]);
expect(check.status).toBe('WARNING');
expect(check.errorMessage).toMatch(/different order/);
expect(check.errorMessage).toMatch(/index 0/);
expect(check.details).toMatchObject({ toolCount: 3, probes: 3 });
expect(check.details?.untestable).toBeUndefined();
expect(check.details?.orders).toEqual([
['a', 'b', 'c'],
['b', 'c', 'a'],
['c', 'a', 'b']
]);
});

it('flags a divergence that only appears on the last probe', () => {
const check = buildToolsListDeterministicOrderCheck([
names('a', 'b', 'c'),
names('a', 'b', 'c'),
names('a', 'c', 'b')
]);
expect(check.status).toBe('WARNING');
expect(check.errorMessage).toMatch(/probe 3/);
expect(check.errorMessage).toMatch(/index 1/);
});

it('reports untestable (WARNING) when the set of tools changed between probes', () => {
const check = buildToolsListDeterministicOrderCheck([
names('a', 'b', 'c'),
names('a', 'b', 'd')
]);
expect(check.status).toBe('WARNING');
expect(check.errorMessage).toMatch(/^Not testable: /);
expect(check.errorMessage).toMatch(/added: d/);
expect(check.errorMessage).toMatch(/removed: c/);
expect(check.details).toMatchObject({ untestable: true });
});

it('reports untestable (WARNING) when a probe returned no tools array', () => {
const check = buildToolsListDeterministicOrderCheck([
names('a', 'b'),
undefined
]);
expect(check.status).toBe('WARNING');
expect(check.errorMessage).toMatch(/^Not testable: /);
expect(check.details).toMatchObject({ untestable: true });
});

it('reports untestable (WARNING) with fewer than two probes', () => {
const check = buildToolsListDeterministicOrderCheck([names('a', 'b')]);
expect(check.status).toBe('WARNING');
expect(check.errorMessage).toMatch(/^Not testable: /);
});

it('returns INFO when no probe saw two tools to order', () => {
const one = buildToolsListDeterministicOrderCheck([names('a'), names('a')]);
expect(one.status).toBe('INFO');
expect(one.errorMessage).toMatch(/nothing to compare/);
expect(one.details).toEqual({
toolCount: 1,
probes: 2,
orders: [['a'], ['a']]
});
const none = buildToolsListDeterministicOrderCheck([[], []]);
expect(none.status).toBe('INFO');
});

it('reports untestable when a probe grows from one tool to two', () => {
const check = buildToolsListDeterministicOrderCheck([
names('a'),
names('a', 'b')
]);
expect(check.status).toBe('WARNING');
expect(check.errorMessage).toMatch(/^Not testable: /);
expect(check.errorMessage).toMatch(/added: b/);
});

it('treats a tool without a string name as one placeholder entry', () => {
const check = buildToolsListDeterministicOrderCheck([
[{ name: 'a' }, { name: 42 }],
[{ name: 42 }, { name: 'a' }]
]);
expect(check.status).toBe('WARNING');
expect(check.errorMessage).toMatch(/different order/);
expect(check.details?.orders).toEqual([
['a', '<tool missing name>'],
['<tool missing name>', 'a']
]);
});

it('treats duplicate names as a set change only when their multiplicity changes', () => {
const stable = buildToolsListDeterministicOrderCheck([
names('a', 'a', 'b'),
names('a', 'a', 'b')
]);
expect(stable.status).toBe('SUCCESS');
const changed = buildToolsListDeterministicOrderCheck([
names('a', 'a', 'b'),
names('a', 'b', 'b')
]);
expect(changed.status).toBe('WARNING');
expect(changed.errorMessage).toMatch(/^Not testable: /);
});

it('gates itself to the 2026-07-28 wire', () => {
const check = buildToolsListDeterministicOrderCheck([
names('a', 'b'),
names('a', 'b')
]);
expect(check.source).toEqual({ introducedIn: '2026-07-28' });
});
});
Loading