Compare commits

..
112 changed files with 675 additions and 7193 deletions

View file

@ -24,17 +24,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up JDK ${{ env.JAVA_VERSION }} - name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v6 uses: actions/setup-java@v5
with: with:
java-version: ${{ env.JAVA_VERSION }} java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }} distribution: ${{ env.JAVA_DISTRIBUTION }}
cache: gradle cache: gradle
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.3.0 uses: gradle/actions/setup-gradle@v6
- name: Generate Dokka Site - name: Generate Dokka Site
run: |- run: |-

View file

@ -27,7 +27,7 @@ jobs:
name: Mark/unmark stale PRs and issues name: Mark/unmark stale PRs and issues
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v11 - uses: actions/stale@v10
with: with:
debug-only: ${{ env.DRY_RUN }} debug-only: ${{ env.DRY_RUN }}
exempt-draft-pr: true exempt-draft-pr: true
@ -66,7 +66,7 @@ jobs:
name: Close stale PRs and issues name: Close stale PRs and issues
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v11 - uses: actions/stale@v10
with: with:
debug-only: ${{ env.DRY_RUN }} debug-only: ${{ env.DRY_RUN }}
only-labels: 'stale' only-labels: 'stale'

View file

@ -43,17 +43,17 @@ jobs:
max-workers: 2 max-workers: 2
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up JDK ${{ env.JAVA_VERSION }} - name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v6 uses: actions/setup-java@v5
with: with:
java-version: ${{ env.JAVA_VERSION }} java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }} distribution: ${{ env.JAVA_DISTRIBUTION }}
- name: Set up Node.js - name: Set up Node.js
if: matrix.os == 'ubuntu-latest' if: matrix.os == 'ubuntu-latest'
uses: actions/setup-node@v7 uses: actions/setup-node@v6
with: with:
node-version: '22' # increase only after https://github.com/nodejs/node/issues/56645 will be fixed node-version: '22' # increase only after https://github.com/nodejs/node/issues/56645 will be fixed
@ -63,7 +63,7 @@ jobs:
run: npm ci run: npm ci
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.3.0 uses: gradle/actions/setup-gradle@v6
with: with:
validate-wrappers: true validate-wrappers: true
add-job-summary: 'always' add-job-summary: 'always'
@ -126,16 +126,16 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up JDK ${{ env.JAVA_VERSION }} - name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v6 uses: actions/setup-java@v5
with: with:
java-version: ${{ env.JAVA_VERSION }} java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }} distribution: ${{ env.JAVA_DISTRIBUTION }}
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.3.0 uses: gradle/actions/setup-gradle@v6
with: with:
validate-wrappers: true validate-wrappers: true
add-job-summary: 'always' add-job-summary: 'always'
@ -168,16 +168,16 @@ jobs:
name: "Build Sample: ${{ matrix.sample }}" name: "Build Sample: ${{ matrix.sample }}"
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up JDK ${{ env.JAVA_VERSION }} - name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v6 uses: actions/setup-java@v5
with: with:
java-version: ${{ env.JAVA_VERSION }} java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }} distribution: ${{ env.JAVA_DISTRIBUTION }}
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.3.0 uses: gradle/actions/setup-gradle@v6
with: with:
add-job-summary: 'always' add-job-summary: 'always'
cache-read-only: true cache-read-only: true

View file

@ -25,22 +25,22 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v6
- name: Set up JDK ${{ env.JAVA_VERSION }} - name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v6 uses: actions/setup-java@v5
with: with:
distribution: ${{ env.JAVA_DISTRIBUTION }} distribution: ${{ env.JAVA_DISTRIBUTION }}
java-version: ${{ env.JAVA_VERSION }} java-version: ${{ env.JAVA_VERSION }}
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v4.38.0 uses: github/codeql-action/init@v4
with: with:
languages: java-kotlin languages: java-kotlin
build-mode: manual build-mode: manual
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.3.0 uses: gradle/actions/setup-gradle@v6
with: with:
validate-wrappers: true validate-wrappers: true
cache-read-only: true cache-read-only: true
@ -52,6 +52,6 @@ jobs:
--no-daemon --rerun-tasks --stacktrace --no-daemon --rerun-tasks --stacktrace
- name: Analyze - name: Analyze
uses: github/codeql-action/analyze@v4.38.0 uses: github/codeql-action/analyze@v4
with: with:
category: '/language:java-kotlin' category: '/language:java-kotlin'

View file

@ -25,16 +25,16 @@ jobs:
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v6 uses: actions/setup-java@v5
with: with:
java-version: ${{ env.JAVA_VERSION }} java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }} distribution: ${{ env.JAVA_DISTRIBUTION }}
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.3.0 uses: gradle/actions/setup-gradle@v6
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }} cache-read-only: ${{ github.ref != 'refs/heads/main' }}
@ -76,16 +76,16 @@ jobs:
- sse-retry - sse-retry
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v6 uses: actions/setup-java@v5
with: with:
java-version: ${{ env.JAVA_VERSION }} java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }} distribution: ${{ env.JAVA_DISTRIBUTION }}
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.3.0 uses: gradle/actions/setup-gradle@v6
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }} cache-read-only: ${{ github.ref != 'refs/heads/main' }}
@ -107,16 +107,16 @@ jobs:
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v6 uses: actions/setup-java@v5
with: with:
java-version: ${{ env.JAVA_VERSION }} java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }} distribution: ${{ env.JAVA_DISTRIBUTION }}
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.3.0 uses: gradle/actions/setup-gradle@v6
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }} cache-read-only: ${{ github.ref != 'refs/heads/main' }}
@ -146,16 +146,16 @@ jobs:
- auth/cross-app-access-complete-flow - auth/cross-app-access-complete-flow
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up JDK - name: Set up JDK
uses: actions/setup-java@v6 uses: actions/setup-java@v5
with: with:
java-version: ${{ env.JAVA_VERSION }} java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }} distribution: ${{ env.JAVA_DISTRIBUTION }}
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.3.0 uses: gradle/actions/setup-gradle@v6
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/main' }} cache-read-only: ${{ github.ref != 'refs/heads/main' }}

View file

@ -17,20 +17,20 @@ jobs:
packages: write packages: write
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v6 uses: actions/setup-java@v5
with: with:
java-version: '21' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v7 uses: actions/setup-node@v6
with: with:
node-version: '22' # increase only after https://github.com/nodejs/node/issues/56645 will be fixed node-version: '22' # increase only after https://github.com/nodejs/node/issues/56645 will be fixed
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.3.0 uses: gradle/actions/setup-gradle@v6
- name: Check - name: Check
run: ./gradlew check --no-configuration-cache run: ./gradlew check --no-configuration-cache

View file

@ -36,16 +36,16 @@ jobs:
name: Build Sample (${{ matrix.sample }}) name: Build Sample (${{ matrix.sample }})
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Set up JDK ${{ env.JAVA_VERSION }} - name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v6 uses: actions/setup-java@v5
with: with:
java-version: ${{ env.JAVA_VERSION }} java-version: ${{ env.JAVA_VERSION }}
distribution: ${{ env.JAVA_DISTRIBUTION }} distribution: ${{ env.JAVA_DISTRIBUTION }}
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v6.3.0 uses: gradle/actions/setup-gradle@v6
with: with:
validate-wrappers: true validate-wrappers: true
add-job-summary: 'always' add-job-summary: 'always'

View file

@ -729,7 +729,7 @@ val client = Client(
) )
client.setRequestHandler<CreateMessageRequest>(Method.Defined.SamplingCreateMessage) { request, _ -> client.setRequestHandler<CreateMessageRequest>(Method.Defined.SamplingCreateMessage) { request, _ ->
val content = request.messages.lastOrNull()?.content?.firstOrNull() val content = request.messages.lastOrNull()?.content
val prompt = if (content is TextContent) content.text else "your topic" val prompt = if (content is TextContent) content.text else "your topic"
CreateMessageResult( CreateMessageResult(
model = "gpt-4o-mini", model = "gpt-4o-mini",

View file

@ -416,6 +416,7 @@ potential-bugs:
Deprecation: Deprecation:
active: false active: false
aliases: ['DEPRECATION'] aliases: ['DEPRECATION']
excludeImportStatements: false
DontDowncastCollectionTypes: DontDowncastCollectionTypes:
active: false active: false
DoubleMutabilityForCollection: DoubleMutabilityForCollection:

View file

@ -179,13 +179,7 @@ fun Server.registerConformanceTools() {
), ),
), ),
) )
val sampledText = result.content.joinToString("\n") { content -> CallToolResult(listOf(TextContent(result.content.joinToString("\n") { it.toString() })))
when (content) {
is TextContent -> content.text
else -> "Non-text sampling content: ${content::class.simpleName}"
}
}
CallToolResult(listOf(TextContent(sampledText)))
} }
// 9. Elicitation // 9. Elicitation

View file

@ -8,4 +8,4 @@ kotlin.daemon.jvmargs=-Xmx4G
kotlin.native.ignoreDisabledTargets=true kotlin.native.ignoreDisabledTargets=true
group=io.modelcontextprotocol group=io.modelcontextprotocol
version=0.15.0 version=0.13.0

View file

