AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
MCP verified Apache-2.0 Self-run

Beam Mcp

mcp-scriptkittyos-beam-mcp · by ScriptKittyOS

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

— No reviews yet
0 installs
13 views
0.0% view→install

Install

$ agentstack add mcp-scriptkittyos-beam-mcp

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • ✓ Prompt-injection patterns
  • ✓ Secret / credential exfiltration
  • ✓ Dangerous shell & filesystem operations
  • ✓ Untrusted network calls
  • ✓ Known-malicious package signatures

What it can access

  • ✓ Network access No
  • ✓ Filesystem access No
  • ✓ Shell / process execution No
  • ✓ Environment & secrets No
  • ✓ Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/mcp-scriptkittyos-beam-mcp)

Reliability & compatibility

✓ Security review passed
0 installs to date
— no reviews yet
● 10d ago

Declared compatibility

Claude CodeClaude DesktopCursorWindsurf

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of Beam Mcp? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

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.

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.

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.

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

Running it

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.

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:

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:

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.

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

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.