DEV Community

Cover image for Model Context Protocol in .NET: Building and Consuming Universal AI Tools
Brian Spann
Brian Spann

Posted on

Model Context Protocol in .NET: Building and Consuming Universal AI Tools

Introduction

In Part 1 and Part 2 of this series, we explored Microsoft Agent Framework's core concepts and workflow orchestration. Now we tackle a crucial question: how do your agents interact with the outside world?

Traditionally, each AI framework had its own way of defining tools. Semantic Kernel used KernelFunction, LangChain used @tool decorators, and so on. This created silos—tools built for one framework couldn't be used in another.

Enter the Model Context Protocol (MCP)—an open standard that makes AI tools universal. Build a tool once, use it everywhere.


What is MCP?

Model Context Protocol is an open specification (developed by Anthropic and adopted across the industry) that standardizes how AI applications access tools and context. Think of it as HTTP for AI tools—a common language that any AI client can speak.

The Core Ideas

1. Servers expose capabilities:

MCP Server: "I can do these things"
├── Tools: Functions agents can call
├── Resources: Data agents can read
└── Prompts: Templates for common tasks
Enter fullscreen mode Exit fullscreen mode

2. Clients consume them:

AI Agent (MCP Client) → MCP Server → External System
                          ↓
                    GitHub, Slack, 
                    Database, API...
Enter fullscreen mode Exit fullscreen mode

3. Transport is flexible:

  • stdio: Local process communication
  • HTTP/SSE: Network-based, web-friendly
  • WebSocket: Real-time bidirectional

Why MCP Matters for .NET

Before MCP:

// Tool only works in Semantic Kernel
[KernelFunction]
public string SearchProducts(string query) { ... }

// Different tool for LangChain (Python)
@tool
def search_products(query: str) -> str: ...

// Yet another for AutoGen
def search_products(query): ...
Enter fullscreen mode Exit fullscreen mode

With MCP:

// Build once as MCP server
// Use from ANY MCP-compatible client:
// - Agent Framework
// - Claude Desktop
// - VS Code Copilot
// - Python agents
// - Any future client
Enter fullscreen mode Exit fullscreen mode

The Official C# SDK

Microsoft and Anthropic jointly maintain the official MCP C# SDK. It's production-ready, well-documented, and integrates seamlessly with Agent Framework.

# Client for consuming MCP servers
dotnet add package ModelContextProtocol

# Server for building MCP servers
dotnet add package ModelContextProtocol.Server

# ASP.NET Core integration
dotnet add package ModelContextProtocol.AspNetCore
Enter fullscreen mode Exit fullscreen mode

Consuming MCP Servers

Let's start by consuming existing MCP servers. There's a growing ecosystem of pre-built servers for common services.

Connecting to an MCP Server

using ModelContextProtocol;
using ModelContextProtocol.Client;

// Connect to a local MCP server via stdio
var transport = new StdioClientTransport(
    command: "npx",
    arguments: new[] { "-y", "@modelcontextprotocol/server-github" },
    environmentVariables: new Dictionary<string, string>
    {
        ["GITHUB_PERSONAL_ACCESS_TOKEN"] = config["GitHub:PAT"]!
    });

var client = await McpClient.CreateAsync(transport);

// Discover available tools
var tools = await client.ListToolsAsync();
foreach (var tool in tools)
{
    Console.WriteLine($"Tool: {tool.Name}");
    Console.WriteLine($"  Description: {tool.Description}");
    Console.WriteLine($"  Schema: {tool.InputSchema}");
}
Enter fullscreen mode Exit fullscreen mode

Output:

Tool: create_issue
  Description: Create a new issue in a GitHub repository
  Schema: {"type":"object","properties":{"repo":{"type":"string"},...}}

Tool: search_repositories
  Description: Search GitHub repositories
  Schema: {"type":"object","properties":{"query":{"type":"string"},...}}

Tool: get_file_contents
  Description: Get contents of a file from a repository
  Schema: {"type":"object","properties":{"repo":{"type":"string"},"path":{"type":"string"},...}}
Enter fullscreen mode Exit fullscreen mode

Calling MCP Tools

// Call a tool
var result = await client.CallToolAsync(
    name: "search_repositories",
    arguments: new Dictionary<string, object>
    {
        ["query"] = "semantic-kernel language:csharp stars:>100"
    });

// Result is MCP content
foreach (var content in result.Content)
{
    if (content is TextContent text)
    {
        Console.WriteLine(text.Text);
    }
}
Enter fullscreen mode Exit fullscreen mode

Integrating MCP with Agent Framework

Here's where it gets powerful—MCP tools become first-class agent tools:

using Microsoft.Agents.AI;
using ModelContextProtocol;

// Create the MCP client
var mcpClient = await McpClient.CreateAsync(
    new StdioClientTransport("npx", new[] { "-y", "@modelcontextprotocol/server-github" },
        new Dictionary<string, string> { ["GITHUB_PERSONAL_ACCESS_TOKEN"] = token }));

// Create an agent
var agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
{
    Name = "GitHubAssistant",
    Instructions = """
        You are a GitHub assistant. You can:
        - Search repositories
        - Create and manage issues
        - Read file contents
        - Analyze code

        Use the available GitHub tools to help users.
        """
});

// Add all MCP tools to the agent
agent.AddMcpTools(mcpClient);

// Now the agent can use GitHub!
var response = await agent.InvokeAsync(
    "Find the most popular C# AI frameworks and summarize their features");

Console.WriteLine(response.Content);
Enter fullscreen mode Exit fullscreen mode

The agent now has full GitHub access through MCP, with proper schema validation and error handling.

Connecting to HTTP-based MCP Servers

// HTTP/SSE transport for remote servers
var transport = new HttpClientTransport(
    new Uri("https://mcp.example.com/github"),
    httpClient: new HttpClient
    {
        DefaultRequestHeaders =
        {
            Authorization = new AuthenticationHeaderValue("Bearer", apiKey)
        }
    });

var client = await McpClient.CreateAsync(transport);
Enter fullscreen mode Exit fullscreen mode

Building an MCP Server

Now let's build our own MCP server to expose internal capabilities.

A Simple Product Inventory Server

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

// Define the server
var builder = McpServerBuilder.Create(
    name: "inventory-server",
    version: "1.0.0");

// Add a tool: Get Product
builder.AddTool(
    name: "get_product",
    description: "Get detailed product information by SKU",
    inputSchema: new JsonSchemaBuilder()
        .Type(SchemaValueType.Object)
        .Properties(
            ("sku", new JsonSchemaBuilder()
                .Type(SchemaValueType.String)
                .Description("Product SKU code")),
            ("include_inventory", new JsonSchemaBuilder()
                .Type(SchemaValueType.Boolean)
                .Description("Include current inventory levels")
                .Default(false)))
        .Required("sku")
        .Build(),
    handler: async (arguments, cancellationToken) =>
    {
        var sku = arguments["sku"]!.GetValue<string>();
        var includeInventory = arguments.TryGetValue("include_inventory", out var inv) 
            && inv.GetValue<bool>();

        var product = await productService.GetBySkuAsync(sku, cancellationToken);
        if (product == null)
        {
            return new ToolResult(isError: true, 
                content: new[] { new TextContent($"Product {sku} not found") });
        }

        var response = new
        {
            product.Sku,
            product.Name,
            product.Description,
            product.Price,
            product.Category,
            Inventory = includeInventory ? await inventoryService.GetLevelsAsync(sku) : null
        };

        return new ToolResult(
            content: new[] { new TextContent(JsonSerializer.Serialize(response)) });
    });

// Add a tool: Search Products
builder.AddTool(
    name: "search_products",
    description: "Search products by name, category, or other criteria",
    inputSchema: new JsonSchemaBuilder()
        .Type(SchemaValueType.Object)
        .Properties(
            ("query", new JsonSchemaBuilder()
                .Type(SchemaValueType.String)
                .Description("Search query")),
            ("category", new JsonSchemaBuilder()
                .Type(SchemaValueType.String)
                .Description("Filter by category")),
            ("min_price", new JsonSchemaBuilder()
                .Type(SchemaValueType.Number)
                .Description("Minimum price filter")),
            ("max_price", new JsonSchemaBuilder()
                .Type(SchemaValueType.Number)
                .Description("Maximum price filter")),
            ("limit", new JsonSchemaBuilder()
                .Type(SchemaValueType.Integer)
                .Description("Maximum results to return")
                .Default(10)))
        .Build(),
    handler: async (arguments, cancellationToken) =>
    {
        var searchCriteria = new ProductSearchCriteria
        {
            Query = arguments.TryGetValue("query", out var q) ? q.GetValue<string>() : null,
            Category = arguments.TryGetValue("category", out var c) ? c.GetValue<string>() : null,
            MinPrice = arguments.TryGetValue("min_price", out var minP) ? minP.GetValue<decimal>() : null,
            MaxPrice = arguments.TryGetValue("max_price", out var maxP) ? maxP.GetValue<decimal>() : null,
            Limit = arguments.TryGetValue("limit", out var l) ? l.GetValue<int>() : 10
        };

        var results = await productService.SearchAsync(searchCriteria, cancellationToken);
        return new ToolResult(
            content: new[] { new TextContent(JsonSerializer.Serialize(results)) });
    });

// Add a tool: Update Inventory
builder.AddTool(
    name: "update_inventory",
    description: "Update inventory levels for a product",
    inputSchema: new JsonSchemaBuilder()
        .Type(SchemaValueType.Object)
        .Properties(
            ("sku", new JsonSchemaBuilder().Type(SchemaValueType.String)),
            ("quantity_change", new JsonSchemaBuilder()
                .Type(SchemaValueType.Integer)
                .Description("Positive to add, negative to remove")),
            ("reason", new JsonSchemaBuilder()
                .Type(SchemaValueType.String)
                .Enum("sale", "return", "restock", "adjustment", "damage")))
        .Required("sku", "quantity_change", "reason")
        .Build(),
    handler: async (arguments, cancellationToken) =>
    {
        var sku = arguments["sku"]!.GetValue<string>();
        var change = arguments["quantity_change"]!.GetValue<int>();
        var reason = arguments["reason"]!.GetValue<string>();

        var result = await inventoryService.UpdateAsync(sku, change, reason, cancellationToken);
        return new ToolResult(
            content: new[] { new TextContent($"Inventory updated. New level: {result.NewQuantity}") });
    });

// Build and run
var server = builder.Build();
await server.RunStdioAsync();
Enter fullscreen mode Exit fullscreen mode

Running Your MCP Server

Compile and run as a standalone process:

dotnet build -c Release
./bin/Release/net8.0/InventoryServer
Enter fullscreen mode Exit fullscreen mode

Test with the MCP Inspector:

npx @modelcontextprotocol/inspector ./bin/Release/net8.0/InventoryServer
Enter fullscreen mode Exit fullscreen mode

MCP Resources: Exposing Data

Beyond tools, MCP supports resources—read-only data that agents can access.

builder.AddResource(
    uri: "inventory://products",
    name: "Product Catalog",
    description: "Complete list of products in the catalog",
    mimeType: "application/json",
    handler: async cancellationToken =>
    {
        var products = await productService.GetAllAsync(cancellationToken);
        return new ResourceContent(
            uri: "inventory://products",
            mimeType: "application/json",
            text: JsonSerializer.Serialize(products));
    });

builder.AddResource(
    uri: "inventory://categories",
    name: "Product Categories",
    description: "Available product categories",
    mimeType: "application/json",
    handler: async cancellationToken =>
    {
        var categories = await categoryService.GetAllAsync(cancellationToken);
        return new ResourceContent(
            uri: "inventory://categories",
            mimeType: "application/json",
            text: JsonSerializer.Serialize(categories));
    });

// Dynamic resources with templates
builder.AddResourceTemplate(
    uriTemplate: "inventory://products/{sku}",
    name: "Product Details",
    description: "Detailed information for a specific product",
    mimeType: "application/json",
    handler: async (uri, cancellationToken) =>
    {
        // Extract SKU from URI
        var sku = uri.Segments.Last();
        var product = await productService.GetBySkuAsync(sku, cancellationToken);

        if (product == null)
            return null; // Resource not found

        return new ResourceContent(
            uri: uri.ToString(),
            mimeType: "application/json",
            text: JsonSerializer.Serialize(product));
    });
Enter fullscreen mode Exit fullscreen mode

Reading Resources from Client

// List available resources
var resources = await client.ListResourcesAsync();

// Read a specific resource
var productCatalog = await client.ReadResourceAsync("inventory://products");
var catalogData = JsonSerializer.Deserialize<List<Product>>(productCatalog.Text);

// Read a templated resource
var productDetails = await client.ReadResourceAsync("inventory://products/SKU-12345");
Enter fullscreen mode Exit fullscreen mode

MCP Prompts: Reusable Templates

Prompts are pre-defined templates that standardize common interactions:

builder.AddPrompt(
    name: "analyze_inventory",
    description: "Analyze inventory levels and suggest restocking",
    arguments: new[]
    {
        new PromptArgument
        {
            Name = "category",
            Description = "Product category to analyze",
            Required = false
        },
        new PromptArgument
        {
            Name = "threshold",
            Description = "Low inventory threshold",
            Required = false
        }
    },
    handler: async (arguments, cancellationToken) =>
    {
        var category = arguments.TryGetValue("category", out var c) ? c : null;
        var threshold = arguments.TryGetValue("threshold", out var t) ? int.Parse(t) : 10;

        var lowStock = await inventoryService.GetLowStockAsync(category, threshold, cancellationToken);

        return new GetPromptResult
        {
            Messages = new[]
            {
                new PromptMessage
                {
                    Role = Role.User,
                    Content = new TextContent($"""
                        Analyze these low-stock items and recommend restocking priorities:

                        {JsonSerializer.Serialize(lowStock, new JsonSerializerOptions { WriteIndented = true })}

                        Consider:
                        1. Sales velocity (items selling faster need more stock)
                        2. Lead time from suppliers
                        3. Seasonal demand patterns
                        4. Storage costs

                        Provide a prioritized restocking plan.
                        """)
                }
            }
        };
    });
Enter fullscreen mode Exit fullscreen mode

Using Prompts from Client

// List available prompts
var prompts = await client.ListPromptsAsync();

// Get a prompt
var prompt = await client.GetPromptAsync(
    name: "analyze_inventory",
    arguments: new Dictionary<string, string>
    {
        ["category"] = "electronics",
        ["threshold"] = "5"
    });

// Use with an agent
var response = await agent.InvokeAsync(prompt.Messages);
Enter fullscreen mode Exit fullscreen mode

HTTP Transport with ASP.NET Core

For production deployments, expose your MCP server over HTTP:

using ModelContextProtocol.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

// Register services
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<IInventoryService, InventoryService>();

// Build MCP server
builder.Services.AddMcpServer("inventory-server", "1.0.0")
    .AddTool<GetProductTool>()
    .AddTool<SearchProductsTool>()
    .AddTool<UpdateInventoryTool>()
    .AddResource<ProductCatalogResource>()
    .AddResource<CategoriesResource>();

var app = builder.Build();

// Map MCP endpoints
app.MapMcpServer("/mcp");

// Health check
app.MapGet("/health", () => Results.Ok());

app.Run();
Enter fullscreen mode Exit fullscreen mode

Tool Classes for DI

public class GetProductTool : IMcpTool
{
    public string Name => "get_product";
    public string Description => "Get detailed product information by SKU";
    public JsonSchema InputSchema => new JsonSchemaBuilder()
        .Type(SchemaValueType.Object)
        .Properties(
            ("sku", new JsonSchemaBuilder().Type(SchemaValueType.String)))
        .Required("sku")
        .Build();

    private readonly IProductService _productService;

    public GetProductTool(IProductService productService)
    {
        _productService = productService;
    }

