Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,45 @@ All notable changes to this project are documented here. The format follows

## [Unreleased]

### Changed — BREAKING, and it breaks a host contract rather than the wire

- **`BeamMCP.ToolCatalog` is replaced by `BeamMCP.Catalog`, and `all/0` by `capabilities/0`.**
Every host implementing a catalog must change. While this package is `0.x` a break lands at
the **minor** position, so this is `0.4.0` and `~> 0.3.0` — the requirement the README
recommends — already excludes it. No consumer is carried across by a routine
`mix deps.update`; that is what the tight pin is for.

`capabilities/0` returns `%{tools: [ToolSpec.t()], resources: [], prompts: []}`. `resources`
and `prompts` are **required and may be empty**. Nothing reads them yet. They exist so that
serving resources and prompts later adds a reader rather than changing this contract a second
time — the break is taken once, now, before those slices exist.

**The callback is renamed, not just re-typed.** Keeping `all/0` while changing its return from
a list to a map would compile against every existing host and fail at the first request with a
`BadMapError`. Renaming makes the break arrive at compile time as an unimplemented callback.

The option is `:catalog`, not `:tool_catalog` — a catalog carrying resources and prompts is
not a tool catalog, and renaming it in the same break costs less than a second one later.

**Migration:**

```elixir
# before
@behaviour BeamMCP.ToolCatalog
def all, do: [%BeamMCP.ToolSpec{...}]

# after
@behaviour BeamMCP.Catalog
def capabilities, do: %{tools: [%BeamMCP.ToolSpec{...}], resources: [], prompts: []}
```

- **A malformed catalog is refused by `BeamMCP.Server.new/1`**, at startup, with a message naming
what is wrong — an absent key, a non-list `:tools`, an entry that is not a `%ToolSpec{}`, a
`capabilities/0` that does not return a map, or a module that does not export it.
`Transport.HTTP.init/1` checks only that the callback is exported, deliberately: under Plug's
default initialisation it runs at the host's **compile** time, where calling a catalog that
reads config would fail for a correct host.

### Added

- **`:authorize_body`, an optional post-read authorization hook.** `authorize/1` runs before the
Expand Down
27 changes: 18 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,23 @@ not. The tighter form is deliberate and is not an over-pin to be tidied away.

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

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

`capabilities/0` returns a map with three required keys. `resources` and `prompts` may be empty
and nothing reads them yet; they are required so that serving them later adds a reader rather
than changing this contract a second time. **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.

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

