mcp_authorization v0.7.1

20. Talk to a TCP socket from a tool

A tool should open a raw TCP connection to a service, send a probe, read the reply, and report latency โ€” without hanging the request thread or becoming an SS...

Problem. A tool should open a raw TCP connection to a service, send a probe, read the reply, and report latency โ€” without hanging the request thread or becoming an SSRF hole.

Solution. Use Socket.tcp with a connect_timeout:, gate the read with wait_readable, and allowlist the destination before you connect. Always close in an ensure. Gate the whole tool behind a permission, because "open an arbitrary TCP connection from inside our network" is a capability, not a convenience.

# app/service/ops/probe_service.rb
require "socket"

module Ops
  class ProbeService
    # @rbs import error

    include McpAuthorization::DSL

    # @rbs type success = {
    #   success: true,
    #   host: String,
    #   port: Integer,
    #   banner: String,
    #   latency_ms: Integer
    # }

    # @rbs type output = success | error

    # Only these hosts may be probed. The LLM picks the host; the allowlist
    # decides whether we honor it. Never skip this for an LLM-supplied address.
    ALLOWED_HOSTS = %w[redis.internal cache.internal queue.internal].freeze
    CONNECT_TIMEOUT = 3 # seconds
    READ_TIMEOUT    = 3 # seconds

    def description
      "Open a TCP connection to an allowlisted internal service and read its banner."
    end

    #: (
    #:   host: String    @desc(Service hostname; must be on the allowlist),
    #:   port: Integer   @min(1) @max(65535),
    #:   ?probe: String? @max(256) @default(PING)
    #: ) -> Hash[Symbol, untyped]
    def call(host:, port:, probe: "PING")
      return rejected(host) unless ALLOWED_HOSTS.include?(host)

      started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      socket  = Socket.tcp(host, port, connect_timeout: CONNECT_TIMEOUT)
      begin
        socket.write("#{probe}\r\n")

        # Bound the blocking read. wait_readable returns nil on timeout.
        unless socket.wait_readable(READ_TIMEOUT)
          return connection_error(host, port, "read timed out after #{READ_TIMEOUT}s")
        end
        banner = socket.gets.to_s.strip

        elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
        { success: true, host: host, port: port, banner: banner, latency_ms: elapsed }
      ensure
        socket.close
      end
    rescue Errno::ETIMEDOUT
      connection_error(host, port, "connect timed out after #{CONNECT_TIMEOUT}s")
    rescue SystemCallError => e # ECONNREFUSED, EHOSTUNREACH, ECONNRESET, ...
      connection_error(host, port, e.message)
    end

    private

    def rejected(host)
      { success: false, error: { code: "host_not_allowed", message: "#{host} is not probeable",
                                 hint: "Allowed hosts: #{ALLOWED_HOSTS.join(', ')}." } }
    end

    def connection_error(host, port, detail)
      { success: false, error: { code: "connection_failed",
                                 message: "Could not reach #{host}:#{port} โ€” #{detail}",
                                 hint: "Verify the service is up and the port is correct." } }
    end
  end
end
# app/mcp/ops/probe_service_tool.rb
module Ops
  class ProbeServiceTool < McpAuthorization::Tool
    tool_name "probe_service"
    authorization :ops_diagnostics   # capability gate โ€” not everyone gets a raw socket
    open_world!                      # honest hint: this touches external services
    tags "operations"
    dynamic_contract Ops::ProbeService
  end
end

Result. Three timeouts that matter are all handled โ€” connect (connect_timeout:), read (wait_readable), and the ensure socket.close that runs on every path including the early return. The allowlist check happens before Socket.tcp, so an LLM that asks to probe 169.254.169.254 (the cloud metadata endpoint) gets a structured host_not_allowed error instead of an SSRF. The connection is opened and closed entirely within the call, matching the stateless transport.

Why not Timeout.timeout? It raises asynchronously from a separate thread and can fire while a socket is mid-syscall, leaving connections in a half-open state. Prefer connect_timeout: for the dial and wait_readable(seconds) for the read โ€” they're cooperative and don't interrupt I/O at an arbitrary point.

Collected from COOKBOOK.md in the repository. Edit it there, not here.