Skip to content

Commit 9757573

Browse files
authored
Add basic scaladoc for MCP client (#153)
* feat: client public surface docs * feat: transport docs * feat: custom MCP timeout exception * fix: remove stream parameter from StreamingStdioTransport, it should not be bounded to anything sttp related * feat: add a test for stdio transport respecting configured timeout
1 parent 8d3f46c commit 9757573

17 files changed

Lines changed: 244 additions & 62 deletions

client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioStreamingHttpTransport.scala

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,6 @@ final class ZioStreamingHttpTransport private (
258258
)
259259

260260
object ZioStreamingHttpTransport:
261-
import scala.concurrent.duration.DurationInt
262261

263262
val defaultReconnectSchedule: Schedule[Any, Any, Any] =
264263
Schedule.exponential(Duration.fromMillis(100)).jittered || Schedule.spaced(Duration.fromSeconds(30))
@@ -267,7 +266,7 @@ object ZioStreamingHttpTransport:
267266
backend: StreamBackend[Task, ZioStreams],
268267
uri: Uri,
269268
protocolVersion: ProtocolVersion = ProtocolVersion.Latest,
270-
timeout: FiniteDuration = 60.seconds,
269+
timeout: FiniteDuration = Transport.defaultTimeout,
271270
reconnectSchedule: Schedule[Any, Any, Any] = defaultReconnectSchedule
272271
): Task[ZioStreamingHttpTransport] =
273272
for
@@ -299,7 +298,7 @@ object ZioStreamingHttpTransport:
299298
backend: StreamBackend[Task, ZioStreams],
300299
uri: Uri,
301300
protocolVersion: ProtocolVersion = ProtocolVersion.Latest,
302-
timeout: FiniteDuration = 60.seconds,
301+
timeout: FiniteDuration = Transport.defaultTimeout,
303302
reconnectSchedule: Schedule[Any, Any, Any] = defaultReconnectSchedule
304303
): ZIO[Scope, Throwable, ZioStreamingHttpTransport] =
305304
ZIO.acquireRelease(apply(backend, uri, protocolVersion, timeout, reconnectSchedule))(_.close().ignore)
@@ -308,7 +307,7 @@ object ZioStreamingHttpTransport:
308307
backend: StreamBackend[Task, ZioStreams],
309308
uri: Uri,
310309
protocolVersion: ProtocolVersion = ProtocolVersion.Latest,
311-
timeout: FiniteDuration = 60.seconds,
310+
timeout: FiniteDuration = Transport.defaultTimeout,
312311
reconnectSchedule: Schedule[Any, Any, Any] = defaultReconnectSchedule
313312
): ZLayer[Any, Throwable, ZioStreamingHttpTransport] =
314313
ZLayer.scoped(scoped(backend, uri, protocolVersion, timeout, reconnectSchedule))

client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioStreamingStdioTransport.scala

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package chimp.client.transport.zio
33
import chimp.client.transport.{StreamingStdioTransport, Transport}
44
import chimp.protocol.JSONRPCMessage
55
import org.slf4j.LoggerFactory
6-
import sttp.capabilities.zio.ZioStreams
76
import sttp.client4.impl.zio.RIOMonadAsyncError
87
import sttp.monad.MonadError
98
import zio.process.{Command, Process, ProcessInput}
@@ -24,7 +23,7 @@ final class ZioStreamingStdioTransport private (
2423
writeQueue: Queue[JSONRPCMessage],
2524
pending: ZioPendingRequests,
2625
incomingRef: Ref[JSONRPCMessage => Task[Unit]]
27-
) extends StreamingStdioTransport[Task, ZioStreams](command, env, workDir):
26+
) extends StreamingStdioTransport[Task](command, env, workDir):
2827

2928
private val log = LoggerFactory.getLogger(classOf[ZioStreamingStdioTransport])
3029

@@ -70,14 +69,12 @@ final class ZioStreamingStdioTransport private (
7069
.unit
7170

7271
object ZioStreamingStdioTransport:
73-
import scala.concurrent.duration.DurationInt
74-
private val defaultTimeout: FiniteDuration = 60.seconds
7572

7673
def apply(
7774
command: List[String],
7875
env: Map[String, String] = Map.empty,
7976
workDir: Option[File] = None,
80-
timeout: FiniteDuration = defaultTimeout
77+
timeout: FiniteDuration = Transport.defaultTimeout
8178
): Task[ZioStreamingStdioTransport] =
8279
for
8380
scope <- Scope.make
@@ -102,14 +99,14 @@ object ZioStreamingStdioTransport:
10299
command: List[String],
103100
env: Map[String, String] = Map.empty,
104101
workDir: Option[File] = None,
105-
timeout: FiniteDuration = defaultTimeout
102+
timeout: FiniteDuration = Transport.defaultTimeout
106103
): ZIO[Scope, Throwable, ZioStreamingStdioTransport] =
107104
ZIO.acquireRelease(apply(command, env, workDir, timeout))(_.close().ignore)
108105

109106
def layer(
110107
command: List[String],
111108
env: Map[String, String] = Map.empty,
112109
workDir: Option[File] = None,
113-
timeout: FiniteDuration = defaultTimeout
110+
timeout: FiniteDuration = Transport.defaultTimeout
114111
): ZLayer[Any, Throwable, ZioStreamingStdioTransport] =
115112
ZLayer.scoped(scoped(command, env, workDir, timeout))

client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/internal/ZioPendingRequests.scala

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,37 @@
11
package chimp.client.transport.zio
22

3-
import chimp.client.McpTransportException
3+
import chimp.client.{McpTimeoutException, McpTransportException}
44
import chimp.client.internal.PendingRequests
55
import chimp.protocol.{JSONRPCMessage, RequestId}
66
import zio.{Duration, Promise, Ref, Task, UIO, ZIO}
77

8-
import java.util.concurrent.TimeoutException
98
import scala.concurrent.duration.FiniteDuration
109

1110
private[zio] final class ZioPendingRequests private (pending: Ref[Map[RequestId, Promise[Throwable, JSONRPCMessage]]])
1211
extends PendingRequests[Task]:
13-
override def register(id: RequestId, timeout: FiniteDuration): Task[() => Task[JSONRPCMessage]] =
12+
override def register(requestId: RequestId, timeout: FiniteDuration): Task[() => Task[JSONRPCMessage]] =
1413
Promise
1514
.make[Throwable, JSONRPCMessage]
1615
.flatMap: promise =>
1716
pending
18-
.update(_ + (id -> promise))
17+
.update(_ + (requestId -> promise))
1918
.as: () =>
2019
promise.await
21-
.timeoutFail(new TimeoutException(s"Request $id timed out after $timeout"))(Duration.fromScala(timeout))
22-
.onError(_ => pending.update(_ - id))
20+
.timeoutFail(new McpTimeoutException(requestId))(Duration.fromScala(timeout))
21+
.onError(_ => pending.update(_ - requestId))
2322

24-
override def complete(id: RequestId, msg: JSONRPCMessage): Task[Boolean] =
23+
override def complete(requestId: RequestId, msg: JSONRPCMessage): Task[Boolean] =
2524
pending
2625
.modify { pending =>
27-
pending.get(id) match
28-
case Some(promise) => (Some(promise), pending - id)
26+
pending.get(requestId) match
27+
case Some(promise) => (Some(promise), pending - requestId)
2928
case None => (None, pending)
3029
}
3130
.flatMap:
3231
case Some(promise) => promise.succeed(msg).as(true)
3332
case None => ZIO.succeed(false)
3433

35-
override def isPending(id: RequestId): Task[Boolean] = pending.get.map(_.contains(id))
34+
override def isPending(requestId: RequestId): Task[Boolean] = pending.get.map(_.contains(requestId))
3635

3736
override def closeAll(reason: String): Task[Unit] =
3837
pending

client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioStdioIntegrationSpec.scala

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import chimp.client.integration.StdioIntegrationSpec
44
import chimp.client.transport.BidirectionalTransport
55
import zio.{Task, ZIO}
66

7+
import scala.concurrent.duration.FiniteDuration
8+
79
class ZioStdioIntegrationSpec extends StdioIntegrationSpec[Task] with ZioToFuture:
810

9-
override def usingTransport[A](command: List[String])(use: BidirectionalTransport[Task] => Task[A]): Task[A] =
10-
ZIO.scoped(ZioStreamingStdioTransport.scoped(command).flatMap(use))
11+
override def usingTransport[A](command: List[String], timeout: FiniteDuration)(use: BidirectionalTransport[Task] => Task[A]): Task[A] =
12+
ZIO.scoped(ZioStreamingStdioTransport.scoped(command, timeout = timeout).flatMap(use))

client/src/main/scala/chimp/client/McpClient.scala

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,45 +5,159 @@ import chimp.client.transport.{BidirectionalTransport, Transport}
55
import chimp.protocol.*
66
import io.circe.Json
77

8+
/** A Model Context Protocol (MCP) client that has completed the initialization handshake with a server.
9+
*
10+
* Exposes the server's advertised capabilities and identity, and provides methods for sending client-initiated requests and notifications
11+
* over the underlying [[chimp.client.transport.Transport]].
12+
*
13+
* For bidirectional interaction (server-initiated requests, resource subscriptions, notification listeners), use
14+
* [[BidirectionalMcpClient]] instead.
15+
*/
816
trait McpClient[F[_]]:
17+
/** Sends a `ping` request to the server. */
918
def ping(): F[Unit]
19+
20+
/** Closes the underlying transport and releases any resources held by this client. */
1021
def close(): F[Unit]
1122

23+
/** Capabilities advertised by the server during the initialization handshake. */
1224
def serverCapabilities: ServerCapabilities
25+
26+
/** Server implementation name and version, received during the initialization handshake. */
1327
def serverInfo: Implementation
1428

29+
/** Lists tools exposed by the server.
30+
*
31+
* @param cursor
32+
* Optional pagination cursor returned by a previous call; by default uses `None` and starts from the beginning.
33+
*/
1534
def listTools(cursor: Option[Cursor] = None): F[ListToolsResponse]
35+
36+
/** Invokes a tool on the server.
37+
*
38+
* @param name
39+
* The tool's name, as reported by [[listTools]].
40+
* @param arguments
41+
* JSON object containing the tool's input, matching its declared input schema.
42+
*/
1643
def callTool(name: String, arguments: Json): F[CallToolResult]
1744

45+
/** Lists prompt templates exposed by the server.
46+
*
47+
* @param cursor
48+
* Optional pagination cursor returned by a previous call; by default uses `None` and starts from the beginning.
49+
*/
1850
def listPrompts(cursor: Option[Cursor] = None): F[ListPromptsResult]
51+
52+
/** Retrieves a prompt template by name, substituting the given arguments.
53+
*
54+
* @param name
55+
* The prompt's name, as reported by [[listPrompts]].
56+
* @param arguments
57+
* Values for the prompt template's arguments; defaults to empty.
58+
*/
1959
def getPrompt(name: String, arguments: Map[String, String] = Map.empty): F[GetPromptResult]
2060

61+
/** Lists resources exposed by the server.
62+
*
63+
* @param cursor
64+
* Optional pagination cursor returned by a previous call; by default uses `None` and starts from the beginning.
65+
*/
2166
def listResources(cursor: Option[Cursor] = None): F[ListResourcesResult]
67+
68+
/** Lists resource templates exposed by the server.
69+
*
70+
* @param cursor
71+
* Optional pagination cursor returned by a previous call; by default uses `None` and starts from the beginning.
72+
*/
2273
def listResourceTemplates(cursor: Option[Cursor] = None): F[ListResourceTemplatesResult]
74+
75+
/** Reads the contents of a resource identified by its URI. */
2376
def readResource(uri: String): F[ReadResourceResult]
2477

78+
/** Requests completion suggestions for an argument of a prompt or resource template reference. */
2579
def complete(ref: CompleteRef, argument: CompleteArgument): F[CompleteResult]
2680

81+
/** Sets the minimum severity level for log messages the server should forward to this client. */
2782
def setLoggingLevel(level: LoggingLevel): F[Unit]
2883

