diff --git a/README.md b/README.md index 0f71222..d113f56 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ case class AdderInput(a: Int, b: Int) derives Codec, Schema @main def server(): Unit = val adder = tool("adder").description("Adds two numbers").input[AdderInput] - .handle(i => Right(s"Result: ${i.a + i.b}")) + .handle(i => ToolResult.text(s"Result: ${i.a + i.b}")) - NettySyncServer().port(8080).addEndpoint(mcpEndpoint(List(adder), List("mcp"))).startAndWait() + NettySyncServer().port(8080).addEndpoint(McpServer(tools = List(adder)).endpoint(List("mcp"))).startAndWait() ``` Connect and invoke the tool as an MCP client: diff --git a/build.sbt b/build.sbt index 732d5ae..c137fb5 100644 --- a/build.sbt +++ b/build.sbt @@ -10,6 +10,7 @@ val tapirV = "1.13.23" val sttpClientV = "4.0.25" val zioV = "2.1.26" val zioProcessV = "0.8.0" +val zioHttpV = "3.8.0" val testcontainersScalaV = "0.41.8" lazy val verifyExamplesCompileUsingScalaCli = taskKey[Unit]("Verify that each example compiles using Scala CLI") @@ -35,7 +36,7 @@ val scalaTest = "org.scalatest" %% "scalatest" % scalaTestV % Test lazy val root = (project in file(".")) .settings(commonSettings: _*) .settings(publishArtifact := false, name := "chimp") - .aggregate(core, server, client, clientZio, examples, serverConformance, clientConformance) + .aggregate(core, server, serverZio, client, clientZio, examples, serverConformance, clientConformance) val conformance = inputKey[Unit]("Run the MCP conformance harness via npx, extra args are passed through") @@ -62,10 +63,27 @@ lazy val server: Project = (project in file("server")) "com.softwaremill.sttp.tapir" %% "tapir-core" % tapirV, "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % tapirV, "com.softwaremill.sttp.tapir" %% "tapir-apispec-docs" % tapirV, - "com.softwaremill.sttp.apispec" %% "jsonschema-circe" % "0.11.10" + "com.softwaremill.sttp.apispec" %% "jsonschema-circe" % "0.11.10", + "com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % tapirV % Test, + "com.softwaremill.sttp.client4" %% "core" % sttpClientV % Test ) ) - .dependsOn(core) + .dependsOn(core, client % "test->compile") + +lazy val serverZio: Project = (project in file("server-streaming/server-zio")) + .settings(commonSettings: _*) + .settings( + name := "chimp-server-zio", + libraryDependencies ++= Seq( + scalaTest, + "dev.zio" %% "zio" % zioV, + "dev.zio" %% "zio-streams" % zioV, + "com.softwaremill.sttp.tapir" %% "tapir-zio" % tapirV, + "com.softwaremill.sttp.tapir" %% "tapir-zio-http-server" % tapirV, + "dev.zio" %% "zio-http" % zioHttpV + ) + ) + .dependsOn(server % "compile->compile;test->test", clientZio % "test->compile") lazy val client: Project = (project in file("client")) .settings(commonSettings: _*) @@ -244,7 +262,8 @@ lazy val docs: Project = (project in file("generated-docs")) ), mdocOut := file("generated-docs/out"), mdocExtraArguments := Seq("--clean-target", "--exclude", ".venv", "--exclude", "_build"), + libraryDependencies += "com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % tapirV, publishArtifact := false, name := "docs" ) - .dependsOn(core, server, client, clientZio) + .dependsOn(core, server, serverZio, client, clientZio) diff --git a/client-conformance/src/main/scala/chimp/conformance/client/Main.scala b/client-conformance/src/main/scala/chimp/conformance/client/Main.scala index 3c04629..53b2e56 100644 --- a/client-conformance/src/main/scala/chimp/conformance/client/Main.scala +++ b/client-conformance/src/main/scala/chimp/conformance/client/Main.scala @@ -1,7 +1,7 @@ package chimp.conformance.client import chimp.client.McpClient -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.* import io.circe.Json import sttp.client4.DefaultSyncBackend @@ -28,7 +28,7 @@ object Main: .getOrElse(ProtocolVersion.Latest) val backend = DefaultSyncBackend() - val transport = HttpTransport[Identity](backend, serverUrl, protocolVersion) + val transport = ClientHttpTransport[Identity](backend, serverUrl, protocolVersion) val rc: Int = try diff --git a/client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioStreamingHttpTransport.scala b/client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioClientHttpTransport.scala similarity index 91% rename from client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioStreamingHttpTransport.scala rename to client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioClientHttpTransport.scala index 55f2767..fe3f8aa 100644 --- a/client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioStreamingHttpTransport.scala +++ b/client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioClientHttpTransport.scala @@ -1,7 +1,7 @@ package chimp.client.transport.zio -import chimp.client.transport.HttpTransport.HttpOutcome -import chimp.client.transport.{HttpTransport, StreamingHttpTransport, Transport} +import chimp.client.transport.ClientHttpTransport.HttpOutcome +import chimp.client.transport.{ClientHttpTransport, ClientStreamingHttpTransport, ClientTransport} import chimp.client.{McpProtocolException, McpSessionNotFoundException} import chimp.protocol.{JSONRPCErrorCodes, JSONRPCErrorObject, JSONRPCMessage, ProtocolVersion, RequestId} import org.slf4j.LoggerFactory @@ -15,7 +15,7 @@ import zio.{Duration, Exit, Promise, Ref, Schedule, Scope, Task, ZIO, ZLayer} import scala.concurrent.duration.FiniteDuration -final class ZioStreamingHttpTransport private ( +final class ZioClientHttpTransport private ( backend: StreamBackend[Task, ZioStreams], uri: Uri, protocolVersion: ProtocolVersion, @@ -28,9 +28,9 @@ final class ZioStreamingHttpTransport private ( incomingRef: Ref[JSONRPCMessage => Task[Unit]], lastEventId: Ref[Option[String]], closingRef: Ref[Boolean] -) extends StreamingHttpTransport[Task, ZioStreams](backend, uri, ZioStreams): +) extends ClientStreamingHttpTransport[Task, ZioStreams](backend, uri, ZioStreams): - private val log = LoggerFactory.getLogger(classOf[ZioStreamingHttpTransport]) + private val log = LoggerFactory.getLogger(classOf[ZioClientHttpTransport]) override given monad: MonadError[Task] = backend.monad @@ -53,7 +53,7 @@ final class ZioStreamingHttpTransport private ( .getAndSet(None) .flatMap: case Some(id) => - HttpTransport + ClientHttpTransport .baseDeleteRequest(uri, protocolVersion, id) .response(asStreamUnsafe(ZioStreams)) .send(backend) @@ -66,7 +66,7 @@ final class ZioStreamingHttpTransport private ( post(request).flatMap: resp => captureSession(resp) *> sessionRef.get.flatMap: session => - HttpTransport.resolveResponse(resp, session) match + ClientHttpTransport.resolveResponse(resp, session) match case Left(err: McpSessionNotFoundException) => sessionRef.set(None) *> ZIO.fail(err) case Left(err) => @@ -87,7 +87,7 @@ final class ZioStreamingHttpTransport private ( post(msg).flatMap: response => captureSession(response) *> sessionRef.get.flatMap: session => - HttpTransport.resolveResponse(response, session) match + ClientHttpTransport.resolveResponse(response, session) match case Left(err: McpSessionNotFoundException) => sessionRef.set(None) *> ZIO.fail(err) case Left(err) => @@ -101,8 +101,8 @@ final class ZioStreamingHttpTransport private ( private def post(msg: JSONRPCMessage): Task[Response[Either[String, Stream[Throwable, Byte]]]] = sessionRef.get.flatMap: session => - HttpTransport - .basePostRequest(uri, protocolVersion, session, Transport.encode(msg)) + ClientHttpTransport + .basePostRequest(uri, protocolVersion, session, ClientTransport.encode(msg)) .response(asStreamUnsafe(ZioStreams)) .send(backend) @@ -123,7 +123,7 @@ final class ZioStreamingHttpTransport private ( case Right(stream) => stream.runDrain.ignore private def decode(body: String): Task[JSONRPCMessage] = - Transport.decode(body) match + ClientTransport.decode(body) match case Right(msg) => ZIO.succeed(msg) case Left(err) => ZIO.fail(McpProtocolException(s"Failed to decode response body: ${err.getMessage}, payload $body")) @@ -164,7 +164,7 @@ final class ZioStreamingHttpTransport private ( private def dispatch(event: ServerSentEvent): Task[Unit] = event.data match case Some(data) if data.nonEmpty => - Transport.decode(data) match + ClientTransport.decode(data) match case Right(msg) => routeMessage(msg) case Left(_) => ZIO.unit case _ => ZIO.unit @@ -257,7 +257,7 @@ final class ZioStreamingHttpTransport private ( error = JSONRPCErrorObject(code = JSONRPCErrorCodes.InvocationError.code, message = "SSE stream ended before response") ) -object ZioStreamingHttpTransport: +object ZioClientHttpTransport: val defaultReconnectSchedule: Schedule[Any, Any, Any] = Schedule.exponential(Duration.fromMillis(100)).jittered || Schedule.spaced(Duration.fromSeconds(30)) @@ -266,9 +266,9 @@ object ZioStreamingHttpTransport: backend: StreamBackend[Task, ZioStreams], uri: Uri, protocolVersion: ProtocolVersion = ProtocolVersion.Latest, - timeout: FiniteDuration = Transport.defaultTimeout, + timeout: FiniteDuration = ClientTransport.defaultTimeout, reconnectSchedule: Schedule[Any, Any, Any] = defaultReconnectSchedule - ): Task[ZioStreamingHttpTransport] = + ): Task[ZioClientHttpTransport] = for scope <- Scope.make sessionRef <- Ref.make(Option.empty[String]) @@ -277,7 +277,7 @@ object ZioStreamingHttpTransport: incomingRef <- Ref.make[JSONRPCMessage => Task[Unit]](_ => ZIO.unit) lastEventId <- Ref.make(Option.empty[String]) closingRef <- Ref.make(false) - transport = new ZioStreamingHttpTransport( + transport = new ZioClientHttpTransport( backend, uri, protocolVersion, @@ -298,16 +298,16 @@ object ZioStreamingHttpTransport: backend: StreamBackend[Task, ZioStreams], uri: Uri, protocolVersion: ProtocolVersion = ProtocolVersion.Latest, - timeout: FiniteDuration = Transport.defaultTimeout, + timeout: FiniteDuration = ClientTransport.defaultTimeout, reconnectSchedule: Schedule[Any, Any, Any] = defaultReconnectSchedule - ): ZIO[Scope, Throwable, ZioStreamingHttpTransport] = + ): ZIO[Scope, Throwable, ZioClientHttpTransport] = ZIO.acquireRelease(apply(backend, uri, protocolVersion, timeout, reconnectSchedule))(_.close().ignore) def layer( backend: StreamBackend[Task, ZioStreams], uri: Uri, protocolVersion: ProtocolVersion = ProtocolVersion.Latest, - timeout: FiniteDuration = Transport.defaultTimeout, + timeout: FiniteDuration = ClientTransport.defaultTimeout, reconnectSchedule: Schedule[Any, Any, Any] = defaultReconnectSchedule - ): ZLayer[Any, Throwable, ZioStreamingHttpTransport] = + ): ZLayer[Any, Throwable, ZioClientHttpTransport] = ZLayer.scoped(scoped(backend, uri, protocolVersion, timeout, reconnectSchedule)) diff --git a/client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioStreamingStdioTransport.scala b/client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioClientStdioTransport.scala similarity index 79% rename from client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioStreamingStdioTransport.scala rename to client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioClientStdioTransport.scala index adfc597..92ad9e6 100644 --- a/client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioStreamingStdioTransport.scala +++ b/client-streaming/client-zio/src/main/scala/chimp/client/transport/zio/ZioClientStdioTransport.scala @@ -1,6 +1,6 @@ package chimp.client.transport.zio -import chimp.client.transport.{StreamingStdioTransport, Transport} +import chimp.client.transport.{ClientStreamingStdioTransport, ClientTransport} import chimp.protocol.JSONRPCMessage import org.slf4j.LoggerFactory import sttp.client4.impl.zio.RIOMonadAsyncError @@ -13,7 +13,7 @@ import java.io.File import java.nio.charset.StandardCharsets import scala.concurrent.duration.FiniteDuration -final class ZioStreamingStdioTransport private ( +final class ZioClientStdioTransport private ( command: List[String], env: Map[String, String], workDir: Option[File], @@ -23,9 +23,9 @@ final class ZioStreamingStdioTransport private ( writeQueue: Queue[JSONRPCMessage], pending: ZioPendingRequests, incomingRef: Ref[JSONRPCMessage => Task[Unit]] -) extends StreamingStdioTransport[Task](command, env, workDir): +) extends ClientStreamingStdioTransport[Task](command, env, workDir): - private val log = LoggerFactory.getLogger(classOf[ZioStreamingStdioTransport]) + private val log = LoggerFactory.getLogger(classOf[ZioClientStdioTransport]) override given monad: MonadError[Task] = new RIOMonadAsyncError[Any] @@ -54,7 +54,7 @@ final class ZioStreamingStdioTransport private ( val drain = process.stdout.linesStream .filter(_.nonEmpty) .mapZIO: line => - Transport.decode(line) match + ClientTransport.decode(line) match case Right(msg) => dispatch(msg) case Left(err) => ZIO.succeed(log.warn(s"Failed to parse JSON-RPC line: ${err.getMessage}, raw: $line")) .runDrain @@ -68,14 +68,14 @@ final class ZioStreamingStdioTransport private ( .forkIn(scope) .unit -object ZioStreamingStdioTransport: +object ZioClientStdioTransport: def apply( command: List[String], env: Map[String, String] = Map.empty, workDir: Option[File] = None, - timeout: FiniteDuration = Transport.defaultTimeout - ): Task[ZioStreamingStdioTransport] = + timeout: FiniteDuration = ClientTransport.defaultTimeout + ): Task[ZioClientStdioTransport] = for scope <- Scope.make writeQueue <- Queue.bounded[JSONRPCMessage](256) @@ -83,14 +83,14 @@ object ZioStreamingStdioTransport: incomingRef <- Ref.make[JSONRPCMessage => Task[Unit]](_ => ZIO.unit) stdinBytes = ZStream .fromQueue(writeQueue) - .map(msg => Chunk.fromArray((Transport.encode(msg) + "\n").getBytes(StandardCharsets.UTF_8))) + .map(msg => Chunk.fromArray((ClientTransport.encode(msg) + "\n").getBytes(StandardCharsets.UTF_8))) .flattenChunks baseCmd = Command(command.head, command.tail*) withEnv = if env.isEmpty then baseCmd else baseCmd.env(env) withDir = workDir.fold(withEnv)(withEnv.workingDirectory) cmd = withDir.stdin(ProcessInput.fromStream(stdinBytes, flushChunksEagerly = true)) process <- cmd.run.provideEnvironment(zio.ZEnvironment(scope)) - transport = new ZioStreamingStdioTransport(command, env, workDir, timeout, scope, process, writeQueue, pending, incomingRef) + transport = new ZioClientStdioTransport(command, env, workDir, timeout, scope, process, writeQueue, pending, incomingRef) _ <- transport.startReader _ <- transport.startStderr yield transport @@ -99,14 +99,14 @@ object ZioStreamingStdioTransport: command: List[String], env: Map[String, String] = Map.empty, workDir: Option[File] = None, - timeout: FiniteDuration = Transport.defaultTimeout - ): ZIO[Scope, Throwable, ZioStreamingStdioTransport] = + timeout: FiniteDuration = ClientTransport.defaultTimeout + ): ZIO[Scope, Throwable, ZioClientStdioTransport] = ZIO.acquireRelease(apply(command, env, workDir, timeout))(_.close().ignore) def layer( command: List[String], env: Map[String, String] = Map.empty, workDir: Option[File] = None, - timeout: FiniteDuration = Transport.defaultTimeout - ): ZLayer[Any, Throwable, ZioStreamingStdioTransport] = + timeout: FiniteDuration = ClientTransport.defaultTimeout + ): ZLayer[Any, Throwable, ZioClientStdioTransport] = ZLayer.scoped(scoped(command, env, workDir, timeout)) diff --git a/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioStreamingHttpIntegrationSpec.scala b/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientHttpIntegrationSpec.scala similarity index 58% rename from client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioStreamingHttpIntegrationSpec.scala rename to client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientHttpIntegrationSpec.scala index 9621987..eb7997d 100644 --- a/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioStreamingHttpIntegrationSpec.scala +++ b/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientHttpIntegrationSpec.scala @@ -1,7 +1,7 @@ package chimp.client.transport.zio -import chimp.client.integration.StreamingHttpIntegrationSpec -import chimp.client.transport.BidirectionalTransport +import chimp.client.integration.McpClientStreamingHttpIntegrationSpec +import chimp.client.transport.ClientBidirectionalTransport import chimp.protocol.ProtocolVersion import sttp.capabilities.zio.ZioStreams import sttp.client4.StreamBackend @@ -11,13 +11,13 @@ import zio.{Task, ZIO} import scala.concurrent.duration.FiniteDuration -class ZioStreamingHttpIntegrationSpec extends StreamingHttpIntegrationSpec[Task, StreamBackend[Task, ZioStreams]] with ZioToFuture: +class ZioMcpClientHttpIntegrationSpec extends McpClientStreamingHttpIntegrationSpec[Task, StreamBackend[Task, ZioStreams]] with ZioToFuture: override def usingBackend[A](use: StreamBackend[Task, ZioStreams] => Task[A]): Task[A] = HttpClientZioBackend().flatMap: b => use(b).ensuring(b.close().orDie) override def usingBidirectionalTransport[A](b: StreamBackend[Task, ZioStreams], uri: Uri, timeout: FiniteDuration)( - use: BidirectionalTransport[Task] => Task[A] + use: ClientBidirectionalTransport[Task] => Task[A] ): Task[A] = - ZIO.scoped(ZioStreamingHttpTransport.scoped(b, uri, ProtocolVersion.Latest, timeout).flatMap(use)) + ZIO.scoped(ZioClientHttpTransport.scoped(b, uri, ProtocolVersion.Latest, timeout).flatMap(use)) diff --git a/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientStdioIntegrationSpec.scala b/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientStdioIntegrationSpec.scala new file mode 100644 index 0000000..3d9dd14 --- /dev/null +++ b/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientStdioIntegrationSpec.scala @@ -0,0 +1,14 @@ +package chimp.client.transport.zio + +import chimp.client.integration.McpClientStdioIntegrationSpec +import chimp.client.transport.ClientBidirectionalTransport +import zio.{Task, ZIO} + +import scala.concurrent.duration.FiniteDuration + +class ZioMcpClientStdioIntegrationSpec extends McpClientStdioIntegrationSpec[Task] with ZioToFuture: + + override def usingTransport[A](command: List[String], timeout: FiniteDuration)( + use: ClientBidirectionalTransport[Task] => Task[A] + ): Task[A] = + ZIO.scoped(ZioClientStdioTransport.scoped(command, timeout = timeout).flatMap(use)) diff --git a/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioStdioIntegrationSpec.scala b/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioStdioIntegrationSpec.scala deleted file mode 100644 index bd65da3..0000000 --- a/client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioStdioIntegrationSpec.scala +++ /dev/null @@ -1,12 +0,0 @@ -package chimp.client.transport.zio - -import chimp.client.integration.StdioIntegrationSpec -import chimp.client.transport.BidirectionalTransport -import zio.{Task, ZIO} - -import scala.concurrent.duration.FiniteDuration - -class ZioStdioIntegrationSpec extends StdioIntegrationSpec[Task] with ZioToFuture: - - override def usingTransport[A](command: List[String], timeout: FiniteDuration)(use: BidirectionalTransport[Task] => Task[A]): Task[A] = - ZIO.scoped(ZioStreamingStdioTransport.scoped(command, timeout = timeout).flatMap(use)) diff --git a/client/src/main/scala/chimp/client/McpClient.scala b/client/src/main/scala/chimp/client/McpClient.scala index 39ccb3c..8c33c40 100644 --- a/client/src/main/scala/chimp/client/McpClient.scala +++ b/client/src/main/scala/chimp/client/McpClient.scala @@ -1,14 +1,14 @@ package chimp.client import chimp.client.notifications.ServerNotificationListener -import chimp.client.transport.{BidirectionalTransport, Transport} +import chimp.client.transport.{ClientBidirectionalTransport, ClientTransport} import chimp.protocol.* import io.circe.Json /** A Model Context Protocol (MCP) client that has completed the initialization handshake with a server. * * Exposes the server's advertised capabilities and identity, and provides methods for sending client-initiated requests and notifications - * over the underlying [[chimp.client.transport.Transport]]. + * over the underlying [[chimp.client.transport.ClientTransport]]. * * For bidirectional interaction (server-initiated requests, resource subscriptions, notification listeners), use * [[BidirectionalMcpClient]] instead. @@ -94,16 +94,7 @@ trait McpClient[F[_]]: */ def sendProgress(token: ProgressToken, progress: Double, total: Option[Double] = None, message: Option[String] = None): F[Unit] - /** Sends a `cancelled` notification, asking the server to stop processing a previously issued request. - * - * @param requestId - * Identifier of the request to cancel. - * @param reason - * Optional human-readable explanation. - */ - def sendCancelled(requestId: RequestId, reason: Option[String] = None): F[Unit] - -/** An [[McpClient]] used over a [[chimp.client.transport.BidirectionalTransport]], which additionally supports server-initiated +/** An [[McpClient]] used over a [[chimp.client.transport.ClientBidirectionalTransport]], which additionally supports server-initiated * interactions: subscribing to resource updates, notifying the server about changes to the client's roots, and handling notifications * pushed by the server. */ @@ -123,8 +114,8 @@ trait BidirectionalMcpClient[F[_]] extends McpClient[F]: def onServerNotification(listener: ServerNotificationListener[F]): F[Unit] object McpClient: - /** Creates an unidirectional [[McpClient]] over the given [[chimp.client.transport.Transport]] and performs the initialization handshake - * with the server. + /** Creates an unidirectional [[McpClient]] over the given [[chimp.client.transport.ClientTransport]] and performs the initialization + * handshake with the server. * * @param transport * The transport carrying JSON-RPC messages between client and server. @@ -134,15 +125,15 @@ object McpClient: * Protocol version proposed during initialization; defaults to the latest version supported by chimp. */ def apply[F[_]]( - transport: Transport[F], + transport: ClientTransport[F], clientInfo: Implementation, protocolVersion: ProtocolVersion = ProtocolVersion.Latest ): F[McpClient[F]] = McpClientImpl.create(transport, clientInfo, protocolVersion) - /** Creates a [[BidirectionalMcpClient]] over the given [[chimp.client.transport.BidirectionalTransport]] and performs the initialization - * handshake. The optional handlers determine which client capabilities (roots, sampling, elicitation) are advertised to the server; only - * capabilities backed by a handler are enabled. + /** Creates a [[BidirectionalMcpClient]] over the given [[chimp.client.transport.ClientBidirectionalTransport]] and performs the + * initialization handshake. The optional handlers determine which client capabilities (roots, sampling, elicitation) are advertised to + * the server; only capabilities backed by a handler are enabled. * * @param transport * The bidirectional transport carrying JSON-RPC messages in both directions. @@ -158,7 +149,7 @@ object McpClient: * Protocol version proposed during initialization; defaults to the latest version supported by chimp. */ def bidirectional[F[_]]( - transport: BidirectionalTransport[F], + transport: ClientBidirectionalTransport[F], clientInfo: Implementation, rootsHandler: Option[() => F[ListRootsResult]] = None, samplingHandler: Option[CreateMessageRequest => F[CreateMessageResult]] = None, diff --git a/client/src/main/scala/chimp/client/McpClientImpl.scala b/client/src/main/scala/chimp/client/McpClientImpl.scala index 15ad08c..0b96663 100644 --- a/client/src/main/scala/chimp/client/McpClientImpl.scala +++ b/client/src/main/scala/chimp/client/McpClientImpl.scala @@ -2,7 +2,7 @@ package chimp.client import chimp.client.internal.{Correlator, UUIDCorrelator} import chimp.client.notifications.{ServerNotification, ServerNotificationListener} -import chimp.client.transport.{BidirectionalTransport, Transport} +import chimp.client.transport.{ClientBidirectionalTransport, ClientTransport} import chimp.protocol.* import io.circe.syntax.* import io.circe.{Decoder, Json} @@ -13,7 +13,7 @@ import java.util.concurrent.atomic.AtomicReference object McpClientImpl: def create[F[_]]( - transport: Transport[F], + transport: ClientTransport[F], clientInfo: Implementation, protocolVersion: ProtocolVersion, correlator: Correlator = UUIDCorrelator() @@ -24,7 +24,7 @@ object McpClientImpl: new Impl[F](transport, clientInfo, protocolVersion, clientCapabilities, correlator, initResult) def createBidirectional[F[_]]( - transport: BidirectionalTransport[F], + transport: ClientBidirectionalTransport[F], clientInfo: Implementation, protocolVersion: ProtocolVersion, rootsHandler: Option[() => F[ListRootsResult]], @@ -57,7 +57,7 @@ object McpClientImpl: ) private def initialize[F[_]]( - transport: Transport[F], + transport: ClientTransport[F], clientInfo: Implementation, protocolVersion: ProtocolVersion, clientCapabilities: ClientCapabilities, @@ -121,7 +121,7 @@ object McpClientImpl: entries.toMap private def buildIncomingHandler[F[_]]( - transport: BidirectionalTransport[F], + transport: ClientBidirectionalTransport[F], serverInitiatedRequestHandlers: Map[String, Json => F[Json]], serverNotificationListeners: AtomicReference[List[ServerNotificationListener[F]]] )(using monad: MonadError[F]): JSONRPCMessage => F[Unit] = @@ -156,7 +156,7 @@ object McpClientImpl: monad.unit(()) private class Impl[F[_]]( - protected val transport: Transport[F], + protected val transport: ClientTransport[F], protected val clientInfo: Implementation, protected val protocolVersion: ProtocolVersion, protected val clientCapabilities: ClientCapabilities, @@ -220,10 +220,6 @@ object McpClientImpl: val params = ProgressParams(progressToken = token, progress = progress, total = total, message = message).asJson sendNotification("notifications/progress", Some(params)) - override def sendCancelled(requestId: RequestId, reason: Option[String]): F[Unit] = - val params = CancelledParams(requestId = requestId, reason = reason).asJson - sendNotification("notifications/cancelled", Some(params)) - protected def requireServerCapability[A](method: String, present: ServerCapabilities => Boolean)(action: => F[A]): F[A] = if present(serverCapabilities) then action else monad.error(McpProtocolException(s"Server did not negotiate the capability required for $method")) @@ -249,7 +245,7 @@ object McpClientImpl: transport.send(notification).map(_ => ()) private final class BidirectionalImpl[F[_]]( - bidiTransport: BidirectionalTransport[F], + bidiTransport: ClientBidirectionalTransport[F], clientInfo: Implementation, protocolVersion: ProtocolVersion, clientCapabilities: ClientCapabilities, diff --git a/client/src/main/scala/chimp/client/transport/HttpTransport.scala b/client/src/main/scala/chimp/client/transport/ClientHttpTransport.scala similarity index 89% rename from client/src/main/scala/chimp/client/transport/HttpTransport.scala rename to client/src/main/scala/chimp/client/transport/ClientHttpTransport.scala index 34ede3b..0610d46 100644 --- a/client/src/main/scala/chimp/client/transport/HttpTransport.scala +++ b/client/src/main/scala/chimp/client/transport/ClientHttpTransport.scala @@ -1,6 +1,6 @@ package chimp.client.transport -import chimp.client.transport.HttpTransport.HttpOutcome +import chimp.client.transport.ClientHttpTransport.HttpOutcome import chimp.client.{McpAuthorizationException, McpProtocolException, McpSessionNotFoundException, McpTransportException} import chimp.protocol.{JSONRPCMessage, ProtocolVersion} import sttp.client4.{basicRequest, Backend, Request, Response} @@ -21,22 +21,22 @@ import scala.util.chaining.* * @param protocolVersion * Protocol version advertised via the `MCP-Protocol-Version` header; defaults to the latest version supported by chimp. */ -final class HttpTransport[F[_]]( +final class ClientHttpTransport[F[_]]( backend: Backend[F], uri: Uri, protocolVersion: ProtocolVersion = ProtocolVersion.Latest -) extends Transport[F]: +) extends ClientTransport[F]: given monad: MonadError[F] = backend.monad private val sessionId = AtomicReference[Option[String]](None) override def send(msg: JSONRPCMessage): F[Option[JSONRPCMessage]] = - HttpTransport.basePostRequest(uri, protocolVersion, sessionId.get(), Transport.encode(msg)).send(backend).flatMap(interpret) + ClientHttpTransport.basePostRequest(uri, protocolVersion, sessionId.get(), ClientTransport.encode(msg)).send(backend).flatMap(interpret) private def interpret(response: Response[Either[String, String]]): F[Option[JSONRPCMessage]] = response.header("Mcp-Session-Id").foreach(s => sessionId.set(Some(s))) - HttpTransport.resolveResponse(response, sessionId.get()) match + ClientHttpTransport.resolveResponse(response, sessionId.get()) match case Left(error: McpSessionNotFoundException) => sessionId.set(None) monad.error(error) @@ -48,12 +48,12 @@ final class HttpTransport[F[_]]( case Right(body) => val payload = kind match case HttpOutcome.JsonBody => if body.isEmpty then None else Some(body) - case HttpOutcome.SseBody => HttpTransport.extractSingleSseData(body) + case HttpOutcome.SseBody => ClientHttpTransport.extractSingleSseData(body) case HttpOutcome.NoBody => None payload match case None => monad.unit(None) case Some(json) => - Transport.decode(json) match + ClientTransport.decode(json) match case Right(message) => monad.unit(Some(message)) case Left(error) => monad.error(McpProtocolException(s"Failed to decode response body: ${error.getMessage}, payload $json")) @@ -63,9 +63,9 @@ final class HttpTransport[F[_]]( case None => monad.unit(()) case Some(id) => sessionId.set(None) - HttpTransport.baseDeleteRequest(uri, protocolVersion, id).send(backend).map(_ => ()) + ClientHttpTransport.baseDeleteRequest(uri, protocolVersion, id).send(backend).map(_ => ()) -object HttpTransport: +object ClientHttpTransport: enum HttpOutcome: case NoBody case JsonBody diff --git a/client/src/main/scala/chimp/client/transport/StdioTransport.scala b/client/src/main/scala/chimp/client/transport/ClientStdioTransport.scala similarity index 93% rename from client/src/main/scala/chimp/client/transport/StdioTransport.scala rename to client/src/main/scala/chimp/client/transport/ClientStdioTransport.scala index 9ccdc02..92133cc 100644 --- a/client/src/main/scala/chimp/client/transport/StdioTransport.scala +++ b/client/src/main/scala/chimp/client/transport/ClientStdioTransport.scala @@ -25,14 +25,14 @@ import scala.jdk.CollectionConverters.* * @param timeout * Maximum time to wait for a response to each request before raising an [[chimp.client.McpTimeoutException]]. */ -final class StdioTransport( +final class ClientStdioTransport( command: List[String], env: Map[String, String] = Map.empty, workDir: Option[File] = None, - timeout: FiniteDuration = Transport.defaultTimeout -) extends BidirectionalTransport[Identity]: + timeout: FiniteDuration = ClientTransport.defaultTimeout +) extends ClientBidirectionalTransport[Identity]: - private val log = LoggerFactory.getLogger(classOf[StdioTransport]) + private val log = LoggerFactory.getLogger(classOf[ClientStdioTransport]) given monad: MonadError[Identity] = IdentityMonad @@ -68,7 +68,7 @@ final class StdioTransport( var line: String = reader.readLine() while line != null do if line.nonEmpty then - Transport.decode(line) match + ClientTransport.decode(line) match case Right(msg) => dispatch(msg) case Left(e) => log.warn(s"Failed to parse JSON-RPC line: ${e.getMessage}; raw: $line") line = reader.readLine() @@ -104,7 +104,7 @@ final class StdioTransport( private def writeLine(msg: JSONRPCMessage): Unit = writer.synchronized: - writer.write(Transport.encode(msg)) + writer.write(ClientTransport.encode(msg)) writer.newLine() writer.flush() diff --git a/client/src/main/scala/chimp/client/transport/StreamingHttpTransport.scala b/client/src/main/scala/chimp/client/transport/ClientStreamingHttpTransport.scala similarity index 69% rename from client/src/main/scala/chimp/client/transport/StreamingHttpTransport.scala rename to client/src/main/scala/chimp/client/transport/ClientStreamingHttpTransport.scala index f77dbf9..e53bdba 100644 --- a/client/src/main/scala/chimp/client/transport/StreamingHttpTransport.scala +++ b/client/src/main/scala/chimp/client/transport/ClientStreamingHttpTransport.scala @@ -5,11 +5,10 @@ import sttp.client4.StreamBackend import sttp.model.Uri /** Abstract base for streaming HTTP transports. The extra type parameter `S` carries the streaming capability evidence required by the sttp - * [[sttp.client4.StreamBackend]], which is needed to consume Server-Sent Event responses as an asynchronous stream. Concrete - * implementations live in effect-specific modules. + * [[sttp.client4.StreamBackend]], which is needed to consume Server-Sent Event responses as an asynchronous stream. */ -abstract class StreamingHttpTransport[F[_], S]( +abstract class ClientStreamingHttpTransport[F[_], S]( protected val backend: StreamBackend[F, S], protected val uri: Uri, protected val streams: Streams[S] -) extends BidirectionalTransport[F] +) extends ClientBidirectionalTransport[F] diff --git a/client/src/main/scala/chimp/client/transport/StreamingStdioTransport.scala b/client/src/main/scala/chimp/client/transport/ClientStreamingStdioTransport.scala similarity index 53% rename from client/src/main/scala/chimp/client/transport/StreamingStdioTransport.scala rename to client/src/main/scala/chimp/client/transport/ClientStreamingStdioTransport.scala index 2021960..42b9b99 100644 --- a/client/src/main/scala/chimp/client/transport/StreamingStdioTransport.scala +++ b/client/src/main/scala/chimp/client/transport/ClientStreamingStdioTransport.scala @@ -3,12 +3,11 @@ package chimp.client.transport import java.io.File import scala.concurrent.duration.FiniteDuration -/** Abstract base for streaming stdio transports that should consume the subprocess's stdout as an asynchronous stream. Concrete - * implementations live in effect-specific modules. +/** Abstract base for streaming stdio transports that should consume the subprocess's stdout as an asynchronous stream. */ -abstract class StreamingStdioTransport[F[_]]( +abstract class ClientStreamingStdioTransport[F[_]]( protected val command: List[String], protected val env: Map[String, String] = Map.empty, protected val workDir: Option[File] = None, - protected val timeout: FiniteDuration = Transport.defaultTimeout -) extends BidirectionalTransport[F] + protected val timeout: FiniteDuration = ClientTransport.defaultTimeout +) extends ClientBidirectionalTransport[F] diff --git a/client/src/main/scala/chimp/client/transport/Transport.scala b/client/src/main/scala/chimp/client/transport/ClientTransport.scala similarity index 68% rename from client/src/main/scala/chimp/client/transport/Transport.scala rename to client/src/main/scala/chimp/client/transport/ClientTransport.scala index e32e63e..2d18e2a 100644 --- a/client/src/main/scala/chimp/client/transport/Transport.scala +++ b/client/src/main/scala/chimp/client/transport/ClientTransport.scala @@ -10,18 +10,18 @@ import scala.concurrent.duration.{DurationInt, FiniteDuration} /** A unidirectional MCP transport: the client sends a [[chimp.protocol.JSONRPCMessage]] and optionally receives a response back. For * transports that doesn't handle server initiated requests. */ -trait Transport[F[_]]: +trait ClientTransport[F[_]]: given monad: MonadError[F] def send(msg: JSONRPCMessage): F[Option[JSONRPCMessage]] def close(): F[Unit] -/** A bidirectional MCP transport that, in addition to [[Transport.send]] calls, allows the server to push messages to the client. Incoming - * messages are delivered to the registered handler via [[onIncoming]]. Used by [[chimp.client.BidirectionalMcpClient]]. +/** A bidirectional MCP transport that, in addition to [[ClientTransport.send]] calls, allows the server to push messages to the client. + * Incoming messages are delivered to the registered handler via [[onIncoming]]. Used by [[chimp.client.BidirectionalMcpClient]]. */ -trait BidirectionalTransport[F[_]] extends Transport[F]: +trait ClientBidirectionalTransport[F[_]] extends ClientTransport[F]: def onIncoming(handler: JSONRPCMessage => F[Unit]): F[Unit] -object Transport: +object ClientTransport: val defaultTimeout: FiniteDuration = 60.seconds diff --git a/client/src/test/scala/chimp/client/HttpTransportSpec.scala b/client/src/test/scala/chimp/client/ClientHttpTransportSpec.scala similarity index 84% rename from client/src/test/scala/chimp/client/HttpTransportSpec.scala rename to client/src/test/scala/chimp/client/ClientHttpTransportSpec.scala index 54e3328..f0724a0 100644 --- a/client/src/test/scala/chimp/client/HttpTransportSpec.scala +++ b/client/src/test/scala/chimp/client/ClientHttpTransportSpec.scala @@ -1,6 +1,6 @@ package chimp.client -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.* import io.circe.Json import io.circe.syntax.* @@ -11,7 +11,7 @@ import sttp.client4.testing.SyncBackendStub import sttp.model.StatusCode import sttp.shared.Identity -class HttpTransportSpec extends AnyFlatSpec with Matchers: +class ClientHttpTransportSpec extends AnyFlatSpec with Matchers: private val mcpUri = sttp.model.Uri.parse("http://localhost/mcp").toOption.get @@ -22,7 +22,7 @@ class HttpTransportSpec extends AnyFlatSpec with Matchers: StatusCode.Ok ) - val transport = HttpTransport[Identity](backend, mcpUri) + val transport = ClientHttpTransport[Identity](backend, mcpUri) val request: JSONRPCMessage = JSONRPCMessage.Request(method = "x", params = None, id = RequestId(1)) transport.send(request) match case Some(JSONRPCMessage.Response(_, _, result)) => Assertions.succeed @@ -30,13 +30,13 @@ class HttpTransportSpec extends AnyFlatSpec with Matchers: it should "return none for 202 Accepted (notification ack)" in: val backend = SyncBackendStub.whenAnyRequest.thenRespondAdjust("", StatusCode.Accepted) - val transport = HttpTransport[Identity](backend, mcpUri) + val transport = ClientHttpTransport[Identity](backend, mcpUri) val notification: JSONRPCMessage = JSONRPCMessage.Notification(method = "notifications/initialized") transport.send(notification) shouldBe None it should "fail with McpAuthorizationException on 401" in: val backend = SyncBackendStub.whenAnyRequest.thenRespondAdjust("", StatusCode.Unauthorized) - val transport = HttpTransport[Identity](backend, mcpUri) + val transport = ClientHttpTransport[Identity](backend, mcpUri) val request: JSONRPCMessage = JSONRPCMessage.Request(method = "x", params = None, id = RequestId(1)) val ex = intercept[McpAuthorizationException](transport.send(request)) ex.statusCode shouldBe 401 diff --git a/client/src/test/scala/chimp/client/InMemoryTransport.scala b/client/src/test/scala/chimp/client/InMemoryTransport.scala index f3c4e3c..ddcea52 100644 --- a/client/src/test/scala/chimp/client/InMemoryTransport.scala +++ b/client/src/test/scala/chimp/client/InMemoryTransport.scala @@ -1,6 +1,6 @@ package chimp.client -import chimp.client.transport.BidirectionalTransport +import chimp.client.transport.ClientBidirectionalTransport import chimp.protocol.JSONRPCMessage import sttp.monad.{IdentityMonad, MonadError} import sttp.shared.Identity @@ -8,7 +8,7 @@ import sttp.shared.Identity import java.util.concurrent.atomic.AtomicReference import scala.collection.mutable -final class InMemoryTransport extends BidirectionalTransport[Identity]: +final class InMemoryTransport extends ClientBidirectionalTransport[Identity]: given monad: MonadError[Identity] = IdentityMonad private val incomingHandler = AtomicReference[JSONRPCMessage => Identity[Unit]](_ => ()) diff --git a/client/src/test/scala/chimp/client/McpClientSpec.scala b/client/src/test/scala/chimp/client/McpClientSpec.scala index 8b08ca1..63c3077 100644 --- a/client/src/test/scala/chimp/client/McpClientSpec.scala +++ b/client/src/test/scala/chimp/client/McpClientSpec.scala @@ -1,6 +1,6 @@ package chimp.client -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.* import io.circe.syntax.* import org.scalatest.flatspec.AnyFlatSpec @@ -30,7 +30,7 @@ class McpClientSpec extends AnyFlatSpec with Matchers: (JSONRPCMessage.Response(id = RequestId(1), result = initResult.asJson): JSONRPCMessage).asJson.noSpaces val backend = SyncBackendStub.whenAnyRequest.thenRespondAdjust(responseEnvelope) - val client = McpClient[Identity](HttpTransport[Identity](backend, mcpUri), clientInfo, ProtocolVersion.Latest) + val client = McpClient[Identity](ClientHttpTransport[Identity](backend, mcpUri), clientInfo, ProtocolVersion.Latest) client.serverInfo.name shouldBe "test-server" it should "call a tool and decode the result after initialization" in: @@ -53,7 +53,7 @@ class McpClientSpec extends AnyFlatSpec with Matchers: .whenAnyRequest .thenRespondAdjust("", StatusCode.Accepted) - val client = McpClient[Identity](HttpTransport[Identity](backend, mcpUri), clientInfo, ProtocolVersion.Latest) + val client = McpClient[Identity](ClientHttpTransport[Identity](backend, mcpUri), clientInfo, ProtocolVersion.Latest) val result = client.callTool("echo", io.circe.Json.obj("message" -> io.circe.Json.fromString("hi"))) result.isError shouldBe false result.content.head shouldBe ToolContent.Text("text", "hi") @@ -67,11 +67,11 @@ class McpClientSpec extends AnyFlatSpec with Matchers: val initEnvelope = (JSONRPCMessage.Response(id = RequestId(1), result = initResult.asJson): JSONRPCMessage).asJson.noSpaces val backend = SyncBackendStub.whenAnyRequest.thenRespondAdjust(initEnvelope) - val client = McpClient[Identity](HttpTransport[Identity](backend, mcpUri), clientInfo, ProtocolVersion.Latest) + val client = McpClient[Identity](ClientHttpTransport[Identity](backend, mcpUri), clientInfo, ProtocolVersion.Latest) val ex = intercept[McpProtocolException](client.callTool("anything", io.circe.Json.obj())) ex.getMessage should include("Server did not negotiate the capability required for tools/call") it should "fail construction when no initialize response is received" in: val backend = SyncBackendStub.whenAnyRequest.thenRespondAdjust("") - val t = HttpTransport[Identity](backend, mcpUri) + val t = ClientHttpTransport[Identity](backend, mcpUri) intercept[McpTransportException](McpClient[Identity](t, clientInfo, ProtocolVersion.Latest)) diff --git a/client/src/test/scala/chimp/client/integration/BidirectionalHttpMcpClientTests.scala b/client/src/test/scala/chimp/client/integration/McpClientBidirectionalHttpTests.scala similarity index 93% rename from client/src/test/scala/chimp/client/integration/BidirectionalHttpMcpClientTests.scala rename to client/src/test/scala/chimp/client/integration/McpClientBidirectionalHttpTests.scala index fc9fdb4..befd94b 100644 --- a/client/src/test/scala/chimp/client/integration/BidirectionalHttpMcpClientTests.scala +++ b/client/src/test/scala/chimp/client/integration/McpClientBidirectionalHttpTests.scala @@ -1,7 +1,7 @@ package chimp.client.integration import chimp.client.notifications.{ServerNotification, ServerNotificationListener} -import chimp.client.transport.Transport +import chimp.client.transport.ClientTransport import chimp.client.{BidirectionalMcpClient, McpTimeoutException} import chimp.protocol.* import io.circe.Json @@ -14,13 +14,13 @@ import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger, AtomicReferenc import scala.concurrent.Future import scala.concurrent.duration.{DurationInt, FiniteDuration} -trait BidirectionalHttpMcpClientTests[F[_]] extends AsyncFlatSpec with Matchers: +trait McpClientBidirectionalHttpTests[F[_]] extends AsyncFlatSpec with Matchers: this: ToFuture[F] => protected def withProxiedBidirectionalClient( samplingHandler: Option[CreateMessageRequest => F[CreateMessageResult]] = None, - timeout: FiniteDuration = Transport.defaultTimeout - )(test: (MCPProxyContainer, BidirectionalMcpClient[F]) => F[Assertion]): Future[Assertion] + timeout: FiniteDuration = ClientTransport.defaultTimeout + )(test: (McpToxiproxyContainer, BidirectionalMcpClient[F]) => F[Assertion]): Future[Assertion] "GET SSE stream" should "resume delivering notifications after the underlying connection is cut" in: val logCount = AtomicInteger(0) diff --git a/client/src/test/scala/chimp/client/integration/BidirectionalMcpClientTests.scala b/client/src/test/scala/chimp/client/integration/McpClientBidirectionalTests.scala similarity index 98% rename from client/src/test/scala/chimp/client/integration/BidirectionalMcpClientTests.scala rename to client/src/test/scala/chimp/client/integration/McpClientBidirectionalTests.scala index 61e9fb2..e24d938 100644 --- a/client/src/test/scala/chimp/client/integration/BidirectionalMcpClientTests.scala +++ b/client/src/test/scala/chimp/client/integration/McpClientBidirectionalTests.scala @@ -12,7 +12,7 @@ import sttp.monad.syntax.* import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference} import scala.concurrent.Future -trait BidirectionalMcpClientTests[F[_]] extends AsyncFlatSpec with Matchers: +trait McpClientBidirectionalTests[F[_]] extends AsyncFlatSpec with Matchers: this: ToFuture[F] => protected def withBidirectionalClient( diff --git a/client/src/test/scala/chimp/client/integration/HttpIntegrationSpec.scala b/client/src/test/scala/chimp/client/integration/McpClientHttpIntegrationSpec.scala similarity index 81% rename from client/src/test/scala/chimp/client/integration/HttpIntegrationSpec.scala rename to client/src/test/scala/chimp/client/integration/McpClientHttpIntegrationSpec.scala index 9212599..0baa655 100644 --- a/client/src/test/scala/chimp/client/integration/HttpIntegrationSpec.scala +++ b/client/src/test/scala/chimp/client/integration/McpClientHttpIntegrationSpec.scala @@ -1,7 +1,7 @@ package chimp.client.integration import chimp.client.McpClient -import chimp.client.transport.Transport +import chimp.client.transport.ClientTransport import chimp.protocol.{Implementation, ProtocolVersion} import org.scalatest.flatspec.AsyncFlatSpec import org.scalatest.matchers.should.Matchers @@ -12,7 +12,7 @@ import sttp.monad.syntax.* import scala.concurrent.Future -abstract class HttpIntegrationSpec[F[_], B] +abstract class McpClientHttpIntegrationSpec[F[_], B] extends AsyncFlatSpec with Matchers with BeforeAndAfterAll @@ -21,7 +21,7 @@ abstract class HttpIntegrationSpec[F[_], B] this: ToFuture[F] => protected val network: Network = Network.newNetwork() - protected val mcpEverythingContainer: MCPEverythingContainer = new MCPEverythingContainer(network = Some(network)) + protected val mcpEverythingContainer: McpEverythingContainer = new McpEverythingContainer(network = Some(network)) override def beforeAll(): Unit = super.beforeAll() @@ -34,7 +34,7 @@ abstract class HttpIntegrationSpec[F[_], B] finally super.afterAll() def usingBackend[A](use: B => F[A]): F[A] - def usingTransport[A](backend: B, uri: Uri)(use: Transport[F] => F[A]): F[A] + def usingTransport[A](backend: B, uri: Uri)(use: ClientTransport[F] => F[A]): F[A] private val clientInfo = Implementation(name = "chimp-integration", version = "0.0.1") diff --git a/client/src/test/scala/chimp/client/integration/StdioIntegrationSpec.scala b/client/src/test/scala/chimp/client/integration/McpClientStdioIntegrationSpec.scala similarity index 87% rename from client/src/test/scala/chimp/client/integration/StdioIntegrationSpec.scala rename to client/src/test/scala/chimp/client/integration/McpClientStdioIntegrationSpec.scala index fd4f57c..f5ff3b9 100644 --- a/client/src/test/scala/chimp/client/integration/StdioIntegrationSpec.scala +++ b/client/src/test/scala/chimp/client/integration/McpClientStdioIntegrationSpec.scala @@ -1,6 +1,6 @@ package chimp.client.integration -import chimp.client.transport.{BidirectionalTransport, Transport} +import chimp.client.transport.{ClientBidirectionalTransport, ClientTransport} import chimp.client.{BidirectionalMcpClient, McpClient, McpTimeoutException} import chimp.protocol.* import org.scalatest.Assertion @@ -12,18 +12,18 @@ import java.util.concurrent.atomic.AtomicReference import scala.concurrent.Future import scala.concurrent.duration.{DurationInt, FiniteDuration} -abstract class StdioIntegrationSpec[F[_]] +abstract class McpClientStdioIntegrationSpec[F[_]] extends AsyncFlatSpec with Matchers with IntegrationSpec with McpClientTests[F] - with BidirectionalMcpClientTests[F]: + with McpClientBidirectionalTests[F]: this: ToFuture[F] => protected val everythingServerCommand: List[String] = List("npx", "-y", "@modelcontextprotocol/server-everything@2026.1.26") - def usingTransport[A](command: List[String], timeout: FiniteDuration)(use: BidirectionalTransport[F] => F[A]): F[A] + def usingTransport[A](command: List[String], timeout: FiniteDuration)(use: ClientBidirectionalTransport[F] => F[A]): F[A] private val clientInfo = Implementation(name = "chimp-integration", version = "0.0.1") @@ -36,7 +36,7 @@ abstract class StdioIntegrationSpec[F[_]] elicitationHandler: Option[ElicitRequest => F[ElicitResult]] = None )(test: BidirectionalMcpClient[F] => F[Assertion]): Future[Assertion] = toFuture( - usingTransport(everythingServerCommand, Transport.defaultTimeout): transport => + usingTransport(everythingServerCommand, ClientTransport.defaultTimeout): transport => McpClient .bidirectional[F](transport, clientInfo, rootsHandler, samplingHandler, elicitationHandler, ProtocolVersion.Latest) .flatMap: client => diff --git a/client/src/test/scala/chimp/client/integration/StreamingHttpIntegrationSpec.scala b/client/src/test/scala/chimp/client/integration/McpClientStreamingHttpIntegrationSpec.scala similarity index 69% rename from client/src/test/scala/chimp/client/integration/StreamingHttpIntegrationSpec.scala rename to client/src/test/scala/chimp/client/integration/McpClientStreamingHttpIntegrationSpec.scala index 4f72d80..e5028e9 100644 --- a/client/src/test/scala/chimp/client/integration/StreamingHttpIntegrationSpec.scala +++ b/client/src/test/scala/chimp/client/integration/McpClientStreamingHttpIntegrationSpec.scala @@ -1,6 +1,6 @@ package chimp.client.integration -import chimp.client.transport.{BidirectionalTransport, Transport} +import chimp.client.transport.{ClientBidirectionalTransport, ClientTransport} import chimp.client.{BidirectionalMcpClient, McpClient} import chimp.protocol.* import org.scalatest.Assertion @@ -10,13 +10,13 @@ import sttp.monad.syntax.* import scala.concurrent.Future import scala.concurrent.duration.FiniteDuration -abstract class StreamingHttpIntegrationSpec[F[_], B] - extends HttpIntegrationSpec[F, B] - with BidirectionalMcpClientTests[F] - with BidirectionalHttpMcpClientTests[F]: +abstract class McpClientStreamingHttpIntegrationSpec[F[_], B] + extends McpClientHttpIntegrationSpec[F, B] + with McpClientBidirectionalTests[F] + with McpClientBidirectionalHttpTests[F]: this: ToFuture[F] => - private val proxyContainer: MCPProxyContainer = new MCPProxyContainer(network, mcpEverythingContainer.alias, 3001) + private val proxyContainer: McpToxiproxyContainer = new McpToxiproxyContainer(network, mcpEverythingContainer.alias, 3001) override def beforeAll(): Unit = super.beforeAll() @@ -26,10 +26,10 @@ abstract class StreamingHttpIntegrationSpec[F[_], B] try proxyContainer.stop() finally super.afterAll() - def usingBidirectionalTransport[A](b: B, uri: Uri, timeout: FiniteDuration)(use: BidirectionalTransport[F] => F[A]): F[A] + def usingBidirectionalTransport[A](b: B, uri: Uri, timeout: FiniteDuration)(use: ClientBidirectionalTransport[F] => F[A]): F[A] - override def usingTransport[A](backend: B, uri: Uri)(use: Transport[F] => F[A]): F[A] = - usingBidirectionalTransport(backend, uri, Transport.defaultTimeout)(use) + override def usingTransport[A](backend: B, uri: Uri)(use: ClientTransport[F] => F[A]): F[A] = + usingBidirectionalTransport(backend, uri, ClientTransport.defaultTimeout)(use) private val clientInfo = Implementation(name = "chimp-integration", version = "0.0.1") @@ -40,7 +40,7 @@ abstract class StreamingHttpIntegrationSpec[F[_], B] )(test: BidirectionalMcpClient[F] => F[Assertion]): Future[Assertion] = toFuture( usingBackend: backend => - usingBidirectionalTransport(backend, mcpEverythingContainer.mcpUri, Transport.defaultTimeout): transport => + usingBidirectionalTransport(backend, mcpEverythingContainer.mcpUri, ClientTransport.defaultTimeout): transport => McpClient .bidirectional[F](transport, clientInfo, rootsHandler, samplingHandler, elicitationHandler, ProtocolVersion.Latest) .flatMap: client => @@ -49,8 +49,8 @@ abstract class StreamingHttpIntegrationSpec[F[_], B] override protected def withProxiedBidirectionalClient( samplingHandler: Option[CreateMessageRequest => F[CreateMessageResult]] = None, - timeout: FiniteDuration = Transport.defaultTimeout - )(test: (MCPProxyContainer, BidirectionalMcpClient[F]) => F[Assertion]): Future[Assertion] = + timeout: FiniteDuration = ClientTransport.defaultTimeout + )(test: (McpToxiproxyContainer, BidirectionalMcpClient[F]) => F[Assertion]): Future[Assertion] = proxyContainer.restoreConnections() proxyContainer.clearToxics() toFuture( diff --git a/client/src/test/scala/chimp/client/integration/MCPEverythingContainer.scala b/client/src/test/scala/chimp/client/integration/McpEverythingContainer.scala similarity index 92% rename from client/src/test/scala/chimp/client/integration/MCPEverythingContainer.scala rename to client/src/test/scala/chimp/client/integration/McpEverythingContainer.scala index cf0fd9b..32d61b6 100644 --- a/client/src/test/scala/chimp/client/integration/MCPEverythingContainer.scala +++ b/client/src/test/scala/chimp/client/integration/McpEverythingContainer.scala @@ -7,7 +7,7 @@ import sttp.model.Uri import java.time.Duration -class MCPEverythingContainer(network: Option[Network] = None, networkAlias: String = "everything") +class McpEverythingContainer(network: Option[Network] = None, networkAlias: String = "everything") extends GenericContainer( dockerImage = "node:24-alpine", exposedPorts = Seq(3001), diff --git a/client/src/test/scala/chimp/client/integration/MCPProxyContainer.scala b/client/src/test/scala/chimp/client/integration/McpToxiproxyContainer.scala similarity index 93% rename from client/src/test/scala/chimp/client/integration/MCPProxyContainer.scala rename to client/src/test/scala/chimp/client/integration/McpToxiproxyContainer.scala index 9f7c098..c7c6963 100644 --- a/client/src/test/scala/chimp/client/integration/MCPProxyContainer.scala +++ b/client/src/test/scala/chimp/client/integration/McpToxiproxyContainer.scala @@ -8,7 +8,7 @@ import sttp.model.Uri import scala.jdk.CollectionConverters.* -class MCPProxyContainer(network: Network, upstreamAlias: String, upstreamPort: Int) +class McpToxiproxyContainer(network: Network, upstreamAlias: String, upstreamPort: Int) extends ToxiproxyContainer(DockerImageName.parse("ghcr.io/shopify/toxiproxy:2.12.0")): container.withNetwork(network) diff --git a/client/src/test/scala/chimp/client/integration/SyncHttpIntegrationSpec.scala b/client/src/test/scala/chimp/client/integration/SyncHttpIntegrationSpec.scala deleted file mode 100644 index 1360777..0000000 --- a/client/src/test/scala/chimp/client/integration/SyncHttpIntegrationSpec.scala +++ /dev/null @@ -1,18 +0,0 @@ -package chimp.client.integration - -import chimp.client.transport.{HttpTransport, Transport} -import sttp.client4.{Backend, DefaultSyncBackend} -import sttp.model.Uri -import sttp.shared.Identity - -class SyncHttpIntegrationSpec extends HttpIntegrationSpec[Identity, Backend[Identity]] with SyncToFuture: - - override def usingBackend[A](use: Backend[Identity] => Identity[A]): Identity[A] = - val backend = DefaultSyncBackend() - try use(backend) - finally backend.close() - - override def usingTransport[A](backend: Backend[Identity], uri: Uri)(use: Transport[Identity] => Identity[A]): Identity[A] = - val transport = HttpTransport[Identity](backend, uri) - try use(transport) - finally transport.close() diff --git a/client/src/test/scala/chimp/client/integration/SyncStdioIntegrationSpec.scala b/client/src/test/scala/chimp/client/integration/SyncStdioIntegrationSpec.scala deleted file mode 100644 index b69820e..0000000 --- a/client/src/test/scala/chimp/client/integration/SyncStdioIntegrationSpec.scala +++ /dev/null @@ -1,15 +0,0 @@ -package chimp.client.integration - -import chimp.client.transport.{BidirectionalTransport, StdioTransport} -import sttp.shared.Identity - -import scala.concurrent.duration.FiniteDuration - -class SyncStdioIntegrationSpec extends StdioIntegrationSpec[Identity] with SyncToFuture: - - override def usingTransport[A](command: List[String], timeout: FiniteDuration)( - use: BidirectionalTransport[Identity] => Identity[A] - ): Identity[A] = - val transport = StdioTransport(command, timeout = timeout) - try use(transport) - finally transport.close() diff --git a/conformance-baseline.yml b/conformance-baseline.yml index 2eb1a2e..113306b 100644 --- a/conformance-baseline.yml +++ b/conformance-baseline.yml @@ -1,32 +1,14 @@ server: - - tools-call-image - - tools-call-audio - - tools-call-mixed-content - - tools-call-embedded-resource - tools-call-with-progress - tools-call-with-logging - tools-call-sampling - tools-call-elicitation - - json-schema-2020-12 - elicitation-sep1034-defaults - elicitation-sep1330-enums - - resources-list - - resources-read-text - - resources-read-binary - - resources-templates-read - - resources-subscribe - - resources-unsubscribe - - sep-2164-resource-not-found - - prompts-list - - prompts-get-simple - - prompts-get-with-args - - prompts-get-embedded-resource - - prompts-get-with-image - - logging-set-level - - completion-complete - server-sse-polling - server-sse-multiple-streams - - dns-rebinding-protection + - json-schema-2020-12 + - sep-2164-resource-not-found client: - elicitation-sep1034-client-defaults - sse-retry diff --git a/docs/client/capabilities.md b/docs/client/capabilities.md index 27464bd..f9e28d3 100644 --- a/docs/client/capabilities.md +++ b/docs/client/capabilities.md @@ -9,26 +9,37 @@ Beyond calling tools, an MCP client can advertise capabilities that let the serv - [Notifications](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#notifications) — receiving server-pushed events such as resource updates and list changes. ```{note} -All of these require the server to push messages to the client, so they only work over a **bidirectional, streaming transport** (e.g. `ZioStreamingHttpTransport`). They are unavailable on the plain `HttpTransport`. +All of these require the server to push messages to the client, so they only work over a **bidirectional, streaming transport** (e.g. `ZioClientHttpTransport`). They are unavailable on the plain `ClientHttpTransport`. ``` Create the client with `McpClient.bidirectional`, providing a handler for each capability you want to enable — only capabilities backed by a handler are advertised to the server: -```scala -val client = McpClient.bidirectional[Task]( - transport, - clientInfo = Implementation("my-client", "0.1.0"), - rootsHandler = Some(() => ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))), - // samplingHandler = Some(...), - // elicitationHandler = Some(...), -) +```scala mdoc:compile-only +import chimp.client.* +import chimp.client.transport.ClientBidirectionalTransport +import chimp.protocol.* +import zio.* + +def connect(transport: ClientBidirectionalTransport[Task]): Task[BidirectionalMcpClient[Task]] = + McpClient.bidirectional[Task]( + transport, + clientInfo = Implementation("my-client", "0.1.0"), + rootsHandler = Some(() => ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))) + // samplingHandler = Some(...), + // elicitationHandler = Some(...), + ) ``` Register a listener for server notifications with `onServerNotification`: -```scala -client.onServerNotification { - case ServerNotification.ResourceUpdated(uri) => ZIO.logInfo(s"resource changed: $uri") - case _ => ZIO.unit -} +```scala mdoc:compile-only +import chimp.client.* +import chimp.client.notifications.ServerNotification +import zio.* + +def listen(client: BidirectionalMcpClient[Task]): Task[Unit] = + client.onServerNotification { + case ServerNotification.ResourceUpdated(params) => ZIO.logInfo(s"resource changed: ${params.uri}") + case _ => ZIO.unit + } ``` diff --git a/docs/client/examples.md b/docs/client/examples.md index 7a70b9f..e853e94 100644 --- a/docs/client/examples.md +++ b/docs/client/examples.md @@ -2,65 +2,59 @@ ## HTTP client -A synchronous client over `HttpTransport`, calling a tool: - -```scala -//> using dep com.softwaremill.chimp::chimp-client:0.2.0 -//> using dep com.softwaremill.sttp.client4::core:4.0.23 +A synchronous client over `ClientHttpTransport`, calling a tool: +```scala mdoc:compile-only import chimp.client.* -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.* import io.circe.Json import sttp.client4.DefaultSyncBackend import sttp.model.Uri.UriContext import sttp.shared.Identity -@main def httpClient(): Unit = - val backend = DefaultSyncBackend() - val transport = HttpTransport[Identity](backend, uri"http://localhost:8080/mcp") - val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) +object HttpClient: + def main(args: Array[String]): Unit = + val backend = DefaultSyncBackend() + val transport = ClientHttpTransport[Identity](backend, uri"http://localhost:8080/mcp") + val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) - val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - result.content.collect { case ToolContent.Text(_, text) => println(text) } + val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) + result.content.collect { case ToolContent.Text(_, text) => text }.foreach(println) - client.close() - backend.close() + client.close() + backend.close() ``` ## STDIO client -A synchronous client that launches a local MCP server as a subprocess over `StdioTransport`: - -```scala -//> using dep com.softwaremill.chimp::chimp-client:0.2.0 +A synchronous client that launches a local MCP server as a subprocess over `ClientStdioTransport`: +```scala mdoc:compile-only import chimp.client.* -import chimp.client.transport.StdioTransport +import chimp.client.transport.ClientStdioTransport import chimp.protocol.* import io.circe.Json import sttp.shared.Identity -@main def stdioClient(): Unit = - val transport = StdioTransport(command = List("my-mcp-server")) - val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) +object StdioClient: + def main(args: Array[String]): Unit = + val transport = ClientStdioTransport(command = List("my-mcp-server")) + val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) - val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - result.content.collect { case ToolContent.Text(_, text) => println(text) } + val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) + result.content.collect { case ToolContent.Text(_, text) => text }.foreach(println) - client.close() + client.close() ``` ## Roots over a ZIO streaming transport -[Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) require a bidirectional, streaming transport — here `ZioStreamingHttpTransport`: - -```scala -//> using dep com.softwaremill.chimp::chimp-client-zio:0.2.0 -//> using dep com.softwaremill.sttp.client4::zio:4.0.23 +[Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) require a bidirectional, streaming transport — here `ZioClientHttpTransport`: +```scala mdoc:compile-only import chimp.client.* -import chimp.client.transport.zio.ZioStreamingHttpTransport +import chimp.client.transport.zio.ZioClientHttpTransport import chimp.protocol.* import sttp.client4.httpclient.zio.HttpClientZioBackend import sttp.model.Uri.UriContext @@ -71,12 +65,11 @@ object RootsClient extends ZIOAppDefault: HttpClientZioBackend.scoped().flatMap { backend => ZIO.scoped { for - transport <- ZioStreamingHttpTransport.scoped(backend, uri"http://localhost:8080/mcp") + transport <- ZioClientHttpTransport.scoped(backend, uri"http://localhost:8080/mcp") client <- McpClient.bidirectional[Task]( transport, clientInfo = Implementation("my-client", "0.1.0"), - rootsHandler = Some(() => - ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))) + rootsHandler = Some(() => ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))) ) tools <- client.listTools() _ <- Console.printLine(s"server exposes ${tools.tools.size} tools") diff --git a/docs/client/quickstart.md b/docs/client/quickstart.md index fe13cf9..da2cf0e 100644 --- a/docs/client/quickstart.md +++ b/docs/client/quickstart.md @@ -12,28 +12,26 @@ libraryDependencies += "com.softwaremill.chimp" %% "chimp-client" % "0.3.0" Below is a self-contained, [scala-cli](https://scala-cli.virtuslab.org)-runnable example that connects to an MCP server over HTTP and invokes a tool: -```scala -//> using dep com.softwaremill.chimp::chimp-client:0.3.0 -//> using dep com.softwaremill.sttp.client4::core:4.0.23 - +```scala mdoc:compile-only import chimp.client.* -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.* import io.circe.Json import sttp.client4.DefaultSyncBackend import sttp.model.Uri.UriContext import sttp.shared.Identity -@main def mcpClient(): Unit = - val backend = DefaultSyncBackend() - val transport = HttpTransport[Identity](backend, uri"http://localhost:8080/mcp") - val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) +object QuickstartClient: + def main(args: Array[String]): Unit = + val backend = DefaultSyncBackend() + val transport = ClientHttpTransport[Identity](backend, uri"http://localhost:8080/mcp") + val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) - val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - result.content.collect { case ToolContent.Text(_, text) => println(text) } + val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) + result.content.collect { case ToolContent.Text(_, text) => text }.foreach(println) - client.close() - backend.close() + client.close() + backend.close() ``` For streaming transports (e.g. ZIO), also add: diff --git a/docs/client/transport.md b/docs/client/transport.md index 64aac4a..c1f8b0c 100644 --- a/docs/client/transport.md +++ b/docs/client/transport.md @@ -2,38 +2,46 @@ A transport carries JSON-RPC messages between the client and the server. There are two families: -- **Unidirectional** (`Transport[F]`) — the client sends a message and optionally gets a response back. Enough for calling tools, listing resources, etc. -- **Bidirectional** (`BidirectionalTransport[F]`) — additionally lets the server push messages to the client (server-initiated requests and notifications). Required for [client capabilities](capabilities.md). +- **Unidirectional** (`ClientTransport[F]`) — the client sends a message and optionally gets a response back. Enough for calling tools, listing resources, etc. +- **Bidirectional** (`ClientBidirectionalTransport[F]`) — additionally lets the server push messages to the client (server-initiated requests and notifications). Required for [client capabilities](capabilities.md). The streaming transports are abstract; their concrete, effect-specific implementations live in separate modules (e.g. ZIO). ```{mermaid} classDiagram - class Transport~F~ { + class ClientTransport~F~ { <> +send(msg) Option~Message~ +close() } - class BidirectionalTransport~F~ { + class ClientBidirectionalTransport~F~ { <> +onIncoming(handler) } - class HttpTransport~F~ - class StdioTransport - class StreamingHttpTransport~F, S~ { + class ClientHttpTransport~F~ + class ClientStdioTransport + class ClientStreamingHttpTransport~F, S~ { <> } - class StreamingStdioTransport~F~ { + class ClientStreamingStdioTransport~F~ { <> } - Transport <|-- BidirectionalTransport - Transport <|-- HttpTransport - BidirectionalTransport <|-- StdioTransport - BidirectionalTransport <|-- StreamingHttpTransport - BidirectionalTransport <|-- StreamingStdioTransport + ClientTransport <|-- ClientBidirectionalTransport + ClientTransport <|-- ClientHttpTransport + ClientBidirectionalTransport <|-- ClientStdioTransport + ClientBidirectionalTransport <|-- ClientStreamingHttpTransport + ClientBidirectionalTransport <|-- ClientStreamingStdioTransport ``` +## Streaming integrations + +The streaming transports have concrete implementations per effect system, in separate modules: + +| Integration | Streaming HTTP | STDIO | +|---|---|---| +| ZIO | `ZioClientHttpTransport` | `ZioClientStdioTransport` | + ## Backends - **HTTP** transports run on any [sttp](https://sttp.softwaremill.com/en/latest/) backend. The streaming HTTP transports additionally require a backend with streaming capability. diff --git a/docs/conf.py b/docs/conf.py index bffb669..e714400 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -58,9 +58,9 @@ # built documents. # # The short X.Y version. -version = u'0.1' +version = u'0.3' # The full version, including alpha/beta/rc tags. -release = u'0.1' +release = u'0.3.0' # The language for content autogenerated by Sphinx. language = 'en' diff --git a/docs/index.md b/docs/index.md index da56c91..1504059 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,9 +10,12 @@ and [sttp](https://github.com/softwaremill/sttp), supporting the variety of the :caption: Server server/quickstart - server/protocol + server/transport server/tools - server/zio + server/prompts + server/resources + server/capabilities + server/examples .. toctree:: :maxdepth: 2 diff --git a/docs/server/capabilities.md b/docs/server/capabilities.md new file mode 100644 index 0000000..f4f34cf --- /dev/null +++ b/docs/server/capabilities.md @@ -0,0 +1,31 @@ +# Server capabilities + +Most tools just answer a request, so `serverLogic`/`handle` expose no context. A tool that needs to **push to the client while it runs** uses `streamingServerLogic`, which receives a `StreamingServerContext[F]`: + +- `reportProgress` — [progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress) notifications, auto-wired to the request's progress token. +- `log` — [logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) notifications. + +```{note} +Pushing to the client requires an open stream, so a `streamingServerLogic` tool is registered with `addStreamingTool` on a `StreamingMcpServer`, and will not compile on the plain request/response endpoint. +``` + +```scala mdoc:compile-only +import chimp.server.* +import chimp.protocol.LoggingLevel +import io.circe.{Codec, Json} +import sttp.shared.Identity +import sttp.tapir.* + +case class WorkInput(steps: Int) derives Codec, Schema + +val work = tool("work") + .input[WorkInput] + .streamingServerLogic[Identity]: (_, ctx, _) => + ctx.reportProgress(0.5, total = Some(1.0)) + ctx.log(LoggingLevel.Info, Json.fromString("halfway")) + ToolResult.text("done") + +val server = StreamingMcpServer[Identity]().addStreamingTool(work) +``` + +Server-wide capabilities are enabled by registering a handler — only what you wire up is advertised: `.withCompletion`, `.withLoggingLevel`, `.withSubscriptions`. diff --git a/docs/server/examples.md b/docs/server/examples.md new file mode 100644 index 0000000..10c4451 --- /dev/null +++ b/docs/server/examples.md @@ -0,0 +1,91 @@ +# Examples + +Each example builds an `McpServer` (or `StreamingMcpServer`) and serves it over a transport. The sync HTTP example uses `chimp-server`; the ZIO examples additionally use `chimp-server-zio`. + +## HTTP server + +A synchronous server exposed with the Tapir Netty interpreter: + +```scala mdoc:compile-only +import chimp.server.* +import io.circe.Codec +import sttp.tapir.* +import sttp.tapir.server.netty.sync.NettySyncServer + +case class SyncAddInput(a: Int, b: Int) derives Codec, Schema + +object HttpSyncServer: + def main(args: Array[String]): Unit = + val adder = tool("adder").description("Adds two numbers").input[SyncAddInput].handle(in => ToolResult.text(s"The result is ${in.a + in.b}")) + val endpoint = McpServer(tools = List(adder)).endpoint(List("mcp")) + NettySyncServer().port(8080).addEndpoint(endpoint).startAndWait() +``` + +## HTTP server (ZIO) + +The Tapir-ZIO integration requires a `RIO[R, A]` effect (error channel fixed to `Throwable`), so the effect type is stated explicitly: + +```scala mdoc:compile-only +import chimp.server.{McpServer, ToolResult, tool} +import io.circe.Codec +import sttp.tapir.* +import sttp.tapir.server.ziohttp.ZioHttpInterpreter +import zio.{RIO, ZIO, ZIOAppDefault} +import zio.http.Server + +case class ZioAddInput(a: Int, b: Int) derives Codec, Schema + +object HttpZioServer extends ZIOAppDefault: + val adder = tool("adder").description("Adds two numbers").input[ZioAddInput].serverLogic[[X] =>> RIO[Any, X]]: (in, _) => + ZIO.succeed(ToolResult.text(s"The result is ${in.a + in.b}")) + val endpoint = McpServer(tools = List(adder)).endpoint(List("mcp")) + override def run = Server.serve(ZioHttpInterpreter().toHttp(endpoint)).provide(Server.default) +``` + +## Streaming HTTP server (ZIO) + +A streaming tool that pushes progress and log notifications over SSE while it runs, served with `ZioServerHttpTransport`: + +```scala mdoc:compile-only +import chimp.server.{StreamingMcpServer, ToolResult, tool} +import chimp.server.zio.ZioServerHttpTransport +import chimp.protocol.LoggingLevel +import io.circe.{Codec, Json} +import sttp.tapir.* +import sttp.tapir.server.ziohttp.ZioHttpInterpreter +import zio.{Task, ZIO, ZIOAppDefault} +import zio.http.Server + +case class ProgressInput(steps: Int) derives Codec, Schema + +object StreamingZioServer extends ZIOAppDefault: + val work = tool("work").input[ProgressInput].streamingServerLogic[Task]: (_, ctx, _) => + for + _ <- ctx.reportProgress(0.5, total = Some(1.0)) + _ <- ctx.log(LoggingLevel.Info, Json.fromString("halfway")) + yield ToolResult.text("done") + val server = StreamingMcpServer[Task]().withLoggingLevel(_ => ZIO.unit).addStreamingTool(work) + val endpoint = ZioServerHttpTransport(List("mcp")).serve(server) + override def run = Server.serve(ZioHttpInterpreter().toHttp(endpoint)).provide(Server.default) +``` + +## STDIO server (ZIO) + +A server that exchanges line-delimited JSON-RPC over stdin/stdout, served with `ZioServerStdioTransport`: + +```scala mdoc:compile-only +import chimp.server.{StreamingMcpServer, ToolResult, tool} +import chimp.server.zio.ZioServerStdioTransport +import io.circe.Codec +import sttp.tapir.* +import zio.{Task, ZIO, ZIOAppDefault} + +case class EchoInput(message: String) derives Codec, Schema + +object StdioZioServer extends ZIOAppDefault: + val echo = tool("echo").input[EchoInput].serverLogic[Task]((in, _) => ZIO.succeed(ToolResult.text(in.message))) + val server = StreamingMcpServer[Task]().addTool(echo) + override def run = ZioServerStdioTransport().serve(server) +``` + +More runnable examples live in [`examples/`](https://github.com/softwaremill/chimp/tree/master/examples/src/main/scala/examples). diff --git a/docs/server/prompts.md b/docs/server/prompts.md new file mode 100644 index 0000000..34128d3 --- /dev/null +++ b/docs/server/prompts.md @@ -0,0 +1,25 @@ +# Prompts + +- Use `prompt(name)` to start defining a [prompt](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts). +- Add a description and declare the arguments it accepts. +- Provide the logic that turns the supplied argument values into a `GetPromptResult`: + - `handle` — synchronous logic from the argument values to `GetPromptResult`. + - `handleWithHeaders` — synchronous logic that also receives the request headers. + - `serverLogic` — effectful logic, with the request headers. +- Register prompts with `.addPrompt` / `.addPrompts`. + +```scala mdoc:compile-only +import chimp.server.* +import chimp.protocol.{GetPromptResult, PromptMessage, Role, ToolContent} + +val greeting = prompt("greeting") + .description("Greets a person") + .argument("name", required = true) + .handle: args => + val name = args.getOrElse("name", "world") + GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = s"Hello, $name!")))) + +val endpoint = McpServer(prompts = List(greeting)).endpoint(List("mcp")) +``` + +Prompt messages reuse the `ToolContent` content types, so they can embed images (`ToolContent.Image`) and resources (`ToolContent.ResourceContent`) just like tool results. diff --git a/docs/server/protocol.md b/docs/server/protocol.md deleted file mode 100644 index bc9cd7f..0000000 --- a/docs/server/protocol.md +++ /dev/null @@ -1,9 +0,0 @@ -# MCP Protocol - -Chimp implements the HTTP transport of the [MCP protocol](https://modelcontextprotocol.io/specification/2025-03-26) (version **2025-03-26**). Only tools are supported, via the following JSON-RPC commands: - -- Initialization and capabilities negotiation (`initialize`) -- Listing available tools (`tools/list`) -- Invoking a tool (`tools/call`) - -All requests and responses use JSON-RPC 2.0. Tool input schemas are described using JSON Schema, auto-generated from Scala types. diff --git a/docs/server/quickstart.md b/docs/server/quickstart.md index 4fce543..c1532da 100644 --- a/docs/server/quickstart.md +++ b/docs/server/quickstart.md @@ -27,10 +27,10 @@ case class AdderInput(a: Int, b: Int) derives io.circe.Codec, Schema val adderTool = tool("adder").description("Adds two numbers").input[AdderInput] // combine the tool description with the server-side logic - val adderServerTool = adderTool.handle(i => Right(s"The result is ${i.a + i.b}")) + val adderServerTool = adderTool.handle(i => ToolResult.text(s"The result is ${i.a + i.b}")) // create the MCP server endpoint; it will be available at http://localhost:8080/mcp - val mcpServerEndpoint = mcpEndpoint(List(adderServerTool), List("mcp")) + val mcpServerEndpoint = McpServer(tools = List(adderServerTool)).endpoint(List("mcp")) // start the server NettySyncServer().port(8080).addEndpoint(mcpServerEndpoint).startAndWait() diff --git a/docs/server/resources.md b/docs/server/resources.md new file mode 100644 index 0000000..7bbe3e8 --- /dev/null +++ b/docs/server/resources.md @@ -0,0 +1,27 @@ +# Resources + +- Use `resource(uri)` for a fixed [resource](https://modelcontextprotocol.io/specification/2025-11-25/server/resources), or `resourceTemplate(uriTemplate)` for a `{variable}` URI template. +- Add metadata: `name`, `title`, `description`, `mimeType`, `size`. +- Provide the read logic, returning `Either[ResourceError, List[ResourceContents]]`: + - `handle` — synchronous read logic. + - `handleWithHeaders` — synchronous read logic that also receives the request headers. + - `serverLogic` — effectful read logic, with the request headers. +- Register with `.addResource` / `.addResourceTemplate`. Subscriptions are wired with `.withSubscriptions`. + +```scala mdoc:compile-only +import chimp.server.* +import chimp.protocol.ResourceContents + +val readme = resource("file:///readme.txt") + .name("readme") + .mimeType("text/plain") + .handle(() => Right(List(ResourceContents.Text(uri = "file:///readme.txt", text = "Hello!", mimeType = Some("text/plain"))))) + +val item = resourceTemplate("item://{id}") + .name("item") + .handle((vars, uri) => Right(List(ResourceContents.Text(uri = uri, text = s"item ${vars("id")}")))) + +val endpoint = McpServer(resources = List(readme), resourceTemplates = List(item)).endpoint(List("mcp")) +``` + +Returning `Left(ResourceError(...))` (or reading an unknown URI) responds with a JSON-RPC error carrying the offending `uri`. diff --git a/docs/server/tools.md b/docs/server/tools.md index db323ea..40b01f6 100644 --- a/docs/server/tools.md +++ b/docs/server/tools.md @@ -1,10 +1,30 @@ -# Defining tools and server logic +# Tools -- Use `tool(name)` to start defining a tool. +- Use `tool(name)` to start defining a [tool](https://modelcontextprotocol.io/specification/2025-11-25/server/tools). - Add a description and annotations for metadata and hints. -- Specify the input type (must have a Circe `Codec` and Tapir `Schema`). -- Provide the server logic as a function from input to `Either[String, String]` (or a generic effect type). - - Use `handle` to connect the tool definition with the server logic when the use of headers is not required. - - Use `handleWithHeaders` to connect the tool definition with the server logic when headers are required. -- Create a Tapir endpoint by providing your tools to `mcpEndpoint`. -- Start an HTTP server using your preferred Tapir server interpreter. +- Specify the input type (must have a Circe `Codec` and Tapir `Schema`), or use `.inputJson(schema)` for a raw JSON Schema. +- Provide the server logic: + - `handle` — synchronous logic from input to `ToolResult`. + - `handleWithHeaders` — synchronous logic that also receives the request headers. + - `serverLogic` — effectful logic, with the request headers. + + A tool that pushes to the client while running (progress, logging) instead uses `streamingServerLogic` — see [server capabilities](capabilities.md). +- Assemble tools into an `McpServer` and call `.endpoint(path)` to create a Tapir endpoint. + +```scala mdoc:compile-only +import chimp.server.* +import io.circe.Codec +import sttp.tapir.* + +case class AddInput(a: Int, b: Int) derives Codec, Schema + +val adder = tool("adder") + .description("Adds two numbers") + .withAnnotations(ToolAnnotations(idempotentHint = Some(true))) + .input[AddInput] + .handle(in => ToolResult.text(s"The result is ${in.a + in.b}")) + +val endpoint = McpServer(tools = List(adder)).endpoint(List("mcp")) +``` + +A `ToolResult` can carry text, images, audio, embedded resources and structured output — see its constructors (`text`, `image`, `audio`, `embedded`, `structured`). diff --git a/docs/server/transport.md b/docs/server/transport.md new file mode 100644 index 0000000..0931154 --- /dev/null +++ b/docs/server/transport.md @@ -0,0 +1,49 @@ +# Transport + +A transport exposes an `McpServer` over a particular medium. `serve(server)` produces the transport-specific artifact `A` — a Tapir `ServerEndpoint` for HTTP, or a runnable loop for stdio. There are two families: + +- **Unidirectional** (`ServerTransport[F, A]`) — request/response only. Enough for tools, resources, prompts, completion. +- **Bidirectional** (`StreamingServerTransport[F, A]`) — additionally lets the server push messages to the client (progress and logging notifications). Required for [streaming server capabilities](capabilities.md). + +The streaming transports are abstract; their concrete, effect-specific implementations live in separate modules (e.g. ZIO). + +```{mermaid} +classDiagram + class ServerTransport~F, A~ { + <> + +serve(server) A + } + class StreamingServerTransport~F, A~ { + <> + +serve(server) A + } + class ServerHttpTransport~F~ + class ServerStdioTransport + class ServerStreamingHttpTransport~F, S~ { + <> + } + class ServerStreamingStdioTransport~F~ { + <> + } + + ServerTransport <|-- ServerHttpTransport + ServerTransport <|-- ServerStdioTransport + StreamingServerTransport <|-- ServerStdioTransport + StreamingServerTransport <|-- ServerStreamingHttpTransport + StreamingServerTransport <|-- ServerStreamingStdioTransport +``` + +`McpServer(...).endpoint(path)` is a shortcut for `ServerHttpTransport(path).serve(...)`. + +## Streaming integrations + +The streaming transports have concrete implementations per effect system, in separate modules: + +| Integration | Streaming HTTP | STDIO | +|---|---|---| +| ZIO | `ZioServerHttpTransport` | `ZioServerStdioTransport` | + +## Medium + +- **HTTP** transports produce a Tapir `ServerEndpoint` that you run on any Tapir server interpreter. The streaming HTTP transport additionally requires an interpreter with streaming capability. +- **STDIO** transports run the read/dispatch/write loop using plain JDK components (synchronous), or an effect's own semantics. diff --git a/docs/server/zio.md b/docs/server/zio.md deleted file mode 100644 index ccd5d9b..0000000 --- a/docs/server/zio.md +++ /dev/null @@ -1,8 +0,0 @@ -# Using with ZIO - -When using ZIO, you might have to explicitly state the effect type that you are using, as the Tapir-ZIO integration requires a `RIO[R, A]` effect (which is an alias for `ZIO[R, Throwable, A]`), for example: - -```scala -val myServerTool = myTool.serverLogic[[X] =>> RIO[Any, X]]: (input, headers) => - ZIO.succeed(???) -``` diff --git a/examples/src/main/scala/examples/client/everythingClient.scala b/examples/src/main/scala/examples/client/everythingClient.scala index 424b6da..1e505d0 100644 --- a/examples/src/main/scala/examples/client/everythingClient.scala +++ b/examples/src/main/scala/examples/client/everythingClient.scala @@ -8,7 +8,7 @@ package examples.client import chimp.client.* -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.* import io.circe.Json import sttp.client4.DefaultSyncBackend @@ -17,7 +17,7 @@ import sttp.shared.Identity @main def everythingClient(): Unit = val backend = DefaultSyncBackend() - val transport = HttpTransport[Identity](backend, uri"http://localhost:3001/mcp") + val transport = ClientHttpTransport[Identity](backend, uri"http://localhost:3001/mcp") val client = McpClient[Identity]( transport, clientInfo = Implementation(name = "chimp-everything-client", version = "0.1.0"), diff --git a/examples/src/main/scala/examples/server/AdderMcpZio.scala b/examples/src/main/scala/examples/server/AdderMcpZio.scala index 82acf73..01a5caf 100644 --- a/examples/src/main/scala/examples/server/AdderMcpZio.scala +++ b/examples/src/main/scala/examples/server/AdderMcpZio.scala @@ -24,9 +24,9 @@ object Main extends ZIOAppDefault: // note that here we need to explicitly state the effect type, as the Tapir-ZIO integration requires a `RIO[R, A]` // effect (with the error channel fixed to `Throwable`) val adderServerTool = adderTool.serverLogic[[X] =>> RIO[Any, X]]: (input, _) => - ZIO.succeed(Right(s"The result is ${input.a + input.b}")) + ZIO.succeed(ToolResult.text(s"The result is ${input.a + input.b}")) - val mcpServerEndpoint = mcpEndpoint(List(adderServerTool), List("mcp")) + val mcpServerEndpoint = McpServer(tools = List(adderServerTool)).endpoint(List("mcp")) val routes = ZioHttpInterpreter().toHttp(mcpServerEndpoint) diff --git a/examples/src/main/scala/examples/server/adderMcp.scala b/examples/src/main/scala/examples/server/adderMcp.scala index bb20ab0..ed239ee 100644 --- a/examples/src/main/scala/examples/server/adderMcp.scala +++ b/examples/src/main/scala/examples/server/adderMcp.scala @@ -19,9 +19,9 @@ case class Input(a: Int, b: Int) derives Codec, Schema def logic(i: Input): Either[String, String] = Right(s"The result is ${i.a + i.b}") - val adderServerTool = adderTool.handle(logic) + val adderServerTool = adderTool.handle(i => ToolResult.fromEither(logic(i))) - val mcpServerEndpoint = mcpEndpoint(List(adderServerTool), List("mcp")) + val mcpServerEndpoint = McpServer(tools = List(adderServerTool)).endpoint(List("mcp")) NettySyncServer() .port(8080) diff --git a/examples/src/main/scala/examples/server/adderWithAuthMcp.scala b/examples/src/main/scala/examples/server/adderWithAuthMcp.scala index 1a30311..6042c34 100644 --- a/examples/src/main/scala/examples/server/adderWithAuthMcp.scala +++ b/examples/src/main/scala/examples/server/adderWithAuthMcp.scala @@ -23,9 +23,9 @@ import sttp.tapir.server.netty.sync.NettySyncServer headers.find(_.name == "test_header").map(t => s"token: ${t.value} (header name used: ${t.name})").getOrElse("no token provided") Right(s"The result is ${i.a + i.b} ($tokenMsg)") - val adderServerTool = adderTool.handleWithHeaders(logic) + val adderServerTool = adderTool.handleWithHeaders((i, headers) => ToolResult.fromEither(logic(i, headers))) - val mcpServerEndpoint = mcpEndpoint(List(adderServerTool), List("mcp")) + val mcpServerEndpoint = McpServer(tools = List(adderServerTool)).endpoint(List("mcp")) NettySyncServer() .port(8080) diff --git a/examples/src/main/scala/examples/server/twoToolsMcp.scala b/examples/src/main/scala/examples/server/twoToolsMcp.scala index 4a717e2..49e1411 100644 --- a/examples/src/main/scala/examples/server/twoToolsMcp.scala +++ b/examples/src/main/scala/examples/server/twoToolsMcp.scala @@ -18,16 +18,16 @@ case class IsFibonacciInput(n: Int) derives Codec, Schema .input[IsPrimeInput] .handle(i => if i.n <= 0 - then Left("Only positive numbers can be prime-checked") - else Right(isPrimeWithDescription(i.n)) + then ToolResult.error("Only positive numbers can be prime-checked") + else ToolResult.text(isPrimeWithDescription(i.n)) ) val isFibonacci = tool("isFibonacci") .description("Checks if a number is a Fibonacci number") .input[IsFibonacciInput] - .handle(i => Right(isFibonacciWithDescription(i.n))) + .handle(i => ToolResult.text(isFibonacciWithDescription(i.n))) - val mcpServerEndpoint = mcpEndpoint(List(isPrimeTool, isFibonacci), List("mcp")) + val mcpServerEndpoint = McpServer(tools = List(isPrimeTool, isFibonacci)).endpoint(List("mcp")) NettySyncServer().port(8080).addEndpoint(mcpServerEndpoint).startAndWait() private def smallestDivisor(n: Int): Int = diff --git a/examples/src/main/scala/examples/server/weatherMcp.scala b/examples/src/main/scala/examples/server/weatherMcp.scala index 2ff7008..dbfab4f 100644 --- a/examples/src/main/scala/examples/server/weatherMcp.scala +++ b/examples/src/main/scala/examples/server/weatherMcp.scala @@ -30,12 +30,13 @@ case class OpenMeteoResponse(current_weather: OpenMeteoCurrentWeather) derives C .description("Checks the weather in the given city") .input[WeatherInput] .handle: input => - either: - val geocodeResult = geocodeCity(input.city, sttpBackend).ok() - val weatherResult = fetchWeather(geocodeResult.lat, geocodeResult.lon, sttpBackend).ok() - weatherDescription(geocodeResult.display_name, weatherResult.temperature, weatherResult.weathercode) + ToolResult.fromEither: + either: + val geocodeResult = geocodeCity(input.city, sttpBackend).ok() + val weatherResult = fetchWeather(geocodeResult.lat, geocodeResult.lon, sttpBackend).ok() + weatherDescription(geocodeResult.display_name, weatherResult.temperature, weatherResult.weathercode) - val mcpServerEndpoint = mcpEndpoint(List(weatherTool), List("mcp")) + val mcpServerEndpoint = McpServer(tools = List(weatherTool)).endpoint(List("mcp")) NettySyncServer().port(8080).addEndpoint(mcpServerEndpoint).startAndWait() /** Maps Open-Meteo weather codes to human-readable descriptions. */ diff --git a/generated-docs/out/README.md b/generated-docs/out/README.md index 8bdae51..c5d6c44 100644 --- a/generated-docs/out/README.md +++ b/generated-docs/out/README.md @@ -34,5 +34,5 @@ Commit both `docs/` (the source) and `generated-docs/` (the mdoc output) — if ## Notes -- `0.1.8+15-aee4bbdd+20260531-1302-SNAPSHOT` and other mdoc variables are **not** substituted in the local watch mode. For a fully-rendered preview, run `sbt docs/mdoc` from the repo root and serve `generated-docs/out/` instead. +- `0.3.0+22-cb3e0e1c+20260625-0956-SNAPSHOT` and other mdoc variables are **not** substituted in the local watch mode. For a fully-rendered preview, run `sbt docs/mdoc` from the repo root and serve `generated-docs/out/` instead. - Scala code snippets are verified by `sbt compileDocs` (also runs in CI). diff --git a/generated-docs/out/client/capabilities.md b/generated-docs/out/client/capabilities.md index 27464bd..2966673 100644 --- a/generated-docs/out/client/capabilities.md +++ b/generated-docs/out/client/capabilities.md @@ -9,26 +9,37 @@ Beyond calling tools, an MCP client can advertise capabilities that let the serv - [Notifications](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#notifications) — receiving server-pushed events such as resource updates and list changes. ```{note} -All of these require the server to push messages to the client, so they only work over a **bidirectional, streaming transport** (e.g. `ZioStreamingHttpTransport`). They are unavailable on the plain `HttpTransport`. +All of these require the server to push messages to the client, so they only work over a **bidirectional, streaming transport** (e.g. `ZioClientHttpTransport`). They are unavailable on the plain `ClientHttpTransport`. ``` Create the client with `McpClient.bidirectional`, providing a handler for each capability you want to enable — only capabilities backed by a handler are advertised to the server: ```scala -val client = McpClient.bidirectional[Task]( - transport, - clientInfo = Implementation("my-client", "0.1.0"), - rootsHandler = Some(() => ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))), - // samplingHandler = Some(...), - // elicitationHandler = Some(...), -) +import chimp.client.* +import chimp.client.transport.ClientBidirectionalTransport +import chimp.protocol.* +import zio.* + +def connect(transport: ClientBidirectionalTransport[Task]): Task[BidirectionalMcpClient[Task]] = + McpClient.bidirectional[Task]( + transport, + clientInfo = Implementation("my-client", "0.1.0"), + rootsHandler = Some(() => ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))) + // samplingHandler = Some(...), + // elicitationHandler = Some(...), + ) ``` Register a listener for server notifications with `onServerNotification`: ```scala -client.onServerNotification { - case ServerNotification.ResourceUpdated(uri) => ZIO.logInfo(s"resource changed: $uri") - case _ => ZIO.unit -} +import chimp.client.* +import chimp.client.notifications.ServerNotification +import zio.* + +def listen(client: BidirectionalMcpClient[Task]): Task[Unit] = + client.onServerNotification { + case ServerNotification.ResourceUpdated(params) => ZIO.logInfo(s"resource changed: ${params.uri}") + case _ => ZIO.unit + } ``` diff --git a/generated-docs/out/client/examples.md b/generated-docs/out/client/examples.md index 7a70b9f..b44541b 100644 --- a/generated-docs/out/client/examples.md +++ b/generated-docs/out/client/examples.md @@ -2,65 +2,59 @@ ## HTTP client -A synchronous client over `HttpTransport`, calling a tool: +A synchronous client over `ClientHttpTransport`, calling a tool: ```scala -//> using dep com.softwaremill.chimp::chimp-client:0.2.0 -//> using dep com.softwaremill.sttp.client4::core:4.0.23 - import chimp.client.* -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.* import io.circe.Json import sttp.client4.DefaultSyncBackend import sttp.model.Uri.UriContext import sttp.shared.Identity -@main def httpClient(): Unit = - val backend = DefaultSyncBackend() - val transport = HttpTransport[Identity](backend, uri"http://localhost:8080/mcp") - val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) +object HttpClient: + def main(args: Array[String]): Unit = + val backend = DefaultSyncBackend() + val transport = ClientHttpTransport[Identity](backend, uri"http://localhost:8080/mcp") + val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) - val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - result.content.collect { case ToolContent.Text(_, text) => println(text) } + val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) + result.content.collect { case ToolContent.Text(_, text) => text }.foreach(println) - client.close() - backend.close() + client.close() + backend.close() ``` ## STDIO client -A synchronous client that launches a local MCP server as a subprocess over `StdioTransport`: +A synchronous client that launches a local MCP server as a subprocess over `ClientStdioTransport`: ```scala -//> using dep com.softwaremill.chimp::chimp-client:0.2.0 - import chimp.client.* -import chimp.client.transport.StdioTransport +import chimp.client.transport.ClientStdioTransport import chimp.protocol.* import io.circe.Json import sttp.shared.Identity -@main def stdioClient(): Unit = - val transport = StdioTransport(command = List("my-mcp-server")) - val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) +object StdioClient: + def main(args: Array[String]): Unit = + val transport = ClientStdioTransport(command = List("my-mcp-server")) + val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) - val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - result.content.collect { case ToolContent.Text(_, text) => println(text) } + val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) + result.content.collect { case ToolContent.Text(_, text) => text }.foreach(println) - client.close() + client.close() ``` ## Roots over a ZIO streaming transport -[Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) require a bidirectional, streaming transport — here `ZioStreamingHttpTransport`: +[Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) require a bidirectional, streaming transport — here `ZioClientHttpTransport`: ```scala -//> using dep com.softwaremill.chimp::chimp-client-zio:0.2.0 -//> using dep com.softwaremill.sttp.client4::zio:4.0.23 - import chimp.client.* -import chimp.client.transport.zio.ZioStreamingHttpTransport +import chimp.client.transport.zio.ZioClientHttpTransport import chimp.protocol.* import sttp.client4.httpclient.zio.HttpClientZioBackend import sttp.model.Uri.UriContext @@ -71,12 +65,11 @@ object RootsClient extends ZIOAppDefault: HttpClientZioBackend.scoped().flatMap { backend => ZIO.scoped { for - transport <- ZioStreamingHttpTransport.scoped(backend, uri"http://localhost:8080/mcp") + transport <- ZioClientHttpTransport.scoped(backend, uri"http://localhost:8080/mcp") client <- McpClient.bidirectional[Task]( transport, clientInfo = Implementation("my-client", "0.1.0"), - rootsHandler = Some(() => - ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))) + rootsHandler = Some(() => ZIO.succeed(ListRootsResult(roots = List(Root("file:///workspace", Some("workspace")))))) ) tools <- client.listTools() _ <- Console.printLine(s"server exposes ${tools.tools.size} tools") diff --git a/generated-docs/out/client/quickstart.md b/generated-docs/out/client/quickstart.md index 538f576..0a82d8a 100644 --- a/generated-docs/out/client/quickstart.md +++ b/generated-docs/out/client/quickstart.md @@ -5,7 +5,7 @@ Chimp ships an MCP client that connects to any MCP-compliant server. The client Add the dependency to your `build.sbt`: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-client" % "0.2.0" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-client" % "0.3.0" ``` ## Example: the simplest MCP client @@ -13,31 +13,29 @@ libraryDependencies += "com.softwaremill.chimp" %% "chimp-client" % "0.2.0" Below is a self-contained, [scala-cli](https://scala-cli.virtuslab.org)-runnable example that connects to an MCP server over HTTP and invokes a tool: ```scala -//> using dep com.softwaremill.chimp::chimp-client:0.2.0 -//> using dep com.softwaremill.sttp.client4::core:4.0.23 - import chimp.client.* -import chimp.client.transport.HttpTransport +import chimp.client.transport.ClientHttpTransport import chimp.protocol.* import io.circe.Json import sttp.client4.DefaultSyncBackend import sttp.model.Uri.UriContext import sttp.shared.Identity -@main def mcpClient(): Unit = - val backend = DefaultSyncBackend() - val transport = HttpTransport[Identity](backend, uri"http://localhost:8080/mcp") - val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) +object QuickstartClient: + def main(args: Array[String]): Unit = + val backend = DefaultSyncBackend() + val transport = ClientHttpTransport[Identity](backend, uri"http://localhost:8080/mcp") + val client = McpClient[Identity](transport, Implementation("my-client", "0.1.0")) - val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) - result.content.collect { case ToolContent.Text(_, text) => println(text) } + val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3))) + result.content.collect { case ToolContent.Text(_, text) => text }.foreach(println) - client.close() - backend.close() + client.close() + backend.close() ``` For streaming transports (e.g. ZIO), also add: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-client-zio" % "0.2.0" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-client-zio" % "0.3.0" ``` diff --git a/generated-docs/out/client/transport.md b/generated-docs/out/client/transport.md index 64aac4a..c1f8b0c 100644 --- a/generated-docs/out/client/transport.md +++ b/generated-docs/out/client/transport.md @@ -2,38 +2,46 @@ A transport carries JSON-RPC messages between the client and the server. There are two families: -- **Unidirectional** (`Transport[F]`) — the client sends a message and optionally gets a response back. Enough for calling tools, listing resources, etc. -- **Bidirectional** (`BidirectionalTransport[F]`) — additionally lets the server push messages to the client (server-initiated requests and notifications). Required for [client capabilities](capabilities.md). +- **Unidirectional** (`ClientTransport[F]`) — the client sends a message and optionally gets a response back. Enough for calling tools, listing resources, etc. +- **Bidirectional** (`ClientBidirectionalTransport[F]`) — additionally lets the server push messages to the client (server-initiated requests and notifications). Required for [client capabilities](capabilities.md). The streaming transports are abstract; their concrete, effect-specific implementations live in separate modules (e.g. ZIO). ```{mermaid} classDiagram - class Transport~F~ { + class ClientTransport~F~ { <> +send(msg) Option~Message~ +close() } - class BidirectionalTransport~F~ { + class ClientBidirectionalTransport~F~ { <> +onIncoming(handler) } - class HttpTransport~F~ - class StdioTransport - class StreamingHttpTransport~F, S~ { + class ClientHttpTransport~F~ + class ClientStdioTransport + class ClientStreamingHttpTransport~F, S~ { <> } - class StreamingStdioTransport~F~ { + class ClientStreamingStdioTransport~F~ { <> } - Transport <|-- BidirectionalTransport - Transport <|-- HttpTransport - BidirectionalTransport <|-- StdioTransport - BidirectionalTransport <|-- StreamingHttpTransport - BidirectionalTransport <|-- StreamingStdioTransport + ClientTransport <|-- ClientBidirectionalTransport + ClientTransport <|-- ClientHttpTransport + ClientBidirectionalTransport <|-- ClientStdioTransport + ClientBidirectionalTransport <|-- ClientStreamingHttpTransport + ClientBidirectionalTransport <|-- ClientStreamingStdioTransport ``` +## Streaming integrations + +The streaming transports have concrete implementations per effect system, in separate modules: + +| Integration | Streaming HTTP | STDIO | +|---|---|---| +| ZIO | `ZioClientHttpTransport` | `ZioClientStdioTransport` | + ## Backends - **HTTP** transports run on any [sttp](https://sttp.softwaremill.com/en/latest/) backend. The streaming HTTP transports additionally require a backend with streaming capability. diff --git a/generated-docs/out/index.md b/generated-docs/out/index.md index da56c91..1504059 100644 --- a/generated-docs/out/index.md +++ b/generated-docs/out/index.md @@ -10,9 +10,12 @@ and [sttp](https://github.com/softwaremill/sttp), supporting the variety of the :caption: Server server/quickstart - server/protocol + server/transport server/tools - server/zio + server/prompts + server/resources + server/capabilities + server/examples .. toctree:: :maxdepth: 2 diff --git a/generated-docs/out/server/capabilities.md b/generated-docs/out/server/capabilities.md new file mode 100644 index 0000000..ebe60eb --- /dev/null +++ b/generated-docs/out/server/capabilities.md @@ -0,0 +1,31 @@ +# Server capabilities + +Most tools just answer a request, so `serverLogic`/`handle` expose no context. A tool that needs to **push to the client while it runs** uses `streamingServerLogic`, which receives a `StreamingServerContext[F]`: + +- `reportProgress` — [progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress) notifications, auto-wired to the request's progress token. +- `log` — [logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) notifications. + +```{note} +Pushing to the client requires an open stream, so a `streamingServerLogic` tool is registered with `addStreamingTool` on a `StreamingMcpServer`, and will not compile on the plain request/response endpoint. +``` + +```scala +import chimp.server.* +import chimp.protocol.LoggingLevel +import io.circe.{Codec, Json} +import sttp.shared.Identity +import sttp.tapir.* + +case class WorkInput(steps: Int) derives Codec, Schema + +val work = tool("work") + .input[WorkInput] + .streamingServerLogic[Identity]: (_, ctx, _) => + ctx.reportProgress(0.5, total = Some(1.0)) + ctx.log(LoggingLevel.Info, Json.fromString("halfway")) + ToolResult.text("done") + +val server = StreamingMcpServer[Identity]().addStreamingTool(work) +``` + +Server-wide capabilities are enabled by registering a handler — only what you wire up is advertised: `.withCompletion`, `.withLoggingLevel`, `.withSubscriptions`. diff --git a/generated-docs/out/server/examples.md b/generated-docs/out/server/examples.md new file mode 100644 index 0000000..d662bef --- /dev/null +++ b/generated-docs/out/server/examples.md @@ -0,0 +1,91 @@ +# Examples + +Each example builds an `McpServer` (or `StreamingMcpServer`) and serves it over a transport. The sync HTTP example uses `chimp-server`; the ZIO examples additionally use `chimp-server-zio`. + +## HTTP server + +A synchronous server exposed with the Tapir Netty interpreter: + +```scala +import chimp.server.* +import io.circe.Codec +import sttp.tapir.* +import sttp.tapir.server.netty.sync.NettySyncServer + +case class SyncAddInput(a: Int, b: Int) derives Codec, Schema + +object HttpSyncServer: + def main(args: Array[String]): Unit = + val adder = tool("adder").description("Adds two numbers").input[SyncAddInput].handle(in => ToolResult.text(s"The result is ${in.a + in.b}")) + val endpoint = McpServer(tools = List(adder)).endpoint(List("mcp")) + NettySyncServer().port(8080).addEndpoint(endpoint).startAndWait() +``` + +## HTTP server (ZIO) + +The Tapir-ZIO integration requires a `RIO[R, A]` effect (error channel fixed to `Throwable`), so the effect type is stated explicitly: + +```scala +import chimp.server.{McpServer, ToolResult, tool} +import io.circe.Codec +import sttp.tapir.* +import sttp.tapir.server.ziohttp.ZioHttpInterpreter +import zio.{RIO, ZIO, ZIOAppDefault} +import zio.http.Server + +case class ZioAddInput(a: Int, b: Int) derives Codec, Schema + +object HttpZioServer extends ZIOAppDefault: + val adder = tool("adder").description("Adds two numbers").input[ZioAddInput].serverLogic[[X] =>> RIO[Any, X]]: (in, _) => + ZIO.succeed(ToolResult.text(s"The result is ${in.a + in.b}")) + val endpoint = McpServer(tools = List(adder)).endpoint(List("mcp")) + override def run = Server.serve(ZioHttpInterpreter().toHttp(endpoint)).provide(Server.default) +``` + +## Streaming HTTP server (ZIO) + +A streaming tool that pushes progress and log notifications over SSE while it runs, served with `ZioServerHttpTransport`: + +```scala +import chimp.server.{StreamingMcpServer, ToolResult, tool} +import chimp.server.zio.ZioServerHttpTransport +import chimp.protocol.LoggingLevel +import io.circe.{Codec, Json} +import sttp.tapir.* +import sttp.tapir.server.ziohttp.ZioHttpInterpreter +import zio.{Task, ZIO, ZIOAppDefault} +import zio.http.Server + +case class ProgressInput(steps: Int) derives Codec, Schema + +object StreamingZioServer extends ZIOAppDefault: + val work = tool("work").input[ProgressInput].streamingServerLogic[Task]: (_, ctx, _) => + for + _ <- ctx.reportProgress(0.5, total = Some(1.0)) + _ <- ctx.log(LoggingLevel.Info, Json.fromString("halfway")) + yield ToolResult.text("done") + val server = StreamingMcpServer[Task]().withLoggingLevel(_ => ZIO.unit).addStreamingTool(work) + val endpoint = ZioServerHttpTransport(List("mcp")).serve(server) + override def run = Server.serve(ZioHttpInterpreter().toHttp(endpoint)).provide(Server.default) +``` + +## STDIO server (ZIO) + +A server that exchanges line-delimited JSON-RPC over stdin/stdout, served with `ZioServerStdioTransport`: + +```scala +import chimp.server.{StreamingMcpServer, ToolResult, tool} +import chimp.server.zio.ZioServerStdioTransport +import io.circe.Codec +import sttp.tapir.* +import zio.{Task, ZIO, ZIOAppDefault} + +case class EchoInput(message: String) derives Codec, Schema + +object StdioZioServer extends ZIOAppDefault: + val echo = tool("echo").input[EchoInput].serverLogic[Task]((in, _) => ZIO.succeed(ToolResult.text(in.message))) + val server = StreamingMcpServer[Task]().addTool(echo) + override def run = ZioServerStdioTransport().serve(server) +``` + +More runnable examples live in [`examples/`](https://github.com/softwaremill/chimp/tree/master/examples/src/main/scala/examples). diff --git a/generated-docs/out/server/prompts.md b/generated-docs/out/server/prompts.md new file mode 100644 index 0000000..edf9c27 --- /dev/null +++ b/generated-docs/out/server/prompts.md @@ -0,0 +1,25 @@ +# Prompts + +- Use `prompt(name)` to start defining a [prompt](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts). +- Add a description and declare the arguments it accepts. +- Provide the logic that turns the supplied argument values into a `GetPromptResult`: + - `handle` — synchronous logic from the argument values to `GetPromptResult`. + - `handleWithHeaders` — synchronous logic that also receives the request headers. + - `serverLogic` — effectful logic, with the request headers. +- Register prompts with `.addPrompt` / `.addPrompts`. + +```scala +import chimp.server.* +import chimp.protocol.{GetPromptResult, PromptMessage, Role, ToolContent} + +val greeting = prompt("greeting") + .description("Greets a person") + .argument("name", required = true) + .handle: args => + val name = args.getOrElse("name", "world") + GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = s"Hello, $name!")))) + +val endpoint = McpServer(prompts = List(greeting)).endpoint(List("mcp")) +``` + +Prompt messages reuse the `ToolContent` content types, so they can embed images (`ToolContent.Image`) and resources (`ToolContent.ResourceContent`) just like tool results. diff --git a/generated-docs/out/server/protocol.md b/generated-docs/out/server/protocol.md deleted file mode 100644 index bc9cd7f..0000000 --- a/generated-docs/out/server/protocol.md +++ /dev/null @@ -1,9 +0,0 @@ -# MCP Protocol - -Chimp implements the HTTP transport of the [MCP protocol](https://modelcontextprotocol.io/specification/2025-03-26) (version **2025-03-26**). Only tools are supported, via the following JSON-RPC commands: - -- Initialization and capabilities negotiation (`initialize`) -- Listing available tools (`tools/list`) -- Invoking a tool (`tools/call`) - -All requests and responses use JSON-RPC 2.0. Tool input schemas are described using JSON Schema, auto-generated from Scala types. diff --git a/generated-docs/out/server/quickstart.md b/generated-docs/out/server/quickstart.md index 2356494..c1532da 100644 --- a/generated-docs/out/server/quickstart.md +++ b/generated-docs/out/server/quickstart.md @@ -5,7 +5,7 @@ Chimp lets you expose MCP tools over a JSON-RPC HTTP API. Tool inputs are descri Add the dependency to your `build.sbt`: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-server" % "0.2.0" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-server" % "0.3.0" ``` ## Example: the simplest MCP server @@ -13,7 +13,7 @@ libraryDependencies += "com.softwaremill.chimp" %% "chimp-server" % "0.2.0" Below is a self-contained, [scala-cli](https://scala-cli.virtuslab.org)-runnable example: ```scala -//> using dep com.softwaremill.chimp::chimp-server:0.2.0 +//> using dep com.softwaremill.chimp::chimp-server:0.3.0 import chimp.* import sttp.tapir.* @@ -27,10 +27,10 @@ case class AdderInput(a: Int, b: Int) derives io.circe.Codec, Schema val adderTool = tool("adder").description("Adds two numbers").input[AdderInput] // combine the tool description with the server-side logic - val adderServerTool = adderTool.handle(i => Right(s"The result is ${i.a + i.b}")) + val adderServerTool = adderTool.handle(i => ToolResult.text(s"The result is ${i.a + i.b}")) // create the MCP server endpoint; it will be available at http://localhost:8080/mcp - val mcpServerEndpoint = mcpEndpoint(List(adderServerTool), List("mcp")) + val mcpServerEndpoint = McpServer(tools = List(adderServerTool)).endpoint(List("mcp")) // start the server NettySyncServer().port(8080).addEndpoint(mcpServerEndpoint).startAndWait() diff --git a/generated-docs/out/server/resources.md b/generated-docs/out/server/resources.md new file mode 100644 index 0000000..bb95bb3 --- /dev/null +++ b/generated-docs/out/server/resources.md @@ -0,0 +1,27 @@ +# Resources + +- Use `resource(uri)` for a fixed [resource](https://modelcontextprotocol.io/specification/2025-11-25/server/resources), or `resourceTemplate(uriTemplate)` for a `{variable}` URI template. +- Add metadata: `name`, `title`, `description`, `mimeType`, `size`. +- Provide the read logic, returning `Either[ResourceError, List[ResourceContents]]`: + - `handle` — synchronous read logic. + - `handleWithHeaders` — synchronous read logic that also receives the request headers. + - `serverLogic` — effectful read logic, with the request headers. +- Register with `.addResource` / `.addResourceTemplate`. Subscriptions are wired with `.withSubscriptions`. + +```scala +import chimp.server.* +import chimp.protocol.ResourceContents + +val readme = resource("file:///readme.txt") + .name("readme") + .mimeType("text/plain") + .handle(() => Right(List(ResourceContents.Text(uri = "file:///readme.txt", text = "Hello!", mimeType = Some("text/plain"))))) + +val item = resourceTemplate("item://{id}") + .name("item") + .handle((vars, uri) => Right(List(ResourceContents.Text(uri = uri, text = s"item ${vars("id")}")))) + +val endpoint = McpServer(resources = List(readme), resourceTemplates = List(item)).endpoint(List("mcp")) +``` + +Returning `Left(ResourceError(...))` (or reading an unknown URI) responds with a JSON-RPC error carrying the offending `uri`. diff --git a/generated-docs/out/server/tools.md b/generated-docs/out/server/tools.md index db323ea..83b774f 100644 --- a/generated-docs/out/server/tools.md +++ b/generated-docs/out/server/tools.md @@ -1,10 +1,30 @@ -# Defining tools and server logic +# Tools -- Use `tool(name)` to start defining a tool. +- Use `tool(name)` to start defining a [tool](https://modelcontextprotocol.io/specification/2025-11-25/server/tools). - Add a description and annotations for metadata and hints. -- Specify the input type (must have a Circe `Codec` and Tapir `Schema`). -- Provide the server logic as a function from input to `Either[String, String]` (or a generic effect type). - - Use `handle` to connect the tool definition with the server logic when the use of headers is not required. - - Use `handleWithHeaders` to connect the tool definition with the server logic when headers are required. -- Create a Tapir endpoint by providing your tools to `mcpEndpoint`. -- Start an HTTP server using your preferred Tapir server interpreter. +- Specify the input type (must have a Circe `Codec` and Tapir `Schema`), or use `.inputJson(schema)` for a raw JSON Schema. +- Provide the server logic: + - `handle` — synchronous logic from input to `ToolResult`. + - `handleWithHeaders` — synchronous logic that also receives the request headers. + - `serverLogic` — effectful logic, with the request headers. + + A tool that pushes to the client while running (progress, logging) instead uses `streamingServerLogic` — see [server capabilities](capabilities.md). +- Assemble tools into an `McpServer` and call `.endpoint(path)` to create a Tapir endpoint. + +```scala +import chimp.server.* +import io.circe.Codec +import sttp.tapir.* + +case class AddInput(a: Int, b: Int) derives Codec, Schema + +val adder = tool("adder") + .description("Adds two numbers") + .withAnnotations(ToolAnnotations(idempotentHint = Some(true))) + .input[AddInput] + .handle(in => ToolResult.text(s"The result is ${in.a + in.b}")) + +val endpoint = McpServer(tools = List(adder)).endpoint(List("mcp")) +``` + +A `ToolResult` can carry text, images, audio, embedded resources and structured output — see its constructors (`text`, `image`, `audio`, `embedded`, `structured`). diff --git a/generated-docs/out/server/transport.md b/generated-docs/out/server/transport.md new file mode 100644 index 0000000..0931154 --- /dev/null +++ b/generated-docs/out/server/transport.md @@ -0,0 +1,49 @@ +# Transport + +A transport exposes an `McpServer` over a particular medium. `serve(server)` produces the transport-specific artifact `A` — a Tapir `ServerEndpoint` for HTTP, or a runnable loop for stdio. There are two families: + +- **Unidirectional** (`ServerTransport[F, A]`) — request/response only. Enough for tools, resources, prompts, completion. +- **Bidirectional** (`StreamingServerTransport[F, A]`) — additionally lets the server push messages to the client (progress and logging notifications). Required for [streaming server capabilities](capabilities.md). + +The streaming transports are abstract; their concrete, effect-specific implementations live in separate modules (e.g. ZIO). + +```{mermaid} +classDiagram + class ServerTransport~F, A~ { + <> + +serve(server) A + } + class StreamingServerTransport~F, A~ { + <> + +serve(server) A + } + class ServerHttpTransport~F~ + class ServerStdioTransport + class ServerStreamingHttpTransport~F, S~ { + <> + } + class ServerStreamingStdioTransport~F~ { + <> + } + + ServerTransport <|-- ServerHttpTransport + ServerTransport <|-- ServerStdioTransport + StreamingServerTransport <|-- ServerStdioTransport + StreamingServerTransport <|-- ServerStreamingHttpTransport + StreamingServerTransport <|-- ServerStreamingStdioTransport +``` + +`McpServer(...).endpoint(path)` is a shortcut for `ServerHttpTransport(path).serve(...)`. + +## Streaming integrations + +The streaming transports have concrete implementations per effect system, in separate modules: + +| Integration | Streaming HTTP | STDIO | +|---|---|---| +| ZIO | `ZioServerHttpTransport` | `ZioServerStdioTransport` | + +## Medium + +- **HTTP** transports produce a Tapir `ServerEndpoint` that you run on any Tapir server interpreter. The streaming HTTP transport additionally requires an interpreter with streaming capability. +- **STDIO** transports run the read/dispatch/write loop using plain JDK components (synchronous), or an effect's own semantics. diff --git a/generated-docs/out/server/zio.md b/generated-docs/out/server/zio.md deleted file mode 100644 index ccd5d9b..0000000 --- a/generated-docs/out/server/zio.md +++ /dev/null @@ -1,8 +0,0 @@ -# Using with ZIO - -When using ZIO, you might have to explicitly state the effect type that you are using, as the Tapir-ZIO integration requires a `RIO[R, A]` effect (which is an alias for `ZIO[R, Throwable, A]`), for example: - -```scala -val myServerTool = myTool.serverLogic[[X] =>> RIO[Any, X]]: (input, headers) => - ZIO.succeed(???) -``` diff --git a/server-conformance/src/main/resources/sample.png b/server-conformance/src/main/resources/sample.png new file mode 100644 index 0000000..07801cf Binary files /dev/null and b/server-conformance/src/main/resources/sample.png differ diff --git a/server-conformance/src/main/resources/sample.wav b/server-conformance/src/main/resources/sample.wav new file mode 100644 index 0000000..fac21de Binary files /dev/null and b/server-conformance/src/main/resources/sample.wav differ diff --git a/server-conformance/src/main/scala/chimp/conformance/server/Main.scala b/server-conformance/src/main/scala/chimp/conformance/server/Main.scala index 64255f5..eaa3c9c 100644 --- a/server-conformance/src/main/scala/chimp/conformance/server/Main.scala +++ b/server-conformance/src/main/scala/chimp/conformance/server/Main.scala @@ -1,32 +1,172 @@ package chimp.conformance.server +import chimp.protocol.* import chimp.server.* -import io.circe.Codec +import io.circe.parser.parse +import io.circe.{Codec, Json} import ox.supervised +import sttp.shared.Identity import sttp.tapir.Schema import sttp.tapir.server.netty.sync.NettySyncServer +import java.util.Base64 + object Main: private case class AddNumbersInput(a: Double, b: Double) derives Codec, Schema private case class NoInput() derives Codec, Schema + private def base64Resource(path: String): String = + val stream = getClass.getResourceAsStream(path) + require(stream != null, s"conformance fixture resource not found on the classpath: $path") + try Base64.getEncoder.encodeToString(stream.readAllBytes()) + finally stream.close() + + private val pngData = base64Resource("/sample.png") + private val wavData = base64Resource("/sample.wav") + private val addNumbers = tool("add_numbers") .description("Adds two numbers and returns the result as text") .input[AddNumbersInput] - .handle(in => Right((in.a + in.b).toString)) + .handle(in => ToolResult.text((in.a + in.b).toString)) private val simpleText = tool("test_simple_text") .description("Returns a fixed text string") .input[NoInput] - .handle(_ => Right("This is a simple text response for testing")) + .handle(_ => ToolResult.text("This is a simple text response for testing")) private val errorTool = tool("test_error_handling") .description("Always returns an error result") .input[NoInput] - .handle(_ => Left("This tool intentionally returns an error for testing")) + .handle(_ => ToolResult.error("This tool intentionally returns an error for testing")) + + private val imageTool = tool("test_image_content") + .description("Returns image content") + .input[NoInput] + .handle(_ => ToolResult.image(pngData, "image/png")) + + private val audioTool = tool("test_audio_content") + .description("Returns audio content") + .input[NoInput] + .handle(_ => ToolResult.audio(wavData, "audio/wav")) + + private val mixedTool = tool("test_multiple_content_types") + .description("Returns text, image and embedded-resource content") + .input[NoInput] + .handle(_ => + ToolResult.content( + ToolContent.Text(text = "Here is some mixed content"), + ToolContent.Image(data = pngData, mimeType = "image/png"), + ToolContent + .ResourceContent(resource = ResourceContents.Text(uri = "test://embedded", text = "embedded", mimeType = Some("text/plain"))) + ) + ) + + private val embeddedResourceTool = tool("test_embedded_resource") + .description("Returns an embedded resource") + .input[NoInput] + .handle(_ => + ToolResult.embedded(ResourceContents.Text(uri = "test://embedded", text = "embedded resource content", mimeType = Some("text/plain"))) + ) + + private val jsonSchema2020: Json = parse( + """{ + | "$schema": "https://json-schema.org/draft/2020-12/schema", + | "type": "object", + | "$defs": { + | "address": { + | "$anchor": "addressDef", + | "type": "object", + | "properties": { + | "street": { "type": "string" }, + | "city": { "type": "string" } + | } + | } + | }, + | "properties": { + | "name": { "type": "string" }, + | "address": { "$ref": "#/$defs/address" }, + | "contactMethod": { "type": "string", "enum": ["phone", "email"] }, + | "phone": { "type": "string" }, + | "email": { "type": "string" } + | }, + | "allOf": [ + | { "anyOf": [{ "required": ["phone"] }, { "required": ["email"] }] } + | ], + | "if": { + | "properties": { "contactMethod": { "const": "phone" } }, + | "required": ["contactMethod"] + | }, + | "then": { "required": ["phone"] }, + | "else": { "required": ["email"] }, + | "additionalProperties": false + |}""".stripMargin + ).toOption.get + + private val jsonSchemaTool = tool("json_schema_2020_12_tool") + .description("Advertises a JSON Schema 2020-12 input schema") + .inputJson(jsonSchema2020) + .handle(_ => ToolResult.text("ok")) + + private val staticText = resource("test://static-text") + .name("static-text") + .mimeType("text/plain") + .handle(() => + Right(List(ResourceContents.Text(uri = "test://static-text", text = "Hello, text resource!", mimeType = Some("text/plain")))) + ) + + private val staticBinary = resource("test://static-binary") + .name("static-binary") + .mimeType("image/png") + .handle(() => Right(List(ResourceContents.Blob(uri = "test://static-binary", blob = pngData, mimeType = Some("image/png"))))) + + private val dataTemplate = resourceTemplate("test://template/{id}/data") + .name("data-template") + .mimeType("text/plain") + .handle((vars, uri) => + Right(List(ResourceContents.Text(uri = uri, text = s"data for ${vars.getOrElse("id", "?")}", mimeType = Some("text/plain")))) + ) + + private val simplePrompt = prompt("test_simple_prompt") + .description("A simple prompt") + .handle(_ => GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = "This is a simple prompt."))))) + + private val argsPrompt = prompt("test_prompt_with_arguments") + .description("A prompt with arguments") + .argument("arg1", required = true) + .argument("arg2", required = true) + .handle: args => + val text = s"arg1=${args.getOrElse("arg1", "")}, arg2=${args.getOrElse("arg2", "")}" + GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = text)))) + + private val embeddedResourcePrompt = prompt("test_prompt_with_embedded_resource") + .description("A prompt embedding a resource") + .argument("resourceUri", required = true) + .handle: args => + val uri = args.getOrElse("resourceUri", "test://example-resource") + GetPromptResult(messages = + List( + PromptMessage( + Role.User, + ToolContent.ResourceContent(resource = + ResourceContents.Text(uri = uri, text = "embedded resource content", mimeType = Some("text/plain")) + ) + ) + ) + ) + + private val imagePrompt = prompt("test_prompt_with_image") + .description("A prompt with an image") + .handle(_ => GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Image(data = pngData, mimeType = "image/png"))))) - private val tools = List(addNumbers, simpleText, errorTool) + private val server = McpServer[Identity](name = "chimp-conformance-server", version = "0.1.0") + .addTools(addNumbers, simpleText, errorTool, imageTool, audioTool, mixedTool, embeddedResourceTool, jsonSchemaTool) + .addResources(staticText, staticBinary) + .addResourceTemplate(dataTemplate) + .addPrompts(simplePrompt, argsPrompt, embeddedResourcePrompt, imagePrompt) + .withCompletion((_, _, _) => Completion(values = List("alpha", "beta"))) + .withLoggingLevel(_ => ()) + .withSubscriptions(ResourceSubscriptions[Identity](_ => (), _ => ())) def main(args: Array[String]): Unit = val requestedPort = args @@ -34,7 +174,7 @@ object Main: .orElse(sys.env.get("CHIMP_CONFORMANCE_PORT").map(_.toInt)) .getOrElse(0) - val endpoint = mcpEndpoint(tools, List("mcp"), name = "chimp-conformance-server", version = "0.1.0") + val endpoint = server.endpoint(List("mcp")) supervised: val binding = NettySyncServer().port(requestedPort).addEndpoint(endpoint).start() diff --git a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerHttpTransport.scala b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerHttpTransport.scala new file mode 100644 index 0000000..fe70d6d --- /dev/null +++ b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerHttpTransport.scala @@ -0,0 +1,47 @@ +package chimp.server.zio + +import chimp.protocol.JSONRPCMessage +import chimp.server.OutboundSink +import chimp.server.transport.ServerStreamingHttpTransport +import io.circe.Json +import io.circe.syntax.* +import sttp.capabilities.zio.ZioStreams +import sttp.model.sse.ServerSentEvent +import sttp.tapir.* +import sttp.tapir.ztapir.ZioServerSentEvents +import zio.stream.{Stream, ZStream} +import zio.{Queue, Task, ZIO} + +import java.nio.charset.StandardCharsets + +final class ZioServerHttpTransport(path: List[String]) extends ServerStreamingHttpTransport[Task, ZioStreams](path): + val streams: ZioStreams = ZioStreams + + type EventStream = Stream[Throwable, ServerSentEvent] + + val sseBody: StreamBodyIO[Stream[Throwable, Byte], EventStream, ZioStreams] = + streamTextBody(ZioStreams)(CodecFormat.TextEventStream(), Some(StandardCharsets.UTF_8)) + .map(ZioServerSentEvents.parseBytesToSSE)(ZioServerSentEvents.serialiseSSEToBytes) + + val emptyStream: EventStream = ZStream.empty + + def eventStream(handle: OutboundSink[Task] => Task[Option[Json]]): Task[EventStream] = + ZIO.succeed { + ZStream.unwrap { + for + queue <- Queue.unbounded[Outbound] + sink = new OutboundSink[Task]: + def send(message: JSONRPCMessage): Task[Unit] = + queue.offer(Outbound.Message(message.asJson)).unit + _ <- handle(sink) + .flatMap(response => ZIO.foreachDiscard(response)(json => queue.offer(Outbound.Message(json)))) + .ensuring(queue.offer(Outbound.Close)) + .catchAllCause(_ => ZIO.unit) + .forkDaemon + yield ZStream.fromQueue(queue).collectWhile { case Outbound.Message(json) => ServerSentEvent(data = Some(json.noSpaces)) } + } + } + + private enum Outbound: + case Message(json: Json) + case Close diff --git a/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerStdioTransport.scala b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerStdioTransport.scala new file mode 100644 index 0000000..7986de0 --- /dev/null +++ b/server-streaming/server-zio/src/main/scala/chimp/server/zio/ZioServerStdioTransport.scala @@ -0,0 +1,54 @@ +package chimp.server.zio + +import chimp.protocol.{JSONRPCMessage, ProgressToken} +import chimp.server.{McpHandler, OutboundSink, SinkStreamingServerContext, StreamingMcpServer, StreamingServerContext} +import chimp.server.transport.ServerStreamingStdioTransport +import io.circe.syntax.* +import io.circe.{parser, Json} +import org.slf4j.LoggerFactory +import sttp.monad.MonadError +import sttp.tapir.ztapir.RIOMonadError +import zio.{Task, ZIO} + +import java.io.{BufferedReader, BufferedWriter, InputStream, InputStreamReader, OutputStream, OutputStreamWriter} +import java.nio.charset.StandardCharsets + +final class ZioServerStdioTransport(in: InputStream = System.in, out: OutputStream = System.out) + extends ServerStreamingStdioTransport[Task]: + private val log = LoggerFactory.getLogger(classOf[ZioServerStdioTransport]) + private given MonadError[Task] = new RIOMonadError[Any] + + def serve(server: StreamingMcpServer[Task]): Task[Unit] = + val handler = new McpHandler[Task, StreamingServerContext[Task]](server) + val reader = BufferedReader(InputStreamReader(in, StandardCharsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(out, StandardCharsets.UTF_8)) + + def writeLine(json: Json): Task[Unit] = + ZIO.attemptBlocking: + writer.write(json.noSpaces) + writer.newLine() + writer.flush() + + val sink = new OutboundSink[Task]: + def send(message: JSONRPCMessage): Task[Unit] = writeLine(message.asJson.deepDropNullValues) + + val makeContext: Option[ProgressToken] => StreamingServerContext[Task] = + token => SinkStreamingServerContext(sink, token) + + def loop: Task[Unit] = + ZIO + .attemptBlocking(Option(reader.readLine())) + .flatMap: + case None => ZIO.unit + case Some(line) => + val handled = + if line.isEmpty then ZIO.unit + else + parser.parse(line) match + case Right(json) => + handler.handleJsonRpc(json, Nil, makeContext).flatMap(response => ZIO.foreachDiscard(response.body)(writeLine)) + case Left(error) => + ZIO.succeed(log.warn(s"Failed to parse JSON-RPC line: ${error.getMessage}; raw: $line")) + handled *> loop + + loop diff --git a/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerHttpSpec.scala b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerHttpSpec.scala new file mode 100644 index 0000000..32525b8 --- /dev/null +++ b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerHttpSpec.scala @@ -0,0 +1,37 @@ +package chimp.server.zio + +import chimp.client.transport.ClientTransport +import chimp.client.transport.zio.ZioClientHttpTransport +import chimp.client.{BidirectionalMcpClient, McpClient} +import chimp.protocol.{Implementation, ProtocolVersion} +import chimp.server.{McpServer, McpServerStreamingTests, McpServerTests, StreamingMcpServer} +import org.scalatest.Assertion +import sttp.client4.* +import sttp.client4.httpclient.zio.HttpClientZioBackend +import sttp.tapir.server.ziohttp.ZioHttpInterpreter +import zio.http.Server +import zio.{Scope, Task, ZIO} + +import scala.concurrent.Future + +class ZioMcpServerHttpSpec extends McpServerTests[Task] with McpServerStreamingTests[Task] with ZioToFuture: + private val clientInfo = Implementation("chimp-server-test", "0.0.1") + + override protected def withServer(server: McpServer[Task])(test: McpClient[Task] => Task[Assertion]): Future[Assertion] = + withStreamingServer(server.streaming)(test) + + override protected def withStreamingServer( + server: StreamingMcpServer[Task] + )(test: BidirectionalMcpClient[Task] => Task[Assertion]): Future[Assertion] = + toFuture: + val routes = ZioHttpInterpreter().toHttp(ZioServerHttpTransport(List("mcp")).serve(server)) + ZIO.scoped: + (for + port <- Server.install(routes) + result <- HttpClientZioBackend().flatMap: backend => + ZioClientHttpTransport + .scoped(backend, uri"http://localhost:$port/mcp", ProtocolVersion.Latest, ClientTransport.defaultTimeout) + .flatMap(transport => McpClient.bidirectional(transport, clientInfo)) + .flatMap(client => test(client)) + .ensuring(backend.close().ignore) + yield result).provideSome[Scope](Server.defaultWithPort(0)) diff --git a/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStdioSpec.scala b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStdioSpec.scala new file mode 100644 index 0000000..8c7bcd4 --- /dev/null +++ b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioMcpServerStdioSpec.scala @@ -0,0 +1,18 @@ +package chimp.server.zio + +import chimp.server.{ServerStdioTransportTests, StreamingMcpServer} +import zio.{Runtime, Task, Unsafe} + +import java.io.{InputStream, OutputStream} + +class ZioMcpServerStdioSpec extends ServerStdioTransportTests[Task] with ZioToFuture: + private val runtime = Runtime.default + + override protected def runStdioServer(server: StreamingMcpServer[Task], in: InputStream, out: OutputStream): Unit = + val thread = Thread(() => + Unsafe.unsafe { implicit u => + runtime.unsafe.run(ZioServerStdioTransport(in, out).serve(server)).getOrThrowFiberFailure() + } + ) + thread.setDaemon(true) + thread.start() diff --git a/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioToFuture.scala b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioToFuture.scala new file mode 100644 index 0000000..e7a3173 --- /dev/null +++ b/server-streaming/server-zio/src/test/scala/chimp/server/zio/ZioToFuture.scala @@ -0,0 +1,19 @@ +package chimp.server.zio + +import sttp.client4.impl.zio.RIOMonadAsyncError +import sttp.monad.MonadError +import zio.{Duration, Runtime, Task, Unsafe, ZIO} + +import scala.concurrent.Future + +trait ZioToFuture extends chimp.server.ToFuture[Task]: + override given monad: MonadError[Task] = new RIOMonadAsyncError[Any] + + private val runtime: Runtime[Any] = Runtime.default + + override def toFuture[A](fa: Task[A]): Future[A] = + Unsafe.unsafe { implicit u => + runtime.unsafe.runToFuture(fa) + } + + override def sleep(millis: Long): Task[Unit] = ZIO.sleep(Duration.fromMillis(millis)) diff --git a/server/src/main/scala/chimp/server/McpHandler.scala b/server/src/main/scala/chimp/server/McpHandler.scala index c83ffe3..8de5c5d 100644 --- a/server/src/main/scala/chimp/server/McpHandler.scala +++ b/server/src/main/scala/chimp/server/McpHandler.scala @@ -10,12 +10,8 @@ import sttp.monad.MonadError import sttp.monad.syntax.* import sttp.tapir.docs.apispec.schema.TapirSchemaToJsonSchema -/** Represents different types of HTTP responses for JSON-RPC requests */ enum McpResponse: - /** Response with JSON body (for requests and errors) */ case JsonResponse(json: Json) - - /** Response with no body (for notifications) */ case EmptyAcceptResponse def statusCode: StatusCode = this match @@ -30,140 +26,210 @@ enum McpResponse: case JsonResponse(json) => JsonResponse(json.deepDropNullValues) case EmptyAcceptResponse => this -/** The MCP server handles JSON-RPC requests for tool listing, invocation, and initialization. - * - * @param tools - * The list of available server tools. - * @param name - * The server name (for protocol reporting). - * @param version - * The server version (for protocol reporting). - * @param showJsonSchemaMetadata - * Whether to include JSON Schema metadata (such as $schema) in the tool input schemas. Some agents do not recognize it, so it can be - * disabled. - */ -class McpHandler[F[_]]( - tools: List[ServerTool[?, F]], - name: String, - version: String, - showJsonSchemaMetadata: Boolean -): - private val logger = LoggerFactory.getLogger(classOf[McpHandler[_]]) - private val toolsByName = tools.map(t => t.name -> t).toMap - - /** Converts a ServerTool to its protocol definition. */ - private def toolToDefinition(tool: ServerTool[?, F]): ToolDefinition = - val jsonSchema = - val base = TapirSchemaToJsonSchema(tool.inputSchema, markOptionsAsNullable = false) - if showJsonSchemaMetadata then base - else base.copy($schema = None) - - val json = jsonSchema.asJson +private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerDef[F, C]): + private val logger = LoggerFactory.getLogger(classOf[McpHandler[?, ?]]) + private val toolsByName = server.tools.map(tool => tool.name -> tool).toMap + private val promptsByName = server.prompts.map(prompt => prompt.definition.name -> prompt).toMap + private val resourcesByUri = server.resources.map(resource => resource.definition.uri -> resource).toMap + private val hasResources = server.resources.nonEmpty || server.resourceTemplates.nonEmpty + private val toolDefinitions = server.tools.map(toolToDefinition) + + private def toolToDefinition(tool: ServerTool[?, F, C]): ToolDefinition = + val jsonSchema = tool.inputSchema match + case ToolSchema.Derived(schema) => + val base = TapirSchemaToJsonSchema(schema, markOptionsAsNullable = false) + (if server.showJsonSchemaMetadata then base else base.copy($schema = None)).asJson + case ToolSchema.Raw(json) => json ToolDefinition( name = tool.name, description = tool.description, - inputSchema = json, + inputSchema = jsonSchema, annotations = tool.annotations - .map(a => ToolAnnotations(a.title, a.readOnlyHint, a.destructiveHint, a.idempotentHint, a.openWorldHint)) + .map(annotation => + ToolAnnotations( + annotation.title, + annotation.readOnlyHint, + annotation.destructiveHint, + annotation.idempotentHint, + annotation.openWorldHint + ) + ) ) - private val toolDefs: List[ToolDefinition] = tools.map(toolToDefinition) + def handleJsonRpc(request: Json, headers: Seq[Header], makeContext: Option[ProgressToken] => C)(using MonadError[F]): F[McpResponse] = + doHandleJsonRpc(request, headers, makeContext).map: response => + logger.debug(s"Request: $request, response: ${response.statusCode}, body: ${response.body}") + response.withNullsDroppedDeep + + def handleJsonRpc(request: Json, headers: Seq[Header])(using m: MonadError[F], ev: ServerContext[F] <:< C): F[McpResponse] = + handleJsonRpc(request, headers, _ => ev(ServerContext.noop[F])) - private def protocolError(id: RequestId, code: Int, message: String): JSONRPCMessage.Error = + private def doHandleJsonRpc(request: Json, headers: Seq[Header], makeContext: Option[ProgressToken] => C)(using + MonadError[F] + ): F[McpResponse] = + request.as[JSONRPCMessage] match + case Left(err) => + jsonResponse(protocolError(RequestId("null"), JSONRPCErrorCodes.ParseError.code, s"Parse error: ${err.message}")).unit + case Right(JSONRPCMessage.Request(_, method, params: Option[Json], id)) => + method match + case "initialize" => + jsonResponse(handleInitialize(params, id)).unit + case "ping" => + jsonResponse(JSONRPCMessage.Response(id = id, result = Json.obj())).unit + case "tools/list" => + jsonResponse(JSONRPCMessage.Response(id = id, result = ListToolsResponse(toolDefinitions).asJson)).unit + case "tools/call" => + handleToolsCall(params, id, headers, makeContext).map(jsonResponse) + case "resources/list" if hasResources => + jsonResponse(JSONRPCMessage.Response(id = id, result = ListResourcesResult(server.resources.map(_.definition)).asJson)).unit + case "resources/templates/list" if hasResources => + jsonResponse( + JSONRPCMessage.Response(id = id, result = ListResourceTemplatesResult(server.resourceTemplates.map(_.definition)).asJson) + ).unit + case "resources/read" if hasResources => + handleResourcesRead(params, id, headers).map(jsonResponse) + case "resources/subscribe" if server.subscriptions.isDefined => + handleSubscribe(params, id, subscribe = true).map(jsonResponse) + case "resources/unsubscribe" if server.subscriptions.isDefined => + handleSubscribe(params, id, subscribe = false).map(jsonResponse) + case "prompts/list" if server.prompts.nonEmpty => + jsonResponse(JSONRPCMessage.Response(id = id, result = ListPromptsResult(server.prompts.map(_.definition)).asJson)).unit + case "prompts/get" if server.prompts.nonEmpty => + handlePromptsGet(params, id, headers).map(jsonResponse) + case "completion/complete" if server.completion.isDefined => + handleComplete(params, id).map(jsonResponse) + case "logging/setLevel" if server.loggingLevel.isDefined => + handleSetLoggingLevel(params, id).map(jsonResponse) + case other => + jsonResponse(protocolError(id, JSONRPCErrorCodes.MethodNotFound.code, s"Unknown method: $other")).unit + case Right(notification: JSONRPCMessage.Notification) => + logger.debug(s"Received notification: ${notification.method}") + McpResponse.EmptyAcceptResponse.unit + case Right(_) => + jsonResponse(protocolError(RequestId("null"), JSONRPCErrorCodes.InvalidRequest.code, "Invalid request type")).unit + end doHandleJsonRpc + + private def protocolError(id: RequestId, code: Int, message: String, data: Option[Json] = None): JSONRPCMessage.Error = logger.debug(s"Protocol error (id=$id, code=$code): $message") - JSONRPCMessage.Error(id = id, error = JSONRPCErrorObject(code = code, message = message)) + JSONRPCMessage.Error(id = id, error = JSONRPCErrorObject(code = code, message = message, data = data)) + + private def jsonResponse(message: JSONRPCMessage): McpResponse = McpResponse.JsonResponse(message.asJson) private def handleInitialize(params: Option[Json], id: RequestId): JSONRPCMessage.Response = val requested = params.flatMap(_.hcursor.downField("protocolVersion").as[String].toOption) val negotiated = requested.map(ProtocolVersion.negotiate).getOrElse(ProtocolVersion.Latest) - val capabilities = ServerCapabilities(tools = Some(ServerToolsCapability(listChanged = Some(false)))) - val result = - InitializeResult(protocolVersion = negotiated.name, capabilities = capabilities, serverInfo = Implementation(name, version)) + val capabilities = ServerCapabilities( + logging = Option.when(server.loggingLevel.isDefined)(Json.obj()), + completions = Option.when(server.completion.isDefined)(Json.obj()), + prompts = Option.when(server.prompts.nonEmpty)(ServerPromptsCapability(listChanged = Some(false))), + resources = + Option.when(hasResources)(ServerResourcesCapability(subscribe = Some(server.subscriptions.isDefined), listChanged = Some(false))), + tools = Option.when(server.tools.nonEmpty)(ServerToolsCapability(listChanged = Some(false))) + ) + val result = InitializeResult( + protocolVersion = negotiated.name, + capabilities = capabilities, + serverInfo = Implementation(server.name, server.version), + instructions = server.instructions + ) JSONRPCMessage.Response(id = id, result = result.asJson) - /** Handles the 'tools/list' JSON-RPC method, returning the list of available tools. */ - private def handleToolsList(id: RequestId): JSONRPCMessage.Response = - JSONRPCMessage.Response(id = id, result = ListToolsResponse(toolDefs).asJson) - - /** Handles the 'tools/call' JSON-RPC method. Attempts to decode the tool name and arguments, then dispatches to the tool logic. Provides - * detailed error messages for decode failures. - */ - private def handleToolsCall(params: Option[io.circe.Json], id: RequestId, headers: Seq[Header])(using + private def handleToolsCall(params: Option[Json], id: RequestId, headers: Seq[Header], makeContext: Option[ProgressToken] => C)(using MonadError[F] ): F[JSONRPCMessage] = - // Extract tool name and arguments in a functional, idiomatic way - val toolNameOpt = params.flatMap(_.hcursor.downField("name").as[String].toOption) - val args = params.flatMap(_.hcursor.downField("arguments").focus).getOrElse(Json.obj()) - toolNameOpt match - case Some(toolName) => - toolsByName.get(toolName) match + val name = params.flatMap(_.hcursor.downField("name").as[String].toOption) + val arguments = params.flatMap(_.hcursor.downField("arguments").focus).getOrElse(Json.obj()) + val progressToken = params.flatMap(_.hcursor.downField("_meta").downField("progressToken").as[ProgressToken].toOption) + name match + case Some(name) => + toolsByName.get(name) match case Some(tool) => - def inputSnippet = args.noSpaces.take(200) - tool.inputDecoder.decodeJson(args) match - case Right(decodedInput) => handleDecodedInput(tool, decodedInput, id, headers) + tool.inputDecoder.decodeJson(arguments) match + case Right(input) => + val context = makeContext(progressToken) + tool + .logic(input, context, headers) + .map: result => + toolCallResponse(id, result) case Left(decodingError) => + val snippet = arguments.noSpaces.take(200) protocolError( id, JSONRPCErrorCodes.InvalidParams.code, - s"Invalid arguments: ${decodingError.getMessage}. Input: $inputSnippet" + s"Invalid arguments: ${decodingError.getMessage}. Input: $snippet" ).unit - case None => protocolError(id, JSONRPCErrorCodes.MethodNotFound.code, s"Unknown tool: $toolName").unit + case None => protocolError(id, JSONRPCErrorCodes.MethodNotFound.code, s"Unknown tool: $name").unit case None => protocolError(id, JSONRPCErrorCodes.InvalidParams.code, "Missing tool name").unit - /** Handles a successfully decoded tool input, dispatching to the tool's logic. */ - private def handleDecodedInput[T](tool: ServerTool[T, F], decodedInput: T, id: RequestId, headers: Seq[Header])(using + private def toolCallResponse(id: RequestId, result: ToolResult): JSONRPCMessage = + JSONRPCMessage.Response( + id = id, + result = CallToolResult( + content = result.content, + structuredContent = result.structuredContent, + isError = result.isError + ).asJson + ) + + private def handleResourcesRead(params: Option[Json], id: RequestId, headers: Seq[Header])(using MonadError[F]): F[JSONRPCMessage] = + decodeParams[ReadResourceParams](params, id): params => + resourcesByUri.get(params.uri) match + case Some(resource) => resource.read(headers).map(resourceReadResponse(id, params.uri)) + case None => + val templateMatch = server.resourceTemplates.iterator + .map(template => (template, template.matcher.matchUri(params.uri))) + .collectFirst { case (template, Some(vars)) => (template, vars) } + templateMatch match + case Some((template, vars)) => template.read(vars, params.uri, headers).map(resourceReadResponse(id, params.uri)) + case None => + protocolError( + id, + JSONRPCErrorCodes.InvalidParams.code, + s"Resource not found: ${params.uri}", + Some(Json.obj("uri" -> Json.fromString(params.uri))) + ).unit + + private def decodeParams[P: Decoder](params: Option[Json], id: RequestId)(f: P => F[JSONRPCMessage])(using MonadError[F] ): F[JSONRPCMessage] = - tool - .logic(decodedInput, headers) - .map: - case Right(result) => - val callResult = CallToolResult( - content = List(ToolContent.Text(text = result)), - isError = false - ) - JSONRPCMessage.Response(id = id, result = callResult.asJson) - case Left(errorMsg) => - val callResult = CallToolResult( - content = List(ToolContent.Text(text = errorMsg)), - isError = true - ) - JSONRPCMessage.Response(id = id, result = callResult.asJson) - - /** Handles a JSON-RPC request, dispatching to the appropriate handler. Logs requests and responses. */ - private def doHandleJsonRpc(request: Json, headers: Seq[Header])(using MonadError[F]): F[McpResponse] = - request.as[JSONRPCMessage] match - case Left(err) => - val errorResponse = protocolError(RequestId("null"), JSONRPCErrorCodes.ParseError.code, s"Parse error: ${err.message}") - McpResponse.JsonResponse((errorResponse: JSONRPCMessage).asJson).unit - case Right(JSONRPCMessage.Request(_, method, params: Option[io.circe.Json], id)) => - method match - case "tools/list" => - val response = handleToolsList(id) - McpResponse.JsonResponse((response: JSONRPCMessage).asJson).unit - case "tools/call" => - handleToolsCall(params, id, headers).map { response => - McpResponse.JsonResponse((response: JSONRPCMessage).asJson) - } - case "initialize" => - val response = handleInitialize(params, id) - McpResponse.JsonResponse((response: JSONRPCMessage).asJson).unit - case "ping" => - val response = JSONRPCMessage.Response(id = id, result = Json.obj()) - McpResponse.JsonResponse((response: JSONRPCMessage).asJson).unit - case other => - val errorResponse = protocolError(id, JSONRPCErrorCodes.MethodNotFound.code, s"Unknown method: $other") - McpResponse.JsonResponse((errorResponse: JSONRPCMessage).asJson).unit - case Right(notification: JSONRPCMessage.Notification) => - logger.debug(s"Received notification: ${notification.method}") - McpResponse.EmptyAcceptResponse.unit - case Right(_) => - val errorResponse = protocolError(RequestId("null"), JSONRPCErrorCodes.InvalidRequest.code, "Invalid request type") - McpResponse.JsonResponse((errorResponse: JSONRPCMessage).asJson).unit - end doHandleJsonRpc - - def handleJsonRpc(request: Json, headers: Seq[Header])(using MonadError[F]): F[McpResponse] = - doHandleJsonRpc(request, headers).map: response => - logger.debug(s"Request: $request, response: ${response.statusCode}, body: ${response.body}") - response.withNullsDroppedDeep + params.flatMap(_.as[P].toOption) match + case Some(params) => f(params) + case None => protocolError(id, JSONRPCErrorCodes.InvalidParams.code, "Invalid or missing params").unit + + private def resourceReadResponse(id: RequestId, uri: String)(result: Either[ResourceError, List[ResourceContents]]): JSONRPCMessage = + result match + case Right(contents) => JSONRPCMessage.Response(id = id, result = ReadResourceResult(contents).asJson) + case Left(error) => + protocolError( + id, + JSONRPCErrorCodes.InvalidParams.code, + error.message, + error.uri.orElse(Some(uri)).map(uri => Json.obj("uri" -> Json.fromString(uri))) + ) + + private def handleSubscribe(params: Option[Json], id: RequestId, subscribe: Boolean)(using MonadError[F]): F[JSONRPCMessage] = + val subs = server.subscriptions.get + if subscribe then decodeParams[SubscribeParams](params, id)(params => subs.onSubscribe(params).map(_ => emptyResult(id))) + else decodeParams[UnsubscribeParams](params, id)(params => subs.onUnsubscribe(params).map(_ => emptyResult(id))) + + private def emptyResult(id: RequestId): JSONRPCMessage = JSONRPCMessage.Response(id = id, result = Json.obj()) + + private def handlePromptsGet(params: Option[Json], id: RequestId, headers: Seq[Header])(using MonadError[F]): F[JSONRPCMessage] = + decodeParams[GetPromptParams](params, id): params => + promptsByName.get(params.name) match + case Some(prompt) => + prompt + .logic(params.arguments.getOrElse(Map.empty), headers) + .map(result => JSONRPCMessage.Response(id = id, result = result.asJson)) + case None => protocolError(id, JSONRPCErrorCodes.InvalidParams.code, s"Unknown prompt: ${params.name}").unit + + private def handleComplete(params: Option[Json], id: RequestId)(using MonadError[F]): F[JSONRPCMessage] = + val handler = server.completion.get + decodeParams[CompleteParams](params, id): params => + handler(params.ref, params.argument, params.context) + .map(completion => JSONRPCMessage.Response(id = id, result = CompleteResult(completion).asJson)) + + private def handleSetLoggingLevel(params: Option[Json], id: RequestId)(using MonadError[F]): F[JSONRPCMessage] = + val handler = server.loggingLevel.get + decodeParams[SetLevelParams](params, id)(params => handler(params.level).map(_ => emptyResult(id))) diff --git a/server/src/main/scala/chimp/server/McpServer.scala b/server/src/main/scala/chimp/server/McpServer.scala new file mode 100644 index 0000000..6ad88e2 --- /dev/null +++ b/server/src/main/scala/chimp/server/McpServer.scala @@ -0,0 +1,176 @@ +package chimp.server + +import chimp.protocol.* +import chimp.server.transport.ServerHttpTransport +import sttp.tapir.server.ServerEndpoint + +type CompletionHandler[F[_]] = (CompleteRef, CompleteArgument, Option[CompleteContext]) => F[Completion] + +type SetLoggingLevelHandler[F[_]] = LoggingLevel => F[Unit] + +case class ResourceSubscriptions[F[_]]( + onSubscribe: SubscribeParams => F[Unit], + onUnsubscribe: UnsubscribeParams => F[Unit] +) + +sealed trait McpServerDef[F[_], C <: ServerContext[F]]: + def name: String + def version: String + def instructions: Option[String] + def showJsonSchemaMetadata: Boolean + def originCheck: OriginCheck + def tools: List[ServerTool[?, F, C]] + def prompts: List[ServerPrompt[F]] + def resources: List[ServerResource[F]] + def resourceTemplates: List[ServerResourceTemplate[F]] + def completion: Option[CompletionHandler[F]] + def loggingLevel: Option[SetLoggingLevelHandler[F]] + def subscriptions: Option[ResourceSubscriptions[F]] + +case class McpServer[F[_]]( + name: String = "Chimp MCP server", + version: String = "1.0.0", + instructions: Option[String] = None, + showJsonSchemaMetadata: Boolean = true, + originCheck: OriginCheck = OriginCheck.localhostOnly, + tools: List[ServerTool[?, F, ServerContext[F]]] = Nil, + prompts: List[ServerPrompt[F]] = Nil, + resources: List[ServerResource[F]] = Nil, + resourceTemplates: List[ServerResourceTemplate[F]] = Nil, + completion: Option[CompletionHandler[F]] = None, + loggingLevel: Option[SetLoggingLevelHandler[F]] = None, + subscriptions: Option[ResourceSubscriptions[F]] = None +) extends McpServerDef[F, ServerContext[F]]: + def name(value: String): McpServer[F] = + copy(name = value) + + def version(value: String): McpServer[F] = + copy(version = value) + + def instructions(value: String): McpServer[F] = + copy(instructions = Some(value)) + + def withJsonSchemaMetadata(value: Boolean): McpServer[F] = + copy(showJsonSchemaMetadata = value) + + def withOriginCheck(value: OriginCheck): McpServer[F] = + copy(originCheck = value) + + def addTool(tool: ServerTool[?, F, ServerContext[F]]): McpServer[F] = + copy(tools = tools :+ tool) + + def addTools(tools: ServerTool[?, F, ServerContext[F]]*): McpServer[F] = + copy(tools = this.tools ++ tools) + + def addPrompt(prompt: ServerPrompt[F]): McpServer[F] = + copy(prompts = prompts :+ prompt) + + def addPrompts(prompts: ServerPrompt[F]*): McpServer[F] = + copy(prompts = this.prompts ++ prompts) + + def addResource(resource: ServerResource[F]): McpServer[F] = + copy(resources = resources :+ resource) + + def addResources(resources: ServerResource[F]*): McpServer[F] = + copy(resources = this.resources ++ resources) + + def addResourceTemplate(resourceTemplate: ServerResourceTemplate[F]): McpServer[F] = + copy(resourceTemplates = resourceTemplates :+ resourceTemplate) + + def addResourceTemplates(resourceTemplates: ServerResourceTemplate[F]*): McpServer[F] = + copy(resourceTemplates = this.resourceTemplates ++ resourceTemplates) + + def withCompletion(handler: CompletionHandler[F]): McpServer[F] = + copy(completion = Some(handler)) + + def withLoggingLevel(handler: SetLoggingLevelHandler[F]): McpServer[F] = + copy(loggingLevel = Some(handler)) + + def withSubscriptions(handler: ResourceSubscriptions[F]): McpServer[F] = + copy(subscriptions = Some(handler)) + + def endpoint(path: List[String]): ServerEndpoint[Any, F] = ServerHttpTransport(path).serve(this) + + def streaming: StreamingMcpServer[F] = + StreamingMcpServer( + name, + version, + instructions, + showJsonSchemaMetadata, + originCheck, + tools, + prompts, + resources, + resourceTemplates, + completion, + loggingLevel, + subscriptions + ) + +case class StreamingMcpServer[F[_]]( + name: String = "Chimp MCP server", + version: String = "1.0.0", + instructions: Option[String] = None, + showJsonSchemaMetadata: Boolean = true, + originCheck: OriginCheck = OriginCheck.localhostOnly, + tools: List[ServerTool[?, F, StreamingServerContext[F]]] = Nil, + prompts: List[ServerPrompt[F]] = Nil, + resources: List[ServerResource[F]] = Nil, + resourceTemplates: List[ServerResourceTemplate[F]] = Nil, + completion: Option[CompletionHandler[F]] = None, + loggingLevel: Option[SetLoggingLevelHandler[F]] = None, + subscriptions: Option[ResourceSubscriptions[F]] = None +) extends McpServerDef[F, StreamingServerContext[F]]: + def name(value: String): StreamingMcpServer[F] = + copy(name = value) + + def version(value: String): StreamingMcpServer[F] = + copy(version = value) + + def instructions(value: String): StreamingMcpServer[F] = + copy(instructions = Some(value)) + + def withJsonSchemaMetadata(value: Boolean): StreamingMcpServer[F] = + copy(showJsonSchemaMetadata = value) + + def withOriginCheck(value: OriginCheck): StreamingMcpServer[F] = + copy(originCheck = value) + + def addTool(tool: ServerTool[?, F, ServerContext[F]]): StreamingMcpServer[F] = + copy(tools = tools :+ tool) + + def addTools(tools: ServerTool[?, F, ServerContext[F]]*): StreamingMcpServer[F] = + copy(tools = this.tools ++ tools) + + def addStreamingTool(tool: ServerTool[?, F, StreamingServerContext[F]]): StreamingMcpServer[F] = + copy(tools = tools :+ tool) + + def addStreamingTools(tools: ServerTool[?, F, StreamingServerContext[F]]*): StreamingMcpServer[F] = + copy(tools = this.tools ++ tools) + + def addPrompt(prompt: ServerPrompt[F]): StreamingMcpServer[F] = + copy(prompts = prompts :+ prompt) + + def addPrompts(prompts: ServerPrompt[F]*): StreamingMcpServer[F] = + copy(prompts = this.prompts ++ prompts) + + def addResource(resource: ServerResource[F]): StreamingMcpServer[F] = + copy(resources = resources :+ resource) + + def addResources(resources: ServerResource[F]*): StreamingMcpServer[F] = + copy(resources = this.resources ++ resources) + + def addResourceTemplate(resourceTemplate: ServerResourceTemplate[F]): StreamingMcpServer[F] = + copy(resourceTemplates = resourceTemplates :+ resourceTemplate) + + def addResourceTemplates(resourceTemplates: ServerResourceTemplate[F]*): StreamingMcpServer[F] = + copy(resourceTemplates = this.resourceTemplates ++ resourceTemplates) + + def withCompletion(handler: CompletionHandler[F]): StreamingMcpServer[F] = + copy(completion = Some(handler)) + + def withLoggingLevel(handler: SetLoggingLevelHandler[F]): StreamingMcpServer[F] = + copy(loggingLevel = Some(handler)) + + def withSubscriptions(handler: ResourceSubscriptions[F]): StreamingMcpServer[F] = + copy(subscriptions = Some(handler)) diff --git a/server/src/main/scala/chimp/server/OriginCheck.scala b/server/src/main/scala/chimp/server/OriginCheck.scala new file mode 100644 index 0000000..8321086 --- /dev/null +++ b/server/src/main/scala/chimp/server/OriginCheck.scala @@ -0,0 +1,23 @@ +package chimp.server + +case class OriginCheck(allowedHosts: Set[String], enabled: Boolean = true): + def validate(host: Option[String], origin: Option[String]): Boolean = + if !enabled then true + else host.forall(allowed) && origin.forall(allowed) + + private def allowed(headerValue: String): Boolean = allowedHosts.contains(OriginCheck.hostName(headerValue)) + +object OriginCheck: + private val localhostHosts: Set[String] = Set("localhost", "127.0.0.1", "[::1]", "::1") + + val localhostOnly: OriginCheck = OriginCheck(localhostHosts) + val disabled: OriginCheck = OriginCheck(Set.empty, enabled = false) + + private def hostName(headerValue: String): String = + val trimmed = headerValue.trim + val schemeIdx = trimmed.indexOf("://") + val authority = if schemeIdx >= 0 then trimmed.substring(schemeIdx + 3) else trimmed + if authority.startsWith("[") then + val close = authority.indexOf("]") + if close >= 0 then authority.substring(0, close + 1) else "" + else authority.takeWhile(_ != ':') diff --git a/server/src/main/scala/chimp/server/OutboundSink.scala b/server/src/main/scala/chimp/server/OutboundSink.scala new file mode 100644 index 0000000..2d19feb --- /dev/null +++ b/server/src/main/scala/chimp/server/OutboundSink.scala @@ -0,0 +1,8 @@ +package chimp.server + +import chimp.protocol.JSONRPCMessage + +/** The sink for any server to client interaction. Each streaming transport supplies its own implementation. + */ +trait OutboundSink[F[_]]: + def send(message: JSONRPCMessage): F[Unit] diff --git a/server/src/main/scala/chimp/server/Prompt.scala b/server/src/main/scala/chimp/server/Prompt.scala new file mode 100644 index 0000000..6e42081 --- /dev/null +++ b/server/src/main/scala/chimp/server/Prompt.scala @@ -0,0 +1,49 @@ +package chimp.server + +import chimp.protocol.{GetPromptResult, Prompt, PromptArgument} +import sttp.model.Header +import sttp.shared.Identity + +/** Starts defining a prompt with the given name. */ +def prompt(name: String): PartialPrompt = PartialPrompt(name) + +/** A prompt being defined, before its logic is attached. */ +case class PartialPrompt( + name: String, + title: Option[String] = None, + description: Option[String] = None, + arguments: List[PromptArgument] = Nil +): + def title(value: String): PartialPrompt = + copy(title = Some(value)) + + def description(value: String): PartialPrompt = + copy(description = Some(value)) + + /** Declares a single argument the prompt accepts. */ + def argument(name: String, description: Option[String] = None, required: Boolean = false): PartialPrompt = + copy(arguments = arguments :+ PromptArgument(name, description, required = Some(required))) + + /** Declares multiple arguments the prompt accepts. */ + def arguments(args: PromptArgument*): PartialPrompt = + copy(arguments = arguments ++ args) + + /** Attaches effectful logic, with access to the request headers, producing the prompt's messages. */ + def serverLogic[F[_]](logic: (Map[String, String], Seq[Header]) => F[GetPromptResult]): ServerPrompt[F] = + ServerPrompt(definition, logic) + + /** Attaches synchronous logic that also receives the request headers. */ + def handleWithHeaders(logic: (Map[String, String], Seq[Header]) => GetPromptResult): ServerPrompt[Identity] = + ServerPrompt(definition, logic) + + /** Attaches synchronous logic over just the supplied argument values. */ + def handle(logic: Map[String, String] => GetPromptResult): ServerPrompt[Identity] = + handleWithHeaders((args, _) => logic(args)) + + private def definition: Prompt = + Prompt(name, title, description, Option.when(arguments.nonEmpty)(arguments)) + +end PartialPrompt + +/** A fully-defined prompt: its metadata plus the logic producing its messages. */ +case class ServerPrompt[F[_]](definition: Prompt, logic: (Map[String, String], Seq[Header]) => F[GetPromptResult]) diff --git a/server/src/main/scala/chimp/server/Resource.scala b/server/src/main/scala/chimp/server/Resource.scala new file mode 100644 index 0000000..60ec2d2 --- /dev/null +++ b/server/src/main/scala/chimp/server/Resource.scala @@ -0,0 +1,131 @@ +package chimp.server + +import chimp.protocol.{Resource, ResourceContents, ResourceTemplate} +import sttp.model.Header +import sttp.shared.Identity + +import java.util.regex.Pattern +import scala.util.matching.Regex + +/** An error returned when reading a resource fails, optionally naming the offending URI. */ +case class ResourceError(message: String, uri: Option[String] = None) + +/** Starts defining a resource served at the given fixed URI. */ +def resource(uri: String): PartialResource = PartialResource(uri) + +/** Starts defining a resource template whose URI carries `{variable}` placeholders. */ +def resourceTemplate(uriTemplate: String): PartialResourceTemplate = PartialResourceTemplate(uriTemplate) + +/** A resource being defined, before its read logic is attached. */ +case class PartialResource( + uri: String, + name: Option[String] = None, + title: Option[String] = None, + description: Option[String] = None, + mimeType: Option[String] = None, + size: Option[Long] = None +): + def name(value: String): PartialResource = + copy(name = Some(value)) + + def title(value: String): PartialResource = + copy(title = Some(value)) + + def description(value: String): PartialResource = + copy(description = Some(value)) + + def mimeType(value: String): PartialResource = + copy(mimeType = Some(value)) + + def size(value: Long): PartialResource = + copy(size = Some(value)) + + /** Attaches effectful logic, with access to the request headers, producing the resource's contents (or an error). */ + def serverLogic[F[_]](logic: Seq[Header] => F[Either[ResourceError, List[ResourceContents]]]): ServerResource[F] = + ServerResource(definition, logic) + + /** Attaches synchronous logic that also receives the request headers. */ + def handleWithHeaders(logic: Seq[Header] => Either[ResourceError, List[ResourceContents]]): ServerResource[Identity] = + ServerResource(definition, logic) + + /** Attaches synchronous logic producing the resource's contents (or an error). */ + def handle(logic: () => Either[ResourceError, List[ResourceContents]]): ServerResource[Identity] = + handleWithHeaders(_ => logic()) + + private def definition: Resource = Resource(uri, name.getOrElse(uri), title, description, mimeType, size) + +end PartialResource + +/** A fully-defined resource: its metadata plus the logic reading its contents. */ +case class ServerResource[F[_]](definition: Resource, read: Seq[Header] => F[Either[ResourceError, List[ResourceContents]]]) + +/** A resource template being defined, before its read logic is attached. */ +case class PartialResourceTemplate( + uriTemplate: String, + name: Option[String] = None, + title: Option[String] = None, + description: Option[String] = None, + mimeType: Option[String] = None +): + def name(value: String): PartialResourceTemplate = + copy(name = Some(value)) + + def title(value: String): PartialResourceTemplate = + copy(title = Some(value)) + + def description(value: String): PartialResourceTemplate = + copy(description = Some(value)) + + def mimeType(value: String): PartialResourceTemplate = + copy(mimeType = Some(value)) + + /** Attaches effectful logic reading a matched URI; receives the extracted variables, the full URI, and the request headers. */ + def serverLogic[F[_]]( + logic: (Map[String, String], String, Seq[Header]) => F[Either[ResourceError, List[ResourceContents]]] + ): ServerResourceTemplate[F] = + ServerResourceTemplate(definition, UriTemplate.compile(uriTemplate), logic) + + /** Attaches synchronous logic that also receives the request headers. */ + def handleWithHeaders( + logic: (Map[String, String], String, Seq[Header]) => Either[ResourceError, List[ResourceContents]] + ): ServerResourceTemplate[Identity] = + ServerResourceTemplate(definition, UriTemplate.compile(uriTemplate), logic) + + /** Attaches synchronous logic receiving the extracted variables and the full URI. */ + def handle(logic: (Map[String, String], String) => Either[ResourceError, List[ResourceContents]]): ServerResourceTemplate[Identity] = + handleWithHeaders((vars, uri, _) => logic(vars, uri)) + + private def definition: ResourceTemplate = + ResourceTemplate(uriTemplate, name.getOrElse(uriTemplate), title, description, mimeType) + +end PartialResourceTemplate + +/** A fully-defined resource template: its metadata, a compiled URI matcher, and the logic reading matched URIs. */ +case class ServerResourceTemplate[F[_]]( + definition: ResourceTemplate, + matcher: UriTemplate, + read: (Map[String, String], String, Seq[Header]) => F[Either[ResourceError, List[ResourceContents]]] +) + +/** A compiled URI template that matches concrete URIs and extracts their `{variable}` values. */ +final class UriTemplate private (regex: Regex, names: List[String]): + /** Returns the extracted variables if `uri` matches, or `None` otherwise. */ + def matchUri(uri: String): Option[Map[String, String]] = + regex.findFirstMatchIn(uri).map(m => names.zipWithIndex.map((n, i) => n -> m.group(i + 1)).toMap) + +object UriTemplate: + private val VarPattern: Regex = "\\{([^}]+)\\}".r + + /** Compiles a `{variable}` URI template into a matcher; each variable matches one path segment. */ + def compile(template: String): UriTemplate = + val names = scala.collection.mutable.ListBuffer.empty[String] + val regex = new StringBuilder("^") + var last = 0 + for `match` <- VarPattern.findAllMatchIn(template) do + regex.append(Pattern.quote(template.substring(last, `match`.start))) + regex.append("([^/]+)") + names += `match`.group(1) + last = `match`.end + regex.append(Pattern.quote(template.substring(last))) + regex.append("$") + new UriTemplate(regex.toString.r, names.toList) diff --git a/server/src/main/scala/chimp/server/ServerContext.scala b/server/src/main/scala/chimp/server/ServerContext.scala new file mode 100644 index 0000000..c764818 --- /dev/null +++ b/server/src/main/scala/chimp/server/ServerContext.scala @@ -0,0 +1,34 @@ +package chimp.server + +import chimp.protocol.* +import io.circe.Json +import io.circe.syntax.* +import sttp.monad.MonadError + +trait ServerContext[F[_]] + +object ServerContext: + def noop[F[_]]: ServerContext[F] = new ServerContext[F] {} + +trait StreamingServerContext[F[_]] extends ServerContext[F]: + def reportProgress(progress: Double, total: Option[Double] = None, message: Option[String] = None): F[Unit] + def log(level: LoggingLevel, data: Json, logger: Option[String] = None): F[Unit] + +private[server] final class SinkStreamingServerContext[F[_]](sink: OutboundSink[F], progressToken: Option[ProgressToken])(using + m: MonadError[F] +) extends StreamingServerContext[F]: + def reportProgress(progress: Double, total: Option[Double] = None, message: Option[String] = None): F[Unit] = + progressToken match + case Some(token) => + sink.send( + JSONRPCMessage.Notification( + method = "notifications/progress", + params = Some(ProgressParams(token, progress, total, message).asJson) + ) + ) + case None => m.unit(()) + + def log(level: LoggingLevel, data: Json, logger: Option[String] = None): F[Unit] = + sink.send( + JSONRPCMessage.Notification(method = "notifications/message", params = Some(LoggingMessageParams(level, data, logger).asJson)) + ) diff --git a/server/src/main/scala/chimp/server/Tool.scala b/server/src/main/scala/chimp/server/Tool.scala new file mode 100644 index 0000000..617c599 --- /dev/null +++ b/server/src/main/scala/chimp/server/Tool.scala @@ -0,0 +1,106 @@ +package chimp.server + +import chimp.protocol.{ResourceContents, ToolContent} +import io.circe.syntax.* +import io.circe.{Decoder, Encoder, Json} +import sttp.model.Header +import sttp.shared.Identity +import sttp.tapir.Schema + +/** Optional behavioral hints about a tool, surfaced to clients. */ +case class ToolAnnotations( + title: Option[String] = None, + readOnlyHint: Option[Boolean] = None, + destructiveHint: Option[Boolean] = None, + idempotentHint: Option[Boolean] = None, + openWorldHint: Option[Boolean] = None +) + +/** The result of a tool call: content blocks, optional structured output, and whether the call failed. */ +case class ToolResult( + content: List[ToolContent], + structuredContent: Option[Json] = None, + isError: Boolean = false +): + def asError: ToolResult = copy(isError = true) + def withStructured(json: Json): ToolResult = copy(structuredContent = Some(json)) + +/** Constructors for the common [[ToolResult]] shapes. */ +object ToolResult: + def text(text: String): ToolResult = ToolResult(List(ToolContent.Text(text = text))) + def error(message: String): ToolResult = ToolResult(List(ToolContent.Text(text = message)), isError = true) + def image(data: String, mimeType: String): ToolResult = ToolResult(List(ToolContent.Image(data = data, mimeType = mimeType))) + def audio(data: String, mimeType: String): ToolResult = ToolResult(List(ToolContent.Audio(data = data, mimeType = mimeType))) + def embedded(resource: ResourceContents): ToolResult = ToolResult(List(ToolContent.ResourceContent(resource = resource))) + def content(content: ToolContent*): ToolResult = ToolResult(content.toList) + def structured[A: Encoder](value: A): ToolResult = ToolResult(Nil, structuredContent = Some(value.asJson)) + def fromEither(result: Either[String, String]): ToolResult = result.fold(error, text) + +/** A tool's input schema: either derived from a Scala type or supplied as raw JSON Schema. */ +enum ToolSchema: + case Derived(schema: Schema[?]) + case Raw(json: Json) + +/** https://modelcontextprotocol.io/seps/986-specify-format-for-tool-names */ +private val ToolNameRegex = "^[A-Za-z0-9_./-]+$".r + +/** Starts defining a tool with the given unique name */ +def tool(name: String): PartialTool = + require(name.nonEmpty && name.length <= 64, s"Tool name must be 1..64 characters long, got ${name.length}: $name") + require(ToolNameRegex.matches(name), s"Tool name must match ${ToolNameRegex.regex}, got: $name") + PartialTool(name) + +/** A tool being defined, before its input type is fixed. */ +case class PartialTool( + name: String, + description: Option[String] = None, + annotations: Option[ToolAnnotations] = None +): + def description(desc: String): PartialTool = + copy(description = Some(desc)) + + def withAnnotations(ann: ToolAnnotations): PartialTool = + copy(annotations = Some(ann)) + + /** Fixes the input type, deriving its JSON Schema and decoder from the given instances. */ + def input[I: Schema: Decoder]: Tool[I] = + Tool[I](name, description, ToolSchema.Derived(summon[Schema[I]]), summon[Decoder[I]], annotations) + + /** Fixes the input as raw JSON, validated against the given JSON Schema. */ + def inputJson(schema: Json): Tool[Json] = Tool[Json](name, description, ToolSchema.Raw(schema), summon[Decoder[Json]], annotations) + +/** A tool with a known input type `I`, ready to be given its handling logic. */ +case class Tool[I]( + name: String, + description: Option[String], + inputSchema: ToolSchema, + inputDecoder: Decoder[I], + annotations: Option[ToolAnnotations] +): + /** Attaches effectful logic, with access to the request headers. */ + def serverLogic[F[_]](logic: (I, Seq[Header]) => F[ToolResult]): ServerTool[I, F, ServerContext[F]] = + ServerTool(name, description, inputSchema, inputDecoder, annotations, (input, _, headers) => logic(input, headers)) + + /** Attaches effectful logic with access to the [[StreamingServerContext]]; usable only on a streaming server. */ + def streamingServerLogic[F[_]]( + logic: (I, StreamingServerContext[F], Seq[Header]) => F[ToolResult] + ): ServerTool[I, F, StreamingServerContext[F]] = + ServerTool(name, description, inputSchema, inputDecoder, annotations, logic) + + /** Attaches synchronous logic that also receives the request headers. */ + def handleWithHeaders(logic: (I, Seq[Header]) => ToolResult): ServerTool[I, Identity, ServerContext[Identity]] = + ServerTool(name, description, inputSchema, inputDecoder, annotations, (i, _, headers) => logic(i, headers)) + + /** Attaches synchronous logic over just the decoded input. */ + def handle(logic: I => ToolResult): ServerTool[I, Identity, ServerContext[Identity]] = + handleWithHeaders((i, _) => logic(i)) + +/** A fully-defined tool: its metadata plus the logic handling a call, in effect `F` with context `C`. */ +case class ServerTool[I, F[_], -C <: ServerContext[F]]( + name: String, + description: Option[String], + inputSchema: ToolSchema, + inputDecoder: Decoder[I], + annotations: Option[ToolAnnotations], + logic: (I, C, Seq[Header]) => F[ToolResult] +) diff --git a/server/src/main/scala/chimp/server/mcpEndpoint.scala b/server/src/main/scala/chimp/server/mcpEndpoint.scala deleted file mode 100644 index caef462..0000000 --- a/server/src/main/scala/chimp/server/mcpEndpoint.scala +++ /dev/null @@ -1,48 +0,0 @@ -package chimp.server - -import io.circe.Json -import org.slf4j.LoggerFactory -import sttp.monad.MonadError -import sttp.monad.syntax.* -import sttp.tapir.* -import sttp.tapir.json.circe.* -import sttp.tapir.server.ServerEndpoint -import sttp.model.Header - -private val logger = LoggerFactory.getLogger(classOf[McpHandler[_]]) - -/** Creates a Tapir endpoint description, which will handle MCP HTTP server requests, using the provided tools. - * - * @param tools - * The list of tools to expose. - * @param path - * The path components at which to expose the MCP server. - * - * @tparam F - * The effect type. Might be `Identity` for a endpoints with synchronous logic. - */ -def mcpEndpoint[F[_]]( - tools: List[ServerTool[?, F]], - path: List[String], - name: String = "Chimp MCP server", - version: String = "1.0.0", - showJsonSchemaMetadata: Boolean = true -): ServerEndpoint[Any, F] = - val mcpHandler = new McpHandler(tools, name, version, showJsonSchemaMetadata) - val e = infallibleEndpoint.post - .in(path.foldLeft(emptyInput)((inputSoFar, pathComponent) => inputSoFar / pathComponent)) - .in(extractFromRequest(_.headers)) - .in(jsonBody[Json]) - .out(statusCode) - .out(jsonBody[Option[Json]]) - - ServerEndpoint.public( - e, - me => { (input: (Seq[Header], Json)) => - val (headers, json) = input - given MonadError[F] = me - mcpHandler - .handleJsonRpc(json, headers) - .map(response => Right((response.statusCode, response.body))) - } - ) diff --git a/server/src/main/scala/chimp/server/tool.scala b/server/src/main/scala/chimp/server/tool.scala deleted file mode 100644 index 691a319..0000000 --- a/server/src/main/scala/chimp/server/tool.scala +++ /dev/null @@ -1,76 +0,0 @@ -package chimp.server - -import sttp.tapir.Schema -import io.circe.Decoder -import sttp.model.Header -import sttp.shared.Identity - -case class ToolAnnotations( - title: Option[String] = None, - readOnlyHint: Option[Boolean] = None, - destructiveHint: Option[Boolean] = None, - idempotentHint: Option[Boolean] = None, - openWorldHint: Option[Boolean] = None -) - -/** Describes a tool before the input is specified. */ -case class PartialTool( - name: String, - description: Option[String] = None, - annotations: Option[ToolAnnotations] = None -): - def description(desc: String): PartialTool = copy(description = Some(desc)) - def withAnnotations(ann: ToolAnnotations): PartialTool = copy(annotations = Some(ann)) - - /** Specify the input type for the tool, providing both a Tapir Schema and a Circe Decoder. */ - def input[I: Schema: Decoder]: Tool[I] = Tool[I](name, description, summon[Schema[I]], summon[Decoder[I]], annotations) - -private val ToolNameRegex = "^[A-Za-z0-9_./-]+$".r - -/** Creates a new MCP tool description with the given name. The name must match `^[A-Za-z0-9_./-]+$` and be 1–64 characters long. */ -def tool(name: String): PartialTool = - require(name.length >= 1 && name.length <= 64, s"Tool name must be 1..64 characters long, got ${name.length}: $name") - require(ToolNameRegex.matches(name), s"Tool name must match ${ToolNameRegex.regex}, got: $name") - PartialTool(name) - -// - -/** Describes a tool after the input is specified. */ -case class Tool[I]( - name: String, - description: Option[String], - inputSchema: Schema[I], - inputDecoder: Decoder[I], - annotations: Option[ToolAnnotations] -): - /** Combine the tool description with the server logic, that should be executed when the tool is invoked. The logic, given the input, - * should return either a tool execution error (`Left`), or a successful textual result (`Right`), using the F-effect. - */ - def serverLogic[F[_]](logic: (I, Seq[Header]) => F[Either[String, String]]): ServerTool[I, F] = - ServerTool(name, description, inputSchema, inputDecoder, annotations, logic) - - /** Combine the tool description with the server logic, that should be executed when the tool is invoked. The logic, given the input, - * should return either a tool execution error (`Left`), or a successful textual result (`Right`). - * - * Same as [[serverLogic]], but using the identity "effect". - */ - def handleWithHeaders(logic: (I, Seq[Header]) => Either[String, String]): ServerTool[I, Identity] = - ServerTool(name, description, inputSchema, inputDecoder, annotations, (i, t) => logic(i, t)) - - /** Combine the tool description with the server logic, that should be executed when the tool is invoked. The logic, given the input, - * should return either a tool execution error (`Left`), or a successful textual result (`Right`). - * - * Same as [[handleWithHeaders]], but using no headers. - */ - def handle(logic: I => Either[String, String]): ServerTool[I, Identity] = - handleWithHeaders((i, _) => logic(i)) - -/** A tool that can be executed by the MCP server. */ -case class ServerTool[I, F[_]]( - name: String, - description: Option[String], - inputSchema: Schema[I], - inputDecoder: Decoder[I], - annotations: Option[ToolAnnotations], - logic: (I, Seq[Header]) => F[Either[String, String]] -) diff --git a/server/src/main/scala/chimp/server/transport/ServerHttpTransport.scala b/server/src/main/scala/chimp/server/transport/ServerHttpTransport.scala new file mode 100644 index 0000000..1b91bda --- /dev/null +++ b/server/src/main/scala/chimp/server/transport/ServerHttpTransport.scala @@ -0,0 +1,41 @@ +package chimp.server.transport + +import chimp.server.* +import io.circe.Json +import sttp.model.{Header, HeaderNames, StatusCode} +import sttp.monad.MonadError +import sttp.monad.syntax.* +import sttp.tapir.* +import sttp.tapir.json.circe.* +import sttp.tapir.server.ServerEndpoint + +/** Implementation of unidirectional MCP server using Streamable HTTP. Responds to JSON-RPC messages from an MCP client with a single + * JSON-RPC message response. + * + * @param path + * The MCP endpoint path. + */ +final case class ServerHttpTransport[F[_]](path: List[String]) extends ServerTransport[F, ServerEndpoint[Any, F]]: + def serve(server: McpServer[F]): ServerEndpoint[Any, F] = + val handler = new McpHandler(server) + val endpoint = infallibleEndpoint.post + .in(path.foldLeft(emptyInput)((inputSoFar, pathComponent) => inputSoFar / pathComponent)) + .in(extractFromRequest(_.headers)) + .in(jsonBody[Json]) + .out(statusCode) + .out(jsonBody[Option[Json]]) + + ServerEndpoint.public( + endpoint, + me => { (input: (Seq[Header], Json)) => + val (headers, json) = input + given MonadError[F] = me + val host = headers.find(_.name.equalsIgnoreCase(HeaderNames.Host)).map(_.value) + val origin = headers.find(_.name.equalsIgnoreCase(HeaderNames.Origin)).map(_.value) + if !server.originCheck.validate(host, origin) then me.unit(Right((StatusCode.Forbidden, None))) + else + handler + .handleJsonRpc(json, headers) + .map(response => Right((response.statusCode, response.body))) + } + ) diff --git a/server/src/main/scala/chimp/server/transport/ServerStdioTransport.scala b/server/src/main/scala/chimp/server/transport/ServerStdioTransport.scala new file mode 100644 index 0000000..52cf63c --- /dev/null +++ b/server/src/main/scala/chimp/server/transport/ServerStdioTransport.scala @@ -0,0 +1,54 @@ +package chimp.server.transport + +import chimp.protocol.{JSONRPCMessage, ProgressToken} +import chimp.server.* +import io.circe.parser +import io.circe.syntax.* +import org.slf4j.LoggerFactory +import sttp.monad.{IdentityMonad, MonadError} +import sttp.shared.Identity + +import java.io.{BufferedReader, BufferedWriter, InputStream, InputStreamReader, OutputStream, OutputStreamWriter} +import java.nio.charset.StandardCharsets + +/** A synchronous implementation of MCP server using stdio transport. Exchanges line-delimited JSON-RPC messages over its standard input and + * output. + * + * @param in + * Server input stream. + * @param out + * Server output stream. + */ +final class ServerStdioTransport(in: InputStream = System.in, out: OutputStream = System.out) + extends ServerTransport[Identity, Unit] + with StreamingServerTransport[Identity, Unit]: + + private val log = LoggerFactory.getLogger(classOf[ServerStdioTransport]) + + def serve(server: McpServer[Identity]): Unit = serve(server.streaming) + + def serve(server: StreamingMcpServer[Identity]): Unit = + given MonadError[Identity] = IdentityMonad + val handler = new McpHandler[Identity, StreamingServerContext[Identity]](server) + val reader = BufferedReader(InputStreamReader(in, StandardCharsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(out, StandardCharsets.UTF_8)) + + def writeLine(json: io.circe.Json): Unit = + writer.synchronized: + writer.write(json.noSpaces) + writer.newLine() + writer.flush() + + val sink = new OutboundSink[Identity]: + def send(message: JSONRPCMessage): Identity[Unit] = writeLine(message.asJson.deepDropNullValues) + + val makeContext: Option[ProgressToken] => StreamingServerContext[Identity] = + token => SinkStreamingServerContext(sink, token) + + var line = reader.readLine() + while line != null do + if line.nonEmpty then + parser.parse(line) match + case Right(json) => handler.handleJsonRpc(json, Nil, makeContext).body.foreach(writeLine) + case Left(error) => log.warn(s"Failed to parse JSON-RPC line: ${error.getMessage}; raw: $line") + line = reader.readLine() diff --git a/server/src/main/scala/chimp/server/transport/ServerStreamingHttpTransport.scala b/server/src/main/scala/chimp/server/transport/ServerStreamingHttpTransport.scala new file mode 100644 index 0000000..4dae664 --- /dev/null +++ b/server/src/main/scala/chimp/server/transport/ServerStreamingHttpTransport.scala @@ -0,0 +1,54 @@ +package chimp.server.transport + +import chimp.protocol.ProgressToken +import chimp.server.* +import io.circe.Json +import sttp.capabilities.Streams +import sttp.model.{Header, HeaderNames, StatusCode} +import sttp.monad.MonadError +import sttp.monad.syntax.* +import sttp.tapir.* +import sttp.tapir.json.circe.* +import sttp.tapir.server.ServerEndpoint + +/** Abstract base for bidirectional MCP server using Streamable HTTP. Responds to JSON-RPC messages from an MCP client with a + * Server-Sent-Event stream. Messages in the stream are interleaved with the final response on that stream. + * + * The extra type parameter `S` carries the streaming capability evidence required by the Tapir [[sttp.tapir.server.ServerEndpoint]] to + * produce asynchronous stream of Server-Sent Events as response. + * + * @param path + * The MCP endpoint path. + */ +abstract class ServerStreamingHttpTransport[F[_], S](path: List[String]) extends StreamingServerTransport[F, ServerEndpoint[S, F]]: + val streams: Streams[S] + type EventStream + def sseBody: StreamBodyIO[streams.BinaryStream, EventStream, S] + def emptyStream: EventStream + def eventStream(handle: OutboundSink[F] => F[Option[Json]]): F[EventStream] + + final def serve(server: StreamingMcpServer[F]): ServerEndpoint[S, F] = + val handler = new McpHandler[F, StreamingServerContext[F]](server) + val endpoint = infallibleEndpoint.post + .in(path.foldLeft(emptyInput)((inputSoFar, pathComponent) => inputSoFar / pathComponent)) + .in(extractFromRequest(_.headers)) + .in(jsonBody[Json]) + .out(statusCode) + .out(sseBody) + + ServerEndpoint.public( + endpoint, + me => { (input: (Seq[Header], Json)) => + val (headers, json) = input + given MonadError[F] = me + val host = headers.find(_.name.equalsIgnoreCase(HeaderNames.Host)).map(_.value) + val origin = headers.find(_.name.equalsIgnoreCase(HeaderNames.Origin)).map(_.value) + if !server.originCheck.validate(host, origin) then me.unit(Right((StatusCode.Forbidden, emptyStream))) + else + eventStream { sink => + val makeContext: Option[ProgressToken] => StreamingServerContext[F] = + token => SinkStreamingServerContext(sink, token) + handler.handleJsonRpc(json, headers, makeContext).map(_.body) + }.map(events => Right((StatusCode.Ok, events))) + } + ) diff --git a/server/src/main/scala/chimp/server/transport/ServerStreamingStdioTransport.scala b/server/src/main/scala/chimp/server/transport/ServerStreamingStdioTransport.scala new file mode 100644 index 0000000..1bb076d --- /dev/null +++ b/server/src/main/scala/chimp/server/transport/ServerStreamingStdioTransport.scala @@ -0,0 +1,5 @@ +package chimp.server.transport + +/** Abstract base for streaming stdio MCP server transports that should consume the stdin as an asynchronous stream. + */ +abstract class ServerStreamingStdioTransport[F[_]] extends StreamingServerTransport[F, F[Unit]] diff --git a/server/src/main/scala/chimp/server/transport/ServerTransport.scala b/server/src/main/scala/chimp/server/transport/ServerTransport.scala new file mode 100644 index 0000000..175c085 --- /dev/null +++ b/server/src/main/scala/chimp/server/transport/ServerTransport.scala @@ -0,0 +1,15 @@ +package chimp.server.transport + +import chimp.server.{McpServer, StreamingMcpServer} + +/** A unidirectional MCP server transport. Binds a server definition producing the transport-specific medium - an endpoint for HTTP or + * runnable loop for stdio. + */ +trait ServerTransport[F[_], A]: + def serve(server: McpServer[F]): A + +/** A bidirectional MCP server transport that can push messages to the clients. Binds a server definition producing the transport-specific + * medium - an streamable endpoint for HTTP or runnable loop for stdio. + */ +trait StreamingServerTransport[F[_], A]: + def serve(server: StreamingMcpServer[F]): A diff --git a/server/src/test/scala/chimp/server/McpHandlerSpec.scala b/server/src/test/scala/chimp/server/McpHandlerSpec.scala index 330b652..5ceb8e2 100644 --- a/server/src/test/scala/chimp/server/McpHandlerSpec.scala +++ b/server/src/test/scala/chimp/server/McpHandlerSpec.scala @@ -16,61 +16,82 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: import JSONRPCMessage.* import chimp.protocol.JSONRPCErrorCodes.* - // Simple test input types case class EchoInput(message: String) derives Schema, Codec case class AddInput(a: Int, b: Int) derives Schema, Codec - // Test tools val echoTool = tool("echo") .description("Echoes the input message.") .input[EchoInput] - .handle(in => Right(in.message)) + .handle(in => ToolResult.text(in.message)) val addTool = tool("add") .description("Adds two numbers.") .input[AddInput] - .handle(in => Right((in.a + in.b).toString)) + .handle(in => ToolResult.text((in.a + in.b).toString)) val errorTool = tool("fail") .description("Always fails.") .input[EchoInput] - .handle(_ => Left("Intentional failure")) + .handle(_ => ToolResult.error("Intentional failure")) - // Tool that echoes the header's value for testing - case class HeaderEchoInput(dummy: String) derives Schema, Codec private val headerEchoTool = tool("headerEcho") .description("Echoes the header value if present.") - .input[HeaderEchoInput] + .input[EchoInput] .handleWithHeaders { (_, headers) => - if headers.isEmpty then Right("no header") + if headers.isEmpty then ToolResult.text("no header") else - Right( + ToolResult.text( headers .map(header => s"header name: ${header.name}, header value: ${header.value}") .mkString(", ") ) } - val handler = McpHandler(List(echoTool, addTool, errorTool, headerEchoTool), "Chimp MCP server", "1.0.0", true) + private val handler = McpHandler(McpServer(tools = List(echoTool, addTool, errorTool, headerEchoTool))) + + private val textResource = resource("test://text") + .name("text") + .mimeType("text/plain") + .handle(() => Right(List(ResourceContents.Text(uri = "test://text", text = "hello text", mimeType = Some("text/plain"))))) + + private val itemTemplate = resourceTemplate("test://item/{id}") + .name("item") + .handle((vars, uri) => Right(List(ResourceContents.Text(uri = uri, text = s"item ${vars("id")}")))) + + private val greetPrompt = prompt("greet") + .description("Greets by name") + .argument("name", required = true) + .handle(args => + GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = s"Hello ${args.getOrElse("name", "?")}")))) + ) + + private val levelRef = new java.util.concurrent.atomic.AtomicReference(Option.empty[LoggingLevel]) + + private val featuresServer = McpServer[Identity](name = "Features") + .addResource(textResource) + .addResourceTemplate(itemTemplate) + .addPrompt(greetPrompt) + .withCompletion((_, _, _) => Completion(values = List("Alice", "Bob"))) + .withLoggingLevel(level => levelRef.set(Some(level))) + + private val featuresHandler = McpHandler(featuresServer) def parseJson(str: String): Json = parse(str).getOrElse(throw new RuntimeException("Invalid JSON")) given MonadError[Identity] = IdentityMonad - // Helper function to extract JSON from McpResponse for testing private def extractJsonFromResponse(response: McpResponse): Json = response match case McpResponse.JsonResponse(json) => json case McpResponse.EmptyAcceptResponse => fail("Expected JsonResponse but got EmptyAcceptResponse") "McpHandler" should "respond to initialize" in: - // Given val req: JSONRPCMessage = Request(method = "initialize", id = RequestId("1")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) - // Then + resp match case Response(_, _, result) => val resultObj = result.as[InitializeResult].getOrElse(fail("Failed to decode result")) @@ -82,14 +103,13 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: respJson.hcursor.downField("result").downField("instructions").focus shouldBe None it should "list available tools" in: - // Given val req: JSONRPCMessage = Request(method = "tools/list", id = RequestId("2")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) - // Then + resp match case Response(_, _, result) => val resultObj = result.as[ListToolsResponse].getOrElse(fail("Failed to decode result")) @@ -97,18 +117,17 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Response") it should "call a tool successfully (echo)" in: - // Given val params = Json.obj( "name" -> Json.fromString("echo"), "arguments" -> Json.obj("message" -> Json.fromString("hello")) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("3")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) - // Then + resp match case Response(_, _, result) => val resultObj = result.as[CallToolResult].getOrElse(fail("Failed to decode result")) @@ -118,18 +137,17 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Response") it should "call a tool successfully (add)" in: - // Given val params = Json.obj( "name" -> Json.fromString("add"), "arguments" -> Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3)) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("4")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) - // Then + resp match case Response(_, _, result) => val resultObj = result.as[CallToolResult].getOrElse(fail("Failed to decode result")) @@ -138,38 +156,31 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Response") it should "accept notifications and return EmptyAcceptResponse" in: - // Given val req: JSONRPCMessage = Notification(method = "notifications/initialized") val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) - // Then - // Notifications should return EmptyAcceptResponse to indicate no body should be sent response shouldBe McpResponse.EmptyAcceptResponse it should "accept different notification types and return EmptyAcceptResponse" in: - // Given val req: JSONRPCMessage = Notification(method = "notifications/tools/list_changed") val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) - // Then - // All notifications should return EmptyAcceptResponse to indicate no body should be sent response shouldBe McpResponse.EmptyAcceptResponse it should "return an error for unknown tool" in: - // Given val params = Json.obj( "name" -> Json.fromString("unknown"), "arguments" -> Json.obj("foo" -> Json.fromString("bar")) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("5")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Expected error response")) - // Then + resp match case Error(_, _, error) => error.code shouldBe MethodNotFound.code @@ -177,18 +188,17 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Error") it should "return an error for invalid arguments" in: - // Given val params = Json.obj( "name" -> Json.fromString("add"), "arguments" -> Json.obj("a" -> Json.fromString("notAnInt"), "b" -> Json.fromInt(3)) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("6")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Expected error response")) - // Then + resp match case Error(_, _, error) => error.code shouldBe InvalidParams.code @@ -196,17 +206,16 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Error") it should "return an error when required fields are missing (no arguments object)" in: - // Given val params = Json.obj( "name" -> Json.fromString("add") ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("7")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Expected error response")) - // Then + resp match case Error(_, _, error) => error.code shouldBe InvalidParams.code @@ -214,18 +223,17 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Error") it should "return an error for missing tool name" in: - // Given val params = Json.obj( // missing 'name' "arguments" -> Json.obj("message" -> Json.fromString("hello")) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("8")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Expected error response")) - // Then + resp match case Error(_, _, error) => error.code shouldBe InvalidParams.code @@ -233,18 +241,17 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Error") it should "return an error for tool logic failure" in: - // Given val params = Json.obj( "name" -> Json.fromString("fail"), "arguments" -> Json.obj("message" -> Json.fromString("test")) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("9")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) - // Then + resp match case Response(_, _, result) => val resultObj = result.as[CallToolResult].getOrElse(fail("Failed to decode result")) @@ -253,14 +260,13 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Response") it should "return an error for unknown method" in: - // Given val req: JSONRPCMessage = Request(method = "not/a/real/method", id = RequestId("10")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Expected error response")) - // Then + resp match case Error(_, _, error) => error.code shouldBe MethodNotFound.code @@ -268,18 +274,17 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Error") it should "call a tool with a header and receive the header's value in the response" in: - // Given val params = Json.obj( "name" -> Json.fromString("headerEcho"), - "arguments" -> Json.obj("dummy" -> Json.fromString("irrelevant")) + "arguments" -> Json.obj("message" -> Json.fromString("irrelevant")) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("header1")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq(Header("header-name", "my-secret-header"))) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) - // Then + resp match case Response(_, _, result) => val resultObj = result.as[CallToolResult].getOrElse(fail("Failed to decode result")) @@ -288,10 +293,9 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Response") it should "call a tool with a header and receive multiple header's values in the response" in: - // Given val params = Json.obj( "name" -> Json.fromString("headerEcho"), - "arguments" -> Json.obj("dummy" -> Json.fromString("irrelevant")) + "arguments" -> Json.obj("message" -> Json.fromString("irrelevant")) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("header1")) val json = req.asJson @@ -312,18 +316,17 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Response") it should "call a tool without a header value and receive 'no header' in the response" in: - // Given val params = Json.obj( "name" -> Json.fromString("headerEcho"), - "arguments" -> Json.obj("dummy" -> Json.fromString("irrelevant")) + "arguments" -> Json.obj("message" -> Json.fromString("irrelevant")) ) val req: JSONRPCMessage = Request(method = "tools/call", params = Some(params), id = RequestId("header2")) val json = req.asJson - // When + val response = handler.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) - // Then + resp match case Response(_, _, result) => val resultObj = result.as[CallToolResult].getOrElse(fail("Failed to decode result")) @@ -332,22 +335,21 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: case _ => fail("Expected Response") it should "not use type arrays for optional fields in JSON schema" in: - // Given - a tool with optional fields case class OptionalFieldInput(requiredField: String, optionalField: Option[Long]) derives Schema, Codec val optionalTool = tool("optionalTest") .description("Test tool with optional fields.") .input[OptionalFieldInput] - .handle(_ => Right("ok")) + .handle(_ => ToolResult.text("ok")) - val handlerWithOptional = McpHandler(List(optionalTool), "Test", "1.0.0", true) + val handlerWithOptional = McpHandler(McpServer(tools = List(optionalTool))) val req: JSONRPCMessage = Request(method = "tools/list", id = RequestId("opt1")) val json = req.asJson - // When + val response = handlerWithOptional.handleJsonRpc(json, Seq.empty) val respJson = extractJsonFromResponse(response) val resp = respJson.as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) - // Then + resp match case Response(_, _, result) => val resultObj = result.as[ListToolsResponse].getOrElse(fail("Failed to decode result")) @@ -375,3 +377,92 @@ class McpHandlerSpec extends AnyFlatSpec with Matchers: requiredFields should contain("requiredField") requiredFields should not contain "optionalField" case _ => fail("Expected Response") + + private def featureResult(method: String, params: Option[Json], id: String): JSONRPCMessage = + val req: JSONRPCMessage = Request(method = method, params = params, id = RequestId(id)) + val response = featuresHandler.handleJsonRpc(req.asJson, Seq.empty) + extractJsonFromResponse(response).as[JSONRPCMessage].getOrElse(fail("Failed to decode response")) + + it should "list resources" in: + featureResult("resources/list", None, "r1") match + case Response(_, _, result) => + result.as[ListResourcesResult].getOrElse(fail("result")).resources.map(_.uri) shouldBe List("test://text") + case _ => fail("Expected Response") + + it should "read a static text resource" in: + val params = Json.obj("uri" -> Json.fromString("test://text")) + featureResult("resources/read", Some(params), "r2") match + case Response(_, _, result) => + result.as[ReadResourceResult].getOrElse(fail("result")).contents shouldBe + List(ResourceContents.Text(uri = "test://text", text = "hello text", mimeType = Some("text/plain"))) + case _ => fail("Expected Response") + + it should "read a templated resource, substituting variables" in: + val params = Json.obj("uri" -> Json.fromString("test://item/42")) + featureResult("resources/read", Some(params), "r3") match + case Response(_, _, result) => + val contents = result.as[ReadResourceResult].getOrElse(fail("result")).contents + contents.head match + case ResourceContents.Text(uri, text, _, _) => + uri shouldBe "test://item/42" + text should include("42") + case _ => fail("Expected text contents") + case _ => fail("Expected Response") + + it should "return a -32602 error with data.uri for an unknown resource (sep-2164)" in: + val params = Json.obj("uri" -> Json.fromString("test://missing")) + featureResult("resources/read", Some(params), "r4") match + case Error(_, _, error) => + error.code shouldBe InvalidParams.code + error.data.flatMap(_.hcursor.downField("uri").as[String].toOption) shouldBe Some("test://missing") + case _ => fail("Expected Error") + + it should "list prompts" in: + featureResult("prompts/list", None, "p1") match + case Response(_, _, result) => + result.as[ListPromptsResult].getOrElse(fail("result")).prompts.map(_.name) shouldBe List("greet") + case _ => fail("Expected Response") + + it should "get a prompt, substituting arguments" in: + val params = Json.obj("name" -> Json.fromString("greet"), "arguments" -> Json.obj("name" -> Json.fromString("World"))) + featureResult("prompts/get", Some(params), "p2") match + case Response(_, _, result) => + result.as[GetPromptResult].getOrElse(fail("result")).messages.head.content match + case ToolContent.Text(_, text) => text should include("World") + case _ => fail("Expected text content") + case _ => fail("Expected Response") + + it should "return completion values" in: + val params = Json.obj( + "ref" -> Json.obj("type" -> Json.fromString("ref/prompt"), "name" -> Json.fromString("greet")), + "argument" -> Json.obj("name" -> Json.fromString("name"), "value" -> Json.fromString("A")) + ) + featureResult("completion/complete", Some(params), "c1") match + case Response(_, _, result) => + result.as[CompleteResult].getOrElse(fail("result")).completion.values shouldBe List("Alice", "Bob") + case _ => fail("Expected Response") + + it should "set the logging level and return an empty result" in: + val params = Json.obj("level" -> Json.fromString("info")) + featureResult("logging/setLevel", Some(params), "l1") match + case Response(_, _, result) => result shouldBe Json.obj() + case _ => fail("Expected Response") + levelRef.get() shouldBe Some(LoggingLevel.Info) + + it should "advertise capabilities derived from registered features" in: + featureResult("initialize", None, "i1") match + case Response(_, _, result) => + val caps = result.as[InitializeResult].getOrElse(fail("result")).capabilities + caps.resources.flatMap(_.subscribe) shouldBe Some(false) + caps.prompts.isDefined shouldBe true + caps.completions.isDefined shouldBe true + caps.logging.isDefined shouldBe true + caps.tools shouldBe None + case _ => fail("Expected Response") + + it should "return MethodNotFound for a feature method that is not configured" in: + // featuresServer has no subscriptions handler, so resources/subscribe is not available + val params = Json.obj("uri" -> Json.fromString("test://text")) + featureResult("resources/subscribe", Some(params), "n1") match + case Error(_, _, error) => error.code shouldBe MethodNotFound.code + case _ => fail("Expected Error") diff --git a/server/src/test/scala/chimp/server/McpServerStreamingTests.scala b/server/src/test/scala/chimp/server/McpServerStreamingTests.scala new file mode 100644 index 0000000..da568d6 --- /dev/null +++ b/server/src/test/scala/chimp/server/McpServerStreamingTests.scala @@ -0,0 +1,52 @@ +package chimp.server + +import chimp.client.BidirectionalMcpClient +import chimp.client.notifications.ServerNotification +import chimp.protocol.* +import io.circe.{Codec, Json} +import org.scalatest.Assertion +import org.scalatest.flatspec.AsyncFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.monad.syntax.* +import sttp.tapir.Schema + +import java.util.concurrent.ConcurrentLinkedQueue +import scala.concurrent.Future +import scala.jdk.CollectionConverters.* + +trait McpServerStreamingTests[F[_]] extends AsyncFlatSpec with Matchers: + this: ToFuture[F] => + + protected def withStreamingServer(server: StreamingMcpServer[F])(test: BidirectionalMcpClient[F] => F[Assertion]): Future[Assertion] + + private case class NoInput() derives Codec, Schema + + protected def streamingServer: StreamingMcpServer[F] = + StreamingMcpServer[F]() + .withLoggingLevel(_ => monad.unit(())) + .addStreamingTool( + tool("noisy") + .description("Logs several messages, then returns") + .input[NoInput] + .streamingServerLogic[F]: (_, ctx, _) => + for + _ <- ctx.log(LoggingLevel.Info, Json.fromString("one")) + _ <- ctx.log(LoggingLevel.Info, Json.fromString("two")) + _ <- ctx.log(LoggingLevel.Info, Json.fromString("three")) + yield ToolResult.text("done") + ) + + "a streaming MCP server" should "deliver log notifications emitted during a tool call" in + withStreamingServer(streamingServer): client => + val messages = ConcurrentLinkedQueue[Json]() + val listener: ServerNotification => F[Unit] = { + case ServerNotification.LoggingMessage(params) => messages.add(params.data); monad.unit(()) + case _ => monad.unit(()) + } + client + .onServerNotification(notification => listener(notification)) + .flatMap(_ => client.callTool("noisy", Json.obj())) + .flatMap: result => + waitUntil(messages.size >= 3).map: _ => + result.content shouldBe List(ToolContent.Text("text", "done")) + messages.asScala.toList shouldBe List(Json.fromString("one"), Json.fromString("two"), Json.fromString("three")) diff --git a/server/src/test/scala/chimp/server/McpServerTests.scala b/server/src/test/scala/chimp/server/McpServerTests.scala new file mode 100644 index 0000000..58ef073 --- /dev/null +++ b/server/src/test/scala/chimp/server/McpServerTests.scala @@ -0,0 +1,84 @@ +package chimp.server + +import chimp.client.McpClient +import chimp.protocol.* +import io.circe.{Codec, Json} +import org.scalatest.Assertion +import org.scalatest.flatspec.AsyncFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.monad.syntax.* +import sttp.tapir.Schema + +import scala.concurrent.Future + +trait McpServerTests[F[_]] extends AsyncFlatSpec with Matchers: + this: ToFuture[F] => + + protected def withServer(server: McpServer[F])(test: McpClient[F] => F[Assertion]): Future[Assertion] + + private case class EchoInput(message: String) derives Codec, Schema + + protected def sampleServer: McpServer[F] = + McpServer[F]() + .addTool( + tool("echo") + .description("Echoes a message") + .input[EchoInput] + .serverLogic[F]((in, _) => monad.unit(ToolResult.text(in.message))) + ) + .addResource( + resource("test://greeting") + .mimeType("text/plain") + .serverLogic[F](_ => + monad.unit(Right(List(ResourceContents.Text(uri = "test://greeting", text = "hello", mimeType = Some("text/plain"))))) + ) + ) + .addPrompt( + prompt("greet") + .argument("name", required = true) + .serverLogic[F]((args, _) => + monad.unit( + GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = s"Hi ${args.getOrElse("name", "?")}")))) + ) + ) + ) + .withCompletion((_, _, _) => monad.unit(Completion(values = List("alpha", "beta")))) + + "an MCP server" should "list its tools" in withServer(sampleServer): client => + client.listTools().map(_.tools.map(_.name) should contain("echo")) + + it should "advertise capabilities for the registered features" in withServer(sampleServer): client => + monad.unit: + client.serverCapabilities.tools shouldBe defined + client.serverCapabilities.resources shouldBe defined + client.serverCapabilities.prompts shouldBe defined + client.serverCapabilities.completions shouldBe defined + + it should "execute a tool call" in withServer(sampleServer): client => + client + .callTool("echo", Json.obj("message" -> Json.fromString("hi"))) + .map: result => + result.isError shouldBe false + result.content shouldBe List(ToolContent.Text("text", "hi")) + + it should "read a resource" in withServer(sampleServer): client => + client + .readResource("test://greeting") + .map: result => + result.contents.head match + case ResourceContents.Text(_, text, _, _) => text shouldBe "hello" + case other => fail(s"expected text contents, got $other") + + it should "get a prompt with arguments" in withServer(sampleServer): client => + client + .getPrompt("greet", Map("name" -> "World")) + .map: result => + result.messages.head.content match + case ToolContent.Text(_, text) => text should include("World") + case other => fail(s"expected text content, got $other") + + it should "return completion suggestions" in withServer(sampleServer): client => + client + .complete(CompleteRef.Prompt(PromptReference(name = "greet")), CompleteArgument("name", "W")) + .map: result => + result.completion.values shouldBe List("alpha", "beta") diff --git a/server/src/test/scala/chimp/server/OriginCheckSpec.scala b/server/src/test/scala/chimp/server/OriginCheckSpec.scala new file mode 100644 index 0000000..70baf04 --- /dev/null +++ b/server/src/test/scala/chimp/server/OriginCheckSpec.scala @@ -0,0 +1,28 @@ +package chimp.server + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class OriginCheckSpec extends AnyFlatSpec with Matchers: + private val check = OriginCheck.localhostOnly + + "OriginCheck.localhostOnly" should "allow localhost Host and Origin" in: + check.validate(Some("localhost:8080"), Some("http://localhost:8080")) shouldBe true + + it should "allow 127.0.0.1 with a port" in: + check.validate(Some("127.0.0.1:8080"), None) shouldBe true + + it should "allow a bracketed IPv6 loopback Host and Origin" in: + check.validate(Some("[::1]:8080"), Some("http://[::1]:8080")) shouldBe true + + it should "allow requests with no Host or Origin" in: + check.validate(None, None) shouldBe true + + it should "reject a non-localhost Host" in: + check.validate(Some("evil.example.com"), None) shouldBe false + + it should "reject a non-localhost Origin even when Host is localhost" in: + check.validate(Some("localhost:8080"), Some("http://evil.example.com")) shouldBe false + + "A disabled OriginCheck" should "allow any host" in: + OriginCheck.disabled.validate(Some("evil.example.com"), Some("http://evil.example.com")) shouldBe true diff --git a/server/src/test/scala/chimp/server/ServerStdioTransportSpec.scala b/server/src/test/scala/chimp/server/ServerStdioTransportSpec.scala new file mode 100644 index 0000000..df50b51 --- /dev/null +++ b/server/src/test/scala/chimp/server/ServerStdioTransportSpec.scala @@ -0,0 +1,12 @@ +package chimp.server + +import chimp.server.transport.ServerStdioTransport +import sttp.shared.Identity + +import java.io.{InputStream, OutputStream} + +class ServerStdioTransportSpec extends ServerStdioTransportTests[Identity] with SyncToFuture: + override protected def runStdioServer(server: StreamingMcpServer[Identity], in: InputStream, out: OutputStream): Unit = + val thread = Thread(() => ServerStdioTransport(in, out).serve(server)) + thread.setDaemon(true) + thread.start() diff --git a/server/src/test/scala/chimp/server/ServerStdioTransportTests.scala b/server/src/test/scala/chimp/server/ServerStdioTransportTests.scala new file mode 100644 index 0000000..b7894a5 --- /dev/null +++ b/server/src/test/scala/chimp/server/ServerStdioTransportTests.scala @@ -0,0 +1,98 @@ +package chimp.server + +import chimp.protocol.{JSONRPCErrorCodes, LoggingLevel} +import io.circe.{parser, Codec, Json} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.monad.syntax.* +import sttp.tapir.Schema + +import java.io.{ + BufferedReader, + BufferedWriter, + InputStream, + InputStreamReader, + OutputStream, + OutputStreamWriter, + PipedInputStream, + PipedOutputStream +} +import java.nio.charset.StandardCharsets + +trait ServerStdioTransportTests[F[_]] extends AnyFlatSpec with Matchers: + this: ToFuture[F] => + + protected def runStdioServer(server: StreamingMcpServer[F], in: InputStream, out: OutputStream): Unit + + private case class EchoInput(message: String) derives Codec, Schema + private case class NoInput() derives Codec, Schema + + private def server: StreamingMcpServer[F] = + StreamingMcpServer[F]() + .withLoggingLevel(_ => monad.unit(())) + .addTool(tool("echo").input[EchoInput].serverLogic[F]((in, _) => monad.unit(ToolResult.text(in.message)))) + .addStreamingTool( + tool("noisy") + .input[NoInput] + .streamingServerLogic[F] { (_, ctx, _) => + for + _ <- ctx.log(LoggingLevel.Info, Json.fromString("one")) + _ <- ctx.log(LoggingLevel.Info, Json.fromString("two")) + _ <- ctx.log(LoggingLevel.Info, Json.fromString("three")) + yield ToolResult.text("done") + } + ) + + private def withStdioServer[A](body: (String => Unit, () => Json) => A): A = + val toServer = PipedOutputStream() + val serverIn = PipedInputStream(toServer) + val fromServer = PipedInputStream() + val serverOut = PipedOutputStream(fromServer) + + runStdioServer(server, serverIn, serverOut) + + val writer = BufferedWriter(OutputStreamWriter(toServer, StandardCharsets.UTF_8)) + val reader = BufferedReader(InputStreamReader(fromServer, StandardCharsets.UTF_8)) + + def send(line: String): Unit = + writer.write(line) + writer.newLine() + writer.flush() + + def readResponse(): Json = parser.parse(reader.readLine()).toOption.get + + try body(send, readResponse) + finally writer.close() + + "a stdio server" should "answer requests and stream notifications over stdin/stdout" in withStdioServer { (send, readResponse) => + send( + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}""" + ) + val init = readResponse() + init.hcursor.downField("id").as[Int] shouldBe Right(1) + init.hcursor.downField("result").downField("serverInfo").downField("name").as[String].isRight shouldBe true + + send("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hi"}}}""") + val echo = readResponse() + echo.hcursor.downField("result").downField("content").downN(0).downField("text").as[String] shouldBe Right("hi") + + send("""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"noisy","arguments":{}}}""") + val notifications = List(readResponse(), readResponse(), readResponse()) + notifications.map(_.hcursor.downField("method").as[String]) shouldBe List.fill(3)(Right("notifications/message")) + notifications.flatMap(_.hcursor.downField("params").downField("data").as[String].toOption) shouldBe List("one", "two", "three") + + val response = readResponse() + response.hcursor.downField("id").as[Int] shouldBe Right(3) + response.hcursor.downField("result").downField("content").downN(0).downField("text").as[String] shouldBe Right("done") + } + + it should "skip notifications and malformed lines, and still report protocol errors" in withStdioServer { (send, readResponse) => + send("""{"jsonrpc":"2.0","method":"notifications/initialized"}""") + send("this is not valid json") + send("""{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"missing","arguments":{}}}""") + + val error = readResponse() + error.hcursor.downField("id").as[Int] shouldBe Right(9) + error.hcursor.downField("error").downField("code").as[Int] shouldBe Right(JSONRPCErrorCodes.MethodNotFound.code) + error.hcursor.downField("error").downField("message").as[String].toOption.get should include("missing") + } diff --git a/server/src/test/scala/chimp/server/SyncHttpMcpServerSpec.scala b/server/src/test/scala/chimp/server/SyncHttpMcpServerSpec.scala new file mode 100644 index 0000000..8238a81 --- /dev/null +++ b/server/src/test/scala/chimp/server/SyncHttpMcpServerSpec.scala @@ -0,0 +1,28 @@ +package chimp.server + +import chimp.client.McpClient +import chimp.client.transport.ClientHttpTransport +import chimp.protocol.Implementation +import org.scalatest.Assertion +import ox.supervised +import sttp.client4.* +import sttp.shared.Identity +import sttp.tapir.server.netty.sync.NettySyncServer + +import scala.concurrent.Future + +class SyncHttpMcpServerSpec extends McpServerTests[Identity] with SyncToFuture: + private val clientInfo = Implementation("chimp-server-test", "0.0.1") + + override protected def withServer(server: McpServer[Identity])(test: McpClient[Identity] => Identity[Assertion]): Future[Assertion] = + toFuture: + supervised: + val binding = NettySyncServer().port(0).addEndpoint(server.endpoint(List("mcp"))).start() + try + val backend = DefaultSyncBackend() + try + val transport = ClientHttpTransport[Identity](backend, uri"http://localhost:${binding.port}/mcp") + try test(McpClient(transport, clientInfo)) + finally transport.close() + finally backend.close() + finally binding.stop() diff --git a/server/src/test/scala/chimp/server/SyncToFuture.scala b/server/src/test/scala/chimp/server/SyncToFuture.scala new file mode 100644 index 0000000..7f09677 --- /dev/null +++ b/server/src/test/scala/chimp/server/SyncToFuture.scala @@ -0,0 +1,11 @@ +package chimp.server + +import sttp.monad.{IdentityMonad, MonadError} +import sttp.shared.Identity + +import scala.concurrent.Future + +trait SyncToFuture extends ToFuture[Identity]: + override given monad: MonadError[Identity] = IdentityMonad + override def toFuture[A](fa: Identity[A]): Future[A] = Future.successful(fa) + override def sleep(millis: Long): Identity[Unit] = Thread.sleep(millis) diff --git a/server/src/test/scala/chimp/server/ToFuture.scala b/server/src/test/scala/chimp/server/ToFuture.scala new file mode 100644 index 0000000..6faec17 --- /dev/null +++ b/server/src/test/scala/chimp/server/ToFuture.scala @@ -0,0 +1,15 @@ +package chimp.server + +import sttp.monad.MonadError +import sttp.monad.syntax.* + +import scala.concurrent.Future + +trait ToFuture[F[_]]: + given monad: MonadError[F] + def toFuture[A](fa: F[A]): Future[A] + def sleep(millis: Long): F[Unit] + + def waitUntil(condition: => Boolean, attempts: Int = 100, intervalMs: Long = 20): F[Unit] = + if condition || attempts <= 0 then monad.unit(()) + else sleep(intervalMs).flatMap(_ => waitUntil(condition, attempts - 1, intervalMs))