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

/**
* Negative test server for spec Tool Names SHOULD rules (2025-11-25+).
*
* AGENTS.md negative-fixture pattern: bypass SDK registerTool validation via
* setRequestHandler(ListToolsRequestSchema) so tools/list advertises a name
* that violates core spec prose at #tool-names. Proves tools-name-format emits
* WARNING (SHOULD-level per AGENTS.md), not FAILURE. everything-server unchanged.
*
* ## Why this file is not named after SEP-986
*
* Tool name format rules trace to [SEP-986](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986)
* (opened 2025-07-16), but the **authoritative rules live in dated spec prose**,
* not the SEP markdown artifact. That split is a process wart worth preserving:
*
* - **SEP-986 markdown** (`seps/986-specify-format-for-tool-names.md`) still
* documents stale rules: 1–64 chars, `[A-Za-z0-9_./-]` including `/`. The
* finalized SEP file was **never updated** after spec integration.
* - **Integrated spec** ([PR #1603](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1603),
* Oct 2025) landed different prose in draft, then **2025-11-25** `#tool-names`:
* 1–128 chars, `[A-Za-z0-9_.-]` only (no `/`).
* - **Conformance #240** incorrectly encoded the stale SEP markdown (64 + `/`,
* FAILURE severity). This suite follows the integrated spec diff per AGENTS.md;
* see `tools.ts` block comment and `specReferences` (core URLs first, SEP links
* context-only). There is intentionally **no `sep-986.yaml`** traceability row.
*
* Naming the fixture after the SEP would imply traceability we deliberately
* declined — and would obscure that the SEP artifact and published spec diverged.
*/

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`
);
});
37 changes: 37 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';

function startServer(scriptPath: string, port: number): Promise<ChildProcess> {
Expand Down Expand Up @@ -211,6 +212,42 @@ describe('Server scenario negative tests', () => {
}, 10000);
});

describe('tools-name-format', () => {
// AGENTS.md: negative vitest pins tools-name-format → WARNING, not failures.length.
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
123 changes: 106 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,128 @@ 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', () => {
// Locks version gate: no tools-name-format on 2025-03-26 / 2025-06-18 (AGENTS.md).
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