RubyでMCPサーバーを書く — mcp gemの使い方とRails組み込み
RubyでMCPサーバーを書くなら公式のmcp gemを使います。bundle add mcpから、Claude Code接続、RailsのStreamable HTTP組み込みまでをコード付きで解説します。
RubyでMCPサーバーを書くと何が手に入るか
RubyでMCPサーバーを書くには、公式のSDKであるmcp gemを使います。bundle add mcpで入り、Ruby 2.7.0以上で動きます。MCP::Toolを継承したクラスにツールを書き、stdioで起動すれば、そのままClaude Codeから呼べます。
MCP(Model Context Protocol)のRuby実装は、mcp gemとして公式に配布されています。MCP公式のSDK一覧では、RubyはTypeScript・Python・C#・Go・RustとならんでTier 1です。Tier 2はJava、Tier 3はSwift・PHP・Kotlinという並びで、各Tierの基準はMCP SDK Tierシステムにまとめています。
Tier 1の条件の中心は、プロトコルのConformance Tests(適合性テスト)に100%合格することです。ほかにも、Issueの2営業日以内の仕分け、致命的バグの7日以内の解決、安定版リリース、全機能の例つきドキュメントなどが求められます。
RubyのSDKが他言語と違うのは、Rack・Railsにそのまま載せられる点です。同じMCP::Serverをstdioで動かすことも、mountでRailsのルーティングに組み込むこともできます。MCPの仕組み自体はMCP実用ガイド、TypeScriptとPythonでの自作はMCPサーバー自作ガイドにあります。ここではRuby固有の手順に絞ります。
mcp gemの要件と依存gem
mcp gemのライセンスはApache-2.0で、ドキュメントはCC-BY-4.0です。本体の実行時依存はjson_schemerだけです。機能ごとに追加するgemは次のとおりです。
| 使う機能 | 追加するgem |
|---|---|
HTTPクライアント(MCP::Client::HTTP) | 追加するgemfaraday(2.0以上) |
| SSE応答の読み取り | 追加するgemevent_stream_parser(1.0以上) |
サーバーでのJWT検証(JWTVerifier) | 追加するgemjwt(EdDSA署名ならjwt-eddsaも) |
最小のMCPサーバーを動かす
ステップ1: gemを追加する
Rubyは2.7.0以上が必要です(mcp 1.8.0の要件)。mcp 1.8.0はjson_schemer(2.4以上)を依存gemとして連れてきます。
bundle add mcpgem install mcpで単体に入れても動きます。HTTPクライアントを使うならfaraday、SSE(text/event-stream)応答も読むならevent_stream_parserが別途要ります(詳しくは後述のクライアントの節)。
ステップ2: ツールを1つ実装する
ツールはMCP::Toolを継承したクラスで定義します。descriptionで説明、input_schemaで引数のJSON Schemaを宣言し、callに実処理を書きます。
require "mcp"
class ExampleTool < MCP::Tool
description "A simple example tool that echoes back its arguments"
input_schema(
properties: {
message: { type: "string" },
},
required: ["message"]
)
class << self
def call(message:, server_context:)
MCP::Tool::Response.new([{
type: "text",
text: "Hello from example tool! Message: #{message}",
}])
end
end
end
server = MCP::Server.new(
name: "example_server",
tools: [ExampleTool],
)
transport = MCP::Server::Transports::StdioTransport.new(server)
transport.open引数はcallのキーワード引数として受け取ります。input_schemaのrequiredとcallの引数名がずれると呼び出し時に食い違うので、ツールを増やすときは2か所を一緒に直します。
server_contextには、サーバーに渡した値がそのまま入ります。認証状態のようなリクエスト単位の情報を運ぶ用途で、Rails組み込みの節で実例を出します。
クラス定義のほかに、MCP::Tool.defineでブロックから作る書き方と、server.define_toolでサーバーに直接登録する書き方もあります。
ステップ3: stdioで起動して手で叩く
上のスクリプトをserver.rbとして保存し、実行したまま標準入力にJSON-RPCを1行ずつ流します。
ruby server.rb
{"jsonrpc":"2.0","id":"1","method":"ping"}
{"jsonrpc":"2.0","id":"2","method":"tools/list"}
{"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}}ツール名のexample_toolはクラス名ExampleToolから決まります。名前を固定したいときはtool_name "get_weather"のように指定します。
Claude Codeからこのサーバーを呼ぶ
stdioのサーバーは、Claude Codeに起動コマンドごと登録します。登録はclaude mcp add <名前> -- <起動コマンド>の形で、環境変数は-e、HTTPサーバーは--transport httpで指定します。
claude mcp add my-server -e API_KEY=xxx -- npx my-mcp-server
claude mcp add my-server -- my-command --some-flag arg1
claude mcp add --transport http sentry https://mcp.sentry.dev/mcp--より後ろがサーバーの起動コマンドです。Rubyならclaude mcp add ruby-example -- bundle exec ruby /path/to/server.rbの形になります。-sでlocal・user・projectのスコープを選べ、省略時はlocalです。HTTP版のサーバーは--transport httpでURLを渡します。登録後の接続トラブルはMCP接続のトラブルシューティングが参考になります。
別のRubyプロセスからクライアントとして呼ぶ
SDKにはクライアントも入っています。MCP::Client::Stdioがサーバーをサブプロセスとして起動し、MCP::Clientがその上でツールを呼びます。
stdio_transport = MCP::Client::Stdio.new(
command: "bundle",
args: ["exec", "ruby", "path/to/server.rb"],
env: { "API_KEY" => "my_secret_key" },
read_timeout: 30
)
client = MCP::Client.new(transport: stdio_transport)
client.connect
tools = client.tools
tools.each do |tool|
puts "Tool: #{tool.name} - #{tool.description}"
end
response = client.call_tool(
tool: tools.first,
arguments: { message: "Hello, world!" }
)
stdio_transport.closeclient.connectはプロトコルのライフサイクル交渉を行う呼び出しで、リクエストの前に必ず入れます。read_timeoutの既定はnil、つまりタイムアウトなしです。値を入れておけば、応答しないサーバーで固まりません。
stdioのクライアントが自動で扱うのは、サーバー起動(Open3.popen3)、ライフサイクル交渉、改行区切りJSONの送受信、サーバーからのpingへの応答までです。elicitation/createやsampling/createMessageをサーバーがワイヤ上で直接送る従来方式は、stdioでは無視されます。その方式のサーバーにはStreamable HTTPを使います。新しいライフサイクルのinput_required経由なら、stdioでもon_elicitationとon_samplingのハンドラーが動きます。
HTTPサーバーにつなぐMCP::Client::HTTPは、Gemfileにこの3行を足して使います。
gem "mcp"
gem "faraday", ">= 2.0"
gem "event_stream_parser", ">= 1.0" # SSE応答を読むときに必要応答がJSONかSSEかはサーバーが1応答ごとに決めます。このSDK自身のサーバーは既定でSSEを選ぶため、相手を選ばないクライアントなら2つとも入れておきます。
RailsにMCPサーバーを組み込む
StreamableHTTPTransportは標準のRackアプリなので、Railsのルーティングにそのままmountできます。
# config/routes.rb
server = MCP::Server.new(
name: "my_server",
version: "1.0.0",
tools: [SomeTool, AnotherTool],
)
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
Rails.application.routes.draw do
mount transport => "/mcp"
endmountは/mcpへのすべてのHTTPメソッドをトランスポートに渡します。POST(JSON-RPC)・GET(サーバーからの通知用SSE)・DELETE(セッション終了)の振り分けはSDK側が行うので、ルートを分ける必要はありません。ローカルのClaude Codeへはclaude mcp add --transport http rails-tools http://localhost:3000/mcpで登録できます。
組み込み方は、サーバーをいつ作るかで2通りに分かれます。
Railsへの組み込み方は2通り
mount(起動時に1回)
サーバーは起動時に1つだけ作ります。通知やsubscriptions/listenのSSEストリームを維持でき、公式のexamples/railsもこの形です。ツール構成はリクエストで変えられません。
コントローラー(リクエストごと)
リクエストごとにサーバーを作るので、ユーザーやルートに応じてツールやserver_contextを変えられます。引き換えに、セッションIDを持ち越せず、SSEのlistenストリームも返せません。
コントローラー型の実装は次の形です。server_contextにユーザーIDを渡し、ツール側で参照します。
class McpController < ActionController::API
def create
server = MCP::Server.new(
name: "my_server",
version: "1.0.0",
tools: [SomeTool, AnotherTool],
server_context: { user_id: current_user.id },
)
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
server,
stateless: true,
serve_subscriptions_listen: false,
)
status, headers, body = transport.handle_request(request)
render(json: body.first, status: status, headers: headers)
end
endstateless: trueはセッションIDが共有されないための指定です。serve_subscriptions_listen: falseを外すと、renderが本文を丸ごとバッファするせいで、新しいライフサイクルのクライアントのlistenストリームが500で失敗します。付けておくと404(JSON-RPCの-32601)で断り、server/discoverも通知系の機能を広告しなくなります。
本番に出す前に決める3つの設定
Streamable HTTPには、デプロイ形態で効き方が変わる設定がいくつかあります。
HTTP運用で効く設定
ステートレスモード
stateless: trueでセッションIDを発行せず、各POSTが自己完結します。複数ノードに置くのが簡単になる代わりに、進捗やログなどの通知は捨てられ、サーバーからクライアントへのリクエストはエラーになります。JSON応答モード
enable_json_response: trueで、応答がtext/event-streamではなくapplication/jsonの1オブジェクトになります。サーバー起点のリクエストが要らない単純なツールサーバー向けです。Host・Origin検証
既定でDNSリバインディング対策のHost・Origin検証が働き、不許可の値はHTTP 403で返ります。
Originヘッダーを送らない非ブラウザークライアントは、この検証の影響を受けません。
ループバック(127.0.0.1・::1・localhost)以外で公開するならallowed_hosts:の追加が要り、ブラウザーから呼ばれるならallowed_origins:も要ります。allowed_hosts:の項目は、ホスト名だけ(ポート不問)でもhost:portの完全一致でも書けます。上流のプロキシがHost・Originを検証済みなら、dns_rebinding_protection: falseで検査自体を切れます。
従来のハンドシェイク型ライフサイクル(セッションを使う既定のモード)では、セッションとSSEの状態がプロセス内メモリに置かれます。Pumaのworkers 0のような単一プロセス構成が前提で、UnicornやPumaのworkersが1以上だとプロセス間で状態が共有されず、セッション管理とSSEが壊れます。従来のセッション型を複数台に並べるときは、Mcp-Session-Idヘッダーが同じ台に届くスティッキーセッションをロードバランサーで設定します。
2026-07-28の新しいライフサイクルのリクエストは、セッションを持たない単発のやり取りで、単一プロセスの前提が要りません。ただしsubscriptions/listenのストリームは、受けたプロセスの中にだけ届きます。複数ラウンドトリップにまたがるrequestStateは、ワーカー間でRequestStateSecurityの鍵を共有したときだけ引き継がれます。
セッションの既定の上限は、アイドル30分(session_idle_timeout: 1800)と同時1万件(max_sessions: 10000)です。上限に達した状態で新しいinitializeが来ると、既存セッションを追い出さずHTTP 503で断ります。リクエスト本文は4MiBまで(max_request_bytes)で、超えると413です。4MiBの上限は、ほかにもあります。stdioのサーバーとクライアントはmax_line_bytes、HTTPクライアントはmax_message_bytesで、いずれも既定は4MiBです。
セッションIDは乱数のUUIDで、ユーザーには紐づきません。IDを盗まれた場合に備えて所有者を確認するには、session_request_validator:にラムダを渡します。
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
server,
session_request_validator: ->(request, session_id) { owns_session?(request, session_id) },
)ラムダが偽を返したリクエストは403で拒否されます。バリデーターを渡さなければ所有者の確認は行われません。補助として、initialize時のOriginヘッダーを記録し、後続のリクエストでOriginが食い違えば拒否します。両方にOriginがあるときに限る照合で、認証の代わりにはなりません。
ツールの応答とスキーマの書き分け
MCP::Tool::Responseは、失敗を返す手段も持っています。error: trueを付けると、JSON-RPC応答にisErrorとして載ります。
ツールには、動作の性質を宣言するannotationsも付けられます。既定値は次のとおりで、何も書かないと「破壊的で、外部とやり取りする」ツールとして宣言されます。
| ヒント | 既定値 |
|---|---|
read_only_hint | 既定値false |
destructive_hint | 既定値true |
idempotent_hint | 既定値false |
open_world_hint | 既定値true |
読み取り専用のツールなら、annotations(read_only_hint: true, destructive_hint: false)のように明示します。output_schemaもinput_schemaと同じ書き方で宣言でき、戻り値の形をクライアントに伝えられます。MCP::Tool::Responseのstructured_contentに構造化データを入れると、JSON-RPC応答のstructuredContentとして返ります。サーバー側でのoutput_schemaとの照合は既定で無効です。MCP::Configuration.new(validate_tool_call_results: true)で有効にできます。有効なとき、成功応答はoutput_schemaに合うstructured_contentを含む必要があります。エラー応答は照合されません。
つまずきやすいところ
- ネストした引数を文字列キーで読む: ツールの引数はJSONを
symbolize_names: trueで読み込んで渡されるため、入れ子の中までシンボルキーです。payload["subject"]はnilを返し、payload[:subject]なら値が取れます。ツールを直接呼ぶテストで文字列キーを渡すと、テストは通るのに本番でnilになります。テストではJSON.parse(JSON.generate(arguments), symbolize_names: true)を通した形で呼びます client.connectを呼び忘れる: リクエストの前にconnectでライフサイクル交渉を済ませる必要があります。stdioでもHTTPでも、最初にconnectを呼びます
まとめ
stdioで試したサーバー定義はそのままRailsに移せますが、手戻りが出やすいのはHTTPに出す段階です。公開ホストのallowed_hosts:と、複数台構成でのセッションの扱いを先に決めておくと、移行で作り直す箇所が減ります。
Javaでの同等の実装はSpring AIでのMCPサーバーにあります。