Packages

An escript for connecting STDIO based MCP clients to HTTP (SSE) based MCP servers.

Current section

Files

Jump to
mcp_proxy lib mcp_proxy.ex
Raw

lib/mcp_proxy.ex

defmodule McpProxy do
@moduledoc false
require Logger
@doc false
def main(args) do
{opts, args} = OptionParser.parse!(args, strict: [debug: :boolean])
base_url =
case args do
[arg_url] ->
arg_url
[] ->
System.get_env("SSE_URL") ||
raise "either the URL is passed as first argument or the SSE_URL environment variable must be set"
many ->
raise "expected one or zero arguments, got: #{inspect(many)}"
end
debug = Keyword.get(opts, :debug, false)
if debug do
Logger.configure(level: :debug)
Logger.debug("Starting MCP wrapper script with base URL: #{base_url}")
end
Application.ensure_all_started(:req)
# Connect to SSE endpoint and get the message endpoint, then loop and process stdin
case connect_to_sse(base_url, debug) do
{:ok, endpoint, sse_pid} ->
if debug, do: Logger.debug("Received endpoint: #{endpoint}")
# Monitor the SSE process to detect when it dies
sse_ref = Process.monitor(sse_pid)
# Start the stdio loop with the received endpoint
process_stdio(endpoint, debug, sse_ref)
{:error, reason} ->
Logger.error("Failed to connect to SSE endpoint: #{inspect(reason)}")
System.halt(1)
end
end
defp connect_to_sse(sse_url, debug) do
if debug, do: Logger.debug("Connecting to SSE endpoint: #{sse_url}")
parent = self()
# Spawn a process to handle the SSE connection
pid =
spawn_link(fn ->
try do
# Use Req.get! with into function to process the streaming response
Req.get!(
sse_url,
headers: [{"accept", "text/event-stream"}],
into: fn {:data, chunk}, {req, resp} ->
# Process each chunk of data
process_sse_chunk(chunk, parent, debug)
# Return {:cont, {req, resp}} to continue streaming
{:cont, {req, resp}}
end,
receive_timeout: :infinity,
retry: false
)
rescue
e ->
Logger.error("Error in SSE connection: #{inspect(e)}")
System.stop(1)
end
Logger.info("SSE connection closed. Exiting.")
System.stop(1)
end)
# Wait for the endpoint URL
receive do
{:endpoint, endpoint} ->
{:ok, endpoint, pid}
after
10_000 ->
{:error, "Timeout waiting for endpoint URL"}
end
end
defp process_sse_chunk(chunk, parent, debug) do
if debug, do: Logger.debug("Received SSE chunk: #{inspect(chunk)}")
# Split the chunk into lines and process each event
chunk
|> String.split("\n\n", trim: true)
|> Enum.each(fn event_data ->
case parse_sse_event(event_data) do
{:ok, "endpoint", endpoint} ->
if debug, do: Logger.debug("Found endpoint: #{endpoint}")
send(parent, {:endpoint, endpoint})
{:ok, event_type, data} ->
if debug, do: Logger.debug("Received SSE event: #{event_type}, data: #{data}")
IO.write(:stdio, [data, ?\n])
{:error, error} ->
if debug, do: Logger.debug("Error parsing SSE event: #{inspect(error)}")
end
end)
end
defp parse_sse_event(data) do
lines = String.split(data, "\n", trim: true)
event_type =
lines
|> Enum.find(fn line -> String.starts_with?(line, "event:") end)
|> case do
nil -> "message"
line -> String.trim(String.replace_prefix(line, "event:", ""))
end
data_line =
lines
|> Enum.find(fn line -> String.starts_with?(line, "data:") end)
|> case do
nil -> nil
line -> String.trim(String.replace_prefix(line, "data:", ""))
end
case data_line do
nil -> {:error, "No data found in SSE event"}
data -> {:ok, event_type, data}
end
end
defp process_stdio(endpoint, debug, sse_ref) do
receive do
{:DOWN, ^sse_ref, :process, _pid, reason} ->
if debug, do: Logger.debug("SSE connection process terminated: #{inspect(reason)}")
Logger.info("SSE connection closed, exiting")
System.halt(0)
after
0 -> :ok
end
case IO.read(:stdio, :line) do
:eof ->
# End of input, exit
if debug, do: Logger.debug("Stdin closed (EOF), exiting")
System.halt(0)
{:error, reason} ->
Logger.error("Error reading from stdin: #{inspect(reason)}")
System.halt(1)
line ->
if debug, do: Logger.debug("Received input: #{inspect(line)}")
# forward a POST to the message endpoint
forward_request(endpoint, line, debug)
process_stdio(endpoint, debug, sse_ref)
end
end
defp forward_request(endpoint, request, debug) do
if debug, do: Logger.debug("Sending request to: #{endpoint}")
req =
Req.new(
url: endpoint,
method: :post,
body: request,
headers: [
{"accept", "application/json"},
{"content-type", "application/json"}
],
receive_timeout: :infinity,
retry: false
)
result = Req.request(req)
if debug, do: Logger.debug("Response: #{inspect(result)}")
case result do
{:ok, %{status: 200, body: body}} ->
body
{:ok, %{status: _status, body: body}} when is_map(body) ->
body
{:ok, %{status: status, body: body}} ->
if debug, do: Logger.debug("Unexpected response body: #{inspect(body)}")
%{
jsonrpc: "2.0",
error: %{
code: -32603,
message: "Internal error",
data: %{
details: "Server responded with status #{status}",
body: body
}
}
}
{:error, exception} ->
# Handle all error cases
%{
jsonrpc: "2.0",
error: %{
code: -32603,
message: "Internal error",
data: %{
details: "Error connecting to server: #{inspect(exception)}"
}
}
}
end
end
end