FastMCP-Scala

Uma biblioteca Scala 3 para construir servidores do Model Context Protocol (MCP).

Documentação

fast-mcp-scala

Maven Central CI Conformance Native Image License: MIT

Scala 3 para MCP: APIs orientadas a anotações e contratos tipados na JVM, Scala.js/Bun e Scala Native.

fast-mcp-scala é uma biblioteca amigável para desenvolvedores que permite construir servidores Model Context Protocol. Estenda um trait, declare suas ferramentas, pronto:

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

Dois caminhos de registro, anotações no estilo @Tool e contratos tipados McpTool, convergem no mesmo backend. Construído sobre ZIO 2 e zio-json, com JSON Schemas derivados diretamente por macros Scala 3. Toda a camada de protocolo MCP (JSON-RPC, tipos de fio, roteador, transportes) é Scala 3 nativo em shared/; não há SDK embutido. Ele tem como alvo MCP 2026-07-28 e mantém um adaptador de compatibilidade para revisões anteriores do protocolo.

Instalação

// 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 requer Scala 3.9.0 ou mais novo (ele emite TASTy 28.9, que compiladores 3.8 e mais antigos não conseguem ler; o pré-lançamento RC3, compilado com Scala 3.8.3, é o último lançamento que um projeto Scala 3.8 pode usar). 1.0.0 não é compilado com -experimental, então consumidores não precisam mais do flag (os candidatos a lançamento exigiam isso em todos os caminhos de registro; TJC-2335). JVM: JDK 17+ (CI testa os lançamentos LTS 17, 21 e 25). Scala.js: sjs1_3, roda em Bun (primeira classe) e, para stdio, em Node 18+ (verificado com Node 18 e 26 no dogfood 1.0.0, ainda não no CI; o listener HTTP é somente Bun.serve, e ids de tarefa bearer vêm de globalThis.crypto, que Node 18 expõe apenas atrás de --experimental-global-webcrypto); a saída Scala 3.9 precisa de um linker Scala.js 1.22+ (Mill: mill-bun 0.3.x com um scalaJSVersion explícito; scala-cli: --js-version 1.22.0). Scala Native: native0.5_3, somente stdio, experimental. Detalhes de plataforma e quickstarts: docs/platforms.md.

Quickstart

Um servidor de arquivo único com uma ferramenta; o mesmo código vive em 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

Sem import zio.*, sem override def run, sem ZIO.succeed(...). O trait McpServerApp[T, Self] lida com a construção do servidor, varredura de anotações e ciclo de vida do transporte; o transporte é um parâmetro de tipo fantasma (Stdio / Http) que seleciona o runner em tempo de compilação.

Exercite-o através do MCP Inspector:

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

A primeira execução baixa o compilador e dependências (cerca de 60 MB) e compila o script, o que pode exceder o timeout de conexão de 15 s do Inspector (ele então reporta apenas Connection closed). Execute scala-cli compile scripts/quickstart.sc uma vez antes, ou passe --connect-timeout <ms> para o Inspector.

Escolhendo um caminho de registro

Anotações (@Tool + scanAnnotations)Contratos tipados (McpTool)
EstiloMétodos em um objeto, descobertos por macrovals de primeira classe
SchemaDerivado da assinatura do método e @ParamDerivado de campos de case class e @Param
TesteChame o método diretamenteInvoque .handler no valor
ComposabilidadeQuaisquer métodos que o objeto expõeColete em listas, gere a partir de config
Melhor paraServidores rápidos, protótipos, apps de módulo únicoBibliotecas, compartilhamento entre módulos, codebases de produção

Ambos funcionam em todas as plataformas e coexistem no mesmo servidor: sobrescreva tools / prompts / staticResources / templateResources no seu McpServerApp para montar contratos tipados junto com 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
    }
  )

O In de uma ferramenta tipada é uma case class (seu schema deve ser um objeto JSON, como arguments MCP sempre são); uma ferramenta sem argumentos usa um vazio — case class NoArgs() — já que McpTool[Unit, _] não tem decoder. Lambdas de handler retornam valores simples, ZIO, Either[Throwable, _] ou scala.util.Try; o typeclass ToHandlerEffect[F[_], R] escolhe o lift certo (R é o ambiente ZIO — Any para os builders simples), e você pode trazer seu próprio given para outros sistemas de efeitos implementando ToHandlerEffect[F, Any] para seu tipo de efeito F. Veja AnnotatedServer.scala para o caminho de anotações e ContractServer.scala para contratos tipados.