@ -1,28 +1,28 @@
[versions] [versions]
# plugins version # plugins version
atomicfu = "0.33.0" atomicfu = "0.32.1"
binaryCompatibilityValidatorPlugin = "0.18.2" binaryCompatibilityValidatorPlugin = "0.18.1"
detekt = "2.0.0-alpha.6" detekt = "2.0.0-alpha.3"
dokka = "2.2.0" dokka = "2.2.0"
kotlin = "2.4.10" kotlin = "2.3.21"
kover = "0.9.9" kover = "0.9.8"
ktlint = "14.2.0" ktlint = "14.2.0"
knit = "0.5.1" knit = "0.5.1"
mavenPublish = "0.37.0" mavenPublish = "0.36.0"
netty = "4.2.18.Final" netty = "4.2.14.Final"
# libraries version # libraries version
awaitility = "4.3.0" awaitility = "4.3.0"
collections-immutable = "0.5.2" collections-immutable = "0.4.0"
coroutines = "1.11.0" coroutines = "1.11.0"
kotest = "6.2.5" kotest = "6.1.11"
kotlinx-io = "0.9.1" kotlinx-io = "0.9.0"
ktor = "3.5.2" ktor = "3.4.3"
logging = "8.0.4" logging = "8.0.03"
mockk = "1.14.11" mockk = "1.14.9"
serialization = "1.11.0" serialization = "1.11.0"
slf4j = "2.0.19" slf4j = "2.0.18"
logback = "1.6.3" logback = "1.5.32"
junit = "5.14.4" junit = "5.14.4"
[libraries] [libraries]

Binary file not shown.

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0 retries=0
retryBackOffMs=500 retryBackOffMs=500

4
gradlew vendored
View file

@ -20,7 +20,7 @@
############################################################################## ##############################################################################
# #
# gradlew start up script for POSIX generated by Gradle. # Gradle start up script for POSIX generated by Gradle.
# #
# Important for running: # Important for running:
# #
@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole # bash, then to run this script, type that shell name before the whole
# command line, like: # command line, like:
# #
# ksh gradlew # ksh Gradle
# #
# Busybox and similar reduced shells will NOT work, because this script # Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features: # requires all of these POSIX shell features:

4
gradlew.bat vendored
View file

@ -19,7 +19,7 @@
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem gradlew startup script for Windows @rem Gradle startup script for Windows
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2
@rem Execute gradlew @rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command @rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel

View file

@ -14,17 +14,13 @@ import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult
import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema
import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestFormParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestFormParams
import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestURLParams
import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult import io.modelcontextprotocol.kotlin.sdk.types.ElicitResult
import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotification
import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotificationParams
import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject
import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest
import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult
import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse
import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION
@ -50,10 +46,8 @@ import io.modelcontextprotocol.kotlin.sdk.types.Tool
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema
import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema
import io.modelcontextprotocol.kotlin.sdk.types.UrlElicitationRequiredException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
@ -73,7 +67,6 @@ import kotlin.test.assertIs
import kotlin.test.assertNull import kotlin.test.assertNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlin.test.fail import kotlin.test.fail
import kotlin.time.Duration.Companion.seconds
class ClientTest { class ClientTest {
@Test @Test
@ -546,15 +539,6 @@ class ClientTest {
val serverSession = serverSessionResult.await() val serverSession = serverSessionResult.await()
// Register the tap after createSession so it chains onto the session's subscription instead
// of pre-empting the handshake.
val cancelledOnWire = CompletableDeferred<Unit>()
serverTransport.onMessage { message ->
if (message is JSONRPCNotification && message.method == Method.Defined.NotificationsCancelled.value) {
cancelledOnWire.complete(Unit)
}
}
serverSession.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ -> serverSession.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
// Simulate delay // Simulate delay
def.complete(Unit) def.complete(Unit)
@ -580,7 +564,6 @@ class ClientTest {
def.await() def.await()
runCatching { job.cancel("Cancelled by test") } runCatching { job.cancel("Cancelled by test") }
defCancel.await() defCancel.await()
cancelledOnWire.await()
defTimeOut.await() defTimeOut.await()
} }
@ -619,9 +602,17 @@ class ClientTest {
val serverSession = serverSessionResult.await() val serverSession = serverSessionResult.await()
serverSession.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ -> serverSession.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
// Never respond: the handler runs on a real dispatcher while the 1 ms timeout runs on the // Simulate a delayed response
// virtual test clock, so a real-time delay could race it. Parking keeps the test deterministic. // Wait ~100ms unless canceled
awaitCancellation() try {
withTimeout(100L) {
// Just delay here, if timeout is 0 on the client side, this won't return in time
delay(100)
}
} catch (_: Exception) {
// If aborted, just rethrow or return early
}
ListResourcesResult(resources = emptyList())
} }
// Request with 1 msec timeout should fail immediately // Request with 1 msec timeout should fail immediately
@ -900,8 +891,8 @@ class ClientTest {
), ),
) )
// The handler runs on Dispatchers.Default after concurrent dispatch is enabled; await the deferred. // Track notifications
val rootListChangedReceived = CompletableDeferred<Unit>() var rootListChangedNotificationReceived = false
val serverSessionResult = CompletableDeferred<ServerSession>() val serverSessionResult = CompletableDeferred<ServerSession>()
@ -920,13 +911,16 @@ class ClientTest {
serverSession.setNotificationHandler<RootsListChangedNotification>( serverSession.setNotificationHandler<RootsListChangedNotification>(
Method.Defined.NotificationsRootsListChanged, Method.Defined.NotificationsRootsListChanged,
) { ) {
rootListChangedReceived.complete(Unit) rootListChangedNotificationReceived = true
CompletableDeferred(Unit) CompletableDeferred(Unit)
} }
client.sendRootsListChanged() client.sendRootsListChanged()
withTimeout(5.seconds) { rootListChangedReceived.await() } assertTrue(
rootListChangedNotificationReceived,
"Notification should be sent when sendRootsListChanged is called",
)
} }
@Test @Test
@ -1124,103 +1118,6 @@ class ClientTest {
assertEquals(elicitationResultContent, result.content) assertEquals(elicitationResultContent, result.content)
} }
@Test
fun `should reject accepted elicitation content that does not match requested schema`() = runTest {
val requestedSchema = ElicitRequestParams.RequestedSchema(
properties = mapOf("name" to StringSchema()),
required = listOf("name"),
)
val (client, serverSession) = setupElicitationPair {
ElicitResult(action = ElicitResult.Action.Accept, content = buildJsonObject { put("name", 42) })
}
val exception = assertFailsWith<McpException> {
serverSession.createElicitation(message = "mismatch", requestedSchema = requestedSchema)
}
assertTrue(
exception.message.orEmpty()
.startsWith("Elicitation response content does not match requested schema: "),
"Unexpected message: ${exception.message}",
)
assertTrue("'name' must be string" in exception.message.orEmpty(), "Unexpected message: ${exception.message}")
client.close()
}
@Test
fun `should reject accepted elicitation without content when schema has required properties`() = runTest {
val requestedSchema = ElicitRequestParams.RequestedSchema(
properties = mapOf("name" to StringSchema()),
required = listOf("name"),
)
val (client, serverSession) = setupElicitationPair {
ElicitResult(action = ElicitResult.Action.Accept, content = null)
}
val exception = assertFailsWith<McpException> {
serverSession.createElicitation(message = "no content", requestedSchema = requestedSchema)
}
assertTrue(
"must have required property 'name'" in exception.message.orEmpty(),
"Unexpected message: ${exception.message}",
)
client.close()
}
@Test
fun `should accept elicitation without content when schema has no required properties`() = runTest {
val requestedSchema = ElicitRequestParams.RequestedSchema(
properties = mapOf("name" to StringSchema()),
)
val (client, serverSession) = setupElicitationPair {
ElicitResult(action = ElicitResult.Action.Accept, content = null)
}
val result = serverSession.createElicitation(message = "no required", requestedSchema = requestedSchema)
assertEquals(ElicitResult.Action.Accept, result.action)
assertNull(result.content)
client.close()
}
@Test
fun `should pass through declined elicitation without validation`() = runTest {
val requestedSchema = ElicitRequestParams.RequestedSchema(
properties = mapOf("name" to StringSchema()),
required = listOf("name"),
)
val (client, serverSession) = setupElicitationPair {
ElicitResult(action = ElicitResult.Action.Decline)
}
val result = serverSession.createElicitation(message = "decline", requestedSchema = requestedSchema)
assertEquals(ElicitResult.Action.Decline, result.action)
assertNull(result.content)
client.close()
}
@Test
fun `should pass through cancelled elicitation without validation`() = runTest {
val requestedSchema = ElicitRequestParams.RequestedSchema(
properties = mapOf("name" to StringSchema()),
required = listOf("name"),
)
val (client, serverSession) = setupElicitationPair {
ElicitResult(action = ElicitResult.Action.Cancel)
}
val result = serverSession.createElicitation(message = "cancel", requestedSchema = requestedSchema)
assertEquals(ElicitResult.Action.Cancel, result.action)
assertNull(result.content)
client.close()
}
@Test @Test
fun `should apply elicitation defaults for missing fields in empty content`() = runTest { fun `should apply elicitation defaults for missing fields in empty content`() = runTest {
val schema = defaultsTestSchema() val schema = defaultsTestSchema()
@ -1354,189 +1251,6 @@ class ClientTest {
client.close() client.close()
} }
// ── URL-mode elicitation (SEP-1036) ─────────────────────────────────
@Test
fun `should handle URL mode elicitation end-to-end`() = runTest {
val client = Client(
Implementation(name = "test client", version = "1.0"),
ClientOptions(
capabilities = ClientCapabilities(
elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject),
),
),
)
val elicitationId = "550e8400-e29b-41d4-a716-446655440000"
val url = "https://oauth.example.com/authorize"
client.setElicitationHandler { request ->
val params = assertIs<ElicitRequestURLParams>(request.params)
assertEquals(elicitationId, params.elicitationId)
assertEquals(url, params.url)
ElicitResult(action = ElicitResult.Action.Accept)
}
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val server = Server(
serverInfo = Implementation(name = "test server", version = "1.0"),
options = ServerOptions(capabilities = ServerCapabilities()),
)
val serverSessionResult = CompletableDeferred<ServerSession>()
listOf(
launch { client.connect(clientTransport) },
launch { serverSessionResult.complete(server.createSession(serverTransport)) },
).joinAll()
val serverSession = serverSessionResult.await()
val result = serverSession.createElicitation(
message = "Authorize access to continue",
elicitationId = elicitationId,
url = url,
)
assertEquals(ElicitResult.Action.Accept, result.action)
assertNull(result.content)
client.close()
}
@Test
fun `should reject URL mode elicitation when client supports only form mode`() = runTest {
val (client, serverSession) = setupElicitationPair {
ElicitResult(action = ElicitResult.Action.Accept)
}
val exception = assertFailsWith<IllegalArgumentException> {
serverSession.createElicitation(
message = "Authorize",
elicitationId = "id-1",
url = "https://example.com/auth",
)
}
assertTrue(exception.message!!.contains("elicitation.url"))
client.close()
}
@Test
fun `should deliver elicitation complete notification to client`() = runTest {
val received = CompletableDeferred<ElicitationCompleteNotification>()
val client = Client(
Implementation(name = "test client", version = "1.0"),
ClientOptions(
capabilities = ClientCapabilities(
elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject),
),
),
)
client.setElicitationCompleteHandler { received.complete(it) }
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val server = Server(
serverInfo = Implementation(name = "test server", version = "1.0"),
options = ServerOptions(capabilities = ServerCapabilities()),
)
val serverSessionResult = CompletableDeferred<ServerSession>()
listOf(
launch { client.connect(clientTransport) },
launch { serverSessionResult.complete(server.createSession(serverTransport)) },
).joinAll()
val serverSession = serverSessionResult.await()
val elicitationId = "complete-id-1"
serverSession.sendElicitationComplete(
ElicitationCompleteNotification(ElicitationCompleteNotificationParams(elicitationId = elicitationId)),
)
val notification = received.await()
assertEquals(elicitationId, notification.params.elicitationId)
client.close()
}
@Test
fun `should reject elicitation complete when client supports only form mode`() = runTest {
val (client, serverSession) = setupElicitationPair {
ElicitResult(action = ElicitResult.Action.Accept)
}
val exception = assertFailsWith<IllegalArgumentException> {
serverSession.sendElicitationComplete(
ElicitationCompleteNotification(ElicitationCompleteNotificationParams(elicitationId = "id-1")),
)
}
assertTrue(exception.message!!.contains("elicitation.url"))
client.close()
}
@Test
fun `setElicitationCompleteHandler should require url capability`() = runTest {
val client = Client(
Implementation(name = "test client", version = "1.0"),
ClientOptions(
capabilities = ClientCapabilities(
elicitation = ClientCapabilities.Elicitation(),
),
),
)
assertFailsWith<IllegalStateException> {
client.setElicitationCompleteHandler { }
}
}
@Test
fun `should surface URL elicitation required error to client as typed exception`() = runTest {
val client = Client(
Implementation(name = "test client", version = "1.0"),
ClientOptions(
capabilities = ClientCapabilities(
elicitation = ClientCapabilities.Elicitation(url = EmptyJsonObject),
),
),
)
val elicitationId = "auth-required-1"
val url = "https://oauth.example.com/authorize"
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val server = Server(
serverInfo = Implementation(name = "test server", version = "1.0"),
options = ServerOptions(capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(true))),
)
server.addTool("needs-auth", "Requires URL elicitation") {
throw UrlElicitationRequiredException(
listOf(
ElicitRequestURLParams(
message = "Authorize to continue",
elicitationId = elicitationId,
url = url,
),
),
)
}
val serverSessionResult = CompletableDeferred<ServerSession>()
listOf(
launch { client.connect(clientTransport) },
launch { serverSessionResult.complete(server.createSession(serverTransport)) },
).joinAll()
serverSessionResult.await()
val exception = assertFailsWith<UrlElicitationRequiredException> {
client.callTool(name = "needs-auth", arguments = emptyMap())
}
val elicitation = exception.elicitations.single()
assertEquals(elicitationId, elicitation.elicitationId)
assertEquals(url, elicitation.url)
client.close()
}
private fun defaultsTestSchema(): ElicitRequestParams.RequestedSchema = ElicitRequestParams.RequestedSchema( private fun defaultsTestSchema(): ElicitRequestParams.RequestedSchema = ElicitRequestParams.RequestedSchema(
properties = mapOf( properties = mapOf(
"name" to StringSchema(description = "User name", default = "John Doe"), "name" to StringSchema(description = "User name", default = "John Doe"),

View file

@ -7,13 +7,9 @@ import io.modelcontextprotocol.kotlin.sdk.types.McpException
import io.modelcontextprotocol.kotlin.test.utils.createSleepyProcessBuilder import io.modelcontextprotocol.kotlin.test.utils.createSleepyProcessBuilder
import io.modelcontextprotocol.kotlin.test.utils.createTeeProcessBuilder import io.modelcontextprotocol.kotlin.test.utils.createTeeProcessBuilder
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.io.Buffer
import kotlinx.io.RawSource
import kotlinx.io.asSink import kotlinx.io.asSink
import kotlinx.io.asSource import kotlinx.io.asSource
import kotlinx.io.buffered import kotlinx.io.buffered
@ -22,12 +18,10 @@ import org.junit.jupiter.api.Timeout
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.condition.DisabledOnOs import org.junit.jupiter.api.condition.DisabledOnOs
import org.junit.jupiter.api.condition.OS import org.junit.jupiter.api.condition.OS
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlin.test.fail import kotlin.test.fail
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -115,43 +109,6 @@ class StdioClientTransportTest : BaseTransportTest() {
} }
} }
@OptIn(ExperimentalAtomicApi::class)
@Test
fun `should close cleanly while stdin read is blocked`(): Unit = runBlocking(Dispatchers.IO) {
val input = BlockingRawSource()
val transport = StdioClientTransport(
input = input.buffered(),
output = Buffer(),
)
val didClose = AtomicBoolean(false)
transport.onClose { didClose.store(true) }
transport.onError { error ->
fail("Unexpected error while closing transport: $error")
}
transport.start()
eventually(2.seconds) {
assertTrue(input.readStarted, "Transport should start reading stdin before close")
}
val closeJob = async {
transport.close()
}
val closed = withTimeoutOrNull(1.seconds) {
closeJob.await()
}
input.close()
closeJob.await()
assertNotNull(closed, "Transport.close() should not wait for stdin to produce data or EOF")
assertTrue(didClose.load(), "Transport should be closed after close() call")
}
@Test @Test
fun `should read messages`() = runTest { fun `should read messages`() = runTest {
val processBuilder = createTeeProcessBuilder() val processBuilder = createTeeProcessBuilder()
@ -190,22 +147,4 @@ class StdioClientTransportTest : BaseTransportTest() {
process.waitFor() process.waitFor()
process.destroyForcibly() process.destroyForcibly()
} }
private class BlockingRawSource : RawSource {
private val closed = CountDownLatch(1)
@Volatile
var readStarted: Boolean = false
private set
override fun readAtMostTo(sink: Buffer, byteCount: Long): Long {
readStarted = true
closed.await()
return -1L
}
override fun close() {
closed.countDown()
}
}
} }

