Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Scala 3でMCPサーバとフレームワークをフルスクラッチした

だいたいタイトルの通り。最近MCPサーバ書くのが流行っており、実は自分はけっこう前にサーバ自体は書いてしまっていたのだが、記事にする元気がなかったので放置していた。しかし冷静に考えるとけっこう学びがあったので記事として残しておく。

記事書いたほうがいいかな〜と思っていたが、以下の記事を見て触発されて書くことにした。

zenn.dev

これであなたもAI人材!という感じの記事ではない。最低限必要なものを全部実装しました、という記事だ。あまり有益な記事ではないかもしれないが、頑張って動いて嬉しかった、という日記だと思って読んでください。

やったこと

以下のことをやった。既にあるフレームワークを使うだけなのは芸がないので、自分でサーバ自体をフルスクラッチして、ユーザが関数を渡すだけでサーバが立つようにした。全部自作なので、すごい量の知見が貯まった。

  • ScalaでMCPサーバを構築するためのフレームワークを作成した
  • それを使って数列の和を返すようなToolを定義できた

コードはここに公開してある。

github.com

コード例

百聞は一見に如かず。実際にコードを見てみよう。3つのものを用意するだけでサーバを作ることができる。すなわち、入力を定義するcase classと、実際に入力を受け取ってIO[Seq[ContentPart]]型を返す関数、そしてこの関数を複数収めたMapである。

case class SumInput(
    xs: Seq[Int]
) derives io.circe.Decoder, sttp.tapir.Schema

def sum(input: SumInput): IO[Seq[ContentPart]] =
  val sum = input.xs.sum
  IO.pure(Seq(ContentPart.TextContentPart(sum.toString)))

/** Entry point for the Stdio server.
  */
object StdioMain extends IOApp.Simple {
  val tools = Map(
    "sum" -> server.Tool(
      sum,
      "Calculate the sum of a sequence of numbers."
    )
  )

  def run: IO[Unit] = StdioServer.serve(Handler.methodHandlers(tools))
}

できること

最小限の機能を実装してあるが、実用上はこれでぜんぜん困らない。

  • Toolを定義できる
  • ToolがTextを返すことができる

MCPサーバとは

今更説明するまでもないと思うが、MCPとはLLMが外部のツールにアクセスできるようにするためのインターフェイスである。MCPを実装したサーバがMCPサーバであり、ClaudeやClineなどのAgentにこれを登録することで、その機能がLLM側から「視え」るようになり、利用できるようになる。

speakerdeck.com

modelcontextprotocol.io

通信方式(stdioを採用)

MCPには二種類のTransport Layerが存在する。stdio、すなわち標準入出力と、Streamable HTTP、つまりHTTPにストリーミング機能を足したやつ、である。Streamable HTTPは素朴なHTTPサーバだとハンドルが面倒そうだったので、素朴に実装できるstdioを採用した。

Scalaでstdioを使ってずっとクライアントとなんらかの通信を続ける、みたいな用途ではfs2を使うのが便利だ。

fs2.io

Purely Functionalだとかなんだとかいかめしいことが書いてあるが、要は便利ですよ、くらいの意味で捉えておけばいい。

fs2を使うとstdioまわりの処理は以下のような雰囲気で書けるので便利だ:

import cats.effect._
import fs2._
import fs2.io.{stdin, stdout}

val bufferSize = 4096

def serve(methodHandlers: MethodHandlers[IO]): IO[Unit] = {
  stdin[IO](bufferSize)
    .through(text.utf8.decode)
    .through(text.lines)
    .evalMap(Server.handleJsonRpcRequest(methodHandlers))
    .map(result => s"$result\n")
    .through(text.utf8.encode)
    .through(stdout[IO])
    .compile
    .drain
}

