How the 4-project solution wires together · Files: NexaFlowDemo.Web/Program.cs and NexaFlowDemo.McpServer/Program.cs
The MCP Version adds one project compared to the ClaudeLiveKey version:
| Project | Type | Role |
|---|---|---|
| NexaFlowDemo.Core | Class library | Domain models, agents, RAG, memory, workflows, DI interfaces. Unchanged from ClaudeLiveKey. |
| NexaFlowDemo.Web | ASP.NET Core 9 MVC | Controllers, hubs, views. RealMcpClientService replaces the old McpServerService. |
| NexaFlowDemo.McpServer | Console app (new) | Hosts the real MCP server over stdio. Contains 9 [McpServerToolType] tool classes. |
| NexaFlowDemo.Tests | xUnit test project | 181 tests: 154 original Core tests + 27 new McpServer tool tests (direct method calls, no subprocess). |
When a user types a question in the chat UI and presses Enter, here is the complete chain including the McpServer subprocess:
The yellow lines show the new MCP subprocess path. The McpServer process is started lazily by RealMcpClientService on the first tool call — you do not need to start it manually.
The key change versus ClaudeLiveKey: IMcpServerService is now registered as RealMcpClientService instead of the simulated McpServerService. Individual tool registrations are gone — the tools live in the McpServer project.
| Registration | What it does |
|---|---|
| AddHttpClient<ILlmService, ClaudeService> | Registers ClaudeService with IHttpClientFactory. Base URL and auth headers set once at startup. See Guide 01. |
| AddSingleton<IMcpServerService, RealMcpClientService> | New in MCP Version. The real MCP client bridge. Spawns the McpServer subprocess lazily and routes all tool calls over JSON-RPC 2.0 stdio. Replaces the old McpServerService + 9 individual tool registrations. |
| Configure<McpServerOptions>("McpServer") | Binds the McpServer section from appsettings.json — provides the server executable path override (optional; auto-detect is used by default). |
| AddSingleton<IEmbeddingService>(new SimulatedDenseEmbeddingService(384)) | Simulated embeddings used by RAG in the Web app (Chat/RAG pages). The McpServer registers its own instance for the document_search tool. |
| AddSingleton<IVectorStore, InMemoryVectorStore> | In-memory similarity search store for the Web app's RAG pipeline. |
| AddSingleton<IDocumentStore>(...) | Factory: pulls IVectorStore and IEmbeddingService to build VectorDocumentStore. |
| AddSingleton<ReactAgent> etc. | Three agent types registered as concrete types. The orchestrator takes all three via constructor injection. |
| AddSingleton<IMultiAgentOrchestrator, MultiAgentOrchestrator> | Routes incoming queries to the right agent. Called by the SignalR hub. |
| AddSingleton<RagPipeline> + AddSingleton<RagEvaluator> | RAG pipeline and quality evaluator for the /Chat and /Evaluation pages. |
| AddSingleton<IMemoryStore, InMemoryMemoryStore> | In-memory keyword-searchable memory store. |
| AddSingleton<IConversationStore, InMemoryConversationStore> | In-memory conversation history. Switch to SqliteConversationStore for persistence. |
| AddSingleton<IWorkflow, ReorderWorkflow> etc. | Each workflow registered under IWorkflow. WorkflowEngine receives them as IEnumerable<IWorkflow>. |
| AddSingleton<ITenantContext>(TenantRegistry.Acme) | Hard-wires the active tenant to "Acme". In a real multi-tenant system this would be resolved per request. |
| AddSingleton<IAuditLogger, InMemoryAuditLogger> | In-memory append-only audit log. Viewed at /Audit. |
| AddSingleton<ICarrierApiService, SimulatedCarrierApiService> | Simulated shipping carrier (FedEx, UPS, USPS, DHL). Returns deterministic fake tracking data. |
| AddSingleton<IUserService, DemoUserService> | 4 hardcoded users (admin, ops, manager, exec) with SHA-256 hashed passwords. |
AddSingleton<IMcpTool, ...>() lines and the AddSingleton<IMcpServerService, McpServerService>() line are replaced by a single AddSingleton<IMcpServerService, RealMcpClientService>(). Tools now live in the McpServer project instead of the Core project.
The McpServer is a minimal .NET console app. Its only job is to host the MCP server over stdio:
var host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(l => l.AddFilter("Microsoft", LogLevel.Warning))
.ConfigureServices((ctx, services) =>
{
// MCP server — scans assembly for [McpServerToolType] classes
services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly(Assembly.GetExecutingAssembly(), jsonOptions);
// Services injected into tool constructors (e.g. for document_search)
services.AddSingleton<IEmbeddingService>(new SimulatedDenseEmbeddingService(384));
services.AddSingleton<IVectorStore, InMemoryVectorStore>();
services.AddSingleton<IDocumentStore>(sp => ...);
services.AddSingleton<RagPipeline>();
services.AddHostedService<KnowledgeBaseSeeder>(); // seeds docs at startup
})
.Build();
await host.RunAsync();
Log output is suppressed except warnings — because the McpServer communicates over stdout, any log lines written to stdout would corrupt the JSON-RPC stream. The AddFilter call prevents that.
SignalR keeps a persistent WebSocket connection open between the browser and the server. Instead of waiting for the whole answer, the server pushes each AgentStep — one "Thinking…", one "Calling tool…", one "Got result: 250 units" — to the browser as soon as it happens.
IAsyncEnumerable over the network:
You already know that await foreach yields results one at a time from an async sequence. SignalR does the same thing across a network connection. The server calls Clients.Caller.SendAsync("AgentStep", step) for each item — exactly like yielding from an IAsyncEnumerable.
// AgentHub.cs — simplified
public async Task RunQuery(string query, string agentType)
{
await foreach (var step in _orchestrator.RunAsync(new AgentRunRequest { Query = query, AgentType = agentType }))
{
await Clients.Caller.SendAsync("AgentStep", step);
}
}
This project supports three tenants: Acme, Globex, and Initech. The active tenant is provided via ITenantContext. TenantScopedDocumentStore wraps IDocumentStore and automatically stamps every document with metadata["tenantId"] = tenantId, then filters search results to the current tenant.
public interface ITenantContext
{
string TenantId { get; } // e.g. "tenant-acme"
string TenantName { get; } // e.g. "Acme Corp"
string[] AllowedRoles { get; }
}
public static class TenantRegistry
{
public static ITenantContext Acme = new TenantContext("tenant-acme", "Acme Corp", ...);
public static ITenantContext Globex = new TenantContext("tenant-globex", "Globex Ltd", ...);
public static ITenantContext Initech = new TenantContext("tenant-initech", "Initech Inc", ...);
}
Authentication uses ASP.NET Core cookie authentication. DemoUserService holds four hardcoded users:
| Username | Password | Role | Tenant |
|---|---|---|---|
| admin | admin123 | Admin | Acme |
| ops | ops123 | Ops | Acme |
| manager | manager123 | Manager | Globex |
| exec | exec123 | Exec | Initech |
Passwords are stored as SHA-256 hashes. On login, the input is hashed and compared:
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(password));
var hex = Convert.ToHexString(hash).ToLowerInvariant();
Every MCP tool call, workflow trigger, and important action can be recorded. InMemoryAuditLogger stores entries in a thread-safe list and returns them most-recent-first:
public class AuditEntry
{
public string Id { get; set; }
public DateTime Timestamp { get; set; }
public string UserId { get; set; }
public string TenantId { get; set; }
public string Action { get; set; } // "tool_call", "workflow_trigger", etc.
public string Resource { get; set; } // tool name, workflow name, etc.
public string Details { get; set; } // JSON input/output
public bool Success { get; set; }
}
SimulatedCarrierApiService returns deterministic fake rates from FedEx, UPS, USPS, and DHL. Shipments get tracking numbers in the format NF0001. To plug in a real carrier API, implement ICarrierApiService and swap the registration in Program.cs.
| SignalR (WebSocket) | SSE (Server-Sent Events) | |
|---|---|---|
| Direction | Two-way: browser ↔ server | One-way: server → browser only |
| Setup | Requires SignalR JS library + hub endpoint | Plain fetch() or EventSource, no library needed |
| Use case | Chat UI where user can cancel, send new messages mid-stream | Simple monitoring page, external tool calling the API |
| In this project | Main chat interface (AgentHub.cs) | GET /stream/run?query=... (StreamController.cs) |
NexaFlowDemo.McpServer/Tools/, create a new class or add a method to an existing class.[McpServerToolType] and each method with [McpServerTool(Name="...")] + [Description("...")].NexaFlowDemo.McpServer. WithToolsFromAssembly picks up the new tool automatically.RealMcpClientService will see the new tool listed at next ListToolsAsync() call — no web app changes needed.src/NexaFlowDemo.Core/Workflows/ implementing IWorkflow.builder.Services.AddSingleton<IWorkflow, YourWorkflow>(); in NexaFlowDemo.Web/Program.cs.workflowEngine.TriggerAsync("your_trigger_name", context);src/NexaFlowDemo.Core/Data/KnowledgeBaseSeeder.cs (used by the Web app) and the KnowledgeBaseSeeder inside the McpServer project (used by the document_search tool).await store.AddAsync(new Document { ... }); to both.RealMcpClientService that talks to the McpServer subprocess over real JSON-RPC 2.0.[McpServerToolType] in the McpServer project. No web app changes needed.ILlmService, inject it, call CompleteAsync(). Add an MCP server for data access. Done.