somatize_mcp/protocol.rs
1//! MCP JSON-RPC 2.0 protocol types.
2
3use serde::{Deserialize, Serialize};
4
5/// One JSON-RPC 2.0 request, as read line-by-line off stdin.
6#[derive(Debug, Deserialize)]
7pub struct JsonRpcRequest {
8 /// Protocol version marker; `"2.0"` for every client we speak to.
9 pub jsonrpc: String,
10 /// Request id to echo back in the response. JSON-RPC allows a
11 /// string, a number or null here, so it stays an opaque
12 /// [`serde_json::Value`] rather than committing to one shape.
13 pub id: serde_json::Value,
14 /// The method being invoked — `initialize`, `tools/list`,
15 /// `tools/call`, ...
16 pub method: String,
17 /// Method parameters. Defaults to `Value::Null` because clients may
18 /// omit the field entirely for parameterless methods.
19 #[serde(default)]
20 pub params: serde_json::Value,
21}
22
23/// One JSON-RPC 2.0 response, written as a single line to stdout.
24///
25/// Exactly one of `result` and `error` is set; the [`success`] and
26/// [`error`] constructors are the only ways this crate builds one, so
27/// the invariant holds by construction.
28///
29/// [`success`]: JsonRpcResponse::success
30/// [`error`]: JsonRpcResponse::error
31#[derive(Debug, Serialize)]
32pub struct JsonRpcResponse {
33 /// Protocol version marker, always `"2.0"`.
34 pub jsonrpc: String,
35 /// The id of the request this answers, echoed verbatim.
36 pub id: serde_json::Value,
37 /// The successful payload; absent (not null) on failure.
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub result: Option<serde_json::Value>,
40 /// The failure; absent on success.
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub error: Option<JsonRpcError>,
43}
44
45/// The `error` member of a failed [`JsonRpcResponse`].
46///
47/// Protocol-level failure (unknown method, bad params, panic). A *tool*
48/// that fails still returns a successful response carrying a
49/// [`ToolCallResult`] with `is_error: true` — that distinction is MCP's,
50/// not ours: the model sees tool errors, the client sees protocol ones.
51#[derive(Debug, Serialize)]
52pub struct JsonRpcError {
53 /// Numeric error code; see [`METHOD_NOT_FOUND`], [`INVALID_PARAMS`],
54 /// [`INTERNAL_ERROR`].
55 pub code: i64,
56 /// Human-readable description of what went wrong.
57 pub message: String,
58 /// Optional structured detail; this server never sets it.
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub data: Option<serde_json::Value>,
61}
62
63impl JsonRpcResponse {
64 /// A successful response carrying `result` for request `id`.
65 pub fn success(id: serde_json::Value, result: serde_json::Value) -> Self {
66 Self {
67 jsonrpc: "2.0".into(),
68 id,
69 result: Some(result),
70 error: None,
71 }
72 }
73
74 /// A failed response for request `id` with the given code and
75 /// message; `data` is left unset.
76 pub fn error(id: serde_json::Value, code: i64, message: impl Into<String>) -> Self {
77 Self {
78 jsonrpc: "2.0".into(),
79 id,
80 result: None,
81 error: Some(JsonRpcError {
82 code,
83 message: message.into(),
84 data: None,
85 }),
86 }
87 }
88}
89
90/// The server's half of the MCP `initialize` handshake.
91#[derive(Debug, Serialize)]
92pub struct InitializeResult {
93 /// MCP protocol revision the server speaks (a date string,
94 /// e.g. `"2024-11-05"`).
95 #[serde(rename = "protocolVersion")]
96 pub protocol_version: String,
97 /// What the server can do — see [`ServerCapabilities`].
98 pub capabilities: ServerCapabilities,
99 /// Who is answering — see [`ServerInfo`].
100 #[serde(rename = "serverInfo")]
101 pub server_info: ServerInfo,
102}
103
104/// What this server offers a client.
105///
106/// Tools only: soma-mcp serves no resources and no prompts, so those
107/// capability fields do not exist here — MCP treats an absent field as
108/// "not supported", which is exactly the claim.
109#[derive(Debug, Serialize)]
110pub struct ServerCapabilities {
111 /// The tools capability; the 20 tools are the whole API.
112 pub tools: ToolsCapability,
113}
114
115/// Details of the tools capability advertised in the handshake.
116#[derive(Debug, Serialize)]
117pub struct ToolsCapability {
118 /// Whether the server emits `tools/list_changed` notifications.
119 /// This server's tool list is fixed at compile time, so it
120 /// advertises `false` and a client need never re-fetch the list.
121 #[serde(rename = "listChanged")]
122 pub list_changed: bool,
123}
124
125/// Server identity reported in the `initialize` handshake.
126#[derive(Debug, Serialize)]
127pub struct ServerInfo {
128 /// Server name (`"soma-mcp"`).
129 pub name: String,
130 /// Crate version, taken from `CARGO_PKG_VERSION` so it cannot drift
131 /// from the release.
132 pub version: String,
133}
134
135/// What this server publishes, and what an agent consumes.
136///
137/// The same type either way: [`somatize_core::tool::ToolSpec`]. Soma is both
138/// a tool provider (here) and a tool caller (`soma-llm`), and describing a
139/// tool twice is how the two descriptions drift.
140pub use somatize_core::tool::ToolSpec as ToolDefinition;
141
142/// What a `tools/call` returns: the text a model will read.
143///
144/// Every handler in [`crate::context`] produces one of these, and the
145/// renderers in [`crate::render`] decide what goes in it — the text IS
146/// the API (each experiment-pool result ends with a `next:` line and
147/// carries its `run_dir:`), so this type stays a thin envelope.
148#[derive(Debug, Serialize)]
149pub struct ToolCallResult {
150 /// The content items, concatenated by [`content_text`] when a
151 /// caller wants the single string a model sees.
152 ///
153 /// [`content_text`]: ToolCallResult::content_text
154 pub content: Vec<ContentItem>,
155 /// `Some(true)` when the tool failed. MCP keeps tool failure inside
156 /// a *successful* response — the model reads the error text and can
157 /// react to it, unlike a protocol-level [`JsonRpcError`].
158 #[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
159 pub is_error: Option<bool>,
160}
161
162/// One block of tool-result content.
163///
164/// This server only ever emits text, so the type is not an enum: a
165/// `type` tag plus the text is the whole story.
166#[derive(Debug, Serialize)]
167pub struct ContentItem {
168 /// MCP content discriminator; always `"text"` here.
169 #[serde(rename = "type")]
170 pub content_type: String,
171 /// The text itself.
172 pub text: String,
173}
174
175impl ContentItem {
176 /// A text content block.
177 pub fn text(s: impl Into<String>) -> Self {
178 Self {
179 content_type: "text".into(),
180 text: s.into(),
181 }
182 }
183}
184
185impl ToolCallResult {
186 /// A successful result carrying one text block.
187 pub fn text(s: impl Into<String>) -> Self {
188 Self {
189 content: vec![ContentItem::text(s)],
190 is_error: None,
191 }
192 }
193
194 /// A failed result: the same text block, flagged `isError` so the
195 /// model knows it is reading a failure, not an answer.
196 pub fn error(s: impl Into<String>) -> Self {
197 Self {
198 content: vec![ContentItem::text(s)],
199 is_error: Some(true),
200 }
201 }
202
203 /// The text a model would see — every content item concatenated.
204 /// The protocol carries text, so this is the whole result.
205 pub fn content_text(&self) -> String {
206 self.content
207 .iter()
208 .map(|c| c.text.as_str())
209 .collect::<Vec<_>>()
210 .join("\n")
211 }
212
213 /// Whether this result is an error; an absent flag means success.
214 pub fn is_error(&self) -> bool {
215 self.is_error.unwrap_or(false)
216 }
217}
218
219/// JSON-RPC 2.0 spec code: the requested method does not exist. What
220/// the server answers for any method it does not recognise.
221pub const METHOD_NOT_FOUND: i64 = -32601;
222/// JSON-RPC 2.0 spec code: the method exists but the parameters are
223/// invalid. Currently unsent — a missing tool argument is reported as a
224/// [`ToolCallResult::error`] instead, so the *model* sees it and can
225/// retry with the argument filled in.
226pub const INVALID_PARAMS: i64 = -32602;
227/// JSON-RPC 2.0 spec code: the server itself failed. Currently unsent;
228/// kept beside its siblings so a future handler does not reinvent the
229/// number.
230pub const INTERNAL_ERROR: i64 = -32603;