Polish gh-764

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
Daniel Garnier-Moiroux 2026-02-10 11:29:08 +01:00
commit be5ec12e1b
5 changed files with 117 additions and 49 deletions

View file

@ -1466,6 +1466,7 @@ public final class McpSchema {
} }
public Tool build() { public Tool build() {
Assert.hasText(name, "name must not be empty");
return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta); return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta);
} }

View file

@ -4,30 +4,33 @@
package io.modelcontextprotocol.server; package io.modelcontextprotocol.server;
import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.McpServerTransportProvider; import io.modelcontextprotocol.spec.McpServerTransportProvider;
import io.modelcontextprotocol.util.ToolNameValidator; import io.modelcontextprotocol.util.ToolNameValidator;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.test.StepVerifier; import reactor.test.StepVerifier;
import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
/** /**
* Tests for {@link McpServerFeatures.AsyncToolSpecification.Builder}. * Tests for {@link McpServerFeatures.AsyncToolSpecification.Builder}.
* *
@ -276,15 +279,23 @@ class AsyncToolSpecificationBuilderTest {
private McpServerTransportProvider transportProvider; private McpServerTransportProvider transportProvider;
private final Logger logger = (Logger) LoggerFactory.getLogger(ToolNameValidator.class);
private final ListAppender<ILoggingEvent> logAppender = new ListAppender<>();
@BeforeEach @BeforeEach
void setUp() { void setUp() {
transportProvider = mock(McpServerTransportProvider.class); transportProvider = mock(McpServerTransportProvider.class);
System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY); System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY);
logAppender.start();
logger.addAppender(logAppender);
} }
@AfterEach @AfterEach
void tearDown() { void tearDown() {
System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY); System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY);
logger.detachAppender(logAppender);
logAppender.stop();
} }
@Test @Test
@ -297,25 +308,27 @@ class AsyncToolSpecificationBuilderTest {
} }
@Test @Test
void systemPropertyFalseShouldWarnOnly() { void lenientDefaultShouldLogOnInvalidName() {
System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false");
Tool invalidTool = Tool.builder().name("invalid tool name").build(); Tool invalidTool = Tool.builder().name("invalid tool name").build();
assertThatCode(() -> McpServer.async(transportProvider).tool(invalidTool, (exchange, args) -> null)) assertThatCode(() -> McpServer.async(transportProvider).tool(invalidTool, (exchange, args) -> null))
.doesNotThrowAnyException(); .doesNotThrowAnyException();
assertThat(logAppender.list).hasSize(1);
} }
@Test @Test
void perServerFalseShouldWarnOnly() { void lenientConfigurationShouldLogOnInvalidName() {
Tool invalidTool = Tool.builder().name("invalid tool name").build(); Tool invalidTool = Tool.builder().name("invalid tool name").build();
assertThatCode(() -> McpServer.async(transportProvider) assertThatCode(() -> McpServer.async(transportProvider)
.strictToolNameValidation(false) .strictToolNameValidation(false)
.tool(invalidTool, (exchange, args) -> null)).doesNotThrowAnyException(); .tool(invalidTool, (exchange, args) -> null)).doesNotThrowAnyException();
assertThat(logAppender.list).hasSize(1);
} }
@Test @Test
void perServerTrueShouldOverrideSystemProperty() { void serverConfigurationShouldOverrideDefault() {
System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false");
Tool invalidTool = Tool.builder().name("invalid tool name").build(); Tool invalidTool = Tool.builder().name("invalid tool name").build();

View file

@ -4,26 +4,29 @@
package io.modelcontextprotocol.server; package io.modelcontextprotocol.server;
import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.McpServerTransportProvider; import io.modelcontextprotocol.spec.McpServerTransportProvider;
import io.modelcontextprotocol.util.ToolNameValidator; import io.modelcontextprotocol.util.ToolNameValidator;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult; import static org.assertj.core.api.Assertions.assertThat;
import io.modelcontextprotocol.spec.McpSchema.TextContent; import static org.assertj.core.api.Assertions.assertThatCode;
import io.modelcontextprotocol.spec.McpSchema.Tool; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
/** /**
* Tests for {@link McpServerFeatures.SyncToolSpecification.Builder}. * Tests for {@link McpServerFeatures.SyncToolSpecification.Builder}.
@ -114,15 +117,23 @@ class SyncToolSpecificationBuilderTest {
private McpServerTransportProvider transportProvider; private McpServerTransportProvider transportProvider;
private final Logger logger = (Logger) LoggerFactory.getLogger(ToolNameValidator.class);
private final ListAppender<ILoggingEvent> logAppender = new ListAppender<>();
@BeforeEach @BeforeEach
void setUp() { void setUp() {
transportProvider = mock(McpServerTransportProvider.class); transportProvider = mock(McpServerTransportProvider.class);
System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY); System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY);
logAppender.start();
logger.addAppender(logAppender);
} }
@AfterEach @AfterEach
void tearDown() { void tearDown() {
System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY); System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY);
logger.detachAppender(logAppender);
logAppender.stop();
} }
@Test @Test
@ -135,25 +146,27 @@ class SyncToolSpecificationBuilderTest {
} }
@Test @Test
void systemPropertyFalseShouldWarnOnly() { void lenientDefaultShouldLogOnInvalidName() {
System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false");
Tool invalidTool = Tool.builder().name("invalid tool name").build(); Tool invalidTool = Tool.builder().name("invalid tool name").build();
assertThatCode(() -> McpServer.sync(transportProvider).tool(invalidTool, (exchange, args) -> null)) assertThatCode(() -> McpServer.sync(transportProvider).tool(invalidTool, (exchange, args) -> null))
.doesNotThrowAnyException(); .doesNotThrowAnyException();
assertThat(logAppender.list).hasSize(1);
} }
@Test @Test
void perServerFalseShouldWarnOnly() { void lenientConfigurationShouldLogOnInvalidName() {
Tool invalidTool = Tool.builder().name("invalid tool name").build(); Tool invalidTool = Tool.builder().name("invalid tool name").build();
assertThatCode(() -> McpServer.sync(transportProvider) assertThatCode(() -> McpServer.sync(transportProvider)
.strictToolNameValidation(false) .strictToolNameValidation(false)
.tool(invalidTool, (exchange, args) -> null)).doesNotThrowAnyException(); .tool(invalidTool, (exchange, args) -> null)).doesNotThrowAnyException();
assertThat(logAppender.list).hasSize(1);
} }
@Test @Test
void perServerTrueShouldOverrideSystemProperty() { void serverConfigurationShouldOverrideDefault() {
System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false");
Tool invalidTool = Tool.builder().name("invalid tool name").build(); Tool invalidTool = Tool.builder().name("invalid tool name").build();

View file

@ -1765,14 +1765,4 @@ public class McpSchemaTests {
{"progressToken":"progress-token-789","progress":0.25}""")); {"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");
}
} }

View file

@ -4,10 +4,21 @@
package io.modelcontextprotocol.util; package io.modelcontextprotocol.util;
import java.util.List;
import java.util.function.Consumer;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.params.provider.ValueSource;
import org.slf4j.LoggerFactory;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ -16,33 +27,49 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
*/ */
class ToolNameValidatorTests { class ToolNameValidatorTests {
private final Logger logger = (Logger) LoggerFactory.getLogger(ToolNameValidator.class);
private final ListAppender<ILoggingEvent> logAppender = new ListAppender<>();
@BeforeEach
void setUp() {
logAppender.start();
logger.addAppender(logAppender);
}
@AfterEach
void tearDown() {
logger.detachAppender(logAppender);
logAppender.stop();
}
@ParameterizedTest @ParameterizedTest
@ValueSource(strings = { "getUser", "DATA_EXPORT_v2", "admin.tools.list", "my-tool", "Tool123", "a", "A", @ValueSource(strings = { "getUser", "DATA_EXPORT_v2", "admin.tools.list", "my-tool", "Tool123", "a", "A",
"_private", "tool_name", "tool-name", "tool.name", "UPPERCASE", "lowercase", "MixedCase123" }) "_private", "tool_name", "tool-name", "tool.name", "UPPERCASE", "lowercase", "MixedCase123" })
void validToolNames_strictMode(String name) { void validToolNames(String name) {
assertThatCode(() -> ToolNameValidator.validate(name, true)).doesNotThrowAnyException(); assertThatCode(() -> ToolNameValidator.validate(name, true)).doesNotThrowAnyException();
ToolNameValidator.validate(name, false);
assertThat(logAppender.list).isEmpty();
} }
@Test @Test
void validToolName_maxLength() { void validToolNameMaxLength() {
String name = "a".repeat(128); String name = "a".repeat(128);
assertThatCode(() -> ToolNameValidator.validate(name, true)).doesNotThrowAnyException(); assertThatCode(() -> ToolNameValidator.validate(name, true)).doesNotThrowAnyException();
ToolNameValidator.validate(name, false);
assertThat(logAppender.list).isEmpty();
} }
@Test @Test
void invalidToolName_null_strictMode() { void nullOrEmpty() {
assertThatThrownBy(() -> ToolNameValidator.validate(null, true)).isInstanceOf(IllegalArgumentException.class) assertThatThrownBy(() -> ToolNameValidator.validate(null, true)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("null or empty"); .hasMessageContaining("null or empty");
}
@Test
void invalidToolName_empty_strictMode() {
assertThatThrownBy(() -> ToolNameValidator.validate("", true)).isInstanceOf(IllegalArgumentException.class) assertThatThrownBy(() -> ToolNameValidator.validate("", true)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("null or empty"); .hasMessageContaining("null or empty");
} }
@Test @Test
void invalidToolName_tooLong_strictMode() { void strictLength() {
String name = "a".repeat(129); String name = "a".repeat(129);
assertThatThrownBy(() -> ToolNameValidator.validate(name, true)).isInstanceOf(IllegalArgumentException.class) assertThatThrownBy(() -> ToolNameValidator.validate(name, true)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("128 characters"); .hasMessageContaining("128 characters");
@ -79,18 +106,42 @@ class ToolNameValidatorTests {
"tööl", // non-ASCII "tööl", // non-ASCII
"工具" // unicode "工具" // unicode
}) })
void invalidToolNames_specialCharacters_strictMode(String name) { void strictInvalidCharacters(String name) {
assertThatThrownBy(() -> ToolNameValidator.validate(name, true)).isInstanceOf(IllegalArgumentException.class) assertThatThrownBy(() -> ToolNameValidator.validate(name, true)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("invalid characters"); .hasMessageContaining("invalid characters");
} }
@Test @Test
void invalidToolName_nonStrictMode_doesNotThrow() { void lenientNull() {
// strict=false means warn only, should not throw
assertThatCode(() -> ToolNameValidator.validate("invalid name", false)).doesNotThrowAnyException();
assertThatCode(() -> ToolNameValidator.validate(null, false)).doesNotThrowAnyException(); assertThatCode(() -> ToolNameValidator.validate(null, false)).doesNotThrowAnyException();
assertThat(logAppender.list).satisfies(hasWarning("null or empty"));
}
@Test
void lenientEmpty() {
assertThatCode(() -> ToolNameValidator.validate("", false)).doesNotThrowAnyException(); assertThatCode(() -> ToolNameValidator.validate("", false)).doesNotThrowAnyException();
assertThat(logAppender.list).satisfies(hasWarning("null or empty"));
}
@Test
void lenientLength() {
assertThatCode(() -> ToolNameValidator.validate("a".repeat(129), false)).doesNotThrowAnyException(); assertThatCode(() -> ToolNameValidator.validate("a".repeat(129), false)).doesNotThrowAnyException();
assertThat(logAppender.list).satisfies(hasWarning("128 characters"));
}
@Test
void lenientInvalidCharacters() {
assertThatCode(() -> ToolNameValidator.validate("invalid name", false)).doesNotThrowAnyException();
assertThat(logAppender.list).satisfies(hasWarning("invalid characters"));
}
private Consumer<List<? extends ILoggingEvent>> hasWarning(String errorMessage) {
return logs -> {
assertThat(logs).hasSize(1).first().satisfies(log -> {
assertThat(log.getLevel()).isEqualTo(Level.WARN);
assertThat(log.getFormattedMessage()).contains(errorMessage);
});
};
} }
} }