Skip to content

Latest commit

 

History

History
289 lines (214 loc) · 27.6 KB

File metadata and controls

289 lines (214 loc) · 27.6 KB

BlazeDex MCP — Build Plan

Companion to blazor-razor-mcp.md. This is the execution plan for building our own Blazor/Razor-aware MCP (Option 3 in the spec, revised).

Stack Decision

  • Language: C# (.NET)
  • MCP SDK: ModelContextProtocol v1.2.0+ (official, stdio transport)
  • Parsing: Roslyn (Microsoft.CodeAnalysis.CSharp) over obj/**/*.razor.g.cs generated files + .razor.cs code-behind
  • Rationale: Avoids unstable internal Razor APIs; generated files are compiler-authoritative; native .NET tooling; no Python/Go fork maintenance.
  • Fallback (if Step 0 invalidates the approach): tree-sitter-razor + direct .razor parsing.

Test target

C:\path\to\YourBlazorApp — real Blazor WASM codebase with the WidgetExample anchor case from spec §10.

Steps

Step 0 — Validate .razor.g.cs strategy (spike, no code) ✅ DONE

  • Inspect target repo structure — 3 Razor projects: Client, User.Client, Components
  • Target framework: net10.0; SDKs Microsoft.NET.Sdk.BlazorWebAssembly + Microsoft.NET.Sdk.Razor
  • Trigger build with -p:EmitCompilerGeneratedFiles=true -p:CompilerGeneratedFilesOutputPath=generated — non-invasive; no source/csproj changes
  • Verified against WidgetExample.razor + WidgetExamplePage.razor:
    • __builder.OpenComponent<global::FullyQualifiedType>(seq) — trivial USES_COMPONENT extraction
    • AddComponentParameter(seq, nameof(Type.PropName), valueExpr) — semantic parameter resolution via nameof
    • EventCallback.Factory.Create(this, HandlerName) — handler wiring, resolves to code-behind via SemanticModel
    • #line (startLine,startCol)-(endLine,endCol) extended format — Roslyn Location.GetMappedLineSpan() handles natively
    • [RouteAttribute(...)] per @page directive on partial class
    • @inheritsBaseList of partial class declaration
    • @using + _Imports.razor inherited chain all inlined (Phase 2 feature free)
    • Nested RenderFragment lambdas for ChildContent / named fragments
  • Anchor case proven: WidgetExample.razor:96 OnLoginTypeChanged="HandleValueChanged" resolvable end-to-end
  • Decision: proceed to Step 1. Stack: .NET 10 + ModelContextProtocol SDK + Roslyn (Microsoft.CodeAnalysis.CSharp.Workspaces + Microsoft.CodeAnalysis.Workspaces.MSBuild). No tree-sitter, no internal Razor APIs, no project mutation.
  • Assumption verified via spikes/LoadProjectSpike: MSBuildWorkspace.OpenProjectAsync + GetCompilationAsync materializes Razor generator outputs in compilation.SyntaxTrees purely in-memory. No files written to disk. No EmitCompilerGeneratedFiles required. Measured: 1.5s project open + 3.1s compilation for User.Client (30 documents, 17 Razor trees). SemanticModel resolves handler identifiers to code-behind partial class symbols automatically (WidgetExampleBase.HandleValueChanged). Location.GetMappedLineSpan() returns source .razor:96:61 — anchor case §10 proven.
  • Cold-start budget: extrapolated ~15–20s for full solution (3 projects, ~500 razor files). Within spec §10 criterion of <30s.
  • Known MSBuildLocator gotcha: must pin Microsoft.Build* package versions to 17.11.48 with ExcludeAssets="runtime" PrivateAssets="all" to avoid MSBL001 runtime assembly-loading error. Csproj template in spikes/LoadProjectSpike/LoadProjectSpike.csproj.

