Skip to content

Instantly share code, notes, and snippets.

@manewitz
Last active November 4, 2025 00:42
Show Gist options
  • Select an option

  • Save manewitz/7e626d0976ae801c37832ac93eab3c54 to your computer and use it in GitHub Desktop.

Select an option

Save manewitz/7e626d0976ae801c37832ac93eab3c54 to your computer and use it in GitHub Desktop.
Using Tidewave MCP with Codex CLI
  • Prereqs
    • Tidewave gem already mounted (e.g., /tidewave/mcp on http://localhost:3100).
    • Rails server running locally on that port (bin/rails s -p 3100 or equivalent).
  • Install the shim globally
    1. Copy tidewave_mcp_stdio_proxy.rb into ~/bin/tidewave-mcp-cli-bridge (or any directory on your $PATH).
    2. Make it executable: chmod +x ~/bin/tidewave-mcp-cli-bridge.
    3. (Optional) If you need request logging, export TIDEWAVE_MCP_PROXY_LOG=/path/to/logfile.log. Leave it unset or set to off to disable logging.
  • Configure Codex
    • Edit ~/.codex/config.toml and add:

      [mcp_servers.tidewave]
      command = "/Users/you/bin/tidewave-mcp-cli-bridge"
      args = ["http://localhost:3100/tidewave/mcp"]
      startup_timeout_sec = 20
      tool_timeout_sec = 60
    • If you prefer per-project config, drop the same block into <project>/.codex/config.toml and set CODEX_HOME accordingly.

  • Run Codex
    • Start Codex (codex from the repo root works best so it sees local trust settings).
    • Enter /mcp; you should now see Tidewave’s tools listed.
    • Use tools normally (project_eval, execute_sql_query, etc.). Responses should match the behavior from when mcp-proxy used to work with Codex.
  • Why the shim?
    • Tidewave 0.4.x moved from SSE to POST-only streamable HTTP. Tidewave’s Rust mcp-proxy still assumes SSE and crashes inside Codex’s sandbox (macOS dynamic-store call). The shim bridges Codex’s STDIO client to Tidewave’s POST endpoint and emits the notifications/tools/listChanged event Codex needs.
  • Security notes
    • It talks only to http://localhost:.... If you point it elsewhere, do so on a trusted network.
    • Logging is opt-in; enable it only if you need the request history.
    • Keep the script versioned (gist or repo) so you can track updates if Tidewave changes its transport.
[mcp_servers.tidewave]
command = "/path/to/tidewave-mcp-cli-bridge"
args = ["http://localhost:3100/tidewave/mcp"]
startup_timeout_sec = 20.0
tool_timeout_sec = 60.0
#!/usr/bin/env ruby
# frozen_string_literal: true
require "json"
require "net/http"
require "time"
require "uri"
#
# Tidewave MCP STDIO shim
# Bridges Codex' STDIO client to Tidewave's POST-only MCP transport.
#
# Usage:
# tidewave_mcp_stdio_proxy <url>
# TIDEWAVE_MCP_URL=http://localhost:3100/tidewave/mcp tidewave_mcp_stdio_proxy
#
# Environment variables:
# TIDEWAVE_MCP_URL Optional alternative to the positional URL argument.
# TIDEWAVE_MCP_PROXY_LOG Absolute or relative path for request logs. Set to "off"
# (or leave unset) to disable logging, or point to a file
# such as "./log/tidewave_mcp_proxy.log" to enable it.
#
class TidewaveMcpStdioProxy
PROXY_ERROR = {
"jsonrpc" => "2.0",
"error" => {
"code" => -32098,
"message" => "Proxy error: HTTP request failed"
},
"id" => nil
}.freeze
def initialize(uri, log_path: self.class.fetch_log_path)
@uri = URI(uri)
@log_path = log_path
@http = nil
@pending_tools_notification = false
@tools_notified = false
end
def run
STDOUT.sync = true
each_request do |payload|
message = parse_json(payload)
response = post(payload)
unless response.is_a?(Net::HTTPSuccess)
handle_http_error(response, message)
next
end
handle_response(message, response.body)
end
ensure
finish_http
end
private
def each_request
STDIN.each_line do |line|
payload = line.strip
next if payload.empty?
log("→ #{payload}")
yield(payload)
end
end
def post(body)
request = Net::HTTP::Post.new(@uri)
request["Content-Type"] = "application/json"
request.body = body
http.request(request)
rescue IOError, SystemCallError, Timeout::Error
reconnect_http
retry
end
def http
@http ||= Net::HTTP.start(
@uri.host,
@uri.port,
use_ssl: @uri.scheme == "https",
open_timeout: 5,
read_timeout: 300
)
end
def reconnect_http
finish_http
@http = Net::HTTP.start(
@uri.host,
@uri.port,
use_ssl: @uri.scheme == "https",
open_timeout: 5,
read_timeout: 300
)
end
def finish_http
@http&.finish if @http.respond_to?(:active?) && @http.active?
rescue IOError
# Connection already closed.
ensure
@http = nil
end
def parse_json(payload)
JSON.parse(payload)
rescue JSON::ParserError
nil
end
def handle_http_error(response, message)
warn "[tidewave_mcp_stdio_proxy] HTTP #{response.code}: #{response.body}"
log("← HTTP #{response.code}: #{response.body}")
error_response = PROXY_ERROR.dup
error_response["id"] = message["id"] if message.is_a?(Hash) && message.key?("id")
STDOUT.puts(JSON.generate(error_response))
STDOUT.flush
end
def handle_response(message, body)
message_hash = message.is_a?(Hash) ? message : nil
track_handshake_state(message_hash)
if message_hash&.key?("id")
STDOUT.puts(body)
log("← #{body}")
STDOUT.flush
else
log("← (notification ack) #{body}")
end
end
def track_handshake_state(message)
return unless message
case message["method"]
when "initialize"
@pending_tools_notification = true
@tools_notified = false
when "notifications/initialized"
send_tools_notification if @pending_tools_notification && !@tools_notified
@pending_tools_notification = false
end
end
def send_tools_notification
request_id = -(Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
payload = JSON.generate(
"jsonrpc" => "2.0",
"id" => request_id,
"method" => "tools/list"
)
log("→ (proxy) #{payload}")
response = post(payload)
unless response.is_a?(Net::HTTPSuccess)
log("← (proxy) HTTP #{response.code}: #{response.body}")
return
end
log("← (proxy) #{response.body}")
tools = extract_tools(response.body)
return unless tools
notification = JSON.generate(
"jsonrpc" => "2.0",
"method" => "notifications/tools/listChanged",
"params" => { "tools" => tools }
)
STDOUT.puts(notification)
STDOUT.flush
log("→ (notify) #{notification}")
@tools_notified = true
end
def extract_tools(body)
parsed = JSON.parse(body)
tools = parsed.dig("result", "tools")
tools.is_a?(Array) ? tools : nil
rescue JSON::ParserError => e
log("tool notification parse error: #{e.message}")
nil
end
def log(message)
return unless @log_path
File.open(@log_path, "a") do |file|
file.puts("#{Time.now.utc.iso8601} #{message}")
end
rescue IOError, SystemCallError
# Best-effort logging only.
end
def self.fetch_log_path
value = ENV["TIDEWAVE_MCP_PROXY_LOG"]
return nil if value&.strip&.downcase&.start_with?("off")
return nil if value == ""
return File.expand_path(value) if value
nil
end
end
url = ARGV[0] || ENV["TIDEWAVE_MCP_URL"]
if url.nil? || url.strip.empty?
warn "Usage: tidewave_mcp_stdio_proxy <url>"
exit 1
end
proxy = TidewaveMcpStdioProxy.new(url)
proxy.run
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment