Class: SolidLoop::Mcp::Server

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

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)

  authorized = @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 unauthorized unless authorized
  principal = Principal.wrap(authorized)

  if request.request_method == "DELETE"
    handle_delete(request, principal)
  else
    handle_post(request, principal)
  end
end