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
62 changes: 62 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,68 @@ All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.2] — unreleased

### Changed — two fields are REMOVED from results for legacy-declared requests

**Read this before upgrading if any client sends `_meta` naming `2025-11-25`.** A result
answering such a request no longer carries `resultType` or `_meta`
`io.modelcontextprotocol/serverInfo`. In `0.1.0` it carried both. This is not limited to
`ping` — it applies to every method reaching that path, `tools/list`, `tools/call` and
`shutdown` included:

0.1.0: tools/list + _meta 2025-11-25
-> {"result":{"_meta":{"io.modelcontextprotocol/serverInfo":{...}},"resultType":"complete","tools":[...]}}
0.1.2: tools/list + _meta 2025-11-25
-> {"result":{"tools":[...]}}

A client that reads `result.resultType` on that path gets `nil` after what is numbered a patch
release. It is numbered a patch because `0.y.z` is outside semver's compatibility contract and
because the removed fields were never correct — they announced a revision the client did not
ask for. **Neither of those makes the wire change smaller**, and the honest signal for a
published package where the JSON *is* the API would arguably be `0.2.0`. Recorded here as the
removal it is so the number is not the only thing a reader has to go on. Raised by review;
the version choice is the owner's at publish time.

Requests declaring `2026-07-28`, and requests with no `_meta` at all, are unaffected.

### Fixed

- **A request declaring `2025-11-25` through per-request `_meta` is now served as
`2025-11-25`.** The `_meta` clause branched on the method and never on the declared
revision, so `ping` was refused with `-32601` at every revision reaching it, and every
result was decorated with `resultType` and `_meta` `serverInfo` — two fields `2026-07-28`
introduced and `2025-11-25` does not define. Both halves came from one version-blind `cond`.

Measured against `0.1.1`, each message the first and only one on a fresh state. (`0.1.1`
here and `0.1.0` above name the **same** before-state: `0.1.1` changed documentation only,
so the version-blind clause is byte-identical at the `v0.1.0` tag and on `main`. Two numbers
for one behaviour, flagged by review as a readability trap.)

ping + _meta 2025-11-25 -> {"error":{"code":-32601,"message":"Method not found: ping"},...}
tools/list + _meta 2025-11-25
-> {"result":{"_meta":{"io.modelcontextprotocol/serverInfo":{...}},"resultType":"complete",...}}

and after:

ping + _meta 2025-11-25 -> {"id":1,"jsonrpc":"2.0","result":{}}
tools/list + _meta 2025-11-25 -> {"result":{"tools":[...]}} # no resultType, no _meta

Live in published `0.1.0`. The refusal is reachable only through `_meta`: a bare `ping`, and
a `_meta` carrying no `io.modelcontextprotocol/protocolVersion`, were always answered.

Why a `_meta` may name the legacy revision at all: this server advertises `2025-11-25` in
`server/discover` and lists it in the `-32022` `supported` payload, and the specification
tells a client receiving `-32022` to select from `supported` and **retry the request** —
which produces exactly this message. `_meta` fixes statelessness; the revision it names
fixes the semantics.

### Note on `0.1.1`

`0.1.1` reached `main` and was **never published to Hex**. `mix.exs` now reads `0.1.2`, so
`0.1.1` will not exist as a release and the moduledoc fix recorded below ships inside `0.1.2`.
The `0.1.1` section is left exactly as written; this note is appended rather than a rewrite.

## [0.1.1] — unreleased

### Fixed
Expand Down
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,37 @@ one legacy revision.

| | `2026-07-28` (modern) | `2025-11-25` (legacy) |
|---|---|---|
| opens with | any request, or `server/discover` | `initialize` |
| version travels in | `_meta` on every request | the `initialize` params |
| session | none; each request stands alone | yes |
| opens with | any request, or `server/discover` | `initialize`, or `_meta` naming it |
| version travels in | `_meta` on every request | the `initialize` params, or `_meta` |
| session | none; each request stands alone | tracked, not enforced — see below |
| `ping` | removed from the revision, refused | answered |
| result envelope | `resultType` and `_meta` `serverInfo` | neither; both are `2026-07-28` additions |

`server/discover`, `tools/list`, `tools/call`, `shutdown`, `exit` at both eras; `initialize`
and `notifications/initialized` at legacy only.

**A revision, not a carrier, decides the semantics.** `_meta` decides only that a request is
served statelessly. Which revision the `_meta` *names* then decides the method table and the
result envelope, so a `ping` declaring `2025-11-25` through `_meta` is answered and its result
carries no `resultType`. This matters because `-32022` tells a client to pick from `supported`
— which lists `2025-11-25` — and retry the request, so a `_meta` naming the legacy revision is
a message this server asks clients to send.

