🔧 Bug Fix Log — 2026-05-30 / 2026-05-31 9 Resolved

NexaFlowDemo Supply Chain AI • MCP Version • ModelContextProtocol v1.3.0 • .NET 9 • ASP.NET Core MVC

Bug #1 — MCP Server Startup Crash Resolved

Date Resolved
2026-05-30
Affected File
McpServer/Program.cs
Exit Code
3762504530 (0xE0434352)
Severity
🔴 Startup blocker

Summary

The MCP server process (NexaFlowDemo.McpServer) crashed immediately on every startup with Windows fatal exit code 0xE0434352 (unhandled .NET exception). Claude Code reported it as "MCP server process exited unexpectedly", making all MCP tools unavailable. A single-line fix — adding a TypeInfoResolver to the JsonSerializerOptions instance passed to WithToolsFromAssembly — resolved the crash completely.

Symptom

Claude Code / host-side error

IOException: MCP server process exited unexpectedly (exit code: 3762504530)
Server's stderr tail:
  at System.Collections.Concurrent.ConcurrentDictionary`2.GetOrAdd(...)
  at Microsoft.Extensions.DependencyInjection.ServiceProvider.GetService(...)
  at Microsoft.Extensions.Hosting.Internal.Host.StartAsync(...)
  at Program.<Main>$(String[] args)
     in ...\NexaFlowDemo.McpServer\Program.cs:line 45

Server-side unhandled exception (full)

Unhandled exception. System.InvalidOperationException:
  JsonSerializerOptions instance must specify a TypeInfoResolver
  setting before being marked as read-only.

Stack highlights:
  System.Text.Json.ThrowHelper.ThrowInvalidOperationException_JsonSerializerOptionsNoTypeInfoResolverSpecified()
  System.Text.Json.JsonSerializerOptions.MakeReadOnly()
  Microsoft.Extensions.AI.AIFunctionFactory.ReflectionAIFunctionDescriptor.GetOrCreate(...)
  Microsoft.Extensions.AI.AIFunctionFactory.ReflectionAIFunction.Build(...)
  ModelContextProtocol.Server.AIFunctionMcpServerTool.Create(...)
  ModelContextProtocol.Server.McpServerTool.Create(...)
  McpServerBuilderExtensions.WithTools(...)  ← triggered by WithToolsFromAssembly

Exit code 3762504530 is the hexadecimal value 0xE0434352 — the standard Windows HRESULT for an unhandled CLR (.NET) exception propagating past Main.

Root Cause

Program.cs created a bare JsonSerializerOptions and passed it to WithToolsFromAssembly:

var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);  // ← no TypeInfoResolver
builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithToolsFromAssembly(Assembly.GetExecutingAssembly(), jsonOptions);

When WithToolsFromAssembly internally calls AIFunctionFactory.Create (via McpServerTool.Create), the MCP SDK seals the JsonSerializerOptions instance by calling MakeReadOnly(). Starting in .NET 8, MakeReadOnly() enforces that a TypeInfoResolver must be present — this is required for the JSON source-generation and reflection pipeline to function correctly. Because no resolver was set, the runtime threw InvalidOperationException before the host could finish starting.

Component Version Relevance
ModelContextProtocol 1.3.0 Calls AIFunctionFactory which seals the options
Microsoft.Extensions.AI (transitive) Contains AIFunctionFactory.ReflectionAIFunctionDescriptor
System.Text.Json (.NET 9) 9.0.x Enforces TypeInfoResolver requirement on MakeReadOnly()
ⓘ This constraint was introduced in .NET 8 as part of the push toward AOT / trimming safety. Any JsonSerializerOptions that participates in reflection-based serialization must declare a resolver so the runtime knows how to locate type metadata.

Fix

File: src/NexaFlowDemo.McpServer/Program.cs — line 32

Before

var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);

After

var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver()
};

Why DefaultJsonTypeInfoResolver?

DefaultJsonTypeInfoResolver is the standard reflection-based resolver shipped with System.Text.Json. It handles all runtime-reflection scenarios (attributes, converters, polymorphism) that the MCP tool-descriptor pipeline relies on. No source-generator or custom contract resolver is required here because the tool methods are discovered via MethodInfo reflection at startup — not at compile time.

Alternative (if you want to drop the options object entirely)

If you have no other customisation in jsonOptions, you can remove it altogether — the MCP SDK defaults are safe:

builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithToolsFromAssembly(Assembly.GetExecutingAssembly());  // no jsonOptions arg

The chosen fix keeps the explicit options object so future Web-defaults tweaks remain easy to add.

Verification

After applying the fix, running the server directly produced a clean startup:

$ dotnet run --project src/NexaFlowDemo.McpServer

info: Program[0]  Seeding NexaFlow knowledge base...
info: Program[0]  Seeded 6 knowledge base documents
info: ModelContextProtocol.Server.StdioServerTransport[...]
      Server (stream) (NexaFlowDemo.McpServer) transport reading messages.
info: Microsoft.Hosting.Lifetime[0]  Application started. Press Ctrl+C to shut down.
...
EXIT: 0

Background & Further Reading

How WithToolsFromAssembly works

WithToolsFromAssembly scans the given assembly for classes annotated with [McpServerToolType], then calls McpServerTool.Create for each public method annotated with [McpServerTool]. Internally this delegates to Microsoft.Extensions.AI.AIFunctionFactory.Create(MethodInfo, …), which builds an AIFunctionFactoryOptions wrapping the caller-supplied JsonSerializerOptions. The first time a descriptor is materialised it calls JsonSerializerOptions.MakeReadOnly() — at which point .NET 9 validates the TypeInfoResolver requirement.

Tip: watch for this pattern in future MCP upgrades

Any future upgrade to ModelContextProtocol or Microsoft.Extensions.AI that changes how JsonSerializerOptions is consumed may surface similar issues. Always verify that any JsonSerializerOptions passed across library boundaries includes at minimum a DefaultJsonTypeInfoResolver.

Bug #2 — Pipeline Page: signalR is not defined Resolved

Date Resolved
2026-05-30
Affected Page
Pipeline (Orchestration)
Affected File
Views/Shared/_Layout.cshtml
Severity
🔴 Feature broken

Symptom

Browser console

Uncaught ReferenceError: signalR is not defined
  at Orchestration/Index (line: const connection = new signalR.HubConnectionBuilder()...)

The Pipeline page loaded but the SignalR hub connection could not be created, so the Run Pipeline button did nothing and no agent steps ever appeared.

Root Cause

_Layout.cshtml loaded the SignalR JavaScript client from cdnjs with a integrity (SRI) hash that did not match the actual file content:

<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/8.0.0/signalr.min.js"
        integrity="sha512-oMBqGbGFobfwXNFRZb/eKNmQBn7qLQoJrjB2kFdHCVBi42yBxVCZCaJVg2YiRnCGTAeMVNGpkPxYCR1CpNw=="
        crossorigin="anonymous"></script>

When a browser enforces Subresource Integrity (SRI) and the computed hash of the downloaded file does not match the declared integrity attribute, it blocks the script entirely — the file is never executed and the signalR global is never defined. Any code that subsequently references signalR throws a ReferenceError.

Fix

The CDN dependency and the unreliable hash were eliminated by downloading signalr.min.js (v8.0.0) locally and referencing it as a static asset:

1 — Download the file

curl -sL https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/8.0.0/signalr.min.js \
     -o src/NexaFlowDemo.Web/wwwroot/lib/signalr/signalr.min.js

2 — Update _Layout.cshtml

<script src="https://cdnjs.cloudflare.com/ajax/libs/..." integrity="sha512-..." crossorigin="anonymous"></script>
<script src="~/lib/signalr/signalr.min.js"></script>

Result

The file is served from the local web server — no CDN, no SRI check, no network dependency. signalR is defined when the page scripts run and the hub connection succeeds.

Bug #3 — Integration Page: Unable to resolve service for ErpConnectorTool Resolved

Date Resolved
2026-05-30
Affected Page
Integration
Affected File
Web/Program.cs
Severity
🔴 Page crash (500)

Symptom

ASP.NET Core exception

InvalidOperationException: Unable to resolve service for type
  'NexaFlowDemo.Core.MCP.Tools.ErpConnectorTool'
while attempting to activate
  'NexaFlowDemo.Web.Controllers.IntegrationController'.

Navigating to the Integration page threw a 500 immediately. No ERP actions (create PO, get rates, etc.) were accessible.

Root Cause

IntegrationController declares ErpConnectorTool as a constructor parameter, so ASP.NET Core's DI framework must resolve it when the controller is activated:

// IntegrationController.cs
public IntegrationController(ICarrierApiService carrier, ErpConnectorTool erp)  // ← not registered
{
    _carrier = carrier;
    _erp     = erp;
}

ErpConnectorTool was registered in the MCP server project's DI (NexaFlowDemo.McpServer/Program.cs) but was never added to the web application's DI container (NexaFlowDemo.Web/Program.cs). The two projects are separate processes with separate DI roots — a registration in one has no effect on the other.

Why is ErpConnectorTool injected directly?
The Web app uses ErpConnectorTool for the interactive ERP panel on the Integration page (create PO, post GR, get financials). These actions hit the in-process simulated ERP directly, independently of the MCP server subprocess. The MCP server also exposes the same tool over JSON-RPC for agent use.

Fix

File: src/NexaFlowDemo.Web/Program.cs — Integration section

Before

// ── Integration ───────────────────────────────────────────────────────────
builder.Services.AddSingleton<ICarrierApiService, SimulatedCarrierApiService>();

After

// ── Integration ───────────────────────────────────────────────────────────
builder.Services.AddSingleton<ICarrierApiService, SimulatedCarrierApiService>();
builder.Services.AddSingleton<NexaFlowDemo.Core.MCP.Tools.ErpConnectorTool>();

Result

The Integration page loads without error. ErpConnectorTool is registered as a singleton — matching its usage pattern (it holds in-memory PO state that should persist for the lifetime of the web process). The MCP server subprocess retains its own independent singleton instance as before.

Pattern to avoid

When a class from Core is used by both a subprocess (MCP server) and the main web app, it must be registered in each process's DI container independently. DI registrations are process-scoped, not solution-scoped.

Bug #4 — IIS Deployment Shows Development Error Page Resolved

Date Resolved
2026-05-30
Affected File
Web/web.config (new)
Affected Host
Local IIS
Severity
🔴 Deployment blocker

Symptom

Browser error page on IIS deployment

Development Mode
Swapping to Development environment will display more detailed
information about the error that occurred.

The Development environment shouldn't be enabled for deployed
applications. It can result in displaying sensitive information
from exceptions to end users. For local debugging, enable the
Development environment by setting the ASPNETCORE_ENVIRONMENT
environment variable to Development and restarting the app.

Every page in the IIS-hosted app showed this screen instead of rendering normally. The app itself was starting successfully, but ASP.NET Core was running in Development mode and its developer exception page was intercepting all requests.

Root Cause

Visual Studio sets a machine-level environment variable ASPNETCORE_ENVIRONMENT=Development on developer workstations during installation. IIS worker processes inherit all machine and user environment variables, so every ASP.NET Core app hosted in IIS on that machine also started in Development mode — regardless of the launchSettings.json profile used in Visual Studio (that file is only read by dotnet run and the VS debugger, never by IIS).

Because the project had no web.config, there was nothing to override the inherited machine variable at the application level. Program.cs correctly gates the developer exception page on the environment:

// Program.cs — correct, but powerless without the right env value
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}

With the environment resolving to Development, IsDevelopment() returned true, the production error handler was skipped, and the ASP.NET Core developer exception page handled every request.

Why does launchSettings.json not help here?
launchSettings.json is a local developer convenience file consumed only by dotnet run and the Visual Studio launcher. It is never read by IIS, Kestrel when started as a Windows Service, or any other production host. It is intentionally excluded from publish output.

Fix

A web.config was added to src/NexaFlowDemo.Web/. The Microsoft.NET.Sdk.Web SDK automatically copies files named web.config from the project root to the publish output, so no .csproj changes were required.

New file: src/NexaFlowDemo.Web/web.config

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <location path="." inheritInChildApplications="false">
    <system.webServer>
      <handlers>
        <add name="aspNetCore" path="*" verb="*"
             modules="AspNetCoreModuleV2" resourceType="Unspecified" />
      </handlers>
      <aspNetCore processPath="dotnet"
                  arguments=".\NexaFlowDemo.Web.dll"
                  stdoutLogEnabled="false"
                  stdoutLogFile=".\logs\stdout"
                  hostingModel="inprocess">
        <environmentVariables>
          <environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Production" />
        </environmentVariables>
      </aspNetCore>
    </system.webServer>
  </location>
</configuration>

The <environmentVariables> block inside <aspNetCore> is processed by the ASP.NET Core Module (ANCM) v2 before the app starts. App-level variables set here take precedence over machine-level and app-pool-level environment variables, so ASPNETCORE_ENVIRONMENT=Production is always effective regardless of what the developer workstation has set at the OS level.

Result

IIS now runs the app in Production mode. The production error handler (/Home/Error) is active, HSTS is applied, and no developer-mode information is exposed to end users.

Note: stdout log folder

stdoutLogEnabled is set to false. If you ever enable it for diagnostic purposes, create a logs\ folder in the IIS site root first — ANCM will not create it automatically and the app will fail to start if the path does not exist.

Verification

After deploying the updated publish output (which now includes web.config) and recycling the IIS application pool:

Alternative approach (not taken): You can also set ASPNETCORE_ENVIRONMENT=Production directly in IIS Manager under Configuration Editor → system.webServer/aspNetCore → environmentVariables, or in the application pool's Advanced Settings. The web.config approach is preferred because it is source-controlled and survives redeployments without manual IIS configuration steps.

Bug #5 — Home Page Crash After Production Mode Fix Resolved

Date Resolved
2026-05-30
Affected File
Web/Controllers/HomeController.cs
Affected Host
Local IIS
Severity
🔴 Home page crash (500)

Symptom

Browser — production error page on home page load

An error occurred while processing your request.
Request ID: 00-49cf9594af012c65bfadbb22569d26a9-cddc18e9fbb93643-00

Development Mode
Swapping to Development environment will display more detailed
information about the error that occurred.

The Development environment shouldn't be enabled for deployed
applications...

After the Bug #4 fix set ASPNETCORE_ENVIRONMENT=Production, the home page started returning a 500 error. The error message looks similar to Bug #4, causing confusion, but it is structurally different:

ⓘ The two errors look nearly identical in the browser but have opposite meanings. Bug #4 was the developer exception page (app running in Development mode, no real request error). Bug #5 is the production error page (app correctly in Production mode, real exception thrown by a page request).

Root Cause

HomeController.Index() calls _mcp.ListTools() to populate the tool count on the dashboard. ListTools() blocks synchronously on EnsureInitializedAsync(), which spawns the MCP server as a subprocess on first call:

// HomeController.cs — before fix
public async Task<IActionResult> Index()
{
    var allDocs   = await _docs.GetAllAsync();
    var allMemory = await _memory.GetAllAsync();
    var vm = new HomeViewModel
    {
        DocumentCount    = allDocs.Count,
        ToolCount        = _mcp.ListTools().Count,   // throws if MCP subprocess fails
        MemoryEntryCount = allMemory.Count
    };
    return View(vm);
}

Under IIS in-process hosting (hostingModel="inprocess"), the ASP.NET Core Module loads the .NET runtime directly into w3wp.exe — it does not invoke dotnet.exe to start the web app. As a result, the IIS worker process environment may not have dotnet on its PATH, causing Process.Start("dotnet", …) (used to spawn the MCP server) to throw a Win32Exception: The system cannot find the file specified.

Because this exception was unhandled in the controller action, it propagated through the exception handler middleware to /Home/Error, crashing the home page on every request.

Call chainOutcome
HomeController.Index() Calls _mcp.ListTools() unconditionally
RealMcpClientService.ListTools() Blocks on EnsureInitializedAsync().GetAwaiter().GetResult()
EnsureInitializedAsync() Calls Process.Start("dotnet", mcpDll) — fails under IIS
Exception propagates MVC exception handler renders /Home/Error → 500

Fix

File: src/NexaFlowDemo.Web/Controllers/HomeController.cs

Wrap the ListTools() call in a try/catch so the home page renders with a tool count of 0 if the MCP server is unavailable, and logs a warning instead of crashing.

Before

var vm = new HomeViewModel
{
    DocumentCount    = allDocs.Count,
    ToolCount        = _mcp.ListTools().Count,
    MemoryEntryCount = allMemory.Count
};

After

int toolCount = 0;
try { toolCount = _mcp.ListTools().Count; }
catch (Exception ex) { _logger.LogWarning(ex, "MCP server unavailable on home page load"); }

var vm = new HomeViewModel
{
    DocumentCount    = allDocs.Count,
    ToolCount        = toolCount,
    MemoryEntryCount = allMemory.Count
};

Result

The home page loads successfully even when the MCP server subprocess cannot be started. The tool count displays as 0 and a warning is written to the application log. MCP-dependent features (chat, pipeline) remain available for investigation independently, so a subprocess path or PATH problem does not take down the entire UI.

Underlying MCP subprocess issue

If dotnet is not on PATH for the IIS worker process, configure the full executable path in appsettings.json:

"McpServer": {
    "Command": "C:\\Program Files\\dotnet\\dotnet.exe",
    "Arguments": null,
    ...
}

Run where dotnet in a terminal to find the correct path, then recycle the IIS application pool. Enable stdout logging (stdoutLogEnabled="true" in web.config, with a logs\ folder present) to capture the exact exception if the problem persists.

Verification

Bug #6 — MCP Tools Page Crashes with "Development Mode" Error Resolved

Date Resolved
2026-05-30
Affected Page
MCP Tools (/Mcp/Index)
Affected Files
McpController.cs, ViewModels.cs, Mcp/Index.cshtml, RealMcpClientService.cs, Web.csproj
Severity
🔴 Page crash (500)

Symptom

Browser — production error page on /Mcp/Index

An error occurred while processing your request.
Request ID: …

Development Mode
Swapping to Development environment will display more detailed
information about the error that occurred.

The Development environment shouldn't be enabled for deployed
applications…

All other pages loaded normally (the app was in Production mode, ports 8081/8082, IIS in-process hosting). Only navigating to MCP Tools triggered a 500. The "Development Mode" text is static boilerplate in the default Views/Shared/Error.cshtml template; it does not mean the app is in Development mode — it is the standard production error page.

Root Cause

McpController.Index() called _mcp.ListTools() with no exception handling:

public IActionResult Index() => View(new McpViewModel { Tools = [.. _mcp.ListTools()] });

Under IIS (publish output), RealMcpClientService.ResolveServerCommand() computed paths based on Assembly.Location. In a flat publish directory the relative path four levels up points to nothing:

ScenariowebBinComputed McpServer pathExists?
dotnet run (source)…/bin/Debug/net9.0/…/src/NexaFlowDemo.McpServer/bin/Debug/net9.0/NexaFlowDemo.McpServer.dll✓ if built
IIS publishC:\site\nexaflow\C:\NexaFlowDemo.McpServer\bin\Release\net9.0\NexaFlowDemo.McpServer.dll✕ never

The dotnet-run fallback (dotnet run --project <csproj>) similarly failed because the csproj is not in the publish output. McpClient.CreateAsync() threw an IOException (server process exited unexpectedly), which propagated through the unguarded controller action to the production error handler, rendering Error.cshtml with its hardcoded "Development Mode" advisory text.

Why did Bug #5 not catch this? Bug #5 added try/catch to HomeController.Index(), which prevented the home page from crashing. McpController.Index() had no equivalent guard — same root failure, different entry point.

Fix

1 — McpController.cs: add exception handling + inject ILogger

public IActionResult Index() => View(new McpViewModel { Tools = [.. _mcp.ListTools()] });

public IActionResult Index()
{
    string? error = null;
    List<McpToolDefinition> tools = [];
    try   { tools = [.. _mcp.ListTools()]; }
    catch (Exception ex)
    {
        _logger.LogError(ex, "MCP server unavailable on page load");
        error = ex.Message;
    }
    return View(new McpViewModel { Tools = tools, Error = error });
}

// Also guard the API endpoint:
[HttpGet("api/mcp/tools")]
public IActionResult ListTools()
{
    try { return Ok(_mcp.ListTools()); }
    catch (Exception ex) { return StatusCode(503, new { error = ex.Message }); }
}

2 — ViewModels.cs: add Error property to McpViewModel

public class McpViewModel
{
    public List<McpToolDefinition> Tools   { get; set; } = [];
    public string  SelectedTool            { get; set; } = string.Empty;
    public string  InputJson               { get; set; } = string.Empty;
    public string? ResultJson              { get; set; }
    public string? Error                   { get; set; }
}

3 — Views/Mcp/Index.cshtml: render error banner when MCP is unavailable

@if (Model.Error != null)
{
    <div class="alert alert-danger">
        <strong>MCP Server Unavailable</strong> — @Model.Error
        <!-- links to this doc page for resolution steps -->
    </div>
}

4 — RealMcpClientService.cs: check adjacent DLL first (flat publish layout)

var webBin = Path.GetDirectoryName(typeof(RealMcpClientService).Assembly.Location)!;

// Flat publish: both web and McpServer published to the same output dir.
var mcpDllAdjacent = Path.Combine(webBin, "NexaFlowDemo.McpServer.dll");
if (File.Exists(mcpDllAdjacent))
    return (_options.Command, new[] { mcpDllAdjacent });

// Source / debug run: sibling project bin output.
var config = webBin.Contains("Release", ...) ? "Release" : "Debug";
var mcpDll = Path.GetFullPath(Path.Combine(webBin, "..", "..", "..", "...",
             "NexaFlowDemo.McpServer", "bin", config, "net9.0", "NexaFlowDemo.McpServer.dll"));
...

5 — NexaFlowDemo.Web.csproj: publish McpServer alongside web on every dotnet publish

<Target Name="PublishMcpServer" AfterTargets="Publish">
  <MSBuild
    Projects="$(MSBuildThisFileDirectory)..\NexaFlowDemo.McpServer\NexaFlowDemo.McpServer.csproj"
    Targets="Publish"
    Properties="PublishDir=$(PublishDir);Configuration=$(Configuration);SelfContained=false" />
</Target>

After this change, dotnet publish on the web project automatically publishes the McpServer to the same output directory. ResolveServerCommand() then finds NexaFlowDemo.McpServer.dll adjacent to the web DLL and spawns it directly — no path-guessing required.

Result

The MCP Tools page renders with a clear amber alert when the server is unavailable, instead of a generic 500 error page. When the McpServer DLL is present in the publish output (after applying fix 5), the page loads normally and all tools are listed.

Manual configuration fallback

If you cannot re-publish, configure the McpServer path explicitly in appsettings.json (use where dotnet to find the runtime path):

"McpServer": {
    "Command":        "C:\\Program Files\\dotnet\\dotnet.exe",
    "ExecutablePath": "C:\\full\\path\\to\\NexaFlowDemo.McpServer.dll"
}

Verification

Bug #7 — Docs Link Returns 404 in Deployed / IIS Scenario Resolved

Date Resolved
2026-05-30
Affected URL
/docs/learn_index.html
Affected Files
Web/Program.cs, NexaFlowDemo.Web.csproj
Severity
🔴 Broken navigation link

Symptom

Browser — 404 Not Found

https://localhost:8082/docs/learn_index.html  can't be found

The Docs nav link (top-right, all pages) leads to /docs/learn_index.html. In the deployed IIS environment the link returned a 404 with no further explanation. The HTML files existed on disk; they just weren't being served.

Root Cause

Program.cs registered a static-file middleware for the /docs virtual path only when the physical folder existed:

var docsPath = Path.GetFullPath(
    Path.Combine(app.Environment.ContentRootPath, "..", "..", "docs"));
if (Directory.Exists(docsPath))   // ← silently skipped if false
{
    app.UseStaticFiles(new StaticFileOptions { … RequestPath = "/docs" });
}

ContentRootPath is set to Directory.GetCurrentDirectory() at startup, which differs between the two run modes:

Run modeContentRootPathResolved ../../docsExists?
dotnet run (source) …/src/NexaFlowDemo.Web/ …/AgenticSample/docs/
IIS publish (C:\site\nexaflow\) C:\site\nexaflow\ C:\docs\ ✕ never

In the publish scenario Directory.Exists returned false, so the UseStaticFiles call was never made. Every request to /docs/* fell through to the MVC routing layer, which had no matching route and returned 404.

Fix

1 — NexaFlowDemo.Web.csproj: include docs folder as publish content

<ItemGroup>
  <Content Include="..\..\docs\**"
           Link="docs\%(RecursiveDir)%(Filename)%(Extension)"
           CopyToOutputDirectory="PreserveNewest"
           CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>

The relative path ..\..\docs\** resolves from src/NexaFlowDemo.Web/ to AgenticSample/docs/. MSBuild copies every HTML file into <PublishDir>/docs/ during dotnet publish.

2 — Program.cs: probe publish location first, fall back to source layout

var docsPath = Path.GetFullPath(Path.Combine(app.Environment.ContentRootPath, "..", "..", "docs"));

// Publish layout: docs copied to ContentRoot/docs by the csproj Content item.
var docsAtRoot = Path.GetFullPath(Path.Combine(app.Environment.ContentRootPath, "docs"));
// Source layout: docs reside two levels above the web project directory.
var docsAtSrc  = Path.GetFullPath(Path.Combine(app.Environment.ContentRootPath, "..", "..", "docs"));
var docsPath   = Directory.Exists(docsAtRoot) ? docsAtRoot : docsAtSrc;

if (Directory.Exists(docsPath))
{
    app.UseStaticFiles(new StaticFileOptions
    {
        FileProvider = new PhysicalFileProvider(docsPath),
        RequestPath  = "/docs"
    });
}

Result

After republishing, https://localhost:8082/docs/learn_index.html returns HTTP 200 and renders correctly. The Docs nav link works in both deployed (IIS/Kestrel publish) and source (dotnet run) scenarios without any manual file copying.

Verification

Bug #8 — RAG Chat: Unexpected token '<' on Query Submit Resolved

Date Resolved
2026-05-31
Affected Page
RAG Chat (/Chat/Ask)
Affected Files
ChatController.cs, Views/Chat/Index.cshtml
Severity
🔴 Feature completely broken

Symptom

Browser — JavaScript error in RAG Chat

Error: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
  at sendQuery (Chat/Index:57)

Clicking the Inventory policy suggestion button (or typing any query) and pressing Ask caused the chat window to show a red error message "Error: Unexpected token '<'". No answer was displayed, no sources appeared, and the Thinking… indicator was replaced by the error text.

The query that reliably reproduced the issue:

What is the inventory reorder policy?

Root Cause

The error is a JavaScript parse failure: res.json() received an HTML document (<!DOCTYPE html>…) when it expected JSON. The < in "Unexpected token '<'" is the first character of the HTML page.

Why HTML was returned instead of JSON

The full call chain when a query is submitted:

LayerCodeOutcome
JavaScript fetch('/Chat/Ask', { method:'POST', … }) POST sent with JSON body
Controller ChatController.Ask() Calls _rag.QueryAsync()no try/catch
RAG Pipeline RagPipeline.QueryAsync() Calls _llm.CompleteAsync()
LLM ClaudeService.CompleteAsync() Sends HTTP POST to https://api.anthropic.com/v1/messages
Anthropic API Returns non-2xx (e.g. 401 invalid key, 429 rate-limit, 5xx outage)
LLM response.EnsureSuccessStatusCode() Throws HttpRequestException
Controller Exception propagates — no catch ASP.NET Core middleware handles it
ASP.NET Core Dev mode: developer exception page
Prod mode: redirect to /Home/Error
HTML page returned to client
JavaScript await res.json() ❌ Throws "Unexpected token '<'"
The same failure path is triggered by any exception in the RAG pipeline — not only API key errors. A network timeout, a JSON parse error on the API response, an embedding service failure, or a vector store exception would all produce the same symptom because ChatController.Ask() had no exception guard and would always surface an HTML error page.

Why the JavaScript error is misleading

The error message "Unexpected token '<'" comes from JSON.parse() when it encounters the < from <!DOCTYPE html>. It gives no indication that the server returned an error — the developer must open the Network tab in DevTools and inspect the raw response body to see the actual exception.

Fix

Two changes were made — one server-side (primary) and one client-side (secondary defence).

Fix 1 — src/NexaFlowDemo.Web/Controllers/ChatController.cs

Add a try/catch around _rag.QueryAsync() so that any exception is caught and returned as a JSON { error: "…" } response with HTTP 500 — never as an HTML page. Also inject ILogger<ChatController> so the exception is logged with full stack trace.

Before

public class ChatController : Controller
{
    private readonly RagPipeline _rag;

    public ChatController(RagPipeline rag) => _rag = rag;

    [HttpPost]
    public async Task<IActionResult> Ask([FromBody] ChatRequest req, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(req.Query))
            return BadRequest(new { error = "Query is required." });

        var response = await _rag.QueryAsync(req.Query, ct);  // unguarded — any exception → HTML
        return Json(new
        {
            answer  = response.Answer,
            sources = response.SourceResults.Select(r => new { … })
        });
    }
}

After

public class ChatController : Controller
{
    private readonly RagPipeline _rag;
    private readonly ILogger<ChatController> _logger;

    public ChatController(RagPipeline rag, ILogger<ChatController> logger)
    {
        _rag    = rag;
        _logger = logger;
    }

    [HttpPost]
    public async Task<IActionResult> Ask([FromBody] ChatRequest req, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(req.Query))
            return BadRequest(new { error = "Query is required." });

        try
        {
            var response = await _rag.QueryAsync(req.Query, ct);
            return Json(new
            {
                answer  = response.Answer,
                sources = response.SourceResults.Select(r => new { … })
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "RAG query failed for: {Query}", req.Query);
            return StatusCode(500, new { error = ex.Message });
        }
    }
}

Why this is the primary fix

The contract of POST /Chat/Ask is to return JSON. Adding try/catch enforces that contract unconditionally — no matter what goes wrong inside the pipeline, the client always receives a JSON response. HTTP 500 + { error: "…" } is a valid, parseable response; an HTML developer exception page is not.

Fix 2 — src/NexaFlowDemo.Web/Views/Chat/Index.cshtml

Add a res.ok check immediately after res.json(). If the server returns a non-2xx status (HTTP 400, 500, etc.), throw a meaningful error using the error field from the JSON body rather than letting a subsequent property access fail silently or display undefined in the chat.

Before

const res  = await fetch('/Chat/Ask', { … });
const data = await res.json();              // throws "Unexpected token '<'" if HTML returned
document.getElementById(thinkingId)?.remove();

After

const res  = await fetch('/Chat/Ask', { … });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? `Server error ${res.status}`);
document.getElementById(thinkingId)?.remove();

Why both fixes are needed

  • Fix 1 (server) guarantees the response is always JSON — it prevents the "Unexpected token" exception entirely.
  • Fix 2 (client) is a defence-in-depth layer: if a non-2xx JSON response does arrive (e.g. the HTTP 400 from the empty-query guard), the error message from data.error is surfaced in the chat window rather than silently displaying undefined as the answer.

How to Diagnose This Class of Error in Future

Quick checklist when you see "Unexpected token '<'"

  1. Open Browser DevTools → Network tab, repeat the action, click the failing request.
  2. In the Response tab: if you see <!DOCTYPE html> or a Razor error page, the server returned HTML instead of JSON.
  3. In the Headers tab: check the HTTP status code (401, 500, etc.).
  4. Read the HTML body — it usually contains the actual exception message and stack trace (in Development mode) or a Request ID you can look up in application logs.

API key not configured

If the application log shows Claude API error 401 after applying this fix, the Anthropic API key is still the placeholder value. Set it in appsettings.Development.json:

"LLM": {
    "ApiKey": "sk-ant-…your-real-key…",
    "Model":  "claude-sonnet-4-6",
    "MaxTokens": 4096
}

Never commit a real API key to source control. Use dotnet user-secrets set "LLM:ApiKey" "sk-ant-…" for local development or an environment variable (LLM__ApiKey) in production.

Verification

After applying both fixes:

Example successful response (happy path)

{
  "answer": "NexaFlow's reorder policy triggers a purchase order when on-hand stock
             falls to or below the SKU's reorder point. Reorder points are calculated
             as (average daily usage × lead-time days) + safety stock …",
  "sources": [
    {
      "id":      "doc-inv-policy",
      "title":   "NexaFlow Inventory Management Policy",
      "snippet": "Reorder points are set at (avg daily usage × lead time) + safety stock …",
      "score":   0.8412
    }
  ]
}

Bug #9 — RAG Chat: Policy Details Missing From Answer ("refer to the full document") Resolved

Date Resolved
2026-05-31
Affected Page
RAG Chat (/Chat/Ask)
Affected Files
RagPipeline.cs
VectorDocumentStore.cs
DocumentSearchTool.cs
Severity
🟡 Incomplete answers

Symptom

RAG Chat response when asking about the inventory reorder policy

However, the full details of the reorder policy (such as the specific ROP values
per SKU, order quantities, or escalation procedures) are not fully captured in
the provided context excerpts. For complete details, please refer to the
NexaFlow Inventory Management Policy v3.2.

Queries about the inventory reorder policy returned a partially correct but incomplete answer. The LLM knew the existence of the policy but couldn't answer specifics — per-SKU ROP values, reorder quantities, stock category thresholds — because those details were absent from the context it received.

The query that reliably reproduced the issue:

What is the inventory reorder policy?

Root Cause

The policy document (doc-inv-policy) is seeded in full by KnowledgeBaseSeeder.cs (lines 20–43) and is approximately 1,200 characters long. However, three independent hard-coded truncation limits stripped the content before it reached the LLM, leaving it with only the opening paragraph (~300 chars) and none of the per-SKU detail table.

FileLocationCap% of policy delivered to LLM
RagPipeline.cs BuildContext() line 38 300 chars ~25 %
VectorDocumentStore.cs SearchAsync() line 56 200 chars on chunk ~17 %
DocumentSearchTool.cs InvokeAsync() line 37 150 chars (MCP path) ~13 %
ⓘ The system prompt in RagPipeline.BuildPrompt() correctly instructs the LLM: "If the context doesn't contain the answer, say so." Because the truncated context genuinely did not contain the SKU table, the LLM was behaving correctly — it admitted the gap and referred the user to the source document. The bug was in the pipeline, not the prompt or the LLM.

Call chain (direct RAG path)

StepCodeWhat happens
1. Retrieve VectorDocumentStore.SearchAsync() Returns SearchResult with Snippet capped at 200 chars from the chunk
2. Build context RagPipeline.BuildContext() Falls back to r.Document.Content[..300] when Snippet is set but still short
3. Prompt LLM RagPipeline.BuildPrompt() LLM receives only first ~300 chars — everything after "Stock Categories:" is missing

Call chain (MCP agent path)

StepCodeWhat happens
1. Tool call DocumentSearchTool.InvokeAsync() Serialises results with snippet capped at 150 chars
2. Agent context LLM receives tool JSON Only first 150 chars visible — per-SKU ROP table completely absent

Fix

Removed all three hard-coded truncation limits. The policy document is ~1,200 chars — well within any reasonable context window — so capping it serves no purpose and actively destroys information.

1 — src/NexaFlowDemo.Core/RAG/RagPipeline.csBuildContext()

Before

return string.Join("\n\n", results.Select((r, i) =>
    $"[{i + 1}] {r.Document.Title}\n{r.Snippet ?? r.Document.Content[..Math.Min(300, r.Document.Content.Length)]}"));

After

return string.Join("\n\n", results.Select((r, i) =>
    $"[{i + 1}] {r.Document.Title}\n{r.Snippet ?? r.Document.Content}"));

Impact

The full document content is now passed to the LLM for every retrieved document. For the policy document this means the LLM receives all 6 SKU entries with their ROP values, reorder quantities, stock category definitions, and cycle count rules.

2 — src/NexaFlowDemo.Core/VectorStore/VectorDocumentStore.csSearchAsync()

Before

results.Add(new SearchResult
{
    Document = doc,
    Score    = score,
    Snippet  = chunk.Content[..Math.Min(200, chunk.Content.Length)]
});

After

results.Add(new SearchResult
{
    Document = doc,
    Score    = score,
    Snippet  = chunk.Content
});

Impact

The Snippet field now contains the full chunk text. Since BuildContext() prefers Snippet over Document.Content, this is the primary value seen by the LLM in most queries. With TextChunker using 512-word chunks and the policy fitting in a single chunk, the full document is now always available in Snippet.

3 — src/NexaFlowDemo.Core/MCP/Tools/DocumentSearchTool.csInvokeAsync()

Before

snippet = r.Snippet ?? r.Document.Content[..Math.Min(150, r.Document.Content.Length)],

After

snippet = r.Snippet ?? r.Document.Content,

Impact

The MCP document_search tool now returns the full snippet (or full document content) to the agent. Agent-driven queries that call this tool receive the complete policy detail, enabling them to answer follow-up questions about specific SKUs without needing to issue multiple tool calls.

Why the Caps Were There

The 300 / 200 / 150 char limits were likely defensive placeholders added during initial scaffolding to avoid accidentally flooding the LLM context with a large document. For a production system with very large documents these limits make sense — but they should be implemented as configurable maximum values applied only when the document exceeds a threshold, not as unconditional hard cuts on small policy files.

Recommendation for larger deployments

If the knowledge base grows to include large documents (contracts, manuals, hundreds of pages), reintroduce a soft cap — but make it configurable and apply it only when Document.Content.Length exceeds a threshold (e.g. 8,000 chars), and pair it with a smarter chunking strategy that keeps semantically related content together rather than splitting at fixed word counts.

Verification

After applying all three fixes:

Example successful response after fix

{
  "answer": "NexaFlow's inventory reorder policy triggers a purchase order automatically
             when on-hand stock falls to or below the SKU's Reorder Point (ROP).

             Current SKU reorder points and quantities:
             • NF-SKU-001 Industrial Conveyor Belt  — ROP 50,  reorder qty 200
             • NF-SKU-002 Forklift Hydraulic Seal   — ROP 10,  reorder qty 50
             • NF-SKU-003 Pallet Wrap Film 500m     — ROP 100, reorder qty 500
             • NF-SKU-004 RFID Tracking Label Pack  — ROP 25,  reorder qty 100
             • NF-SKU-005 Cold-Chain Temp Sensor    — ROP 10,  reorder qty 20
             • NF-SKU-006 Heavy-Duty Cargo Net      — ROP 30,  reorder qty 120

             Stock is classified as Critical Low when quantity falls below 50% of
             the ROP (safety stock threshold).",
  "sources": [
    {
      "id":    "doc-inv-policy",
      "title": "NexaFlow Inventory Management Policy",
      "score": 0.9127
    }
  ]
}