Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Rails で Remote MCP サーバーを作った話

Avatar for 4geru 4geru
July 18, 2026
0

Rails で Remote MCP サーバーを作った話

[関西Ruby会議09 - ひとつの指示で、部屋にあかりを。](https://regional.rubykaigi.org/kansai09/presentations/4geru) で登壇した話です。

ひとつの指示で、LED が呼吸をするように部屋を照らします。

2024年末から、MCP(Model Context Protocol)が AI エージェントの標準プロトコルとして広まっています。
Claude Code や Cursor などの AI ツールは、MCP を通じて外部サービスと連携できます。
今回は Rails で Remote MCP サーバーを実装し、AI と LED をつなげました。

本セッションでは、Rails で Remote MCP サーバーの作成の流れを、認証・実装・セキュリティの観点から一緒に追いかけます。
ライブラリなしの実装と modelcontextprotocol/ruby-sdk の比較。OAuth を 0 から実装し、LINE ログインとの連携。セキュリティ実装を通じて、Doorkeeper・Devise が何を解決してくれているかが見えてきます。

Rails を触ったことがある方なら、すぐに試せる内容です。Rails と AI の可能性を感じてください。

Avatar for 4geru

4geru

July 18, 2026

More Decks by 4geru

Transcript

  1. ▸ 自己紹介 ニックネーム:しげる。 名前:内西 功一 所属:マネーフォワード 勤怠・給与 - 横断開発 -

    横断BizOps 最近は Salesforce メインで営業生産性向上チーム 🌊 滋賀県、彦根市出身です。彦根にも遊びに来て下さい。 今日は Rails で作った Remote MCP サーバーの話をします @4geru LINE API Expert DATA Saber
  2. chapter 1 MCP × Rails × LED MCP の基礎・terasu の全体

    chapter 2 0からの実装 自前実装 chapter 3 ライブラリで書き直した 差分の解説 chapter 4 比較と気づき Gem が何を肩代わりするか
  3. ▸ MCP は AI とツールの「中央ハブ」 MCP(Model Context Protocol) — 1

    つの規格を挟むだけで、どの AI ↔ どのツールも繋がる AI クライアント 🤖 Claude 🖱 Cursor ✨ Gemini ⇄ 🔌 MCP JSON-RPC 共通の口 ⇄ 外部サービス GitHub Slack Notion LINE // MCP を喋れれば自作サービスも全 AI から使える。 // 通信は JSON-RPC (メソッド名+引数を送る関数呼び出し)
  4. ▸ Local MCP vs Remote MCP Local MCP claude →

    (fork) → mcp-server 通信 stdio 同じ PC 内の直通パイプ 利用者 自分ひとり 認証 不要(同じマシンの中だけ) 起動 AI が手元でプログラムを起動 配布 各 PC に install 必要 Remote MCP claude → HTTP → mcp-server 通信 Streamable HTTP HTTP + SSE(開きっぱなしの通知線) 利用者 複数ユーザー 認証 OAuth 2.0 必須(誰でも叩けるから) 起動 常時稼働のサーバー 配布 URL 1 本で誰でも繋がる なぜ Rails? サーバー常駐 + HTTP + DB + Gem エ コシステム — Remote MCP に必要な要素が標準 装備 HTTP で公開 = 誰でも叩ける → 後半で OAuth / Doorkeeper が必要になる理由がここで決まる
  5. ▸ Claude が接続、登録を自動でしてくれる .mcp.json に URL を 1 行書くだけで、以下を Claude

    が全部自動で叩く Claude Code ├─ GET /.well-known/oauth-protected-resource ← サーバー情報を取得 ├─ GET /.well-known/oauth-authorization-server ← OAuth エンドポイント発見 ├─ POST /register ← 自分を OAuth クライアントとして登録 ├─ GET /oauth/authorize ← LINE Login へリダイレクト ├─ POST /oauth/token ← Bearer Token 取得 └─ POST /mcp ← ツール呼び出し(Authorization: Bearer ... ) ▪ ① 発見(discovery ) — お店の場所と入口を調べる ▪ ② クライアント登録 — 会員証を自分で作りに来る ▪ ③ 認可・トークン取得 — 入場の鍵をもらう ▪ ④ ツール呼び出し — 鍵を見せて注文する
  6. ▸ Rails は 2 つの OAuth を同時にしゃべっている state = OAuth

    でリクエストとレスポンスを紐づける「合言葉」 (CSRF 対策兼用) Rails は Claude・LINE の両方と同時に OAuth の会話をしている。 🤖 Claude mcp_state=abc Rails はサーバー役 → → ← ← Rails DB で変換・橋渡し Rails はクライアント役 → → ← ← 💚 LINE line_state=xyz mcp_state = "abc" Claude が発行した合言葉。認証完了時にそのまま Claude に返す必要がある。紛失すると認証失敗。 line_state = "xyz" Rails が独自に生成した合言葉。LINE callback でど の OauthToken レコードか特定するために使う。
  7. ▸ 登場人物は 3 つだけ — tools のファイル構造 gem を使わず、Rails の標準構成だけで

    MCP の tools を組み立てる 対象ファイル app/ ├── controllers/ │ └── mcp_controller.rb JSON-RPC dispatcher └── services/ ├── mcp_tools/ ツール定義(1 ツール 1 ファイル) │ ├── illuminate_tool.rb │ ├── set_color_tool.rb │ └── … 各ツール └── hue_service.rb Hue API 呼び出し 📐 自前で書くもの JSON-RPC parsing / dispatching tools/list で tool 一覧を返す tools/call で実行 + レスポンス整形 capabilities ハンドシェイク 🛠 物理デバイスへの橋渡し HueService が HTTP で Hue Bridge を叩く
  8. ▸ McpTools — ツールの定義 1ツール = 1 モジュール module McpTools::IlluminateTool

    DEFINITION = { name: "illuminate", # 🏷️ ツールの一意な識別子 description: " 部屋の照明をつける", # 📦 ツールの説明 # === 🎨 引数の JSON Schema 🎨 === inputSchema: { type: "object", properties: { brightness: { type: "integer" }, color: { type: "string" } } } # === 🎨 引数の JSON Schema 🎨 === }.freeze def self.call(args) HueService.illuminate(...) " 照らしました" McpTools::XxxTool DEFINITION に定義 name — 🏷️ tools/call で指定される名前 description — 📦 LLM がいつ使うか判断する材料 inputSchema — 🎨 型・必須・プロパティを宣言 self.call に実処理
  9. ▸ McpController — JSON-RPC を全部自前で(1/5) ツールを TOOLS に登録し、 TOOL_MAP (name→tool)で引けるように

    class McpController < ApplicationController TOOLS = [ McpTools::IlluminateTool, McpTools::TurnOffTool, McpTools::SetColorTool ] TOOL_MAP = TOOLS.index_by { |t| t::DEFINITION[:name] }.freeze end ① ツールを登録 ツールモジュールを TOOLS 配列に登録 TOOL_MAP で name → tool を引けるように 新ツールはファイルを足して TOOLS に追加
  10. ▸ McpController — JSON-RPC を全部自前で(2/5) JSON-RPC body を受け取って method で分岐

    body = JSON.parse(request.body.read) case body["method"] class McpController < ApplicationController def handle when "initialize" ... when "tools/list" ... when "tools/call" ... end end end ② JSON-RPC body を受信 POST /mcp のボディを JSON.parse method フィールドで分岐 Claude からのリクエストの入り口
  11. ▸ McpController — JSON-RPC を全部自前で(3/5) initialize でプロトコル情報を返す when "initialize" render

    json: { jsonrpc: "2.0", id: body["id"], result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "terasu" } } } class McpController < ApplicationController def handle body = JSON.parse(request.body.read) case body["method"] when "tools/list" ... when "tools/call" ... end end end ③ initialize 応答 プロトコルバージョンを返す capabilities で対応機能を宣言 — Claude は ここに載せた機能しか頼んでこない サーバー情報を伝える(name: "terasu")
  12. ▸ McpController — JSON-RPC を全部自前で(4/5) tools/list で各ツールの DEFINITION を map

    で集約 ④ tools/list で一覧 使えるツール一覧を返す 各ツールの DEFINITION を map で集 約 Claude がツール選択に使う 📋 DEFINITION の中身 name — ツールの識別子 description — LLM が選ぶ判断材料 inputSchema — 引数の JSON when "tools/list" render json: { jsonrpc: "2.0", id: body["id"], result: { tools: TOOLS.map { |t| t::DEFINITION } } } class McpController < ApplicationController def handle body = JSON.parse(request.body.read) case body["method"] when "initialize" ... when "tools/call" ... end end end
  13. ▸ McpController — JSON-RPC を全部自前で(5/5) tools/call で dispatch して実行 when

    "tools/call" name = body.dig("params", "name") args = body.dig("params", "arguments") result = TOOL_MAP[name].call(args) render json: { jsonrpc: "2.0", id: body["id"], result: { content: [{ type: "text", text: result }] } } class McpController < ApplicationController def handle body = JSON.parse(request.body.read) case body["method"] when "initialize" ... when "tools/list" ... end end end ⑤ tools/call で実行 ツール名と引数を取り出し TOOL_MAP[name] を引いて call 結果を JSON-RPC で返す
  14. ▸ Gem 化で 505 行 → 162 行 BEFORE 自前実装

    505 行 → AFTER Gem あり 162 行 Devise 60 → 7 Doorkeeper 200 → 75 OmniAuth 60 → 51 ruby-sdk 185 → 29 // 削れたのは「プロトコルとセキュリティの定型」 // LineService / HueService のロジックは 1 行も変わらない
  15. ▸ アーキテクチャの差分 — 自前実装 → Gem 実装 不変 After Gem

    実装 Before 自前実装 置換 置換 置換 置換 OauthController OAuth / PKCE state / LINE API セッション自前 McpController JSON-RPC 分岐 👤 Devise ユーザー認証 🛡️ Doorkeeper OAuth 認可 🔑 OmniAuth 外部認証 📦 ruby-sdk MCP プロトコル LineService ✓ HueService ✓ 手書き重量級(消える) Gem(置き換わる) Service(不変)
  16. ▸ 👤 Devise › ユーザー認証 User モデルに宣言を 1 行足すだけ //

    password は使わない。必要な 2 モジュールだけを選んで宣言する app/models/user.rb −1 +2 🧱 素の ActiveRecord ログインの入口もセッション管理も無い 🔑 :omniauthable /users/auth/line と callback を自動生成 📍 :trackable ログインの記録を自動で取る class User < ApplicationRecord # 何もしない(ActiveRecord だけ) devise :omniauthable, :trackable, omniauth_providers: %i[line] end
  17. ▸ 👤 Devise › ユーザー認証 current_user / sign_in / sign_out

    は書かない // 自前ヘルパーの書き忘れは脆弱性。Devise は定義漏れごと排除 app/controllers/sessions_controller.rb −5 +1 🧱 自前ヘルパー 書き忘れ = そのまま脆弱性 👤 current_user / user_signed_in? memoize 込みで自動定義 🔑 sign_in / sign_out セッション張り替え・破棄も提供 class SessionsController < ApplicationController def current_user @current_user ||= User.find_by(id: session[:user_id]) end def signed_in? = current_user.present? # 書くコードなし — devise 宣言が全部定義 end
  18. 🛡 Doorkeeper OAuth 認可 OAuth 2.0 認可サーバーを肩代わり 🚦 /authorize 認可

    endpoint 🎟 /token アクセストークン発行 🔑 PKCE S256 検証 💾 token 管理 hash 保存・期限・失効
  19. ▸ 🛡 Doorkeeper › OAuth 認可 1 テーブル → 安全な

    3 テーブルへ // 平文トークンは漏れたら即アウト。generator schema は hash ・失効・期限・PKCE 入り 🧱 自前設計 table: oauth_tokens token ⚠️ 平文 失効なし 期限なし PKCE なし → oauth_applications 🪪 会員証 / クライアント登 録 初回の 1 回だけ発行 uid secret redirect_uri scopes oauth_access_grants 🎫 整理券 / 認可コード発行 ログインのたびに発行 token (code) redirect_uri expires_in code_challenge PKCE oauth_access_tokens 🔑 鍵 / アクセストークン発 行 整理券と交換で発行 token 🔒 SHA-256 refresh_token expires_in revoked_at
  20. ▸ 🛡 Doorkeeper › OAuth 認可 API を守るのは before_action 1

    行 // 手書き authenticate! が、期限 (expires_in) ・失効 (revoked_at) まで検証する 1 行に app/controllers/mcp_controller.rb −7 +1 🧱 手書き authenticate! 平文 token を DB 照合するだけ。 期限も失効も見ない 🛡 doorkeeper_authorize! hash 照合・期限 (expires_in)・ 失効 (revoked_at) まで 1 行で全部 検証 🚪 守り方は Rails の流儀 見慣れた before_action で API 全体を保護 class McpController < ApplicationController before_action :authenticate! before_action :doorkeeper_authorize! def authenticate! token = request.headers["Authorization"] &.delete_prefix("Bearer ") return if OauthToken.valid.exists?(token: token) render json: { error: "unauthorized" }, status: 401 end end
  21. 🔑 OmniAuth 外部認証 LINE ログインを肩代わり(token / CSRF を strategy に隔離できるのが価値)

    🔒 state CSRF 対策 🎟 token 交換 LINE token API 👤 profile 取得 LINE Profile API 📋 auth_hash uid / info / extra
  22. ▸ 🔑 OmniAuth › 外部認証 token 交換は OAuth2 親クラスの仕事になる //

    行数減はおまけ。token / CSRF / state が strategy に隔離されるのが本体 app/services/line_service.rb −6 lib/omniauth/strategies/line.rb +4 🧱 secret を扱う = 漏らし うる Net::HTTP に client_secret を 手詰め 🎁 < OAuth2 token 交換の POST は親クラ ス 📦 存在しないものは漏らせな い client_secret がアプリから消 える class LineService def self.exchange_token(code:, redirect_uri:) http = Net::HTTP.new(uri.host, uri.port) req.set_form_data(grant_type: "authorization_code", code:, client_id: CHANNEL_ID, client_secret: SECRET) JSON.parse(http.request(req).body) end module OmniAuth::Strategies class Line < OmniAuth::Strategies::OAuth2 option :name, "line" end
  23. ▸ 🔑 OmniAuth › 外部認証 Bearer の手組みが 1 行の access_token.get

    に // Bearer を手で組む場所が無い = token を漏らす場所も無い app/services/line_service.rb −6 lib/omniauth/strategies/line.rb +3 🧱 Bearer 手組み 生 token をヘッダーに手詰め 🎫 access_token.get Bearer ヘッダーは自動付与 🚫 token 非露出 生 token を触るコードが消える class LineService def self.profile(access_token:) http = Net::HTTP.new(uri.host, uri.port) req = Net::HTTP::Get.new(uri.path) req["Authorization"] = "Bearer #{access_token}" JSON.parse(http.request(req).body) end class Line < OmniAuth::Strategies::OAuth2 def raw_info @raw_info ||= access_token.get("/v2/profile").parsed end
  24. 📦 ruby-sdk MCP プロトコル プロトコル実装をすべて受け持つ公式 Gem 🔄 JSON-RPC dispatch 📡

    SSE streaming 🤝 capabilities ハンドシェイク 🧩 Tool DSL tool_name / schema
  25. ▸ 📦 ruby-sdk › MCP プロトコル (1/4) プロトコルはもう書かない // routes

    3 本 + あの case 文が、match 1 本 + 実質 2 行に config/routes.rb −5 +1 app/controllers/mcp_controller.rb −3 +3 🧱 routes 3 本 + case 文 分岐も SSE も手書きだった 🚪 match "/mcp" 1 本 GET=SSE / POST=JSON-RPC DELETE=セッション終了 📡 SSE HTTP を開きっぱなしにして サーバー発の通知を push Rails.application.routes.draw do scope :mcp do ... match "/mcp", to: "mcp#handle", via: %i[get post delete] end def handle case JSON.parse(request.body.read)["method"] when "initialize" then ... when "tools/list" then ... server = MCP::Server.new(name: "terasu", tools: TOOLS) StreamableHTTPTransport.new(server, stateless: true) .handle_request(request) end
  26. ▸ 📦 ruby-sdk › MCP プロトコル (2/4) ツールは「宣言」するだけ — Hash

    が DSL に // MCP 仕様の形を知らずとも、MCP::Tool を継承して宣言するだけ app/services/mcp_tools/set_color_code_tool.rb −7 +6 🧱 仕様書どおりの生 Hash DEFINITION を手で組んで いた 🏷️ クラスマクロで宣言 tool_name / description / input_schema 📋 tools/list 自動生成 JSON Schema 変換も map も gem module McpTools::SetColorCodeTool DEFINITION = { name: "set_light_color_hex", description: " 照明の色を hex で指定する", inputSchema: { type: "object", required: ["hex"], properties: { hex: { type: "string" } } } }.freeze class SetColorCodeTool < MCP::Tool tool_name "set_light_color_hex" description " 照明の色を hex で指定する" input_schema( required: ["hex"], properties: { hex: { type: "string" } }) end
  27. ▸ 📦 ruby-sdk › MCP プロトコル (3/4) call は Response

    を返すだけ // 返せる型(text / image / audio … )の決定権がツール側に移る app/controllers/mcp_controller.rb −3 app/services/mcp_tools/set_color_code_tool.rb −2 +4 🧱 text 一択 controller で type: "text" のみ決定 🎨 type を選べる tool が自分で宣言(全 5 種類) text image / audio resource resource_link def handle when "tools/call" result = TOOL_MAP[name].call(args) # ← 戻りは文字列 render json: { jsonrpc: "2.0", id: body["id"], result: { content: [{ type: "text", text: result }] } } end class SetColorCodeTool < MCP::Tool def self.call(args) "#{args["hex"]} に変えました" # 文字列を返すだけ def self.call(hex:, server_context:) HueService.set_color_hex(hex) MCP::Tool::Response.new( [{ type: "text", text: "#{hex} に変えました" }]) end
  28. ▸ 📦 ruby-sdk › MCP プロトコル (4/4) 恩恵 — 仕事の分担が変わる

    // プロトコルの仕事は gem へ。 「私」のまま残るのは 2 行だけ 仕事 🧱 自前実装 📦 gem 化後 JSON-RPC の応答 😮‍💨 私 📦 gem SSE(Server-Sent Events) ストリーム 😮‍💨 私 📦 gem schema 変換・tools/list 😮‍💨 私 📦 gem 仕様追従(protocolVersion ) 😮‍💨 私 📦 gem AI に渡す語彙の設計 ✍️ 私 ✍️ 私のまま call のロジック ✍️ 私 ✍️ 私のまま
  29. ▸ アーキテクチャの差分 — 自前実装 → Gem 実装 不変 After Gem

    実装 Before 自前実装 置換 置換 置換 置換 OauthController OAuth / PKCE state / LINE API セッション自前 McpController JSON-RPC 分岐 👤 Devise ユーザー認証 🛡️ Doorkeeper OAuth 認可 🔑 OmniAuth 外部認証 📦 ruby-sdk MCP プロトコル LineService ✓ HueService ✓ 手書き重量級(消える) Gem(置き換わる) Service(不変)
  30. ▸ 気づき① 自前実装は「動くけど危ない」 # 0 から書くと仕組みが見える。代わりに、セキュリティの穴は全部自分で塞ぐことになる 防ぎたい事故 0 から自前 Doorkeeper

    導入後 認可コードを横取りされる PKCE ⚠ 手書き 約15行 ✓ force_pkce 1 行 偽サイトに誘導される redirect_uri ⚠ 抜けやすい ✓ 事前登録制で照合 漏れた鍵がいつまでも使える token 失効 ⚠ 自前で管理 ✓ 期限設定 + revoke DB が漏れたら全員の鍵が丸見え token 保存 ⚠ 平文で保存 ✓ hash 化して保存 // Gem が消すのは行数より「抜け漏れ」 // 0 から書いたから「どこが危ないか」が分 かる
  31. ▸ 気づき② 認可と認証は別物 — だから OAuth が 2 重になる #

    「何を許すか」と「誰なのか」— 問いが 2 つあるから、OAuth も state も 2 つになる 🤖 Claude ↔ Rails 認可 — 何を許すか 「この LLM に照明を触らせてよいか」 🛡 Doorkeeper mcp_state + 💚 Rails ↔ LINE 認証 — 誰なのか 「今アクセスしているのは誰か」 🔑 OmniAuth line_state 🚧 Doorkeeper は Claude しか知らず、OmniAuth は LINE しか知らない Gem はそれぞれの門番まで。その先は自分のドメイン
  32. ▸ 気づき③ Gem は「全部乗る / 全部使う」ものではない # 一気に乗らない・全部使わない・全部は任せない — Gem

    との間合いの話 一気に乗らない 🪜 動くまま 1 つずつ差し替え 自前で動かす → MCP SDK を導入 → 認可・認証を導入 どの矢印の途中でも壊れない。 Service 層は 1 行も変わっていな い 全部使わない ✂️ Devise 2 つ LINE ログインに必要な分だけ :trackable :omniauthable 全部は任せない 🤖 Gem が追いつかない所も Claude が OAuth クライアン トとして自己登録してくる /register・discovery は自前実装のまま // こうして間合いを測って削った先、LLM に残って見えるのは tool_name と description だ け — MCP の設計とは語彙の設計
  33. 1 Rails は MCP のハブになる LLM → Rails → 物理デバイスまで一直線

    MCP を喋れば、どの AI とも繋がる 2 認可と認証は別の OAuth Claude ↔ Rails(認可)と Rails ↔ LINE(認証) は別物だから state も 2 種類要る 3 Ruby のエコシステムは層が厚い 認証 Devise・認可 Doorkeeper 外部認証 OmniAuth・MCP ruby-sdk どの持ち場にも Gem がいる 4 全部乗る / 全部使う ではない 一気に乗らない・全部使わない・全部は任せない Gem とは間合いを測って付き合う // そして LLM に見えるのは tool_name と description だけ — MCP の設計とは語彙の設計