**Two exceptions, and they are exceptions to the row above.** `server/discover` and
`initialize` are matched *before* the revision switch, so neither is affected by what a `_meta`
declares and **neither result is decorated** — a `server/discover` result carries no
`resultType` even under `2026-07-28`, where the specification requires one on every result.
`server/discover` is matched first on purpose: on stdio it is the era probe, sent by a client
that does not yet know what it is talking to. The missing `resultType` on it is a known gap,
not a design choice.

**The session is tracked, not enforced.** Nothing in this package refuses a request because
`initialize` has not been seen: every method it implements is served bare, `tools/call`
included — and `tools/call` executes through the host's dispatch. On stdio that is defensible,
because whoever can write to the transport already has the host's privileges. **On any
transport where that is not true, refusing unestablished callers is the host's job, and this
package does not do it for you.**

A request naming a revision the server does not support gets `UnsupportedProtocolVersionError`
(**`-32022`**) listing what it does support. **`2024-11-05` is not supported** — it predates
the two chosen revisions.
Expand Down
56 changes: 41 additions & 15 deletions lib/beam_mcp/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,17 @@ defmodule BeamMCP.Server do
## Two eras

It serves `2026-07-28` and `2025-11-25`, and tells them apart the way the specification says
a dual-era server should: a request carrying per-request `_meta` is served statelessly under
the modern revision, and an `initialize` request selects legacy semantics. A request naming
a revision it does not support gets `UnsupportedProtocolVersionError` (`-32022`) listing
what it does.
a dual-era server should: a request carrying per-request `_meta` is served statelessly, and
an `initialize` request selects legacy semantics. `_meta` decides only the statelessness —
the revision it *names* then decides the method table and the result envelope, so a request
declaring `2025-11-25` through `_meta` gets that revision's semantics, not the modern ones.
A request naming a revision it does not support gets `UnsupportedProtocolVersionError`
(`-32022`) listing what it does.

Two methods are matched before that switch and so are served identically at both eras:
`server/discover`, which is the stdio era probe and must answer a client that does not yet
know what it is talking to, and `initialize`, which selects legacy semantics whatever else
it carries. Neither result is decorated.

## What the host supplies

Expand Down Expand Up @@ -116,22 +123,41 @@ defmodule BeamMCP.Server do
end
end

# A request carrying modern per-request _meta is served statelessly under 2026-07-28.
# A request carrying a _meta that NAMES A REVISION is served statelessly: no session,
# whatever revision it names. A _meta that is not a map, or that carries no version key,
# does not match this head at all and falls through to the handlers below.
# Which revision it names then decides the method table and the result envelope,
# because the spec requires every request to declare its version in _meta and requires the
# server to serve or refuse *that* version. Both branches below are reachable: -32022 tells
# a client to pick from `supported` -- which lists 2025-11-25 -- and retry the request,
# so a _meta naming the legacy revision is a message this server asks clients to send.
def handle_message(
state,
%{"jsonrpc" => "2.0", "id" => id, "_meta" => %{@version_meta_key => version}} = message
) do
cond do
version not in @supported_versions ->
bare = Map.drop(message, ["_meta"])

case version do
@modern_version ->
# ping was removed in 2026-07-28. The legacy handler below must not be inherited by a
# request that declared the modern revision.
if message["method"] == "ping" do
{state, error(id, -32_601, "Method not found: ping")}
else
{next, response} = handle_message(state, bare)
# `next`, not `state`: modernise/2 reads only server_name today, which nothing
# mutates, so this is currently indistinguishable -- and would stop being so the
# moment any handler changed a field modernise/2 reads.
{next, modernise(response, next)}
end

@legacy_version ->
# 2025-11-25 semantics: ping exists, and the result carries neither resultType nor
# serverInfo _meta, both of which 2026-07-28 introduced.
handle_message(state, bare)

_other ->
{state, unsupported_version(id, version)}

# ping was removed in 2026-07-28. The legacy handler must not inherit it.
message["method"] == "ping" ->
{state, error(id, -32_601, "Method not found: ping")}

true ->
{next, response} = handle_message(state, Map.drop(message, ["_meta"]))
{next, modernise(response, state)}
end
end

Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
defmodule BeamMCP.MixProject do
use Mix.Project

@version "0.1.1"
@version "0.1.2"
@source_url "https://github.com/ScriptKittyOS/beam_mcp"

def project do
Expand Down
Loading