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:
parent
641df74f5d
commit
8698b96dd2
92 changed files with 979 additions and 2003 deletions
9
.github/workflows/codeql.yml
vendored
9
.github/workflows/codeql.yml
vendored
|
|
@ -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
4
.gitignore
vendored
|
|
@ -50,3 +50,7 @@ bin/
|
|||
### Node.js ###
|
||||
node_modules
|
||||
dist
|
||||
|
||||
### SWE agents ###
|
||||
.claude/
|
||||
.junie/
|
||||
|
|
|
|||
282
build.gradle.kts
282
build.gradle.kts
|
|
@ -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
16
buildSrc/build.gradle.kts
Normal 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)
|
||||
}
|
||||
7
buildSrc/settings.gradle.kts
Normal file
7
buildSrc/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
dependencyResolutionManagement {
|
||||
versionCatalogs {
|
||||
create("libs") {
|
||||
from(files("../gradle/libs.versions.toml"))
|
||||
}
|
||||
}
|
||||
}
|
||||
23
buildSrc/src/main/kotlin/mcp.dokka.gradle.kts
Normal file
23
buildSrc/src/main/kotlin/mcp.dokka.gradle.kts
Normal 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}")
|
||||
}
|
||||
}
|
||||
73
buildSrc/src/main/kotlin/mcp.jreleaser.gradle.kts
Normal file
73
buildSrc/src/main/kotlin/mcp.jreleaser.gradle.kts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
50
buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts
Normal file
50
buildSrc/src/main/kotlin/mcp.multiplatform.gradle.kts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
66
buildSrc/src/main/kotlin/mcp.publishing.gradle.kts
Normal file
66
buildSrc/src/main/kotlin/mcp.publishing.gradle.kts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
117
kotlin-sdk-client/api/kotlin-sdk-client.api
Normal file
117
kotlin-sdk-client/api/kotlin-sdk-client.api
Normal 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;
|
||||
}
|
||||
|
||||
43
kotlin-sdk-client/build.gradle.kts
Normal file
43
kotlin-sdk-client/build.gradle.kts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue