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
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ fails quietly against the revision most evaluators would try first.
- Argument keys are derived from the tool's schema; values pass through unchanged, since
coercing a string to a domain term belongs to the host (`c5da02f`).

### Fixed

- **Error payloads no longer carry `inspect/1` output.** A failed call returned Elixir term
syntax — a map literal and a bare atom — to a client with no way to parse it and no reason
to know the server's language. `structuredContent` now carries the error as a JSON object
and `content` carries a sentence.

### Removed

- Per-tool `input_schema/1` clauses, `input_schema_for/1`, the hardcoded argument-key
Expand Down Expand Up @@ -84,7 +91,6 @@ Each is additive and can be adopted without a breaking change.

- CI has not run against this history at the time of writing; a committed workflow is not a
working one until a run exists.
- Error payloads carry `inspect/1` output, so Elixir term syntax reaches the wire. A boundary
defect at every revision, and its own slice before publish.
- Streamable HTTP and the rest of the non-stdio surface (see below).
- Streamable HTTP, `subscriptions/listen`, MRTR, tasks, authorization, elicitation, sampling
and roots are not implemented. This is a stdio, tools-only server.
32 changes: 24 additions & 8 deletions lib/beam_mcp/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -195,16 +195,34 @@ defmodule BeamMCP.Server do
}
end

# An error crossing the wire carries JSON. `structuredContent` gets the term as data so a
# client can read a field; `content` gets a sentence a person can read. Neither carries
# Elixir syntax: a caller has no reason to know what language this is written in, and no way
# to parse its terms.
defp tool_failure(reason) do
message = format_reason(reason)

%{
"content" => [%{"type" => "text", "text" => message}],
"structuredContent" => %{"error" => message},
"content" => [%{"type" => "text", "text" => error_text(reason)}],
"structuredContent" => %{"error" => to_json_value(reason)},
"isError" => true
}
end

defp error_text(reason) when is_binary(reason), do: reason

defp error_text(%{"tool" => tool, "reason" => detail}), do: "#{tool}: #{detail}"

defp error_text(reason) when is_map(reason) do
reason
|> to_json_value()
|> Enum.map_join(", ", fn {key, value} -> "#{key}: #{stringify(value)}" end)
end

defp error_text(reason), do: stringify(to_json_value(reason))

defp stringify(value) when is_binary(value), do: value
defp stringify(value) when is_number(value), do: to_string(value)
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) when is_binary(name) do
Expand All @@ -228,7 +246,8 @@ defmodule BeamMCP.Server do
state.dispatch.(spec.name, args, state.dispatch_opts)

{:error, reason} ->
{:error, %{tool: spec.name, reason: "invalid arguments: #{reason}"}}
{:error,
%{"tool" => Atom.to_string(spec.name), "reason" => "invalid arguments: #{reason}"}}
end
end

Expand Down Expand Up @@ -270,9 +289,6 @@ defmodule BeamMCP.Server do
defp to_json_value(value) when is_atom(value), do: Atom.to_string(value)
defp to_json_value(value), do: value

defp format_reason(reason) when is_binary(reason), do: reason
defp format_reason(reason), do: inspect(reason)

defp unsupported_version(id, requested) do
%{
"jsonrpc" => "2.0",
Expand Down
100 changes: 100 additions & 0 deletions test/beam_mcp/error_payload_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0

defmodule BeamMCP.ErrorPayloadTest do
@moduledoc """
An error crossing the wire carries JSON, not Elixir.

A client has no reason to know what language the server is written in, and no way to parse
its term syntax. `inspect/1` output in a response is both unusable and a disclosure of
implementation detail across the boundary this package exists to keep clean.
"""
use ExUnit.Case, async: true

alias BeamMCP.Server

defmodule Catalog do
@behaviour BeamMCP.ToolCatalog

@impl true
def all do
[
%BeamMCP.ToolSpec{
name: :widget,
command_class: :observe,
mode: :read_only,
description: "d",
input_schema: %{
"type" => "object",
"properties" => %{"a" => %{"type" => "string"}},
"required" => ["a"]
}
}
]
end
end

defp call(dispatch, args) do
Server.new(tool_catalog: Catalog, dispatch: dispatch)
|> Server.handle_message(%{
"jsonrpc" => "2.0",
"id" => 1,
"method" => "tools/call",
"params" => %{"name" => "widget", "arguments" => args}
})
|> elem(1)
end

defp ok_dispatch, do: fn _n, a, _o -> {:ok, a} end

# Elixir syntax that must never appear in a wire payload: map literals, bare atoms.
defp elixir_syntax?(text), do: text =~ ~r/%\{|(?<![\w:]):[a-z_]+\b/

test "a validation failure carries structured fields, not an inspected map" do
r = call(ok_dispatch(), %{})

err = r["result"]["structuredContent"]["error"]

assert is_map(err), "the error is a JSON object a client can read, not a stringified term"
assert err["tool"] == "widget"
assert err["reason"] =~ "required"
refute elixir_syntax?(Jason.encode!(err))
end

test "the human-readable content carries no Elixir syntax either" do
r = call(ok_dispatch(), %{})

text = hd(r["result"]["content"])["text"]

refute elixir_syntax?(text),
"the text a user is shown must not be an inspected Elixir term: #{text}"

assert text =~ "widget"
assert text =~ "required"
end

test "a host's error term is carried as JSON, not as an inspected term" do
r =
call(fn _n, _a, _o -> {:error, %{code: "upstream_timeout", retry_after: 30}} end, %{
"a" => "x"
})

err = r["result"]["structuredContent"]["error"]

assert err["code"] == "upstream_timeout"
assert err["retry_after"] == 30
refute elixir_syntax?(Jason.encode!(err))
end

test "a host's plain-string error survives unchanged" do
r = call(fn _n, _a, _o -> {:error, "upstream unavailable"} end, %{"a" => "x"})

assert r["result"]["structuredContent"]["error"] == "upstream unavailable"
assert hd(r["result"]["content"])["text"] == "upstream unavailable"
end

test "every error result is still flagged isError" do
assert call(ok_dispatch(), %{})["result"]["isError"]
assert call(fn _n, _a, _o -> {:error, "x"} end, %{"a" => "y"})["result"]["isError"]
end
end
3 changes: 2 additions & 1 deletion test/beam_mcp/tool_spec_schema_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ defmodule BeamMCP.ToolSpecSchemaTest do
assert resp["result"]["isError"],
"the server accepted a call that violates the schema its own catalog advertises"

assert resp["result"]["structuredContent"]["error"] =~ "widget_id"
# The error is a JSON object now, not a string: `=~` no longer applies to it.
assert resp["result"]["structuredContent"]["error"]["reason"] =~ "widget_id"
end

test "a call carrying a property the catalog's schema forbids is refused" do
Expand Down
Loading