-
Notifications
You must be signed in to change notification settings - Fork 207
feat(server): add tool name validation per SEP-986 #695
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| package io.modelcontextprotocol.kotlin.sdk.server.utils | ||
|
|
||
| import io.github.oshai.kotlinlogging.KotlinLogging | ||
|
|
||
| private val logger = KotlinLogging.logger("ToolNameValidation") | ||
|
|
||
| private const val MAX_TOOL_NAME_LENGTH = 128 | ||
|
|
||
| private const val SEP_986_URL = "https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986" | ||
|
|
||
| /** | ||
| * Validates a tool name against the MCP tool naming standard (SEP-986). | ||
| * Returns a list of warnings. An empty list means the name is valid. | ||
| */ | ||
| internal fun validateToolName(name: String): List<String> { | ||
| if (name.isEmpty()) return listOf("Tool name cannot be empty") | ||
|
|
||
| val invalidChars = name.toSet().filterNot { it.isAsciiAllowed() } | ||
| val remainingInvalidChars = invalidChars - setOf(' ', ',') | ||
|
devcrocod marked this conversation as resolved.
|
||
|
|
||
| return listOfNotNull( | ||
| "Tool name exceeds maximum length of $MAX_TOOL_NAME_LENGTH characters (current: ${name.length})" | ||
| .takeIf { name.length > MAX_TOOL_NAME_LENGTH }, | ||
|
Comment on lines
+7
to
+23
|
||
| "Tool name contains spaces, which may cause parsing issues" | ||
| .takeIf { ' ' in invalidChars }, | ||
| "Tool name contains commas, which may cause parsing issues" | ||
| .takeIf { ',' in invalidChars }, | ||
| "Tool name contains invalid characters: ${remainingInvalidChars.joinToString(", ") { "\"$it\"" }}" | ||
| .takeIf { remainingInvalidChars.isNotEmpty() }, | ||
| "Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)" | ||
| .takeIf { invalidChars.isNotEmpty() }, | ||
|
devcrocod marked this conversation as resolved.
|
||
| "Tool name starts or ends with a dash, which may cause parsing issues in some contexts" | ||
| .takeIf { name.startsWith('-') || name.endsWith('-') }, | ||
| "Tool name starts or ends with a dot, which may cause parsing issues in some contexts" | ||
| .takeIf { name.startsWith('.') || name.endsWith('.') }, | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Validates the tool [name] and logs a warning if it does not conform to SEP-986. | ||
| * Does not block registration — tools with non-conforming names are still accepted. | ||
| */ | ||
| internal fun warnIfInvalidToolName(name: String) { | ||
| val warnings = validateToolName(name) | ||
| if (warnings.isEmpty()) return | ||
|
|
||
| val warningLines = warnings.joinToString("\n") { " - $it" } | ||
| logger.warn { | ||
| """ | ||
| |Tool name validation warning for "$name": | ||
| |$warningLines | ||
| |Tool registration will proceed, but this may cause compatibility issues. | ||
| |Consider updating the tool name to conform to the MCP tool naming standard. | ||
| |See SEP-986: $SEP_986_URL | ||
| """.trimMargin() | ||
| } | ||
| } | ||
|
|
||
| private fun Char.isAsciiAllowed(): Boolean = | ||
| this in 'a'..'z' || this in 'A'..'Z' || this in '0'..'9' || this == '_' || this == '-' || this == '.' | ||
|
devcrocod marked this conversation as resolved.
Comment on lines
+28
to
+60
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| package io.modelcontextprotocol.kotlin.sdk.server.utils | ||
|
|
||
| import io.kotest.matchers.booleans.shouldBeTrue | ||
| import io.kotest.matchers.collections.shouldBeEmpty | ||
| import io.kotest.matchers.collections.shouldContain | ||
| import io.kotest.matchers.collections.shouldHaveSize | ||
| import io.kotest.matchers.collections.shouldNotBeEmpty | ||
| import org.junit.jupiter.api.Test | ||
| import org.junit.jupiter.params.ParameterizedTest | ||
| import org.junit.jupiter.params.provider.ValueSource | ||
|
|
||
| class ToolNameValidationTest { | ||
|
|
||
| @ParameterizedTest | ||
| @ValueSource( | ||
| strings = [ | ||
| "getUser", | ||
| "get_user_profile", | ||
| "user-profile-update", | ||
| "admin.tools.list", | ||
| "DATA_EXPORT_v2.1", | ||
| "a", | ||
| "Z", | ||
| "0", | ||
| "a-b.c_d", | ||
| ], | ||
| ) | ||
| fun `should return no warnings for valid tool names`(name: String) { | ||
| validateToolName(name).shouldBeEmpty() | ||
| } | ||
|
|
||
| @Test | ||
| fun `should return no warnings for max length name`() { | ||
| validateToolName("a".repeat(128)).shouldBeEmpty() | ||
| } | ||
|
Comment on lines
+32
to
+35
|
||
|
|
||
| @Test | ||
| fun `should warn for empty name`() { | ||
| val warnings = validateToolName("") | ||
| warnings shouldHaveSize 1 | ||
| warnings shouldContain "Tool name cannot be empty" | ||
| } | ||
|
|
||
| @Test | ||
| fun `should warn for name exceeding max length`() { | ||
| val warnings = validateToolName("a".repeat(129)) | ||
| warnings.shouldNotBeEmpty() | ||
| warnings.any { "exceeds maximum length" in it }.shouldBeTrue() | ||
| } | ||
|
|
||
| @ParameterizedTest | ||
| @ValueSource(strings = ["get user profile", "my tool"]) | ||
| fun `should warn for names with spaces`(name: String) { | ||
| val warnings = validateToolName(name) | ||
| warnings shouldContain "Tool name contains spaces, which may cause parsing issues" | ||
| } | ||
|
|
||
| @ParameterizedTest | ||
| @ValueSource(strings = ["get,user,profile", "a,b"]) | ||
| fun `should warn for names with commas`(name: String) { | ||
| val warnings = validateToolName(name) | ||
| warnings shouldContain "Tool name contains commas, which may cause parsing issues" | ||
| } | ||
|
|
||
| @ParameterizedTest | ||
| @ValueSource(strings = ["user/profile/update", "a/b"]) | ||
| fun `should warn for names with forward slashes`(name: String) { | ||
| val warnings = validateToolName(name) | ||
| warnings.any { "invalid characters" in it }.shouldBeTrue() | ||
| } | ||
|
Comment on lines
+65
to
+70
|
||
|
|
||
| @ParameterizedTest | ||
| @ValueSource(strings = ["user@domain.com", "tool#1", "tool\$name"]) | ||
| fun `should warn for names with special characters`(name: String) { | ||
| val warnings = validateToolName(name) | ||
| warnings.any { "invalid characters" in it }.shouldBeTrue() | ||
| warnings shouldContain "Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)" | ||
| } | ||
|
Comment on lines
+72
to
+78
|
||
|
|
||
| @Test | ||
| fun `should warn for unicode characters`() { | ||
| val warnings = validateToolName("user-\u00f1ame") | ||
| warnings.any { "invalid characters" in it }.shouldBeTrue() | ||
| } | ||
|
|
||
| @Test | ||
| fun `should warn for name starting with dash`() { | ||
| val warnings = validateToolName("-my-tool") | ||
| warnings shouldContain "Tool name starts or ends with a dash, which may cause parsing issues in some contexts" | ||
| } | ||
|
|
||
| @Test | ||
| fun `should warn for name ending with dash`() { | ||
| val warnings = validateToolName("my-tool-") | ||
| warnings shouldContain "Tool name starts or ends with a dash, which may cause parsing issues in some contexts" | ||
| } | ||
|
|
||
| @Test | ||
| fun `should warn for name starting with dot`() { | ||
| val warnings = validateToolName(".hidden") | ||
| warnings shouldContain "Tool name starts or ends with a dot, which may cause parsing issues in some contexts" | ||
| } | ||
|
|
||
| @Test | ||
| fun `should warn for name ending with dot`() { | ||
| val warnings = validateToolName("config.") | ||
| warnings shouldContain "Tool name starts or ends with a dot, which may cause parsing issues in some contexts" | ||
| } | ||
|
|
||
| @Test | ||
| fun `should produce multiple warnings for multiple issues`() { | ||
| val warnings = validateToolName("my tool,name") | ||
| warnings.any { "spaces" in it }.shouldBeTrue() | ||
| warnings.any { "commas" in it }.shouldBeTrue() | ||
| } | ||
|
|
||
| @Test | ||
| fun `should not duplicate space or comma in generic invalid characters message`() { | ||
| val warnings = validateToolName("my tool") | ||
| warnings shouldContain "Tool name contains spaces, which may cause parsing issues" | ||
| warnings.none { it.startsWith("Tool name contains invalid characters") }.shouldBeTrue() | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.