Packages

GenServer calls and casts over MCP, on Snodo.Tool.GenServer

Current section

Files

Jump to
snodo_otp README.md
Raw

README.md

# 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.4"},
    {:snodo_otp, "~> 0.4.4"}
  ]
end
```
<!-- x-release-please-end -->

## Contract

`use Snodo.OTP.Contract` declares each operation once, with `call/2` or
`cast/2`, and generates both sides from it:

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

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