Skip to content

Roslyn Navigator 0.10.0: symbol resolution correctness, response contracts, and two new tools - #26

Merged
iammukeshm merged 14 commits into
mainfrom
feature/roslyn-navigator-parity
Aug 7, 2026
Merged

Roslyn Navigator 0.10.0: symbol resolution correctness, response contracts, and two new tools#26
iammukeshm merged 14 commits into
mainfrom
feature/roslyn-navigator-parity

Conversation

@iammukeshm

@iammukeshm iammukeshm commented Aug 7, 2026

Copy link
Copy Markdown
Member

Correctness pass on the Roslyn MCP server: symbol resolution, response contracts, and workspace reload behaviour — plus two new tools.

Fourteen commits, one per fix, each with tests.

Correctness

Symbol resolution accepted only bare declared names. find_references("OrderService.CreateOrderAsync") returned an empty result — indistinguishable from a symbol with no references. Qualified names now resolve, with parameter lists, generic arguments, and metadata arity stripped, and suffix matching anchored on segment boundaries so derService.Get cannot match OrderService.Get.

Ambiguity was resolved by return symbols[0]. A bare name matching several types silently answered about whichever one Roslyn enumerated first, with full confidence. This was not hypothetical: the existing FindCallers("GetByIdAsync") test was passing on exactly this behaviour — five declarations match that name in the fixture solution.

Nine tools returned an empty list when a symbol could not be resolved, so "this name does not exist" and "this symbol exists and has no references" were the same response. An agent reading that concludes code is dead, from a typo. Tools now return SymbolNotFound / AmbiguousMatch (with candidates) / WrongSymbolKind / FileNotFound / NoSource.

Truncation was left to a Count vs TotalFound comparison, which reads as truncated when the result set happens to be exactly the limit. Now stated directly as Truncated + Limit, produced by a shared Paging helper so the contract cannot drift between tools.

find_dead_code inferred "unused" from a zero-reference count — which is exactly what a reflection-bound type looks like. A class resolved through Type.GetType("MyApp.LegacyPlugin") was reported at high confidence, inviting a breaking deletion. Findings are now graded, with AssemblyScanningDetected flagging solutions where every result is softer.

A reload left the compilation cache populated. Fresh ProjectIds meant orphaned entries that never evict and pin whole Roslyn compilations in memory. Two of four LoadSolutionAsync call sites cleared it; the clear moved inside.

Navigation results did not distinguish generated source, so an agent could follow a hit into a .g.cs file the next build overwrites. Now flagged with IsGenerated.

New tools (20 → 22)

resolve_stack_trace — maps an exception onto the solution. The frames that matter are the ones hardest to grep for, because the compiler rewrites async methods, lambdas, and local functions into generated types whose names never appear in source. Undoes those rewrites, marks which frames are yours, and points at FirstSolutionFrame.

analyze_change_impact — "what breaks if I change this?" in one call. Implementations and overrides are counted separately from references, because they are not references: a signature change forces them to change too, so a reference count alone understates the work. Adds assembly-boundary exposure and a risk rating with its rationale.

Infrastructure

Reloads were triggered by csproj timestamps, so a branch switch or formatter rewrite caused a full MSBuild re-evaluation for identical content. Build files now carry a content hash with the timestamp kept as a pre-filter. This also closed a real gap: Directory.Build.props, Directory.Packages.props, Directory.Build.targets, global.json, and nuget.config were entirely untracked despite governing every project beneath them.

The structural scan for new source files ran inline, so one arbitrary tool call per cooldown paid for a recursive walk of the repository. Now runs in the background with the same staleness bound.

Container distribution via SDK container publishing (no Dockerfile, per the kit's own container-publish guidance). The base image is the SDK, not runtimeMSBuildLocator needs a real SDK installation, and on a runtime image the server starts cleanly then fails every call with "No .NET SDKs were found".

Verification

  • Build clean, 0 warnings
  • dotnet format --verify-no-changes clean
  • 318/318 tests pass (was 238)
  • The two highest-risk fixes were checked by reverting them and confirming the new tests fail: the cache fix (6 orphaned entries vs 3) and the build-file hashing (2 loads vs 1)

Not verified: the container image was never run. The publish resolves properties, produces the linux-x64 output, and reaches image assembly — then stops at "Cannot find docker/podman executable", as no Docker daemon is available on the machine this was built on. Everything up to the daemon handoff is confirmed; the image itself is not.

Not included: npm distribution. That needs an npm account and publish pipeline — a separate call.

Versions

CWM.RoslynNavigator 0.9.0 → 0.10.0, plugin 0.11.0 → 0.12.0, tool count updated across the manifest, marketplace entry, and READMEs. The MCP README documents the three new contracts (symbol-name forms, error codes, paging fields), since none are discoverable from a tool list.

🤖 Generated with Claude Code

iammukeshm and others added 14 commits August 7, 2026 08:53
A reload mints fresh ProjectIds, so every previously cached Compilation is
keyed on a dead id. Two of the four LoadSolutionAsync call sites cleared the
cache first; the roots-discovery and error-retry paths did not, leaving
orphaned entries that never evict and pin whole Roslyn compilations in memory.

Moving the clear into LoadSolutionAsync itself makes it unconditional and
removes the obligation from callers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SymbolResolver passed the caller's string straight to GetSymbolsWithName,
which matches a symbol's declared name only. Any qualified input therefore
found nothing: find_references("OrderService.CreateOrderAsync") returned an
empty result, indistinguishable from a symbol with no references.

Resolution now normalizes the request (dropping parameter lists, generic
arguments, and metadata arity), looks up the final segment, then filters
candidates on a segment-aligned suffix match of their namespace/containing-type
chain. Suffix matching is anchored on '.' so "derService.GetOrderAsync" cannot
match OrderService.

This also gives callers a way to disambiguate a name reused across types
without knowing its file or line, which matters most for interface members
where every implementation shares the name.

get_symbol_source drops its own dotted-name parsing in favour of the shared
path, losing the fallback that silently returned an unrelated symbol when the
containingType hint did not match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine tools returned an empty result set when a symbol could not be resolved,
making "this name does not exist" indistinguishable from "this symbol exists
and has no references". An agent reading that empty response concludes the code
is dead — from a typo.

Worse, ResolveSymbolAsync ended in `return symbols[0]`. A bare name matching
several types silently picked whichever project Roslyn enumerated first and
answered with full confidence about the wrong symbol. The existing
FindCallers("GetByIdAsync") test was passing on exactly this behaviour: five
declarations match that name in the sample solution.

Tools now return ErrorResponse with a stable code — SymbolNotFound,
AmbiguousMatch, WrongSymbolKind, FileNotFound, NoSource — and AmbiguousMatch
carries the candidate list so the caller can re-query with a qualified name
rather than guess.

Overloads, partial declarations, and per-target-framework duplicates share a
qualified name and kind, so they group as one logical symbol; only genuinely
distinct symbols raise AmbiguousMatch.

SymbolResolution carries the outcome as a struct rather than a tuple so
MemberNotNullWhen tells the compiler the symbol is non-null past the guard —
no null-forgiving operators, no nullability warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Responses carried Count and TotalFound but never said whether the cap had
actually dropped anything. Callers had to infer it by comparing the two, and
the boundary case — Count equal to the limit with nothing dropped — reads as
truncated under the obvious check.

List-returning tools now report Truncated and Limit directly. Truncated is the
single field to branch on; Limit tells the caller what to raise on a re-query
instead of guessing at the tool's default.

Paging.Apply centralises the cap so the contract cannot drift between tools.
find_references and find_nuget_packages keep their own capping — the first
builds snippets lazily as locations stream in, the second caps packages across
projects rather than the project list it returns — but report the same fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
find_symbol, find_references, find_callers, find_implementations, and
find_overrides returned generated declarations and call sites indistinguishable
from hand-written ones. An agent following those results edits a .g.cs file that
the next build overwrites.

Each result now carries IsGenerated, derived from the classifier the anti-pattern
detectors already use — .g.cs/.Designer.cs/obj-path conventions plus an
<auto-generated> header — extended with Roslyn's IsImplicitlyDeclared to cover
compiler-synthesized members that have no declaration at all.

GeneratedCodeIndex memoizes per syntax tree: a result set typically spans few
files and many symbols, so leading trivia is read once per file rather than once
per hit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
find_dead_code inferred "unused" from a zero-reference count, which is exactly
what a reflection-bound type looks like. A class resolved through
Type.GetType("MyApp.LegacyPlugin") has no references at all, and the tool
reported it at high confidence — an invitation to a breaking deletion.

Findings are now graded. A name appearing in any string literal in the solution
drops to low confidence with the reason attached: that is precisely how
reflection names a type it never references. The existing convention-suffix
heuristic stays at medium, and only symbols with neither signal remain high.

The result also reports AssemblyScanningDetected. When a solution registers
types by scanning — Scrutor, MediatR, FluentValidation assembly registration,
ApplyConfigurationsFromAssembly — every zero-reference result is softer, and the
caller should know that without inspecting each finding.

The literal index covers the whole solution rather than the requested scope,
since a project scoped out of the scan can still be the one naming the type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turning an exception into a place to look was manual work: read the trace,
guess which frames are yours, grep for the method, open the file. The frames
that matter are also the ones hardest to grep for, because the compiler rewrites
async methods, lambdas, and local functions into generated types whose names do
not appear in source — a throw inside an async method surfaces as
OrderService+<CancelOrderAsync>d__4.MoveNext().

The tool undoes those rewrites (async state machines, lambdas hoisted into
closure or display classes, local functions, generic arity, nested-type
separators, constructors), resolves each frame against the solution, and returns
file, line, and the source line itself.

Frames carry InSolution so framework noise can be filtered, and the result
points at FirstSolutionFrame — the topmost frame in the user's own code, which
is where an investigation almost always starts. The line reported by the trace
wins over the declaration line, since that is where it actually threw.

Only source declarations are indexed, so "resolves" and "is the user's code"
are the same test — no separate allowlist of framework namespaces to maintain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Answering "what breaks if I change this?" meant four separate calls —
find_references for the call sites, find_implementations and find_overrides for
the declarations that must move in lockstep, get_project_graph for the reach —
and then stitching the results together by hand.

The tool does it in one pass and adds what the individual calls cannot see:

- Implementations and overrides are counted separately from references, because
  they are not references. A signature change forces them to change too, so a
  reference count on its own understates the work.
- References are grouped by project and by file: one says how far the change
  reaches, the other says where the edits land.
- Transitive callers walk the call graph breadth-first to a bounded depth, with
  visited symbols tracked across levels so recursion terminates.
- CrossesAssemblyBoundary flags changes to a public surface, where consumers
  outside this solution can break without any local reference to show for it.

Risk carries its rationale rather than a bare label, since "high" from
implementations-in-lockstep calls for different handling than "high" from sheer
call-site count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Change detection compared csproj timestamps, so any rewrite triggered a full
solution reload — the most expensive thing this server does. A branch switch
rewrites every project file whether or not its content differs, and so does a
formatter or a checkout of the same commit, each costing a complete MSBuild
re-evaluation for nothing.

Build files now carry a content hash alongside the timestamp. The timestamp
stays as the pre-filter, so the steady-state cost is still one stat per file;
only a file that moved gets hashed, and an identical rewrite costs one hash
instead of a reload. Its recorded timestamp is refreshed on the way through so
the next poll short-circuits at the stat.

Directory.Build.props, Directory.Packages.props, Directory.Build.targets,
global.json, and nuget.config are now tracked too. They govern the compilation
of every project beneath them, but they are not documents and not csproj files,
so a version bump in Directory.Packages.props was previously invisible until
something else forced a reload.

An unreadable build file hashes to null, which compares unequal to everything
and so counts as changed — a locked file errs toward reloading rather than
serving stale analysis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Detecting newly added source files meant enumerating every .cs file under every
project directory, and it ran inline inside the refresh that precedes every tool
call. Past the 60-second cooldown, one unlucky request paid for a full recursive
walk of the repository before any analysis started — and which request paid was
arbitrary.

The walk now runs on a background task, at most one at a time. A tool call
schedules it and returns; the finding is picked up by a later refresh via an
interlocked flag. Results are at most one cooldown stale, which is exactly the
staleness the inline version already had — the cooldown was always the bound,
not the walk.

The scan takes the shutdown token and checks it per project and per file, so
disposal does not wait on a large enumeration, and Dispose caps its wait at two
seconds regardless. It also captures the solution snapshot once up front, since
a concurrent reload can swap the field mid-walk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Installing the global tool requires a .NET 10 SDK on the host, and on macOS and
Linux a wrapper-script `dotnet` with DOTNET_ROOT unset makes that setup fail —
the case the troubleshooting section already documents. A container removes the
host SDK from the equation entirely.

Built with the SDK's own container publishing rather than a Dockerfile, matching
the kit's container-publish guidance, so there is no separate build recipe to
keep in sync with the csproj.

The base image is the SDK, not aspnet or runtime. MSBuildLocator needs a real SDK
installation to open a solution; on a runtime image the server starts cleanly and
then fails every call with "No .NET SDKs were found" — the exact problem the
container was meant to avoid.

Verified as far as this machine allows: the publish resolves the properties,
produces the linux-x64 output, and reaches image assembly against the SDK base
image. The final push needs a running Docker daemon, which is not available here,
so the image itself has not been run.

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

Tool count 20 -> 22 across the plugin manifest, marketplace entry, and READMEs.

The MCP README gains the three contracts this release introduced, since none of
them are discoverable from a tool list: the accepted symbol-name forms, the error
codes and what distinguishes each, and the Truncated/Limit paging fields. Also
documents the IsGenerated flag and the container install path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI requires plugin.json, marketplace.json, and the newest CHANGELOG release
heading to agree. The manifests were bumped to 0.12.0 but the entry was still
sitting under [Unreleased], so the version-consistency gate failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CHANGELOG and MCP README credited an external package as the prompt for this
release. The notes stand on their own without naming it, and describing our
release relative to someone else's package is not information a reader of these
notes needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@iammukeshm
iammukeshm merged commit 2330089 into main Aug 7, 2026
11 checks passed
@iammukeshm
iammukeshm deleted the feature/roslyn-navigator-parity branch August 7, 2026 06:04
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.

1 participant