diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 000000000..b226b5f6c
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,527 @@
+# Architecture
+
+This document describes the internal architecture of the MCP Kotlin SDK. It covers both client and server sides, the transport abstraction, state management, and concurrency model.
+
+
+
+* [Layered Design](#layered-design)
+* [Module Structure](#module-structure)
+* [Client Architecture](#client-architecture)
+ * [How the Client Works](#how-the-client-works)
+ * [Client Transport Hierarchy](#client-transport-hierarchy)
+ * [Client Transport State Machine](#client-transport-state-machine)
+* [Server Architecture](#server-architecture)
+ * [How the Server Works](#how-the-server-works)
+ * [Server Class Hierarchy](#server-class-hierarchy)
+ * [Server Transport State Machine](#server-transport-state-machine)
+ * [Server Components](#server-components)
+ * [Session Lifecycle](#session-lifecycle)
+* [Concurrency Model](#concurrency-model)
+ * [Concurrent Request Handling](#concurrent-request-handling)
+ * [Streamable HTTP Request Flow](#streamable-http-request-flow)
+* [Capability Enforcement](#capability-enforcement)
+* [Testing](#testing)
+
+
+
+## Layered Design
+
+The SDK follows a four-layer architecture where each layer has a single responsibility and communicates only with its immediate neighbors.
+
+**Application** registers tools, prompts, and resources (server-side) or calls them (client-side). This is where your code lives.
+
+**Client / Server** provides typed MCP operations — `callTool()`, `listResources()`, `createMessage()` — and enforces capability negotiation. You don't send raw JSON-RPC here; the layer maps high-level calls to protocol messages and validates that both sides advertised the required capabilities.
+
+**Protocol** handles JSON-RPC framing: request/response correlation by ID, timeouts, progress notifications, and cancellation. It's transport-agnostic — it just calls `transport.send()` and reacts to `transport.onMessage()`.
+
+**Transport** moves bytes. It knows about stdio pipes, HTTP connections, or SSE streams, but nothing about MCP semantics.
+
+```mermaid
+graph TB
+ App["Application
(tools, prompts, resources)"]
+ CS["Client / Server
(typed MCP operations, capability enforcement)"]
+ Proto["Protocol
(JSON-RPC framing, request correlation, timeouts)"]
+ Transport["Transport
(pluggable I/O: stdio, Streamable HTTP)"]
+
+ App --> CS --> Proto --> Transport
+```
+
+This layering means you can swap transports without touching application code, and you can test the protocol layer with an in-memory `ChannelTransport` that never opens a socket.
+
+## Module Structure
+
+| Module | Contents |
+|--------|----------|
+| `kotlin-sdk-core` | `Transport` interface, `AbstractTransport`, `AbstractClientTransport`, `Protocol` base class, JSON-RPC types, `ClientTransportState` |
+| `kotlin-sdk-client` | `Client`, client transports (`StdioClientTransport`, `StreamableHttpClientTransport`, `SseClientTransport`) |
+| `kotlin-sdk-server` | `Server`, `ServerSession`, server transports (`StdioServerTransport`, `StreamableHttpServerTransport`), `AbstractServerTransport`, `ServerTransportState`, feature registries |
+| `kotlin-sdk-testing` | `ChannelTransport` — an in-memory linked pair for client-server testing without network I/O |
+| `kotlin-sdk` | Umbrella module that re-exports client + server |
+
+## Client Architecture
+
+### How the Client Works
+
+`Client` extends `Protocol` and owns the initialization handshake. When you call `client.connect(transport)`, it:
+
+1. Calls `Protocol.connect(transport)` — attaches callbacks, starts the transport.
+2. Sends an `InitializeRequest` with the client's protocol version, capabilities, and implementation info.
+3. Receives `InitializeResult` from the server — stores `serverCapabilities`, `serverVersion`, and `serverInstructions`.
+4. Sends an `InitializedNotification` to signal that the operational phase can begin.
+
+After this, `Client` provides typed methods — `callTool()`, `listResources()`, `getPrompt()` — that delegate to `Protocol.request()` with the appropriate request type. Each method checks server capabilities before sending (if `enforceStrictCapabilities` is enabled).
+
+`Client` also handles incoming requests from the server: `roots/list` (if the client advertised `roots` capability), `sampling/createMessage`, and `elicitation/create`. You register handlers for these on the client side.
+
+### Client Transport Hierarchy
+
+All client transports extend `AbstractClientTransport`, which provides a state machine (`ClientTransportState`) and three hooks that subclasses implement:
+
+- `initialize()` — open connections, launch I/O coroutines
+- `performSend(message, options)` — write a message to the wire
+- `closeResources()` — tear down connections, cancel coroutines
+
+The parent class gates `send()` on the `Operational` state and ensures `close()` is idempotent with exactly-once `onClose` callback semantics.
+
+```mermaid
+classDiagram
+ class AbstractClientTransport {
+ <>
+ #state: ClientTransportState
+ #initialize()*
+ #performSend(message, options)*
+ #closeResources()*
+ +start()
+ +send(message, options)
+ +close()
+ +markDisconnected()
+ +reconnect()
+ }
+
+ class StdioClientTransport {
+ -input: Source
+ -output: Sink
+ -error: Source?
+ -sendChannel: Channel
+ }
+
+ class StreamableHttpClientTransport {
+ -httpClient: HttpClient
+ -sessionId: String?
+ -reconnectionOptions
+ }
+
+ class SseClientTransport {
+ -client: HttpClient
+ -sseUrl: String
+ }
+
+ class ChannelTransport {
+ -sendChannel
+ -receiveChannel
+ +createLinkedPair()
+ }
+
+ AbstractClientTransport <|-- StdioClientTransport
+ AbstractClientTransport <|-- StreamableHttpClientTransport
+ AbstractClientTransport <|-- SseClientTransport
+ AbstractClientTransport <|-- ChannelTransport
+
+ note for SseClientTransport "Deprecated — use StreamableHttpClientTransport"
+```
+
+**`StdioClientTransport`** launches a child process and communicates via stdin/stdout. It spawns three coroutines: one reads stdout, one monitors stderr (with configurable severity classification), and one writes to stdin from a buffered channel.
+
+**`StreamableHttpClientTransport`** implements the Streamable HTTP transport spec. It sends each JSON-RPC message as an HTTP POST and optionally opens SSE streams for server-initiated messages. It handles session IDs, resumption tokens, priming events, and exponential-backoff reconnection — all internally.
+
+**`SseClientTransport`** implements the legacy HTTP+SSE transport (protocol version 2024-11-05). It's kept for backward compatibility with older servers.
+
+**`ChannelTransport`** (in `kotlin-sdk-testing`) connects a client and server through coroutine channels. `createLinkedPair()` returns two transports wired back-to-back — useful for tests that need to exercise the full protocol stack without network I/O.
+
+> **Deprecated transports:** `WebSocketClientTransport` and `SseClientTransport` are legacy transports from earlier MCP protocol versions. They still work but receive no new features. Use `StreamableHttpClientTransport` for all new HTTP-based integrations — it subsumes both SSE and WebSocket use cases and supports session management, resumption, and server-side disconnect.
+
+### Client Transport State Machine
+
+The client state machine includes `Disconnected` and `Reconnecting` states that enable transport-level reconnection without re-running the MCP initialization handshake:
+
+- `Operational → Disconnected` — triggered when the underlying connection drops.
+- `Disconnected → Reconnecting` — the transport attempts to re-establish the connection.
+- `Reconnecting → Operational` — success; the session resumes.
+- `Reconnecting → Disconnected` — failed attempt; the transport can retry.
+
+Calling `close()` from any non-terminal state transitions through `ShuttingDown` to `Stopped`. Terminal states (`InitializationFailed`, `Stopped`, `ShutdownFailed`) allow no further transitions.
+
+```mermaid
+stateDiagram-v2
+ [*] --> New
+ New --> Initializing : start()
+ New --> Stopped : close()
+ Initializing --> Operational : initialize() succeeds
+ Initializing --> InitializationFailed : initialize() throws
+ Operational --> Disconnected : connection lost
+ Operational --> ShuttingDown : close()
+ Disconnected --> Reconnecting : reconnect()
+ Disconnected --> ShuttingDown : close()
+ Disconnected --> Stopped : close()
+ Reconnecting --> Operational : reconnection succeeds
+ Reconnecting --> Disconnected : reconnection fails (retry)
+ Reconnecting --> ShuttingDown : close()
+ Reconnecting --> Stopped : close()
+ ShuttingDown --> Stopped : closeResources() succeeds
+ ShuttingDown --> ShutdownFailed : closeResources() throws
+
+ InitializationFailed --> [*]
+ Stopped --> [*]
+ ShutdownFailed --> [*]
+```
+
+## Server Architecture
+
+### How the Server Works
+
+`Server` is a session manager and feature registry. It doesn't extend `Protocol` — instead, it creates `ServerSession` instances (which do extend `Protocol`) for each connected client.
+
+When a transport connection arrives, you call `server.createSession(transport)`. This:
+
+1. Creates a `ServerSession` with the server's capabilities and info.
+2. Registers request handlers for all enabled capabilities (tools, prompts, resources) on that session.
+3. Calls `session.connect(transport)` — which starts the transport and waits for the client's `InitializeRequest`.
+4. Adds the session to the `ServerSessionRegistry` and subscribes it to feature-change notifications.
+
+From there, the `ServerSession` handles the MCP initialization handshake. When it receives `InitializeRequest`, it stores the client's capabilities (via atomic CAS to prevent double-init) and returns `InitializeResult`. When it receives `InitializedNotification`, it fires the `onInitialized` callback.
+
+### Server Class Hierarchy
+
+The server side has two parallel hierarchies:
+
+**Transport hierarchy**: `AbstractServerTransport` provides state management for server transports, mirroring the client-side `AbstractClientTransport` pattern with the same three hooks (`initialize`, `performSend`, `closeResources`).
+
+**Protocol hierarchy**: `ServerSession` extends `Protocol` and adds MCP server semantics — initialization handling, capability assertions, and client connection management.
+
+`Server` itself is a plain class that composes sessions and registries.
+
+```mermaid
+classDiagram
+ class Transport {
+ <>
+ +start()
+ +send(message, options)
+ +close()
+ +onClose(block)
+ +onError(block)
+ +onMessage(block)
+ }
+
+ class AbstractTransport {
+ <>
+ #_onClose
+ #_onError
+ #_onMessage
+ #invokeOnCloseCallback()
+ }
+
+ class AbstractServerTransport {
+ <>
+ #state: ServerTransportState
+ #initialize()*
+ #performSend(message, options)*
+ #closeResources()*
+ +start()
+ +send(message, options)
+ +close()
+ }
+
+ class StdioServerTransport {
+ -readChannel
+ -writeChannel
+ -scope: CoroutineScope
+ }
+
+ class StreamableHttpServerTransport {
+ -initialized: AtomicBoolean
+ -streamsMapping
+ +handleRequest(session, call)
+ +handlePostRequest(session, call)
+ +handleGetRequest(session, call)
+ +handleDeleteRequest(call)
+ }
+
+ class Protocol {
+ <>
+ +transport: Transport?
+ +requestHandlers
+ +notificationHandlers
+ -handlerScope: CoroutineScope
+ -_activeRequests
+ +connect(transport)
+ +close()
+ +request(request, options)
+ +notification(notification)
+ }
+
+ class ServerSession {
+ +sessionId: String
+ +clientCapabilities
+ +clientVersion
+ -handleInitialize()
+ +onInitialized(block)
+ }
+
+ class Server {
+ +sessions
+ +tools / prompts / resources
+ +createSession(transport)
+ +addTool() / addPrompt() / addResource()
+ +close()
+ }
+
+ Transport <|.. AbstractTransport
+ AbstractTransport <|-- AbstractServerTransport
+ AbstractServerTransport <|-- StdioServerTransport
+ AbstractServerTransport <|-- StreamableHttpServerTransport
+ Protocol <|-- ServerSession
+ Server *-- ServerSession : manages
+ Server *-- FeatureRegistry : tools, prompts, resources
+ ServerSession --> Transport : uses
+```
+
+> **Deprecated transports:** `SseServerTransport` and `WebSocketMcpServerTransport` are legacy server transports. They extend `AbstractTransport` directly (not `AbstractServerTransport`) and use ad-hoc `AtomicBoolean` state guards instead of the state machine. They still function but are not actively developed. New servers should use `StdioServerTransport` or `StreamableHttpServerTransport`.
+
+### Server Transport State Machine
+
+Server transports use `ServerTransportState`, which is simpler than the client-side equivalent — no reconnection states. When a client disconnects, the server transport closes. Session recovery across reconnections is handled at the `Server` level, not the transport level.
+
+The state flow is linear: `New → Initializing → Active → ShuttingDown → Stopped`, with failure branches at initialization and shutdown. Terminal states (`InitializationFailed`, `Stopped`, `ShutdownFailed`) accept no further transitions. Calling `close()` on a `New` transport goes directly to `Stopped` without running `closeResources()` (nothing was initialized).
+
+```mermaid
+stateDiagram-v2
+ [*] --> New
+ New --> Initializing : start()
+ New --> Stopped : close()
+ Initializing --> Active : initialize() succeeds
+ Initializing --> InitializationFailed : initialize() throws
+ Active --> ShuttingDown : close()
+ ShuttingDown --> Stopped : closeResources() succeeds
+ ShuttingDown --> ShutdownFailed : closeResources() throws
+
+ InitializationFailed --> [*]
+ Stopped --> [*]
+ ShutdownFailed --> [*]
+```
+
+### Server Components
+
+`Server` composes several internal components:
+
+- **`ServerSessionRegistry`** — thread-safe map of active sessions, keyed by session ID. Sessions register on connect and deregister when their transport closes.
+- **`FeatureRegistry`** — generic, thread-safe registry used for tools, prompts, and resources. Supports add/remove/get with change listeners.
+- **`FeatureNotificationService`** — listens for registry changes and broadcasts `tools/list_changed`, `prompts/list_changed`, or `resources/list_changed` notifications to all subscribed sessions.
+- **`ClientConnection`** — a facade on `ServerSession` that exposes server-to-client operations: `createMessage()` (sampling), `createElicitation()`, `listRoots()`, `sendLoggingMessage()`, etc. Tool and prompt handlers receive this as their receiver, so they can call back to the client during execution.
+
+```mermaid
+graph TB
+ Server --> SessionRegistry
+ Server --> ToolRegistry["FeatureRegistry<Tool>"]
+ Server --> PromptRegistry["FeatureRegistry<Prompt>"]
+ Server --> ResourceRegistry["FeatureRegistry<Resource>"]
+ Server --> NotificationService["FeatureNotificationService"]
+
+ SessionRegistry --> Session1["ServerSession #1"]
+ SessionRegistry --> Session2["ServerSession #2"]
+
+ Session1 --> Transport1["Transport (Streamable HTTP)"]
+ Session2 --> Transport2["Transport (stdio)"]
+
+ Session1 --> ClientConn1["ClientConnection"]
+ Session2 --> ClientConn2["ClientConnection"]
+
+ NotificationService --> Session1
+ NotificationService --> Session2
+```
+
+### Session Lifecycle
+
+Every MCP connection starts with a three-step handshake defined by the [MCP lifecycle spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle). The sequence below shows this from the server's perspective — the client initiates by sending `initialize`, the server responds with its capabilities, and the client confirms with `notifications/initialized`.
+
+Only after this handshake completes can either side send operational requests like `tools/call` or `resources/read`.
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Transport
+ participant ServerSession
+ participant Server
+
+ Client->>Transport: POST initialize
+ Transport->>ServerSession: onMessage(InitializeRequest)
+ ServerSession->>ServerSession: handleInitialize()
+ ServerSession->>Transport: send(InitializeResult)
+ Transport->>Client: Response
+
+ Client->>Transport: POST notifications/initialized
+ Transport->>ServerSession: onMessage(InitializedNotification)
+ ServerSession->>ServerSession: _onInitialized()
+
+ Note over Client,Server: Operational Phase
+
+ Client->>Transport: POST tools/call
+ Transport->>ServerSession: onMessage(CallToolRequest)
+ ServerSession->>Server: handleCallTool()
+ Server->>ServerSession: CallToolResult
+ ServerSession->>Transport: send(CallToolResult)
+ Transport->>Client: Response
+```
+
+## Concurrency Model
+
+### Concurrent Request Handling
+
+`Protocol.onRequest()` launches each incoming request handler in a supervised coroutine via `handlerScope`. This is essential for MCP features that require bidirectional communication during request handling — for example, a tool call that needs to elicit user input or sample from the LLM while executing.
+
+Without concurrent dispatch, a long-running tool call would block all other request processing, including ping responses. With it, the server (or client) can handle multiple requests in flight simultaneously.
+
+**Context inheritance.** `handlerScope` inherits its `CoroutineContext` from whoever calls `connect()`. The Protocol does not impose a specific dispatcher — the caller controls threading. In production, a server typically calls `connect()` from a Ktor dispatcher or `Dispatchers.IO` scope; in tests, `runTest` provides a `TestCoroutineScheduler` that the handlers inherit automatically. If you need to isolate handler threads from transport I/O, call `connect()` from a dedicated scope (e.g., `Dispatchers.Default.limitedParallelism(16)`), or use `withContext` inside individual handlers.
+
+**Cancellation.** Active request jobs are tracked in `_activeRequests` (keyed by request ID). Two cancellation paths exist:
+
+- **Remote cancellation**: when a `notifications/cancelled` message arrives, the corresponding handler job is cancelled via `_activeRequests[requestId]?.cancel()`.
+- **Local cancellation**: when the calling coroutine is cancelled (e.g., `job.cancel()`), `Protocol.request()` sends a `CancelledNotification` to the remote side (via `NonCancellable` to guarantee delivery even during cancellation), then rethrows.
+
+On `close()`, the entire `handlerScope` is cancelled.
+
+**Example: tool that elicits user input during execution.** Without concurrent dispatch, the elicitation response from the client would be blocked behind the tool handler — a deadlock. With concurrent dispatch, the server handles the elicitation response on a separate coroutine while the tool handler awaits:
+
+```kotlin
+server.addTool("confirm-action", "Asks user to confirm") {
+ // This sends a request TO the client and suspends until the user responds.
+ // Other incoming requests (including the elicitation response) are handled
+ // concurrently by Protocol's handlerScope.
+ val result = createElicitation(
+ message = "Confirm deployment to production?",
+ requestedSchema = ElicitRequestParams.RequestedSchema(
+ properties = mapOf("confirm" to BooleanSchema(description = "Confirm?")),
+ ),
+ )
+ CallToolResult(content = listOf(TextContent(text = "User said: ${result.action}")))
+}
+```
+
+**Example: controlling the handler dispatcher.** The caller of `connect()` decides the threading model:
+
+```kotlin
+// Production: handlers run on a dedicated pool, isolated from transport I/O
+val handlerPool = Dispatchers.Default.limitedParallelism(16)
+CoroutineScope(handlerPool).launch {
+ session.connect(transport)
+}
+
+// Test: handlers inherit the test scheduler — virtual time, no real threads
+@Test
+fun `should handle concurrent requests`() = runTest {
+ client.connect(transport) // handlerScope inherits TestCoroutineScheduler
+ // ...
+}
+```
+
+**Example: cancelling a request.** The client cancels a long-running tool call; the server handler is cancelled cooperatively:
+
+```kotlin
+// Client side
+val job = launch { client.callTool("slow-tool", emptyMap()) }
+// ... later
+job.cancel("User pressed cancel")
+// Protocol.request() sends CancelledNotification to server via NonCancellable,
+// server's _activeRequests[requestId] job is cancelled
+
+// Server side — handler must be cooperative
+server.addTool("slow-tool", "Takes a long time") {
+ repeat(100) { step ->
+ delay(100) // suspension point — CancellationException thrown here
+ reportProgress(step, 100)
+ }
+ CallToolResult(content = listOf(TextContent(text = "Done")))
+}
+```
+
+```mermaid
+sequenceDiagram
+ participant Client
+ participant Protocol
+ participant HandlerScope
+
+ Client->>Protocol: Request A (slow tool call)
+ Protocol->>HandlerScope: launch { handleA() }
+
+ Client->>Protocol: Request B (ping)
+ Protocol->>HandlerScope: launch { handleB() }
+
+ HandlerScope-->>Protocol: Response B (ping)
+ Protocol-->>Client: Response B
+
+ Note over HandlerScope: handleA() still running...
+
+ HandlerScope-->>Protocol: Response A
+ Protocol-->>Client: Response A
+```
+
+### Streamable HTTP Request Flow
+
+`StreamableHttpServerTransport` is the most complex transport. Each POST creates a new stream context, and the transport must keep the HTTP connection alive until all responses for that request batch are delivered.
+
+The transport supports two response modes:
+
+- **SSE mode** (default) — the POST response is an SSE stream. The server sends JSON-RPC messages as SSE events, then closes the stream.
+- **JSON mode** — the POST response is a single JSON body containing the response(s).
+
+In both modes, the transport sets up a stream-to-request mapping before dispatching messages to the protocol layer. Because request handlers run asynchronously (launched by `Protocol`), the transport awaits a batch-completion signal before returning from `handlePostRequest`. This prevents the HTTP connection from closing prematurely.
+
+```mermaid
+sequenceDiagram
+ participant Client as HTTP Client
+ participant Ktor as Ktor Route Handler
+ participant Transport as StreamableHttpServerTransport
+ participant Protocol
+ participant Handler as Request Handler
+
+ Client->>Ktor: POST /mcp (JSON-RPC request)
+ Ktor->>Transport: handlePostRequest(session, call)
+ Transport->>Transport: validateSession(), parseBody()
+ Transport->>Transport: set up stream mapping
+ Transport->>Protocol: _onMessage(request)
+ Protocol->>Handler: launch { handler(request) }
+
+ Note over Transport: awaits batch completion
+
+ Handler-->>Protocol: result
+ Protocol->>Transport: send(JSONRPCResponse)
+ Transport->>Transport: route to correct stream
+
+ alt SSE mode
+ Transport->>Client: SSE event: message
+ Transport->>Transport: close SSE session
+ else JSON mode
+ Transport->>Client: HTTP 200 JSON response
+ end
+
+ Transport->>Transport: signal batch complete
+ Transport-->>Ktor: return
+```
+
+## Capability Enforcement
+
+Both `Client` and `ServerSession` implement three abstract methods from `Protocol` that enforce capability contracts:
+
+- **`assertCapabilityForMethod(method)`** — checks the *remote* side's capabilities before sending a request. For example, the server checks `clientCapabilities.sampling` before sending `sampling/createMessage`.
+- **`assertNotificationCapability(method)`** — checks the *local* side's capabilities before sending a notification. For example, the server checks its own `serverCapabilities.tools` before sending `tools/list_changed`.
+- **`assertRequestHandlerCapability(method)`** — checks the *local* side's capabilities when registering a handler. Prevents you from registering a `tools/call` handler if the server didn't declare `tools` capability.
+
+When `enforceStrictCapabilities` is enabled (default for both client and server), `Protocol.request()` calls `assertCapabilityForMethod()` before sending. This catches capability mismatches at the call site rather than as a runtime error from the remote side.
+
+## Testing
+
+The SDK provides several testing utilities:
+
+- **`ChannelTransport.createLinkedPair()`** — returns a client and server transport connected by coroutine channels. Use this for unit tests that need the full protocol stack without network I/O.
+- **`AbstractServerTransportTest` / `AbstractClientTransportTest`** — comprehensive state machine tests that use a `TestTransport` stub. If you write a custom transport, model your tests after these.
+- **`runIntegrationTest()`** (from `test-utils`) — a helper that wraps `runBlocking` with `Dispatchers.IO` and a configurable timeout. Use it for JVM tests that need real concurrency.
+- **Conformance tests** — run the official MCP test suite against the SDK's sample server. Requires Node.js.
diff --git a/docs/build.gradle.kts b/docs/build.gradle.kts
index 1857ef1a3..e30073640 100644
--- a/docs/build.gradle.kts
+++ b/docs/build.gradle.kts
@@ -18,7 +18,10 @@ tasks.matching {
knit {
rootDir = project.rootDir
- files = files(project.rootDir.resolve("README.md"))
+ files = files(
+ project.rootDir.resolve("README.md"),
+ project.rootDir.resolve("docs/architecture.md"),
+ )
defaultLineSeparator = "\n"
siteRoot = "" // Disable site root validation
}
diff --git a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt
index d0acda59d..64a2dde24 100644
--- a/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt
+++ b/integration-test/src/commonTest/kotlin/io/modelcontextprotocol/kotlin/sdk/client/ClientTest.kt
@@ -1,6 +1,7 @@
package io.modelcontextprotocol.kotlin.sdk.client
import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldContain
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.ServerSession
@@ -8,6 +9,7 @@ import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport
import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions
import io.modelcontextprotocol.kotlin.sdk.types.BooleanSchema
+import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageRequest
import io.modelcontextprotocol.kotlin.sdk.types.CreateMessageResult
@@ -47,6 +49,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
import io.modelcontextprotocol.kotlin.sdk.types.UntitledMultiSelectEnumSchema
import io.modelcontextprotocol.kotlin.sdk.types.UntitledSingleSelectEnumSchema
import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
@@ -67,6 +70,8 @@ import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.test.fail
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
class ClientTest {
@Test
@@ -387,7 +392,10 @@ class ClientTest {
val ex = assertFailsWith {
client.listPrompts()
}
- assertTrue(ex.message?.contains("Server does not support prompts") == true)
+ ex.message shouldContain "Server does not support prompts"
+
+ client.close()
+ server.close()
}
@Test
@@ -441,6 +449,10 @@ class ClientTest {
clientWithoutCapability.sendRootsListChanged()
}
assertTrue(ex.message?.startsWith("Client does not support") == true)
+
+ client.close()
+ clientWithoutCapability.close()
+ server.close()
}
@Test
@@ -499,6 +511,9 @@ class ClientTest {
serverSession.sendToolListChanged()
}
assertTrue(ex.message?.contains("Server does not support notifying of tool list changes") == true)
+
+ client.close()
+ server.close()
}
@Test
@@ -543,7 +558,7 @@ class ClientTest {
// Simulate delay
def.complete(Unit)
try {
- delay(1000)
+ delay(1.seconds)
} catch (e: CancellationException) {
defTimeOut.complete(Unit)
throw e
@@ -565,6 +580,9 @@ class ClientTest {
runCatching { job.cancel("Cancelled by test") }
defCancel.await()
defTimeOut.await()
+
+ client.close()
+ server.close()
}
@Test
@@ -602,26 +620,20 @@ class ClientTest {
val serverSession = serverSessionResult.await()
serverSession.setRequestHandler(Method.Defined.ResourcesList) { _, _ ->
- // Simulate a delayed response
- // Wait ~100ms unless canceled
- try {
- withTimeout(100L) {
- // Just delay here, if timeout is 0 on the client side, this won't return in time
- delay(100)
- }
- } catch (_: Exception) {
- // If aborted, just rethrow or return early
- }
+ delay(100.milliseconds)
ListResourcesResult(resources = emptyList())
}
// Request with 1 msec timeout should fail immediately
val ex = assertFailsWith {
- withTimeout(1) {
+ withTimeout(1.milliseconds) {
client.listResources()
}
}
assertTrue(ex is TimeoutCancellationException)
+
+ client.close()
+ server.close()
}
@Test
@@ -654,88 +666,43 @@ class ClientTest {
}
@Test
- fun `JSONRPCRequest with ToolsList method and default params returns list of tools`() = runTest {
- val serverOptions = ServerOptions(
- capabilities = ServerCapabilities(
- tools = ServerCapabilities.Tools(null),
+ fun `listTools returns list of tools`() = runTest {
+ val expectedTools = listOf(
+ Tool(
+ name = "testTool",
+ title = "testTool title",
+ description = "testTool description",
+ annotations = null,
+ inputSchema = ToolSchema(),
+ outputSchema = null,
),
)
+
val server = Server(
Implementation(name = "test server", version = "1.0"),
- serverOptions,
- )
+ ServerOptions(capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(null))),
+ ) {
+ addTool(expectedTools[0]) { CallToolResult(content = emptyList()) }
+ }
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val client = Client(
clientInfo = Implementation(name = "test client", version = "1.0"),
- options = ClientOptions(
- capabilities = ClientCapabilities(sampling = EmptyJsonObject),
- ),
- )
-
- var receivedMessage: JSONRPCMessage? = null
- clientTransport.onMessage { msg ->
- receivedMessage = msg
- }
-
- val serverSessionResult = CompletableDeferred()
-
- listOf(
- launch {
- client.connect(clientTransport)
- println("Client connected")
- },
- launch {
- serverSessionResult.complete(server.createSession(serverTransport))
- println("Server connected")
- },
- ).joinAll()
-
- val serverSession = serverSessionResult.await()
-
- serverSession.setRequestHandler(Method.Defined.Initialize) { _, _ ->
- InitializeResult(
- protocolVersion = LATEST_PROTOCOL_VERSION,
- capabilities = ServerCapabilities(
- resources = ServerCapabilities.Resources(null, null),
- tools = ServerCapabilities.Tools(null),
- ),
- serverInfo = Implementation(name = "test", version = "1.0"),
- )
- }
-
- val serverListToolsResult = ListToolsResult(
- tools = listOf(
- Tool(
- name = "testTool",
- title = "testTool title",
- description = "testTool description",
- annotations = null,
- inputSchema = ToolSchema(),
- outputSchema = null,
- ),
- ),
- nextCursor = null,
+ options = ClientOptions(),
)
- serverSession.setRequestHandler(Method.Defined.ToolsList) { _, _ ->
- serverListToolsResult
- }
+ connectClientServer(client, server, clientTransport, serverTransport)
- val serverCapabilities = client.serverCapabilities
- assertEquals(ServerCapabilities.Tools(null), serverCapabilities?.tools)
+ val result = client.listTools()
- val request = JSONRPCRequest(
- method = Method.Defined.ToolsList.value,
- )
- clientTransport.send(request)
+ assertEquals(expectedTools.size, result.tools.size)
+ assertEquals(expectedTools[0].name, result.tools[0].name)
+ assertEquals(expectedTools[0].title, result.tools[0].title)
+ assertEquals(expectedTools[0].description, result.tools[0].description)
- assertIs(receivedMessage)
- val receivedAsResponse = receivedMessage as JSONRPCResponse
- assertEquals(request.id, receivedAsResponse.id)
- assertEquals(request.jsonrpc, receivedAsResponse.jsonrpc)
- assertEquals(serverListToolsResult, receivedAsResponse.result)
+ client.close()
+ server.close()
}
@Test
@@ -785,6 +752,9 @@ class ClientTest {
val listRootsResult = serverSession.listRoots()
assertEquals(listRootsResult.roots, clientRoots)
+
+ client.close()
+ server.close()
}
@Test
@@ -923,6 +893,9 @@ class ClientTest {
rootListChangedNotificationReceived,
"Notification should be sent when sendRootsListChanged is called",
)
+
+ client.close()
+ server.close()
}
@Test
@@ -972,6 +945,9 @@ class ClientTest {
"Client does not support elicitation (required for elicitation/create)",
exception.message,
)
+
+ client.close()
+ server.close()
}
@Test
@@ -1039,7 +1015,7 @@ class ClientTest {
)
}
- delay(100)
+ delay(100.milliseconds)
// Only warning and error should be received
assertEquals(2, receivedMessages.size, "Should receive only 2 messages (warning and error)")
@@ -1052,6 +1028,9 @@ class ClientTest {
"Received message with level ${message.params.level} should have severity >= $minLevel",
)
}
+
+ client.close()
+ server.close()
}
@Test
@@ -1118,6 +1097,9 @@ class ClientTest {
assertEquals(elicitationResultAction, result.action)
assertEquals(elicitationResultContent, result.content)
+
+ client.close()
+ server.close()
}
@Test
@@ -1290,4 +1272,18 @@ class ClientTest {
client to serverSessionResult.await()
}
+
+ private suspend fun CoroutineScope.connectClientServer(
+ client: Client,
+ server: Server,
+ clientTransport: InMemoryTransport,
+ serverTransport: InMemoryTransport,
+ ): ServerSession {
+ val result = CompletableDeferred()
+ listOf(
+ launch { client.connect(clientTransport) },
+ launch { result.complete(server.createSession(serverTransport)) },
+ ).joinAll()
+ return result.await()
+ }
}
diff --git a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt
index 5031a9c06..232df8d84 100644
--- a/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt
+++ b/integration-test/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerSessionInitializeTest.kt
@@ -1,5 +1,7 @@
package io.modelcontextprotocol.kotlin.sdk.server
+import io.kotest.matchers.collections.shouldHaveSize
+import io.kotest.matchers.shouldBe
import io.modelcontextprotocol.kotlin.sdk.shared.InMemoryTransport
import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
@@ -23,7 +25,6 @@ import java.util.concurrent.CopyOnWriteArrayList
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
-import kotlin.test.assertTrue
class ServerSessionInitializeTest {
@@ -94,13 +95,16 @@ class ServerSessionInitializeTest {
secondResponseDone.await()
- assertEquals(2, responses.size)
- assertTrue(responses[0] is JSONRPCResponse, "First response should be success")
- assertTrue(responses[1] is JSONRPCError, "Second response should be error")
- assertEquals(RPCError.ErrorCode.INVALID_REQUEST, (responses[1] as JSONRPCError).error.code)
+ responses.size shouldBe 2
+ // With concurrent dispatch, responses may arrive in any order
+ val successResponses = responses.filterIsInstance()
+ val errorResponses = responses.filterIsInstance()
+ successResponses.shouldHaveSize(1)
+ errorResponses.shouldHaveSize(1)
+ errorResponses[0].error.code shouldBe RPCError.ErrorCode.INVALID_REQUEST
// Capabilities still reflect the first client, not overwritten
- assertEquals("first-client", session.clientVersion?.name)
+ session.clientVersion?.name shouldBe "first-client"
}
@Test
diff --git a/integration-test/src/jvmTest/typescript/package-lock.json b/integration-test/src/jvmTest/typescript/package-lock.json
index ca30f6578..1a0a378ec 100644
--- a/integration-test/src/jvmTest/typescript/package-lock.json
+++ b/integration-test/src/jvmTest/typescript/package-lock.json
@@ -91,8 +91,6 @@
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.2",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
- "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
"cpu": [
"arm64"
],
@@ -465,8 +463,6 @@
},
"node_modules/@hono/node-server": {
"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"
@@ -477,8 +473,6 @@
},
"node_modules/@modelcontextprotocol/sdk": {
"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",
@@ -517,8 +511,6 @@
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
- "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
- "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -528,8 +520,6 @@
},
"node_modules/@types/connect": {
"version": "3.4.38",
- "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
- "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -538,8 +528,6 @@
},
"node_modules/@types/cors": {
"version": "2.8.19",
- "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
- "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -548,8 +536,6 @@
},
"node_modules/@types/express": {
"version": "5.0.6",
- "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
- "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -560,8 +546,6 @@
},
"node_modules/@types/express-serve-static-core": {
"version": "5.1.1",
- "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
- "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -573,15 +557,11 @@
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
- "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
- "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.5.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
- "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -590,22 +570,16 @@
},
"node_modules/@types/qs": {
"version": "6.14.0",
- "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
- "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
- "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
- "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
- "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -614,8 +588,6 @@
},
"node_modules/@types/serve-static": {
"version": "2.2.0",
- "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
- "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -625,8 +597,6 @@
},
"node_modules/accepts": {
"version": "2.0.0",
- "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
- "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
@@ -638,8 +608,6 @@
},
"node_modules/ajv": {
"version": "8.18.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
- "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -654,8 +622,6 @@
},
"node_modules/ajv-formats": {
"version": "3.0.1",
- "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
- "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
@@ -671,8 +637,6 @@
},
"node_modules/body-parser": {
"version": "2.2.2",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
- "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
@@ -695,8 +659,6 @@
},
"node_modules/bytes": {
"version": "3.1.2",
- "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
- "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -704,8 +666,6 @@
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
- "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
- "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -717,8 +677,6 @@
},
"node_modules/call-bound": {
"version": "1.0.4",
- "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
- "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -733,8 +691,6 @@
},
"node_modules/content-disposition": {
"version": "1.0.1",
- "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
- "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -746,8 +702,6 @@
},
"node_modules/content-type": {
"version": "1.0.5",
- "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
- "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -755,8 +709,6 @@
},
"node_modules/cookie": {
"version": "0.7.2",
- "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
- "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -764,8 +716,6 @@
},
"node_modules/cookie-signature": {
"version": "1.2.2",
- "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
- "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
@@ -773,8 +723,6 @@
},
"node_modules/cors": {
"version": "2.8.6",
- "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
- "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
@@ -790,8 +738,6 @@
},
"node_modules/cross-spawn": {
"version": "7.0.6",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
- "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -804,8 +750,6 @@
},
"node_modules/debug": {
"version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -821,8 +765,6 @@
},
"node_modules/depd": {
"version": "2.0.0",
- "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
- "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -830,8 +772,6 @@
},
"node_modules/dunder-proto": {
"version": "1.0.1",
- "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
- "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -844,14 +784,10 @@
},
"node_modules/ee-first": {
"version": "1.1.1",
- "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
- "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
- "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
- "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -859,8 +795,6 @@
},
"node_modules/es-define-property": {
"version": "1.0.1",
- "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
- "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -868,8 +802,6 @@
},
"node_modules/es-errors": {
"version": "1.3.0",
- "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
- "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -877,8 +809,6 @@
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
- "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
- "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -889,8 +819,6 @@
},
"node_modules/esbuild": {
"version": "0.27.2",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
- "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -931,14 +859,10 @@
},
"node_modules/escape-html": {
"version": "1.0.3",
- "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
- "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
- "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
- "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -946,8 +870,6 @@
},
"node_modules/eventsource": {
"version": "3.0.7",
- "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
- "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
@@ -958,8 +880,6 @@
},
"node_modules/eventsource-parser": {
"version": "3.0.6",
- "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
- "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
@@ -967,8 +887,6 @@
},
"node_modules/express": {
"version": "5.2.1",
- "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
- "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
@@ -1010,8 +928,6 @@
},
"node_modules/express-rate-limit": {
"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.1.0"
@@ -1028,14 +944,10 @@
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
- "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
@@ -1050,8 +962,6 @@
},
"node_modules/finalhandler": {
"version": "2.1.1",
- "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
- "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
@@ -1071,8 +981,6 @@
},
"node_modules/forwarded": {
"version": "0.2.0",
- "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
- "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -1080,8 +988,6 @@
},
"node_modules/fresh": {
"version": "2.0.0",
- "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
- "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -1089,8 +995,6 @@
},
"node_modules/fsevents": {
"version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1103,8 +1007,6 @@
},
"node_modules/function-bind": {
"version": "1.1.2",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
- "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -1112,8 +1014,6 @@
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
- "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -1136,8 +1036,6 @@
},
"node_modules/get-proto": {
"version": "1.0.1",
- "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
- "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -1149,8 +1047,6 @@
},
"node_modules/get-tsconfig": {
"version": "4.13.0",
- "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
- "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1162,8 +1058,6 @@
},
"node_modules/gopd": {
"version": "1.2.0",
- "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
- "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -1174,8 +1068,6 @@
},
"node_modules/has-symbols": {
"version": "1.1.0",
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
- "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -1186,8 +1078,6 @@
},
"node_modules/hasown": {
"version": "2.0.2",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -1198,8 +1088,6 @@
},
"node_modules/hono": {
"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"
@@ -1207,8 +1095,6 @@
},
"node_modules/http-errors": {
"version": "2.0.1",
- "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
- "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
@@ -1227,8 +1113,6 @@
},
"node_modules/iconv-lite": {
"version": "0.7.2",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
- "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -1243,14 +1127,10 @@
},
"node_modules/inherits": {
"version": "2.0.4",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"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"
@@ -1258,8 +1138,6 @@
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
- "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
- "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
@@ -1267,20 +1145,14 @@
},
"node_modules/is-promise": {
"version": "4.0.0",
- "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
- "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
- "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
- "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/jose": {
"version": "6.1.3",
- "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
- "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -1288,20 +1160,14 @@
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
- "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
- "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/json-schema-typed": {
"version": "8.0.2",
- "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
- "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
- "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
- "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -1309,8 +1175,6 @@
},
"node_modules/media-typer": {
"version": "1.1.0",
- "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
- "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -1318,8 +1182,6 @@
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
- "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
- "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -1330,8 +1192,6 @@
},
"node_modules/mime-db": {
"version": "1.54.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
- "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -1339,8 +1199,6 @@
},
"node_modules/mime-types": {
"version": "3.0.2",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
- "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
@@ -1355,14 +1213,10 @@
},
"node_modules/ms": {
"version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
- "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
- "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -1370,8 +1224,6 @@
},
"node_modules/object-assign": {
"version": "4.1.1",
- "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
- "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -1379,8 +1231,6 @@
},
"node_modules/object-inspect": {
"version": "1.13.4",
- "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
- "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -1391,8 +1241,6 @@
},
"node_modules/on-finished": {
"version": "2.4.1",
- "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
- "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
@@ -1403,8 +1251,6 @@
},
"node_modules/once": {
"version": "1.4.0",
- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
- "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
@@ -1412,8 +1258,6 @@
},
"node_modules/parseurl": {
"version": "1.3.3",
- "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
- "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -1421,8 +1265,6 @@
},
"node_modules/path-key": {
"version": "3.1.1",
- "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
- "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
@@ -1430,8 +1272,6 @@
},
"node_modules/path-to-regexp": {
"version": "8.4.0",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
- "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -1440,8 +1280,6 @@
},
"node_modules/pkce-challenge": {
"version": "5.0.1",
- "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
- "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"license": "MIT",
"engines": {
"node": ">=16.20.0"
@@ -1449,8 +1287,6 @@
},
"node_modules/proxy-addr": {
"version": "2.0.7",
- "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
- "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@@ -1462,8 +1298,6 @@
},
"node_modules/qs": {
"version": "6.14.2",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
- "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -1477,8 +1311,6 @@
},
"node_modules/range-parser": {
"version": "1.2.1",
- "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
- "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -1486,8 +1318,6 @@
},
"node_modules/raw-body": {
"version": "3.0.2",
- "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
- "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@@ -1501,8 +1331,6 @@
},
"node_modules/require-from-string": {
"version": "2.0.2",
- "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
- "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -1510,8 +1338,6 @@
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
- "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
- "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1520,8 +1346,6 @@
},
"node_modules/router": {
"version": "2.2.0",
- "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
- "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
@@ -1536,14 +1360,10 @@
},
"node_modules/safer-buffer": {
"version": "2.1.2",
- "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
- "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
- "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
- "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
@@ -1568,8 +1388,6 @@
},
"node_modules/serve-static": {
"version": "2.2.1",
- "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
- "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
@@ -1587,14 +1405,10 @@
},
"node_modules/setprototypeof": {
"version": "1.2.0",
- "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
- "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
- "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
- "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -1605,8 +1419,6 @@
},
"node_modules/shebang-regex": {
"version": "3.0.0",
- "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
- "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
@@ -1614,8 +1426,6 @@
},
"node_modules/side-channel": {
"version": "1.1.0",
- "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
- "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -1633,8 +1443,6 @@
},
"node_modules/side-channel-list": {
"version": "1.0.0",
- "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
- "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -1649,8 +1457,6 @@
},
"node_modules/side-channel-map": {
"version": "1.0.1",
- "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
- "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -1667,8 +1473,6 @@
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
- "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
- "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -1686,8 +1490,6 @@
},
"node_modules/statuses": {
"version": "2.0.2",
- "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
- "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -1695,8 +1497,6 @@
},
"node_modules/toidentifier": {
"version": "1.0.1",
- "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
- "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
@@ -1704,8 +1504,6 @@
},
"node_modules/tsx": {
"version": "4.21.0",
- "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
- "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1724,8 +1522,6 @@
},
"node_modules/type-is": {
"version": "2.0.1",
- "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
- "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
@@ -1738,8 +1534,6 @@
},
"node_modules/typescript": {
"version": "6.0.2",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
- "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -1752,15 +1546,11 @@
},
"node_modules/undici-types": {
"version": "7.18.2",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
- "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
- "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
- "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -1768,8 +1558,6 @@
},
"node_modules/vary": {
"version": "1.1.2",
- "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
- "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
@@ -1777,8 +1565,6 @@
},
"node_modules/which": {
"version": "2.0.2",
- "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
- "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -1792,14 +1578,10 @@
},
"node_modules/wrappy": {
"version": "1.0.2",
- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/zod": {
"version": "4.3.6",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
- "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -1807,8 +1589,6 @@
},
"node_modules/zod-to-json-schema": {
"version": "3.25.1",
- "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
- "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25 || ^4"
diff --git a/kotlin-sdk-core/api/kotlin-sdk-core.api b/kotlin-sdk-core/api/kotlin-sdk-core.api
index d4f88ba47..52616edab 100644
--- a/kotlin-sdk-core/api/kotlin-sdk-core.api
+++ b/kotlin-sdk-core/api/kotlin-sdk-core.api
@@ -40,10 +40,12 @@ public abstract class io/modelcontextprotocol/kotlin/sdk/shared/AbstractTranspor
}
public final class io/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState : java/lang/Enum {
+ public static final field Disconnected Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState;
public static final field InitializationFailed Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState;
public static final field Initializing Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState;
public static final field New Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState;
public static final field Operational Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState;
+ public static final field Reconnecting Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState;
public static final field ShutdownFailed Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState;
public static final field ShuttingDown Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState;
public static final field Stopped Lio/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState;
diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport.kt
index 5f679992e..092d6579b 100644
--- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport.kt
+++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransport.kt
@@ -231,13 +231,21 @@ public abstract class AbstractClientTransport : AbstractTransport() {
val performClose: Boolean
when (state) {
ClientTransportState.Operational -> {
- // Only Operational state can transition to ShuttingDown
stateTransition(ClientTransportState.Operational, ClientTransportState.ShuttingDown)
performClose = true
}
+ ClientTransportState.Disconnected -> {
+ stateTransition(ClientTransportState.Disconnected, ClientTransportState.ShuttingDown)
+ performClose = true
+ }
+
+ ClientTransportState.Reconnecting -> {
+ stateTransition(ClientTransportState.Reconnecting, ClientTransportState.ShuttingDown)
+ performClose = true
+ }
+
ClientTransportState.New -> {
- // New state transitions directly to Stopped without any cleanup
stateTransition(ClientTransportState.New, ClientTransportState.Stopped)
performClose = false
}
diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState.kt
index 4f14d0321..a8aefba9b 100644
--- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState.kt
+++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ClientTransportState.kt
@@ -37,6 +37,22 @@ public enum class ClientTransportState {
*/
Operational,
+ /**
+ * The underlying connection has been lost but the session may be recoverable.
+ *
+ * The transport can attempt to reconnect by transitioning to [Reconnecting],
+ * or give up by transitioning to [ShuttingDown] or [Stopped].
+ */
+ Disconnected,
+
+ /**
+ * The transport is actively attempting to re-establish the connection.
+ *
+ * On success, transitions back to [Operational].
+ * On failure, transitions to [Disconnected] (to allow retry) or [Stopped] (to give up).
+ */
+ Reconnecting,
+
/**
* Represents the shutting down phase of the protocol lifecycle.
*
@@ -68,7 +84,9 @@ public enum class ClientTransportState {
val VALID_TRANSITIONS: Map> = mapOf(
New to setOf(Initializing, Stopped),
Initializing to setOf(Operational, InitializationFailed),
- Operational to setOf(ShuttingDown),
+ Operational to setOf(Disconnected, ShuttingDown),
+ Disconnected to setOf(Reconnecting, ShuttingDown, Stopped),
+ Reconnecting to setOf(Operational, Disconnected, ShuttingDown, Stopped),
ShuttingDown to setOf(Stopped, ShutdownFailed),
// Terminal states allow no transitions
InitializationFailed to emptySet(),
diff --git a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt
index 43c0ae583..1ec594154 100644
--- a/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt
+++ b/kotlin-sdk-core/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/Protocol.kt
@@ -29,14 +29,23 @@ import kotlinx.atomicfu.getAndUpdate
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.encodeToJsonElement
-import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -185,6 +194,10 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
public val progressHandlers: Map
get() = _progressHandlers.value
+ private var handlerScope: CoroutineScope? = null
+ private val _activeRequests: AtomicRef> =
+ atomic(persistentMapOf())
+
/**
* Callback for when the connection is closed for any reason.
*
@@ -222,15 +235,29 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
setRequestHandler(Method.Defined.Ping) { _, _ ->
EmptyResult()
}
+
+ setNotificationHandler(Method.Defined.NotificationsCancelled) { notification ->
+ val requestId = notification.params.requestId
+ _activeRequests.value[requestId]?.cancel(
+ CancellationException(notification.params.reason ?: "Request cancelled"),
+ )
+ COMPLETED
+ }
}
/**
* Attaches to the given transport, starts it, and starts listening for messages.
*
- * The Protocol object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward.
+ * The Protocol object assumes ownership of the Transport, replacing any callbacks
+ * that have already been set, and expects that it is the only user of the Transport instance going forward.
*/
public open suspend fun connect(transport: Transport) {
this.transport = transport
+ // Inherit the caller's coroutine context (dispatcher, test scheduler, etc.)
+ // but use an independent SupervisorJob — the handler scope's lifetime is managed
+ // by doClose(), not by the caller's job hierarchy.
+ handlerScope = CoroutineScope(currentCoroutineContext() + SupervisorJob())
+
transport.onClose {
doClose()
}
@@ -254,6 +281,9 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
}
private fun doClose() {
+ handlerScope?.cancel()
+ handlerScope = null
+ _activeRequests.getAndSet(persistentMapOf())
val handlersToNotify = _responseHandlers.value.values.toList()
_responseHandlers.getAndSet(persistentMapOf())
_progressHandlers.getAndSet(persistentMapOf())
@@ -277,66 +307,87 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
}
try {
handler(notification)
+ } catch (e: CancellationException) {
+ throw e
} catch (cause: Throwable) {
logger.error(cause) { "Error handling notification: ${notification.method}" }
onError(cause)
}
}
- private suspend fun onRequest(request: JSONRPCRequest) {
+ private fun onRequest(request: JSONRPCRequest) {
logger.trace { "Received request: ${request.method} (id: ${request.id})" }
-
+ val scope = handlerScope ?: return
val handler = requestHandlers[request.method] ?: fallbackRequestHandler
- if (handler === null) {
+ if (handler == null) {
logger.trace { "No handler found for request: ${request.method}" }
- try {
- transport?.send(
- JSONRPCError(
- id = request.id,
- error = RPCError(
- code = RPCError.ErrorCode.METHOD_NOT_FOUND,
- message = "Server does not support ${request.method}",
- ),
- ),
- )
- } catch (cause: Throwable) {
- logger.error(cause) { "Error sending method not found response" }
- onError(cause)
- }
+ // UNDISPATCHED: start eagerly on the caller's thread until first suspension,
+ // then resume on the scope's dispatcher. This is compatible with StandardTestDispatcher
+ // and avoids requiring a dispatch before the handler starts executing.
+ scope.launch(start = CoroutineStart.UNDISPATCHED) { sendMethodNotFound(request) }
return
}
- @Suppress("TooGenericExceptionCaught", "InstanceOfCheckForException")
- try {
- val result = handler(request, RequestHandlerExtra())
- logger.trace { "Request handled successfully: ${request.method} (id: ${request.id})" }
+ val job = scope.launch(start = CoroutineStart.UNDISPATCHED) {
+ executeRequestHandler(request, handler)
+ }
+ _activeRequests.update { it.put(request.id, job) }
+ }
+ private suspend fun sendMethodNotFound(request: JSONRPCRequest) {
+ try {
transport?.send(
- JSONRPCResponse(
+ JSONRPCError(
id = request.id,
- result = result ?: EmptyResult(),
+ error = RPCError(
+ code = RPCError.ErrorCode.METHOD_NOT_FOUND,
+ message = "Server does not support ${request.method}",
+ ),
),
)
} catch (e: CancellationException) {
throw e
+ } catch (cause: Throwable) {
+ logger.error(cause) { "Error sending method not found response" }
+ onError(cause)
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught", "InstanceOfCheckForException")
+ private suspend fun executeRequestHandler(
+ request: JSONRPCRequest,
+ handler: suspend (JSONRPCRequest, RequestHandlerExtra) -> RequestResult?,
+ ) {
+ try {
+ val result = handler(request, RequestHandlerExtra())
+ logger.trace { "Request handled successfully: ${request.method} (id: ${request.id})" }
+ transport?.send(
+ JSONRPCResponse(id = request.id, result = result ?: EmptyResult()),
+ )
+ } catch (_: CancellationException) {
+ // Request cancelled — no response sent
} catch (cause: Throwable) {
logger.error(cause) { "Error handling request: ${request.method} (id: ${request.id})" }
+ sendErrorResponse(request, cause)
+ } finally {
+ _activeRequests.update { it.remove(request.id) }
+ }
+ }
- try {
- val rpcError = if (cause is McpException) {
- RPCError(code = cause.code, message = cause.message.orEmpty(), data = cause.data)
- } else {
- RPCError(code = RPCError.ErrorCode.INTERNAL_ERROR, message = cause.message ?: "Internal error")
- }
- transport?.send(JSONRPCError(id = request.id, error = rpcError))
- } catch (e: CancellationException) {
- throw e
- } catch (sendError: Throwable) {
- logger.error(sendError) {
- "Failed to send error response for request: ${request.method} (id: ${request.id})"
- }
- // Optionally implement fallback behavior here
+ private suspend fun sendErrorResponse(request: JSONRPCRequest, cause: Throwable) {
+ try {
+ val rpcError = if (cause is McpException) {
+ RPCError(code = cause.code, message = cause.message.orEmpty(), data = cause.data)
+ } else {
+ RPCError(code = RPCError.ErrorCode.INTERNAL_ERROR, message = cause.message ?: "Internal error")
+ }
+ transport?.send(JSONRPCError(id = request.id, error = rpcError))
+ } catch (_: CancellationException) {
+ // Shutting down
+ } catch (sendError: Throwable) {
+ logger.error(sendError) {
+ "Failed to send error response for request: ${request.method} (id: ${request.id})"
}
}
}
@@ -493,7 +544,10 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
val jsonRpcNotification = notification.toJSON()
- transport.send(jsonRpcNotification, options)
+ // Use NonCancellable to ensure the notification is sent even during cancellation
+ withContext(NonCancellable) {
+ transport.send(jsonRpcNotification, options)
+ }
result.completeExceptionally(reason)
}
@@ -516,6 +570,10 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
)
result.cancel(cause)
throw cause
+ } catch (cause: CancellationException) {
+ // Coroutine was cancelled (e.g., job.cancel()) — notify the remote side
+ cancel(cause)
+ throw cause
}
}
diff --git a/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransportTest.kt b/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransportTest.kt
index 1d144d861..20521b459 100644
--- a/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransportTest.kt
+++ b/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/AbstractClientTransportTest.kt
@@ -212,7 +212,15 @@ class AbstractClientTransportTest {
"New, Stopped",
"Initializing, Operational",
"Initializing, InitializationFailed",
+ "Operational, Disconnected",
"Operational, ShuttingDown",
+ "Disconnected, Reconnecting",
+ "Disconnected, ShuttingDown",
+ "Disconnected, Stopped",
+ "Reconnecting, Operational",
+ "Reconnecting, Disconnected",
+ "Reconnecting, ShuttingDown",
+ "Reconnecting, Stopped",
"ShuttingDown, Stopped",
"ShuttingDown, ShutdownFailed",
)
@@ -228,29 +236,46 @@ class AbstractClientTransportTest {
@ParameterizedTest
@CsvSource(
- // From New: only Initializing is valid
+ // From New: only Initializing and Stopped are valid
"New, Operational",
"New, InitializationFailed",
+ "New, Disconnected",
+ "New, Reconnecting",
"New, ShuttingDown",
"New, ShutdownFailed",
// From Initializing: only Operational or InitializationFailed are valid
"Initializing, New",
"Initializing, Initializing",
+ "Initializing, Disconnected",
+ "Initializing, Reconnecting",
"Initializing, ShuttingDown",
"Initializing, Stopped",
"Initializing, ShutdownFailed",
- // From Operational: only ShuttingDown is valid
+ // From Operational: only Disconnected and ShuttingDown are valid
"Operational, New",
"Operational, Initializing",
"Operational, InitializationFailed",
"Operational, Operational",
+ "Operational, Reconnecting",
"Operational, Stopped",
"Operational, ShutdownFailed",
+ // From Disconnected: only Reconnecting, ShuttingDown, Stopped are valid
+ "Disconnected, New",
+ "Disconnected, Operational",
+ "Disconnected, Disconnected",
+ "Disconnected, InitializationFailed",
+ // From Reconnecting: only Operational, Disconnected, ShuttingDown, Stopped are valid
+ "Reconnecting, New",
+ "Reconnecting, Initializing",
+ "Reconnecting, InitializationFailed",
+ "Reconnecting, Reconnecting",
// From ShuttingDown: only Stopped or ShutdownFailed are valid
"ShuttingDown, New",
"ShuttingDown, Initializing",
"ShuttingDown, InitializationFailed",
"ShuttingDown, Operational",
+ "ShuttingDown, Disconnected",
+ "ShuttingDown, Reconnecting",
"ShuttingDown, ShuttingDown",
// From terminal states: no transitions allowed
"InitializationFailed, New",
diff --git a/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrentTest.kt b/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrentTest.kt
new file mode 100644
index 000000000..a1d9cf734
--- /dev/null
+++ b/kotlin-sdk-core/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/shared/ProtocolConcurrentTest.kt
@@ -0,0 +1,135 @@
+package io.modelcontextprotocol.kotlin.sdk.shared
+
+import io.kotest.matchers.shouldBe
+import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotification
+import io.modelcontextprotocol.kotlin.sdk.types.CancelledNotificationParams
+import io.modelcontextprotocol.kotlin.sdk.types.CustomRequest
+import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult
+import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
+import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest
+import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse
+import io.modelcontextprotocol.kotlin.sdk.types.Method
+import io.modelcontextprotocol.kotlin.sdk.types.RequestId
+import io.modelcontextprotocol.kotlin.sdk.types.toJSON
+import io.modelcontextprotocol.kotlin.test.utils.runIntegrationTest
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withTimeout
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import kotlin.time.Duration.Companion.seconds
+
+class ProtocolConcurrentTest {
+
+ private lateinit var protocol: ConcurrentTestProtocol
+ private lateinit var transport: ConcurrentTestTransport
+
+ @BeforeEach
+ fun setUp() {
+ protocol = ConcurrentTestProtocol()
+ transport = ConcurrentTestTransport()
+ }
+
+ @Test
+ fun `should handle requests concurrently without blocking ping`() = runIntegrationTest(timeout = 10.seconds) {
+ val slowStarted = CompletableDeferred()
+ val slowGate = CompletableDeferred()
+
+ protocol.setRequestHandler(Method.Custom("slow")) { _, _ ->
+ slowStarted.complete(Unit)
+ slowGate.await()
+ EmptyResult()
+ }
+
+ protocol.connect(transport)
+
+ // Start a slow request
+ transport.deliver(JSONRPCRequest(id = RequestId.NumberId(1), method = "slow"))
+ withTimeout(5.seconds) { slowStarted.await() }
+
+ // Ping should not be blocked by the slow handler
+ transport.deliver(JSONRPCRequest(id = RequestId.NumberId(2), method = Method.Defined.Ping.value))
+
+ val pingResponse = withTimeout(5.seconds) { transport.awaitResponse() }
+ (pingResponse as JSONRPCResponse).id shouldBe RequestId.NumberId(2)
+
+ // Release slow handler and collect its response
+ slowGate.complete(Unit)
+ val slowResponse = withTimeout(5.seconds) { transport.awaitResponse() }
+ (slowResponse as JSONRPCResponse).id shouldBe RequestId.NumberId(1)
+ }
+
+ @Test
+ fun `should cancel active request on CancelledNotification`() = runIntegrationTest(timeout = 10.seconds) {
+ val handlerStarted = CompletableDeferred()
+ var handlerCancelled = false
+
+ protocol.setRequestHandler(Method.Custom("cancellable")) { _, _ ->
+ handlerStarted.complete(Unit)
+ try {
+ delay(60.seconds)
+ EmptyResult()
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ handlerCancelled = true
+ throw e
+ }
+ }
+
+ protocol.connect(transport)
+
+ transport.deliver(JSONRPCRequest(id = RequestId.NumberId(42), method = "cancellable"))
+ withTimeout(5.seconds) { handlerStarted.await() }
+
+ // Send cancellation
+ transport.deliver(
+ CancelledNotification(
+ CancelledNotificationParams(requestId = RequestId.NumberId(42), reason = "test"),
+ ).toJSON(),
+ )
+
+ withTimeout(5.seconds) {
+ while (!handlerCancelled) delay(10)
+ }
+ handlerCancelled shouldBe true
+ }
+}
+
+private class ConcurrentTestProtocol : Protocol(null) {
+ override fun assertCapabilityForMethod(method: Method) {}
+ override fun assertNotificationCapability(method: Method) {}
+ override fun assertRequestHandlerCapability(method: Method) {}
+}
+
+private class ConcurrentTestTransport : Transport {
+ private val sentMessages = Channel(Channel.UNLIMITED)
+ private var onMessageCallback: (suspend (JSONRPCMessage) -> Unit)? = null
+ private var onCloseCallback: (() -> Unit)? = null
+
+ override suspend fun start() {}
+
+ override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {
+ sentMessages.send(message)
+ }
+
+ override suspend fun close() {
+ onCloseCallback?.invoke()
+ }
+
+ override fun onClose(block: () -> Unit) {
+ onCloseCallback = block
+ }
+
+ override fun onError(block: (Throwable) -> Unit) {}
+
+ override fun onMessage(block: suspend (JSONRPCMessage) -> Unit) {
+ onMessageCallback = block
+ }
+
+ suspend fun deliver(message: JSONRPCMessage) {
+ val callback = onMessageCallback ?: error("onMessage callback not registered")
+ callback(message)
+ }
+
+ suspend fun awaitResponse(): JSONRPCMessage = sentMessages.receive()
+}
diff --git a/kotlin-sdk-server/api/kotlin-sdk-server.api b/kotlin-sdk-server/api/kotlin-sdk-server.api
index 80da5b53e..07a9eaeb1 100644
--- a/kotlin-sdk-server/api/kotlin-sdk-server.api
+++ b/kotlin-sdk-server/api/kotlin-sdk-server.api
@@ -1,3 +1,20 @@
+public abstract class io/modelcontextprotocol/kotlin/sdk/server/AbstractServerTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport {
+ public fun ()V
+ protected final fun checkState (Lio/modelcontextprotocol/kotlin/sdk/server/ServerTransportState;Lkotlin/jvm/functions/Function1;)V
+ public static synthetic fun checkState$default (Lio/modelcontextprotocol/kotlin/sdk/server/AbstractServerTransport;Lio/modelcontextprotocol/kotlin/sdk/server/ServerTransportState;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
+ public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ protected abstract fun closeResources (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ protected abstract fun getLogger ()Lio/github/oshai/kotlinlogging/KLogger;
+ protected final fun getState ()Lio/modelcontextprotocol/kotlin/sdk/server/ServerTransportState;
+ protected abstract fun initialize (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ protected abstract fun performSend (Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lio/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public static synthetic fun performSend$default (Lio/modelcontextprotocol/kotlin/sdk/server/AbstractServerTransport;Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lio/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
+ public fun send (Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lio/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public fun start (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ protected final fun stateTransition (Lio/modelcontextprotocol/kotlin/sdk/server/ServerTransportState;Lio/modelcontextprotocol/kotlin/sdk/server/ServerTransportState;)V
+ protected final fun updateState (Lio/modelcontextprotocol/kotlin/sdk/server/ServerTransportState;)V
+}
+
public abstract interface class io/modelcontextprotocol/kotlin/sdk/server/ClientConnection {
public abstract fun createElicitation (Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun createElicitation (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/ElicitRequestParams$RequestedSchema;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
@@ -192,6 +209,19 @@ public class io/modelcontextprotocol/kotlin/sdk/server/ServerSession : io/modelc
public final fun sendToolListChanged (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
+public final class io/modelcontextprotocol/kotlin/sdk/server/ServerTransportState : java/lang/Enum {
+ public static final field Active Lio/modelcontextprotocol/kotlin/sdk/server/ServerTransportState;
+ public static final field InitializationFailed Lio/modelcontextprotocol/kotlin/sdk/server/ServerTransportState;
+ public static final field Initializing Lio/modelcontextprotocol/kotlin/sdk/server/ServerTransportState;
+ public static final field New Lio/modelcontextprotocol/kotlin/sdk/server/ServerTransportState;
+ public static final field ShutdownFailed Lio/modelcontextprotocol/kotlin/sdk/server/ServerTransportState;
+ public static final field ShuttingDown Lio/modelcontextprotocol/kotlin/sdk/server/ServerTransportState;
+ public static final field Stopped Lio/modelcontextprotocol/kotlin/sdk/server/ServerTransportState;
+ public static fun getEntries ()Lkotlin/enums/EnumEntries;
+ public static fun valueOf (Ljava/lang/String;)Lio/modelcontextprotocol/kotlin/sdk/server/ServerTransportState;
+ public static fun values ()[Lio/modelcontextprotocol/kotlin/sdk/server/ServerTransportState;
+}
+
public final class io/modelcontextprotocol/kotlin/sdk/server/SseServerTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport {
public fun (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;)V
public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
@@ -202,31 +232,25 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/SseServerTransport
public fun start (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
-public final class io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport {
+public final class io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport : io/modelcontextprotocol/kotlin/sdk/server/AbstractServerTransport {
public fun (Lkotlinx/io/Source;Lkotlinx/io/Sink;)V
- public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
- public fun send (Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lio/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
- public fun start (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
-public final class io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport {
+public final class io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport : io/modelcontextprotocol/kotlin/sdk/server/AbstractServerTransport {
public static final field STANDALONE_SSE_STREAM_ID Ljava/lang/String;
public fun ()V
public fun (Lio/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport$Configuration;)V
public fun (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Ljava/lang/Long;)V
public synthetic fun (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
- public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun closeSseStream (Lio/modelcontextprotocol/kotlin/sdk/types/RequestId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun getSessionId ()Ljava/lang/String;
public final fun handleDeleteRequest (Lio/ktor/server/application/ApplicationCall;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun handleGetRequest (Lio/ktor/server/sse/ServerSSESession;Lio/ktor/server/application/ApplicationCall;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun handlePostRequest (Lio/ktor/server/sse/ServerSSESession;Lio/ktor/server/application/ApplicationCall;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun handleRequest (Lio/ktor/server/sse/ServerSSESession;Lio/ktor/server/application/ApplicationCall;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
- public fun send (Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lio/modelcontextprotocol/kotlin/sdk/shared/TransportSendOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun setOnSessionClosed (Lkotlin/jvm/functions/Function1;)V
public final fun setOnSessionInitialized (Lkotlin/jvm/functions/Function1;)V
public final fun setSessionIdGenerator (Lkotlin/jvm/functions/Function0;)V
- public fun start (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public final class io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport$Configuration {
diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractServerTransport.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractServerTransport.kt
new file mode 100644
index 000000000..529e5e84e
--- /dev/null
+++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractServerTransport.kt
@@ -0,0 +1,160 @@
+package io.modelcontextprotocol.kotlin.sdk.server
+
+import io.github.oshai.kotlinlogging.KLogger
+import io.modelcontextprotocol.kotlin.sdk.InternalMcpApi
+import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
+import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions
+import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
+import io.modelcontextprotocol.kotlin.sdk.types.McpException
+import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.CONNECTION_CLOSED
+import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.INTERNAL_ERROR
+import kotlin.concurrent.atomics.AtomicReference
+import kotlin.concurrent.atomics.ExperimentalAtomicApi
+import kotlin.coroutines.cancellation.CancellationException
+
+/**
+ * Abstract base class for server-side transport implementations.
+ *
+ * Manages the transport lifecycle via [ServerTransportState] state machine,
+ * enforcing valid state transitions and gating operations on the current state.
+ *
+ * Subclasses implement three hooks:
+ * - [initialize] for transport-specific startup
+ * - [performSend] for transport-specific message transmission
+ * - [closeResources] for transport-specific cleanup
+ */
+@OptIn(ExperimentalAtomicApi::class, InternalMcpApi::class)
+public abstract class AbstractServerTransport : AbstractTransport() {
+
+ protected abstract val logger: KLogger
+
+ private val _state: AtomicReference = AtomicReference(ServerTransportState.New)
+
+ @InternalMcpApi
+ protected val state: ServerTransportState
+ get() = _state.load()
+
+ @InternalMcpApi
+ protected fun updateState(new: ServerTransportState) {
+ _state.store(new)
+ }
+
+ @InternalMcpApi
+ protected fun stateTransition(from: ServerTransportState, to: ServerTransportState) {
+ require(to in ServerTransportState.VALID_TRANSITIONS.getValue(from)) {
+ "Invalid transition: $from → $to"
+ }
+ val actualState = _state.compareAndExchange(
+ expectedValue = from,
+ newValue = to,
+ )
+ check(actualState == from) {
+ "Can't change state: expected transport state $from, but found $actualState."
+ }
+ }
+
+ protected fun checkState(
+ expected: ServerTransportState,
+ lazyMessage: (ServerTransportState) -> Any = {
+ "Expected transport state $expected, but found $it"
+ },
+ ) {
+ val actualState = state
+ check(actualState == expected) { lazyMessage(actualState) }
+ }
+
+ /**
+ * Performs transport-specific initialization.
+ *
+ * Called by [start] during [ServerTransportState.Initializing].
+ */
+ protected abstract suspend fun initialize()
+
+ /**
+ * Transmits a JSON-RPC message via the underlying transport.
+ *
+ * Called by [send] after verifying the transport [state] is [ServerTransportState.Active].
+ */
+ protected abstract suspend fun performSend(message: JSONRPCMessage, options: TransportSendOptions? = null)
+
+ /**
+ * Releases transport-specific resources.
+ *
+ * Called by [close] during [ServerTransportState.ShuttingDown].
+ */
+ protected abstract suspend fun closeResources()
+
+ public override suspend fun start() {
+ stateTransition(from = ServerTransportState.New, to = ServerTransportState.Initializing)
+ @Suppress("TooGenericExceptionCaught")
+ try {
+ initialize()
+ stateTransition(from = ServerTransportState.Initializing, to = ServerTransportState.Active)
+ } catch (e: Exception) {
+ _state.store(ServerTransportState.InitializationFailed)
+ closeResources()
+ throw e
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught", "ThrowsCount")
+ public override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {
+ if (state != ServerTransportState.Active) {
+ throw McpException(
+ code = CONNECTION_CLOSED,
+ message = "Transport is not active",
+ )
+ }
+ try {
+ performSend(message, options)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ _onError(e)
+ @Suppress("InstanceOfCheckForException")
+ if (e is McpException) {
+ throw e
+ } else {
+ throw McpException(
+ code = INTERNAL_ERROR,
+ message = "Error while sending message: ${e.message}",
+ cause = e,
+ )
+ }
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ public override suspend fun close() {
+ val performClose: Boolean
+ when (state) {
+ ServerTransportState.Active -> {
+ stateTransition(ServerTransportState.Active, ServerTransportState.ShuttingDown)
+ performClose = true
+ }
+
+ ServerTransportState.New -> {
+ stateTransition(ServerTransportState.New, ServerTransportState.Stopped)
+ performClose = false
+ }
+
+ else -> {
+ performClose = false
+ }
+ }
+ if (performClose) {
+ try {
+ closeResources()
+ stateTransition(from = ServerTransportState.ShuttingDown, to = ServerTransportState.Stopped)
+ } catch (e: CancellationException) {
+ stateTransition(from = ServerTransportState.ShuttingDown, to = ServerTransportState.ShutdownFailed)
+ throw e
+ } catch (e: Exception) {
+ logger.error(e) { "Error during transport shutdown" }
+ stateTransition(from = ServerTransportState.ShuttingDown, to = ServerTransportState.ShutdownFailed)
+ } finally {
+ invokeOnCloseCallback()
+ }
+ }
+ }
+}
diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerTransportState.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerTransportState.kt
new file mode 100644
index 000000000..28d9c0392
--- /dev/null
+++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerTransportState.kt
@@ -0,0 +1,68 @@
+package io.modelcontextprotocol.kotlin.sdk.server
+
+/**
+ * States of a server transport within the protocol lifecycle.
+ *
+ * These states model the lifecycle of a server-side transport connection,
+ * from creation through active message exchange to shutdown.
+ *
+ * Unlike [io.modelcontextprotocol.kotlin.sdk.shared.ClientTransportState],
+ * server transports do not support reconnection —
+ * when a client disconnects, the server transport closes. Session recovery
+ * on reconnect is handled at the [io.modelcontextprotocol.kotlin.sdk.server.Server] level.
+ */
+public enum class ServerTransportState {
+
+ /**
+ * The transport has just been created.
+ *
+ * This is the initial state.
+ */
+ New,
+
+ /**
+ * The transport is being initialized (starting I/O, establishing connections).
+ */
+ Initializing,
+
+ /**
+ * Initialization failed. Terminal state.
+ */
+ InitializationFailed,
+
+ /**
+ * The transport is actively serving requests.
+ */
+ Active,
+
+ /**
+ * The transport is in the process of shutting down.
+ * No new outgoing messages should be accepted.
+ */
+ ShuttingDown,
+
+ /**
+ * Shutdown encountered an error. Terminal state.
+ */
+ ShutdownFailed,
+
+ /**
+ * The transport has fully stopped. Terminal state.
+ */
+ Stopped,
+
+ ;
+
+ internal companion object {
+ val VALID_TRANSITIONS: Map> = mapOf(
+ New to setOf(Initializing, Stopped),
+ Initializing to setOf(Active, InitializationFailed),
+ Active to setOf(ShuttingDown),
+ ShuttingDown to setOf(Stopped, ShutdownFailed),
+ // Terminal states allow no transitions
+ InitializationFailed to emptySet(),
+ Stopped to emptySet(),
+ ShutdownFailed to emptySet(),
+ )
+ }
+}
diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt
index 8fd46dd21..d41f6f85c 100644
--- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt
+++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransport.kt
@@ -1,8 +1,8 @@
package io.modelcontextprotocol.kotlin.sdk.server
+import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.internal.IODispatcher
-import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
import io.modelcontextprotocol.kotlin.sdk.shared.ReadBuffer
import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions
import io.modelcontextprotocol.kotlin.sdk.shared.serializeMessage
@@ -14,6 +14,7 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -23,9 +24,7 @@ import kotlinx.io.Source
import kotlinx.io.buffered
import kotlinx.io.readByteArray
import kotlinx.io.writeString
-import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
-import kotlin.coroutines.CoroutineContext
private const val READ_BUFFER_SIZE = 8192L
@@ -34,38 +33,31 @@ private const val READ_BUFFER_SIZE = 8192L
*
* Reads from input [Source] and writes to output [Sink].
*
- * @constructor Creates a new instance of [StdioServerTransport].
+ * The transport's internal I/O coroutines inherit the calling coroutine's context
+ * (from [start] / [io.modelcontextprotocol.kotlin.sdk.shared.Protocol.connect]),
+ * ensuring structured concurrency. The [IODispatcher] is added for I/O operations.
+ *
* @param inputStream The input [Source] used to receive data.
* @param outputStream The output [Sink] used to send data.
*/
@OptIn(ExperimentalAtomicApi::class)
-public class StdioServerTransport(private val inputStream: Source, outputStream: Sink) : AbstractTransport() {
+public class StdioServerTransport(private val inputStream: Source, outputStream: Sink) : AbstractServerTransport() {
- private val logger = KotlinLogging.logger {}
+ override val logger: KLogger = KotlinLogging.logger {}
private val readBuffer = ReadBuffer()
- private val initialized: AtomicBoolean = AtomicBoolean(false)
private var readingJob: Job? = null
private var sendingJob: Job? = null
private var processingJob: Job? = null
- private val coroutineContext: CoroutineContext = IODispatcher + SupervisorJob()
- private val scope = CoroutineScope(coroutineContext)
+ private lateinit var scope: CoroutineScope
private val readChannel = Channel(Channel.UNLIMITED)
private val writeChannel = Channel(Channel.UNLIMITED)
private val outputSink = outputStream.buffered()
- override suspend fun start() {
- if (!initialized.compareAndSet(expectedValue = false, newValue = true)) {
- error("StdioServerTransport already started!")
- }
-
- // Launch a coroutine to read from stdin
+ override suspend fun initialize() {
+ scope = CoroutineScope(currentCoroutineContext() + IODispatcher + SupervisorJob())
readingJob = launchReadingJob()
-
- // Launch a coroutine to process messages from readChannel
processingJob = launchProcessingJob()
-
- // Launch a coroutine to handle message sending
sendingJob = launchSendingJob()
}
@@ -77,7 +69,6 @@ public class StdioServerTransport(private val inputStream: Source, outputStream:
while (isActive) {
val bytesRead = inputStream.readAtMostTo(buf, READ_BUFFER_SIZE)
if (bytesRead == -1L) {
- // EOF reached
break
}
if (bytesRead > 0) {
@@ -91,7 +82,6 @@ public class StdioServerTransport(private val inputStream: Source, outputStream:
logger.error(e) { "Error reading from stdin" }
_onError.invoke(e)
} finally {
- // Reached EOF or error, close connection
close()
}
}
@@ -157,7 +147,6 @@ public class StdioServerTransport(private val inputStream: Source, outputStream:
}
if (message == null) break
- // Async invocation broke delivery order
try {
_onMessage.invoke(message)
} catch (e: CancellationException) {
@@ -171,22 +160,13 @@ public class StdioServerTransport(private val inputStream: Source, outputStream:
private fun logJobCompletion(jobName: String, cause: Throwable?) {
when (cause) {
- is CancellationException -> {
- }
-
- null -> {
- logger.debug { "$jobName job completed" }
- }
-
- else -> {
- logger.debug(cause) { "$jobName job completed exceptionally" }
- }
+ is CancellationException -> {}
+ null -> logger.debug { "$jobName job completed" }
+ else -> logger.debug(cause) { "$jobName job completed exceptionally" }
}
}
- override suspend fun close() {
- if (!initialized.compareAndSet(expectedValue = true, newValue = false)) return
-
+ override suspend fun closeResources() {
withContext(NonCancellable) {
writeChannel.close()
sendingJob?.cancelAndJoin()
@@ -206,12 +186,10 @@ public class StdioServerTransport(private val inputStream: Source, outputStream:
outputSink.flush()
outputSink.close()
}.onFailure { logger.warn(it) { "Failed to close stdout" } }
-
- invokeOnCloseCallback()
}
}
- override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {
+ override suspend fun performSend(message: JSONRPCMessage, options: TransportSendOptions?) {
writeChannel.send(message)
}
}
diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt
index 70948d9ce..d30094cac 100644
--- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt
+++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt
@@ -1,5 +1,7 @@
package io.modelcontextprotocol.kotlin.sdk.server
+import io.github.oshai.kotlinlogging.KLogger
+import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
@@ -14,7 +16,6 @@ import io.ktor.server.response.respond
import io.ktor.server.response.respondNullable
import io.ktor.server.sse.ServerSSESession
import io.ktor.util.collections.ConcurrentMap
-import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions
import io.modelcontextprotocol.kotlin.sdk.types.DEFAULT_NEGOTIATED_PROTOCOL_VERSION
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCEmptyMessage
@@ -28,6 +29,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.RPCError
import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.REQUEST_TIMEOUT
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
import io.modelcontextprotocol.kotlin.sdk.types.SUPPORTED_PROTOCOL_VERSIONS
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.job
import kotlinx.coroutines.sync.Mutex
@@ -55,6 +57,8 @@ private const val MIN_PRIMING_EVENT_PROTOCOL_VERSION = "2025-11-25"
*/
private data class SessionContext(val session: ServerSSESession?, val call: ApplicationCall)
+private data class StreamCompletion(val pendingRequestIds: Set, val deferred: CompletableDeferred)
+
/**
* Server transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification.
* It supports both SSE streaming and direct HTTP responses.
@@ -75,7 +79,7 @@ private data class SessionContext(val session: ServerSSESession?, val call: Appl
*/
@OptIn(ExperimentalUuidApi::class, ExperimentalAtomicApi::class)
@Suppress("TooManyFunctions")
-public class StreamableHttpServerTransport(private val configuration: Configuration) : AbstractTransport() {
+public class StreamableHttpServerTransport(private val configuration: Configuration) : AbstractServerTransport() {
@Deprecated("Use default constructor with explicit Configuration()")
public constructor() : this(configuration = Configuration())
@@ -157,13 +161,15 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
private var onSessionInitialized: ((sessionId: String) -> Unit)? = null
private var onSessionClosed: ((sessionId: String) -> Unit)? = null
- private val started: AtomicBoolean = AtomicBoolean(false)
+ override val logger: KLogger = KotlinLogging.logger {}
private val initialized: AtomicBoolean = AtomicBoolean(false)
private val streamsMapping: ConcurrentMap = ConcurrentMap()
private val requestToStreamMapping: ConcurrentMap = ConcurrentMap()
private val requestToResponseMapping: ConcurrentMap = ConcurrentMap()
+ private val streamCompletions: ConcurrentMap = ConcurrentMap()
+
private val sessionMutex = Mutex()
private val streamMutex = Mutex()
@@ -205,15 +211,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
onSessionClosed = block
}
- override suspend fun start() {
- check(started.compareAndSet(expectedValue = false, newValue = true)) {
- "StreamableHttpServerTransport already started! If using Server class, " +
- "note that connect() calls start() automatically."
- }
+ override suspend fun initialize() {
+ // No transport-specific initialization needed.
+ // HTTP requests are handled per-call via handleRequest().
}
@Suppress("CyclomaticComplexMethod", "ReturnCount")
- override suspend fun send(message: JSONRPCMessage, options: TransportSendOptions?) {
+ override suspend fun performSend(message: JSONRPCMessage, options: TransportSendOptions?) {
val responseRequestId: RequestId? = when (message) {
is JSONRPCResponse -> message.id
is JSONRPCError -> message.id
@@ -279,10 +283,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
requestToResponseMapping.remove(requestId)
requestToStreamMapping.remove(requestId)
}
+
+ // Signal batch completion so handlePostRequest can return
+ streamCompletions.remove(streamId)?.deferred?.complete(Unit)
}
}
- override suspend fun close() {
+ override suspend fun closeResources() {
streamMutex.withLock {
streamsMapping.values.forEach {
try {
@@ -293,7 +300,8 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
streamsMapping.clear()
requestToStreamMapping.clear()
requestToResponseMapping.clear()
- invokeOnCloseCallback()
+ streamCompletions.values.forEach { it.deferred.complete(Unit) }
+ streamCompletions.clear()
}
}
@@ -406,7 +414,18 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
}
call.coroutineContext.job.invokeOnCompletion { streamsMapping.remove(streamId) }
+ // Signal when all responses for this batch have been sent.
+ // Request handlers may run asynchronously (launched by Protocol),
+ // so we must not return before the responses are delivered.
+ val batchComplete = if (hasRequest) CompletableDeferred() else null
+ if (batchComplete != null) {
+ val requestIds = messages.filterIsInstance().map { it.id }.toSet()
+ streamCompletions[streamId] = StreamCompletion(requestIds, batchComplete)
+ }
+
messages.forEach { message -> _onMessage(message) }
+
+ batchComplete?.await()
} catch (e: Exception) {
call.reject(
HttpStatusCode.BadRequest,
diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractServerTransportTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractServerTransportTest.kt
new file mode 100644
index 000000000..b0a060d31
--- /dev/null
+++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/AbstractServerTransportTest.kt
@@ -0,0 +1,440 @@
+package io.modelcontextprotocol.kotlin.sdk.server
+
+import io.github.oshai.kotlinlogging.KLogger
+import io.github.oshai.kotlinlogging.KotlinLogging
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldContain
+import io.mockk.spyk
+import io.modelcontextprotocol.kotlin.sdk.InternalMcpApi
+import io.modelcontextprotocol.kotlin.sdk.shared.TransportSendOptions
+import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
+import io.modelcontextprotocol.kotlin.sdk.types.McpException
+import io.modelcontextprotocol.kotlin.sdk.types.PingRequest
+import io.modelcontextprotocol.kotlin.sdk.types.RPCError
+import io.modelcontextprotocol.kotlin.sdk.types.toJSON
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.CsvSource
+import org.junit.jupiter.params.provider.EnumSource
+import kotlin.concurrent.atomics.ExperimentalAtomicApi
+import kotlin.coroutines.cancellation.CancellationException
+
+@OptIn(ExperimentalAtomicApi::class)
+class AbstractServerTransportTest {
+
+ private lateinit var transport: TestServerTransport
+
+ @BeforeEach
+ fun beforeEach() {
+ transport = spyk(TestServerTransport())
+ }
+
+ @Nested
+ @DisplayName("Lifecycle")
+ inner class LifecycleTests {
+
+ @Test
+ fun `should start with New state`() {
+ transport.currentState shouldBe ServerTransportState.New
+ }
+
+ @Test
+ fun `should transition to Active after successful start`() = runTest {
+ transport.start()
+
+ transport.currentState shouldBe ServerTransportState.Active
+ transport.initializeCalled shouldBe true
+ }
+
+ @Test
+ fun `should call initialize during start`() = runTest {
+ transport.start()
+
+ transport.initializeCalled shouldBe true
+ transport.currentState shouldBe ServerTransportState.Active
+ }
+
+ @Test
+ fun `should transition to InitializationFailed on start error`() = runTest {
+ transport.shouldFailInitialization = true
+
+ shouldThrow {
+ transport.start()
+ }
+
+ transport.currentState shouldBe ServerTransportState.InitializationFailed
+ transport.closeResourcesCalled shouldBe true
+ }
+
+ @Test
+ fun `should call closeResources on initialization failure`() = runTest {
+ transport.shouldFailInitialization = true
+
+ shouldThrow {
+ transport.start()
+ }
+
+ transport.closeResourcesCalled shouldBe true
+ }
+
+ @Test
+ fun `should reject starting twice`() = runTest {
+ transport.start()
+
+ val exception = shouldThrow {
+ transport.start()
+ }
+ exception.message shouldContain "expected transport state New"
+ }
+
+ @Test
+ fun `should transition to Stopped after successful close`() = runTest {
+ transport.start()
+ transport.close()
+
+ transport.currentState shouldBe ServerTransportState.Stopped
+ transport.closeResourcesCalled shouldBe true
+ }
+
+ @Test
+ fun `should call closeResources during close`() = runTest {
+ transport.start()
+
+ transport.close()
+
+ transport.closeResourcesCalled shouldBe true
+ transport.currentState shouldBe ServerTransportState.Stopped
+ }
+
+ @Test
+ fun `should be idempotent when closed multiple times`() = runTest {
+ transport.start()
+ transport.close()
+
+ transport.close()
+ transport.close()
+
+ transport.currentState shouldBe ServerTransportState.Stopped
+ transport.closeResourcesCallCount shouldBe 1
+ }
+
+ @Test
+ fun `should call onClose callback exactly once on multiple close calls`() = runTest {
+ var onCloseCallCount = 0
+ transport.onClose { onCloseCallCount++ }
+
+ transport.start()
+ transport.close()
+ transport.close()
+
+ onCloseCallCount shouldBe 1
+ }
+
+ @Test
+ fun `should transition to ShutdownFailed on close error`() = runTest {
+ transport.shouldFailClose = true
+ transport.start()
+
+ transport.close()
+
+ transport.currentState shouldBe ServerTransportState.ShutdownFailed
+ }
+
+ @Test
+ fun `should call onClose even when close fails`() = runTest {
+ var onCloseCalled = false
+ transport.onClose { onCloseCalled = true }
+ transport.shouldFailClose = true
+ transport.start()
+
+ transport.close()
+
+ onCloseCalled shouldBe true
+ }
+
+ @Test
+ fun `should propagate CancellationException on close`() = runTest {
+ transport.shouldThrowCancellation = true
+ transport.start()
+
+ shouldThrow {
+ transport.close()
+ }
+
+ transport.currentState shouldBe ServerTransportState.ShutdownFailed
+ }
+
+ @Test
+ fun `should call onClose even when CancellationException is thrown`() = runTest {
+ var onCloseCalled = false
+ transport.onClose { onCloseCalled = true }
+ transport.shouldThrowCancellation = true
+ transport.start()
+
+ shouldThrow {
+ transport.close()
+ }
+
+ onCloseCalled shouldBe true
+ }
+ }
+
+ @Nested
+ @DisplayName("State Transitions")
+ inner class StateTransitionTests {
+
+ @ParameterizedTest
+ @CsvSource(
+ "New, Initializing",
+ "New, Stopped",
+ "Initializing, Active",
+ "Initializing, InitializationFailed",
+ "Active, ShuttingDown",
+ "ShuttingDown, Stopped",
+ "ShuttingDown, ShutdownFailed",
+ )
+ fun `should allow valid transitions`(fromName: String, toName: String) {
+ val from = ServerTransportState.valueOf(fromName)
+ val to = ServerTransportState.valueOf(toName)
+
+ transport.forceState(from)
+ transport.testStateTransition(from, to)
+
+ transport.currentState shouldBe to
+ }
+
+ @ParameterizedTest
+ @CsvSource(
+ "New, Active",
+ "New, InitializationFailed",
+ "New, ShuttingDown",
+ "New, ShutdownFailed",
+ "Initializing, New",
+ "Initializing, Initializing",
+ "Initializing, ShuttingDown",
+ "Initializing, Stopped",
+ "Active, New",
+ "Active, Initializing",
+ "Active, InitializationFailed",
+ "Active, Active",
+ "Active, Stopped",
+ "Active, ShutdownFailed",
+ "ShuttingDown, New",
+ "ShuttingDown, Initializing",
+ "ShuttingDown, Active",
+ "ShuttingDown, ShuttingDown",
+ "InitializationFailed, New",
+ "InitializationFailed, Active",
+ "Stopped, New",
+ "Stopped, Active",
+ "ShutdownFailed, New",
+ "ShutdownFailed, Active",
+ )
+ fun `should reject invalid transitions`(fromName: String, toName: String) {
+ val from = ServerTransportState.valueOf(fromName)
+ val to = ServerTransportState.valueOf(toName)
+
+ transport.forceState(from)
+
+ val exception = shouldThrow {
+ transport.testStateTransition(from, to)
+ }
+ exception.message shouldContain "Invalid transition: $from → $to"
+ }
+ }
+
+ @Nested
+ @DisplayName("Send Operations")
+ inner class SendTests {
+
+ @Test
+ fun `should send message successfully when Active`() = runTest {
+ val message = PingRequest().toJSON()
+
+ transport.start()
+ transport.send(message)
+
+ transport.sentMessages shouldBe listOf(message)
+ }
+
+ @Test
+ fun `should pass options to performSend`() = runTest {
+ val message = PingRequest().toJSON()
+ val options = TransportSendOptions()
+
+ transport.start()
+ transport.send(message, options)
+
+ transport.sentMessages shouldBe listOf(message)
+ transport.lastSendOptions shouldBe options
+ }
+
+ @Test
+ fun `should throw when sending before start`() = runTest {
+ val exception = shouldThrow {
+ transport.send(PingRequest().toJSON())
+ }
+
+ exception.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED
+ exception.message shouldContain "Transport is not active"
+ }
+
+ @Test
+ fun `should throw when sending after close`() = runTest {
+ transport.start()
+ transport.close()
+
+ val exception = shouldThrow {
+ transport.send(PingRequest().toJSON())
+ }
+
+ exception.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED
+ exception.message shouldContain "Transport is not active"
+ }
+
+ @ParameterizedTest
+ @EnumSource(
+ value = ServerTransportState::class,
+ names = ["Active"],
+ mode = EnumSource.Mode.EXCLUDE,
+ )
+ fun `should throw when sending in non-Active state`(state: ServerTransportState) = runTest {
+ transport.forceState(state)
+
+ val exception = shouldThrow {
+ transport.send(PingRequest().toJSON())
+ }
+
+ exception.code shouldBe RPCError.ErrorCode.CONNECTION_CLOSED
+ exception.message shouldContain "Transport is not active"
+ transport.sentMessages.isEmpty() shouldBe true
+ }
+
+ @Test
+ fun `should call onError when performSend throws McpException`() = runTest {
+ var capturedError: Throwable? = null
+ transport.onError { capturedError = it }
+ transport.shouldFailSend = true
+ transport.sendException = McpException(RPCError.ErrorCode.INTERNAL_ERROR, "Send failed")
+
+ transport.start()
+ val exception = shouldThrow {
+ transport.send(PingRequest().toJSON())
+ }
+
+ capturedError shouldBe transport.sendException
+ exception shouldBe transport.sendException
+ }
+
+ @Test
+ fun `should NOT call onError when performSend throws CancellationException`() = runTest {
+ var errorCallCount = 0
+ transport.onError { errorCallCount++ }
+ transport.shouldFailSend = true
+ transport.sendException = CancellationException("Operation cancelled")
+
+ transport.start()
+ shouldThrow {
+ transport.send(PingRequest().toJSON())
+ }
+
+ errorCallCount shouldBe 0
+ }
+ }
+
+ @Nested
+ @DisplayName("Idempotency")
+ inner class IdempotencyTests {
+
+ @Test
+ fun `should be idempotent when closing from New state`() = runTest {
+ transport.close()
+
+ transport.currentState shouldBe ServerTransportState.Stopped
+ transport.closeResourcesCalled shouldBe false
+ }
+
+ @Test
+ fun `should be idempotent when closing from InitializationFailed state`() = runTest {
+ transport.shouldFailInitialization = true
+ shouldThrow { transport.start() }
+
+ transport.close()
+
+ transport.currentState shouldBe ServerTransportState.InitializationFailed
+ transport.closeResourcesCallCount shouldBe 1
+ }
+
+ @ParameterizedTest
+ @EnumSource(
+ value = ServerTransportState::class,
+ names = ["ShuttingDown", "ShutdownFailed", "Stopped"],
+ )
+ fun `should be idempotent when closing from terminal or shutdown states`(state: ServerTransportState) =
+ runTest {
+ transport.forceState(state)
+ val initialCloseCount = transport.closeResourcesCallCount
+
+ transport.close()
+
+ transport.currentState shouldBe state
+ transport.closeResourcesCallCount shouldBe initialCloseCount
+ }
+ }
+
+ @OptIn(InternalMcpApi::class)
+ class TestServerTransport : AbstractServerTransport() {
+ override val logger: KLogger = KotlinLogging.logger {}
+ val sentMessages = mutableListOf()
+ var lastSendOptions: TransportSendOptions? = null
+ var initializeCalled = false
+ var closeResourcesCalled = false
+ var closeResourcesCallCount = 0
+ var shouldFailInitialization = false
+ var shouldFailClose = false
+ var shouldThrowCancellation = false
+ var shouldFailSend = false
+ var sendException: Throwable? = null
+
+ val currentState: ServerTransportState
+ get() = state
+
+ override suspend fun initialize() {
+ initializeCalled = true
+ if (shouldFailInitialization) {
+ throw TestException("Initialization failed")
+ }
+ }
+
+ override suspend fun performSend(message: JSONRPCMessage, options: TransportSendOptions?) {
+ if (shouldFailSend) {
+ throw sendException ?: TestException("Send failed")
+ }
+ sentMessages.add(message)
+ lastSendOptions = options
+ }
+
+ override suspend fun closeResources() {
+ closeResourcesCalled = true
+ closeResourcesCallCount++
+
+ when {
+ shouldThrowCancellation -> throw CancellationException("Test cancellation")
+ shouldFailClose -> throw TestException("Close failed")
+ }
+ }
+
+ fun testStateTransition(from: ServerTransportState, to: ServerTransportState) {
+ stateTransition(from, to)
+ }
+
+ fun forceState(newState: ServerTransportState) = updateState(newState)
+ }
+
+ private class TestException(message: String) : Exception(message)
+}
diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerTransportStateTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerTransportStateTest.kt
new file mode 100644
index 000000000..be4683bc1
--- /dev/null
+++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/ServerTransportStateTest.kt
@@ -0,0 +1,80 @@
+package io.modelcontextprotocol.kotlin.sdk.server
+
+import io.kotest.matchers.collections.shouldBeEmpty
+import io.kotest.matchers.shouldBe
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.CsvSource
+import org.junit.jupiter.params.provider.EnumSource
+
+@DisplayName("ServerTransportState")
+class ServerTransportStateTest {
+
+ @Nested
+ @DisplayName("Valid Transitions")
+ inner class ValidTransitions {
+
+ @ParameterizedTest
+ @CsvSource(
+ "New, Initializing",
+ "New, Stopped",
+ "Initializing, Active",
+ "Initializing, InitializationFailed",
+ "Active, ShuttingDown",
+ "ShuttingDown, Stopped",
+ "ShuttingDown, ShutdownFailed",
+ )
+ fun `should allow valid transitions`(fromName: String, toName: String) {
+ val from = ServerTransportState.valueOf(fromName)
+ val to = ServerTransportState.valueOf(toName)
+
+ val allowed = ServerTransportState.VALID_TRANSITIONS.getValue(from)
+ (to in allowed) shouldBe true
+ }
+ }
+
+ @Nested
+ @DisplayName("Invalid Transitions")
+ inner class InvalidTransitions {
+
+ @ParameterizedTest
+ @CsvSource(
+ "New, Active",
+ "New, InitializationFailed",
+ "New, ShuttingDown",
+ "New, ShutdownFailed",
+ "Initializing, New",
+ "Initializing, ShuttingDown",
+ "Initializing, Stopped",
+ "Active, New",
+ "Active, Initializing",
+ "Active, Active",
+ "Active, Stopped",
+ "ShuttingDown, New",
+ "ShuttingDown, Active",
+ "ShuttingDown, ShuttingDown",
+ )
+ fun `should reject invalid transitions`(fromName: String, toName: String) {
+ val from = ServerTransportState.valueOf(fromName)
+ val to = ServerTransportState.valueOf(toName)
+
+ val allowed = ServerTransportState.VALID_TRANSITIONS.getValue(from)
+ (to in allowed) shouldBe false
+ }
+ }
+
+ @Nested
+ @DisplayName("Terminal States")
+ inner class TerminalStates {
+
+ @ParameterizedTest
+ @EnumSource(
+ value = ServerTransportState::class,
+ names = ["InitializationFailed", "Stopped", "ShutdownFailed"],
+ )
+ fun `terminal states should have no outgoing transitions`(state: ServerTransportState) {
+ ServerTransportState.VALID_TRANSITIONS.getValue(state).shouldBeEmpty()
+ }
+ }
+}
diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransportLifecycleTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransportLifecycleTest.kt
new file mode 100644
index 000000000..d954bfaba
--- /dev/null
+++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StdioServerTransportLifecycleTest.kt
@@ -0,0 +1,83 @@
+package io.modelcontextprotocol.kotlin.sdk.server
+
+import io.kotest.assertions.nondeterministic.eventually
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldContain
+import io.modelcontextprotocol.kotlin.sdk.types.McpException
+import io.modelcontextprotocol.kotlin.sdk.types.PingRequest
+import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.CONNECTION_CLOSED
+import io.modelcontextprotocol.kotlin.sdk.types.toJSON
+import kotlinx.coroutines.test.runTest
+import kotlinx.io.Buffer
+import org.junit.jupiter.api.Test
+import kotlin.time.Duration.Companion.seconds
+
+class StdioServerTransportLifecycleTest {
+
+ private fun createTransport(): StdioServerTransport {
+ val input: kotlinx.io.Source = Buffer()
+ val output: kotlinx.io.Sink = Buffer()
+ return StdioServerTransport(input, output)
+ }
+
+ @Test
+ fun `should throw when started twice`() = runTest {
+ val transport = createTransport()
+ transport.start()
+
+ val exception = shouldThrow {
+ transport.start()
+ }
+ exception.message shouldContain "expected transport state New"
+ }
+
+ @Test
+ fun `should be idempotent when closed twice`() = runTest {
+ val transport = createTransport()
+ transport.start()
+ transport.close()
+
+ // Second close should not throw
+ transport.close()
+ }
+
+ @Test
+ fun `should throw when sending before start`() = runTest {
+ val transport = createTransport()
+
+ val exception = shouldThrow {
+ transport.send(PingRequest().toJSON())
+ }
+ exception.code shouldBe CONNECTION_CLOSED
+ }
+
+ @Test
+ fun `should throw when sending after close`() = runTest {
+ val transport = createTransport()
+ transport.start()
+ transport.close()
+
+ eventually(2.seconds) {
+ shouldThrow {
+ transport.send(PingRequest().toJSON())
+ }
+ }
+ }
+
+ @Test
+ fun `should call onClose exactly once`() = runTest {
+ val transport = createTransport()
+
+ var closeCallCount = 0
+ transport.onClose { closeCallCount++ }
+
+ transport.start()
+ transport.close()
+ transport.close()
+
+ eventually(2.seconds) {
+ closeCallCount shouldBe 1
+ }
+ }
+}
diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportLifecycleTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportLifecycleTest.kt
new file mode 100644
index 000000000..76acdb371
--- /dev/null
+++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportLifecycleTest.kt
@@ -0,0 +1,48 @@
+package io.modelcontextprotocol.kotlin.sdk.server
+
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldContain
+import io.modelcontextprotocol.kotlin.sdk.types.McpException
+import io.modelcontextprotocol.kotlin.sdk.types.PingRequest
+import io.modelcontextprotocol.kotlin.sdk.types.RPCError.ErrorCode.CONNECTION_CLOSED
+import io.modelcontextprotocol.kotlin.sdk.types.toJSON
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Test
+
+class StreamableHttpServerTransportLifecycleTest {
+
+ private fun createTransport(): StreamableHttpServerTransport =
+ StreamableHttpServerTransport(StreamableHttpServerTransport.Configuration())
+
+ @Test
+ fun `should throw when started twice`() = runTest {
+ val transport = createTransport()
+ transport.start()
+
+ val exception = shouldThrow {
+ transport.start()
+ }
+ exception.message shouldContain "expected transport state New"
+ }
+
+ @Test
+ fun `should be idempotent when closed twice`() = runTest {
+ val transport = createTransport()
+ transport.start()
+ transport.close()
+
+ // Second close should not throw
+ transport.close()
+ }
+
+ @Test
+ fun `should throw when sending before start`() = runTest {
+ val transport = createTransport()
+
+ val exception = shouldThrow {
+ transport.send(PingRequest().toJSON())
+ }
+ exception.code shouldBe CONNECTION_CLOSED
+ }
+}
diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt
index 9cf916afb..258b60fee 100644
--- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt
+++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt
@@ -441,6 +441,7 @@ class StreamableHttpServerTransportTest {
}
}
}
+ kotlinx.coroutines.runBlocking { transport.start() }
}
private fun HttpRequestBuilder.addStreamableHeaders() {