Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions kotlin-sdk-core/api/kotlin-sdk-core.api

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package io.modelcontextprotocol.kotlin.sdk.shared

import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import kotlinx.coroutines.CompletableDeferred

/**
* Implements [onClose], [onError] and [onMessage] functions of [Transport] providing
* corresponding [_onClose], [_onError] and [_onMessage] properties to use for an implementation.
*/
@Suppress("PropertyName")
public abstract class AbstractTransport : Transport {
protected var _onClose: (() -> Unit) = {}
private set
protected var _onError: ((Throwable) -> Unit) = {}
private set

// to not skip messages
private val _onMessageInitialized = CompletableDeferred<Unit>()
protected var _onMessage: (suspend ((JSONRPCMessage) -> Unit)) = {
_onMessageInitialized.await()
_onMessage.invoke(it)
}
private set

override fun onClose(block: () -> Unit) {
val old = _onClose
_onClose = {
old()
block()
}
}

override fun onError(block: (Throwable) -> Unit) {
val old = _onError
_onError = { e ->
old(e)
block(e)
}
}

override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) {
val old: suspend (JSONRPCMessage) -> Unit = when (_onMessageInitialized.isCompleted) {
true -> _onMessage
false -> { _ -> }
}

_onMessage = { message ->
old(message)
block(message)
}

_onMessageInitialized.complete(Unit)
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package io.modelcontextprotocol.kotlin.sdk.shared

import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import kotlinx.coroutines.CompletableDeferred

/**
* Describes the minimal contract for MCP transport that a client or server can communicate over.
Expand Down Expand Up @@ -47,53 +46,3 @@ public interface Transport {
*/
public fun onMessage(block: suspend (JSONRPCMessage) -> Unit)
}

