Current section

Files

Jump to
hermes_mcp lib hermes mcp message.ex
Raw

lib/hermes/mcp/message.ex

defmodule Hermes.MCP.Message do
@moduledoc """
Handles encoding and decoding of MCP protocol messages.
This module provides functions for parsing, validating, and creating
MCP (Model Context Protocol) messages using the JSON-RPC 2.0 format.
## Message Types
The MCP protocol uses JSON-RPC 2.0 messages in these forms:
1. Requests - Messages sent from client to server that expect a response
2. Responses - Messages sent from server to client in response to a request
3. Notifications - Messages sent in either direction that don't expect a response
4. Errors - Error responses sent from server to client
## Examples
```elixir
# Decode MCP messages from a string
{:ok, messages} = Hermes.MCP.Message.decode(json_string)
# Encode a request
{:ok, request_json} = Hermes.MCP.Message.encode_request(%{"method" => "ping", "params" => %{}}, "req_123")
# Encode a notification
{:ok, notif_json} = Hermes.MCP.Message.encode_notification(%{"method" => "notifications/progress", "params" => %{...}})
```
"""
alias Hermes.MCP.Error
alias Hermes.MCP.Response
@request_methods ~w(initialize ping resources/list resources/read prompts/get prompts/list tools/call tools/list logging/setLevel)
@notification_methods ~w(notifications/initialized notifications/cancelled notifications/progress notifications/message)
@typedoc """
Represents any MCP protocol message.
MCP protocol uses JSON-RPC 2.0 with several message formats:
1. Request Message:
```
{
"jsonrpc": "2.0",
"method": String, // Method name like "ping", "resources/list"
"params": Object, // Method parameters
"id": String|Number // Request identifier
}
```
2. Notification Message:
```
{
"jsonrpc": "2.0",
"method": String, // Method name like "notifications/progress"
"params": Object // Notification parameters
}
```
3. Response Message:
```
{
"jsonrpc": "2.0",
"result": Object, // Result value
"id": String|Number // Request identifier
}
```
4. Error Message:
```
{
"jsonrpc": "2.0",
"error": {
"code": Number, // Error code
"message": String, // Error message
"data": Any // Optional error data
},
"id": String|Number // Request identifier
}
```
"""
@type message :: map()
# Message type guard functions
defguard is_request(data) when is_map_key(data, "method") and is_map_key(data, "id")
defguard is_notification(data) when is_map_key(data, "method") and not is_map_key(data, "id")
defguard is_response(data) when is_map_key(data, "result") and is_map_key(data, "id")
defguard is_error(data) when is_map_key(data, "error") and is_map_key(data, "id")
@doc """
Decodes a JSON string into MCP message(s).
This function handles both single messages and newline-delimited message streams.
## Parameters
* `data` - The JSON string to decode
## Returns
* `{:ok, messages}` where messages is a list of parsed MCP messages
* `{:error, error}` if parsing fails
## Examples
iex> Hermes.MCP.Message.decode(~s({"jsonrpc":"2.0","result":{},"id":"req_123"}))
{:ok, [%{"jsonrpc" => "2.0", "result" => %{}, "id" => "req_123"}]}
iex> {:error, error} = Hermes.MCP.Message.decode("invalid")
iex> error.code
-32700
iex> error.reason
:parse_error
"""
@spec decode(String.t()) :: {:ok, list(message())} | {:error, Error.t()}
def decode(data) when is_binary(data) do
data
|> String.split("\n", trim: true)
|> Enum.reduce_while({:ok, []}, &parse_message/2)
|> then(fn
{:ok, messages} -> {:ok, Enum.reverse(messages)}
{:error, reason} -> {:error, reason}
end)
end
defp parse_message(line, {:ok, acc}) do
case JSON.decode(line) do
{:ok, message} ->
{:cont, {:ok, [message | acc]}}
{:error, _} ->
{:halt, {:error, Error.parse_error(%{line: line})}}
end
end
@doc """
Encodes a request message into a JSON-RPC 2.0 compliant string.
## Parameters
* `request` - A map containing the request method and params
* `id` - A unique identifier for the request
## Returns
* `{:ok, json_string}` with the encoded request and a newline
* `{:error, error}` if encoding fails
## Examples
iex> {:ok, request} = Hermes.MCP.Message.encode_request(%{"method" => "ping", "params" => %{}}, "req_123")
iex> request_map = request |> String.trim() |> JSON.decode!()
iex> request_map["jsonrpc"]
"2.0"
iex> request_map["method"]
"ping"
iex> request_map["id"]
"req_123"
"""
@spec encode_request(map(), String.t() | integer()) :: {:ok, String.t()} | {:error, Error.t()}
def encode_request(request, id) do
data =
request
|> Map.put("jsonrpc", "2.0")
|> Map.put("id", id)
{:ok, JSON.encode!(data) <> "\n"}
end
@doc """
Encodes a notification message into a JSON-RPC 2.0 compliant string.
## Parameters
* `notification` - A map containing the notification method and params
## Returns
* `{:ok, json_string}` with the encoded notification and a newline
* `{:error, error}` if encoding fails
## Examples
iex> {:ok, notif} = Hermes.MCP.Message.encode_notification(%{"method" => "notifications/progress", "params" => %{"progress" => 50}})
iex> notif_map = notif |> String.trim() |> JSON.decode!()
iex> notif_map["jsonrpc"]
"2.0"
iex> notif_map["method"]
"notifications/progress"
iex> notif_map["params"]["progress"]
50
"""
@spec encode_notification(map()) :: {:ok, String.t()} | {:error, Error.t()}
def encode_notification(notification) do
data = Map.put(notification, "jsonrpc", "2.0")
{:ok, JSON.encode!(data) <> "\n"}
end
@doc """
Encodes a progress notification message.
## Parameters
* `progress_token` - The token that was provided in the original request (string or integer)
* `progress` - The current progress value (number)
* `total` - Optional total value for the operation (number)
## Returns
* `{:ok, json_string}` with the encoded notification and a newline
* `{:error, error}` if encoding fails
## Examples
iex> {:ok, notif} = Hermes.MCP.Message.encode_progress_notification("token123", 50)
iex> notif_map = notif |> String.trim() |> JSON.decode!()
iex> notif_map["method"]
"notifications/progress"
iex> notif_map["params"]["progressToken"]
"token123"
iex> notif_map["params"]["progress"]
50
"""
@spec encode_progress_notification(String.t() | integer(), number(), number() | nil) ::
{:ok, String.t()} | {:error, Error.t()}
def encode_progress_notification(progress_token, progress, total \\ nil)
when (is_binary(progress_token) or is_integer(progress_token)) and is_number(progress) do
params = %{
"progressToken" => progress_token,
"progress" => progress
}
params = if total, do: Map.put(params, "total", total), else: params
encode_notification(%{
"method" => "notifications/progress",
"params" => params
})
end
@doc """
Validates a message according to the MCP protocol.
Performs basic validation to ensure the message conforms to the JSON-RPC 2.0
structure and has valid MCP-specific fields.
## Parameters
* `message` - The parsed JSON message to validate
## Returns
* `:ok` if the message is valid
* `{:error, error}` if validation fails
## Examples
iex> Hermes.MCP.Message.validate(%{"jsonrpc" => "2.0", "result" => %{}, "id" => "req_123"})
:ok
iex> {:error, error} = Hermes.MCP.Message.validate(%{"jsonrpc" => "1.0", "result" => %{}, "id" => "req_123"})
iex> error.reason
:invalid_request
"""
@spec validate(map()) :: :ok | {:error, Error.t()}
def validate(message) when not is_map(message) do
{:error, Error.invalid_request(%{reason: "not_a_map"})}
end
def validate(message) when is_map(message) and not is_map_key(message, "jsonrpc") do
{:error, Error.invalid_request(%{reason: "missing_jsonrpc_field"})}
end
def validate(%{"jsonrpc" => version}) when version != "2.0" do
{:error, Error.invalid_request(%{reason: "invalid_jsonrpc_version"})}
end
def validate(message) when is_request(message) do
validate_request(message)
end
def validate(message) when is_notification(message) do
validate_notification(message)
end
def validate(message) when is_response(message) do
:ok
end
def validate(message) when is_error(message) do
:ok
end
def validate(message) when is_map(message) do
{:error, Error.invalid_request(%{reason: "invalid_message_structure"})}
end
defp validate_request(%{"method" => method} = message) do
with :ok <- validate_method(method, @request_methods) do
validate_id(message["id"])
end
end
defp validate_notification(%{"method" => method}) do
validate_method(method, @notification_methods)
end
defp validate_method(method, allowed_methods) do
if method in allowed_methods do
:ok
else
{:error, Error.method_not_found(%{method: method})}
end
end
defp validate_id(id) when is_binary(id) or is_integer(id), do: :ok
defp validate_id(_), do: {:error, Error.invalid_request(%{reason: "invalid_id_type"})}
@doc """
Converts a message to the appropriate domain object based on its type.
## Parameters
* `message` - A parsed JSON-RPC 2.0 message
## Returns
* `{:ok, domain_object}` with the appropriate type (Response or Error)
* `{:error, error}` if conversion fails
## Examples
iex> resp = %{"jsonrpc" => "2.0", "result" => %{"data" => "value"}, "id" => "req_123"}
iex> {:ok, response} = Hermes.MCP.Message.to_domain(resp)
iex> response.__struct__
Hermes.MCP.Response
iex> err = %{"jsonrpc" => "2.0", "error" => %{"code" => -32700, "message" => "Parse error"}, "id" => "req_123"}
iex> {:ok, error} = Hermes.MCP.Message.to_domain(err)
iex> error.__struct__
Hermes.MCP.Error
"""
@spec to_domain(message()) ::
{:ok, Response.t() | Error.t()}
| {:error, Error.t()}
def to_domain(message) when is_response(message) do
{:ok, Response.from_json_rpc(message)}
end
def to_domain(message) when is_error(message) do
{:ok, Error.from_json_rpc(message["error"])}
end
def to_domain(_message) do
{:error, Error.invalid_request(%{reason: "cannot_convert_to_domain"})}
end
end