    public async Task<ToolResult> ExecuteAsync(
        IReadOnlyDictionary<string, JsonElement> arguments,
        CancellationToken cancellationToken)
    {
        var sku = arguments["sku"].GetString()!;
        var product = await _productService.GetBySkuAsync(sku, cancellationToken);

        if (product == null)
            return ToolResult.Error($"Product {sku} not found");

        return ToolResult.Success(JsonSerializer.Serialize(product));
    }
}
Enter fullscreen mode Exit fullscreen mode

Authentication (2025-06-18 Spec)

The MCP specification added authentication support in June 2025:

Server-Side Authentication

builder.Services.AddMcpServer("inventory-server", "1.0.0")
    .WithAuthentication(options =>
    {
        options.Provider = AuthProvider.OAuth2;
        options.TokenEndpoint = new Uri("https://auth.example.com/token");
        options.AuthorizationEndpoint = new Uri("https://auth.example.com/authorize");
        options.Scopes = new[] { "inventory:read", "inventory:write" };
        options.ClientId = config["OAuth:ClientId"]!;

        // Validate tokens
        options.TokenValidator = async (token, ct) =>
        {
            var result = await tokenValidator.ValidateAsync(token, ct);
            return new TokenValidationResult
            {
                IsValid = result.IsValid,
                Claims = result.Claims,
                Scopes = result.Scopes
            };
        };
    })
    .AddTool<GetProductTool>()
    .AddTool<UpdateInventoryTool>();

// In tool implementation, check permissions:
public class UpdateInventoryTool : IMcpTool
{
    public async Task<ToolResult> ExecuteAsync(
        IReadOnlyDictionary<string, JsonElement> arguments,
        McpContext context, // Includes auth info
        CancellationToken cancellationToken)
    {
        // Check for required scope
        if (!context.HasScope("inventory:write"))
        {
            return ToolResult.Error("Permission denied: requires inventory:write scope");
        }

        // Log who made the change
        var userId = context.Claims.GetValueOrDefault("sub");
        logger.LogInformation("Inventory update by {UserId}", userId);

        // Proceed with update...
    }
}
Enter fullscreen mode Exit fullscreen mode

Client-Side Authentication

var transport = new HttpClientTransport(
    new Uri("https://inventory.example.com/mcp"),
    authHandler: async ct =>
    {
        // Get or refresh OAuth token
        var token = await tokenProvider.GetAccessTokenAsync(
            scopes: new[] { "inventory:read", "inventory:write" },
            cancellationToken: ct);

        return new AuthenticationHeaderValue("Bearer", token);
    });

var client = await McpClient.CreateAsync(transport);
Enter fullscreen mode Exit fullscreen mode

When to Use MCP vs Native Tools

Use this decision matrix:

Scenario Recommendation Why
Internal tool, single app Native [AgentTool] Simpler, less overhead
Shared across agents MCP server Write once, use everywhere
Cross-language teams MCP server Python/JS agents can use it
Third-party integration MCP client Consume existing servers
External API wrapper Either MCP if sharing, native if not
Real-time requirements Native Lower latency
Needs authentication MCP (HTTP) Built-in OAuth support

Hybrid Approach

You can mix both:

var agent = new ChatClientAgent(chatClient, options);

// Native tools for simple, internal things
agent.AddTools<CalculatorTools>();
agent.AddTools<DateTimeTools>();

// MCP for shared/external capabilities
agent.AddMcpTools(await McpClient.CreateAsync(githubTransport));
agent.AddMcpTools(await McpClient.CreateAsync(slackTransport));
agent.AddMcpTools(await McpClient.CreateAsync(inventoryTransport));
Enter fullscreen mode Exit fullscreen mode

Ecosystem: Available MCP Servers

The MCP ecosystem is growing rapidly. Here are some notable servers:

Official Servers

Server Description Install
GitHub Repos, issues, PRs, code search npx @modelcontextprotocol/server-github
Slack Channels, messages, users npx @modelcontextprotocol/server-slack
Google Drive Files, folders, search npx @modelcontextprotocol/server-gdrive
PostgreSQL Database queries npx @modelcontextprotocol/server-postgres
Filesystem Local file access npx @modelcontextprotocol/server-filesystem

Community Servers

