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) ⇒ 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_routerObject (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

#authenticatorObject (readonly)

Returns the value of attribute authenticator.



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

def authenticator
  @authenticator
end

#hostObject (readonly)

Returns the value of attribute host.



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

def host
  @host
end

#meeting_routerObject (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_routerObject (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

#portObject (readonly)

Returns the value of attribute port.



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

def port
  @port
end

#token_storeObject (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_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.



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_skillsetsObject

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.message}"
end

#handle_healthObject


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.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



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

#runObject

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)

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.



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