refactor(integration-test): modernize ts tests, update versions, rename SSE -> HTTP (#618)
- add tsconfig - fix tsignore - rename sse -> http - clean up code ## How Has This Been Tested? all tests passed ## Breaking Changes none ## Types of changes - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist - [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 - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed
This commit is contained in:
parent
558399039c
commit
435698a611
22 changed files with 169 additions and 437 deletions
|
|
@ -39,7 +39,6 @@ kotlin {
|
|||
forkEvery = 100
|
||||
testLogging {
|
||||
exceptionFormat = TestExceptionFormat.SHORT
|
||||
showStandardStreams = true
|
||||
events("failed")
|
||||
}
|
||||
systemProperty("kotest.output.ansi", "true")
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ complexity:
|
|||
LongMethod:
|
||||
excludes: *testFolders
|
||||
|
||||
coroutines:
|
||||
InjectDispatcher:
|
||||
excludes: *testFolders
|
||||
|
||||
empty-blocks:
|
||||
EmptyFunctionBlock:
|
||||
excludes: *testFolders
|
||||
|
|
|
|||
|
|
@ -8,24 +8,10 @@
|
|||
<ID>AbstractClassCanBeConcreteClass:BaseTransportTest.kt:BaseTransportTest$BaseTransportTest</ID>
|
||||
<ID>CyclomaticComplexMethod:AbstractToolIntegrationTest.kt:AbstractToolIntegrationTest$private fun setupCalculatorTool</ID>
|
||||
<ID>ForbiddenComment:StdioClientTransportTest.kt:StdioClientTransportTest$// TODO: fix running on windows</ID>
|
||||
<ID>InjectDispatcher:AbstractKotlinClientTsServerTest.kt:AbstractKotlinClientTsServerTest$IO</ID>
|
||||
<ID>InjectDispatcher:AbstractPromptIntegrationTest.kt:AbstractPromptIntegrationTest$IO</ID>
|
||||
<ID>InjectDispatcher:AbstractResourceIntegrationTest.kt:AbstractResourceIntegrationTest$IO</ID>
|
||||
<ID>InjectDispatcher:AbstractToolIntegrationTest.kt:AbstractToolIntegrationTest$IO</ID>
|
||||
<ID>InjectDispatcher:KotlinClientTsServerEdgeCasesTestSse.kt:KotlinClientTsServerEdgeCasesTestSse$IO</ID>
|
||||
<ID>InjectDispatcher:KotlinClientTsServerEdgeCasesTestStdio.kt:KotlinClientTsServerEdgeCasesTestStdio$IO</ID>
|
||||
<ID>InjectDispatcher:SseIntegrationTest.kt:SseIntegrationTest$IO</ID>
|
||||
<ID>InjectDispatcher:StdioClientTransportTest.kt:StdioClientTransportTest$IO</ID>
|
||||
<ID>InjectDispatcher:StreamableHttpIntegrationTest.kt:StreamableHttpIntegrationTest$IO</ID>
|
||||
<ID>InjectDispatcher:TsEdgeCasesTestSse.kt:TsEdgeCasesTestSse$IO</ID>
|
||||
<ID>InjectDispatcher:WebSocketIntegrationTest.kt:WebSocketIntegrationTest$IO</ID>
|
||||
<ID>MatchingDeclarationName:PromptIntegrationTestSse.kt:SchemaPromptIntegrationTestSse : AbstractPromptIntegrationTest</ID>
|
||||
<ID>SleepInsteadOfDelay:KotlinServerForTsClientSse.kt:KotlinServerForTsClient$sleep(500)</ID>
|
||||
<ID>ThrowsCount:AbstractPromptIntegrationTest.kt:AbstractPromptIntegrationTest$override fun configureServer</ID>
|
||||
<ID>UnusedPrivateProperty:KotlinClientTsServerTestSse.kt:KotlinClientTsServerTestSse$private val host = "localhost"</ID>
|
||||
<ID>UseCheckOrError:ClientTest.kt:ClientTest.<no name provided>$throw IllegalStateException("Test error")</ID>
|
||||
<ID>UseOrEmpty:AbstractToolIntegrationTest.kt:AbstractToolIntegrationTest$(request.params.arguments?.get("tags") as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.content } ?: emptyList()</ID>
|
||||
<ID>UseRequire:AbstractPromptIntegrationTest.kt:AbstractPromptIntegrationTest$throw IllegalArgumentException("Missing required argument: $argName")</ID>
|
||||
<ID>VarCouldBeVal:KotlinClientTsServerEdgeCasesTestSse.kt:KotlinClientTsServerEdgeCasesTestSse$private lateinit var client: Client</ID>
|
||||
</CurrentIssues>
|
||||
</SmellBaseline>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ import io.ktor.server.sse.SSE as ServerSSE
|
|||
/**
|
||||
* Base class for MCP authentication integration tests.
|
||||
*/
|
||||
@Suppress("InjectDispatcher")
|
||||
abstract class AbstractAuthenticationTest {
|
||||
|
||||
protected companion object {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Timeout
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
abstract class AbstractKotlinClientTsServerEdgeCasesTest : TsTestBase() {
|
||||
|
||||
protected abstract suspend fun <T> useClient(block: suspend (Client) -> T): T
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testNonExistentTool(): Unit = runBlocking(Dispatchers.IO) {
|
||||
useClient { client ->
|
||||
val nonExistentToolName = "non-existent-tool"
|
||||
val arguments = mapOf("name" to "TestUser")
|
||||
|
||||
val result = client.callTool(nonExistentToolName, arguments)
|
||||
assertNotNull(result, "Tool call result should not be null")
|
||||
|
||||
assertTrue(result.isError ?: false, "isError should be true for non-existent tool")
|
||||
|
||||
val textContent = result.content.firstOrNull { it is TextContent } as? TextContent
|
||||
assertNotNull(textContent, "Error content should be present in the result")
|
||||
|
||||
val errorText = textContent.text
|
||||
assertTrue(
|
||||
errorText.contains("non-existent-tool") && errorText.contains("not found"),
|
||||
"Error message should indicate the tool was not found",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testSpecialCharactersInArguments(): Unit = runBlocking(Dispatchers.IO) {
|
||||
useClient { client ->
|
||||
val specialChars = "!@#$%^&*()_+{}[]|\\:;\"'<>,.?/"
|
||||
val arguments = mapOf("name" to specialChars)
|
||||
|
||||
val result = client.callTool("greet", arguments)
|
||||
assertNotNull(result, "Tool call result should not be null")
|
||||
|
||||
val textContent = result.content.firstOrNull { it is TextContent } as? TextContent
|
||||
assertNotNull(textContent, "Text content should be present in the result")
|
||||
|
||||
val text = textContent.text
|
||||
assertTrue(
|
||||
text.contains(specialChars),
|
||||
"Tool response should contain the special characters",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testLargePayload(): Unit = runBlocking(Dispatchers.IO) {
|
||||
useClient { client ->
|
||||
val largeName = "A".repeat(10 * 1024)
|
||||
val arguments = mapOf("name" to largeName)
|
||||
|
||||
val result = client.callTool("greet", arguments)
|
||||
assertNotNull(result, "Tool call result should not be null")
|
||||
|
||||
val textContent = result.content.firstOrNull { it is TextContent } as? TextContent
|
||||
assertNotNull(textContent, "Text content should be present in the result")
|
||||
|
||||
val text = textContent.text
|
||||
assertTrue(
|
||||
text.contains("Hello,") && text.contains("A"),
|
||||
"Tool response should contain the greeting with the large name",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(60, unit = TimeUnit.SECONDS)
|
||||
fun testConcurrentRequests(): Unit = runBlocking(Dispatchers.IO) {
|
||||
useClient { client ->
|
||||
val concurrentCount = 5
|
||||
val responses = coroutineScope {
|
||||
val results = (1..concurrentCount).map { i ->
|
||||
async {
|
||||
val name = "ConcurrentClient$i"
|
||||
val arguments = mapOf("name" to name)
|
||||
|
||||
val result = client.callTool("greet", arguments)
|
||||
assertNotNull(result, "Tool call result should not be null for client $i")
|
||||
|
||||
val textContent = result.content.firstOrNull { it is TextContent } as? TextContent
|
||||
assertNotNull(textContent, "Text content should be present for client $i")
|
||||
|
||||
textContent.text
|
||||
}
|
||||
}
|
||||
results.awaitAll()
|
||||
}
|
||||
|
||||
for (i in 1..concurrentCount) {
|
||||
val expectedName = "ConcurrentClient$i"
|
||||
val matchingResponses = responses.filter { it.contains("Hello, $expectedName!") }
|
||||
assertEquals(
|
||||
1,
|
||||
matchingResponses.size,
|
||||
"Should have exactly one response for $expectedName",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testInvalidArguments(): Unit = runBlocking(Dispatchers.IO) {
|
||||
useClient { client ->
|
||||
val invalidArguments = mapOf(
|
||||
"name" to JsonObject(mapOf("nested" to JsonPrimitive("value"))),
|
||||
)
|
||||
|
||||
val result = client.callTool("greet", invalidArguments)
|
||||
assertNotNull(result, "Tool call result should not be null")
|
||||
|
||||
assertTrue(result.isError ?: false, "isError should be true for invalid arguments")
|
||||
|
||||
val textContent = result.content.firstOrNull { it is TextContent } as? TextContent
|
||||
assertNotNull(textContent, "Error content should be present in the result")
|
||||
|
||||
val errorText = textContent.text
|
||||
assertTrue(
|
||||
errorText.contains("Invalid arguments") && errorText.contains("greet"),
|
||||
"Error message should indicate invalid arguments for tool greet",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testMultipleToolCalls(): Unit = runBlocking(Dispatchers.IO) {
|
||||
useClient { client ->
|
||||
repeat(10) { i ->
|
||||
val name = "SequentialClient$i"
|
||||
val arguments = mapOf("name" to name)
|
||||
|
||||
val result = client.callTool("greet", arguments)
|
||||
assertNotNull(result, "Tool call result should not be null for call $i")
|
||||
|
||||
val textContent = result.content.firstOrNull { it is TextContent } as? TextContent
|
||||
assertNotNull(textContent, "Text content should be present for call $i")
|
||||
|
||||
assertEquals(
|
||||
"Hello, $name!",
|
||||
textContent.text,
|
||||
"Tool response should contain the greeting with the provided name",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import io.ktor.client.plugins.sse.SSE
|
|||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.mcpStreamableHttp
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.sse.KotlinServerForTsClient
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.http.KotlinServerForTsClient
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.Server
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
|
|
@ -26,7 +26,7 @@ import java.util.concurrent.TimeUnit
|
|||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
enum class TransportKind { SSE, STDIO }
|
||||
enum class TransportKind { HTTP, STDIO }
|
||||
|
||||
@Retry(times = 3)
|
||||
abstract class TsTestBase {
|
||||
|
|
@ -43,10 +43,10 @@ abstract class TsTestBase {
|
|||
protected val typescriptDir = File(projectRoot, "src/jvmTest/typescript")
|
||||
|
||||
@JvmStatic
|
||||
private var sharedSseServer: Process? = null
|
||||
private var sharedHttpServer: Process? = null
|
||||
|
||||
@JvmStatic
|
||||
private var sharedSsePort: Int = 0
|
||||
private var sharedHttpPort: Int = 0
|
||||
|
||||
init {
|
||||
installDependencies(typescriptDir)
|
||||
|
|
@ -54,23 +54,23 @@ abstract class TsTestBase {
|
|||
|
||||
@JvmStatic
|
||||
@Synchronized
|
||||
protected fun getSharedSseUrl(): String {
|
||||
if (sharedSseServer == null || !sharedSseServer!!.isAlive) {
|
||||
sharedSsePort = io.modelcontextprotocol.kotlin.test.utils.findFreePort()
|
||||
protected fun getSharedHttpUrl(): String {
|
||||
if (sharedHttpServer == null || !sharedHttpServer!!.isAlive) {
|
||||
sharedHttpPort = io.modelcontextprotocol.kotlin.test.utils.findFreePort()
|
||||
val server = TypeScriptServer(typescriptDir)
|
||||
sharedSseServer = server.startSse(sharedSsePort)
|
||||
println("Shared TypeScript SSE server started on port $sharedSsePort")
|
||||
sharedHttpServer = server.startHttp(sharedHttpPort)
|
||||
println("Shared TypeScript HTTP server started on port $sharedHttpPort")
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(
|
||||
Thread {
|
||||
sharedSseServer?.let {
|
||||
println("Stopping shared TypeScript SSE server")
|
||||
sharedHttpServer?.let {
|
||||
println("Stopping shared TypeScript HTTP server")
|
||||
io.modelcontextprotocol.kotlin.test.utils.stopProcess(it)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
return "http://localhost:$sharedSsePort/mcp"
|
||||
return "http://localhost:$sharedHttpPort/mcp"
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
|
@ -141,7 +141,7 @@ abstract class TsTestBase {
|
|||
println("$name stopped")
|
||||
}
|
||||
|
||||
// ===== SSE client helpers =====
|
||||
// ===== HTTP client helpers =====
|
||||
protected suspend fun newClient(serverUrl: String): Client =
|
||||
HttpClient(CIO) { install(SSE) }.mcpStreamableHttp(serverUrl)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,28 +7,28 @@ 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.
|
||||
* This class provides methods to start TypeScript clients using HTTP 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.
|
||||
* Starts a TypeScript MCP client using Streamable HTTP 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
|
||||
fun startHttp(arguments: List<String>, log: Boolean = true): Process {
|
||||
val scriptPath = File(typescriptDir, "client/http-client.ts").absolutePath
|
||||
val proc = TypeScriptRunner.run(
|
||||
typescriptDir = typescriptDir,
|
||||
scriptPath = scriptPath,
|
||||
arguments = arguments,
|
||||
env = emptyMap(),
|
||||
redirectErrorStream = false,
|
||||
logPrefix = "TS-CLIENT-SSE",
|
||||
logPrefix = "TS-CLIENT-HTTP",
|
||||
log = log,
|
||||
)
|
||||
process = proc
|
||||
|
|
|
|||
|
|
@ -12,23 +12,23 @@ import java.util.concurrent.TimeUnit
|
|||
* Factory class for starting and managing TypeScript MCP servers for integration tests.
|
||||
*
|
||||
* This class provides methods to:
|
||||
* - Start TypeScript servers using SSE or STDIO transports via npx tsx
|
||||
* - Start TypeScript servers using HTTP or STDIO transports via npx tsx
|
||||
* - Install npm dependencies in the TypeScript test directory
|
||||
* - Manage server lifecycle (start/stop)
|
||||
* - Handle port management and process cleanup
|
||||
*
|
||||
* The servers are started using npx tsx to execute TypeScript files directly:
|
||||
* - `npx tsx server/stdio-server.ts` for STDIO transport
|
||||
* - `npx tsx server/sse-server.ts` for SSE transport
|
||||
* - `npx tsx server/http-server.ts` for HTTP transport
|
||||
*
|
||||
* Usage:
|
||||
* ```kotlin
|
||||
* val typescriptDir = File("kotlin-sdk-test/src/jvmTest/typescript")
|
||||
* val server = TypeScriptServer(typescriptDir)
|
||||
*
|
||||
* // Start SSE server
|
||||
* // Start HTTP server
|
||||
* val port = TypeScriptServer.findFreePort()
|
||||
* val process = server.startSse(port)
|
||||
* val process = server.startHttp(port)
|
||||
*
|
||||
* // Stop server
|
||||
* server.stop()
|
||||
|
|
@ -38,27 +38,27 @@ class TypeScriptServer(private val typescriptDir: File) {
|
|||
private var process: Process? = null
|
||||
|
||||
/**
|
||||
* Starts a TypeScript MCP server using SSE transport.
|
||||
* Starts a TypeScript MCP server using Streamable HTTP transport.
|
||||
*
|
||||
* @param port The port number to bind the SSE server to
|
||||
* @param port The port number to bind the HTTP server to
|
||||
* @return The started Process
|
||||
* @throws IllegalStateException if the server fails to start within timeout
|
||||
*/
|
||||
fun startSse(port: Int): Process {
|
||||
fun startHttp(port: Int): Process {
|
||||
killProcessOnPort(port)
|
||||
val serverPath = File(typescriptDir, "server/sse-server.ts").absolutePath
|
||||
val serverPath = File(typescriptDir, "server/http-server.ts").absolutePath
|
||||
val proc = TypeScriptRunner.run(
|
||||
typescriptDir = typescriptDir,
|
||||
scriptPath = serverPath,
|
||||
env = mapOf("MCP_PORT" to port.toString()),
|
||||
redirectErrorStream = true,
|
||||
logPrefix = "TS-SERVER-SSE",
|
||||
logPrefix = "TS-SERVER-HTTP",
|
||||
)
|
||||
process = proc
|
||||
|
||||
check(waitForPort(port = port, timeoutSeconds = 60)) {
|
||||
proc.destroyForcibly()
|
||||
"TypeScript SSE server did not become ready on localhost:$port within timeout"
|
||||
"TypeScript HTTP server did not become ready on localhost:$port within timeout"
|
||||
}
|
||||
return proc
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript.http
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractKotlinClientTsServerEdgeCasesTest
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||
|
||||
class KotlinClientTsServerEdgeCasesTestHttp : AbstractKotlinClientTsServerEdgeCasesTest() {
|
||||
|
||||
override val transportKind = TransportKind.HTTP
|
||||
|
||||
private val serverUrl: String by lazy { getSharedHttpUrl() }
|
||||
|
||||
override suspend fun <T> useClient(block: suspend (Client) -> T): T = withClient(serverUrl, block)
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript.http
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractKotlinClientTsServerTest
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class KotlinClientTsServerTestHttp : AbstractKotlinClientTsServerTest() {
|
||||
override val transportKind = TransportKind.HTTP
|
||||
|
||||
private val serverUrl: String by lazy { getSharedHttpUrl() }
|
||||
|
||||
override suspend fun <T> useClient(block: suspend (Client) -> T): T = withClient(serverUrl) { client ->
|
||||
try {
|
||||
withTimeout(20.seconds) { block(client) }
|
||||
} finally {
|
||||
try {
|
||||
withTimeout(3.seconds) { client.close() }
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,465 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript.http
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.cio.CIO
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.request.header
|
||||
import io.ktor.server.request.receiveText
|
||||
import io.ktor.server.response.header
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.response.respondTextWriter
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.routing
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.Server
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
|
||||
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
|
||||
import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Role
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
class KotlinServerForTsClient {
|
||||
private val serverTransports = ConcurrentHashMap<String, HttpServerTransport>()
|
||||
private val jsonFormat = Json { ignoreUnknownKeys = true }
|
||||
private var server: EmbeddedServer<*, *>? = null
|
||||
|
||||
fun start(port: Int = 3000) {
|
||||
logger.info { "Starting HTTP server on port $port" }
|
||||
|
||||
server = embeddedServer(CIO, port = port) {
|
||||
routing {
|
||||
get("/mcp") {
|
||||
val sessionId = call.request.header("mcp-session-id")
|
||||
if (sessionId == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Missing mcp-session-id header")
|
||||
return@get
|
||||
}
|
||||
val transport = serverTransports[sessionId]
|
||||
if (transport == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Invalid mcp-session-id")
|
||||
return@get
|
||||
}
|
||||
transport.stream(call)
|
||||
}
|
||||
|
||||
post("/mcp") {
|
||||
val sessionId = call.request.header("mcp-session-id")
|
||||
val requestBody = call.receiveText()
|
||||
|
||||
logger.debug { "Received request with sessionId: $sessionId" }
|
||||
logger.trace { "Request body: $requestBody" }
|
||||
|
||||
val jsonElement = try {
|
||||
jsonFormat.parseToJsonElement(requestBody)
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Failed to parse request body as JSON" }
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
jsonFormat.encodeToString(
|
||||
JsonObject.serializer(),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"jsonrpc" to JsonPrimitive("2.0"),
|
||||
"error" to JsonObject(
|
||||
mapOf(
|
||||
"code" to JsonPrimitive(-32700),
|
||||
"message" to JsonPrimitive("Parse error: ${e.message}"),
|
||||
),
|
||||
),
|
||||
"id" to JsonNull,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return@post
|
||||
}
|
||||
|
||||
if (sessionId != null && serverTransports.containsKey(sessionId)) {
|
||||
logger.debug { "Using existing transport for session: $sessionId" }
|
||||
val transport = serverTransports[sessionId]!!
|
||||
transport.handleRequest(call, jsonElement)
|
||||
} else {
|
||||
if (isInitializeRequest(jsonElement)) {
|
||||
val newSessionId = UUID.randomUUID().toString()
|
||||
logger.info { "Creating new session with ID: $newSessionId" }
|
||||
|
||||
val transport = HttpServerTransport(newSessionId)
|
||||
|
||||
serverTransports[newSessionId] = transport
|
||||
|
||||
val mcpServer = createMcpServer()
|
||||
|
||||
call.response.header("mcp-session-id", newSessionId)
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
mcpServer.createSession(transport)
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Failed to create MCP session" }
|
||||
transport.ready.completeExceptionally(e)
|
||||
}
|
||||
}
|
||||
|
||||
transport.ready.await()
|
||||
|
||||
transport.handleRequest(call, jsonElement)
|
||||
} else {
|
||||
logger.warn { "Invalid request: no session ID or not an initialization request" }
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
jsonFormat.encodeToString(
|
||||
JsonObject.serializer(),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"jsonrpc" to JsonPrimitive("2.0"),
|
||||
"error" to JsonObject(
|
||||
mapOf(
|
||||
"code" to JsonPrimitive(-32000),
|
||||
"message" to
|
||||
JsonPrimitive("Bad Request: No valid session ID provided"),
|
||||
),
|
||||
),
|
||||
"id" to JsonNull,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete("/mcp") {
|
||||
val sessionId = call.request.header("mcp-session-id")
|
||||
if (sessionId != null && serverTransports.containsKey(sessionId)) {
|
||||
logger.info { "Terminating session: $sessionId" }
|
||||
val transport = serverTransports[sessionId]!!
|
||||
serverTransports.remove(sessionId)
|
||||
transport.close()
|
||||
call.respond(HttpStatusCode.OK)
|
||||
} else {
|
||||
logger.warn { "Invalid session termination request: $sessionId" }
|
||||
call.respond(HttpStatusCode.BadRequest, "Invalid or missing session ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
server?.start(wait = false)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
logger.info { "Stopping HTTP server" }
|
||||
server?.stop(500, 1000)
|
||||
server = null
|
||||
}
|
||||
|
||||
fun createMcpServer(): Server {
|
||||
val server = Server(
|
||||
Implementation(
|
||||
name = "kotlin-http-server",
|
||||
version = "1.0.0",
|
||||
),
|
||||
ServerOptions(
|
||||
capabilities = ServerCapabilities(
|
||||
prompts = ServerCapabilities.Prompts(listChanged = true),
|
||||
resources = ServerCapabilities.Resources(subscribe = true, listChanged = true),
|
||||
tools = ServerCapabilities.Tools(listChanged = true),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
server.addTool(
|
||||
name = "greet",
|
||||
description = "A simple greeting tool",
|
||||
inputSchema = ToolSchema(
|
||||
properties = buildJsonObject {
|
||||
put(
|
||||
"name",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put("description", JsonPrimitive("Name to greet"))
|
||||
},
|
||||
)
|
||||
},
|
||||
required = listOf("name"),
|
||||
),
|
||||
) { request ->
|
||||
val name = (request.params.arguments?.get("name") as? JsonPrimitive)?.content ?: "World"
|
||||
CallToolResult(
|
||||
content = listOf(TextContent("Hello, $name!")),
|
||||
structuredContent = buildJsonObject {
|
||||
put("greeting", JsonPrimitive("Hello, $name!"))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
server.addTool(
|
||||
name = "multi-greet",
|
||||
description = "A greeting tool that sends multiple notifications",
|
||||
inputSchema = ToolSchema(
|
||||
properties = buildJsonObject {
|
||||
put(
|
||||
"name",
|
||||
buildJsonObject {
|
||||
put("type", JsonPrimitive("string"))
|
||||
put("description", JsonPrimitive("Name to greet"))
|
||||
},
|
||||
)
|
||||
},
|
||||
required = listOf("name"),
|
||||
),
|
||||
) { request ->
|
||||
val name = (request.params.arguments?.get("name") as? JsonPrimitive)?.content ?: "World"
|
||||
|
||||
CallToolResult(
|
||||
content = listOf(TextContent("Multiple greetings sent to $name!")),
|
||||
structuredContent = buildJsonObject {
|
||||
put("greeting", JsonPrimitive("Multiple greetings sent to $name!"))
|
||||
put("notificationCount", JsonPrimitive(3))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
server.addPrompt(
|
||||
name = "greeting-template",
|
||||
description = "A simple greeting prompt template",
|
||||
arguments = listOf(
|
||||
PromptArgument(
|
||||
name = "name",
|
||||
description = "Name to include in greeting",
|
||||
required = true,
|
||||
),
|
||||
),
|
||||
) { request ->
|
||||
GetPromptResult(
|
||||
messages = listOf(
|
||||
PromptMessage(
|
||||
role = Role.User,
|
||||
content = TextContent(
|
||||
"Please greet ${request.params.arguments?.get("name") ?: "someone"} in a friendly manner.",
|
||||
),
|
||||
),
|
||||
),
|
||||
description = "Greeting for ${request.params.name}",
|
||||
)
|
||||
}
|
||||
|
||||
server.addResource(
|
||||
uri = "https://example.com/greetings/default",
|
||||
name = "Default Greeting",
|
||||
description = "A simple greeting resource",
|
||||
mimeType = "text/plain",
|
||||
) { request ->
|
||||
ReadResourceResult(
|
||||
contents = listOf(
|
||||
TextResourceContents("Hello, world!", request.params.uri, "text/plain"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
private fun isInitializeRequest(json: JsonElement): Boolean {
|
||||
if (json !is JsonObject) return false
|
||||
|
||||
val method = json["method"]?.jsonPrimitive?.contentOrNull
|
||||
return method == "initialize"
|
||||
}
|
||||
}
|
||||
|
||||
class HttpServerTransport(private val sessionId: String) : AbstractTransport() {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
private val pendingResponses = ConcurrentHashMap<String, CompletableDeferred<JSONRPCMessage>>()
|
||||
private val messageQueue = Channel<JSONRPCMessage>(Channel.UNLIMITED)
|
||||
internal val ready = CompletableDeferred<Unit>()
|
||||
|
||||
suspend fun stream(call: ApplicationCall) {
|
||||
logger.debug { "Starting Event stream for session: $sessionId" }
|
||||
call.response.header("Cache-Control", "no-cache")
|
||||
call.response.header("Connection", "keep-alive")
|
||||
call.respondTextWriter(ContentType.Text.EventStream) {
|
||||
try {
|
||||
while (true) {
|
||||
val result = messageQueue.receiveCatching()
|
||||
val msg = result.getOrNull() ?: break
|
||||
val json = McpJson.encodeToString(msg)
|
||||
write("event: message\n")
|
||||
write("data: ")
|
||||
write(json)
|
||||
write("\n\n")
|
||||
flush()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.warn(e) { "Event stream terminated for session: $sessionId" }
|
||||
} finally {
|
||||
logger.debug { "Event stream closed for session: $sessionId" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleRequest(call: ApplicationCall, requestBody: JsonElement) {
|
||||
try {
|
||||
logger.trace { "Handling request body: $requestBody" }
|
||||
val message = McpJson.decodeFromJsonElement<JSONRPCMessage>(requestBody)
|
||||
logger.debug { "Decoded message: $message" }
|
||||
|
||||
if (message is JSONRPCRequest) {
|
||||
val id = message.id.toString()
|
||||
logger.debug { "Received request with ID: $id, method: ${message.method}" }
|
||||
val responseDeferred = CompletableDeferred<JSONRPCMessage>()
|
||||
pendingResponses[id] = responseDeferred
|
||||
|
||||
logger.trace { "Invoking onMessage handler for ID: $id" }
|
||||
_onMessage.invoke(message)
|
||||
logger.trace { "onMessage handler completed for ID: $id" }
|
||||
|
||||
try {
|
||||
val response = withTimeoutOrNull(10000) {
|
||||
responseDeferred.await()
|
||||
}
|
||||
|
||||
if (response != null) {
|
||||
val jsonResponse = McpJson.encodeToString(response)
|
||||
call.respondText(jsonResponse, ContentType.Application.Json)
|
||||
} else {
|
||||
logger.warn { "Timeout waiting for response to request ID: $id" }
|
||||
call.respondText(
|
||||
McpJson.encodeToString(
|
||||
JSONRPCError(
|
||||
id = message.id,
|
||||
error = RPCError(
|
||||
code = RPCError.ErrorCode.REQUEST_TIMEOUT,
|
||||
message = "Request timed out",
|
||||
),
|
||||
),
|
||||
),
|
||||
ContentType.Application.Json,
|
||||
)
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
logger.warn { "Request cancelled for ID: $id" }
|
||||
pendingResponses.remove(id)
|
||||
if (!call.response.isCommitted) {
|
||||
call.respondText(
|
||||
McpJson.encodeToString(
|
||||
JSONRPCError(
|
||||
id = message.id,
|
||||
error = RPCError(
|
||||
code = RPCError.ErrorCode.CONNECTION_CLOSED,
|
||||
message = "Request cancelled",
|
||||
),
|
||||
),
|
||||
),
|
||||
ContentType.Application.Json,
|
||||
HttpStatusCode.ServiceUnavailable,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
call.respondText("", ContentType.Application.Json, HttpStatusCode.Accepted)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Error handling request" }
|
||||
if (!call.response.isCommitted) {
|
||||
try {
|
||||
val errorResponse = JSONRPCError(
|
||||
id = RequestId(0),
|
||||
error = RPCError(
|
||||
code = RPCError.ErrorCode.INTERNAL_ERROR,
|
||||
message = "Internal server error: ${e.message}",
|
||||
),
|
||||
)
|
||||
|
||||
call.respondText(
|
||||
McpJson.encodeToString(errorResponse),
|
||||
ContentType.Application.Json,
|
||||
HttpStatusCode.InternalServerError,
|
||||
)
|
||||
} catch (responseEx: Exception) {
|
||||
logger.error(responseEx) { "Failed to send error response" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun start() {
|
||||
logger.debug { "Starting HTTP server transport for session: $sessionId" }
|
||||
ready.complete(Unit)
|
||||
}
|
||||
|
||||
override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {
|
||||
logger.trace { "Sending message: $message" }
|
||||
|
||||
if (message is JSONRPCResponse) {
|
||||
val id = message.id.toString()
|
||||
logger.debug { "Completing response for request ID: $id" }
|
||||
val deferred = pendingResponses.remove(id)
|
||||
if (deferred != null) {
|
||||
deferred.complete(message)
|
||||
return
|
||||
} else {
|
||||
logger.warn { "No pending response found for ID: $id" }
|
||||
}
|
||||
} else if (message is JSONRPCRequest) {
|
||||
logger.debug { "Sending request with ID: ${message.id}" }
|
||||
} else if (message is JSONRPCNotification) {
|
||||
logger.debug { "Sending notification: ${message.method}" }
|
||||
}
|
||||
|
||||
messageQueue.send(message)
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
logger.debug { "Closing HTTP server transport for session: $sessionId" }
|
||||
messageQueue.close()
|
||||
invokeOnCloseCallback()
|
||||
}
|
||||
}
|
||||
|
||||
fun main() {
|
||||
val server = KotlinServerForTsClient()
|
||||
server.start()
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript.http
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractTsClientKotlinServerTest
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||
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
|
||||
|
||||
class TsClientKotlinServerTestHttp : AbstractTsClientKotlinServerTest() {
|
||||
|
||||
override val transportKind = TransportKind.HTTP
|
||||
|
||||
private var port: Int = 0
|
||||
private lateinit var serverUrl: String
|
||||
private var httpServer: KotlinServerForTsClient? = null
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
port = findFreePort()
|
||||
serverUrl = "http://localhost:$port/mcp"
|
||||
killProcessOnPort(port)
|
||||
httpServer = KotlinServerForTsClient().also { it.start(port) }
|
||||
check(waitForPort(port = port)) { "Kotlin test server did not become ready on localhost:$port within timeout" }
|
||||
println("Kotlin server started on port $port")
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
try {
|
||||
httpServer?.stop()
|
||||
println("HTTP server stopped")
|
||||
} catch (e: Exception) {
|
||||
println("Error during server shutdown: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun runClient(vararg args: String): String = tsClient.use { client ->
|
||||
val process = client.startHttp(listOf(serverUrl) + args.toList(), log = true)
|
||||
val output = StringBuilder()
|
||||
process.inputStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
println("[TS-CLIENT-HTTP] $line")
|
||||
output.append(line).append("\n")
|
||||
}
|
||||
}
|
||||
process.waitFor(25, java.util.concurrent.TimeUnit.SECONDS)
|
||||
output.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript.http
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase
|
||||
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.DisabledOnOs
|
||||
import org.junit.jupiter.api.condition.OS
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
|
||||
class TsEdgeCasesTestHttp : TsTestBase() {
|
||||
|
||||
override val transportKind = TransportKind.HTTP
|
||||
|
||||
private var port: Int = 0
|
||||
private lateinit var serverUrl: String
|
||||
private var httpServer: KotlinServerForTsClient? = null
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
port = findFreePort()
|
||||
serverUrl = "http://localhost:$port/mcp"
|
||||
killProcessOnPort(port)
|
||||
httpServer = KotlinServerForTsClient()
|
||||
httpServer?.start(port)
|
||||
check(waitForPort(port = port)) {
|
||||
"Kotlin test server did not become ready on localhost:$port within timeout"
|
||||
}
|
||||
println("Kotlin server started on port $port")
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
try {
|
||||
httpServer?.stop()
|
||||
println("HTTP server stopped")
|
||||
} catch (e: Exception) {
|
||||
println("Error during server shutdown: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testInvalidURL() = runTest {
|
||||
val nonExistentToolCommand = "npx tsx client/http-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 client/http-client.ts http://localhost:${port + 1000}/mcp greet TestUser"
|
||||
val invalidUrlOutput = executeCommandAllowingFailure(invalidUrlCommand, typescriptDir)
|
||||
|
||||
assertTrue(
|
||||
invalidUrlOutput.contains("Invalid URL") ||
|
||||
invalidUrlOutput.contains("ERR_INVALID_URL") ||
|
||||
invalidUrlOutput.contains("ECONNREFUSED"),
|
||||
"Client should handle connection errors gracefully",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testSpecialCharacters() = runTest {
|
||||
val specialChars = "!@#$+-[].,?"
|
||||
|
||||
val tempFile = File.createTempFile("special_chars", ".txt")
|
||||
tempFile.writeText(specialChars)
|
||||
tempFile.deleteOnExit()
|
||||
|
||||
val specialCharsContent = tempFile.readText()
|
||||
val specialCharsCommand = "npx tsx client/http-client.ts $serverUrl greet \"$specialCharsContent\""
|
||||
val specialCharsOutput = executeCommand(specialCharsCommand, typescriptDir)
|
||||
|
||||
assertTrue(
|
||||
specialCharsOutput.contains("Hello, $specialChars!"),
|
||||
"Tool should handle special characters in arguments",
|
||||
)
|
||||
assertTrue(
|
||||
specialCharsOutput.contains("Disconnected from server"),
|
||||
"Client should disconnect cleanly after handling special characters",
|
||||
)
|
||||
}
|
||||
|
||||
// skip on windows as it can't handle long commands
|
||||
@Test
|
||||
@Timeout(40, unit = TimeUnit.SECONDS)
|
||||
@DisabledOnOs(OS.WINDOWS)
|
||||
fun testLargePayload() = runTest {
|
||||
val largeName = "A".repeat(10 * 1024)
|
||||
|
||||
val tempFile = File.createTempFile("large_name", ".txt")
|
||||
tempFile.writeText(largeName)
|
||||
tempFile.deleteOnExit()
|
||||
|
||||
val largeNameContent = tempFile.readText()
|
||||
val largePayloadCommand = "npx tsx client/http-client.ts $serverUrl greet \"$largeNameContent\""
|
||||
val largePayloadOutput = executeCommand(largePayloadCommand, typescriptDir)
|
||||
|
||||
tempFile.delete()
|
||||
|
||||
assertTrue(
|
||||
largePayloadOutput.contains("Hello,") && largePayloadOutput.contains("A".repeat(20)),
|
||||
"Tool should handle large payloads",
|
||||
)
|
||||
assertTrue(
|
||||
largePayloadOutput.contains("Disconnected from server"),
|
||||
"Client should disconnect cleanly after handling large payload",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testComplexConcurrentRequests(): Unit = runBlocking {
|
||||
fun prettyFail(
|
||||
index: Int,
|
||||
command: String,
|
||||
expectation: String,
|
||||
output: String,
|
||||
): Nothing {
|
||||
val msg = buildString {
|
||||
appendLine("Assertion failed for client #$index")
|
||||
appendLine("Expectation: $expectation")
|
||||
appendLine("Command: $command")
|
||||
appendLine("----- OUTPUT BEGIN -----")
|
||||
appendLine(output.trimEnd())
|
||||
appendLine("----- OUTPUT END -----")
|
||||
}
|
||||
fail(msg)
|
||||
}
|
||||
|
||||
fun assertContains(
|
||||
index: Int,
|
||||
command: String,
|
||||
output: String,
|
||||
needle: String,
|
||||
description: String,
|
||||
) {
|
||||
if (!output.contains(needle)) {
|
||||
prettyFail(index, command, "$description — expected to contain: \"$needle\"", output)
|
||||
}
|
||||
}
|
||||
|
||||
val commands = listOf(
|
||||
"npx tsx client/http-client.ts $serverUrl greet \"Client1\"",
|
||||
"npx tsx client/http-client.ts $serverUrl multi-greet \"Client2\"",
|
||||
"npx tsx client/http-client.ts $serverUrl greet \"Client3\"",
|
||||
"npx tsx client/http-client.ts $serverUrl",
|
||||
"npx tsx client/http-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, typescriptDir)
|
||||
println("Client $index completed")
|
||||
|
||||
assertContains(
|
||||
index,
|
||||
command,
|
||||
output,
|
||||
"Connected to server",
|
||||
"Client should connect to server",
|
||||
)
|
||||
assertContains(
|
||||
index,
|
||||
command,
|
||||
output,
|
||||
"Disconnected from server",
|
||||
"Client should disconnect cleanly",
|
||||
)
|
||||
|
||||
when {
|
||||
command.contains("greet \"Client1\"") ->
|
||||
assertContains(
|
||||
index,
|
||||
command,
|
||||
output,
|
||||
"Hello, Client1!",
|
||||
"Client 1 should receive correct greeting",
|
||||
)
|
||||
|
||||
command.contains("multi-greet \"Client2\"") ->
|
||||
assertContains(
|
||||
index,
|
||||
command,
|
||||
output,
|
||||
"Multiple greetings",
|
||||
"Client 2 should receive multiple greetings",
|
||||
)
|
||||
|
||||
command.contains("greet \"Client3\"") ->
|
||||
assertContains(
|
||||
index,
|
||||
command,
|
||||
output,
|
||||
"Hello, Client3!",
|
||||
"Client 3 should receive correct greeting",
|
||||
)
|
||||
|
||||
!command.contains("greet") && !command.contains("multi-greet") ->
|
||||
assertContains(
|
||||
index,
|
||||
command,
|
||||
output,
|
||||
"Available utils:",
|
||||
"Client 4 should list available tools",
|
||||
)
|
||||
|
||||
command.contains("multi-greet \"Client5\"") ->
|
||||
assertContains(
|
||||
index,
|
||||
command,
|
||||
output,
|
||||
"Multiple greetings",
|
||||
"Client 5 should receive multiple greetings",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
jobs.awaitAll()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(120, unit = TimeUnit.SECONDS)
|
||||
fun testRapidSequentialRequests() = runBlocking {
|
||||
val outputs = (1..10).map { i ->
|
||||
val command = "npx tsx client/http-client.ts $serverUrl greet \"RapidClient$i\""
|
||||
val output = executeCommand(command, typescriptDir)
|
||||
|
||||
assertTrue(
|
||||
output.contains("Connected to server"),
|
||||
"Client $i should connect to server",
|
||||
)
|
||||
assertTrue(
|
||||
output.contains("Hello, RapidClient$i!"),
|
||||
"Client $i should receive correct greeting",
|
||||
)
|
||||
assertTrue(
|
||||
output.contains("Disconnected from server"),
|
||||
"Client $i should disconnect cleanly",
|
||||
)
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
assertEquals(10, outputs.size, "All 10 rapid requests should complete successfully")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,173 +1,12 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.integration.typescript.stdio
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.AbstractKotlinClientTsServerEdgeCasesTest
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TransportKind
|
||||
import io.modelcontextprotocol.kotlin.sdk.integration.typescript.TsTestBase
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Timeout
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class KotlinClientTsServerEdgeCasesTestStdio : TsTestBase() {
|
||||
class KotlinClientTsServerEdgeCasesTestStdio : AbstractKotlinClientTsServerEdgeCasesTest() {
|
||||
|
||||
override val transportKind = TransportKind.STDIO
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testNonExistentToolOverStdio(): Unit = runBlocking(Dispatchers.IO) {
|
||||
withClientStdio { client: Client, _ ->
|
||||
val nonExistentToolName = "non-existent-tool"
|
||||
val arguments = mapOf("name" to "TestUser")
|
||||
|
||||
val result = client.callTool(nonExistentToolName, arguments)
|
||||
assertNotNull(result, "Tool call result should not be null")
|
||||
|
||||
assertTrue(result.isError ?: false, "isError should be true for non-existent tool")
|
||||
|
||||
val textContent = result.content.firstOrNull { it is TextContent } as? TextContent
|
||||
assertNotNull(textContent, "Error content should be present in the result")
|
||||
|
||||
val errorText = textContent.text
|
||||
assertTrue(
|
||||
errorText.contains("non-existent-tool") && errorText.contains("not found"),
|
||||
"Error message should indicate the tool was not found",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testSpecialCharactersInArgumentsOverStdio(): Unit = runBlocking(Dispatchers.IO) {
|
||||
withClientStdio { client: Client, _ ->
|
||||
val specialChars = "!@#$%^&*()_+{}[]|\\:;\"'<>.,?/"
|
||||
val arguments = mapOf("name" to specialChars)
|
||||
|
||||
val result = client.callTool("greet", arguments)
|
||||
assertNotNull(result, "Tool call result should not be null")
|
||||
|
||||
val textContent = result.content.firstOrNull { it is TextContent } as? TextContent
|
||||
assertNotNull(textContent, "Text content should be present in the result")
|
||||
|
||||
val text = textContent.text
|
||||
assertTrue(
|
||||
text.contains(specialChars),
|
||||
"Tool response should contain the special characters",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testLargePayloadOverStdio(): Unit = runBlocking(Dispatchers.IO) {
|
||||
withClientStdio { client: Client, _ ->
|
||||
val largeName = "A".repeat(10 * 1024)
|
||||
val arguments = mapOf("name" to largeName)
|
||||
|
||||
val result = client.callTool("greet", arguments)
|
||||
assertNotNull(result, "Tool call result should not be null")
|
||||
|
||||
val callResult = result
|
||||
val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent
|
||||
assertNotNull(textContent, "Text content should be present in the result")
|
||||
|
||||
val text = textContent.text
|
||||
assertTrue(
|
||||
text.contains("Hello,") && text.contains("A"),
|
||||
"Tool response should contain the greeting with the large name",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testConcurrentRequestsOverStdio(): Unit = runBlocking(Dispatchers.IO) {
|
||||
withClientStdio { client: Client, _ ->
|
||||
val concurrentCount = 5
|
||||
val responses = coroutineScope {
|
||||
val results = (1..concurrentCount).map { i ->
|
||||
async {
|
||||
val name = "ConcurrentClient$i"
|
||||
val arguments = mapOf("name" to name)
|
||||
|
||||
val result = client.callTool("greet", arguments)
|
||||
assertNotNull(result, "Tool call result should not be null for client $i")
|
||||
|
||||
val textContent = result.content.firstOrNull { it is TextContent } as? TextContent
|
||||
assertNotNull(textContent, "Text content should be present for client $i")
|
||||
|
||||
textContent.text
|
||||
}
|
||||
}
|
||||
results.awaitAll()
|
||||
}
|
||||
|
||||
for (i in 1..concurrentCount) {
|
||||
val expectedName = "ConcurrentClient$i"
|
||||
val matchingResponses = responses.filter { it.contains("Hello, $expectedName!") }
|
||||
assertEquals(
|
||||
1,
|
||||
matchingResponses.size,
|
||||
"Should have exactly one response for $expectedName",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testInvalidArgumentsOverStdio(): Unit = runBlocking(Dispatchers.IO) {
|
||||
withClientStdio { client: Client, _ ->
|
||||
val invalidArguments = mapOf(
|
||||
"name" to JsonObject(mapOf("nested" to JsonPrimitive("value"))),
|
||||
)
|
||||
|
||||
val result = client.callTool("greet", invalidArguments)
|
||||
assertNotNull(result, "Tool call result should not be null")
|
||||
|
||||
val callResult = result
|
||||
assertTrue(callResult.isError ?: false, "isError should be true for invalid arguments")
|
||||
|
||||
val textContent = callResult.content.firstOrNull { it is TextContent } as? TextContent
|
||||
assertNotNull(textContent, "Error content should be present in the result")
|
||||
|
||||
val errorText = textContent.text
|
||||
assertTrue(
|
||||
errorText.contains("Invalid arguments") && errorText.contains("greet"),
|
||||
"Error message should indicate invalid arguments for tool greet",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Timeout(30, unit = TimeUnit.SECONDS)
|
||||
fun testMultipleToolCallsOverStdio(): Unit = runBlocking(Dispatchers.IO) {
|
||||
withClientStdio { client: Client, _ ->
|
||||
repeat(10) { i ->
|
||||
val name = "SequentialClient$i"
|
||||
val arguments = mapOf("name" to name)
|
||||
|
||||
val result = client.callTool("greet", arguments)
|
||||
assertNotNull(result, "Tool call result should not be null for call $i")
|
||||
|
||||
val textContent = result.content.firstOrNull { it is TextContent } as? TextContent
|
||||
assertNotNull(textContent, "Text content should be present for call $i")
|
||||
|
||||
assertEquals(
|
||||
"Hello, $name!",
|
||||
textContent.text,
|
||||
"Tool response should contain the greeting with the provided name",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
override suspend fun <T> useClient(block: suspend (Client) -> T): T = withClientStdio { c, _ -> block(c) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
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:3000/mcp';
|
||||
const toolName = args[1];
|
||||
const toolArgs = args.slice(2);
|
||||
const PROTOCOL_VERSION = "2025-11-25";
|
||||
|
||||
async function main() {
|
||||
if (!toolName) {
|
||||
console.log('Usage: npx tsx client/http-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'
|
||||
}, {
|
||||
fallbackNotificationHandler: async (notification) => {
|
||||
console.log('Notification:', notification.method, JSON.stringify(notification));
|
||||
}
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
|
||||
|
||||
try {
|
||||
await client.connect(transport, {protocolVersion: PROTOCOL_VERSION});
|
||||
console.log('Connected to server');
|
||||
|
||||
const toolsResult = await client.listTools();
|
||||
const tools = toolsResult.tools;
|
||||
console.log('Available utils:', tools.map((t) => t.name).join(', '));
|
||||
|
||||
if (!toolName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tool = tools.find((t) => t.name === toolName);
|
||||
if (!tool) {
|
||||
throw new Error(`Tool "${toolName}" not found`);
|
||||
}
|
||||
|
||||
const toolArguments: Record<string, string> = {};
|
||||
|
||||
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);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await client.close();
|
||||
console.log('Disconnected from server');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@ 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";
|
||||
const PROTOCOL_VERSION = "2025-11-25";
|
||||
|
||||
async function main() {
|
||||
if (!toolName) {
|
||||
|
|
@ -65,6 +65,10 @@ async function main() {
|
|||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
}, {
|
||||
fallbackNotificationHandler: async (notification) => {
|
||||
console.error('Notification:', notification.method, JSON.stringify(notification));
|
||||
}
|
||||
});
|
||||
|
||||
const transport = new MinimalStdioClientTransport();
|
||||
|
|
@ -73,21 +77,6 @@ async function main() {
|
|||
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(', '));
|
||||
|
|
|
|||
|
|
@ -8,18 +8,14 @@
|
|||
"name": "mcp-typescript-test-sdk",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"body-parser": "^2.2.2",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"express": "^5.2.1",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^24.10.6",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
|
|
@ -467,23 +463,10 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.39.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
|
||||
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://eslint.org/donate"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.9",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
|
||||
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
|
||||
"version": "1.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
||||
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
|
|
@ -493,9 +476,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
|
||||
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz",
|
||||
"integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
|
|
@ -640,13 +623,6 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
|
|
@ -1029,12 +1005,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
|
||||
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
|
|
@ -1217,9 +1193,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.11.9",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
|
||||
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
|
||||
"version": "4.12.9",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
|
||||
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
|
|
@ -1268,9 +1244,9 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
|
|
@ -1786,19 +1762,6 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
|
|
|||
|
|
@ -4,23 +4,19 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"client:stdio": "tsx client/stdio-client.ts",
|
||||
"client:sse": "tsx client/sse-client.ts http://localhost:3000/mcp greet Hello",
|
||||
"client:http": "tsx client/http-client.ts http://localhost:3000/mcp greet Hello",
|
||||
"server:stdio": "tsx server/stdio-server.ts",
|
||||
"server:sse": "tsx server/sse-server.ts"
|
||||
"server:http": "tsx server/http-server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"body-parser": "^2.2.2",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"express": "^5.2.1",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^24.10.6",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
|
|
|
|||
275
integration-test/src/jvmTest/typescript/server/http-server.ts
Normal file
275
integration-test/src/jvmTest/typescript/server/http-server.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import express, {Request, Response} from 'express';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import cors from 'cors';
|
||||
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp';
|
||||
import {registerTestUtils} from './server-common';
|
||||
import {
|
||||
getOAuthProtectedResourceMetadataUrl,
|
||||
mcpAuthMetadataRouter
|
||||
} from '@modelcontextprotocol/sdk/server/auth/router';
|
||||
import {requireBearerAuth} from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth';
|
||||
import {isInitializeRequest} from '@modelcontextprotocol/sdk/types';
|
||||
import {InMemoryEventStore} from '@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore';
|
||||
import {setupAuthServer} from '@modelcontextprotocol/sdk/examples/server/demoInMemoryOAuthProvider';
|
||||
import {checkResourceAllowed} from '@modelcontextprotocol/sdk/shared/auth-utils';
|
||||
import {OAuthMetadata} from "@modelcontextprotocol/sdk/shared/auth";
|
||||
|
||||
async function main() {
|
||||
const useOAuth = process.argv.includes('--oauth');
|
||||
const strictOAuth = process.argv.includes('--oauth-strict');
|
||||
|
||||
const getServer = () => {
|
||||
const server = new McpServer({
|
||||
name: 'simple-streamable-http-server',
|
||||
version: '1.0.0'
|
||||
}, {capabilities: {logging: {}}});
|
||||
|
||||
registerTestUtils(server);
|
||||
|
||||
return server;
|
||||
};
|
||||
|
||||
const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000;
|
||||
const AUTH_PORT = process.env.MCP_AUTH_PORT ? parseInt(process.env.MCP_AUTH_PORT, 10) : 3001;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Allow CORS all domains, expose the Mcp-Session-Id header
|
||||
app.use(cors({
|
||||
origin: '*',
|
||||
exposedHeaders: ["Mcp-Session-Id"]
|
||||
}));
|
||||
|
||||
// Set up OAuth if enabled
|
||||
let authMiddleware = null;
|
||||
if (useOAuth) {
|
||||
const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`);
|
||||
const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`);
|
||||
|
||||
const oauthMetadata: OAuthMetadata = setupAuthServer({
|
||||
authServerUrl,
|
||||
mcpServerUrl,
|
||||
strictResource: strictOAuth
|
||||
});
|
||||
|
||||
const tokenVerifier = {
|
||||
verifyAccessToken: async (token: string) => {
|
||||
const endpoint = oauthMetadata.introspection_endpoint;
|
||||
|
||||
if (!endpoint) {
|
||||
throw new Error('No token verification endpoint available in metadata');
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
token: token
|
||||
}).toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Invalid or expired token: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (strictOAuth) {
|
||||
if (!data.aud) {
|
||||
throw new Error(`Resource Indicator (RFC8707) missing`);
|
||||
}
|
||||
if (!checkResourceAllowed({requestedResource: data.aud, configuredResource: mcpServerUrl})) {
|
||||
throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
clientId: data.client_id,
|
||||
scopes: data.scope ? data.scope.split(' ') : [],
|
||||
expiresAt: data.exp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
app.use(mcpAuthMetadataRouter({
|
||||
oauthMetadata,
|
||||
resourceServerUrl: mcpServerUrl,
|
||||
scopesSupported: ['mcp:tools'],
|
||||
resourceName: 'MCP Demo Server',
|
||||
}));
|
||||
|
||||
authMiddleware = requireBearerAuth({
|
||||
verifier: tokenVerifier,
|
||||
requiredScopes: [],
|
||||
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
|
||||
});
|
||||
}
|
||||
|
||||
// Map to store transports by session ID
|
||||
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
|
||||
|
||||
// MCP POST endpoint with optional auth
|
||||
const mcpPostHandler = async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (sessionId) {
|
||||
console.log(`Received MCP request for session: ${sessionId}`);
|
||||
} else {
|
||||
console.log('Request body:', req.body);
|
||||
}
|
||||
|
||||
if (useOAuth && req.auth) {
|
||||
console.log('Authenticated user:', req.auth);
|
||||
}
|
||||
try {
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
if (sessionId && transports[sessionId]) {
|
||||
transport = transports[sessionId];
|
||||
} else if (!sessionId && isInitializeRequest(req.body)) {
|
||||
const eventStore = new InMemoryEventStore();
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
eventStore,
|
||||
onsessioninitialized: (sessionId) => {
|
||||
console.log(`Session initialized with ID: ${sessionId}`);
|
||||
transports[sessionId] = transport;
|
||||
}
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
const sid = transport.sessionId;
|
||||
if (sid && transports[sid]) {
|
||||
console.log(`Transport closed for session ${sid}, removing from transports map`);
|
||||
delete transports[sid];
|
||||
}
|
||||
};
|
||||
|
||||
const server = getServer();
|
||||
await server.connect(transport);
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
return;
|
||||
} else {
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Bad Request: No valid session ID provided',
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
console.error('Error handling MCP request:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32603,
|
||||
message: 'Internal server error',
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (useOAuth && authMiddleware) {
|
||||
app.post('/mcp', authMiddleware, mcpPostHandler);
|
||||
} else {
|
||||
app.post('/mcp', mcpPostHandler);
|
||||
}
|
||||
|
||||
// Handle GET requests for event streams
|
||||
const mcpGetHandler = async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId || !transports[sessionId]) {
|
||||
res.status(400).send('Invalid or missing session ID');
|
||||
return;
|
||||
}
|
||||
|
||||
if (useOAuth && req.auth) {
|
||||
console.log('Authenticated event stream connection from user:', req.auth);
|
||||
}
|
||||
|
||||
const lastEventId = req.headers['last-event-id'] as string | undefined;
|
||||
if (lastEventId) {
|
||||
console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`);
|
||||
} else {
|
||||
console.log(`Establishing new event stream for session ${sessionId}`);
|
||||
}
|
||||
|
||||
const transport = transports[sessionId];
|
||||
await transport.handleRequest(req, res);
|
||||
};
|
||||
|
||||
if (useOAuth && authMiddleware) {
|
||||
app.get('/mcp', authMiddleware, mcpGetHandler);
|
||||
} else {
|
||||
app.get('/mcp', mcpGetHandler);
|
||||
}
|
||||
|
||||
// Handle DELETE requests for session termination (according to MCP spec)
|
||||
const mcpDeleteHandler = async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId || !transports[sessionId]) {
|
||||
res.status(400).send('Invalid or missing session ID');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Received session termination request for session ${sessionId}`);
|
||||
|
||||
try {
|
||||
const transport = transports[sessionId];
|
||||
await transport.handleRequest(req, res);
|
||||
} catch (error) {
|
||||
console.error('Error handling session termination:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send('Error processing session termination');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (useOAuth && authMiddleware) {
|
||||
app.delete('/mcp', authMiddleware, mcpDeleteHandler);
|
||||
} else {
|
||||
app.delete('/mcp', mcpDeleteHandler);
|
||||
}
|
||||
|
||||
app.listen(MCP_PORT, (error) => {
|
||||
if (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`);
|
||||
});
|
||||
|
||||
// Handle server shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Shutting down server...');
|
||||
|
||||
for (const sessionId in transports) {
|
||||
try {
|
||||
console.log(`Closing transport for session ${sessionId}`);
|
||||
await transports[sessionId].close();
|
||||
delete transports[sessionId];
|
||||
} catch (error) {
|
||||
console.error(`Error closing transport for session ${sessionId}:`, error);
|
||||
}
|
||||
}
|
||||
console.log('Server shutdown complete');
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
12
integration-test/src/jvmTest/typescript/tsconfig.json
Normal file
12
integration-test/src/jvmTest/typescript/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue