# Beam Mcp

> MCP server core for the BEAM. Elixir, Apache-2.0, dual-era 2026-07-28 and 2025-11-25.

- **Type:** MCP server
- **Install:** `agentstack add mcp-scriptkittyos-beam-mcp`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [ScriptKittyOS](https://agentstack.voostack.com/s/scriptkittyos)
- **Installs:** 0
- **Category:** [Integrations](https://agentstack.voostack.com/c/integrations)
- **Latest version:** 0.1.0
- **License:** Apache-2.0
- **Upstream author:** [ScriptKittyOS](https://github.com/ScriptKittyOS)
- **Source:** https://github.com/ScriptKittyOS/beam_mcp

## Install

```sh
agentstack add mcp-scriptkittyos-beam-mcp
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# BeamMCP

A Model Context Protocol server core for the BEAM. Protocol handling, two transports — stdio and
a stateless Streamable HTTP `Plug` — and JSON Schema validation, with the tool catalog and the
dispatch function injected by the host.

The package holds no tools, no domain, and no policy. It decides what a well-formed request is
and refuses one that is not; what a tool *does* is the host's business.

```elixir
def deps do
  [{:beam_mcp, "~> 0.5.0"}]
end
```

**`~> 0.5.0`, not the more usual `~> 0.5`.** While this package is `0.x` it documents breaks
at the **minor** position, and it has used that position four times: `0.2.0` removed two
fields from results for legacy-declared requests, `0.3.0` added the HTTP transport and the
`ttlMs`/`cacheScope` fields `2026-07-28` requires on `tools/list`, `0.4.0` replaced the
catalog behaviour a host implements — `BeamMCP.ToolCatalog` by `BeamMCP.Catalog` — a break in
the host contract rather than on the wire, and `0.5.0` reads a request's `_meta` at
`params._meta` and refuses it at the top level (on the wire), renames the one sign the package
writes and moves `schema_version` to `2` (in the exported bytes), and requires a catalog's
`resources` and `prompts` lists to hold the package's structs (the host contract), each with a
how-to-tell sentence in the changelog. `~> 0.5` admits `0.6.0`, so it
would carry you across the next such break on a routine `mix deps.update`; `~> 0.5.0` does
not. The tighter form is deliberate and is not an over-pin to be tidied away.

## Two contracts

Injection without a specification is a claim with nothing behind it, so both are declared.

**`BeamMCP.Catalog`** — the host names what it offers.

`capabilities/0` returns a map with three required keys. `tools` holds `BeamMCP.ToolSpec`
structs; `resources` holds `BeamMCP.ResourceSpec` and `BeamMCP.ResourceTemplateSpec` structs
— one list, two kinds, no `uri` or `uri_template` twice — and a catalog that lists either
also exports `read_resource/1`;
`prompts` holds `BeamMCP.PromptSpec` structs, each with its `BeamMCP.PromptArgument` list,
and a catalog that lists a prompt exports `get_prompt/2`.
**An absent key is a malformed catalog, not an empty one**, and `BeamMCP.Server.new/1`
refuses it at startup rather than at the first request — as it refuses a `resources` or
`prompts` entry that is not its struct, a repeated key, and a listed resource, template or
prompt with no reader.

```elixir
defmodule MyApp.Catalog do
  @behaviour BeamMCP.Catalog

  @impl true
  def capabilities do
    %{
      resources: [
        %BeamMCP.ResourceSpec{uri: "weather://places", name: "places", mime_type: "text/plain"},
        %BeamMCP.ResourceTemplateSpec{uri_template: "weather://place/{name}", name: "place"}
      ],
      prompts: [
        %BeamMCP.PromptSpec{
          name: "forecast",
          description: "Ask for a forecast.",
          arguments: [%BeamMCP.PromptArgument{name: "place", required: true}]
        }
      ],
      tools: [
      %BeamMCP.ToolSpec{
        name: :get_weather,
        command_class: :observe,
        mode: :read_only,
        description: "Read the current weather for a place.",
        input_schema: %{
          "type" => "object",
          "properties" => %{"place" => %{"type" => "string"}},
          "required" => ["place"],
          "additionalProperties" => false
        }
      }
        ]
    }
  end

  @impl true
  def read_resource("weather://places"), do: {:ok, [%{uri: "weather://places", text: "Oslo\nLima"}]}
  def read_resource("weather://place/" <> name), do: {:ok, [%{uri: "weather://place/#{name}", text: "12°C"}]}

  @impl true
  def get_prompt("forecast", %{place: place}),
    do: {:ok, %{messages: [%{role: :user, text: "What is the forecast for #{place}?"}]}}
end
```

**Resources are advertised and read from one reader.** `resources/list` and
`resources/templates/list` serve what `capabilities/0` names, sorted by `uri` and
`uriTemplate`; `resources/read` accepts a uri only when that same list names it or a listed
template matches it (RFC 6570 `{var}` for one non-empty segment, `{+var}` across segments —
nothing more is claimed, and a template with any other expression, or a bare brace, is
refused at startup) and refuses any other as not found before the reader runs — `-32602`
with the uri as data under `2026-07-28`, `-32002` under `2025-11-25`, the code each revision
names for it — so what is advertised and what is readable cannot drift. The read is the
catalog's `read_resource/1`: `{:ok, contents}` with `text` as a string or `blob` as raw bytes
(base64 on the wire), or `{:error, reason}`, carried to the client as the same not-found code
with the reason as data. Both lists are
paginated by one opaque cursor (`BeamMCP.Cursor`, keyed on the item rather than an offset, so
a list that changes between pages never skips an item that was there before); the page size
is `BeamMCP.Server.new/1`'s `page_size:` (default 50), and a cursor from another list is
refused by name. `ttlMs` and `cacheScope` on the three results are `resources_ttl_ms:` and
`resources_cache_scope:`, defaulting to `0` and `"private"` for the reasons the tools pair
does. The server sends no notifications: `resources` is advertised with `listChanged: false`
and `subscribe: false`.

**Prompts take the tools' own validation path.** `prompts/list` serves what `capabilities/0`
names, sorted by name and paginated by the same cursor; `prompts/get` renders only a prompt
the list names — an unknown name is `-32602` with the name as data, before the reader runs —
and its arguments are validated by the tools validator over a JSON Schema derived from the
declared argument list (`BeamMCP.PromptSpec.argument_schema/1`: one `string` property per
argument, `required` from the flags, nothing undeclared admitted), then handed to
`get_prompt/2` keyed by the declared names, as a tool's arguments reach its dispatch. One
validator, one normaliser, two callers; a caller's argument name becomes an atom on neither
path, measured over 10,000 distinct keys. The reader answers `{:ok, %{messages: [%{role:
:user | :assistant, text: ...}], description: ...}}` — text content only, as for tools — or
`{:error, reason}`, carried as `-32602` with the reason as data. `prompts/list` carries
`prompts_ttl_ms:` / `prompts_cache_scope:` (defaults `0` / `"private"`); `prompts/get` is
not cacheable and carries neither. `prompts` is advertised with `listChanged: false`;
`completion/complete` belongs to the separate `completions` capability, which this package
does not advertise.

**The dispatch callback** — the host does the work.

```elixir
@type dispatch :: (atom(), map(), keyword() -> {:ok, term()} | {:error, term()})
```

## Running it

```elixir
BeamMCP.Transport.Stdio.run(
  catalog: MyApp.Catalog,
  dispatch: &MyApp.Dispatch.call/3,
  server_name: "my-app"
)
```

`:catalog` is required. `:dispatch` is required for `tools/call`. `:server_name` defaults
to `beam_mcp`, and a host that wants its own name in `initialize` says so.

## One schema, one source

A tool's schema lives on its `BeamMCP.ToolSpec`. `tools/list` advertises **that** schema and
`tools/call` enforces **that** schema, so the contract a client is shown and the contract it is
held to cannot drift apart. Argument keys are derived from the schema's `properties` and reach
`dispatch` as **atoms** — a tool declaring `"place"` is dispatched `%{place: "Oslo"}`, not
`%{"place" => "Oslo"}`. Values are passed through unchanged, because turning a string into a
domain term is the host's job and a generic layer that guesses has acquired someone else's
domain.

A `BeamMCP.ToolSpec` that omits `input_schema` is a tool with no arguments: it advertises an open
empty object, so `tools/call` refuses nothing and dispatch is handed `%{}` whatever the client
sent.

Validation is a deliberately small subset of JSON Schema — `type`, `properties`, `required`,
`additionalProperties`, and bounds. It refuses rather than guesses, and it is not a general
validator.

## Transports

**stdio** — `BeamMCP.Transport.Stdio.run/1`, newline-delimited JSON-RPC over a pipe.

**HTTP** — `BeamMCP.Transport.HTTP`, a `Plug` serving the `2026-07-28` stateless model at one
endpoint: no sessions, no `Mcp-Session-Id`, no SSE resumability. `plug` and `bandit` are optional
dependencies; a stdio-only host does not pull them in.

**If you add `plug` to a host that already has this package compiled, run
`mix deps.compile beam_mcp --force`.** The module is guarded by `Code.ensure_loaded?(Plug)`,
which is evaluated once at compile time and is not a tracked compile-time dependency, so adding
the dependency afterwards does not rebuild this package: `mix compile` reports success and
`BeamMCP.Transport.HTTP` does not exist. Changing this package's version rebuilds it and needs
no such step.

```elixir
Bandit.child_spec(
  plug: {BeamMCP.Transport.HTTP,
         catalog: MyApp.Catalog,
         dispatch: &MyApp.Dispatch.call/3,
         authorize: &MyApp.Auth.check/1,
         allowed_origins: ["https://app.example.com"]},
  port: 4000,
  ip: {127, 0, 0, 1}
)
```

Or mounted inside an existing router, where `forward` matches on a path prefix and the Plug
serves everything under it:

```elixir
defmodule MyApp.Router do
  use Plug.Router
  plug :match
  plug :dispatch

  forward "/mcp",
    to: BeamMCP.Transport.HTTP,
    init_opts: [
      catalog: MyApp.Catalog,
      dispatch: &MyApp.Dispatch.call/3,
      authorize: &MyApp.Auth.check/1,
      allowed_origins: ["https://app.example.com"]
    ]

  match _, do: send_resp(conn, 404, "")
end
```

**`authorize` and `allowed_origins` are required and have no defaults.** Omit either and the Plug
raises when it is initialised — at start, not on the first request.

That is deliberate. This package cannot decide who may call your tools: it has no view of your
identity model, and deciding for you would be claiming something it cannot keep. But serving
`tools/call` to anyone who can reach the port is a confused-deputy surface, and a README sentence
telling you to authenticate is documentation rather than a control. **A required argument with no
default is a contract, because you cannot start without answering it.** To accept every caller,
say so: `authorize: fn _conn -> :ok end`.

**`authorize/1` must not read the request body.** It runs before this Plug reads it, and
`Plug.Conn.read_body/2` can be called once: a host that consumes the body in `authorize/1`
leaves the transport nothing to parse, and the request fails as a parse error rather than as
whatever the host meant. Authorize on the `Plug.Conn` — headers, peer, assigns set by an earlier
plug — and if a decision genuinely needs the payload, make it in `dispatch/3`, which is handed
the decoded arguments.

Said plainly, because it is a real limitation and not a preference: **body-signature
authentication is not possible in `authorize/1`.** The callback runs before the body is read and
returns `:ok | {:error, reason}`, with no way to hand back the `conn` it read from. A host that
reads the body there does not get an error — a small request appears to work because the body is
already in the adapter's buffer, and a larger one hangs until the server's read timeout and then
returns `408` with the connection dead. Measured: 119 bytes `200`, 16 KiB and 200 KiB both `408`
after 15.0 s. Today the workarounds are a plug in front of this one that reads the body and re-supplies it,
or deciding in `dispatch/3`.

### `authorize_body/2`, the optional post-read hook

That design question is settled, and not by changing `authorize/1`. **`authorize/1` keeps its
position before the body read**, because that is what refuses an unauthenticated caller without
buffering megabytes on their behalf. A second, **optional** hook sits beside it:

```elixir
authorize_body: fn conn, body -> MyApp.Auth.verify_signature(conn, body) end
```

**`:authorize_body` is called after the body is read and before it is decoded, and the second
argument is the request body exactly as received.** Not a re-encoding of it: a signature covers
bytes, so a hook handed `Jason.encode!(Jason.decode!(body))` would reject every correct signature
while looking like a fault in the host's cryptography.

It is optional — absent, it is skipped and nothing changes. Present, it must be a 2-arity
function or the Plug raises at `init/1`, so a wrong arity is a startup failure rather than a
per-request one.

A refusal is opaque: **the reason goes to the log, never to the caller**, exactly as with
`authorize/1`, so a client cannot tell "no signature" from "bad signature". A refusal here
answers `403`; a hook that raises answers `500` and tells the caller nothing.

**A post-read refusal does not close the connection.** The pre-read refusals do, because the body
is still on the wire and the adapter would drain it; by the time this hook runs the body is read,
the connection is clean, and an ordinary response is possible.

**This package performs no cryptography.** The hook is named `:authorize_body` rather than
`:verify_signature` because verifying is the host's work; making it possible is this module's.

### Resources this Plug bounds, and the ones it does not

`@max_body_bytes` caps a single body at 1 MiB. Three things that is **not**:

- It is not an aggregate bound. Each in-flight request at the cap costs about 1.05 MiB, measured
  linear with no plateau to 8,000 concurrent (+8.16 GiB RSS). The concurrent-request ceiling is
  your HTTP server's: for `Bandit`/`ThousandIsland` it is `num_acceptors * num_connections`,
  defaulting to 100 × 16,384 = **1,638,400**. Setting it is the host's capacity decision, and a
  number this package picked for you would be one it cannot keep.
- It is not a ceiling on bytes read. What the **server** reads before refusing is the cap
  itself: a declared 32 MiB body is refused after `read_body/2` returns a partial of exactly
  **1,048,576 bytes**, constant across six socket-buffer settings and four runs. How much the
  **client** got onto the wire by then is a different quantity and not a property of this
  package — the same 24 measurements put it between 1.125 MiB and 7.438 MiB, varying run to run
  at one fixed buffer size — so there is no number to design against there, only the
  server-side constant above.
- It is not a time bound. A slow client is held by `read_body/2`'s `:read_timeout`, which this
  package does not set and therefore inherits from the server — 15,000 ms under `Bandit`. That is
  a whole-body deadline rather than a per-read reset, so a drip client is answered `408` at 15 s
  rather than held indefinitely. That makes slow connections a **transient** rather than a hold:
  they cost memory for at most the timeout, and a legitimate request was still served in under
  0.01 s with 12,000 of them in flight.

- It does not bound **headers**. `@max_body_bytes` is a body limit; the number and size of
  request headers are your HTTP server's settings, inherited the same way the read timeout is.

**A refusal issued before the body is read ends the connection, and says so.** The `Origin`
`403`, the `405`, `authorize/1`'s refusals and the body-cap `413` are all issued before this
Plug has read the request body, so each carries `connection: close`. Without it your server reads
that body anyway, on behalf of a caller this Plug has already refused — `Bandit` drains up to
8 MB, waiting up to its read timeout to do it — and past that it gives up and drops the
connection with nothing said to the client. A refusal issued *after* the body has been read
keeps the connection, because by then there is nothing left to drain. What this does not do is
get a pipelined second request answered: it cannot, and declining to read a refused caller's
body is the point.

`allowed_origins` is separate because the specification makes validating `Origin` a MUST, to
prevent DNS rebinding; which origins are legitimate is yours to say. `:any` is available and must
be chosen

…

## Source & license

This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [ScriptKittyOS](https://github.com/ScriptKittyOS)
- **Source:** [ScriptKittyOS/beam_mcp](https://github.com/ScriptKittyOS/beam_mcp)
- **License:** Apache-2.0

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/mcp-scriptkittyos-beam-mcp
- Seller: https://agentstack.voostack.com/s/scriptkittyos
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
