Changes per released version of the Architecture MCP server, newest first. Starts at 26.3.1; earlier versions shipped without release notes.
list_cycles no longer tells your agent to break cycles by hand. Its description claimed the server "does not compute a minimum feedback set, so use judgement" — which was wrong, and was read at the exact moment an agent decides what to do about a cycle. It now routes instead: suggest_relocations first for a package cycle (a file move costs an import update, not a refactoring), analyze_cycle for a component cycle or when relocation reports CUTS_REQUIRED. It also states what analyze_cycle actually returns — an exact minimum-weight cut on a small group, a shrinking split on a large one, iterated until groups are small enough to solve exactly — and that the goal is to drive cyclicity down rather than reach zero.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.
locate_fqn now reports EVERY component declaring a name, and its reply shape changed. The top-level filterName and perFile moved down one level into a new components array:// 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.
query_dependencies replies with components (a list) instead of component, for the same reason, and it no longer refuses a name owned by several. It answers for all of them as one: the results are the union of what the parts reach, and the parts are not reported as depending on each other. trace_dependency is unchanged in shape — it now searches every pair and reports the endpoints of the shortest path it found.
duplicateFqns no longer reports a C# partial type. If you were filtering those out yourself, you can stop. On nhibernate-core this was 401 of 2429 top-level names, so for a C# project the signal was almost entirely noise. Java and Python are unchanged: two modules shipping one name really is a collision there, and is still reported.
"language": "csharp" in zugel.json, or just call generate_config — it detects a solution and writes that configuration itself. Violations, cycles, the baseline ratchet and the dependency queries then run on a C# model exactly as they do for Java.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.
"language": "python" in zugel.json, or just call generate_config — it detects a Python project and writes that configuration itself. Everything else works as it does for Java: violations, cycles, the baseline ratchet, and the dependency queries all run on a Python model.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.
server_version reporting "no update staged or pending" when what it really meant was "we have not looked since before that version was published". The startup check is now obligatory, and the interval does what it was wanted for: keeping a session that stays open for days from missing a release. Updating is otherwise unchanged: the download runs in the background and the new version activates on the next restart, and --launcher.no_update_check still switches the whole thing off.Getting it needs the new launcher, which does not update itself — re-download zugel-launcher.jar if you want this.
generate_config bootstraps Bazel workspaces. Maven, Gradle and Bazel are all detected now, and each build system present is tried in turn until one answers. Bazel is tried last on purpose: where a repository carries both a MODULE.bazel and a Gradle build, the Gradle build is usually the authoritative one, and a Bazel-only workspace has nothing ahead of it in the list anyway.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.
unrestricted did nothing when the artifact was declared last — the position it is usually written in. An unrestricted artifact may depend on any of its siblings, above it or below it. The connectors that grant this were generated only for artifacts that had another sibling after them, so the last artifact in a body got none at all and every dependency it had was reported as a violation. That is the catch-all shape the modifier exists for: a Spring-style unrestricted public artifact Other { include "**" } closing the file reported violations on everything it touched. The guard belonged to strict alone, which reaches only the next sibling down; relaxed and unrestricted are unaffected in every other position, and neither strict nor relaxed changes behaviour at all.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.
A classpath entry that no longer exists is now reported, instead of quietly shrinking your model. Every scan (rescan_sources, reload_all) now names in configWarnings any module whose configured classpath entries are not on disk, with how many are missing and what to do about it. This used to be completely silent: the sources still parse, so there was no warning and no error — and the only casualty was every dependency into those libraries, which means violations and cycles running through them could not be seen either. A mvn clean over a sibling module's jar, a checked-in libs/ directory that moved, or a hand-edited configuration are the ways in. Re-running generate_config restores them. A project whose classpath is intact is unaffected.
query_dependencies now tells the truth about how complete its answer is. completeness was wrong in both directions. It counted the language's own constructs as unresolved — a type variable (T, RespT) and the supertype of every new Something() { … }, though both resolve fine and neither names a file — so answers were reported as incomplete when nothing was missing. And it missed references it had nothing to report them by: a static call on a type whose jar is gone (StringUtils.capitalize(x)) or an unresolvable import left no trace at all, so answers that really were missing dependencies claimed to be complete. Both are fixed, and an unresolvable import is reported by its whole name rather than the package prefix the compiler gives.
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.
server_version no longer announces an update that was never downloaded. It counted a version as staged when the launcher's cache merely held a directory for it — which the launcher creates as soon as it fetches that version's release notes, or begins staging the download. A restart was therefore advised for a build that did not exist, and the launcher, which checks for the jar itself, went on correctly running the previous version. A version now counts only when a verified jar is present. Release notes are unaffected: they are deliberately cached for versions whose jar will never be fetched, so that a pinned or out-of-maintenance user can still find out what an update contains.
A failed update check is now reported instead of vanishing. The launcher's explanation goes to stderr, and MCP clients stop capturing stderr once the handshake completes — so the reason a download never landed reached nobody, and the symptom was a version that silently never changed. Launcher 1.0.2 records the failure to a file; server_version reports it as update.lastCheckFailed. Older launchers do not write it, which reads as nothing to report.
.arc patterns. An include/exclude can now match something other than a component's name by naming a retriever first — include "JavaImplementsInterface: **.Controller" gathers every class whose ancestry reaches that interface, wherever it is declared and whatever it is called. This is the case naming conventions cannot express: generated or framework-driven code that carries its role in an annotation or a contract rather than in its name. Seven are available for Java:| 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.
locate_fqn, query_dependencies and trace_dependency. Passing com.example.Outer.Inner previously missed the FQN index and fell through to reporting the type as External/com/example/Outer/Inner with status UNASSIGNED_UNKNOWN — an internal, assigned type described as an unassigned external one, which an agent could reasonably read as "no artifact governs this target". It now resolves to the component declaring the enclosing type, since a component is a file. Pass nested names source-level with dots; the binary Outer$Inner form is still not recognised.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.
generate_config no longer gives up when the first build system fails. On a project carrying both a Maven and a Gradle build — a Tycho pom.xml beside a Gradle build, for instance — a Maven failure now falls back to Gradle instead of aborting. Maven is still preferred when both are present and healthy. The fallback is reported in warnings, so a Gradle-derived module structure never appears without an explanation, and if nothing can configure the project the error names every build system tried rather than only the last.locate_fqn answers differently for a nested type. It used to report com.example.Outer.Inner as External/com/example/Outer/Inner with status UNASSIGNED_UNKNOWN; it now resolves to the component declaring Outer. If you built anything on the old answer — stripping the nested part before asking, or treating that status as "not ours" — it can go.zugel.json; the server jar is Zugel-<version>.jar; the MCP server reports itself as zugel.architecture-mcp.json if no zugel.json is present, and the launcher renames the file for you the first time it starts a 26.3.4 or newer server. It never renames when a zugel.json already exists, and never when the server it is about to start is older than 26.3.4 and could not read the new name..mcp.json to zugel if you want the tools to appear as mcp__zugel__*; the old key keeps working.release_notes — the server's own release notes, per version, read from the copy inside the running jar. It pairs with server_version: that answers which build you are talking to, this answers what is in it. Call it with no arguments for the running version, since for everything released after a version you were on, or version for one in particular.upgrading field when that release has one: what it does TO you — a forced re-parse, a changed reply shape, a moved configuration key — as opposed to what it offers. It stays in the section text as well, so nothing is lost by reading only notes.generate_config writes JSON arrays with one entry per line, instead of putting a module's entire classpath on one line. Existing files keep working; re-run generate_config to reflow the current one.generate_config to pick up moduleRoot. Older configurations keep working without it.remove_baseline returns removed as an array of names in both modes, previously a bare string. Everything else is additive.moduleFilter in zugel.json — includes/excludes patterns selecting which modules generate_config writes into the project section, replacing the flat exclusion list. Patterns use the product's wildcard syntax (**, * within one dot-separated segment, ?) against the whole module name. generate_config gains includeModules, excludeModules and removeModulePatterns, all additive against what is persisted.moduleRoot per module — the module's own directory. Lets the loader settle a source root two modules claim, by the same rule the generators use.configWarnings on every scan: source root that is not a directory, dependsOn naming no configured module, source root claimed by two modules, module that indexed nothing. Warnings only — no scan fails and no configuration is rewritten.architectureWarnings on every scan: non-optional artifacts that matched no component, and deprecated artifacts that still hold components.baselineDrift on scans, at startup, and on all four baseline tools — baselines that have outlived the configuration they were captured under. While the active baseline has drifted, diff.sinceSessionBaseline is withheld. Adding a module does not drift a baseline; removing one or moving its source roots does.status: UNVERIFIABLE — no recorded scope to check. Their diffs are still served, and they are not deleted by the bulk cleanup.remove_baseline {"drifted": true} deletes every drifted baseline in one call. The active baseline is skipped rather than failing the call.server_version — reports the running build. The same version is now used for the startup log line and for serverInfo.version in the handshake (previously a hardcoded 1.0.0).generate_config reply carries a rescan object with the same payload rescan_sources returns, plus an arcFilesLoaded count. generate_config was always a reload; a following reload_all is unnecessary.diff.addedDependencies is now diff.problematicAddedDependencies and lists only new couplings that break a rule, sit inside a flagged cycle, or sit inside a newly created tolerated cycle. Ordered violations first, then cycle members, capped at 50, with problematicAddedDependenciesTotal giving the real count. Edges of pre-existing tolerated cycles and ordinary new dependencies are no longer listed. addedViolations is never capped.list_artifact_components returns a declared-but-empty artifact instead of Artifact not found; "matched nothing" and "never declared" are now separate states.list_reachable_components still excludes externals.moduleRoot is recorded, instead of by scan order. Without that evidence the collision is reported, as before.generate_config no longer reports "no .arc files configured (cycles-only mode)" when the project has .arc files.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.