From 00412f2bb5798a8f5845b9f33cc8b5f92d3bb259 Mon Sep 17 00:00:00 2001 From: Alexandre-Roussel48 Date: Sun, 16 Nov 2025 00:18:50 +0100 Subject: [PATCH 1/7] chore: ignore ivy folder --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9e79245..ff8001a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ local.sbt # Metals .bloop/ .metals/ +.ivy/ metals.sbt # IDEA From c2bfb62b8ce8760bd0defc51db801ebd16e6227e Mon Sep 17 00:00:00 2001 From: Alexandre-Roussel48 Date: Sun, 16 Nov 2025 00:19:27 +0100 Subject: [PATCH 2/7] chore(sbt): added sbt config --- build.sbt | 19 +++++++++++++++++++ project/build.properties | 1 + project/plugins.sbt | 1 + 3 files changed, 21 insertions(+) create mode 100644 build.sbt create mode 100644 project/build.properties create mode 100644 project/plugins.sbt diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..90575c5 --- /dev/null +++ b/build.sbt @@ -0,0 +1,19 @@ +val scala3Version = "3.7.4" + +lazy val root = project + .in(file(".")) + .settings( + name := "almcp", + version := "0.1.0-SNAPSHOT", + + scalaVersion := scala3Version, + + libraryDependencies += "dev.zio" %% "zio" % "2.1.0", + libraryDependencies += "dev.zio" %% "zio-streams" % "2.1.0", + libraryDependencies += "dev.zio" %% "zio-test" % "2.1.0" % Test, + libraryDependencies += "dev.zio" %% "zio-test-sbt" % "2.1.0" % Test, + libraryDependencies += "io.circe" %% "circe-core" % "0.14.9", + libraryDependencies += "io.circe" %% "circe-generic" % "0.14.9", + libraryDependencies += "io.circe" %% "circe-parser" % "0.14.9", + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..01a16ed --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.11.7 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..f8ea5d0 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1") From 8c1861e88730e31df7388f464340890f453d9737 Mon Sep 17 00:00:00 2001 From: Alexandre-Roussel48 Date: Sun, 16 Nov 2025 00:20:12 +0100 Subject: [PATCH 3/7] feat: added code --- src/main/scala/mcp/Main.scala | 51 ++++++ src/main/scala/mcp/config/ServerConfig.scala | 43 +++++ .../mcp/model/jsonrpc/JsonRpcError.scala | 22 +++ .../scala/mcp/model/jsonrpc/JsonRpcId.scala | 21 +++ .../mcp/model/jsonrpc/JsonRpcMessage.scala | 71 ++++++++ .../scala/mcp/model/mcp/Capabilities.scala | 70 ++++++++ src/main/scala/mcp/model/mcp/Initialize.scala | 40 +++++ .../scala/mcp/model/mcp/ToolsProtocol.scala | 52 ++++++ src/main/scala/mcp/router/ErrorMapping.scala | 40 +++++ .../scala/mcp/router/InitializeHandler.scala | 77 +++++++++ src/main/scala/mcp/router/RequestRouter.scala | 61 +++++++ src/main/scala/mcp/router/ToolsHandler.scala | 63 +++++++ .../scala/mcp/session/SessionService.scala | 39 +++++ src/main/scala/mcp/session/SessionState.scala | 18 ++ .../scala/mcp/tools/ArithmeticTools.scala | 18 ++ src/main/scala/mcp/tools/LogicTools.scala | 29 ++++ src/main/scala/mcp/tools/Tool.scala | 16 ++ src/main/scala/mcp/tools/ToolRegistry.scala | 23 +++ .../scala/mcp/tools/arithmetic/AddTool.scala | 23 +++ .../mcp/tools/arithmetic/DivideTool.scala | 23 +++ .../mcp/tools/arithmetic/MultiplyTool.scala | 23 +++ .../mcp/tools/arithmetic/SubtractTool.scala | 23 +++ .../scala/mcp/tools/common/BinaryArgs.scala | 9 + .../mcp/tools/common/NumericPairSchema.scala | 13 ++ src/main/scala/mcp/tools/logic/AndTool.scala | 23 +++ .../scala/mcp/tools/logic/EqualsTool.scala | 23 +++ .../mcp/tools/logic/GreaterThanTool.scala | 23 +++ .../scala/mcp/tools/logic/LessThanTool.scala | 24 +++ src/main/scala/mcp/tools/logic/OrTool.scala | 23 +++ src/main/scala/mcp/tools/logic/XorTool.scala | 23 +++ .../scala/mcp/transport/StdioTransport.scala | 24 +++ src/main/scala/mcp/transport/Transport.scala | 15 ++ src/main/scala/mcp/util/DebugLogging.scala | 11 ++ .../scala/mcp/test/JsonRpcCodecSpec.scala | 112 ++++++++++++ .../scala/mcp/test/RequestRouterSpec.scala | 160 ++++++++++++++++++ .../scala/mcp/test/SessionServiceSpec.scala | 65 +++++++ .../scala/mcp/test/TestSessionLayer.scala | 36 ++++ src/test/scala/mcp/test/ToolsSpec.scala | 115 +++++++++++++ 38 files changed, 1545 insertions(+) create mode 100644 src/main/scala/mcp/Main.scala create mode 100644 src/main/scala/mcp/config/ServerConfig.scala create mode 100644 src/main/scala/mcp/model/jsonrpc/JsonRpcError.scala create mode 100644 src/main/scala/mcp/model/jsonrpc/JsonRpcId.scala create mode 100644 src/main/scala/mcp/model/jsonrpc/JsonRpcMessage.scala create mode 100644 src/main/scala/mcp/model/mcp/Capabilities.scala create mode 100644 src/main/scala/mcp/model/mcp/Initialize.scala create mode 100644 src/main/scala/mcp/model/mcp/ToolsProtocol.scala create mode 100644 src/main/scala/mcp/router/ErrorMapping.scala create mode 100644 src/main/scala/mcp/router/InitializeHandler.scala create mode 100644 src/main/scala/mcp/router/RequestRouter.scala create mode 100644 src/main/scala/mcp/router/ToolsHandler.scala create mode 100644 src/main/scala/mcp/session/SessionService.scala create mode 100644 src/main/scala/mcp/session/SessionState.scala create mode 100644 src/main/scala/mcp/tools/ArithmeticTools.scala create mode 100644 src/main/scala/mcp/tools/LogicTools.scala create mode 100644 src/main/scala/mcp/tools/Tool.scala create mode 100644 src/main/scala/mcp/tools/ToolRegistry.scala create mode 100644 src/main/scala/mcp/tools/arithmetic/AddTool.scala create mode 100644 src/main/scala/mcp/tools/arithmetic/DivideTool.scala create mode 100644 src/main/scala/mcp/tools/arithmetic/MultiplyTool.scala create mode 100644 src/main/scala/mcp/tools/arithmetic/SubtractTool.scala create mode 100644 src/main/scala/mcp/tools/common/BinaryArgs.scala create mode 100644 src/main/scala/mcp/tools/common/NumericPairSchema.scala create mode 100644 src/main/scala/mcp/tools/logic/AndTool.scala create mode 100644 src/main/scala/mcp/tools/logic/EqualsTool.scala create mode 100644 src/main/scala/mcp/tools/logic/GreaterThanTool.scala create mode 100644 src/main/scala/mcp/tools/logic/LessThanTool.scala create mode 100644 src/main/scala/mcp/tools/logic/OrTool.scala create mode 100644 src/main/scala/mcp/tools/logic/XorTool.scala create mode 100644 src/main/scala/mcp/transport/StdioTransport.scala create mode 100644 src/main/scala/mcp/transport/Transport.scala create mode 100644 src/main/scala/mcp/util/DebugLogging.scala create mode 100644 src/test/scala/mcp/test/JsonRpcCodecSpec.scala create mode 100644 src/test/scala/mcp/test/RequestRouterSpec.scala create mode 100644 src/test/scala/mcp/test/SessionServiceSpec.scala create mode 100644 src/test/scala/mcp/test/TestSessionLayer.scala create mode 100644 src/test/scala/mcp/test/ToolsSpec.scala diff --git a/src/main/scala/mcp/Main.scala b/src/main/scala/mcp/Main.scala new file mode 100644 index 0000000..2422e53 --- /dev/null +++ b/src/main/scala/mcp/Main.scala @@ -0,0 +1,51 @@ +package mcp + +import zio._ +import mcp.transport.{Transport, StdioTransport} +import mcp.session.SessionService +import mcp.config.ServerConfig +import mcp.tools.ToolRegistry +import mcp.router.{InitializeHandler, RequestRouter, ToolsHandler} +import mcp.model.jsonrpc.JsonRpcMessage +import io.circe.syntax.* +import io.circe.parser.decode + +object Main extends ZIOAppDefault: + val appLayer: ZLayer[Any, Throwable, Transport & RequestRouter] = + ZLayer.make[Transport & RequestRouter]( + ServerConfig.layer, + SessionService.layer, + ToolRegistry.layer, + InitializeHandler.layer, + ToolsHandler.layer, + RequestRouter.layer, + StdioTransport.layer + ) + + override def run: ZIO[Any, Throwable, Unit] = + val program = + for + transport <- ZIO.service[Transport] + router <- ZIO.service[RequestRouter] + _ <- loop(transport, router) + yield () + + program.provide(appLayer) + + private def loop( + transport: Transport, + router: RequestRouter + ): ZIO[Any, Throwable, Unit] = + transport.read.runForeach { line => + decode[JsonRpcMessage](line) match + case Left(_) => + ZIO.unit + case Right(msg) => + router.handle(msg).flatMap { + case Some(response) => + val json = response.asJson.noSpaces + transport.write(json) + case None => + ZIO.unit + } + } diff --git a/src/main/scala/mcp/config/ServerConfig.scala b/src/main/scala/mcp/config/ServerConfig.scala new file mode 100644 index 0000000..4cf5219 --- /dev/null +++ b/src/main/scala/mcp/config/ServerConfig.scala @@ -0,0 +1,43 @@ +package mcp.config + +import mcp.model.mcp.{ServerCapabilities, ToolsServerCapabilities} +import zio._ +import io.circe.{Decoder, Encoder} +import io.circe.generic.semiauto._ +import mcp.model.mcp.given + +final case class ServerConfig( + protocolVersion: String, + serverInfo: ServerInfo, + capabilities: ServerCapabilities +) + +final case class ServerInfo( + name: String, + title: String, + version: String +) + +object ServerConfig: + val layer: ZLayer[Any, Nothing, ServerConfig] = ZLayer.succeed( + ServerConfig( + protocolVersion = "2025-06-18", + serverInfo = ServerInfo( + name = "ALMCP", + title = "Arithmetic & Logic Model for Code Processing", + version = "1.0.0" + ), + capabilities = ServerCapabilities( + tools = Some(ToolsServerCapabilities(false)), + logging = None, + resources = None, + prompts = None + ) + ) + ) + +given Encoder[ServerInfo] = deriveEncoder +given Decoder[ServerInfo] = deriveDecoder + +given Encoder[ServerConfig] = deriveEncoder +given Decoder[ServerConfig] = deriveDecoder diff --git a/src/main/scala/mcp/model/jsonrpc/JsonRpcError.scala b/src/main/scala/mcp/model/jsonrpc/JsonRpcError.scala new file mode 100644 index 0000000..454d292 --- /dev/null +++ b/src/main/scala/mcp/model/jsonrpc/JsonRpcError.scala @@ -0,0 +1,22 @@ +package mcp.model.jsonrpc + +import io.circe.{Decoder, Encoder, Json} +import io.circe.generic.semiauto._ + +final case class JsonRpcError( + code: Int, + message: String, + data: Option[Json] +) + +given Encoder[JsonRpcError] = deriveEncoder + +given Decoder[JsonRpcError] = deriveDecoder + +object JsonRpcError: + val ParseError: JsonRpcError = JsonRpcError(-32700, "Parse Error", None) + val InvalidRequest: JsonRpcError = JsonRpcError(-32600, "Invalid Request", None) + val MethodNotFound: JsonRpcError = JsonRpcError(-32601, "Method Not Found", None) + val InvalidParams: JsonRpcError = JsonRpcError(-32602, "Invalid Params", None) + val InternalError: JsonRpcError = JsonRpcError(-32603, "Internal Error", None) + val ServerError: JsonRpcError = JsonRpcError(-32000, "Server Error", None) diff --git a/src/main/scala/mcp/model/jsonrpc/JsonRpcId.scala b/src/main/scala/mcp/model/jsonrpc/JsonRpcId.scala new file mode 100644 index 0000000..3c29b7b --- /dev/null +++ b/src/main/scala/mcp/model/jsonrpc/JsonRpcId.scala @@ -0,0 +1,21 @@ +package mcp.model.jsonrpc + +import io.circe.{Decoder, Encoder, Json} +import io.circe.generic.semiauto._ + +enum JsonRpcId: + case StringId(value: String) + case NumberId(value: Long) + +given Encoder[JsonRpcId] = Encoder.instance { + case JsonRpcId.StringId(v) => Json.fromString(v) + case JsonRpcId.NumberId(v) => Json.fromLong(v) +} + +given Decoder[JsonRpcId] = Decoder.instance { c => + c.as[String] + .map(JsonRpcId.StringId.apply) + .orElse( + c.as[Long].map(JsonRpcId.NumberId.apply) + ) +} \ No newline at end of file diff --git a/src/main/scala/mcp/model/jsonrpc/JsonRpcMessage.scala b/src/main/scala/mcp/model/jsonrpc/JsonRpcMessage.scala new file mode 100644 index 0000000..97e11f0 --- /dev/null +++ b/src/main/scala/mcp/model/jsonrpc/JsonRpcMessage.scala @@ -0,0 +1,71 @@ +package mcp.model.jsonrpc + +import io.circe.{Decoder, Encoder, Json, JsonObject} +import io.circe.generic.semiauto._ +import io.circe.syntax._ + +sealed trait JsonRpcMessage: + def jsonrpc: String + +object JsonRpcMessage: + final case class Request( + method: String, + params: Option[JsonObject], + id: JsonRpcId, + jsonrpc: String = "2.0" + ) extends JsonRpcMessage + + final case class Notification( + method: String, + params: Option[JsonObject], + jsonrpc: String = "2.0" + ) extends JsonRpcMessage + + final case class Response( + id: JsonRpcId, + result: Option[Json], + error: Option[JsonRpcError], + jsonrpc: String = "2.0" + ) extends JsonRpcMessage + + given Encoder[Request] = deriveEncoder + + given Decoder[Request] = deriveDecoder + + given Encoder[Notification] = deriveEncoder + + given Decoder[Notification] = deriveDecoder + + given Encoder.AsObject[Response] = Encoder.AsObject.instance { resp => + val baseFields = List( + "jsonrpc" -> Json.fromString(resp.jsonrpc), + "id" -> resp.id.asJson + ) + val withResult = resp.result match + case Some(value) => ("result", value) :: baseFields + case None => baseFields + val withError = resp.error match + case Some(err) => ("error", err.asJson) :: withResult + case None => withResult + + JsonObject.fromIterable(withError.reverse) + } + + given Decoder[Response] = deriveDecoder + + given Decoder[JsonRpcMessage] = Decoder.instance { c => + c.downField("id").as[JsonRpcId].map(Some(_)).orElse(Right(None)).flatMap { + case Some(id) => + // Response or Request + val hasResult = c.downField("result").success.isDefined + val hasError = c.downField("error").success.isDefined + + if hasResult || hasError then + c.as[JsonRpcMessage.Response] + else + c.as[JsonRpcMessage.Request] + + case None => + c.as[JsonRpcMessage.Notification] + } + } diff --git a/src/main/scala/mcp/model/mcp/Capabilities.scala b/src/main/scala/mcp/model/mcp/Capabilities.scala new file mode 100644 index 0000000..0b712e7 --- /dev/null +++ b/src/main/scala/mcp/model/mcp/Capabilities.scala @@ -0,0 +1,70 @@ +package mcp.model.mcp + +import io.circe.{Decoder, Encoder, JsonObject} +import io.circe.generic.semiauto._ + +final case class ClientCapabilities( + roots: Option[RootCapabilities], + sampling: Option[SamplingCapabilities], + elicitation: Option[ElicitationCapabilities] +) + +final case class ServerCapabilities( + tools: Option[ToolsServerCapabilities], + logging: Option[LoggingCapabilities], + resources: Option[ResourcesCapabilities], + prompts: Option[PromptsCapabilities] +) + +final case class ToolsServerCapabilities(listChanged: Boolean) + +final case class LoggingCapabilities() + +final case class ResourcesCapabilities(subscribe: Boolean, listChanged: Boolean) + +final case class PromptsCapabilities(listChanged: Boolean) + +final case class RootCapabilities(listChanged: Boolean) + +final case class SamplingCapabilities() + +final case class ElicitationCapabilities() + +private def dropCapabilityNulls(obj: JsonObject): JsonObject = + JsonObject.fromIterable(obj.toIterable.filterNot(_._2.isNull)) + +given Encoder.AsObject[ClientCapabilities] = deriveEncoder[ClientCapabilities].mapJsonObject(dropCapabilityNulls) + +given Decoder[ClientCapabilities] = deriveDecoder + +given Encoder.AsObject[ServerCapabilities] = deriveEncoder[ServerCapabilities].mapJsonObject(dropCapabilityNulls) + +given Decoder[ServerCapabilities] = deriveDecoder + +given Encoder[ToolsServerCapabilities] = deriveEncoder + +given Decoder[ToolsServerCapabilities] = deriveDecoder + +given Encoder[LoggingCapabilities] = deriveEncoder + +given Decoder[LoggingCapabilities] = deriveDecoder + +given Encoder[ResourcesCapabilities] = deriveEncoder + +given Decoder[ResourcesCapabilities] = deriveDecoder + +given Encoder[PromptsCapabilities] = deriveEncoder + +given Decoder[PromptsCapabilities] = deriveDecoder + +given Encoder[RootCapabilities] = deriveEncoder + +given Decoder[RootCapabilities] = deriveDecoder + +given Encoder[SamplingCapabilities] = deriveEncoder + +given Decoder[SamplingCapabilities] = deriveDecoder + +given Encoder[ElicitationCapabilities] = deriveEncoder + +given Decoder[ElicitationCapabilities] = deriveDecoder diff --git a/src/main/scala/mcp/model/mcp/Initialize.scala b/src/main/scala/mcp/model/mcp/Initialize.scala new file mode 100644 index 0000000..abfdc21 --- /dev/null +++ b/src/main/scala/mcp/model/mcp/Initialize.scala @@ -0,0 +1,40 @@ +package mcp.model.mcp + +import io.circe.{Decoder, Encoder, JsonObject} +import mcp.config.ServerInfo +import io.circe.generic.semiauto._ +import mcp.config.given + +final case class InitializeParams( + protocolVersion: String, + capabilities: ClientCapabilities, + clientInfo: ClientInfo +) + +final case class InitializeResult( + protocolVersion: String, + capabilities: ServerCapabilities, + serverInfo: ServerInfo, + instructions: Option[String] +) + +final case class ClientInfo( + name: String, + title: Option[String], + version: String +) + +given Encoder[InitializeParams] = deriveEncoder + +given Decoder[InitializeParams] = deriveDecoder + +private def dropInitializeNulls(obj: JsonObject): JsonObject = + JsonObject.fromIterable(obj.toIterable.filterNot(_._2.isNull)) + +given Encoder.AsObject[InitializeResult] = deriveEncoder[InitializeResult].mapJsonObject(dropInitializeNulls) + +given Decoder[InitializeResult] = deriveDecoder + +given Encoder[ClientInfo] = deriveEncoder + +given Decoder[ClientInfo] = deriveDecoder diff --git a/src/main/scala/mcp/model/mcp/ToolsProtocol.scala b/src/main/scala/mcp/model/mcp/ToolsProtocol.scala new file mode 100644 index 0000000..98a5051 --- /dev/null +++ b/src/main/scala/mcp/model/mcp/ToolsProtocol.scala @@ -0,0 +1,52 @@ +package mcp.model.mcp + +import io.circe.{Decoder, Encoder, Json, JsonObject} +import io.circe.generic.semiauto._ + +final case class ToolDefinition( + name: String, + description: String, + parameters: JsonObject +) + +final case class ToolsListResult( + tools: List[ToolDefinition] +) + +final case class ToolCallParams( + name: String, + arguments: JsonObject +) + +final case class ToolResultContent( + `type`: String, + text: String +) + +object ToolResultContent: + def text(value: String): ToolResultContent = + ToolResultContent(`type` = "text", text = value) + +final case class ToolCallResult( + content: List[ToolResultContent] +) + +given Encoder[ToolDefinition] = deriveEncoder + +given Decoder[ToolDefinition] = deriveDecoder + +given Encoder[ToolsListResult] = deriveEncoder + +given Decoder[ToolsListResult] = deriveDecoder + +given Encoder[ToolCallParams] = deriveEncoder + +given Decoder[ToolCallParams] = deriveDecoder + +given Encoder[ToolResultContent] = deriveEncoder + +given Decoder[ToolResultContent] = deriveDecoder + +given Encoder[ToolCallResult] = deriveEncoder + +given Decoder[ToolCallResult] = deriveDecoder diff --git a/src/main/scala/mcp/router/ErrorMapping.scala b/src/main/scala/mcp/router/ErrorMapping.scala new file mode 100644 index 0000000..b078c8d --- /dev/null +++ b/src/main/scala/mcp/router/ErrorMapping.scala @@ -0,0 +1,40 @@ +package mcp.router + +import io.circe.Json +import mcp.model.jsonrpc.JsonRpcError +import mcp.session.SessionError +import mcp.tools.ToolError + +object ErrorMapping: + def fromSessionError(e: SessionError): JsonRpcError = e match + case SessionError.NotInitialized => + JsonRpcError.InvalidRequest.copy(message = "Session not initialized") + case SessionError.InvalidVersion(requested, supported) => + JsonRpcError.InvalidRequest.copy( + message = "Unsupported protocol version", + data = Some( + Json.obj( + "requested" -> Json.fromString(requested), + "supported" -> Json.fromString(supported) + ) + ) + ) + + def fromToolError(e: ToolError): JsonRpcError = e match + case ToolError.ToolNotFound(name) => + JsonRpcError.MethodNotFound.copy( + data = Some(Json.obj("tool" -> Json.fromString(name))) + ) + case ToolError.InvalidArguments(msg) => + JsonRpcError.InvalidParams.copy( + data = Some(Json.obj("reason" -> Json.fromString(msg))) + ) + case ToolError.ExecutionFailed(msg) => + JsonRpcError.InternalError.copy( + data = Some(Json.obj("reason" -> Json.fromString(msg))) + ) + + def unknownMethod(method: String): JsonRpcError = + JsonRpcError.MethodNotFound.copy( + data = Some(Json.obj("method" -> Json.fromString(method))) + ) diff --git a/src/main/scala/mcp/router/InitializeHandler.scala b/src/main/scala/mcp/router/InitializeHandler.scala new file mode 100644 index 0000000..559678c --- /dev/null +++ b/src/main/scala/mcp/router/InitializeHandler.scala @@ -0,0 +1,77 @@ +package mcp.router + +import io.circe.Json +import io.circe.syntax._ +import mcp.config.ServerConfig +import mcp.model.jsonrpc.{JsonRpcError, JsonRpcMessage} +import mcp.model.mcp.{InitializeParams, InitializeResult} +import mcp.model.mcp.given +import mcp.session.{SessionError, SessionService} +import zio._ + +trait InitializeHandler: + def handle(req: JsonRpcMessage.Request): ZIO[Any, JsonRpcError, JsonRpcMessage.Response] + +object InitializeHandler: + val layer: ZLayer[ServerConfig & SessionService, Nothing, InitializeHandler] = + ZLayer.fromFunction { (config: ServerConfig, sessionService: SessionService) => + new InitializeHandler { + def handle(req: JsonRpcMessage.Request): ZIO[Any, JsonRpcError, JsonRpcMessage.Response] = { + for { + paramsObj <- ZIO.fromOption(req.params).orElseFail(JsonRpcError.InvalidParams) + params <- ZIO + .fromEither(Json.fromJsonObject(paramsObj).as[InitializeParams]) + .mapError(_ => JsonRpcError.InvalidParams) + _ <- ensureNotAlreadyInitialized(sessionService) + response <- buildResponse(req, params, config, sessionService) + } yield response + } + } + } + + private def buildResponse( + req: JsonRpcMessage.Request, + params: InitializeParams, + config: ServerConfig, + sessionService: SessionService + ): ZIO[Any, JsonRpcError, JsonRpcMessage.Response] = + val clientVersion = params.protocolVersion + val serverVersion = config.protocolVersion + + if clientVersion != serverVersion then + ZIO.fail( + JsonRpcError.InvalidParams.copy( + message = "Unsupported protocol version", + data = Some( + Json.obj( + "supported" -> Json.fromString(serverVersion), + "requested" -> Json.fromString(clientVersion) + ) + ) + ) + ) + else + val result = InitializeResult( + protocolVersion = serverVersion, + capabilities = config.capabilities, + serverInfo = config.serverInfo, + instructions = None + ) + + for + _ <- sessionService.markInitialized(serverVersion, params.clientInfo) + yield JsonRpcMessage.Response( + id = req.id, + result = Some(result.asJson), + error = None + ) + + private def ensureNotAlreadyInitialized(sessionService: SessionService): ZIO[Any, JsonRpcError, Unit] = + sessionService.ensureInitialized().either.flatMap { + case Right(_) => + ZIO.fail(JsonRpcError.InvalidRequest.copy(message = "Already initialized")) + case Left(SessionError.NotInitialized) => + ZIO.unit + case Left(err) => + ZIO.fail(ErrorMapping.fromSessionError(err)) + } diff --git a/src/main/scala/mcp/router/RequestRouter.scala b/src/main/scala/mcp/router/RequestRouter.scala new file mode 100644 index 0000000..28f877e --- /dev/null +++ b/src/main/scala/mcp/router/RequestRouter.scala @@ -0,0 +1,61 @@ +package mcp.router + +import mcp.model.jsonrpc.{JsonRpcError, JsonRpcId, JsonRpcMessage} +import mcp.router.ErrorMapping +import mcp.session.SessionService +import zio._ + +trait RequestRouter: + def handle(msg: JsonRpcMessage): ZIO[Any, Nothing, Option[JsonRpcMessage.Response]] + +object RequestRouter: + val layer: ZLayer[InitializeHandler & ToolsHandler & SessionService, Nothing, RequestRouter] = + ZLayer.fromFunction { + ( + initHandler: InitializeHandler, + toolsHandler: ToolsHandler, + sessionService: SessionService + ) => + new RequestRouter { + def handle(msg: JsonRpcMessage): ZIO[Any, Nothing, Option[JsonRpcMessage.Response]] = + msg match + case req: JsonRpcMessage.Request => + req.method match + case "initialize" => + respond(initHandler.handle(req), req.id) + + case "tools/list" => + respond(toolsHandler.handleList(req), req.id) + + case "tools/call" => + respond(toolsHandler.handleCall(req), req.id) + + case other => + ZIO.succeed(Some(errorResponse(req.id, ErrorMapping.unknownMethod(other)))) + + case notif: JsonRpcMessage.Notification => + notif.method match + case "notifications/initialized" => + sessionService.markClientAcknowledged().as(None) + case _ => + ZIO.none + + case _: JsonRpcMessage.Response => + ZIO.none + + private def respond( + effect: ZIO[Any, JsonRpcError, JsonRpcMessage.Response], + id: JsonRpcId + ): ZIO[Any, Nothing, Option[JsonRpcMessage.Response]] = + effect + .map(response => Some(response)) + .catchAll(err => ZIO.succeed(Some(errorResponse(id, err)))) + + private def errorResponse(id: JsonRpcId, err: JsonRpcError): JsonRpcMessage.Response = + JsonRpcMessage.Response( + id = id, + result = None, + error = Some(err) + ) + } + } diff --git a/src/main/scala/mcp/router/ToolsHandler.scala b/src/main/scala/mcp/router/ToolsHandler.scala new file mode 100644 index 0000000..18f2132 --- /dev/null +++ b/src/main/scala/mcp/router/ToolsHandler.scala @@ -0,0 +1,63 @@ +package mcp.router + +import io.circe.syntax._ +import io.circe.{JsonObject, Json} +import mcp.model.jsonrpc.{JsonRpcError, JsonRpcMessage} +import mcp.model.mcp.{ + ToolCallParams, + ToolCallResult, + ToolResultContent, + ToolsListResult, + given +} +import mcp.session.SessionService +import mcp.tools.ToolRegistry +import zio._ + +trait ToolsHandler: + def handleList(req: JsonRpcMessage.Request): ZIO[Any, JsonRpcError, JsonRpcMessage.Response] + def handleCall(req: JsonRpcMessage.Request): ZIO[Any, JsonRpcError, JsonRpcMessage.Response] + +object ToolsHandler: + val layer: ZLayer[ToolRegistry & SessionService, Nothing, ToolsHandler] = + ZLayer.fromFunction { (registry: ToolRegistry, sessionService: SessionService) => + new ToolsHandler { + def handleList(req: JsonRpcMessage.Request): ZIO[Any, JsonRpcError, JsonRpcMessage.Response] = + for + _ <- ensureSession(sessionService) + tools <- registry.listTools() + resultObj = ToolsListResult(tools) + jsonResult = resultObj.asJson + yield JsonRpcMessage.Response( + id = req.id, + result = Some(jsonResult), + error = None + ) + + def handleCall(req: JsonRpcMessage.Request): ZIO[Any, JsonRpcError, JsonRpcMessage.Response] = + for + _ <- ensureSession(sessionService) + paramsObj <- ZIO.fromOption(req.params).orElseFail(JsonRpcError.InvalidParams) + callParams <- decodeCallParams(paramsObj) + tool <- registry.getTool(callParams.name).mapError(ErrorMapping.fromToolError) + toolResult <- tool.execute(callParams.arguments).mapError(ErrorMapping.fromToolError) + resultObj = ToolCallResult(List(toTextContent(toolResult))) + jsonResult = resultObj.asJson + yield JsonRpcMessage.Response( + id = req.id, + result = Some(jsonResult), + error = None + ) + } + } + + private def ensureSession(sessionService: SessionService): IO[JsonRpcError, Unit] = + sessionService.ensureInitialized().mapError(_ => JsonRpcError.InvalidRequest) + + private def toTextContent(json: Json): ToolResultContent = + ToolResultContent.text(json.asString.getOrElse(json.spaces2)) + + private def decodeCallParams(params: JsonObject): IO[JsonRpcError, ToolCallParams] = + ZIO + .fromEither(Json.fromJsonObject(params).as[ToolCallParams]) + .mapError(_ => JsonRpcError.InvalidParams) diff --git a/src/main/scala/mcp/session/SessionService.scala b/src/main/scala/mcp/session/SessionService.scala new file mode 100644 index 0000000..90f9873 --- /dev/null +++ b/src/main/scala/mcp/session/SessionService.scala @@ -0,0 +1,39 @@ +package mcp.session + +import mcp.model.mcp.ClientInfo +import zio._ + +sealed trait SessionError + +object SessionError: + case object NotInitialized extends SessionError + final case class InvalidVersion(requested: String, supported: String) extends SessionError + +trait SessionService: + def get: UIO[SessionState] + def markInitialized(version: String, clientInfo: ClientInfo): UIO[Unit] + def markClientAcknowledged(): UIO[Unit] + def ensureInitialized(): IO[SessionError, Unit] + +object SessionService: + private var sessionState: SessionState = SessionState.initial + + val layer: ZLayer[Any, Nothing, SessionService] = ZLayer.succeed(new SessionService { + def get: UIO[SessionState] = ZIO.succeed(sessionState) + def markInitialized(version: String, clientInfo: ClientInfo): UIO[Unit] = + ZIO.succeed { + sessionState = sessionState.copy( + initializeCompleted = true, + protocolVersion = Some(version), + clientInfo = Some(clientInfo) + ) + } + + def markClientAcknowledged(): UIO[Unit] = ZIO.succeed { + sessionState = sessionState.copy(clientAcknowledged = true) + } + + def ensureInitialized(): IO[SessionError, Unit] = + if sessionState.initializeCompleted && sessionState.clientAcknowledged then ZIO.unit + else ZIO.fail(SessionError.NotInitialized) + }) diff --git a/src/main/scala/mcp/session/SessionState.scala b/src/main/scala/mcp/session/SessionState.scala new file mode 100644 index 0000000..ae8c369 --- /dev/null +++ b/src/main/scala/mcp/session/SessionState.scala @@ -0,0 +1,18 @@ +package mcp.session + +import mcp.model.mcp.ClientInfo + +final case class SessionState( + initializeCompleted: Boolean, + clientAcknowledged: Boolean, + protocolVersion: Option[String], + clientInfo: Option[ClientInfo] +) + +object SessionState: + val initial: SessionState = SessionState( + initializeCompleted = false, + clientAcknowledged = false, + protocolVersion = None, + clientInfo = None + ) diff --git a/src/main/scala/mcp/tools/ArithmeticTools.scala b/src/main/scala/mcp/tools/ArithmeticTools.scala new file mode 100644 index 0000000..bb5abbe --- /dev/null +++ b/src/main/scala/mcp/tools/ArithmeticTools.scala @@ -0,0 +1,18 @@ +package mcp.tools + +import mcp.tools.arithmetic.{AddTool, DivideTool, MultiplyTool, SubtractTool} + +object ArithmeticTools: + private val tools: List[Tool] = List( + AddTool, + SubtractTool, + MultiplyTool, + DivideTool + ) + + def all: List[Tool] = tools + + def add: Tool = AddTool + def subtract: Tool = SubtractTool + def multiply: Tool = MultiplyTool + def divide: Tool = DivideTool diff --git a/src/main/scala/mcp/tools/LogicTools.scala b/src/main/scala/mcp/tools/LogicTools.scala new file mode 100644 index 0000000..0f883f2 --- /dev/null +++ b/src/main/scala/mcp/tools/LogicTools.scala @@ -0,0 +1,29 @@ +package mcp.tools + +import mcp.tools.logic.{ + AndTool, + OrTool, + XorTool, + LessThanTool, + GreaterThanTool, + EqualsTool +} + +object LogicTools: + private val tools: List[Tool] = List( + AndTool, + OrTool, + XorTool, + LessThanTool, + GreaterThanTool, + EqualsTool + ) + + def all: List[Tool] = tools + + def and: Tool = AndTool + def or: Tool = OrTool + def xor: Tool = XorTool + def lessThan: Tool = LessThanTool + def greaterThan: Tool = GreaterThanTool + def equals: Tool = EqualsTool diff --git a/src/main/scala/mcp/tools/Tool.scala b/src/main/scala/mcp/tools/Tool.scala new file mode 100644 index 0000000..02c3a04 --- /dev/null +++ b/src/main/scala/mcp/tools/Tool.scala @@ -0,0 +1,16 @@ +package mcp.tools + +import io.circe.{Json, JsonObject} +import mcp.model.mcp.ToolDefinition +import zio._ + +sealed trait ToolError + +object ToolError: + final case class InvalidArguments(msg: String) extends ToolError + final case class ExecutionFailed(msg: String) extends ToolError + final case class ToolNotFound(name: String) extends ToolError + +trait Tool: + def definition: ToolDefinition + def execute(args: JsonObject): ZIO[Any, ToolError, Json] diff --git a/src/main/scala/mcp/tools/ToolRegistry.scala b/src/main/scala/mcp/tools/ToolRegistry.scala new file mode 100644 index 0000000..369de51 --- /dev/null +++ b/src/main/scala/mcp/tools/ToolRegistry.scala @@ -0,0 +1,23 @@ +package mcp.tools + +import mcp.model.mcp.ToolDefinition +import zio._ + +trait ToolRegistry: + def listTools(): UIO[List[ToolDefinition]] + def getTool(name: String): IO[ToolError, Tool] + +object ToolRegistry: + private val bundledTools: List[Tool] = ArithmeticTools.all ++ LogicTools.all + + val layer: ZLayer[Any, Nothing, ToolRegistry] = + ZLayer.succeed(make()) + + def make(tools: List[Tool] = bundledTools): ToolRegistry = new ToolRegistry: + def listTools(): UIO[List[ToolDefinition]] = + ZIO.succeed(tools.map(_.definition)) + + def getTool(name: String): IO[ToolError, Tool] = + ZIO.fromEither( + tools.find(_.definition.name == name).toRight(ToolError.ToolNotFound(name)) + ) diff --git a/src/main/scala/mcp/tools/arithmetic/AddTool.scala b/src/main/scala/mcp/tools/arithmetic/AddTool.scala new file mode 100644 index 0000000..44d3846 --- /dev/null +++ b/src/main/scala/mcp/tools/arithmetic/AddTool.scala @@ -0,0 +1,23 @@ +package mcp.tools.arithmetic + +import io.circe.syntax._ +import io.circe.{Json, JsonObject} +import zio._ + +import mcp.tools.common.{BinaryArgs, NumericPairSchema} +import mcp.tools.common.BinaryArgs.given +import mcp.model.mcp.ToolDefinition +import mcp.tools.{Tool, ToolError} + +object AddTool extends Tool: + val definition: ToolDefinition = ToolDefinition( + name = "add", + description = "Add two numbers together.", + parameters = NumericPairSchema.definition + ) + + def execute(args: JsonObject): ZIO[Any, ToolError, Json] = + ZIO + .fromEither(args.asJson.as[BinaryArgs]) + .mapError(err => ToolError.InvalidArguments(s"Invalid arguments for add: ${err.getMessage}")) + .map(parsed => Json.fromInt(parsed.a + parsed.b)) diff --git a/src/main/scala/mcp/tools/arithmetic/DivideTool.scala b/src/main/scala/mcp/tools/arithmetic/DivideTool.scala new file mode 100644 index 0000000..51cc59f --- /dev/null +++ b/src/main/scala/mcp/tools/arithmetic/DivideTool.scala @@ -0,0 +1,23 @@ +package mcp.tools.arithmetic + +import io.circe.syntax._ +import io.circe.{Json, JsonObject} +import zio._ + +import mcp.tools.common.{BinaryArgs, NumericPairSchema} +import mcp.tools.common.BinaryArgs.given +import mcp.model.mcp.ToolDefinition +import mcp.tools.{Tool, ToolError} + +object DivideTool extends Tool: + val definition: ToolDefinition = ToolDefinition( + name = "divide", + description = "Divide two numbers.", + parameters = NumericPairSchema.definition + ) + + def execute(args: JsonObject): ZIO[Any, ToolError, Json] = + ZIO + .fromEither(args.asJson.as[BinaryArgs]) + .mapError(err => ToolError.InvalidArguments(s"Invalid arguments for divide: ${err.getMessage}")) + .map(parsed => Json.fromInt(parsed.a / parsed.b)) diff --git a/src/main/scala/mcp/tools/arithmetic/MultiplyTool.scala b/src/main/scala/mcp/tools/arithmetic/MultiplyTool.scala new file mode 100644 index 0000000..a156062 --- /dev/null +++ b/src/main/scala/mcp/tools/arithmetic/MultiplyTool.scala @@ -0,0 +1,23 @@ +package mcp.tools.arithmetic + +import io.circe.syntax._ +import io.circe.{Json, JsonObject} +import zio._ + +import mcp.tools.common.{BinaryArgs, NumericPairSchema} +import mcp.tools.common.BinaryArgs.given +import mcp.model.mcp.ToolDefinition +import mcp.tools.{Tool, ToolError} + +object MultiplyTool extends Tool: + val definition: ToolDefinition = ToolDefinition( + name = "multiply", + description = "Multiply two numbers.", + parameters = NumericPairSchema.definition + ) + + def execute(args: JsonObject): ZIO[Any, ToolError, Json] = + ZIO + .fromEither(args.asJson.as[BinaryArgs]) + .mapError(err => ToolError.InvalidArguments(s"Invalid arguments for multiply: ${err.getMessage}")) + .map(parsed => Json.fromInt(parsed.a * parsed.b)) diff --git a/src/main/scala/mcp/tools/arithmetic/SubtractTool.scala b/src/main/scala/mcp/tools/arithmetic/SubtractTool.scala new file mode 100644 index 0000000..ea813f7 --- /dev/null +++ b/src/main/scala/mcp/tools/arithmetic/SubtractTool.scala @@ -0,0 +1,23 @@ +package mcp.tools.arithmetic + +import io.circe.syntax._ +import io.circe.{Json, JsonObject} +import zio._ + +import mcp.tools.common.{BinaryArgs, NumericPairSchema} +import mcp.tools.common.BinaryArgs.given +import mcp.model.mcp.ToolDefinition +import mcp.tools.{Tool, ToolError} + +object SubtractTool extends Tool: + val definition: ToolDefinition = ToolDefinition( + name = "subtract", + description = "Subtract two numbers.", + parameters = NumericPairSchema.definition + ) + + def execute(args: JsonObject): ZIO[Any, ToolError, Json] = + ZIO + .fromEither(args.asJson.as[BinaryArgs]) + .mapError(err => ToolError.InvalidArguments(s"Invalid arguments for subtract: ${err.getMessage}")) + .map(parsed => Json.fromInt(parsed.a - parsed.b)) diff --git a/src/main/scala/mcp/tools/common/BinaryArgs.scala b/src/main/scala/mcp/tools/common/BinaryArgs.scala new file mode 100644 index 0000000..15b1650 --- /dev/null +++ b/src/main/scala/mcp/tools/common/BinaryArgs.scala @@ -0,0 +1,9 @@ +package mcp.tools.common + +import io.circe.Decoder +import io.circe.generic.semiauto.deriveDecoder + +final case class BinaryArgs(a: Int, b: Int) + +object BinaryArgs: + given Decoder[BinaryArgs] = deriveDecoder diff --git a/src/main/scala/mcp/tools/common/NumericPairSchema.scala b/src/main/scala/mcp/tools/common/NumericPairSchema.scala new file mode 100644 index 0000000..0d40e0b --- /dev/null +++ b/src/main/scala/mcp/tools/common/NumericPairSchema.scala @@ -0,0 +1,13 @@ +package mcp.tools.common + +import io.circe.{Json, JsonObject} + +object NumericPairSchema: + val definition: JsonObject = JsonObject( + "type" -> Json.fromString("object"), + "properties" -> Json.obj( + "a" -> Json.obj("type" -> Json.fromString("number")), + "b" -> Json.obj("type" -> Json.fromString("number")) + ), + "required" -> Json.arr(Json.fromString("a"), Json.fromString("b")) + ) diff --git a/src/main/scala/mcp/tools/logic/AndTool.scala b/src/main/scala/mcp/tools/logic/AndTool.scala new file mode 100644 index 0000000..ba10836 --- /dev/null +++ b/src/main/scala/mcp/tools/logic/AndTool.scala @@ -0,0 +1,23 @@ +package mcp.tools.logic + +import io.circe.syntax._ +import io.circe.{Json, JsonObject} +import zio._ + +import mcp.tools.common.{BinaryArgs, NumericPairSchema} +import mcp.tools.common.BinaryArgs.given +import mcp.model.mcp.ToolDefinition +import mcp.tools.{Tool, ToolError} + +object AndTool extends Tool: + val definition: ToolDefinition = ToolDefinition( + name = "and", + description = "Bitwise AND operation on integers.", + parameters = NumericPairSchema.definition + ) + + def execute(args: JsonObject): ZIO[Any, ToolError, Json] = + ZIO + .fromEither(args.asJson.as[BinaryArgs]) + .mapError(err => ToolError.InvalidArguments(s"Invalid arguments for and: ${err.getMessage}")) + .map(parsed => Json.fromInt(parsed.a & parsed.b)) diff --git a/src/main/scala/mcp/tools/logic/EqualsTool.scala b/src/main/scala/mcp/tools/logic/EqualsTool.scala new file mode 100644 index 0000000..66083b3 --- /dev/null +++ b/src/main/scala/mcp/tools/logic/EqualsTool.scala @@ -0,0 +1,23 @@ +package mcp.tools.logic + +import io.circe.syntax._ +import io.circe.{Json, JsonObject} +import zio._ + +import mcp.tools.common.{BinaryArgs, NumericPairSchema} +import mcp.tools.common.BinaryArgs.given +import mcp.model.mcp.ToolDefinition +import mcp.tools.{Tool, ToolError} + +object EqualsTool extends Tool: + val definition: ToolDefinition = ToolDefinition( + name = "equals", + description = "Logical equality operation.", + parameters = NumericPairSchema.definition + ) + + def execute(args: JsonObject): ZIO[Any, ToolError, Json] = + ZIO + .fromEither(args.asJson.as[BinaryArgs]) + .mapError(err => ToolError.InvalidArguments(s"Invalid arguments for equals: ${err.getMessage}")) + .map(parsed => Json.fromBoolean(parsed.a == parsed.b)) \ No newline at end of file diff --git a/src/main/scala/mcp/tools/logic/GreaterThanTool.scala b/src/main/scala/mcp/tools/logic/GreaterThanTool.scala new file mode 100644 index 0000000..894f582 --- /dev/null +++ b/src/main/scala/mcp/tools/logic/GreaterThanTool.scala @@ -0,0 +1,23 @@ +package mcp.tools.logic + +import io.circe.syntax._ +import io.circe.{Json, JsonObject} +import zio._ + +import mcp.tools.common.{BinaryArgs, NumericPairSchema} +import mcp.tools.common.BinaryArgs.given +import mcp.model.mcp.ToolDefinition +import mcp.tools.{Tool, ToolError} + +object GreaterThanTool extends Tool: + val definition: ToolDefinition = ToolDefinition( + name = "greaterThan", + description = "Logical greater than operation.", + parameters = NumericPairSchema.definition + ) + + def execute(args: JsonObject): ZIO[Any, ToolError, Json] = + ZIO + .fromEither(args.asJson.as[BinaryArgs]) + .mapError(err => ToolError.InvalidArguments(s"Invalid arguments for greaterThan: ${err.getMessage}")) + .map(parsed => Json.fromBoolean(parsed.a > parsed.b)) diff --git a/src/main/scala/mcp/tools/logic/LessThanTool.scala b/src/main/scala/mcp/tools/logic/LessThanTool.scala new file mode 100644 index 0000000..f71270d --- /dev/null +++ b/src/main/scala/mcp/tools/logic/LessThanTool.scala @@ -0,0 +1,24 @@ +package mcp.tools.logic + +import io.circe.syntax._ +import io.circe.{Json, JsonObject} +import zio._ + +import mcp.tools.common.{BinaryArgs, NumericPairSchema} +import mcp.tools.common.BinaryArgs.given +import mcp.model.mcp.ToolDefinition +import mcp.tools.{Tool, ToolError} + +object LessThanTool extends Tool: + val definition: ToolDefinition = ToolDefinition( + name = "lessThan", + description = "Logical less than operation.", + parameters = NumericPairSchema.definition + ) + + def execute(args: JsonObject): ZIO[Any, ToolError, Json] = + ZIO + .fromEither(args.asJson.as[BinaryArgs]) + .mapError(err => ToolError.InvalidArguments(s"Invalid arguments for lessThan: ${err.getMessage}")) + .map(parsed => Json.fromBoolean(parsed.a < parsed.b)) + diff --git a/src/main/scala/mcp/tools/logic/OrTool.scala b/src/main/scala/mcp/tools/logic/OrTool.scala new file mode 100644 index 0000000..f0d42fc --- /dev/null +++ b/src/main/scala/mcp/tools/logic/OrTool.scala @@ -0,0 +1,23 @@ +package mcp.tools.logic + +import io.circe.syntax._ +import io.circe.{Json, JsonObject} +import zio._ + +import mcp.tools.common.{BinaryArgs, NumericPairSchema} +import mcp.tools.common.BinaryArgs.given +import mcp.model.mcp.ToolDefinition +import mcp.tools.{Tool, ToolError} + +object OrTool extends Tool: + val definition: ToolDefinition = ToolDefinition( + name = "or", + description = "Bitwise OR operation on integers.", + parameters = NumericPairSchema.definition + ) + + def execute(args: JsonObject): ZIO[Any, ToolError, Json] = + ZIO + .fromEither(args.asJson.as[BinaryArgs]) + .mapError(err => ToolError.InvalidArguments(s"Invalid arguments for or: ${err.getMessage}")) + .map(parsed => Json.fromInt(parsed.a | parsed.b)) diff --git a/src/main/scala/mcp/tools/logic/XorTool.scala b/src/main/scala/mcp/tools/logic/XorTool.scala new file mode 100644 index 0000000..80ed52e --- /dev/null +++ b/src/main/scala/mcp/tools/logic/XorTool.scala @@ -0,0 +1,23 @@ +package mcp.tools.logic + +import io.circe.syntax._ +import io.circe.{Json, JsonObject} +import zio._ + +import mcp.tools.common.{BinaryArgs, NumericPairSchema} +import mcp.tools.common.BinaryArgs.given +import mcp.model.mcp.ToolDefinition +import mcp.tools.{Tool, ToolError} + +object XorTool extends Tool: + val definition: ToolDefinition = ToolDefinition( + name = "xor", + description = "Bitwise XOR operation on integers.", + parameters = NumericPairSchema.definition + ) + + def execute(args: JsonObject): ZIO[Any, ToolError, Json] = + ZIO + .fromEither(args.asJson.as[BinaryArgs]) + .mapError(err => ToolError.InvalidArguments(s"Invalid arguments for xor: ${err.getMessage}")) + .map(parsed => Json.fromInt(parsed.a ^ parsed.b)) diff --git a/src/main/scala/mcp/transport/StdioTransport.scala b/src/main/scala/mcp/transport/StdioTransport.scala new file mode 100644 index 0000000..ae83467 --- /dev/null +++ b/src/main/scala/mcp/transport/StdioTransport.scala @@ -0,0 +1,24 @@ +package mcp.transport + +import zio._ +import zio.stream._ +import mcp.util.DebugLogging + +object StdioTransport: + val layer: ZLayer[Any, Nothing, Transport] = ZLayer.succeed(new Transport: + val read: ZStream[Any, TransportError, String] = + ZStream + .repeatZIO( + Console.readLine.mapError { + case eof: java.io.EOFException => + TransportError.EndOfStream + case other => + TransportError.Read(other) + } + ) + .catchSome { case TransportError.EndOfStream => ZStream.empty } + .tap(line => DebugLogging.log(s"[MCP RX] $line")) + + def write(line: String): UIO[Unit] = + Console.printLine(line).orDie *> DebugLogging.log(s"[MCP TX] $line") + ) diff --git a/src/main/scala/mcp/transport/Transport.scala b/src/main/scala/mcp/transport/Transport.scala new file mode 100644 index 0000000..4ea0053 --- /dev/null +++ b/src/main/scala/mcp/transport/Transport.scala @@ -0,0 +1,15 @@ +package mcp.transport + +import zio._ +import zio.stream._ + +sealed trait TransportError extends Throwable + +object TransportError: + final case class Read(cause: Throwable) extends TransportError + final case class Write(cause: Throwable) extends TransportError + case object EndOfStream extends TransportError + +trait Transport: + def read: ZStream[Any, TransportError, String] + def write(line: String): UIO[Unit] diff --git a/src/main/scala/mcp/util/DebugLogging.scala b/src/main/scala/mcp/util/DebugLogging.scala new file mode 100644 index 0000000..18d0ada --- /dev/null +++ b/src/main/scala/mcp/util/DebugLogging.scala @@ -0,0 +1,11 @@ +package mcp.util + +import zio._ + +object DebugLogging: + private val enabled: Boolean = + sys.env.get("MCP_DEBUG").exists(_.nonEmpty) + + def log(line: => String): UIO[Unit] = + if enabled then Console.printLineError(line).orDie + else ZIO.unit diff --git a/src/test/scala/mcp/test/JsonRpcCodecSpec.scala b/src/test/scala/mcp/test/JsonRpcCodecSpec.scala new file mode 100644 index 0000000..0bb0686 --- /dev/null +++ b/src/test/scala/mcp/test/JsonRpcCodecSpec.scala @@ -0,0 +1,112 @@ +package mcp.test + +import io.circe._ +import io.circe.syntax._ +import mcp.config.ServerInfo +import mcp.model.jsonrpc.{JsonRpcError, JsonRpcId, JsonRpcMessage} +import mcp.model.mcp._ +import mcp.model.mcp.given +import zio.test.Assertion._ +import zio.test._ + +object JsonRpcCodecSpec extends ZIOSpecDefault: + + def spec: Spec[TestEnvironment, Any] = + suite("JsonRpcCodecSpec")( + test("encode and decode Request with StringId round-trips") { + val params = JsonObject("x" -> Json.fromInt(1)) + val request = JsonRpcMessage.Request( + method = "test/method", + params = Some(params), + id = JsonRpcId.StringId("abc") + ) + + val decoded = request.asJson.as[JsonRpcMessage.Request] + assert(decoded)(isRight(equalTo(request))) + }, + test("encode and decode Response with result round-trips") { + val response = JsonRpcMessage.Response( + id = JsonRpcId.NumberId(1), + result = Some(Json.obj("value" -> Json.fromBoolean(true))), + error = None + ) + + val decoded = response.asJson.as[JsonRpcMessage.Response] + assert(decoded)(isRight(equalTo(response))) + }, + test("encode and decode Response with error round-trips") { + val error = JsonRpcError(code = -32001, message = "Custom error", data = Some(Json.obj("a" -> Json.fromInt(1)))) + val response = JsonRpcMessage.Response( + id = JsonRpcId.StringId("err"), + result = None, + error = Some(error) + ) + + val decoded = response.asJson.as[JsonRpcMessage.Response] + assert(decoded)(isRight(equalTo(response))) + }, + test("decode distinguishes Request vs Notification based on id") { + val params = JsonObject("flag" -> Json.fromBoolean(true)) + val requestJson = Json.obj( + "jsonrpc" -> Json.fromString("2.0"), + "method" -> Json.fromString("ping"), + "params" -> Json.fromJsonObject(params), + "id" -> Json.fromInt(42) + ) + val notificationJson = Json.obj( + "jsonrpc" -> Json.fromString("2.0"), + "method" -> Json.fromString("ping"), + "params" -> Json.fromJsonObject(params) + ) + + val decodedRequest = requestJson.as[JsonRpcMessage] + val decodedNotification = notificationJson.as[JsonRpcMessage] + + assert(decodedRequest)(isRight(isSubtype[JsonRpcMessage.Request](anything))) && + assert(decodedNotification)(isRight(isSubtype[JsonRpcMessage.Notification](anything))) + }, + test("InitializeParams and InitializeResult encode/decode round-trip") { + val clientCaps = ClientCapabilities( + roots = Some(RootCapabilities(listChanged = true)), + sampling = Some(SamplingCapabilities()), + elicitation = Some(ElicitationCapabilities()) + ) + val params = InitializeParams( + protocolVersion = "2025-06-18", + capabilities = clientCaps, + clientInfo = ClientInfo(name = "test", title = Some("Test Client"), version = "0.1.0") + ) + + val serverCaps = ServerCapabilities( + tools = Some(ToolsServerCapabilities(listChanged = false)), + logging = Some(LoggingCapabilities()), + resources = Some(ResourcesCapabilities(subscribe = true, listChanged = true)), + prompts = Some(PromptsCapabilities(listChanged = false)) + ) + val result = InitializeResult( + protocolVersion = "2025-06-18", + capabilities = serverCaps, + serverInfo = ServerInfo("almcp", "Arithmetic & Logic", "1.0.0"), + instructions = Some("Ready") + ) + + val decodedParams = params.asJson.as[InitializeParams] + val decodedResult = result.asJson.as[InitializeResult] + + assert(decodedParams)(isRight(equalTo(params))) && + assert(decodedResult)(isRight(equalTo(result))) + }, + test("ToolCallParams and ToolCallResult encode/decode round-trip") { + val params = ToolCallParams( + name = "add", + arguments = JsonObject("a" -> Json.fromInt(1), "b" -> Json.fromInt(2)) + ) + val result = ToolCallResult(content = List(ToolResultContent.text("3"))) + + val decodedParams = params.asJson.as[ToolCallParams] + val decodedResult = result.asJson.as[ToolCallResult] + + assert(decodedParams)(isRight(equalTo(params))) && + assert(decodedResult)(isRight(equalTo(result))) + } + ) diff --git a/src/test/scala/mcp/test/RequestRouterSpec.scala b/src/test/scala/mcp/test/RequestRouterSpec.scala new file mode 100644 index 0000000..dbcaef5 --- /dev/null +++ b/src/test/scala/mcp/test/RequestRouterSpec.scala @@ -0,0 +1,160 @@ +package mcp.test + +import io.circe.{Decoder, Encoder, Json, JsonObject} +import io.circe.syntax._ +import mcp.config.ServerConfig +import mcp.model.jsonrpc.{JsonRpcError, JsonRpcId, JsonRpcMessage} +import mcp.model.mcp._ +import mcp.model.mcp.given +import mcp.router.{InitializeHandler, RequestRouter, ToolsHandler} +import mcp.session.SessionService +import mcp.tools.ToolRegistry +import zio._ +import zio.test.Assertion._ +import zio.test._ + +object RequestRouterSpec extends ZIOSpecDefault: + + private def routerLayer = + ZLayer.make[RequestRouter & SessionService & ServerConfig & ToolRegistry]( + ServerConfig.layer, + ToolRegistry.layer, + TestSessionLayer.make(), + InitializeHandler.layer, + ToolsHandler.layer, + RequestRouter.layer + ) + + private def withRouterEnv[A]( + effect: ZIO[RequestRouter & SessionService & ServerConfig & ToolRegistry, Any, A] + ): ZIO[Any, Any, A] = + effect.provideLayer(routerLayer) + + private def paramsObject[A: Encoder](value: A): JsonObject = + value.asJson.asObject.getOrElse(JsonObject.empty) + + private def request(id: Int, method: String, params: Option[JsonObject]): JsonRpcMessage.Request = + JsonRpcMessage.Request( + method = method, + params = params, + id = JsonRpcId.NumberId(id.toLong) + ) + + private def decodeResult[A: Decoder](response: JsonRpcMessage.Response): UIO[A] = + ZIO + .fromOption(response.result) + .orElseFail(new RuntimeException("expected result")) + .flatMap(json => ZIO.fromEither(json.as[A])) + .orDie + + private def expectResponse(opt: Option[JsonRpcMessage.Response]): UIO[JsonRpcMessage.Response] = + ZIO + .fromOption(opt) + .orElseFail(new RuntimeException("expected response")) + .orDie + + def spec: Spec[TestEnvironment, Any] = + suite("RequestRouterSpec")( + test("initialize with supported protocol returns success and marks session initialized") { + withRouterEnv { + for + config <- ZIO.service[ServerConfig] + router <- ZIO.service[RequestRouter] + session <- ZIO.service[SessionService] + params: InitializeParams = + InitializeParams( + protocolVersion = config.protocolVersion, + capabilities = ClientCapabilities(None, None, None), + clientInfo = ClientInfo("client", Some("Test Client"), "0.0.1") + ) + req = request(1, "initialize", Some(paramsObject[InitializeParams](params))) + response <- router.handle(req).flatMap(expectResponse) + result <- decodeResult[InitializeResult](response) + state <- session.get + yield assertTrue( + response.id == req.id, + result.protocolVersion == config.protocolVersion, + state.initializeCompleted, + state.protocolVersion.contains(config.protocolVersion), + state.clientInfo.exists(_.name == "client") + ) + } + }, + test("initialize with unsupported protocol returns error") { + withRouterEnv { + for + router <- ZIO.service[RequestRouter] + params: InitializeParams = + InitializeParams( + protocolVersion = "1.0.0", + capabilities = ClientCapabilities(None, None, None), + clientInfo = ClientInfo("client", Some("Test Client"), "0.0.1") + ) + req = request(2, "initialize", Some(paramsObject[InitializeParams](params))) + response <- router.handle(req).flatMap(expectResponse) + error = response.error + yield assertTrue(error.isDefined) && + assertTrue(error.exists(_.code == JsonRpcError.InvalidParams.code)) && + assertTrue(error.exists(_.message.contains("Unsupported protocol version"))) + } + }, + test("tools/list before initialize returns InvalidRequest error") { + withRouterEnv { + for + router <- ZIO.service[RequestRouter] + listReq = request(3, "tools/list", None) + response <- router.handle(listReq).flatMap(expectResponse) + yield assertTrue(response.error.exists(_.code == JsonRpcError.InvalidRequest.code)) + } + }, + test("full flow: initialize -> acknowledge -> tools/list -> tools/call/add") { + withRouterEnv { + for + config <- ZIO.service[ServerConfig] + router <- ZIO.service[RequestRouter] + params: InitializeParams = + InitializeParams( + protocolVersion = config.protocolVersion, + capabilities = ClientCapabilities(None, None, None), + clientInfo = ClientInfo("client", Some("Test Client"), "0.0.1") + ) + initReq = request(4, "initialize", Some(paramsObject[InitializeParams](params))) + _ <- router.handle(initReq).flatMap(expectResponse) + _ <- router.handle(JsonRpcMessage.Notification("notifications/initialized", None)) + listReq = request(5, "tools/list", None) + listResp <- router.handle(listReq).flatMap(expectResponse) + listRes <- decodeResult[ToolsListResult](listResp) + tools = listRes.tools.map(_.name).toSet + callParams = ToolCallParams( + name = "add", + arguments = JsonObject("a" -> Json.fromInt(2), "b" -> Json.fromInt(3)) + ) + callReq = request(6, "tools/call", Some(paramsObject[ToolCallParams](callParams))) + callResp <- router.handle(callReq).flatMap(expectResponse) + callRes <- decodeResult[ToolCallResult](callResp) + yield assertTrue( + tools.contains("add"), + callRes.content == List(ToolResultContent.text("5")) + ) + } + }, + test("unknown method returns MethodNotFound error") { + withRouterEnv { + for + router <- ZIO.service[RequestRouter] + req = request(7, "bogus/method", None) + response <- router.handle(req).flatMap(expectResponse) + error = response.error + yield assertTrue(error.exists(_.code == JsonRpcError.MethodNotFound.code)) + } + }, + test("notifications/initialized does not produce a response") { + withRouterEnv { + for + router <- ZIO.service[RequestRouter] + notif = JsonRpcMessage.Notification("notifications/initialized", None) + result <- router.handle(notif) + yield assertTrue(result.isEmpty) + } + } + ) diff --git a/src/test/scala/mcp/test/SessionServiceSpec.scala b/src/test/scala/mcp/test/SessionServiceSpec.scala new file mode 100644 index 0000000..9a338f2 --- /dev/null +++ b/src/test/scala/mcp/test/SessionServiceSpec.scala @@ -0,0 +1,65 @@ +package mcp.test + +import mcp.model.mcp.ClientInfo +import mcp.session.{SessionError, SessionService} +import zio._ +import zio.test.Assertion._ +import zio.test._ + +object SessionServiceSpec extends ZIOSpecDefault: + + private val testClient = ClientInfo(name = "cli", title = Some("CLI Tester"), version = "0.0.1") + + private def withSession[A](effect: ZIO[SessionService, Any, A]): ZIO[Any, Any, A] = + effect.provideLayer(TestSessionLayer.make()) + + def spec: Spec[TestEnvironment, Any] = + suite("SessionServiceSpec")( + test("initial state is not initialized") { + withSession { + for + service <- ZIO.service[SessionService] + state <- service.get + yield assertTrue( + state.initializeCompleted == false, + state.clientAcknowledged == false, + state.protocolVersion.isEmpty, + state.clientInfo.isEmpty + ) + } + }, + test("markInitialized updates the state") { + withSession { + for + service <- ZIO.service[SessionService] + _ <- service.markInitialized("2025-06-18", testClient) + state <- service.get + yield assertTrue( + state.initializeCompleted, + !state.clientAcknowledged, + state.protocolVersion.contains("2025-06-18"), + state.clientInfo.contains(testClient) + ) + } + }, + test("ensureInitialized fails when not initialized") { + withSession { + for + service <- ZIO.service[SessionService] + result <- service.ensureInitialized().either + yield assert(result)(isLeft(equalTo(SessionError.NotInitialized))) + } + }, + test("ensureInitialized succeeds only after client acknowledgement") { + withSession { + for + service <- ZIO.service[SessionService] + _ <- service.markInitialized("2025-06-18", testClient) + interim <- service.ensureInitialized().either + _ <- service.markClientAcknowledged() + finalR <- service.ensureInitialized().either + yield assert(interim)(isLeft(equalTo(SessionError.NotInitialized))) && + assert(finalR)(isRight(isUnit)) + } + } + ) diff --git a/src/test/scala/mcp/test/TestSessionLayer.scala b/src/test/scala/mcp/test/TestSessionLayer.scala new file mode 100644 index 0000000..e00157c --- /dev/null +++ b/src/test/scala/mcp/test/TestSessionLayer.scala @@ -0,0 +1,36 @@ +package mcp.test + +import mcp.model.mcp.ClientInfo +import mcp.session.{SessionError, SessionService, SessionState} +import zio._ + +object TestSessionLayer: + + def make(initial: SessionState = SessionState.initial): ULayer[SessionService] = + ZLayer.fromZIO( + Ref.make(initial).map { ref => + new SessionService { + def get: UIO[SessionState] = ref.get + + def markInitialized(version: String, clientInfo: ClientInfo): UIO[Unit] = + ref + .update( + _.copy( + initializeCompleted = true, + protocolVersion = Some(version), + clientInfo = Some(clientInfo) + ) + ) + .unit + + def markClientAcknowledged(): UIO[Unit] = + ref.update(_.copy(clientAcknowledged = true)).unit + + def ensureInitialized(): IO[SessionError, Unit] = + ref.get.flatMap { state => + if state.initializeCompleted && state.clientAcknowledged then ZIO.unit + else ZIO.fail(SessionError.NotInitialized) + } + } + } + ) diff --git a/src/test/scala/mcp/test/ToolsSpec.scala b/src/test/scala/mcp/test/ToolsSpec.scala new file mode 100644 index 0000000..0fefe1d --- /dev/null +++ b/src/test/scala/mcp/test/ToolsSpec.scala @@ -0,0 +1,115 @@ +package mcp.test + +import io.circe.{Json, JsonObject} +import mcp.tools._ +import zio._ +import zio.test.Assertion._ +import zio.test._ + +object ToolsSpec extends ZIOSpecDefault: + + private def args(a: Int, b: Int): JsonObject = + JsonObject("a" -> Json.fromInt(a), "b" -> Json.fromInt(b)) + + private def withRegistry[A](effect: ZIO[ToolRegistry, Any, A]): ZIO[Any, Any, A] = + effect.provideLayer(ToolRegistry.layer) + + def spec: Spec[TestEnvironment, Any] = + suite("ToolsSpec")( + suite("Arithmetic tools")( + test("add returns correct sum") { + val tool = ArithmeticTools.add + assertZIO(tool.execute(args(2, 3)))(equalTo(Json.fromInt(5))) + }, + test("subtract returns correct difference") { + val tool = ArithmeticTools.subtract + assertZIO(tool.execute(args(7, 4)))(equalTo(Json.fromInt(3))) + }, + test("multiply returns correct product") { + val tool = ArithmeticTools.multiply + assertZIO(tool.execute(args(5, 6)))(equalTo(Json.fromInt(30))) + }, + test("divide returns integer quotient") { + val tool = ArithmeticTools.divide + assertZIO(tool.execute(args(9, 3)))(equalTo(Json.fromInt(3))) + }, + test("invalid arguments surface ToolError.InvalidArguments") { + val tool = ArithmeticTools.add + val invalidArgs = JsonObject("a" -> Json.fromInt(1)) + assertZIO(tool.execute(invalidArgs).either)( + isLeft(isSubtype[ToolError.InvalidArguments](anything)) + ) + } + ), + suite("Logic tools")( + test("and returns bitwise conjunction") { + val tool = LogicTools.and + for + both <- tool.execute(args(1, 1)) + mixed <- tool.execute(args(1, 0)) + yield assertTrue(both == Json.fromInt(1), mixed == Json.fromInt(0)) + }, + test("or returns bitwise disjunction") { + val tool = LogicTools.or + for result <- tool.execute(args(1, 2)) + yield assertTrue(result == Json.fromInt(3)) + }, + test("xor returns bitwise exclusive or") { + val tool = LogicTools.xor + for + res1 <- tool.execute(args(1, 2)) + res2 <- tool.execute(args(3, 3)) + yield assertTrue(res1 == Json.fromInt(3), res2 == Json.fromInt(0)) + }, + test("comparison tools yield boolean results") { + val less = LogicTools.lessThan + val greater = LogicTools.greaterThan + val equal = LogicTools.equals + for + lt <- less.execute(args(1, 2)) + gt <- greater.execute(args(3, 2)) + eq <- equal.execute(args(5, 5)) + yield assertTrue( + lt == Json.fromBoolean(true), + gt == Json.fromBoolean(true), + eq == Json.fromBoolean(true) + ) + } + ), + suite("ToolRegistry")( + test("listTools contains arithmetic and logic definitions") { + withRegistry { + for + registry <- ZIO.service[ToolRegistry] + tools <- registry.listTools() + names = tools.map(_.name).toSet + yield assertTrue( + names.contains("add"), + names.contains("subtract"), + names.contains("multiply"), + names.contains("divide"), + names.contains("and"), + names.contains("equals") + ) + } + }, + test("getTool returns a tool when present") { + withRegistry { + for + registry <- ZIO.service[ToolRegistry] + tool <- registry.getTool("add") + yield assertTrue(tool.definition.name == "add") + } + }, + test("getTool fails with ToolNotFound for unknown tool") { + withRegistry { + for + registry <- ZIO.service[ToolRegistry] + result <- registry.getTool("nonexistent").either + yield assert(result)( + isLeft(equalTo(ToolError.ToolNotFound("nonexistent"))) + ) + } + } + ) + ) From c7505df896b4813d87a870720b71323e34a93757 Mon Sep 17 00:00:00 2001 From: Alexandre-Roussel48 Date: Sun, 16 Nov 2025 00:22:03 +0100 Subject: [PATCH 4/7] docs: added README --- README.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 05368da..145e4c6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,74 @@ -# Scala STDIO MCP Server +# Arithmetic + Logic MCP Server -Work-in-progress Model Context Protocol server implemented in Scala 3 with ZIO and Circe. It communicates over STDIO and is being built to provide arithmetic and logic tooling for editor integrations. More documentation will follow as the feature set lands. +## Overview +- STDIO-based Model Context Protocol (MCP) server implemented in Scala 3 using ZIO and Circe. +- Ships arithmetic and logic tools (add, subtract, multiply, divide, bitwise operators, comparisons) behind a clean modular architecture. +- Designed around layered functional composition so each concern—transport, routing, tool orchestration, configuration—remains isolated and testable. + +## High-Level Architecture +### Project structure +- `src/main/scala/mcp/Main.scala` – boots the server, assembles ZIO layers, and wires the STDIO bridge. +- `src/main/scala/mcp/transport` – STDIO JSON-RPC transport adapters (interfaces plus the concrete stream implementation). +- `src/main/scala/mcp/router` – JSON-RPC router plus method handlers (initialize, tools/list, tool dispatch) and shared error mapping. +- `src/main/scala/mcp/session` – session state service and abstractions for maintaining capabilities across requests. +- `src/main/scala/mcp/tools` – tool definitions, schemas, registries, and arithmetic/logic implementations grouped by concern. +- `src/main/scala/mcp/model` – data models for JSON-RPC and MCP payloads (initialize payloads, tool protocol, IDs, errors, capabilities). +- `src/main/scala/mcp/config` – configuration loading and environment wiring. +- `src/main/scala/mcp/util` – shared logging helpers. +- `src/test/scala/mcp/test` – ZIO test suites for initialization, routing, tool discovery, and session behavior. + +### Core components +1. **Transport layer (STDIO JSON-RPC)** – `transport.StdioTransport` reads and writes JSON-RPC envelopes over standard input/output, abstracted behind a `Transport` trait for testing. +2. **Router** – `router.RequestRouter` decodes JSON-RPC messages, matches MCP method names, and invokes the right handler (`InitializeHandler`, `ToolsHandler`, arithmetic dispatch, etc.). +3. **Tool registry** – `tools.ToolRegistry` aggregates arithmetic and logic tool metadata, schemas, and callable references for the MCP tools/list response. +4. **Tool executor** – each tool implements `Tool`, receives validated arguments (e.g., via `tools.common.NumericPairSchema`), and returns structured MCP results. +5. **Configuration layer** – `config.ServerConfig` produces configuration values (timeouts, logging verbosity, etc.) exposed as a ZIO layer. + +### ZIO layer composition at startup +`Main` composes layers roughly in this order: configuration → tool registry / tool logic → session services → routing handlers → STDIO transport. When the ZIO runtime boots, the composed layer graph injects dependencies into the server effect, ensuring each component only sees the interfaces it needs. + +### Execution flow +`VS Code sends JSON-RPC → STDIO transport reads it → router dispatches to the correct handler → handler executes the tool or method logic via the registry/executor → response propagates back over STDIO to VS Code`. + +## Run with Docker +```bash +docker run -it --rm roussalex/almcp:latest +``` +- The published image bundles the complete MCP server; no local Scala toolchain or JVM setup is required for basic usage. +- The container exposes the STDIO MCP contract automatically, making it easy to integrate with editors or clients. + +## Add the server to VS Code +1. Press `CTRL+SHIFT+P`. +2. Run **MCP: Add Server**. +3. Choose the **Docker image** option (VS Code will wrap the STDIO invocation automatically). +4. Enter `roussalex/almcp:latest` as the image and keep the default STDIO settings unless you need extra flags. +5. Save the entry; the VS Code MCP client will now launch this containerized server directly whenever Copilot connects. + +## Copilot Agent Mode is mandatory +- Copilot **Agent Mode** is the only mode that performs argument extraction and tool invocation. +- Regular chat mode does not populate tool parameters, so requests like `Add 2 and 3` will never reach the MCP server there. +- Always switch Copilot to Agent Mode before trying to interact with these tools. + +## Usage examples (Agent Mode only) +- **List the tools** – Ask Copilot: “List the available tools from the arithmetic MCP server.” The agent issues `tools/list`, returning every arithmetic and logic operator. +- **Call the `add` tool** – Simply say: “Add 2 and 3.” Copilot infers `{ "a": 2, "b": 3 }`, invokes the `add` tool, and streams the sum back to the conversation. +- Any supported operator (subtract, multiply, divide, AND/OR/XOR, comparisons) works the same way—natural language → agent infers parameters → MCP tool executes. + +## Testing +- ZIO test suites cover initialization (`SessionServiceSpec`), `tools/list` discovery (`ToolsSpec`), routing (`RequestRouterSpec`), JSON-RPC codecs, and session layers. +- Continuous integration runs these suites (see GitHub Actions for recent runs) to guarantee protocol compliance across changes. + +## Configuration +- Defaults are embedded in `ServerConfig`; there are no required environment variables or custom build steps for standard Docker/VS Code usage. +- Advanced setups can override configuration through typical ZIO layer overrides, but nothing special is needed out of the box. + +## Limitations & notes +- Copilot Agent Mode is **100% required** for argument extraction and tool execution; normal chat sessions cannot call tools. +- The server currently targets STDIO transport only; other transports would require additional wiring. + +## Goal of this README +Equip new contributors and VS Code users with a concise understanding of: +- what the server provides (arithmetic + logic tools), +- how its ZIO-based layers and router fit together, +- how to run it quickly via Docker, +- and how to connect through VS Code with Copilot Agent Mode for productive tool calls. From 603a0cae8e6c192d05df1fa378c4dba73b43f99b Mon Sep 17 00:00:00 2001 From: Alexandre-Roussel48 Date: Sun, 16 Nov 2025 01:00:45 +0100 Subject: [PATCH 5/7] chore: added Dockerfile --- Dockerfile | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d883305 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# ============================= +# Stage 1 — Build (sbt assembly) +# ============================= +FROM eclipse-temurin:21-jdk AS builder + +# Install SBT +RUN apt-get update && \ + apt-get install apt-transport-https curl gnupg -yqq && \ + echo "deb https://repo.scala-sbt.org/scalasbt/debian all main" | tee /etc/apt/sources.list.d/sbt.list && \ + echo "deb https://repo.scala-sbt.org/scalasbt/debian /" | tee /etc/apt/sources.list.d/sbt_old.list && \ + curl -sL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x2EE0EA64E40A89B84B2DF73499E82A75642AC823" | gpg --no-default-keyring --keyring gnupg-ring:/etc/apt/trusted.gpg.d/scalasbt-release.gpg --import && \ + chmod 644 /etc/apt/trusted.gpg.d/scalasbt-release.gpg && \ + apt-get update && \ + apt-get install sbt -y + +WORKDIR /app + +# Cache dependencies +COPY project ./project +COPY build.sbt . + +RUN sbt update + +# Copy sources +COPY . . + +# Assemble fat JAR +RUN sbt assembly + +# ============================= +# Stage 2 — Runtime (JRE only) +# ============================= +FROM eclipse-temurin:21-jre AS runtime + +WORKDIR /app + +# Copy assembled JAR +COPY --from=builder /app/target/scala-3.*/almcp-assembly-*.jar /app/almcp.jar + +# Pass-through env (default 0) +ENV MCP_DEBUG=0 + +# Run the MCP server with whatever MCP_DEBUG the user provides +ENTRYPOINT ["sh", "-c", "exec java -jar /app/almcp.jar"] From 84b58931b43d9e947aa32adfbf7c647ff5f382d9 Mon Sep 17 00:00:00 2001 From: Alexandre-Roussel48 Date: Sun, 16 Nov 2025 01:01:06 +0100 Subject: [PATCH 6/7] docs: updated image link to GHCR --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 145e4c6..9cc28f1 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ ## Run with Docker ```bash -docker run -it --rm roussalex/almcp:latest +docker run -it --rm ghcr.io/alexandre-roussel48/almcp:latest ``` - The published image bundles the complete MCP server; no local Scala toolchain or JVM setup is required for basic usage. - The container exposes the STDIO MCP contract automatically, making it easy to integrate with editors or clients. @@ -41,7 +41,7 @@ docker run -it --rm roussalex/almcp:latest 1. Press `CTRL+SHIFT+P`. 2. Run **MCP: Add Server**. 3. Choose the **Docker image** option (VS Code will wrap the STDIO invocation automatically). -4. Enter `roussalex/almcp:latest` as the image and keep the default STDIO settings unless you need extra flags. +4. Enter `ghcr.io/alexandre-roussel48/almcp:latest` as the image and keep the default STDIO settings unless you need extra flags (Use MCP_DEBUG=1 to debug requests and responses). 5. Save the entry; the VS Code MCP client will now launch this containerized server directly whenever Copilot connects. ## Copilot Agent Mode is mandatory From 9250012613ef0b382de59a4bc3f841ceaa747bf0 Mon Sep 17 00:00:00 2001 From: Alexandre-Roussel48 Date: Sun, 16 Nov 2025 01:01:55 +0100 Subject: [PATCH 7/7] chore(Logs): updated MCP_DEBUG check from exist to is true --- src/main/scala/mcp/util/DebugLogging.scala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/scala/mcp/util/DebugLogging.scala b/src/main/scala/mcp/util/DebugLogging.scala index 18d0ada..9e85d95 100644 --- a/src/main/scala/mcp/util/DebugLogging.scala +++ b/src/main/scala/mcp/util/DebugLogging.scala @@ -4,7 +4,10 @@ import zio._ object DebugLogging: private val enabled: Boolean = - sys.env.get("MCP_DEBUG").exists(_.nonEmpty) + sys.env + .get("MCP_DEBUG") + .map(_.trim.toLowerCase) + .exists(value => value == "1" || value == "true" || value == "yes") def log(line: => String): UIO[Unit] = if enabled then Console.printLineError(line).orDie