Building MCP Clients in .NET: Consuming External AI Tools

Learn to build .NET MCP clients that discover, connect to, and orchestrate external AI agent tools. Practical patterns for distributed agentic systems.

0

Why MCP Clients Matter in Agentic AI

If you have built an MCP server in .NET, you probably understand the server side of the Model Context Protocol. But real distributed agentic systems need the opposite direction: applications that act as clients, discovering and consuming tools exposed by other MCP servers. This is where the real orchestration happens.

Most MCP content focuses on building servers. Building a client is different. You need to handle tool discovery, manage transport connections, parse tool schemas, invoke remote tools reliably, and compose multiple tools into workflows. This article walks through the practical patterns for doing that in .NET using the official MCP C# SDK.

Understanding MCP Client Architecture

An MCP client is a .NET application that initiates a connection to an MCP server, discovers what tools the server exposes, and invokes those tools on demand. The server responds with results, errors, or streaming data depending on the tool and the call.

The basic flow looks like this:

  • Create a client using McpClient.CreateAsync with an appropriate transport
  • The client performs an initialization handshake with the server
  • Request the tools list from the server using ListToolsAsync
  • Parse tool schemas and store them locally for discovery
  • Invoke tools when your application logic needs them using CallToolAsync
  • Handle responses, errors, and cleanup

The transport layer is critical. The MCP C# SDK provides two main transport types: stdio for local process communication and HTTP for remote server connections. Streamable HTTP is the modern, bidirectional protocol recommended for most scenarios. Server-Sent Events (SSE) is also supported as a fallback. The SDK handles the transport mechanics, so you focus on your application logic.

Setting Up an MCP Client Connection

The official MCP C# SDK makes client setup straightforward. Install the ModelContextProtocol NuGet package and create a client with your chosen transport.

using ModelContextProtocol.Client;
using ModelContextProtocol.Client.Transport;

// For a remote HTTP server using Streamable HTTP (recommended)
var httpTransport = new HttpClientTransport(
    new Uri("https://your-mcp-server.com"),
    transportMode: HttpTransportMode.StreamableHttp
);

var client = await McpClient.CreateAsync(httpTransport);

// The client automatically performs the initialization handshake
// and negotiates capabilities with the server

// For a local process using stdio
var stdioTransport = new StdioClientTransport(
    command: "node",
    arguments: new[] { "path/to/mcp-server.js" }
);

var localClient = await McpClient.CreateAsync(stdioTransport);

The McpClient.CreateAsync factory method handles the initialization handshake automatically. It sends the client’s protocol version and capabilities, receives the server’s capabilities in response, and confirms the session is ready. After initialization, you can immediately start discovering and invoking tools.

Tool Discovery and Invocation

Once connected, discovering tools is straightforward. List available tools, inspect their schemas, and invoke them as needed.

// Discover all tools the server exposes
var tools = await client.ListToolsAsync();

foreach (var tool in tools)
{
    Console.WriteLine($"Tool: {tool.Name}");
    Console.WriteLine($"Description: {tool.Description}");
    Console.WriteLine($"Input Schema: {tool.InputSchema}");
}

// Invoke a tool
var result = await client.CallToolAsync(
    toolName: "get_user",
    arguments: new Dictionary<string, object>
    {
        { "user_id", "12345" }
    }
);

if (result.IsError)
{
    Console.WriteLine($"Error: {result.Error}");
}
else
{
    Console.WriteLine($"Result: {result.Content}");
}

The SDK returns tool definitions with their input schemas as JSON Schema objects. This makes it easy to validate arguments before invoking, or to present tools to an LLM for function calling.

Building a Tool Registry for Discovery

For applications with many tools or multiple servers, a local registry provides caching and query capabilities.

public class ToolRegistry
{
    private readonly McpClient _client;
    private Dictionary<string, Tool> _toolsByName;

    public ToolRegistry(McpClient client)
    {
        _client = client;
        _toolsByName = new Dictionary<string, Tool>();
    }

    public async Task RefreshToolsAsync()
    {
        var tools = await _client.ListToolsAsync();
        _toolsByName.Clear();

        foreach (var tool in tools)
        {
            _toolsByName[tool.Name] = tool;
        }
    }

