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:
parent
658b370c87
commit
6ad9bf2122
21 changed files with 419 additions and 441 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
108
integration-test/src/jvmTest/typescript/client/sse-client.ts
Normal file
108
integration-test/src/jvmTest/typescript/client/sse-client.ts
Normal 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);
|
||||
});
|
||||
149
integration-test/src/jvmTest/typescript/client/stdio-client.ts
Normal file
149
integration-test/src/jvmTest/typescript/client/stdio-client.ts
Normal 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
Loading…
Reference in a new issue