MCPEx.Transport.Http (MCPEx v0.1.0)

Implementation of the MCP transport over HTTP with Server-Sent Events (SSE).

This transport follows the Model Context Protocol (MCP) HTTP transport specification:

  1. SSE Connection: The client connects to an SSE endpoint (e.g., https://example.com/sse) to receive server events
  2. Message Endpoint Notification: The server sends a special endpoint event via SSE with the following format:
    event: endpoint
    data: /messages?sessionId=abc123
  3. Message Endpoint Construction: The client constructs the full message endpoint URL by using the same origin as the SSE connection and appending the received path and query string (e.g., https://example.com/messages?sessionId=abc123)
  4. Message Exchange: The client sends all JSON-RPC messages as HTTP POST requests to this constructed message endpoint

This approach allows the server to dynamically configure where messages should be sent, supporting load balancing and session routing while maintaining the same origin. The transport manages this by:

  • Establishing and maintaining the SSE connection
  • Processing the endpoint event to configure the message endpoint
  • Sending all client messages to the configured endpoint
  • Handling server messages received via the SSE stream

This transport follows Req's API patterns, making it easy to test with custom HTTP clients.

Testing

There are two main approaches for testing code that uses this transport:

  1. Using a custom HTTP client (recommended):

    # Define a mock HTTP client for testing
    defmodule MockHttpClient do
    def post(_url, json_data, _headers, _timeout) do
     # Verify request data
     assert json_data.method == "test"
     
     # Return a mock response
     {:ok, %{status: 200, body: %{"result" => "success"}}}
    end
    end
    
    # Start the transport with the mock client
    {:ok, transport} = Http.start_link(
    url: "https://example.com/sse",
    http_client: MockHttpClient
    )
    
    # Simulate the SSE message endpoint notification
    # Note: only the path is sent, the client constructs the full URL
    send(transport, {:sse_endpoint, "/messages?sessionId=abc123"})
    
    # Test sending a message
    {:ok, response} = Http.send_message(transport, %{method: "test"})
    assert response.status == 200
    assert response.body["result"] == "success"
  2. Using Req.Test (for testing without process boundaries):

    # Set up Req.Test
    Req.Test.stub(:test_stub, fn conn ->
    assert conn.body["method"] == "test"
    %{conn | status: 200, body: %{"result" => "success"}}
    end)
    
    # Create a request with the test plug
    req = Http.new(
    url: "https://example.com/sse",
    plug: {Req.Test, :test_stub}
    )
    
    # This approach works for direct Req calls but has limitations
    # with the GenServer process boundary in the transport

See product/docs/010.01.req-usage.md for more detailed examples and patterns.

Summary

Functions

Returns a specification to start this module under a supervisor.

Closes the transport connection.

Creates a new HTTP transport request configuration.

Sends a message to the server via HTTP POST.

Starts a new HTTP transport as a linked process.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

close(pid)

@spec close(pid()) :: :ok

Closes the transport connection.

Parameters

  • pid - The transport process

Returns

  • :ok - The connection was closed successfully

new(options)

@spec new(keyword()) :: Req.Request.t()

Creates a new HTTP transport request configuration.

Options

  • :url - The URL of the MCP server SSE endpoint (required)
  • :headers - Additional HTTP headers to include in requests
  • :timeout - Request timeout in milliseconds (default: 30000)
  • :http_client - Optional custom HTTP client module for testing (see example in tests)
  • Plus any other options from Req.new/1

Returns

  • %Req.Request{} - A request configuration for the HTTP transport

Examples

iex> req = MCPEx.Transport.Http.new(url: "https://example.com/sse")
%Req.Request{}

send_message(pid, message)

@spec send_message(pid(), String.t() | map()) :: {:ok, map()} | {:error, term()}

Sends a message to the server via HTTP POST.

Parameters

  • pid - The transport process
  • message - The message to send (string or map)

Returns

  • {:ok, response} - The message was sent successfully with the full response
  • {:error, reason} - Failed to send the message

Examples

iex> Http.send_message(pid, ~s({"jsonrpc":"2.0","method":"test"}))
{:ok, %{status: 200, body: %{...}}}

iex> Http.send_message(pid, %{jsonrpc: "2.0", method: "test"})
{:ok, %{status: 200, body: %{...}}}

start_link(options)

@spec start_link(keyword()) :: {:ok, pid()} | {:error, term()}

Starts a new HTTP transport as a linked process.

Options

  • :request - A Req.Request struct created with Http.new/1
  • :http_client - Optional custom HTTP client module for testing
  • Or any option accepted by Http.new/1

Returns

  • {:ok, pid} - The transport was started successfully
  • {:error, reason} - Failed to start the transport

Examples

# Using Http.new
iex> req = MCPEx.Transport.Http.new(url: "https://example.com/sse")
iex> {:ok, pid} = MCPEx.Transport.Http.start_link(request: req)

# Or directly with options
iex> {:ok, pid} = MCPEx.Transport.Http.start_link(url: "https://example.com/sse")