/**
* Implements [onClose], [onError] and [onMessage] functions of [Transport] providing
* corresponding [_onClose], [_onError] and [_onMessage] properties to use for an implementation.
*/
@Suppress("PropertyName")
public abstract class AbstractTransport : Transport {
protected var _onClose: (() -> Unit) = {}
private set
protected var _onError: ((Throwable) -> Unit) = {}
private set

// to not skip messages
private val _onMessageInitialized = CompletableDeferred<Unit>()
protected var _onMessage: (suspend ((JSONRPCMessage) -> Unit)) = {
_onMessageInitialized.await()
_onMessage.invoke(it)
}
private set

override fun onClose(block: () -> Unit) {
val old = _onClose
_onClose = {
old()
block()
}
}

override fun onError(block: (Throwable) -> Unit) {
val old = _onError
_onError = { e ->
old(e)
block(e)
}
}

override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) {
val old: suspend (JSONRPCMessage) -> Unit = when (_onMessageInitialized.isCompleted) {
true -> _onMessage
false -> { _ -> }
}

_onMessage = { message ->
old(message)
block(message)
}

_onMessageInitialized.complete(Unit)
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package io.modelcontextprotocol.kotlin.sdk.types

import io.modelcontextprotocol.kotlin.sdk.types.Icon.Theme.Dark
import io.modelcontextprotocol.kotlin.sdk.types.Icon.Theme.Light
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
Expand Down Expand Up @@ -27,6 +29,14 @@ public val SUPPORTED_PROTOCOL_VERSIONS: List<String> = listOf(
public sealed interface WithMeta {
@SerialName("_meta")
public val meta: JsonObject?

@Deprecated(
message = "Use 'meta' instead.",
replaceWith = ReplaceWith("meta"),
)
@Suppress("PropertyName", "VariableNaming")
public val _meta: JsonObject
get() = meta ?: EmptyJsonObject
}

// ============================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,22 @@ public data class GetPromptRequest(override val params: GetPromptRequestParams)
@EncodeDefault
override val method: Method = Method.Defined.PromptsGet

@Deprecated(
message = "Use constructor with GetPromptRequestParams instead",
replaceWith = ReplaceWith("GetPromptRequest(GetPromptRequestParams(name, arguments, meta))"),
)
public constructor(
name: String,
arguments: Map<String, String>? = null,
meta: RequestMeta? = null,
) : this(
GetPromptRequestParams(
name = name,
arguments = arguments,
meta = meta,
),
)

/**
* The name of the prompt or prompt template to retrieve.
*/
Expand Down
1 change: 1 addition & 0 deletions kotlin-sdk-test/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ kotlin {
implementation(dependencies.platform(libs.ktor.bom))
implementation(project(":kotlin-sdk"))
implementation(kotlin("test"))
implementation(libs.kotest.assertions.core)
implementation(libs.kotest.assertions.json)
implementation(libs.kotlin.logging)
implementation(libs.kotlinx.coroutines.test)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
package io.modelcontextprotocol.kotlin.sdk.server

import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.throwable.shouldHaveMessage
import io.modelcontextprotocol.kotlin.sdk.EmptyJsonObject
import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest
import io.modelcontextprotocol.kotlin.sdk.GetPromptResult
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.Method
import io.modelcontextprotocol.kotlin.sdk.Prompt
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.McpException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals
Expand All @@ -21,25 +33,84 @@ class OldSchemaServerPromptsTest : OldSchemaAbstractServerFeaturesTest() {
)

@Test
fun `removePrompt should remove a prompt`() = runTest {
fun `Should list no prompts by default`() = runTest {
client.listPrompts() shouldNotBeNull {
prompts.shouldBeEmpty()
}
}

@Test
fun `Should add a prompt`() = runTest {
// Add a prompt
val testPrompt = Prompt("test-prompt", "Test Prompt", null)
val testPrompt = Prompt(
name = "test-prompt-with-custom-handler",
description = "Test Prompt",
arguments = null,
)
val expectedPromptResult = GetPromptResult(
description = "Test prompt description",
messages = listOf(),
)

server.addPrompt(testPrompt) {
expectedPromptResult
}

client.getPrompt(
GetPromptRequest(
name = "test-prompt-with-custom-handler",
arguments = null,
),
) shouldBe expectedPromptResult

client.listPrompts() shouldNotBeNull {
prompts shouldContainExactly listOf(testPrompt)
nextCursor shouldBe null
_meta shouldBe EmptyJsonObject
}
}

@Test
fun `Should remove a prompt`() = runTest {
// given
val testPrompt = Prompt(
name = "test-prompt-to-remove",
description = "Test Prompt",
arguments = null,
)
server.addPrompt(testPrompt) {
GetPromptResult(
description = "Test prompt description",
messages = listOf(),
)
}

// Remove the prompt
client.listPrompts() shouldNotBeNull {
prompts shouldContain testPrompt
}

// when
val result = server.removePrompt(testPrompt.name)

// Verify the prompt was removed
// then
assertTrue(result, "Prompt should be removed successfully")
val mcpException = shouldThrow<McpException> {
client.getPrompt(
GetPromptRequest(
name = testPrompt.name,
arguments = null,
),
)
}
mcpException shouldHaveMessage "MCP error -32603: Prompt not found: ${testPrompt.name}"

client.listPrompts() shouldNotBeNull {
prompts.firstOrNull { it.name == testPrompt.name } shouldBe null
}
}

@Test
fun `removePrompts should remove multiple prompts and send notification`() = runTest {
fun `Should remove multiple prompts and send notification`() = runTest {
// Add prompts
val testPrompt1 = Prompt("test-prompt-1", "Test Prompt 1", null)
val testPrompt2 = Prompt("test-prompt-2", "Test Prompt 2", null)
Expand All @@ -56,11 +127,17 @@ class OldSchemaServerPromptsTest : OldSchemaAbstractServerFeaturesTest() {
)
}

client.listPrompts() shouldNotBeNull {
prompts shouldHaveSize 2
}
// Remove the prompts
val result = server.removePrompts(listOf(testPrompt1.name, testPrompt2.name))

// Verify the prompts were removed
assertEquals(2, result, "Both prompts should be removed")
client.listPrompts() shouldNotBeNull {
prompts.shouldBeEmpty()
}
}

@Test
Expand All @@ -82,21 +159,55 @@ class OldSchemaServerPromptsTest : OldSchemaAbstractServerFeaturesTest() {
assertFalse(promptListChangedNotificationReceived, "No notification should be sent when prompt doesn't exist")
}

@Test
fun `removePrompt should throw when prompts capability is not supported`() = runTest {
@Nested
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need inner class? Maybe it would be better to separate it?

Copy link
Contributor Author

@kpavlov kpavlov Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a good idea. Let's do it in follow-up PR

inner class NoPromptsCapabilitiesTests {
// Create server without prompts capability
val serverOptions = ServerOptions(
capabilities = ServerCapabilities(),
)
val server = Server(
val serverWithoutPrompts = Server(
Implementation(name = "test server", version = "1.0"),
serverOptions,
ServerOptions(
capabilities = ServerCapabilities(),
),
)

// Verify that removing a prompt throws an exception
val exception = assertThrows<IllegalStateException> {
server.removePrompt("test-prompt")
@Test
fun `RemovePrompt should throw when prompts capability is not supported`() = runTest {
// Verify that removing a prompt throws an exception
val exception = assertThrows<IllegalStateException> {
serverWithoutPrompts.removePrompt("test-prompt")
}
assertEquals("Server does not support prompts capability.", exception.message)
}

@Test
fun `Remove Prompts should throw when prompts capability is not supported`() = runTest {
// Verify that removing a prompt throws an exception
val exception = assertThrows<IllegalStateException> {
serverWithoutPrompts.removePrompts(emptyList())
}
assertEquals("Server does not support prompts capability.", exception.message)
}

@Test
fun `Add Prompt should throw when prompts capability is not supported`() = runTest {
// Verify that removing a prompt throws an exception
val exception = assertThrows<IllegalStateException> {
serverWithoutPrompts.addPrompt(name = "test-prompt") {
GetPromptResult(
description = "Test prompt description",
messages = listOf(),
)
}
}
assertEquals("Server does not support prompts capability.", exception.message)
}

@Test
fun `Add Prompts should throw when prompts capability is not supported`() = runTest {
// Verify that removing a prompt throws an exception
val exception = assertThrows<IllegalStateException> {
serverWithoutPrompts.addPrompts(emptyList())
}
assertEquals("Server does not support prompts capability.", exception.message)
}
assertEquals("Server does not support prompts capability.", exception.message)
}
}
Loading