🐖

最小限のMCP Host/Client/Serverをスクラッチで実装する

に公開

ここ1~2ヶ月でMCPについての解説がたくさん出ているしMCPが何かの説明はいらないと思うので割愛。全く何かわからない人はやさしいMCP入門を読むと良い。

こういったMCPは何か?についての記事/スライドやどのように活用するのか?の実用的な解説&紹介はすでにたくさん出ている。一方でMCPの内部について学ぼうと思った時に時にどうすればいいかというと公式のSpecificationを読むことになるはず。

ただこの入門的な解説とSpecificationの間には少しギャップがある。自分のような理解力に乏しい人間にはいきなり仕様だけ読んで「はい理解しました」とはなれない。

そこで今回はMCPのHost/Client/Serverをスクラッチで実装することを通じて、その入門とSpecificationの間を埋められると良いなと思い実装してみた。実装する言語にはRubyを使用した。

全体像

https://modelcontextprotocol.io/specification/2025-03-26#overview

実装するのは上記のHostとMCP ClientとMCP Serverの3つ。それぞれの役割について簡単に雑に説明すると、

  • Hostはユーザー入力とLLMとのやり取りを管理してその結果をユーザーに示す
  • MCP ClientはMCP Serverに対してリクエストを送りHostに結果を返す
  • MCP ServerはMCP Clientからのリクエストを受けて所定の処理を行いClientに結果を返す

ClineとかClaude DesktopとかがHostで、その内部にMCP Clientが同居していて、Figma MCPとかFirecrawlみたいなやつがMCP Serverにそれぞれ対応している。

Transport Layer

https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio

MCP ClientとMCP Server間の通信はメッセージフォーマットとしてはJSON-RPCを用いる。通信手段としてはstdio(標準入出力)/Streamable HTTPが推奨されている(一応それ以外の独自の通信手段でも良いらしい)。実装が簡易になるので今回はstdioしか実装していない。

具体的に"stdioを使う"というのは、C言語のpopenのようなものを用いてMCP Serverのプロセスを立ち上げ、そのプロセスの標準入出力を得て、そこにread/writeすることでメッセージのやり取りをするということ。Rubyだとこんな感じ。

require 'open3'

# サーバーを起動
# 起動するとそのサーバープロセスの標準入出力を得ることができる
@stdio, @stdout, @stderr, @wait_thr = Open3.popen3(`ruby hoge_server.rb`)

@stdio.puts(request_message) # hoge_serverにリクエストを送る
response = @stdout.read # hoge_serverからレスポンスを受け取る

puts response

@stdout.close # 標準出力を閉じる

実装

最初に今回実装するものについて。下記のようにメッセージをLLMに送ると適切にMCPサーバーを実行して結果を受け取りユーザにメッセージを返してくれるCLIを作る想定している。

リポジトリは下記。
https://github.com/YuheiNakasaka/scratch-mcp-rb/

MCPサーバーとしてはサイコロを振る機能を持ったものを実装した。指定されたサイコロの面数に応じてランダムで数値を返す簡単なものだ。

ユーザーから入力されたメッセージを基にLLMは面数を読み取りMCPサーバーに引数として渡してを実行させる。

Hostの実装

Hostで実装するのは下記の3つ。

  • ユーザー入力を受け取る
  • LLMにメッセージを送信して結果を受け取る
  • ユーザーに結果を返す

ユーザー入力を受け取るために$stdin.getsを使い、ユーザーに出力するためには$stdout.putsを使う。

LLMにメッセージを送信して結果を受け取るというフェーズではさらに2種類のことをしている。

一つ目は「ユーザー入力」と「MCPサーバーが実装している機能(Tool)」を組み合わせてLLMに次に何をすべきか考えてもらうこと。

二つ目はLLMからの返答を受け取り、その返答結果に応じたアクション(MCPサーバーのToolを実行するなど)を行い、その結果を全て含めてLLMに送信し最終結果を生成してもらうこと。

要はユーザー入力に対して2回LLMと通信が走る。ちなみにもっと複雑ではあるがコーディングエージェントも似たような感じでSystem Promptを作成しFeedbackを得てSystem Promptを更新してLLMにまた送信し…ということをloopでやっている。

具体的な実装は下記。
https://github.com/YuheiNakasaka/scratch-mcp-rb/blob/main/mcp/host.rb

ユーザー入力とMCPサーバーのToolを組み合わせる処理はこれ。MCP Clientにlist_tools(MCP Serverの実装している機能一覧の取得)を実行させて、その結果と基本メッセージをまとめてClaudeに送信している。

messages = [
  {
    'role': 'user',
    'content': query
  }
]

response = @client.list_tools
available_tools = response[:tools].map do |tool|
  {
    name: tool[:name],
    description: tool[:description],
    input_schema: tool[:input_schema]
  }
end

response = @anthropic_client.messages(
  parameters: {
    model: 'claude-3-7-sonnet-20250219',
    system: 'Respond only in Japanese.',
    messages: messages,
    max_tokens: 1000,
    tools: available_tools
  }
)

2つ目に関しては下記。最初のLLMのresponseとしてcontentが複数返ってくるのでそれぞれのcontent typeに応じた処理を行っている。

tool_useというtypeの場合がMCPサーバーのToolを実行する場合の処理。LLMから返ってきたname(Tool名)と引数(input)を用いてMCPサーバーのToolをcall_toolを通じて実行する。

response['content'].each do |content|
  if content['type'] == 'text'
    final_text << content['text']
    assistant_message_content << content
  elsif content['type'] == 'tool_use'
    tool_name = content['name']
    tool_args = content['input']

    result = @client.call_tool(name: tool_name, args: tool_args)
    final_text.push("[Calling tool #{tool_name} with args #{tool_args}]")

    assistant_message_content.push(content)
    messages.push({
      'role': 'assistant',
      'content': assistant_message_content
    })
    messages.push({
      'role': 'user',
      'content': [
        {
          'type': 'tool_result',
          'tool_use_id': content['id'],
          'content': result.to_s
        }
      ]
    })

    response = @anthropic_client.messages(
      parameters: {
        model: 'claude-3-7-sonnet-20250219',
        max_tokens: 1000,
        messages: messages,
        tools: available_tools
      }
    )

    final_text.push(response['content'][0]['text'])
  end
end

list_toolscall_toolは後ほど実装する。

なおAnthropicのAPIクライアントに関しては今回の実装の説明には不要なのでgemを利用したのは許してほしい。

Clientの実装

MCP ClientではJSON-RPCに則ってメッセージをやり取りする。JSON-RPCについてはこれを読むと良い。一言で言うとjsonrpc/method/params/idなどのmemberフィールドを持つJSONオブジェクトを用いてメッセージをやり取りするためのプロトコル。とりあえず7 Examplesを読んでイメージを掴めば良い。

Clientの実装については下記の図がわかりやすい。

最低限の実装としてはこの図のInitialization PhaseとOperation PhaseとShutdown Phaseの3つのフェーズを実装すれば良い。

Initialization Phase

Initialization PhaseではClientとしてはinitialized requestinitialized notificationの2つのメッセージを送信する。

initialized requestはMCP Serverに対してClientの初期化を要求するメッセージ。最小のメッセージとしては下記。

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "clientInfo": {
      "name": "ExampleClient",
      "version": "1.0.0"
    }
  }
}

initialized notificationはMCP Serverに対してClientの初期化が完了したことを通知するメッセージ。最小のメッセージとしては下記。

{
  "jsonrpc": "2.0",
  "method": "notifications/initialized"
}

具体的な実装は下記。
https://github.com/YuheiNakasaka/scratch-mcp-rb/blob/main/mcp/client.rb

Initialization Phaseの実装だけ抜き出すとこんな感じ。

# Initialize request/initialize response
response = send_request({
  jsonrpc: '2.0',
  method: 'initialize',
  params: {
    protocolVersion: '2024-11-05',
    clientInfo: {
      name: 'MCP Client',
      version: '1.0.0'
    }
  },
  id: SecureRandom.uuid
})

# initialized notification
@stdin.puts(JSON.generate({
  jsonrpc: '2.0',
  method: 'notifications/initialized'
}))

Client/Server共にこのInitialization Phaseが完了するまではPING/LOGGING以外のその他のメッセージを送信してはいけないことになっている。

ちなみinitialize requestに関してはparamsでcapabilitiesというものを指定できる。これはoptionalのprotocolで、initialization phaseでclientとserverの間で明示的にやり取りしておく必要がある。

capabilitiesの一覧は下記。今回は実装していないが参考まで。

Operation Phase

Operation PhaseではClient/Server間でそれぞれのcapability negotiationの決まりに従ってメッセージのやり取りを行う。

今回はlist_toolscall_toolというToolのcapabilityを使いたいので、そのToolを実行するためのメッセージを送信する。

実装を見た方が早いので下記に両方とも示す。

def list_tools
  return raise 'Server is not running' if @pid.nil?

  send_request({
    jsonrpc: '2.0',
    method: 'tools/list',
    params: {},
    id: SecureRandom.uuid
  })
end
def call_tool(name:, args: {})
  send_request({
    jsonrpc: '2.0',
    method: 'tools/call',
    params: { name: name, args: args },
    id: SecureRandom.uuid
  })
end

それぞれmethodにtools/listtools/callを指定している。必要に応じてparamsを指定できるようにした。MCP Server側ではこれらのメッセージを受け取ったらそれぞれのリクエストに合わせた処理を実行するようにする。

Shutdown Phase

Shutdown PhaseではClientからstdioを閉じてServerにKILL_TERMを送るだけ。

