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
69 changes: 69 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,75 @@ All notable changes to this project are documented here. The format follows

## [Unreleased]

### Changed (BREAKING, the host contract): the request's context reaches dispatch under `:mcp`

- The host's dispatch function receives, in its third argument, its own `dispatch_opts`
followed by one reserved key, `:mcp`: a map of the request's `params._meta` as the client
sent it (string keys, `%{}` when it sent none), the client's capabilities (the request's own
in `_meta`, else what `initialize` declared; `nil` when there are none, or when they are not
an object), and the protocol revision the request is served under (the one its `_meta` names,
else `2025-11-25`). It is the client's word: authorise on the transport's hooks, not on it. Before, the core stripped `params._meta` before
`tools/call` was matched and kept no client capabilities, so a host could not see a trace id
or a capability the client declared. Fields added later join this map at a minor.
`BeamMCP.Server.new/1` refuses `dispatch_opts` carrying `:mcp`, and so does
`BeamMCP.Transport.HTTP` at init.
**How to tell whether you are affected:** your `dispatch_opts` has a key named `:mcp` (rename it); or your dispatch
function checks its third argument as a whole: matches it exactly (`fn _, _, [secret: s] ->`),
compares it (`opts == []`), or validates it against a closed key list (`Keyword.validate!/2`,
`NimbleOptions.validate!/2`, which now raise on `:mcp`): match or fetch the keys you read
(`Keyword.fetch!(opts, :secret)`); or you log or forward the whole of it, which now carries
the client's `_meta`: drop `:mcp` first (`Keyword.delete(opts, :mcp)`).

### Changed (BREAKING, the exported bytes): `schema_version` 4 and the `resource_template` node kind

- A resource template is a node of the declared connectome, of the new kind
`:resource_template`, identified by its uri template (`srv/resource_template/<template>`), in
a key space of its own. Before, every template a catalog offered was enumerated as an
unreadable catalog entry and had no node. The canonical bytes move to `schema_version` `4`
because their node vocabulary grew; the envelope's shape is unchanged, so a graph with no
template differs from its `3` bytes in the version alone, and bytes at `3` stay verifiable
by the same rule (`docs/connectome-canonical.md`, *Versions*).
**How to tell whether you are affected:** you verify or store the canonical bytes and pin `schema_version` `3`, or you
match a node's kind exhaustively (accept `4`, and `resource_template`); or you build a graph
with a literal `schema_version: 3` (`BeamMCP.Connectome.Graph.new/1` now refuses it, as it refused `2` in
0.6.0: pass `BeamMCP.Connectome.Graph.schema_version()`); or your catalog offers resource
templates and you read the bound's `unreadable_catalog_entries` (now empty) or track the
diff record's `declared_nodes` and `nodes_in_both` over time: both count one more per
template, while the diff record's own `schema_version` stays `3`, because its bytes carry
node ids and never a node's kind.

### Changed: the wire's bytes are the same on every OTP release

- Every JSON value the transports write has its object keys in sorted order at every depth,
the tool result written as text inside `content` included, and a refusal names the first
offending property, and lists unknown ones, in sorted order. A struct is written by its own
`Jason.Encoder`, as before; no struct reaches the wire from this package today. A map of more than 32 keys
iterates in hash order, and that order differs between OTP releases: one `tools/list` whose
schema had 40 properties was measured hashing differently on OTP 28.5.0.8 and OTP 29.1.
The content is unchanged; a client that compares decoded JSON sees nothing, and one that
hashes or caches the bytes now sees one answer per content.

### Added: what may grow, and how (`docs/api-stability.md`)

