Install
$ agentstack add mcp-scriptkittyos-beam-mcp ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo 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.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
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 →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_bytesis 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
- Source: ScriptKittyOS/beam_mcp
- License: Apache-2.0
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.