The official Model Context Protocol in practice · Projects: NexaFlowDemo.McpServer and NexaFlowDemo.Web/Services/RealMcpClientService.cs
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.McpServer) is a separate console app that reads JSON-RPC requests from stdin and writes responses to stdout.RealMcpClientService in the web app) spawns McpServer as a subprocess and talks to it through StdioClientTransport.ReactAgent calls IMcpServerServiceRealMcpClientService implements itMcpServer.exe as a subprocessStdioClientTransportMcpClient.ListToolsAsync()McpClient.CallToolAsync(name, args)AddMcpServer().WithStdioServerTransport()WithToolsFromAssembly(assembly, jsonOptions)[McpServerToolType] classes auto-discoveredtools/call requestsMcpServer.exe. The "client stub" is McpClient from the NuGet package. The difference: you don't write any serialisation code — the SDK handles it.
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:
[McpServerToolType] on the class — marks the class as a container of MCP tools[McpServerTool(Name = "...", ReadOnly = true)] on each method — names the tool and marks read-only operations[Description("...")] on the method and each parameter — generates the JSON Schema Claude reads to understand the toolusing 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.
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.
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.
.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.
This project has 9 tools (no agent_delegation tool — that simulated approach is replaced by the real subprocess model):
action param to choose.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:
SemaphoreSlim to avoid races on concurrent requests).ResolveServerCommand() walks from the Web project's binary to the sibling NexaFlowDemo.McpServer bin output, finding the server DLL automatically without hard-coded paths.McpClient.ListToolsAsync() → converts McpClientTool objects into McpToolDefinition for the system prompt by parsing tool.JsonSchema.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 };
}
McpClientTool.JsonSchema — the JSON schema property (NOT InputSchema)McpClient.ListToolsAsync(RequestOptions?, CancellationToken) → ValueTask<IList<McpClientTool>> — direct list, no .Tools wrapperMcpClient.CallToolAsync(string, IReadOnlyDictionary<string,object?>?, ...) → ValueTask<CallToolResult>StdioClientTransportOptions — has Command, Arguments, Name, WorkingDirectory — no Version propertyThe 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.
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." |
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_po | Creates a purchase order in the in-memory ERP store |
get_po | Retrieves a PO by number |
post_gr | Posts a goods receipt (marks a PO as received) |
get_financials | Returns simulated P&L and cash-flow data for a period |
[McpServerToolType] on the class and [McpServerTool] + [Description] on each method — no interface, no manual schema.WithToolsFromAssembly scans and registers all tool classes automatically — adding a new tool is just adding a new class with the right attributes.RealMcpClientService spawns the McpServer subprocess lazily (on first use), auto-detects its path, and bridges IMcpServerService to the real MCP client API.McpClient.ListToolsAsync() → direct list; McpClient.CallToolAsync(name, args) → CallToolResult; read text from result.Content[0].Text.<tool name="..."><description>...</description></tool>.agent_delegation — the subprocess model itself provides the separation that delegation was simulating).