Class: RailsAiContext::McpController

Inherits:
ActionController::API
  • Object
show all
Includes:
ActionController::Live
Defined in:
app/controllers/rails_ai_context/mcp_controller.rb

Overview

Rails controller for serving MCP over Streamable HTTP. Alternative to the Rack middleware - integrates with Rails routing, authentication, and middleware stack.

Mount in routes: mount RailsAiContext::Engine, at: "/mcp"

Defined Under Namespace

Classes: FlushableStream

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.mcp_transportObject

Class-level memoization - transport persists across requests. Thread-safe: MCP::Server and transport are stateless for reads.



84
85
86
87
88
89
90
91
# File 'app/controllers/rails_ai_context/mcp_controller.rb', line 84

def mcp_transport
  @transport_mutex.synchronize do
    @mcp_transport ||= begin
      server = RailsAiContext::Server.new(Rails.application, transport: :http).build
      MCP::Server::Transports::StreamableHTTPTransport.new(server)
    end
  end
end

.reset_transport!Object



93
94
95
# File 'app/controllers/rails_ai_context/mcp_controller.rb', line 93

def reset_transport!
  @transport_mutex.synchronize { @mcp_transport = nil }
end

Instance Method Details

#handleObject



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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
# File 'app/controllers/rails_ai_context/mcp_controller.rb', line 29

def handle
  status_code, rack_headers, body = self.class.mcp_transport.handle_request(request)
  self.status = status_code
  rack_headers.each { |k, v| response.headers[k] = v }

  if body.respond_to?(:each)
    # Plain enumerable body (initialize, errors, JSON mode): join to a
    # string so Content-Length/ETag semantics stay conventional.
    chunks = []
    body.each { |chunk| chunks << chunk }
    body.close if body.respond_to?(:close)
    self.response_body = chunks.join
  elsif body.respond_to?(:call)
    stream = response.stream
    stream = FlushableStream.new(stream) unless stream.respond_to?(:flush)
    begin
      body.call(stream)
      # A GET opens the server-push channel: the transport registers the
      # stream and returns, expecting it to outlive this call. Live closes
      # the response when the action returns, so hold the thread until the
      # transport's keepalive (or the client) closes the stream. Live also
      # sends headers only on the first write - the transport writes
      # nothing until its first keepalive ping, so commit with an SSE
      # comment up front or clients sit waiting on headers.
      if request.get?
        begin
          stream.write(": connected\n\n")
        rescue IOError
          nil
        end
        wait_for_stream_close
      end
    ensure
      begin
        response.stream.close
      rescue StandardError
        nil
      end
    end
  else
    self.response_body = body
  end
end