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
Original file line number Diff line number Diff line change
Expand Up @@ -1325,6 +1325,15 @@ public record ListToolsResult( // @formatter:off
@JsonProperty("nextCursor") String nextCursor,
@JsonProperty("_meta") Map<String, Object> meta) implements Result { // @formatter:on

/**
* Compact constructor that validates tool names on deserialization (warns only).
*/
public ListToolsResult {
if (tools != null) {
tools.forEach(tool -> ToolNameValidator.validate(tool.name(), false));
}
}

public ListToolsResult(List<Tool> tools, String nextCursor) {
this(tools, nextCursor, null);
}
Expand Down Expand Up @@ -1466,7 +1475,7 @@ public Builder meta(Map<String, Object> meta) {
}

public Tool build() {
Assert.hasText(name, "name must not be empty");
ToolNameValidator.validate(name, true);
return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta);
}

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

/**
* Compact constructor that validates tool name on deserialization (warns only).
*/
public CallToolRequest {
ToolNameValidator.validate(name, false);
}

public CallToolRequest(McpJsonMapper jsonMapper, String name, String jsonArguments) {
this(name, parseJsonArguments(jsonMapper, jsonArguments), null);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright 2024-2024 the original author or authors.
*/

package io.modelcontextprotocol.spec;

import java.util.regex.Pattern;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Validates tool names according to the MCP specification.
*
* <p>
* Tool names must conform to the following rules:
* <ul>
* <li>Must be between 1 and 128 characters in length</li>
* <li>May only contain: A-Z, a-z, 0-9, underscore (_), hyphen (-), and dot (.)</li>
* <li>Must not contain spaces, commas, or other special characters</li>
* </ul>
*
* @see <a href=
* "https://modelcontextprotocol.io/specification/draft/server/tools#tool-names">MCP
* Specification - Tool Names</a>
*/
public final class ToolNameValidator {

private static final Logger logger = LoggerFactory.getLogger(ToolNameValidator.class);

private static final int MAX_LENGTH = 128;

private static final Pattern VALID_NAME_PATTERN = Pattern.compile("^[A-Za-z0-9_\\-.]+$");

private ToolNameValidator() {
}

/**
* Validates a tool name according to MCP specification.
* @param name the tool name to validate
* @param strict if true, throws exception on invalid name; if false, logs warning
* @throws IllegalArgumentException if strict is true and name is invalid
*/
public static void validate(String name, boolean strict) {
if (name == null || name.isEmpty()) {
handleError("Tool name must not be null or empty", name, strict);
return;
}
if (name.length() > MAX_LENGTH) {
handleError("Tool name must not exceed 128 characters", name, strict);
return;
}
if (!VALID_NAME_PATTERN.matcher(name).matches()) {
handleError("Tool name contains invalid characters (allowed: A-Z, a-z, 0-9, _, -, .)", name, strict);
}
}

private static void handleError(String message, String name, boolean strict) {
String fullMessage = message + ": '" + name + "'";
if (strict) {
throw new IllegalArgumentException(fullMessage);
}
else {
logger.warn("{}. Processing continues, but tool name should be fixed.", fullMessage);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -1765,4 +1765,33 @@ void testProgressNotificationWithoutMessage() throws Exception {
{"progressToken":"progress-token-789","progress":0.25}"""));
}

// Tool Name Validation Tests

@Test
void testToolBuilderWithValidName() {
McpSchema.Tool tool = McpSchema.Tool.builder().name("valid_tool-name.v1").description("A test tool").build();

assertThat(tool.name()).isEqualTo("valid_tool-name.v1");
assertThat(tool.description()).isEqualTo("A test tool");
}

@Test
void testToolBuilderWithInvalidNameThrowsException() {
assertThatThrownBy(() -> McpSchema.Tool.builder().name("invalid tool name").build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("invalid characters");
}

@Test
void testListToolsResultDeserializationWithInvalidToolName() throws Exception {
// Deserialization should not throw, just warn
String json = """
{"tools":[{"name":"invalid tool name","description":"test"}],"nextCursor":null}""";

McpSchema.ListToolsResult result = JSON_MAPPER.readValue(json, McpSchema.ListToolsResult.class);

assertThat(result.tools()).hasSize(1);
assertThat(result.tools().get(0).name()).isEqualTo("invalid tool name");
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Copyright 2024-2024 the original author or authors.
*/

package io.modelcontextprotocol.spec;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
* Tests for {@link ToolNameValidator}.
*/
class ToolNameValidatorTests {

@ParameterizedTest
@ValueSource(strings = { "getUser", "DATA_EXPORT_v2", "admin.tools.list", "my-tool", "Tool123", "a", "A",
"_private", "tool_name", "tool-name", "tool.name", "UPPERCASE", "lowercase", "MixedCase123" })
void validToolNames_strictMode(String name) {
assertThatCode(() -> ToolNameValidator.validate(name, true)).doesNotThrowAnyException();
}

@Test
void validToolName_maxLength() {
String name = "a".repeat(128);
assertThatCode(() -> ToolNameValidator.validate(name, true)).doesNotThrowAnyException();
}

@Test
void invalidToolName_null_strictMode() {
assertThatThrownBy(() -> ToolNameValidator.validate(null, true)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("null or empty");
}

@Test
void invalidToolName_empty_strictMode() {
assertThatThrownBy(() -> ToolNameValidator.validate("", true)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("null or empty");
}

@Test
void invalidToolName_tooLong_strictMode() {
String name = "a".repeat(129);
assertThatThrownBy(() -> ToolNameValidator.validate(name, true)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("128 characters");
}

@ParameterizedTest
@ValueSource(strings = { "tool name", // space
"tool,name", // comma
"tool@name", // at sign
"tool#name", // hash
"tool$name", // dollar
"tool%name", // percent
"tool&name", // ampersand
"tool*name", // asterisk
"tool+name", // plus
"tool=name", // equals
"tool/name", // slash
"tool\\name", // backslash
"tool:name", // colon
"tool;name", // semicolon
"tool'name", // single quote
"tool\"name", // double quote
"tool<name", // less than
"tool>name", // greater than
"tool?name", // question mark
"tool!name", // exclamation
"tool(name)", // parentheses
"tool[name]", // brackets
"tool{name}", // braces
"tool|name", // pipe
"tool~name", // tilde
"tool`name", // backtick
"tool^name", // caret
"tööl", // non-ASCII
"工具" // unicode
})
void invalidToolNames_specialCharacters_strictMode(String name) {
assertThatThrownBy(() -> ToolNameValidator.validate(name, true)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("invalid characters");
}

@Test
void invalidToolName_nonStrictMode_doesNotThrow() {
// Non-strict mode should not throw, just warn
assertThatCode(() -> ToolNameValidator.validate("invalid name", false)).doesNotThrowAnyException();
assertThatCode(() -> ToolNameValidator.validate(null, false)).doesNotThrowAnyException();
assertThatCode(() -> ToolNameValidator.validate("", false)).doesNotThrowAnyException();
assertThatCode(() -> ToolNameValidator.validate("a".repeat(129), false)).doesNotThrowAnyException();
}

@Test
void toolBuilder_validatesName_strictMode() {
assertThatThrownBy(() -> McpSchema.Tool.builder().name("invalid name with space").build())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("invalid characters");
}

@Test
void toolBuilder_validName() {
McpSchema.Tool tool = McpSchema.Tool.builder().name("valid_tool-name.v1").build();
assertThat(tool.name()).isEqualTo("valid_tool-name.v1");
}

}