Class: KairosMcp::HttpServer
- Inherits:
-
Object
- Object
- KairosMcp::HttpServer
- Defined in:
- lib/kairos_mcp/http_server.rb
Overview
HttpServer: Streamable HTTP transport for MCP
Implements the MCP Streamable HTTP transport (2025-03-26 spec). Uses Rack as the application interface and Puma as the web server.
Endpoints:
POST /mcp - MCP JSON-RPC endpoint (requires Bearer token)
GET /health - Health check (no auth required)
GET /admin/* - Admin UI (requires owner Bearer token via session)
POST /admin/* - Admin operations (htmx, requires owner session)
Usage:
HttpServer.run(port: 8080)
Configuration:
Set in skills/config.yml under 'http' key, or via CLI options.
Constant Summary collapse
- DEFAULT_PORT =
8080- DEFAULT_HOST =
'127.0.0.1'- JSON_HEADERS =
{ 'Content-Type' => 'application/json', 'Cache-Control' => 'no-cache' }.freeze
Instance Attribute Summary collapse
-
#admin_router ⇒ Object
readonly
Returns the value of attribute admin_router.
-
#authenticator ⇒ Object
readonly
Returns the value of attribute authenticator.
-
#host ⇒ Object
readonly
Returns the value of attribute host.
-
#meeting_router ⇒ Object
readonly
Returns the value of attribute meeting_router.
-
#place_router ⇒ Object
readonly
Returns the value of attribute place_router.
-
#port ⇒ Object
readonly
Returns the value of attribute port.
-
#token_store ⇒ Object
readonly
Returns the value of attribute token_store.
Class Method Summary collapse
-
.build_app(token_store_path: nil) ⇒ Object
Build the Rack application (class method for testing).
Instance Method Summary collapse
-
#build_rack_app ⇒ Object
Build Rack application as a lambda.
-
#eager_load_skillsets ⇒ Object
Load SkillSets at startup so /meeting/* endpoints work immediately.
-
#handle_health ⇒ Object
———————————————————————– Request Handlers (public for Rack lambda access) ———————————————————————–.
- #handle_mcp(env) ⇒ Object
-
#handle_place(env) ⇒ Object
Handle /place/* routes via Hestia PlaceRouter.
-
#initialize(port: nil, host: nil, token_store_path: nil) ⇒ HttpServer
constructor
A new instance of HttpServer.
-
#json_response(status, body_hash) ⇒ Object
———————————————————————– Helpers ———————————————————————–.
-
#run ⇒ Object
Start the HTTP server with Puma.
-
#start_place(identity:, trust_anchor_client: nil, hestia_config: nil) ⇒ Object
Start the Meeting Place (called by meeting_place_start tool or auto-start).
Constructor Details
#initialize(port: nil, host: nil, token_store_path: nil) ⇒ HttpServer
Returns a new instance of HttpServer.
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
# File 'lib/kairos_mcp/http_server.rb', line 44 def initialize(port: nil, host: nil, token_store_path: nil) http_config = SkillsConfig.load['http'] || {} @port = port || http_config['port'] || DEFAULT_PORT @host = host || http_config['host'] || DEFAULT_HOST # SkillSets must load BEFORE TokenStore.create so that plugins # (e.g. Multiuser) can register alternative backends first. eager_load_skillsets store_path = token_store_path || http_config['token_store'] if store_path && !File.absolute_path?(store_path) store_path = File.join(KairosMcp.data_dir, store_path) end @token_store = Auth::TokenStore.create( backend: http_config['token_backend'], store_path: store_path ) @authenticator = Auth::Authenticator.new(@token_store) @admin_router = Admin::Router.new(token_store: @token_store, authenticator: @authenticator) @meeting_router = MeetingRouter.new @place_router = nil end |
Instance Attribute Details
#admin_router ⇒ Object (readonly)
Returns the value of attribute admin_router.
42 43 44 |
# File 'lib/kairos_mcp/http_server.rb', line 42 def admin_router @admin_router end |
#authenticator ⇒ Object (readonly)
Returns the value of attribute authenticator.
42 43 44 |
# File 'lib/kairos_mcp/http_server.rb', line 42 def authenticator @authenticator end |
#host ⇒ Object (readonly)
Returns the value of attribute host.
42 43 44 |
# File 'lib/kairos_mcp/http_server.rb', line 42 def host @host end |
#meeting_router ⇒ Object (readonly)
Returns the value of attribute meeting_router.
42 43 44 |
# File 'lib/kairos_mcp/http_server.rb', line 42 def meeting_router @meeting_router end |
#place_router ⇒ Object (readonly)
Returns the value of attribute place_router.
42 43 44 |
# File 'lib/kairos_mcp/http_server.rb', line 42 def place_router @place_router end |
#port ⇒ Object (readonly)
Returns the value of attribute port.
42 43 44 |
# File 'lib/kairos_mcp/http_server.rb', line 42 def port @port end |
#token_store ⇒ Object (readonly)
Returns the value of attribute token_store.
42 43 44 |
# File 'lib/kairos_mcp/http_server.rb', line 42 def token_store @token_store end |
Class Method Details
.build_app(token_store_path: nil) ⇒ Object
Build the Rack application (class method for testing)
118 119 120 121 |
# File 'lib/kairos_mcp/http_server.rb', line 118 def self.build_app(token_store_path: nil) server = new(token_store_path: token_store_path) server.build_rack_app end |
Instance Method Details
#build_rack_app ⇒ Object
Build Rack application as a lambda
Captures self (HttpServer instance) via closure. Each POST /mcp request creates a new Protocol instance for thread safety. Admin UI requests are delegated to Admin::Router.
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 |
# File 'lib/kairos_mcp/http_server.rb', line 128 def build_rack_app server = self ->(env) do request_method = env['REQUEST_METHOD'] path = env['PATH_INFO'] # Admin UI routes if path.start_with?('/admin') return server.admin_router.call(env) end # MMP (Meeting Protocol) P2P endpoints if path.start_with?('/meeting/') return server.meeting_router.call(env) end # Hestia Meeting Place endpoints if path.start_with?('/place/') return server.handle_place(env) end case [request_method, path] when ['GET', '/health'] server.handle_health when ['POST', '/mcp'] server.handle_mcp(env) when ['DELETE', '/mcp'] # Streamable HTTP spec: session termination (no-op in stateless mode) [204, {}, []] when ['GET', '/mcp'] # Streamable HTTP spec: GET /mcp for SSE streaming server.json_response(501, error: 'not_implemented', message: 'SSE streaming via GET /mcp is not yet supported') else server.json_response(404, error: 'not_found', message: 'Endpoint not found. Use POST /mcp for MCP requests.') end end end |
#eager_load_skillsets ⇒ Object
Load SkillSets at startup so /meeting/* endpoints work immediately. Without this, MMP module is not defined until the first MCP request.
71 72 73 74 75 76 |
# File 'lib/kairos_mcp/http_server.rb', line 71 def eager_load_skillsets require_relative 'skillset_manager' SkillSetManager.new.enabled_skillsets.each(&:load!) rescue StandardError => e $stderr.puts "[HttpServer] SkillSet eager load: #{e.}" end |
#handle_health ⇒ Object
Request Handlers (public for Rack lambda access)
173 174 175 176 177 178 179 180 181 182 183 184 |
# File 'lib/kairos_mcp/http_server.rb', line 173 def handle_health body = { status: 'ok', server: 'kairos-chain', version: KairosMcp::VERSION, transport: 'streamable-http', tokens_configured: !@token_store.empty?, place_started: !@place_router.nil? } [200, JSON_HEADERS, [body.to_json]] end |
#handle_mcp(env) ⇒ Object
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 |
# File 'lib/kairos_mcp/http_server.rb', line 186 def handle_mcp(env) # 1. Authenticate auth_result = @authenticator.authenticate!(env) unless auth_result.success? return json_response(401, error: 'unauthorized', message: auth_result.) end # 2. Read and validate request body body = env['rack.input']&.read if body.nil? || body.empty? return json_response(400, error: 'bad_request', message: 'Request body is required') end content_type = env['CONTENT_TYPE'] || '' unless content_type.include?('application/json') return json_response(400, error: 'bad_request', message: 'Content-Type must be application/json') end # 3. Parse request to detect method parsed = JSON.parse(body) method = parsed['method'] # 4. Process MCP message with user context # # Stateless design: each request creates a fresh Protocol instance. # For non-initialize methods, we auto-initialize the Protocol first # so that @initialized=true and tools are available. # See L1 knowledge: kairoschain_operations "Streamable HTTP Transport: Stateless Design" user_context = auth_result.user_context # Inject remote_ip for Service Grant IP rate limiting (D-5). # Uses shared ClientIpResolver when available (Path A/B consistency). if user_context user_context[:remote_ip] = if defined?(ServiceGrant) && ServiceGrant.respond_to?(:ip_resolver) && ServiceGrant.ip_resolver ServiceGrant.ip_resolver.resolve(env) else env['HTTP_X_REAL_IP'] || env['REMOTE_ADDR'] end end protocol = Protocol.new(user_context: user_context) if method == 'initialize' # First request in MCP handshake — process normally, return Mcp-Session-Id response = protocol.(body) session_id = SecureRandom.hex(32) headers = JSON_HEADERS.merge('Mcp-Session-Id' => session_id) [200, headers, [response.to_json]] elsif method == 'initialized' # Notification — no response body needed [204, {}, []] else # For tools/list, tools/call, etc.: auto-initialize the Protocol # so it doesn't reject the request due to @initialized being false. protocol.({ 'jsonrpc' => '2.0', 'id' => '_init', 'method' => 'initialize', 'params' => { 'protocolVersion' => Protocol::HTTP_PROTOCOL_VERSION, 'capabilities' => {}, 'clientInfo' => { 'name' => 'http-stateless', 'version' => '1.0' } } }.to_json) response = protocol.(body) if response [200, JSON_HEADERS, [response.to_json]] else [204, {}, []] end end rescue JSON::ParserError json_response(400, error: 'bad_request', message: 'Invalid JSON in request body') rescue StandardError => e $stderr.puts "[ERROR] MCP request failed: #{e.}" $stderr.puts e.backtrace.first(5).join("\n") json_response(500, error: 'internal_error', message: "Internal server error: #{e.}") end |
#handle_place(env) ⇒ Object
Handle /place/* routes via Hestia PlaceRouter
264 265 266 267 268 269 270 |
# File 'lib/kairos_mcp/http_server.rb', line 264 def handle_place(env) unless @place_router return json_response(503, error: 'place_not_started', message: 'Meeting Place is not started. Use meeting_place_start tool first.') end @place_router.call(env) end |
#json_response(status, body_hash) ⇒ Object
Helpers
295 296 297 |
# File 'lib/kairos_mcp/http_server.rb', line 295 def json_response(status, body_hash) [status, JSON_HEADERS, [body_hash.to_json]] end |
#run ⇒ Object
Start the HTTP server with Puma
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
# File 'lib/kairos_mcp/http_server.rb', line 79 def run KairosMcp.http_server = self check_dependencies! check_tokens! check_version_mismatch auto_start_meeting_place app = build_rack_app server = self log "Starting KairosChain MCP Server v#{VERSION} (Streamable HTTP)" log "Listening on #{@host}:#{@port}" log "MCP endpoint: POST /mcp" log "Health check: GET /health" log "Admin UI: GET /admin" log "MMP P2P: /meeting/v1/*" log "Place API: /place/v1/*" if @place_router require 'puma' require 'puma/configuration' require 'puma/launcher' puma_config = Puma::Configuration.new do |config| config.bind "tcp://#{server.host}:#{server.port}" config.app app config.workers 0 config.threads 1, 5 config.environment 'production' config.log_requests false config.quiet false end launcher = Puma::Launcher.new(puma_config) launcher.run rescue Interrupt log "KairosChain HTTP Server interrupted." end |
#start_place(identity:, trust_anchor_client: nil, hestia_config: nil) ⇒ Object
Start the Meeting Place (called by meeting_place_start tool or auto-start)
277 278 279 280 281 282 283 284 285 286 287 288 289 |
# File 'lib/kairos_mcp/http_server.rb', line 277 def start_place(identity:, trust_anchor_client: nil, hestia_config: nil) require 'hestia' router = ::Hestia::PlaceRouter.new(config: hestia_config) router.start( identity: identity, session_store: @meeting_router.session_store, trust_anchor_client: trust_anchor_client ) @place_router = router # Register place extensions from enabled SkillSets register_place_extensions(router) end |