Skip to content

Commit f807421

Browse files
committed
feat: implement tool name validation according to SEP specification
- Add comprehensive tool name validation utility with regex patterns - Validate tool names against SEP-0001 specification requirements - Support valid characters: lowercase letters, numbers, hyphens, underscores - Enforce length limits (1-64 characters) and naming conventions - Generate appropriate warnings for non-compliant tool names - Add extensive test coverage with Jest spies for console methods - Integrate validation into McpServer.registerTool() method - Update test suite to cover all validation scenarios - Add documentation and examples for tool name validation This ensures all registered tools comply with the Model Context Protocol specification for tool naming, improving interoperability and consistency.
1 parent 5dd7a2b commit f807421

File tree

4 files changed

+402
-0
lines changed

4 files changed

+402
-0
lines changed

src/server/mcp.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ describe("ResourceTemplate", () => {
222222
});
223223

224224
describe("tool()", () => {
225+
afterEach(() => {
226+
jest.restoreAllMocks();
227+
});
228+
225229
/***
226230
* Test: Zero-Argument Tool Registration
227231
*/
@@ -1726,6 +1730,44 @@ describe("tool()", () => {
17261730
expect(result.tools[0].name).toBe("test-without-meta");
17271731
expect(result.tools[0]._meta).toBeUndefined();
17281732
});
1733+
1734+
test("should validate tool names according to SEP specification", () => {
1735+
// Create a new server instance for this test
1736+
const testServer = new McpServer({
1737+
name: "test server",
1738+
version: "1.0",
1739+
});
1740+
1741+
// Spy on console.warn to verify warnings are logged
1742+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
1743+
1744+
// Test valid tool names
1745+
testServer.registerTool("valid-tool-name", {
1746+
description: "A valid tool name"
1747+
}, async () => ({ content: [{ type: "text", text: "Success" }] }));
1748+
1749+
// Test tool name with warnings (starts with dash)
1750+
testServer.registerTool("-warning-tool", {
1751+
description: "A tool name that generates warnings"
1752+
}, async () => ({ content: [{ type: "text", text: "Success" }] }));
1753+
1754+
// Test invalid tool name (contains spaces)
1755+
testServer.registerTool("invalid tool name", {
1756+
description: "An invalid tool name"
1757+
}, async () => ({ content: [{ type: "text", text: "Success" }] }));
1758+
1759+
// Verify that warnings were issued (both for warnings and validation failures)
1760+
expect(warnSpy).toHaveBeenCalled();
1761+
1762+
// Verify specific warning content
1763+
const warningCalls = warnSpy.mock.calls.map(call => call.join(' '));
1764+
expect(warningCalls.some(call => call.includes('Tool name starts or ends with a dash'))).toBe(true);
1765+
expect(warningCalls.some(call => call.includes('Tool name contains spaces'))).toBe(true);
1766+
expect(warningCalls.some(call => call.includes('Tool name contains invalid characters'))).toBe(true);
1767+
1768+
// Clean up spies
1769+
warnSpy.mockRestore();
1770+
});
17291771
});
17301772

