fix(integration-test): refactor integration test infrastructure, fix and enable tests with ts-client (#478)

# Refactor integration test infrastructure, fix and enable tests with
ts-client

- Refactor TypeScript integration test infrastructure to support
parallel execution and real I/O operations.
- Extract reusable TypeScriptClient component
- Migrate some tests from `runTest` to `runBlocking` for tests that
spawn actual processes and communicate over real network connections.

## Key Changes 

  - Extract `TypeScriptClient` class for managing TS client processes
- Move client scripts to integration-test/src/jvmTest/typescript/client/
- Migrate typescript: `myClient.ts`(2) → `sse-client.ts`,
`stdio-client.ts`
  - Automate TypeScript dependency installation in static initializer
  
- Enable JUnit parallel execution (dynamic strategy, concurrent mode)
with test timeout of 2 minutes
- Replace `mutableListOf` with `ConcurrentLinkedQueue` for thread-safe
notification handling
  - Migrate integration tests from `runTest` to `runBlocking`
**Rationale:** Integration tests that spawn real TypeScript processes,
establish actual
HTTP/SSE connections, and manage server lifecycles require real-time I/O
behavior. The runTest framework's virtual time is designed for fast unit
tests with simulated timing, not for integration scenarios where
external processes and network communication must occur in real time.
Parallel execution requires thread-safe data structures
(ConcurrentLinkedQueue)
to safely collect notifications from multiple concurrent test instances.

  - Add `EmbeddedServer.actualPort()` extension to get bound port
  - Standardize kotlin.time.Duration usage across test utilities
  - Update processUtils.stopProcess() to accept Duration parameter
  - Replace RuntimeException checks with idiomatic check() calls
  - Revert `--rerun-tasks` flag to Gradle workflows for reliable builds

## Motivation and Context

Integration tests with typescript client are now broken. Enabling
parallel test execution will force writing thread-safe independent tests
and will highlight problems faster.

## How Has This Been Tested?
CI

## Breaking Changes
<!-- Will users need to update their code or configurations? -->

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [x] CI Update
- [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
Follow up for #477
This commit is contained in:
Konstantin Pavlov 2026-01-11 09:38:13 +02:00 • committed by GitHub
commit 6ad9bf2122
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 419 additions and 441 deletions

View file

@ -5,6 +5,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.Implementation
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.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
@ -30,7 +31,7 @@ import kotlin.time.Duration.Companion.seconds
class StdioClientTransportTest : BaseTransportTest() {
@Test
fun `handle stdio error`(): Unit = runBlocking {
fun `handle stdio error`(): Unit = runBlocking(Dispatchers.IO) {
val processBuilder = createSleepyProcessBuilder()
val process = processBuilder.start()

View file

@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")
package io.modelcontextprotocol.kotlin.sdk.integration.kotlin.websocket
import io.ktor.client.HttpClient
@ -21,11 +23,9 @@ import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.mcpWebSocket
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
import io.ktor.client.engine.cio.CIO as ClientCIO
import io.ktor.client.plugins.websocket.WebSockets as ClientWebSocket
import io.ktor.server.cio.CIO as ServerCIO
@ -34,16 +34,14 @@ import io.ktor.server.websocket.WebSockets as ServerWebSockets
class OldSchemaWebSocketIntegrationTest {
@Test
fun `client should be able to connect to websocket server 2`() = runTest(timeout = 5.seconds) {
fun `client should be able to connect to websocket server 2`() = runBlocking(Dispatchers.IO) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var client: Client? = null
try {
withContext(Dispatchers.Default) {
server = initServer()
val port = server.engine.resolvedConnectors().first().port
client = initClient(serverPort = port)
}
server = initServer()
val port = server.engine.resolvedConnectors().first().port
client = initClient(serverPort = port)
} finally {
client?.close()
server?.stop(1000, 2000)
@ -58,19 +56,17 @@ class OldSchemaWebSocketIntegrationTest {
* 3. Observe that Client A receives a response related to it.
*/
@Test
fun `single websocket connection`() = runTest(timeout = 5.seconds) {
fun `single websocket connection`() = runBlocking(Dispatchers.IO) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var client: Client? = null
try {
withContext(Dispatchers.Default) {
server = initServer()
val port = server.engine.resolvedConnectors().first().port
client = initClient("Client A", port)
server = initServer()
val port = server.engine.resolvedConnectors().first().port
client = initClient("Client A", port)
val promptA = getPrompt(client, "Client A")
assertTrue { "Client A" in promptA }
}
val promptA = getPrompt(client, "Client A")
assertTrue { "Client A" in promptA }
} finally {
client?.close()
server?.stop(1000, 2000)
@ -86,26 +82,24 @@ class OldSchemaWebSocketIntegrationTest {
* 4. Observe that Client B (connection #2) receives a response related to sessionId#1.
*/
@Test
fun `multiple websocket connections`() = runTest(timeout = 5.seconds) {
fun `multiple websocket connections`() = runBlocking(Dispatchers.IO) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var clientA: Client? = null
var clientB: Client? = null
try {
withContext(Dispatchers.Default) {
server = initServer()
val port = server.engine.resolvedConnectors().first().port
clientA = initClient("Client A", port)
clientB = initClient("Client B", port)
server = initServer()
val port = server.engine.resolvedConnectors().first().port
clientA = initClient("Client A", port)
clientB = initClient("Client B", port)
// Step 3: Send a prompt request from Client A
val promptA = getPrompt(clientA, "Client A")
// Step 4: Send a prompt request from Client B
val promptB = getPrompt(clientB, "Client B")
// Step 3: Send a prompt request from Client A
val promptA = getPrompt(clientA, "Client A")
// Step 4: Send a prompt request from Client B
val promptB = getPrompt(clientB, "Client B")
assertTrue { "Client A" in promptA }
assertTrue { "Client B" in promptB }
}
assertTrue { "Client A" in promptA }
assertTrue { "Client B" in promptB }
} finally {
clientA?.close()
clientB?.close()

View file

@ -21,11 +21,9 @@ import io.modelcontextprotocol.kotlin.sdk.types.Role
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
import io.ktor.client.engine.cio.CIO as ClientCIO
import io.ktor.client.plugins.websocket.WebSockets as ClientWebSocket
import io.ktor.server.cio.CIO as ServerCIO
@ -34,16 +32,14 @@ import io.ktor.server.websocket.WebSockets as ServerWebSockets
class WebSocketIntegrationTest {
@Test
fun `client should be able to connect to websocket server 2`() = runTest(timeout = 5.seconds) {
fun `client should be able to connect to websocket server 2`() = runBlocking(Dispatchers.IO) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var client: Client? = null
try {
withContext(Dispatchers.Default) {
server = initServer()
val port = server.engine.resolvedConnectors().first().port
client = initClient(serverPort = port)
}
server = initServer()
val port = server.engine.resolvedConnectors().first().port
client = initClient(serverPort = port)
} finally {
client?.close()
server?.stop(1000, 2000)
@ -58,19 +54,17 @@ class WebSocketIntegrationTest {
* 3. Observe that Client A receives a response related to it.
*/
@Test
fun `single websocket connection`() = runTest(timeout = 5.seconds) {
fun `single websocket connection`() = runBlocking(Dispatchers.IO) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var client: Client? = null
try {
withContext(Dispatchers.Default) {
server = initServer()
val port = server.engine.resolvedConnectors().first().port
client = initClient("Client A", port)
server = initServer()
val port = server.engine.resolvedConnectors().first().port
client = initClient("Client A", port)
val promptA = getPrompt(client, "Client A")
assertTrue { "Client A" in promptA }
}
val promptA = getPrompt(client, "Client A")
assertTrue { "Client A" in promptA }
} finally {
client?.close()
server?.stop(1000, 2000)
@ -86,26 +80,24 @@ class WebSocketIntegrationTest {
* 4. Observe that Client B (connection #2) receives a response related to sessionId#1.
*/
@Test
fun `multiple websocket connections`() = runTest(timeout = 5.seconds) {
fun `multiple websocket connections`() = runBlocking(Dispatchers.IO) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var clientA: Client? = null
var clientB: Client? = null
try {
withContext(Dispatchers.Default) {
server = initServer()
val port = server.engine.resolvedConnectors().first().port
clientA = initClient("Client A", port)
clientB = initClient("Client B", port)
server = initServer()
val port = server.engine.resolvedConnectors().first().port
clientA = initClient("Client A", port)
clientB = initClient("Client B", port)
// Step 3: Send a prompt request from Client A
val promptA = getPrompt(clientA, "Client A")
// Step 4: Send a prompt request from Client B
val promptB = getPrompt(clientB, "Client B")
// Step 3: Send a prompt request from Client A
val promptA = getPrompt(clientA, "Client A")
// Step 4: Send a prompt request from Client B
val promptB = getPrompt(clientB, "Client B")
assertTrue { "Client A" in promptA }
assertTrue { "Client B" in promptB }
}
assertTrue { "Client A" in promptA }
assertTrue { "Client B" in promptB }
} finally {
clientA?.close()
clientB?.close()

View file

@ -25,8 +25,6 @@ import io.ktor.server.sse.SSE as ServerSSE
open class AbstractSseIntegrationTest {
suspend fun EmbeddedServer<*, *>.actualPort() = engine.resolvedConnectors().single().port
suspend fun initTestClient(serverPort: Int, name: String? = null): Client {
val client = Client(
Implementation(name = name ?: DEFAULT_CLIENT_NAME, version = VERSION),

View file

@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")
package io.modelcontextprotocol.kotlin.sdk.integration.sse
import io.ktor.client.HttpClient
@ -25,8 +27,6 @@ import io.ktor.server.sse.SSE as ServerSSE
open class OldSchemaAbstractSseIntegrationTest {
suspend fun EmbeddedServer<*, *>.actualPort() = engine.resolvedConnectors().single().port
suspend fun initTestClient(serverPort: Int, name: String? = null): Client {
val client = Client(
Implementation(name = name ?: DEFAULT_CLIENT_NAME, version = VERSION),

View file

@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION")
package io.modelcontextprotocol.kotlin.sdk.integration.sse
import io.ktor.server.cio.CIOApplicationEngine
@ -6,26 +8,23 @@ import io.modelcontextprotocol.kotlin.sdk.GetPromptRequest
import io.modelcontextprotocol.kotlin.sdk.TextContent
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams
import io.modelcontextprotocol.kotlin.test.utils.actualPort
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
class OldSchemaSseIntegrationTest : OldSchemaAbstractSseIntegrationTest() {
@Test
fun `client should be able to connect to sse server`() = runTest(timeout = 5.seconds) {
fun `client should be able to connect to sse server`() = runBlocking(Dispatchers.IO) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var client: Client? = null
try {
withContext(Dispatchers.Default) {
server = initTestServer()
val port = server.engine.resolvedConnectors().single().port
client = initTestClient(serverPort = port)
}
server = initTestServer()
val port = server.actualPort()
client = initTestClient(serverPort = port)
} finally {
client?.close()
server?.stopSuspend(1000, 2000)
@ -40,18 +39,16 @@ class OldSchemaSseIntegrationTest : OldSchemaAbstractSseIntegrationTest() {
* 3. Observe that Client A receives a response related to it.
*/
@Test
fun `single sse connection`() = runTest(timeout = 5.seconds) {
fun `single sse connection`() = runBlocking(Dispatchers.IO) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var client: Client? = null
try {
withContext(Dispatchers.Default) {
server = initTestServer()
val port = server.engine.resolvedConnectors().single().port
client = initTestClient(port, "Client A")
server = initTestServer()
val port = server.actualPort()
client = initTestClient(port, "Client A")
val promptA = getPrompt(client, "Client A")
assertTrue { "Client A" in promptA }
}
val promptA = getPrompt(client, "Client A")
assertTrue { "Client A" in promptA }
} finally {
client?.close()
server?.stopSuspend(1000, 2000)
@ -67,27 +64,25 @@ class OldSchemaSseIntegrationTest : OldSchemaAbstractSseIntegrationTest() {
* 4. Observe that Client B (connection #2) receives a response related to sessionId#1.
*/
@Test
fun `multiple sse connections`() = runTest(timeout = 5.seconds) {
fun `multiple sse connections`() = runBlocking(Dispatchers.IO) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var clientA: Client? = null
var clientB: Client? = null
try {
withContext(Dispatchers.Default) {
server = initTestServer()
val port = server.engine.resolvedConnectors().first().port
server = initTestServer()
val port = server.engine.resolvedConnectors().first().port
clientA = initTestClient(port, "Client A")
clientB = initTestClient(port, "Client B")
clientA = initTestClient(port, "Client A")
clientB = initTestClient(port, "Client B")
// Step 3: Send a prompt request from Client A
val promptA = getPrompt(clientA, "Client A")
// Step 4: Send a prompt request from Client B
val promptB = getPrompt(clientB, "Client B")
// Step 3: Send a prompt request from Client A
val promptA = getPrompt(clientA, "Client A")
// Step 4: Send a prompt request from Client B
val promptB = getPrompt(clientB, "Client B")
assertTrue { "Client A" in promptA }
assertTrue { "Client B" in promptB }
}
assertTrue { "Client A" in promptA }
assertTrue { "Client B" in promptB }
} finally {
clientA?.close()
clientB?.close()

View file

@ -6,26 +6,23 @@ import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import io.modelcontextprotocol.kotlin.test.utils.actualPort
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
class SseIntegrationTest : AbstractSseIntegrationTest() {
@Test
fun `client should be able to connect to sse server`() = runTest(timeout = 5.seconds) {
fun `client should be able to connect to sse server`() = runBlocking(Dispatchers.IO) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var client: Client? = null
try {
withContext(Dispatchers.Default) {
server = initTestServer()
val port = server.engine.resolvedConnectors().single().port
client = initTestClient(serverPort = port)
}
server = initTestServer()
val port = server.actualPort()
client = initTestClient(serverPort = port)
} finally {
client?.close()
server?.stopSuspend(1000, 2000)
@ -40,18 +37,16 @@ class SseIntegrationTest : AbstractSseIntegrationTest() {
* 3. Observe that Client A receives a response related to it.
*/
@Test
fun `single sse connection`() = runTest(timeout = 5.seconds) {
fun `single sse connection`() = runBlocking(Dispatchers.IO) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var client: Client? = null
try {
withContext(Dispatchers.Default) {
server = initTestServer()
val port = server.engine.resolvedConnectors().single().port
client = initTestClient(port, "Client A")
server = initTestServer()
val port = server.actualPort()
client = initTestClient(port, "Client A")
val promptA = getPrompt(client, "Client A")
assertTrue { "Client A" in promptA }
}
val promptA = getPrompt(client, "Client A")
assertTrue { "Client A" in promptA }
} finally {
client?.close()
server?.stopSuspend(1000, 2000)
@ -67,27 +62,25 @@ class SseIntegrationTest : AbstractSseIntegrationTest() {
* 4. Observe that Client B (connection #2) receives a response related to sessionId#1.
*/
@Test
fun `multiple sse connections`() = runTest(timeout = 5.seconds) {
fun `multiple sse connections`() = runBlocking(Dispatchers.IO) {
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
var clientA: Client? = null
var clientB: Client? = null
try {
withContext(Dispatchers.Default) {
server = initTestServer()
val port = server.engine.resolvedConnectors().first().port
server = initTestServer()
val port = server.actualPort()
clientA = initTestClient(port, "Client A")
clientB = initTestClient(port, "Client B")
clientA = initTestClient(port, "Client A")
clientB = initTestClient(port, "Client B")
// Step 3: Send a prompt request from Client A
val promptA = getPrompt(clientA, "Client A")
// Step 4: Send a prompt request from Client B
val promptB = getPrompt(clientB, "Client B")
// Step 3: Send a prompt request from Client A
val promptA = getPrompt(clientA, "Client A")
// Step 4: Send a prompt request from Client B
val promptB = getPrompt(clientB, "Client B")
assertTrue { "Client A" in promptA }
assertTrue { "Client B" in promptB }
}
assertTrue { "Client A" in promptA }
assertTrue { "Client B" in promptB }
} finally {
clientA?.close()
clientB?.close()

View file

@ -2,7 +2,6 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
@ -12,7 +11,6 @@ import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@DisabledOnCI
abstract class AbstractKotlinClientTsServerTest : TsTestBase() {
protected abstract suspend fun <T> useClient(block: suspend (Client) -> T): T

View file

@ -1,13 +1,14 @@
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI
import io.kotest.assertions.nondeterministic.eventually
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Timeout
import java.util.concurrent.TimeUnit
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
@DisabledOnCI
abstract class AbstractTsClientKotlinServerTest : TsTestBase() {
protected open fun beforeServer() {}
@ -25,8 +26,10 @@ abstract class AbstractTsClientKotlinServerTest : TsTestBase() {
try {
val testName = "TestUser"
val out = runClient("greet", testName)
assertTrue(out.contains("Text content:"), "Output should contain the text content section.\n$out")
assertTrue(out.contains("Hello, $testName!"), "Tool response should contain the greeting.\n$out")
eventually(5.seconds) {
assertTrue(out.contains("Text content:"), "Output should contain the text content section.\n$out")
assertTrue(out.contains("Hello, $testName!"), "Tool response should contain the greeting.\n$out")
}
assertTrue(
out.contains("Structured content:"),
"Output should contain the structured content section.\n$out",
@ -46,7 +49,7 @@ abstract class AbstractTsClientKotlinServerTest : TsTestBase() {
@Test
@Timeout(60, unit = TimeUnit.SECONDS)
fun notifications() = runTest {
fun notifications() = runBlocking {
beforeServer()
try {
val name = "NotifUser"
@ -66,7 +69,7 @@ abstract class AbstractTsClientKotlinServerTest : TsTestBase() {
@Test
@Timeout(120, unit = TimeUnit.SECONDS)
fun multipleClientSequence() = runTest {
fun multipleClientSequence() = runBlocking {
beforeServer()
try {
val out1 = runClient("greet", "FirstClient")

View file

@ -11,7 +11,7 @@ import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.test.utils.Retry
import io.modelcontextprotocol.kotlin.test.utils.TypeScriptRunner
import io.modelcontextprotocol.kotlin.test.utils.TypeScriptRunner.installDependencies
import io.modelcontextprotocol.kotlin.test.utils.isWindows
import kotlinx.coroutines.withTimeout
import kotlinx.io.Sink
@ -19,12 +19,11 @@ import kotlinx.io.Source
import kotlinx.io.asSink
import kotlinx.io.asSource
import kotlinx.io.buffered
import org.junit.jupiter.api.BeforeAll
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.util.concurrent.TimeUnit
import kotlin.io.path.createTempDirectory
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
enum class TransportKind { SSE, STDIO }
@ -34,20 +33,14 @@ abstract class TsTestBase {
protected abstract val transportKind: TransportKind
protected val tsClient: TypeScriptClient get() = TypeScriptClient(typescriptDir)
companion object {
@JvmStatic
protected val projectRoot: File get() = File(System.getProperty("user.dir"))
protected val projectRoot = File(System.getProperty("user.dir"))
@JvmStatic
protected val tsClientDir: File
get() = File(projectRoot, "src/jvmTest/typescript")
@JvmStatic
protected val tsSourceDir: File
get() = File(
projectRoot,
"src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/integration/typescript",
)
protected val typescriptDir = File(projectRoot, "src/jvmTest/typescript")
@JvmStatic
private var sharedSseServer: Process? = null
@ -55,12 +48,16 @@ abstract class TsTestBase {
@JvmStatic
private var sharedSsePort: Int = 0
init {
installDependencies(typescriptDir)
}
@JvmStatic
@Synchronized
protected fun getSharedSseUrl(): String {
if (sharedSseServer == null || !sharedSseServer!!.isAlive) {
sharedSsePort = io.modelcontextprotocol.kotlin.test.utils.findFreePort()
val server = TypeScriptServer(tsClientDir)
val server = TypeScriptServer(typescriptDir)
sharedSseServer = server.startSse(sharedSsePort)
println("Shared TypeScript SSE server started on port $sharedSsePort")
@ -76,69 +73,6 @@ abstract class TsTestBase {
return "http://localhost:$sharedSsePort/mcp"
}
@JvmStatic
private val tempRootDir: File = createTempDirectory("typescript-sdk-").toFile().apply { deleteOnExit() }
@JvmStatic
protected val sdkDir: File = File(tempRootDir, "typescript-sdk")
@JvmStatic
@BeforeAll
@Deprecated("Use setupTypeScriptSdkWithDependencies instead")
fun setupTypeScriptSdk() {
println("Cloning TypeScript SDK repository")
if (!sdkDir.exists()) {
val process = ProcessBuilder(
"git",
"clone",
"--depth",
"1",
"https://github.com/modelcontextprotocol/typescript-sdk.git",
sdkDir.absolutePath,
)
.redirectErrorStream(true)
.start()
val exitCode = process.waitFor()
require(exitCode == 0) {
"Failed to clone TypeScript SDK repository: exit code $exitCode"
}
}
println("Installing TypeScript SDK dependencies")
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)
}
}
}
@Deprecated("never patch package.json")
private fun patchPackageJson(dir: File) {
val packageJson = File(dir, "package.json")
if (!packageJson.exists()) return
var content = packageJson.readText()
// Replace "catalog:something" with "*" or a reasonable default if we knew it.
// Since we don't know the exact versions in the catalog, "*" is a risky but possible fallback for tests.
// However, a better approach is to use pnpm if available.
content = content.replace(Regex(""""catalog:[^"]*""""), "\"*\"")
// 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
content = content.replace(Regex(""""link:[^"]*""""), "\"*\"")
packageJson.writeText(content)
}
@JvmStatic
protected fun executeCommand(
command: String,
@ -147,21 +81,21 @@ abstract class TsTestBase {
timeoutSeconds: Long? = null,
): String {
if (!workingDir.exists()) {
if (!workingDir.mkdirs()) {
throw RuntimeException("Failed to create working directory: ${workingDir.absolutePath}")
check(workingDir.mkdirs()) {
"Failed to create working directory: ${workingDir.absolutePath}"
}
}
if (!workingDir.isDirectory || !workingDir.canRead()) {
throw RuntimeException("Working directory is not accessible: ${workingDir.absolutePath}")
check(workingDir.isDirectory && workingDir.canRead()) {
"Working directory is not accessible: ${workingDir.absolutePath}"
}
val processBuilder = if (isWindows) {
ProcessBuilder()
.command("cmd.exe", "/c", "set TYPESCRIPT_SDK_DIR=${sdkDir.absolutePath} && $command")
.command("cmd.exe", "/c", command)
} else {
ProcessBuilder()
.command("bash", "-c", "TYPESCRIPT_SDK_DIR='${sdkDir.absolutePath}' $command")
.command("bash", "-c", command)
}
val process = processBuilder
@ -192,24 +126,18 @@ abstract class TsTestBase {
}
}
private fun waitForProcessTermination(process: Process, timeoutSeconds: Long): Boolean {
io.modelcontextprotocol.kotlin.test.utils.stopProcess(process)
return true
}
protected fun waitForPort(host: String = "localhost", port: Int, timeoutSeconds: Long = 10): Boolean =
io.modelcontextprotocol.kotlin.test.utils.waitForPort(host, port, timeoutSeconds)
protected fun executeCommandAllowingFailure(command: String, workingDir: File, timeoutSeconds: Long = 20): String =
executeCommand(command, workingDir, allowFailure = true, timeoutSeconds = timeoutSeconds)
protected fun startTypeScriptServer(port: Int): Process {
val server = TypeScriptServer(tsClientDir)
return server.startSse(port)
}
protected fun stopProcess(process: Process, waitSeconds: Long = 3, name: String = "TypeScript server") {
io.modelcontextprotocol.kotlin.test.utils.stopProcess(process)
protected fun stopProcess(
process: Process,
waitDuration: Duration = 3.seconds,
name: String = "TypeScript server",
) {
io.modelcontextprotocol.kotlin.test.utils.stopProcess(process, waitDuration)
println("$name stopped")
}
@ -232,7 +160,7 @@ abstract class TsTestBase {
// ===== STDIO client + server helpers =====
protected fun startTypeScriptServerStdio(): Process {
val server = TypeScriptServer(tsClientDir)
val server = TypeScriptServer(typescriptDir)
return server.startStdio()
}
@ -263,22 +191,9 @@ abstract class TsTestBase {
}
// ===== 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 = tsClient.use { client ->
// Start Node stdio client (it will speak MCP over its stdout/stdin)
val subDirName = when (transportKind) {
TransportKind.STDIO -> "stdio"
TransportKind.SSE -> "sse"
}
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",
)
val process = client.startStdio(args.toList(), log = false)
// Create Kotlin server and attach stdio transport to the process streams
val server: Server = KotlinServerForTsClient().createMcpServer()
@ -327,6 +242,6 @@ abstract class TsTestBase {
} catch (_: Exception) {
}
return output.toString()
output.toString()
}
}

View file

@ -0,0 +1,69 @@
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
import io.modelcontextprotocol.kotlin.test.utils.TypeScriptRunner
import io.modelcontextprotocol.kotlin.test.utils.stopProcess
import java.io.File
/**
* Component for starting and managing TypeScript MCP clients for integration tests.
*
* This class provides methods to start TypeScript clients using SSE or STDIO transports.
* The clients are started using npx tsx to execute TypeScript files directly.
*/
class TypeScriptClient(private val typescriptDir: File) : AutoCloseable {
private var process: Process? = null
/**
* Starts a TypeScript MCP client using SSE transport.
*
* @param arguments Arguments to pass to the client (typically server URL, tool name, and tool args)
* @param log Whether to automatically log process output/error
* @return The started Process
*/
fun startSse(arguments: List<String>, log: Boolean = true): Process {
val scriptPath = File(typescriptDir, "client/sse-client.ts").absolutePath
val proc = TypeScriptRunner.run(
typescriptDir = typescriptDir,
scriptPath = scriptPath,
arguments = arguments,
env = emptyMap(),
redirectErrorStream = false,
logPrefix = "TS-CLIENT-SSE",
log = log,
)
process = proc
return proc
}
/**
* Starts a TypeScript MCP client using STDIO transport.
*
* @param arguments Arguments to pass to the client (typically tool name and tool args)
* @param log Whether to automatically log process output/error
* @return The started Process
*/
fun startStdio(arguments: List<String>, log: Boolean = true): Process {
val scriptPath = File(typescriptDir, "client/stdio-client.ts").absolutePath
val proc = TypeScriptRunner.run(
typescriptDir = typescriptDir,
scriptPath = scriptPath,
arguments = arguments,
env = emptyMap(),
redirectErrorStream = false,
logPrefix = "TS-CLIENT-STDIO",
log = log,
)
process = proc
return proc
}
/**
* Stops the currently running client process.
*/
override fun close() {
process?.let {
stopProcess(it)
}
process = null
}
}

View file

@ -25,7 +25,6 @@ class KotlinClientTsServerEdgeCasesTestSse : TsTestBase() {
override val transportKind = TransportKind.SSE
private val host = "localhost"
private val serverUrl: String by lazy { getSharedSseUrl() }
private lateinit var client: Client

View file

@ -2,13 +2,11 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractTsClientKotlinServerTest
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
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.BeforeEach
@DisabledOnCI
class TsClientKotlinServerTestSse : AbstractTsClientKotlinServerTest() {
override val transportKind = TransportKind.SSE
@ -37,18 +35,16 @@ class TsClientKotlinServerTestSse : AbstractTsClientKotlinServerTest() {
}
}
override fun beforeServer() {}
override fun afterServer() {}
override fun runClient(vararg args: String): String {
val cmd = buildString {
append("npx tsx myClient.ts ")
append(serverUrl)
if (args.isNotEmpty()) {
append(' ')
append(args.joinToString(" "))
override fun runClient(vararg args: String): String = tsClient.use { client ->
val process = client.startSse(listOf(serverUrl) + args.toList(), log = true)
val output = StringBuilder()
process.inputStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
println("[TS-CLIENT-SSE] $line")
output.append(line).append("\n")
}
}
return executeCommand(cmd, tsClientDir)
process.waitFor(25, java.util.concurrent.TimeUnit.SECONDS)
output.toString()
}
}

View file

@ -2,18 +2,18 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase
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.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Timeout
import org.junit.jupiter.api.condition.EnabledOnOs
import org.junit.jupiter.api.condition.DisabledOnOs
import org.junit.jupiter.api.condition.OS
import java.io.File
import java.util.concurrent.TimeUnit
@ -21,7 +21,6 @@ import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.fail
@DisabledOnCI
class TsEdgeCasesTestSse : TsTestBase() {
override val transportKind = TransportKind.SSE
@ -56,16 +55,16 @@ class TsEdgeCasesTestSse : TsTestBase() {
@Test
@Timeout(30, unit = TimeUnit.SECONDS)
fun testInvalidURL() = runTest {
val nonExistentToolCommand = "npx tsx myClient.ts $serverUrl non-existent-tool"
val nonExistentToolOutput = executeCommandAllowingFailure(nonExistentToolCommand, tsClientDir)
val nonExistentToolCommand = "npx tsx client/sse-client.ts $serverUrl non-existent-tool"
val nonExistentToolOutput = executeCommandAllowingFailure(nonExistentToolCommand, typescriptDir)
assertTrue(
nonExistentToolOutput.contains("Tool \"non-existent-tool\" not found"),
"Client should handle non-existent tool gracefully",
)
val invalidUrlCommand = "npx tsx myClient.ts http://localhost:${port + 1000}/mcp greet TestUser"
val invalidUrlOutput = executeCommandAllowingFailure(invalidUrlCommand, tsClientDir)
val invalidUrlCommand = "npx tsx client/sse-client.ts http://localhost:${port + 1000}/mcp greet TestUser"
val invalidUrlOutput = executeCommandAllowingFailure(invalidUrlCommand, typescriptDir)
assertTrue(
invalidUrlOutput.contains("Invalid URL") ||
@ -85,8 +84,8 @@ class TsEdgeCasesTestSse : TsTestBase() {
tempFile.deleteOnExit()
val specialCharsContent = tempFile.readText()
val specialCharsCommand = "npx tsx myClient.ts $serverUrl greet \"$specialCharsContent\""
val specialCharsOutput = executeCommand(specialCharsCommand, tsClientDir)
val specialCharsCommand = "npx tsx client/sse-client.ts $serverUrl greet \"$specialCharsContent\""
val specialCharsOutput = executeCommand(specialCharsCommand, typescriptDir)
assertTrue(
specialCharsOutput.contains("Hello, $specialChars!"),
@ -100,8 +99,8 @@ class TsEdgeCasesTestSse : TsTestBase() {
// skip on windows as it can't handle long commands
@Test
@Timeout(30, unit = TimeUnit.SECONDS)
@EnabledOnOs(OS.MAC, OS.LINUX)
@Timeout(40, unit = TimeUnit.SECONDS)
@DisabledOnOs(OS.WINDOWS)
fun testLargePayload() = runTest {
val largeName = "A".repeat(10 * 1024)
@ -110,8 +109,8 @@ class TsEdgeCasesTestSse : TsTestBase() {
tempFile.deleteOnExit()
val largeNameContent = tempFile.readText()
val largePayloadCommand = "npx tsx myClient.ts $serverUrl greet \"$largeNameContent\""
val largePayloadOutput = executeCommand(largePayloadCommand, tsClientDir)
val largePayloadCommand = "npx tsx client/sse-client.ts $serverUrl greet \"$largeNameContent\""
val largePayloadOutput = executeCommand(largePayloadCommand, typescriptDir)
tempFile.delete()
@ -126,8 +125,8 @@ class TsEdgeCasesTestSse : TsTestBase() {
}
@Test
@Timeout(60, unit = TimeUnit.SECONDS)
fun testComplexConcurrentRequests() = runTest {
@Timeout(30, unit = TimeUnit.SECONDS)
fun testComplexConcurrentRequests(): Unit = runBlocking {
fun prettyFail(
index: Int,
command: String,
@ -158,18 +157,18 @@ class TsEdgeCasesTestSse : TsTestBase() {
}
val commands = listOf(
"npx tsx myClient.ts $serverUrl greet \"Client1\"",
"npx tsx myClient.ts $serverUrl multi-greet \"Client2\"",
"npx tsx myClient.ts $serverUrl greet \"Client3\"",
"npx tsx myClient.ts $serverUrl",
"npx tsx myClient.ts $serverUrl multi-greet \"Client5\"",
"npx tsx client/sse-client.ts $serverUrl greet \"Client1\"",
"npx tsx client/sse-client.ts $serverUrl multi-greet \"Client2\"",
"npx tsx client/sse-client.ts $serverUrl greet \"Client3\"",
"npx tsx client/sse-client.ts $serverUrl",
"npx tsx client/sse-client.ts $serverUrl multi-greet \"Client5\"",
)
coroutineScope {
val jobs = commands.mapIndexed { index, command ->
async(kotlinx.coroutines.Dispatchers.IO) {
println("Starting client $index")
val output = executeCommand(command, tsClientDir)
val output = executeCommand(command, typescriptDir)
println("Client $index completed")
assertContains(
@ -241,10 +240,10 @@ class TsEdgeCasesTestSse : TsTestBase() {
@Test
@Timeout(120, unit = TimeUnit.SECONDS)
fun testRapidSequentialRequests() = runTest {
fun testRapidSequentialRequests() = runBlocking {
val outputs = (1..10).map { i ->
val command = "npx tsx myClient.ts $serverUrl greet \"RapidClient$i\""
val output = executeCommand(command, tsClientDir)
val command = "npx tsx client/sse-client.ts $serverUrl greet \"RapidClient$i\""
val output = executeCommand(command, typescriptDir)
assertTrue(
output.contains("Connected to server"),

View file

@ -2,9 +2,7 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.stdio
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractTsClientKotlinServerTest
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI
@DisabledOnCI
class TsClientKotlinServerTestStdio : AbstractTsClientKotlinServerTest() {
override val transportKind = TransportKind.STDIO
override fun runClient(vararg args: String): String = runStdioClient(*args)

View file

@ -2,7 +2,6 @@ package io.modelcontextprotocol.kotlin.sdk.integration.typescript.stdio
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase
import io.modelcontextprotocol.kotlin.test.utils.DisabledOnCI
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Timeout
import org.junit.jupiter.api.condition.EnabledOnOs
@ -12,7 +11,6 @@ import java.util.concurrent.TimeUnit
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@DisabledOnCI
class TsEdgeCasesTestStdio : TsTestBase() {
override val transportKind: TransportKind = TransportKind.STDIO

View file

@ -1,6 +1,7 @@
package io.modelcontextprotocol.kotlin.sdk.server
import io.modelcontextprotocol.kotlin.sdk.types.Method
import io.kotest.matchers.collections.shouldHaveSize
import io.modelcontextprotocol.kotlin.sdk.types.Method.Defined.NotificationsResourcesUpdated
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
import io.modelcontextprotocol.kotlin.sdk.types.ResourceUpdatedNotification
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
@ -8,13 +9,19 @@ import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequest
import io.modelcontextprotocol.kotlin.sdk.types.SubscribeRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.awaitility.kotlin.await
import org.awaitility.kotlin.untilAsserted
import org.junit.jupiter.api.Test
import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
class ServerResourcesNotificationSubscribeTest : AbstractServerFeaturesTest() {
override fun getServerCapabilities(): ServerCapabilities = ServerCapabilities(
@ -22,159 +29,107 @@ class ServerResourcesNotificationSubscribeTest : AbstractServerFeaturesTest() {
)
@Test
fun `removeResource should send resource update notification`() = runTest {
val notifications = mutableListOf<ResourceUpdatedNotification>()
client.setNotificationHandler<ResourceUpdatedNotification>(Method.Defined.NotificationsResourcesUpdated) {
fun `should send resource notifications`() = runBlocking {
val notifications = ConcurrentLinkedQueue<ResourceUpdatedNotification>()
client.setNotificationHandler<ResourceUpdatedNotification>(NotificationsResourcesUpdated) {
notifications.add(it)
CompletableDeferred(Unit)
}
// Add resources
val testResourceUri1 = "test://resource1"
val testResourceUri2 = "test://resource2"
server.addResource(
uri = testResourceUri1,
name = "Test Resource 1",
description = "A test resource 1",
mimeType = "text/plain",
) {
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = "Test resource content 1",
uri = testResourceUri1,
mimeType = "text/plain",
),
),
)
}
server.addResource(
uri = testResourceUri2,
name = "Test Resource 2",
description = "A test resource 2",
mimeType = "text/plain",
) {
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = "Test resource content 2",
uri = testResourceUri2,
mimeType = "text/plain",
),
),
)
}
client.subscribeResource(SubscribeRequest(SubscribeRequestParams(uri = testResourceUri1)))
// Remove the resource
val result = server.removeResource(testResourceUri1)
// Verify the resource was removed
assertTrue(result, "Resource should be removed successfully")
// Verify that the notification was sent
await untilAsserted {
assertTrue(
notifications.any { it.params.uri == testResourceUri1 },
"Notification should be sent when resource 1 was deleted",
)
}
}
@Test
fun `removeResource for two resources should send two separate notifications`() = runTest {
val notifications = mutableListOf<ResourceUpdatedNotification>()
client.setNotificationHandler<ResourceUpdatedNotification>(Method.Defined.NotificationsResourcesUpdated) {
notifications.add(it)
CompletableDeferred(Unit)
}
// Add resources
val testResourceUri1 = "test://resource1"
val testResourceUri2 = "test://resource2"
server.addResource(
uri = testResourceUri1,
name = "Test Resource 1",
description = "A test resource 1",
mimeType = "text/plain",
) {
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = "Test resource content 1",
uri = testResourceUri1,
mimeType = "text/plain",
),
),
)
}
server.addResource(
uri = testResourceUri2,
name = "Test Resource 2",
description = "A test resource 2",
mimeType = "text/plain",
) {
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = "Test resource content 2",
uri = testResourceUri2,
mimeType = "text/plain",
),
),
)
}
val testResourceUri1 = "test://resource1-${Uuid.random()}"
val testResourceUri2 = "test://resource2-${Uuid.random()}"
client.subscribeResource(SubscribeRequest(SubscribeRequestParams(uri = testResourceUri1)))
client.subscribeResource(SubscribeRequest(SubscribeRequestParams(uri = testResourceUri2)))
// Remove the resource
val result1 = server.removeResource(testResourceUri1)
val result2 = server.removeResource(testResourceUri2)
// Verify the resource was removed
assertTrue(result1, "Resource 1 should be removed successfully")
assertTrue(result2, "Resource 2 should be removed successfully")
println(notifications.map { it.params.uri })
// Verify that the notification was sent
await untilAsserted {
assertTrue(
notifications.any { it.params.uri == testResourceUri1 },
"Notification should be sent when resource 1 and resource 2 was deleted",
server.addResource(
uri = testResourceUri1,
name = "Test Resource 1",
description = "A test resource 1",
mimeType = "text/plain",
) {
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = "Test resource content 1",
uri = it.uri,
mimeType = "text/plain",
),
),
)
}
val deletedResources = listOf(notifications[0].params.uri, notifications[1].params.uri)
assertTrue(
deletedResources.contains(testResourceUri1),
"Notification should contain the removed resource 1 URI",
)
assertTrue(
deletedResources.contains(testResourceUri2),
"Notification should contain the removed resource 2 URI",
)
server.addResource(
uri = testResourceUri2,
name = "Test Resource 2",
description = "A test resource 2",
mimeType = "text/plain",
) {
ReadResourceResult(
contents = listOf(
TextResourceContents(
text = "Test resource content 2",
uri = it.uri,
mimeType = "text/plain",
),
),
)
}
// Verify that the notification was sent
await untilAsserted {
notifications.shouldHaveSize(2)
assertTrue(
notifications.any { it.params.uri == testResourceUri1 && it.method == NotificationsResourcesUpdated },
"Notification should be sent when resource 1 was deleted",
)
assertTrue(
notifications.any { it.params.uri == testResourceUri2 && it.method == NotificationsResourcesUpdated },
"Notification should be sent when resource 2 was deleted",
)
}
notifications.clear()
// Remove the resource
val result1 = server.removeResource(testResourceUri1)
assertTrue(result1, "Resource 1 should be removed successfully")
val result2 = server.removeResource(testResourceUri2)
// Verify the resource was removed
assertTrue(result2, "Resource 2 should be removed successfully")
// Verify that the notification was sent
await untilAsserted {
notifications.shouldHaveSize(2)
assertTrue(
notifications.any { it.params.uri == testResourceUri1 && it.method == NotificationsResourcesUpdated },
"Notification should be sent when resource 1 was deleted",
)
assertTrue(
notifications.any { it.params.uri == testResourceUri2 && it.method == NotificationsResourcesUpdated },
"Notification should be sent when resource 2 was deleted",
)
}
}
@Test
fun `notification should not be send when removed resource does not exists`() = runTest {
fun `notification should not be send when removed resource does not exists`() = runBlocking {
println("Thread ${Thread.currentThread().name} test 1")
// Track notifications
val notifications = mutableListOf<ResourceUpdatedNotification>()
client.setNotificationHandler<ResourceUpdatedNotification>(Method.Defined.NotificationsResourcesUpdated) {
val notifications = ConcurrentLinkedQueue<ResourceUpdatedNotification>()
client.setNotificationHandler<ResourceUpdatedNotification>(NotificationsResourcesUpdated) {
notifications.add(it)
CompletableDeferred(Unit)
}
// Try to remove a non-existent resource
val result = server.removeResource("non-existent-resource")
assertFalse(result, "Removing non-existent resource should return false")
// Verify the result
assertFalse(result, "Removing non-existent resource should return false")
delay(1.seconds)
assertTrue(
notifications.isEmpty(),
"No notification should be sent when resource doesn't exist",

View file

@ -0,0 +1,6 @@
## https://docs.junit.org/5.3.0-M1/user-guide/index.html#writing-tests-parallel-execution
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.mode.classes.default=concurrent
junit.jupiter.execution.timeout.default=2m

View file

@ -0,0 +1,108 @@
import {Client} from '@modelcontextprotocol/sdk/client/index';
import {StreamableHTTPClientTransport} from '@modelcontextprotocol/sdk/client/streamableHttp';
const args = process.argv.slice(2);
const serverUrl = args[0] || 'http://localhost:3001/mcp';
const toolName = args[1];
const toolArgs = args.slice(2);
const PROTOCOL_VERSION = "2024-11-05";
async function main() {
if (!toolName) {
console.log('Usage: npx tsx client/sse-client.ts [server-url] <tool-name> [tool-args...]');
console.log('Using default server URL:', serverUrl);
console.log('Available utils will be listed after connection');
}
console.log(`Connecting to server at ${serverUrl}`);
if (toolName) {
console.log(`Will call tool: ${toolName} with args: ${toolArgs.join(', ')}`);
}
const client = new Client({
name: 'test-client',
version: '1.0.0'
});
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
try {
await client.connect(transport, {protocolVersion: PROTOCOL_VERSION});
console.log('Connected to server');
try {
if (typeof (client as any).on === 'function') {
(client as any).on('notification', (n: any) => {
try {
const method = (n && (n.method || (n.params && n.params.method))) || 'unknown';
console.log('Notification:', method, JSON.stringify(n));
} catch {
console.log('Notification: <unparsable>');
}
});
}
} catch {
}
const toolsResult = await client.listTools();
const tools = toolsResult.tools;
console.log('Available utils:', tools.map((t: { name: any; }) => t.name).join(', '));
if (!toolName) {
await client.close();
return;
}
const tool = tools.find((t: { name: string; }) => t.name === toolName);
if (!tool) {
console.error(`Tool "${toolName}" not found`);
// @ts-ignore
process.exit(1);
}
const toolArguments = {};
if (toolName === "greet" && toolArgs.length > 0) {
toolArguments["name"] = toolArgs[0];
} else if (tool.inputSchema && tool.inputSchema.properties) {
const propNames = Object.keys(tool.inputSchema.properties);
if (propNames.length > 0 && toolArgs.length > 0) {
toolArguments[propNames[0]] = toolArgs[0];
}
}
console.log(`Calling tool ${toolName} with arguments:`, toolArguments);
const result = await client.callTool({
name: toolName,
arguments: toolArguments
});
console.log('Tool result:', result);
if (result.content) {
for (const content of result.content) {
if (content.type === 'text') {
console.log('Text content:', content.text);
}
}
}
if (result.structuredContent) {
console.log('Structured content:', JSON.stringify(result.structuredContent, null, 2));
}
} catch (error) {
console.error('Error:', error);
// @ts-ignore
process.exit(1);
} finally {
await client.close();
console.log('Disconnected from server');
}
}
main().catch(error => {
console.error('Unhandled error:', error);
// @ts-ignore
process.exit(1);
});

View file

@ -0,0 +1,149 @@
import {Client} from '@modelcontextprotocol/sdk/client/index';
const args = process.argv.slice(2);
const toolName = args[0];
const toolArgs = args.slice(1);
const PROTOCOL_VERSION = "2024-11-05";
async function main() {
if (!toolName) {
console.error('Available utils will be listed after connection');
} else {
console.error(`Will call tool: ${toolName} with args: ${toolArgs.join(', ')}`);
}
class MinimalStdioClientTransport {
onmessage: ((msg: any) => void) | undefined;
private buffer: string = '';
private closed = false;
async start(): Promise<void> {
process.stdin.setEncoding('utf8');
process.stdin.resume();
process.stdin.on('data', (chunk: string) => {
if (this.closed) return;
this.buffer += chunk;
this.processBuffer();
});
}
private processBuffer() {
while (true) {
const idx = this.buffer.indexOf('\n');
if (idx === -1) break;
const line = this.buffer.slice(0, idx);
this.buffer = this.buffer.slice(idx + 1);
const trimmed = line.trim();
if (!trimmed) continue;
try {
const msg = JSON.parse(trimmed);
this.onmessage && this.onmessage(msg);
} catch (e) {
console.error('Parse error in client stdio (line):', trimmed, e);
}
}
}
async send(msg: any): Promise<void> {
if (this.closed) return;
const json = JSON.stringify(msg);
const payload = json + '\n';
await new Promise<void>((resolve, reject) => {
process.stdout.write(payload, 'utf8', (err?: Error | null) => err ? reject(err) : resolve());
});
}
async close(): Promise<void> {
this.closed = true;
try {
process.stdin.pause();
} catch {
}
}
}
const client = new Client({
name: 'test-client',
version: '1.0.0'
});
const transport = new MinimalStdioClientTransport();
try {
await client.connect(transport, {protocolVersion: PROTOCOL_VERSION});
console.error('Connected to server over stdio');
try {
if (typeof (client as any).on === 'function') {
(client as any).on('notification', (n: any) => {
try {
const method = (n && (n.method || (n.params && n.params.method))) || 'unknown';
console.error('Notification:', method, JSON.stringify(n));
} catch {
console.error('Notification: <unparsable>');
}
});
}
} catch {
// ignore
}
const toolsResult = await client.listTools();
const tools = toolsResult.tools;
console.error('Available utils:', tools.map((t: { name: any; }) => t.name).join(', '));
if (!toolName) {
await client.close();
return;
}
const tool = tools.find((t: { name: string; }) => t.name === toolName);
if (!tool) {
console.error(`Tool "${toolName}" not found`);
process.exit(1);
}
const toolArguments: any = {};
if (toolName === 'greet' && toolArgs.length > 0) {
toolArguments['name'] = toolArgs[0];
} else if (tool.inputSchema && tool.inputSchema.properties) {
const propNames = Object.keys(tool.inputSchema.properties);
if (propNames.length > 0 && toolArgs.length > 0) {
toolArguments[propNames[0]] = toolArgs[0];
}
}
console.error(`Calling tool ${toolName} with arguments:`, toolArguments);
const result = await client.callTool({
name: toolName,
arguments: toolArguments
});
console.error('Tool result:', JSON.stringify(result));
if (result.content) {
for (const content of result.content) {
if (content.type === 'text') {
console.error('Text content:', content.text);
}
}
}
if (result.structuredContent) {
console.error('Structured content:', JSON.stringify(result.structuredContent, null, 2));
}
} catch (error) {
console.error('Error:', error);
process.exit(1);
} finally {
await client.close();
console.error('Disconnected from server');
}
}
main().catch(error => {
console.error('Unhandled error:', error);
process.exit(1);
});

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