Server Description Source
Azure DevOps Work items, builds, repos github.com/...
Jira Issues, projects, sprints github.com/...
Linear Issue tracking github.com/...
Notion Pages, databases github.com/...
Puppeteer Browser automation github.com/...

Running Multiple Servers

// Create multiple MCP clients
var clients = new Dictionary<string, McpClient>
{
    ["github"] = await McpClient.CreateAsync(githubTransport),
    ["slack"] = await McpClient.CreateAsync(slackTransport),
    ["jira"] = await McpClient.CreateAsync(jiraTransport),
    ["db"] = await McpClient.CreateAsync(postgresTransport)
};

// Add all to agent
foreach (var (name, client) in clients)
{
    agent.AddMcpTools(client, prefix: $"{name}_");
}

// Agent now has:
// - github_create_issue
// - github_search_repositories
// - slack_send_message
// - slack_list_channels
// - jira_create_issue
// - db_query
// etc.
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Design Tool Interfaces Carefully

// ❌ Too vague
builder.AddTool(
    name: "do_thing",
    description: "Does a thing",
    // ...
);

// ✅ Clear and specific
builder.AddTool(
    name: "create_invoice",
    description: "Creates a new invoice for a customer. Returns the invoice ID and PDF URL.",
    // ...
);
Enter fullscreen mode Exit fullscreen mode

2. Provide Rich Schemas

// ❌ Minimal schema
new JsonSchemaBuilder()
    .Type(SchemaValueType.Object)
    .Properties(("data", new JsonSchemaBuilder().Type(SchemaValueType.String)))
    .Build()

// ✅ Detailed schema with descriptions and constraints
new JsonSchemaBuilder()
    .Type(SchemaValueType.Object)
    .Properties(
        ("customer_id", new JsonSchemaBuilder()
            .Type(SchemaValueType.String)
            .Description("Customer ID in format CUST-XXXXX")
            .Pattern("^CUST-[A-Z0-9]{5}$")),
        ("line_items", new JsonSchemaBuilder()
            .Type(SchemaValueType.Array)
            .Items(new JsonSchemaBuilder()
                .Type(SchemaValueType.Object)
                .Properties(
                    ("sku", new JsonSchemaBuilder().Type(SchemaValueType.String)),
                    ("quantity", new JsonSchemaBuilder()
                        .Type(SchemaValueType.Integer)
                        .Minimum(1)),
                    ("unit_price", new JsonSchemaBuilder()
                        .Type(SchemaValueType.Number)
                        .Minimum(0)))))
            .MinItems(1)),
        ("due_date", new JsonSchemaBuilder()
            .Type(SchemaValueType.String)
            .Format("date")
            .Description("Payment due date in ISO 8601 format")))
    .Required("customer_id", "line_items")
    .Build()
Enter fullscreen mode Exit fullscreen mode

3. Handle Errors Gracefully

handler: async (arguments, cancellationToken) =>
{
    try
    {
        var result = await service.DoThingAsync(arguments, cancellationToken);
        return ToolResult.Success(JsonSerializer.Serialize(result));
    }
    catch (NotFoundException ex)
    {
        return ToolResult.Error($"Not found: {ex.Message}");
    }
    catch (ValidationException ex)
    {
        return ToolResult.Error($"Invalid input: {ex.Message}");
    }
    catch (UnauthorizedException)
    {
        return ToolResult.Error("Permission denied");
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "Unexpected error in tool");
        return ToolResult.Error("An unexpected error occurred. Please try again.");
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Use Connection Pooling

// Register as singleton or use pooling
services.AddSingleton<IMcpClientPool>(sp =>
{
    return new McpClientPool(
        maxClientsPerServer: 5,
        idleTimeout: TimeSpan.FromMinutes(10));
});

// Use pooled client
await using var client = await pool.GetClientAsync("inventory-server");
var result = await client.CallToolAsync("get_product", args);
Enter fullscreen mode Exit fullscreen mode

What's Next

In Part 4, we'll take everything we've learned to production:

  • Deploying to Azure AI Foundry
  • Observability with OpenTelemetry
  • Scaling multi-agent systems
  • Security best practices
  • CI/CD for agent deployments

Resources

Top comments (0)