Class: RailsAiContext::McpController
- Inherits:
-
ActionController::API
- Object
- ActionController::API
- RailsAiContext::McpController
- 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
-
.mcp_transport ⇒ Object
Class-level memoization - transport persists across requests.
- .reset_transport! ⇒ Object
Instance Method Summary collapse
Class Method Details
.mcp_transport ⇒ Object
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
#handle ⇒ Object
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 |