View file

@ -1,306 +0,0 @@
package io.modelcontextprotocol.kotlin.sdk.integration
import io.kotest.matchers.shouldBe
import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.ServerSession
import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport
import io.modelcontextprotocol.kotlin.sdk.shared.currentRequestHandlerExtra
import io.modelcontextprotocol.kotlin.sdk.testing.ChannelTransport
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest
import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest
import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse
import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION
import io.modelcontextprotocol.kotlin.sdk.types.ListRootsRequest
import io.modelcontextprotocol.kotlin.sdk.types.ListRootsResult
import io.modelcontextprotocol.kotlin.sdk.types.ListToolsRequest
import io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult
import io.modelcontextprotocol.kotlin.sdk.types.Method
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
import io.modelcontextprotocol.kotlin.sdk.types.Role
import io.modelcontextprotocol.kotlin.sdk.types.SamplingMessage
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import io.modelcontextprotocol.kotlin.sdk.types.toJSON
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicReference
import kotlin.coroutines.cancellation.CancellationException
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.time.Duration.Companion.seconds
/**
* End-to-end tests for concurrent inbound dispatch enabled after the MCP handshake.
*
* Handlers run on a real [kotlinx.coroutines.Dispatchers.Default], so these use [runBlocking] with
* explicit [withTimeout] guards. A virtual test clock would false-fire timeouts against cross-thread
* real dispatchers.
*/
@OptIn(ExperimentalMcpApi::class)
class ProtocolConcurrencyIntegrationTest {
private fun rootsClient(): Client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(
capabilities = ClientCapabilities(roots = ClientCapabilities.Roots(listChanged = null)),
),
)
private fun toolsServer(): Server = Server(
serverInfo = Implementation(name = "test server", version = "1.0"),
options = ServerOptions(
capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = null)),
),
)
// ChannelTransport delivers inbound messages as soon as its read loop starts, which is before
// start() marks it operational. Creating the session first keeps the client's initialize from
// landing on a transport that would reject the session's response.
private suspend fun connect(
client: Client,
clientTransport: ChannelTransport,
server: Server,
serverTransport: ChannelTransport,
): ServerSession {
val serverSession = server.createSession(serverTransport)
client.connect(clientTransport)
return serverSession
}
// The motivating deadlock over real serial read loops: a server tool handler makes a
// server-to-client request (roots/list) while still handling the client's tools/call.
@Test
fun `server tool handler can call roots list mid-handling without deadlock`() = runBlocking {
val client = rootsClient()
val server = toolsServer()
val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair()
val serverSession = connect(client, clientTransport, server, serverTransport)
// Register the tools/call handler directly on the session so it can call back to the client
// mid-handling. Before concurrent dispatch this would deadlock: the server read loop is
// blocked by the tool handler and can never deliver the roots/list response.
serverSession.setRequestHandler<CallToolRequest>(Method.Defined.ToolsCall) { _, _ ->
val roots = serverSession.request<ListRootsResult>(ListRootsRequest())
CallToolResult(content = listOf(TextContent(text = "roots=${roots.roots.size}")))
}
val result = withTimeout(10.seconds) {
client.callTool(CallToolRequest(CallToolRequestParams("needs-roots")))
}
val text = (result as CallToolResult).content.filterIsInstance<TextContent>().single().text
text shouldBe "roots=0"
client.close()
server.close()
}
// Client-side mirror: a client sampling handler makes a client-to-server request (tools/list)
// while still handling the server's sampling/createMessage.
@Test
fun `client sampling handler can call the server mid-handling without deadlock`() = runBlocking {
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
options = ClientOptions(
capabilities = ClientCapabilities(sampling = ClientCapabilities.Sampling()),
),
)
val server = toolsServer()
val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair()
client.setRequestHandler<CreateMessageRequest>(Method.Defined.SamplingCreateMessage) { _, _ ->
val tools = client.request<ListToolsResult>(ListToolsRequest())
CreateMessageResult(
role = Role.Assistant,
content = TextContent(text = "tools=${tools.tools.size}"),
model = "test-model",
)
}
val serverSession = connect(client, clientTransport, server, serverTransport)
val result = withTimeout(10.seconds) {
serverSession.request<CreateMessageResult>(
CreateMessageRequest(
CreateMessageRequestParams(
maxTokens = 100,
messages = listOf(SamplingMessage(role = Role.User, content = TextContent(text = "hi"))),
),
),
)
}
val text = result.content.filterIsInstance<TextContent>().single().text
text shouldBe "tools=0"
client.close()
server.close()
}
// Nested-cancel cascade: cancelling the client's tools/call must reach the server tool handler
// over the wire and cascade into its nested roots/list request, cancelling the parked
// client-side roots handler. No tools/call response is ever sent.
@Test
fun `cancelling a tool call cascades into its nested roots list request`() = runBlocking {
val client = rootsClient()
val server = toolsServer()
val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair()
val capturedToolCallId = AtomicReference<RequestId?>(null)
val toolHandlerCancelled = CompletableDeferred<CancellationException>()
val rootsHandlerEntered = CompletableDeferred<Unit>()
val rootsHandlerCancelled = CompletableDeferred<CancellationException>()
val parkForever = CompletableDeferred<ListRootsResult>()
val unexpectedToolCallResponse = CompletableDeferred<Unit>()
// Tap the server->client direction (client transport inbound) to prove no tools/call
// response is ever delivered once the call is cancelled.
clientTransport.onMessage { message ->
if (message is JSONRPCResponse && message.id == capturedToolCallId.get()) {
unexpectedToolCallResponse.complete(Unit)
}
}
val serverSession = connect(client, clientTransport, server, serverTransport)
// Client roots handler parks until cancelled and records the cancellation.
client.setRequestHandler<ListRootsRequest>(Method.Defined.RootsList) { _, _ ->
rootsHandlerEntered.complete(Unit)
try {
parkForever.await()
} catch (e: CancellationException) {
rootsHandlerCancelled.complete(e)
throw e
}
}
// Tool handler makes a nested server->client request and records its own cancellation.
serverSession.setRequestHandler<CallToolRequest>(Method.Defined.ToolsCall) { _, _ ->
val extra = currentRequestHandlerExtra()!!
capturedToolCallId.set(extra.requestId)
try {
extra.sendRequest<ListRootsResult>(ListRootsRequest())
} catch (e: CancellationException) {
toolHandlerCancelled.complete(e)
throw e
}
CallToolResult(content = listOf(TextContent(text = "unreachable")))
}
val call = launch { client.callTool(CallToolRequest(CallToolRequestParams("nested"))) }
withTimeout(10.seconds) { rootsHandlerEntered.await() }
call.cancel(CancellationException("user aborted"))
withTimeout(10.seconds) { toolHandlerCancelled.await() }
withTimeout(10.seconds) { rootsHandlerCancelled.await() }
// The server suppresses the response for a cancelled request, so none is ever sent.
assertFalse(
unexpectedToolCallResponse.isCompleted,
"no tools/call response should be delivered after the call was cancelled",
)
client.close()
server.close()
}
// Init gate plus context element in a high-level handler: initialize and tools/list are
// pipelined before notifications/initialized and answered serially in order. After the
// handshake a high-level addTool handler can observe currentRequestHandlerExtra().
@Test
fun `pipelined initialize and tools list are processed serially then context element is available`(): Unit =
runBlocking {
val server = toolsServer()
server.addTool(name = "ctx-tool", description = "returns its request id") {
val extra = currentRequestHandlerExtra()
checkNotNull(extra) { "currentRequestHandlerExtra() must be non-null inside a tool handler" }
CallToolResult(content = listOf(TextContent(text = extra.requestId.asText())))
}
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val arrivalOrder = CopyOnWriteArrayList<RequestId>()
val pending = ConcurrentHashMap<RequestId, CompletableDeferred<JSONRPCMessage>>()
clientTransport.onMessage { message ->
val id = when (message) {
is JSONRPCResponse -> message.id
is JSONRPCError -> message.id
else -> null
}
if (id != null) {
arrivalOrder.add(id)
pending[id]?.complete(message)
}
}
// Drive the client side manually over the raw transport.
server.createSession(serverTransport)
val initRequest = InitializeRequest(
InitializeRequestParams(
protocolVersion = LATEST_PROTOCOL_VERSION,
capabilities = ClientCapabilities(),
clientInfo = Implementation(name = "raw-client", version = "1.0"),
),
).toJSON()
val toolsListRequest = ListToolsRequest().toJSON()
val initAnswered = pending.expect(initRequest.id)
val toolsListAnswered = pending.expect(toolsListRequest.id)
// Pipelined before notifications/initialized: both handled in the serial dispatch phase.
clientTransport.send(initRequest)
clientTransport.send(toolsListRequest)
withTimeout(10.seconds) { initAnswered.await() }
withTimeout(10.seconds) { toolsListAnswered.await() }
// Deterministic serial ordering: initialize answered before tools/list.
assertEquals(
listOf(initRequest.id, toolsListRequest.id),
arrivalOrder.toList(),
"pipelined requests must be answered in arrival order",
)
// Complete the handshake, then call the high-level tool.
clientTransport.send(InitializedNotification().toJSON())
val toolCallRequest = CallToolRequest(CallToolRequestParams("ctx-tool")).toJSON()
val toolCallAnswered = pending.expect(toolCallRequest.id)
clientTransport.send(toolCallRequest)
val toolCallResponse = withTimeout(10.seconds) { toolCallAnswered.await() } as JSONRPCResponse
val text = (toolCallResponse.result as CallToolResult).content.filterIsInstance<TextContent>().single().text
// The handler echoed its request id, proving currentRequestHandlerExtra() was available.
text shouldBe toolCallRequest.id.asText()
}
private fun ConcurrentHashMap<RequestId, CompletableDeferred<JSONRPCMessage>>.expect(
id: RequestId,
): CompletableDeferred<JSONRPCMessage> = CompletableDeferred<JSONRPCMessage>().also { put(id, it) }
private fun RequestId.asText(): String = when (this) {
is RequestId.StringId -> value
is RequestId.NumberId -> value.toString()
}
}

View file

@ -698,8 +698,8 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() {
val expectedMessage = "Prompt not found: non-existent-prompt" val expectedMessage = "Prompt not found: non-existent-prompt"
withClue("Exception code should be INVALID_PARAMS: ${RPCError.ErrorCode.INVALID_PARAMS}") { withClue("Exception code should be INTERNAL_ERROR: -32603") {
exception.code shouldBe RPCError.ErrorCode.INVALID_PARAMS exception.code shouldBe -32603
} }
withClue("Unexpected error message for non-existent prompt") { withClue("Unexpected error message for non-existent prompt") {
exception.message shouldBe expectedMessage exception.message shouldBe expectedMessage

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