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
endResult. 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. Preferconnect_timeout:for the dial andwait_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.