Skip to content

Commit 2c2dec0

Browse files
author
zoey
authored
feat: rpc and mcp specific message parsing (#5)
## Problem We needed a robust way to validate and process Model Context Protocol (MCP) messages according to the schema specification. ## Solution This PR introduces the `Hermes.Message` module that: - Defines specialized schemas for MCP protocol message validation - Provides encoding/decoding functions for JSON-RPC messages - Implements validation for all standard MCP message types (requests, notifications, responses, errors) - Adds a comprehensive test suite to ensure proper handling of all message formats ## Rationale 1. **Schema Definition Approach**: We defined schemas for each message type and their parameters separately to allow for precise validation of the different MCP request and notification types. 2. **Dependent Validation**: Implemented smart parameter validation that changes based on the message method using Peri's dependent field support. This implementation provides a clean, maintainable way to handle MCP protocol messages while ensuring strict adherence to the protocol specification.
1 parent 8186cfe commit 2c2dec0

File tree

2 files changed

+509
-0
lines changed

2 files changed

+509
-0
lines changed

lib/hermes/message.ex

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
defmodule Hermes.Message do
2+
@moduledoc """
3+
Handles parsing and validation of MCP (Model Context Protocol) messages using the Peri library.
4+
5+
This module provides functions to parse and validate MCP messages based on the Model Context Protocol schema
6+
"""
7+
8+
import Peri
9+
10+
# MCP message schemas
11+
12+
@request_methods ~w(initialize ping resources/list resources/read prompts/get prompts/list tools/call tools/list)
13+
14+
@init_params_schema %{
15+
"protocolVersion" => {:required, :string},
16+
"capabilities" => {:required, :map},
17+
"clientInfo" => %{
18+
"name" => {:required, :string},
19+
"version" => {:required, :string}
20+
}
21+
}
22+
23+
@ping_params_schema :map
24+
25+
@resources_list_params_schema %{
26+
"cursor" => :string
27+
}
28+
29+
@resources_read_params_schema %{
30+
"uri" => {:required, :string}
31+
}
32+
33+
@prompts_list_params_schema %{
34+
"cursor" => :string
35+
}
36+
37+
@prompts_get_params_schema %{
38+
"name" => {:required, :string},
39+
"arguments" => :map
40+
}
41+
42+
@tools_list_params_schema %{
43+
"cursor" => :string
44+
}
45+
46+
@tools_call_params_schema %{
47+
"name" => {:required, :string},
48+
"arguments" => :map
49+
}
50+
51+
defschema :request_schema, %{
52+
"jsonrpc" => {:required, {:string, {:eq, "2.0"}}},
53+
"method" => {:required, {:enum, @request_methods}},
54+
"params" => {:dependent, &parse_request_params_by_method/1},
55+
"id" => {:required, {:either, {:string, :integer}}}
56+
}
57+
58+
defp parse_request_params_by_method(%{"method" => "initialize"}), do: {:ok, @init_params_schema}
59+
defp parse_request_params_by_method(%{"method" => "ping"}), do: {:ok, @ping_params_schema}
60+
61+
defp parse_request_params_by_method(%{"method" => "resources/list"}),
62+
do: {:ok, @resources_list_params_schema}
63+
64+
defp parse_request_params_by_method(%{"method" => "resources/read"}),
65+
do: {:ok, @resources_read_params_schema}
66+
67+
defp parse_request_params_by_method(%{"method" => "prompts/list"}),
68+
do: {:ok, @prompts_list_params_schema}
69+
70+
defp parse_request_params_by_method(%{"method" => "prompts/get"}),
71+
do: {:ok, @prompts_get_params_schema}
72+
73+
defp parse_request_params_by_method(%{"method" => "tools/list"}),
74+
do: {:ok, @tools_list_params_schema}
75+
76+
defp parse_request_params_by_method(%{"method" => "tools/call"}),
77+
do: {:ok, @tools_call_params_schema}
78+
79+
defp parse_request_params_by_method(_), do: {:ok, :map}
80+
81+
@init_noti_params_schema :map
82+
@cancel_noti_params_schema %{
83+
"requestId" => {:required, {:either, {:string, :integer}}},
84+
"reason" => :string
85+
}
86+
@progress_notif_params_schema %{
87+
"progressToken" => {:required, {:either, {:string, :integer}}},
88+
"progress" => {:required, :float},
89+
"total" => :float
90+
}
91+
92+
defschema :notification_schema, %{
93+
"jsonrpc" => {:required, {:string, {:eq, "2.0"}}},
94+
"method" =>
95+
{:required,
96+
{:enum, ~w(notifications/initialize notifications/cancelled notifications/progress)}},
97+
"params" => {:dependent, &parse_notification_params_by_method/1}
98+
}
99+
100+
defp parse_notification_params_by_method(%{"method" => "notifications/initialize"}),
101+
do: {:ok, @init_noti_params_schema}
102+
103+
defp parse_notification_params_by_method(%{"method" => "notifications/cancelled"}),
104+
do: {:ok, @cancel_noti_params_schema}
105+
106+
defp parse_notification_params_by_method(%{"method" => "notifications/progress"}),
107+
do: {:ok, @progress_notif_params_schema}
108+
109+
defp parse_notification_params_by_method(_), do: {:ok, :map}
110+
111+
defschema :response_schema, %{
112+
"jsonrpc" => {:required, {:string, {:eq, "2.0"}}},
113+
"result" => {:required, :map},
114+
"id" => {:required, {:either, {:string, :integer}}}
115+
}
116+
117+
defschema :error_schema, %{
118+
"jsonrpc" => {:required, {:string, {:eq, "2.0"}}},
119+
"error" => %{
120+
"code" => {:required, :integer},
121+
"message" => {:required, :string},
122+
"data" => :any
123+
},
124+
"id" => {:required, {:either, {:string, :integer}}}
125+
}
126+
127+
defschema :mcp_message_schema,
128+
{:oneof,
129+
[
130+
get_schema(:request_schema),
131+
get_schema(:notification_schema),
132+
get_schema(:response_schema),
133+
get_schema(:error_schema)
134+
]}
135+
136+
@doc """
137+
Determines if a JSON-RPC message is a request.
138+
"""
139+
defguard is_request(data) when is_map_key(data, "method") and is_map_key(data, "id")
140+
141+
@doc """
142+
Determines if a JSON-RPC message is a notification.
143+
"""
144+
defguard is_notification(data) when is_map_key(data, "method") and not is_map_key(data, "id")
145+
146+
@doc """
147+
Determines if a JSON-RPC message is a response.
148+
"""
149+
defguard is_response(data) when is_map_key(data, "result") and is_map_key(data, "id")
150+
151+
@doc """
152+
Determines if a JSON-RPC message is an error.
153+
"""
154+
defguard is_error(data) when is_map_key(data, "error") and is_map_key(data, "id")
155+
156+
@doc """
157+
Decodes raw data (possibly containing multiple messages) into JSON-RPC messages.
158+
159+
Returns either:
160+
- `{:ok, messages}` where messages is a list of parsed JSON-RPC messages
161+
- `{:error, reason}` if parsing fails
162+
"""
163+
def decode(data) when is_binary(data) do
164+
data
165+
|> String.split("\n", trim: true)
166+
|> Enum.reduce_while({:ok, []}, &parse_message/2)
167+
|> then(fn
168+
{:ok, messages} -> {:ok, Enum.reverse(messages)}
169+
{:error, reason} -> {:error, reason}
170+
end)
171+
end
172+
173+
defp parse_message(line, {:ok, acc}) do
174+
with {:ok, message} <- JSON.decode(line),
175+
{:ok, message} <- validate_message(message) do
176+
{:cont, {:ok, [message | acc]}}
177+
else
178+
err -> {:halt, err}
179+
end
180+
end
181+
182+
@doc """
183+
Validates a decoded JSON message to ensure it complies with the MCP schema.
184+
"""
185+
def validate_message(message) when is_map(message) do
186+
with {:error, _} <- mcp_message_schema(message) do
187+
{:error, :invalid_message}
188+
end
189+
end
190+
191+
@doc """
192+
Encodes a request message to a JSON-RPC 2.0 compliant string.
193+
194+
Returns the encoded string with a newline character appended.
195+
"""
196+
def encode_request(request, id) do
197+
schema = get_schema(:request_schema)
198+
199+
request
200+
|> Map.put("jsonrpc", "2.0")
201+
|> Map.put("id", id)
202+
|> encode_message(schema)
203+
end
204+
205+
@doc """
206+
Encodes a notification message to a JSON-RPC 2.0 compliant string.
207+
208+
Returns the encoded string with a newline character appended.
209+
"""
210+
def encode_notification(notification) do
211+
schema = get_schema(:notification_schema)
212+
213+
notification
214+
|> Map.put("jsonrpc", "2.0")
215+
|> encode_message(schema)
216+
end
217+
218+
defp encode_message(data, schema) do
219+
encoder = {schema, {:transform, fn data -> JSON.encode!(data) <> "\n" end}}
220+
Peri.validate(encoder, data)
221+
end
222+
end

0 commit comments

Comments
 (0)