From 0d9e2c052094e57bb5899be147aa7477c03feff7 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Mon, 7 Sep 2026 02:39:21 +0200 Subject: [PATCH 01/26] Update sbt-mdoc to 2.9.2 (#256) --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 4fe42c9..ebf986a 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -2,6 +2,6 @@ val sbtSoftwareMillVersion = "2.1.2" addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-common" % sbtSoftwareMillVersion) addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-publish" % sbtSoftwareMillVersion) -addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.9.1") +addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.9.2") addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0") addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.5.0") From aaed0a82aeef1c7e3fc470693d5d3d2175857bb9 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Tue, 8 Sep 2026 02:39:44 +0200 Subject: [PATCH 02/26] Update slf4j-api to 2.0.19 (#257) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 9fe17e1..18577d8 100644 --- a/build.sbt +++ b/build.sbt @@ -4,7 +4,7 @@ import com.softwaremill.UpdateVersionInDocs val scalaTestV = "3.2.20" val circeV = "0.14.16" -val slf4jV = "2.0.18" +val slf4jV = "2.0.19" val logbackV = "1.6.3" val tapirV = "1.13.31" val sttpClientV = "4.0.26" From a4ad7b76fd92c076ee1fde23cd100893ca81593d Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Tue, 15 Sep 2026 02:37:36 +0200 Subject: [PATCH 03/26] Update zio-http to 3.11.5 (#261) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 18577d8..30f197b 100644 --- a/build.sbt +++ b/build.sbt @@ -10,7 +10,7 @@ val tapirV = "1.13.31" val sttpClientV = "4.0.26" val zioV = "2.1.26" val zioProcessV = "0.8.0" -val zioHttpV = "3.11.4" +val zioHttpV = "3.11.5" val oxV = "1.0.6" val testcontainersScalaV = "0.41.8" val pekkoV = "1.7.0" From 14580722364decf7803a40b70ef6e480a6e0dd05 Mon Sep 17 00:00:00 2001 From: Maxime P Date: Tue, 15 Sep 2026 12:51:42 -0700 Subject: [PATCH 04/26] Add serverSecurityLogic (#259) * Give the tools a principal made by the server security logic * Add streaming support to secured MCP servers * Fold StreamingBackend into ServerStreamingHttpTransport to not break bin-compat * Add more tests + doc * Address review comments --------- Co-authored-by: Maxime P <7041978+max-peroch@users.noreply.github.com> --- docs/server/tools.md | 3 +- docs/server/transport.md | 78 +++++++- .../chimp/server/ox/OxMcpServerHttpSpec.scala | 83 ++++++-- .../server/pekko/PekkoMcpServerHttpSpec.scala | 53 ++++- .../server/zio/ZioMcpServerHttpSpec.scala | 58 +++++- .../main/scala/chimp/server/McpServer.scala | 181 +++++++++++++++++- .../scala/chimp/server/ServerContext.scala | 24 +++ server/src/main/scala/chimp/server/Tool.scala | 31 +++ .../transport/JsonRpcHttpResponses.scala | 26 +++ .../SecuredServerHttpTransport.scala | 41 ++++ .../SecuredServerStreamingHttpTransport.scala | 53 +++++ .../transport/ServerHttpTransport.scala | 10 +- .../ServerStreamingHttpTransport.scala | 30 ++- .../SecuredMcpServerStreamingTests.scala | 85 ++++++++ .../chimp/server/SecuredMcpServerTests.scala | 120 ++++++++++++ 15 files changed, 819 insertions(+), 57 deletions(-) create mode 100644 server/src/main/scala/chimp/server/transport/JsonRpcHttpResponses.scala create mode 100644 server/src/main/scala/chimp/server/transport/SecuredServerHttpTransport.scala create mode 100644 server/src/main/scala/chimp/server/transport/SecuredServerStreamingHttpTransport.scala create mode 100644 server/src/test/scala/chimp/server/SecuredMcpServerStreamingTests.scala create mode 100644 server/src/test/scala/chimp/server/SecuredMcpServerTests.scala diff --git a/docs/server/tools.md b/docs/server/tools.md index 9ac1ee1..656d7dc 100644 --- a/docs/server/tools.md +++ b/docs/server/tools.md @@ -8,8 +8,9 @@ - `handle` — synchronous logic from input to `ToolResult`. - `handleWithHeaders` — synchronous logic that also receives the request headers. - `serverLogic` — effectful logic, with the request headers. + - `handleSecured` (synchronous) or `securedServerLogic` (effectful) — logic that also receives the principal made by the server's security logic; usable only on a secured server — see [transport security](transport.md). - A tool that pushes to the client while running (progress, logging) instead uses `streamingServerLogic` — see [server capabilities](capabilities.md). + A tool that pushes to the client while running (progress, logging) instead uses `streamingServerLogic` — see [server capabilities](capabilities.md). A secured, streaming tool uses `securedStreamingServerLogic`, which gives both the principal and the streaming context — see [transport security](transport.md). - Assemble tools into an `McpServer` and call `.endpoint(path)` to create a Tapir endpoint. ```scala mdoc:compile-only diff --git a/docs/server/transport.md b/docs/server/transport.md index 5a33b68..c63f21a 100644 --- a/docs/server/transport.md +++ b/docs/server/transport.md @@ -106,6 +106,82 @@ object SecuredMcpServer: NettySyncServer().port(8080).addEndpoint(securedEndpoint).startAndWait() ``` -The result of the security logic does not reach the tool logic. If a tool needs data from the caller, read the request headers with `handleWithHeaders` (or `serverLogic` with headers). +The result of the security logic of `prependSecurity` does not reach the tool logic. Use `prependSecurity` if the tools do not need data from the caller. If a tool needs such data, read the request headers with `handleWithHeaders` (or `serverLogic` with headers), or give the tools a principal, as below. For all the security inputs - API keys, basic and bearer authorization, OAuth2 flows - see the [Tapir endpoint security documentation](https://tapir.softwaremill.com/en/latest/endpoint/security.html). + +### Giving the security result to the tools + +To validate the caller one time and give the result to the tool logic, use `serverSecurityLogic` (or `serverSecurityLogicPure`, if the logic needs no effect). It takes the same security input and error output as `prependSecurity`, and makes a principal - a value of your own type, such as the identity of the caller. + +The server gives the principal to the logic of each tool which you add to it. Define such a tool with `handleSecured`, or with `securedServerLogic` if the logic needs an effect. Tools which do not need the principal keep their usual logic. The other builders of `McpServer` stay available, so you can configure the server before or after you add the security logic: + +```scala mdoc:compile-only +import chimp.server.* +import sttp.model.StatusCode +import sttp.shared.Identity +import sttp.tapir.* +import sttp.tapir.server.netty.sync.NettySyncServer + +case class User(email: String) + +object McpServerWithPrincipal: + def main(args: Array[String]): Unit = + val echo = tool("echo").input[String].handle(message => ToolResult.text(message)) + val whoAmI = tool("whoAmI").input[String].handleSecured[User]((_, user) => ToolResult.text(user.email)) + + val securedEndpoint = McpServer[Identity]() + .serverSecurityLogicPure( + auth.bearer[String](), + statusCode(StatusCode.Unauthorized).and(stringBody) + )(token => if token == "s3cret" then Right(User("employee@example.com")) else Left("Invalid token")) + .name("my-mcp-server") + .addTools(echo, whoAmI) + .endpoint(List("mcp")) + + NettySyncServer().port(8080).addEndpoint(securedEndpoint).startAndWait() +``` + +The security logic runs one time for each request, before the server handles the MCP message. If it gives a rejection, the server sends the error output and no tool logic runs. This is necessary if the client must get an HTTP status code, because a tool which rejects a call can only give a JSON-RPC error with status code 200. + +### Combining security with streaming + +`.streaming` on a secured server gives a `SecuredStreamingMcpServer`, which accepts streaming tools alongside the plain secured ones. Define such a tool with `securedStreamingServerLogic`, which gives both the principal and the `StreamingServerContext`: + +```scala mdoc:compile-only +import chimp.protocol.LoggingLevel +import chimp.server.* +import chimp.server.ox.OxServerHttpTransport +import chimp.server.transport.SecuredServerStreamingHttpTransport +import io.circe.Json +import sttp.model.StatusCode +import sttp.shared.Identity +import sttp.tapir.* +import sttp.tapir.server.netty.sync.NettySyncServer + +case class User(email: String) + +object McpServerWithPrincipalAndStreaming: + def main(args: Array[String]): Unit = + val whoAmI = tool("whoAmI") + .input[String] + .securedStreamingServerLogic[Identity, User]: (_, user, ctx, _) => + ctx.log(LoggingLevel.Info, Json.fromString(s"called by ${user.email}")) + ToolResult.text(user.email) + + val securedStreamingServer = McpServer[Identity]() + .serverSecurityLogicPure( + auth.bearer[String](), + statusCode(StatusCode.Unauthorized).and(stringBody) + )(token => if token == "s3cret" then Right(User("employee@example.com")) else Left("Invalid token")) + .streaming + .addStreamingTool(whoAmI) + + // `OxServerHttpTransport` is one effect backend's streaming machinery; substitute your own (Pekko, ZIO, ...). + val backend = OxServerHttpTransport(List("mcp")) + val securedEndpoint = SecuredServerStreamingHttpTransport(List("mcp"), backend).serve(securedStreamingServer) + + NettySyncServer().port(8080).addEndpoint(securedEndpoint).startAndWait() +``` + +`SecuredServerStreamingHttpTransport` takes an existing `ServerStreamingHttpTransport` as its source of streaming machinery, rather than extending it, so the same effect-specific backend instance - `OxServerHttpTransport`, `PekkoServerHttpTransport`, `ZioServerHttpTransport` - serves both a plain `StreamingMcpServer` and a `SecuredStreamingMcpServer`. diff --git a/server-streaming/server-ox/src/test/scala/chimp/server/ox/OxMcpServerHttpSpec.scala b/server-streaming/server-ox/src/test/scala/chimp/server/ox/OxMcpServerHttpSpec.scala index de51b3b..724be68 100644 --- a/server-streaming/server-ox/src/test/scala/chimp/server/ox/OxMcpServerHttpSpec.scala +++ b/server-streaming/server-ox/src/test/scala/chimp/server/ox/OxMcpServerHttpSpec.scala @@ -1,20 +1,39 @@ package chimp.server.ox -import chimp.client.transport.ClientTransport +import chimp.client.transport.{ClientHttpTransport, ClientTransport} import chimp.client.transport.ox.OxClientHttpTransport import chimp.client.{BidirectionalMcpClient, McpClient} import chimp.protocol.{Implementation, ProtocolVersion} -import chimp.server.{McpServer, McpServerStreamingTests, McpServerTests, StreamingMcpServer, SyncToFuture} +import chimp.server.transport.SecuredServerStreamingHttpTransport +import chimp.server.{ + McpServer, + McpServerStreamingTests, + McpServerTests, + SecuredMcpServer, + SecuredMcpServerStreamingTests, + SecuredMcpServerTests, + SecuredStreamingMcpServer, + StreamingMcpServer, + SyncToFuture +} import org.scalatest.Assertion -import ox.supervised -import sttp.client4.DefaultSyncBackend +import ox.{supervised, Ox} +import sttp.capabilities.WebSockets +import sttp.client4.{DefaultSyncBackend, SyncBackend} +import sttp.model.Header import sttp.model.Uri.UriContext import sttp.shared.Identity -import sttp.tapir.server.netty.sync.NettySyncServer +import sttp.tapir.server.netty.sync.{NettySyncServer, OxStreams} +import sttp.tapir.server.ServerEndpoint import scala.concurrent.Future -class OxMcpServerHttpSpec extends McpServerTests[Identity] with McpServerStreamingTests[Identity] with SyncToFuture: +class OxMcpServerHttpSpec + extends McpServerTests[Identity] + with McpServerStreamingTests[Identity] + with SecuredMcpServerTests[Identity] + with SecuredMcpServerStreamingTests[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] = @@ -23,21 +42,51 @@ class OxMcpServerHttpSpec extends McpServerTests[Identity] with McpServerStreami override protected def withStreamingServer( server: StreamingMcpServer[Identity] )(test: BidirectionalMcpClient[Identity] => Identity[Assertion]): Future[Assertion] = + withHttpServer(OxServerHttpTransport(List("mcp")).serve(server)): (port, backend, ox) => + given Ox = ox + val transport = + OxClientHttpTransport(backend, uri"http://localhost:$port/mcp", ProtocolVersion.Latest, ClientTransport.defaultTimeout) + try test(McpClient.bidirectional(transport, clientInfo)) + finally transport.close() + + override protected def withSecuredServer( + server: SecuredMcpServer[Identity, String, String, User], + token: String + )(test: McpClient[Identity] => Identity[Assertion]): Future[Assertion] = + withHttpServer(server.endpoint(List("mcp"))): (port, backend, _) => + val transport = ClientHttpTransport[Identity]( + backend, + uri"http://localhost:$port/mcp", + headers = List(Header.authorization("Bearer", token)) + ) + try test(McpClient(transport, clientInfo)) + finally transport.close() + + override protected def withSecuredStreamingServer( + server: SecuredStreamingMcpServer[Identity, String, String, User], + token: String + )(test: BidirectionalMcpClient[Identity] => Identity[Assertion]): Future[Assertion] = + val endpoint = SecuredServerStreamingHttpTransport(List("mcp"), OxServerHttpTransport(List("mcp"))).serve(server) + withHttpServer(endpoint): (port, backend, ox) => + given Ox = ox + val transport = OxClientHttpTransport( + backend, + uri"http://localhost:$port/mcp", + ProtocolVersion.Latest, + ClientTransport.defaultTimeout, + headers = List(Header.authorization("Bearer", token)) + ) + try test(McpClient.bidirectional(transport, clientInfo)) + finally transport.close() + + private def withHttpServer( + endpoint: ServerEndpoint[OxStreams & WebSockets, Identity] + )(test: (Int, SyncBackend, Ox) => Assertion): Future[Assertion] = toFuture: supervised: - val endpoint = OxServerHttpTransport(List("mcp")).serve(server) val binding = NettySyncServer().port(0).addEndpoint(endpoint).start() try val backend = DefaultSyncBackend() - try - val transport = - OxClientHttpTransport( - backend, - uri"http://localhost:${binding.port}/mcp", - ProtocolVersion.Latest, - ClientTransport.defaultTimeout - ) - try test(McpClient.bidirectional(transport, clientInfo)) - finally transport.close() + try test(binding.port, backend, summon[Ox]) finally backend.close() finally binding.stop() diff --git a/server-streaming/server-pekko/src/test/scala/chimp/server/pekko/PekkoMcpServerHttpSpec.scala b/server-streaming/server-pekko/src/test/scala/chimp/server/pekko/PekkoMcpServerHttpSpec.scala index 016c7c8..f3c0944 100644 --- a/server-streaming/server-pekko/src/test/scala/chimp/server/pekko/PekkoMcpServerHttpSpec.scala +++ b/server-streaming/server-pekko/src/test/scala/chimp/server/pekko/PekkoMcpServerHttpSpec.scala @@ -3,17 +3,36 @@ package chimp.server.pekko import chimp.client.transport.pekko.PekkoClientHttpTransport import chimp.client.{BidirectionalMcpClient, McpClient} import chimp.protocol.Implementation -import chimp.server.{McpServer, McpServerStreamingTests, McpServerTests, StreamingMcpServer} +import chimp.server.transport.SecuredServerStreamingHttpTransport +import chimp.server.{ + McpServer, + McpServerStreamingTests, + McpServerTests, + SecuredMcpServer, + SecuredMcpServerStreamingTests, + SecuredMcpServerTests, + SecuredStreamingMcpServer, + StreamingMcpServer +} import org.apache.pekko.http.scaladsl.Http import org.scalatest.Assertion +import sttp.capabilities.WebSockets +import sttp.capabilities.pekko.PekkoStreams import sttp.client4.pekkohttp.PekkoHttpBackend +import sttp.model.Header import sttp.model.Uri.UriContext import sttp.tapir.server.pekkohttp.PekkoHttpServerInterpreter +import sttp.tapir.server.ServerEndpoint import scala.concurrent.duration.DurationInt import scala.concurrent.{ExecutionContext, Future} -class PekkoMcpServerHttpSpec extends McpServerTests[Future] with McpServerStreamingTests[Future] with PekkoToFuture: +class PekkoMcpServerHttpSpec + extends McpServerTests[Future] + with McpServerStreamingTests[Future] + with SecuredMcpServerTests[Future] + with SecuredMcpServerStreamingTests[Future] + with PekkoToFuture: private val clientInfo = Implementation("chimp-server-test", "0.0.1") override protected def withServer(server: McpServer[Future])(test: McpClient[Future] => Future[Assertion]): Future[Assertion] = @@ -22,17 +41,37 @@ class PekkoMcpServerHttpSpec extends McpServerTests[Future] with McpServerStream override protected def withStreamingServer( server: StreamingMcpServer[Future] )(test: BidirectionalMcpClient[Future] => Future[Assertion]): Future[Assertion] = + withHttpServer(PekkoServerHttpTransport(List("mcp")).serve(server), Nil): transport => + McpClient.bidirectional(transport, clientInfo).flatMap(test) + + override protected def withSecuredServer( + server: SecuredMcpServer[Future, String, String, User], + token: String + )(test: McpClient[Future] => Future[Assertion]): Future[Assertion] = + withHttpServer(server.endpoint(List("mcp")), List(Header.authorization("Bearer", token))): transport => + McpClient(transport, clientInfo).flatMap(test) + + override protected def withSecuredStreamingServer( + server: SecuredStreamingMcpServer[Future, String, String, User], + token: String + )(test: BidirectionalMcpClient[Future] => Future[Assertion]): Future[Assertion] = + val endpoint = SecuredServerStreamingHttpTransport(List("mcp"), PekkoServerHttpTransport(List("mcp"))).serve(server) + withHttpServer(endpoint, List(Header.authorization("Bearer", token))): transport => + McpClient.bidirectional(transport, clientInfo).flatMap(test) + + private def withHttpServer( + endpoint: ServerEndpoint[PekkoStreams & WebSockets, Future], + headers: List[Header] + )(test: PekkoClientHttpTransport => Future[Assertion]): Future[Assertion] = given ExecutionContext = actorSystem.dispatcher - val endpoint = PekkoServerHttpTransport(List("mcp")).serve(server) Http() .newServerAt("localhost", 0) .bind(PekkoHttpServerInterpreter().toRoute(endpoint)) .flatMap: binding => val backend = PekkoHttpBackend.usingActorSystem(actorSystem) - val transport = PekkoClientHttpTransport(backend, uri"http://localhost:${binding.localAddress.getPort}/mcp") - McpClient - .bidirectional(transport, clientInfo) - .flatMap(test) + val transport = + PekkoClientHttpTransport(backend, uri"http://localhost:${binding.localAddress.getPort}/mcp", headers = headers) + test(transport) .transformWith: result => transport .close() 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 index 32525b8..8bd995a 100644 --- 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 @@ -4,17 +4,36 @@ 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 chimp.server.transport.SecuredServerStreamingHttpTransport +import chimp.server.{ + McpServer, + McpServerStreamingTests, + McpServerTests, + SecuredMcpServer, + SecuredMcpServerStreamingTests, + SecuredMcpServerTests, + SecuredStreamingMcpServer, + StreamingMcpServer +} import org.scalatest.Assertion +import sttp.capabilities.WebSockets +import sttp.capabilities.zio.ZioStreams import sttp.client4.* import sttp.client4.httpclient.zio.HttpClientZioBackend +import sttp.model.Header import sttp.tapir.server.ziohttp.ZioHttpInterpreter +import sttp.tapir.server.ServerEndpoint import zio.http.Server import zio.{Scope, Task, ZIO} import scala.concurrent.Future -class ZioMcpServerHttpSpec extends McpServerTests[Task] with McpServerStreamingTests[Task] with ZioToFuture: +class ZioMcpServerHttpSpec + extends McpServerTests[Task] + with McpServerStreamingTests[Task] + with SecuredMcpServerTests[Task] + with SecuredMcpServerStreamingTests[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] = @@ -23,15 +42,42 @@ class ZioMcpServerHttpSpec extends McpServerTests[Task] with McpServerStreamingT override protected def withStreamingServer( server: StreamingMcpServer[Task] )(test: BidirectionalMcpClient[Task] => Task[Assertion]): Future[Assertion] = + withHttpServer(ZioServerHttpTransport(List("mcp")).serve(server), Nil): transport => + McpClient.bidirectional(transport, clientInfo).flatMap(test) + + override protected def withSecuredServer( + server: SecuredMcpServer[Task, String, String, User], + token: String + )(test: McpClient[Task] => Task[Assertion]): Future[Assertion] = + withHttpServer(server.endpoint(List("mcp")), List(Header.authorization("Bearer", token))): transport => + McpClient(transport, clientInfo).flatMap(test) + + override protected def withSecuredStreamingServer( + server: SecuredStreamingMcpServer[Task, String, String, User], + token: String + )(test: BidirectionalMcpClient[Task] => Task[Assertion]): Future[Assertion] = + val endpoint = SecuredServerStreamingHttpTransport(List("mcp"), ZioServerHttpTransport(List("mcp"))).serve(server) + withHttpServer(endpoint, List(Header.authorization("Bearer", token))): transport => + McpClient.bidirectional(transport, clientInfo).flatMap(test) + + private def withHttpServer( + endpoint: ServerEndpoint[ZioStreams & WebSockets, Task], + headers: List[Header] + )(test: ZioClientHttpTransport => Task[Assertion]): Future[Assertion] = toFuture: - val routes = ZioHttpInterpreter().toHttp(ZioServerHttpTransport(List("mcp")).serve(server)) + val routes = ZioHttpInterpreter().toHttp(endpoint) 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)) + .scoped( + backend, + uri"http://localhost:$port/mcp", + ProtocolVersion.Latest, + ClientTransport.defaultTimeout, + headers = headers + ) + .flatMap(test) .ensuring(backend.close().ignore) yield result).provideSome[Scope](Server.defaultWithPort(0)) diff --git a/server/src/main/scala/chimp/server/McpServer.scala b/server/src/main/scala/chimp/server/McpServer.scala index 75fca77..49f380c 100644 --- a/server/src/main/scala/chimp/server/McpServer.scala +++ b/server/src/main/scala/chimp/server/McpServer.scala @@ -1,8 +1,10 @@ package chimp.server import chimp.protocol.* -import chimp.server.transport.ServerHttpTransport +import chimp.server.transport.{SecuredServerHttpTransport, ServerHttpTransport} +import sttp.monad.MonadError import sttp.tapir.server.ServerEndpoint +import sttp.tapir.{EndpointInput, EndpointOutput} type CompletionHandler[F[_]] = (CompleteRef, CompleteArgument, Option[CompleteContext]) => F[Completion] @@ -91,6 +93,20 @@ case class McpServer[F[_]]( def endpoint(path: List[String]): ServerEndpoint[Any, F] = ServerHttpTransport(path).serve(this) + /** Adds the security input, the error output which describes a rejection, and the logic which validates the security input and makes the + * principal. The principal is given to the logic of the tools which are added to the returned server. + */ + def serverSecurityLogic[S, E, P](securityInput: EndpointInput[S], errorOutput: EndpointOutput[E])( + logic: S => F[Either[E, P]] + ): SecuredMcpServer[F, S, E, P] = + SecuredMcpServer(this, securityInput, errorOutput, _ => logic) + + /** The same as [[serverSecurityLogic]], but for security logic which needs no effect. */ + def serverSecurityLogicPure[S, E, P](securityInput: EndpointInput[S], errorOutput: EndpointOutput[E])( + logic: S => Either[E, P] + ): SecuredMcpServer[F, S, E, P] = + SecuredMcpServer(this, securityInput, errorOutput, monad => input => monad.unit(logic(input))) + def streaming: StreamingMcpServer[F] = StreamingMcpServer( name, @@ -174,3 +190,166 @@ case class StreamingMcpServer[F[_]]( def withSubscriptions(handler: ResourceSubscriptions[F]): StreamingMcpServer[F] = copy(subscriptions = Some(handler)) + +/** An [[McpServer]] with security logic, which runs before the server handles an MCP message. The result of the security logic, the + * principal, is given to the logic of the tools which are added to this server. Tools of the initial server, which do not need the + * principal, are kept. + * + * @tparam S + * The type of the security input, for example a bearer token. + * @tparam E + * The type of the error output, which the server sends if the security logic gives a rejection. + * @tparam P + * The type of the principal, which the security logic makes from the security input. + */ +case class SecuredMcpServer[F[_], S, E, P]( + server: McpServer[F], + securityInput: EndpointInput[S], + errorOutput: EndpointOutput[E], + securityLogic: MonadError[F] => S => F[Either[E, P]], + securedTools: List[ServerTool[?, ?, F, SecuredServerContext[F, P]]] = Nil +) extends McpServerDef[F, SecuredServerContext[F, P]]: + def name: String = server.name + def version: String = server.version + def instructions: Option[String] = server.instructions + def showJsonSchemaMetadata: Boolean = server.showJsonSchemaMetadata + def originCheck: OriginCheck = server.originCheck + def prompts: List[ServerPrompt[F]] = server.prompts + def resources: List[ServerResource[F]] = server.resources + def resourceTemplates: List[ServerResourceTemplate[F]] = server.resourceTemplates + def completion: Option[CompletionHandler[F]] = server.completion + def loggingLevel: Option[SetLoggingLevelHandler[F]] = server.loggingLevel + def subscriptions: Option[ResourceSubscriptions[F]] = server.subscriptions + + def tools: List[ServerTool[?, ?, F, SecuredServerContext[F, P]]] = server.tools ++ securedTools + + def name(value: String): SecuredMcpServer[F, S, E, P] = + copy(server = server.name(value)) + + def version(value: String): SecuredMcpServer[F, S, E, P] = + copy(server = server.version(value)) + + def instructions(value: String): SecuredMcpServer[F, S, E, P] = + copy(server = server.instructions(value)) + + def withJsonSchemaMetadata(value: Boolean): SecuredMcpServer[F, S, E, P] = + copy(server = server.withJsonSchemaMetadata(value)) + + def withOriginCheck(value: OriginCheck): SecuredMcpServer[F, S, E, P] = + copy(server = server.withOriginCheck(value)) + + def addTool(tool: ServerTool[?, ?, F, SecuredServerContext[F, P]]): SecuredMcpServer[F, S, E, P] = + copy(securedTools = securedTools :+ tool) + + def addTools(tools: ServerTool[?, ?, F, SecuredServerContext[F, P]]*): SecuredMcpServer[F, S, E, P] = + copy(securedTools = this.securedTools ++ tools) + + def addPrompt(prompt: ServerPrompt[F]): SecuredMcpServer[F, S, E, P] = + copy(server = server.addPrompt(prompt)) + + def addPrompts(prompts: ServerPrompt[F]*): SecuredMcpServer[F, S, E, P] = + copy(server = server.addPrompts(prompts*)) + + def addResource(resource: ServerResource[F]): SecuredMcpServer[F, S, E, P] = + copy(server = server.addResource(resource)) + + def addResources(resources: ServerResource[F]*): SecuredMcpServer[F, S, E, P] = + copy(server = server.addResources(resources*)) + + def addResourceTemplate(resourceTemplate: ServerResourceTemplate[F]): SecuredMcpServer[F, S, E, P] = + copy(server = server.addResourceTemplate(resourceTemplate)) + + def addResourceTemplates(resourceTemplates: ServerResourceTemplate[F]*): SecuredMcpServer[F, S, E, P] = + copy(server = server.addResourceTemplates(resourceTemplates*)) + + def withCompletion(handler: CompletionHandler[F]): SecuredMcpServer[F, S, E, P] = + copy(server = server.withCompletion(handler)) + + def withLoggingLevel(handler: SetLoggingLevelHandler[F]): SecuredMcpServer[F, S, E, P] = + copy(server = server.withLoggingLevel(handler)) + + def withSubscriptions(handler: ResourceSubscriptions[F]): SecuredMcpServer[F, S, E, P] = + copy(server = server.withSubscriptions(handler)) + + def endpoint(path: List[String]): ServerEndpoint[Any, F] = SecuredServerHttpTransport[F, S, E, P](path).serve(this) + + def streaming: SecuredStreamingMcpServer[F, S, E, P] = SecuredStreamingMcpServer(this) + +/** A [[SecuredMcpServer]] which also accepts streaming tools, which are given a [[SecuredStreamingServerContext]] combining the principal + * with the [[StreamingServerContext]]. Tools of the initial secured server, which need only the principal, are kept. + */ +case class SecuredStreamingMcpServer[F[_], S, E, P]( + server: SecuredMcpServer[F, S, E, P], + streamingTools: List[ServerTool[?, ?, F, SecuredStreamingServerContext[F, P]]] = Nil +) extends McpServerDef[F, SecuredStreamingServerContext[F, P]]: + def name: String = server.name + def version: String = server.version + def instructions: Option[String] = server.instructions + def showJsonSchemaMetadata: Boolean = server.showJsonSchemaMetadata + def originCheck: OriginCheck = server.originCheck + def prompts: List[ServerPrompt[F]] = server.prompts + def resources: List[ServerResource[F]] = server.resources + def resourceTemplates: List[ServerResourceTemplate[F]] = server.resourceTemplates + def completion: Option[CompletionHandler[F]] = server.completion + def loggingLevel: Option[SetLoggingLevelHandler[F]] = server.loggingLevel + def subscriptions: Option[ResourceSubscriptions[F]] = server.subscriptions + + def securityInput: EndpointInput[S] = server.securityInput + def errorOutput: EndpointOutput[E] = server.errorOutput + def securityLogic: MonadError[F] => S => F[Either[E, P]] = server.securityLogic + + def tools: List[ServerTool[?, ?, F, SecuredStreamingServerContext[F, P]]] = server.tools ++ streamingTools + + def name(value: String): SecuredStreamingMcpServer[F, S, E, P] = + copy(server = server.name(value)) + + def version(value: String): SecuredStreamingMcpServer[F, S, E, P] = + copy(server = server.version(value)) + + def instructions(value: String): SecuredStreamingMcpServer[F, S, E, P] = + copy(server = server.instructions(value)) + + def withJsonSchemaMetadata(value: Boolean): SecuredStreamingMcpServer[F, S, E, P] = + copy(server = server.withJsonSchemaMetadata(value)) + + def withOriginCheck(value: OriginCheck): SecuredStreamingMcpServer[F, S, E, P] = + copy(server = server.withOriginCheck(value)) + + def addTool(tool: ServerTool[?, ?, F, SecuredServerContext[F, P]]): SecuredStreamingMcpServer[F, S, E, P] = + copy(server = server.addTool(tool)) + + def addTools(tools: ServerTool[?, ?, F, SecuredServerContext[F, P]]*): SecuredStreamingMcpServer[F, S, E, P] = + copy(server = server.addTools(tools*)) + + def addStreamingTool(tool: ServerTool[?, ?, F, SecuredStreamingServerContext[F, P]]): SecuredStreamingMcpServer[F, S, E, P] = + copy(streamingTools = streamingTools :+ tool) + + def addStreamingTools(tools: ServerTool[?, ?, F, SecuredStreamingServerContext[F, P]]*): SecuredStreamingMcpServer[F, S, E, P] = + copy(streamingTools = this.streamingTools ++ tools) + + def addPrompt(prompt: ServerPrompt[F]): SecuredStreamingMcpServer[F, S, E, P] = + copy(server = server.addPrompt(prompt)) + + def addPrompts(prompts: ServerPrompt[F]*): SecuredStreamingMcpServer[F, S, E, P] = + copy(server = server.addPrompts(prompts*)) + + def addResource(resource: ServerResource[F]): SecuredStreamingMcpServer[F, S, E, P] = + copy(server = server.addResource(resource)) + + def addResources(resources: ServerResource[F]*): SecuredStreamingMcpServer[F, S, E, P] = + copy(server = server.addResources(resources*)) + + def addResourceTemplate(resourceTemplate: ServerResourceTemplate[F]): SecuredStreamingMcpServer[F, S, E, P] = + copy(server = server.addResourceTemplate(resourceTemplate)) + + def addResourceTemplates(resourceTemplates: ServerResourceTemplate[F]*): SecuredStreamingMcpServer[F, S, E, P] = + copy(server = server.addResourceTemplates(resourceTemplates*)) + + def withCompletion(handler: CompletionHandler[F]): SecuredStreamingMcpServer[F, S, E, P] = + copy(server = server.withCompletion(handler)) + + def withLoggingLevel(handler: SetLoggingLevelHandler[F]): SecuredStreamingMcpServer[F, S, E, P] = + copy(server = server.withLoggingLevel(handler)) + + def withSubscriptions(handler: ResourceSubscriptions[F]): SecuredStreamingMcpServer[F, S, E, P] = + copy(server = server.withSubscriptions(handler)) diff --git a/server/src/main/scala/chimp/server/ServerContext.scala b/server/src/main/scala/chimp/server/ServerContext.scala index c764818..ebcfa60 100644 --- a/server/src/main/scala/chimp/server/ServerContext.scala +++ b/server/src/main/scala/chimp/server/ServerContext.scala @@ -10,6 +10,14 @@ trait ServerContext[F[_]] object ServerContext: def noop[F[_]]: ServerContext[F] = new ServerContext[F] {} +/** A context which also gives the principal that the security logic of a [[SecuredMcpServer]] made from the request. */ +trait SecuredServerContext[F[_], +P] extends ServerContext[F]: + def principal: P + +object SecuredServerContext: + def apply[F[_], P](value: P): SecuredServerContext[F, P] = new SecuredServerContext[F, P]: + def principal: P = value + 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] @@ -32,3 +40,19 @@ private[server] final class SinkStreamingServerContext[F[_]](sink: OutboundSink[ sink.send( JSONRPCMessage.Notification(method = "notifications/message", params = Some(LoggingMessageParams(level, data, logger).asJson)) ) + +/** A context which gives both the principal that the security logic of a [[SecuredMcpServer]] made from the request, and the streaming + * capabilities of a [[StreamingServerContext]]. + */ +trait SecuredStreamingServerContext[F[_], +P] extends SecuredServerContext[F, P] with StreamingServerContext[F] + +private[server] final class SinkSecuredStreamingServerContext[F[_], P]( + sink: OutboundSink[F], + progressToken: Option[ProgressToken], + val principal: P +)(using MonadError[F]) + extends SecuredStreamingServerContext[F, P]: + private val delegate = SinkStreamingServerContext[F](sink, progressToken) + def reportProgress(progress: Double, total: Option[Double] = None, message: Option[String] = None): F[Unit] = + delegate.reportProgress(progress, total, message) + def log(level: LoggingLevel, data: Json, logger: Option[String] = None): F[Unit] = delegate.log(level, data, logger) diff --git a/server/src/main/scala/chimp/server/Tool.scala b/server/src/main/scala/chimp/server/Tool.scala index 1e4f489..e6aecd3 100644 --- a/server/src/main/scala/chimp/server/Tool.scala +++ b/server/src/main/scala/chimp/server/Tool.scala @@ -103,12 +103,39 @@ case class Tool[I, O]( def serverLogic[F[_]](logic: (I, Seq[Header]) => F[ToolResult[O]]): ServerTool[I, O, F, ServerContext[F]] = ServerTool(name, description, inputSchema, inputDecoder, outputSchema, annotations, (input, _, headers) => logic(input, headers)) + /** Attaches effectful logic, with access to the principal; usable only on a [[SecuredMcpServer]]. */ + def securedServerLogic[F[_], P](logic: (I, P, Seq[Header]) => F[ToolResult[O]]): ServerTool[I, O, F, SecuredServerContext[F, P]] = + ServerTool( + name, + description, + inputSchema, + inputDecoder, + outputSchema, + annotations, + (input, context, headers) => logic(input, context.principal, 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[O]] ): ServerTool[I, O, F, StreamingServerContext[F]] = ServerTool(name, description, inputSchema, inputDecoder, outputSchema, annotations, logic) + /** Attaches effectful logic with access to the principal and the [[StreamingServerContext]]; usable only on a secured streaming server. + */ + def securedStreamingServerLogic[F[_], P]( + logic: (I, P, StreamingServerContext[F], Seq[Header]) => F[ToolResult[O]] + ): ServerTool[I, O, F, SecuredStreamingServerContext[F, P]] = + ServerTool( + name, + description, + inputSchema, + inputDecoder, + outputSchema, + annotations, + (input, context, headers) => logic(input, context.principal, context, headers) + ) + /** Attaches synchronous logic that also receives the request headers. */ def handleWithHeaders(logic: (I, Seq[Header]) => ToolResult[O]): ServerTool[I, O, Identity, ServerContext[Identity]] = ServerTool(name, description, inputSchema, inputDecoder, outputSchema, annotations, (i, _, headers) => logic(i, headers)) @@ -117,6 +144,10 @@ case class Tool[I, O]( def handle(logic: I => ToolResult[O]): ServerTool[I, O, Identity, ServerContext[Identity]] = handleWithHeaders((i, _) => logic(i)) + /** Attaches synchronous logic over the decoded input and the principal; usable only on a [[SecuredMcpServer]]. */ + def handleSecured[P](logic: (I, P) => ToolResult[O]): ServerTool[I, O, Identity, SecuredServerContext[Identity, P]] = + securedServerLogic[Identity, P]((i, principal, _) => logic(i, principal)) + /** A fully-defined tool: its metadata plus the logic handling a call, in effect `F` with context `C`. */ case class ServerTool[I, O, F[_], -C <: ServerContext[F]]( name: String, diff --git a/server/src/main/scala/chimp/server/transport/JsonRpcHttpResponses.scala b/server/src/main/scala/chimp/server/transport/JsonRpcHttpResponses.scala new file mode 100644 index 0000000..b89b9ec --- /dev/null +++ b/server/src/main/scala/chimp/server/transport/JsonRpcHttpResponses.scala @@ -0,0 +1,26 @@ +package chimp.server.transport + +import chimp.server.{McpResponse, OriginCheck, OutboundSink} +import io.circe.Json +import sttp.model.{Header, HeaderNames, StatusCode} +import sttp.monad.MonadError +import sttp.monad.syntax.* + +private[transport] def originAllowed(originCheck: OriginCheck, headers: Seq[Header]): Boolean = + val host = headers.find(_.name.equalsIgnoreCase(HeaderNames.Host)).map(_.value) + val origin = headers.find(_.name.equalsIgnoreCase(HeaderNames.Origin)).map(_.value) + originCheck.validate(host, origin) + +private[transport] def respondToJsonRpc[F[_]](originCheck: OriginCheck, headers: Seq[Header])(handle: => F[McpResponse])(using + m: MonadError[F] +): F[(StatusCode, Option[Json])] = + if !originAllowed(originCheck, headers) then m.unit((StatusCode.Forbidden, None)) + else handle.map(response => (response.statusCode, response.body)) + +private[transport] def respondWithEventStream[F[_], Caps]( + originCheck: OriginCheck, + headers: Seq[Header], + backend: ServerStreamingHttpTransport[F, Caps] +)(handle: OutboundSink[F] => F[Option[Json]])(using m: MonadError[F]): F[(StatusCode, backend.EventStream)] = + if !originAllowed(originCheck, headers) then m.unit((StatusCode.Forbidden, backend.emptyStream)) + else backend.eventStream(handle).map(events => (StatusCode.Ok, events)) diff --git a/server/src/main/scala/chimp/server/transport/SecuredServerHttpTransport.scala b/server/src/main/scala/chimp/server/transport/SecuredServerHttpTransport.scala new file mode 100644 index 0000000..ebc452f --- /dev/null +++ b/server/src/main/scala/chimp/server/transport/SecuredServerHttpTransport.scala @@ -0,0 +1,41 @@ +package chimp.server.transport + +import chimp.server.* +import io.circe.Json +import sttp.model.Header +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, protected by the security logic of a [[SecuredMcpServer]]. The + * security logic runs before any MCP message is handled. If it gives a rejection, the server sends the error output and no tool logic + * runs. If it gives a principal, the principal goes to the tool logic. + * + * @param path + * The MCP endpoint path. + */ +final case class SecuredServerHttpTransport[F[_], S, E, P](path: List[String]): + def serve(server: SecuredMcpServer[F, S, E, P]): ServerEndpoint[Any, F] = + val handler = new McpHandler(server) + val mcpEndpoint = endpoint.post + .securityIn(server.securityInput) + .in(path.foldLeft(emptyInput)((inputSoFar, pathComponent) => inputSoFar / pathComponent)) + .in(extractFromRequest(_.headers)) + .in(jsonBody[Json]) + .errorOut(server.errorOutput) + .out(statusCode) + .out(jsonBody[Option[Json]]) + + ServerEndpoint( + mcpEndpoint, + server.securityLogic, + me => { (principal: P) => (input: (Seq[Header], Json)) => + val (headers, json) = input + given MonadError[F] = me + respondToJsonRpc(server.originCheck, headers) { + handler.handleJsonRpc(json, headers, _ => SecuredServerContext[F, P](principal)) + }.map(Right(_)) + } + ) diff --git a/server/src/main/scala/chimp/server/transport/SecuredServerStreamingHttpTransport.scala b/server/src/main/scala/chimp/server/transport/SecuredServerStreamingHttpTransport.scala new file mode 100644 index 0000000..4a6faa7 --- /dev/null +++ b/server/src/main/scala/chimp/server/transport/SecuredServerStreamingHttpTransport.scala @@ -0,0 +1,53 @@ +package chimp.server.transport + +import chimp.protocol.ProgressToken +import chimp.server.* +import io.circe.Json +import sttp.model.Header +import sttp.monad.MonadError +import sttp.monad.syntax.* +import sttp.tapir.* +import sttp.tapir.json.circe.* +import sttp.tapir.server.ServerEndpoint + +/** Implementation of bidirectional MCP server using Streamable HTTP, protected by the security logic of a [[SecuredMcpServer]]. The + * security logic runs before any MCP message is handled. If it gives a rejection, the server sends the error output and no tool logic + * runs. If it gives a principal, the principal goes to the tool logic, together with the [[StreamingServerContext]]. + * + * Takes an existing [[ServerStreamingHttpTransport]] as the source of the streaming machinery for the effect backend, rather than + * extending it, so that the same backend instance - for example an `chimp.server.ox.OxServerHttpTransport` - serves both a plain + * [[StreamingMcpServer]] and a [[SecuredStreamingMcpServer]]. Its own `path` plays no part here; only its streaming machinery is used. + * + * @param path + * The MCP endpoint path. + * @param backend + * Supplies the streaming machinery for the effect type `F` and the streaming capability `Caps`. + */ +final case class SecuredServerStreamingHttpTransport[F[_], Caps, S, E, P]( + path: List[String], + backend: ServerStreamingHttpTransport[F, Caps] +): + def serve(server: SecuredStreamingMcpServer[F, S, E, P]): ServerEndpoint[Caps, F] = + val handler = new McpHandler[F, SecuredStreamingServerContext[F, P]](server) + val mcpEndpoint = endpoint.post + .securityIn(server.securityInput) + .in(path.foldLeft(emptyInput)((inputSoFar, pathComponent) => inputSoFar / pathComponent)) + .in(extractFromRequest(_.headers)) + .in(jsonBody[Json]) + .errorOut(server.errorOutput) + .out(statusCode) + .out(backend.sseBody) + + ServerEndpoint( + mcpEndpoint, + server.securityLogic, + me => { (principal: P) => (input: (Seq[Header], Json)) => + val (headers, json) = input + given MonadError[F] = me + respondWithEventStream(server.originCheck, headers, backend) { sink => + val makeContext: Option[ProgressToken] => SecuredStreamingServerContext[F, P] = + token => SinkSecuredStreamingServerContext(sink, token, principal) + handler.handleJsonRpc(json, headers, makeContext).map(_.body) + }.map(Right(_)) + } + ) diff --git a/server/src/main/scala/chimp/server/transport/ServerHttpTransport.scala b/server/src/main/scala/chimp/server/transport/ServerHttpTransport.scala index 1b91bda..44be5a5 100644 --- a/server/src/main/scala/chimp/server/transport/ServerHttpTransport.scala +++ b/server/src/main/scala/chimp/server/transport/ServerHttpTransport.scala @@ -2,7 +2,7 @@ package chimp.server.transport import chimp.server.* import io.circe.Json -import sttp.model.{Header, HeaderNames, StatusCode} +import sttp.model.Header import sttp.monad.MonadError import sttp.monad.syntax.* import sttp.tapir.* @@ -30,12 +30,6 @@ final case class ServerHttpTransport[F[_]](path: List[String]) extends ServerTra 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))) + respondToJsonRpc(server.originCheck, headers)(handler.handleJsonRpc(json, headers)).map(Right(_)) } ) diff --git a/server/src/main/scala/chimp/server/transport/ServerStreamingHttpTransport.scala b/server/src/main/scala/chimp/server/transport/ServerStreamingHttpTransport.scala index 4dae664..8ae7503 100644 --- a/server/src/main/scala/chimp/server/transport/ServerStreamingHttpTransport.scala +++ b/server/src/main/scala/chimp/server/transport/ServerStreamingHttpTransport.scala @@ -4,7 +4,7 @@ import chimp.protocol.ProgressToken import chimp.server.* import io.circe.Json import sttp.capabilities.Streams -import sttp.model.{Header, HeaderNames, StatusCode} +import sttp.model.Header import sttp.monad.MonadError import sttp.monad.syntax.* import sttp.tapir.* @@ -14,20 +14,22 @@ 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. + * The extra type parameter `Caps` carries the streaming capability evidence required by the Tapir [[sttp.tapir.server.ServerEndpoint]] to + * produce asynchronous stream of Server-Sent Events as response. An instance also serves as the streaming machinery which + * [[SecuredServerStreamingHttpTransport]] needs from the same effect backend - its `path` plays no part in that, so the same instance, or + * another one for the same `F`/`Caps`, can back both a plain [[StreamingMcpServer]] and a [[SecuredStreamingMcpServer]]. * * @param path * The MCP endpoint path. */ -abstract class ServerStreamingHttpTransport[F[_], S](path: List[String]) extends StreamingServerTransport[F, ServerEndpoint[S, F]]: - val streams: Streams[S] +abstract class ServerStreamingHttpTransport[F[_], Caps](path: List[String]) extends StreamingServerTransport[F, ServerEndpoint[Caps, F]]: + val streams: Streams[Caps] type EventStream - def sseBody: StreamBodyIO[streams.BinaryStream, EventStream, S] + def sseBody: StreamBodyIO[streams.BinaryStream, EventStream, Caps] def emptyStream: EventStream def eventStream(handle: OutboundSink[F] => F[Option[Json]]): F[EventStream] - final def serve(server: StreamingMcpServer[F]): ServerEndpoint[S, F] = + final def serve(server: StreamingMcpServer[F]): ServerEndpoint[Caps, F] = val handler = new McpHandler[F, StreamingServerContext[F]](server) val endpoint = infallibleEndpoint.post .in(path.foldLeft(emptyInput)((inputSoFar, pathComponent) => inputSoFar / pathComponent)) @@ -41,14 +43,10 @@ abstract class ServerStreamingHttpTransport[F[_], S](path: List[String]) extends 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))) + respondWithEventStream(server.originCheck, headers, this) { sink => + val makeContext: Option[ProgressToken] => StreamingServerContext[F] = + token => SinkStreamingServerContext(sink, token) + handler.handleJsonRpc(json, headers, makeContext).map(_.body) + }.map(Right(_)) } ) diff --git a/server/src/test/scala/chimp/server/SecuredMcpServerStreamingTests.scala b/server/src/test/scala/chimp/server/SecuredMcpServerStreamingTests.scala new file mode 100644 index 0000000..6dc12c1 --- /dev/null +++ b/server/src/test/scala/chimp/server/SecuredMcpServerStreamingTests.scala @@ -0,0 +1,85 @@ +package chimp.server + +import chimp.client.{BidirectionalMcpClient, McpAuthorizationException} +import chimp.client.notifications.ServerNotification +import chimp.protocol.* +import io.circe.{Codec, Json} +import org.scalatest.{Assertion, RecoverMethods} +import org.scalatest.flatspec.AsyncFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.model.StatusCode +import sttp.monad.syntax.* +import sttp.tapir.* + +import java.util.concurrent.ConcurrentLinkedQueue +import scala.concurrent.Future +import scala.jdk.CollectionConverters.* + +trait SecuredMcpServerTestFixtures[F[_]]: + protected case class User(email: String) + + protected val validToken = "s3cret" + +trait SecuredMcpServerStreamingTests[F[_]] extends AsyncFlatSpec with Matchers with RecoverMethods with SecuredMcpServerTestFixtures[F]: + this: ToFuture[F] => + + /** @param token + * The bearer token the client authenticates with; defaults to [[validToken]] so most tests need not pass it. + */ + protected def withSecuredStreamingServer(server: SecuredStreamingMcpServer[F, String, String, User], token: String = validToken)( + test: BidirectionalMcpClient[F] => F[Assertion] + ): Future[Assertion] + + private case class NoInput() derives Codec, Schema + + private def securityLogic(token: String): Either[String, User] = + if token == validToken then Right(User("employee@example.com")) else Left("Invalid token") + + private def securityLogicEffectful(token: String): F[Either[String, User]] = + monad.unit(securityLogic(token)) + + private def whoAmITool: ServerTool[NoInput, NoStructuredOutput, F, SecuredStreamingServerContext[F, User]] = + tool("whoAmI") + .description("Logs the caller's email, then returns it") + .input[NoInput] + .securedStreamingServerLogic[F, User]: (_, user, ctx, _) => + ctx.log(LoggingLevel.Info, Json.fromString(user.email)).map(_ => ToolResult.text(user.email)) + + protected def securedStreamingServer: SecuredStreamingMcpServer[F, String, String, User] = + McpServer[F]() + .serverSecurityLogicPure(auth.bearer[String](), statusCode(StatusCode.Unauthorized).and(stringBody))(securityLogic) + .withLoggingLevel(_ => monad.unit(())) + .streaming + .addStreamingTool(whoAmITool) + + protected def securedStreamingServerWithEffectfulSecurityLogic: SecuredStreamingMcpServer[F, String, String, User] = + McpServer[F]() + .serverSecurityLogic(auth.bearer[String](), statusCode(StatusCode.Unauthorized).and(stringBody))(securityLogicEffectful) + .withLoggingLevel(_ => monad.unit(())) + .streaming + .addStreamingTool(whoAmITool) + + private def assertWhoAmIDeliversPrincipal(client: BidirectionalMcpClient[F]): F[Assertion] = + 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("whoAmI", Json.obj())) + .flatMap: result => + waitUntil(messages.size >= 1).map: _ => + result.content shouldBe List(ToolContent.Text("text", "employee@example.com")) + messages.asScala.toList shouldBe List(Json.fromString("employee@example.com")) + + "a secured streaming MCP server" should "give the principal to a streaming tool, and deliver its log notifications" in + withSecuredStreamingServer(securedStreamingServer)(assertWhoAmIDeliversPrincipal) + + it should "give the principal to a streaming tool when the security logic is effectful" in + withSecuredStreamingServer(securedStreamingServerWithEffectfulSecurityLogic)(assertWhoAmIDeliversPrincipal) + + it should "reject an invalid security input with the error output, before any tool logic runs" in + recoverToExceptionIf[McpAuthorizationException] { + Future(withSecuredStreamingServer(securedStreamingServer, token = "wrong")(_ => monad.unit(succeed))).flatten + }.map(_.statusCode shouldBe StatusCode.Unauthorized.code) diff --git a/server/src/test/scala/chimp/server/SecuredMcpServerTests.scala b/server/src/test/scala/chimp/server/SecuredMcpServerTests.scala new file mode 100644 index 0000000..3a4208e --- /dev/null +++ b/server/src/test/scala/chimp/server/SecuredMcpServerTests.scala @@ -0,0 +1,120 @@ +package chimp.server + +import chimp.client.{McpAuthorizationException, McpClient} +import chimp.protocol.{Implementation, ResourceContents, ToolContent} +import io.circe.{Codec, Json} +import org.scalatest.{Assertion, RecoverMethods} +import org.scalatest.flatspec.AsyncFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.monad.syntax.* +import sttp.model.StatusCode +import sttp.tapir.* + +import scala.concurrent.Future + +trait SecuredMcpServerTests[F[_]] extends AsyncFlatSpec with Matchers with RecoverMethods with SecuredMcpServerTestFixtures[F]: + this: ToFuture[F] => + + protected def withSecuredServer(server: SecuredMcpServer[F, String, String, User], token: String = validToken)( + test: McpClient[F] => F[Assertion] + ): Future[Assertion] + + private case class EchoInput(message: String) derives Codec, Schema + + private def securityLogic(token: String): Either[String, User] = + if token == validToken then Right(User("employee@example.com")) else Left("Invalid token") + + private def securityLogicEffectful(token: String): F[Either[String, User]] = + monad.unit(securityLogic(token)) + + private def echoTool: ServerTool[EchoInput, NoStructuredOutput, F, ServerContext[F]] = + tool("echo") + .description("Echoes a message.") + .input[EchoInput] + .serverLogic[F]((in, _) => monad.unit(ToolResult.text(in.message))) + + private def whoAmITool: ServerTool[EchoInput, NoStructuredOutput, F, SecuredServerContext[F, User]] = + tool("whoAmI") + .description("Echoes a message and the caller's email.") + .input[EchoInput] + .securedServerLogic[F, User]((in, user, _) => monad.unit(ToolResult.text(s"${in.message} ${user.email}"))) + + private def greetingResource: ServerResource[F] = + resource("test://greeting") + .name("greeting") + .mimeType("text/plain") + .serverLogic[F](_ => + monad.unit(Right(List(ResourceContents.Text(uri = "test://greeting", text = "hello", mimeType = Some("text/plain"))))) + ) + + private def securedServer: SecuredMcpServer[F, String, String, User] = + McpServer[F]() + .addTool(echoTool) + .serverSecurityLogicPure(auth.bearer[String](), statusCode(StatusCode.Unauthorized).and(stringBody))(securityLogic) + .addTool(whoAmITool) + + private def securedServerWithEffectfulSecurityLogic: SecuredMcpServer[F, String, String, User] = + McpServer[F]() + .addTool(echoTool) + .serverSecurityLogic(auth.bearer[String](), statusCode(StatusCode.Unauthorized).and(stringBody))(securityLogicEffectful) + .addTool(whoAmITool) + + private def serverConfiguredAfterSecurity: SecuredMcpServer[F, String, String, User] = + McpServer[F]() + .serverSecurityLogicPure(auth.bearer[String](), statusCode(StatusCode.Unauthorized).and(stringBody))(securityLogic) + .name("secured-server") + .version("2.0.0") + .addResource(greetingResource) + .addTool(whoAmITool) + + private def assertWhoAmIDeliversPrincipal(client: McpClient[F]): F[Assertion] = + client + .callTool("whoAmI", Json.obj("message" -> Json.fromString("hi"))) + .map: result => + result.isError shouldBe false + result.content shouldBe List(ToolContent.Text("text", "hi employee@example.com")) + + "a secured MCP server" should "give the principal to the tool logic" in + withSecuredServer(securedServer)(assertWhoAmIDeliversPrincipal) + + it should "give the principal to the tool logic when the security logic is effectful" in + withSecuredServer(securedServerWithEffectfulSecurityLogic)(assertWhoAmIDeliversPrincipal) + + it should "also serve tools which do not need the principal" in + withSecuredServer(securedServer): client => + client + .listTools() + .flatMap: tools => + tools.tools.map(_.name) should contain allOf ("echo", "whoAmI") + client + .callTool("echo", Json.obj("message" -> Json.fromString("hi"))) + .map: result => + result.content shouldBe List(ToolContent.Text("text", "hi")) + + it should "use the identity which the builders set after the security logic" in + withSecuredServer(serverConfiguredAfterSecurity): client => + monad.unit(client.serverInfo shouldBe Implementation("secured-server", "2.0.0")) + + it should "serve a resource which was added after the security logic" in + withSecuredServer(serverConfiguredAfterSecurity): client => + client.serverCapabilities.resources shouldBe defined + client + .listResources() + .flatMap: listed => + listed.resources.map(_.uri) shouldBe List("test://greeting") + 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 "reject an invalid security input with HTTP 401 before any tool logic runs" in + recoverToExceptionIf[McpAuthorizationException] { + Future(withSecuredServer(securedServer, token = "wrong")(_ => monad.unit(succeed))).flatten + }.map(_.statusCode shouldBe StatusCode.Unauthorized.code) + + it should "reject invalid security input with HTTP 401 when the security logic is effectful" in + recoverToExceptionIf[McpAuthorizationException] { + Future(withSecuredServer(securedServerWithEffectfulSecurityLogic, token = "wrong")(_ => monad.unit(succeed))).flatten + }.map(_.statusCode shouldBe StatusCode.Unauthorized.code) From 55e4dc3da07c15ac46b7b891267ad0f1980b2f45 Mon Sep 17 00:00:00 2001 From: Jakub Cichy <78345873+kubinio123@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:20:18 +0200 Subject: [PATCH 05/26] Commit messages structure for agents (#264) chore: commit messages structure for agents --- .claude/skills/commit/SKILL.md | 23 +++++++++++++++++++++++ AGENTS.md | 4 ++++ 2 files changed, 27 insertions(+) create mode 100644 .claude/skills/commit/SKILL.md diff --git a/.claude/skills/commit/SKILL.md b/.claude/skills/commit/SKILL.md new file mode 100644 index 0000000..fb5d031 --- /dev/null +++ b/.claude/skills/commit/SKILL.md @@ -0,0 +1,23 @@ +--- +name: commit +description: Write commit messages for the chimp project. Use when drafting or suggesting a git commit message. +--- + +# Commit Message Structure + +Use Conventional Commits prefixes: + +- `feat:` - a new feature +- `fix:` - a bug fix +- `docs:` - documentation only +- `test:` - test changes only +- `refactor:` - code change with no behaviour change +- `chore:` - build, tooling, or dependency changes +- `perf:` - a performance improvement + +Rules: + +- Subject line: prefix + short description. Max 40 characters. +- Describe what the change does, not why. +- Body lines: max 70 characters each. Wrap long lines. +- Keep the body short. Add it only if the subject is not enough. diff --git a/AGENTS.md b/AGENTS.md index 9339e9e..fd54cb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,3 +43,7 @@ ALWAYS use ASD-STE100 Simplified Technical English for documentation, responses ### 6 - Code comments NEVER add any comments in the code, unless you're asked to do so. + +### 7 - Commits + +Use `/commit` skill to keep the common style of commit messages. From 1bd51345608425ceaaf1443875140c427ad92316 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Wed, 16 Sep 2026 02:41:13 +0200 Subject: [PATCH 06/26] Update zio-process to 0.8.1 (#262) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 30f197b..81180f2 100644 --- a/build.sbt +++ b/build.sbt @@ -9,7 +9,7 @@ val logbackV = "1.6.3" val tapirV = "1.13.31" val sttpClientV = "4.0.26" val zioV = "2.1.26" -val zioProcessV = "0.8.0" +val zioProcessV = "0.8.1" val zioHttpV = "3.11.5" val oxV = "1.0.6" val testcontainersScalaV = "0.41.8" From 6083984eeb0255f5f33c4c01d0f084b1c53a52f4 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Thu, 17 Sep 2026 10:55:40 +0200 Subject: [PATCH 07/26] Use -Yfuture-lazy-vals (#267) * Use -Yfuture-lazy-vals * Emit Java 21 bytecode, matching the JDK that ox requires * Revert "Emit Java 21 bytecode, matching the JDK that ox requires" This reverts commit fbc1058bdddf34f40004aa6aebf365894bfbee2f. --- build.sbt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.sbt b/build.sbt index 81180f2..2d7ba33 100644 --- a/build.sbt +++ b/build.sbt @@ -20,12 +20,12 @@ lazy val verifyExamplesCompileUsingScalaCli = taskKey[Unit]("Verify that each ex lazy val commonSettings = commonSmlBuildSettings ++ ossPublishSettings ++ Seq( organization := "com.softwaremill.chimp", - scalaVersion := "3.3.7", + scalaVersion := "3.3.8", Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.Assertion:s", Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.compatible.Assertion:s", Test / test / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-l", "Integration"), Test / parallelExecution := false, - scalacOptions ++= Seq("-Wunused:all", "-Werror") + scalacOptions ++= Seq("-Wunused:all", "-Werror", "-Yfuture-lazy-vals", "-java-output-version", "11") ) val scalaTest = "org.scalatest" %% "scalatest" % scalaTestV % Test From de91b3342e92e6e414248ef5f9d26e53565bc7e6 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Fri, 18 Sep 2026 02:41:08 +0200 Subject: [PATCH 08/26] Update zio-http to 3.11.6 (#269) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 2d7ba33..4830d4f 100644 --- a/build.sbt +++ b/build.sbt @@ -10,7 +10,7 @@ val tapirV = "1.13.31" val sttpClientV = "4.0.26" val zioV = "2.1.26" val zioProcessV = "0.8.1" -val zioHttpV = "3.11.5" +val zioHttpV = "3.11.6" val oxV = "1.0.6" val testcontainersScalaV = "0.41.8" val pekkoV = "1.7.0" From 42ab72e4ee3304b4b438b5d9a7bcd47b82bfff3d Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Sat, 19 Sep 2026 02:41:26 +0200 Subject: [PATCH 09/26] Update ox:core to 1.0.7 (#268) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 4830d4f..970988e 100644 --- a/build.sbt +++ b/build.sbt @@ -11,7 +11,7 @@ val sttpClientV = "4.0.26" val zioV = "2.1.26" val zioProcessV = "0.8.1" val zioHttpV = "3.11.6" -val oxV = "1.0.6" +val oxV = "1.0.7" val testcontainersScalaV = "0.41.8" val pekkoV = "1.7.0" val conformanceHarnessV = "0.2.0-alpha.11" From 5c3a58d9460afb8dc1bfa87b8c26230aeca8d8a4 Mon Sep 17 00:00:00 2001 From: Maxime P Date: Tue, 22 Sep 2026 06:37:02 -0700 Subject: [PATCH 10/26] Add secure prompts and resources (#266) * Add secure prompts and resources * Address PR feedback * Address PR feedback --- docs/server/prompts.md | 1 + docs/server/resources.md | 1 + docs/server/transport.md | 14 +- .../main/scala/chimp/server/McpHandler.scala | 41 +++--- .../main/scala/chimp/server/McpServer.scala | 124 +++++++++++------- .../src/main/scala/chimp/server/Prompt.scala | 31 ++++- .../main/scala/chimp/server/Resource.scala | 63 +++++++-- .../SecuredMcpServerStreamingTests.scala | 20 +++ .../chimp/server/SecuredMcpServerTests.scala | 121 ++++++++++++++++- 9 files changed, 321 insertions(+), 95 deletions(-) diff --git a/docs/server/prompts.md b/docs/server/prompts.md index 34128d3..0e818c1 100644 --- a/docs/server/prompts.md +++ b/docs/server/prompts.md @@ -6,6 +6,7 @@ - `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. + - `handleSecured` (synchronous) or `securedServerLogic` (effectful) — logic that also receives the principal made by the server's security logic; usable only on a secured server — see [transport security](transport.md). - Register prompts with `.addPrompt` / `.addPrompts`. ```scala mdoc:compile-only diff --git a/docs/server/resources.md b/docs/server/resources.md index 7bbe3e8..86f823f 100644 --- a/docs/server/resources.md +++ b/docs/server/resources.md @@ -6,6 +6,7 @@ - `handle` — synchronous read logic. - `handleWithHeaders` — synchronous read logic that also receives the request headers. - `serverLogic` — effectful read logic, with the request headers. + - `handleSecured` (synchronous) or `securedServerLogic` (effectful) — logic that also receives the principal made by the server's security logic; usable only on a secured server — see [transport security](transport.md). - Register with `.addResource` / `.addResourceTemplate`. Subscriptions are wired with `.withSubscriptions`. ```scala mdoc:compile-only diff --git a/docs/server/transport.md b/docs/server/transport.md index c63f21a..d4b7dd6 100644 --- a/docs/server/transport.md +++ b/docs/server/transport.md @@ -106,17 +106,18 @@ object SecuredMcpServer: NettySyncServer().port(8080).addEndpoint(securedEndpoint).startAndWait() ``` -The result of the security logic of `prependSecurity` does not reach the tool logic. Use `prependSecurity` if the tools do not need data from the caller. If a tool needs such data, read the request headers with `handleWithHeaders` (or `serverLogic` with headers), or give the tools a principal, as below. +The result of the security logic of `prependSecurity` does not reach the tool, prompt, or resource logic. Use `prependSecurity` if they do not need data from the caller. If they need such data, read the request headers with `handleWithHeaders` (or `serverLogic` with headers), or give them a principal, as below. For all the security inputs - API keys, basic and bearer authorization, OAuth2 flows - see the [Tapir endpoint security documentation](https://tapir.softwaremill.com/en/latest/endpoint/security.html). -### Giving the security result to the tools +### Giving the security result to tools, prompts, and resources -To validate the caller one time and give the result to the tool logic, use `serverSecurityLogic` (or `serverSecurityLogicPure`, if the logic needs no effect). It takes the same security input and error output as `prependSecurity`, and makes a principal - a value of your own type, such as the identity of the caller. +To validate the caller one time and give the result to the tool, prompt, or resource logic, use `serverSecurityLogic` (or `serverSecurityLogicPure`, if the logic needs no effect). It takes the same security input and error output as `prependSecurity`, and makes a principal - a value of your own type, such as the identity of the caller. -The server gives the principal to the logic of each tool which you add to it. Define such a tool with `handleSecured`, or with `securedServerLogic` if the logic needs an effect. Tools which do not need the principal keep their usual logic. The other builders of `McpServer` stay available, so you can configure the server before or after you add the security logic: +The server gives the principal to the logic of each tool, prompt, or resource which you add to it. Define such handlers with `handleSecured`, or with `securedServerLogic` if the logic needs an effect. Handlers which do not need the principal keep their usual logic. The other builders of `McpServer` stay available, so you can configure the server before or after you add the security logic: ```scala mdoc:compile-only +import chimp.protocol.ResourceContents import chimp.server.* import sttp.model.StatusCode import sttp.shared.Identity @@ -129,6 +130,8 @@ object McpServerWithPrincipal: def main(args: Array[String]): Unit = val echo = tool("echo").input[String].handle(message => ToolResult.text(message)) val whoAmI = tool("whoAmI").input[String].handleSecured[User]((_, user) => ToolResult.text(user.email)) + val profile = resource("user://profile") + .handleSecured[User](user => Right(List(ResourceContents.Text(uri = "user://profile", text = user.email)))) val securedEndpoint = McpServer[Identity]() .serverSecurityLogicPure( @@ -137,12 +140,13 @@ object McpServerWithPrincipal: )(token => if token == "s3cret" then Right(User("employee@example.com")) else Left("Invalid token")) .name("my-mcp-server") .addTools(echo, whoAmI) + .addResource(profile) .endpoint(List("mcp")) NettySyncServer().port(8080).addEndpoint(securedEndpoint).startAndWait() ``` -The security logic runs one time for each request, before the server handles the MCP message. If it gives a rejection, the server sends the error output and no tool logic runs. This is necessary if the client must get an HTTP status code, because a tool which rejects a call can only give a JSON-RPC error with status code 200. +The security logic runs one time for each request, before the server handles the MCP message. If it gives a rejection, the server sends the error output and no tool, prompt, or resource logic runs. This is necessary if the client must get an HTTP status code, because a tool which rejects a call can only give a JSON-RPC error with status code 200. ### Combining security with streaming diff --git a/server/src/main/scala/chimp/server/McpHandler.scala b/server/src/main/scala/chimp/server/McpHandler.scala index 585dcee..38b50ee 100644 --- a/server/src/main/scala/chimp/server/McpHandler.scala +++ b/server/src/main/scala/chimp/server/McpHandler.scala @@ -32,6 +32,7 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD 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 hasPrompts = server.prompts.nonEmpty private val toolDefinitions = server.tools.map(toolToDefinition) private def toJsonSchema(toolSchema: ToolSchema): Json = toolSchema match @@ -89,15 +90,15 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD JSONRPCMessage.Response(id = id, result = ListResourceTemplatesResult(server.resourceTemplates.map(_.definition)).asJson) ).unit case "resources/read" if hasResources => - handleResourcesRead(params, id, headers).map(jsonResponse) + handleResourcesRead(params, id, headers, makeContext).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 => + case "prompts/list" if hasPrompts => 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 "prompts/get" if hasPrompts => + handlePromptsGet(params, id, headers, makeContext).map(jsonResponse) case "completion/complete" if server.completion.isDefined => handleComplete(params, id).map(jsonResponse) case "logging/setLevel" if server.loggingLevel.isDefined => @@ -123,7 +124,7 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD 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))), + prompts = Option.when(hasPrompts)(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))) @@ -178,17 +179,22 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD ).asJson ) - private def handleResourcesRead(params: Option[Json], id: RequestId, headers: Seq[Header])(using MonadError[F]): F[JSONRPCMessage] = + private def handleResourcesRead( + params: Option[Json], + id: RequestId, + headers: Seq[Header], + makeContext: Option[ProgressToken] => C + )(using MonadError[F]): F[JSONRPCMessage] = decodeParams[ReadResourceParams](params, id): params => + val context = makeContext(None) resourcesByUri.get(params.uri) match - case Some(resource) => resource.read(headers).map(resourceReadResponse(id, params.uri)) + case Some(resource) => resource.read(context, headers).map(resourceReadResponse(id, params.uri)) case None => - val templateMatch = server.resourceTemplates.iterator + 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 => + .collectFirst { case (template, Some(vars)) => template.read(vars, params.uri, context, headers) } match + case Some(result) => result.map(resourceReadResponse(id, params.uri)) + case None => protocolError( id, JSONRPCErrorCodes.ResourceNotFound.code, @@ -221,13 +227,18 @@ private[server] class McpHandler[F[_], C <: ServerContext[F]](server: McpServerD 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] = + private def handlePromptsGet( + params: Option[Json], + id: RequestId, + headers: Seq[Header], + makeContext: Option[ProgressToken] => C + )(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)) + .logic(params.arguments.getOrElse(Map.empty), makeContext(None), headers) + .map(promptResult => JSONRPCMessage.Response(id = id, result = promptResult.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] = diff --git a/server/src/main/scala/chimp/server/McpServer.scala b/server/src/main/scala/chimp/server/McpServer.scala index 49f380c..c994f3d 100644 --- a/server/src/main/scala/chimp/server/McpServer.scala +++ b/server/src/main/scala/chimp/server/McpServer.scala @@ -15,6 +15,9 @@ case class ResourceSubscriptions[F[_]]( onUnsubscribe: UnsubscribeParams => F[Unit] ) +private def mergeBy[A, K](first: List[A], second: List[A])(key: A => K): List[A] = + (first ++ second).reverse.distinctBy(key).reverse + sealed trait McpServerDef[F[_], C <: ServerContext[F]]: def name: String def version: String @@ -22,9 +25,9 @@ sealed trait McpServerDef[F[_], C <: ServerContext[F]]: 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 prompts: List[ServerPrompt[F, C]] + def resources: List[ServerResource[F, C]] + def resourceTemplates: List[ServerResourceTemplate[F, C]] def completion: Option[CompletionHandler[F]] def loggingLevel: Option[SetLoggingLevelHandler[F]] def subscriptions: Option[ResourceSubscriptions[F]] @@ -36,9 +39,9 @@ case class McpServer[F[_]]( 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, + prompts: List[ServerPrompt[F, ServerContext[F]]] = Nil, + resources: List[ServerResource[F, ServerContext[F]]] = Nil, + resourceTemplates: List[ServerResourceTemplate[F, ServerContext[F]]] = Nil, completion: Option[CompletionHandler[F]] = None, loggingLevel: Option[SetLoggingLevelHandler[F]] = None, subscriptions: Option[ResourceSubscriptions[F]] = None @@ -64,22 +67,22 @@ case class McpServer[F[_]]( def addTools(tools: ServerTool[?, ?, F, ServerContext[F]]*): McpServer[F] = copy(tools = this.tools ++ tools) - def addPrompt(prompt: ServerPrompt[F]): McpServer[F] = + def addPrompt(prompt: ServerPrompt[F, ServerContext[F]]): McpServer[F] = copy(prompts = prompts :+ prompt) - def addPrompts(prompts: ServerPrompt[F]*): McpServer[F] = + def addPrompts(prompts: ServerPrompt[F, ServerContext[F]]*): McpServer[F] = copy(prompts = this.prompts ++ prompts) - def addResource(resource: ServerResource[F]): McpServer[F] = + def addResource(resource: ServerResource[F, ServerContext[F]]): McpServer[F] = copy(resources = resources :+ resource) - def addResources(resources: ServerResource[F]*): McpServer[F] = + def addResources(resources: ServerResource[F, ServerContext[F]]*): McpServer[F] = copy(resources = this.resources ++ resources) - def addResourceTemplate(resourceTemplate: ServerResourceTemplate[F]): McpServer[F] = + def addResourceTemplate(resourceTemplate: ServerResourceTemplate[F, ServerContext[F]]): McpServer[F] = copy(resourceTemplates = resourceTemplates :+ resourceTemplate) - def addResourceTemplates(resourceTemplates: ServerResourceTemplate[F]*): McpServer[F] = + def addResourceTemplates(resourceTemplates: ServerResourceTemplate[F, ServerContext[F]]*): McpServer[F] = copy(resourceTemplates = this.resourceTemplates ++ resourceTemplates) def withCompletion(handler: CompletionHandler[F]): McpServer[F] = @@ -94,7 +97,7 @@ case class McpServer[F[_]]( def endpoint(path: List[String]): ServerEndpoint[Any, F] = ServerHttpTransport(path).serve(this) /** Adds the security input, the error output which describes a rejection, and the logic which validates the security input and makes the - * principal. The principal is given to the logic of the tools which are added to the returned server. + * principal. The principal is given to the logic of the tools, prompts, and resources which are added to the returned server. */ def serverSecurityLogic[S, E, P](securityInput: EndpointInput[S], errorOutput: EndpointOutput[E])( logic: S => F[Either[E, P]] @@ -130,9 +133,9 @@ case class StreamingMcpServer[F[_]]( 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, + prompts: List[ServerPrompt[F, StreamingServerContext[F]]] = Nil, + resources: List[ServerResource[F, StreamingServerContext[F]]] = Nil, + resourceTemplates: List[ServerResourceTemplate[F, StreamingServerContext[F]]] = Nil, completion: Option[CompletionHandler[F]] = None, loggingLevel: Option[SetLoggingLevelHandler[F]] = None, subscriptions: Option[ResourceSubscriptions[F]] = None @@ -164,22 +167,22 @@ case class StreamingMcpServer[F[_]]( def addStreamingTools(tools: ServerTool[?, ?, F, StreamingServerContext[F]]*): StreamingMcpServer[F] = copy(tools = this.tools ++ tools) - def addPrompt(prompt: ServerPrompt[F]): StreamingMcpServer[F] = + def addPrompt(prompt: ServerPrompt[F, ServerContext[F]]): StreamingMcpServer[F] = copy(prompts = prompts :+ prompt) - def addPrompts(prompts: ServerPrompt[F]*): StreamingMcpServer[F] = + def addPrompts(prompts: ServerPrompt[F, ServerContext[F]]*): StreamingMcpServer[F] = copy(prompts = this.prompts ++ prompts) - def addResource(resource: ServerResource[F]): StreamingMcpServer[F] = + def addResource(resource: ServerResource[F, ServerContext[F]]): StreamingMcpServer[F] = copy(resources = resources :+ resource) - def addResources(resources: ServerResource[F]*): StreamingMcpServer[F] = + def addResources(resources: ServerResource[F, ServerContext[F]]*): StreamingMcpServer[F] = copy(resources = this.resources ++ resources) - def addResourceTemplate(resourceTemplate: ServerResourceTemplate[F]): StreamingMcpServer[F] = + def addResourceTemplate(resourceTemplate: ServerResourceTemplate[F, ServerContext[F]]): StreamingMcpServer[F] = copy(resourceTemplates = resourceTemplates :+ resourceTemplate) - def addResourceTemplates(resourceTemplates: ServerResourceTemplate[F]*): StreamingMcpServer[F] = + def addResourceTemplates(resourceTemplates: ServerResourceTemplate[F, ServerContext[F]]*): StreamingMcpServer[F] = copy(resourceTemplates = this.resourceTemplates ++ resourceTemplates) def withCompletion(handler: CompletionHandler[F]): StreamingMcpServer[F] = @@ -192,8 +195,8 @@ case class StreamingMcpServer[F[_]]( copy(subscriptions = Some(handler)) /** An [[McpServer]] with security logic, which runs before the server handles an MCP message. The result of the security logic, the - * principal, is given to the logic of the tools which are added to this server. Tools of the initial server, which do not need the - * principal, are kept. + * principal, is given to the logic of the tools, prompts, and resources which are added to this server. Tools, prompts, and resources of + * the initial server, which do not need the principal, are kept. * * @tparam S * The type of the security input, for example a bearer token. @@ -207,21 +210,31 @@ case class SecuredMcpServer[F[_], S, E, P]( securityInput: EndpointInput[S], errorOutput: EndpointOutput[E], securityLogic: MonadError[F] => S => F[Either[E, P]], - securedTools: List[ServerTool[?, ?, F, SecuredServerContext[F, P]]] = Nil + securedTools: List[ServerTool[?, ?, F, SecuredServerContext[F, P]]] = Nil, + securedPrompts: List[ServerPrompt[F, SecuredServerContext[F, P]]] = Nil, + securedResources: List[ServerResource[F, SecuredServerContext[F, P]]] = Nil, + securedResourceTemplates: List[ServerResourceTemplate[F, SecuredServerContext[F, P]]] = Nil ) extends McpServerDef[F, SecuredServerContext[F, P]]: def name: String = server.name def version: String = server.version def instructions: Option[String] = server.instructions def showJsonSchemaMetadata: Boolean = server.showJsonSchemaMetadata def originCheck: OriginCheck = server.originCheck - def prompts: List[ServerPrompt[F]] = server.prompts - def resources: List[ServerResource[F]] = server.resources - def resourceTemplates: List[ServerResourceTemplate[F]] = server.resourceTemplates def completion: Option[CompletionHandler[F]] = server.completion def loggingLevel: Option[SetLoggingLevelHandler[F]] = server.loggingLevel def subscriptions: Option[ResourceSubscriptions[F]] = server.subscriptions - def tools: List[ServerTool[?, ?, F, SecuredServerContext[F, P]]] = server.tools ++ securedTools + def tools: List[ServerTool[?, ?, F, SecuredServerContext[F, P]]] = + mergeBy(server.tools, securedTools)(_.name) + + def prompts: List[ServerPrompt[F, SecuredServerContext[F, P]]] = + mergeBy(server.prompts, securedPrompts)(_.definition.name) + + def resources: List[ServerResource[F, SecuredServerContext[F, P]]] = + mergeBy(server.resources, securedResources)(_.definition.uri) + + def resourceTemplates: List[ServerResourceTemplate[F, SecuredServerContext[F, P]]] = + mergeBy(server.resourceTemplates, securedResourceTemplates)(_.definition.uriTemplate) def name(value: String): SecuredMcpServer[F, S, E, P] = copy(server = server.name(value)) @@ -244,23 +257,27 @@ case class SecuredMcpServer[F[_], S, E, P]( def addTools(tools: ServerTool[?, ?, F, SecuredServerContext[F, P]]*): SecuredMcpServer[F, S, E, P] = copy(securedTools = this.securedTools ++ tools) - def addPrompt(prompt: ServerPrompt[F]): SecuredMcpServer[F, S, E, P] = - copy(server = server.addPrompt(prompt)) + def addPrompt(prompt: ServerPrompt[F, SecuredServerContext[F, P]]): SecuredMcpServer[F, S, E, P] = + copy(securedPrompts = securedPrompts :+ prompt) - def addPrompts(prompts: ServerPrompt[F]*): SecuredMcpServer[F, S, E, P] = - copy(server = server.addPrompts(prompts*)) + def addPrompts(prompts: ServerPrompt[F, SecuredServerContext[F, P]]*): SecuredMcpServer[F, S, E, P] = + copy(securedPrompts = securedPrompts ++ prompts) - def addResource(resource: ServerResource[F]): SecuredMcpServer[F, S, E, P] = - copy(server = server.addResource(resource)) + def addResource(resource: ServerResource[F, SecuredServerContext[F, P]]): SecuredMcpServer[F, S, E, P] = + copy(securedResources = securedResources :+ resource) - def addResources(resources: ServerResource[F]*): SecuredMcpServer[F, S, E, P] = - copy(server = server.addResources(resources*)) + def addResources(resources: ServerResource[F, SecuredServerContext[F, P]]*): SecuredMcpServer[F, S, E, P] = + copy(securedResources = securedResources ++ resources) - def addResourceTemplate(resourceTemplate: ServerResourceTemplate[F]): SecuredMcpServer[F, S, E, P] = - copy(server = server.addResourceTemplate(resourceTemplate)) + def addResourceTemplate( + resourceTemplate: ServerResourceTemplate[F, SecuredServerContext[F, P]] + ): SecuredMcpServer[F, S, E, P] = + copy(securedResourceTemplates = securedResourceTemplates :+ resourceTemplate) - def addResourceTemplates(resourceTemplates: ServerResourceTemplate[F]*): SecuredMcpServer[F, S, E, P] = - copy(server = server.addResourceTemplates(resourceTemplates*)) + def addResourceTemplates( + resourceTemplates: ServerResourceTemplate[F, SecuredServerContext[F, P]]* + ): SecuredMcpServer[F, S, E, P] = + copy(securedResourceTemplates = securedResourceTemplates ++ resourceTemplates) def withCompletion(handler: CompletionHandler[F]): SecuredMcpServer[F, S, E, P] = copy(server = server.withCompletion(handler)) @@ -287,9 +304,9 @@ case class SecuredStreamingMcpServer[F[_], S, E, P]( def instructions: Option[String] = server.instructions def showJsonSchemaMetadata: Boolean = server.showJsonSchemaMetadata def originCheck: OriginCheck = server.originCheck - def prompts: List[ServerPrompt[F]] = server.prompts - def resources: List[ServerResource[F]] = server.resources - def resourceTemplates: List[ServerResourceTemplate[F]] = server.resourceTemplates + def prompts: List[ServerPrompt[F, SecuredStreamingServerContext[F, P]]] = server.prompts + def resources: List[ServerResource[F, SecuredStreamingServerContext[F, P]]] = server.resources + def resourceTemplates: List[ServerResourceTemplate[F, SecuredStreamingServerContext[F, P]]] = server.resourceTemplates def completion: Option[CompletionHandler[F]] = server.completion def loggingLevel: Option[SetLoggingLevelHandler[F]] = server.loggingLevel def subscriptions: Option[ResourceSubscriptions[F]] = server.subscriptions @@ -298,7 +315,8 @@ case class SecuredStreamingMcpServer[F[_], S, E, P]( def errorOutput: EndpointOutput[E] = server.errorOutput def securityLogic: MonadError[F] => S => F[Either[E, P]] = server.securityLogic - def tools: List[ServerTool[?, ?, F, SecuredStreamingServerContext[F, P]]] = server.tools ++ streamingTools + def tools: List[ServerTool[?, ?, F, SecuredStreamingServerContext[F, P]]] = + mergeBy(server.tools, streamingTools)(_.name) def name(value: String): SecuredStreamingMcpServer[F, S, E, P] = copy(server = server.name(value)) @@ -327,22 +345,26 @@ case class SecuredStreamingMcpServer[F[_], S, E, P]( def addStreamingTools(tools: ServerTool[?, ?, F, SecuredStreamingServerContext[F, P]]*): SecuredStreamingMcpServer[F, S, E, P] = copy(streamingTools = this.streamingTools ++ tools) - def addPrompt(prompt: ServerPrompt[F]): SecuredStreamingMcpServer[F, S, E, P] = + def addPrompt(prompt: ServerPrompt[F, SecuredServerContext[F, P]]): SecuredStreamingMcpServer[F, S, E, P] = copy(server = server.addPrompt(prompt)) - def addPrompts(prompts: ServerPrompt[F]*): SecuredStreamingMcpServer[F, S, E, P] = + def addPrompts(prompts: ServerPrompt[F, SecuredServerContext[F, P]]*): SecuredStreamingMcpServer[F, S, E, P] = copy(server = server.addPrompts(prompts*)) - def addResource(resource: ServerResource[F]): SecuredStreamingMcpServer[F, S, E, P] = + def addResource(resource: ServerResource[F, SecuredServerContext[F, P]]): SecuredStreamingMcpServer[F, S, E, P] = copy(server = server.addResource(resource)) - def addResources(resources: ServerResource[F]*): SecuredStreamingMcpServer[F, S, E, P] = + def addResources(resources: ServerResource[F, SecuredServerContext[F, P]]*): SecuredStreamingMcpServer[F, S, E, P] = copy(server = server.addResources(resources*)) - def addResourceTemplate(resourceTemplate: ServerResourceTemplate[F]): SecuredStreamingMcpServer[F, S, E, P] = + def addResourceTemplate( + resourceTemplate: ServerResourceTemplate[F, SecuredServerContext[F, P]] + ): SecuredStreamingMcpServer[F, S, E, P] = copy(server = server.addResourceTemplate(resourceTemplate)) - def addResourceTemplates(resourceTemplates: ServerResourceTemplate[F]*): SecuredStreamingMcpServer[F, S, E, P] = + def addResourceTemplates( + resourceTemplates: ServerResourceTemplate[F, SecuredServerContext[F, P]]* + ): SecuredStreamingMcpServer[F, S, E, P] = copy(server = server.addResourceTemplates(resourceTemplates*)) def withCompletion(handler: CompletionHandler[F]): SecuredStreamingMcpServer[F, S, E, P] = diff --git a/server/src/main/scala/chimp/server/Prompt.scala b/server/src/main/scala/chimp/server/Prompt.scala index 6e42081..8bbbc8c 100644 --- a/server/src/main/scala/chimp/server/Prompt.scala +++ b/server/src/main/scala/chimp/server/Prompt.scala @@ -29,21 +29,40 @@ case class 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) + def serverLogic[F[_]]( + logic: (Map[String, String], Seq[Header]) => F[GetPromptResult] + ): ServerPrompt[F, ServerContext[F]] = + ServerPrompt(definition, (args, _, headers) => logic(args, headers)) + + /** Attaches effectful logic, with access to the principal; usable only on a [[SecuredMcpServer]]. */ + def securedServerLogic[F[_], P]( + logic: (Map[String, String], P, Seq[Header]) => F[GetPromptResult] + ): ServerPrompt[F, SecuredServerContext[F, P]] = + ServerPrompt(definition, (args, context, headers) => logic(args, context.principal, headers)) /** Attaches synchronous logic that also receives the request headers. */ - def handleWithHeaders(logic: (Map[String, String], Seq[Header]) => GetPromptResult): ServerPrompt[Identity] = - ServerPrompt(definition, logic) + def handleWithHeaders( + logic: (Map[String, String], Seq[Header]) => GetPromptResult + ): ServerPrompt[Identity, ServerContext[Identity]] = + serverLogic[Identity](logic) /** Attaches synchronous logic over just the supplied argument values. */ - def handle(logic: Map[String, String] => GetPromptResult): ServerPrompt[Identity] = + def handle(logic: Map[String, String] => GetPromptResult): ServerPrompt[Identity, ServerContext[Identity]] = handleWithHeaders((args, _) => logic(args)) + /** Attaches synchronous logic over the supplied argument values and the principal; usable only on a [[SecuredMcpServer]]. */ + def handleSecured[P]( + logic: (Map[String, String], P) => GetPromptResult + ): ServerPrompt[Identity, SecuredServerContext[Identity, P]] = + securedServerLogic[Identity, P]((args, principal, _) => logic(args, principal)) + 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]) +case class ServerPrompt[F[_], -C <: ServerContext[F]]( + definition: Prompt, + logic: (Map[String, String], C, Seq[Header]) => F[GetPromptResult] +) diff --git a/server/src/main/scala/chimp/server/Resource.scala b/server/src/main/scala/chimp/server/Resource.scala index 60ec2d2..ec37ce8 100644 --- a/server/src/main/scala/chimp/server/Resource.scala +++ b/server/src/main/scala/chimp/server/Resource.scala @@ -41,23 +41,42 @@ case class 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) + def serverLogic[F[_]]( + logic: Seq[Header] => F[Either[ResourceError, List[ResourceContents]]] + ): ServerResource[F, ServerContext[F]] = + ServerResource(definition, (_, headers) => logic(headers)) + + /** Attaches effectful logic, with access to the principal; usable only on a [[SecuredMcpServer]]. */ + def securedServerLogic[F[_], P]( + logic: (P, Seq[Header]) => F[Either[ResourceError, List[ResourceContents]]] + ): ServerResource[F, SecuredServerContext[F, P]] = + ServerResource(definition, (context, headers) => logic(context.principal, headers)) /** Attaches synchronous logic that also receives the request headers. */ - def handleWithHeaders(logic: Seq[Header] => Either[ResourceError, List[ResourceContents]]): ServerResource[Identity] = - ServerResource(definition, logic) + def handleWithHeaders( + logic: Seq[Header] => Either[ResourceError, List[ResourceContents]] + ): ServerResource[Identity, ServerContext[Identity]] = + serverLogic[Identity](logic) /** Attaches synchronous logic producing the resource's contents (or an error). */ - def handle(logic: () => Either[ResourceError, List[ResourceContents]]): ServerResource[Identity] = + def handle(logic: () => Either[ResourceError, List[ResourceContents]]): ServerResource[Identity, ServerContext[Identity]] = handleWithHeaders(_ => logic()) + /** Attaches synchronous logic over the principal; usable only on a [[SecuredMcpServer]]. */ + def handleSecured[P]( + logic: P => Either[ResourceError, List[ResourceContents]] + ): ServerResource[Identity, SecuredServerContext[Identity, P]] = + securedServerLogic[Identity, P]((principal, _) => logic(principal)) + 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]]]) +case class ServerResource[F[_], -C <: ServerContext[F]]( + definition: Resource, + read: (C, Seq[Header]) => F[Either[ResourceError, List[ResourceContents]]] +) /** A resource template being defined, before its read logic is attached. */ case class PartialResourceTemplate( @@ -82,29 +101,47 @@ case class PartialResourceTemplate( /** 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) + ): ServerResourceTemplate[F, ServerContext[F]] = + ServerResourceTemplate(definition, UriTemplate.compile(uriTemplate), (vars, uri, _, headers) => logic(vars, uri, headers)) + + /** Attaches effectful logic, with access to the principal; usable only on a [[SecuredMcpServer]]. */ + def securedServerLogic[F[_], P]( + logic: (Map[String, String], String, P, Seq[Header]) => F[Either[ResourceError, List[ResourceContents]]] + ): ServerResourceTemplate[F, SecuredServerContext[F, P]] = + ServerResourceTemplate( + definition, + UriTemplate.compile(uriTemplate), + (vars, uri, context, headers) => logic(vars, uri, context.principal, headers) + ) /** 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) + ): ServerResourceTemplate[Identity, ServerContext[Identity]] = + serverLogic[Identity](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] = + def handle( + logic: (Map[String, String], String) => Either[ResourceError, List[ResourceContents]] + ): ServerResourceTemplate[Identity, ServerContext[Identity]] = handleWithHeaders((vars, uri, _) => logic(vars, uri)) + /** Attaches synchronous logic over the extracted variables, the full URI, and the principal; usable only on a [[SecuredMcpServer]]. */ + def handleSecured[P]( + logic: (Map[String, String], String, P) => Either[ResourceError, List[ResourceContents]] + ): ServerResourceTemplate[Identity, SecuredServerContext[Identity, P]] = + securedServerLogic[Identity, P]((vars, uri, principal, _) => logic(vars, uri, principal)) + 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[_]]( +case class ServerResourceTemplate[F[_], -C <: ServerContext[F]]( definition: ResourceTemplate, matcher: UriTemplate, - read: (Map[String, String], String, Seq[Header]) => F[Either[ResourceError, List[ResourceContents]]] + read: (Map[String, String], String, C, Seq[Header]) => F[Either[ResourceError, List[ResourceContents]]] ) /** A compiled URI template that matches concrete URIs and extracts their `{variable}` values. */ diff --git a/server/src/test/scala/chimp/server/SecuredMcpServerStreamingTests.scala b/server/src/test/scala/chimp/server/SecuredMcpServerStreamingTests.scala index 6dc12c1..750f720 100644 --- a/server/src/test/scala/chimp/server/SecuredMcpServerStreamingTests.scala +++ b/server/src/test/scala/chimp/server/SecuredMcpServerStreamingTests.scala @@ -59,6 +59,20 @@ trait SecuredMcpServerStreamingTests[F[_]] extends AsyncFlatSpec with Matchers w .streaming .addStreamingTool(whoAmITool) + private def whoAmIPrompt: ServerPrompt[F, SecuredServerContext[F, User]] = + prompt("whoAmI") + .description("Greets the caller") + .securedServerLogic[F, User]((_, user, _) => + monad.unit(GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = user.email))))) + ) + + private def securedStreamingServerWithPrompt: SecuredStreamingMcpServer[F, String, String, User] = + McpServer[F]() + .serverSecurityLogicPure(auth.bearer[String](), statusCode(StatusCode.Unauthorized).and(stringBody))(securityLogic) + .streaming + .addStreamingTool(whoAmITool) + .addPrompt(whoAmIPrompt) + private def assertWhoAmIDeliversPrincipal(client: BidirectionalMcpClient[F]): F[Assertion] = val messages = ConcurrentLinkedQueue[Json]() val listener: ServerNotification => F[Unit] = { @@ -79,6 +93,12 @@ trait SecuredMcpServerStreamingTests[F[_]] extends AsyncFlatSpec with Matchers w it should "give the principal to a streaming tool when the security logic is effectful" in withSecuredStreamingServer(securedStreamingServerWithEffectfulSecurityLogic)(assertWhoAmIDeliversPrincipal) + it should "give the principal to the prompt logic" in + withSecuredStreamingServer(securedStreamingServerWithPrompt): client => + client + .getPrompt("whoAmI") + .map(_.messages shouldBe List(PromptMessage(Role.User, ToolContent.Text(text = "employee@example.com")))) + it should "reject an invalid security input with the error output, before any tool logic runs" in recoverToExceptionIf[McpAuthorizationException] { Future(withSecuredStreamingServer(securedStreamingServer, token = "wrong")(_ => monad.unit(succeed))).flatten diff --git a/server/src/test/scala/chimp/server/SecuredMcpServerTests.scala b/server/src/test/scala/chimp/server/SecuredMcpServerTests.scala index 3a4208e..97d153d 100644 --- a/server/src/test/scala/chimp/server/SecuredMcpServerTests.scala +++ b/server/src/test/scala/chimp/server/SecuredMcpServerTests.scala @@ -1,7 +1,7 @@ package chimp.server import chimp.client.{McpAuthorizationException, McpClient} -import chimp.protocol.{Implementation, ResourceContents, ToolContent} +import chimp.protocol.{GetPromptResult, Implementation, PromptMessage, ResourceContents, Role, ToolContent} import io.circe.{Codec, Json} import org.scalatest.{Assertion, RecoverMethods} import org.scalatest.flatspec.AsyncFlatSpec @@ -33,13 +33,19 @@ trait SecuredMcpServerTests[F[_]] extends AsyncFlatSpec with Matchers with Recov .input[EchoInput] .serverLogic[F]((in, _) => monad.unit(ToolResult.text(in.message))) + private def publicWhoAmITool: ServerTool[EchoInput, NoStructuredOutput, F, ServerContext[F]] = + tool("whoAmI") + .description("Echoes a message.") + .input[EchoInput] + .serverLogic[F]((in, _) => monad.unit(ToolResult.text(s"${in.message} public"))) + private def whoAmITool: ServerTool[EchoInput, NoStructuredOutput, F, SecuredServerContext[F, User]] = tool("whoAmI") .description("Echoes a message and the caller's email.") .input[EchoInput] .securedServerLogic[F, User]((in, user, _) => monad.unit(ToolResult.text(s"${in.message} ${user.email}"))) - private def greetingResource: ServerResource[F] = + private def greetingResource: ServerResource[F, ServerContext[F]] = resource("test://greeting") .name("greeting") .mimeType("text/plain") @@ -47,15 +53,54 @@ trait SecuredMcpServerTests[F[_]] extends AsyncFlatSpec with Matchers with Recov monad.unit(Right(List(ResourceContents.Text(uri = "test://greeting", text = "hello", mimeType = Some("text/plain"))))) ) + private def publicWhoAmIPrompt: ServerPrompt[F, ServerContext[F]] = + prompt("whoAmI") + .serverLogic[F]((_, _) => monad.unit(GetPromptResult(messages = List(PromptMessage(Role.User, ToolContent.Text(text = "public")))))) + + private def publicWhoAmIResource: ServerResource[F, ServerContext[F]] = + resource("test://whoami") + .serverLogic[F](_ => monad.unit(Right(List(ResourceContents.Text(uri = "test://whoami", text = "public"))))) + + private def publicWhoAmIResourceTemplate: ServerResourceTemplate[F, ServerContext[F]] = + resourceTemplate("test://user/{id}") + .serverLogic[F]((_, uri, _) => monad.unit(Right(List(ResourceContents.Text(uri = uri, text = "public"))))) + + private def whoAmIPrompt: ServerPrompt[F, SecuredServerContext[F, User]] = + prompt("whoAmI") + .description("Greets the caller.") + .argument("name", required = true) + .securedServerLogic[F, User]((args, user, _) => + monad.unit( + GetPromptResult(messages = + List(PromptMessage(Role.User, ToolContent.Text(text = s"Hello ${args.getOrElse("name", "world")} from ${user.email}"))) + ) + ) + ) + + private def whoAmIResource: ServerResource[F, SecuredServerContext[F, User]] = + resource("test://whoami") + .name("whoami") + .mimeType("text/plain") + .securedServerLogic[F, User]((user, _) => + monad.unit(Right(List(ResourceContents.Text(uri = "test://whoami", text = user.email, mimeType = Some("text/plain"))))) + ) + + private def whoAmIResourceTemplate: ServerResourceTemplate[F, SecuredServerContext[F, User]] = + resourceTemplate("test://user/{id}") + .name("user") + .securedServerLogic[F, User]((vars, uri, user, _) => + monad.unit(Right(List(ResourceContents.Text(uri = uri, text = s"${vars("id")} ${user.email}", mimeType = Some("text/plain"))))) + ) + private def securedServer: SecuredMcpServer[F, String, String, User] = McpServer[F]() - .addTool(echoTool) + .addTools(echoTool, publicWhoAmITool) .serverSecurityLogicPure(auth.bearer[String](), statusCode(StatusCode.Unauthorized).and(stringBody))(securityLogic) .addTool(whoAmITool) private def securedServerWithEffectfulSecurityLogic: SecuredMcpServer[F, String, String, User] = McpServer[F]() - .addTool(echoTool) + .addTools(echoTool, publicWhoAmITool) .serverSecurityLogic(auth.bearer[String](), statusCode(StatusCode.Unauthorized).and(stringBody))(securityLogicEffectful) .addTool(whoAmITool) @@ -67,6 +112,16 @@ trait SecuredMcpServerTests[F[_]] extends AsyncFlatSpec with Matchers with Recov .addResource(greetingResource) .addTool(whoAmITool) + private def securedServerWithPromptAndResources: SecuredMcpServer[F, String, String, User] = + McpServer[F]() + .addPrompts(publicWhoAmIPrompt) + .addResources(greetingResource, publicWhoAmIResource) + .addResourceTemplates(publicWhoAmIResourceTemplate) + .serverSecurityLogicPure(auth.bearer[String](), statusCode(StatusCode.Unauthorized).and(stringBody))(securityLogic) + .addPrompts(whoAmIPrompt) + .addResources(whoAmIResource) + .addResourceTemplates(whoAmIResourceTemplate) + private def assertWhoAmIDeliversPrincipal(client: McpClient[F]): F[Assertion] = client .callTool("whoAmI", Json.obj("message" -> Json.fromString("hi"))) @@ -85,7 +140,7 @@ trait SecuredMcpServerTests[F[_]] extends AsyncFlatSpec with Matchers with Recov client .listTools() .flatMap: tools => - tools.tools.map(_.name) should contain allOf ("echo", "whoAmI") + tools.tools.map(_.name) shouldBe List("echo", "whoAmI") client .callTool("echo", Json.obj("message" -> Json.fromString("hi"))) .map: result => @@ -95,6 +150,13 @@ trait SecuredMcpServerTests[F[_]] extends AsyncFlatSpec with Matchers with Recov withSecuredServer(serverConfiguredAfterSecurity): client => monad.unit(client.serverInfo shouldBe Implementation("secured-server", "2.0.0")) + it should "expose complete prompt and resource collections" in Future { + securedServer.tools.map(_.name) shouldBe List("echo", "whoAmI") + securedServerWithPromptAndResources.prompts.map(_.definition.name) shouldBe List("whoAmI") + securedServerWithPromptAndResources.resources.map(_.definition.uri) shouldBe List("test://greeting", "test://whoami") + securedServerWithPromptAndResources.resourceTemplates.map(_.definition.uriTemplate) shouldBe List("test://user/{id}") + } + it should "serve a resource which was added after the security logic" in withSecuredServer(serverConfiguredAfterSecurity): client => client.serverCapabilities.resources shouldBe defined @@ -109,6 +171,55 @@ trait SecuredMcpServerTests[F[_]] extends AsyncFlatSpec with Matchers with Recov case ResourceContents.Text(_, text, _, _) => text shouldBe "hello" case other => fail(s"expected text contents, got $other") + it should "give the principal to the prompt logic" in + withSecuredServer(securedServerWithPromptAndResources): client => + client.serverCapabilities.prompts shouldBe defined + client + .listPrompts() + .flatMap: listed => + listed.prompts.map(_.name) shouldBe List("whoAmI") + client + .getPrompt("whoAmI", Map("name" -> "Ada")) + .map: result => + result.messages shouldBe List( + PromptMessage(Role.User, ToolContent.Text(text = "Hello Ada from employee@example.com")) + ) + + it should "give the principal to the resource logic" in + withSecuredServer(securedServerWithPromptAndResources): client => + client + .listResources() + .flatMap: listed => + listed.resources.map(_.uri) shouldBe List("test://greeting", "test://whoami") + client + .readResource("test://whoami") + .map: result => + result.contents.head match + case ResourceContents.Text(_, text, _, _) => text shouldBe "employee@example.com" + case other => fail(s"expected text contents, got $other") + + it should "also serve a resource which does not need the principal" in + withSecuredServer(securedServerWithPromptAndResources): 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 "give the principal to the resource template logic" in + withSecuredServer(securedServerWithPromptAndResources): client => + client + .listResourceTemplates() + .flatMap: listed => + listed.resourceTemplates.map(_.uriTemplate) shouldBe List("test://user/{id}") + client + .readResource("test://user/42") + .map: result => + result.contents.head match + case ResourceContents.Text(_, text, _, _) => text shouldBe "42 employee@example.com" + case other => fail(s"expected text contents, got $other") + it should "reject an invalid security input with HTTP 401 before any tool logic runs" in recoverToExceptionIf[McpAuthorizationException] { Future(withSecuredServer(securedServer, token = "wrong")(_ => monad.unit(succeed))).flatten From 86fd286d1f9ba557562d87abe811eb95a0b5a512 Mon Sep 17 00:00:00 2001 From: kubinio123 Date: Tue, 22 Sep 2026 15:44:44 +0200 Subject: [PATCH 11/26] Release 0.6.0 --- README.md | 12 +-- docs/client/quickstart.md | 8 +- docs/conf.py | 4 +- docs/server/quickstart.md | 10 +-- .../scala/examples/both/serverAndClient.scala | 4 +- .../both/streamingServerAndClient.scala | 4 +- .../client/bidirectionalClientOx.scala | 2 +- .../examples/client/everythingClient.scala | 2 +- .../scala/examples/client/stdioClientOx.scala | 2 +- .../scala/examples/server/AdderMcpZio.scala | 2 +- .../main/scala/examples/server/adderMcp.scala | 2 +- .../examples/server/adderWithAuthMcp.scala | 2 +- .../scala/examples/server/stdioMcpOx.scala | 2 +- .../examples/server/streamingMcpOx.scala | 2 +- .../scala/examples/server/twoToolsMcp.scala | 2 +- .../scala/examples/server/weatherMcp.scala | 2 +- generated-docs/out/README.md | 2 +- generated-docs/out/client/quickstart.md | 8 +- generated-docs/out/conf.py | 4 +- generated-docs/out/server/prompts.md | 1 + generated-docs/out/server/quickstart.md | 10 +-- generated-docs/out/server/resources.md | 1 + generated-docs/out/server/tools.md | 3 +- generated-docs/out/server/transport.md | 82 ++++++++++++++++++- 24 files changed, 128 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index c5855c5..aa69e8b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ the MCP protocol with bidirectional communication between server and client. Cur Run a basic MCP server with Netty exposing a simple adder tool: ```scala -//> using dep com.softwaremill.chimp::chimp-server:0.5.2 +//> using dep com.softwaremill.chimp::chimp-server:0.6.0 //> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.13.19 import chimp.server.* @@ -39,7 +39,7 @@ case class AdderInput(a: Int, b: Int) derives Codec, Schema Connect and invoke the tool as an MCP client: ```scala -//> using dep com.softwaremill.chimp::chimp-client:0.5.2 +//> using dep com.softwaremill.chimp::chimp-client:0.6.0 //> using dep com.softwaremill.sttp.client4::core:4.0.24 import chimp.client.* @@ -65,7 +65,7 @@ import io.circe.Json Run a basic MCP server using stdio transport: ```scala -//> using dep com.softwaremill.chimp::chimp-server:0.5.2 +//> using dep com.softwaremill.chimp::chimp-server:0.6.0 import chimp.server.* import chimp.server.transport.ServerStdioTransport @@ -84,7 +84,7 @@ case class EchoInput(message: String) derives Codec, Schema Start the server as a subprocess and invoke the tool as an MCP client: ```scala -//> using dep com.softwaremill.chimp::chimp-client:0.5.2 +//> using dep com.softwaremill.chimp::chimp-client:0.6.0 import chimp.client.* import chimp.client.transport.ClientStdioTransport @@ -107,7 +107,7 @@ With the integration modules (like `chimp-server-ox` and `chimp-client-ox` for o For example, run a basic streaming MCP server using Netty and ox: ```scala -//> using dep com.softwaremill.chimp::chimp-server-ox:0.5.2 +//> using dep com.softwaremill.chimp::chimp-server-ox:0.6.0 import chimp.protocol.LoggingLevel import chimp.server.* @@ -136,7 +136,7 @@ case class WorkInput(steps: Int) derives Codec, Schema Connect and invoke the tool as an MCP client, receiving server's notifications while the tool call is in flight: ```scala -//> using dep com.softwaremill.chimp::chimp-client-ox:0.5.2 +//> using dep com.softwaremill.chimp::chimp-client-ox:0.6.0 import chimp.client.McpClient import chimp.client.notifications.ServerNotification diff --git a/docs/client/quickstart.md b/docs/client/quickstart.md index 089cc5b..d8026fb 100644 --- a/docs/client/quickstart.md +++ b/docs/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.5.2" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-client" % "0.6.0" ``` ## Example: the simplest MCP client @@ -37,17 +37,17 @@ object QuickstartClient: For streaming transports, also add the dependency for your effect system — ZIO: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-client-zio" % "0.5.2" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-client-zio" % "0.6.0" ``` direct-style Ox: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-client-ox" % "0.5.2" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-client-ox" % "0.6.0" ``` or Pekko: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-client-pekko" % "0.5.2" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-client-pekko" % "0.6.0" ``` diff --git a/docs/conf.py b/docs/conf.py index 43aa362..59cd4e8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -58,9 +58,9 @@ # built documents. # # The short X.Y version. -version = u'0.5' +version = u'0.6' # The full version, including alpha/beta/rc tags. -release = u'0.5.2' +release = u'0.6.0' # The language for content autogenerated by Sphinx. language = 'en' diff --git a/docs/server/quickstart.md b/docs/server/quickstart.md index bdc791b..5a98a1a 100644 --- a/docs/server/quickstart.md +++ b/docs/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.5.2" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-server" % "0.6.0" ``` ## Example: the simplest MCP server @@ -13,7 +13,7 @@ libraryDependencies += "com.softwaremill.chimp" %% "chimp-server" % "0.5.2" Below is a self-contained, [scala-cli](https://scala-cli.virtuslab.org)-runnable example: ```scala -//> using dep com.softwaremill.chimp::chimp-server:0.5.2 +//> using dep com.softwaremill.chimp::chimp-server:0.6.0 import chimp.* import sttp.tapir.* @@ -39,19 +39,19 @@ case class AdderInput(a: Int, b: Int) derives io.circe.Codec, Schema For a streaming server that pushes progress and log notifications over SSE, add the dependency for your effect system — ZIO: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-server-zio" % "0.5.2" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-server-zio" % "0.6.0" ``` direct-style Ox: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-server-ox" % "0.5.2" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-server-ox" % "0.6.0" ``` or Pekko: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-server-pekko" % "0.5.2" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-server-pekko" % "0.6.0" ``` More runnable examples live in [`examples/`](https://github.com/softwaremill/chimp/tree/master/examples/src/main/scala/examples). diff --git a/examples/src/main/scala/examples/both/serverAndClient.scala b/examples/src/main/scala/examples/both/serverAndClient.scala index db45921..6ed3b28 100644 --- a/examples/src/main/scala/examples/both/serverAndClient.scala +++ b/examples/src/main/scala/examples/both/serverAndClient.scala @@ -1,5 +1,5 @@ -//> using dep com.softwaremill.chimp::chimp-server:0.5.2 -//> using dep com.softwaremill.chimp::chimp-client:0.5.2 +//> using dep com.softwaremill.chimp::chimp-server:0.6.0 +//> using dep com.softwaremill.chimp::chimp-client:0.6.0 //> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.13.26 //> using dep ch.qos.logback:logback-classic:1.5.37 diff --git a/examples/src/main/scala/examples/both/streamingServerAndClient.scala b/examples/src/main/scala/examples/both/streamingServerAndClient.scala index faf7c8b..7f76df7 100644 --- a/examples/src/main/scala/examples/both/streamingServerAndClient.scala +++ b/examples/src/main/scala/examples/both/streamingServerAndClient.scala @@ -1,5 +1,5 @@ -//> using dep com.softwaremill.chimp::chimp-server-ox:0.5.2 -//> using dep com.softwaremill.chimp::chimp-client-ox:0.5.2 +//> using dep com.softwaremill.chimp::chimp-server-ox:0.6.0 +//> using dep com.softwaremill.chimp::chimp-client-ox:0.6.0 //> using dep ch.qos.logback:logback-classic:1.5.37 package examples.both diff --git a/examples/src/main/scala/examples/client/bidirectionalClientOx.scala b/examples/src/main/scala/examples/client/bidirectionalClientOx.scala index 06e241d..223673e 100644 --- a/examples/src/main/scala/examples/client/bidirectionalClientOx.scala +++ b/examples/src/main/scala/examples/client/bidirectionalClientOx.scala @@ -1,4 +1,4 @@ -//> using dep com.softwaremill.chimp::chimp-client-ox:0.5.2 +//> using dep com.softwaremill.chimp::chimp-client-ox:0.6.0 //> using dep ch.qos.logback:logback-classic:1.5.37 package examples.client diff --git a/examples/src/main/scala/examples/client/everythingClient.scala b/examples/src/main/scala/examples/client/everythingClient.scala index 790a902..4a6be43 100644 --- a/examples/src/main/scala/examples/client/everythingClient.scala +++ b/examples/src/main/scala/examples/client/everythingClient.scala @@ -1,4 +1,4 @@ -//> using dep com.softwaremill.chimp::chimp-client:0.5.2 +//> using dep com.softwaremill.chimp::chimp-client:0.6.0 //> using dep com.softwaremill.sttp.client4::core:4.0.23 //> using dep ch.qos.logback:logback-classic:1.5.32 diff --git a/examples/src/main/scala/examples/client/stdioClientOx.scala b/examples/src/main/scala/examples/client/stdioClientOx.scala index fe75834..2b235c9 100644 --- a/examples/src/main/scala/examples/client/stdioClientOx.scala +++ b/examples/src/main/scala/examples/client/stdioClientOx.scala @@ -1,4 +1,4 @@ -//> using dep com.softwaremill.chimp::chimp-client-ox:0.5.2 +//> using dep com.softwaremill.chimp::chimp-client-ox:0.6.0 //> using dep ch.qos.logback:logback-classic:1.5.37 package examples.client diff --git a/examples/src/main/scala/examples/server/AdderMcpZio.scala b/examples/src/main/scala/examples/server/AdderMcpZio.scala index 4df7a53..df63858 100644 --- a/examples/src/main/scala/examples/server/AdderMcpZio.scala +++ b/examples/src/main/scala/examples/server/AdderMcpZio.scala @@ -1,4 +1,4 @@ -//> using dep com.softwaremill.chimp::chimp-server:0.5.2 +//> using dep com.softwaremill.chimp::chimp-server:0.6.0 //> using dep com.softwaremill.sttp.tapir::tapir-zio-http-server:1.11.50 //> using dep ch.qos.logback:logback-classic:1.5.20 diff --git a/examples/src/main/scala/examples/server/adderMcp.scala b/examples/src/main/scala/examples/server/adderMcp.scala index 780d9a6..c624027 100644 --- a/examples/src/main/scala/examples/server/adderMcp.scala +++ b/examples/src/main/scala/examples/server/adderMcp.scala @@ -1,4 +1,4 @@ -//> using dep com.softwaremill.chimp::chimp-server:0.5.2 +//> using dep com.softwaremill.chimp::chimp-server:0.6.0 //> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.11.50 //> using dep ch.qos.logback:logback-classic:1.5.20 diff --git a/examples/src/main/scala/examples/server/adderWithAuthMcp.scala b/examples/src/main/scala/examples/server/adderWithAuthMcp.scala index 6dd33c1..a8b75c5 100644 --- a/examples/src/main/scala/examples/server/adderWithAuthMcp.scala +++ b/examples/src/main/scala/examples/server/adderWithAuthMcp.scala @@ -1,4 +1,4 @@ -//> using dep com.softwaremill.chimp::chimp-server:0.5.2 +//> using dep com.softwaremill.chimp::chimp-server:0.6.0 //> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.11.50 //> using dep ch.qos.logback:logback-classic:1.5.20 diff --git a/examples/src/main/scala/examples/server/stdioMcpOx.scala b/examples/src/main/scala/examples/server/stdioMcpOx.scala index 140bc39..219e6b9 100644 --- a/examples/src/main/scala/examples/server/stdioMcpOx.scala +++ b/examples/src/main/scala/examples/server/stdioMcpOx.scala @@ -1,4 +1,4 @@ -//> using dep com.softwaremill.chimp::chimp-server-ox:0.5.2 +//> using dep com.softwaremill.chimp::chimp-server-ox:0.6.0 //> using dep ch.qos.logback:logback-classic:1.5.37 package examples.server diff --git a/examples/src/main/scala/examples/server/streamingMcpOx.scala b/examples/src/main/scala/examples/server/streamingMcpOx.scala index 3849895..8d446aa 100644 --- a/examples/src/main/scala/examples/server/streamingMcpOx.scala +++ b/examples/src/main/scala/examples/server/streamingMcpOx.scala @@ -1,4 +1,4 @@ -//> using dep com.softwaremill.chimp::chimp-server-ox:0.5.2 +//> using dep com.softwaremill.chimp::chimp-server-ox:0.6.0 //> using dep ch.qos.logback:logback-classic:1.5.37 package examples.server diff --git a/examples/src/main/scala/examples/server/twoToolsMcp.scala b/examples/src/main/scala/examples/server/twoToolsMcp.scala index 6a1834f..c64fa95 100644 --- a/examples/src/main/scala/examples/server/twoToolsMcp.scala +++ b/examples/src/main/scala/examples/server/twoToolsMcp.scala @@ -1,4 +1,4 @@ -//> using dep com.softwaremill.chimp::chimp-server:0.5.2 +//> using dep com.softwaremill.chimp::chimp-server:0.6.0 //> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.11.50 //> using dep ch.qos.logback:logback-classic:1.5.20 diff --git a/examples/src/main/scala/examples/server/weatherMcp.scala b/examples/src/main/scala/examples/server/weatherMcp.scala index 2f68d33..313331f 100644 --- a/examples/src/main/scala/examples/server/weatherMcp.scala +++ b/examples/src/main/scala/examples/server/weatherMcp.scala @@ -1,4 +1,4 @@ -//> using dep com.softwaremill.chimp::chimp-server:0.5.2 +//> using dep com.softwaremill.chimp::chimp-server:0.6.0 //> using dep com.softwaremill.sttp.client4::core:4.0.8 //> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.11.50 //> using dep ch.qos.logback:logback-classic:1.5.20 diff --git a/generated-docs/out/README.md b/generated-docs/out/README.md index 3df20f6..b144f80 100644 --- a/generated-docs/out/README.md +++ b/generated-docs/out/README.md @@ -21,4 +21,4 @@ pip install -r requirements.txt ## Notes -- `0.5.2` 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/` (don't commit the result). +- `0.6.0` 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/` (don't commit the result). diff --git a/generated-docs/out/client/quickstart.md b/generated-docs/out/client/quickstart.md index c041a98..0526b98 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.5.2" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-client" % "0.6.0" ``` ## Example: the simplest MCP client @@ -37,17 +37,17 @@ object QuickstartClient: For streaming transports, also add the dependency for your effect system — ZIO: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-client-zio" % "0.5.2" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-client-zio" % "0.6.0" ``` direct-style Ox: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-client-ox" % "0.5.2" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-client-ox" % "0.6.0" ``` or Pekko: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-client-pekko" % "0.5.2" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-client-pekko" % "0.6.0" ``` diff --git a/generated-docs/out/conf.py b/generated-docs/out/conf.py index 43aa362..59cd4e8 100644 --- a/generated-docs/out/conf.py +++ b/generated-docs/out/conf.py @@ -58,9 +58,9 @@ # built documents. # # The short X.Y version. -version = u'0.5' +version = u'0.6' # The full version, including alpha/beta/rc tags. -release = u'0.5.2' +release = u'0.6.0' # The language for content autogenerated by Sphinx. language = 'en' diff --git a/generated-docs/out/server/prompts.md b/generated-docs/out/server/prompts.md index edf9c27..3f3afbb 100644 --- a/generated-docs/out/server/prompts.md +++ b/generated-docs/out/server/prompts.md @@ -6,6 +6,7 @@ - `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. + - `handleSecured` (synchronous) or `securedServerLogic` (effectful) — logic that also receives the principal made by the server's security logic; usable only on a secured server — see [transport security](transport.md). - Register prompts with `.addPrompt` / `.addPrompts`. ```scala diff --git a/generated-docs/out/server/quickstart.md b/generated-docs/out/server/quickstart.md index bdc791b..5a98a1a 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.5.2" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-server" % "0.6.0" ``` ## Example: the simplest MCP server @@ -13,7 +13,7 @@ libraryDependencies += "com.softwaremill.chimp" %% "chimp-server" % "0.5.2" Below is a self-contained, [scala-cli](https://scala-cli.virtuslab.org)-runnable example: ```scala -//> using dep com.softwaremill.chimp::chimp-server:0.5.2 +//> using dep com.softwaremill.chimp::chimp-server:0.6.0 import chimp.* import sttp.tapir.* @@ -39,19 +39,19 @@ case class AdderInput(a: Int, b: Int) derives io.circe.Codec, Schema For a streaming server that pushes progress and log notifications over SSE, add the dependency for your effect system — ZIO: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-server-zio" % "0.5.2" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-server-zio" % "0.6.0" ``` direct-style Ox: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-server-ox" % "0.5.2" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-server-ox" % "0.6.0" ``` or Pekko: ```scala -libraryDependencies += "com.softwaremill.chimp" %% "chimp-server-pekko" % "0.5.2" +libraryDependencies += "com.softwaremill.chimp" %% "chimp-server-pekko" % "0.6.0" ``` 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/resources.md b/generated-docs/out/server/resources.md index bb95bb3..35ce841 100644 --- a/generated-docs/out/server/resources.md +++ b/generated-docs/out/server/resources.md @@ -6,6 +6,7 @@ - `handle` — synchronous read logic. - `handleWithHeaders` — synchronous read logic that also receives the request headers. - `serverLogic` — effectful read logic, with the request headers. + - `handleSecured` (synchronous) or `securedServerLogic` (effectful) — logic that also receives the principal made by the server's security logic; usable only on a secured server — see [transport security](transport.md). - Register with `.addResource` / `.addResourceTemplate`. Subscriptions are wired with `.withSubscriptions`. ```scala diff --git a/generated-docs/out/server/tools.md b/generated-docs/out/server/tools.md index fe629b2..3868279 100644 --- a/generated-docs/out/server/tools.md +++ b/generated-docs/out/server/tools.md @@ -8,8 +8,9 @@ - `handle` — synchronous logic from input to `ToolResult`. - `handleWithHeaders` — synchronous logic that also receives the request headers. - `serverLogic` — effectful logic, with the request headers. + - `handleSecured` (synchronous) or `securedServerLogic` (effectful) — logic that also receives the principal made by the server's security logic; usable only on a secured server — see [transport security](transport.md). - A tool that pushes to the client while running (progress, logging) instead uses `streamingServerLogic` — see [server capabilities](capabilities.md). + A tool that pushes to the client while running (progress, logging) instead uses `streamingServerLogic` — see [server capabilities](capabilities.md). A secured, streaming tool uses `securedStreamingServerLogic`, which gives both the principal and the streaming context — see [transport security](transport.md). - Assemble tools into an `McpServer` and call `.endpoint(path)` to create a Tapir endpoint. ```scala diff --git a/generated-docs/out/server/transport.md b/generated-docs/out/server/transport.md index 6041921..242ede0 100644 --- a/generated-docs/out/server/transport.md +++ b/generated-docs/out/server/transport.md @@ -106,6 +106,86 @@ object SecuredMcpServer: NettySyncServer().port(8080).addEndpoint(securedEndpoint).startAndWait() ``` -The result of the security logic does not reach the tool logic. If a tool needs data from the caller, read the request headers with `handleWithHeaders` (or `serverLogic` with headers). +The result of the security logic of `prependSecurity` does not reach the tool, prompt, or resource logic. Use `prependSecurity` if they do not need data from the caller. If they need such data, read the request headers with `handleWithHeaders` (or `serverLogic` with headers), or give them a principal, as below. For all the security inputs - API keys, basic and bearer authorization, OAuth2 flows - see the [Tapir endpoint security documentation](https://tapir.softwaremill.com/en/latest/endpoint/security.html). + +### Giving the security result to tools, prompts, and resources + +To validate the caller one time and give the result to the tool, prompt, or resource logic, use `serverSecurityLogic` (or `serverSecurityLogicPure`, if the logic needs no effect). It takes the same security input and error output as `prependSecurity`, and makes a principal - a value of your own type, such as the identity of the caller. + +The server gives the principal to the logic of each tool, prompt, or resource which you add to it. Define such handlers with `handleSecured`, or with `securedServerLogic` if the logic needs an effect. Handlers which do not need the principal keep their usual logic. The other builders of `McpServer` stay available, so you can configure the server before or after you add the security logic: + +```scala +import chimp.protocol.ResourceContents +import chimp.server.* +import sttp.model.StatusCode +import sttp.shared.Identity +import sttp.tapir.* +import sttp.tapir.server.netty.sync.NettySyncServer + +case class User(email: String) + +object McpServerWithPrincipal: + def main(args: Array[String]): Unit = + val echo = tool("echo").input[String].handle(message => ToolResult.text(message)) + val whoAmI = tool("whoAmI").input[String].handleSecured[User]((_, user) => ToolResult.text(user.email)) + val profile = resource("user://profile") + .handleSecured[User](user => Right(List(ResourceContents.Text(uri = "user://profile", text = user.email)))) + + val securedEndpoint = McpServer[Identity]() + .serverSecurityLogicPure( + auth.bearer[String](), + statusCode(StatusCode.Unauthorized).and(stringBody) + )(token => if token == "s3cret" then Right(User("employee@example.com")) else Left("Invalid token")) + .name("my-mcp-server") + .addTools(echo, whoAmI) + .addResource(profile) + .endpoint(List("mcp")) + + NettySyncServer().port(8080).addEndpoint(securedEndpoint).startAndWait() +``` + +The security logic runs one time for each request, before the server handles the MCP message. If it gives a rejection, the server sends the error output and no tool, prompt, or resource logic runs. This is necessary if the client must get an HTTP status code, because a tool which rejects a call can only give a JSON-RPC error with status code 200. + +### Combining security with streaming + +`.streaming` on a secured server gives a `SecuredStreamingMcpServer`, which accepts streaming tools alongside the plain secured ones. Define such a tool with `securedStreamingServerLogic`, which gives both the principal and the `StreamingServerContext`: + +```scala +import chimp.protocol.LoggingLevel +import chimp.server.* +import chimp.server.ox.OxServerHttpTransport +import chimp.server.transport.SecuredServerStreamingHttpTransport +import io.circe.Json +import sttp.model.StatusCode +import sttp.shared.Identity +import sttp.tapir.* +import sttp.tapir.server.netty.sync.NettySyncServer + +case class User(email: String) + +object McpServerWithPrincipalAndStreaming: + def main(args: Array[String]): Unit = + val whoAmI = tool("whoAmI") + .input[String] + .securedStreamingServerLogic[Identity, User]: (_, user, ctx, _) => + ctx.log(LoggingLevel.Info, Json.fromString(s"called by ${user.email}")) + ToolResult.text(user.email) + + val securedStreamingServer = McpServer[Identity]() + .serverSecurityLogicPure( + auth.bearer[String](), + statusCode(StatusCode.Unauthorized).and(stringBody) + )(token => if token == "s3cret" then Right(User("employee@example.com")) else Left("Invalid token")) + .streaming + .addStreamingTool(whoAmI) + + // `OxServerHttpTransport` is one effect backend's streaming machinery; substitute your own (Pekko, ZIO, ...). + val backend = OxServerHttpTransport(List("mcp")) + val securedEndpoint = SecuredServerStreamingHttpTransport(List("mcp"), backend).serve(securedStreamingServer) + + NettySyncServer().port(8080).addEndpoint(securedEndpoint).startAndWait() +``` + +`SecuredServerStreamingHttpTransport` takes an existing `ServerStreamingHttpTransport` as its source of streaming machinery, rather than extending it, so the same effect-specific backend instance - `OxServerHttpTransport`, `PekkoServerHttpTransport`, `ZioServerHttpTransport` - serves both a plain `StreamingMcpServer` and a `SecuredStreamingMcpServer`. From 62a75b9e4599ecc5471b71f4812544ac24370d04 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Sat, 26 Sep 2026 02:40:18 +0200 Subject: [PATCH 12/26] Update slf4j-api to 2.0.20 (#277) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 970988e..0dbd4fd 100644 --- a/build.sbt +++ b/build.sbt @@ -4,7 +4,7 @@ import com.softwaremill.UpdateVersionInDocs val scalaTestV = "3.2.20" val circeV = "0.14.16" -val slf4jV = "2.0.19" +val slf4jV = "2.0.20" val logbackV = "1.6.3" val tapirV = "1.13.31" val sttpClientV = "4.0.26" From 3ff7c1bf98c669fcddce11dc215ea2e07d79e500 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Sun, 27 Sep 2026 02:46:50 +0200 Subject: [PATCH 13/26] Update ox:core to 1.0.8 (#278) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 0dbd4fd..9653e7a 100644 --- a/build.sbt +++ b/build.sbt @@ -11,7 +11,7 @@ val sttpClientV = "4.0.26" val zioV = "2.1.26" val zioProcessV = "0.8.1" val zioHttpV = "3.11.6" -val oxV = "1.0.7" +val oxV = "1.0.8" val testcontainersScalaV = "0.41.8" val pekkoV = "1.7.0" val conformanceHarnessV = "0.2.0-alpha.11" From 030892b9a7493250c8347f79e1bf8ef8aecd79ae Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Mon, 28 Sep 2026 02:47:41 +0200 Subject: [PATCH 14/26] Update logback-classic to 1.6.4 (#279) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 9653e7a..9b4e0d8 100644 --- a/build.sbt +++ b/build.sbt @@ -5,7 +5,7 @@ import com.softwaremill.UpdateVersionInDocs val scalaTestV = "3.2.20" val circeV = "0.14.16" val slf4jV = "2.0.20" -val logbackV = "1.6.3" +val logbackV = "1.6.4" val tapirV = "1.13.31" val sttpClientV = "4.0.26" val zioV = "2.1.26" From 662933cfc8578969820f232d9ef49c7458c55e14 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Tue, 29 Sep 2026 02:44:01 +0200 Subject: [PATCH 15/26] Update pekko-http-backend, zio to 4.0.27 (#280) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 9b4e0d8..c15b3a9 100644 --- a/build.sbt +++ b/build.sbt @@ -7,7 +7,7 @@ val circeV = "0.14.16" val slf4jV = "2.0.20" val logbackV = "1.6.4" val tapirV = "1.13.31" -val sttpClientV = "4.0.26" +val sttpClientV = "4.0.27" val zioV = "2.1.26" val zioProcessV = "0.8.1" val zioHttpV = "3.11.6" From ad8e5cbc8ab930cd48b4ca1f93c38f41d81f3366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pier=C5=9Bci=C5=84ski?= <30854591+pierscin@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:58:49 +0200 Subject: [PATCH 16/26] Introducing sbt 2.0.9 (#272) * Introducing sbt 2.0.9 * use bare settings * Define compileDocs on the root project only Co-Authored-By: Claude Opus 5.5 * Pin setup-sbt to v1.5.10 (defaults to sbt 2.0.9) * Reordering --------- Co-authored-by: Claude Opus 5.5 --- .devcontainer/Dockerfile | 4 +-- .github/workflows/ci.yml | 8 ++--- .scalafmt.conf | 7 +++- build.sbt | 69 ++++++++++++++++------------------------ project/build.properties | 2 +- project/plugins.sbt | 3 +- 6 files changed, 41 insertions(+), 52 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index d4aa44e..0f8eb9c 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -7,10 +7,10 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* # Install sbt -RUN curl -fL "https://github.com/sbt/sbt/releases/download/v1.12.0/sbt-1.12.0.tgz" | tar xz -C /tmp && \ +RUN curl -fL "https://github.com/sbt/sbt/releases/download/v2.0.9/sbt-2.0.9.tgz" | tar xz -C /tmp && \ mv /tmp/sbt/bin/sbt /usr/local/bin/ && \ mv /tmp/sbt/bin/sbt-launch.jar /usr/local/bin/ && \ chmod +x /usr/local/bin/sbt && \ rm -rf /tmp/sbt -ENV DEVCONTAINER=true \ No newline at end of file +ENV DEVCONTAINER=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 614be8f..09324bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: distribution: 'temurin' java-version: 21 cache: 'sbt' - - uses: sbt/setup-sbt@3e125ece5c3e5248e18da9ed8d2cce3d335ec8dd # v1, specifically v1.1.14 + - uses: sbt/setup-sbt@ce95da69b39609ea153bad087708da5f37366897 # v1, specifically v1.5.10 - name: Set up Node uses: actions/setup-node@v4 with: @@ -45,13 +45,13 @@ jobs: - name: Compile documentation run: sbt -v compileDocs - name: Unit tests - run: sbt -v test + run: sbt -v testFull - name: Client conformance tests run: sbt -v "clientConformance/conformance client --requirements 2025-11-25" - name: Server conformance tests run: sbt -v "serverConformance/conformance server --requirements 2025-11-25" - name: Integration tests - run: sbt -v "testOnly -- -n Integration" + run: sbt -v "testOnly * -- -n Integration" - uses: actions/upload-artifact@v5 if: success() || failure() with: @@ -76,4 +76,4 @@ jobs: if: github.event.pull_request.user.login == 'softwaremill-ci' needs: [ build, label ] uses: softwaremill/github-actions-workflows/.github/workflows/auto-merge.yml@main - secrets: inherit \ No newline at end of file + secrets: inherit diff --git a/.scalafmt.conf b/.scalafmt.conf index 2389649..eb2cdaf 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,9 @@ version = 3.11.5 maxColumn = 140 rewrite.rules = [RedundantBraces, RedundantParens, SortImports] -runner.dialect = scala3 \ No newline at end of file +runner.dialect = scala3 +fileOverride { + "glob:**/*.sbt" { + runner.dialect = scala3 + } +} diff --git a/build.sbt b/build.sbt index c15b3a9..ef05673 100644 --- a/build.sbt +++ b/build.sbt @@ -16,26 +16,27 @@ val testcontainersScalaV = "0.41.8" val pekkoV = "1.7.0" val conformanceHarnessV = "0.2.0-alpha.11" -lazy val verifyExamplesCompileUsingScalaCli = taskKey[Unit]("Verify that each example compiles using Scala CLI") +lazy val verifyExamplesCompileUsingScalaCli: TaskKey[Unit] = taskKey[Unit]("Verify that each example compiles using Scala CLI") +lazy val compileDocs: TaskKey[Unit] = taskKey[Unit]("Compiles docs module throwing away its output") -lazy val commonSettings = commonSmlBuildSettings ++ ossPublishSettings ++ Seq( - organization := "com.softwaremill.chimp", - scalaVersion := "3.3.8", - Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.Assertion:s", - Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.compatible.Assertion:s", - Test / test / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-l", "Integration"), - Test / parallelExecution := false, - scalacOptions ++= Seq("-Wunused:all", "-Werror", "-Yfuture-lazy-vals", "-java-output-version", "11") -) +commonSmlBuildSettings +ossPublishSettings + +organization := "com.softwaremill.chimp" +scalaVersion := "3.3.8" +Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.Assertion:s" +Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.compatible.Assertion:s" +Test / test / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-l", "Integration") +Test / parallelExecution := false +scalacOptions ++= Seq("-Wunused:all", "-Werror", "-Yfuture-lazy-vals", "-java-output-version", "11") val scalaTest = "org.scalatest" %% "scalatest" % scalaTestV % Test lazy val root = (project in file(".")) - .settings(commonSettings: _*) .settings( publishArtifact := false, name := "chimp", - updateDocs := Def.taskDyn { + updateDocs := Def.uncached(Def.taskDyn { val log = sLog.value val org = organization.value val releaseVersion = version.value @@ -46,7 +47,8 @@ lazy val root = (project in file(".")) (docs / mdoc).toTask("").value files ++ Seq(file("generated-docs/out")) } - }.value + }.value), + compileDocs := (docs / mdoc).toTask(" --out target/chimp-docs").value ) .aggregate( core, @@ -66,7 +68,6 @@ lazy val root = (project in file(".")) val conformance = inputKey[Unit]("Run the MCP conformance harness via npx, extra args are passed through") lazy val core: Project = (project in file("core")) - .settings(commonSettings: _*) .settings( name := "chimp-core", libraryDependencies ++= Seq( @@ -80,7 +81,6 @@ lazy val core: Project = (project in file("core")) ) lazy val server: Project = (project in file("server")) - .settings(commonSettings: _*) .settings( name := "chimp-server", libraryDependencies ++= Seq( @@ -96,7 +96,6 @@ lazy val server: Project = (project in file("server")) .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( @@ -111,7 +110,6 @@ lazy val serverZio: Project = (project in file("server-streaming/server-zio")) .dependsOn(server % "compile->compile;test->test", clientZio % "test->compile") lazy val serverOx: Project = (project in file("server-streaming/server-ox")) - .settings(commonSettings: _*) .settings( name := "chimp-server-ox", libraryDependencies ++= Seq( @@ -123,7 +121,6 @@ lazy val serverOx: Project = (project in file("server-streaming/server-ox")) .dependsOn(server % "compile->compile;test->test", clientOx % "test->compile") lazy val serverPekko: Project = (project in file("server-streaming/server-pekko")) - .settings(commonSettings: _*) .settings( name := "chimp-server-pekko", libraryDependencies ++= Seq( @@ -135,7 +132,6 @@ lazy val serverPekko: Project = (project in file("server-streaming/server-pekko" .dependsOn(server % "compile->compile;test->test", clientPekko % "test->compile") lazy val client: Project = (project in file("client")) - .settings(commonSettings: _*) .settings( name := "chimp-client", libraryDependencies ++= Seq( @@ -149,7 +145,6 @@ lazy val client: Project = (project in file("client")) .dependsOn(core) lazy val clientZio: Project = (project in file("client-streaming/client-zio")) - .settings(commonSettings: _*) .settings( name := "chimp-client-zio", libraryDependencies ++= Seq( @@ -163,7 +158,6 @@ lazy val clientZio: Project = (project in file("client-streaming/client-zio")) .dependsOn(client % "compile->compile;test->test") lazy val clientOx: Project = (project in file("client-streaming/client-ox")) - .settings(commonSettings: _*) .settings( name := "chimp-client-ox", libraryDependencies ++= Seq( @@ -175,7 +169,6 @@ lazy val clientOx: Project = (project in file("client-streaming/client-ox")) .dependsOn(client % "compile->compile;test->test") lazy val clientPekko: Project = (project in file("client-streaming/client-pekko")) - .settings(commonSettings: _*) .settings( name := "chimp-client-pekko", libraryDependencies ++= Seq( @@ -188,7 +181,6 @@ lazy val clientPekko: Project = (project in file("client-streaming/client-pekko" .dependsOn(client % "compile->compile;test->test") lazy val examples = (project in file("examples")) - .settings(commonSettings: _*) .settings( publishArtifact := false, name := "examples", @@ -198,7 +190,7 @@ lazy val examples = (project in file("examples")) "com.softwaremill.sttp.tapir" %% "tapir-zio-http-server" % tapirV, "ch.qos.logback" % "logback-classic" % logbackV ), - verifyExamplesCompileUsingScalaCli := VerifyExamplesCompileUsingScalaCli(sLog.value, sourceDirectory.value) + verifyExamplesCompileUsingScalaCli := Def.uncached(VerifyExamplesCompileUsingScalaCli(sLog.value, sourceDirectory.value)) ) .dependsOn(server, serverOx, client, clientOx) @@ -206,13 +198,13 @@ import sbtassembly.AssemblyPlugin.autoImport.* lazy val assemblySettings = Seq( assembly / assemblyMergeStrategy := { - case PathList("META-INF", "MANIFEST.MF") => MergeStrategy.discard - case PathList("META-INF", "INDEX.LIST") => MergeStrategy.discard - case PathList("META-INF", "DEPENDENCIES") => MergeStrategy.discard - case PathList("META-INF", "services", _ @_*) => MergeStrategy.concat - case PathList("META-INF", xs @ _*) if xs.lastOption.exists(s => s.endsWith(".SF") || s.endsWith(".DSA") || s.endsWith(".RSA")) => + case PathList("META-INF", "MANIFEST.MF") => MergeStrategy.discard + case PathList("META-INF", "INDEX.LIST") => MergeStrategy.discard + case PathList("META-INF", "DEPENDENCIES") => MergeStrategy.discard + case PathList("META-INF", "services", _*) => MergeStrategy.concat + case PathList("META-INF", xs*) if xs.lastOption.exists(s => s.endsWith(".SF") || s.endsWith(".DSA") || s.endsWith(".RSA")) => MergeStrategy.discard - case PathList("META-INF", _ @_*) => MergeStrategy.first + case PathList("META-INF", _*) => MergeStrategy.first case PathList("module-info.class") => MergeStrategy.discard case _ => MergeStrategy.first } @@ -220,8 +212,7 @@ lazy val assemblySettings = Seq( lazy val serverConformance = (project in file("server-conformance")) .enablePlugins(AssemblyPlugin) - .settings(commonSettings: _*) - .settings(assemblySettings: _*) + .settings(assemblySettings*) .settings( publishArtifact := false, name := "server-conformance", @@ -236,7 +227,7 @@ lazy val serverConformance = (project in file("server-conformance")) import scala.sys.process.* val args = spaceDelimited("").parsed.toList - val jar = assembly.value + val jar = fileConverter.value.toPath(assembly.value).toFile val rootDir = (LocalRootProject / baseDirectory).value val baseline = (rootDir / "conformance-baseline.yml").getAbsolutePath val log = streams.value.log @@ -256,7 +247,7 @@ lazy val serverConformance = (project in file("server-conformance")) more = reader.readLine() } } catch { - case t: Throwable => urlPromise.tryFailure(t) + case t: Throwable => urlPromise.tryFailure(t): Unit } }) readerThread.setDaemon(true) @@ -294,8 +285,7 @@ lazy val serverConformance = (project in file("server-conformance")) lazy val clientConformance = (project in file("client-conformance")) .enablePlugins(AssemblyPlugin) - .settings(commonSettings: _*) - .settings(assemblySettings: _*) + .settings(assemblySettings*) .settings( publishArtifact := false, name := "client-conformance", @@ -309,7 +299,7 @@ lazy val clientConformance = (project in file("client-conformance")) import scala.sys.process.* val args = spaceDelimited("").parsed.toList - val jar = assembly.value + val jar = fileConverter.value.toPath(assembly.value).toFile val baseDir = baseDirectory.value val rootDir = (LocalRootProject / baseDirectory).value val wrapper = (baseDir / "bin" / "chimp-conformance-client").getAbsolutePath @@ -321,13 +311,8 @@ lazy val clientConformance = (project in file("client-conformance")) ) .dependsOn(clientOx) -val compileDocs: TaskKey[Unit] = taskKey[Unit]("Compiles docs module throwing away its output") -compileDocs := - (docs / mdoc).toTask(" --out target/chimp-docs").value - lazy val docs: Project = (project in file("generated-docs")) .enablePlugins(MdocPlugin) - .settings(commonSettings) .settings( mdocIn := file("docs"), moduleName := "chimp-docs", diff --git a/project/build.properties b/project/build.properties index f693be1..ddca111 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.13.0 \ No newline at end of file +sbt.version=2.0.9 diff --git a/project/plugins.sbt b/project/plugins.sbt index ebf986a..11c8dd1 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,7 +1,6 @@ -val sbtSoftwareMillVersion = "2.1.2" +val sbtSoftwareMillVersion = "3.0.1" addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-common" % sbtSoftwareMillVersion) addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-publish" % sbtSoftwareMillVersion) addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.9.2") -addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0") addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.5.0") From 8ad778a9d37b8bbcdfd69518e36bf9604263a651 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Thu, 1 Oct 2026 03:31:17 +0200 Subject: [PATCH 17/26] Update tapir-apispec-docs, tapir-core, ... to 1.13.32 (#281) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index ef05673..377b7d7 100644 --- a/build.sbt +++ b/build.sbt @@ -6,7 +6,7 @@ val scalaTestV = "3.2.20" val circeV = "0.14.16" val slf4jV = "2.0.20" val logbackV = "1.6.4" -val tapirV = "1.13.31" +val tapirV = "1.13.32" val sttpClientV = "4.0.27" val zioV = "2.1.26" val zioProcessV = "0.8.1" From 8a493de109156ae7f8fa2853a212692429fc1262 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Mon, 5 Oct 2026 02:45:16 +0200 Subject: [PATCH 18/26] Update ox:core to 1.0.9 (#284) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 377b7d7..b184146 100644 --- a/build.sbt +++ b/build.sbt @@ -11,7 +11,7 @@ val sttpClientV = "4.0.27" val zioV = "2.1.26" val zioProcessV = "0.8.1" val zioHttpV = "3.11.6" -val oxV = "1.0.8" +val oxV = "1.0.9" val testcontainersScalaV = "0.41.8" val pekkoV = "1.7.0" val conformanceHarnessV = "0.2.0-alpha.11" From 92bcdd9a2e5df58c57b1ea5a6989c98800cb26fd Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Mon, 5 Oct 2026 02:45:19 +0200 Subject: [PATCH 19/26] Update logback-classic to 1.6.5 (#282) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index b184146..ac191dd 100644 --- a/build.sbt +++ b/build.sbt @@ -5,7 +5,7 @@ import com.softwaremill.UpdateVersionInDocs val scalaTestV = "3.2.20" val circeV = "0.14.16" val slf4jV = "2.0.20" -val logbackV = "1.6.4" +val logbackV = "1.6.5" val tapirV = "1.13.32" val sttpClientV = "4.0.27" val zioV = "2.1.26" From 4e8c8757eb4ac39eefd993299a5d98feae9eea5a Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Tue, 6 Oct 2026 03:08:18 +0200 Subject: [PATCH 20/26] Update sbt to 2.0.10 (#285) --- project/build.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/build.properties b/project/build.properties index ddca111..570ab8e 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=2.0.9 +sbt.version=2.0.10 From fd65fc2b83baf4fda76b2fc2bdaf09b949624a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pier=C5=9Bci=C5=84ski?= <30854591+pierscin@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:50:55 +0200 Subject: [PATCH 21/26] Update sbt to 2.0.10 (#286) Update sbt to 2.0.10 in devcontainer Co-authored-by: Claude Opus 5.5 --- .devcontainer/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 0f8eb9c..317964c 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -7,7 +7,7 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* # Install sbt -RUN curl -fL "https://github.com/sbt/sbt/releases/download/v2.0.9/sbt-2.0.9.tgz" | tar xz -C /tmp && \ +RUN curl -fL "https://github.com/sbt/sbt/releases/download/v2.0.10/sbt-2.0.10.tgz" | tar xz -C /tmp && \ mv /tmp/sbt/bin/sbt /usr/local/bin/ && \ mv /tmp/sbt/bin/sbt-launch.jar /usr/local/bin/ && \ chmod +x /usr/local/bin/sbt && \ From eb09ab066760475e38829a8c5919e419b4140b97 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Thu, 8 Oct 2026 02:42:06 +0200 Subject: [PATCH 22/26] Update pekko-actor-typed, pekko-stream to 1.7.1 (#288) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index ac191dd..c85692a 100644 --- a/build.sbt +++ b/build.sbt @@ -13,7 +13,7 @@ val zioProcessV = "0.8.1" val zioHttpV = "3.11.6" val oxV = "1.0.9" val testcontainersScalaV = "0.41.8" -val pekkoV = "1.7.0" +val pekkoV = "1.7.1" val conformanceHarnessV = "0.2.0-alpha.11" lazy val verifyExamplesCompileUsingScalaCli: TaskKey[Unit] = taskKey[Unit]("Verify that each example compiles using Scala CLI") From da76f276e896e283a67070bf8358c22d0121c562 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Sat, 10 Oct 2026 03:05:21 +0200 Subject: [PATCH 23/26] Update circe-core, circe-generic, ... to 0.14.17 (#290) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index c85692a..482d300 100644 --- a/build.sbt +++ b/build.sbt @@ -3,7 +3,7 @@ import com.softwaremill.SbtSoftwareMillCommon.commonSmlBuildSettings import com.softwaremill.UpdateVersionInDocs val scalaTestV = "3.2.20" -val circeV = "0.14.16" +val circeV = "0.14.17" val slf4jV = "2.0.20" val logbackV = "1.6.5" val tapirV = "1.13.32" From 07661b49ce599222dd99bad79ce53841dae1d88d Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Sun, 11 Oct 2026 02:48:16 +0200 Subject: [PATCH 24/26] Update tapir-apispec-docs, tapir-core, ... to 1.13.33 (#291) --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 482d300..336bdb4 100644 --- a/build.sbt +++ b/build.sbt @@ -6,7 +6,7 @@ val scalaTestV = "3.2.20" val circeV = "0.14.17" val slf4jV = "2.0.20" val logbackV = "1.6.5" -val tapirV = "1.13.32" +val tapirV = "1.13.33" val sttpClientV = "4.0.27" val zioV = "2.1.26" val zioProcessV = "0.8.1" From a96d8c4cccc17e505e755b61901903c42b4e0b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pier=C5=9Bci=C5=84ski?= <30854591+pierscin@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:00:38 +0200 Subject: [PATCH 25/26] Migrate to Scala 3.9.0 LTS (#287) * Migrate to Scala 3.9.0 LTS - scalaVersion 3.3.8 -> 3.9.0 - -java-output-version 11 -> 17 (Scala 3.9 requires JDK 17) - drop -Yfuture-lazy-vals (rejected as a bad option by 3.9) - Scala Steward: pin scala3-library to 3.9. Co-Authored-By: Claude Opus 5.5 * Remove redundant eta-expansion underscores (Scala 3.9 warning) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .scala-steward.conf | 2 +- build.sbt | 4 ++-- .../scala/chimp/client/transport/ClientStdioTransport.scala | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.scala-steward.conf b/.scala-steward.conf index 4d97bf0..e546b9d 100644 --- a/.scala-steward.conf +++ b/.scala-steward.conf @@ -1,3 +1,3 @@ updates.pin = [ - { groupId = "org.scala-lang", artifactId = "scala3-library", version = "3.3." } + { groupId = "org.scala-lang", artifactId = "scala3-library", version = "3.9." } ] diff --git a/build.sbt b/build.sbt index 336bdb4..849da2b 100644 --- a/build.sbt +++ b/build.sbt @@ -23,12 +23,12 @@ commonSmlBuildSettings ossPublishSettings organization := "com.softwaremill.chimp" -scalaVersion := "3.3.8" +scalaVersion := "3.9.0" Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.Assertion:s" Test / scalacOptions += "-Wconf:msg=unused value of type org.scalatest.compatible.Assertion:s" Test / test / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-l", "Integration") Test / parallelExecution := false -scalacOptions ++= Seq("-Wunused:all", "-Werror", "-Yfuture-lazy-vals", "-java-output-version", "11") +scalacOptions ++= Seq("-Wunused:all", "-Werror", "-java-output-version", "17") val scalaTest = "org.scalatest" %% "scalatest" % scalaTestV % Test diff --git a/client/src/main/scala/chimp/client/transport/ClientStdioTransport.scala b/client/src/main/scala/chimp/client/transport/ClientStdioTransport.scala index db4f5f1..49dfa19 100644 --- a/client/src/main/scala/chimp/client/transport/ClientStdioTransport.scala +++ b/client/src/main/scala/chimp/client/transport/ClientStdioTransport.scala @@ -57,8 +57,8 @@ final class ClientStdioTransport( private val incomingHandler = AtomicReference[JSONRPCMessage => Identity[Unit]](_ => ()) private val closed = AtomicBoolean(false) - private val readerThread = startDaemon("mcp-stdio-reader", readLoop _) - private val stderrThread = startDaemon("mcp-stdio-stderr", drainStderr _) + private val readerThread = startDaemon("mcp-stdio-reader", readLoop) + private val stderrThread = startDaemon("mcp-stdio-stderr", drainStderr) private def startDaemon(name: String, body: () => Unit): Thread = val thread = Thread(() => body()) From ad45de5d1c5999c32a6b374f038bdaf5f6604458 Mon Sep 17 00:00:00 2001 From: softwaremill-ci Date: Wed, 14 Oct 2026 02:37:09 +0200 Subject: [PATCH 26/26] Update sbt-softwaremill-common, ... to 3.0.2 (#292) --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 11c8dd1..9f0c45c 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,4 +1,4 @@ -val sbtSoftwareMillVersion = "3.0.1" +val sbtSoftwareMillVersion = "3.0.2" addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-common" % sbtSoftwareMillVersion) addSbtPlugin("com.softwaremill.sbt-softwaremill" % "sbt-softwaremill-publish" % sbtSoftwareMillVersion)