Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ local.sbt
# Metals
.bloop/
.metals/
.ivy/
metals.sbt

# IDEA
Expand Down
44 changes: 44 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
75 changes: 73 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 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.

## 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 `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
- 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.
19 changes: 19 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -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")
)
1 change: 1 addition & 0 deletions project/build.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sbt.version=1.11.7
1 change: 1 addition & 0 deletions project/plugins.sbt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1")
51 changes: 51 additions & 0 deletions src/main/scala/mcp/Main.scala
Original file line number Diff line number Diff line change
@@ -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
}
}
43 changes: 43 additions & 0 deletions src/main/scala/mcp/config/ServerConfig.scala
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions src/main/scala/mcp/model/jsonrpc/JsonRpcError.scala
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 21 additions & 0 deletions src/main/scala/mcp/model/jsonrpc/JsonRpcId.scala
Original file line number Diff line number Diff line change
@@ -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)
)
}
71 changes: 71 additions & 0 deletions src/main/scala/mcp/model/jsonrpc/JsonRpcMessage.scala
Original file line number Diff line number Diff line change
@@ -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]
}
}
Loading
Loading