Class: Pgbus::MCP::RackApp

Inherits:
Object
  • Object
show all
Defined in:
lib/pgbus/mcp/rack_app.rb

Overview

A turnkey, gated Rack app that serves the read-only pgbus diagnostic MCP server over HTTP. Mount it inside your existing Rails app — no second process, same DB credentials, same connection pool:

# config/routes.rb
mount Pgbus::MCP.rack_app(token: ENV["PGBUS_MCP_TOKEN"]) => "/pgbus/mcp"

The underlying transport runs in stateless + JSON-response mode, which:

* makes every request a self-contained POST returning a single JSON
object (no in-memory session, no SSE stream, no reaper thread), so it
is safe behind multiple Puma/Falcon workers — any worker can answer
any request;
* is exactly what a read-only diagnostic server needs (it never pushes
server-initiated messages to the client).

Security: requests are rejected with 401 unless they carry the configured token (or pass the supplied auth callable). Run it on an internal network / behind your VPN, never internet-exposed.

DNS-rebinding protection: since mcp 0.23 the transport validates the Host header (loopback only, by default) and the Origin header (same-origin only). That defends a server bound to localhost against a browser page whose DNS name was re-pointed at 127.0.0.1 — a page that can carry no bearer token, because the secret never reaches the attacker's origin. So when this app is gated (+token+ or auth) the check is redundant, and left on it rejects every request to a real hostname (https://app.example.com/pgbus/mcp → 403 "Invalid Host header"). The default therefore follows the gate: off when gated, on when unauthenticated. Override with dns_rebinding_protection:, and widen the accepted hosts / origins with allowed_hosts: / allowed_origins: when the check is on.

Constant Summary collapse

BEARER_PREFIX =
"Bearer "
MIN_MCP_VERSION =

First mcp release with the transport's allowed_hosts / allowed_origins / dns_rebinding_protection options. Older gems would raise ArgumentError on the pass-through; fail with the fix spelled out instead.

Gem::Version.new("0.23.0")
UNAUTHORIZED_BODY =

Only the JSON body string is frozen and reused. The outer response triple and its headers hash MUST be built fresh per call (#unauthorized) so downstream Rack middleware can mutate them — Rack::TempfileReaper assigns response and Rack::ETag adds headers. Returning a frozen array/hash raised FrozenError → 500 instead of 401 (issue #304).

{ jsonrpc: "2.0", id: nil, error: { code: -32_001, message: "Unauthorized" } }.to_json.freeze

Instance Method Summary collapse

Constructor Details

#initialize(data_source: Pgbus::Web::DataSource.new, allow_payloads: false, token: nil, auth: nil, allowed_hosts: nil, allowed_origins: nil, dns_rebinding_protection: nil) ⇒ RackApp

Returns a new instance of RackApp.

Parameters:

  • data_source (Pgbus::Web::DataSource) (defaults to: Pgbus::Web::DataSource.new)

    read layer the tools query. Built once and shared: DataSource acquires Pgbus::BusRecord.connection from the ActiveRecord pool on each call, so a single instance is request-safe across threads/workers.

  • allow_payloads (Boolean) (defaults to: false)

    when true, tools honor a per-call include_payloads flag; otherwise message bodies are always redacted.

  • token (String, nil) (defaults to: nil)

    shared secret. When present, requests must send Authorization: Bearer <token>. nil disables the built-in gate.

  • auth (#call, nil) (defaults to: nil)

    custom authenticator taking a Rack::Request and returning truthy to allow. Mirrors Pgbus.configuration.web_auth. Takes precedence over token when both are given.

  • allowed_hosts (Array<String>, nil) (defaults to: nil)

    extra Host values the transport accepts beyond loopback when DNS-rebinding protection is on; a bare name matches any port, "host:port" matches exactly.

  • allowed_origins (Array<String>, nil) (defaults to: nil)

    extra Origin values accepted beyond same-origin when DNS-rebinding protection is on.

  • dns_rebinding_protection (Boolean, nil) (defaults to: nil)

    nil (default) = on only when the app is unauthenticated; true/false forces it. See the class docs for why the gate makes the check redundant.



67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/pgbus/mcp/rack_app.rb', line 67

def initialize(data_source: Pgbus::Web::DataSource.new, allow_payloads: false, token: nil, auth: nil,
               allowed_hosts: nil, allowed_origins: nil, dns_rebinding_protection: nil)
  check_mcp_version!
  @token = token
  @auth = auth
  @server = Server.build(data_source: data_source, allow_payloads: allow_payloads)
  @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
    @server,
    stateless: true, enable_json_response: true,
    allowed_hosts: allowed_hosts, allowed_origins: allowed_origins,
    dns_rebinding_protection: dns_rebinding_protection.nil? ? unauthenticated? : dns_rebinding_protection
  )
  warn_unauthenticated! if unauthenticated?
end

Instance Method Details

#call(env) ⇒ Object

Mount THIS object, never the bare transport. The auth gate lives here and runs before handle_request for every HTTP method (POST/GET/ DELETE/...). Mounting @transport directly would skip authorization — the gem's transport has no auth of its own.



86
87
88
89
90
91
# File 'lib/pgbus/mcp/rack_app.rb', line 86

def call(env)
  request = Rack::Request.new(env)
  return unauthorized unless authorized?(request)

  @transport.handle_request(request)
end