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:

: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.