    public Tool GetTool(string name)
    {
        if (_toolsByName.TryGetValue(name, out var tool))
            return tool;

        throw new KeyNotFoundException($"Tool '{name}' not found in registry");
    }

    public List<Tool> GetAllTools()
    {
        return _toolsByName.Values.ToList();
    }

    public List<Tool> FindToolsByKeyword(string keyword)
    {
        var lowerKeyword = keyword.ToLower();
        return _toolsByName.Values
            .Where(t => t.Name.ToLower().Contains(lowerKeyword) ||
                        t.Description.ToLower().Contains(lowerKeyword))
            .ToList();
    }
}

The registry acts as a local cache and query interface. This is useful when you need to present available tools to an LLM for function calling, or when you want to validate that a tool exists before invoking it.

Orchestrating Multi-Tool Workflows

The real power of MCP clients emerges when you compose multiple external tools into a workflow. Here is a pattern for chaining tool calls and handling dependencies.

public class ToolOrchestrator
{
    private readonly McpClient _client;
    private readonly ToolRegistry _registry;
    private readonly ILogger<ToolOrchestrator> _logger;

    public ToolOrchestrator(McpClient client, ToolRegistry registry, ILogger<ToolOrchestrator> logger)
    {
        _client = client;
        _registry = registry;
        _logger = logger;
    }

    public async Task<WorkflowResult> ExecuteSequentialWorkflowAsync(
        List<ToolInvocation> invocations)
    {
        var results = new List<ToolInvocationResult>();
        var context = new Dictionary<string, object>();

        foreach (var invocation in invocations)
        {
            _logger.LogInformation($"Executing tool: {invocation.ToolName}");

            var resolvedArgs = ResolveArguments(invocation.Arguments, context);

            var result = await _client.CallToolAsync(
                invocation.ToolName,
                resolvedArgs
            );

            var invocationResult = new ToolInvocationResult
            {
                ToolName = invocation.ToolName,
                IsSuccess = !result.IsError,
                Output = result.IsError ? null : result.Content.FirstOrDefault()?.Text,
                Error = result.IsError ? result.Error : null
            };

            results.Add(invocationResult);
            context[invocation.OutputKey] = invocationResult.Output;

            if (!invocationResult.IsSuccess && invocation.StopOnFailure)
            {
                _logger.LogError($"Tool {invocation.ToolName} failed. Stopping workflow.");
                break;
            }
        }

        return new WorkflowResult
        {
            IsSuccess = results.All(r => r.IsSuccess),
            Results = results,
            FinalContext = context
        };
    }

    private Dictionary<string, object> ResolveArguments(
        Dictionary<string, object> arguments,
        Dictionary<string, object> context)
    {
        var resolved = new Dictionary<string, object>();

        foreach (var kvp in arguments)
        {
            if (kvp.Value is string strValue && strValue.StartsWith("$"))
            {
                var contextKey = strValue.Substring(1);
                if (context.TryGetValue(contextKey, out var contextValue))
                {
                    resolved[kvp.Key] = contextValue;
                }
                else
                {
                    throw new InvalidOperationException(
                        $"Context key '{contextKey}' not found"
                    );
                }
            }
            else
            {
                resolved[kvp.Key] = kvp.Value;
            }
        }

        return resolved;
    }
}

public class ToolInvocation
{
    public string ToolName { get; set; }
    public Dictionary<string, object> Arguments { get; set; }
    public string OutputKey { get; set; }
    public bool StopOnFailure { get; set; } = true;
}

public class ToolInvocationResult
{
    public string ToolName { get; set; }
    public bool IsSuccess { get; set; }
    public string Output { get; set; }
    public string Error { get; set; }
}

public class WorkflowResult
{
    public bool IsSuccess { get; set; }
    public List<ToolInvocationResult> Results { get; set; }
    public Dictionary<string, object> FinalContext { get; set; }
}

This orchestrator enables sequential workflows where one tool’s output feeds into another’s input. The context dictionary carries state through the workflow. Notice the argument resolution logic, which allows tool arguments to reference previous outputs using a simple $ prefix syntax.

Error Handling and Resilience

Real-world MCP clients need robust error handling. Network timeouts, malformed responses, and tool failures all happen. The SDK provides error details in the result, and you can wrap client calls with retry logic.

public class ResilientMcpClientWrapper
{
    private readonly McpClient _client;
    private readonly IRetryPolicy _retryPolicy;
    private readonly ILogger<ResilientMcpClientWrapper> _logger;

    public ResilientMcpClientWrapper(
        McpClient client,
        IRetryPolicy retryPolicy,
        ILogger<ResilientMcpClientWrapper> logger)
    {
        _client = client;
        _retryPolicy = retryPolicy;
        _logger = logger;
    }

    public async Task<ToolResult> InvokeToolWithRetryAsync(
        string toolName,
        Dictionary<string, object> arguments)
    {
        return await _retryPolicy.ExecuteAsync(async () =>
        {
            _logger.LogInformation($"Invoking tool: {toolName}");
            return await _client.CallToolAsync(toolName, arguments);
        });
    }
}

public interface IRetryPolicy
{
    Task<T> ExecuteAsync<T>(Func<Task<T>> action);
}

public class ExponentialBackoffRetryPolicy : IRetryPolicy
{
    private readonly int _maxRetries;
    private readonly int _initialDelayMs;

    public ExponentialBackoffRetryPolicy(int maxRetries = 3, int initialDelayMs = 100)
    {
        _maxRetries = maxRetries;
        _initialDelayMs = initialDelayMs;
    }

    public async Task<T> ExecuteAsync<T>(Func<Task<T>> action)
    {
        int attempt = 0;
        while (true)
        {
            try
            {
                return await action();
            }
            catch (Exception ex) when (attempt < _maxRetries)
            {
                attempt++;
                var delayMs = _initialDelayMs * (int)Math.Pow(2, attempt - 1);
                _logger?.LogWarning($"Attempt {attempt} failed, retrying in {delayMs}ms: {ex.Message}");
                await Task.Delay(delayMs);
            }
        }
    }

    private ILogger _logger;
}

Exponential backoff gives transient failures time to resolve without hammering the server. Wrap your client calls with this policy for production resilience.

Integration with Dependency Injection

Wire everything up cleanly in your ASP.NET Core or Worker Service startup.

public static class McpClientServiceCollectionExtensions
{
    public static IServiceCollection AddMcpClient(
        this IServiceCollection services,
        string mcpServerUri)
    {
        services.AddSingleton(async sp =>
        {
            var httpTransport = new HttpClientTransport(
                new Uri(mcpServerUri),
                transportMode: HttpTransportMode.StreamableHttp
            );
            return await McpClient.CreateAsync(httpTransport);
        });

        services.AddSingleton<IRetryPolicy>(
            new ExponentialBackoffRetryPolicy(maxRetries: 3, initialDelayMs: 100)
        );

        services.AddSingleton<ToolRegistry>();
        services.AddSingleton<ToolOrchestrator>();

        return services;
    }
}

// In Program.cs
var builder = WebApplicationBuilder.CreateBuilder(args);

var mcpServerUri = builder.Configuration["MCP:ServerUri"] ?? "http://localhost:8000";
builder.Services.AddMcpClient(mcpServerUri);

var app = builder.Build();

// Initialize tools on startup
var registry = app.Services.GetRequiredService<ToolRegistry>();
await registry.RefreshToolsAsync();

This setup keeps your dependency graph clean and testable. You can swap implementations or add more decorators without changing your application code.

Real-World Example: Chaining Tools

Suppose you have two external MCP servers: one that fetches user data and another that sends notifications. You want to fetch a user and then send them a welcome message.

public class WelcomeUserService
{
    private readonly ToolOrchestrator _orchestrator;
    private readonly ILogger<WelcomeUserService> _logger;

    public WelcomeUserService(ToolOrchestrator orchestrator, ILogger<WelcomeUserService> logger)
    {
        _orchestrator = orchestrator;
        _logger = logger;
    }

    public async Task WelcomeNewUserAsync(string userId)
    {
        var workflow = new List<ToolInvocation>
        {
            new ToolInvocation
            {
                ToolName = "get_user",
                Arguments = new Dictionary<string, object> { { "user_id", userId } },
                OutputKey = "user_data",
                StopOnFailure = true
            },
            new ToolInvocation
            {
                ToolName = "send_email",
                Arguments = new Dictionary<string, object>
                {
                    { "email", "$user_data" },
                    { "subject", "Welcome!" },
                    { "body", "Thanks for joining our platform." }
                },
                OutputKey = "email_result",
                StopOnFailure = false
            }
        };

        var result = await _orchestrator.ExecuteSequentialWorkflowAsync(workflow);

        if (result.IsSuccess)
        {
            _logger.LogInformation($"Successfully welcomed user {userId}");
        }
        else
        {
            _logger.LogError($"Welcome workflow failed for user {userId}");
        }
    }
}

