Skip to content

Commit 90b2334

Browse files
authored
feat: Tool name format checks (SEP-986) (#240)
1 parent 88c087e commit 90b2334

2 files changed

Lines changed: 189 additions & 1 deletion

File tree

src/scenarios/server/tools.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { buildToolsNameFormatCheck, validateToolNameFormat } from './tools.js';
3+
4+
describe('validateToolNameFormat', () => {
5+
it('accepts a typical snake_case name', () => {
6+
expect(validateToolNameFormat('test_simple_text')).toBeNull();
7+
});
8+
9+
it('accepts all allowed character classes', () => {
10+
expect(validateToolNameFormat('Aa0_.-/')).toBeNull();
11+
});
12+
13+
it('accepts a single-character name (lower length boundary)', () => {
14+
expect(validateToolNameFormat('a')).toBeNull();
15+
});
16+
17+
it('accepts a 64-character name (upper length boundary)', () => {
18+
expect(validateToolNameFormat('a'.repeat(64))).toBeNull();
19+
});
20+
21+
it('rejects an empty name', () => {
22+
expect(validateToolNameFormat('')).toMatch(/length 0 is outside/);
23+
});
24+
25+
it('rejects a 65-character name', () => {
26+
expect(validateToolNameFormat('a'.repeat(65))).toMatch(
27+
/length 65 is outside/
28+
);
29+
});
30+
31+
it.each([
32+
['space', 'bad name'],
33+
['colon', 'bad:name'],
34+
['at sign', 'bad@name'],
35+
['unicode', 'bad\u00e9name'],
36+
['backslash', 'bad\\name'],
37+
['plus', 'bad+name']
38+
])('rejects a name with a disallowed character (%s)', (_label, name) => {
39+
expect(validateToolNameFormat(name)).toMatch(/contains characters outside/);
40+
});
41+
});
42+
43+
describe('buildToolsNameFormatCheck', () => {
44+
it('returns INFO when tools is undefined', () => {
45+
const check = buildToolsNameFormatCheck(undefined);
46+
expect(check.status).toBe('INFO');
47+
expect(check.id).toBe('tools-name-format');
48+
expect(check.details).toEqual({ toolCount: 0 });
49+
});
50+
51+
it('returns INFO when tools is an empty array', () => {
52+
const check = buildToolsNameFormatCheck([]);
53+
expect(check.status).toBe('INFO');
54+
expect(check.errorMessage).toBe('No tools advertised; nothing to validate');
55+
});
56+
57+
it('returns SUCCESS when all tool names are valid', () => {
58+
const check = buildToolsNameFormatCheck([
59+
{ name: 'test_simple_text' },
60+
{ name: 'namespace/tool-v1.2' }
61+
]);
62+
expect(check.status).toBe('SUCCESS');
63+
expect(check.errorMessage).toBeUndefined();
64+
expect(check.details).toMatchObject({
65+
toolCount: 2,
66+
results: {
67+
test_simple_text: 'valid',
68+
'namespace/tool-v1.2': 'valid'
69+
}
70+
});
71+
});
72+
73+
it('returns FAILURE with per-tool details when some names are invalid', () => {
74+
const check = buildToolsNameFormatCheck([
75+
{ name: 'good_tool' },
76+
{ name: 'bad name with spaces' },
77+
{ name: 'a'.repeat(65) }
78+
]);
79+
80+
expect(check.status).toBe('FAILURE');
81+
expect(check.errorMessage).toContain(
82+
'2 tool name(s) violate SEP-986 format'
83+
);
84+
85+
const results = (check.details as { results: Record<string, string> })
86+
.results;
87+
expect(results['good_tool']).toBe('valid');
88+
expect(results['bad name with spaces']).toMatch(/^invalid: /);
89+
expect(results['a'.repeat(65)]).toMatch(/^invalid: length 65/);
90+
});
91+
92+
it('flags a tool whose name is not a string', () => {
93+
const check = buildToolsNameFormatCheck([{ name: 123 as unknown }]);
94+
expect(check.status).toBe('FAILURE');
95+
const results = (check.details as { results: Record<string, string> })
96+
.results;
97+
expect(results['<tool[0] missing name>']).toBe(
98+
'invalid: name is not a string'
99+
);
100+
});
101+
102+
it('includes both MCP-Tools-List and SEP-986 spec references', () => {
103+
const check = buildToolsNameFormatCheck([{ name: 'ok' }]);
104+
const ids = check.specReferences?.map((r) => r.id);
105+
expect(ids).toEqual(['MCP-Tools-List', 'SEP-986']);
106+
});
107+
});

src/scenarios/server/tools.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,83 @@ import {
1111
Progress
1212
} from '@modelcontextprotocol/sdk/types.js';
1313

14+
const TOOL_NAME_PATTERN = /^[A-Za-z0-9_./-]+$/;
15+
const TOOL_NAME_MAX_LENGTH = 64;
16+
17+
const TOOLS_NAME_FORMAT_SPEC_REFS = [
18+
{
19+
id: 'MCP-Tools-List',
20+
url: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#listing-tools'
21+
},
22+
{
23+
id: 'SEP-986',
24+
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/SEP/SEP-986.md'
25+
}
26+
];
27+
28+
export function validateToolNameFormat(name: string): string | null {
29+
if (name.length < 1 || name.length > TOOL_NAME_MAX_LENGTH) {
30+
return `length ${name.length} is outside 1-${TOOL_NAME_MAX_LENGTH}`;
31+
}
32+
if (!TOOL_NAME_PATTERN.test(name)) {
33+
return 'contains characters outside [A-Za-z0-9_./-]';
34+
}
35+
return null;
36+
}
37+
38+
export function buildToolsNameFormatCheck(
39+
tools: ReadonlyArray<{ name?: unknown }> | undefined
40+
): ConformanceCheck {
41+
const timestamp = new Date().toISOString();
42+
const baseCheck = {
43+
id: 'tools-name-format',
44+
name: 'ToolsNameFormat',
45+
description: 'Tool names are 1-64 characters and match ^[A-Za-z0-9_./-]+$',
46+
specReferences: TOOLS_NAME_FORMAT_SPEC_REFS,
47+
timestamp
48+
};
49+
50+
if (!Array.isArray(tools) || tools.length === 0) {
51+
return {
52+
...baseCheck,
53+
status: 'INFO',
54+
errorMessage: 'No tools advertised; nothing to validate',
55+
details: { toolCount: 0 }
56+
};
57+
}
58+
59+
const toolResults: Record<string, string> = {};
60+
const violations: string[] = [];
61+
62+
tools.forEach((tool, index) => {
63+
const name = typeof tool.name === 'string' ? tool.name : '';
64+
const key = name || `<tool[${index}] missing name>`;
65+
const reason =
66+
typeof tool.name === 'string'
67+
? validateToolNameFormat(tool.name)
68+
: 'name is not a string';
69+
if (reason) {
70+
toolResults[key] = `invalid: ${reason}`;
71+
violations.push(`${key}: ${reason}`);
72+
} else {
73+
toolResults[key] = 'valid';
74+
}
75+
});
76+
77+
return {
78+
...baseCheck,
79+
status: violations.length === 0 ? 'SUCCESS' : 'FAILURE',
80+
errorMessage:
81+
violations.length > 0
82+
? `${violations.length} tool name(s) violate SEP-986 format: ${violations.join('; ')}`
83+
: undefined,
84+
details: {
85+
toolCount: tools.length,
86+
results: toolResults
87+
}
88+
};
89+
}
90+
1491
export class ToolsListScenario implements ClientScenario {
1592
name = 'tools-list';
1693
specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25'];
@@ -23,7 +100,7 @@ export class ToolsListScenario implements ClientScenario {
23100
**Requirements**:
24101
- Return array of all available tools
25102
- Each tool MUST have:
26-
- \`name\` (string)
103+
- \`name\` (string, 1-64 chars, matching \`^[A-Za-z0-9_./-]+$\`)
27104
- \`description\` (string)
28105
- \`inputSchema\` (valid JSON Schema object)`;
29106

@@ -72,6 +149,10 @@ export class ToolsListScenario implements ClientScenario {
72149
}
73150
});
74151

152+
// Validate tool name format per SEP-986:
153+
// names MUST be 1-64 chars matching ^[A-Za-z0-9_./-]+$
154+
checks.push(buildToolsNameFormatCheck(result.tools));
155+
75156
await connection.close();
76157
} catch (error) {
77158
checks.push({

0 commit comments

Comments
 (0)