stdinからbufferSize分を確保し、utf8としてデコードし、行で分割し、handleJsonRpcRequestで処理し、処理結果に改行をつけて、utf8でエンコードし、stdoutに流す、というパイプラインを組め、と指示している。書いてある通りである。forやwhileでグルグル回すようなコードを書かなくていいのが嬉しいポイント。

だいたい自作してる人はstdioで実装している気がする。面倒だもん・・・

JSON-RPC 2.0

MCPはJSON-RPC 2.0で通信すること、と決まっている。前述したtransport layerの実装では標準入力から行を取り出してくれるところまでしかやってくれないので、MCPのToolなどを実装するよりも先に、JSON-RPCとしてパースし、JSON-RPCとして返す、というレイヤーを挟む構成にした。

www.jsonrpc.org

とはいえここは全然本質的ではない上にエラーコードの実装とかが面倒だったので、5ドルくらい払ってClineにやらせてしまった。AI Agentのツールを作るために金を払ってAI Agentを使っている。俺は何をしているんだ?そんな疑問が惹起する頃、AIくんが綺麗に実装を用意してくれたのでこれを使うことにした。AIくんは仕様がガチッと決まっているものは得意だ。先にRFCをダウンロードしておいて、この通りに実装して、インターフェイスはこんな感じにして、と言っておくと、なんか良い感じにしてくれる。ついでにテストも書かせた。

Scala 3からはenumキーワードがあり、ADTを簡潔に記述できるようになったため、プロトコル通りの実装を割と簡単に書ける:

object JsonRpc:
// ...
  enum Request:
    case Call(method: String, params: Option[Params], id: RequestId)
    case Notification(method: String, params: Option[Params])
    case Batch(requests: List[Request])

  enum ErrorCode(val code: Int):
    case ParseError extends ErrorCode(-32700)
    case InvalidRequest extends ErrorCode(-32600)
    case MethodNotFound extends ErrorCode(-32601)
    case InvalidParams extends ErrorCode(-32602)
    case InternalError extends ErrorCode(-32603)
// ...

JSON-RPCのレイヤーは素朴で、入力をJSON-RPCのリクエストとしてパースし、メソッドコールであればメソッド名に該当する関数に引数を渡し、帰ってきたものをまたJSONに包んで返す、というだけ。

MCP実装

ようやくMCPの本当にMCPの部分を実装する準備ができた。

MCPの「最小限」*1の機能を実装するには、JSON-RPC上に以下の3つのメソッドを実装する必要がある:

  • initialize
    • MCPサーバを起動・接続したLLMがまず最初に発出するメソッド。ワタクシこういう者です・・・といった情報を交換する。
    • どういう拡張機能が使えます、といった情報もここで交換している。
  • tools/list
    • このサーバはどのようなToolsを使えるのか?を探るためにLLMが発出するメソッド。登録されているToolsなどを決められたフォーマットで返せばよい。
  • tools/call
    • 実際にToolsを呼び出すメソッド。ユーザがLLMに指示した内容を受け取り、Toolを利用するべきだと判断した際に呼び出される。

これらを良い感じに(それが面倒なのだが)返すことで、MCPサーバとして振る舞うJSON-RPC 2.0サーバが完成、というわけ。ちなみに他のメソッドも呼ばれるのだが、全部「いや自分よく分かんないっス」とエラーを投げ返すことでごまかしている。

メソッドと実際の処理は辞書、ScalaでいうところのMapで管理しており、例えばtools/callは以下のような記述で処理している:

Map(
  // ...
  "tools/call" -> byNameHandler { (params: Method.CallTool) =>
    handleCallTool(tools)(params.name, params.arguments).map(x => Right(x))
  }
)

byNameHandlerは読んで字のごとく、関数を渡すだけでいい感じにJSON-RPCのハンドラにしてくれるラッパーである。これのおかげで簡潔にJSON-RPCのエンドポイントを書けるようにしている(中でいい感じに展開したりJSONに戻したりしてくれる)。Scalaはこの手の階層的な抽象化に強い。ラッパーがないと、stdioとJSONとJSON-RPCとMCPとが入り混じって大変なことになる。関心の分離は大事だ。

