refactor(test): add centralized TypeScript server factory for integration tests (#477)
# refactor(test): add centralized TypeScript server factory for integration tests - Create TypeScriptServer class for managing TypeScript MCP servers - Add dedicated typescript/ directory with server infrastructure - Refactor TsTestBase to use TypeScriptServer for stdio transport - Extract TypeScript test tools (greet, multi-greet, prompts, resources) - Use npx tsx for direct execution (better performance than npm run) - Install TypeScript dependencies automatically in test setup The TypeScriptServer factory provides: - startSse(port): Start SSE transport server - startStdio(): Start STDIO transport server - stop(): Graceful server shutdown - installDependencies(): npm install automation <!-- Provide a brief summary of your changes --> ## Motivation and Context To resurrect typescript tests ## How Has This Been Tested? Locally, CI ## Breaking Changes <!-- Will users need to update their code or configurations? --> ## Types of changes - [x] Bug fix (non-breaking change which fixes an issue) - [x] Test fix ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [ ] I have added appropriate error handling - [ ] I have added or updated documentation as needed ## Additional context #475
This commit is contained in:
parent
29d34b8e48
commit
658b370c87
27 changed files with 2848 additions and 1119 deletions
14
.github/workflows/build.yml
vendored
14
.github/workflows/build.yml
vendored
|
|
@ -41,7 +41,7 @@ jobs:
|
||||||
|
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
job-name: "Windows Tests"
|
job-name: "Windows Tests"
|
||||||
gradle-tasks: "jvmTest"
|
gradle-tasks: "jvmTest -x :integration-test:jvmTest"
|
||||||
max-workers: 3
|
max-workers: 3
|
||||||
|
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
|
|
@ -64,6 +64,11 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: 'lts/*'
|
node-version: 'lts/*'
|
||||||
|
|
||||||
|
- name: Set up Node Packages
|
||||||
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
working-directory: integration-test/src/jvmTest/typescript
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v5
|
uses: gradle/actions/setup-gradle@v5
|
||||||
with:
|
with:
|
||||||
|
|
@ -75,7 +80,7 @@ jobs:
|
||||||
caches
|
caches
|
||||||
notifications
|
notifications
|
||||||
sdks
|
sdks
|
||||||
.konan/**
|
../.konan/**
|
||||||
|
|
||||||
- name: ${{ matrix.job-name }}
|
- name: ${{ matrix.job-name }}
|
||||||
run: |-
|
run: |-
|
||||||
|
|
@ -144,6 +149,11 @@ jobs:
|
||||||
with:
|
with:
|
||||||
add-job-summary: 'always'
|
add-job-summary: 'always'
|
||||||
cache-read-only: true
|
cache-read-only: true
|
||||||
|
gradle-home-cache-includes: |
|
||||||
|
caches
|
||||||
|
notifications
|
||||||
|
sdks
|
||||||
|
../.konan/**
|
||||||
|
|
||||||
- name: Download Maven Local Repository
|
- name: Download Maven Local Repository
|
||||||
uses: actions/download-artifact@v7
|
uses: actions/download-artifact@v7
|
||||||
|
|
|
||||||
11
.github/workflows/conformance.yml
vendored
11
.github/workflows/conformance.yml
vendored
|
|
@ -31,19 +31,24 @@ jobs:
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '24.11.1'
|
node-version: 'lts/*'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v5
|
uses: gradle/actions/setup-gradle@v5
|
||||||
with:
|
with:
|
||||||
add-job-summary: 'always'
|
add-job-summary: 'always'
|
||||||
cache-read-only: true
|
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||||
|
gradle-home-cache-includes: |
|
||||||
|
caches
|
||||||
|
notifications
|
||||||
|
sdks
|
||||||
|
../.konan/**
|
||||||
|
|
||||||
- name: Run Conformance Tests
|
- name: Run Conformance Tests
|
||||||
run: ./gradlew --no-daemon :conformance-test:test
|
run: ./gradlew --no-daemon :conformance-test:test
|
||||||
|
|
||||||
- name: Upload Conformance Results
|
- name: Upload Conformance Results
|
||||||
if: ${{ !cancelled() }}
|
if: always()
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: conformance-results
|
name: conformance-results
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ plugins {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
testImplementation(project(":kotlin-sdk"))
|
testImplementation(project(":kotlin-sdk"))
|
||||||
testImplementation(kotlin("test"))
|
|
||||||
testImplementation(project(":test-utils"))
|
testImplementation(project(":test-utils"))
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
testImplementation(libs.kotlin.logging)
|
testImplementation(libs.kotlin.logging)
|
||||||
testImplementation(libs.ktor.client.cio)
|
testImplementation(libs.ktor.client.cio)
|
||||||
testImplementation(libs.ktor.server.cio)
|
testImplementation(libs.ktor.server.cio)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package io.modelcontextprotocol.kotlin.sdk.conformance
|
package io.modelcontextprotocol.kotlin.sdk.conformance
|
||||||
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import io.modelcontextprotocol.kotlin.test.utils.findFreePort
|
||||||
import org.junit.jupiter.api.AfterAll
|
import org.junit.jupiter.api.AfterAll
|
||||||
import org.junit.jupiter.api.BeforeAll
|
import org.junit.jupiter.api.BeforeAll
|
||||||
import org.junit.jupiter.api.DynamicTest
|
import org.junit.jupiter.api.DynamicTest
|
||||||
|
|
@ -10,7 +11,6 @@ import java.io.BufferedReader
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.lang.management.ManagementFactory
|
import java.lang.management.ManagementFactory
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.ServerSocket
|
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.properties.Delegates
|
import kotlin.properties.Delegates
|
||||||
|
|
@ -70,8 +70,6 @@ class ConformanceTest {
|
||||||
private const val GRACEFUL_SHUTDOWN_SECONDS = 5L
|
private const val GRACEFUL_SHUTDOWN_SECONDS = 5L
|
||||||
private const val FORCE_SHUTDOWN_SECONDS = 2L
|
private const val FORCE_SHUTDOWN_SECONDS = 2L
|
||||||
|
|
||||||
private fun findFreePort(): Int = ServerSocket(0).use { it.localPort }
|
|
||||||
|
|
||||||
private fun getRuntimeClasspath(): String = ManagementFactory.getRuntimeMXBean().classPath
|
private fun getRuntimeClasspath(): String = ManagementFactory.getRuntimeMXBean().classPath
|
||||||
|
|
||||||
private fun getTestClasspath(): String = System.getProperty("test.classpath") ?: getRuntimeClasspath()
|
private fun getTestClasspath(): String = System.getProperty("test.classpath") ?: getRuntimeClasspath()
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ abstract class AbstractKotlinClientTsServerTest : TsTestBase() {
|
||||||
protected abstract suspend fun <T> useClient(block: suspend (Client) -> T): T
|
protected abstract suspend fun <T> useClient(block: suspend (Client) -> T): T
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
@Timeout(20, unit = TimeUnit.SECONDS)
|
||||||
fun connectsAndPings() = runBlocking(Dispatchers.IO) {
|
fun connectsAndPings() = runBlocking(Dispatchers.IO) {
|
||||||
useClient { client ->
|
useClient { client ->
|
||||||
assertNotNull(client, "Client should be initialized")
|
assertNotNull(client, "Client should be initialized")
|
||||||
|
|
@ -30,7 +30,7 @@ abstract class AbstractKotlinClientTsServerTest : TsTestBase() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
@Timeout(20, unit = TimeUnit.SECONDS)
|
||||||
fun listsTools() = runBlocking(Dispatchers.IO) {
|
fun listsTools() = runBlocking(Dispatchers.IO) {
|
||||||
useClient { client ->
|
useClient { client ->
|
||||||
val result = client.listTools()
|
val result = client.listTools()
|
||||||
|
|
@ -44,7 +44,7 @@ abstract class AbstractKotlinClientTsServerTest : TsTestBase() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
@Timeout(20, unit = TimeUnit.SECONDS)
|
||||||
fun callGreet() = runBlocking(Dispatchers.IO) {
|
fun callGreet() = runBlocking(Dispatchers.IO) {
|
||||||
useClient { client ->
|
useClient { client ->
|
||||||
val testName = "TestUser"
|
val testName = "TestUser"
|
||||||
|
|
@ -59,7 +59,7 @@ abstract class AbstractKotlinClientTsServerTest : TsTestBase() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
@Timeout(20, unit = TimeUnit.SECONDS)
|
||||||
fun multipleClients() = runBlocking(Dispatchers.IO) {
|
fun multipleClients() = runBlocking(Dispatchers.IO) {
|
||||||
useClient { client1 ->
|
useClient { client1 ->
|
||||||
useClient { client2 ->
|
useClient { client2 ->
|
||||||
|
|
|
||||||
|
|
@ -11,59 +11,71 @@ import io.modelcontextprotocol.kotlin.sdk.server.Server
|
||||||
import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport
|
import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport
|
||||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||||
import io.modelcontextprotocol.kotlin.test.utils.Retry
|
import io.modelcontextprotocol.kotlin.test.utils.Retry
|
||||||
|
import io.modelcontextprotocol.kotlin.test.utils.TypeScriptRunner
|
||||||
|
import io.modelcontextprotocol.kotlin.test.utils.isWindows
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import kotlinx.io.Sink
|
import kotlinx.io.Sink
|
||||||
import kotlinx.io.Source
|
import kotlinx.io.Source
|
||||||
import kotlinx.io.asSink
|
import kotlinx.io.asSink
|
||||||
import kotlinx.io.asSource
|
import kotlinx.io.asSource
|
||||||
import kotlinx.io.buffered
|
import kotlinx.io.buffered
|
||||||
import org.awaitility.kotlin.await
|
|
||||||
import org.junit.jupiter.api.BeforeAll
|
import org.junit.jupiter.api.BeforeAll
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.net.ServerSocket
|
|
||||||
import java.net.Socket
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.io.path.createTempDirectory
|
import kotlin.io.path.createTempDirectory
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
enum class TransportKind { SSE, STDIO, DEFAULT }
|
enum class TransportKind { SSE, STDIO }
|
||||||
|
|
||||||
@Retry(times = 3)
|
@Retry(times = 3)
|
||||||
abstract class TsTestBase {
|
abstract class TsTestBase {
|
||||||
|
|
||||||
protected open val transportKind: TransportKind = TransportKind.DEFAULT
|
protected abstract val transportKind: TransportKind
|
||||||
|
|
||||||
protected val projectRoot: File get() = File(System.getProperty("user.dir"))
|
companion object {
|
||||||
protected val tsClientDir: File
|
@JvmStatic
|
||||||
get() {
|
protected val projectRoot: File get() = File(System.getProperty("user.dir"))
|
||||||
val base = File(
|
|
||||||
|
@JvmStatic
|
||||||
|
protected val tsClientDir: File
|
||||||
|
get() = File(projectRoot, "src/jvmTest/typescript")
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
protected val tsSourceDir: File
|
||||||
|
get() = File(
|
||||||
projectRoot,
|
projectRoot,
|
||||||
"src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript",
|
"src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Allow override via system property for CI: -Dts.transport=stdio|sse
|
@JvmStatic
|
||||||
val fromProp = System.getProperty("ts.transport")?.lowercase()
|
private var sharedSseServer: Process? = null
|
||||||
val overrideSubDir = when (fromProp) {
|
|
||||||
"stdio" -> "stdio"
|
|
||||||
"sse" -> "sse"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
val subDirName = overrideSubDir ?: when (transportKind) {
|
@JvmStatic
|
||||||
TransportKind.STDIO -> "stdio"
|
private var sharedSsePort: Int = 0
|
||||||
TransportKind.SSE -> "sse"
|
|
||||||
TransportKind.DEFAULT -> null
|
@JvmStatic
|
||||||
|
@Synchronized
|
||||||
|
protected fun getSharedSseUrl(): String {
|
||||||
|
if (sharedSseServer == null || !sharedSseServer!!.isAlive) {
|
||||||
|
sharedSsePort = io.modelcontextprotocol.kotlin.test.utils.findFreePort()
|
||||||
|
val server = TypeScriptServer(tsClientDir)
|
||||||
|
sharedSseServer = server.startSse(sharedSsePort)
|
||||||
|
println("Shared TypeScript SSE server started on port $sharedSsePort")
|
||||||
|
|
||||||
|
Runtime.getRuntime().addShutdownHook(
|
||||||
|
Thread {
|
||||||
|
sharedSseServer?.let {
|
||||||
|
println("Stopping shared TypeScript SSE server")
|
||||||
|
io.modelcontextprotocol.kotlin.test.utils.stopProcess(it)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (subDirName != null) {
|
return "http://localhost:$sharedSsePort/mcp"
|
||||||
val sub = File(base, subDirName)
|
|
||||||
if (sub.exists()) return sub
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
private val tempRootDir: File = createTempDirectory("typescript-sdk-").toFile().apply { deleteOnExit() }
|
private val tempRootDir: File = createTempDirectory("typescript-sdk-").toFile().apply { deleteOnExit() }
|
||||||
|
|
||||||
|
|
@ -72,6 +84,7 @@ abstract class TsTestBase {
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
@BeforeAll
|
@BeforeAll
|
||||||
|
@Deprecated("Use setupTypeScriptSdkWithDependencies instead")
|
||||||
fun setupTypeScriptSdk() {
|
fun setupTypeScriptSdk() {
|
||||||
println("Cloning TypeScript SDK repository")
|
println("Cloning TypeScript SDK repository")
|
||||||
|
|
||||||
|
|
@ -87,32 +100,43 @@ abstract class TsTestBase {
|
||||||
.redirectErrorStream(true)
|
.redirectErrorStream(true)
|
||||||
.start()
|
.start()
|
||||||
val exitCode = process.waitFor()
|
val exitCode = process.waitFor()
|
||||||
if (exitCode != 0) {
|
require(exitCode == 0) {
|
||||||
throw RuntimeException("Failed to clone TypeScript SDK repository: exit code $exitCode")
|
"Failed to clone TypeScript SDK repository: exit code $exitCode"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println("Installing TypeScript SDK dependencies")
|
println("Installing TypeScript SDK dependencies")
|
||||||
executeCommand("npm install", sdkDir, allowFailure = false, timeoutSeconds = null)
|
val npmInstallResult = executeCommand("npm install", sdkDir, allowFailure = true, timeoutSeconds = null)
|
||||||
|
if (npmInstallResult.contains("EUNSUPPORTEDPROTOCOL") || npmInstallResult.contains("catalog:")) {
|
||||||
|
println("npm install failed due to catalog protocol. Trying pnpm...")
|
||||||
|
try {
|
||||||
|
// Try to use npx pnpm to ensure it's available
|
||||||
|
executeCommand("npx pnpm install", sdkDir, allowFailure = false, timeoutSeconds = null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("pnpm install failed or not found: ${e.message}")
|
||||||
|
println("Attempting to patch package.json to remove catalog: protocol and use npm")
|
||||||
|
patchPackageJson(sdkDir)
|
||||||
|
// Also patch other package.json files in the workspace if they exist
|
||||||
|
sdkDir.walk().filter { it.name == "package.json" }.forEach { patchPackageJson(it.parentFile) }
|
||||||
|
executeCommand("npm install", sdkDir, allowFailure = false, timeoutSeconds = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@Deprecated("never patch package.json")
|
||||||
protected fun killProcessOnPort(port: Int) {
|
private fun patchPackageJson(dir: File) {
|
||||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
val packageJson = File(dir, "package.json")
|
||||||
val killCommand = if (isWindows) {
|
if (!packageJson.exists()) return
|
||||||
"netstat -ano | findstr :$port | for /f \"tokens=5\" %a in ('more')" +
|
var content = packageJson.readText()
|
||||||
" do taskkill /F /PID %a 2>nul || echo No process found"
|
// Replace "catalog:something" with "*" or a reasonable default if we knew it.
|
||||||
} else {
|
// Since we don't know the exact versions in the catalog, "*" is a risky but possible fallback for tests.
|
||||||
"lsof -ti:$port | xargs kill -9 2>/dev/null || true"
|
// However, a better approach is to use pnpm if available.
|
||||||
}
|
content = content.replace(Regex(""""catalog:[^"]*""""), "\"*\"")
|
||||||
executeCommand(killCommand, File("."), allowFailure = true, timeoutSeconds = null)
|
// Also replace workspace: protocol if we are using npm fallback
|
||||||
}
|
content = content.replace(Regex(""""workspace:[^"]*""""), "\"*\"")
|
||||||
|
// And any other unsupported protocols that might be in the package.json
|
||||||
@JvmStatic
|
content = content.replace(Regex(""""link:[^"]*""""), "\"*\"")
|
||||||
protected fun findFreePort(): Int {
|
packageJson.writeText(content)
|
||||||
ServerSocket(0).use { socket ->
|
|
||||||
return socket.localPort
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
|
|
@ -132,7 +156,6 @@ abstract class TsTestBase {
|
||||||
throw RuntimeException("Working directory is not accessible: ${workingDir.absolutePath}")
|
throw RuntimeException("Working directory is not accessible: ${workingDir.absolutePath}")
|
||||||
}
|
}
|
||||||
|
|
||||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
|
||||||
val processBuilder = if (isWindows) {
|
val processBuilder = if (isWindows) {
|
||||||
ProcessBuilder()
|
ProcessBuilder()
|
||||||
.command("cmd.exe", "/c", "set TYPESCRIPT_SDK_DIR=${sdkDir.absolutePath} && $command")
|
.command("cmd.exe", "/c", "set TYPESCRIPT_SDK_DIR=${sdkDir.absolutePath} && $command")
|
||||||
|
|
@ -157,11 +180,9 @@ abstract class TsTestBase {
|
||||||
|
|
||||||
if (timeoutSeconds == null) {
|
if (timeoutSeconds == null) {
|
||||||
val exitCode = process.waitFor()
|
val exitCode = process.waitFor()
|
||||||
if (!allowFailure && exitCode != 0) {
|
require(allowFailure || exitCode == 0) {
|
||||||
throw RuntimeException(
|
"Command execution failed with exit code $exitCode: $command\n" +
|
||||||
"Command execution failed with exit code $exitCode: $command\n" +
|
"Working dir: ${workingDir.absolutePath}\nOutput:\n$output"
|
||||||
"Working dir: ${workingDir.absolutePath}\nOutput:\n$output",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
process.waitFor(timeoutSeconds, TimeUnit.SECONDS)
|
||||||
|
|
@ -172,114 +193,24 @@ abstract class TsTestBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun waitForProcessTermination(process: Process, timeoutSeconds: Long): Boolean {
|
private fun waitForProcessTermination(process: Process, timeoutSeconds: Long): Boolean {
|
||||||
if (process.isAlive && !process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) {
|
io.modelcontextprotocol.kotlin.test.utils.stopProcess(process)
|
||||||
process.destroyForcibly()
|
|
||||||
process.waitFor(2, TimeUnit.SECONDS)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createProcessOutputReader(process: Process, prefix: String = "TS-SERVER"): Thread {
|
protected fun waitForPort(host: String = "localhost", port: Int, timeoutSeconds: Long = 10): Boolean =
|
||||||
val outputReader = Thread {
|
io.modelcontextprotocol.kotlin.test.utils.waitForPort(host, port, timeoutSeconds)
|
||||||
try {
|
|
||||||
process.inputStream.bufferedReader().useLines { lines ->
|
|
||||||
for (line in lines) {
|
|
||||||
println("[$prefix] $line")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Warning: Error reading process output: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
outputReader.isDaemon = true
|
|
||||||
return outputReader
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createProcessErrorReader(process: Process, prefix: String = "TS-SERVER"): Thread {
|
|
||||||
val errorReader = Thread {
|
|
||||||
try {
|
|
||||||
process.errorStream.bufferedReader().useLines { lines ->
|
|
||||||
for (line in lines) {
|
|
||||||
println("[$prefix][err] $line")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Warning: Error reading process error stream: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
errorReader.isDaemon = true
|
|
||||||
return errorReader
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun waitForPort(host: String = "localhost", port: Int, timeoutSeconds: Long = 10): Boolean = try {
|
|
||||||
await.atMost(timeoutSeconds, TimeUnit.SECONDS)
|
|
||||||
.pollDelay(200, TimeUnit.MILLISECONDS)
|
|
||||||
.pollInterval(100, TimeUnit.MILLISECONDS)
|
|
||||||
.until {
|
|
||||||
try {
|
|
||||||
Socket(host, port).use { true }
|
|
||||||
} catch (_: Exception) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true
|
|
||||||
} catch (_: Exception) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fun executeCommandAllowingFailure(command: String, workingDir: File, timeoutSeconds: Long = 20): String =
|
protected fun executeCommandAllowingFailure(command: String, workingDir: File, timeoutSeconds: Long = 20): String =
|
||||||
executeCommand(command, workingDir, allowFailure = true, timeoutSeconds = timeoutSeconds)
|
executeCommand(command, workingDir, allowFailure = true, timeoutSeconds = timeoutSeconds)
|
||||||
|
|
||||||
protected fun startTypeScriptServer(port: Int): Process {
|
protected fun startTypeScriptServer(port: Int): Process {
|
||||||
killProcessOnPort(port)
|
val server = TypeScriptServer(tsClientDir)
|
||||||
|
return server.startSse(port)
|
||||||
if (!sdkDir.exists() || !sdkDir.isDirectory) {
|
|
||||||
throw IllegalStateException(
|
|
||||||
"TypeScript SDK directory does not exist or is not accessible: ${sdkDir.absolutePath}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
|
||||||
val localServerPath = File(tsClientDir, "simpleStreamableHttp.ts").absolutePath
|
|
||||||
val processBuilder = if (isWindows) {
|
|
||||||
ProcessBuilder()
|
|
||||||
.command(
|
|
||||||
"cmd.exe",
|
|
||||||
"/c",
|
|
||||||
"set MCP_PORT=$port && set NODE_PATH=${sdkDir.absolutePath}\\node_modules && npx --prefix \"${sdkDir.absolutePath}\" tsx \"$localServerPath\"",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ProcessBuilder()
|
|
||||||
.command(
|
|
||||||
"bash",
|
|
||||||
"-c",
|
|
||||||
"MCP_PORT=$port NODE_PATH='${sdkDir.absolutePath}/node_modules' npx --prefix '${sdkDir.absolutePath}' tsx \"$localServerPath\"",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
processBuilder.environment()["TYPESCRIPT_SDK_DIR"] = sdkDir.absolutePath
|
|
||||||
|
|
||||||
val process = processBuilder
|
|
||||||
.directory(tsClientDir)
|
|
||||||
.redirectErrorStream(true)
|
|
||||||
.start()
|
|
||||||
|
|
||||||
createProcessOutputReader(process).start()
|
|
||||||
|
|
||||||
if (!waitForPort(port = port, timeoutSeconds = 20)) {
|
|
||||||
throw IllegalStateException("TypeScript server did not become ready on localhost:$port within timeout")
|
|
||||||
}
|
|
||||||
return process
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun stopProcess(process: Process, waitSeconds: Long = 3, name: String = "TypeScript server") {
|
protected fun stopProcess(process: Process, waitSeconds: Long = 3, name: String = "TypeScript server") {
|
||||||
process.destroy()
|
io.modelcontextprotocol.kotlin.test.utils.stopProcess(process)
|
||||||
if (waitForProcessTermination(process, waitSeconds)) {
|
println("$name stopped")
|
||||||
println("$name stopped gracefully")
|
|
||||||
} else {
|
|
||||||
println("$name did not stop gracefully, forced termination")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== SSE client helpers =====
|
// ===== SSE client helpers =====
|
||||||
|
|
@ -301,41 +232,8 @@ abstract class TsTestBase {
|
||||||
|
|
||||||
// ===== STDIO client + server helpers =====
|
// ===== STDIO client + server helpers =====
|
||||||
protected fun startTypeScriptServerStdio(): Process {
|
protected fun startTypeScriptServerStdio(): Process {
|
||||||
if (!sdkDir.exists() || !sdkDir.isDirectory) {
|
val server = TypeScriptServer(tsClientDir)
|
||||||
throw IllegalStateException(
|
return server.startStdio()
|
||||||
"TypeScript SDK directory does not exist or is not accessible: ${sdkDir.absolutePath}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
|
||||||
val localServerPath = File(tsClientDir, "simpleStdio.ts").absolutePath
|
|
||||||
val processBuilder = if (isWindows) {
|
|
||||||
ProcessBuilder()
|
|
||||||
.command(
|
|
||||||
"cmd.exe",
|
|
||||||
"/c",
|
|
||||||
"set NODE_PATH=${sdkDir.absolutePath}\\node_modules && npx --prefix \"${sdkDir.absolutePath}\" tsx \"$localServerPath\"",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ProcessBuilder()
|
|
||||||
.command(
|
|
||||||
"bash",
|
|
||||||
"-c",
|
|
||||||
"NODE_PATH='${sdkDir.absolutePath}/node_modules' npx --prefix '${sdkDir.absolutePath}' tsx \"$localServerPath\"",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
processBuilder.environment()["TYPESCRIPT_SDK_DIR"] = sdkDir.absolutePath
|
|
||||||
val process = processBuilder
|
|
||||||
.directory(tsClientDir)
|
|
||||||
.redirectErrorStream(false)
|
|
||||||
.start()
|
|
||||||
// For stdio transports, do NOT read from stdout (it's used for protocol). Read stderr for logs only.
|
|
||||||
createProcessErrorReader(process, prefix = "TS-SERVER-STDIO").start()
|
|
||||||
// Give the process a moment to start
|
|
||||||
await.atMost(2, TimeUnit.SECONDS)
|
|
||||||
.pollDelay(200, TimeUnit.MILLISECONDS)
|
|
||||||
.pollInterval(100, TimeUnit.MILLISECONDS)
|
|
||||||
.until { process.isAlive }
|
|
||||||
return process
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected suspend fun newClientStdio(process: Process): Client {
|
protected suspend fun newClientStdio(process: Process): Client {
|
||||||
|
|
@ -367,40 +265,20 @@ abstract class TsTestBase {
|
||||||
// ===== Helpers to run TypeScript client over STDIO against Kotlin server over STDIO =====
|
// ===== Helpers to run TypeScript client over STDIO against Kotlin server over STDIO =====
|
||||||
protected fun runStdioClient(vararg args: String): String {
|
protected fun runStdioClient(vararg args: String): String {
|
||||||
// Start Node stdio client (it will speak MCP over its stdout/stdin)
|
// Start Node stdio client (it will speak MCP over its stdout/stdin)
|
||||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
val subDirName = when (transportKind) {
|
||||||
val clientPath = File(tsClientDir, "myClient.ts").absolutePath
|
TransportKind.STDIO -> "stdio"
|
||||||
|
TransportKind.SSE -> "sse"
|
||||||
val process = if (isWindows) {
|
|
||||||
ProcessBuilder()
|
|
||||||
.command(
|
|
||||||
"cmd.exe",
|
|
||||||
"/c",
|
|
||||||
(
|
|
||||||
"set TYPESCRIPT_SDK_DIR=${sdkDir.absolutePath} && " +
|
|
||||||
"set NODE_PATH=${sdkDir.absolutePath}\\node_modules && " +
|
|
||||||
"npx --prefix \"${sdkDir.absolutePath}\" tsx \"$clientPath\" " +
|
|
||||||
args.joinToString(" ")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.directory(tsClientDir)
|
|
||||||
.redirectErrorStream(false)
|
|
||||||
.start()
|
|
||||||
} else {
|
|
||||||
ProcessBuilder()
|
|
||||||
.command(
|
|
||||||
"bash",
|
|
||||||
"-c",
|
|
||||||
(
|
|
||||||
"TYPESCRIPT_SDK_DIR='${sdkDir.absolutePath}' " +
|
|
||||||
"NODE_PATH='${sdkDir.absolutePath}/node_modules' " +
|
|
||||||
"npx --prefix '${sdkDir.absolutePath}' tsx \"$clientPath\" " +
|
|
||||||
args.joinToString(" ")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.directory(tsClientDir)
|
|
||||||
.redirectErrorStream(false)
|
|
||||||
.start()
|
|
||||||
}
|
}
|
||||||
|
val clientPath = File(tsSourceDir, "$subDirName/myClient.ts").absolutePath
|
||||||
|
|
||||||
|
val process = TypeScriptRunner.run(
|
||||||
|
typescriptDir = tsClientDir,
|
||||||
|
scriptPath = clientPath,
|
||||||
|
arguments = args.toList(),
|
||||||
|
env = mapOf("TYPESCRIPT_SDK_DIR" to sdkDir.absolutePath),
|
||||||
|
redirectErrorStream = false,
|
||||||
|
logPrefix = "TS-CLIENT-STDIO",
|
||||||
|
)
|
||||||
|
|
||||||
// Create Kotlin server and attach stdio transport to the process streams
|
// Create Kotlin server and attach stdio transport to the process streams
|
||||||
val server: Server = KotlinServerForTsClient().createMcpServer()
|
val server: Server = KotlinServerForTsClient().createMcpServer()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
|
||||||
|
|
||||||
|
import io.modelcontextprotocol.kotlin.test.utils.TypeScriptRunner
|
||||||
|
import io.modelcontextprotocol.kotlin.test.utils.killProcessOnPort
|
||||||
|
import io.modelcontextprotocol.kotlin.test.utils.stopProcess
|
||||||
|
import io.modelcontextprotocol.kotlin.test.utils.waitForPort
|
||||||
|
import org.awaitility.kotlin.await
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory class for starting and managing TypeScript MCP servers for integration tests.
|
||||||
|
*
|
||||||
|
* This class provides methods to:
|
||||||
|
* - Start TypeScript servers using SSE or STDIO transports via npx tsx
|
||||||
|
* - Install npm dependencies in the TypeScript test directory
|
||||||
|
* - Manage server lifecycle (start/stop)
|
||||||
|
* - Handle port management and process cleanup
|
||||||
|
*
|
||||||
|
* The servers are started using npx tsx to execute TypeScript files directly:
|
||||||
|
* - `npx tsx server/stdio-server.ts` for STDIO transport
|
||||||
|
* - `npx tsx server/sse-server.ts` for SSE transport
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```kotlin
|
||||||
|
* val typescriptDir = File("kotlin-sdk-test/src/jvmTest/typescript")
|
||||||
|
* val server = TypeScriptServer(typescriptDir)
|
||||||
|
*
|
||||||
|
* // Start SSE server
|
||||||
|
* val port = TypeScriptServer.findFreePort()
|
||||||
|
* val process = server.startSse(port)
|
||||||
|
*
|
||||||
|
* // Stop server
|
||||||
|
* server.stop()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class TypeScriptServer(private val typescriptDir: File) {
|
||||||
|
private var process: Process? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a TypeScript MCP server using SSE transport.
|
||||||
|
*
|
||||||
|
* @param port The port number to bind the SSE server to
|
||||||
|
* @return The started Process
|
||||||
|
* @throws IllegalStateException if the server fails to start within timeout
|
||||||
|
*/
|
||||||
|
fun startSse(port: Int): Process {
|
||||||
|
killProcessOnPort(port)
|
||||||
|
val serverPath = File(typescriptDir, "server/sse-server.ts").absolutePath
|
||||||
|
val proc = TypeScriptRunner.run(
|
||||||
|
typescriptDir = typescriptDir,
|
||||||
|
scriptPath = serverPath,
|
||||||
|
env = mapOf("MCP_PORT" to port.toString()),
|
||||||
|
redirectErrorStream = true,
|
||||||
|
logPrefix = "TS-SERVER-SSE",
|
||||||
|
)
|
||||||
|
process = proc
|
||||||
|
|
||||||
|
check(waitForPort(port = port, timeoutSeconds = 10)) {
|
||||||
|
proc.destroyForcibly()
|
||||||
|
"TypeScript SSE server did not become ready on localhost:$port within timeout"
|
||||||
|
}
|
||||||
|
return proc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a TypeScript MCP server using STDIO transport.
|
||||||
|
*
|
||||||
|
* @return The started Process
|
||||||
|
*/
|
||||||
|
fun startStdio(): Process {
|
||||||
|
val serverPath = File(typescriptDir, "server/stdio-server.ts").absolutePath
|
||||||
|
val proc = TypeScriptRunner.run(
|
||||||
|
typescriptDir = typescriptDir,
|
||||||
|
scriptPath = serverPath,
|
||||||
|
redirectErrorStream = false,
|
||||||
|
logPrefix = "TS-SERVER-STDIO",
|
||||||
|
)
|
||||||
|
process = proc
|
||||||
|
|
||||||
|
await.atMost(5, TimeUnit.SECONDS)
|
||||||
|
.pollDelay(200, TimeUnit.MILLISECONDS)
|
||||||
|
.pollInterval(100, TimeUnit.MILLISECONDS)
|
||||||
|
.until { proc.isAlive }
|
||||||
|
|
||||||
|
return proc
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the currently running server process.
|
||||||
|
*
|
||||||
|
* Attempts graceful shutdown first, then forces termination if necessary.
|
||||||
|
*/
|
||||||
|
fun stop() {
|
||||||
|
process?.let {
|
||||||
|
stopProcess(it)
|
||||||
|
}
|
||||||
|
process = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Installs npm dependencies in the specified TypeScript directory.
|
||||||
|
*
|
||||||
|
* @param typescriptDir The directory containing package.json
|
||||||
|
* @throws RuntimeException if npm install fails
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun installDependencies(typescriptDir: File) {
|
||||||
|
TypeScriptRunner.installDependencies(typescriptDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds an available port by opening and closing a ServerSocket.
|
||||||
|
*
|
||||||
|
* @return An available port number
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun findFreePort(): Int = io.modelcontextprotocol.kotlin.test.utils.findFreePort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,6 @@ import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase
|
||||||
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||||
import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
|
|
@ -14,7 +13,6 @@ import kotlinx.coroutines.withTimeout
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.Timeout
|
import org.junit.jupiter.api.Timeout
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
@ -23,25 +21,14 @@ import kotlin.test.assertNotNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@DisabledOnCI
|
|
||||||
class KotlinClientTsServerEdgeCasesTestSse : TsTestBase() {
|
class KotlinClientTsServerEdgeCasesTestSse : TsTestBase() {
|
||||||
|
|
||||||
override val transportKind = TransportKind.SSE
|
override val transportKind = TransportKind.SSE
|
||||||
|
|
||||||
private var port: Int = 0
|
|
||||||
private val host = "localhost"
|
private val host = "localhost"
|
||||||
private lateinit var serverUrl: String
|
private val serverUrl: String by lazy { getSharedSseUrl() }
|
||||||
|
|
||||||
private lateinit var client: Client
|
private lateinit var client: Client
|
||||||
private lateinit var tsServerProcess: Process
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setUp() {
|
|
||||||
port = findFreePort()
|
|
||||||
serverUrl = "http://$host:$port/mcp"
|
|
||||||
tsServerProcess = startTypeScriptServer(port)
|
|
||||||
println("TypeScript server started on port $port")
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
|
@ -56,15 +43,6 @@ class KotlinClientTsServerEdgeCasesTestSse : TsTestBase() {
|
||||||
println("Warning: Error during client close: ${e.message}")
|
println("Warning: Error during client close: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (::tsServerProcess.isInitialized) {
|
|
||||||
try {
|
|
||||||
println("Stopping TypeScript server")
|
|
||||||
stopProcess(tsServerProcess)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Warning: Error during TypeScript server stop: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -3,41 +3,14 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse
|
||||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractKotlinClientTsServerTest
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractKotlinClientTsServerTest
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||||
import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI
|
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import org.junit.jupiter.api.AfterEach
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@DisabledOnCI
|
|
||||||
class KotlinClientTsServerTestSse : AbstractKotlinClientTsServerTest() {
|
class KotlinClientTsServerTestSse : AbstractKotlinClientTsServerTest() {
|
||||||
|
|
||||||
override val transportKind = TransportKind.SSE
|
override val transportKind = TransportKind.SSE
|
||||||
|
|
||||||
private var port: Int = 0
|
|
||||||
private val host = "localhost"
|
private val host = "localhost"
|
||||||
private lateinit var serverUrl: String
|
private val serverUrl: String by lazy { getSharedSseUrl() }
|
||||||
private lateinit var tsServerProcess: Process
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setUpSse() {
|
|
||||||
port = findFreePort()
|
|
||||||
serverUrl = "http://$host:$port/mcp"
|
|
||||||
tsServerProcess = startTypeScriptServer(port)
|
|
||||||
println("TypeScript server started on port $port")
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
fun tearDownSse() {
|
|
||||||
if (::tsServerProcess.isInitialized) {
|
|
||||||
try {
|
|
||||||
println("Stopping TypeScript server")
|
|
||||||
stopProcess(tsServerProcess)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Warning: Error during TypeScript server stop: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun <T> useClient(block: suspend (Client) -> T): T = withClient(serverUrl) { client ->
|
override suspend fun <T> useClient(block: suspend (Client) -> T): T = withClient(serverUrl) { client ->
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import io.modelcontextprotocol.kotlin.sdk.TextContent
|
||||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase
|
||||||
import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
|
|
@ -14,7 +13,6 @@ import kotlinx.coroutines.withTimeout
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.junit.jupiter.api.Timeout
|
import org.junit.jupiter.api.Timeout
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
@ -23,25 +21,14 @@ import kotlin.test.assertNotNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@DisabledOnCI
|
|
||||||
class OldSchemaKotlinClientTsServerEdgeCasesTestSse : TsTestBase() {
|
class OldSchemaKotlinClientTsServerEdgeCasesTestSse : TsTestBase() {
|
||||||
|
|
||||||
override val transportKind = TransportKind.SSE
|
override val transportKind = TransportKind.SSE
|
||||||
|
|
||||||
private var port: Int = 0
|
|
||||||
private val host = "localhost"
|
private val host = "localhost"
|
||||||
private lateinit var serverUrl: String
|
private val serverUrl: String by lazy { getSharedSseUrl() }
|
||||||
|
|
||||||
private lateinit var client: Client
|
private lateinit var client: Client
|
||||||
private lateinit var tsServerProcess: Process
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setUp() {
|
|
||||||
port = findFreePort()
|
|
||||||
serverUrl = "http://$host:$port/mcp"
|
|
||||||
tsServerProcess = startTypeScriptServer(port)
|
|
||||||
println("TypeScript server started on port $port")
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
|
|
@ -56,15 +43,6 @@ class OldSchemaKotlinClientTsServerEdgeCasesTestSse : TsTestBase() {
|
||||||
println("Warning: Error during client close: ${e.message}")
|
println("Warning: Error during client close: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (::tsServerProcess.isInitialized) {
|
|
||||||
try {
|
|
||||||
println("Stopping TypeScript server")
|
|
||||||
stopProcess(tsServerProcess)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Warning: Error during TypeScript server stop: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -3,41 +3,15 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse
|
||||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.OldSchemaAbstractKotlinClientTsServerTest
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.OldSchemaAbstractKotlinClientTsServerTest
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||||
import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI
|
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import org.junit.jupiter.api.AfterEach
|
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@DisabledOnCI
|
|
||||||
class OldSchemaKotlinClientTsServerTestSse : OldSchemaAbstractKotlinClientTsServerTest() {
|
class OldSchemaKotlinClientTsServerTestSse : OldSchemaAbstractKotlinClientTsServerTest() {
|
||||||
|
|
||||||
override val transportKind = TransportKind.SSE
|
override val transportKind = TransportKind.SSE
|
||||||
|
|
||||||
private var port: Int = 0
|
|
||||||
private val host = "localhost"
|
private val host = "localhost"
|
||||||
private lateinit var serverUrl: String
|
private val serverUrl: String by lazy { getSharedSseUrl() }
|
||||||
private lateinit var tsServerProcess: Process
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setUpSse() {
|
|
||||||
port = findFreePort()
|
|
||||||
serverUrl = "http://$host:$port/mcp"
|
|
||||||
tsServerProcess = startTypeScriptServer(port)
|
|
||||||
println("TypeScript server started on port $port")
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
fun tearDownSse() {
|
|
||||||
if (::tsServerProcess.isInitialized) {
|
|
||||||
try {
|
|
||||||
println("Stopping TypeScript server")
|
|
||||||
stopProcess(tsServerProcess)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Warning: Error during TypeScript server stop: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun <T> useClient(block: suspend (Client) -> T): T = withClient(serverUrl) { client ->
|
override suspend fun <T> useClient(block: suspend (Client) -> T): T = withClient(serverUrl) { client ->
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractTsClientKotlinServerTest
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractTsClientKotlinServerTest
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||||
import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI
|
import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI
|
||||||
|
import io.modelcontextprotocol.kotlin.test.utils.findFreePort
|
||||||
|
import io.modelcontextprotocol.kotlin.test.utils.killProcessOnPort
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.BeforeEach
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase
|
||||||
import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI
|
import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI
|
||||||
|
import io.modelcontextprotocol.kotlin.test.utils.findFreePort
|
||||||
|
import io.modelcontextprotocol.kotlin.test.utils.killProcessOnPort
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
|
@ -35,8 +37,8 @@ class TsEdgeCasesTestSse : TsTestBase() {
|
||||||
killProcessOnPort(port)
|
killProcessOnPort(port)
|
||||||
httpServer = KotlinServerForTsClient()
|
httpServer = KotlinServerForTsClient()
|
||||||
httpServer?.start(port)
|
httpServer?.start(port)
|
||||||
if (!waitForPort(port = port)) {
|
check(waitForPort(port = port)) {
|
||||||
throw IllegalStateException("Kotlin test server did not become ready on localhost:$port within timeout")
|
"Kotlin test server did not become ready on localhost:$port within timeout"
|
||||||
}
|
}
|
||||||
println("Kotlin server started on port $port")
|
println("Kotlin server started on port $port")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ async function main() {
|
||||||
const path = await import('path');
|
const path = await import('path');
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const { pathToFileURL } = await import('url');
|
const { pathToFileURL } = await import('url');
|
||||||
const clientUrl = pathToFileURL(path.join(sdkDir, 'src', 'client', 'index.ts')).href;
|
const clientUrl = pathToFileURL(path.join(sdkDir, 'packages', 'client', 'src', 'index.ts')).href;
|
||||||
const streamUrl = pathToFileURL(path.join(sdkDir, 'src', 'client', 'streamableHttp.js')).href;
|
const streamUrl = pathToFileURL(path.join(sdkDir, 'packages', 'client', 'src', 'streamableHttp.ts')).href;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
({ Client } = await import(clientUrl));
|
({ Client } = await import(clientUrl));
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
|
||||||
|
|
@ -1,701 +0,0 @@
|
||||||
// @ts-nocheck
|
|
||||||
import express, {Request, Response} from 'express';
|
|
||||||
import {randomUUID} from 'node:crypto';
|
|
||||||
import {z} from 'zod';
|
|
||||||
import cors from 'cors';
|
|
||||||
import path from 'node:path';
|
|
||||||
import {pathToFileURL} from 'node:url';
|
|
||||||
|
|
||||||
const SDK_DIR = process.env.TYPESCRIPT_SDK_DIR;
|
|
||||||
if (!SDK_DIR) {
|
|
||||||
throw new Error('TYPESCRIPT_SDK_DIR environment variable is not set. It should point to the cloned typescript-sdk directory.');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importFromSdk<T = any>(rel: string): Promise<T> {
|
|
||||||
const full = path.resolve(SDK_DIR!, rel);
|
|
||||||
const url = pathToFileURL(full).href;
|
|
||||||
return (await import(url)) as unknown as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const {McpServer} = await importFromSdk<any>('src/server/mcp.ts');
|
|
||||||
const {StreamableHTTPServerTransport} = await importFromSdk<any>('src/server/streamableHttp.ts');
|
|
||||||
const {
|
|
||||||
getOAuthProtectedResourceMetadataUrl,
|
|
||||||
mcpAuthMetadataRouter
|
|
||||||
} = await importFromSdk<any>('src/server/auth/router.ts');
|
|
||||||
const {requireBearerAuth} = await importFromSdk<any>('src/server/auth/middleware/bearerAuth.ts');
|
|
||||||
const {
|
|
||||||
isInitializeRequest,
|
|
||||||
} = await importFromSdk<any>('src/types.ts');
|
|
||||||
const {InMemoryEventStore} = await importFromSdk<any>('src/examples/shared/inMemoryEventStore.ts');
|
|
||||||
const {setupAuthServer} = await importFromSdk<any>('src/examples/server/demoInMemoryOAuthProvider.ts');
|
|
||||||
const {checkResourceAllowed} = await importFromSdk<any>('src/shared/auth-utils.ts');
|
|
||||||
|
|
||||||
// Check for OAuth flag
|
|
||||||
const useOAuth = process.argv.includes('--oauth');
|
|
||||||
const strictOAuth = process.argv.includes('--oauth-strict');
|
|
||||||
|
|
||||||
// Create an MCP server with implementation details
|
|
||||||
const getServer = () => {
|
|
||||||
const server = new McpServer({
|
|
||||||
name: 'simple-streamable-http-server',
|
|
||||||
version: '1.0.0'
|
|
||||||
}, {capabilities: {logging: {}}});
|
|
||||||
|
|
||||||
// Register a simple tool that returns a greeting
|
|
||||||
server.registerTool(
|
|
||||||
'greet',
|
|
||||||
{
|
|
||||||
title: 'Greeting Tool', // Display name for UI
|
|
||||||
description: 'A simple greeting tool',
|
|
||||||
inputSchema: {
|
|
||||||
name: z.string().describe('Name to greet'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({name}): Promise<CallToolResult> => {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: `Hello, ${name}!`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Register a tool that sends multiple greetings with notifications (with annotations)
|
|
||||||
server.tool(
|
|
||||||
'multi-greet',
|
|
||||||
'A tool that sends different greetings with delays between them',
|
|
||||||
{
|
|
||||||
name: z.string().describe('Name to greet'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Multiple Greeting Tool',
|
|
||||||
readOnlyHint: true,
|
|
||||||
openWorldHint: false
|
|
||||||
},
|
|
||||||
async ({name}, extra): Promise<CallToolResult> => {
|
|
||||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
await server.sendLoggingMessage({
|
|
||||||
level: "debug",
|
|
||||||
data: `Starting multi-greet for ${name}`
|
|
||||||
}, extra.sessionId);
|
|
||||||
|
|
||||||
await sleep(1000); // Wait 1 second before first greeting
|
|
||||||
|
|
||||||
await server.sendLoggingMessage({
|
|
||||||
level: "info",
|
|
||||||
data: `Sending first greeting to ${name}`
|
|
||||||
}, extra.sessionId);
|
|
||||||
|
|
||||||
await sleep(1000); // Wait another second before second greeting
|
|
||||||
|
|
||||||
await server.sendLoggingMessage({
|
|
||||||
level: "info",
|
|
||||||
data: `Sending second greeting to ${name}`
|
|
||||||
}, extra.sessionId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: `Good morning, ${name}!`,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
// Register a tool that demonstrates elicitation (user input collection)
|
|
||||||
// This creates a closure that captures the server instance
|
|
||||||
server.tool(
|
|
||||||
'collect-user-info',
|
|
||||||
'A tool that collects user information through elicitation',
|
|
||||||
{
|
|
||||||
infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect'),
|
|
||||||
},
|
|
||||||
async ({infoType}): Promise<CallToolResult> => {
|
|
||||||
let message: string;
|
|
||||||
let requestedSchema: {
|
|
||||||
type: 'object';
|
|
||||||
properties: Record<string, PrimitiveSchemaDefinition>;
|
|
||||||
required?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (infoType) {
|
|
||||||
case 'contact':
|
|
||||||
message = 'Please provide your contact information';
|
|
||||||
requestedSchema = {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
name: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Full Name',
|
|
||||||
description: 'Your full name',
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Email Address',
|
|
||||||
description: 'Your email address',
|
|
||||||
format: 'email',
|
|
||||||
},
|
|
||||||
phone: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Phone Number',
|
|
||||||
description: 'Your phone number (optional)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ['name', 'email'],
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case 'preferences':
|
|
||||||
message = 'Please set your preferences';
|
|
||||||
requestedSchema = {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
theme: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Theme',
|
|
||||||
description: 'Choose your preferred theme',
|
|
||||||
enum: ['light', 'dark', 'auto'],
|
|
||||||
enumNames: ['Light', 'Dark', 'Auto'],
|
|
||||||
},
|
|
||||||
notifications: {
|
|
||||||
type: 'boolean',
|
|
||||||
title: 'Enable Notifications',
|
|
||||||
description: 'Would you like to receive notifications?',
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
frequency: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Notification Frequency',
|
|
||||||
description: 'How often would you like notifications?',
|
|
||||||
enum: ['daily', 'weekly', 'monthly'],
|
|
||||||
enumNames: ['Daily', 'Weekly', 'Monthly'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ['theme'],
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case 'feedback':
|
|
||||||
message = 'Please provide your feedback';
|
|
||||||
requestedSchema = {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
rating: {
|
|
||||||
type: 'integer',
|
|
||||||
title: 'Rating',
|
|
||||||
description: 'Rate your experience (1-5)',
|
|
||||||
minimum: 1,
|
|
||||||
maximum: 5,
|
|
||||||
},
|
|
||||||
comments: {
|
|
||||||
type: 'string',
|
|
||||||
title: 'Comments',
|
|
||||||
description: 'Additional comments (optional)',
|
|
||||||
maxLength: 500,
|
|
||||||
},
|
|
||||||
recommend: {
|
|
||||||
type: 'boolean',
|
|
||||||
title: 'Would you recommend this?',
|
|
||||||
description: 'Would you recommend this to others?',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ['rating', 'recommend'],
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown info type: ${infoType}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the underlying server instance to elicit input from the client
|
|
||||||
const result = await server.server.elicitInput({
|
|
||||||
message,
|
|
||||||
requestedSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.action === 'accept') {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: `Thank you! Collected ${infoType} information: ${JSON.stringify(result.content, null, 2)}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} else if (result.action === 'decline') {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: `No information was collected. User declined ${infoType} information request.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: `Information collection was cancelled by the user.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: `Error collecting ${infoType} information: ${error}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Register a simple prompt with title
|
|
||||||
server.registerPrompt(
|
|
||||||
'greeting-template',
|
|
||||||
{
|
|
||||||
title: 'Greeting Template', // Display name for UI
|
|
||||||
description: 'A simple greeting prompt template',
|
|
||||||
argsSchema: {
|
|
||||||
name: z.string().describe('Name to include in greeting'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({name}): Promise<GetPromptResult> => {
|
|
||||||
return {
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: {
|
|
||||||
type: 'text',
|
|
||||||
text: `Please greet ${name} in a friendly manner.`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Register a tool specifically for testing resumability
|
|
||||||
server.tool(
|
|
||||||
'start-notification-stream',
|
|
||||||
'Starts sending periodic notifications for testing resumability',
|
|
||||||
{
|
|
||||||
interval: z.number().describe('Interval in milliseconds between notifications').default(100),
|
|
||||||
count: z.number().describe('Number of notifications to send (0 for 100)').default(50),
|
|
||||||
},
|
|
||||||
async ({interval, count}, extra): Promise<CallToolResult> => {
|
|
||||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
let counter = 0;
|
|
||||||
|
|
||||||
while (count === 0 || counter < count) {
|
|
||||||
counter++;
|
|
||||||
try {
|
|
||||||
await server.sendLoggingMessage({
|
|
||||||
level: "info",
|
|
||||||
data: `Periodic notification #${counter} at ${new Date().toISOString()}`
|
|
||||||
}, extra.sessionId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error sending notification:", error);
|
|
||||||
}
|
|
||||||
// Wait for the specified interval
|
|
||||||
await sleep(interval);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: `Started sending periodic notifications every ${interval}ms`,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create a simple resource at a fixed URI
|
|
||||||
server.registerResource(
|
|
||||||
'greeting-resource',
|
|
||||||
'https://example.com/greetings/default',
|
|
||||||
{
|
|
||||||
title: 'Default Greeting', // Display name for UI
|
|
||||||
description: 'A simple greeting resource',
|
|
||||||
mimeType: 'text/plain'
|
|
||||||
},
|
|
||||||
async (): Promise<ReadResourceResult> => {
|
|
||||||
return {
|
|
||||||
contents: [
|
|
||||||
{
|
|
||||||
uri: 'https://example.com/greetings/default',
|
|
||||||
text: 'Hello, world!',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create additional resources for ResourceLink demonstration
|
|
||||||
server.registerResource(
|
|
||||||
'example-file-1',
|
|
||||||
'file:///example/file1.txt',
|
|
||||||
{
|
|
||||||
title: 'Example File 1',
|
|
||||||
description: 'First example file for ResourceLink demonstration',
|
|
||||||
mimeType: 'text/plain'
|
|
||||||
},
|
|
||||||
async (): Promise<ReadResourceResult> => {
|
|
||||||
return {
|
|
||||||
contents: [
|
|
||||||
{
|
|
||||||
uri: 'file:///example/file1.txt',
|
|
||||||
text: 'This is the content of file 1',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerResource(
|
|
||||||
'example-file-2',
|
|
||||||
'file:///example/file2.txt',
|
|
||||||
{
|
|
||||||
title: 'Example File 2',
|
|
||||||
description: 'Second example file for ResourceLink demonstration',
|
|
||||||
mimeType: 'text/plain'
|
|
||||||
},
|
|
||||||
async (): Promise<ReadResourceResult> => {
|
|
||||||
return {
|
|
||||||
contents: [
|
|
||||||
{
|
|
||||||
uri: 'file:///example/file2.txt',
|
|
||||||
text: 'This is the content of file 2',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Register a tool that returns ResourceLinks
|
|
||||||
server.registerTool(
|
|
||||||
'list-files',
|
|
||||||
{
|
|
||||||
title: 'List Files with ResourceLinks',
|
|
||||||
description: 'Returns a list of files as ResourceLinks without embedding their content',
|
|
||||||
inputSchema: {
|
|
||||||
includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({includeDescriptions = true}): Promise<CallToolResult> => {
|
|
||||||
const resourceLinks: ResourceLink[] = [
|
|
||||||
{
|
|
||||||
type: 'resource_link',
|
|
||||||
uri: 'https://example.com/greetings/default',
|
|
||||||
name: 'Default Greeting',
|
|
||||||
mimeType: 'text/plain',
|
|
||||||
...(includeDescriptions && {description: 'A simple greeting resource'})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'resource_link',
|
|
||||||
uri: 'file:///example/file1.txt',
|
|
||||||
name: 'Example File 1',
|
|
||||||
mimeType: 'text/plain',
|
|
||||||
...(includeDescriptions && {description: 'First example file for ResourceLink demonstration'})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'resource_link',
|
|
||||||
uri: 'file:///example/file2.txt',
|
|
||||||
name: 'Example File 2',
|
|
||||||
mimeType: 'text/plain',
|
|
||||||
...(includeDescriptions && {description: 'Second example file for ResourceLink demonstration'})
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: 'Here are the available files as resource links:',
|
|
||||||
},
|
|
||||||
...resourceLinks,
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: '\nYou can read any of these resources using their URI.',
|
|
||||||
}
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return server;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000;
|
|
||||||
const AUTH_PORT = process.env.MCP_AUTH_PORT ? parseInt(process.env.MCP_AUTH_PORT, 10) : 3001;
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
// Allow CORS all domains, expose the Mcp-Session-Id header
|
|
||||||
app.use(cors({
|
|
||||||
origin: '*', // Allow all origins
|
|
||||||
exposedHeaders: ["Mcp-Session-Id"]
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Set up OAuth if enabled
|
|
||||||
let authMiddleware = null;
|
|
||||||
if (useOAuth) {
|
|
||||||
// Create auth middleware for MCP endpoints
|
|
||||||
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
|
|
||||||
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
|
|
||||||
|
|
||||||
const oauthMetadata: OAuthMetadata = setupAuthServer({
|
|
||||||
authServerUrl,
|
|
||||||
mcpServerUrl,
|
|
||||||
strictResource: strictOAuth
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokenVerifier = {
|
|
||||||
verifyAccessToken: async (token: string) => {
|
|
||||||
const endpoint = oauthMetadata.introspection_endpoint;
|
|
||||||
|
|
||||||
if (!endpoint) {
|
|
||||||
throw new Error('No token verification endpoint available in metadata');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
token: token
|
|
||||||
}).toString()
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Invalid or expired token: ${await response.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (strictOAuth) {
|
|
||||||
if (!data.aud) {
|
|
||||||
throw new Error(`Resource Indicator (RFC8707) missing`);
|
|
||||||
}
|
|
||||||
if (!checkResourceAllowed({requestedResource: data.aud, configuredResource: mcpServerUrl})) {
|
|
||||||
throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert the response to AuthInfo format
|
|
||||||
return {
|
|
||||||
token,
|
|
||||||
clientId: data.client_id,
|
|
||||||
scopes: data.scope ? data.scope.split(' ') : [],
|
|
||||||
expiresAt: data.exp,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add metadata routes to the main MCP server
|
|
||||||
app.use(mcpAuthMetadataRouter({
|
|
||||||
oauthMetadata,
|
|
||||||
resourceServerUrl: mcpServerUrl,
|
|
||||||
scopesSupported: ['mcp:tools'],
|
|
||||||
resourceName: 'MCP Demo Server',
|
|
||||||
}));
|
|
||||||
|
|
||||||
authMiddleware = requireBearerAuth({
|
|
||||||
verifier: tokenVerifier,
|
|
||||||
requiredScopes: [],
|
|
||||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map to store transports by session ID
|
|
||||||
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
|
|
||||||
|
|
||||||
// MCP POST endpoint with optional auth
|
|
||||||
const mcpPostHandler = async (req: Request, res: Response) => {
|
|
||||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
||||||
if (sessionId) {
|
|
||||||
console.log(`Received MCP request for session: ${sessionId}`);
|
|
||||||
} else {
|
|
||||||
console.log('Request body:', req.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useOAuth && req.auth) {
|
|
||||||
console.log('Authenticated user:', req.auth);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
let transport: StreamableHTTPServerTransport;
|
|
||||||
if (sessionId && transports[sessionId]) {
|
|
||||||
// Reuse existing transport
|
|
||||||
transport = transports[sessionId];
|
|
||||||
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
||||||
// New initialization request
|
|
||||||
const eventStore = new InMemoryEventStore();
|
|
||||||
transport = new StreamableHTTPServerTransport({
|
|
||||||
sessionIdGenerator: () => randomUUID(),
|
|
||||||
eventStore, // Enable resumability
|
|
||||||
onsessioninitialized: (sessionId) => {
|
|
||||||
// Store the transport by session ID when session is initialized
|
|
||||||
// This avoids race conditions where requests might come in before the session is stored
|
|
||||||
console.log(`Session initialized with ID: ${sessionId}`);
|
|
||||||
transports[sessionId] = transport;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up onclose handler to clean up transport when closed
|
|
||||||
transport.onclose = () => {
|
|
||||||
const sid = transport.sessionId;
|
|
||||||
if (sid && transports[sid]) {
|
|
||||||
console.log(`Transport closed for session ${sid}, removing from transports map`);
|
|
||||||
delete transports[sid];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Connect the transport to the MCP server BEFORE handling the request
|
|
||||||
// so responses can flow back through the same transport
|
|
||||||
const server = getServer();
|
|
||||||
await server.connect(transport);
|
|
||||||
|
|
||||||
await transport.handleRequest(req, res, req.body);
|
|
||||||
return; // Already handled
|
|
||||||
} else {
|
|
||||||
// Invalid request - no session ID or not initialization request
|
|
||||||
res.status(400).json({
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
error: {
|
|
||||||
code: -32000,
|
|
||||||
message: 'Bad Request: No valid session ID provided',
|
|
||||||
},
|
|
||||||
id: null,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle the request with existing transport - no need to reconnect
|
|
||||||
// The existing transport is already connected to the server
|
|
||||||
await transport.handleRequest(req, res, req.body);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error handling MCP request:', error);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
error: {
|
|
||||||
code: -32603,
|
|
||||||
message: 'Internal server error',
|
|
||||||
},
|
|
||||||
id: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set up routes with conditional auth middleware
|
|
||||||
if (useOAuth && authMiddleware) {
|
|
||||||
app.post('/mcp', authMiddleware, mcpPostHandler);
|
|
||||||
} else {
|
|
||||||
app.post('/mcp', mcpPostHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle GET requests for SSE streams (using built-in support from StreamableHTTP)
|
|
||||||
const mcpGetHandler = async (req: Request, res: Response) => {
|
|
||||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
||||||
if (!sessionId || !transports[sessionId]) {
|
|
||||||
res.status(400).send('Invalid or missing session ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useOAuth && req.auth) {
|
|
||||||
console.log('Authenticated SSE connection from user:', req.auth);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for Last-Event-ID header for resumability
|
|
||||||
const lastEventId = req.headers['last-event-id'] as string | undefined;
|
|
||||||
if (lastEventId) {
|
|
||||||
console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`);
|
|
||||||
} else {
|
|
||||||
console.log(`Establishing new SSE stream for session ${sessionId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const transport = transports[sessionId];
|
|
||||||
await transport.handleRequest(req, res);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set up GET route with conditional auth middleware
|
|
||||||
if (useOAuth && authMiddleware) {
|
|
||||||
app.get('/mcp', authMiddleware, mcpGetHandler);
|
|
||||||
} else {
|
|
||||||
app.get('/mcp', mcpGetHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle DELETE requests for session termination (according to MCP spec)
|
|
||||||
const mcpDeleteHandler = async (req: Request, res: Response) => {
|
|
||||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
||||||
if (!sessionId || !transports[sessionId]) {
|
|
||||||
res.status(400).send('Invalid or missing session ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Received session termination request for session ${sessionId}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const transport = transports[sessionId];
|
|
||||||
await transport.handleRequest(req, res);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error handling session termination:', error);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).send('Error processing session termination');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set up DELETE route with conditional auth middleware
|
|
||||||
if (useOAuth && authMiddleware) {
|
|
||||||
app.delete('/mcp', authMiddleware, mcpDeleteHandler);
|
|
||||||
} else {
|
|
||||||
app.delete('/mcp', mcpDeleteHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
app.listen(MCP_PORT, (error) => {
|
|
||||||
if (error) {
|
|
||||||
console.error('Failed to start server:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle server shutdown
|
|
||||||
process.on('SIGINT', async () => {
|
|
||||||
console.log('Shutting down server...');
|
|
||||||
|
|
||||||
// Close all active transports to properly clean up resources
|
|
||||||
for (const sessionId in transports) {
|
|
||||||
try {
|
|
||||||
console.log(`Closing transport for session ${sessionId}`);
|
|
||||||
await transports[sessionId].close();
|
|
||||||
delete transports[sessionId];
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error closing transport for session ${sessionId}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log('Server shutdown complete');
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error('Failed to start server:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -4,7 +4,6 @@ import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase
|
||||||
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||||
import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
|
|
@ -19,7 +18,6 @@ import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
@DisabledOnCI
|
|
||||||
class KotlinClientTsServerEdgeCasesTestStdio : TsTestBase() {
|
class KotlinClientTsServerEdgeCasesTestStdio : TsTestBase() {
|
||||||
|
|
||||||
override val transportKind = TransportKind.STDIO
|
override val transportKind = TransportKind.STDIO
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,7 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.stdio
|
||||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractKotlinClientTsServerTest
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractKotlinClientTsServerTest
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||||
import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI
|
|
||||||
|
|
||||||
@DisabledOnCI
|
|
||||||
class KotlinClientTsServerTestStdio : AbstractKotlinClientTsServerTest() {
|
class KotlinClientTsServerTestStdio : AbstractKotlinClientTsServerTest() {
|
||||||
|
|
||||||
override val transportKind = TransportKind.STDIO
|
override val transportKind = TransportKind.STDIO
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import io.modelcontextprotocol.kotlin.sdk.TextContent
|
||||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase
|
||||||
import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
|
|
@ -19,7 +18,6 @@ import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
@DisabledOnCI
|
|
||||||
class OldSchemaKotlinClientTsServerEdgeCasesTestStdio : TsTestBase() {
|
class OldSchemaKotlinClientTsServerEdgeCasesTestStdio : TsTestBase() {
|
||||||
|
|
||||||
override val transportKind = TransportKind.STDIO
|
override val transportKind = TransportKind.STDIO
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,7 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.stdio
|
||||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.OldSchemaAbstractKotlinClientTsServerTest
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.OldSchemaAbstractKotlinClientTsServerTest
|
||||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||||
import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI
|
|
||||||
|
|
||||||
@DisabledOnCI
|
|
||||||
class OldSchemaKotlinClientTsServerTestStdio : OldSchemaAbstractKotlinClientTsServerTest() {
|
class OldSchemaKotlinClientTsServerTestStdio : OldSchemaAbstractKotlinClientTsServerTest() {
|
||||||
|
|
||||||
override val transportKind = TransportKind.STDIO
|
override val transportKind = TransportKind.STDIO
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ async function main() {
|
||||||
|
|
||||||
const path = await import('path');
|
const path = await import('path');
|
||||||
const { pathToFileURL } = await import('url');
|
const { pathToFileURL } = await import('url');
|
||||||
const clientUrl = pathToFileURL(path.join(sdkDir, 'src', 'client', 'index.ts')).href;
|
const clientUrl = pathToFileURL(path.join(sdkDir, 'packages', 'client', 'src', 'index.ts')).href;
|
||||||
({ Client } = await import(clientUrl));
|
({ Client } = await import(clientUrl));
|
||||||
|
|
||||||
if (!toolName) {
|
if (!toolName) {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue