Bug #1 — MCP Server Startup Crash Resolved
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() |
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
- Knowledge base seeded successfully (6 documents).
- Stdio JSON-RPC transport started and ready to receive messages.
- Process exited cleanly (code
0) after CTRL+C — no unhandled exceptions. - Claude Code MCP integration reconnects successfully after the fix.
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
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
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.
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
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.
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:
- The home page, chat page, pipeline page, and integration page all load normally.
- Triggering a deliberate error routes to
/Home/Error— the production error page — not the developer exception page. - No
ASPNETCORE_ENVIRONMENTchange was required on the machine or in IIS Manager.
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
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 page now begins with "An error occurred while processing your request."
and a Request ID — these are rendered by the production error handler
(
/Home/Error), not the developer exception page. - The "Development Mode / Swapping to Development environment…" block
below is static boilerplate hardcoded in the default
Views/Shared/Error.cshtmltemplate — it always appears on the production error page, regardless of the actual environment.
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 chain | Outcome |
|---|---|
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
- Home page returns HTTP 200 and renders the dashboard normally.
- If the MCP subprocess fails, tool count shows
0with a warning in the application log — no 500 error. - If the subprocess starts successfully, tool count reflects the actual registered tools.
- All other pages (Chat, Pipeline, Integration) are unaffected.
Bug #6 — MCP Tools Page Crashes with "Development Mode" Error Resolved
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:
| Scenario | webBin | Computed McpServer path | Exists? |
|---|---|---|---|
dotnet run (source) | …/bin/Debug/net9.0/ | …/src/NexaFlowDemo.McpServer/bin/Debug/net9.0/NexaFlowDemo.McpServer.dll | ✓ if built |
| IIS publish | C:\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.
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
- After republishing, the MCP Tools page loads and lists all tools (inventory_lookup, supplier_lookup, etc.).
- If the McpServer DLL is removed, the page shows an amber "MCP Server Unavailable" alert — not a 500.
- The home page tool count continues to display (via its own existing try/catch from Bug #5).
Bug #7 — Docs Link Returns 404 in Deployed / IIS Scenario Resolved
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 mode | ContentRootPath | Resolved ../../docs | Exists? |
|---|---|---|---|
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
dotnet publishoutput contains adocs/subfolder with all 10 HTML learning guides.https://localhost:8082/docs/learn_index.htmlreturns 200.- All
/docs/*.htmllinks within the learning guides resolve correctly. dotnet runfrom the source directory continues to serve docs via the fallback path — no regressions.
Bug #8 — RAG Chat: Unexpected token '<' on Query Submit
Resolved
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:
| Layer | Code | Outcome |
|---|---|---|
| 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 '<'" |
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.erroris surfaced in the chat window rather than silently displayingundefinedas the answer.
How to Diagnose This Class of Error in Future
Quick checklist when you see "Unexpected token '<'"
- Open Browser DevTools → Network tab, repeat the action, click the failing request.
- In the Response tab: if you see
<!DOCTYPE html>or a Razor error page, the server returned HTML instead of JSON. - In the Headers tab: check the HTTP status code (401, 500, etc.).
- 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:
- Asking "What is the inventory reorder policy?" returns a JSON answer referencing the
doc-inv-policyknowledge base document — no JavaScript error. - If the Claude API key is invalid, the chat window displays:
"Error: 401 (Unauthorized)" (the
HttpRequestExceptionmessage fromClaudeService) — a readable error instead of "Unexpected token '<'". - An empty query still returns HTTP 400 with
{ "error": "Query is required." }and the client surfaces that message in the chat window. - The RAG pipeline vector search and context-building steps are unaffected — only the error handling path changed.
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
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.
| File | Location | Cap | % 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 % |
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)
| Step | Code | What 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)
| Step | Code | What 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.cs — BuildContext()
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.cs — SearchAsync()
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.cs — InvokeAsync()
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:
- Asking "What is the inventory reorder policy?" returns the full per-SKU table including ROP values (e.g. NF-SKU-001 ROP 50, NF-SKU-003 ROP 100, etc.) and reorder quantities.
- No "refer to the full document" hedging appears — the LLM has the data it needs to answer directly.
- The MCP
document_searchtool also returns full snippets; agent-driven queries answer per-SKU follow-ups in a single tool call. - No regressions in other RAG queries — documents that were already short enough to fit within the old cap are unaffected.
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
}
]
}