Step 1 — Scaffold MCP project

  • Setup private GIT repository — github.com/tomasfil/blazedex (private), main pushed
  • Create BlazeDex.Mcp solution at repo root — BlazeDex.Mcp.slnx + src/BlazeDex.Mcp/
  • Target framework matching the test corpus clients — net10.0
  • Packages: ModelContextProtocol 1.2.0, Microsoft.CodeAnalysis.CSharp.Workspaces 5.3.0, Microsoft.CodeAnalysis.Workspaces.MSBuild 5.3.0, Microsoft.Extensions.Hosting 10.0.0, Microsoft.Build.Locator 1.11.2, Microsoft.Build* 17.11.48 pinned (MSBL001 workaround)
  • Program.cs with stdio transport + WithToolsFromAssembly() + MSBuildLocator.RegisterDefaults() + console logging routed to stderr
  • Stub [McpServerTool] Ping() returning "pong" (registered as tool name ping)
  • Register in .mcp.json; smoke-tested via direct JSON-RPC (initializetools/listtools/call ping"pong"). Pending: manual verification from Claude Code.

Step 2 — Thinnest vertical slice: find_component_usages ✅ DONE

  • Load one .csproj via MSBuildWorkspace.OpenProjectAsync (ProjectIndexer.BuildAsync)
  • Walk .razor.g.cs syntax trees, collect OpenComponent<T> invocations (CollectOpenComponentInvocations)
  • Resolve back to source .razor file + line via #line directives (ResolveUsageLocation walks forward past synthesized #line hidden block, then uses Location.GetMappedLineSpan())
  • Implement find_component_usages(name, limit, offset) → rows {file, line, column, componentName, componentFullName, resolved} (ComponentTools.FindComponentUsagesAsync)
  • In-memory Tier 1 cache + per-call stat-fingerprint revalidation (IndexService + Fingerprint + FingerprintBuilder)
  • Tool-boundary try/catch → structured error rows (every [McpServerTool] body wraps; OperationCanceledException rethrown)
  • get_index_status stub returning {state, project_path, last_built_at, last_build_ms, components_indexed, fingerprint_size, load_errors, parse_errors}; peeks cache without triggering rebuild
  • Success gate: find_component_usages("WidgetExample") returns WidgetExamplePage.razor:14:24 (anchor row from spec §10) — verified via scripts/smoke-find-component.py. Cold build 5.4s, warm 6ms, post-touch rebuild 4.0s.

Runtime contract: target .csproj is configured via the BLAZEDEX_PROJECT environment variable. No path is baked into the binary — the MCP stays host-agnostic. Step 4 will replace this with solution-level multi-project loading.

Step 3 — Phase 1 tool set (per spec §9)

  • list_razor_components(glob?, limit, offset) — file enumeration
  • list_pages(glob?) — extract @page / [Route] attributes
  • find_handler_bindings(method_qn) — markup sites binding to a code-behind handler
  • get_component_api(component_qn) — parameters + events + injects
  • In-memory index (no SQLite yet); rebuild on workspace load
  • Success gate: all four Phase 1 tools verified end-to-end against MyApp.User.Client. find_component_usages("WidgetExample") hits the spec §10 anchor; find_handler_bindings("HandleValueChanged") resolves to WidgetExample.razor:96:61; get_component_api("WidgetExample") returns 2 params + 0 events + 9 injects (event-callback slice is non-load-bearing; test corpus is actively edited). Verified via scripts/smoke-list-razor-components.py, scripts/smoke-list-pages.py, scripts/smoke-find-handler-bindings.py, scripts/smoke-get-component-api.py. Cold build ~5.0s, warm call ~5ms, post-touch rebuild ~4.0s. Index totals: 65 usages, 16 components, 7 pages, 67 bindings, 16 APIs.

Step 4 — Harden + index lifecycle

  • Multi-project solution load via BLAZEDEX_SOLUTION (hybrid D1 — solution mode + single-project fallback with two-stage Razor-SDK filter)
  • Per-project snapshot reshape (D2 Layer 1 ProjectSnapshot + Layer 2 derived union rows)
  • ProjectDependencyGraph + reverse-dep closure — built, logged, consumed on drift; Step 4 still rebuilds full solution (partial rebuild deferred, see Findings §Step 4 deferral — partial rebuild on drift)
  • Codebehind triple (.razor.razor.cs.razor.css) surfaced on RazorComponentRow + ComponentApiRow
  • Tier 2 on-disk SQLite cache (D3, Microsoft.Data.Sqlite 10.0.5) — per-project write-back, warm-load, corrupt-DB graceful degradation, schema v1
  • Passive FileSystemWatcher dirty hint (D4) — fast-path window, sliding 10s/3-failure recreation budget, watcher health in get_index_status, BLAZEDEX_WATCHER / BLAZEDEX_VERIFIED_CLEAN_WINDOW_MS tunables

Step 4 gate results

  • Multi-project index totals (test corpus, solution mode): projectCount=3, components=160, pages=37, handler bindings=578, component usages=626, component APIs=157.
  • Cold Roslyn build: ~10-16s full solution (spec §10 budget <30s).
  • Tier 2 warm-load: 18-22 ms (target <500 ms, hard limit 2000 ms). First list_razor_components on warm restart: ~82 ms wall-clock vs ~11 s cold → ~134× speedup.
  • Tier 2 DB size after cold write-back (the test corpus): ~876-884 KB (meta + 3 projects + 271 fingerprint entries + row tables).
  • Watcher fast-path hit (Phase 1 of smoke-watcher.py): immediate 2nd tool call returns in 0.001 s vs 10.72 s cold.
  • Watcher touch → rebuild (Phase 2 of smoke-watcher.py): os.utime on .razor → 500 ms wait → next call detects drift, runs full rebuild 8.28 s, lastBuiltAtUtc advances ~8.8 s.
  • Corrupt-DB degradation (Phase 2 of smoke-tier2.py): overwrite DB with garbage → server still serves via Tier 1 cold build, state ∈ {green, yellow}, tier2_loaded=false, no crash.
  • Smoke suite: 11 scripts green in solution mode (smoke-find-component, smoke-find-handler-bindings, smoke-list-razor-components, smoke-list-pages, smoke-get-component-api, smoke-freshness, smoke-invalidation, smoke-multi-project, smoke-codebehind-partners, smoke-tier2, smoke-watcher).
  • Anchor case at every gate: find_handler_bindings("HandleValueChanged")WidgetExample.razor:96:61 → WidgetExampleBase.HandleValueChanged resolves on cold Roslyn, Tier 2 warm-load, and watcher-disabled paths.

Step 5 — Phase 2 API surface extensions (get_component_api)

[COMPLETE 2026-04-12]

  • Phase 2 shipped per .claude/specs/main/2026-04-11-blazor-mcp-phase2-spec.md: 13 new tools, 13 POCO types, schema v3, partial rebuild, add_parameter edit tool.

Phase 1 get_component_api returns [Parameter] + [Inject] with inheritance-chain walking via INamedTypeSymbol.BaseType + name-dedupe — confirmed working cross-project (WidgetExample reaches AnonymousBaseComponent in the sibling Components project, 9 injects total, 7 inherited correctly). Phase 2 closes the remaining surface gaps:

  • [CascadingParameter] properties → new cascadingParameters bucket on ComponentApiRow
  • [EditorRequired] flag on Parameter rows
  • [SupplyParameterFromQuery] flag on Parameter rows (Blazor routing metadata)
  • [Parameter(CaptureUnmatchedValues = true)] flag on Parameter rows
  • RenderFragment / RenderFragment<T> split out of the parameters bucket into a dedicated fragments bucket — today they land in parameters undifferentiated
  • @code { } members beyond [Parameter]/[Inject]: public/protected methods, lifecycle overrides (OnInitializedAsync, OnParametersSet, OnAfterRender, …), public fields, nested types → new methods / fields buckets or a single otherMembers bucket (TBD)
  • C# event declarations — plain .NET events (e.g. AuthStateChanged += …), NOT EventCallback
  • XML doc comments (<summary>) on parameters, events, injects, methods → powers IDE-style hover
  • Class-level attributes: [Authorize] / [AllowAnonymous] / [Route] / [RenderModeXxx] → new classAttributes bucket
  • Generic type constraints (where TItem : …) on the component type signature

All doable via the same INamedTypeSymbol walk + GetAttributes() / GetMembers() filters — no new workspace plumbing, just extra collector branches in ProjectIndexer.CollectComponentApis.

Phase 1 verified working: cross-project, cross-partial, base-of-base inheritance (see Step 3 Success gate — 7/9 WidgetExample injects come from AnonymousBaseComponent in a sibling project).

Out of scope for v0

  • Edit operations (spec Phase 3)
  • CascadingValue flow tracking (spec Phase 2)
  • RenderFragment resolution (spec Phase 2)

Findings & Design Decisions

Step 0 validation — what the Razor source generator gives us (free)

Verified end-to-end against real the test corpus code via spikes/LoadProjectSpike. The .razor.g.cs generator output is a clean, compiler-authoritative projection that answers every spec §4/§5 node and edge type:

Spec requirement How it appears in generated C# Roslyn handle
USES_COMPONENT __builder.OpenComponent<global::FullyQualifiedType>(seq) Match generic invocation; type via SemanticModel.GetSymbolInfo
PASSES_PARAMETER / BINDS_TO AddComponentParameter(seq, nameof(Type.PropName), valueExpr) nameof semantically resolved; value expr has exact .razor location
INVOKES_HANDLER / WIRES_EVENTCALLBACK EventCallback.Factory.Create(this, HandlerName) or Create<T>(...) Handler symbol resolved via SemanticModel — lands on code-behind partial class automatically
ROUTES_TO (@page) [global::...RouteAttribute("/...")] on partial class, one per @page ClassDeclarationSyntax.AttributeLists
INHERITS_COMPONENT (@inherits) public partial class X : BaseName ClassDeclarationSyntax.BaseList
INJECTS (@inject) [Inject] property on generated partial class Property with attribute lookup
_Imports.razor inheritance Inlined at top of each .g.cs with #line back to _Imports.razor Free — SemanticModel already sees them
CODEBEHIND_PARTNERS partial class across .razor.g.cs + .razor.cs FreeINamedTypeSymbol unifies partials
RENDERS_FRAGMENT / ChildContent Nested (RenderFragment)((__builder2) => {...}) lambdas Walk nested invocations on the inner builder
Source-location mapping #line (startLine,startCol)-(endLine,endCol) "source.razor" extended format Location.GetMappedLineSpan() — native, no manual parsing

Anchor case §10 proven:

[spike] HandleValueChanged:
  mapped source:   ...WidgetExample.razor
  mapped line/col: 96:61
  semantic symbol: WidgetExampleBase.HandleValueChanged()
  containing type: WidgetExampleBase

Measured on the real repo: User.Client project opens in 1.5s, full compilation (including 17 Razor-generated trees) in 3.1s. Extrapolated full-solution cold start: 15–20s.

Load-time robustness (MSBuildLocator gotcha)

MSBuildWorkspace requires MSBuildLocator.RegisterDefaults() called before any MSBuild assembly loads, plus Microsoft.Build* package references pinned with ExcludeAssets="runtime" PrivateAssets="all" to avoid error MSBL001. Verified working template:

<PackageReference Include="Microsoft.Build.Locator" Version="1.11.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.3.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="5.3.0" />
<PackageReference Include="Microsoft.Build"                Version="17.11.48" ExcludeAssets="runtime" PrivateAssets="all" />
<PackageReference Include="Microsoft.Build.Framework"      Version="17.11.48" ExcludeAssets="runtime" PrivateAssets="all" />
<PackageReference Include="Microsoft.Build.Tasks.Core"     Version="17.11.48" ExcludeAssets="runtime" PrivateAssets="all" />
<PackageReference Include="Microsoft.Build.Utilities.Core" Version="17.11.48" ExcludeAssets="runtime" PrivateAssets="all" />

Copy this into BlazeDex.Mcp.csproj in Step 1. Reference implementation: spikes/LoadProjectSpike/.

