Skip to content

A moved type is a binary break: the MCP module could not construct its plugin (#2370) - #2397

Merged
rbuergi merged 4 commits into
mainfrom
fix/2370-mcp-module-binary-contract
Aug 26, 2026
Merged

rbuergi merged 4 commits into
mainfrom
fix/2370-mcp-module-binary-contract

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Closes #2370.

The failure

Every MCP tool call from every external client of the deployment died in the McpMeshPlugin
constructor:

System.TypeLoadException: Could not load type 'MeshWeaver.AI.MeshOperations'
    from assembly 'MeshWeaver.AI, Version=3.0.0.0, Culture=neutral, PublicKeyToken=null'

Not search — all of them. The MCP SDK builds its tool target per invocation
(CreateTarget → ActivatorUtilities.CreateInstance), so the failure sits in a constructor every
call has to run: get, create, update, render_area, the LSP and chunk tools alike.

Root cause, from evidence

the move #2283 (b53aed0aa, merged 2026-08-26 00:56Z) took MeshOperations, MeshExportManifest, MeshExportFileEntry, NodeReadOutcome out of MeshWeaver.AI into the new MeshWeaver.Mesh.Operations, renaming their namespace to MeshWeaver.Mesh
what a module binds not a using — TypeRef MeshWeaver.AI.MeshOperations scoped to the MeshWeaver.AI AssemblyRef
the shipped module MeshWeaver.Mcp 1.1.4, built before the move
first sighting 2026-08-26 12:25Z, hours after the platform rolled

The module lane's gate is a semver floor, never MVID equality, precisely so that "a landed
module keeps loading across ordinary platform updates"
(Doc/Architecture/Modules → the skip
rules). That sentence is a promise about binary compatibility. Moving a public type to another
assembly, or renaming its namespace, breaks it for every module compiled earlier.

The verification that passed was the wrong one. #2283 was checked by building the plugins
repo's MeshWeaver.Mcp against the branch — 0 errors. That answers "does the module's SOURCE still
compile", not "does the module ALREADY PUBLISHED still bind"; the module's source imports both
namespaces. landed-modules-gate has the identical blind spot by construction and went green on the
change that caused the outage. check-record-signatures.py (#2298) covers the constructor half of
this class and stops at constructors. The semver floor cannot see a type at all.

The fix

Keep the move, restore the names, forward the types.

  • The four types keep their original full names — namespace MeshWeaver.AI, inside
    MeshWeaver.Mesh.Operations — and src/MeshWeaver.AI/TypeForwards.cs carries
    [assembly: TypeForwardedTo] for each. The CLR resolves the module's old TypeRef through
    MeshWeaver.AI to one type identity, not a shim (a shim would mint a second identity and
    reintroduce the as/is trap-door AGENTS.md forbids).
  • A forwarder cannot rename, so the namespace is now marked frozen where it is declared, and
    NodeReadOutcome gets its original name back (NodeReadResult was a rename chosen to dodge a
    collision that exists only in MeshWeaver.Mesh).

…and the class, not just the instance

scripts/check-type-forwards.py refuses any public type moving between src/ assemblies without a
forwarder for its original full name.

  • 17 self-tests, run as their own step before the gate, so a broken matcher fails loudly
    rather than passing everything.
  • Verified in situ against real history. Replaying the breaking merge names all four types and
    exits 1:
    python3 scripts/check-type-forwards.py --base b53aed0aa^1 --head b53aed0aa
    
    Across the last 25 merges on main it reports zero.
  • An assembly that leaves the repo entirely is deliberately out of scope, with a self-test
    pinning it — without that rule the Blazor exit alone produces 15 false positives, and a gate that
    cries wolf teaches people to allow-list.
  • The allow file is a statement that no shipped module can hold the TypeRef; a stale entry fails,
    same ratchet as the repo's others.

It runs beside #2298's gate in the same job, renamed Public surface (binary compatibility).

MovedTypeBinaryContractTest proves the forwarders resolve the way a module's TypeRef does — with
TypeForwards.cs removed, 5 of its 6 assertions fail; the sixth pins the names from the other
side (compile-time typeof).

Verification

  • Full solution build under CI's flags (-c Release -p:CIRun=true -warnaserror) — 0 warnings, 0 errors.
  • The plugins repo's src/MeshWeaver.Mcp at its own main, built against this branch with
    -p:MeshWeaverRoot=… — 0 warnings, 0 errors. That is landed-modules-gate parity for the
    module that broke, so no MeshWeaver.Plugins change is required: with the forwarders, a bundle
    built before or after MeshOperations is not AI — it leaves the AI assembly (#2276 step A1) #2283 loads.
  • MovedTypeBinaryContractTest + NodeReadOutcomeClassifierTest: 16 passed.
  • WhatsNewEntryIntegrityTest, DocumentationLinkIntegrityTest, MigrationWorkloadModelGuard: 5 passed.

Not fixed here — reported separately

Running the new gate over v3.0.0-rc7 → main finds 17 unguarded public-type moves; this PR
covers four. Three of the remaining thirteen have a proven module consumer in the plugins repo
(IMcpBackConnection, McpConnectionInfo, IProviderKeyProtector) and are already being worked on
in dedicated branches. Two groups (MeshWeaver.GitSync → MeshWeaver.AI, MeshWeaver.Hosting →
MeshWeaver.AI) cannot be forwarded at all — the forwarder must live in the assembly being left,
which would need a reference cycle — so those require either moving the type back or a genuinely
atomic republish (#2234). Details in the follow-up issue.

…s plugin (#2370)

Every MCP tool call from every external client of the deployment died in the `McpMeshPlugin`
constructor:

    System.TypeLoadException: Could not load type 'MeshWeaver.AI.MeshOperations'
        from assembly 'MeshWeaver.AI, Version=3.0.0.0, Culture=neutral, PublicKeyToken=null'

Not `search` — ALL of them. The MCP SDK builds its tool target per invocation
(`McpServerBuilderExtensions.CreateTarget` -> `ActivatorUtilities.CreateInstance`), so the failure
sits in a constructor every single call has to run: `get`, `create`, `update`, `render_area`, the
LSP and chunk tools alike. A full outage of the `/mcp` surface.

**Root cause.** #2283 moved `MeshOperations`, `MeshExportManifest`, `MeshExportFileEntry` and
`NodeReadOutcome` out of `MeshWeaver.AI` into the new `MeshWeaver.Mesh.Operations`, renaming their
namespace to `MeshWeaver.Mesh` on the way. A module's IL holds neither a `using` nor a source
reference — it holds `TypeRef MeshWeaver.AI.MeshOperations` scoped to the `MeshWeaver.AI`
AssemblyRef — so the store-installed `MeshWeaver.Mcp` 1.1.4, built before the move, bound a name
that no longer existed. The module lane's gate is a semver floor and deliberately never MVID
equality, precisely so that "a landed module keeps loading across ordinary platform updates"
(Modules.md). That is a promise about BINARY compatibility, and this broke it.

**The verification that passed was the wrong one.** #2283 was checked by BUILDING the plugins
repo's `MeshWeaver.Mcp` against the branch — 0 errors. That answers "does the module's SOURCE still
compile", which is a different question from "does the module ALREADY PUBLISHED still bind"; the
module's source carries `using` directives for both namespaces. `landed-modules-gate` has the same
blind spot by construction and went green on the change that caused the outage.

**Fix: keep the move, restore the names, forward the types.** The assembly move stands. The four
types keep their ORIGINAL full names (`namespace MeshWeaver.AI`, inside `MeshWeaver.Mesh.Operations`)
and `MeshWeaver.AI` carries `[assembly: TypeForwardedTo]` for each, so the CLR resolves the old
TypeRef through the old assembly to ONE type identity — not a shim, which would mint a second
identity and reintroduce the `as`/`is` trap-door AGENTS.md forbids. A forwarder cannot rename, which
is why the namespace is now marked frozen where it is declared, and why `NodeReadOutcome` gets its
original name back (`NodeReadResult` was a rename to dodge a collision that exists only in
`MeshWeaver.Mesh`).

**And the class, not just the instance.** `scripts/check-type-forwards.py` refuses any public type
moving between `src/` assemblies without a forwarder for its original full name. It carries 17
self-tests (wired as its own step before the gate, so a broken matcher fails loudly), and it is
verified in situ against real history: replaying the breaking merge — `--base b53aed0^1 --head
b53aed0` — it names all four types and exits 1, while across the last 25 merges on main it reports
zero. It runs beside #2298's `check-record-signatures.py`, which covers the primary-constructor half
of the same class; the job is renamed *Public surface (binary compatibility)* to cover both. An
assembly that leaves the repo entirely is deliberately out of scope, with a self-test pinning that
(without it the Blazor exit alone produces 15 false positives).

`MovedTypeBinaryContractTest` proves the forwarders resolve the way a module's TypeRef does: with
`TypeForwards.cs` removed, 5 of its 6 assertions fail; the sixth pins the names from the other side.

Verified: full solution build under CI's flags (`-c Release -p:CIRun=true -warnaserror`) — 0
warnings, 0 errors — and the plugins repo's `src/MeshWeaver.Mcp` at its own `main` built against this
branch with `-p:MeshWeaverRoot=…`, also clean, which is landed-modules-gate parity for the module
that broke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 26, 2026 15:44
@rbuergi

rbuergi commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up for the remaining exposure filed as #2398 — 13 more unguarded moves since v3.0.0-rc7, three with proven module consumers, two groups that cannot be forwarded at all.

…ch tip

A diff gate that anchors on `origin/main` re-reads every change that lands there while a PR is
open. For this one that is not cosmetic: a public type moving on main shows up, against the tip, as
THIS PR moving it BACK — a red on somebody else's change, on the gate whose whole value is that
people believe it. Four such moves landed on main inside one day (#2398), so the window is not
hypothetical.

`actions/checkout` builds a merge commit for a pull_request, so `git merge-base origin/<base> HEAD`
resolves to the base tip that commit was formed from — which keeps the comparison exactly this PR's
own diff even after main moves on.

`check-record-signatures.py` (#2298) has the same latent anchoring in its step; left alone here and
noted on #2398 rather than widened into this fix.

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

`parse_forwards` reads the text inside `typeof(...)`, so it only recognised the fully-qualified
spelling. A forwarder written `[assembly: TypeForwardedTo(typeof(MeshOperations))]`, resolved
through the file's usings, is the identical metadata — and the gate would have demanded a forwarder
that was already there, which is the fastest way to teach someone to allow-list past it.

Matched within the leaving assembly only, so it cannot be satisfied by a same-named type's forwarder
somewhere else. 18th self-test pins it.

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

rbuergi commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

⏳ Not mergeable yet, and not because of this branch: GitHub Actions is in a major_outage.

https://www.githubstatus.com — "Incident with Actions", opened 2026-08-26 15:11Z; at 15:48Z:
"primary failover briefly improved performance but did not fully mitigate, we've throttled inbound
traffic and are investigating upstream Vitess issues."

The symptom on this PR, so it is not mistaken for a branch problem later:

  • the first commit created Clients (green), Hosting Operator (stuck queued) and the Copilot
    review run, but no MeshWeaver Build and Test run at all;
  • the two later commits created no Actions runs whatsoever — the head commit carries only the
    permanently-queued Azure Boards suite;
  • other PRs show the same shape: three MeshWeaver Build and Test runs on fix/2384-… sitting
    queued since 15:29Z, and concurrency not collapsing them.

Nothing to re-run and nothing to fix here: run creation is what is failing. Held at the merge
gate until Consolidate test results is green on the head commit. A watch is armed on this PR for
CI success, CI failure, a new review thread, DIRTY, and merge/close.

Verified locally in the meantime: full solution build under CI's flags (0 warnings, 0 errors), the
plugins repo's src/MeshWeaver.Mcp built against this branch (0 warnings, 0 errors — the
landed-modules-gate question for the module that broke), the new gate's 18 self-tests, its in-situ
replay of the breaking merge, and the affected test classes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Restores binary compatibility for store-installed modules (notably MeshWeaver.Mcp) after public type moves by re-exposing moved types under their original MeshWeaver.AI full names via type forwarders, and adds CI/test/documentation guards to prevent reintroducing this class of break.

Changes:

  • Re-freezes moved public types under namespace MeshWeaver.AI and adds MeshWeaver.AI type forwarders so older modules’ TypeRef bindings keep resolving after platform updates.
  • Adds a binary-contract test (MovedTypeBinaryContractTest) plus updates existing tests to pin the “frozen” namespace expectation.
  • Introduces a new CI gate (scripts/check-type-forwards.py + workflow wiring + allowlist file) and documents the binary-compatibility contract in Modules and What’s New.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tools/MeshWeaver.PluginTester/GateAllowlist.cs Clarifies record-signature gate naming/reference in docs comment.
test/MeshWeaver.Hosting.Monolith.Test/NodeTypeCompileParkTest.cs Pins MeshWeaver.AI namespace usage for MeshOperations in tests.
test/MeshWeaver.Hosting.Monolith.Test/CodeEditRecompileTest.cs Pins MeshWeaver.AI namespace usage for MeshOperations in tests.
test/MeshWeaver.Autocomplete.Test/AutocompleteMultiSourceTest.cs Pins MeshWeaver.AI namespace usage for MeshOperations in tests.
test/MeshWeaver.AI.Test/NodeReadOutcomeClassifierTest.cs Updates test to the restored NodeReadOutcome name.
test/MeshWeaver.AI.Test/MovedTypeBinaryContractTest.cs New test asserting old module-style type resolution still works and forwarders are real forwarders.
src/MeshWeaver.Mesh.Operations/README.md Documents the “frozen namespace” binary contract and the #2370 failure mode.
src/MeshWeaver.Mesh.Operations/NodeReadOutcome.cs Restores MeshWeaver.AI namespace + NodeReadOutcome name for binary compatibility.
src/MeshWeaver.Mesh.Operations/MeshOperations.cs Restores MeshWeaver.AI namespace and documents why it must not change.
src/MeshWeaver.Mesh.Operations/MeshExportManifest.cs Restores MeshWeaver.AI namespace for export types’ binary identity.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-26-mcp-tools-work-again-after-a-platform-update.md New release note explaining the outage and the fix.
src/MeshWeaver.Documentation/Data/Architecture/Modules.md Documents the platform’s binary-compatibility obligation for module loading.
src/MeshWeaver.AI/TypeForwards.cs Adds [assembly: TypeForwardedTo] entries for the moved types.
scripts/type-forwards.allow Adds allowlist mechanism for intentional, safe moves (ratcheted).
scripts/check-type-forwards.py New gate to refuse public type moves without forwarders.
memex/Memex.LocalMesh/LocalMeshApiEndpoints.cs Pins MeshWeaver.AI namespace usage for MeshOperations in memex local mesh endpoints.
.github/workflows/dotnet-test.yml Renames the job and wires in the new type-move binary-compatibility gate steps.
Suppressed comments (1)

scripts/check-type-forwards.py:145

  • After allowing indentation in TYPE_RE, parse_declarations should filter out nested types (and avoid treating 4-space-indented nested types in file-scoped-namespace files as top-level declarations). Without this, the gate risks both false negatives (still missing block-namespace top-level types) and false positives (nested types inside file-scoped namespaces).
def parse_declarations(path: str, text: str) -> list[Decl]:
    """Public top-level types declared by one file, with the namespace in force."""
    assembly = _assembly_of(path)
    namespaces = [(m.start(), m.group("ns")) for m in NAMESPACE_RE.finditer(text)]
    out: list[Decl] = []
    for m in TYPE_RE.finditer(text):
        ns = ""
        for start, candidate in namespaces:
            if start < m.start():

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/check-type-forwards.py Resolved
…aimed to cover (review)

Copilot's review caught a real hole: `TYPE_RE` was anchored at column 0, and 24 files under `src/`
still declare a BLOCK-scoped namespace, which indents every top-level type by four
(`MeshWeaver.Data/TypeDescription.cs`). Those 31 public types — `TypeDescription`, `SchemaInfo`,
`SynchronizationAddress`, `IMessageHubGrain`, `PartitionedHubDataSource`, … — were invisible to the
gate, which would have reported green while covering less than it said. That is the same defect
class the gate exists to catch, one level up.

The indent is now CAPTURED instead of forbidden, and nesting is excluded by comparing it against
the enclosing namespace's FORM: a file-scoped `namespace X;` puts top-level types at column 0, a
block-scoped one at column 4 (brace on the same line or the next), and anything deeper is a nested
type, which no module binds by its own simple name.

Three self-tests pin it, including the nested-inside-a-block-namespace case that a naive "allow any
indent" fix would wrongly pick up — 20 in total. Unchanged where it matters: the in-situ replay of
the breaking merge still names exactly the four types, and the sweep over the last 25 merges on
main still reports zero.

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

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

    9 files      9 suites   3m 58s ⏱️
1 946 tests 1 944 ✅ 2 💤 0 ❌
2 512 runs  2 510 ✅ 2 💤 0 ❌

Results for commit 4e0d50a.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

599 tests   595 ✅  15m 47s ⏱️
  7 suites    4 💤
  7 files      0 ❌

Results for commit 4e0d50a.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

    9 files      9 suites   7m 21s ⏱️
1 609 tests 1 608 ✅ 1 💤 0 ❌
1 610 runs  1 609 ✅ 1 💤 0 ❌

Results for commit 4e0d50a.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

2 878 tests   2 775 ✅  8m 21s ⏱️
    9 suites    103 💤
    9 files        0 ❌

Results for commit 4e0d50a.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

2 027 tests   1 834 ✅  9m 28s ⏱️
    9 suites    193 💤
    9 files        0 ❌

Results for commit 4e0d50a.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

2 194 tests   2 192 ✅  15m 4s ⏱️
   10 suites      2 💤
   10 files        0 ❌

Results for commit 4e0d50a.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

    53 files      53 suites   1h 0m 1s ⏱️
11 251 tests 10 946 ✅ 305 💤 0 ❌
11 820 runs  11 515 ✅ 305 💤 0 ❌

Results for commit 4e0d50a.

@rbuergi
rbuergi merged commit 037b40c into main Aug 26, 2026
27 checks passed
@rbuergi
rbuergi deleted the fix/2370-mcp-module-binary-contract branch August 28, 2026 08:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants