Agent Skills

fast-mcp-scala

A quick and easy way to deploy MCP servers using Scala

README.md

fast-mcp-scala

Maven Central CI Conformance Native Image License: MIT

Scala 3 for MCP: annotation-driven and typed-contract APIs on the JVM, Scala.js/Bun, and Scala Native.

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 + b

Two registration paths, @Tool-style annotations and typed McpTool contracts, converge on the same backend. Built on ZIO 2 and zio-json, with JSON Schemas derived directly by Scala 3 macros. The whole MCP protocol layer (JSON-RPC, wire types, router, transports) is native Scala 3 in shared/; there is no vendored SDK. It targets MCP 2026-07-28 and keeps a compatibility adapter for earlier protocol revisions.

Installation

// sbt — JVM
libraryDependencies += "com.tjclp" %% "fast-mcp-scala" % "1.0.1"

// sbt — Scala.js (Bun-first) or Scala Native (stdio only, experimental); %%% picks the platform artifact
libraryDependencies += "com.tjclp" %%% "fast-mcp-scala" % "1.0.1"

//> using dep com.tjclp::fast-mcp-scala:1.0.1    // scala-cli, JVM
//> using dep com.tjclp::fast-mcp-scala::1.0.1   // scala-cli, Scala.js or Native (with `//> using platform ...`)

Built against Scala 3.9.0 LTS: consuming 1.0.0 requires Scala 3.9.0 or newer (it emits TASTy 28.9, which 3.8 and older compilers cannot read; the RC3 prerelease, built with Scala 3.8.3, is the last release a Scala 3.8 project can use). 1.0.0 is not compiled with -experimental, so consumers no longer need the flag (the release candidates required it on every registration path; TJC-2335). JVM: JDK 17+ (CI tests the LTS releases 17, 21, and 25). Scala.js: sjs1_3, runs on Bun (first-class) and, for stdio, on Node 18+ (verified with Node 18 and 26 in the 1.0.0 dogfood, not yet in CI; the HTTP listener is Bun.serve-only, and bearer task ids come from globalThis.crypto, which Node 18 exposes only behind --experimental-global-webcrypto); Scala 3.9 output needs a Scala.js 1.22+ linker (Mill: mill-bun 0.3.x with an explicit scalaJSVersion; scala-cli: --js-version 1.22.0). Scala Native: native0.5_3, stdio only, experimental. Platform details and quickstarts: docs/platforms.md.

Quickstart

A single-file server with one tool; the same code lives in HelloWorld.scala:

//> using scala 3.9.0
//> using dep com.tjclp::fast-mcp-scala:1.0.1

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 + b

No import zio.*, no override def run, no ZIO.succeed(...). The McpServerApp[T, Self] trait handles server construction, annotation scanning, and transport lifecycle; the transport is a phantom type parameter (Stdio / Http) that selects the runner at compile time.

Exercise it through the MCP Inspector:

npx @modelcontextprotocol/inspector scala-cli scripts/quickstart.sc

The first run downloads the compiler and dependencies (about 60 MB) and compiles the script, which can exceed the Inspector's 15 s connect timeout (it then reports only Connection closed). Run scala-cli compile scripts/quickstart.sc once beforehand, or pass --connect-timeout <ms> to the Inspector.

Choosing a registration path

Annotations (@Tool + scanAnnotations) Typed contracts (McpTool)
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 work on every platform and coexist on the same server: override tools / prompts / staticResources / templateResources on your McpServerApp to mount typed contracts alongside annotated methods.

case class AddArgs(a: Int, b: Int)
case class AddResult(sum: Int)

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
    }
  )

A typed tool's In is a case class (its schema must be a JSON object, as MCP arguments always are); a tool with no arguments takes an empty one — case class NoArgs() — since McpTool[Unit, _] has no decoder. Handler lambdas return plain values, ZIO, Either[Throwable, _], or scala.util.Try; the ToHandlerEffect[F[_], R] typeclass picks the right lift (R is the ZIO environment — Any for the plain builders), and you can bring your own given for other effect systems by implementing ToHandlerEffect[F, Any] for your effect type F. See AnnotatedServer.scala for the annotation path and ContractServer.scala for typed contracts.

Tools and @Param metadata

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's description field
  • examples populates the JSON Schema examples array (clients can show suggestions)
  • required = false, combined with Option[...] or a default value, marks the field optional; a bare Option[...] parameter is optional without it, but a default value alone does not remove the field from required — the default is applied when the argument is omitted, so write required = false to advertise it as optional
  • schema is a raw JSON Schema fragment that overrides the derived schema entirely — repeat the description inside the fragment, since the derived property (description and examples included) is replaced wholesale

An annotated method's result is sent as TextContent(result.toString) unless it is a String, a Content, a List[Content], an Array[Byte] (or a ZIO of one of those): a Some(1), a case class or a Map arrives as Scala's toString text, not JSON. Return a String/Content, or use a typed McpTool (with .withOutputSchema for structuredContent) when clients need JSON.

Overloading is fine: only the annotated overload is registered, and its schema and handler come from that exact declaration; two annotated overloads must register distinct names — duplicate names or resource URI patterns within one object are a compile-time error. Annotation arguments such as name, description and the hints must be literals (Some("..."), Option("..."), None, or a final val constant); anything else is a compile-time error.

An annotated method has exactly one parameter list (no currying, no using clauses), no type parameters, and at most 22 parameters, and only members declared directly on the scanned object are registered (private/protected included — visibility does not gate exposure, so annotate only what you mean to publish). An annotated inherited member or val, like any other unsupported shape, is a compile-time error naming it and the fix; an annotated method inside a nested object is reported as well (an error when the scanned object declares nothing of its own, otherwise a warning, since the nested object may be scanned separately).