Ferramentas e metadados @Param

Cada parâmetro de ferramenta pode carregar metadados que fluem para o JSON Schema 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 popula o campo description do schema
  • examples popula o array examples do JSON Schema (clientes podem mostrar sugestões)
  • required = false, combinado com Option[...] ou um valor padrão, marca o campo como opcional; um parâmetro Option[...] puro é opcional sem isso, mas um valor padrão sozinho não remove o campo de required — o padrão é aplicado quando o argumento é omitido, então escreva required = false para anunciá-lo como opcional
  • schema é um fragmento JSON Schema bruto que sobrescreve o schema derivado inteiramente — repita o description dentro do fragmento, já que a propriedade derivada (descrição e exemplos incluídos) é substituída por completo

O resultado de um método anotado é enviado como TextContent(result.toString) a menos que seja um String, um Content, um List[Content], um Array[Byte] (ou um ZIO de um desses): um Some(1), uma case class ou um Map chega como texto toString do Scala, não JSON. Retorne um String/Content, ou use um McpTool tipado (com .withOutputSchema para structuredContent) quando clientes precisarem de JSON.

Sobrecarga é permitida: apenas o overload anotado é registrado, e seu schema e handler vêm dessa declaração exata; dois overloads anotados devem registrar names distintos — nomes duplicados ou padrões de URI de recurso dentro de um objeto são um erro de compilação. Argumentos de anotação como name, description e as dicas devem ser literais (Some("..."), Option("..."), None ou uma constante final val); qualquer outra coisa é um erro de compilação.

Um método anotado tem exatamente uma lista de parâmetros (sem currying, sem cláusulas using), sem parâmetros de tipo, e no máximo 22 parâmetros, e apenas membros declarados diretamente no objeto varrido são registrados (private/protected incluídos — visibilidade não bloqueia exposição, então anote apenas o que você pretende publicar). Um membro herdado anotado ou val, como qualquer outra forma não suportada, é um erro de compilação nomeando-o e a correção; um método anotado dentro de um objeto aninhado também é reportado (um erro quando o objeto varrido não declara nada próprio, caso contrário um aviso, já que o objeto aninhado pode ser varrido separadamente).

Enums, case classes aninhadas, Option, coleções e valores java.time derivam sem givens fornecidos pelo usuário; formas de fio personalizadas passam por McpInputCodec. Veja docs/custom-types.md.

Dicas de ferramenta

Anotações de ferramenta MCP informam ao cliente como uma ferramenta se comporta. Defina-as em @Tool:

DicaSignificado
titleNome de exibição legível por humanos (distinto do name no nível de fio)
readOnlyHintA ferramenta apenas lê estado; segura para chamar sem confirmação
destructiveHintA ferramenta pode modificar estado irreversivelmente; clientes devem confirmar
idempotentHintChamadas repetidas com os mesmos argumentos têm o efeito de uma única chamada
openWorldHintA ferramenta alcança fora do processo local (rede, filesystem, APIs)
returnDirectRetorne o resultado diretamente ao usuário, pulando pós-processamento do LLM

TaskManagerServer.scala aplica dicas em um conjunto de ferramentas realista.

Recursos (estáticos e modelados)

Recursos estáticos têm um URI fixo e sem parâmetros:

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

Recursos modelados usam {placeholders} no URI, correspondidos contra nomes de parâmetros de 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"}"""

Modelos são listados através de resources/templates/list apenas quando exposeTemplatesEndpoint está definido (resources/list nunca lista modelos; com o false padrão o endpoint de modelos responde com uma página vazia e clientes derivam modelos de URIs {}; resources/read em um URI correspondente funciona de qualquer forma). AnnotatedServer.scala ativa isso:

override def settings = McpServerSettings(exposeTemplatesEndpoint = true)

