# 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

<!-- x-release-please-start-version -->
```elixir
def deps do
  [
    {:snodo, "~> 0.4.3"},
    {:snodo_otp, "~> 0.4.3"}
  ]
end
```
<!-- x-release-please-end -->

## Contract

`Snodo.OTP.Contract` is a behaviour. `ops/0` lists the operations, each with a
`name`, a `kind` (`:call` or `:cast`), an `input_schema`, and optionally an
`output_schema`, a `description`, and a `timeout`. The server side implements
`build_message/2` (JSON arguments to the GenServer message) and
`encode_reply/2` (the reply to a JSON object). The client side implements
`encode_call/1` and `encode_cast/1` (a message to a tool name and JSON
arguments, or `:nomatch`) and `decode_reply/2`.

```elixir
defmodule CounterContract do
  @behaviour Snodo.OTP.Contract

  @by %{
    "type" => "object",
    "properties" => %{"by" => %{"type" => "integer"}},
    "required" => ["by"],
    "additionalProperties" => false
  }
  @value %{
    "type" => "object",
    "properties" => %{"value" => %{"type" => "integer"}},
    "required" => ["value"]
  }

  @impl true
  def ops do
    [
      %{name: "counter_add", kind: :call, input_schema: @by, output_schema: @value},
      %{name: "counter_cast_add", kind: :cast, input_schema: @by}
    ]
  end

  @impl true
  def build_message(_name, %{"by" => by}), do: {:add, by}

  @impl true
  def encode_reply(_name, count), do: %{"value" => count}

  @impl true
  def encode_call({:add, by}), do: {"counter_add", %{"by" => by}}
  def encode_call(_message), do: :nomatch

  @impl true
  def encode_cast({:add, by}), do: {"counter_cast_add", %{"by" => by}}
  def encode_cast(_message), do: :nomatch

  @impl true
  def decode_reply(_name, %{"value" => count}), do: count
end
```

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

```elixir
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:

```elixir
{: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/3` timeout 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 receives `DOWN` and 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 reply
  `decode_reply/2` does 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.
