Module: DebugMcp::TcpSessionDiscovery

Defined in:
lib/debug_mcp/tcp_session_discovery.rb

Class Method Summary collapse

Class Method Details

.container_web_ports(debug_port, host: "localhost") ⇒ Object

Find web server host ports for a Docker container identified by its debug port. Reverse-looks up which container has RUBY_DEBUG_PORT matching debug_port, then returns all other host-mapped ports that are TCP-connectable. Returns an array of port numbers (e.g., [3000]) or [] if not found.



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/debug_mcp/tcp_session_discovery.rb', line 76

def container_web_ports(debug_port, host: "localhost")
  return [] unless docker_available?

  container_ids = `docker ps -q 2>/dev/null`.strip.split("\n")
  container_ids.each do |id|
    json_str = `docker inspect #{id} 2>/dev/null`
    data = JSON.parse(json_str)
    container = data[0]
    next unless container

    # Check if this container has the matching debug port
    env_list = container.dig("Config", "Env") || []
    container_debug_port = env_list
      .find { |e| e.start_with?("RUBY_DEBUG_PORT=") }
      &.split("=", 2)&.last&.to_i
    next unless container_debug_port == debug_port

    # Extract all host-mapped ports except the debug port itself
    port_bindings = container.dig("HostConfig", "PortBindings") || {}
    web_ports = []
    port_bindings.each do |key, bindings|
      container_port = key.split("/").first.to_i
      next if container_port == debug_port
      next unless bindings.is_a?(Array) && !bindings.empty?

      host_port = bindings[0]["HostPort"]&.to_i
      next unless host_port&.positive?
      next unless tcp_connectable?(host, host_port, timeout: 1)

      web_ports << host_port
    end

    return web_ports.sort
  end

  []
rescue StandardError
  []
end

.discoverObject

Discover TCP debug sessions from Docker containers and local processes. Returns array of { host:, port:, name:, source: } hashes.



12
13
14
15
16
# File 'lib/debug_mcp/tcp_session_discovery.rb', line 12

def discover
  (docker_sessions + local_tcp_sessions).uniq { |s| [s[:host], s[:port]] }
rescue StandardError
  []
end

.docker_available?Boolean

— Private helpers —

Returns:

  • (Boolean)


118
119
120
121
122
# File 'lib/debug_mcp/tcp_session_discovery.rb', line 118

def docker_available?
  system("docker", "info", out: File::NULL, err: File::NULL)
rescue StandardError
  false
end

.docker_sessionsObject

Discover debug sessions from Docker containers with RUBY_DEBUG_PORT.



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/debug_mcp/tcp_session_discovery.rb', line 19

def docker_sessions
  return [] unless docker_available?

  container_ids = `docker ps -q 2>/dev/null`.strip.split("\n")
  return [] if container_ids.empty?

  sessions = []
  container_ids.each do |id|
    session = inspect_container(id)
    sessions << session if session
  end
  sessions
rescue StandardError
  []
end

.inspect_container(container_id) ⇒ Object



124
125
126
127
128
129
130
131
132
133
134
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
# File 'lib/debug_mcp/tcp_session_discovery.rb', line 124

def inspect_container(container_id)
  json_str = `docker inspect #{container_id} 2>/dev/null`
  return nil if json_str.strip.empty?

  data = JSON.parse(json_str)
  container = data[0]
  return nil unless container

  env_list = container.dig("Config", "Env") || []
  debug_port = nil
  debug_host = nil

  env_list.each do |env|
    key, value = env.split("=", 2)
    case key
    when "RUBY_DEBUG_PORT"
      debug_port = value&.to_i
    when "RUBY_DEBUG_HOST"
      debug_host = value
    end
  end
  return nil unless debug_port

  host_port = resolve_host_port(container, debug_port)
  return nil unless host_port

  host, port = host_port
  return nil unless tcp_connectable?(host, port)

  name = container.fetch("Name", "").sub(%r{\A/}, "")
  name = container_id[0, 12] if name.empty?

  { host: host, port: port, name: name, source: :docker }
rescue StandardError
  nil
end

.inspect_local_process(environ_path) ⇒ Object



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/debug_mcp/tcp_session_discovery.rb', line 184

def inspect_local_process(environ_path)
  environ = File.read(environ_path)
  envs = environ.split("\0")

  debug_port = nil
  envs.each do |env|
    key, value = env.split("=", 2)
    if key == "RUBY_DEBUG_PORT"
      debug_port = value&.to_i
      break
    end
  end
  return nil unless debug_port

  pid = environ_path[%r{/proc/(\d+)/}, 1]
  return nil unless pid

  # Skip if this process is ourselves
  return nil if pid.to_i == Process.pid

  host = "localhost"
  return nil unless tcp_connectable?(host, debug_port)

  name = process_name(pid)
  { host: host, port: debug_port, name: name, source: :local }
rescue StandardError
  nil
end

.local_tcp_sessionsObject

Discover debug sessions from local processes with RUBY_DEBUG_PORT in /proc/*/environ.



36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/debug_mcp/tcp_session_discovery.rb', line 36

def local_tcp_sessions
  return [] unless File.directory?("/proc")

  sessions = []
  Dir.glob("/proc/[0-9]*/environ").each do |environ_path|
    session = inspect_local_process(environ_path)
    sessions << session if session
  rescue Errno::EACCES, Errno::ENOENT
    next
  end
  sessions
rescue StandardError
  []
end

.process_name(pid) ⇒ Object



213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/debug_mcp/tcp_session_discovery.rb', line 213

def process_name(pid)
  cmdline = File.read("/proc/#{pid}/cmdline").split("\0")
  # Find the Ruby script name from the command line
  ruby_idx = cmdline.index { |arg| arg.match?(/ruby|rails|rdbg|bundle/) }
  if ruby_idx && cmdline[ruby_idx + 1]
    File.basename(cmdline[ruby_idx + 1])
  else
    "pid-#{pid}"
  end
rescue StandardError
  "pid-#{pid}"
end

.resolve_host_port(container, container_port) ⇒ Object



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/debug_mcp/tcp_session_discovery.rb', line 161

def resolve_host_port(container, container_port)
  port_bindings = container.dig("HostConfig", "PortBindings") || {}

  # Look for a binding matching the debug port (e.g., "12345/tcp")
  binding_key = port_bindings.keys.find { |k| k.start_with?("#{container_port}/") }
  if binding_key
    bindings = port_bindings[binding_key]
    if bindings.is_a?(Array) && !bindings.empty?
      host_port = bindings[0]["HostPort"]&.to_i
      return ["localhost", host_port] if host_port && host_port > 0
    end
  end

  # Fallback: NetworkSettings — use container IP directly
  networks = container.dig("NetworkSettings", "Networks") || {}
  networks.each_value do |net|
    ip = net["IPAddress"]
    return [ip, container_port] if ip && !ip.empty?
  end

  nil
end

.tcp_connectable?(host, port, timeout: 2) ⇒ Boolean

Check if a TCP host:port is connectable.

Returns:

  • (Boolean)


52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/debug_mcp/tcp_session_discovery.rb', line 52

def tcp_connectable?(host, port, timeout: 2)
  addr = Socket.getaddrinfo(host, nil, nil, :STREAM)
  sockaddr = Socket.sockaddr_in(port, addr[0][3])
  socket = Socket.new(Socket.const_get(addr[0][0]), Socket::SOCK_STREAM, 0)

  begin
    socket.connect_nonblock(sockaddr)
  rescue IO::WaitWritable
    IO.select(nil, [socket], nil, timeout) ? socket.connect_nonblock(sockaddr) : (return false)
  rescue Errno::EISCONN
    # Already connected — success
  end

  true
rescue StandardError
  false
ensure
  socket&.close
end