The Model Context Protocol (MCP) has emerged as a standard way for AI agents to discover and call tools reliably. If you’re building backend systems in .NET that need to expose capabilities to LLM-powered agents, implementing an MCP server lets you do that without reinventing authentication, discovery, or transport every time a new agent framework arrives.
This guide walks through building MCP servers in .NET, covering the protocol fundamentals, the C# SDK, tool definitions, and patterns for orchestrating multiple agents that communicate through the same server.
Understanding the Model Context Protocol
MCP is a client-server protocol that standardizes how LLM applications request information and invoke tools. An MCP server exposes resources, tools, and prompts. An MCP client (typically an agent or LLM application) connects, discovers what’s available, and calls tools or retrieves resources as needed.
The protocol uses JSON-RPC over stdio or HTTP transport. For most backend scenarios, stdio is simpler (agent spawns the server process and communicates via stdin/stdout), while HTTP is better for distributed deployments.
Three core concepts:
- Resources: Read-only data the server exposes (files, database records, API responses).
- Tools: Functions the agent can call with parameters; the server executes them and returns results.
- Prompts: Pre-written instruction templates the agent can request to customize agent behavior.
For most agentic systems, tools are what matter. Resources and prompts are optional but useful for advanced workflows.
Setting Up the MCP Server SDK in .NET
The official MCP C# SDK is maintained by Anthropic and Microsoft through collaboration on the open protocol. You’ll use the ModelContextProtocol NuGet package. Start with the base package for stdio servers:
dotnet add package ModelContextProtocol
For HTTP-based servers in ASP.NET Core, add the AspNetCore package:
dotnet add package ModelContextProtocol.AspNetCore
Here’s a minimal stdio server that listens for client requests:
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Server;
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.AddConsole(consoleLogOptions =>
{
consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace;
});
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithToolsFromAssembly();
await builder.Build().RunAsync();
The WithToolsFromAssembly() call automatically discovers and registers all tools marked with the appropriate attributes in your assembly.
Defining Tools with Parameters and Schemas
Tools in MCP are defined using attributes on static methods. The SDK automatically generates JSON Schema from your method signatures and descriptions.
Here’s a practical example: a tool that queries a user database:
[McpServerToolType]
public static class UserTools
{
[McpServerTool, Description("Fetch user details by ID. Returns name, email, and account status.")]
public static async Task<string> GetUser(
[Description("The unique user identifier")] string userId,
[Description("Include account metadata (optional)")] bool includeMetadata = false)
{
var user = await UserService.GetUserAsync(userId);
if (user == null)
throw new InvalidOperationException("User not found");
var result = new
{
id = user.Id,
name = user.Name,
email = user.Email,
status = user.Status
};
if (includeMetadata)
{
result = new
{
id = user.Id,
name = user.Name,
email = user.Email,
status = user.Status,
createdAt = user.CreatedAt,
lastLogin = user.LastLogin
};
}
return JsonSerializer.Serialize(result);
}
}
Key points:
- Mark your tool class with [McpServerToolType] and each tool method with [McpServerTool].
- Use [Description] attributes on parameters so agents understand what each parameter does.
- The SDK automatically generates JSON Schema from your method signature.
- Return strings (JSON serialized) or throw exceptions for errors. The SDK handles serialization.
- Document each parameter clearly so the LLM knows when to use it.
Building a Multi-Tool Backend
Most systems expose multiple tools. Organize them by domain and use separate tool classes:
[McpServerToolType]
public static class UserTools
{
[McpServerTool, Description("Fetch user details by ID")]
public static async Task<string> GetUser(string userId)
{
var user = await UserService.GetUserAsync(userId);
if (user == null)
throw new InvalidOperationException("User not found");
return JsonSerializer.Serialize(user);
}
[McpServerTool, Description("List users with optional filtering")]
public static async Task<string> ListUsers(
[Description("Filter by status (active, inactive, suspended)")] string status = "active",
[Description("Maximum results to return")] int limit = 50)
{
var users = await UserService.ListUsersAsync(status, limit);
return JsonSerializer.Serialize(users);
}
[McpServerTool, Description("Update a user's account status")]
public static async Task<string> UpdateUserStatus(
string userId,
[Description("New status: active, inactive, or suspended")] string newStatus)
{
if (!new[] { "active", "inactive", "suspended" }.Contains(newStatus))
throw new ArgumentException($"Invalid status: {newStatus}");
var result = await UserService.UpdateStatusAsync(userId, newStatus);
return JsonSerializer.Serialize(new { updated = true, newStatus });
}
}
[McpServerToolType]
public static class OrderTools
{
[McpServerTool, Description("Fetch order details by order ID")]
public static async Task<string> GetOrder(string orderId)
{
var order = await OrderService.GetOrderAsync(orderId);
if (order == null)
throw new InvalidOperationException("Order not found");
return JsonSerializer.Serialize(order);
}
[McpServerTool, Description("List all orders for a specific user")]
public static async Task<string> ListUserOrders(
string userId,
[Description("Filter by status: pending, completed, cancelled")] string status = null)
{
var orders = await OrderService.ListUserOrdersAsync(userId, status);
return JsonSerializer.Serialize(orders);
}
}
[McpServerToolType]
public static class ReportingTools
{
[McpServerTool, Description("Get total revenue for a specific month")]
public static async Task<string> GetMonthlyRevenue(
[Description("Month in YYYY-MM format")] string month)
{
var revenue = await ReportingService.GetMonthlyRevenueAsync(month);
return JsonSerializer.Serialize(new { month, revenue });
}
}
The SDK discovers all [McpServerToolType] classes and registers their methods automatically. This scales well as your tool set grows.
Error Handling and Validation
Agents depend on clear, structured responses. Robust error handling prevents agents from getting stuck or making incorrect decisions:
[McpServerToolType]
public static class UserTools
{
[McpServerTool, Description("Update a user's account status")]
public static async Task<string> UpdateUserStatus(
[Description("The user ID")] string userId,
[Description("New status: active, inactive, or suspended")] string newStatus)
{
try
{
if (string.IsNullOrEmpty(userId))
throw new ArgumentException("user_id is required");
var validStatuses = new[] { "active", "inactive", "suspended" };
if (string.IsNullOrEmpty(newStatus) || !validStatuses.Contains(newStatus))
throw new ArgumentException($"status must be one of {string.Join(", ", validStatuses)}");
var result = await UserService.UpdateStatusAsync(userId, newStatus);
if (!result.Success)
throw new InvalidOperationException(result.Message);
return JsonSerializer.Serialize(new { updated = true, newStatus, userId });
}
catch (Exception ex)
{
throw new InvalidOperationException($"Error updating user status: {ex.Message}", ex);
}
}
}
When you throw an exception, the SDK catches it and returns an error response to the agent. Always include a clear message describing what went wrong and what the agent should try next.
Multi-Agent Orchestration Patterns
When multiple agents connect to the same MCP server, you need to think about state management, concurrency, and security.
Isolation and Context
Each agent connection should be isolated. If Agent A queries user data, Agent B shouldn’t see that context. Use session tracking to maintain separate state:
public class AgentContext
{
public string SessionId { get; set; }
public string AgentName { get; set; }
public DateTime ConnectedAt { get; set; }
public Dictionary<string, object> State { get; set; } = new();
}
public class AgentSessionManager
{
private readonly ConcurrentDictionary<string, AgentContext> _agents = new();
public void RegisterSession(string sessionId, string agentName)
{
var context = new AgentContext
{
SessionId = sessionId,
AgentName = agentName,
ConnectedAt = DateTime.UtcNow
};
_agents.TryAdd(sessionId, context);
}
public AgentContext GetContext(string sessionId)
{
_agents.TryGetValue(sessionId, out var context);
return context;
}
public void CloseSession(string sessionId)
{
_agents.TryRemove(sessionId, out _);
}
}
Rate Limiting and Resource Quotas
In production, prevent any single agent from monopolizing resources:
public class RateLimiter
{
private readonly ConcurrentDictionary<string, RateLimitBucket> _buckets = new();
private readonly int _requestsPerMinute;
public RateLimiter(int requestsPerMinute = 100)
{
_requestsPerMinute = requestsPerMinute;
}
public bool AllowRequest(string agentId)
{
var bucket = _buckets.GetOrAdd(agentId, _ => new RateLimitBucket(_requestsPerMinute));
return bucket.TryConsumeToken();
}
}
private class RateLimitBucket
{
private int _tokensRemaining;
private DateTime _lastRefillTime = DateTime.UtcNow;
private readonly int _capacity;
private readonly object _lock = new();
public RateLimitBucket(int capacity)
{
_capacity = capacity;
_tokensRemaining = capacity;
}
public bool TryConsumeToken()
{
lock (_lock)
{
RefillTokens();
if (_tokensRemaining > 0)
{
_tokensRemaining--;
return true;
}
return false;
}
}
private void RefillTokens()
{
var now = DateTime.UtcNow;
var elapsed = (now - _lastRefillTime).TotalSeconds;
var tokensToAdd = (int)(elapsed * (_capacity / 60.0));
_tokensRemaining = Math.Min(_capacity, _tokensRemaining + tokensToAdd);
_lastRefillTime = now;
}
}
Orchestrating Agent Workflows
For complex scenarios where agents need to coordinate, implement a workflow coordinator that routes tool calls and manages state:
public class WorkflowOrchestrator
{
private readonly ILogger<WorkflowOrchestrator> _logger;
public async Task<WorkflowResult> ExecuteWorkflowAsync(
string workflowId,
List<AgentStep> steps)
{
var context = new WorkflowExecutionContext { WorkflowId = workflowId };
foreach (var step in steps)
{
_logger.LogInformation("Executing step: {StepName}", step.Name);
try
{
var result = await ExecuteAgentStepAsync(step, context);
context.StepResults.Add(step.Name, result);
if (!result.Success && step.FailureMode == FailureMode.StopOnError)
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Step failed: {StepName}", step.Name);
context.StepResults.Add(step.Name, new StepResult { Success = false, Error = ex.Message });
if (step.FailureMode == FailureMode.StopOnError)
break;
}
}
return new WorkflowResult
{
WorkflowId = workflowId,
Success = !context.StepResults.Values.Any(r => !r.Success),
Results = context.StepResults
};
}
private async Task<StepResult> ExecuteAgentStepAsync(
AgentStep step,
WorkflowExecutionContext context)
{
var toolResult = await CallToolAsync(
step.ToolName,
step.Parameters,
context.StepResults);
return new StepResult
{
Success = true,
Output = toolResult
};
}
}
Testing MCP Servers
Test your server thoroughly before deploying. Create a test harness that simulates agent clients:
[TestClass]
public class UserToolsTests
{
private MockUserService _userService;
[TestInitialize]
public void Setup()
{
_userService = new MockUserService();
}
[TestMethod]
public async Task GetUser_WithValidUserId_ReturnsUserData()
{
_userService.AddUser(new User
{
Id = "123",
Name = "John Doe",
Email = "john@example.com",
Status = "active"
});
var result = await UserTools.GetUser("123", false);
Assert.IsNotNull(result);
var userData = JsonSerializer.Deserialize<User>(result);
Assert.AreEqual("John Doe", userData.Name);
}
[TestMethod]
[ExpectedException(typeof(InvalidOperationException))]
public async Task GetUser_WithInvalidUserId_ThrowsException()
{
await UserTools.GetUser("invalid", false);
}
[TestMethod]
[ExpectedException(typeof(ArgumentException))]
public async Task UpdateUserStatus_WithInvalidStatus_ThrowsException()
{
await UserTools.UpdateUserStatus("123", "invalid_status");
}
}
Deployment Considerations
When moving to production, consider these factors:
- Transport: Use HTTP for cloud deployments or when agents are remote. Stdio works well for local or containerized setups.
- Authentication: Implement API key validation or OAuth2 if your server handles sensitive data. Add this to the protocol handshake.
- Logging and Monitoring: Log all tool calls with request IDs, execution times, and outcomes. Monitor for errors and performance degradation.
- Graceful Shutdown: Implement a shutdown handler to close database connections and finish in-flight requests cleanly.
- Versioning: Version your tools and server. If you change a tool’s schema, update the version and document breaking changes.
Here’s a startup example for an HTTP-based server in ASP.NET Core:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddLogging(config =>
{
config.AddConsole();
config.AddApplicationInsights();
});
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<AgentSessionManager>();
builder.Services.AddSingleton<RateLimiter>();
builder.Services
.AddMcpServer()
.WithHttpTransport(options =>
{
options.SessionMode = HttpServerSessionMode.Stateless;
})
.WithToolsFromAssembly();
var app = builder.Build();
app.MapMcpServer("/mcp");
app.Run();
Connecting Agents to Your Server
Once your server is running, agents connect by specifying the server’s transport. For a local stdio server:
{
"mcpServers": {
"my-backend-tools": {
"command": "dotnet",
"args": ["run", "--project", "./MyMcpServer/MyMcpServer.csproj"],
"env": {
"ASPNETCORE_ENVIRONMENT": "Production",
"ConnectionString": "Server=...;Database=...;"
}
}
}
}
For HTTP:
{
"mcpServers": {
"my-backend-tools": {
"url": "https://api.example.com/mcp",
"auth": {
"type": "bearer",
"token": "your-api-key"
}
}
}
}
The agent will discover all registered tools and use them as part of its decision-making process.
Conclusion
Building an MCP server in .NET gives you a standardized, maintainable way to expose backend capabilities to AI agents. The protocol handles discovery and communication, so you focus on implementing robust tools and handling edge cases well.
Start with a few core tools, test them thoroughly, and expand your tool set as your agentic system matures. The patterns covered here form the foundation for systems that scale across multiple agents and integrate seamlessly with any LLM platform that supports MCP.
What’s the difference between MCP and OpenAI’s function calling?
OpenAI function calling is specific to OpenAI’s API and models. MCP is a protocol-agnostic standard that works with any LLM platform that implements it. MCP also includes resources and prompts beyond just function calls, and it decouples tool definitions from the LLM provider, making it easier to switch providers without rewriting tool logic.
Can I run multiple MCP servers simultaneously?
Yes. Agents can connect to multiple MCP servers at once. Each server registers its own set of tools, and the agent sees all available tools across all connected servers. This is useful for separating concerns: one server for user data, another for orders, another for reporting.
How do I handle authentication in an MCP server?
For stdio transport, authentication typically happens during the protocol handshake. For HTTP transport, use standard HTTP headers (Authorization: Bearer token). Validate credentials before initializing the server or before processing sensitive tool calls. Store API keys securely in environment variables or a secrets manager, never hardcode them.
What’s the best way to version my tools?
Include a version field in your server metadata and in individual tool descriptions. If you make breaking changes to a tool’s schema, create a new tool (e.g., get_user_v2) rather than modifying the existing one. This allows old agents to keep working while new agents use the updated version.
How do I monitor and debug MCP server issues in production?
Log all tool calls with request IDs, parameters, execution time, and results. Use structured logging (JSON) so you can query logs easily. Set up alerts for error rates and slow tool executions. Use Application Insights or similar APM tools to track performance and correlate issues across multiple agent sessions.