@impl true
def all do
[
def capabilities do
%{
resources: [],
prompts: [],
tools: [
%BeamMCP.ToolSpec{
name: :get_weather,
command_class: :observe,
Expand All @@ -50,7 +58,8 @@ defmodule MyApp.Catalog do
"additionalProperties" => false
}
}
]
]
}
end
end
```
Expand All @@ -65,13 +74,13 @@ end

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

`:tool_catalog` is required. `:dispatch` is required for `tools/call`. `:server_name` defaults
`: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
Expand Down Expand Up @@ -110,7 +119,7 @@ no such step.
```elixir
Bandit.child_spec(
plug: {BeamMCP.Transport.HTTP,
tool_catalog: MyApp.Catalog,
catalog: MyApp.Catalog,
dispatch: &MyApp.Dispatch.call/3,
authorize: &MyApp.Auth.check/1,
allowed_origins: ["https://app.example.com"]},
Expand All @@ -131,7 +140,7 @@ defmodule MyApp.Router do
forward "/mcp",
to: BeamMCP.Transport.HTTP,
init_opts: [
tool_catalog: MyApp.Catalog,
catalog: MyApp.Catalog,
dispatch: &MyApp.Dispatch.call/3,
authorize: &MyApp.Auth.check/1,
allowed_origins: ["https://app.example.com"]
Expand Down
154 changes: 154 additions & 0 deletions lib/beam_mcp/catalog.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0

defmodule BeamMCP.Catalog do
@moduledoc """
The contract a host implements to tell a `BeamMCP.Server` what it offers.

The server holds no catalog of its own. It advertises what `capabilities/0` returns and
accepts a `tools/call` only for a tool `capabilities/0` names, so one implementation governs
both — a tool advertised by `tools/list` and refused by `tools/call` is the defect this
behaviour exists to make impossible.

## The shape carries keys this package does not yet serve

%{tools: [BeamMCP.ToolSpec.t()], resources: [], prompts: []}

`resources` and `prompts` are **required and may be empty**. Nothing reads them today. They
are here so that serving them later adds a reader rather than changing this contract a second
time, and every host that has already written `capabilities/0` keeps working when they do.

A key that is absent is a malformed catalog, not an empty one — the two are different claims
and only one of them is checkable.

## Why `capabilities/0` and not `all/0`

This behaviour replaces `BeamMCP.ToolCatalog`, whose callback was `all/0` returning a list.
Keeping the name while changing the return from a list to a map would compile against every
existing host and fail at the first request with a `BadMapError` — a silent shape change,
which is the defect class this repository keeps finding. Renaming makes the break arrive at
compile time as an unimplemented callback, which is the loudest place it can arrive.
"""

@typedoc """
What a host offers. Every key is required; `resources` and `prompts` are typed as generic
lists because nothing in this package reads them yet, and typing them precisely now would be
a claim about a shape no code enforces.
"""
@type t :: %{
required(:tools) => [BeamMCP.ToolSpec.t()],
required(:resources) => list(),
required(:prompts) => list()
}

@callback capabilities() :: t()

@required_keys [:tools, :resources, :prompts]

@doc """
The tools a catalog offers.

**This is the single reader, and that is the point rather than a convenience.** Both paths go
through it: `tools/list` advertises what it returns, and `fetch/2` decides callability from
the same call. Slice 002 fixed a real defect where advertising honoured an injected catalog
and calling ignored it — two readers, two answers. One function is how that stays fixed, and
a mutant that gives the two paths different sources is scored in
`slices/008-catalog-generalization/`.
"""
@spec tools(module()) :: [BeamMCP.ToolSpec.t()]
def tools(catalog), do: catalog.capabilities().tools

@doc """
Finds the spec a tool name refers to, or `:error`.

One lookup, used by every caller that has to answer "which tool does this name mean". The
core uses it to decide whether a `tools/call` is callable at all; the HTTP transport uses it
to read the `x-mcp-header` annotations it must validate against. Two implementations of this
question would be two answers, which is precisely the header-versus-body disagreement the
transport's validation exists to prevent.

## This function raises on a malformed catalog, and the `@spec` does not say so

Stated rather than caught, and the reason is the guarantee above. A host bug turned into
`:error` is indistinguishable from "no such tool" — so a catalog that is broken would present
exactly as a catalog that is working and simply does not have that tool. That is the
advertise-versus-call disagreement this behaviour exists to prevent, reintroduced by the
error handling meant to be defensive.

Measured on this tree rather than inherited from a prior note
(`slices/008-catalog-generalization/logs/probe-fetch-spec.txt`):

host spec.name is a binary, not an atom ArgumentError
capabilities/0 returns a non-list map BadMapError
capabilities/0 returns nil Protocol.UndefinedError
module is not loaded / does not exist UndefinedFunctionError

Use `validate/1` to refuse these at startup instead, which is what `BeamMCP.Server.new/1`
does.
"""
@spec fetch(module(), String.t() | atom()) :: {:ok, BeamMCP.ToolSpec.t()} | :error
def fetch(catalog, name) when is_atom(name), do: fetch(catalog, Atom.to_string(name))

def fetch(catalog, name) when is_atom(catalog) and is_binary(name) do
catalog
|> tools()
|> Enum.find_value(:error, fn spec ->
if Atom.to_string(spec.name) == name, do: {:ok, spec}
end)
end

def fetch(_catalog, _name), do: :error

@doc """
Checks that a module is a usable catalog, returning `:ok` or `{:error, reason}`.

Calls `capabilities/0`, so it belongs where host code may safely run. `BeamMCP.Server.new/1`
calls it; the HTTP transport's `Plug` callback `init/1` deliberately does not, because Plug's
default initialisation is the host's **compile** time and a catalog reading config or ETS
there would fail for a correct host. That transport checks the export and leaves the shape to
`new/1`. (`init/1` is not linked here: it is a hidden callback, and a doc reference to it is a
broken link rather than a useful one.)
"""
@spec validate(module()) :: :ok | {:error, String.t()}
def validate(catalog) when is_atom(catalog) and not is_nil(catalog) do
with {:module, _} <- Code.ensure_compiled(catalog),
true <- function_exported?(catalog, :capabilities, 0) do
validate_shape(catalog)
else
_ -> {:error, "#{inspect(catalog)} does not export capabilities/0"}
end
end

def validate(other), do: {:error, "expected a module, got: #{inspect(other)}"}

defp validate_shape(catalog) do
case catalog.capabilities() do
%{} = caps ->
validate_keys(catalog, caps)

other ->
{:error, "#{inspect(catalog)}.capabilities/0 must return a map, got: #{inspect(other)}"}
end
rescue
e -> {:error, "#{inspect(catalog)}.capabilities/0 raised #{inspect(e.__struct__)}"}
end

defp validate_keys(catalog, caps) do
missing = Enum.reject(@required_keys, &Map.has_key?(caps, &1))

cond do
missing != [] ->
{:error,
"#{inspect(catalog)}.capabilities/0 is missing required key(s): #{inspect(missing)}"}

not is_list(caps.tools) ->
{:error, "#{inspect(catalog)}.capabilities/0's :tools must be a list"}

not Enum.all?(caps.tools, &match?(%BeamMCP.ToolSpec{}, &1)) ->
{:error, "#{inspect(catalog)}.capabilities/0's :tools must all be %BeamMCP.ToolSpec{}"}

true ->
:ok
end
end
end
41 changes: 34 additions & 7 deletions lib/beam_mcp/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
# SPDX-License-Identifier: Apache-2.0

defmodule BeamMCP.Server do
alias BeamMCP.Catalog
alias BeamMCP.Schema
alias BeamMCP.ToolCatalog

@moduledoc """
The protocol core: one message in, one response out, no process and no state of its own.
Expand All @@ -30,7 +30,7 @@ defmodule BeamMCP.Server do
## What the host supplies

BeamMCP.Server.new(
tool_catalog: MyApp.Catalog, # required, a BeamMCP.ToolCatalog
catalog: MyApp.Catalog, # required, a BeamMCP.Catalog
dispatch: &MyApp.Dispatch.call/3, # required for tools/call
server_name: "my-app" # optional, defaults to "beam_mcp"
)
Expand Down Expand Up @@ -70,7 +70,7 @@ defmodule BeamMCP.Server do
initialized?: boolean(),
server_name: String.t(),
shutdown?: boolean(),
tool_catalog: module(),
catalog: module(),
tools_cache_scope: String.t(),
tools_ttl_ms: non_neg_integer()
}
Expand All @@ -83,7 +83,7 @@ defmodule BeamMCP.Server do
initialized?: false,
server_name: Keyword.get(opts, :server_name, @default_server_name),
shutdown?: false,
tool_catalog: Keyword.fetch!(opts, :tool_catalog),
catalog: fetch_catalog!(opts),
# 2026-07-28 requires ttlMs and cacheScope on tools/list results. Neither is the
# package's to invent: ttlMs is a freshness hint about a catalog the host owns, and
# cacheScope is a disclosure decision -- "public" lets shared intermediaries cache a
Expand Down Expand Up @@ -183,7 +183,7 @@ defmodule BeamMCP.Server do
end

def handle_message(state, %{"jsonrpc" => "2.0", "id" => id, "method" => "tools/list"}) do
tools = Enum.map(state.tool_catalog.all(), &tool_definition/1)
tools = state.catalog |> Catalog.tools() |> Enum.map(&tool_definition/1)

# CacheableResult: 2026-07-28 requires both fields on tools/list. They are carried at
# both eras rather than only the modern one -- 2025-11-25 permits any result structure,
Expand Down Expand Up @@ -299,8 +299,35 @@ defmodule BeamMCP.Server do
defp stringify(value), do: Jason.encode!(value)

# One lookup governs both paths: a tool is callable exactly when the injected catalog names
# it, and the spec it returns carries the schema that will be enforced.
defp find_tool(state, name), do: ToolCatalog.fetch(state.tool_catalog, name)
# it, and the spec it returns carries the schema that will be enforced. Both this and the
# advertisement above go through `Catalog.tools/1`, so there is one reader rather than two
# call sites that could drift apart.
defp find_tool(state, name), do: Catalog.fetch(state.catalog, name)

# THE SHAPE IS REFUSED HERE, not at the first request. `new/1` is runtime, so calling the
# host's `capabilities/0` is safe -- unlike `Transport.HTTP.init/1`, which under Plug's
# default init_mode is the host's COMPILE time. Same pattern as `:authorize`: a host that
# mis-wires this learns when it starts, not from a BadMapError in a request path.
defp fetch_catalog!(opts) do
catalog = Keyword.fetch!(opts, :catalog)

case Catalog.validate(catalog) do
:ok ->
catalog

{:error, reason} ->
raise ArgumentError, """
BeamMCP.Server requires a :catalog implementing the BeamMCP.Catalog behaviour.

#{reason}

capabilities/0 must return a map with all three keys; resources and prompts may be
empty, but an absent key is a malformed catalog rather than an empty one:

%{tools: [%BeamMCP.ToolSpec{}], resources: [], prompts: []}
"""
end
end

# The advertised schema is the contract. Validate the wire form -- string keys, as the
# client sent them -- before normalising, so `required` and `additionalProperties`
Expand Down
35 changes: 0 additions & 35 deletions lib/beam_mcp/tool_catalog.ex

This file was deleted.

Loading