Scala 3 for MCP: annotation-driven and typed-contract APIs on both JVM and Scala.js/Bun.
fast-mcp-scala is a developer-friendly library for building Model Context Protocol servers. Extend one trait, declare your tools, done:
object HelloWorld extends McpServerApp[Stdio, HelloWorld.type]:
@Tool(name = Some("add"))
def add(@Param("a") a: Int, @Param("b") b: Int): Int = a + bNo override def run, no import zio.*, no ceremony. Two complementary registration paths converge on the same backend:
@Tool/@Resource/@Promptannotations +scanAnnotations[T]for a zero-boilerplate, macro-driven experience (JVM + Scala.js/Bun)McpTool,McpPrompt,McpStaticResource,McpTemplateResourcefor first-class, testable, cross-platform contract values — handlers return plain values,ZIO,Either[Throwable, _], orTryvia theToHandlerEffecttypeclass
Built on ZIO 2, Tapir-derived schemas, Jackson 3 (JVM) / zio-json (JS), the official Java MCP SDK 1.1.1, and the official TS MCP SDK 1.29.0. Transport is a phantom type parameter — McpServerApp[Stdio, Self.type] or McpServerApp[Http, Self.type] — with compile-time runner dispatch.
- Installation
- Quickstart
- Choosing a registration path
- Tools and
@Parammetadata - Tool hints
- Resources (static and templated)
- Prompts
- Context (
McpContext) - Transports
- Customizing decoding (Jackson 3)
- Two backends, one API
- Spec coverage
- Running examples
- Claude Desktop integration
- Developing locally
// JVM — Java SDK-backed runtime with annotations, derived schemas, HTTP + stdio transports.
libraryDependencies += "com.tjclp" %% "fast-mcp-scala" % "0.3.0-rc3"
// Scala.js — TS SDK-backed runtime on Bun/Node + the same annotation and typed-contract APIs.
libraryDependencies += "com.tjclp" %%% "fast-mcp-scala" % "0.3.0-rc3"Built against Scala 3.8.3. JVM requires JDK 17+. Scala.js artifact is published for sjs1_3 (Scala.js 1.x); runs on Bun (first-class) and Node 18+.
A single-file server with one tool — the same code lives in HelloWorld.scala:
//> using scala 3.8.3
//> using dep com.tjclp::fast-mcp-scala:0.3.0-rc3
//> using options "-Xcheck-macros" "-experimental"
import com.tjclp.fastmcp.{*, given}
object HelloWorld extends McpServerApp[Stdio, HelloWorld.type]:
@Tool(name = Some("add"), description = Some("Add two numbers"), readOnlyHint = Some(true))
def add(@Param("First operand") a: Int, @Param("Second operand") b: Int): Int = a + bThat's it — no import zio.*, no override def run, no ZIO.succeed(...). The McpServerApp[T, Self] trait handles server construction, annotation scanning, and transport lifecycle. Transport is a phantom type parameter (Stdio / Http) that compile-time-selects the runner.
Exercise it through the MCP Inspector:
npx @modelcontextprotocol/inspector scala-cli scripts/quickstart.scAnnotations (@Tool + scanAnnotations) |
Typed contracts (McpTool) |
|
|---|---|---|
| Platform | JVM + Scala.js/Bun | JVM + Scala.js/Bun |
| Style | Methods on an object, discovered by macro | First-class vals |
| Schema | Derived from method signature & @Param |
Derived from case-class fields & @Param |
| Testing | Call the method directly | Invoke .handler on the value |
| Composability | Whatever methods the object exposes | Collect into lists, generate from config |
| Best for | Quick servers, prototypes, single-module apps | Libraries, cross-module sharing, production codebases |
Both coexist on the same server — override tools / prompts / staticResources / templateResources on your McpServerApp to mount typed contracts alongside annotated methods:
object MyServer extends McpServerApp[Stdio, MyServer.type]:
@Tool(name = Some("ping")) def ping(): String = "pong"
override val tools = List(
McpTool[AddArgs, AddResult](name = "add") { args =>
AddResult(args.a + args.b) // plain value — auto-lifted
}
)Handler lambdas return plain values, ZIO, Either[Throwable, _], or scala.util.Try — the ToHandlerEffect[F[_]] typeclass picks the right lift. Bring your own given for other effect systems (cats.effect.IO, Monix, ...).
See AnnotatedServer.scala for the annotation path and ContractServer.scala for typed contracts.
Every tool parameter can carry metadata that flows into the derived JSON schema:
@Tool(name = Some("search"), description = Some("Search with optional filters"))
def search(
@Param(description = "Search query", examples = List("scala", "mcp"))
query: String,
@Param(description = "Maximum results", examples = List("10", "25"), required = false)
limit: Option[Int],
@Param(
description = "Sort order",
schema = Some("""{"type": "string", "enum": ["relevance", "date"]}""")
)
sortBy: String
): String = ???description— populates the schema'sdescriptionfieldexamples— populates the JSON Schemaexamplesarray (clients can show suggestions)required = false— combined withOption[...]or a default value, marks the field optionalschema— raw JSON Schema fragment that overrides the derived schema entirely (useful for enum constraints, patterns, or numeric bounds Scala types can't express)
Full demo in AnnotatedServer.scala.
MCP Tool Annotations (a.k.a. behavioral hints) tell the client how your tool behaves. Set them on @Tool:
| Hint | Meaning |
|---|---|
title |
Human-readable display name (distinct from the wire-level name) |
readOnlyHint |
The tool only reads state; safe to call without confirmation |
destructiveHint |
The tool may irreversibly modify state — clients should confirm |
idempotentHint |
Repeated calls with the same args produce the same effect as one call |
openWorldHint |
The tool reaches outside the local process (network, filesystem, APIs) |
returnDirect |
Return the result directly to the user, skipping LLM post-processing |
@Tool(
name = Some("listTasks"),
description = Some("List tasks with optional filtering"),
readOnlyHint = Some(true),
idempotentHint = Some(true),
openWorldHint = Some(false)
)
def listTasks(filter: TaskFilter): List[Task] = ...See TaskManagerServer.scala for hints across a realistic tool set.
Static resources have a fixed URI and no parameters:
@Resource(uri = "static://welcome", description = Some("A welcome message"))
def welcome(): String = "Welcome!"Templated resources use {placeholders} in the URI, matched against method parameter names:
@Resource(
uri = "users://{userId}/profile",
description = Some("User profile as JSON"),
mimeType = Some("application/json")
)
def userProfile(@Param("The user id") userId: String): String = ...Return a List[Message] — fast-mcp-scala handles the MCP framing:
@Prompt(name = Some("greeting"), description = Some("Personalized greeting"))
def greeting(
@Param("Name of the person") name: String,
@Param("Optional title", required = false) title: String = ""
): List[Message] =
List(Message(Role.User, TextContent(s"Generate a warm greeting for $title $name.")))A prompt that returns a single String is automatically wrapped into a User message.
Add an optional ctx: McpContext (annotation path) or use McpTool.contextual (typed-contract path) to access the client's declared info and capabilities:
def echo(args: Map[String, Any], ctx: Option[McpContext]): String =
val clientName = ctx.flatMap(_.getClientInfo.map(_.name())).getOrElse("unknown")
s"Hello from $clientName"Runnable demo: ContextEchoServer.scala.
Transport is a phantom type parameter on McpServerApp[T, Self] — Stdio or Http. The matching TransportRunner[T] given resolves at compile time, so there's no run-time transport plumbing in user code.
object MyServer extends McpServerApp[Stdio, MyServer.type]:
@Tool(...) def hello(name: String): String = s"Hello, $name!"Flip to Http and override settings to tune the listener. runHttp() serves the full MCP Streamable HTTP spec: POST /mcp for JSON-RPC, the mcp-session-id header for session tracking, and SSE streams for long-running calls.
object MyHttpServer extends McpServerApp[Http, MyHttpServer.type]:
override def settings = McpServerSettings(port = 8090)
@Tool(...) def hello(name: String): String = s"Hello, $name!"Toggle stateless = true on McpServerSettings for request/response-only mode (no sessions, no SSE), useful behind load balancers.
Need lower-level control? Skip the sugar trait and construct directly — val server = McpServer("name", "0.1.0") returns the platform-appropriate server, and you can call .tool(...) / .runHttp() yourself inside your own ZIOAppDefault.
| Setting | Default | Description |
|---|---|---|
host |
0.0.0.0 |
Bind address |
port |
8000 |
Listen port |
httpEndpoint |
/mcp |
JSON-RPC endpoint path |
stateless |
false |
Disable sessions and SSE |
Curl recipes for both modes are in HttpServer.scala.
fast-mcp-scala uses Jackson 3 to turn raw JSON-RPC arguments into Scala values. Primitives, Scala enums, case classes, Option, List, Map, and java.time types work out of the box — no configuration required.
For custom wire formats, supply a given JacksonConverter[T]:
import java.time.LocalDateTime
given JacksonConverter[LocalDateTime] = JacksonConverter.fromPartialFunction[LocalDateTime] {
case s: String => LocalDateTime.parse(s)
}
given JacksonConverter[Task] = DeriveJacksonConverter.derived[Task]The handler receives a JacksonConversionContext (not a raw Jackson mapper) — see docs/jackson-converter-enhancements.md for the detailed API.
fast-mcp-scala is a single library with two real runtime backends — JVM and Scala.js/Bun — behind the same shared abstract API:
shared/src/ (platform-neutral Scala 3)
┌──────────────────────────────────────────────────────────┐
│ annotations │ typed contracts │ Tool/Prompt/Resource │
│ (@Tool, ...)│ (McpTool, McpPrompt)│ managers + McpContext│
│ McpServerApp │ scanAnnotations │ TransportRunner │
│ (sugar trait)│ (shared macros) │ ToHandlerEffect │
└──────────┬─────────────────────────────┬─────────────────┘
│ │
jvm/src/ (FastMcpServer) js/src/ (JsMcpServer)
wraps Java MCP SDK wraps TS MCP SDK via
(mcp-core 1.1.1) Scala.js facades, runs on Bun
McpServerApp[T, Self] is the declarative entry point on both targets; the concrete backend resolves via the McpServerCoreFactory given (FastMcpServer on JVM, JsMcpServer on JS). Typed contracts (McpTool, McpPrompt, McpStaticResource, McpTemplateResource) compile and mount unchanged on both.
What the Scala.js backend gives you:
- A real MCP server runtime on Bun, wrapping the official
@modelcontextprotocol/sdk— stdio (runStdio) and Streamable HTTP (runHttp) transports, with stateful (session + SSE) and stateless (JSON-response-only) modes. - AJV-based schema validation of tool arguments, matching the JVM server's behaviour.
JsMcpContextextension methods (getClientInfo,getClientCapabilities,getSessionId) for handlers that need client-session details.
Current platform parity:
| Capability | JVM | Scala.js (Bun-first) |
|---|---|---|
McpServerApp[T, Self] sugar trait |
✅ | ✅ |
@Tool / @Resource / @Prompt + scanAnnotations[T] |
✅ | ✅ |
Typed contracts (McpTool, McpPrompt, McpStaticResource, McpTemplateResource) |
✅ | ✅ |
ToolSchemaProvider[A] auto-derivation from @Param |
✅ via Tapir | ✅ via Tapir |
ToHandlerEffect[F] — plain values / ZIO / Either / Try |
✅ | ✅ |
| Stdio transport | ✅ (Java SDK) | ✅ (TS SDK) |
| Streamable HTTP — stateful (sessions + SSE) | ✅ (ZIO HTTP) | ✅ (Bun.serve + Web-Standard transport) |
| Streamable HTTP — stateless | ✅ | ✅ |
| Custom decoders | ✅ JacksonConverter |
✅ given JsonDecoder[T] → McpDecoder[T] via zio-json |
Node / Deno parity for the HTTP listener is a follow-up; the same WebStandardStreamableHTTPServerTransport works across runtimes, only the Bun.serve(...) entry point is Bun-specific today.
Proof: the conformance suite at JsServerConformanceTest.scala stands up a JsMcpServer in-process and drives every MCP operation through the official TS SDK client via InMemoryTransport; JsServerHttpTest.scala verifies the Bun HTTP routing; ConformanceTest.scala runs a JS client against the JVM server for cross-backend parity.
//> using scala 3.8.3
//> using dep com.tjclp::fast-mcp-scala_sjs1:0.3.0-rc3
import com.tjclp.fastmcp.{*, given}
object HelloBun extends McpServerApp[Stdio, HelloBun.type]:
@Tool(name = Some("add"), description = Some("Add two numbers"), readOnlyHint = Some(true))
def add(@Param("First operand") a: Int, @Param("Second operand") b: Int): Int = a + bSame shape as the JVM — the McpServerApp trait picks up the Scala.js McpServerCoreFactory given and builds a JsMcpServer under the hood. For typed contracts on Scala.js, McpTool[...] now auto-generates the input schema as well; import sttp.tapir.generic.auto.* at the call site the same way you do on the JVM.
Link with ./mill fast-mcp-scala.js.fastLinkJS, then bun run out/fast-mcp-scala/js/fastLinkJS.dest/main.js. See HelloWorldJs.scala and HttpServerJs.scala for runnable references.
fast-mcp-scala implements a focused subset of the MCP specification:
| Capability | Status |
|---|---|
| Tools (list, call) + Tool Annotations/hints | ✅ |
| Static resources & resource templates | ✅ |
| Prompts with arguments | ✅ |
McpContext (client info, capabilities) |
✅ |
| Stdio transport | ✅ |
| Streamable HTTP transport (sessions + SSE) | ✅ |
| Stateless HTTP transport | ✅ |
| Progress notifications | ❌ (not yet) |
| Sampling | ❌ (not yet) |
| Elicitation | ❌ (not yet) |
| Completion | ❌ (not yet) |
| Resource subscriptions | ❌ (not yet) |
| Log level control | ❌ (not yet) |
See the CHANGELOG for release-by-release changes.
JVM — fast-mcp-scala/jvm/src/com/tjclp/fastmcp/examples/:
| Example | Demonstrates |
|---|---|
HelloWorld.scala |
Minimum viable server — one tool, stdio |
AnnotatedServer.scala |
Flagship annotation path — tools, hints, @Param features, resources, prompts |
ContractServer.scala |
Typed contracts as first-class values; cross-platform story |
TaskManagerServer.scala |
Realistic domain server — custom Jackson converters, hints across a CRUD-style surface |
ContextEchoServer.scala |
McpContext introspection inside a tool handler |
HttpServer.scala |
HTTP transport (Streamable default, Stateless via a flag) with curl recipes |
./mill fast-mcp-scala.jvm.runMain com.tjclp.fastmcp.examples.HelloWorld
# or, via scala-cli:
scala-cli scripts/quickstart.scScala.js / Bun — fast-mcp-scala/js/src/com/tjclp/fastmcp/examples/:
| Example | Demonstrates |
|---|---|
HelloWorldJs.scala |
Minimum viable server on Bun — one tool, stdio |
HttpServerJs.scala |
Streamable HTTP transport on Bun — stateful sessions or stateless |
./mill fast-mcp-scala.js.fastLinkJS
bun run out/fast-mcp-scala/js/fastLinkJS.dest/main.jsAdd to claude_desktop_config.json:
{
"mcpServers": {
"fast-mcp-scala-example": {
"command": "scala-cli",
"args": [
"-e",
"//> using dep com.tjclp::fast-mcp-scala:0.3.0-rc3",
"--main-class",
"com.tjclp.fastmcp.examples.AnnotatedServer"
]
}
}
}fast-mcp-scala example servers are for demo purposes only — they don't do anything useful, but they make it easy to see MCP in action.
For architectural detail, see docs/architecture.md.
./mill fast-mcp-scala.compile # Compile JVM + Scala.js
./mill fast-mcp-scala.test # All tests (JVM + Bun conformance)
./mill fast-mcp-scala.checkFormat # Scalafmt check (all sources)
./mill fast-mcp-scala.reformat # Auto-format (all sources)
./mill fast-mcp-scala.jvm.test # JVM tests only
./mill fast-mcp-scala.js.test.bunTest # Scala.js conformance tests only
./mill fast-mcp-scala.jvm.publishLocal # Publish JVM artifact to ~/.ivy2/localAfter publishLocal:
libraryDependencies += "com.tjclp" %% "fast-mcp-scala" % "0.3.0-SNAPSHOT"Or with Mill:
def ivyDeps = Agg(
ivy"com.tjclp::fast-mcp-scala:0.3.0-SNAPSHOT"
)Or point scala-cli at a built JAR directly:
//> using scala 3.8.3
//> using jar "/absolute/path/to/out/fast-mcp-scala/jvm/jar.dest/out.jar"
//> using options "-Xcheck-macros" "-experimental"