| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | module Mcp |
| 4 | # Protocol-level dispatcher: maps a single JSON-RPC message to a result. |
| 5 | # Knows nothing about HTTP — that's the controller's job. |
| 6 | class Server |
| 7 | PROTOCOL_VERSION = "2025-06-18" |
| 8 | SUPPORTED_VERSIONS = %w[2025-06-18 2025-03-26 2024-11-05].freeze |
| 9 | |
| 10 | def initialize(current_user:) |
| 11 | @current_user = current_user |
| 12 | end |
| 13 | |
| 14 | # message: a parsed JSON-RPC object. Returns a response Hash, or nil for |
| 15 | # notifications (no id) which must not produce a reply. |
| 16 | def handle(message) |
| 17 | id = message["id"] |
| 18 | method = message["method"] |
| 19 | params = message["params"] || {} |
| 20 | |
| 21 | case method |
| 22 | when "initialize" then result(id, initialize_result(params)) |
| 23 | when "ping" then result(id, {}) |
| 24 | when "tools/list" then result(id, { "tools" => Tools.specs }) |
| 25 | when "tools/call" then call_tool(id, params) |
| 26 | when "resources/list" then result(id, { "resources" => [] }) |
| 27 | when "prompts/list" then result(id, { "prompts" => [] }) |
| 28 | when %r{\Anotifications/} then nil # initialized, cancelled, etc. |
| 29 | else error(id, -32601, "Method not found: #{method}") |
| 30 | end |
| 31 | rescue => e |
| 32 | Rails.logger.error("[mcp] #{e.class}: #{e.message}") |
| 33 | error(id, -32603, "Internal error") |
| 34 | end |
| 35 | |
| 36 | private |
| 37 | |
| 38 | def initialize_result(params) |
| 39 | requested = params["protocolVersion"] |
| 40 | version = SUPPORTED_VERSIONS.include?(requested) ? requested : PROTOCOL_VERSION |
| 41 | { |
| 42 | "protocolVersion" => version, |
| 43 | "capabilities" => { "tools" => { "listChanged" => false } }, |
| 44 | "serverInfo" => { "name" => "sigit", "title" => "siGit Code", "version" => "0.1.0" }, |
| 45 | "instructions" => "Tools for siGit-hosted git repositories: list repos, " \ |
| 46 | "read files, search code, and open or comment on pull requests and issues. " \ |
| 47 | "Also web_search, for finding pages on the public web." |
| 48 | } |
| 49 | end |
| 50 | |
| 51 | def call_tool(id, params) |
| 52 | tool = Tools.find(params["name"]) |
| 53 | return error(id, -32602, "Unknown tool: #{params['name']}") unless tool |
| 54 | |
| 55 | output = tool.call(params["arguments"] || {}, current_user: @current_user) |
| 56 | result(id, { "content" => [ text_block(output) ], "isError" => false }) |
| 57 | rescue Mcp::ToolError => e |
| 58 | # Tool failures are reported in-band (isError: true) so the model can see |
| 59 | # and recover from them, not as JSON-RPC protocol errors. |
| 60 | result(id, { "content" => [ text_block(e.message) ], "isError" => true }) |
| 61 | end |
| 62 | |
| 63 | def text_block(value) |
| 64 | text = value.is_a?(String) ? value : JSON.pretty_generate(value) |
| 65 | { "type" => "text", "text" => text } |
| 66 | end |
| 67 | |
| 68 | def result(id, value) |
| 69 | return nil if id.nil? # was a notification |
| 70 | { "jsonrpc" => "2.0", "id" => id, "result" => value } |
| 71 | end |
| 72 | |
| 73 | def error(id, code, message) |
| 74 | { "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message } } |
| 75 | end |
| 76 | end |
| 77 | end |