FastMCP-Scala

Una biblioteca de Scala 3 para construir servidores del Protocolo de Contexto de Modelo (MCP).

Documentación

fast-mcp-scala

Maven Central CI Conformance Native Image License: MIT

Scala 3 para MCP: APIs de contratos tipados y controladas por anotaciones en JVM, Scala.js/Bun y Scala Native.

fast-mcp-scala es una biblioteca amigable para desarrolladores que permite construir servidores Model Context Protocol. Extiende un trait, declara tus herramientas, listo:

object HelloWorld extends McpServerApp[Stdio, HelloWorld.type]:
  @Tool(name = Some("add"))
  def add(@Param("a") a: Int, @Param("b") b: Int): Int = a + b

Dos rutas de registro, anotaciones estilo @Tool y contratos tipados McpTool, convergen en el mismo backend. Construido sobre ZIO 2 y zio-json, con esquemas JSON derivados directamente por macros de Scala 3. Toda la capa de protocolo MCP (JSON-RPC, tipos de cable, enrutador, transportes) es Scala 3 nativo en shared/; no hay SDK integrado. Apunta a MCP 2026-07-28 y mantiene un adaptador de compatibilidad para revisiones de protocolo anteriores.

Instalación

// 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 ...`)

Compilado contra Scala 3.9.0 LTS: consumir 1.0.0 requiere Scala 3.9.0 o más reciente (emite TASTy 28.9, que los compiladores 3.8 y anteriores no pueden leer; el prerelease RC3, compilado con Scala 3.8.3, es la última versión que un proyecto de Scala 3.8 puede usar). 1.0.0 no está compilado con -experimental, por lo que los consumidores ya no necesitan la bandera (los candidatos de lanzamiento la requerían en cada ruta de registro; TJC-2335). JVM: JDK 17+ (CI prueba las versiones LTS 17, 21 y 25). Scala.js: sjs1_3, se ejecuta en Bun (de primera clase) y, para stdio, en Node 18+ (verificado con Node 18 y 26 en el dogfood de 1.0.0, aún no en CI; el listener HTTP es solo Bun.serve, y los IDs de tareas bearer provienen de globalThis.crypto, que Node 18 expone solo detrás de --experimental-global-webcrypto); la salida de Scala 3.9 necesita un enlazador Scala.js 1.22+ (Mill: mill-bun 0.3.x con un scalaJSVersion explícito; scala-cli: --js-version 1.22.0). Scala Native: native0.5_3, solo stdio, experimental. Detalles de plataforma y guías rápidas: docs/platforms.md.

Inicio rápido

Un servidor de un solo archivo con una herramienta; el mismo código vive en 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

Sin import zio.*, sin override def run, sin ZIO.succeed(...). El trait McpServerApp[T, Self] maneja la construcción del servidor, el escaneo de anotaciones y el ciclo de vida del transporte; el transporte es un parámetro de tipo fantasma (Stdio / Http) que selecciona el ejecutor en tiempo de compilación.

Pruébalo a través del Inspector MCP:

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

La primera ejecución descarga el compilador y las dependencias (alrededor de 60 MB) y compila el script, lo que puede exceder el tiempo de espera de conexión de 15 s del Inspector (entonces solo reporta Connection closed). Ejecuta scala-cli compile scripts/quickstart.sc una vez de antemano, o pasa --connect-timeout <ms> al Inspector.

Elegir una ruta de registro

Anotaciones (@Tool + scanAnnotations)Contratos tipados (McpTool)
EstiloMétodos en un objeto, descubiertos por macrovals de primera clase
EsquemaDerivado de la firma del método y @ParamDerivado de los campos de la clase de caso y @Param
PruebasLlama al método directamenteInvoca .handler en el valor
ComponibilidadCualquier método que el objeto expongaRecoge en listas, genera desde configuración
Mejor paraServidores rápidos, prototipos, aplicaciones de un solo móduloBibliotecas, uso compartido entre módulos, bases de código de producción

Ambos funcionan en cada plataforma y coexisten en el mismo servidor: anula tools / prompts / staticResources / templateResources en tu McpServerApp para montar contratos tipados junto a métodos anotados.

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

El In de una herramienta tipada es una clase de caso (su esquema debe ser un objeto JSON, como los arguments de MCP siempre lo son); una herramienta sin argumentos toma uno vacío — case class NoArgs() — ya que McpTool[Unit, _] no tiene decodificador. Las lambdas de manejador devuelven valores simples, ZIO, Either[Throwable, _] o scala.util.Try; el typeclass ToHandlerEffect[F[_], R] elige la elevación correcta (R es el entorno ZIO — Any para los constructores simples), y puedes traer tu propio given para otros sistemas de efectos implementando ToHandlerEffect[F, Any] para tu tipo de efecto F. Consulta AnnotatedServer.scala para la ruta de anotaciones y ContractServer.scala para contratos tipados.