Enums, nested case classes, Option, collections, and java.time values derive with no user-supplied givens; custom wire shapes go through McpInputCodec. See docs/custom-types.md.

Tool hints

MCP Tool Annotations tell the client how a 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 have the effect of 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

TaskManagerServer.scala applies hints across a realistic tool set.

Resources (static and templated)

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 =
  s"""{"userId":"$userId"}"""

Templates are listed through resources/templates/list only when exposeTemplatesEndpoint is set (resources/list never lists templates; with the default false the templates endpoint answers an empty page and clients derive templates from {} URIs; resources/read on a matching URI works either way). AnnotatedServer.scala turns it on:

override def settings = McpServerSettings(exposeTemplatesEndpoint = true)

A placeholder matches a non-empty run of characters within one path segment (never /); literal text is matched verbatim (not as a regex); placeholders in the same segment must be separated by literal text (a template such as x://{a}{b} is rejected at registration, so the server fails to start). Client URIs longer than limits.maxUriChars (8192) are rejected with -32602.

Prompts

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.

Context (McpContext)

Add a parameter named exactly ctx of type McpContext to a @Tool method to read the client's declared info and capabilities, request metadata, and to send progress or logging (the parameter is recognised by its name and is not part of the tool's schema; @Prompt and @Resource methods do not take a context parameter):

@Tool(name = Some("echo"), description = Some("Echo client and request context"))
def echo(
    @Param(description = "Optional note to include", required = false) note: Option[String],
    ctx: McpContext
): String =
  val clientName = ctx.getClientInfo.map(_.name).getOrElse("Unknown Client")
  s"Hello from $clientName${note.fold("")(n => s": $n")}"

Client-visible logging goes through the context too: ctx.sendLogMessage(level, data) returns a ZIO (so the handler returns one), and LoggingLevel (in com.tjclp.fastmcp.core) is root-exported since 1.0.0 — the release candidates needed it imported by name, which still works:

import zio.*
import zio.json.ast.Json
import com.tjclp.fastmcp.core.LoggingLevel   // optional since 1.0.0 (root-exported); the release candidates need it

@Tool(name = Some("echo_logged"), description = Some("Echo the note and log it"))
def echoLogged(@Param("Note to echo") note: String, ctx: McpContext): ZIO[Any, Throwable, String] =
  ctx.sendLogMessage(LoggingLevel.Info, Json.Str(s"echo: $note")).as(note)

Typed contracts use the builder's .contextual — McpTool[In, Out](name = "echo").contextual { (in, ctx) => ... } — whose handler receives (In, Option[McpContext]). Runnable demo: ContextEchoServer.scala.

Transports

Transport is a phantom type parameter on McpServerApp[T, Self]: Stdio or Http.

stdio (for Claude Desktop, MCP Inspector)

object MyServer extends McpServerApp[Stdio, MyServer.type]:
  @Tool(...) def hello(name: String): String = s"Hello, $name!"

HTTP (for remote clients, load balancers, test harnesses)

object MyHttpServer extends McpServerApp[Http, MyHttpServer.type]:
  override def settings = McpServerSettings(port = 8090)

  @Tool(...) def hello(name: String): String = s"Hello, $name!"

For MCP 2026-07-28, runHttp() accepts one stateless JSON-RPC message per POST /mcp, answering with JSON or a request-scoped SSE stream. Older clients are served by a legacy initialize/session adapter that is on by default.

Setting Default Description
host 127.0.0.1 Bind address; set "0.0.0.0" for containers or external exposure
port 8000 Listen port
stateless false Disable the legacy session adapter; modern requests are always stateless

All settings, required request headers, error-code mapping, the legacy adapter, and lower-level construction without the sugar trait: docs/transports.md.

Native image (GraalVM)

Stdio servers compile to self-contained GraalVM binaries with zero hand-written reachability metadata (about 35 MB, instant startup, no JVM in the container): registration and schema derivation are compile-time macros, and the transport-seam split keeps zio-http/netty out of stdio-only images — exclude dev.zio:zio-http_3 from the dependency (netty arrives only through zio-http, so that single exclusion sheds both). HTTP servers compile too and pass the official conformance suite as a native binary in CI. Recipes, flags, and the metadata audit loop: docs/native-image.md.

Platforms

One core, three targets. The protocol layer is shared; each platform contributes only a transport backend.

JVM Scala.js / Bun Scala Native (experimental)
Annotations, typed contracts, McpServerApp ✅ ✅ ✅
Stdio ✅ ✅ ✅ (LLVM binary)
Streamable HTTP, MCP 2026-07-28 ✅ ZIO HTTP ✅ Bun.serve ✗ by design¹
Legacy HTTP session adapter ✅ ✅ ✗ by design¹
Tasks extension ✅ ✅ ✅ (stdio)
Standalone binary GraalVM native image — LLVM via Scala Native

¹ zio-http has no Scala Native artifacts, so McpServerApp[Http] does not compile there; a socket-based backend is in progress (#81).

The official MCP conformance suite runs in CI against the JVM and Bun servers and against the GraalVM native binary, with empty expected-failure baselines. Full parity matrix, Bun and Scala Native quickstarts: docs/platforms.md; coverage details: docs/spec-coverage.md.

Documentation

Claude Desktop integration

Add to claude_desktop_config.json:

{
  "mcpServers": {
    "fast-mcp-scala-example": {
      "command": "scala-cli",
      "args": [
        "-e",
        "//> using scala 3.9.0",
        "-e",
        "//> using dep com.tjclp::fast-mcp-scala:1.0.1",
        "--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.

License

MIT

Search skills and MCP servers

Fuzzy search across 23,137 skills and servers