Skip to content

Commit aeed625

Browse files
authored
Enrich server implementation (#171)
* feat: rich tool result set and server context * feat: remove old mcp endpoint creation * feat: DNS rebinding prevention, tests * feat: server streaming abstractions and first implementation for ZIO * refactor: general refactoring and renames * refactor: clear close semantics for zio server streaming * refactor: rename * feat: very basic scala doc for public API, inline logic handlers across server methods * feat: introduce stdio transport to server, use similar abstraction as for the client * refactor: simpler comment * refactor: comments * format * feat: harness stdio server tests * renaming * renaming * stdio implementation for zio server * feat: documentation updates * remove comments * simplify server context * feat: simplify server context * feat: resource not found instead of invalid params * fix: scoped event stream * fix: update readme
1 parent 2034ae9 commit aeed625

97 files changed

Lines changed: 2555 additions & 747 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
An SDK for building [MCP](https://modelcontextprotocol.io/specification) (Model Context Protocol) servers and
77
clients in Scala 3 using boilerplate-less, type-safe APIs based on [Tapir](https://tapir.softwaremill.com/)
8-
and [sttp](https://github.com/softwaremill/sttp), supporting the variety of the Scala ecosystem.
8+
and [sttp](https://github.com/softwaremill/sttp), supporting the variety of the Scala ecosystem. Both servers
9+
and clients can communicate over **Streamable HTTP** or **stdio**.
910

1011
### Quickstart
1112

@@ -24,9 +25,9 @@ case class AdderInput(a: Int, b: Int) derives Codec, Schema
2425

2526
@main def server(): Unit =
2627
val adder = tool("adder").description("Adds two numbers").input[AdderInput]
27-
.handle(i => Right(s"Result: ${i.a + i.b}"))
28+
.handle(i => ToolResult.text(s"Result: ${i.a + i.b}"))
2829

29-
NettySyncServer().port(8080).addEndpoint(mcpEndpoint(List(adder), List("mcp"))).startAndWait()
30+
NettySyncServer().port(8080).addEndpoint(McpServer(tools = List(adder)).endpoint(List("mcp"))).startAndWait()
3031
```
3132

3233
Connect and invoke the tool as an MCP client:
@@ -36,14 +37,14 @@ Connect and invoke the tool as an MCP client:
3637
//> using dep com.softwaremill.sttp.client4::core:4.0.24
3738

3839
import chimp.client.*
39-
import chimp.client.transport.HttpTransport
40+
import chimp.client.transport.ClientHttpTransport
4041
import chimp.protocol.*
4142
import sttp.client4.*
4243
import io.circe.Json
4344

4445
@main def client(): Unit =
4546
val backend = DefaultSyncBackend()
46-
val transport = HttpTransport(backend, uri"http://localhost:8080/mcp")
47+
val transport = ClientHttpTransport(backend, uri"http://localhost:8080/mcp")
4748
val client = McpClient(transport, Implementation("my-client", "0.1.0"))
4849

4950
val result = client.callTool("adder", Json.obj("a" -> Json.fromInt(2), "b" -> Json.fromInt(3)))

build.sbt

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ val tapirV = "1.13.26"
1010
val sttpClientV = "4.0.26"
1111
val zioV = "2.1.26"
1212
val zioProcessV = "0.8.0"
13+
val zioHttpV = "3.8.0"
1314
val testcontainersScalaV = "0.41.8"
1415

1516
lazy val verifyExamplesCompileUsingScalaCli = taskKey[Unit]("Verify that each example compiles using Scala CLI")
@@ -35,7 +36,7 @@ val scalaTest = "org.scalatest" %% "scalatest" % scalaTestV % Test
3536
lazy val root = (project in file("."))
3637
.settings(commonSettings: _*)
3738
.settings(publishArtifact := false, name := "chimp")
38-
.aggregate(core, server, client, clientZio, examples, serverConformance, clientConformance)
39+
.aggregate(core, server, serverZio, client, clientZio, examples, serverConformance, clientConformance)
3940

4041
val conformance = inputKey[Unit]("Run the MCP conformance harness via npx, extra args are passed through")
4142

@@ -62,10 +63,27 @@ lazy val server: Project = (project in file("server"))
6263
"com.softwaremill.sttp.tapir" %% "tapir-core" % tapirV,
6364
"com.softwaremill.sttp.tapir" %% "tapir-json-circe" % tapirV,
6465
"com.softwaremill.sttp.tapir" %% "tapir-apispec-docs" % tapirV,
65-
"com.softwaremill.sttp.apispec" %% "jsonschema-circe" % "0.11.10"
66+
"com.softwaremill.sttp.apispec" %% "jsonschema-circe" % "0.11.10",
67+
"com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % tapirV % Test,
68+
"com.softwaremill.sttp.client4" %% "core" % sttpClientV % Test
6669
)
6770
)
68-
.dependsOn(core)
71+
.dependsOn(core, client % "test->compile")
72+
73+
lazy val serverZio: Project = (project in file("server-streaming/server-zio"))
74+
.settings(commonSettings: _*)
75+
.settings(
76+
name := "chimp-server-zio",
77+
libraryDependencies ++= Seq(
78+
scalaTest,
79+
"dev.zio" %% "zio" % zioV,
80+
"dev.zio" %% "zio-streams" % zioV,
81+
"com.softwaremill.sttp.tapir" %% "tapir-zio" % tapirV,
82+
"com.softwaremill.sttp.tapir" %% "tapir-zio-http-server" % tapirV,
83+
"dev.zio" %% "zio-http" % zioHttpV
84+
)
85+
)
86+
.dependsOn(server % "compile->compile;test->test", clientZio % "test->compile")
6987

7088
lazy val client: Project = (project in file("client"))
7189
.settings(commonSettings: _*)
@@ -244,7 +262,8 @@ lazy val docs: Project = (project in file("generated-docs"))
244262
),
245263
mdocOut := file("generated-docs/out"),
246264
mdocExtraArguments := Seq("--clean-target", "--exclude", ".venv", "--exclude", "_build"),
265+
libraryDependencies += "com.softwaremill.sttp.tapir" %% "tapir-netty-server-sync" % tapirV,
247266
publishArtifact := false,
248267
name := "docs"
249268
)
250-
.dependsOn(core, server, client, clientZio)
269+
.dependsOn(core, server, serverZio, client, clientZio)

client-conformance/src/main/scala/chimp/conformance/client/Main.scala

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

33
import chimp.client.McpClient
4-
import chimp.client.transport.HttpTransport
4+
import chimp.client.transport.ClientHttpTransport
55
import chimp.protocol.*
66
import io.circe.Json
77
import sttp.client4.DefaultSyncBackend
@@ -28,7 +28,7 @@ object Main:
2828
.getOrElse(ProtocolVersion.Latest)
2929

3030
val backend = DefaultSyncBackend()
31-
val transport = HttpTransport[Identity](backend, serverUrl, protocolVersion)
31+
val transport = ClientHttpTransport[Identity](backend, serverUrl, protocolVersion)
3232

3333
val rc: Int =
3434
try

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

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

3-
import chimp.client.transport.HttpTransport.HttpOutcome
4-
import chimp.client.transport.{HttpTransport, StreamingHttpTransport, Transport}
3+
import chimp.client.transport.ClientHttpTransport.HttpOutcome
4+
import chimp.client.transport.{ClientHttpTransport, ClientStreamingHttpTransport, ClientTransport}
55
import chimp.client.{McpProtocolException, McpSessionNotFoundException}
66
import chimp.protocol.{JSONRPCErrorCodes, JSONRPCErrorObject, JSONRPCMessage, ProtocolVersion, RequestId}
77
import org.slf4j.LoggerFactory
@@ -15,7 +15,7 @@ import zio.{Duration, Exit, Promise, Ref, Schedule, Scope, Task, ZIO, ZLayer}
1515

1616
import scala.concurrent.duration.FiniteDuration
1717

18-
final class ZioStreamingHttpTransport private (
18+
final class ZioClientHttpTransport private (
1919
backend: StreamBackend[Task, ZioStreams],
2020
uri: Uri,
2121
protocolVersion: ProtocolVersion,
@@ -28,9 +28,9 @@ final class ZioStreamingHttpTransport private (
2828
incomingRef: Ref[JSONRPCMessage => Task[Unit]],
2929
lastEventId: Ref[Option[String]],
3030
closingRef: Ref[Boolean]
31-
) extends StreamingHttpTransport[Task, ZioStreams](backend, uri, ZioStreams):
31+
) extends ClientStreamingHttpTransport[Task, ZioStreams](backend, uri, ZioStreams):
3232

33-
private val log = LoggerFactory.getLogger(classOf[ZioStreamingHttpTransport])
33+
private val log = LoggerFactory.getLogger(classOf[ZioClientHttpTransport])
3434

3535
override given monad: MonadError[Task] = backend.monad
3636

@@ -53,7 +53,7 @@ final class ZioStreamingHttpTransport private (
5353
.getAndSet(None)
5454
.flatMap:
5555
case Some(id) =>
56-
HttpTransport
56+
ClientHttpTransport
5757
.baseDeleteRequest(uri, protocolVersion, id)
5858
.response(asStreamUnsafe(ZioStreams))
5959
.send(backend)
@@ -66,7 +66,7 @@ final class ZioStreamingHttpTransport private (
6666
post(request).flatMap: resp =>
6767
captureSession(resp) *>
6868
sessionRef.get.flatMap: session =>
69-
HttpTransport.resolveResponse(resp, session) match
69+
ClientHttpTransport.resolveResponse(resp, session) match
7070
case Left(err: McpSessionNotFoundException) =>
7171
sessionRef.set(None) *> ZIO.fail(err)
7272
case Left(err) =>
@@ -87,7 +87,7 @@ final class ZioStreamingHttpTransport private (
8787
post(msg).flatMap: response =>
8888
captureSession(response) *>
8989
sessionRef.get.flatMap: session =>
90-
HttpTransport.resolveResponse(response, session) match
90+
ClientHttpTransport.resolveResponse(response, session) match
9191
case Left(err: McpSessionNotFoundException) =>
9292
sessionRef.set(None) *> ZIO.fail(err)
9393
case Left(err) =>
@@ -101,8 +101,8 @@ final class ZioStreamingHttpTransport private (
101101

102102
private def post(msg: JSONRPCMessage): Task[Response[Either[String, Stream[Throwable, Byte]]]] =
103103
sessionRef.get.flatMap: session =>
104-
HttpTransport
105-
.basePostRequest(uri, protocolVersion, session, Transport.encode(msg))
104+
ClientHttpTransport
105+
.basePostRequest(uri, protocolVersion, session, ClientTransport.encode(msg))
106106
.response(asStreamUnsafe(ZioStreams))
107107
.send(backend)
108108

@@ -123,7 +123,7 @@ final class ZioStreamingHttpTransport private (
123123
case Right(stream) => stream.runDrain.ignore
124124

125125
private def decode(body: String): Task[JSONRPCMessage] =
126-
Transport.decode(body) match
126+
ClientTransport.decode(body) match
127127
case Right(msg) => ZIO.succeed(msg)
128128
case Left(err) => ZIO.fail(McpProtocolException(s"Failed to decode response body: ${err.getMessage}, payload $body"))
129129

@@ -164,7 +164,7 @@ final class ZioStreamingHttpTransport private (
164164
private def dispatch(event: ServerSentEvent): Task[Unit] =
165165
event.data match
166166
case Some(data) if data.nonEmpty =>
167-
Transport.decode(data) match
167+
ClientTransport.decode(data) match
168168
case Right(msg) => routeMessage(msg)
169169
case Left(_) => ZIO.unit
170170
case _ => ZIO.unit
@@ -257,7 +257,7 @@ final class ZioStreamingHttpTransport private (
257257
error = JSONRPCErrorObject(code = JSONRPCErrorCodes.InvocationError.code, message = "SSE stream ended before response")
258258
)
259259

260-
object ZioStreamingHttpTransport:
260+
object ZioClientHttpTransport:
261261

262262
val defaultReconnectSchedule: Schedule[Any, Any, Any] =
263263
Schedule.exponential(Duration.fromMillis(100)).jittered || Schedule.spaced(Duration.fromSeconds(30))
@@ -266,9 +266,9 @@ object ZioStreamingHttpTransport:
266266
backend: StreamBackend[Task, ZioStreams],
267267
uri: Uri,
268268
protocolVersion: ProtocolVersion = ProtocolVersion.Latest,
269-
timeout: FiniteDuration = Transport.defaultTimeout,
269+
timeout: FiniteDuration = ClientTransport.defaultTimeout,
270270
reconnectSchedule: Schedule[Any, Any, Any] = defaultReconnectSchedule
271-
): Task[ZioStreamingHttpTransport] =
271+
): Task[ZioClientHttpTransport] =
272272
for
273273
scope <- Scope.make
274274
sessionRef <- Ref.make(Option.empty[String])
@@ -277,7 +277,7 @@ object ZioStreamingHttpTransport:
277277
incomingRef <- Ref.make[JSONRPCMessage => Task[Unit]](_ => ZIO.unit)
278278
lastEventId <- Ref.make(Option.empty[String])
279279
closingRef <- Ref.make(false)
280-
transport = new ZioStreamingHttpTransport(
280+
transport = new ZioClientHttpTransport(
281281
backend,
282282
uri,
283283
protocolVersion,
@@ -298,16 +298,16 @@ object ZioStreamingHttpTransport:
298298
backend: StreamBackend[Task, ZioStreams],
299299
uri: Uri,
300300
protocolVersion: ProtocolVersion = ProtocolVersion.Latest,
301-
timeout: FiniteDuration = Transport.defaultTimeout,
301+
timeout: FiniteDuration = ClientTransport.defaultTimeout,
302302
reconnectSchedule: Schedule[Any, Any, Any] = defaultReconnectSchedule
303-
): ZIO[Scope, Throwable, ZioStreamingHttpTransport] =
303+
): ZIO[Scope, Throwable, ZioClientHttpTransport] =
304304
ZIO.acquireRelease(apply(backend, uri, protocolVersion, timeout, reconnectSchedule))(_.close().ignore)
305305

306306
def layer(
307307
backend: StreamBackend[Task, ZioStreams],
308308
uri: Uri,
309309
protocolVersion: ProtocolVersion = ProtocolVersion.Latest,
310-
timeout: FiniteDuration = Transport.defaultTimeout,
310+
timeout: FiniteDuration = ClientTransport.defaultTimeout,
311311
reconnectSchedule: Schedule[Any, Any, Any] = defaultReconnectSchedule
312-
): ZLayer[Any, Throwable, ZioStreamingHttpTransport] =
312+
): ZLayer[Any, Throwable, ZioClientHttpTransport] =
313313
ZLayer.scoped(scoped(backend, uri, protocolVersion, timeout, reconnectSchedule))

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

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

3-
import chimp.client.transport.{StreamingStdioTransport, Transport}
3+
import chimp.client.transport.{ClientStreamingStdioTransport, ClientTransport}
44
import chimp.protocol.JSONRPCMessage
55
import org.slf4j.LoggerFactory
66
import sttp.client4.impl.zio.RIOMonadAsyncError
@@ -13,7 +13,7 @@ import java.io.File
1313
import java.nio.charset.StandardCharsets
1414
import scala.concurrent.duration.FiniteDuration
1515

16-
final class ZioStreamingStdioTransport private (
16+
final class ZioClientStdioTransport private (
1717
command: List[String],
1818
env: Map[String, String],
1919
workDir: Option[File],
@@ -23,9 +23,9 @@ final class ZioStreamingStdioTransport private (
2323
writeQueue: Queue[JSONRPCMessage],
2424
pending: ZioPendingRequests,
2525
incomingRef: Ref[JSONRPCMessage => Task[Unit]]
26-
) extends StreamingStdioTransport[Task](command, env, workDir):
26+
) extends ClientStreamingStdioTransport[Task](command, env, workDir):
2727

28-
private val log = LoggerFactory.getLogger(classOf[ZioStreamingStdioTransport])
28+
private val log = LoggerFactory.getLogger(classOf[ZioClientStdioTransport])
2929

3030
override given monad: MonadError[Task] = new RIOMonadAsyncError[Any]
3131

@@ -54,7 +54,7 @@ final class ZioStreamingStdioTransport private (
5454
val drain = process.stdout.linesStream
5555
.filter(_.nonEmpty)
5656
.mapZIO: line =>
57-
Transport.decode(line) match
57+
ClientTransport.decode(line) match
5858
case Right(msg) => dispatch(msg)
5959
case Left(err) => ZIO.succeed(log.warn(s"Failed to parse JSON-RPC line: ${err.getMessage}, raw: $line"))
6060
.runDrain
@@ -68,29 +68,29 @@ final class ZioStreamingStdioTransport private (
6868
.forkIn(scope)
6969
.unit
7070

71-
object ZioStreamingStdioTransport:
71+
object ZioClientStdioTransport:
7272

7373
def apply(
7474
command: List[String],
7575
env: Map[String, String] = Map.empty,
7676
workDir: Option[File] = None,
77-
timeout: FiniteDuration = Transport.defaultTimeout
78-
): Task[ZioStreamingStdioTransport] =
77+
timeout: FiniteDuration = ClientTransport.defaultTimeout
78+
): Task[ZioClientStdioTransport] =
7979
for
8080
scope <- Scope.make
8181
writeQueue <- Queue.bounded[JSONRPCMessage](256)
8282
pending <- ZioPendingRequests.make
8383
incomingRef <- Ref.make[JSONRPCMessage => Task[Unit]](_ => ZIO.unit)
8484
stdinBytes = ZStream
8585
.fromQueue(writeQueue)
86-
.map(msg => Chunk.fromArray((Transport.encode(msg) + "\n").getBytes(StandardCharsets.UTF_8)))
86+
.map(msg => Chunk.fromArray((ClientTransport.encode(msg) + "\n").getBytes(StandardCharsets.UTF_8)))
8787
.flattenChunks
8888
baseCmd = Command(command.head, command.tail*)
8989
withEnv = if env.isEmpty then baseCmd else baseCmd.env(env)
9090
withDir = workDir.fold(withEnv)(withEnv.workingDirectory)
9191
cmd = withDir.stdin(ProcessInput.fromStream(stdinBytes, flushChunksEagerly = true))
9292
process <- cmd.run.provideEnvironment(zio.ZEnvironment(scope))
93-
transport = new ZioStreamingStdioTransport(command, env, workDir, timeout, scope, process, writeQueue, pending, incomingRef)
93+
transport = new ZioClientStdioTransport(command, env, workDir, timeout, scope, process, writeQueue, pending, incomingRef)
9494
_ <- transport.startReader
9595
_ <- transport.startStderr
9696
yield transport
@@ -99,14 +99,14 @@ object ZioStreamingStdioTransport:
9999
command: List[String],
100100
env: Map[String, String] = Map.empty,
101101
workDir: Option[File] = None,
102-
timeout: FiniteDuration = Transport.defaultTimeout
103-
): ZIO[Scope, Throwable, ZioStreamingStdioTransport] =
102+
timeout: FiniteDuration = ClientTransport.defaultTimeout
103+
): ZIO[Scope, Throwable, ZioClientStdioTransport] =
104104
ZIO.acquireRelease(apply(command, env, workDir, timeout))(_.close().ignore)
105105

106106
def layer(
107107
command: List[String],
108108
env: Map[String, String] = Map.empty,
109109
workDir: Option[File] = None,
110-
timeout: FiniteDuration = Transport.defaultTimeout
111-
): ZLayer[Any, Throwable, ZioStreamingStdioTransport] =
110+
timeout: FiniteDuration = ClientTransport.defaultTimeout
111+
): ZLayer[Any, Throwable, ZioClientStdioTransport] =
112112
ZLayer.scoped(scoped(command, env, workDir, timeout))

client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioStreamingHttpIntegrationSpec.scala renamed to client-streaming/client-zio/src/test/scala/chimp/client/transport/zio/ZioMcpClientHttpIntegrationSpec.scala

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

3-
import chimp.client.integration.StreamingHttpIntegrationSpec
4-
import chimp.client.transport.BidirectionalTransport
3+
import chimp.client.integration.McpClientStreamingHttpIntegrationSpec
4+
import chimp.client.transport.ClientBidirectionalTransport
55
import chimp.protocol.ProtocolVersion
66
import sttp.capabilities.zio.ZioStreams
77
import sttp.client4.StreamBackend
@@ -11,13 +11,13 @@ import zio.{Task, ZIO}
1111

1212
import scala.concurrent.duration.FiniteDuration
1313

14-
class ZioStreamingHttpIntegrationSpec extends StreamingHttpIntegrationSpec[Task, StreamBackend[Task, ZioStreams]] with ZioToFuture:
14+
class ZioMcpClientHttpIntegrationSpec extends McpClientStreamingHttpIntegrationSpec[Task, StreamBackend[Task, ZioStreams]] with ZioToFuture:
1515

1616
override def usingBackend[A](use: StreamBackend[Task, ZioStreams] => Task[A]): Task[A] =
1717
HttpClientZioBackend().flatMap: b =>
1818
use(b).ensuring(b.close().orDie)
1919

2020
override def usingBidirectionalTransport[A](b: StreamBackend[Task, ZioStreams], uri: Uri, timeout: FiniteDuration)(
21-
use: BidirectionalTransport[Task] => Task[A]
21+
use: ClientBidirectionalTransport[Task] => Task[A]
2222
): Task[A] =
23-
ZIO.scoped(ZioStreamingHttpTransport.scoped(b, uri, ProtocolVersion.Latest, timeout).flatMap(use))
23+
ZIO.scoped(ZioClientHttpTransport.scoped(b, uri, ProtocolVersion.Latest, timeout).flatMap(use))
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package chimp.client.transport.zio
2+
3+
import chimp.client.integration.McpClientStdioIntegrationSpec
4+
import chimp.client.transport.ClientBidirectionalTransport
5+
import zio.{Task, ZIO}
6+
7+
import scala.concurrent.duration.FiniteDuration
8+
9+
class ZioMcpClientStdioIntegrationSpec extends McpClientStdioIntegrationSpec[Task] with ZioToFuture:
10+
11+
override def usingTransport[A](command: List[String], timeout: FiniteDuration)(
12+
use: ClientBidirectionalTransport[Task] => Task[A]
13+
): Task[A] =
14+
ZIO.scoped(ZioClientStdioTransport.scoped(command, timeout = timeout).flatMap(use))

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

Lines changed: 0 additions & 12 deletions
This file was deleted.

0 commit comments

Comments
 (0)