Um placeholder corresponde a uma sequência não vazia de caracteres dentro de um segmento de caminho (nunca /); texto literal é correspondido literalmente (não como regex); placeholders no mesmo segmento devem ser separados por texto literal (um modelo como x://{a}{b} é rejeitado no registro, então o servidor falha ao iniciar). URIs de cliente mais longos que limits.maxUriChars (8192) são rejeitados com -32602.

Prompts

Retorne um List[Message]; fast-mcp-scala lida com o enquadramento 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.")))

Um prompt que retorna um único String é automaticamente envolvido em uma mensagem User.

Contexto (McpContext)

Adicione um parâmetro nomeado exatamente ctx do tipo McpContext a um método @Tool para ler as informações e capacidades declaradas do cliente, metadados de requisição, e para enviar progresso ou logging (o parâmetro é reconhecido pelo nome e não faz parte do schema da ferramenta; métodos @Prompt e @Resource não aceitam um 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")}"

Logging visível ao cliente também passa pelo contexto: ctx.sendLogMessage(level, data) retorna um ZIO (então o handler retorna um), e LoggingLevel (em com.tjclp.fastmcp.core) é exportado na raiz desde 1.0.0 — os candidatos a lançamento precisavam importá-lo pelo nome, o que ainda 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)

Contratos tipados usam o .contextual do builder — McpTool[In, Out](name = "echo").contextual { (in, ctx) => ... } — cujo handler recebe (In, Option[McpContext]). Demo executável: ContextEchoServer.scala.

Transportes

Transporte é um parâmetro de tipo fantasma em McpServerApp[T, Self]: Stdio ou 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, harnesses de teste)

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() aceita uma mensagem JSON-RPC sem estado por POST /mcp, respondendo com JSON ou um stream SSE com escopo de requisição. Clientes mais antigos são atendidos por um adaptador legado de initialize/session que está ativo por padrão.

ConfiguraçãoPadrãoDescrição
host127.0.0.1Endereço de bind; defina "0.0.0.0" para containers ou exposição externa
port8000Porta de escuta
statelessfalseDesative o adaptador de sessão legado; requisições modernas são sempre sem estado

Todas as configurações, headers de requisição obrigatórios, mapeamento de códigos de erro, o adaptador legado e construção de nível mais baixo sem o trait de açúcar: docs/transports.md.

Imagem nativa (GraalVM)

Servidores stdio compilam para binários GraalVM autocontidos com zero metadados de reachability escritos à mão (cerca de 35 MB, inicialização instantânea, sem JVM no container): registro e derivação de schema são macros de tempo de compilação, e a divisão da costura de transporte mantém zio-http/netty fora de imagens somente stdio — exclua dev.zio:zio-http_3 da dependência (netty chega apenas através de zio-http, então essa única exclusão elimina ambos). Servidores HTTP também compilam e passam a suíte de conformidade oficial como binário nativo no CI. Receitas, flags e o loop de auditoria de metadados: docs/native-image.md.

Plataformas

Um núcleo, três alvos. A camada de protocolo é compartilhada; cada plataforma contribui apenas com um backend de transporte.

JVMScala.js / BunScala Native (experimental)
Anotações, contratos tipados, McpServerApp✅✅✅
Stdio✅✅✅ (binário LLVM)
Streamable HTTP, MCP 2026-07-28✅ ZIO HTTP✅ Bun.serve✗ por design¹
Adaptador de sessão HTTP legado✅✅✗ por design¹
Extensão de tarefas✅✅✅ (stdio)
Binário autônomoImagem nativa GraalVM—LLVM via Scala Native

¹ zio-http não possui artefatos Scala Native, portanto McpServerApp[Http] não compila lá; um backend baseado em socket está em andamento (#81).

A suíte oficial de conformidade MCP é executada em CI contra os servidores JVM e Bun e contra o binário nativo GraalVM, com linhas de base de falha esperada vazias. Matriz de paridade completa, quickstarts para Bun e Scala Native: docs/platforms.md; detalhes de cobertura: docs/spec-coverage.md.

Documentação

Integração com Claude Desktop

Adicione 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"
      ]
    }
  }
}

Os servidores de exemplo fast-mcp-scala são apenas para fins de demonstração. Eles não fazem nada útil, mas facilitam ver o MCP em ação.

Licença

MIT