Release notes

Changes per released version of the Architecture MCP server, newest first. Starts at 26.3.1; earlier versions shipped without release notes.


26.5.11 — 2026-09-14

Changed


26.5.10 — 2026-09-14

Added

You cannot write to it, and neither can your agent. Every entry is a side effect of a tool called for another reason. There is no record_prevention; a number the measured party can influence is worth nothing to the person being shown it.

It records outcomes, not just intentions. A warning on its own only says the agent asked. But the server rescans, so it can close the loop: a flagged pair that never appears in a later rescan's added dependencies was held; one where the component then checked a different target, got a clean answer and wrote that instead is redirected — the strongest evidence here; one that shows up anyway is overridden, reported as prominently as the rest, because a report that counts only its wins is an advertisement.

Read the activity figures first. dependenciesAdded, violationsAdded and the two cyclicity totals are the denominator: "41 proposals flagged" is unfalsifiable alone, while "3,412 dependencies added, no violation ever added, component cyclicity down 1,020, and 41 proposals flagged along the way" is a claim every term of which was measured.

The reply carries a caveat field stating the limit, and it should be passed on with the numbers: none of this is a counterfactual. held means a flagged proposal was not written — not that it would have been written without the warning.

The ledger starts empty, so a project that upgrades today reports zeroes until it is used, and it is per-checkout rather than committed (an append-only file written from several machines conflicts on every line).

The reply now carries cycleImpact, computed from the dependency graph and independent of any rules. It reports whether the edge leaves the endpoints in a cycle together — newly formed, or an existing one grown to take them in — at component and at package granularity, separately, because two files in one package can close a component cycle without touching the package graph — and for each, how many nodes the new cycle would hold, how far the cyclicity metric would move, whether your tolerance policy excuses it, and which components or packages would be caught in it.

Two numbers are worth reading carefully. size is usually much larger than two: the new cycle absorbs everything on a path from the target back to the source, so a single import can fuse a whole subsystem. cyclicityDelta is discounted by the cycles the new one swallows — a 2-cycle scoring 4 taken into a 3-cycle scoring 9 moves the metric by 5, not 9 — and it is produced by assessing the graph with the edge through the same machinery that assesses it without, so it is precisely what rescan_sources will report if you write the dependency.

absorbs says what kind of mistake you are looking at. It lists the cycles that already exist and would be swallowed, largest first. A size of 43 whose absorbs names a 42 means you are adding one component to a blob that was there before you arrived; the same 43 with no absorbs at all means the code was acyclic and this edge is what tangles it. Those deserve different answers, and size alone cannot tell them apart — nor can size and cyclicityDelta together distinguish joining one tangle from fusing two. Absorbing a tolerated cycle discounts nothing, because it scores 0 today, so each entry carries its own tolerated flag.

A cycle your tolerance policy excuses is still reported, with cyclicityDelta: 0 and tolerated: true. Tolerance decides what the metric counts, not what is structurally true.

When no successful scan has happened yet, assessed is false and a reason says so. That is "cannot tell", not a clearance.


26.5.9 — 2026-09-11

Added

The weld object now carries tolerated, and its reason names the metric that scores it zero. This matters because such a blocker is invisible to both ratchets, so driving the metrics down — which is what the product asks of you — will never lead you to it. On one project a tolerated two-component cycle withheld 224 points of package cyclicity reachable by file moves alone and took four sessions to find; on gradle, 16 of 26 welds are tolerated cycles. Cutting one is worth doing and you should expect the metrics not to move.


26.5.8 — 2026-09-11

Added

Loaded rules imply direction was considered, and violationEdges reinforces the impression. On the same real cut that was false comfort: all eleven relations lay inside one artifact, so not one of the project's 22 violations touched the cut — and seven of the eleven pointed the architecturally natural way, meaning a caller who applied the list would have improved cyclicity and worsened the design. Read the flag as: this is a cyclicity answer, not an architectural one. Judge direction yourself, or give your rules something to say about that region.

It is suppressed entirely when no .arc is loaded, where every relation would carry it and a caveat that is always true is not a signal.


26.5.7 — 2026-09-11

Changed

It is now a list, each relation carrying from, to, sites, kinds and violatingSites — the same treatment inheritanceFreeCut already gave its edges, and for the same reason: cost is not effort. The nested components breakdown is deliberately omitted, since the route you are taking already provides it and the question about a road not taken is only what you would be touching. alternative also gained a projection, so a route that will not finish in one step says so.

Note the field changed type, from a number to an array of objects under the same name.

Fixed

On macOS this is not an exotic setup: /var is a symlink to /private/var, so every temporary directory has two names. A symlinked home or work directory is equally ordinary. Being a race it fires intermittently, which is what made it look like a network problem for a while.


26.5.6 — 2026-09-11

Changed

It made improving a project downgrade its advice. A cycle that shrank from 19 packages to 18 lost the condensed answer entirely, and the flat answer replacing it was worse in absolute terms than the flat answer for the bigger group. The threshold had been set to the quotient solver's reach (18) rather than to the flat solver's break/split point (16), so 17- and 18-package cycles were too big to break flat and too small to condense.

Fixing the boundary would not have been enough. The gate's premise — that a cycle the solver can break exactly needs no second option — confuses optimal with achievable. An exact break is optimal on cyclicity and says nothing about whether anyone will do it: on gradle, a 14-package cycle the solver breaks perfectly costs 80 dependency sites, while one condensed relation costs 7. Across gradle's flagged package cycles the condensed route was cheaper in 15 cases, the same in 7 and dearer in 3 — and most of the cheap ones were small enough for the flat answer to be exact.

So the step is now offered whenever there is a coarser grouping to climb to, and you compare its cost against the flat cut the way you already compare inheritanceFreeCut or the shear-versus-break alternative. Shaving a subsystem off the outside is frequently the step that gets landed; the perfect cut is the one that gets postponed.


26.5.5 — 2026-09-10

Fixed

Both are now present there. The inheritance-free alternative is computed over the quotient, where that path actually makes its decision — an alternative reached some other way would be an alternative to a cut you were never offered. On jPOS's utilspace cycle the reply now offers NameRegistrar → TSpace at 11 sites (extraSites 6, same resulting cyclicity) against a proposed cut that severs JESpace extends Log. That is the architecturally right direction, and it was previously reachable only by noticing equivalentToChosen on the alternative.


26.5.4 — 2026-09-09

Added

Both constructs answer "X has an API and internals" and only one of them survives the API depending on X. Getting it backwards is expensive precisely because the error message is a pile of ordinary-looking violations: on jPOS it turned 3 violations into 7, all shaped Container.QBeanApi → Container, and took a full authoring cycle to diagnose. The violations are genuine, so nothing else in the server had anything to say — and what the author needs to hear is not "these are illegal" but "you probably wanted the other construct". architectureWarnings now says it, naming the artifact and the dependencies.

explain_architecture_dsl carries the same distinction where the choice is actually made: the intent table now recommends interface for that intent and states why the nested form strands the parent, instead of offering both as if they were interchangeable.

This exists because a cheap step was unreadable without it. "1 relation, 2 sites, cyclicity 6400 → 6241, residue uncontained" reads as a nibble beside an alternative that finishes in one 657-site move — and is in fact step one of five totalling 670, a 1% premium for having the work in five reviewable pieces instead of one. Measured across jPOS and gradle the premium was 251 vs 248 sites, 498 vs 492, and 670 vs 657: the small steps converge, and nothing in the reply said so. Compare projection.sites against alternative.sites to make that trade with both numbers in hand — several small changes are usually easier to land than one big one, and this is what the small path really costs. complete: false means the projection was cut short, so both numbers are lower bounds and the path is not known to converge.

For a package cycle above 18 packages the reply now carries parentPackageStep: the packages grouped by their first groupDepth path segments, and a cut of that graph. Measured, this is not marginal. jPOS's 19-package cycle went from a 22-relation, 424-site shear to a 16-relation, 260-site break reaching cyclicity 28 with a contained residue; gradle's 84-package cycle went from 100 relations and 1057 sites to 6 relations and 492. Every oversized package cycle in both calibration projects condensed into exact-solver range, and every condensed break left a contained residue — meaning what remains are self-contained knots inside single groups, each solvable on its own, rather than the same tangle one size smaller. Read residueContained; it is the property the cyclicity numbers hide.

It reads usefully in the negative too: when the seam is somewhere else entirely, the relation you were about to refactor was never what held the group together.

It offers rather than decides because nothing else honestly can. We measured it: across gradle's 38 flagged component cycles the cheapest cut severed inheritance in 10, an inheritance-free cut existed in all 10, and the extra cost was −36, 0, 0, 2, 2, 4, 10, 33, 90 and 430 sites. Six were nearly free and three were prohibitive, two orders of magnitude apart, so no weighting could have been right for both. Read extraSites and decide against a codebase only you know. A negative value means the alternative is also cheaper — take it, there is no trade-off; that happens because a large group is split heuristically rather than solved, so constraining the search can find a better answer. The field is absent when the proposed cut touches no inheritance.

Where no inheritance-free cut can exist — the inheritance edges close a loop by themselves, so breaking it must cut one of them — nothing is offered rather than something impossible.

The cause is almost always a connect to aimed at an artifact declared above it. That is not illegal by itself — two plain artifacts with a single upward edge are acyclic and nothing is reported. It becomes a cycle once something grants access back down, which the implicit grants usually already do: strict reaches the next sibling beneath, relaxed every sibling beneath, public is granted to the siblings above it. All three point downward, so one upward connect is all it takes. unrestricted is the only modifier that reaches upward, and it is exempt: it is a declared quarantine, and a cycle whose closing edge nobody wrote is that hatch doing its job rather than a finding. Do not silence a real one by reaching for it.

apply clones a slice into the host file, so a cyclic slice applied in ten domains would otherwise be ten copies of one problem with one fix. Those are reported once, in the slice's own vocabulary, listing the apply sites that brought them in.

explain_architecture_dsl says this too, where an author is standing when they write the offending line: sibling order is now stated as a direction rather than a layout convention, with the consequence of pointing against it spelled out; connect to Parent.Inner carries the same caveat, since narrowing the target does not change its direction; and a new rule 10 says which grants point which way.

Changed

Upgrading

Two reply-shape changes, both deliberate breaks rather than additions.

Violation lists are grouped. list_violations no longer returns violations; it returns violationsByArcFile, whose entries are {arcFile, violations[]}. The rows inside no longer carry arcFile — it is on the group. The rescan diff's addedViolations / removedViolations became addedViolationsByArcFile / removedViolationsByArcFile with the same shape. The keys were renamed rather than reused so that a reader written against the old shape finds nothing at the old key, instead of silently reading undefined off a row that changed underneath it.

diff.sinceSessionBaseline is now counts. Anything reading violation, dependency or cycle-membership arrays under that key will not find them — the fields are addedViolationsCount, removedViolationsCount, problematicAddedDependenciesCount and …CyclesJoinedCount/…CyclesLeftCount, and the entries come from list_baseline_changes. The Count suffix is deliberate for the same reason: it keeps one field name from meaning an array under sinceLastRescan and an integer under sinceSessionBaseline. Nothing else under sinceLastRescan changed, beyond the added removedViolationsTotal.

If your CLAUDE.md quotes the rescan obligation, the field to check is now addedViolationsByArcFile; generate_config's agentInstructions paragraph says so already.


26.5.3 — 2026-09-03

Added

Changed

The two names differ only where a .csproj sets <AssemblyName> — nhibernate-core's NHibernate.Tool.HbmXsd builds plain HbmXsd, and that module is now called HbmXsd.

Breaking for such solutions. Regenerating the configuration renames those modules and re-keys every componentId under them: existing baselines stop matching, and .arc rules that name the old module name go inert until updated. Solutions where every project builds an assembly of its own name — the common case — are unaffected. Where two projects build the SAME assembly there is now one module name for both; the later project is dropped with a warning, which is what Sonargraph does with that solution too.

Fixed

The instructions are now 4,683 characters and the rules start at character 615. What went was reference material, which had no business in the one channel clients truncate: the tool catalogue became a compact index naming every tool, and the per-language primers moved to where they are read on demand — the retrievers and the C# directories-not-namespaces rule into explain_architecture_dsl, and the prerequisites (.NET 10 SDK; Python 3.10 or newer and the interpreter field) into generate_config's description, which had never mentioned C# at all. Nothing binding was dropped.


26.5.2 — 2026-08-26

Fixed

In practice agents were reaching for analyze_cycle on package cycles and never calling suggest_relocations at all, so the cheapest repair was routinely skipped.


26.5.1 — 2026-08-21

Upgrading

// before                          // now
{ "fqn": "...",                    { "fqn": "...",
  "filterName": "Mod/A/Foo",         "components": [
  "perFile": [ ... ] }                 { "filterName": "Mod/A/Foo", "perFile": [ ... ] } ] }

One name can legitimately belong to several components: a C# partial type is one type written across several files, and the halves can be assigned to different artifacts — one under Domain/ and another under Generated/ is normal. Answering with one of them was answering a different question. Expect a single entry for most types and do not assume exactly one.

Added

What you need on the machine is the .NET 10 SDK — the SDK, not just the runtime, because solutions are opened through MSBuild. The parser itself ships inside the jar, so nothing is downloaded and it works offline. If the solution's NuGet packages have never been restored, the first scan restores them for you rather than telling you to.

A solution file is required, and generate_config records which one it chose. Where a repository holds several — nhibernate-core has three — the choice is written to project.solution and reported, so pointing it at a different one is an edit rather than a mystery.

The one thing that surprises C# developers: .arc rules address directories, not namespaces. A component is a source file and its package is the folder it sits in, relative to the project directory — the same rule as Java. C# does not require namespaces to follow folders, and where a codebase's don't, the rules follow the folders. So an artifact is written include "MyApp/Services/**", not include "MyApp.Services.**".

An external component is External/<assembly>/<namespace>/<type> — for example External/System.Collections/System/Collections/Generic/List<T>. The assembly is part of the name because it is the unit of external identity in .NET and a namespace does not imply one: NHibernate's Antlr.Runtime.* types come from Antlr3.Runtime.dll. It is a single segment, so External/System.Collections/** names everything from one assembly. Generic parameters are part of the name — arity is part of a .NET type's identity, so Task and Task<TResult> are different components — and a nested type has none of its own: a dependency on HashSet<T>.Enumerator lands on HashSet<T>.

Six attribute retrievers, the same ones Sonargraph offers and with the same semantics: CSharpTypeOf, CSharpExtendsClass, CSharpImplementsInterface, CSharpIsClass, CSharpIsInterface and CSharpIsEnum. They assign components by what a type is rather than by where it sits — include "CSharpImplementsInterface: **.IRepository" gathers every repository however each one is named. They match dotted type names, so a single * stops at a dot.

Two more things worth knowing before you write rules. A project that targets several frameworks is analysed under exactly one of them — the newest, recorded in the configuration — and componentIds never mention it, so adding a target framework cannot invalidate a baseline. And a project that declares IsTestProject is not part of the model at all: its cycles and violations would be noise against what the production code owes, so no rule can govern the test-to-production direction.


26.4.1 — 2026-08-20

Added

There is no build system to ask, so the structure comes from the layout. A virtualenv tells you where packages are installed and never where the sources are, so poetry, uv, pdm and conda have nothing to contribute here. Every pyproject.toml is one module — named after the distribution it declares — with its source roots taken from setuptools, hatch or poetry metadata, or from src-vs-flat convention when the metadata says nothing. A project with no pyproject.toml at all is still configured from its directories.

Nothing has to be installed first. An import names its own target, so a freshly cloned repository with no environment set up yields a complete, governable model. What you do need is a Python 3 interpreter the server can find — it looks for python3, then python, and on Windows the py launcher first. Prefer 3.10 or newer: Python's own parser only understands the syntax of its own release, so an older interpreter reports a project's modern syntax as errors in your source. To pin a specific one, add "interpreter" to the project section.

Writing .arc rules for Python. A componentId is module/path/to/file — the source file's location under its source root, with no extension. locate_fqn works as it does for Java, and resolves three kinds of name to a component: a class, a module-level function, and the module itself — pkg.archive names the file, which is what an import writes.

Python has two attribute retrievers of its own. PythonTypeOf matches any direct or indirect base class, so include "PythonTypeOf: pydantic.BaseModel" gathers a DTO layer, and PythonTypeOf: airflow.sdk.bases.operator.BaseOperator an operator layer — roles no naming convention identifies. Where Java needs four hierarchy retrievers, Python needs one: it has no class/interface split, so an ABC or a Protocol is simply another base. PythonHasDecorator matches any decorator on a class or a function — function decorators being where the signal usually is — and matches either spelling: the written app.get, whose head is an instance and resolves to nothing, or the resolved airflow.decorators.task, which keeps matching after import task as t. The Java retrievers do not apply, and naming one in a Python project fails at rule-compile time rather than silently matching nothing.

One limitation: the hierarchy walk stops at the project's edge, since installed packages are not parsed. Deriving from BaseModel is seen; deriving from a third-party class that itself derives from BaseModel is not.

Generated code is declared by pattern rather than by root. Java points generatedSourceRoots at a directory; Python has no such directory, because a module's dotted name is its path from the source root — anything importable as pkg.api.models has to live at pkg/api/models.py, beside the hand-written code. So declare "generatedPatterns": ["**/*_pb2.py"] in the project section instead. Matching files get the same treatment generated Java gets: cycles among them, and violations originating in them, are excused. generate_config seeds the protobuf globs when your project actually has such files.

One configuration covers one language. A repository that is genuinely both needs a server per language, each with its own --config_dir.

Changed

Getting it needs the new launcher, which does not update itself — re-download zugel-launcher.jar if you want this.


26.3.6 — 2026-08-18

Added

A Bazel module is one SOURCE ROOT, not one target. Bazel has no unit corresponding to a Maven module — its unit is the target, at whatever granularity the build author found convenient — so a workspace compiling 325 targets out of three directories would otherwise be modelled as 325 modules nobody on that project would recognise. Source roots are derived from each file's declared package rather than from directory names, because Bazel workspaces do not follow the src/main/java convention and often have no src anywhere.

Generated java comes along: the source jars produced by genrule, java_proto_library, java_grpc_library and the like are unpacked under <config dir>/.zugel-bazel/generated-sources/, which wants a .gitignore entry — the tool says so. Those components are flagged generated, so they are already excused from cycles and violations.

One caveat, and generate_config warns about it: a Bazel-generated zugel.json is machine-specific. Every jar Bazel reports lives under bazel-out/<platform>-<mode>/…, so a configuration generated on macOS does not resolve on Linux, and bazel clean deletes the entire tree it points at. Regenerate it rather than committing it — that is cheap once Bazel's cache is warm. If you do scan against a stale one, the classpath warning below now tells you instead of letting the model quietly shrink.

Verified on Windows as well as macOS, on every route Bazel installs by there — Bazelisk via npm, winget, Chocolatey and Scoop. One Windows prerequisite is not ours to fix and worth knowing: a workspace with Maven dependencies needs BAZEL_SH pointing at a bash (Git Bash will do), or rules_jvm_external's fetch fails before we ever see it.

Fixed

These were false violations, so your next scan may show fewer. If you changed code, or loosened an .arc file, to satisfy one of them, that change can be reverted.

The same gap dropped dependency edges: new SomeProjectClass() { … } recorded no NEW edge, so that coupling was invisible to your architecture rules. It is visible now, so a violation this was hiding may appear on your next scan — the rule catching something real, not a new restriction.


26.3.5 — 2026-08-17

Added

Retriever Matches
JavaIsClass any type that is a class
JavaIsInterface any type that is an interface
JavaExtendsClass any direct or indirect base class of a non-interface type
JavaImplementsInterface any interface implemented by a non-interface type, transitively
JavaExtendsImplementsInterface as above, but interfaces extending interfaces count too
JavaTypeOf any direct or indirect base type, class or interface
JavaHasAnnotation any annotation on the type

Wildcards work on fully qualified names, so * stops at a dot and ** does not: **.Controller matches the interface in any package, *.Controller in one package level only. A type may carry several values (a class implements many interfaces) and matching any one of them matches.

As in Sonargraph, only the component's main type is considered — the one named like the file — and external components match nothing. The walk up a hierarchy stops at the first type outside the analysed sources: its name still counts, but nothing above it does. A retriever name the configured language does not provide is a rule-compile error rather than a pattern that silently matches nothing.

Two Sonargraph retrievers are deliberately absent: JavaHasAnnotationValue, which needs annotation property values, and JavaBelongsToAggregateRoot, which needs generic type arguments. JavaHasAnnotation differs from Sonargraph's in one way on purpose: it sees annotations on the type itself, not on its fields and methods, so a detail of one member cannot decide the whole component's artifact.

explain_architecture_dsl covers them, so an agent asked to express a grouping that no name pattern can capture will find them without being told they exist.

Fixed

A component's contains list consequently holds every declared type rather than only the top-level ones. Duplicate-FQN reporting is unaffected on purpose: nested types are excluded from it, because no dependency edge ever resolves to a nested name (edge targets are normalized to the enclosing top-level type), and a nested FQN can only collide when its enclosing type already has.

Upgrading


26.3.4 — 2026-08-11

Changed


26.3.3 — 2026-08-10

Added


26.3.2 — 2026-08-10

Improved


26.3.1 — 2026-08-05

Upgrading

Added

Changed

Fixed

Known issues

ecj does not close the classpath jars it opens. The open set does not accumulate across rescans (macOS and Windows). On Windows those jars stay locked while the server idles until a garbage collection releases them. Generated configurations put only immutable cache jars under ~/.m2 and ~/.gradle on the parse classpath, and class directories hold no handle, so mvn clean is unaffected. Restarting the server releases everything.