class RailsAiAdmin::Mcp::AccessToken
Personal access tokens. The token is shown once; only its SHA-256 is stored. It names an actor, tenant and security version, and the hostβs resolve_job decides on every request whether that authority is current.
Constants
- LIFETIMES
- MAX_ACTIVE
Public Class Methods
Source
# File lib/rails_ai_admin/mcp.rb, line 49 def self.authenticate(token) return unless token.is_a?(String) && token.match?(TOKEN) active.find_by(token_digest: digest(token)) end
Source
# File lib/rails_ai_admin/mcp.rb, line 36 def self.digest(token) = Digest::SHA256.hexdigest(token) # Returns [record, token]; the token is never readable again. def self.issue!(context, name:, lifetime:) raise AccessDenied, "INVALID_QUERY" unless name.is_a?(String) && name.strip.length.between?(1, 80) && LIFETIMES.key?(lifetime.to_s) raise AccessDenied, "INVALID_QUERY" if for_owner(context).active.count >= MAX_ACTIVE token = "#{TOKEN_PREFIX}#{SecureRandom.urlsafe_base64(32)}" record = create!(id: SecureRandom.uuid, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref, security_version: context.security_version, name: name.strip.squish, token_digest: digest(token), expires_at: Time.current + LIFETIMES.fetch(lifetime.to_s)) [ record, token ] end def self.authenticate(token) return unless token.is_a?(String) && token.match?(TOKEN) active.find_by(token_digest: digest(token)) end def active? = revoked_at.nil? && expires_at > Time.current def conversation = "raa:conversation:mcp-#{id}" end # JSON-RPC 2.0 over one POST. Notifications get no body; batches are not # accepted (MCP 2025-06-18 removed them). class Server ToolCall = ::Data.define(:id) Error = Class.new(StandardError) do attr_reader :code def initialize(code, message) @code = code super(message) end end def initialize(context:, token:, review_url:) @context, @token, @review_url = context, token, review_url end # Returns [status, body or nil]. def handle(message) raise Error.new(-32600, "Invalid request") unless message.is_a?(Hash) && message["jsonrpc"] == "2.0" && message["method"].is_a?(String) id = message["id"] notification = !message.key?("id") result = dispatch(message["method"], message["params"].is_a?(Hash) ? message["params"] : {}) return [ 202, nil ] if notification [ 200, { "jsonrpc" => "2.0", "id" => id, "result" => result } ] rescue Error => error [ 200, { "jsonrpc" => "2.0", "id" => message.is_a?(Hash) ? message["id"] : nil, "error" => { "code" => error.code, "message" => error.message } } ] end private def dispatch(name, params) case name when "initialize" requested = params["protocolVersion"] { "protocolVersion" => PROTOCOL_VERSIONS.include?(requested) ? requested : PROTOCOL_VERSIONS.first, "capabilities" => { "tools" => { "listChanged" => false } }, "serverInfo" => { "name" => "rails_ai_admin", "title" => RailsAiAdmin.configuration.title.to_s.truncate(80), "version" => VERSION }, "instructions" => INSTRUCTIONS } when "ping" then {} when "tools/list" then { "tools" => tools.map { |tool| descriptor(tool) } } when "tools/call" then call(params) when %r{\Anotifications/} then {} else raise Error.new(-32601, "Method not found") end end def session @session ||= Assistant::Session.new(context: @context, chat: nil, fence: -> { }, conversation: @token.conversation) end def tools classes = Assistant::TOOLS.dup classes.push(Assistant::ProposeOperation, Assistant::ProposeChange) if RailsAiAdmin.configuration.surfaces classes.map { |klass| klass.new(session) } end def descriptor(tool) { "name" => tool.name, "description" => tool.description, "inputSchema" => tool.parameters_schema || { "type" => "object", "properties" => {} } } end def call(params) tool = tools.find { |candidate| candidate.name == params["name"] } raise Error.new(-32602, "Unknown tool") unless tool arguments = params["arguments"] || {} raise Error.new(-32602, "Arguments must be an object") unless arguments.is_a?(Hash) result = tool.execute(tool_call: ToolCall.new("mcp-#{SecureRandom.uuid}"), **arguments.transform_keys(&:to_sym)) result = result.merge("review_url" => @review_url.call(result["surface"])) if result.is_a?(Hash) && result["surface"].is_a?(String) { "content" => [ { "type" => "text", "text" => JSON.generate(result) } ], "structuredContent" => result, "isError" => result.is_a?(Hash) && result.key?("error") } end end end end
Source
# File lib/rails_ai_admin/mcp.rb, line 39 def self.issue!(context, name:, lifetime:) raise AccessDenied, "INVALID_QUERY" unless name.is_a?(String) && name.strip.length.between?(1, 80) && LIFETIMES.key?(lifetime.to_s) raise AccessDenied, "INVALID_QUERY" if for_owner(context).active.count >= MAX_ACTIVE token = "#{TOKEN_PREFIX}#{SecureRandom.urlsafe_base64(32)}" record = create!(id: SecureRandom.uuid, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref, security_version: context.security_version, name: name.strip.squish, token_digest: digest(token), expires_at: Time.current + LIFETIMES.fetch(lifetime.to_s)) [ record, token ] end
Returns [record, token]; the token is never readable again.
Public Instance Methods
Source
# File lib/rails_ai_admin/mcp.rb, line 54 def active? = revoked_at.nil? && expires_at > Time.current def conversation = "raa:conversation:mcp-#{id}" end # JSON-RPC 2.0 over one POST. Notifications get no body; batches are not # accepted (MCP 2025-06-18 removed them). class Server ToolCall = ::Data.define(:id) Error = Class.new(StandardError) do attr_reader :code def initialize(code, message) @code = code super(message) end end def initialize(context:, token:, review_url:) @context, @token, @review_url = context, token, review_url end # Returns [status, body or nil]. def handle(message) raise Error.new(-32600, "Invalid request") unless message.is_a?(Hash) && message["jsonrpc"] == "2.0" && message["method"].is_a?(String) id = message["id"] notification = !message.key?("id") result = dispatch(message["method"], message["params"].is_a?(Hash) ? message["params"] : {}) return [ 202, nil ] if notification [ 200, { "jsonrpc" => "2.0", "id" => id, "result" => result } ] rescue Error => error [ 200, { "jsonrpc" => "2.0", "id" => message.is_a?(Hash) ? message["id"] : nil, "error" => { "code" => error.code, "message" => error.message } } ] end private def dispatch(name, params) case name when "initialize" requested = params["protocolVersion"] { "protocolVersion" => PROTOCOL_VERSIONS.include?(requested) ? requested : PROTOCOL_VERSIONS.first, "capabilities" => { "tools" => { "listChanged" => false } }, "serverInfo" => { "name" => "rails_ai_admin", "title" => RailsAiAdmin.configuration.title.to_s.truncate(80), "version" => VERSION }, "instructions" => INSTRUCTIONS } when "ping" then {} when "tools/list" then { "tools" => tools.map { |tool| descriptor(tool) } } when "tools/call" then call(params) when %r{\Anotifications/} then {} else raise Error.new(-32601, "Method not found") end end def session @session ||= Assistant::Session.new(context: @context, chat: nil, fence: -> { }, conversation: @token.conversation) end def tools classes = Assistant::TOOLS.dup classes.push(Assistant::ProposeOperation, Assistant::ProposeChange) if RailsAiAdmin.configuration.surfaces classes.map { |klass| klass.new(session) } end def descriptor(tool) { "name" => tool.name, "description" => tool.description, "inputSchema" => tool.parameters_schema || { "type" => "object", "properties" => {} } } end def call(params) tool = tools.find { |candidate| candidate.name == params["name"] } raise Error.new(-32602, "Unknown tool") unless tool arguments = params["arguments"] || {} raise Error.new(-32602, "Arguments must be an object") unless arguments.is_a?(Hash) result = tool.execute(tool_call: ToolCall.new("mcp-#{SecureRandom.uuid}"), **arguments.transform_keys(&:to_sym)) result = result.merge("review_url" => @review_url.call(result["surface"])) if result.is_a?(Hash) && result["surface"].is_a?(String) { "content" => [ { "type" => "text", "text" => JSON.generate(result) } ], "structuredContent" => result, "isError" => result.is_a?(Hash) && result.key?("error") } end end end
Source
# File lib/rails_ai_admin/mcp.rb, line 55 def conversation = "raa:conversation:mcp-#{id}" end