Code Modules in This Guide
McpServer/Tools
InventoryTools.cs SupplierTools.cs ErpTools.cs CalculatorTools.cs DateTimeTools.cs + 4 more
McpServer
Program.cs
Web/Services
RealMcpClientService.cs McpServerOptions.cs IMcpServerService.cs

1 What Is Real MCP?

The problem in one sentence: Claude is brilliant at reading and reasoning, but it lives in a text bubble — it cannot open a database connection, cannot call your REST API, cannot look at your file system.

MCP (Model Context Protocol) is the standardised solution. It is a protocol — like HTTP, but for AI tools — that defines how a client (your web app) discovers and calls tools exposed by a server (a separate process hosting the tool implementations).

This project uses the official ModelContextProtocol v1.3.0 NuGet package with real JSON-RPC 2.0 communication over stdin/stdout (stdio). That means:

NexaFlowDemo.Web (Client)

  • ReactAgent calls IMcpServerService
  • RealMcpClientService implements it
  • Spawns McpServer.exe as a subprocess
  • Communicates via StdioClientTransport
  • Calls McpClient.ListToolsAsync()
  • Calls McpClient.CallToolAsync(name, args)

NexaFlowDemo.McpServer (Server)

  • Console app — reads/writes stdio
  • AddMcpServer().WithStdioServerTransport()
  • WithToolsFromAssembly(assembly, jsonOptions)
  • 9 [McpServerToolType] classes auto-discovered
  • Receives JSON-RPC 2.0 tools/call requests
  • Returns results as JSON text content
C# analogy — it is like a gRPC microservice, but simpler: gRPC defines a contract (proto file), a server process, and a client stub. MCP does the same but with JSON-RPC over stdin/stdout instead of TCP. The "proto file" is the tool schema (name + description + parameters). The "server" is McpServer.exe. The "client stub" is McpClient from the NuGet package. The difference: you don't write any serialisation code — the SDK handles it.

2 Writing a Real MCP Tool — Attributes Replace Interfaces

src/NexaFlowDemo.McpServer/Tools/InventoryTools.cs

In the previous (simulated) approach, a tool implemented IMcpTool with a Definition property and an InvokeAsync method. In the real MCP approach, you just write a static class with attributes. The SDK discovers and registers everything automatically.

Three attributes do the work:

using System.ComponentModel;
using System.Text.Json;
using ModelContextProtocol.Server;

namespace NexaFlowDemo.McpServer.Tools;

[McpServerToolType]
public static class InventoryTools
{
    [McpServerTool(Name = "inventory_lookup", ReadOnly = true)]
    [Description("Look up current stock level and reorder threshold for a SKU.")]
    public static string LookupInventory(
        [Description("The product SKU code, e.g. NF-SKU-001")] string sku)
    {
        // ... look up in-memory data ...
        return JsonSerializer.Serialize(new
        {
            success = true,
            result  = new { sku, quantity = 250, reorder_threshold = 50, status = "In Stock" }
        });
    }
}

Compare this to the old IMcpTool approach: no interface, no McpToolDefinition object, no McpInvokeResponse wrapper, no Dictionary<string,string> input parsing. The SDK generates the JSON schema from [Description] and handles all the request/response serialisation.

Return type: always string

MCP tool methods return a plain string — typically a JSON-serialised object. The SDK wraps it in a TextContentBlock and sends it back to the client. On the client side, RealMcpClientService reads result.Content[0].Text to get the JSON string back out.

3 Tool Registration in McpServer — WithToolsFromAssembly

src/NexaFlowDemo.McpServer/Program.cs

All 9 tool classes are discovered automatically — you do not register them one by one. The McpServer's Program.cs is minimal:

var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices(services =>
    {
        services
            .AddMcpServer()
            .WithStdioServerTransport()
            .WithToolsFromAssembly(Assembly.GetExecutingAssembly(), jsonOptions);

        // Services the tool methods may need (e.g. embedding service for RAG)
        services.AddSingleton<IEmbeddingService>(new SimulatedDenseEmbeddingService(384));
        services.AddSingleton<IVectorStore, InMemoryVectorStore>();
        // ... IDocumentStore, KnowledgeBaseSeeder ...
    })
    .Build();

await host.RunAsync();

WithToolsFromAssembly scans the assembly for all classes marked [McpServerToolType] and registers their methods as MCP tools. Adding a new tool is: create a class with the right attributes → the SDK registers it automatically on next startup.

Why WithToolsFromAssembly, not individual registration? Individual registration (.WithTool<MyTool>()) works too, but requires a line per tool. WithToolsFromAssembly is like AddControllersFromAssembly in ASP.NET Core — scan and register everything that matches the convention. Zero maintenance when adding new tools.

4 Every Tool in This Project

This project has 9 tools (no agent_delegation tool — that simulated approach is replaced by the real subprocess model):

inventory_lookup
Returns stock level, reorder threshold, and status for a given SKU. Supports category and status filters.
InventoryTools.cs
supplier_lookup
Returns supplier name, contact, lead time, country, and rating. Supports name and country filters.
SupplierTools.cs
shipment_tracker
Returns tracking status, carrier, ETA, and last location for a shipment ID or tracking number.
ShipmentTools.cs
document_search
Full-text search over the knowledge base (procedures, SLAs, policies). Powered by RagPipeline inside McpServer.
DocumentSearchTools.cs
calculator
Evaluates a simple arithmetic expression. Supports +, -, *, /, %, and parentheses.
CalculatorTools.cs
datetime
Returns current UTC date/time. Useful because LLMs don't know the current time.
DateTimeTools.cs
web_search
Simulated web search. Returns canned logistics and supply chain results for demonstration purposes.
WebSearchTools.cs
code_interpreter
Executes a C# code snippet via Roslyn CSharpScript. Returns the result or compilation error.
CodeInterpreterTools.cs
erp_connector
SAP-compatible ERP actions: create_po, get_po, post_gr, get_financials. Pass action param to choose.
ErpTools.cs

5 RealMcpClientService — The Bridge

src/NexaFlowDemo.Web/Services/RealMcpClientService.cs

RealMcpClientService implements IMcpServerService — the same interface ReactAgent has always used. The agent doesn't know or care that tool calls now cross a subprocess boundary. The service handles:

  1. Lazy init: The McpServer subprocess is started the first time any tool is called (protected by SemaphoreSlim to avoid races on concurrent requests).
  2. Auto-detect path: ResolveServerCommand() walks from the Web project's binary to the sibling NexaFlowDemo.McpServer bin output, finding the server DLL automatically without hard-coded paths.
  3. ListTools: Calls McpClient.ListToolsAsync() → converts McpClientTool objects into McpToolDefinition for the system prompt by parsing tool.JsonSchema.
  4. InvokeAsync: Calls McpClient.CallToolAsync(toolName, args) → reads result.Content[0].Text → wraps in McpInvokeResponse.
// Initialise once — spawns the McpServer process
private async Task EnsureInitializedAsync(CancellationToken ct)
{
    if (_client != null) return;
    await _initLock.WaitAsync(ct);
    try
    {
        if (_client != null) return;
        var transport = new StdioClientTransport(new StdioClientTransportOptions
        {
            Command          = "dotnet",
            Arguments        = ["exec", ResolveServerCommand()],
            Name             = "NexaFlowDemoMcpServer",
            WorkingDirectory = Path.GetDirectoryName(ResolveServerCommand())
        });
        _client = await McpClient.CreateAsync(transport, cancellationToken: ct);
    }
    finally { _initLock.Release(); }
}

// List tools — called when building the system prompt
public IReadOnlyList<McpToolDefinition> ListTools()
{
    // McpClient.ListToolsAsync() returns ValueTask<IList<McpClientTool>>
    var mcpTools = _client!.ListToolsAsync().GetAwaiter().GetResult();
    return mcpTools.Select(t => ConvertToDefinition(t)).ToList();
}