This shows the practical benefit of the orchestrator: you define a sequence of tool calls, and the framework handles passing outputs to inputs, error management, and logging.

Testing Your MCP Client

Mock the McpClient to test your orchestration logic without hitting real servers.

public class MockMcpClient : McpClient
{
    private readonly Dictionary<string, Func<Dictionary<string, object>, Task<ToolResult>>> _toolHandlers;

    public MockMcpClient()
    {
        _toolHandlers = new Dictionary<string, Func<Dictionary<string, object>, Task<ToolResult>>>();
    }

    public void RegisterToolHandler(
        string toolName,
        Func<Dictionary<string, object>, Task<ToolResult>> handler)
    {
        _toolHandlers[toolName] = handler;
    }

    public override async Task<ToolResult> CallToolAsync(
        string toolName,
        Dictionary<string, object> arguments)
    {
        if (_toolHandlers.TryGetValue(toolName, out var handler))
        {
            return await handler(arguments);
        }

        return new ToolResult
        {
            IsError = true,
            Error = $"Tool '{toolName}' not registered in mock"
        };
    }
}

// Usage in a test
[Fact]
public async Task WelcomeUserAsync_WhenUserExists_SendsEmail()
{
    var mockClient = new MockMcpClient();
    mockClient.RegisterToolHandler("get_user", async args =>
    {
        return new ToolResult
        {
            IsError = false,
            Content = new[] { new ContentBlock { Text = "user@example.com" } }
        };
    });

    mockClient.RegisterToolHandler("send_email", async args =>
    {
        return new ToolResult
        {
            IsError = false,
            Content = new[] { new ContentBlock { Text = "Email sent" } }
        };
    });

    var registry = new ToolRegistry(mockClient);
    var orchestrator = new ToolOrchestrator(mockClient, registry, new NullLogger<ToolOrchestrator>());
    var service = new WelcomeUserService(orchestrator, new NullLogger<WelcomeUserService>());

    await service.WelcomeNewUserAsync("user123");

    // Assertions here
}

This mock implementation lets you test your orchestration logic in isolation, controlling exactly what each tool returns.

Key Takeaways

Building MCP clients in .NET is about more than just making HTTP calls. You need clean abstractions, reliable transport, tool caching and discovery, orchestration patterns, error handling, and testability. The patterns shown here are production-ready and scale to complex multi-tool workflows. Start with the official SDK, add resilience as you deploy to production, and use the orchestrator to compose tools into meaningful business logic. The MCP ecosystem is growing, and the ability to consume external tools from .NET applications opens up real possibilities for distributed agentic systems.

What is the difference between an MCP server and an MCP client?

An MCP server exposes tools that other applications can call. An MCP client is an application that discovers and invokes those tools. A single application can be both: it might serve its own tools while also consuming tools from other servers.

Which transport should I use for my MCP client?

For remote servers, use Streamable HTTP (the modern, bidirectional protocol recommended by the MCP specification). For local processes, use stdio. The official C# SDK handles both seamlessly. Server-Sent Events (SSE) is also supported as a fallback for HTTP connections.

How do I handle tool arguments that come from an LLM’s function calling output?

Parse the LLM’s function call response to extract the tool name and arguments, then pass them directly to CallToolAsync. The tool registry helps you validate that the tool exists and inspect its schema before invoking it, which is useful for pre-validation before sending to an LLM.

What if a tool takes a long time to execute?

Increase the HttpClient timeout for long-running tools. Consider implementing polling or webhook patterns for truly long operations. The MCP specification also supports streaming responses, which the SDK can handle if the server implements them.

Can I connect to multiple MCP servers at once?

Yes. Create separate McpClient instances for each server and register them in dependency injection with different keys or named clients. The ToolRegistry can be extended to aggregate tools from multiple clients if needed.

Leave a Reply

Your email address will not be published. Required fields are marked *