modularize sdk (#208)

* Modularize SDK: migrated client and server implementations to separate modules. Removed old configuration files and reorganized the source code structure

* add support native targets and update js/wasm configurations

* Refactor sample projects: add samples as composite builds, replace hardcoded dependencies with version catalog, remove obsolete gradle wrapper files

* Update .gitignore: exclude SWE agent directories (.claude/ and .junie/)

* Refine CodeQL workflow: update Kotlin build steps to target individual modules separately instead of building all at once
This commit is contained in:
Pavel Gorgulov 2025-08-04 11:53:38 +02:00 • committed by GitHub
commit 8698b96dd2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
92 changed files with 979 additions and 2003 deletions

View file

@ -53,8 +53,13 @@ jobs:
- name: Build Kotlin sources
run: |
./gradlew \
:build -Pkotlin.incremental=false \
--no-daemon --stacktrace --parallel
:kotlin-sdk-core:compileKotlinJvm \
:kotlin-sdk-client:compileKotlinJvm \
:kotlin-sdk-server:compileKotlinJvm \
:kotlin-sdk:compileKotlinJvm \
:kotlin-sdk-test:compileKotlinJvm \
-Pkotlin.incremental=false \
--no-daemon --stacktrace
- name: Analyze
uses: github/codeql-action/analyze@v3

4
.gitignore vendored
View file

@ -50,3 +50,7 @@ bin/
### Node.js ###
node_modules
dist
### SWE agents ###
.claude/
.junie/

View file

@ -1,278 +1,4 @@
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jreleaser.model.Active
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.atomicfu)
alias(libs.plugins.dokka)
alias(libs.plugins.jreleaser)
`maven-publish`
signing
alias(libs.plugins.kotlinx.binary.compatibility.validator)
}
group = "io.modelcontextprotocol"
version = "0.6.0"
val javadocJar by tasks.registering(Jar::class) {
archiveClassifier.set("javadoc")
}
publishing {
publications.withType(MavenPublication::class).all {
if (name.contains("jvm", ignoreCase = true)) {
artifact(javadocJar)
}
pom.configureMavenCentralMetadata()
signPublicationIfKeyPresent()
}
repositories {
maven(url = layout.buildDirectory.dir("staging-deploy"))
}
}
jreleaser {
gitRootSearch = true
strict = true
signing {
active = Active.ALWAYS
armored = true
artifacts = true
files = true
}
deploy {
active.set(Active.ALWAYS)
maven {
active.set(Active.ALWAYS)
mavenCentral {
val ossrh by creating {
active.set(Active.ALWAYS)
url.set("https://central.sonatype.com/api/v1/publisher")
applyMavenCentralRules = false
maxRetries = 240
stagingRepository(layout.buildDirectory.dir("staging-deploy").get().asFile.path)
// workaround: https://github.com/jreleaser/jreleaser/issues/1784
afterEvaluate {
publishing.publications.forEach { publication ->
if (publication is MavenPublication) {
val pubName = publication.name
if (!pubName.contains("jvm", ignoreCase = true)
&& !pubName.contains("metadata", ignoreCase = true)
&& !pubName.contains("kotlinMultiplatform", ignoreCase = true)
) {
artifactOverride {
artifactId = when {
pubName.contains("wasm", ignoreCase = true) ->
"${project.name}-wasm-${pubName.lowercase().substringAfter("wasm")}"
else -> "${project.name}-${pubName.lowercase()}"
}
jar = false
verifyPom = false
sourceJar = false
javadocJar = false
}
}
}
}
}
}
}
}
}
release {
github {
changelog.enabled = false
skipRelease = true
skipTag = true
overwrite = false
token = "none"
}
}
checksum {
individual = false
artifacts = false
files = false
}
}
fun MavenPom.configureMavenCentralMetadata() {
name by project.name
description by "Kotlin implementation of the Model Context Protocol (MCP)"
url by "https://github.com/modelcontextprotocol/kotlin-sdk"
licenses {
license {
name by "MIT License"
url by "https://github.com/modelcontextprotocol/kotlin-sdk/blob/main/LICENSE"
distribution by "repo"
}
}
developers {
developer {
id by "Anthropic"
name by "Anthropic Team"
organization by "Anthropic"
organizationUrl by "https://www.anthropic.com"
}
}
scm {
url by "https://github.com/modelcontextprotocol/kotlin-sdk"
connection by "scm:git:git://github.com/modelcontextprotocol/kotlin-sdk.git"
developerConnection by "scm:git:git@github.com:modelcontextprotocol/kotlin-sdk.git"
}
}
fun MavenPublication.signPublicationIfKeyPresent() {
val signingKey = project.getSensitiveProperty("GPG_SECRET_KEY")
val signingKeyPassphrase = project.getSensitiveProperty("SIGNING_PASSPHRASE")
if (!signingKey.isNullOrBlank()) {
the<SigningExtension>().apply {
useInMemoryPgpKeys(signingKey, signingKeyPassphrase)
sign(this@signPublicationIfKeyPresent)
}
}
}
fun Project.getSensitiveProperty(name: String?): String? {
if (name == null) {
error("Expected not null property '$name' for publication repository config")
}
return project.findProperty(name) as? String
?: System.getenv(name)
?: System.getProperty(name)
}
infix fun <T> Property<T>.by(value: T) {
set(value)
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
abstract class GenerateLibVersionTask @Inject constructor(
@get:Input val libVersion: String,
@get:OutputDirectory val sourcesDir: File,
) : DefaultTask() {
@TaskAction
fun generate() {
val sourceFile = File(sourcesDir.resolve("io/modelcontextprotocol/kotlin/sdk"), "LibVersion.kt")
if (!sourceFile.exists()) {
sourceFile.parentFile.mkdirs()
sourceFile.createNewFile()
}
sourceFile.writeText(
"""
package io.modelcontextprotocol.kotlin.sdk
public const val LIB_VERSION: String = "$libVersion"
""".trimIndent()
)
}
}
dokka {
moduleName.set("MCP Kotlin SDK")
dokkaSourceSets.configureEach {
sourceLink {
localDirectory.set(file("src/main/kotlin"))
remoteUrl("https://github.com/modelcontextprotocol/kotlin-sdk")
remoteLineSuffix.set("#L")
documentedVisibilities(VisibilityModifier.Public)
}
}
dokkaPublications.html {
outputDirectory.set(project.layout.projectDirectory.dir("docs"))
}
}
val sourcesDir = File(project.layout.buildDirectory.asFile.get(), "generated-sources/libVersion")
val generateLibVersionTask =
tasks.register<GenerateLibVersionTask>("generateLibVersion", version.toString(), sourcesDir)
kotlin {
jvm {
compilerOptions {
jvmTarget = JvmTarget.JVM_1_8
}
}
iosArm64()
iosX64()
iosSimulatorArm64()
js(IR) {
nodejs {
testTask {
useMocha {
timeout = "30s"
}
}
}
}
wasmJs {
nodejs()
}
explicitApi = ExplicitApiMode.Strict
jvmToolchain(21)
sourceSets {
commonMain {
kotlin.srcDir(generateLibVersionTask.map { it.sourcesDir })
dependencies {
api(libs.kotlinx.serialization.json)
api(libs.kotlinx.collections.immutable)
api(libs.ktor.client.cio)
api(libs.ktor.server.cio)
api(libs.ktor.server.sse)
api(libs.ktor.server.websockets)
implementation(libs.kotlin.logging)
}
}
commonTest {
dependencies {
implementation(libs.kotlin.test)
implementation(libs.ktor.server.test.host)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.kotest.assertions.json)
}
}
jvmTest {
dependencies {
implementation(libs.ktor.client.mock)
implementation(libs.mockk)
implementation(libs.slf4j.simple)
}
}
}
}
allprojects {
group = "io.modelcontextprotocol"
version = "0.6.0"
}

16
buildSrc/build.gradle.kts Normal file
View file

@ -0,0 +1,16 @@
plugins {
`kotlin-dsl`
}
repositories {
gradlePluginPortal()
mavenCentral()
}
dependencies {
implementation(libs.kotlin.gradle)
implementation(libs.kotlin.serialization)
implementation(libs.kotlinx.atomicfu.gradle)
implementation(libs.dokka.gradle)
implementation(libs.jreleaser.gradle)
}

View file

@ -0,0 +1,7 @@
dependencyResolutionManagement {
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}

View file

@ -0,0 +1,23 @@
import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier
plugins {
id("org.jetbrains.dokka")
}
dokka {
moduleName.set("MCP Kotlin SDK - ${project.name}")
dokkaSourceSets.configureEach {
sourceLink {
localDirectory = projectDir.resolve("src")
remoteUrl("https://github.com/modelcontextprotocol/kotlin-sdk/tree/main/${project.name}/src")
remoteLineSuffix = "#L"
}
documentedVisibilities(VisibilityModifier.Public)
}
dokkaPublications.html {
outputDirectory = rootProject.layout.projectDirectory.dir("docs/${project.name}")
}
}

View file

@ -0,0 +1,73 @@
import org.jreleaser.model.Active
plugins {
id("org.jreleaser")
id("mcp.publishing")
}
jreleaser {
gitRootSearch = true
strict = true
signing {
active = Active.ALWAYS
armored = true
artifacts = true
files = true
}
deploy {
active = Active.ALWAYS
maven {
active = Active.ALWAYS
mavenCentral.create("ossrh") {
active = Active.ALWAYS
url = "https://central.sonatype.com/api/v1/publisher"
applyMavenCentralRules = false
maxRetries = 240
stagingRepository(layout.buildDirectory.dir("staging-deploy").get().asFile.path)
// workaround: https://github.com/jreleaser/jreleaser/issues/1784
afterEvaluate {
publishing.publications.forEach { publication ->
if (publication is MavenPublication) {
val pubName = publication.name
if (!pubName.contains("jvm", ignoreCase = true)
&& !pubName.contains("metadata", ignoreCase = true)
&& !pubName.contains("kotlinMultiplatform", ignoreCase = true)
) {
artifactOverride {
artifactId = when {
pubName.contains("wasm", ignoreCase = true) ->
"${project.name}-wasm-${pubName.lowercase().substringAfter("wasm")}"
else -> "${project.name}-${pubName.lowercase()}"
}
jar = false
verifyPom = false
sourceJar = false
javadocJar = false
}
}
}
}
}
}
}
release {
github {
enabled = false
skipRelease = true
skipTag = true
}
}
checksum {
individual = false
artifacts = false
files = false
}
}
}

View file

@ -0,0 +1,50 @@
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
id("org.jetbrains.kotlinx.atomicfu")
}
// Generation library versions
val generateLibVersion by tasks.registering {
val outputDir = layout.buildDirectory.dir("generated-sources/libVersion")
outputs.dir(outputDir)
doLast {
val sourceFile = outputDir.get().file("io/modelcontextprotocol/kotlin/sdk/LibVersion.kt").asFile
sourceFile.parentFile.mkdirs()
sourceFile.writeText(
"""
package io.modelcontextprotocol.kotlin.sdk
public const val LIB_VERSION: String = "${project.version}"
""".trimIndent()
)
}
}
kotlin {
jvm {
compilerOptions.jvmTarget = JvmTarget.JVM_1_8
}
macosX64(); macosArm64()
linuxX64(); linuxArm64()
mingwX64()
js { nodejs() }
wasmJs { nodejs() }
explicitApi = ExplicitApiMode.Strict
jvmToolchain(21)
sourceSets {
commonMain {
kotlin.srcDir(generateLibVersion)
}
}
}

View file

@ -0,0 +1,66 @@
plugins {
`maven-publish`
signing
}
val javadocJar by tasks.registering(Jar::class) {
archiveClassifier.set("javadoc")
}
publishing {
publications.withType<MavenPublication>().configureEach {
if (name.contains("jvm", ignoreCase = true)) {
artifact(javadocJar)
}
pom {
name = project.name
description = "Kotlin implementation of the Model Context Protocol (MCP)"
url = "https://github.com/modelcontextprotocol/kotlin-sdk"
licenses {
license {
name = "MIT License"
url = "https://github.com/modelcontextprotocol/kotlin-sdk/blob/main/LICENSE"
distribution = "repo"
}
}
developers {
developer {
id = "Anthropic"
name = "Anthropic Team"
organization = "Anthropic"
organizationUrl = "https://www.anthropic.com"
}
}
scm {
url = "https://github.com/modelcontextprotocol/kotlin-sdk"
connection = "scm:git:git://github.com/modelcontextprotocol/kotlin-sdk.git"
developerConnection = "scm:git:git@github.com:modelcontextprotocol/kotlin-sdk.git"
}
}
}
repositories {
maven {
name = "staging"
url = uri(layout.buildDirectory.dir("staging-deploy"))
}
}
}
signing {
val gpgKeyName = "GPG_SIGNING_KEY"
val gpgPassphraseName = "SIGNING_PASSPHRASE"
val signingKey = providers.environmentVariable(gpgKeyName)
.orElse(providers.gradleProperty(gpgKeyName))
val signingPassphrase = providers.environmentVariable(gpgPassphraseName)
.orElse(providers.gradleProperty(gpgPassphraseName))
if (signingKey.isPresent) {
useInMemoryPgpKeys(signingKey.get(), signingPassphrase.get())
sign(publishing.publications)
}
}

View file

@ -1,9 +1,11 @@
kotlin.code.style=official
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
# Dokka
org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled
org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true
# Kotlin
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx4G
# MPP
kotlin.mpp.enableCInteropCommonization=true

View file

@ -8,17 +8,31 @@ atomicfu = "0.29.0"
serialization = "1.9.0"
collections-immutable = "0.4.0"
coroutines = "1.10.2"
kotlinx-io = "0.8.0"
ktor = "3.2.2"
mockk = "1.14.5"
logging = "7.0.7"
jreleaser = "1.19.0"
binaryCompatibilityValidatorPlugin = "0.18.1"
slf4j = "2.0.17"
kotest = "5.9.1"
# Samples
mcp-kotlin = "0.6.0"
anthropic = "0.8.0"
shadow = "8.1.1"
[libraries]
# Plugins
kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" }
kotlinx-atomicfu-gradle = { module = "org.jetbrains.kotlinx:atomicfu-gradle-plugin", version.ref = "atomicfu" }
dokka-gradle = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" }
jreleaser-gradle = { module = "org.jreleaser:jreleaser-gradle-plugin", version.ref = "jreleaser" }
# Kotlinx libraries
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-io-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-io-core", version.ref = "kotlinx-io" }
kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "collections-immutable" }
kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging", version.ref = "logging" }
@ -29,18 +43,23 @@ ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", v
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }
# Testing
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" }
ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" }
kotest-assertions-json = { group = "io.kotest", name = "kotest-assertions-json", version.ref = "kotest" }
# Samples
mcp-kotlin = { group = "io.modelcontextprotocol", name = "kotlin-sdk", version.ref = "mcp-kotlin" }
anthropic-java = { group = "com.anthropic", name = "anthropic-java", version.ref = "anthropic" }
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
[plugins]
kotlinx-binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binaryCompatibilityValidatorPlugin" }
# Samples
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" }
dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
jreleaser = { id = "org.jreleaser", version.ref = "jreleaser"}
kotlinx-binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binaryCompatibilityValidatorPlugin" }
shadow = { id = "com.github.johnrengelman.shadow", version.ref = "shadow" }

