- Prereqs
- Tidewave gem already mounted (e.g.,
/tidewave/mcp on http://localhost:3100). - Rails server running locally on that port (
bin/rails s -p 3100or equivalent).
- Tidewave gem already mounted (e.g.,
- Install the shim globally
- Copy
tidewave_mcp_stdio_proxy.rbinto~/bin/tidewave-mcp-cli-bridge(or any directory on your$PATH). - Make it executable:
chmod +x ~/bin/tidewave-mcp-cli-bridge. - (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.
- Copy
- Configure Codex
-
Edit
~/.codex/config.tomland 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.tomland setCODEX_HOMEaccordingly.
-
- Run Codex
- Start Codex (
codexfrom 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.
- Start 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/listChangedevent Codex needs.
- 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
- 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.
- It talks only to
Last active
November 4, 2025 00:42
-
-
Save manewitz/7e626d0976ae801c37832ac93eab3c54 to your computer and use it in GitHub Desktop.
Using Tidewave MCP with Codex CLI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| [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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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