84+
/** Sends a progress notification for a previously issued request that advertised a progress token.
85+
*
86+
* @param token
87+
* The progress token associated with the in-flight request.
88+
* @param progress
89+
* Current progress value; the unit is opaque and chosen by the sender.
90+
* @param total
91+
* Optional total value, used by the receiver to compute a percentage.
92+
* @param message
93+
* Optional human-readable description of the current step.
94+
*/
2995
def sendProgress(token: ProgressToken, progress: Double, total: Option[Double] = None, message: Option[String] = None): F[Unit]
96+
97+
/** Sends a `cancelled` notification, asking the server to stop processing a previously issued request.
98+
*
99+
* @param requestId
100+
* Identifier of the request to cancel.
101+
* @param reason
102+
* Optional human-readable explanation.
103+
*/
30104
def sendCancelled(requestId: RequestId, reason: Option[String] = None): F[Unit]
31105

106+
/** An [[McpClient]] used over a [[chimp.client.transport.BidirectionalTransport]], which additionally supports server-initiated
107+
* interactions: subscribing to resource updates, notifying the server about changes to the client's roots, and handling notifications
108+
* pushed by the server.
109+
*/
32110
trait BidirectionalMcpClient[F[_]] extends McpClient[F]:
111+
/** Subscribes to updates for the resource identified by the given URI. The server will emit `resources/updated` notifications, which can
112+
* be observed via [[onServerNotification]].
113+
*/
33114
def subscribeResource(uri: String): F[Unit]
115+
116+
/** Cancels a subscription previously created with [[subscribeResource]]. */
34117
def unsubscribeResource(uri: String): F[Unit]
118+
119+
/** Notifies the server that the list of roots exposed by this client has changed. */
35120
def sendRootsListChanged(): F[Unit]
121+
122+
/** Registers a listener for notifications pushed by the server (e.g. resource updates, tool/prompt list changes, log messages). */
36123
def onServerNotification(listener: ServerNotificationListener[F]): F[Unit]
37124

38125
object McpClient:
126+
/** Creates an unidirectional [[McpClient]] over the given [[chimp.client.transport.Transport]] and performs the initialization handshake
127+
* with the server.
128+
*
129+
* @param transport
130+
* The transport carrying JSON-RPC messages between client and server.
131+
* @param clientInfo
132+
* Implementation name and version advertised to the server during initialization.
133+
* @param protocolVersion
134+
* Protocol version proposed during initialization; defaults to the latest version supported by chimp.
135+
*/
39136
def apply[F[_]](
40137
transport: Transport[F],
41138
clientInfo: Implementation,
42-
protocolVersion: ProtocolVersion
139+
protocolVersion: ProtocolVersion = ProtocolVersion.Latest
43140
): F[McpClient[F]] =
44141
McpClientImpl.create(transport, clientInfo, protocolVersion)
45142

46-
def apply[F[_]](
143+
/** Creates a [[BidirectionalMcpClient]] over the given [[chimp.client.transport.BidirectionalTransport]] and performs the initialization
144+
* handshake. The optional handlers determine which client capabilities (roots, sampling, elicitation) are advertised to the server; only
145+
* capabilities backed by a handler are enabled.
146+
*
147+
* @param transport
148+
* The bidirectional transport carrying JSON-RPC messages in both directions.
149+
* @param clientInfo
150+
* Implementation name and version advertised to the server during initialization.
151+
* @param rootsHandler
152+
* Optional handler invoked when the server requests the client's roots; enables the `roots` capability when provided.
153+
* @param samplingHandler
154+
* Optional handler invoked when the server requests an LLM completion via sampling; enables the `sampling` capability when provided.
155+
* @param elicitationHandler
156+
* Optional handler invoked when the server requests user input via elicitation; enables the `elicitation` capability when provided.
157+
* @param protocolVersion
158+
* Protocol version proposed during initialization; defaults to the latest version supported by chimp.
159+
*/
160+
def bidirectional[F[_]](
47161
transport: BidirectionalTransport[F],
48162
clientInfo: Implementation,
49163
rootsHandler: Option[() => F[ListRootsResult]] = None,

client/src/main/scala/chimp/client/McpClientException.scala

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package chimp.client
22

3+
import chimp.protocol.RequestId
4+
35
class McpTransportException(message: String, cause: Throwable = null) extends RuntimeException(message, cause)
46

57
final class McpAuthorizationException(message: String, val statusCode: Int) extends McpTransportException(message)
@@ -8,3 +10,5 @@ final class McpSessionNotFoundException(sessionId: String)
810
extends McpTransportException(s"Server reported session-id $sessionId as not found")
911

1012
final class McpProtocolException(message: String) extends McpTransportException(message)
13+
14+
final class McpTimeoutException(requestId: RequestId) extends RuntimeException(s"Request $requestId timed out")

client/src/main/scala/chimp/client/internal/PendingRequests.scala

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package chimp.client.internal
22

3+
import chimp.client.McpTimeoutException
34
import chimp.protocol.{JSONRPCErrorCodes, JSONRPCErrorObject, JSONRPCMessage, RequestId}
45
import sttp.shared.Identity
56

@@ -8,35 +9,35 @@ import scala.concurrent.duration.FiniteDuration
89
import scala.concurrent.{Await, Promise}
910

1011
trait PendingRequests[F[_]]:
11-
def register(id: RequestId, timeout: FiniteDuration): F[() => F[JSONRPCMessage]]
12-
def complete(id: RequestId, msg: JSONRPCMessage): F[Boolean]
13-
def isPending(id: RequestId): F[Boolean]
12+
def register(requestId: RequestId, timeout: FiniteDuration): F[() => F[JSONRPCMessage]]
13+
def complete(requestId: RequestId, msg: JSONRPCMessage): F[Boolean]
14+
def isPending(requestId: RequestId): F[Boolean]
1415
def closeAll(reason: String): F[Unit]
1516

1617
private[client] final class SyncPendingRequests extends PendingRequests[Identity]:
1718
private val pending = ConcurrentHashMap[RequestId, Promise[JSONRPCMessage]]()
1819

19-
override def register(id: RequestId, timeout: FiniteDuration): () => JSONRPCMessage =
20+
override def register(requestId: RequestId, timeout: FiniteDuration): () => JSONRPCMessage =
2021
val promise = Promise[JSONRPCMessage]()
21-
pending.put(id, promise)
22+
pending.put(requestId, promise)
2223
() =>
2324
try Await.result(promise.future, timeout)
2425
catch
25-
case t: TimeoutException =>
26-
pending.computeIfPresent(id, (_, _) => null)
27-
throw t
26+
case _: TimeoutException =>
27+
pending.computeIfPresent(requestId, (_, _) => null)
28+
throw new McpTimeoutException(requestId)
2829

29-
override def complete(id: RequestId, msg: JSONRPCMessage): Boolean =
30+
override def complete(requestId: RequestId, msg: JSONRPCMessage): Boolean =
3031
var completed = false
3132
pending.computeIfPresent(
32-
id,
33+
requestId,
3334
(_, promise) =>
3435
completed = promise.trySuccess(msg)
3536
null
3637
)
3738
completed
3839

39-
override def isPending(id: RequestId): Boolean = pending.containsKey(id)
40+
override def isPending(requestId: RequestId): Boolean = pending.containsKey(requestId)
4041

4142
override def closeAll(reason: String): Unit =
4243
val it = pending.entrySet().iterator()

client/src/main/scala/chimp/client/transport/HttpTransport.scala

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ import sttp.monad.syntax.*
1212
import java.util.concurrent.atomic.AtomicReference
1313
import scala.util.chaining.*
1414

15+
/** Implementation of unidirectional MCP Streamable HTTP transport that exchanges JSON-RPC messages with an MCP server over HTTP.
16+
*
17+
* @param backend
18+
* The sttp backend used to send HTTP requests.
19+
* @param uri
20+
* The MCP endpoint URI.
21+
* @param protocolVersion
22+
* Protocol version advertised via the `MCP-Protocol-Version` header; defaults to the latest version supported by chimp.
23+
*/
1524
final class HttpTransport[F[_]](
1625
backend: Backend[F],
1726
uri: Uri,

0 commit comments

Comments
 (0)