View file

@ -0,0 +1,117 @@
public final class io/modelcontextprotocol/kotlin/sdk/LibVersionKt {
public static final field LIB_VERSION Ljava/lang/String;
}
public class io/modelcontextprotocol/kotlin/sdk/client/Client : io/modelcontextprotocol/kotlin/sdk/shared/Protocol {
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/Implementation;Lio/modelcontextprotocol/kotlin/sdk/client/ClientOptions;)V
public synthetic fun <init> (Lio/modelcontextprotocol/kotlin/sdk/Implementation;Lio/modelcontextprotocol/kotlin/sdk/client/ClientOptions;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun addRoot (Ljava/lang/String;Ljava/lang/String;)V
public final fun addRoots (Ljava/util/List;)V
protected final fun assertCapability (Ljava/lang/String;Ljava/lang/String;)V
protected fun assertCapabilityForMethod (Lio/modelcontextprotocol/kotlin/sdk/Method;)V
protected fun assertNotificationCapability (Lio/modelcontextprotocol/kotlin/sdk/Method;)V
public fun assertRequestHandlerCapability (Lio/modelcontextprotocol/kotlin/sdk/Method;)V
public final fun callTool (Lio/modelcontextprotocol/kotlin/sdk/CallToolRequest;ZLio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun callTool (Ljava/lang/String;Ljava/util/Map;ZLio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun callTool$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/CallToolRequest;ZLio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public static synthetic fun callTool$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Ljava/lang/String;Ljava/util/Map;ZLio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun complete (Lio/modelcontextprotocol/kotlin/sdk/CompleteRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun complete$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/CompleteRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public fun connect (Lio/modelcontextprotocol/kotlin/sdk/shared/Transport;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun getPrompt (Lio/modelcontextprotocol/kotlin/sdk/GetPromptRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun getPrompt$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/GetPromptRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun getServerCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/ServerCapabilities;
public final fun getServerVersion ()Lio/modelcontextprotocol/kotlin/sdk/Implementation;
public final fun listPrompts (Lio/modelcontextprotocol/kotlin/sdk/ListPromptsRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun listPrompts$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/ListPromptsRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun listResourceTemplates (Lio/modelcontextprotocol/kotlin/sdk/ListResourceTemplatesRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun listResourceTemplates$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/ListResourceTemplatesRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun listResources (Lio/modelcontextprotocol/kotlin/sdk/ListResourcesRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun listResources$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/ListResourcesRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun listTools (Lio/modelcontextprotocol/kotlin/sdk/ListToolsRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun listTools$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/ListToolsRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun ping (Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun ping$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun readResource (Lio/modelcontextprotocol/kotlin/sdk/ReadResourceRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun readResource$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/ReadResourceRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun removeRoot (Ljava/lang/String;)Z
public final fun removeRoots (Ljava/util/List;)I
public final fun sendRootsListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun setElicitationHandler (Lkotlin/jvm/functions/Function1;)V
public final fun setLoggingLevel (Lio/modelcontextprotocol/kotlin/sdk/LoggingLevel;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun setLoggingLevel$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/LoggingLevel;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun subscribeResource (Lio/modelcontextprotocol/kotlin/sdk/SubscribeRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun subscribeResource$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/SubscribeRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun unsubscribeResource (Lio/modelcontextprotocol/kotlin/sdk/UnsubscribeRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun unsubscribeResource$default (Lio/modelcontextprotocol/kotlin/sdk/client/Client;Lio/modelcontextprotocol/kotlin/sdk/UnsubscribeRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
}
public final class io/modelcontextprotocol/kotlin/sdk/client/ClientOptions : io/modelcontextprotocol/kotlin/sdk/shared/ProtocolOptions {
public fun <init> ()V
public fun <init> (Lio/modelcontextprotocol/kotlin/sdk/ClientCapabilities;Z)V
public synthetic fun <init> (Lio/modelcontextprotocol/kotlin/sdk/ClientCapabilities;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getCapabilities ()Lio/modelcontextprotocol/kotlin/sdk/ClientCapabilities;
}
public final class io/modelcontextprotocol/kotlin/sdk/client/KtorClientKt {
public static final fun mcpSse-BZiP2OM (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun mcpSse-BZiP2OM$default (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public static final fun mcpSseTransport-5_5nbZA (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;)Lio/modelcontextprotocol/kotlin/sdk/client/SseClientTransport;
public static synthetic fun mcpSseTransport-5_5nbZA$default (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/client/SseClientTransport;
}
public final class io/modelcontextprotocol/kotlin/sdk/client/SseClientTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport {
public synthetic fun <init> (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun send (Lio/modelcontextprotocol/kotlin/sdk/JSONRPCMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun start (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public final class io/modelcontextprotocol/kotlin/sdk/client/StdioClientTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport {
public fun <init> (Lkotlinx/io/Source;Lkotlinx/io/Sink;)V
public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun send (Lio/modelcontextprotocol/kotlin/sdk/JSONRPCMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun start (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public final class io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport {
public synthetic fun <init> (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun getProtocolVersion ()Ljava/lang/String;
public final fun getSessionId ()Ljava/lang/String;
public final fun send (Lio/modelcontextprotocol/kotlin/sdk/JSONRPCMessage;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun send (Lio/modelcontextprotocol/kotlin/sdk/JSONRPCMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun send$default (Lio/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport;Lio/modelcontextprotocol/kotlin/sdk/JSONRPCMessage;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public final fun setProtocolVersion (Ljava/lang/String;)V
public fun start (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun terminateSession (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public final class io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpError : java/lang/Exception {
public fun <init> ()V
public fun <init> (Ljava/lang/Integer;Ljava/lang/String;)V
public synthetic fun <init> (Ljava/lang/Integer;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getCode ()Ljava/lang/Integer;
}
public final class io/modelcontextprotocol/kotlin/sdk/client/StreamableHttpMcpKtorClientExtensionsKt {
public static final fun mcpStreamableHttp-BZiP2OM (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun mcpStreamableHttp-BZiP2OM$default (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public static final fun mcpStreamableHttpTransport-5_5nbZA (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;)Lio/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport;
public static synthetic fun mcpStreamableHttpTransport-5_5nbZA$default (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/time/Duration;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/client/StreamableHttpClientTransport;
}
public final class io/modelcontextprotocol/kotlin/sdk/client/WebSocketClientTransport : io/modelcontextprotocol/kotlin/sdk/shared/WebSocketMcpTransport {
public fun <init> (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
public synthetic fun <init> (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
}
public final class io/modelcontextprotocol/kotlin/sdk/client/WebSocketMcpKtorClientExtensionsKt {
public static final fun mcpWebSocket (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static synthetic fun mcpWebSocket$default (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public static final fun mcpWebSocketTransport (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lio/modelcontextprotocol/kotlin/sdk/client/WebSocketClientTransport;
public static synthetic fun mcpWebSocketTransport$default (Lio/ktor/client/HttpClient;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lio/modelcontextprotocol/kotlin/sdk/client/WebSocketClientTransport;
}

View file

@ -0,0 +1,43 @@
@file:OptIn(ExperimentalWasmDsl::class)
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins {
id("mcp.multiplatform")
id("mcp.publishing")
id("mcp.dokka")
id("mcp.jreleaser")
alias(libs.plugins.kotlinx.binary.compatibility.validator)
}
kotlin {
iosArm64(); iosX64(); iosSimulatorArm64()
watchosX64(); watchosArm64(); watchosSimulatorArm64()
tvosX64(); tvosArm64(); tvosSimulatorArm64()
js {
browser()
nodejs()
}
wasmJs {
browser()
nodejs()
}
sourceSets {
commonMain {
dependencies {
api(project(":kotlin-sdk-core"))
api(libs.ktor.client.cio)
implementation(libs.kotlin.logging)
}
}
commonTest {
dependencies {
implementation(kotlin("test"))
implementation(libs.ktor.client.mock)
implementation(libs.kotlinx.coroutines.test)
}
}
}
}

View file

@ -0,0 +1,608 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.CallToolRequest
import io.modelcontextprotocol.kotlin.sdk.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.CallToolResultBase
import io.modelcontextprotocol.kotlin.sdk.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.CompatibilityCallToolResult
import io.modelcontextprotocol.kotlin.sdk.CompleteRequest
import io.modelcontextprotocol.kotlin.sdk.CompleteResult
import io.modelcontextprotocol.kotlin.sdk.CreateElicitationRequest
import io.modelcontextprotocol.kotlin.sdk.CreateElicitationResult
import io.modelcontextprotocol.kotlin.sdk.EmptyRequestResult
import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest
import io.modelcontextprotocol.kotlin.sdk.GetPromptResult
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.InitializeRequest
import io.modelcontextprotocol.kotlin.sdk.InitializeResult
import io.modelcontextprotocol.kotlin.sdk.InitializedNotification
import io.modelcontextprotocol.kotlin.sdk.LATEST_PROTOCOL_VERSION
import io.modelcontextprotocol.kotlin.sdk.ListPromptsRequest
import io.modelcontextprotocol.kotlin.sdk.ListPromptsResult
import io.modelcontextprotocol.kotlin.sdk.ListResourceTemplatesRequest
import io.modelcontextprotocol.kotlin.sdk.ListResourceTemplatesResult
import io.modelcontextprotocol.kotlin.sdk.ListResourcesRequest
import io.modelcontextprotocol.kotlin.sdk.ListResourcesResult
import io.modelcontextprotocol.kotlin.sdk.ListRootsRequest
import io.modelcontextprotocol.kotlin.sdk.ListRootsResult
import io.modelcontextprotocol.kotlin.sdk.ListToolsRequest
import io.modelcontextprotocol.kotlin.sdk.ListToolsResult
import io.modelcontextprotocol.kotlin.sdk.LoggingLevel
import io.modelcontextprotocol.kotlin.sdk.LoggingMessageNotification.SetLevelRequest
import io.modelcontextprotocol.kotlin.sdk.Method
import io.modelcontextprotocol.kotlin.sdk.PingRequest
import io.modelcontextprotocol.kotlin.sdk.ReadResourceRequest
import io.modelcontextprotocol.kotlin.sdk.ReadResourceResult
import io.modelcontextprotocol.kotlin.sdk.Root
import io.modelcontextprotocol.kotlin.sdk.RootsListChangedNotification
import io.modelcontextprotocol.kotlin.sdk.SUPPORTED_PROTOCOL_VERSIONS
import io.modelcontextprotocol.kotlin.sdk.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.SubscribeRequest
import io.modelcontextprotocol.kotlin.sdk.UnsubscribeRequest
import io.modelcontextprotocol.kotlin.sdk.shared.Protocol
import io.modelcontextprotocol.kotlin.sdk.shared.ProtocolOptions
import io.modelcontextprotocol.kotlin.sdk.shared.RequestOptions
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.getAndUpdate
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.minus
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlin.coroutines.cancellation.CancellationException
private val logger = KotlinLogging.logger {}
/**
* Options for configuring the MCP client.
*
* @property capabilities The capabilities this client supports.
* @property enforceStrictCapabilities Whether to strictly enforce capabilities when interacting with the server.
*/
public class ClientOptions(
public val capabilities: ClientCapabilities = ClientCapabilities(),
enforceStrictCapabilities: Boolean = true,
) : ProtocolOptions(enforceStrictCapabilities = enforceStrictCapabilities)
/**
* An MCP client on top of a pluggable transport.
*
* The client automatically performs the initialization handshake with the server when [connect] is called.
* After initialization, [serverCapabilities] and [serverVersion] provide details about the connected server.
*
* You can extend this class with custom request/notification/result types if needed.
*
* @param clientInfo Information about the client implementation (name, version).
* @param options Configuration options for this client.
*/
public open class Client(
private val clientInfo: Implementation,
options: ClientOptions = ClientOptions(),
) : Protocol(options) {
/**
* Retrieves the server's reported capabilities after the initialization process completes.
*
* @return The server's capabilities, or `null` if initialization is not yet complete.
*/
public var serverCapabilities: ServerCapabilities? = null
private set
/**
* Retrieves the server's reported version information after initialization.
*
* @return Information about the server's implementation, or `null` if initialization is not yet complete.
*/
public var serverVersion: Implementation? = null
private set
private val capabilities: ClientCapabilities = options.capabilities
private val roots = atomic(persistentMapOf<String, Root>())
init {
logger.debug { "Initializing MCP client with capabilities: $capabilities" }
// Internal handlers for roots
if (capabilities.roots != null) {
setRequestHandler<ListRootsRequest>(Method.Defined.RootsList) { _, _ ->
handleListRoots()
}
}
}
protected fun assertCapability(capability: String, method: String) {
val caps = serverCapabilities
val hasCapability = when (capability) {
"logging" -> caps?.logging != null
"prompts" -> caps?.prompts != null
"resources" -> caps?.resources != null
"tools" -> caps?.tools != null
else -> true
}
if (!hasCapability) {
throw IllegalStateException("Server does not support $capability (required for $method)")
}
}
/**
* Connects the client to the given [transport], performing the initialization handshake with the server.
*
* @param transport The transport to use for communication with the server.
* @throws IllegalStateException If the server's protocol version is not supported.
*/
override suspend fun connect(transport: Transport) {
super.connect(transport)
try {
val message = InitializeRequest(
protocolVersion = LATEST_PROTOCOL_VERSION,
capabilities = capabilities,
clientInfo = clientInfo
)
val result = request<InitializeResult>(message)
if (!SUPPORTED_PROTOCOL_VERSIONS.contains(result.protocolVersion)) {
throw IllegalStateException(
"Server's protocol version is not supported: ${result.protocolVersion}"
)
}
serverCapabilities = result.capabilities
serverVersion = result.serverInfo
notification(InitializedNotification())
} catch (error: Throwable) {
close()
if (error !is CancellationException) {
throw IllegalStateException("Error connecting to transport: ${error.message}")
}
throw error
}
}
override fun assertCapabilityForMethod(method: Method) {
when (method) {
Method.Defined.LoggingSetLevel -> {
if (serverCapabilities?.logging == null) {
throw IllegalStateException("Server does not support logging (required for $method)")
}
}
Method.Defined.PromptsGet,
Method.Defined.PromptsList,
Method.Defined.CompletionComplete,
-> {
if (serverCapabilities?.prompts == null) {
throw IllegalStateException("Server does not support prompts (required for $method)")
}
}
Method.Defined.ResourcesList,
Method.Defined.ResourcesTemplatesList,
Method.Defined.ResourcesRead,
Method.Defined.ResourcesSubscribe,
Method.Defined.ResourcesUnsubscribe,
-> {
val resCaps = serverCapabilities?.resources
?: error("Server does not support resources (required for $method)")
if (method == Method.Defined.ResourcesSubscribe && resCaps.subscribe != true) {
throw IllegalStateException(
"Server does not support resource subscriptions (required for $method)"
)
}
}
Method.Defined.ToolsCall,
Method.Defined.ToolsList,
-> {
if (serverCapabilities?.tools == null) {
throw IllegalStateException("Server does not support tools (required for $method)")
}
}
Method.Defined.Initialize,
Method.Defined.Ping,
-> {
// No specific capability required
}
else -> {
// For unknown or future methods, no assertion by default
}
}
}
override fun assertNotificationCapability(method: Method) {
when (method) {
Method.Defined.NotificationsRootsListChanged -> {
if (capabilities.roots?.listChanged != true) {
throw IllegalStateException(
"Client does not support roots list changed notifications (required for $method)"
)
}
}
Method.Defined.NotificationsInitialized,
Method.Defined.NotificationsCancelled,
Method.Defined.NotificationsProgress,
-> {
// Always allowed
}
else -> {
// For notifications not specifically listed, no assertion by default
}
}
}
override fun assertRequestHandlerCapability(method: Method) {
when (method) {
Method.Defined.SamplingCreateMessage -> {
if (capabilities.sampling == null) {
throw IllegalStateException(
"Client does not support sampling capability (required for $method)"
)
}
}
Method.Defined.RootsList -> {
if (capabilities.roots == null) {
throw IllegalStateException(
"Client does not support roots capability (required for $method)"
)
}
}
Method.Defined.ElicitationCreate -> {
if (capabilities.elicitation == null) {
throw IllegalStateException(
"Client does not support elicitation capability (required for $method)"
)
}
}
Method.Defined.Ping -> {
// No capability required
}
else -> {}
}
}
/**
* Sends a ping request to the server to check connectivity.
*
* @param options Optional request options.
* @throws IllegalStateException If the server does not support the ping method (unlikely).
*/
public suspend fun ping(options: RequestOptions? = null): EmptyRequestResult {
return request<EmptyRequestResult>(PingRequest(), options)
}
/**
* Sends a completion request to the server, typically to generate or complete some content.
*
* @param params The completion request parameters.
* @param options Optional request options.
* @return The completion result returned by the server, or `null` if none.
* @throws IllegalStateException If the server does not support prompts or completion.
*/
public suspend fun complete(params: CompleteRequest, options: RequestOptions? = null): CompleteResult {
return request(params, options)
}
/**
* Sets the logging level on the server.
*
* @param level The desired logging level.
* @param options Optional request options.
* @throws IllegalStateException If the server does not support logging.
*/
public suspend fun setLoggingLevel(level: LoggingLevel, options: RequestOptions? = null): EmptyRequestResult {
return request<EmptyRequestResult>(SetLevelRequest(level), options)
}
/**
* Retrieves a prompt by name from the server.
*
* @param request The prompt request containing the prompt name.
* @param options Optional request options.
* @return The requested prompt details, or `null` if not found.
* @throws IllegalStateException If the server does not support prompts.
*/
public suspend fun getPrompt(request: GetPromptRequest, options: RequestOptions? = null): GetPromptResult {
return request(request, options)
}
/**
* Lists all available prompts from the server.
*
* @param request A request object for listing prompts (usually empty).
* @param options Optional request options.
* @return The list of available prompts, or `null` if none.
* @throws IllegalStateException If the server does not support prompts.
*/
public suspend fun listPrompts(
request: ListPromptsRequest = ListPromptsRequest(),
options: RequestOptions? = null,
): ListPromptsResult {
return request(request, options)
}
/**
* Lists all available resources from the server.
*
* @param request A request object for listing resources (usually empty).
* @param options Optional request options.
* @return The list of resources, or `null` if none.
* @throws IllegalStateException If the server does not support resources.
*/
public suspend fun listResources(
request: ListResourcesRequest = ListResourcesRequest(),
options: RequestOptions? = null,
): ListResourcesResult {
return request(request, options)
}
/**
* Lists resource templates available on the server.
*
* @param request The request object for listing resource templates.
* @param options Optional request options.
* @return The list of resource templates, or `null` if none.
* @throws IllegalStateException If the server does not support resources.
*/
public suspend fun listResourceTemplates(
request: ListResourceTemplatesRequest,
options: RequestOptions? = null,
): ListResourceTemplatesResult {
return request(request, options)
}
/**
* Reads a resource from the server by its URI.
*
* @param request The request object containing the resource URI.
* @param options Optional request options.
* @return The resource content, or `null` if the resource is not found.
* @throws IllegalStateException If the server does not support resources.
*/
public suspend fun readResource(
request: ReadResourceRequest,
options: RequestOptions? = null,
): ReadResourceResult {
return request(request, options)
}
/**
* Subscribes to resource changes on the server.
*
* @param request The subscription request containing resource details.
* @param options Optional request options.
* @throws IllegalStateException If the server does not support resource subscriptions.
*/
public suspend fun subscribeResource(
request: SubscribeRequest,
options: RequestOptions? = null,
): EmptyRequestResult {
return request(request, options)
}
/**
* Unsubscribes from resource changes on the server.
*
* @param request The unsubscribe request containing resource details.
* @param options Optional request options.
* @throws IllegalStateException If the server does not support resource subscriptions.
*/
public suspend fun unsubscribeResource(
request: UnsubscribeRequest,
options: RequestOptions? = null,
): EmptyRequestResult {
return request(request, options)
}
/**
* Calls a tool on the server by name, passing the specified arguments.
*
* @param name The name of the tool to call.
* @param arguments A map of argument names to values for the tool.
* @param compatibility Whether to use compatibility mode for older protocol versions.
* @param options Optional request options.
* @return The result of the tool call, or `null` if none.
* @throws IllegalStateException If the server does not support tools.
*/
public suspend fun callTool(
name: String,
arguments: Map<String, Any?>,
compatibility: Boolean = false,
options: RequestOptions? = null,
): CallToolResultBase? {
val jsonArguments = arguments.mapValues { (_, value) ->
when (value) {
is String -> JsonPrimitive(value)
is Number -> JsonPrimitive(value)
is Boolean -> JsonPrimitive(value)
is JsonElement -> value
null -> JsonNull
else -> JsonPrimitive(value.toString())
}
}
val request = CallToolRequest(
name = name,
arguments = JsonObject(jsonArguments)
)
return callTool(request, compatibility, options)
}
/**
* Calls a tool on the server using a [CallToolRequest] object.
*
* @param request The request object containing the tool name and arguments.
* @param compatibility Whether to use compatibility mode for older protocol versions.
* @param options Optional request options.
* @return The result of the tool call, or `null` if none.
* @throws IllegalStateException If the server does not support tools.
*/
public suspend fun callTool(
request: CallToolRequest,
compatibility: Boolean = false,
options: RequestOptions? = null,
): CallToolResultBase? {
return if (compatibility) {
request<CompatibilityCallToolResult>(request, options)
} else {
request<CallToolResult>(request, options)
}
}
/**
* Lists all available tools on the server.
*
* @param request A request object for listing tools (usually empty).
* @param options Optional request options.
* @return The list of available tools, or `null` if none.
* @throws IllegalStateException If the server does not support tools.
*/
public suspend fun listTools(
request: ListToolsRequest = ListToolsRequest(),
options: RequestOptions? = null,
): ListToolsResult {
return request(request, options)
}
/**
* Registers a single root.
*
* @param uri The URI of the root.
* @param name A human-readable name for the root.
* @throws IllegalStateException If the client does not support roots.
*/
public fun addRoot(
uri: String,
name: String,
) {
if (capabilities.roots == null) {
logger.error { "Failed to add root '$name': Client does not support roots capability" }
throw IllegalStateException("Client does not support roots capability.")
}
logger.info { "Adding root: $name ($uri)" }
roots.update { current -> current.put(uri, Root(uri, name)) }
}
/**
* Registers multiple roots at once.
*
* @param rootsToAdd A list of [Root] objects to register.
* @throws IllegalStateException If the client does not support roots.
*/
public fun addRoots(rootsToAdd: List<Root>) {
if (capabilities.roots == null) {
logger.error { "Failed to add roots: Client does not support roots capability" }
throw IllegalStateException("Client does not support roots capability.")
}
logger.info { "Adding ${rootsToAdd.size} roots" }
roots.update { current -> current.putAll(rootsToAdd.associateBy { it.uri }) }
}
/**
* Removes a single root by URI.
*
* @param uri The URI of the root to remove.
* @return True if the root was removed, false if it wasn't found.
* @throws IllegalStateException If the client does not support roots.
*/
public fun removeRoot(uri: String): Boolean {
if (capabilities.roots == null) {
logger.error { "Failed to remove root '$uri': Client does not support roots capability" }
throw IllegalStateException("Client does not support roots capability.")
}
logger.info { "Removing root: $uri" }
val oldMap = roots.getAndUpdate { current -> current.remove(uri) }
val removed = uri in oldMap
logger.debug {
if (removed) {
"Root removed: $uri"
} else {
"Root not found: $uri"
}
}
return removed
}
/**
* Removes multiple roots at once.
*
* @param uris A list of root URIs to remove.
* @return The number of roots that were successfully removed.
* @throws IllegalStateException If the client does not support roots.
*/
public fun removeRoots(uris: List<String>): Int {
if (capabilities.roots == null) {
logger.error { "Failed to remove roots: Client does not support roots capability" }
throw IllegalStateException("Client does not support roots capability.")
}
logger.info { "Removing ${uris.size} roots" }
val oldMap = roots.getAndUpdate { current -> current - uris.toPersistentSet() }
val removedCount = uris.count { it in oldMap }
logger.info {
if (removedCount > 0) {
"Removed $removedCount roots"
} else {
"No roots were removed"
}
}
return removedCount
}
/**
* Notifies the server that the list of roots has changed.
* Typically used if the client is managing some form of hierarchical structure.
*
* @throws IllegalStateException If the client or server does not support roots.
*/
public suspend fun sendRootsListChanged() {
notification(RootsListChangedNotification())
}
/**
* Sets the elicitation handler.
*
* @param handler The elicitation handler.
* @throws IllegalStateException if the client does not support elicitation.
*/
public fun setElicitationHandler(handler: (CreateElicitationRequest) -> CreateElicitationResult) {
if (capabilities.elicitation == null) {
logger.error { "Failed to set elicitation handler: Client does not support elicitation" }
throw IllegalStateException("Client does not support elicitation.")
}
logger.info { "Setting the elicitation handler" }
setRequestHandler<CreateElicitationRequest>(Method.Defined.ElicitationCreate) { request, _ ->
handler(request)
}
}
// --- Internal Handlers ---
private suspend fun handleListRoots(): ListRootsResult {
val rootList = roots.value.values.toList()
return ListRootsResult(rootList)
}
}

View file

@ -0,0 +1,46 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.ktor.client.HttpClient
import io.ktor.client.request.HttpRequestBuilder
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.LIB_VERSION
import io.modelcontextprotocol.kotlin.sdk.shared.IMPLEMENTATION_NAME
import kotlin.time.Duration
/**
* Returns a new SSE transport for the Model Context Protocol using the provided HttpClient.
*
* @param urlString Optional URL of the MCP server.
* @param reconnectionTime Optional duration to wait before attempting to reconnect.
* @param requestBuilder Optional lambda to configure the HTTP request.
* @return A [SSEClientTransport] configured for MCP communication.
*/
public fun HttpClient.mcpSseTransport(
urlString: String? = null,
reconnectionTime: Duration? = null,
requestBuilder: HttpRequestBuilder.() -> Unit = {},
): SseClientTransport = SseClientTransport(this, urlString, reconnectionTime, requestBuilder)
/**
* Creates and connects an MCP client over SSE using the provided HttpClient.
*
* @param urlString Optional URL of the MCP server.
* @param reconnectionTime Optional duration to wait before attempting to reconnect.
* @param requestBuilder Optional lambda to configure the HTTP request.
* @return A connected [Client] ready for MCP communication.
*/
public suspend fun HttpClient.mcpSse(
urlString: String? = null,
reconnectionTime: Duration? = null,
requestBuilder: HttpRequestBuilder.() -> Unit = {},
): Client {
val transport = mcpSseTransport(urlString, reconnectionTime, requestBuilder)
val client = Client(
Implementation(
name = IMPLEMENTATION_NAME,
version = LIB_VERSION
)
)
client.connect(transport)
return client
}

View file

@ -0,0 +1,190 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.ktor.client.HttpClient
import io.ktor.client.plugins.sse.ClientSSESession
import io.ktor.client.plugins.sse.sseSession
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.Url
import io.ktor.http.append
import io.ktor.http.isSuccess
import io.ktor.http.protocolWithAuthority
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.serialization.SerializationException
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.time.Duration
@Deprecated("Use SseClientTransport instead", ReplaceWith("SseClientTransport"), DeprecationLevel.WARNING)
public typealias SSEClientTransport = SseClientTransport
/**
* Client transport for SSE: this will connect to a server using Server-Sent Events for receiving
* messages and make separate POST requests for sending messages.
*/
@OptIn(ExperimentalAtomicApi::class)
public class SseClientTransport(
private val client: HttpClient,
private val urlString: String?,
private val reconnectionTime: Duration? = null,
private val requestBuilder: HttpRequestBuilder.() -> Unit = {},
) : AbstractTransport() {
private val initialized: AtomicBoolean = AtomicBoolean(false)
private val endpoint = CompletableDeferred<String>()
private lateinit var session: ClientSSESession
private lateinit var scope: CoroutineScope
private var job: Job? = null
private val baseUrl: String by lazy {
session.call.request.url.let { url ->
val path = url.encodedPath
when {
path.isEmpty() -> url.protocolWithAuthority
path.endsWith("/") -> url.protocolWithAuthority + path.removeSuffix("/")
else -> url.protocolWithAuthority + path.take(path.lastIndexOf("/"))
}
}
}
override suspend fun start() {
check(initialized.compareAndSet(expectedValue = false, newValue = true)) {
"SSEClientTransport already started! If using Client class, note that connect() calls start() automatically."
}
try {
session = urlString?.let {
client.sseSession(
urlString = it,
reconnectionTime = reconnectionTime,
block = requestBuilder,
)
} ?: client.sseSession(
reconnectionTime = reconnectionTime,
block = requestBuilder,
)
scope = CoroutineScope(session.coroutineContext + SupervisorJob())
job = scope.launch(CoroutineName("SseMcpClientTransport.connect#${hashCode()}")) {
collectMessages()
}
endpoint.await()
} catch (e: Exception) {
closeResources()
initialized.store(false)
throw e
}
}
@OptIn(ExperimentalCoroutinesApi::class)
override suspend fun send(message: JSONRPCMessage) {
check(initialized.load()) { "SseClientTransport is not initialized!" }
check(job?.isActive == true) { "SseClientTransport is closed!" }
check(endpoint.isCompleted) { "Not connected!" }
try {
val response = client.post(endpoint.getCompleted()) {
requestBuilder()
headers.append(HttpHeaders.ContentType, ContentType.Application.Json)
setBody(McpJson.encodeToString(message))
}
if (!response.status.isSuccess()) {
val text = response.bodyAsText()
error("Error POSTing to endpoint (HTTP ${response.status}): $text")
}
} catch (e: Throwable) {
_onError(e)
throw e
}
}
override suspend fun close() {
check(initialized.load()) { "SseClientTransport is not initialized!" }
closeResources()
}
private suspend fun CoroutineScope.collectMessages() {
try {
session.incoming.collect { event ->
ensureActive()
when (event.event) {
"error" -> {
val error = IllegalStateException("SSE error: ${event.data}")
_onError(error)
throw error
}
"open" -> {
// The connection is open, but we need to wait for the endpoint to be received.
}
"endpoint" -> handleEndpoint(event.data.orEmpty())
else -> handleMessage(event.data.orEmpty())
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
_onError(e)
throw e
} finally {
closeResources()
}
}
private fun handleEndpoint(eventData: String) {
try {
val path = if (eventData.startsWith("/")) eventData.substring(1) else eventData
val endpointUrl = Url("$baseUrl/$path")
endpoint.complete(endpointUrl.toString())
} catch (e: Throwable) {
_onError(e)
endpoint.completeExceptionally(e)
throw e
}
}
private suspend fun handleMessage(data: String) {
try {
val message = McpJson.decodeFromString<JSONRPCMessage>(data)
_onMessage(message)
} catch (e: SerializationException) {
_onError(e)
}
}
private suspend fun closeResources() {
if (!initialized.compareAndSet(expectedValue = true, newValue = false)) return
job?.cancelAndJoin()
try {
if (::session.isInitialized) session.cancel()
if (::scope.isInitialized) scope.cancel()
endpoint.cancel()
} catch (e: Throwable) {
_onError(e)
}
_onClose()
}
}

View file

@ -0,0 +1,137 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.internal.IODispatcher
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer
import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.io.Buffer
import kotlinx.io.Sink
import kotlinx.io.Source
import kotlinx.io.buffered
import kotlinx.io.readByteArray
import kotlinx.io.writeString
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.coroutines.CoroutineContext
/**
* A transport implementation for JSON-RPC communication that leverages standard input and output streams.
*
* This class reads from an input stream to process incoming JSON-RPC messages and writes JSON-RPC messages
* to an output stream.
*
* @param input The input stream where messages are received.
* @param output The output stream where messages are sent.
*/
@OptIn(ExperimentalAtomicApi::class)
public class StdioClientTransport(
private val input: Source,
private val output: Sink
) : AbstractTransport() {
private val logger = KotlinLogging.logger {}
private val ioCoroutineContext: CoroutineContext = IODispatcher
private val scope by lazy {
CoroutineScope(ioCoroutineContext + SupervisorJob())
}
private var job: Job? = null
private val initialized: AtomicBoolean = AtomicBoolean(false)
private val sendChannel = Channel<JSONRPCMessage>(Channel.UNLIMITED)
private val readBuffer = ReadBuffer()
override suspend fun start() {
if (!initialized.compareAndSet(expectedValue = false, newValue = true)) {
error("StdioClientTransport already started!")
}
logger.debug { "Starting StdioClientTransport..." }
val outputStream = output.buffered()
job = scope.launch(CoroutineName("StdioClientTransport.IO#${hashCode()}")) {
val readJob = launch {
logger.debug { "Read coroutine started." }
try {
input.use {
while (isActive) {
val buffer = Buffer()
val bytesRead = input.readAtMostTo(buffer, 8192)
if (bytesRead == -1L) break
if (bytesRead > 0L) {
readBuffer.append(buffer.readByteArray())
processReadBuffer()
}
}
}
} catch (e: Exception) {
_onError.invoke(e)
logger.error(e) { "Error reading from input stream" }
}
}
val writeJob = launch {
logger.debug { "Write coroutine started." }
try {
sendChannel.consumeEach { message ->
val json = serializeMessage(message)
outputStream.writeString(json)
outputStream.flush()
}
} catch (e: Throwable) {
if (isActive) {
_onError.invoke(e)
logger.error(e) { "Error writing to output stream" }
}
} finally {
output.close()
}
}
readJob.join()
writeJob.cancelAndJoin()
_onClose.invoke()
}
}
override suspend fun send(message: JSONRPCMessage) {
if (!initialized.load()) {
error("Transport not started")
}
sendChannel.send(message)
}
override suspend fun close() {
if (!initialized.compareAndSet(expectedValue = true, newValue = false)) {
error("Transport is already closed")
}
job?.cancelAndJoin()
input.close()
output.close()
readBuffer.clear()
sendChannel.close()
_onClose.invoke()
}
private suspend fun processReadBuffer() {
while (true) {
val msg = readBuffer.readMessage() ?: break
try {
_onMessage.invoke(msg)
} catch (e: Throwable) {
_onError.invoke(e)
logger.error(e) { "Error processing message." }
}
}
}
}

View file

@ -0,0 +1,337 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.HttpClient
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.sse.ClientSSESession
import io.ktor.client.plugins.sse.sseSession
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.accept
import io.ktor.client.request.delete
import io.ktor.client.request.headers
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.utils.io.readUTF8Line
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.JSONRPCNotification
import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest
import io.modelcontextprotocol.kotlin.sdk.JSONRPCResponse
import io.modelcontextprotocol.kotlin.sdk.RequestId
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
import io.modelcontextprotocol.kotlin.sdk.shared.McpJson
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.time.Duration
private val logger = KotlinLogging.logger {}
private const val MCP_SESSION_ID_HEADER = "mcp-session-id"
private const val MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"
private const val MCP_RESUMPTION_TOKEN_HEADER = "Last-Event-ID"
/**
* Error class for Streamable HTTP transport errors.
*/
public class StreamableHttpError(
public val code: Int? = null,
message: String? = null
) : Exception("Streamable HTTP error: $message")
/**
* Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification.
* It will connect to a server using HTTP POST for sending messages and HTTP GET with Server-Sent Events
* for receiving messages.
*/
@OptIn(ExperimentalAtomicApi::class)
public class StreamableHttpClientTransport(
private val client: HttpClient,
private val url: String,
private val reconnectionTime: Duration? = null,
private val requestBuilder: HttpRequestBuilder.() -> Unit = {},
) : AbstractTransport() {
public var sessionId: String? = null
private set
public var protocolVersion: String? = null
private val initialized: AtomicBoolean = AtomicBoolean(false)
private var sseSession: ClientSSESession? = null
private var sseJob: Job? = null
private val scope by lazy { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
private var lastEventId: String? = null
override suspend fun start() {
if (!initialized.compareAndSet(expectedValue = false, newValue = true)) {
error("StreamableHttpClientTransport already started!")
}
logger.debug { "Client transport starting..." }
}
/**
* Sends a single message with optional resumption support
*/
override suspend fun send(message: JSONRPCMessage) {
send(message, null)
}
/**
* Sends one or more messages with optional resumption support.
* This is the main send method that matches the TypeScript implementation.
*/
public suspend fun send(
message: JSONRPCMessage,
resumptionToken: String?,
onResumptionToken: ((String) -> Unit)? = null
) {
logger.debug { "Client sending message via POST to $url: ${McpJson.encodeToString(message)}" }
// If we have a resumption token, reconnect the SSE stream with it
resumptionToken?.let { token ->
startSseSession(
resumptionToken = token, onResumptionToken = onResumptionToken,
replayMessageId = if (message is JSONRPCRequest) message.id else null
)
return
}
val jsonBody = McpJson.encodeToString(message)
val response = client.post(url) {
applyCommonHeaders(this)
headers.append(HttpHeaders.Accept, "${ContentType.Application.Json}, ${ContentType.Text.EventStream}")
contentType(ContentType.Application.Json)
setBody(jsonBody)
requestBuilder()
}
response.headers[MCP_SESSION_ID_HEADER]?.let { sessionId = it }
if (response.status == HttpStatusCode.Accepted) {
if (message is JSONRPCNotification && message.method == "notifications/initialized") {
startSseSession(onResumptionToken = onResumptionToken)
}
return
}
if (!response.status.isSuccess()) {
val error = StreamableHttpError(response.status.value, response.bodyAsText())
_onError(error)
throw error
}
when (response.contentType()?.withoutParameters()) {
ContentType.Application.Json -> response.bodyAsText().takeIf { it.isNotEmpty() }?.let { json ->
runCatching { McpJson.decodeFromString<JSONRPCMessage>(json) }
.onSuccess { _onMessage(it) }
.onFailure(_onError)
}
ContentType.Text.EventStream -> handleInlineSse(
response, onResumptionToken = onResumptionToken,
replayMessageId = if (message is JSONRPCRequest) message.id else null
)
else -> {
val body = response.bodyAsText()
if (response.contentType() == null && body.isBlank()) return
val ct = response.contentType()?.toString() ?: "<none>"
val error = StreamableHttpError(-1, "Unexpected content type: $$ct")
_onError(error)
throw error
}
}
}
override suspend fun close() {
if (!initialized.load()) return // Already closed or never started
logger.debug { "Client transport closing." }
try {
// Try to terminate session if we have one
terminateSession()
sseSession?.cancel()
sseJob?.cancelAndJoin()
scope.cancel()
} catch (_: Exception) {
// Ignore errors during cleanup
} finally {
initialized.store(false)
_onClose()
}
}
/**
* Terminates the current session by sending a DELETE request to the server.
*/
public suspend fun terminateSession() {
if (sessionId == null) return
logger.debug { "Terminating session: $sessionId" }
val response = client.delete(url) {
applyCommonHeaders(this)
requestBuilder()
}
// 405 means server doesn't support explicit session termination
if (!response.status.isSuccess() && response.status != HttpStatusCode.MethodNotAllowed) {
val error = StreamableHttpError(
response.status.value,
"Failed to terminate session: ${response.status.description}"
)
logger.error(error) { "Failed to terminate session" }
_onError(error)
throw error
}
sessionId = null
lastEventId = null
logger.debug { "Session terminated successfully" }
}
private suspend fun startSseSession(
resumptionToken: String? = null,
replayMessageId: RequestId? = null,
onResumptionToken: ((String) -> Unit)? = null
) {
sseSession?.cancel()
sseJob?.cancelAndJoin()
logger.debug { "Client attempting to start SSE session at url: $url" }
try {
sseSession = client.sseSession(
urlString = url,
reconnectionTime = reconnectionTime,
) {
method = HttpMethod.Get
applyCommonHeaders(this)
accept(ContentType.Text.EventStream)
(resumptionToken ?: lastEventId)?.let { headers.append(MCP_RESUMPTION_TOKEN_HEADER, it) }
requestBuilder()
}
logger.debug { "Client SSE session started successfully." }
} catch (e: ClientRequestException) {
if (e.response.status == HttpStatusCode.MethodNotAllowed) {
logger.info { "Server returned 405 for GET/SSE, stream disabled." }
return
}
_onError(e)
throw e
}
sseJob = scope.launch(CoroutineName("StreamableHttpTransport.collect#${hashCode()}")) {
sseSession?.let { collectSse(it, replayMessageId, onResumptionToken) }
}
}
private fun applyCommonHeaders(builder: HttpRequestBuilder) {
builder.headers {
sessionId?.let { append(MCP_SESSION_ID_HEADER, it) }
protocolVersion?.let { append(MCP_PROTOCOL_VERSION_HEADER, it) }
}
}
private suspend fun collectSse(
session: ClientSSESession,
replayMessageId: RequestId?,
onResumptionToken: ((String) -> Unit)?
) {
try {
session.incoming.collect { event ->
event.id?.let {
lastEventId = it
onResumptionToken?.invoke(it)
}
logger.trace { "Client received SSE event: event=${event.event}, data=${event.data}, id=${event.id}" }
when (event.event) {
null, "message" ->
event.data?.takeIf { it.isNotEmpty() }?.let { json ->
runCatching { McpJson.decodeFromString<JSONRPCMessage>(json) }
.onSuccess { msg ->
if (replayMessageId != null && msg is JSONRPCResponse) {
_onMessage(msg.copy(id = replayMessageId))
} else {
_onMessage(msg)
}
}
.onFailure(_onError)
}
"error" -> _onError(StreamableHttpError(null, event.data))
}
}
} catch (_: CancellationException) {
// ignore
} catch (t: Throwable) {
_onError(t)
}
}
private suspend fun handleInlineSse(
response: HttpResponse,
replayMessageId: RequestId?,
onResumptionToken: ((String) -> Unit)?
) {
logger.trace { "Handling inline SSE from POST response" }
val channel = response.bodyAsChannel()
val sb = StringBuilder()
var id: String? = null
var eventName: String? = null
suspend fun dispatch(data: String) {
id?.let {
lastEventId = it
onResumptionToken?.invoke(it)
}
if (eventName == null || eventName == "message") {
runCatching { McpJson.decodeFromString<JSONRPCMessage>(data) }
.onSuccess { msg ->
if (replayMessageId != null && msg is JSONRPCResponse) {
_onMessage(msg.copy(id = replayMessageId))
} else {
_onMessage(msg)
}
}
.onFailure(_onError)
}
// reset
id = null
eventName = null
sb.clear()
}
while (!channel.isClosedForRead) {
val line = channel.readUTF8Line() ?: break
if (line.isEmpty()) {
dispatch(sb.toString())
continue
}
when {
line.startsWith("id:") -> id = line.substringAfter("id:").trim()
line.startsWith("event:") -> eventName = line.substringAfter("event:").trim()
line.startsWith("data:") -> sb.append(line.substringAfter("data:").trim())
}
}
}
}

View file

@ -0,0 +1,42 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.ktor.client.HttpClient
import io.ktor.client.request.HttpRequestBuilder
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.LIB_VERSION
import io.modelcontextprotocol.kotlin.sdk.shared.IMPLEMENTATION_NAME
import kotlin.time.Duration
/**
* Returns a new Streamable HTTP transport for the Model Context Protocol using the provided HttpClient.
*
* @param url URL of the MCP server.
* @param reconnectionTime Optional duration to wait before attempting to reconnect.
* @param requestBuilder Optional lambda to configure the HTTP request.
* @return A [StreamableHttpClientTransport] configured for MCP communication.
*/
public fun HttpClient.mcpStreamableHttpTransport(
url: String,
reconnectionTime: Duration? = null,
requestBuilder: HttpRequestBuilder.() -> Unit = {},
): StreamableHttpClientTransport =
StreamableHttpClientTransport(this, url, reconnectionTime, requestBuilder = requestBuilder)
/**
* Creates and connects an MCP client over Streamable HTTP using the provided HttpClient.
*
* @param url URL of the MCP server.
* @param reconnectionTime Optional duration to wait before attempting to reconnect.
* @param requestBuilder Optional lambda to configure the HTTP request.
* @return A connected [Client] ready for MCP communication.
*/
public suspend fun HttpClient.mcpStreamableHttp(
url: String,
reconnectionTime: Duration? = null,
requestBuilder: HttpRequestBuilder.() -> Unit = {},
): Client {
val transport = mcpStreamableHttpTransport(url, reconnectionTime, requestBuilder)
val client = Client(Implementation(name = IMPLEMENTATION_NAME, version = LIB_VERSION))
client.connect(transport)
return client
}

View file

@ -0,0 +1,36 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.websocket.WebSocketSession
import io.modelcontextprotocol.kotlin.sdk.shared.MCP_SUBPROTOCOL
import io.modelcontextprotocol.kotlin.sdk.shared.WebSocketMcpTransport
import kotlin.properties.Delegates
/**
* Client transport for WebSocket: this will connect to a server over the WebSocket protocol.
*/
public class WebSocketClientTransport(
private val client: HttpClient,
private val urlString: String?,
private val requestBuilder: HttpRequestBuilder.() -> Unit = {},
) : WebSocketMcpTransport() {
override var session: WebSocketSession by Delegates.notNull()
override suspend fun initializeSession() {
session = urlString?.let {
client.webSocketSession(it) {
requestBuilder()
header(HttpHeaders.SecWebSocketProtocol, MCP_SUBPROTOCOL)
}
} ?: client.webSocketSession {
requestBuilder()
header(HttpHeaders.SecWebSocketProtocol, MCP_SUBPROTOCOL)
}
}
}

Some files were not shown because too many files have changed in this diff Show more