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() {
Assert.hasText(name, "name must not be empty");
return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta);
}

View file

@ -4,30 +4,33 @@
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.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.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.util.ToolNameValidator;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
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 org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;
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}.
*
@ -276,15 +279,23 @@ class AsyncToolSpecificationBuilderTest {
private McpServerTransportProvider transportProvider;
private final Logger logger = (Logger) LoggerFactory.getLogger(ToolNameValidator.class);
private final ListAppender<ILoggingEvent> logAppender = new ListAppender<>();
@BeforeEach
void setUp() {
transportProvider = mock(McpServerTransportProvider.class);
System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY);
logAppender.start();
logger.addAppender(logAppender);
}
@AfterEach
void tearDown() {
System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY);
logger.detachAppender(logAppender);
logAppender.stop();
}
@Test
@ -297,25 +308,27 @@ class AsyncToolSpecificationBuilderTest {
}
@Test
void systemPropertyFalseShouldWarnOnly() {
void lenientDefaultShouldLogOnInvalidName() {
System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false");
Tool invalidTool = Tool.builder().name("invalid tool name").build();
assertThatCode(() -> McpServer.async(transportProvider).tool(invalidTool, (exchange, args) -> null))
.doesNotThrowAnyException();
assertThat(logAppender.list).hasSize(1);
}
@Test
void perServerFalseShouldWarnOnly() {
void lenientConfigurationShouldLogOnInvalidName() {
Tool invalidTool = Tool.builder().name("invalid tool name").build();
assertThatCode(() -> McpServer.async(transportProvider)
.strictToolNameValidation(false)
.tool(invalidTool, (exchange, args) -> null)).doesNotThrowAnyException();
assertThat(logAppender.list).hasSize(1);
}
@Test
void perServerTrueShouldOverrideSystemProperty() {
void serverConfigurationShouldOverrideDefault() {
System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false");
Tool invalidTool = Tool.builder().name("invalid tool name").build();

View file

@ -4,26 +4,29 @@
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.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.util.ToolNameValidator;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
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 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.SyncToolSpecification.Builder}.
@ -114,15 +117,23 @@ class SyncToolSpecificationBuilderTest {
private McpServerTransportProvider transportProvider;
private final Logger logger = (Logger) LoggerFactory.getLogger(ToolNameValidator.class);
private final ListAppender<ILoggingEvent> logAppender = new ListAppender<>();
@BeforeEach
void setUp() {
transportProvider = mock(McpServerTransportProvider.class);
System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY);
logAppender.start();
logger.addAppender(logAppender);
}
@AfterEach
void tearDown() {
System.clearProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY);
logger.detachAppender(logAppender);
logAppender.stop();
}
@Test
@ -135,25 +146,27 @@ class SyncToolSpecificationBuilderTest {
}
@Test
void systemPropertyFalseShouldWarnOnly() {
void lenientDefaultShouldLogOnInvalidName() {
System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false");
Tool invalidTool = Tool.builder().name("invalid tool name").build();
assertThatCode(() -> McpServer.sync(transportProvider).tool(invalidTool, (exchange, args) -> null))
.doesNotThrowAnyException();
assertThat(logAppender.list).hasSize(1);
}
@Test
void perServerFalseShouldWarnOnly() {
void lenientConfigurationShouldLogOnInvalidName() {
Tool invalidTool = Tool.builder().name("invalid tool name").build();
assertThatCode(() -> McpServer.sync(transportProvider)
.strictToolNameValidation(false)
.tool(invalidTool, (exchange, args) -> null)).doesNotThrowAnyException();
assertThat(logAppender.list).hasSize(1);
}
@Test
void perServerTrueShouldOverrideSystemProperty() {
void serverConfigurationShouldOverrideDefault() {
System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false");
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}"""));
}
// 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;
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.params.ParameterizedTest;
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.assertThatThrownBy;
@ -16,33 +27,49 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
*/
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
@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) {
void validToolNames(String name) {
assertThatCode(() -> ToolNameValidator.validate(name, true)).doesNotThrowAnyException();
ToolNameValidator.validate(name, false);
assertThat(logAppender.list).isEmpty();
}
@Test
void validToolName_maxLength() {
void validToolNameMaxLength() {
String name = "a".repeat(128);
assertThatCode(() -> ToolNameValidator.validate(name, true)).doesNotThrowAnyException();
ToolNameValidator.validate(name, false);
assertThat(logAppender.list).isEmpty();
}
@Test
void invalidToolName_null_strictMode() {
void nullOrEmpty() {
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() {
void strictLength() {
String name = "a".repeat(129);
assertThatThrownBy(() -> ToolNameValidator.validate(name, true)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("128 characters");
@ -79,18 +106,42 @@ class ToolNameValidatorTests {
"tööl", // non-ASCII
"工具" // unicode
})
void invalidToolNames_specialCharacters_strictMode(String name) {
void strictInvalidCharacters(String name) {
assertThatThrownBy(() -> ToolNameValidator.validate(name, true)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("invalid characters");
}
@Test
void invalidToolName_nonStrictMode_doesNotThrow() {
// strict=false means warn only, should not throw
assertThatCode(() -> ToolNameValidator.validate("invalid name", false)).doesNotThrowAnyException();
void lenientNull() {
assertThatCode(() -> ToolNameValidator.validate(null, false)).doesNotThrowAnyException();
assertThat(logAppender.list).satisfies(hasWarning("null or empty"));
}
@Test
void lenientEmpty() {
assertThatCode(() -> ToolNameValidator.validate("", false)).doesNotThrowAnyException();
assertThat(logAppender.list).satisfies(hasWarning("null or empty"));
}
@Test
void lenientLength() {
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);
});
};
}
}