snodo_otp
GenServer calls and casts over MCP, on top of Snodo.Tool.GenServer.
One contract module declares a GenServer's remote operations. The server side
serves them as MCP tools; the client side runs a local process that stands in
for the remote GenServer, so application code keeps GenServer.call/3 and
GenServer.cast/2.
Installation
def deps do
[
{:snodo, "~> 0.4.4"},
{:snodo_otp, "~> 0.4.4"}
]
end
Contract
use Snodo.OTP.Contract declares each operation once, with call/2 or
cast/2, and generates both sides from it:
defmodule CounterContract do
use Snodo.OTP.Contract
@by %{
"type" => "object",
"properties" => %{"by" => %{"type" => "integer"}},
"required" => ["by"],
"additionalProperties" => false
}
@value %{
"type" => "object",
"properties" => %{"value" => %{"type" => "integer"}},
"required" => ["value"]
}
call("counter_add",
message: {:add, {:arg, "by"}},
reply: "value",
input_schema: @by,
output_schema: @value
)
cast("counter_cast_add", message: {:add, {:arg, "by"}}, input_schema: @by)
end
:message is the GenServer message as data, with {:arg, key} marking a JSON
argument. The server builds the message from the arguments; the client matches
a message against the same shape and sends the arguments it binds. :reply
names the key of the JSON object that carries a call's reply. Each operation
also takes an input_schema, and optionally an output_schema, a
description, and a timeout. Compilation fails on a duplicate name, a
missing :message, a call without :reply, and an argument the input schema
does not list.
The server node and the client node use the same compiled contract module, for example from a shared package.
A contract can instead implement the Snodo.OTP.Contract callbacks by hand,
for messages a :message shape cannot describe: ops/0, the server side
build_message/2 and encode_reply/2, and the client side encode_call/1,
encode_cast/1 (a message to a tool name and JSON arguments, or :nomatch)
and decode_reply/2.
Nothing is inferred: an operation exists because the contract names it, so a client never chooses a process, atom, or term.
Server
Snodo.OTP.Server.expose/2 emits one genserver_call or genserver_cast
declaration per operation inside a Snodo.Server:
defmodule CounterServer do
use Snodo.Server, name: "counter", version: "1.0.0"
import Snodo.OTP.Server
expose(CounterContract, target: Counter)
end
Client
Snodo.OTP.Proxy is a GenServer that stands in for the remote one:
{:ok, counter} = Snodo.OTP.Proxy.start_link(url: url, contract: CounterContract)
GenServer.call(counter, {:add, 2})
GenServer.cast(counter, {:add, 3})
Requests go out one at a time, in the order the proxy receives them, so casts arrive in order and a call sees every cast sent before it, as in OTP. The remote GenServer handles one message at a time anyway; requests do not overlap on the wire.
Failures follow OTP where the wire allows it:
- The caller's
GenServer.call/3timeout governs; a result that arrives after the caller gave up is dropped. - A call the remote side timed out (the adapter's
:timeout, or the proxy's:request_timeout, default 60,000 ms) is answered{:error, :timeout}. - A call to a remote target that is not running stops the proxy with
:noproc, so a monitor receivesDOWNand a supervisor can restart it. A cast to it is dropped, as in OTP. - A request whose process crashes, for example on arguments JSON cannot
encode, is answered
{:error, {:request_failed, reason}}; a replydecode_reply/2does not match is answered{:error, {:decode_failed, name, json}}. Later requests still run. - A connection failure stops the proxy with
{:nodedown, url}. An HTTP error status, such as 401 or 404, is answered{:error, error}and the proxy keeps running. - A message the contract does not know stops the proxy with
{:function_clause, message}.
:connect passes options through to Snodo.Client.connect/2, merged over
protocol: "2026-07-28" and timeout: 15_000, for example
connect: [token_provider: provider].
The proxy has no pid for the remote process and no link to it, and it sees a remote failure only when a call reaches it.