// Invoke a tool — called from the agent loop
public async Task<McpInvokeResponse> InvokeAsync(string toolName, ...)
{
    var result = await _client!.CallToolAsync(toolName, args, cancellationToken: ct);
    var text   = result.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text ?? "";
    return new McpInvokeResponse { Success = !result.IsError, Result = text };
}
Key ModelContextProtocol v1.3.0 API facts (confirmed by reflection)

6 How Tools Appear in the System Prompt

The agent calls _mcp.ListTools() and formats each tool into the system prompt — exactly the same XML format as before. Claude never knows whether the tools came from a simulated in-process registry or a real subprocess. It only sees this:

<tools>
  <tool name="inventory_lookup">
    <description>Look up current stock level and reorder threshold for a SKU.</description>
    <param name="sku" type="string" required="True">The product SKU code, e.g. NF-SKU-001</param>
  </tool>
  <tool name="erp_connector">
    <description>SAP-compatible ERP actions: create_po, get_po, post_gr, get_financials. Pass action param to choose.</description>
    <param name="action" type="string" required="True">ERP action to perform</param>
    <param name="supplier_id" type="string" required="False">Supplier ID for create_po</param>
    ...
  </tool>
  ...
</tools>

The parameters are extracted by RealMcpClientService.ExtractParameters(), which walks the tool.JsonSchema JsonElement to find the properties and required arrays that the SDK generated from the [Description] attributes.

7 Tool Descriptions — Good vs Bad

The [Description("...")] on a tool method is what Claude reads to decide whether to call this tool. It cannot see your code — the description is its only guide.

Vague (bad) Clear (good — used in this project)
"Gets inventory data."
Problem: Claude doesn't know what "data" means here. It might call this for supplier info too.
"Look up current stock level and reorder threshold for a SKU. Use when the user asks about stock quantity, inventory level, or whether to reorder."
Clear trigger phrases tell Claude exactly when to use it.
"Does ERP stuff."
Problem: Claude doesn't know whether to use this for inventory, financials, or POs.
"SAP ERP connector. Actions: create_po (create purchase order), get_po (retrieve PO by ID), post_gr (post goods receipt), get_financials (P&L and cash flow). Pass 'action' parameter to choose."
"Searches documents."
Problem: Claude may try this for inventory queries too.
"Full-text search over the internal knowledge base (policies, SLA agreements, procedures). Use when the user asks about company policies or procedures — NOT for live data like stock levels."
Rule of thumb for descriptions Include: (1) what the tool returns, (2) what input it expects, (3) example use cases, and (4) what it should NOT be used for if there is ambiguity. Claude decides purely from text — the more specific you are, the more reliably it picks the right tool.

8 ErpTools — Multi-Action Tool

src/NexaFlowDemo.McpServer/Tools/ErpTools.cs

Most tools do one thing. ErpTools exposes a single erp_connector tool that routes by an action parameter:

[McpServerToolType]
public class ErpTools
{
    [McpServerTool(Name = "erp_connector", ReadOnly = false)]
    [Description("SAP-compatible ERP connector. Actions: create_po, get_po, post_gr, get_financials.")]
    public string ExecuteErpAction(
        [Description("Action: create_po | get_po | post_gr | get_financials")] string action,
        [Description("Supplier ID for create_po")] string? supplier_id = null,
        [Description("PO number for get_po / post_gr")] string? po_number = null,
        [Description("Period for get_financials, e.g. Q1-2026")] string? period = null)
    {
        return action switch
        {
            "create_po"    => CreatePurchaseOrder(supplier_id),
            "get_po"       => GetPurchaseOrder(po_number),
            "post_gr"      => PostGoodsReceipt(po_number),
            "get_financials" => GetFinancials(period),
            _              => JsonSerializer.Serialize(new { success = false, error = $"Unknown action: {action}" })
        };
    }
    // ... private methods for each action ...
}
Action What it does
create_poCreates a purchase order in the in-memory ERP store
get_poRetrieves a PO by number
post_grPosts a goods receipt (marks a PO as received)
get_financialsReturns simulated P&L and cash-flow data for a period

9 Summary