Skip to content

Commit e74ef2d

Browse files
authored
feat(server): add tool name validation per SEP-986 (#695)
Add warn-only validation for tool names at registration time, following the MCP tool naming standard (SEP-986) closes #417 ## How Has This Been Tested? unit tests ## Breaking Changes none ## Types of changes - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed
1 parent 61fab2f commit e74ef2d

4 files changed

Lines changed: 196 additions & 1 deletion

File tree

integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerToolsTest.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,16 @@ class ServerToolsTest : AbstractServerFeaturesTest() {
9898
assertFalse(toolListChangedNotificationReceived, "No notification should be sent when tool doesn't exist")
9999
}
100100

101+
@Test
102+
fun `addTool should succeed with non-conforming tool name`() = runTest {
103+
server.addTool("my invalid tool!", "Tool with non-conforming name", ToolSchema()) {
104+
CallToolResult(listOf(TextContent("It works")))
105+
}
106+
107+
val result = server.removeTool("my invalid tool!")
108+
assertTrue(result, "Tool with non-conforming name should have been registered and removable")
109+
}
110+
101111
@Test
102112
fun `removeTool should throw when tools capability is not supported`() = runTest {
103113
var toolListChangedNotificationReceived = false

kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/Server.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.modelcontextprotocol.kotlin.sdk.server
22

33
import io.github.oshai.kotlinlogging.KotlinLogging
4+
import io.modelcontextprotocol.kotlin.sdk.server.utils.warnIfInvalidToolName
45
import io.modelcontextprotocol.kotlin.sdk.shared.ProtocolOptions
56
import io.modelcontextprotocol.kotlin.sdk.shared.RequestOptions
67
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
@@ -303,7 +304,7 @@ public open class Server(
303304
logger.error { "Failed to add tool '${tool.name}': Server does not support tools capability" }
304305
"Server does not support tools capability. Enable it in ServerOptions."
305306
}
306-
307+
warnIfInvalidToolName(tool.name)
307308
toolRegistry.add(RegisteredTool(tool, handler))
308309
}
309310

@@ -357,6 +358,7 @@ public open class Server(
357358
logger.error { "Failed to add tools: Server does not support tools capability" }
358359
"Server does not support tools capability."
359360
}
361+
toolsToAdd.forEach { warnIfInvalidToolName(it.tool.name) }
360362
toolRegistry.addAll(toolsToAdd)
361363
}
362364

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package io.modelcontextprotocol.kotlin.sdk.server.utils
2+
3+
import io.github.oshai.kotlinlogging.KotlinLogging
4+
5+
private val logger = KotlinLogging.logger("ToolNameValidation")
6+
7+
private const val MAX_TOOL_NAME_LENGTH = 128
8+
9+
private const val SEP_986_URL = "https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986"
10+
11+
/**
12+
* Validates a tool name against the MCP tool naming standard (SEP-986).
13+
* Returns a list of warnings. An empty list means the name is valid.
14+
*/
15+
internal fun validateToolName(name: String): List<String> {
16+
if (name.isEmpty()) return listOf("Tool name cannot be empty")
17+
18+
val invalidChars = name.toSet().filterNot { it.isAsciiAllowed() }
19+
val remainingInvalidChars = invalidChars - setOf(' ', ',')
20+
21+
return listOfNotNull(
22+
"Tool name exceeds maximum length of $MAX_TOOL_NAME_LENGTH characters (current: ${name.length})"
23+
.takeIf { name.length > MAX_TOOL_NAME_LENGTH },
24+
"Tool name contains spaces, which may cause parsing issues"
25+
.takeIf { ' ' in invalidChars },
26+
"Tool name contains commas, which may cause parsing issues"
27+
.takeIf { ',' in invalidChars },
28+
"Tool name contains invalid characters: ${remainingInvalidChars.joinToString(", ") { "\"$it\"" }}"
29+
.takeIf { remainingInvalidChars.isNotEmpty() },
30+
"Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)"
31+
.takeIf { invalidChars.isNotEmpty() },
32+
"Tool name starts or ends with a dash, which may cause parsing issues in some contexts"
33+
.takeIf { name.startsWith('-') || name.endsWith('-') },
34+
"Tool name starts or ends with a dot, which may cause parsing issues in some contexts"
35+
.takeIf { name.startsWith('.') || name.endsWith('.') },
36+
)
37+
}
38+
39+
/**
40+
* Validates the tool [name] and logs a warning if it does not conform to SEP-986.
41+
* Does not block registration — tools with non-conforming names are still accepted.
42+
*/
43+
internal fun warnIfInvalidToolName(name: String) {
44+
val warnings = validateToolName(name)
45+
if (warnings.isEmpty()) return
46+
47+
val warningLines = warnings.joinToString("\n") { " - $it" }
48+
logger.warn {
49+
"""
50+
|Tool name validation warning for "$name":
51+
|$warningLines
52+
|Tool registration will proceed, but this may cause compatibility issues.
53+
|Consider updating the tool name to conform to the MCP tool naming standard.
54+
|See SEP-986: $SEP_986_URL
55+
""".trimMargin()
56+
}
57+
}
58+
59+
private fun Char.isAsciiAllowed(): Boolean =
60+
this in 'a'..'z' || this in 'A'..'Z' || this in '0'..'9' || this == '_' || this == '-' || this == '.'
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package io.modelcontextprotocol.kotlin.sdk.server.utils
2+
3+
import io.kotest.matchers.booleans.shouldBeTrue
4+
import io.kotest.matchers.collections.shouldBeEmpty
5+
import io.kotest.matchers.collections.shouldContain
6+
import io.kotest.matchers.collections.shouldHaveSize
7+
import io.kotest.matchers.collections.shouldNotBeEmpty
8+
import org.junit.jupiter.api.Test
9+
import org.junit.jupiter.params.ParameterizedTest
10+
import org.junit.jupiter.params.provider.ValueSource
11+
12+
class ToolNameValidationTest {
13+
14+
@ParameterizedTest
15+
@ValueSource(
16+
strings = [
17+
"getUser",
18+
"get_user_profile",
19+
"user-profile-update",
20+
"admin.tools.list",
21+
"DATA_EXPORT_v2.1",
22+
"a",
23+
"Z",
24+
"0",
25+
"a-b.c_d",
26+
],
27+
)
28+
fun `should return no warnings for valid tool names`(name: String) {
29+
validateToolName(name).shouldBeEmpty()
30+
}
31+
32+
@Test
33+
fun `should return no warnings for max length name`() {
34+
validateToolName("a".repeat(128)).shouldBeEmpty()
35+
}
36+
37+
@Test
38+
fun `should warn for empty name`() {
39+
val warnings = validateToolName("")
40+
warnings shouldHaveSize 1
41+
warnings shouldContain "Tool name cannot be empty"
42+
}
43+
44+
@Test
45+
fun `should warn for name exceeding max length`() {
46+
val warnings = validateToolName("a".repeat(129))
47+
warnings.shouldNotBeEmpty()
48+
warnings.any { "exceeds maximum length" in it }.shouldBeTrue()
49+
}
50+
51+
@ParameterizedTest
52+
@ValueSource(strings = ["get user profile", "my tool"])
53+
fun `should warn for names with spaces`(name: String) {
54+
val warnings = validateToolName(name)
55+
warnings shouldContain "Tool name contains spaces, which may cause parsing issues"
56+
}
57+
58+
@ParameterizedTest
59+
@ValueSource(strings = ["get,user,profile", "a,b"])
60+
fun `should warn for names with commas`(name: String) {
61+
val warnings = validateToolName(name)
62+
warnings shouldContain "Tool name contains commas, which may cause parsing issues"
63+
}
64+
65+
@ParameterizedTest
66+
@ValueSource(strings = ["user/profile/update", "a/b"])
67+
fun `should warn for names with forward slashes`(name: String) {
68+
val warnings = validateToolName(name)
69+
warnings.any { "invalid characters" in it }.shouldBeTrue()
70+
}
71+
72+
@ParameterizedTest
73+
@ValueSource(strings = ["user@domain.com", "tool#1", "tool\$name"])
74+
fun `should warn for names with special characters`(name: String) {
75+
val warnings = validateToolName(name)
76+
warnings.any { "invalid characters" in it }.shouldBeTrue()
77+
warnings shouldContain "Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)"
78+
}
79+
80+
@Test
81+
fun `should warn for unicode characters`() {
82+
val warnings = validateToolName("user-\u00f1ame")
83+
warnings.any { "invalid characters" in it }.shouldBeTrue()
84+
}
85+
86+
@Test
87+
fun `should warn for name starting with dash`() {
88+
val warnings = validateToolName("-my-tool")
89+
warnings shouldContain "Tool name starts or ends with a dash, which may cause parsing issues in some contexts"
90+
}
91+
92+
@Test
93+
fun `should warn for name ending with dash`() {
94+
val warnings = validateToolName("my-tool-")
95+
warnings shouldContain "Tool name starts or ends with a dash, which may cause parsing issues in some contexts"
96+
}
97+
98+
@Test
99+
fun `should warn for name starting with dot`() {
100+
val warnings = validateToolName(".hidden")
101+
warnings shouldContain "Tool name starts or ends with a dot, which may cause parsing issues in some contexts"
102+
}
103+
104+
@Test
105+
fun `should warn for name ending with dot`() {
106+
val warnings = validateToolName("config.")
107+
warnings shouldContain "Tool name starts or ends with a dot, which may cause parsing issues in some contexts"
108+
}
109+
110+
@Test
111+
fun `should produce multiple warnings for multiple issues`() {
112+
val warnings = validateToolName("my tool,name")
113+
warnings.any { "spaces" in it }.shouldBeTrue()
114+
warnings.any { "commas" in it }.shouldBeTrue()
115+
}
116+
117+
@Test
118+
fun `should not duplicate space or comma in generic invalid characters message`() {
119+
val warnings = validateToolName("my tool")
120+
warnings shouldContain "Tool name contains spaces, which may cause parsing issues"
121+
warnings.none { it.startsWith("Tool name contains invalid characters") }.shouldBeTrue()
122+
}
123+
}

0 commit comments

Comments
 (0)