17311773
describe("resource()", () => {

src/server/mcp.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { Completable, CompletableDef } from "./completable.js";
4747
import { UriTemplate, Variables } from "../shared/uriTemplate.js";
4848
import { RequestHandlerExtra } from "../shared/protocol.js";
4949
import { Transport } from "../shared/transport.js";
50+
import { validateAndWarnToolName } from "../shared/toolNameValidation.js";
5051

5152
/**
5253
* High-level MCP server that provides a simpler API for working with resources, tools, and prompts.
@@ -777,6 +778,9 @@ export class McpServer {
777778
_meta: Record<string, unknown> | undefined,
778779
callback: ToolCallback<ZodRawShape | undefined>
779780
): RegisteredTool {
781+
// Validate tool name according to SEP specification
782+
validateAndWarnToolName(name);
783+
780784
const registeredTool: RegisteredTool = {
781785
title,
782786
description,
@@ -793,6 +797,9 @@ export class McpServer {
793797
remove: () => registeredTool.update({ name: null }),
794798
update: (updates) => {
795799
if (typeof updates.name !== "undefined" && updates.name !== name) {
800+
if (typeof updates.name === "string") {
801+
validateAndWarnToolName(updates.name);
802+
}
796803
delete this._registeredTools[name]
797804
if (updates.name) this._registeredTools[updates.name] = registeredTool
798805
}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import { validateToolName, validateAndWarnToolName, issueToolNameWarning } from './toolNameValidation.js';
2+
3+
// Spy on console.warn to capture output
4+
let warnSpy: jest.SpyInstance;
5+
6+
beforeEach(() => {
7+
warnSpy = jest.spyOn(console, 'warn').mockImplementation();
8+
});
9+
10+
afterEach(() => {
11+
jest.restoreAllMocks();
12+
});
13+
14+
describe('validateToolName', () => {
15+
describe('valid tool names', () => {
16+
test('should accept simple alphanumeric names', () => {
17+
const result = validateToolName('getUser');
18+
expect(result.isValid).toBe(true);
19+
expect(result.warnings).toHaveLength(0);
20+
});
21+
22+
test('should accept names with underscores', () => {
23+
const result = validateToolName('get_user_profile');
24+
expect(result.isValid).toBe(true);
25+
expect(result.warnings).toHaveLength(0);
26+
});
27+
28+
test('should accept names with dashes', () => {
29+
const result = validateToolName('user-profile-update');
30+
expect(result.isValid).toBe(true);
31+
expect(result.warnings).toHaveLength(0);
32+
});
33+
34+
test('should accept names with dots', () => {
35+
const result = validateToolName('admin.tools.list');
36+
expect(result.isValid).toBe(true);
37+
expect(result.warnings).toHaveLength(0);
38+
});
39+
40+
test('should accept names with forward slashes', () => {
41+
const result = validateToolName('user/profile/update');
42+
expect(result.isValid).toBe(true);
43+
expect(result.warnings).toHaveLength(0);
44+
});
45+
46+
test('should accept mixed character names', () => {
47+
const result = validateToolName('DATA_EXPORT_v2.1');
48+
expect(result.isValid).toBe(true);
49+
expect(result.warnings).toHaveLength(0);
50+
});
51+
52+
test('should accept single character names', () => {
53+
const result = validateToolName('a');
54+
expect(result.isValid).toBe(true);
55+
expect(result.warnings).toHaveLength(0);
56+
});
57+
58+
test('should accept 128 character names', () => {
59+
const name = 'a'.repeat(128);
60+
const result = validateToolName(name);
61+
expect(result.isValid).toBe(true);
62+
expect(result.warnings).toHaveLength(0);
63+
});
64+
});
65+
66+
describe('invalid tool names', () => {
67+
test('should reject empty names', () => {
68+
const result = validateToolName('');
69+
expect(result.isValid).toBe(false);
70+
expect(result.warnings).toContain('Tool name cannot be empty');
71+
});
72+
73+
test('should reject names longer than 128 characters', () => {
74+
const name = 'a'.repeat(129);
75+
const result = validateToolName(name);
76+
expect(result.isValid).toBe(false);
77+
expect(result.warnings).toContain('Tool name exceeds maximum length of 128 characters (current: 129)');
78+
});
79+
80+
test('should reject names with spaces', () => {
81+
const result = validateToolName('get user profile');
82+
expect(result.isValid).toBe(false);
83+
expect(result.warnings).toContain('Tool name contains invalid characters: " "');
84+
});
85+
86+
test('should reject names with commas', () => {
87+
const result = validateToolName('get,user,profile');
88+
expect(result.isValid).toBe(false);
89+
expect(result.warnings).toContain('Tool name contains invalid characters: ","');
90+
});
91+
92+
test('should reject names with other special characters', () => {
93+
const result = validateToolName('[email protected]');
94+
expect(result.isValid).toBe(false);
95+
expect(result.warnings).toContain('Tool name contains invalid characters: "@"');
96+
});
97+
98+
test('should reject names with multiple invalid characters', () => {
99+
const result = validateToolName('user name@domain,com');
100+
expect(result.isValid).toBe(false);
101+
expect(result.warnings).toContain('Tool name contains invalid characters: " ", "@", ","');
102+
});
103+
104+
test('should reject names with unicode characters', () => {
105+
const result = validateToolName('user-ñame');
106+
expect(result.isValid).toBe(false);
107+
expect(result.warnings).toContain('Tool name contains invalid characters: "ñ"');
108+
});
109+
});
110+
111+
describe('warnings for potentially problematic patterns', () => {
112+
test('should warn about names with spaces', () => {
113+
const result = validateToolName('get user profile');
114+
expect(result.isValid).toBe(false);
115+
expect(result.warnings).toContain('Tool name contains spaces, which may cause parsing issues');
116+
});
117+
118+
test('should warn about names with commas', () => {
119+
const result = validateToolName('get,user,profile');
120+
expect(result.isValid).toBe(false);
121+
expect(result.warnings).toContain('Tool name contains commas, which may cause parsing issues');
122+
});
123+
124+
test('should warn about names starting with dash', () => {
125+
const result = validateToolName('-get-user');
126+
expect(result.isValid).toBe(true);
127+
expect(result.warnings).toContain('Tool name starts or ends with a dash, which may cause parsing issues in some contexts');
128+
});
129+
130+
test('should warn about names ending with dash', () => {
131+
const result = validateToolName('get-user-');
132+
expect(result.isValid).toBe(true);
133+
expect(result.warnings).toContain('Tool name starts or ends with a dash, which may cause parsing issues in some contexts');
134+
});
135+
136+
test('should warn about names starting with dot', () => {
137+
const result = validateToolName('.get.user');
138+
expect(result.isValid).toBe(true);
139+
expect(result.warnings).toContain('Tool name starts or ends with a dot, which may cause parsing issues in some contexts');
140+
});
141+
142+
test('should warn about names ending with dot', () => {
143+
const result = validateToolName('get.user.');
144+
expect(result.isValid).toBe(true);
145+
expect(result.warnings).toContain('Tool name starts or ends with a dot, which may cause parsing issues in some contexts');
146+
});
147+
148+
test('should warn about names with both leading and trailing dots', () => {
149+
const result = validateToolName('.get.user.');
150+
expect(result.isValid).toBe(true);
151+
expect(result.warnings).toContain('Tool name starts or ends with a dot, which may cause parsing issues in some contexts');
152+
});
153+
});
154+
});
155+
156+
describe('issueToolNameWarning', () => {
157+
test('should output warnings to console.warn', () => {
158+
const warnings = ['Warning 1', 'Warning 2'];
159+
issueToolNameWarning('test-tool', warnings);
160+
161+
expect(warnSpy).toHaveBeenCalledTimes(6); // Header + 2 warnings + 3 guidance lines
162+
const calls = warnSpy.mock.calls.map(call => call.join(' '));
163+
expect(calls[0]).toContain('Tool name validation warning for "test-tool"');
164+
expect(calls[1]).toContain('- Warning 1');
165+
expect(calls[2]).toContain('- Warning 2');
166+
expect(calls[3]).toContain('Tool registration will proceed, but this may cause compatibility issues.');
167+
expect(calls[4]).toContain('Consider updating the tool name');
168+
expect(calls[5]).toContain('See SEP: Specify Format for Tool Names');
169+
});
170+
171+
test('should handle empty warnings array', () => {
172+
issueToolNameWarning('test-tool', []);
173+
expect(warnSpy).toHaveBeenCalledTimes(0);
174+
});
175+
});
176+
177+
describe('validateAndWarnToolName', () => {
178+
test('should return true and issue warnings for valid names with warnings', () => {
179+
const result = validateAndWarnToolName('-get-user-');
180+
expect(result).toBe(true);
181+
expect(warnSpy).toHaveBeenCalled();
182+
});
183+
184+
test('should return true and not issue warnings for completely valid names', () => {
185+
const result = validateAndWarnToolName('get-user-profile');
186+
expect(result).toBe(true);
187+
expect(warnSpy).not.toHaveBeenCalled();
188+
});
189+
190+
test('should return false and issue warnings for invalid names', () => {
191+
const result = validateAndWarnToolName('get user profile');
192+
expect(result).toBe(false);
193+
expect(warnSpy).toHaveBeenCalled(); // Now issues warnings instead of errors
194+
const warningCalls = warnSpy.mock.calls.map(call => call.join(' '));
195+
expect(warningCalls.some(call => call.includes('Tool name contains spaces'))).toBe(true);
196+
});
197+
198+
test('should return false for empty names', () => {
199+
const result = validateAndWarnToolName('');
200+
expect(result).toBe(false);
201+
expect(warnSpy).toHaveBeenCalled(); // Now issues warnings instead of errors
202+
});
203+
204+
test('should return false for names exceeding length limit', () => {
205+
const longName = 'a'.repeat(129);
206+
const result = validateAndWarnToolName(longName);
207+
expect(result).toBe(false);
208+
expect(warnSpy).toHaveBeenCalled(); // Now issues warnings instead of errors
209+
});
210+
});
211+
212+
describe('edge cases and robustness', () => {
213+
test('should warn about names with only dots', () => {
214+
const result = validateToolName('...');
215+
expect(result.isValid).toBe(true);
216+
expect(result.warnings).toContain('Tool name starts or ends with a dot, which may cause parsing issues in some contexts');
217+
});
218+
219+
test('should handle names with only dashes', () => {
220+
const result = validateToolName('---');
221+
expect(result.isValid).toBe(true);
222+
expect(result.warnings).toContain('Tool name starts or ends with a dash, which may cause parsing issues in some contexts');
223+
});
224+
225+
test('should warn about names with only forward slashes', () => {
226+
const result = validateToolName('///');
227+
expect(result.isValid).toBe(true);
228+
expect(result.warnings).toContain('Tool name starts or ends with a slash, which may cause parsing issues in some contexts');
229+
});
230+
231+
test('should handle names with mixed valid and invalid characters', () => {
232+
const result = validateToolName('user@name123');
233+
expect(result.isValid).toBe(false);
234+
expect(result.warnings).toContain('Tool name contains invalid characters: "@"');
235+
});
236+
});

0 commit comments

Comments
 (0)