Compare commits
112 changed files with 675 additions and 7193 deletions
6
.github/workflows/apidocs.yaml
vendored
6
.github/workflows/apidocs.yaml
vendored
|
|
@ -24,17 +24,17 @@ jobs:
|
|||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK ${{ env.JAVA_VERSION }}
|
||||
uses: actions/setup-java@v6
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
distribution: ${{ env.JAVA_DISTRIBUTION }}
|
||||
cache: gradle
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Generate Dokka Site
|
||||
run: |-
|
||||
|
|
|
|||
4
.github/workflows/automations.yml
vendored
4
.github/workflows/automations.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
name: Mark/unmark stale PRs and issues
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v11
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
debug-only: ${{ env.DRY_RUN }}
|
||||
exempt-draft-pr: true
|
||||
|
|
@ -66,7 +66,7 @@ jobs:
|
|||
name: Close stale PRs and issues
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v11
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
debug-only: ${{ env.DRY_RUN }}
|
||||
only-labels: 'stale'
|
||||
|
|
|
|||
20
.github/workflows/build.yml
vendored
20
.github/workflows/build.yml
vendored
|
|
@ -43,17 +43,17 @@ jobs:
|
|||
max-workers: 2
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK ${{ env.JAVA_VERSION }}
|
||||
uses: actions/setup-java@v6
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
distribution: ${{ env.JAVA_DISTRIBUTION }}
|
||||
|
||||
- name: Set up Node.js
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22' # increase only after https://github.com/nodejs/node/issues/56645 will be fixed
|
||||
|
||||
|
|
@ -63,7 +63,7 @@ jobs:
|
|||
run: npm ci
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
validate-wrappers: true
|
||||
add-job-summary: 'always'
|
||||
|
|
@ -126,16 +126,16 @@ jobs:
|
|||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK ${{ env.JAVA_VERSION }}
|
||||
uses: actions/setup-java@v6
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
distribution: ${{ env.JAVA_DISTRIBUTION }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
validate-wrappers: true
|
||||
add-job-summary: 'always'
|
||||
|
|
@ -168,16 +168,16 @@ jobs:
|
|||
name: "Build Sample: ${{ matrix.sample }}"
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK ${{ env.JAVA_VERSION }}
|
||||
uses: actions/setup-java@v6
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
distribution: ${{ env.JAVA_DISTRIBUTION }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
add-job-summary: 'always'
|
||||
cache-read-only: true
|
||||
|
|
|
|||
10
.github/workflows/codeql.yml
vendored
10
.github/workflows/codeql.yml
vendored
|
|
@ -25,22 +25,22 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK ${{ env.JAVA_VERSION }}
|
||||
uses: actions/setup-java@v6
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: ${{ env.JAVA_DISTRIBUTION }}
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4.38.0
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: java-kotlin
|
||||
build-mode: manual
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
validate-wrappers: true
|
||||
cache-read-only: true
|
||||
|
|
@ -52,6 +52,6 @@ jobs:
|
|||
--no-daemon --rerun-tasks --stacktrace
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@v4.38.0
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: '/language:java-kotlin'
|
||||
|
|
|
|||
24
.github/workflows/conformance.yml
vendored
24
.github/workflows/conformance.yml
vendored
|
|
@ -25,16 +25,16 @@ jobs:
|
|||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v6
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
distribution: ${{ env.JAVA_DISTRIBUTION }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
|
|
@ -76,16 +76,16 @@ jobs:
|
|||
- sse-retry
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v6
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
distribution: ${{ env.JAVA_DISTRIBUTION }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
|
|
@ -107,16 +107,16 @@ jobs:
|
|||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v6
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
distribution: ${{ env.JAVA_DISTRIBUTION }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
|
|
@ -146,16 +146,16 @@ jobs:
|
|||
- auth/cross-app-access-complete-flow
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v6
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
distribution: ${{ env.JAVA_DISTRIBUTION }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
|
|
|
|||
8
.github/workflows/gradle-publish.yml
vendored
8
.github/workflows/gradle-publish.yml
vendored
|
|
@ -17,20 +17,20 @@ jobs:
|
|||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v6
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22' # increase only after https://github.com/nodejs/node/issues/56645 will be fixed
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Check
|
||||
run: ./gradlew check --no-configuration-cache
|
||||
|
|
|
|||
6
.github/workflows/samples.yml
vendored
6
.github/workflows/samples.yml
vendored
|
|
@ -36,16 +36,16 @@ jobs:
|
|||
name: Build Sample (${{ matrix.sample }})
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up JDK ${{ env.JAVA_VERSION }}
|
||||
uses: actions/setup-java@v6
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
distribution: ${{ env.JAVA_DISTRIBUTION }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6.3.0
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
validate-wrappers: true
|
||||
add-job-summary: 'always'
|
||||
|
|
|
|||
|
|
@ -729,7 +729,7 @@ val client = Client(
|
|||
)
|
||||
|
||||
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"
|
||||
CreateMessageResult(
|
||||
model = "gpt-4o-mini",
|
||||
|
|
|
|||
|
|
@ -416,6 +416,7 @@ potential-bugs:
|
|||
Deprecation:
|
||||
active: false
|
||||
aliases: ['DEPRECATION']
|
||||
excludeImportStatements: false
|
||||
DontDowncastCollectionTypes:
|
||||
active: false
|
||||
DoubleMutabilityForCollection:
|
||||
|
|
|
|||
|
|
@ -179,13 +179,7 @@ fun Server.registerConformanceTools() {
|
|||
),
|
||||
),
|
||||
)
|
||||
val sampledText = result.content.joinToString("\n") { content ->
|
||||
when (content) {
|
||||
is TextContent -> content.text
|
||||
else -> "Non-text sampling content: ${content::class.simpleName}"
|
||||
}
|
||||
}
|
||||
CallToolResult(listOf(TextContent(sampledText)))
|
||||
CallToolResult(listOf(TextContent(result.content.joinToString("\n") { it.toString() })))
|
||||
}
|
||||
|
||||
// 9. Elicitation
|
||||
|
|
|
|||
|
|
@ -8,4 +8,4 @@ kotlin.daemon.jvmargs=-Xmx4G
|
|||
kotlin.native.ignoreDisabledTargets=true
|
||||
|
||||
group=io.modelcontextprotocol
|
||||
version=0.15.0
|
||||
version=0.13.0
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
[versions]
|
||||
# plugins version
|
||||
atomicfu = "0.33.0"
|
||||
binaryCompatibilityValidatorPlugin = "0.18.2"
|
||||
detekt = "2.0.0-alpha.6"
|
||||
atomicfu = "0.32.1"
|
||||
binaryCompatibilityValidatorPlugin = "0.18.1"
|
||||
detekt = "2.0.0-alpha.3"
|
||||
dokka = "2.2.0"
|
||||
kotlin = "2.4.10"
|
||||
kover = "0.9.9"
|
||||
kotlin = "2.3.21"
|
||||
kover = "0.9.8"
|
||||
ktlint = "14.2.0"
|
||||
knit = "0.5.1"
|
||||
mavenPublish = "0.37.0"
|
||||
netty = "4.2.18.Final"
|
||||
mavenPublish = "0.36.0"
|
||||
netty = "4.2.14.Final"
|
||||
|
||||
# libraries version
|
||||
awaitility = "4.3.0"
|
||||
collections-immutable = "0.5.2"
|
||||
collections-immutable = "0.4.0"
|
||||
coroutines = "1.11.0"
|
||||
kotest = "6.2.5"
|
||||
kotlinx-io = "0.9.1"
|
||||
ktor = "3.5.2"
|
||||
logging = "8.0.4"
|
||||
mockk = "1.14.11"
|
||||
kotest = "6.1.11"
|
||||
kotlinx-io = "0.9.0"
|
||||
ktor = "3.4.3"
|
||||
logging = "8.0.03"
|
||||
mockk = "1.14.9"
|
||||
serialization = "1.11.0"
|
||||
slf4j = "2.0.19"
|
||||
logback = "1.6.3"
|
||||
slf4j = "2.0.18"
|
||||
logback = "1.5.32"
|
||||
junit = "5.14.4"
|
||||
|
||||
[libraries]
|
||||
|
|
|
|||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -1,6 +1,6 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
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
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
|
|
|
|||
4
gradlew
vendored
4
gradlew
vendored
|
|
@ -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:
|
||||
#
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh gradlew
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
|
|
|
|||
4
gradlew.bat
vendored
4
gradlew.bat
vendored
|
|
@ -19,7 +19,7 @@
|
|||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem gradlew startup script for Windows
|
||||
@rem Gradle startup script for Windows
|
||||
@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 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
|
||||
|
|
|
|||
|
|
@ -14,17 +14,13 @@ import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult
|
|||
import io.modelcontextprotocol.kotlin.sdk.types.DoubleSchema
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitRequestFormParams
|
||||
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.ElicitationCompleteNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ElicitationCompleteNotificationParams
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.InitializeResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.IntegerSchema
|
||||
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.JSONRPCResponse
|
||||
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.UntitledMultiSelectEnumSchema
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.UrlElicitationRequiredException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.joinAll
|
||||
|
|
@ -73,7 +67,6 @@ import kotlin.test.assertIs
|
|||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class ClientTest {
|
||||
@Test
|
||||
|
|
@ -546,15 +539,6 @@ class ClientTest {
|
|||
|
||||
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) { _, _ ->
|
||||
// Simulate delay
|
||||
def.complete(Unit)
|
||||
|
|
@ -580,7 +564,6 @@ class ClientTest {
|
|||
def.await()
|
||||
runCatching { job.cancel("Cancelled by test") }
|
||||
defCancel.await()
|
||||
cancelledOnWire.await()
|
||||
defTimeOut.await()
|
||||
}
|
||||
|
||||
|
|
@ -619,9 +602,17 @@ class ClientTest {
|
|||
|
||||
val serverSession = serverSessionResult.await()
|
||||
serverSession.setRequestHandler<ListResourcesRequest>(Method.Defined.ResourcesList) { _, _ ->
|
||||
// Never respond: the handler runs on a real dispatcher while the 1 ms timeout runs on the
|
||||
// virtual test clock, so a real-time delay could race it. Parking keeps the test deterministic.
|
||||
awaitCancellation()
|
||||
// Simulate a delayed response
|
||||
// Wait ~100ms unless canceled
|
||||
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
|
||||
|
|
@ -900,8 +891,8 @@ class ClientTest {
|
|||
),
|
||||
)
|
||||
|
||||
// The handler runs on Dispatchers.Default after concurrent dispatch is enabled; await the deferred.
|
||||
val rootListChangedReceived = CompletableDeferred<Unit>()
|
||||
// Track notifications
|
||||
var rootListChangedNotificationReceived = false
|
||||
|
||||
val serverSessionResult = CompletableDeferred<ServerSession>()
|
||||
|
||||
|
|
@ -920,13 +911,16 @@ class ClientTest {
|
|||
serverSession.setNotificationHandler<RootsListChangedNotification>(
|
||||
Method.Defined.NotificationsRootsListChanged,
|
||||
) {
|
||||
rootListChangedReceived.complete(Unit)
|
||||
rootListChangedNotificationReceived = true
|
||||
CompletableDeferred(Unit)
|
||||
}
|
||||
|
||||
client.sendRootsListChanged()
|
||||
|
||||
withTimeout(5.seconds) { rootListChangedReceived.await() }
|
||||
assertTrue(
|
||||
rootListChangedNotificationReceived,
|
||||
"Notification should be sent when sendRootsListChanged is called",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -1124,103 +1118,6 @@ class ClientTest {
|
|||
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
|
||||
fun `should apply elicitation defaults for missing fields in empty content`() = runTest {
|
||||
val schema = defaultsTestSchema()
|
||||
|
|
@ -1354,189 +1251,6 @@ class ClientTest {
|
|||
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(
|
||||
properties = mapOf(
|
||||
"name" to StringSchema(description = "User name", default = "John Doe"),
|
||||
|
|
|
|||
|
|
@ -7,13 +7,9 @@ import io.modelcontextprotocol.kotlin.sdk.types.McpException
|
|||
import io.modelcontextprotocol.kotlin.test.utils.createSleepyProcessBuilder
|
||||
import io.modelcontextprotocol.kotlin.test.utils.createTeeProcessBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.io.Buffer
|
||||
import kotlinx.io.RawSource
|
||||
import kotlinx.io.asSink
|
||||
import kotlinx.io.asSource
|
||||
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.condition.DisabledOnOs
|
||||
import org.junit.jupiter.api.condition.OS
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.atomics.AtomicBoolean
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
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
|
||||
fun `should read messages`() = runTest {
|
||||
val processBuilder = createTeeProcessBuilder()
|
||||
|
|
@ -190,22 +147,4 @@ class StdioClientTransportTest : BaseTransportTest() {
|
|||
process.waitFor()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -698,8 +698,8 @@ abstract class AbstractPromptIntegrationTest : KotlinTestBase() {
|
|||
|
||||
val expectedMessage = "Prompt not found: non-existent-prompt"
|
||||
|
||||
withClue("Exception code should be INVALID_PARAMS: ${RPCError.ErrorCode.INVALID_PARAMS}") {
|
||||
exception.code shouldBe RPCError.ErrorCode.INVALID_PARAMS
|
||||
withClue("Exception code should be INTERNAL_ERROR: -32603") {
|
||||
exception.code shouldBe -32603
|
||||
}
|
||||
withClue("Unexpected error message for non-existent prompt") {
|
||||
exception.message shouldBe expectedMessage
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue