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
2. Clients consume them:
AI Agent (MCP Client) → MCP Server → External System
↓
GitHub, Slack,
Database, API...
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): ...
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
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
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}");
}
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"},...}}
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);
}
}
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);
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);
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();
Running Your MCP Server
Compile and run as a standalone process:
dotnet build -c Release
./bin/Release/net8.0/InventoryServer
Test with the MCP Inspector:
npx @modelcontextprotocol/inspector ./bin/Release/net8.0/InventoryServer
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));
});
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");
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.
""")
}
}
};
});
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);
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();
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));
}
}
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...
}
}
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);
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));
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.
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.",
// ...
);
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()
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.");
}
}
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);
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
Top comments (0)