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() {