feat: Tool name format checks (SEP-986) (#240)

This commit is contained in:
Nate Barbettini 2026-04-24 03:57:36 -07:00 • committed by GitHub
commit 90b2334dcc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 189 additions and 1 deletions

View file

@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { buildToolsNameFormatCheck, validateToolNameFormat } from './tools.js';
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();
});
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('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.each([
['space', 'bad name'],
['colon', 'bad:name'],
['at sign', 'bad@name'],
['unicode', 'bad\u00e9name'],
['backslash', 'bad\\name'],
['plus', 'bad+name']
])('rejects a name with a disallowed character (%s)', (_label, name) => {
expect(validateToolNameFormat(name)).toMatch(/contains characters outside/);
});
});
describe('buildToolsNameFormatCheck', () => {
it('returns INFO when tools is undefined', () => {
const check = buildToolsNameFormatCheck(undefined);
expect(check.status).toBe('INFO');
expect(check.id).toBe('tools-name-format');
expect(check.details).toEqual({ toolCount: 0 });
});
it('returns INFO when tools is an empty array', () => {
const check = buildToolsNameFormatCheck([]);
expect(check.status).toBe('INFO');
expect(check.errorMessage).toBe('No tools advertised; nothing to validate');
});
it('returns SUCCESS when all tool names are valid', () => {
const check = buildToolsNameFormatCheck([
{ name: 'test_simple_text' },
{ name: 'namespace/tool-v1.2' }
]);
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'
}
});
});
it('returns FAILURE with per-tool details when some names are invalid', () => {
const check = buildToolsNameFormatCheck([
{ name: 'good_tool' },
{ name: 'bad name with spaces' },
{ name: 'a'.repeat(65) }
]);
expect(check.status).toBe('FAILURE');
expect(check.errorMessage).toContain(
'2 tool name(s) violate SEP-986 format'
);
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/);
});
it('flags a tool whose name is not a string', () => {
const check = buildToolsNameFormatCheck([{ name: 123 as unknown }]);
expect(check.status).toBe('FAILURE');
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', () => {
const check = buildToolsNameFormatCheck([{ name: 'ok' }]);
const ids = check.specReferences?.map((r) => r.id);
expect(ids).toEqual(['MCP-Tools-List', 'SEP-986']);
});
});

View file

@ -11,6 +11,83 @@ import {
Progress
} from '@modelcontextprotocol/sdk/types.js';
const TOOL_NAME_PATTERN = /^[A-Za-z0-9_./-]+$/;
const TOOL_NAME_MAX_LENGTH = 64;
const TOOLS_NAME_FORMAT_SPEC_REFS = [
{
id: 'MCP-Tools-List',
url: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#listing-tools'
},
{
id: 'SEP-986',
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/SEP/SEP-986.md'
}
];
export function validateToolNameFormat(name: string): string | null {
if (name.length < 1 || name.length > TOOL_NAME_MAX_LENGTH) {
return `length ${name.length} is outside 1-${TOOL_NAME_MAX_LENGTH}`;
}
if (!TOOL_NAME_PATTERN.test(name)) {
return 'contains characters outside [A-Za-z0-9_./-]';
}
return null;
}
export function buildToolsNameFormatCheck(
tools: ReadonlyArray<{ name?: unknown }> | undefined
): ConformanceCheck {
const timestamp = new Date().toISOString();
const baseCheck = {
id: 'tools-name-format',
name: 'ToolsNameFormat',
description: 'Tool names are 1-64 characters and match ^[A-Za-z0-9_./-]+$',
specReferences: TOOLS_NAME_FORMAT_SPEC_REFS,
timestamp
};
if (!Array.isArray(tools) || tools.length === 0) {
return {
...baseCheck,
status: 'INFO',
errorMessage: 'No tools advertised; nothing to validate',
details: { toolCount: 0 }
};
}
const toolResults: Record<string, string> = {};
const violations: string[] = [];
tools.forEach((tool, index) => {
const name = typeof tool.name === 'string' ? tool.name : '';
const key = name || `<tool[${index}] missing name>`;
const reason =
typeof tool.name === 'string'
? validateToolNameFormat(tool.name)
: 'name is not a string';
if (reason) {
toolResults[key] = `invalid: ${reason}`;
violations.push(`${key}: ${reason}`);
} else {
toolResults[key] = 'valid';
}
});
return {
...baseCheck,
status: violations.length === 0 ? 'SUCCESS' : 'FAILURE',
errorMessage:
violations.length > 0
? `${violations.length} tool name(s) violate SEP-986 format: ${violations.join('; ')}`
: undefined,
details: {
toolCount: tools.length,
results: toolResults
}
};
}
export class ToolsListScenario implements ClientScenario {
name = 'tools-list';
specVersions: SpecVersion[] = ['2025-06-18', '2025-11-25'];
@ -23,7 +100,7 @@ export class ToolsListScenario implements ClientScenario {
**Requirements**:
- Return array of all available tools
- Each tool MUST have:
- \`name\` (string)
- \`name\` (string, 1-64 chars, matching \`^[A-Za-z0-9_./-]+$\`)
- \`description\` (string)
- \`inputSchema\` (valid JSON Schema object)`;
@ -72,6 +149,10 @@ export class ToolsListScenario implements ClientScenario {
}
});
// Validate tool name format per SEP-986:
// names MUST be 1-64 chars matching ^[A-Za-z0-9_./-]+$
checks.push(buildToolsNameFormatCheck(result.tools));
await connection.close();
} catch (error) {
checks.push({