Skip to content
Merged
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`
);
});
39 changes: 38 additions & 1 deletion src/scenarios/server/negative.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ 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
} from './json-schema-2020-12';
import { ToolsListScenario } from './tools';
import { DRAFT_PROTOCOL_VERSION, LATEST_SPEC_VERSION } from '../../types';
import { takeWireViolations } from '../../validation/wire-schema';

Expand Down Expand Up @@ -217,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('tools-name-format', () => {
let serverProcess: ChildProcess | null = null;
const PORT = 3009;
Expand Down
141 changes: 141 additions & 0 deletions src/scenarios/server/tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
buildToolsListDeterministicOrderCheck,
buildToolsNameFormatCheck,
toolNameFormatCheckApplies,
ToolsListScenario,
Expand Down Expand Up @@ -193,3 +194,143 @@ describe('ToolsListScenario version gate', () => {
);
});
});

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
Loading