Class: KairosMcp::HttpServer

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(port: nil, host: nil, token_store_path: nil, tls: nil) ⇒ HttpServer

Returns a new instance of HttpServer.



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/kairos_mcp/http_server.rb', line 46

def initialize(port: nil, host: nil, token_store_path: nil, tls: nil)
  http_config = SkillsConfig.load['http'] || {}

  @port = port || http_config['port'] || DEFAULT_PORT
  @host = host || http_config['host'] || DEFAULT_HOST

  # TLS is opt-in and delegated to Puma/OpenSSL (Prop 2: execution
  # substrate). `tls: true` from the CLI (--tls) overrides the config.
  @tls = TlsConfig.new(http_config, data_dir: KairosMcp.data_dir, force_enabled: tls)

  # 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_routerObject (readonly)

Returns the value of attribute admin_router.



43
44
45
# File 'lib/kairos_mcp/http_server.rb', line 43

def admin_router
  @admin_router
end

#authenticatorObject (readonly)

Returns the value of attribute authenticator.



43
44
45
# File 'lib/kairos_mcp/http_server.rb', line 43

def authenticator
  @authenticator
end

#hostObject (readonly)

Returns the value of attribute host.



43
44
45
# File 'lib/kairos_mcp/http_server.rb', line 43

def host
  @host
end

#meeting_routerObject (readonly)

Returns the value of attribute meeting_router.



43
44
45
# File 'lib/kairos_mcp/http_server.rb', line 43

def meeting_router
  @meeting_router
end

#place_routerObject (readonly)

Returns the value of attribute place_router.



43
44
45
# File 'lib/kairos_mcp/http_server.rb', line 43

def place_router
  @place_router
end

#portObject (readonly)

Returns the value of attribute port.



43
44
45
# File 'lib/kairos_mcp/http_server.rb', line 43

def port
  @port
end

#tlsObject (readonly)

Returns the value of attribute tls.



43
44
45
# File 'lib/kairos_mcp/http_server.rb', line 43

def tls
  @tls
end

#token_storeObject (readonly)

Returns the value of attribute token_store.



43
44
45
# File 'lib/kairos_mcp/http_server.rb', line 43

def token_store
  @token_store
end

Class Method Details

.build_app(token_store_path: nil) ⇒ Object

Build the Rack application (class method for testing)



127
128
129
130
# File 'lib/kairos_mcp/http_server.rb', line 127

def self.build_app(token_store_path: nil)
  server = new(token_store_path: token_store_path)
  server.build_rack_app
end

.exposed_without_auth?(token_store_empty:, loopback:, tls_enabled:) ⇒ Boolean

Pure predicate (class method for testability): an empty token store means unauthenticated owner access. That is only acceptable on a loopback bind without TLS (local dev). Network-reachable (non-loopback) or TLS-enabled (remote intent) + empty store = an open owner endpoint.

Returns:

  • (Boolean)


362
363
364
# File 'lib/kairos_mcp/http_server.rb', line 362

def self.exposed_without_auth?(token_store_empty:, loopback:, tls_enabled:)
  token_store_empty && (!loopback || tls_enabled)
end

Instance Method Details

#bind_uriObject

Puma bind URI for the configured host/port. Scheme (tcp:// vs ssl://) is decided by TlsConfig. Public for testability.



311
312
313
# File 'lib/kairos_mcp/http_server.rb', line 311

def bind_uri
  @tls.bind_uri(@host, @port)
end

#build_rack_appObject

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.



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
168
169
170
171
172
173
174
175
176
# File 'lib/kairos_mcp/http_server.rb', line 137

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_skillsetsObject

Load SkillSets at startup so /meeting/* endpoints work immediately. Without this, MMP module is not defined until the first MCP request.



77
78
79
80
81
82
# File 'lib/kairos_mcp/http_server.rb', line 77

def eager_load_skillsets
  require_relative 'skillset_manager'
  SkillSetManager.new.enabled_skillsets.each(&:load!)
rescue StandardError => e
  $stderr.puts "[HttpServer] SkillSet eager load: #{e.message}"
end

#handle_healthObject


Request Handlers (public for Rack lambda access)



182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/kairos_mcp/http_server.rb', line 182

def handle_health
  body = {
    status: 'ok',
    server: 'kairos-chain',
    version: KairosMcp::VERSION,
    transport: 'streamable-http',
    tls: @tls.enabled?,
    tokens_configured: !@token_store.empty?,
    place_started: !@place_router.nil?
  }

  [200, JSON_HEADERS, [body.to_json]]
end

#handle_mcp(env) ⇒ Object



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
262
263
264
265
266
267
268
269
270
271
# File 'lib/kairos_mcp/http_server.rb', line 196

def handle_mcp(env)
  # 1. Authenticate
  auth_result = @authenticator.authenticate!(env)
  unless auth_result.success?
    return json_response(401, error: 'unauthorized', message: auth_result.message)
  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.handle_message(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.handle_message({
      '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.handle_message(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.message}"
  $stderr.puts e.backtrace.first(5).join("\n")
  json_response(500, error: 'internal_error',
                     message: "Internal server error: #{e.message}")
end

#handle_place(env) ⇒ Object

Handle /place/* routes via Hestia PlaceRouter



274
275
276
277
278
279
280
# File 'lib/kairos_mcp/http_server.rb', line 274

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



305
306
307
# File 'lib/kairos_mcp/http_server.rb', line 305

def json_response(status, body_hash)
  [status, JSON_HEADERS, [body_hash.to_json]]
end

#runObject

Start the HTTP server with Puma



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
116
117
118
119
120
121
122
123
124
# File 'lib/kairos_mcp/http_server.rb', line 85

def run
  KairosMcp.http_server = self
  check_dependencies!
  check_tls!
  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 #{@tls.scheme}://#{@host}:#{@port}"
  log "TLS: #{@tls.enabled? ? "enabled (cert: #{@tls.cert_path})" : 'disabled (plain HTTP)'}"
  log_tls_expiry
  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 server.bind_uri
    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)

Parameters:

  • hestia_config (Hash, nil) (defaults to: nil)

    Full Hestia config hash. PlaceRouter expects the full config (it accesses config internally). When nil, PlaceRouter falls back to ::Hestia.load_config.



287
288
289
290
291
292
293
294
295
296
297
298
299
# File 'lib/kairos_mcp/http_server.rb', line 287

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