- A growth table marks every union of atoms in a public type OPEN (it may gain a value at a
minor, so code keeps a catch-all) or CLOSED; every one is OPEN today, and so is every value
set not in the table, error reasons included. It is held by a census over the compiled
types. The page also states that encodings a shared contract governs are versioned, not
frozen; that the request context grows inside `:mcp`; that the wire's bytes are stable in
1.x; and that a callback joins a behaviour only as optional.
- `BeamMCP.JSON.decode/1` promises its two named error reasons and leaves every other reason
unspecified (it is the decoder's); its spec says `term()` where it said `struct()`.

### Changed: the will-not-implement page gains entry 13, and entry 12 draws its line at `input_required`

- Entry 13: the package never advertises a change notification it does not deliver: every
`listChanged` and `subscribe` flag is `false` while `subscriptions/listen` is not served,
and `subscriptions/listen` and `resources/subscribe` are answered `-32601` (`404` over HTTP).
Nothing a client sees changes; the rule is written down and held by a test.
- Entry 12 now says the package never writes an `input_required` result, where it said the one
result type written is `complete`, so that another result type the specification defines
can be added without crossing the boundary.

### Changed: a request the HTTP adapter rejects is the adapter's to answer

- An exception raised inside a call this transport makes into the adapter (reading the
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ does not advertise.
@type dispatch :: (atom(), map(), keyword() -> {:ok, term()} | {:error, term()})
```

The third argument is the host's own `dispatch_opts` plus one reserved key, `:mcp`: the
request's context, `%{meta: ..., client_capabilities: ..., protocol_version: ...}`, where
`meta` is the request's `params._meta` exactly as the client sent it. Fields added later join
that map, so match the keys you read.

## Running it

```elixir
Expand Down Expand Up @@ -197,7 +202,7 @@ A `BeamMCP.ToolSpec` that omits `input_schema` is a tool with no arguments: it a
empty object, so `tools/call` refuses nothing and dispatch is handed `%{}` whatever the client
sent.

Dispatch receives the declared properties only: a property outside `properties` is held to
Dispatch's arguments are the declared properties only: a property outside `properties` is held to
`additionalProperties` (refused when it is `false`, checked when it is a schema) and is not
passed on.

Expand Down Expand Up @@ -577,7 +582,7 @@ cryptographic posture, and what a FIPS-mode host needs from it, are
- **Declared**: `BeamMCP.Connectome.Declared.build/1` reads what *can* happen: the catalog,
the call edges of the modules in scope from their beams (OTP's `:xref`), and a grouping of
modules by OTP application. Beside the graph it returns a completeness bound: every dynamic
dispatch site, callee outside the scope and unreadable entry, enumerated rather than guessed.
dispatch site and callee outside the scope, enumerated rather than guessed.
- **Observed**: `BeamMCP.Connectome.Observed` reads what *did* happen: a `:telemetry` span on
the one dispatch site and a host-started ETS collector, plus an optional, off-by-default,
guarded tracer for module-level edges (`BeamMCP.Connectome.Tracer`). Edge identity only:
Expand Down Expand Up @@ -662,7 +667,7 @@ there. A small surface can look unfinished from the outside; this one is a commo
that says which it is. The list is derived, not typed: the thesis sentence at the top of this
README, the census test that no line under `lib/` writes a sign other than `:unset`, and a
test that no line under `lib/` names a receipt, an approval, a risk tier or egress. The full
boundary (twelve entries, each with the test that enforces it by path and by name) is
boundary (thirteen entries, each with the test that enforces it by path and by name) is
[`docs/will-not-implement.md`](docs/will-not-implement.md), held to the tests in both
directions by a census of its own; a request to cross it is answered by pointing there. What
the package defends against on the wire, and what it hands to the server or the host, is
Expand Down
66 changes: 65 additions & 1 deletion docs/api-stability.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ and metadata (`docs/connectome-observed.md`); and the tracer's exit reasons, doc
fields of the public structs and the shapes behind the `@type`s and `@spec`s. A field renamed
or a return shape changed leaves `Module kind name/arity` untouched, so the census does not see
it; such a change is a break like any other and is a reviewer's line and a CHANGELOG entry,
not a census failure. **Not promised anywhere:** the shape of `inspect/1` output, the text of
not a census failure, unless it is growth that *What may grow, and how*, below, allows. **Not promised anywhere:** the shape of `inspect/1` output, the text of
log lines and error messages, the names of the tracer's processes and trace session, and the
layout of the observed collector's ETS rows beyond the `row/0` type
`BeamMCP.Connectome.Observed` documents.
Expand All @@ -96,6 +96,70 @@ and deprecates; a patch fixes. `1.0.0` follows once the public API and the state
have each survived a full minor release unchanged (the README's condition, and the only one
stated); `UPGRADING.md` carries what a `0.x` consumer must do to reach it.

## What may grow, and how

1.0.0 freezes this package's own call surface. It does not freeze a value set that the
protocol, a shared contract (the canonical bytes, the signature schemes, the digest names) or a
system built beside this one may still extend. Five rules say how each kind of thing grows.

**Value sets: open by default.** Every named type, and every public struct field, whose
definition is a union of atoms is listed below, OPEN or CLOSED. An OPEN set may gain a value at a minor release: code that matches on it keeps
a catch-all clause, and a value it does not know is not an error. A CLOSED set changes only at
a major. Every value set not in the table, an atom nested in a list, a tuple or an error reason
included, is OPEN. Every error reason a function returns is OPEN in particular, and
`BeamMCP.JSON.decode/1` promises the two reasons it names (`{:nesting, depth, max}`,
`{:duplicate_key, key}`) and nothing about any other: which decoder produced it, and in which
shape, is unspecified. The table is held by `test/beam_mcp/growth_table_test.exs` "every atom
union in a public type has exactly one row, with its atoms and OPEN or CLOSED", which reads the
compiled types, so a union that grows, shrinks or appears without its row fails.

| Type | Values | Growth |
| -- | -- | -- |
| `t:BeamMCP.Connectome.Node.kind/0` | `:server` `:tool` `:resource` `:resource_template` `:prompt` `:process` `:module` | OPEN |
| `t:BeamMCP.Connectome.Node.level/0` | `:mfa` `:module` `:boundary` `:server` | OPEN |
| `t:BeamMCP.Connectome.Edge.kind/0` | `:invoke` `:read` `:message` `:supervise` | OPEN |
| `t:BeamMCP.Connectome.Edge.provenance/0` | `:declared` `:observed` | OPEN |
| `t:BeamMCP.Connectome.Edge.sign/0` | `:allow` `:deny` `:hold` `:ungoverned` `:unset` | OPEN |
| `t:BeamMCP.Connectome.Canonical.algorithm/0` | `:sha256` `:sha384` `:sha512` | OPEN |
| `t:BeamMCP.Connectome.Canonical.scheme/0` | `:ed25519` `:ecdsa_p384_sha384` `:mldsa87` | OPEN |
| `t:BeamMCP.ToolSpec.t/0` field `mode` | `:read_only` `:proposal` | OPEN |

None is CLOSED today. A set is CLOSED only when it is this package's alone and a consumer's
exhaustive match on it is the point; the column exists so that such a set can say so.

**Encodings: versioned, not frozen.** An encoding a shared contract governs carries its
`schema_version` inside the bytes. It changes only as a new version at a minor, and bytes at an
older version are never rewritten and keep verifying by their own version's rule. The canonical
connectome bytes are the first: `4` since the node vocabulary gained `resource_template`, with
the bytes `0.6.0` to `0.10.1` wrote at `3` kept as goldens and verified under the code that
writes `4` (`test/beam_mcp/connectome/canonical_test.exs` "versioned, not frozen: bytes at 3
still verify under the code that writes 4"; `docs/connectome-canonical.md`, *Versions*). In
`0.x` such a version is a documented break, like every break. From `1.0.0` it is the one change
a minor may make to bytes a consumer pins: the alternative is freezing a contract other systems
share. It ships with a CHANGELOG heading naming the new version and a "how to tell" sentence;
the old version's bytes keep verifying; and code that builds the structure passes the version
the package exports (`BeamMCP.Connectome.Graph.schema_version/0`), never a literal, which a
new version refuses.

**Request context: one reserved key.** What a request carries beyond its arguments reaches the
host's dispatch under the `:mcp` key of its third argument (the request's `params._meta`, the
client's capabilities, the revision served; `BeamMCP.Server` `t:dispatch/0`). A later field,
the next protocol addition that rides in `_meta`, joins that map at a minor, never as a new
function or a new argument; a host matches the keys it reads.

**Wire bytes: stable in 1.x.** Every JSON value the transports write has its object keys in
sorted order at every depth (a struct is written by its own `Jason.Encoder`), and a refusal
walks a client's arguments in sorted order too, so a response's bytes are a function of its
content and not of the OTP release's map iteration order (`test/beam_mcp/wire_bytes_test.exs` pins one sha256 per
response on every CI leg). Field values are the protocol's; a field the protocol adds is an
addition to the bytes, not a break.

**Behaviours: callbacks are added optional.** A callback joins `BeamMCP.Catalog` or
`BeamMCP.Server` at a minor only under `@optional_callbacks`, never as required: a module
written against the earlier behaviour keeps compiling without a warning. `BeamMCP.Signer` is the
one exception by design: its single callback is a boundary (will-not-implement entry 3), held
exactly by `test/beam_mcp/boundary/no_signature_test.exs`, and it does not grow.

## Deprecation: three steps, three minors

A public entry that is going to leave goes in three steps, each on the record:
Expand Down
Loading
Loading