Skip to content

fix(mcp): keep every known MCP revision negotiable under rmcp 3 - #337

Merged
jeremy-wayland merged 1 commit into
chore/rmcp-3x-264from
fix/mcp-protocol-negotiation
Aug 14, 2026
Merged

jeremy-wayland merged 1 commit into
chore/rmcp-3x-264from
fix/mcp-protocol-negotiation

Conversation

@sgathrid

@sgathrid sgathrid commented Aug 11, 2026

Copy link
Copy Markdown
Member

TL;DR

Do not merge #324 without this PR.

#324 narrowed SUPPORTED_PROTOCOL_VERSIONS to two MCP revisions. Under rmcp 3.1, that list is no longer advertisement-only — it also bounds initialize negotiation. Older clients quietly get 2025-11-25 instead of the version they asked for (or hard-fail on _meta).

This PR restores all five known revisions, pins rmcp to 3.1, and adds a regression test.

Stack #338 · base #324 → this → optional #339
Head fix/mcp-protocol-negotiation (91953b2)
Base chore/rmcp-3x-264 (#324)
Risk if skipped Hosts on 2024-11-05 / 2025-03-26 / 2025-06-18 stop negotiating correctly

Problem

Layer What #324 did What rmcp 3.1 actually does with that list
Intent Advertise only “tested” revisions on server/discover Same list feeds negotiate_protocol_version
initialize with e.g. 2025-06-18 Expected: speak that revision Actual: answers 2025-11-25 (quiet fallback)
Request _meta with dropped revision Hard error -32022

There is no per-revision code path in Topos below the 2026-07-28 boundary. Dropping revisions costs hosts and buys no safety.

Our own CI MCP smoke test already speaks 2025-06-18 and only greps tools/list — so the repo was already relying on that revision working.


Fix

  1. Restore all five ProtocolVersion values in SUPPORTED_PROTOCOL_VERSIONS (2024-11-052026-07-28).
  2. Keep the list explicit (don’t defer to rmcp’s default) so a future SDK revision is an intentional diff.
  3. Pin rmcp = "3.1" — negotiation semantics changed in a minor release; "3" is too loose.
  4. Test initialize_negotiates_every_supported_revision — handshake echoes each revision; tool surface stays the same.

Files

File Change
topos/mcp/src/server.rs Full version list + comment on pin intent
topos/mcp/Cargo.toml rmcp "3""3.1"
topos/mcp/tests/lifecycle.rs Negotiation regression test
CHANGELOG.md Unreleased bullet matches full-negotiation wording

How to review

  1. Read the short comment + list in server.rs (supported_protocol_versions).
  2. Skim the new test — it is the behavioral proof.
  3. Confirm CHANGELOG no longer says “narrowed to two revisions.”

No tool/resource/prompt surface changes.


Validate

cargo test -p topos-mcp --test lifecycle
cargo test --workspace

Merge

Order PR Notes
1 #324 rmcp 3 upgrade + lifecycle tests (still ships narrowed list alone — bad)
2 This (#337) Required companion — merge immediately after #324 (or squash together)
3 #339 Optional CI policy for stacked PRs

Do not land #324 without #337.

@sgathrid
sgathrid force-pushed the fix/mcp-protocol-negotiation branch from f1bd1c8 to 91953b2 Compare August 11, 2026 15:15
sgathrid added a commit that referenced this pull request Aug 11, 2026
`on.pull_request.branches` filters on a PR's *base*, and a stacked PR targets
its parent topic branch. The allowlist `[main,
worktree-rust-migration-v0.4.0, 'release/**']` therefore matched no stacked PR
at all: #337 (base `chore/rmcp-3x-264`) drew zero Actions runs, so a stacked
change was reviewed and merged with no verification behind it. `ci.yml` has no
`workflow_dispatch`, so it could not even be triggered by hand.

Widening the allowlist to topic-branch globs would admit every PR aimed at
anyone's branch, which is not the intent. Instead the trigger is unfiltered and
the policy moves into a `gate` job backed by `scripts/ci_gate.py`:

* `push` — already filtered by the trigger. Run.
* `pull_request` into a `TRUNK_PATTERNS` branch. Run, decided before any API
  call, so the path to `main` gains no new dependency.
* `pull_request` belonging to a GitHub stack, whatever it targets. Run.
* Any other `pull_request` — a one-off aimed at a topic branch. Skip.

Stack membership is real data, not inferred from branch names: the
`PullRequestStack` GraphQL API exposes `pullRequest.stack { number size }`,
which returns a stack for #337 and #324 (stack #338, positions 2 and 1) and
`null` for non-stacked PRs such as #320 and #318.

`--selftest` covers all twelve decision-table cases and runs as the gate's
first step, including the `push` path that could otherwise break `main` and a
check that `release/*` does not match across a slash. Actions globs are not
`fnmatch` — `*` stops at `/` while `**` crosses it — so the matcher is
implemented rather than delegated.

Unverifiable locally, and deliberately loud about it: whether an Actions
installation token can read `PullRequestStack` is a different question from
whether the logic is right. The local checks used a personal token. So the gate
prints the raw GraphQL response including any `errors` array, never fails its
own step, and fails *open* — an unreadable response runs CI with a `::warning::`
saying the stacked-PR-only policy is not in effect, rather than silently
skipping verification. This PR's own run is the test of that access.

No CHANGELOG entry: CI plumbing, per `.agents/AGENTS.md:90`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sgathrid sgathrid self-assigned this Aug 11, 2026

@jeremy-wayland jeremy-wayland left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

jeremy-wayland pushed a commit that referenced this pull request Aug 12, 2026
`on.pull_request.branches` filters on a PR's *base*, and a stacked PR targets
its parent topic branch. The allowlist `[main,
worktree-rust-migration-v0.4.0, 'release/**']` therefore matched no stacked PR
at all: #337 (base `chore/rmcp-3x-264`) drew zero Actions runs, so a stacked
change was reviewed and merged with no verification behind it. `ci.yml` has no
`workflow_dispatch`, so it could not even be triggered by hand.

Widening the allowlist to topic-branch globs would admit every PR aimed at
anyone's branch, which is not the intent. Instead the trigger is unfiltered and
the policy moves into a `gate` job backed by `scripts/ci_gate.py`:

* `push` — already filtered by the trigger. Run.
* `pull_request` into a `TRUNK_PATTERNS` branch. Run, decided before any API
  call, so the path to `main` gains no new dependency.
* `pull_request` belonging to a GitHub stack, whatever it targets. Run.
* Any other `pull_request` — a one-off aimed at a topic branch. Skip.

Stack membership is real data, not inferred from branch names: the
`PullRequestStack` GraphQL API exposes `pullRequest.stack { number size }`,
which returns a stack for #337 and #324 (stack #338, positions 2 and 1) and
`null` for non-stacked PRs such as #320 and #318.

`--selftest` covers all twelve decision-table cases and runs as the gate's
first step, including the `push` path that could otherwise break `main` and a
check that `release/*` does not match across a slash. Actions globs are not
`fnmatch` — `*` stops at `/` while `**` crosses it — so the matcher is
implemented rather than delegated.

Unverifiable locally, and deliberately loud about it: whether an Actions
installation token can read `PullRequestStack` is a different question from
whether the logic is right. The local checks used a personal token. So the gate
prints the raw GraphQL response including any `errors` array, never fails its
own step, and fails *open* — an unreadable response runs CI with a `::warning::`
saying the stacked-PR-only policy is not in effect, rather than silently
skipping verification. This PR's own run is the test of that access.

No CHANGELOG entry: CI plumbing, per `.agents/AGENTS.md:90`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeremy-wayland
jeremy-wayland force-pushed the fix/mcp-protocol-negotiation branch from 91953b2 to 5649217 Compare August 12, 2026 15:15
`31f0733` narrowed `SUPPORTED_PROTOCOL_VERSIONS` to `2025-11-25` and
`2026-07-28` on the reasoning that the three older revisions had no test
coverage. Under rmcp 3 that override is not advertisement-only: rmcp 3.1.0
(#1093) rewired `negotiate_protocol_version` to check the handler's list
where rmcp 2.2 checked the SDK's `ProtocolVersion::KNOWN_VERSIONS`, and
upstream documents the list as bounding `initialize` negotiation and
validating per-request `_meta` versions. Narrowing it therefore stopped
Topos speaking the revisions it dropped.

The failure is quiet rather than loud: `initialize` with `2025-06-18` does
not error, it answers `2025-11-25`, and per the MCP lifecycle spec a client
that does not support the returned version should disconnect. A request
declaring the older revision in `_meta` is a hard `-32022`.

The stated rationale does not hold, because there is no per-revision code
to cover. The only version-conditional behavior in rmcp sits at the
`2026-07-28` boundary (SEP-2243 headers, the inline lifecycle, per-request
`_meta`); every revision below it takes the same session-model path, and
Topos returns byte-identical payloads across all of them — verified by
diffing `tools/list` + `resources/list` output across negotiated
`2024-11-05`, `2025-06-18`, and `2025-11-25` (43279 bytes each, differing
only in response arrival order). Restoring the revisions costs no code
path and no maintenance; omitting them only costs hosts.

The list stays spelled out rather than deferring to rmcp's default so an
SDK upgrade that adds a revision is an explicit decision here. For the
same reason the `rmcp` bound moves from `"3"` to `"3.1"`: negotiation
semantics changed in a *minor* release, so a looser bound lets protocol
behavior move with no diff.

Behavioral proof: `initialize_negotiates_every_supported_revision` walks
all five revisions, asserting the handshake echoes each one and that the
session then serves the same tool surface. Reintroducing the narrowing
fails it (`left: "2025-11-25", right: "2024-11-05"`) alongside the
`server/discover` assertion, so the guard is verified against the actual
regression rather than assumed.

Verification: `cargo test --workspace` 647 passed / 0 failed;
`cargo clippy --workspace --all-targets` and `cargo fmt --all` clean;
`topos_assess_worktree_change` on `server.rs` reports LATERAL_MOVE with
verdict unchanged at SIMPLE_SECURE and all metric deltas 0 except
`ast.entropy` +0.003 from the added comments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jeremy-wayland pushed a commit that referenced this pull request Aug 12, 2026
`on.pull_request.branches` filters on a PR's *base*, and a stacked PR targets
its parent topic branch. The allowlist `[main,
worktree-rust-migration-v0.4.0, 'release/**']` therefore matched no stacked PR
at all: #337 (base `chore/rmcp-3x-264`) drew zero Actions runs, so a stacked
change was reviewed and merged with no verification behind it. `ci.yml` has no
`workflow_dispatch`, so it could not even be triggered by hand.

Widening the allowlist to topic-branch globs would admit every PR aimed at
anyone's branch, which is not the intent. Instead the trigger is unfiltered and
the policy moves into a `gate` job backed by `scripts/ci_gate.py`:

* `push` — already filtered by the trigger. Run.
* `pull_request` into a `TRUNK_PATTERNS` branch. Run, decided before any API
  call, so the path to `main` gains no new dependency.
* `pull_request` belonging to a GitHub stack, whatever it targets. Run.
* Any other `pull_request` — a one-off aimed at a topic branch. Skip.

Stack membership is real data, not inferred from branch names: the
`PullRequestStack` GraphQL API exposes `pullRequest.stack { number size }`,
which returns a stack for #337 and #324 (stack #338, positions 2 and 1) and
`null` for non-stacked PRs such as #320 and #318.

`--selftest` covers all twelve decision-table cases and runs as the gate's
first step, including the `push` path that could otherwise break `main` and a
check that `release/*` does not match across a slash. Actions globs are not
`fnmatch` — `*` stops at `/` while `**` crosses it — so the matcher is
implemented rather than delegated.

Unverifiable locally, and deliberately loud about it: whether an Actions
installation token can read `PullRequestStack` is a different question from
whether the logic is right. The local checks used a personal token. So the gate
prints the raw GraphQL response including any `errors` array, never fails its
own step, and fails *open* — an unreadable response runs CI with a `::warning::`
saying the stacked-PR-only policy is not in effect, rather than silently
skipping verification. This PR's own run is the test of that access.

No CHANGELOG entry: CI plumbing, per `.agents/AGENTS.md:90`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeremy-wayland
jeremy-wayland force-pushed the fix/mcp-protocol-negotiation branch from 5649217 to 6f3d343 Compare August 12, 2026 15:22
@jeremy-wayland
jeremy-wayland merged commit 12c3e59 into main Aug 14, 2026
45 checks passed
jeremy-wayland added a commit that referenced this pull request Aug 14, 2026
* ci: run CI on stacked PRs, gated on real stack membership

`on.pull_request.branches` filters on a PR's *base*, and a stacked PR targets
its parent topic branch. The allowlist `[main,
worktree-rust-migration-v0.4.0, 'release/**']` therefore matched no stacked PR
at all: #337 (base `chore/rmcp-3x-264`) drew zero Actions runs, so a stacked
change was reviewed and merged with no verification behind it. `ci.yml` has no
`workflow_dispatch`, so it could not even be triggered by hand.

Widening the allowlist to topic-branch globs would admit every PR aimed at
anyone's branch, which is not the intent. Instead the trigger is unfiltered and
the policy moves into a `gate` job backed by `scripts/ci_gate.py`:

* `push` — already filtered by the trigger. Run.
* `pull_request` into a `TRUNK_PATTERNS` branch. Run, decided before any API
  call, so the path to `main` gains no new dependency.
* `pull_request` belonging to a GitHub stack, whatever it targets. Run.
* Any other `pull_request` — a one-off aimed at a topic branch. Skip.

Stack membership is real data, not inferred from branch names: the
`PullRequestStack` GraphQL API exposes `pullRequest.stack { number size }`,
which returns a stack for #337 and #324 (stack #338, positions 2 and 1) and
`null` for non-stacked PRs such as #320 and #318.

`--selftest` covers all twelve decision-table cases and runs as the gate's
first step, including the `push` path that could otherwise break `main` and a
check that `release/*` does not match across a slash. Actions globs are not
`fnmatch` — `*` stops at `/` while `**` crosses it — so the matcher is
implemented rather than delegated.

Unverifiable locally, and deliberately loud about it: whether an Actions
installation token can read `PullRequestStack` is a different question from
whether the logic is right. The local checks used a personal token. So the gate
prints the raw GraphQL response including any `errors` array, never fails its
own step, and fails *open* — an unreadable response runs CI with a `::warning::`
saying the stacked-PR-only policy is not in effect, rather than silently
skipping verification. This PR's own run is the test of that access.

No CHANGELOG entry: CI plumbing, per `.agents/AGENTS.md:90`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(ci): correct how TRUNK_PATTERNS relates to the trigger filters

The comment described `TRUNK_PATTERNS` as mirroring the old
`on.pull_request.branches` allowlist "plus `on.push.branches`", but the two
differ: the push filter has no `release/**`. Reading it as a union would suggest
a push to `release/*` consults the list, when the trigger never fires for one at
all — pushes are filtered before the gate runs, and `decide()` admits any non-PR
event outright.

Also states plainly that nothing enforces the correspondence with the YAML, so
the sync is by hand.

Comments only; no behavior change. `--selftest` 12/12, `actionlint` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* remove rust migration branch

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: jeremy-wayland <jeremy.don.wayland@gmail.com>
@jeremy-wayland jeremy-wayland mentioned this pull request Aug 24, 2026
13 tasks
@jeremy-wayland
jeremy-wayland deleted the fix/mcp-protocol-negotiation branch September 4, 2026 19:19
This was referenced Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants