From df3f761e84ee736252de7a2a07c242728583a647 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 20 Oct 2026 04:26:03 -0400 Subject: [PATCH] Freeze readiness: nothing a shared contract may still move is frozen at 1.0.0 1.0.0 freezes this package's own call surface. This makes everything a protocol revision, a shared encoding or a system beside this one may still extend either open to growth or versioned, so 1.0.0 does not lock it. Each change was red before its fix. - Wire bytes. Every JSON value the transports write has its object keys in sorted order at every depth, through one internal writer (BeamMCP.JSON.encode!/2, not public). A refusal walks a client's arguments in sorted order too. Before, maps of more than 32 keys were written in hash order: one tools/list with a 40-property schema hashed e6d4e13e... on OTP 28.5.0.8 and 09fd2985... on OTP 29.1. Now both give 40d948fc..., pinned on every leg. - Request context (BREAKING, the host contract). Dispatch's third argument is the host's dispatch_opts plus a reserved :mcp key: the request's params._meta as sent, the client's capabilities and the revision the request is served under. One request's _meta never reaches the next. new/1 and the HTTP transport's init refuse a host's own :mcp. - The resource_template node kind (BREAKING, the exported bytes). A resource template is a node of the declared connectome, identified by its uri template. Before, it was enumerated as unreadable. The canonical bytes move to schema_version 4 because their node vocabulary grew. The envelope's shape is unchanged, and bytes at 3 still verify: 0.6.0 to 0.10.1's goldens are kept and checked under the new code. - docs/api-stability.md, "What may grow, and how": a growth table marks every named atom-union type and struct field OPEN or CLOSED (all eight are OPEN), held by a census over the compiled types. The section also covers versioned encodings, the :mcp key, stable wire bytes, and callbacks added only as optional. BeamMCP.JSON.decode/1 leaves every error reason but its two named ones unspecified. - Will-not-implement entry 13: no listChanged or subscribe is advertised while subscriptions/listen is not served; both are answered -32601, and 404 over HTTP. Entry 12 now draws its line at input_required rather than "only complete". Measured locally: the full gate on OTP 28.1.1 with Elixir 1.18.4 prints "Gate OK"; the wire hashes agree on OTP 28.5.0.8 and 29.1. OTP 27 has not been run locally; this pull request's CI is that measurement. Signed-off-by: Ayla Croft --- CHANGELOG.md | 69 +++++++ README.md | 11 +- docs/api-stability.md | 66 +++++- docs/connectome-canonical.md | 39 ++-- docs/connectome.md | 12 +- docs/threat-model.md | 6 +- docs/will-not-implement.md | 7 +- lib/beam_mcp/connectome/declared.ex | 27 ++- lib/beam_mcp/connectome/graph.ex | 5 +- lib/beam_mcp/connectome/node.ex | 17 +- lib/beam_mcp/cursor.ex | 2 +- lib/beam_mcp/json.ex | 43 +++- lib/beam_mcp/schema.ex | 19 +- lib/beam_mcp/server.ex | 88 +++++++- lib/beam_mcp/transport/http.ex | 24 ++- lib/beam_mcp/transport/stdio.ex | 4 +- livebooks/connectome.livemd | 2 +- livebooks/exports/fx.declared.json | 2 +- livebooks/exports/fx.diff.json | 2 +- livebooks/exports/fx.observed.json | 2 +- livebooks/exports/fx.observed.sidecar.json | 2 +- test/beam_mcp/boundary/no_mrtr_test.exs | 26 ++- test/beam_mcp/boundary/package_reach_test.exs | 7 +- test/beam_mcp/connectome/canonical_test.exs | 43 +++- test/beam_mcp/connectome/declared_test.exs | 19 +- test/beam_mcp/connectome/graph_test.exs | 11 +- test/beam_mcp/connectome/node_test.exs | 3 +- test/beam_mcp/connectome/observed_test.exs | 3 +- test/beam_mcp/connectome_vocabulary_test.exs | 12 +- test/beam_mcp/growth_table_test.exs | 99 +++++++++ test/beam_mcp/readme_claims_test.exs | 49 +++++ test/beam_mcp/request_context_test.exs | 195 ++++++++++++++++++ test/beam_mcp/server_test.exs | 3 +- test/beam_mcp/threat_model_test.exs | 8 +- test/beam_mcp/unserved_subscription_test.exs | 103 +++++++++ test/beam_mcp/wire_bytes_test.exs | 146 +++++++++++++ test/beam_mcp/wire_recording_test.exs | 3 +- test/fixtures/connectome/golden.json | 2 +- test/fixtures/connectome/golden.sha256 | 2 +- test/fixtures/connectome/golden.sha384 | 2 +- test/fixtures/connectome/golden.sha512 | 2 +- test/fixtures/connectome/golden.sidecar.json | 2 +- test/fixtures/connectome/golden.v3.json | 1 + .../connectome/golden.v3.json.license | 2 + test/fixtures/connectome/golden.v3.sha256 | 1 + .../connectome/golden.v3.sha256.license | 2 + 46 files changed, 1072 insertions(+), 123 deletions(-) create mode 100644 test/beam_mcp/growth_table_test.exs create mode 100644 test/beam_mcp/request_context_test.exs create mode 100644 test/beam_mcp/unserved_subscription_test.exs create mode 100644 test/beam_mcp/wire_bytes_test.exs create mode 100644 test/fixtures/connectome/golden.v3.json create mode 100644 test/fixtures/connectome/golden.v3.json.license create mode 100644 test/fixtures/connectome/golden.v3.sha256 create mode 100644 test/fixtures/connectome/golden.v3.sha256.license diff --git a/CHANGELOG.md b/CHANGELOG.md index b46cbfb2..0c7df3fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/