Class: SolidLoop::Mcp::Server
- Inherits:
-
Object
- Object
- SolidLoop::Mcp::Server
- Defined in:
- lib/solid_loop/mcp/server.rb
Overview
Mountable Rack endpoint serving ONE toolset to external MCP clients over Streamable HTTP — the mirror image of HttpTransport over the same port. The POST body goes straight into Toolset#deliver, the exact method the in-process agent path calls, so local and remote behavior cannot drift. See docs/decisions/mcp-server.md.
# config/routes.rb (host app)
mount SolidLoop::Mcp.server(RpcTools, auth: McpAuth.new) => "/rpc"
Fail-closed: auth: is required at construction. The authorizer is any object responding to call(token, request) -> principal | nil; nil means 401 with an empty body. The returned principal is normalized via Mcp::Principal.wrap (a bare value becomes both key and label; an object with #key/#label separates identity from display) and the raw value becomes CallContext#principal for every tool handler.
Principal binding: the session row records the principal's stable KEY and every in-session POST/DELETE lookup is scoped to (mount, session_id, principal_key). A different valid principal who obtains another caller's session_id therefore cannot use or terminate it (404, as for any unknown session). Two mounts of the same toolset class are separated by mount:.
Sessions: every initialize creates a McpInboundSession row (honoring the toolset's on_initialize minting, else a UUID) returned in Mcp-Session-Id. Every other request must carry that header — 400 without it, 404 for an unknown or terminated session. DELETE terminates the session but keeps the row: a client must not be able to erase its own audit trail. Every request that reaches a session writes a SolidLoop::Event (loop-less, eventable = the session) with the JSON-RPC envelopes and a wire log.
Protocol scope is deliberately narrow (see the decision doc): single JSON-RPC request per POST, notifications -> 202, no SSE (GET -> 405), no batching (-32600). Errors raised inside tool handlers become isError results in Toolset#deliver; anything else propagates — internal bugs must surface, not dissolve into JSON-RPC envelopes.
Constant Summary collapse
- ALLOWED_METHODS =
%w[POST DELETE].freeze
Instance Method Summary collapse
- #call(env) ⇒ Object
-
#initialize(toolset, auth:, mount: nil) ⇒ Server
constructor
A new instance of Server.
Constructor Details
#initialize(toolset, auth:, mount: nil) ⇒ Server
Returns a new instance of Server.
41 42 43 44 45 46 47 48 49 50 51 |
# File 'lib/solid_loop/mcp/server.rb', line 41 def initialize(toolset, auth:, mount: nil) unless auth.respond_to?(:call) raise ArgumentError, "SolidLoop::Mcp.server requires auth: responding to " \ "call(token, request) -> principal | nil (see docs/decisions/mcp-server.md)" end @toolset = toolset.is_a?(Class) ? toolset.new : toolset @auth = auth @mount = mount&.to_s end |
Instance Method Details
#call(env) ⇒ Object
53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/solid_loop/mcp/server.rb', line 53 def call(env) request = ActionDispatch::Request.new(env) # 405 before auth: it reveals nothing and keeps method probes away # from the authorizer. Everything else authenticates first. return method_not_allowed unless ALLOWED_METHODS.include?(request.request_method) = @auth.call(bearer_token(request), request) # Deny on any falsey return, not just nil: the contract is truthy principal # => allow. A contract-violating `false` must never authenticate (it would # otherwise mint a "false"-keyed session shared by every such caller). return unless principal = Principal.wrap() if request.request_method == "DELETE" handle_delete(request, principal) else handle_post(request, principal) end end |