Toolsを呼ぶ

Toolsを呼ぶところまで来たらあとは普通の(?)プログラミングだ。あらかじめ名前で登録されているToolに引数を渡すだけでよい。引数用のcase classを定義しておくと、自動的にデコーダとJSON Schemaが生成されるようになっているので、ユーザはcase classを書くだけでよい。

private def handleCallTool(tools: Map[String, server.Tool[?]])(
    name: String,
    arguments: Option[Map[String, io.circe.Json]]
): IO[CallToolResult] =
  tools.get(name) match
    case None => IO.pure(callToolError(s"Tool '$name' not found"))
    case Some(t) =>
      val parsedInput = arguments.asJson.as[t.Input](t.inputDecoder)
      parsedInput match
        case Left(value) =>
          IO.pure(
            callToolError(
              s"Parameter parse failed: Invalid parameters for tool '$name': ${value.message}"
            )
          )
        case Right(input) =>
          t.func(input)
            .map: result =>
              CallToolResult(isError = false, content = result)

実行

ここでおもしろチャレンジ要素。今回は起動速度を重視して、ScalaをJVMではなくJSで動かしている。そんなことできるのかと思うかもしれないがScala.jsという技術が存在し、ScalaがJSにトランスパイルされてそのまま動いてしまう。

www.scala-js.org

Scala風の言語がJSで動くのではなく、ScalaプロジェクトがそのままJSにトランスパイルされる。これのおかげで一瞬で起動するようになった。もろもろのライブラリを利用したので6MiBくらいのJSが出てくる。JVMよりはメモリフットプリントが下がるので許す。

実行用スクリプトを経由してMCPサーバを起動させる。といっても素朴なロギングをしているだけ。

#!/usr/bin/env bash
cd "$(dirname "$0")" || exit
tee in.txt | node core/.js/target/scala-3.6.3/mcp-scala-fastopt/main.js | tee log.txt

そして手元のRoo CodeにMCPサーバを登録してみる:

// mcp_settings.json
{
  "mcpServers": {
    "mcpscala": {
      "disabled": false,
      "timeout": 30,
      "command": "sh",
      "args": [
        "/home/windymelt/src/github.com/windymelt/mcp-scala/run.sh"
      ],
      "transportType": "stdio",
      "alwaysAllow": []
    }
  }
}

すると認識する。

認識した

「1から10までの整数の和を計算して」と頼んだところ、ちゃんとMCPを経由して処理してくれた。

動いた

つらいポイント

JSON-RPCは、メソッドを呼ぶ際に引数を渡す方法が2つある。名前とのペアにして渡すby-name渡しと、アプリオリに順序を決めて引数を渡すby-position渡しである。by-nameだと色々自動生成と相性が良い(名前が分かるので突合できる)のだが、by-positionで渡ってくると順番をちゃんと決めておかないと壊れる。さいわいToolsの呼び出しはby-nameでやってくるのでそこまで困っていない。

また、MCPを実装するにあたってChatGPTに色々尋ねたのだが、知ったかぶりしてハルシネーションを起こしまくって2時間ほどドブに捨ててしまった。許さんぞ。全然仕様に存在しないメソッドを実装させられたりした。AIは危険ですよ。

やってないこと

山のようにある。Toolsを利用する以外の処理は全く実装していない(もっとも、現行の世間のMCPサーバでTools以外が実装されているものはあまりない)し、listToolsされたときのページネーションといった処理はサボっている。

今後

そのうちMavenに公開して他のプロジェクトからすぐ呼び出せるようにしてみたい。Maven面倒なのだよな・・・

*1:仕様上「これが最小限」と定められているわけではない。色々調べて、これだけ実装したら実際に動くようになった、ということ

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?