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
68 changes: 68 additions & 0 deletions examples/servers/typescript/invalid-tool-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env node
Comment thread
canardleteer marked this conversation as resolved.

/**
* Negative fixture for the tools-name-format check: advertises tool names that
* violate the 2025-11-25 Tool Names rules (spec #tool-names) by bypassing the
* SDK's registerTool validation, so the check can be shown to emit WARNING.
*/

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import express from 'express';

function createServer() {
const server = new Server(
{ name: 'invalid-tool-names', version: '1.0.0' },
{ capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'bad tool name',
description: 'Deliberately invalid tool name for conformance testing',
inputSchema: { type: 'object' }
},
{
name: 'valid_tool',
description: 'A conformant tool name',
inputSchema: { type: 'object' }
}
]
}));

return server;
}

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

app.post('/mcp', async (req, res) => {
try {
const server = createServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: `Internal error: ${error instanceof Error ? error.message : String(error)}`
},
id: null
});
}
}
});

const PORT = parseInt(process.env.PORT || '3009', 10);
app.listen(PORT, '127.0.0.1', () => {
console.log(
`Invalid tool names negative test server running on http://localhost:${PORT}/mcp`
);
});
36 changes: 36 additions & 0 deletions src/scenarios/server/negative.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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 @@ -216,6 +217,41 @@ describe('Server scenario negative tests', () => {
}, 10000);
});

describe('tools-name-format', () => {
let serverProcess: ChildProcess | null = null;
const PORT = 3009;

beforeAll(async () => {
serverProcess = await startServer(
path.join(
process.cwd(),
'examples/servers/typescript/invalid-tool-names.ts'
),
PORT
);
}, 35000);

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

it('emits WARNING for tools-name-format against a server advertising invalid tool names', async () => {
const scenario = new ToolsListScenario();
const checks = await scenario.run(
testContext(`http://localhost:${PORT}/mcp`, '2025-11-25')
);

const formatCheck = checks.find((c) => c.id === 'tools-name-format');
expect(formatCheck?.status).toBe('WARNING');
Comment thread
canardleteer marked this conversation as resolved.
expect(formatCheck?.errorMessage).toContain('bad tool name');
expect(formatCheck?.details).toMatchObject({
results: expect.objectContaining({
'bad tool name': expect.stringMatching(/invalid:/)
})
});
}, 10000);
});

