Skip to content
Merged
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ jobs:
clean \
ktlintCheck \
build \
-x :conformance-test:test \
koverLog koverHtmlReport \
publishToMavenLocal \
-Pversion=0.0.1-SNAPSHOT
Expand Down
50 changes: 50 additions & 0 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Conformance Tests

on:
workflow_dispatch:
pull_request:
branches: [ main ]
push:
branches: [ main ]

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# Cancel only when the run is NOT on `main` branch
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
run-conformance:
runs-on: macos-latest-xlarge
name: Run Conformance Tests
timeout-minutes: 20
env:
JAVA_OPTS: "-Xmx8g -Dfile.encoding=UTF-8 -Djava.awt.headless=true -Dkotlin.daemon.jvm.options=-Xmx6g"
steps:
- uses: actions/checkout@v6

- name: Set up JDK 21
uses: actions/setup-java@v5
with:
java-version: '21'
distribution: 'temurin'

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24.11.1'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
add-job-summary: 'always'
cache-read-only: true

- name: Run Conformance Tests
run: ./gradlew --no-daemon :conformance-test:test

- name: Upload Conformance Results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v5
with:
name: conformance-results
path: conformance-test/results/
2 changes: 1 addition & 1 deletion .github/workflows/gradle-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
uses: gradle/actions/setup-gradle@v5

- name: Clean Build with Gradle
run: ./gradlew clean build
run: ./gradlew clean build -x :conformance-test:test

- name: Publish to Maven Central Portal
id: publish
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,6 @@ dist
### SWE agents ###
.claude/
.junie/

### Conformance test results ###
conformance-test/results/
38 changes: 38 additions & 0 deletions conformance-test/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import org.gradle.api.tasks.testing.logging.TestExceptionFormat

plugins {
kotlin("jvm")
}

dependencies {
testImplementation(project(":kotlin-sdk"))
testImplementation(kotlin("test"))
testImplementation(libs.kotlin.logging)
testImplementation(libs.ktor.client.cio)
testImplementation(libs.ktor.server.cio)
testImplementation(libs.ktor.server.websockets)
testRuntimeOnly(libs.slf4j.simple)
}

tasks.test {
useJUnitPlatform()

testLogging {
events("passed", "skipped", "failed")
showStandardStreams = true
showExceptions = true
showCauses = true
showStackTraces = true
exceptionFormat = TestExceptionFormat.FULL
}

doFirst {
systemProperty("test.classpath", classpath.asPath)

println("\n" + "=".repeat(60))
println("MCP CONFORMANCE TESTS")
println("=".repeat(60))
println("These tests validate compliance with the MCP specification.")
println("=".repeat(60) + "\n")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package io.modelcontextprotocol.kotlin.sdk.conformance

import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.sse.SSE
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport
import io.modelcontextprotocol.kotlin.sdk.shared.Transport
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest
import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject

private val logger = KotlinLogging.logger {}

fun main(args: Array<String>) {
require(args.isNotEmpty()) {
"Server URL must be provided as an argument"
}

val serverUrl = args.last()
logger.info { "Connecting to test server at: $serverUrl" }

val httpClient = HttpClient(CIO) {
install(SSE)
}
val transport: Transport = StreamableHttpClientTransport(httpClient, serverUrl)

val client = Client(
clientInfo = Implementation(
name = "kotlin-conformance-client",
version = "1.0.0",
),
)

var exitCode = 0

runBlocking {
try {
client.connect(transport)
logger.info { "✅ Connected to server successfully" }

try {
val tools = client.listTools()
logger.info { "Available tools: ${tools.tools.map { it.name }}" }

if (tools.tools.isNotEmpty()) {
val toolName = tools.tools.first().name
logger.info { "Calling tool: $toolName" }

val result = client.callTool(
CallToolRequest(
params = CallToolRequestParams(
name = toolName,
arguments = buildJsonObject {
put("input", JsonPrimitive("test"))
},
),
),
)
logger.info { "Tool result: ${result.content}" }
}
} catch (e: Exception) {
logger.debug(e) { "Error during tool operations (may be expected for some scenarios)" }
}

logger.info { "✅ Client operations completed successfully" }
} catch (e: Exception) {
logger.error(e) { "❌ Client failed" }
exitCode = 1
} finally {
try {
transport.close()
} catch (e: Exception) {
logger.warn(e) { "Error closing transport" }
}
httpClient.close()
}
}

kotlin.system.exitProcess(exitCode)
}
Loading