Storage model — comparison with existing MCPs

MCP Where Mode Invalidation
serena .serena/cache/ inside the project (pickle files like document_symbols_cache_v23-06-25.pkl) Per-project on-disk cache, version-suffixed Explicit project index or cache-version bump; delegates semantic work to the underlying LSP's in-memory index
codebase-memory-mcp ~/.cache/codebase-memory-mcp/ SQLite DB (override via CBM_CACHE_DIR) Central SQLite graph, persists across restarts Background file watcher + git-based change detection; explicit index_repository tool
BlazeDex (this MCP) In-memory Tier 1 always; optional on-disk Tier 2 in Step 4. Location TBD — likely .blazedex/cache.json or SQLite alongside the solution Cache is allowed, but never trusted without validation — see Freshness section Stat-based fingerprint check on every tool call. Explicit reindex tool as escape hatch.

Freshness: the validate-every-call rule (non-negotiable)

The problem. Roslyn's MSBuildWorkspace.GetCompilationAsync() returns a frozen snapshot. If the user (or Claude) edits a file after the snapshot, the index is silently stale until rebuilt. Unacceptable.

The rule. Every tool call — without exception, regardless of which cache tier is in play — must validate cache freshness before serving. Validation is authoritative; it is not a hint.

The mechanism. Per-call stat-based fingerprint check:

  1. At cache-build time, capture fingerprint = { path → last-write-time, path → size } for every file the index depends on: all .razor, .razor.cs, .razor.css, .cs, .csproj, _Imports.razor, Directory.Build.props, global.json under each loaded project directory. Store the set of paths too, so file additions/deletions are detected.
  2. On every tool call, before returning any row: recompute the fingerprint against the current filesystem. Cheap — ~500 files × ~100μs stat = ~50ms.
  3. If the current fingerprint equals the cached one → serve from the cache. Sub-ms.
  4. If anything differs (added, removed, mtime changed, size changed) → invalidate and rebuild from scratch before returning. Tool call blocks ~15–20s. Correct by construction.

Why stat and not FileSystemWatcher as the source of truth. Watchers silently miss events on:

  • Network shares / SMB
  • WSL ↔ Windows bridged paths
  • VS Code "save all" bursts
  • Symlinks and junctions
  • Rapid programmatic edits
  • Crash / suspend / resume cycles

A watcher can be layered on top purely as a fast-path hint that pre-flags the cache as dirty, so on the next call we skip straight to rebuild. But the stat check always runs after, so watcher failures cannot cause stale results. Reliability floor is the stat check.

Two-tier cache design.

  • Tier 1 — in-memory (always): the index lives in the server process after the first build. Stat check on every call decides if it's still valid. Process restart = cold rebuild. This is what we implement in Step 2/3.
  • Tier 2 — on-disk (Step 4, optional): after building Tier 1, serialize to .blazedex/cache.json or SQLite. On MCP process start, deserialize Tier 2 into Tier 1, run the stat check, serve or rebuild. Survives process restarts. Uses the same validation path as Tier 1 — same fingerprint format, same stat check, zero new invalidation logic.

Build-error tolerance (the MCP never fails on a broken project)

Roslyn already tolerates most of this — we just don't get in its way:

  • GetCompilationAsync() returns a non-null Compilation even when the project has compile errors — it does best-effort parsing.
  • SyntaxTrees are walkable regardless of semantic errors.
  • SemanticModel.GetSymbolInfo() returns a null Symbol for things it can't resolve, rather than throwing.
  • Razor source-generator crashes are isolated per input file — one broken .razor does not take down sibling trees.

What the MCP adds on top:

  1. Catch at the tool boundary. Every [McpServerTool] method wraps its body in a try/catch. Exceptions become structured error rows in the response, never propagate to the MCP client.
  2. Subscribe to MSBuildWorkspace.WorkspaceFailed (via RegisterWorkspaceFailedHandler — the event handler is obsolete in 5.3.0), log every diagnostic, never block loading. Partial projects are still usable.
  3. Per-file degradation in the walker. If one .razor.g.cs tree throws while we're walking it, catch, log, skip that file, continue indexing the rest.
  4. resolved: false rows. When a row is recovered syntactically but semantic resolution fails (e.g., HandleValueChanged can't be resolved because its enclosing class has a syntax error elsewhere), we emit the row anyway with {resolved: false, reason: "..."}. Better a yellow answer than no answer.
  5. get_index_status tool. Returns {last_indexed_at, components_indexed, projects_loaded, load_errors[], parse_errors_by_file[], cache_fingerprint_size}. First thing to call when something looks wrong.
  6. reindex tool. Manual escape hatch if the user suspects drift or wants to force a reload after fixing a build problem.

Response states the MCP serves:

  • Green — clean build; all rows have resolved: true; get_index_status.load_errors is empty.
  • Yellow — some files have errors; affected rows come back as resolved: false with a reason; other rows are fully correct; get_index_status.parse_errors_by_file lists the affected files.
  • Red — whole solution fails to load (e.g., missing SDK, unrestored packages); get_index_status.load_errors explains; other tools return empty results without throwing.

SQLite package decision — Microsoft.Data.Sqlite (not System.Data.SQLite)

  • Chosen: Microsoft.Data.Sqlite 10.0.5 metapackage (NOT .Core — bundles e_sqlite3 native via SQLitePCLRaw RID asset, no <Target> wiring)
  • Rejected: System.Data.SQLiteSQLite.Interop.dll side-loads into same unmanaged resolution path as Microsoft.Build.* (MSBL001 region); two native-DLL side-loads sharing that loader path = documented fragility
  • Maturity argument inverted post-2016: Microsoft.Data.Sqlite ships w/ .NET release train (10.0.5 @ 2026-03-12, MS .NET team, MIT) | System.Data.SQLite last substantive release 2024-09-29
  • Transitive closure verified via dotnet list package --include-transitive → zero SQLite today, clean slate
  • ADO.NET surface: DbConnection/DbCommand/DbDataReader | WAL via explicit PRAGMA journal_mode=WAL | pooling opt-in (off by default) | parameterized via command.Parameters.AddWithValue
  • System.Data.SQLite's only differentiator = DataSet/DataAdapter → not used in hand-written cache layer
  • Ref: https://learn.microsoft.com/en-us/dotnet/standard/data/sqlite/compare

What goes into Step 2's minimum viable slice

Step 2 must already implement the freshness rule end-to-end — it's not a later add-on. The minimal slice is:

  1. MSBuildWorkspace open + compilation + Razor tree filter
  2. Walker collecting OpenComponent<T> invocations → {source_razor_path, line, component_type_qn} rows
  3. In-memory Tier 1 cache with fingerprint capture
  4. Per-call stat-based fingerprint revalidation
  5. One tool: find_component_usages(name) with rebuild-on-stale behavior
  6. Tool-boundary exception catch
  7. get_index_status stub returning at least {last_indexed_at, fingerprint_size}

Adding freshness in Step 4 instead of Step 2 would mean shipping a slice that's knowingly-broken on any file edit. Not acceptable under the reliability rule.

Step 4 deferral — partial rebuild on drift

ProjectDependencyGraph + reverse-dep closure are built and persisted in-memory, and drift detection logs the closure scope. Step 4 still triggers a full solution rebuild on any drift. Rationale: MSBuildWorkspace does not support in-place project reload, and fresh-workspace-per-affected-project cascades to ~whole-solution reload once transitive ProjectReference closure is required for symbol resolution (the test corpus: Components referenced by both clients). Full cold rebuild measured 10-16 s in the test corpus, within spec §10 <30 s budget. Rows are already Roslyn-independent (pre-mapped Location.GetMappedLineSpan() at index build time), so a future step can switch drift handling to true partial rebuild without schema changes.

Step 4 — Tier 2 write-back / watcher bootstrap race (closed in Step 5)

Fire-and-forget Tier 2 write-back (Task 08) may commit only a subset of projects before the server process exits. On next warm-load, IndexSnapshot.ProjectSnapshots contains only the committed subset and BootstrapWatcher initialises FileSystemWatcher instances for that subset only. Drift in an uncommitted project's directory then never fires the watcher hint, and the stat-check still catches it (because stat IS authoritative), but the fast-path window may hide the drift for up to BLAZEDEX_VERIFIED_CLEAN_WINDOW_MS (default 250 ms).

Surfaced by smoke-watcher.py Phase 2, worked around by clearing the Tier 2 DB before the phase to force a cold build that loads all projects. Correctness is preserved — stat check still detects drift outside the fast-path window — but latency-bounded drift may be missed under pathological timing.

Fix options for a later step (Step 5+):

  • (a) Block first tool-call response until write-back drains (adds <1s to the first call, trivial).
  • (b) Run write-back synchronously on IHostApplicationLifetime.ApplicationStopping so every shutdown commits all projects.
  • (c) On warm-load, validate that the loaded project set matches solution discovery; mismatch → discard warm-load, fall through to cold build.

Option (c) is the safest — matches the "validate before serve" freshness invariant.

Step 4 — Layer 2 as union arrays (upgrade path preserved)

Layer 2 derived rows (solution-wide) are exposed as union ImmutableArrays across all ProjectSnapshot.ProjectSnapshots values. Scan cost matches Step 3 (no regression). If a future tool demands O(1) FQN lookup, ImmutableDictionary indexes can layer on top without disturbing the per-project Layer 1 rows.

Step 4 — smoke-invalidation.py scope constraint

The invalidation smoke test must run in solution mode (BLAZEDEX_SOLUTION) because it touches Card.razor in the Components project. In single-project mode (BLAZEDEX_PROJECT=User.Client.csproj) the Components project is not loaded, so drift on a file outside the tracked fingerprint does not trigger a rebuild. The script is intentionally locked to solution mode — this matches the "drift across dependent projects" semantic it is designed to test. A latency post-touch (time.sleep(0.5)) was added in Task 10 to escape the watcher fast-path window in the default 250 ms configuration.

Step 5 — Publishing Readiness ✅ DONE

  • Watcher bootstrap race closed: cold-build Tier 2 write-back is awaited on the first tool call before the response returns; live project-set validation on warm-load via DiscoverExpectedProjectPathsAsync discards mismatched warm-loads and falls through to a cold build (matches the "validate before serve" freshness invariant). Watcher now sees the full project set on every warm-load.
  • .mcp.json redacted of references to the internal test corpus prior to publish.
  • Build lock decoupled from the released DLL: published to artifacts/publish/BlazeDex.Mcp/ (separate from bin/Release); CI uses -c Release consistently to avoid mixed-config artifacts.
  • README.md shipped (10 sections, 400–600 lines): TL;DR, Status / compatibility, Install, Configure, First run, Tool catalog (20 tools across Discovery / Usage / API / Quality / Edit / Diagnostics), Troubleshooting, Known limitations, License, Architecture pointer.
  • docs/env.md shipped: environment variable reference for BLAZEDEX_SOLUTION, BLAZEDEX_PROJECT, BLAZEDEX_WATCHER, BLAZEDEX_VERIFIED_CLEAN_WINDOW_MS, plus per-OS setup.
  • docs/mcp.json.example shipped: consumer .mcp.json template with command: dotnet, args: [BlazeDex.Mcp.dll], env: { BLAZEDEX_SOLUTION }.
  • LICENSE shipped: Elastic License 2.0; copyright Copyright (c) 2026 Tomáš Filip.
  • CHANGELOG.md shipped: keep-a-changelog format with single ## [0.1.0] - 2026-04-13 entry covering Phase 1, Phase 2, the race fix, and publishing readiness.
  • v0.1.0 publish profile + scripts/release.sh for producing the release zip.
  • CI workflow (.github/workflows/ci.yml) enforcing dotnet format --verify-no-changes + dotnet build + dotnet test on every push and pull request.
  • proj-verifier agent's format gate is now mandatory before any /commit invocation.