Herramientas y metadatos @Param

Cada parámetro de herramienta puede llevar metadatos que fluyen al esquema JSON derivado:

@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 completa el campo description del esquema
  • examples completa el array examples del esquema JSON (los clientes pueden mostrar sugerencias)
  • required = false, combinado con Option[...] o un valor predeterminado, marca el campo como opcional; un parámetro Option[...] desnudo es opcional sin él, pero un valor predeterminado solo no elimina el campo de required — el predeterminado se aplica cuando se omite el argumento, así que escribe required = false para anunciarlo como opcional
  • schema es un fragmento de esquema JSON crudo que anula el esquema derivado por completo — repite el description dentro del fragmento, ya que la propiedad derivada (descripción y ejemplos incluidos) se reemplaza por completo

El resultado de un método anotado se envía como TextContent(result.toString) a menos que sea un String, un Content, un List[Content], un Array[Byte] (o un ZIO de uno de esos): un Some(1), una clase de caso o un Map llega como texto toString de Scala, no JSON. Devuelve un String/Content, o usa un McpTool tipado (con .withOutputSchema para structuredContent) cuando los clientes necesiten JSON.

La sobrecarga está bien: solo se registra la sobrecarga anotada, y su esquema y manejador provienen de esa declaración exacta; dos sobrecargas anotadas deben registrar names distintos — nombres duplicados o patrones de URI de recursos dentro de un objeto son un error en tiempo de compilación. Los argumentos de anotación como name, description y las pistas deben ser literales (Some("..."), Option("..."), None o una constante final val); cualquier otra cosa es un error en tiempo de compilación.

Un método anotado tiene exactamente una lista de parámetros (sin currying, sin cláusulas using), sin parámetros de tipo, y como máximo 22 parámetros, y solo los miembros declarados directamente en el objeto escaneado se registran (private/protected incluidos — la visibilidad no bloquea la exposición, así que anota solo lo que pretendes publicar). Un miembro anotado heredado o val, como cualquier otra forma no compatible, es un error en tiempo de compilación que lo nombra y la solución; un método anotado dentro de un objeto anidado también se reporta (un error cuando el objeto escaneado no declara nada propio, de lo contrario una advertencia, ya que el objeto anidado puede escanearse por separado).

Enums, clases de caso anidadas, Option, colecciones y valores java.time se derivan sin givens proporcionados por el usuario; las formas de cable personalizadas pasan por McpInputCodec. Consulta docs/custom-types.md.

Pistas de herramientas

Las anotaciones de herramientas MCP le dicen al cliente cómo se comporta una herramienta. Configúralas en @Tool:

PistaSignificado
titleNombre de visualización legible por humanos (distinto del name a nivel de cable)
readOnlyHintLa herramienta solo lee estado; segura de llamar sin confirmación
destructiveHintLa herramienta puede modificar estado de forma irreversible; los clientes deben confirmar
idempotentHintLlamadas repetidas con los mismos argumentos tienen el efecto de una sola llamada
openWorldHintLa herramienta alcanza fuera del proceso local (red, sistema de archivos, APIs)
returnDirectDevuelve el resultado directamente al usuario, omitiendo el postprocesamiento del LLM

TaskManagerServer.scala aplica pistas en un conjunto de herramientas realista.

Recursos (estáticos y con plantillas)

Los recursos estáticos tienen un URI fijo y sin parámetros:

@Resource(uri = "static://welcome", description = Some("A welcome message"))
def welcome(): String = "Welcome!"

Los recursos con plantillas usan {placeholders} en el URI, coincidiendo con los nombres de parámetros del método:

@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"}"""

Las plantillas se listan a través de resources/templates/list solo cuando exposeTemplatesEndpoint está configurado (resources/list nunca lista plantillas; con el false predeterminado, el endpoint de plantillas responde con una página vacía y los clientes derivan plantillas de los URIs {}; resources/read en un URI coincidente funciona de cualquier manera). AnnotatedServer.scala lo activa:

override def settings = McpServerSettings(exposeTemplatesEndpoint = true)

Un marcador de posición coincide con una ejecución no vacía de caracteres dentro de un segmento de ruta (nunca /); el texto literal se compara textualmente (no como regex); los marcadores de posición en el mismo segmento deben estar separados por texto literal (una plantilla como x://{a}{b} se rechaza en el registro, por lo que el servidor no se inicia). Los URIs de cliente más largos que limits.maxUriChars (8192) se rechazan con -32602.

Prompts

Devuelve un List[Message]; fast-mcp-scala maneja el encuadre MCP:

@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.")))

Un prompt que devuelve un solo String se envuelve automáticamente en un mensaje User.

Contexto (McpContext)

Agrega un parámetro llamado exactamente ctx de tipo McpContext a un método @Tool para leer la información y capacidades declaradas del cliente, metadatos de solicitud, y para enviar progreso o registros (el parámetro se reconoce por su nombre y no es parte del esquema de la herramienta; los métodos @Prompt y @Resource no toman un parámetro de contexto):

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

El registro visible para el cliente también pasa por el contexto: ctx.sendLogMessage(level, data) devuelve un ZIO (por lo que el manejador devuelve uno), y LoggingLevel (en com.tjclp.fastmcp.core) se exporta desde la raíz desde 1.0.0 — los candidatos de lanzamiento necesitaban importarlo por nombre, lo que aún funciona:

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)

Los contratos tipados usan el .contextual del constructor — McpTool[In, Out](name = "echo").contextual { (in, ctx) => ... } — cuyo manejador recibe (In, Option[McpContext]). Demo ejecutable: ContextEchoServer.scala.

Transportes

El transporte es un parámetro de tipo fantasma en McpServerApp[T, Self]: Stdio o Http.

stdio (para Claude Desktop, MCP Inspector)

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

HTTP (para clientes remotos, balanceadores de carga, arneses de prueba)

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

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

Para MCP 2026-07-28, runHttp() acepta un mensaje JSON-RPC sin estado por POST /mcp, respondiendo con JSON o un flujo SSE con ámbito de solicitud. Los clientes más antiguos son atendidos por un adaptador de sesión initialize heredado que está activado por defecto.

ConfiguraciónPredeterminadoDescripción
host127.0.0.1Dirección de enlace; configura "0.0.0.0" para contenedores o exposición externa
port8000Puerto de escucha
statelessfalseDesactiva el adaptador de sesión heredado; las solicitudes modernas siempre son sin estado

Todas las configuraciones, encabezados de solicitud requeridos, mapeo de códigos de error, el adaptador heredado y la construcción de nivel inferior sin el trait de azúcar: docs/transports.md.

Imagen nativa (GraalVM)

Los servidores stdio se compilan en binarios GraalVM autónomos con cero metadatos de alcanzabilidad escritos a mano (alrededor de 35 MB, inicio instantáneo, sin JVM en el contenedor): el registro y la derivación de esquemas son macros en tiempo de compilación, y la división de la costura de transporte mantiene zio-http/netty fuera de las imágenes solo-stdio — excluye dev.zio:zio-http_3 de la dependencia (netty llega solo a través de zio-http, por lo que esa única exclusión elimina ambos). Los servidores HTTP también se compilan y pasan la suite de conformidad oficial como binario nativo en CI. Recetas, banderas y el bucle de auditoría de metadatos: docs/native-image.md.

Plataformas

Un núcleo, tres objetivos. La capa de protocolo es compartida; cada plataforma contribuye solo un backend de transporte.

JVMScala.js / BunScala Native (experimental)
Anotaciones, contratos tipados, McpServerApp✅✅✅
Stdio✅✅✅ (binario LLVM)
Streamable HTTP, MCP 2026-07-28✅ ZIO HTTP✅ Bun.serve✗ por diseño¹
Adaptador de sesión HTTP heredado✅✅✗ por diseño¹
Extensión de tareas✅✅✅ (stdio)
Binario independienteImagen nativa GraalVM—LLVM vía Scala Native

¹ zio-http no tiene artefactos para Scala Native, por lo que McpServerApp[Http] no compila allí; un backend basado en sockets está en progreso (#81).

La suite oficial de conformidad MCP se ejecuta en CI contra los servidores JVM y Bun y contra el binario nativo GraalVM, con líneas base de fallos esperados vacías. Matriz de paridad completa, guías rápidas para Bun y Scala Native: docs/platforms.md; detalles de cobertura: docs/spec-coverage.md.

Documentación

Integración con Claude Desktop

Añade a 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"
      ]
    }
  }
}

Los servidores de ejemplo de fast-mcp-scala son solo para fines de demostración. No hacen nada útil, pero facilitan ver MCP en acción.

Licencia

MIT