feat(core): add extensions field to ClientCapabilities and ServerCapabilities (#678)
<!-- Provide a brief summary of your changes -->
Add support for MCP extension negotiation during the initialize
handshake, as defined in the MCP extensions specification. Clients and
servers can now advertise supported extensions via a new `extensions:
Map<String, JsonObject>?` field on their respective capabilities
objects.
## Motivation and Context
<!-- Why is this change needed? What problem does it solve? -->
Extensions are supported part of the [MCP spec
](https://modelcontextprotocol.io/extensions/overview) and are available
in other language libraries e.g. [Golang
](862d78a265/mcp/protocol.go (L215)).
## How Has This Been Tested?
Existing and added unit tests
## Breaking Changes
<!-- Will users need to update their code or configurations? -->
Adding a new field to existing ClientCapabilities and ServerCapabilities
fields will break already compiled clients. They will need to recompile
on upgrading.
## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [ ] 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
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [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
## Additional context
<!-- Add any other context, implementation notes, or design decisions
-->
Co-authored-by: Pavel Gorgulov <devcrocod@gmail.com>
This commit is contained in:
parent
f6ad332228
commit
bf8dc6d6b1
5 changed files with 217 additions and 8 deletions
|
|
@ -39,6 +39,7 @@ public class ClientCapabilitiesBuilder @PublishedApi internal constructor() {
|
|||
private var sampling: JsonObject? = null
|
||||
private var roots: ClientCapabilities.Roots? = null
|
||||
private var elicitation: JsonObject? = null
|
||||
private var extensions: Map<String, JsonObject>? = null
|
||||
private var experimental: JsonObject? = null
|
||||
|
||||
/**
|
||||
|
|
@ -132,6 +133,30 @@ public class ClientCapabilitiesBuilder @PublishedApi internal constructor() {
|
|||
*/
|
||||
public fun elicitation(block: JsonObjectBuilder.() -> Unit): Unit = elicitation(buildJsonObject(block))
|
||||
|
||||
/**
|
||||
* Defines extensions that the client supports.
|
||||
*
|
||||
* Extension identifiers use the format `{vendor-prefix}/{extension-name}`,
|
||||
* e.g., `"io.modelcontextprotocol/ui"`. Each value is an extension-specific
|
||||
* settings object; an empty [JsonObject] indicates no settings.
|
||||
*
|
||||
* Example:
|
||||
* ```kotlin
|
||||
* capabilities {
|
||||
* extensions(mapOf(
|
||||
* "io.modelcontextprotocol/ui" to buildJsonObject {
|
||||
* put("mimeTypes", JsonArray(listOf(JsonPrimitive("text/html"))))
|
||||
* }
|
||||
* ))
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param value The map of extension identifiers to their settings
|
||||
*/
|
||||
public fun extensions(value: Map<String, JsonObject>) {
|
||||
this.extensions = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines experimental, non-standard capabilities that the client supports.
|
||||
*
|
||||
|
|
@ -177,6 +202,7 @@ public class ClientCapabilitiesBuilder @PublishedApi internal constructor() {
|
|||
sampling = sampling,
|
||||
roots = roots,
|
||||
elicitation = elicitation,
|
||||
extensions = extensions,
|
||||
experimental = experimental,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ public data class Implementation(
|
|||
* @property elicitation Present if the client supports elicitation from the server.
|
||||
* @property experimental Experimental, non-standard capabilities that the client supports.
|
||||
* Keys are capability names, values are capability-specific configuration objects.
|
||||
* @property extensions Optional extensions that the client supports.
|
||||
* Keys are extension identifiers (e.g., `"io.modelcontextprotocol/ui"`),
|
||||
* values are extension-specific settings objects.
|
||||
*/
|
||||
@Serializable
|
||||
public data class ClientCapabilities(
|
||||
|
|
@ -52,6 +55,7 @@ public data class ClientCapabilities(
|
|||
public val roots: Roots? = null,
|
||||
public val elicitation: JsonObject? = null,
|
||||
public val experimental: JsonObject? = null,
|
||||
public val extensions: Map<String, JsonObject>? = null,
|
||||
) {
|
||||
|
||||
/**
|
||||
|
|
@ -93,6 +97,9 @@ public data class ClientCapabilities(
|
|||
* @property completions Present if the server supports argument autocompletion suggestions.
|
||||
* Keys are capability names, values are capability-specific configuration objects.
|
||||
* @property experimental Experimental, non-standard capabilities that the server supports.
|
||||
* @property extensions Optional extensions that the server supports.
|
||||
* Keys are extension identifiers (e.g., `"io.modelcontextprotocol/ui"`),
|
||||
* values are extension-specific settings objects.
|
||||
*/
|
||||
@Serializable
|
||||
public data class ServerCapabilities(
|
||||
|
|
@ -102,6 +109,7 @@ public data class ServerCapabilities(
|
|||
val logging: JsonObject? = null,
|
||||
val completions: JsonObject? = null,
|
||||
val experimental: JsonObject? = null,
|
||||
val extensions: Map<String, JsonObject>? = null,
|
||||
) {
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -182,6 +182,53 @@ class CapabilitiesTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should serialize ClientCapabilities with extensions`() {
|
||||
val extensions = mapOf(
|
||||
"io.modelcontextprotocol/ui" to buildJsonObject {
|
||||
put("mimeTypes", buildJsonObject { put("text/html", true) })
|
||||
},
|
||||
)
|
||||
val capabilities = ClientCapabilities(
|
||||
extensions = extensions,
|
||||
)
|
||||
verifySerialization(
|
||||
capabilities,
|
||||
McpJson,
|
||||
"""
|
||||
{
|
||||
"extensions": {
|
||||
"io.modelcontextprotocol/ui": {
|
||||
"mimeTypes": {
|
||||
"text/html": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should serialize ClientCapabilities with empty extensions settings`() {
|
||||
val capabilities = ClientCapabilities(
|
||||
extensions = mapOf(
|
||||
"io.modelcontextprotocol/ui" to EmptyJsonObject,
|
||||
),
|
||||
)
|
||||
verifySerialization(
|
||||
capabilities,
|
||||
McpJson,
|
||||
"""
|
||||
{
|
||||
"extensions": {
|
||||
"io.modelcontextprotocol/ui": {}
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should serialize ClientCapabilities with experimental`() {
|
||||
val experimental = buildJsonObject {
|
||||
|
|
@ -215,11 +262,15 @@ class CapabilitiesTest {
|
|||
val experimental = buildJsonObject {
|
||||
put("feature1", buildJsonObject { put("enabled", true) })
|
||||
}
|
||||
val extensions = mapOf(
|
||||
"io.modelcontextprotocol/ui" to EmptyJsonObject,
|
||||
)
|
||||
val capabilities = ClientCapabilities(
|
||||
sampling = ClientCapabilities.sampling,
|
||||
roots = ClientCapabilities.Roots(listChanged = true),
|
||||
elicitation = ClientCapabilities.elicitation,
|
||||
experimental = experimental,
|
||||
extensions = extensions,
|
||||
)
|
||||
verifySerialization(
|
||||
capabilities,
|
||||
|
|
@ -235,6 +286,9 @@ class CapabilitiesTest {
|
|||
"feature1": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"io.modelcontextprotocol/ui": {}
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
|
|
@ -270,6 +324,7 @@ class CapabilitiesTest {
|
|||
assertNull(capabilities.roots)
|
||||
assertNull(capabilities.elicitation)
|
||||
assertNull(capabilities.experimental)
|
||||
assertNull(capabilities.extensions)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -278,6 +333,11 @@ class CapabilitiesTest {
|
|||
sampling = ClientCapabilities.sampling,
|
||||
roots = ClientCapabilities.Roots(listChanged = false),
|
||||
elicitation = ClientCapabilities.elicitation,
|
||||
extensions = mapOf(
|
||||
"io.modelcontextprotocol/ui" to buildJsonObject {
|
||||
put("mimeTypes", buildJsonObject { put("text/html", true) })
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
verifySerializationRoundTrip(original, McpJson)
|
||||
|
|
@ -466,6 +526,32 @@ class CapabilitiesTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should serialize ServerCapabilities with extensions`() {
|
||||
val capabilities = ServerCapabilities(
|
||||
extensions = mapOf(
|
||||
"io.modelcontextprotocol/ui" to EmptyJsonObject,
|
||||
"com.example/custom" to buildJsonObject {
|
||||
put("setting", "value")
|
||||
},
|
||||
),
|
||||
)
|
||||
verifySerialization(
|
||||
capabilities,
|
||||
McpJson,
|
||||
"""
|
||||
{
|
||||
"extensions": {
|
||||
"io.modelcontextprotocol/ui": {},
|
||||
"com.example/custom": {
|
||||
"setting": "value"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should serialize ServerCapabilities with experimental`() {
|
||||
val experimental = buildJsonObject {
|
||||
|
|
@ -499,6 +585,9 @@ class CapabilitiesTest {
|
|||
val experimental = buildJsonObject {
|
||||
put("feature1", buildJsonObject { put("enabled", true) })
|
||||
}
|
||||
val extensions = mapOf(
|
||||
"io.modelcontextprotocol/ui" to EmptyJsonObject,
|
||||
)
|
||||
val capabilities = ServerCapabilities(
|
||||
tools = ServerCapabilities.Tools(listChanged = true),
|
||||
resources = ServerCapabilities.Resources(
|
||||
|
|
@ -509,6 +598,7 @@ class CapabilitiesTest {
|
|||
logging = ServerCapabilities.Logging,
|
||||
completions = ServerCapabilities.Completions,
|
||||
experimental = experimental,
|
||||
extensions = extensions,
|
||||
)
|
||||
verifySerialization(
|
||||
capabilities,
|
||||
|
|
@ -531,6 +621,9 @@ class CapabilitiesTest {
|
|||
"feature1": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"io.modelcontextprotocol/ui": {}
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
|
|
@ -578,6 +671,7 @@ class CapabilitiesTest {
|
|||
assertNull(capabilities.logging)
|
||||
assertNull(capabilities.completions)
|
||||
assertNull(capabilities.experimental)
|
||||
assertNull(capabilities.extensions)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -590,6 +684,9 @@ class CapabilitiesTest {
|
|||
),
|
||||
prompts = ServerCapabilities.Prompts(listChanged = true),
|
||||
logging = ServerCapabilities.Logging,
|
||||
extensions = mapOf(
|
||||
"io.modelcontextprotocol/ui" to EmptyJsonObject,
|
||||
),
|
||||
)
|
||||
|
||||
verifySerializationRoundTrip(original, McpJson)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import io.kotest.matchers.nulls.shouldBeNull
|
|||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.buildInitializeRequest
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.double
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
|
@ -31,6 +33,7 @@ class CapabilitiesDslTest {
|
|||
roots.shouldBeNull()
|
||||
elicitation.shouldBeNull()
|
||||
experimental.shouldBeNull()
|
||||
extensions.shouldBeNull()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -56,6 +59,13 @@ class CapabilitiesDslTest {
|
|||
put("beta", false)
|
||||
put("maxConcurrency", 10)
|
||||
}
|
||||
extensions(
|
||||
mapOf(
|
||||
"io.modelcontextprotocol/ui" to buildJsonObject {
|
||||
put("mimeTypes", "text/html")
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
info("Test", "1.0")
|
||||
}
|
||||
|
|
@ -80,6 +90,11 @@ class CapabilitiesDslTest {
|
|||
get("beta")?.jsonPrimitive?.content shouldBe "false"
|
||||
get("maxConcurrency")?.jsonPrimitive?.content shouldBe "10"
|
||||
}
|
||||
extensions shouldNotBeNull {
|
||||
get("io.modelcontextprotocol/ui") shouldNotBeNull {
|
||||
get("mimeTypes")?.jsonPrimitive?.content shouldBe "text/html"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +125,50 @@ class CapabilitiesDslTest {
|
|||
requestNull.params.capabilities.roots?.listChanged.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capabilities should build with extensions containing empty settings`() {
|
||||
val request = buildInitializeRequest {
|
||||
protocolVersion = "2024-11-05"
|
||||
capabilities {
|
||||
extensions(
|
||||
mapOf(
|
||||
"io.modelcontextprotocol/ui" to EmptyJsonObject,
|
||||
"com.example/custom" to EmptyJsonObject,
|
||||
),
|
||||
)
|
||||
}
|
||||
info("Test", "1.0")
|
||||
}
|
||||
|
||||
request.params.capabilities.shouldNotBeNull {
|
||||
extensions shouldNotBeNull {
|
||||
size shouldBe 2
|
||||
get("io.modelcontextprotocol/ui") shouldBe EmptyJsonObject
|
||||
get("com.example/custom") shouldBe EmptyJsonObject
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capabilities should overwrite extensions when set multiple times`() {
|
||||
val request = buildInitializeRequest {
|
||||
protocolVersion = "2024-11-05"
|
||||
capabilities {
|
||||
extensions(mapOf("io.modelcontextprotocol/ui" to EmptyJsonObject))
|
||||
extensions(mapOf("com.example/custom" to EmptyJsonObject)) // Should overwrite
|
||||
}
|
||||
info("Test", "1.0")
|
||||
}
|
||||
|
||||
request.params.capabilities.shouldNotBeNull {
|
||||
extensions shouldNotBeNull {
|
||||
size shouldBe 1
|
||||
get("com.example/custom") shouldBe EmptyJsonObject
|
||||
get("io.modelcontextprotocol/ui").shouldBeNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capabilities should overwrite when same field set multiple times`() {
|
||||
val request = buildInitializeRequest {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import io.kotest.matchers.nulls.shouldNotBeNull
|
|||
import io.kotest.matchers.shouldBe
|
||||
import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.buildInitializeRequest
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
|
|
@ -30,6 +31,11 @@ class InitializeDslTest {
|
|||
experimental {
|
||||
put("custom", true)
|
||||
}
|
||||
extensions(
|
||||
mapOf(
|
||||
"io.modelcontextprotocol/ui" to EmptyJsonObject,
|
||||
),
|
||||
)
|
||||
}
|
||||
info(
|
||||
name = "TestClient",
|
||||
|
|
@ -45,6 +51,9 @@ class InitializeDslTest {
|
|||
roots?.listChanged shouldBe true
|
||||
elicitation?.get("mode")?.jsonPrimitive?.content shouldBe "interactive"
|
||||
experimental?.get("custom")?.jsonPrimitive?.content shouldBe "true"
|
||||
extensions shouldNotBeNull {
|
||||
get("io.modelcontextprotocol/ui") shouldBe EmptyJsonObject
|
||||
}
|
||||
}
|
||||
request.params.clientInfo.shouldNotBeNull {
|
||||
name shouldBe "TestClient"
|
||||
|
|
@ -74,6 +83,9 @@ class InitializeDslTest {
|
|||
val samplingObj = buildJsonObject { put("key", "value") }
|
||||
val elicitationObj = buildJsonObject { put("key", "value") }
|
||||
val experimentalObj = buildJsonObject { put("key", "value") }
|
||||
val extensionsMap = mapOf(
|
||||
"io.modelcontextprotocol/ui" to buildJsonObject { put("key", "value") },
|
||||
)
|
||||
|
||||
val request = buildInitializeRequest {
|
||||
protocolVersion = "1.0"
|
||||
|
|
@ -81,6 +93,7 @@ class InitializeDslTest {
|
|||
sampling(samplingObj)
|
||||
elicitation(elicitationObj)
|
||||
experimental(experimentalObj)
|
||||
extensions(extensionsMap)
|
||||
}
|
||||
info("Test", "1.0")
|
||||
}
|
||||
|
|
@ -89,6 +102,7 @@ class InitializeDslTest {
|
|||
sampling shouldBe samplingObj
|
||||
elicitation shouldBe elicitationObj
|
||||
experimental shouldBe experimentalObj
|
||||
extensions shouldBe extensionsMap
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue