Module: OKF::MCP::HTTP
- Defined in:
- lib/okf/mcp/http.rb
Overview
Bridges the SDK's StreamableHTTPTransport — a plain Rack app — onto the WEBrick that okf already depends on, mirroring the kernel's server/runner pattern (rack and webrick both arrive via the okf gem). Stateless JSON mode: every POST is a self-contained JSON-RPC exchange answered with a single JSON object, so plain buffered responses hold — no SSE stream to keep open. The SDK validates Host/Origin against DNS rebinding for locally bound servers.
Constant Summary collapse
- LOOPBACK_HOSTS =
The Hosts the SDK's StreamableHTTPTransport admits without any extra allowlist — its DNS-rebinding protection accepts these out of the box.
%w[127.0.0.1 ::1 localhost].freeze
- WILDCARD_BINDS =
Binds that mean "every interface" rather than one address.
%w[0.0.0.0 :: *].freeze
- MAX_REQUEST_BYTES =
The largest request body the bridge will hand the transport, matching the SDK's own StreamableHTTPTransport default. Anything past it is 413 before it is allocated (see #read_body).
4 * 1024 * 1024
Class Method Summary collapse
- .allowed_hosts_for(bind, extra: []) ⇒ Object
-
.announce(httpd, bind:, out:) ⇒ Object
The Host allowlist a bind address needs: nil for loopback, which the SDK already admits.
-
.app_for(server, bind:, allowed_hosts: allowed_hosts_for(bind)) ⇒ Object
The SDK transport wired to the server, in stateless JSON mode.
-
.build(app, bind:, port:) ⇒ Object
Returns an unstarted server so tests can drive an ephemeral port.
- .env_for(request, body = request.body.to_s) ⇒ Object
- .handle(app, request, response) ⇒ Object
-
.local_hosts ⇒ Object
Every address this machine answers on, plus its hostname.
- .not_found(response) ⇒ Object
- .oversized(response) ⇒ Object
-
.prepare(server, bind:, port:, allow_hosts: [], out: $stderr) ⇒ Object
Everything before accepting — the bind (where EADDRINUSE, the boot failure that actually happens, raises), the traps and the boot line — split from #start so the CLI can run this under its boot rescue and file a bind failure as the usage error it is, while a mid-serve errno out of #start stays the crash it is.
-
.read_body(request) ⇒ Object
The request body, or nil when it exceeds MAX_REQUEST_BYTES.
-
.say(out, line) ⇒ Object
The boot line is diagnostics, and diagnostics are best-effort: stderr belongs to whoever spawned the process, and a collector that died must not take a bound, healthy server down with it.
Class Method Details
.allowed_hosts_for(bind, extra: []) ⇒ Object
104 105 106 107 108 109 110 |
# File 'lib/okf/mcp/http.rb', line 104 def allowed_hosts_for(bind, extra: []) extra = Array(extra).reject { |host| OKF.blank?(host) } return (extra.empty? ? nil : extra) if LOOPBACK_HOSTS.include?(bind) hosts = WILDCARD_BINDS.include?(bind.to_s) ? local_hosts : [ bind ] (hosts + extra).uniq end |
.announce(httpd, bind:, out:) ⇒ Object
The Host allowlist a bind address needs: nil for loopback, which the SDK already admits.
A wildcard bind is the case that was broken. --bind 0.0.0.0 says
"serve every interface", and allowlisting the literal string "0.0.0.0"
allowlists a Host header no client ever sends: the server bound
everything and answered every real request with 403. What a client
actually sends is the address it dialled, so a wildcard expands to this
machine's own addresses plus its hostname.
extra (the repeatable --allow-host) covers what cannot be derived: a
DNS name or a reverse proxy's Host, which no local interface knows.
The boot line, plus what a non-loopback bind actually means.
The Host allowlist below is a defence against DNS rebinding — a
browser walked into this port by a page the reader never meant to give
it to. It is not access control and must never be sold as one: a client
that is not a browser sets Host to whatever it likes, and there is no
authentication behind it. So binding anywhere but loopback publishes
every served bundle to anything that can reach the port, and the boot
line says so rather than reading like a URL somebody can safely share.
Loopback stays quiet: it is the default and the posture the tool was built for, and a warning printed every time is a warning nobody reads.
82 83 84 85 86 87 88 89 90 91 92 |
# File 'lib/okf/mcp/http.rb', line 82 def announce(httpd, bind:, out:) # The listener's own port, not the asked-for one: with --port 0 the OS # picks, and a boot line reporting 0 would name a port nothing answers. bound = httpd.listeners.first.addr[1] say(out, "okf-mcp listening on http://#{bind}:#{bound}") return if LOOPBACK_HOSTS.include?(bind.to_s) say(out, "okf-mcp: WARNING — #{bind} is not loopback. Every served bundle is") say(out, " readable by anything that can reach this port, with no authentication. The Host") say(out, " allowlist only stops browser DNS rebinding; it is not access control.") end |
.app_for(server, bind:, allowed_hosts: allowed_hosts_for(bind)) ⇒ Object
The SDK transport wired to the server, in stateless JSON mode. A non-loopback bind (e.g. 0.0.0.0) is refused by the SDK's DNS-rebinding guard unless its Host is allowlisted; loopback binds keep the SDK defaults. Protection itself stays on either way.
50 51 52 53 54 55 56 |
# File 'lib/okf/mcp/http.rb', line 50 def app_for(server, bind:, allowed_hosts: allowed_hosts_for(bind)) = { stateless: true, enable_json_response: true, max_request_bytes: MAX_REQUEST_BYTES } [:allowed_hosts] = allowed_hosts if allowed_hosts && !allowed_hosts.empty? app = ::MCP::Server::Transports::StreamableHTTPTransport.new(server, **) server.transport = app app end |
.build(app, bind:, port:) ⇒ Object
Returns an unstarted server so tests can drive an ephemeral port.
124 125 126 127 128 129 130 131 132 133 |
# File 'lib/okf/mcp/http.rb', line 124 def build(app, bind:, port:) httpd = WEBrick::HTTPServer.new( BindAddress: bind, Port: port, Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN), AccessLog: [] ) httpd.mount_proc("/") { |request, response| handle(app, request, response) } httpd end |
.env_for(request, body = request.body.to_s) ⇒ Object
202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 |
# File 'lib/okf/mcp/http.rb', line 202 def env_for(request, body = request.body.to_s) env = { "REQUEST_METHOD" => request.request_method, "SCRIPT_NAME" => "", "PATH_INFO" => request.path, "QUERY_STRING" => request.query_string.to_s, "SERVER_NAME" => request.host.to_s, "SERVER_PORT" => request.port.to_s, "rack.url_scheme" => "http", "rack.input" => StringIO.new(body.to_s), "rack.errors" => $stderr } request.each do |name, value| key = name.upcase.tr("-", "_") key = "HTTP_#{key}" unless %w[CONTENT_TYPE CONTENT_LENGTH].include?(key) env[key] = value end env end |
.handle(app, request, response) ⇒ Object
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 |
# File 'lib/okf/mcp/http.rb', line 135 def handle(app, request, response) # The MCP endpoint is the root and nothing else. The SDK transport # routes on method alone, so handing it every path answered the OAuth # discovery probes a connecting host sends first (GET /.well-known/*, # POST /register — Claude Desktop does) with a 405 and a 200-wrapped # JSON-RPC parse error: a *broken* sign-in service instead of an # absent one, and the host refused the connector on it. 404 is the # answer that reads as absence. return not_found(response) unless request.path == "/" # The cap is enforced *here*, before the body is materialized. The SDK # transport has its own `max_request_bytes` and never reads more than # that off `rack.input` — but WEBrick's `request.body` has no limit, so # handing the transport a StringIO of it meant the allocation the cap # exists to prevent had already happened. A few concurrent 2 GB POSTs # would OOM the warm shared process that `--http` exists to provide. body = read_body(request) return oversized(response) if body.nil? status, headers, out = app.call(env_for(request, body)) response.status = status headers.each { |name, value| response[name] = value } buffer = String.new out.each { |chunk| buffer << chunk } response.body = buffer ensure out.close if out.respond_to?(:close) end |
.local_hosts ⇒ Object
Every address this machine answers on, plus its hostname. Loopback is left out only because the SDK admits it already.
114 115 116 117 118 119 120 121 |
# File 'lib/okf/mcp/http.rb', line 114 def local_hosts addresses = Socket.ip_address_list.reject(&:ipv4_loopback?).reject(&:ipv6_loopback?) names = addresses.map(&:ip_address) names << Socket.gethostname.to_s names.reject { |name| OKF.blank?(name) }.uniq rescue SocketError, SystemCallError [] end |
.not_found(response) ⇒ Object
187 188 189 190 191 |
# File 'lib/okf/mcp/http.rb', line 187 def not_found(response) response.status = 404 response["Content-Type"] = "application/json" response.body = JSON.generate(error: "not found: the MCP endpoint is /") end |
.oversized(response) ⇒ Object
193 194 195 196 197 198 199 200 |
# File 'lib/okf/mcp/http.rb', line 193 def oversized(response) response.status = 413 response["Content-Type"] = "application/json" response.body = JSON.generate( jsonrpc: "2.0", id: nil, error: { code: -32_600, message: "Request body exceeds #{MAX_REQUEST_BYTES} bytes" } ) end |
.prepare(server, bind:, port:, allow_hosts: [], out: $stderr) ⇒ Object
Everything before accepting — the bind (where EADDRINUSE, the boot failure that actually happens, raises), the traps and the boot line — split from #start so the CLI can run this under its boot rescue and file a bind failure as the usage error it is, while a mid-serve errno out of #start stays the crash it is.
38 39 40 41 42 43 44 |
# File 'lib/okf/mcp/http.rb', line 38 def prepare(server, bind:, port:, allow_hosts: [], out: $stderr) app = app_for(server, bind: bind, allowed_hosts: allowed_hosts_for(bind, extra: allow_hosts)) httpd = build(app, bind: bind, port: port) %w[INT TERM].each { |signal| trap(signal) { httpd.shutdown } } announce(httpd, bind: bind, out: out) httpd end |
.read_body(request) ⇒ Object
The request body, or nil when it exceeds MAX_REQUEST_BYTES. A declared Content-Length past the cap is refused without reading a byte; an undeclared (chunked) body is streamed and abandoned the moment it grows past it.
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/okf/mcp/http.rb', line 168 def read_body(request) # The raw header, not WEBrick's #content_length: that helper is # `Integer(self["content-length"])`, which raises TypeError on a # chunked request that legitimately carries no Content-Length at all. declared = request["content-length"] return nil if declared && declared.to_i > MAX_REQUEST_BYTES # Always the block form: `request.body` with no block materializes the # whole entity body, which for an undeclared (chunked) request is the # very allocation this method exists to bound. With a block WEBrick # streams, and a request carrying no body simply never yields. buffer = String.new request.body do |chunk| buffer << chunk return nil if buffer.bytesize > MAX_REQUEST_BYTES end buffer end |
.say(out, line) ⇒ Object
The boot line is diagnostics, and diagnostics are best-effort: stderr belongs to whoever spawned the process, and a collector that died must not take a bound, healthy server down with it. An EPIPE here is lost output, not a lost server.
98 99 100 101 102 |
# File 'lib/okf/mcp/http.rb', line 98 def say(out, line) out.puts(line) rescue Errno::EPIPE, Errno::ECONNRESET nil end |