Skip to content

Commit 32fe526

Browse files
committed
feat: added tools name format validation accordingly #SEP-986
1 parent 0a8cb1e commit 32fe526

File tree

4 files changed

+224
-1
lines changed

4 files changed

+224
-1
lines changed

mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1325,6 +1325,15 @@ public record ListToolsResult( // @formatter:off
13251325
@JsonProperty("nextCursor") String nextCursor,
13261326
@JsonProperty("_meta") Map<String, Object> meta) implements Result { // @formatter:on
13271327

1328+
/**
1329+
* Compact constructor that validates tool names on deserialization (warns only).
1330+
*/
1331+
public ListToolsResult {
1332+
if (tools != null) {
1333+
tools.forEach(tool -> ToolNameValidator.validate(tool.name(), false));
1334+
}
1335+
}
1336+
13281337
public ListToolsResult(List<Tool> tools, String nextCursor) {
13291338
this(tools, nextCursor, null);
13301339
}
@@ -1466,7 +1475,7 @@ public Builder meta(Map<String, Object> meta) {
14661475
}
14671476

14681477
public Tool build() {
1469-
Assert.hasText(name, "name must not be empty");
1478+
ToolNameValidator.validate(name, true);
14701479
return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta);
14711480
}
14721481

@@ -1508,6 +1517,13 @@ public record CallToolRequest( // @formatter:off
15081517
@JsonProperty("arguments") Map<String, Object> arguments,
15091518
@JsonProperty("_meta") Map<String, Object> meta) implements Request { // @formatter:on
15101519

1520+
/**
1521+
* Compact constructor that validates tool name on deserialization (warns only).
1522+
*/
1523+
public CallToolRequest {
1524+
ToolNameValidator.validate(name, false);
1525+
}
1526+
15111527
public CallToolRequest(McpJsonMapper jsonMapper, String name, String jsonArguments) {
15121528
this(name, parseJsonArguments(jsonMapper, jsonArguments), null);
15131529
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright 2024-2024 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.spec;
6+
7+
import java.util.regex.Pattern;
8+
9+
import org.slf4j.Logger;
10+
import org.slf4j.LoggerFactory;
11+
12+
/**
13+
* Validates tool names according to the MCP specification.
14+
*
15+
* <p>
16+
* Tool names must conform to the following rules:
17+
* <ul>
18+
* <li>Must be between 1 and 128 characters in length</li>
19+
* <li>May only contain: A-Z, a-z, 0-9, underscore (_), hyphen (-), and dot (.)</li>
20+
* <li>Must not contain spaces, commas, or other special characters</li>
21+
* </ul>
22+
*
23+
* @see <a href=
24+
* "https://modelcontextprotocol.io/specification/draft/server/tools#tool-names">MCP
25+
* Specification - Tool Names</a>
26+
*/
27+
public final class ToolNameValidator {
28+
29+
private static final Logger logger = LoggerFactory.getLogger(ToolNameValidator.class);
30+
31+
private static final int MAX_LENGTH = 128;
32+
33+
private static final Pattern VALID_NAME_PATTERN = Pattern.compile("^[A-Za-z0-9_\\-.]+$");
34+
35+
private ToolNameValidator() {
36+
}
37+
38+
/**
39+
* Validates a tool name according to MCP specification.
40+
* @param name the tool name to validate
41+
* @param strict if true, throws exception on invalid name; if false, logs warning
42+
* @throws IllegalArgumentException if strict is true and name is invalid
43+
*/
44+
public static void validate(String name, boolean strict) {
45+
if (name == null || name.isEmpty()) {
46+
handleError("Tool name must not be null or empty", name, strict);
47+
return;
48+
}
49+
if (name.length() > MAX_LENGTH) {
50+
handleError("Tool name must not exceed 128 characters", name, strict);
51+
return;
52+
}
53+
if (!VALID_NAME_PATTERN.matcher(name).matches()) {
54+
handleError("Tool name contains invalid characters (allowed: A-Z, a-z, 0-9, _, -, .)", name, strict);
55+
}
56+
}
57+
58+
private static void handleError(String message, String name, boolean strict) {
59+
String fullMessage = message + ": '" + name + "'";
60+
if (strict) {
61+
throw new IllegalArgumentException(fullMessage);
62+
}
63+
else {
64+
logger.warn("{}. Processing continues, but tool name should be fixed.", fullMessage);
65+
}
66+
}
67+
68+
}

mcp-core/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1765,4 +1765,33 @@ void testProgressNotificationWithoutMessage() throws Exception {
17651765
{"progressToken":"progress-token-789","progress":0.25}"""));
17661766
}
17671767

1768+
// Tool Name Validation Tests
1769+
1770+
@Test
1771+
void testToolBuilderWithValidName() {
1772+
McpSchema.Tool tool = McpSchema.Tool.builder().name("valid_tool-name.v1").description("A test tool").build();
1773+
1774+
assertThat(tool.name()).isEqualTo("valid_tool-name.v1");
1775+
assertThat(tool.description()).isEqualTo("A test tool");
1776+
}
1777+
1778+
@Test
1779+
void testToolBuilderWithInvalidNameThrowsException() {
1780+
assertThatThrownBy(() -> McpSchema.Tool.builder().name("invalid tool name").build())
1781+
.isInstanceOf(IllegalArgumentException.class)
1782+
.hasMessageContaining("invalid characters");
1783+
}
1784+
1785+
@Test
1786+
void testListToolsResultDeserializationWithInvalidToolName() throws Exception {
1787+
// Deserialization should not throw, just warn
1788+
String json = """
1789+
{"tools":[{"name":"invalid tool name","description":"test"}],"nextCursor":null}""";
1790+
1791+
McpSchema.ListToolsResult result = JSON_MAPPER.readValue(json, McpSchema.ListToolsResult.class);
1792+
1793+
assertThat(result.tools()).hasSize(1);
1794+
assertThat(result.tools().get(0).name()).isEqualTo("invalid tool name");
1795+
}
1796+
17681797
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Copyright 2024-2024 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.spec;
6+
7+
import org.junit.jupiter.api.Test;
8+
import org.junit.jupiter.params.ParameterizedTest;
9+
import org.junit.jupiter.params.provider.ValueSource;
10+
11+
import static org.assertj.core.api.Assertions.assertThat;
12+
import static org.assertj.core.api.Assertions.assertThatCode;
13+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
14+
15+
/**
16+
* Tests for {@link ToolNameValidator}.
17+
*/
18+
class ToolNameValidatorTests {
19+
20+
@ParameterizedTest
21+
@ValueSource(strings = { "getUser", "DATA_EXPORT_v2", "admin.tools.list", "my-tool", "Tool123", "a", "A",
22+
"_private", "tool_name", "tool-name", "tool.name", "UPPERCASE", "lowercase", "MixedCase123" })
23+
void validToolNames_strictMode(String name) {
24+
assertThatCode(() -> ToolNameValidator.validate(name, true)).doesNotThrowAnyException();
25+
}
26+
27+
@Test
28+
void validToolName_maxLength() {
29+
String name = "a".repeat(128);
30+
assertThatCode(() -> ToolNameValidator.validate(name, true)).doesNotThrowAnyException();
31+
}
32+
33+
@Test
34+
void invalidToolName_null_strictMode() {
35+
assertThatThrownBy(() -> ToolNameValidator.validate(null, true)).isInstanceOf(IllegalArgumentException.class)
36+
.hasMessageContaining("null or empty");
37+
}
38+
39+
@Test
40+
void invalidToolName_empty_strictMode() {
41+
assertThatThrownBy(() -> ToolNameValidator.validate("", true)).isInstanceOf(IllegalArgumentException.class)
42+
.hasMessageContaining("null or empty");
43+
}
44+
45+
@Test
46+
void invalidToolName_tooLong_strictMode() {
47+
String name = "a".repeat(129);
48+
assertThatThrownBy(() -> ToolNameValidator.validate(name, true)).isInstanceOf(IllegalArgumentException.class)
49+
.hasMessageContaining("128 characters");
50+
}
51+
52+
@ParameterizedTest
53+
@ValueSource(strings = { "tool name", // space
54+
"tool,name", // comma
55+
"tool@name", // at sign
56+
"tool#name", // hash
57+
"tool$name", // dollar
58+
"tool%name", // percent
59+
"tool&name", // ampersand
60+
"tool*name", // asterisk
61+
"tool+name", // plus
62+
"tool=name", // equals
63+
"tool/name", // slash
64+
"tool\\name", // backslash
65+
"tool:name", // colon
66+
"tool;name", // semicolon
67+
"tool'name", // single quote
68+
"tool\"name", // double quote
69+
"tool<name", // less than
70+
"tool>name", // greater than
71+
"tool?name", // question mark
72+
"tool!name", // exclamation
73+
"tool(name)", // parentheses
74+
"tool[name]", // brackets
75+
"tool{name}", // braces
76+
"tool|name", // pipe
77+
"tool~name", // tilde
78+
"tool`name", // backtick
79+
"tool^name", // caret
80+
"tööl", // non-ASCII
81+
"工具" // unicode
82+
})
83+
void invalidToolNames_specialCharacters_strictMode(String name) {
84+
assertThatThrownBy(() -> ToolNameValidator.validate(name, true)).isInstanceOf(IllegalArgumentException.class)
85+
.hasMessageContaining("invalid characters");
86+
}
87+
88+
@Test
89+
void invalidToolName_nonStrictMode_doesNotThrow() {
90+
// Non-strict mode should not throw, just warn
91+
assertThatCode(() -> ToolNameValidator.validate("invalid name", false)).doesNotThrowAnyException();
92+
assertThatCode(() -> ToolNameValidator.validate(null, false)).doesNotThrowAnyException();
93+
assertThatCode(() -> ToolNameValidator.validate("", false)).doesNotThrowAnyException();
94+
assertThatCode(() -> ToolNameValidator.validate("a".repeat(129), false)).doesNotThrowAnyException();
95+
}
96+
97+
@Test
98+
void toolBuilder_validatesName_strictMode() {
99+
assertThatThrownBy(() -> McpSchema.Tool.builder().name("invalid name with space").build())
100+
.isInstanceOf(IllegalArgumentException.class)
101+
.hasMessageContaining("invalid characters");
102+
}
103+
104+
@Test
105+
void toolBuilder_validName() {
106+
McpSchema.Tool tool = McpSchema.Tool.builder().name("valid_tool-name.v1").build();
107+
assertThat(tool.name()).isEqualTo("valid_tool-name.v1");
108+
}
109+
110+
}

0 commit comments

Comments
 (0)