describe('sep2106KeywordCheckStatus (soft version gate)', () => {
it('passes preserved keywords at any target version', () => {
expect(sep2106KeywordCheckStatus(true, DRAFT_PROTOCOL_VERSION)).toBe(
Expand Down
122 changes: 105 additions & 17 deletions src/scenarios/server/tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,42 @@
import { describe, it, expect } from 'vitest';
import { buildToolsNameFormatCheck, validateToolNameFormat } from './tools.js';
import {
buildToolsNameFormatCheck,
toolNameFormatCheckApplies,
ToolsListScenario,
validateToolNameFormat
} from './tools.js';
import type { Connection, RunContext } from '../../connection';

describe('validateToolNameFormat', () => {
it('accepts a typical snake_case name', () => {
expect(validateToolNameFormat('test_simple_text')).toBeNull();
});

it('accepts all allowed character classes', () => {
expect(validateToolNameFormat('Aa0_.-/')).toBeNull();
expect(validateToolNameFormat('Aa0_.-')).toBeNull();
});

it('accepts a single-character name (lower length boundary)', () => {
expect(validateToolNameFormat('a')).toBeNull();
});

it('accepts a 64-character name (upper length boundary)', () => {
expect(validateToolNameFormat('a'.repeat(64))).toBeNull();
it('accepts a 128-character name (upper length boundary)', () => {
expect(validateToolNameFormat('a'.repeat(128))).toBeNull();
});

it('rejects an empty name', () => {
expect(validateToolNameFormat('')).toMatch(/length 0 is outside/);
});

it('rejects a 65-character name', () => {
expect(validateToolNameFormat('a'.repeat(65))).toMatch(
/length 65 is outside/
it('rejects a 129-character name', () => {
expect(validateToolNameFormat('a'.repeat(129))).toMatch(
/length 129 is outside/
);
});

it('rejects forward slash (allowed in SEP-986 markdown but not spec prose)', () => {
expect(validateToolNameFormat('namespace/tool')).toMatch(
/contains characters outside/
);
});

Expand Down Expand Up @@ -57,51 +69,127 @@ describe('buildToolsNameFormatCheck', () => {
it('returns SUCCESS when all tool names are valid', () => {
const check = buildToolsNameFormatCheck([
{ name: 'test_simple_text' },
{ name: 'namespace/tool-v1.2' }
{ name: 'admin.tools.list' }
]);
expect(check.status).toBe('SUCCESS');
expect(check.errorMessage).toBeUndefined();
expect(check.details).toMatchObject({
toolCount: 2,
results: {
test_simple_text: 'valid',
'namespace/tool-v1.2': 'valid'
'admin.tools.list': 'valid'
}
});
});

it('returns FAILURE with per-tool details when some names are invalid', () => {
it('returns WARNING with per-tool details when some names are invalid', () => {
const check = buildToolsNameFormatCheck([
{ name: 'good_tool' },
{ name: 'bad name with spaces' },
{ name: 'a'.repeat(65) }
{ name: 'a'.repeat(129) }
]);

expect(check.status).toBe('FAILURE');
expect(check.status).toBe('WARNING');
expect(check.errorMessage).toContain(
'2 tool name(s) violate SEP-986 format'
'2 tool name(s) violate spec Tool Names SHOULD rules'
);

const results = (check.details as { results: Record<string, string> })
.results;
expect(results['good_tool']).toBe('valid');
expect(results['bad name with spaces']).toMatch(/^invalid: /);
expect(results['a'.repeat(65)]).toMatch(/^invalid: length 65/);
expect(results['a'.repeat(129)]).toMatch(/^invalid: length 129/);
});

it('flags a tool whose name is not a string', () => {
const check = buildToolsNameFormatCheck([{ name: 123 as unknown }]);
expect(check.status).toBe('FAILURE');
expect(check.status).toBe('WARNING');
const results = (check.details as { results: Record<string, string> })
.results;
expect(results['<tool[0] missing name>']).toBe(
'invalid: name is not a string'
);
});

it('includes both MCP-Tools-List and SEP-986 spec references', () => {
it('lists core spec Tool Names first, then SEP history for context', () => {
const check = buildToolsNameFormatCheck([{ name: 'ok' }]);
const ids = check.specReferences?.map((r) => r.id);
expect(ids).toEqual(['MCP-Tools-List', 'SEP-986']);
expect(ids).toEqual([
'MCP-Tool-Names',
'MCP-Tool-Names-Draft',
'SEP-986-History',
'SEP-986-Spec-Integration'
]);
expect(check.specReferences?.[0]?.url).toContain(
'2025-11-25/server/tools#tool-names'
);
expect(check.specReferences?.[1]?.url).toContain(
'draft/server/tools#tool-names'
);
expect(check.specReferences?.[2]?.url).toContain('issues/986');
expect(check.specReferences?.[3]?.url).toContain('pull/1603');
});
});

describe('toolNameFormatCheckApplies', () => {
it('is false before 2025-11-25 and true from that version onward', () => {
expect(toolNameFormatCheckApplies('2025-03-26')).toBe(false);
expect(toolNameFormatCheckApplies('2025-06-18')).toBe(false);
expect(toolNameFormatCheckApplies('2025-11-25')).toBe(true);
expect(toolNameFormatCheckApplies('2026-07-28')).toBe(true);
});
});

describe('ToolsListScenario version gate', () => {
// Scenario-level gate: invalid names must not emit the check before 2025-11-25.
function mockContext(
specVersion: RunContext['specVersion'],
tools: Array<{ name: string; description: string; inputSchema: object }>
): RunContext {
const connection: Connection = {
notifications: [],
request: (async () => ({ tools })) as Connection['request'],
discover: async () => ({}),
close: async () => {}
};

return {
serverUrl: 'http://example.test/mcp',
specVersion,
connect: async () => connection
};
}

it('does not emit tools-name-format on 2025-06-18 even when names violate 2025-11-25 rules', async () => {
const scenario = new ToolsListScenario();
const checks = await scenario.run(
mockContext('2025-06-18', [
{
name: 'bad name',
description: 'invalid under 2025-11-25 rules',
inputSchema: { type: 'object' }
}
])
);

expect(checks.some((c) => c.id === 'tools-name-format')).toBe(false);
expect(checks.find((c) => c.id === 'tools-list')?.status).toBe('SUCCESS');
});

it('emits tools-name-format on 2025-11-25 when names violate spec prose', async () => {
const scenario = new ToolsListScenario();
const checks = await scenario.run(
mockContext('2025-11-25', [
{
name: 'bad name',
description: 'invalid under 2025-11-25 rules',
inputSchema: { type: 'object' }
}
])
);

expect(checks.find((c) => c.id === 'tools-name-format')?.status).toBe(
'WARNING'
);
});
});
Loading
Loading