def close
  return if @pid.nil?

  @stdin.close
  @stdout.close
  @stderr.close
  Process.kill('TERM', @pid)
  @wait_thr.value
rescue IOError, Errno::ESRCH
ensure
  @pid = nil
end

MCP Clientに関してはこれで終わり。次はMCP Serverの実装。

Serverの実装

Serverの実装に関しては今見てきたClientの実装に対してレスポンスをどう返すかを実装するだけ。

先に具体的な実装を置いておく。
https://github.com/YuheiNakasaka/scratch-mcp-rb/blob/main/mcp/server.rb

Initialization Phase

Initialization PhaseではClientからのinitialize requestinitialized notificationを受け取り、それぞれのリクエストに合わせた処理を実行する。

initialize requestに対しては下記のようにリクエストに含まれるmethodに合わせてレスポンスを返す。

case request['method']
when 'initialize'
  {
    jsonrpc: '2.0',
    id: 1,
    result: {
      protocolVersion: '2025-03-26',
      serverInfo: {
        name: 'MCP Client',
        version: '1.0.0'
      },
      instructions: 'Optional instructions for the client'
    }
  }
end

initialized notificationに関しては何も返す必要がないので何も返さないようにする。

Operation Phase

Operation PhaseではClientからのlist_toolscall_toolを受け取り、それぞれのリクエストに合わせた処理を実行する。

list_tools

list_toolsに関しては下記のようにMCP Serverが実装しているtoolの一覧をname/description/input_schemaのメッセージオブジェクトにしてまとめて返す必要がある。

when 'tools/list'
  tools = @tools.map do |name, tool|
    {
      name:,
      description: tool[:description],
      input_schema: tool[:input_schema]
    }
  end
  {
    jsonrpc: '2.0',
    id: request['id'],
    result: {
      tools: tools
    }
  }
end

@toolsはどこから出てきたんじゃ?と思うかもしれないが、これはMCP ServerのToolを登録するフェーズで作成される。

どういうことかというと例えば先のリポジトリにはmcp/dice/server.rbというMCP ServerのToolを登録する処理が実装されており、そこで@toolsにはdiceというkeyでToolを登録している。

require_relative '../server'

@server = MCP::Server.new
@server.register_tool(
  name: 'dice',
  description: 'Roll a dice',
  input_schema: {
    type: 'object',
    properties: {
      sides: {
        type: 'integer',
        description: 'The number of sides on the dice'
      }
    },
    required: ['sides']
  },
  handler: proc do |args|
    rand(1..args['sides'])
  end
)
@server.run

コーディングエージェント等でMCP Serverを利用する側の視点だけで言えば、このmcp/dice/server.rbの部分のみ実装しコーディングエージェントに登録するだけで良い。MCP Serverを実装したことがある人は大抵既存のSDKを利用していると思うのでこの辺のClient/Serverの処理は実装しなくてよくて、Toolを定義するだけで十分だったはず。SDK内部では実はこんな感じのことをやっている(公式のMCP SDKの実装を読んだわけではないのが多分こんな感じだと思う)。

call_tool

call_toolに関しては下記のようにMCP Serverが実装しているtoolのhandlerを実行して結果を返すだけ。先のmcp/dice/server.rbで登録されていたhandlerがそのまま実行されていることがわかると思う。

when 'tools/call'
  tool_name = request['params']['name']
  tool_args = request['params']['args']
  tool = @tools[tool_name]
  result = tool[:handler].call(tool_args)
  {
    jsonrpc: '2.0',
    id: request['id'],
    result: {
      content: result
    }
  }
end

call_toolの実装はこれだけ。

まとめ

以上でMCP Host/Client/Serverのシンプルな実装は終わり。

今回は省いたが実用的なことを考えるとError HandlingやTimeout処理が必要だし、Transport LayerにStreamable HTTP(これまでのSSEと何が違うかわかってない...)を使うようにするともっと便利になりそう。あとTools以外の細かなCapability Negotiationは実装していないが結構種類があるので自前実装すると面倒そうなのでやはり実践的には公式のSDKを使うのが良いと思う。

そういえば最新の2025-03-26のSpecificationではAuthorizationも追加されている。MCPの実行とセキュリティについての話は割とホットなトピックなのでその辺もすでに仕様自体は策定されているから各クライアント側が実装していく流れになりそう。
https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization

ちなみに今回の試みは全然新しいものではなく実はfunwarioisii/mcp-rbというリポジトリが既に存在しており、大変参考にさせていただいた。list_toolsのPaginationも実装されているしTransport Layerも抽象化されていて実装としては綺麗なのでこちらも参考にしてほしい。

自分が今回実装したものはSpecificationを読みつつ、この実装からもっと仕様を削ぎ落として学習に集中できるようシンプルにしたものになっている。実用には全く耐えないがMCPとは何か?を実装レベルで学ぶための参考にはなると思う。

https://github.com/YuheiNakasaka/scratch-mcp-rb/

Links

Discussion