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:
Pavel Gorgulov 2026-03-26 13:19:10 +01:00 • committed by GitHub
commit 435698a611
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 169 additions and 437 deletions

View file

@ -39,7 +39,6 @@ kotlin {
forkEvery = 100
testLogging {
exceptionFormat = TestExceptionFormat.SHORT
showStandardStreams = true
events("failed")
}
systemProperty("kotest.output.ansi", "true")

View file

@ -14,6 +14,10 @@ complexity:
LongMethod:
excludes: *testFolders
coroutines:
InjectDispatcher:
excludes: *testFolders
empty-blocks:
EmptyFunctionBlock:
excludes: *testFolders

View file

@ -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.&lt;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>

View file

@ -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 {

View file

@ -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",
)
}
}
}
}

View file

@ -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)

View file

@ -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

View file

@ -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
}

View file

@ -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)
}

View file

@ -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) {}
}
}
}

View file

@ -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()
}

View file

@ -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()
}
}

View file

@ -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")
}
}

View file

@ -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) }
}

View file

@ -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);
});

View file

@ -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(', '));

View file

@ -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",

View file

@ -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"

View 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);
});

View 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