Your REST API already serves frontend apps, mobile clients, and third-party integrations. But when an AI agent — Claude, Copilot, Cursor — needs to interact with your application, it's stuck parsing OpenAPI specs, generating HTTP calls, and hoping the auth tokens line up.
The Model Context Protocol (MCP) changes this. An AI agent connects to your app, discovers available tools with typed parameters and descriptions, and invokes them directly. No glue code. No prompt engineering per endpoint.
In this article, I'll show how to turn any .NET module into an MCP server — with real authorization, GDPR-safe output, and multi-tenant isolation. We'll go from zero to a working MCP server in under 50 lines of code.
What is MCP?
MCP is an open protocol created by Anthropic and backed by Microsoft. Think of it as "USB-C for AI agents" — a standard way for agents to discover and invoke capabilities exposed by your application.
The flow:
- Agent connects to
https://your-app/mcp - Agent calls
tools/list→ gets a typed catalog of available tools - Agent picks the right tool based on the user's intent
- Agent calls
tools/callwith typed parameters - Your app runs the tool, returns structured results
- Agent uses the results to answer the user
User: "Show me unpaid invoices from last week"
Agent → tools/list
App ← [SearchInvoices, GetInvoiceDetails, VoidInvoice, ...]
Agent → tools/call SearchInvoices { status: "unpaid", from: "2026-03-19" }
App ← [{ id: 42, amount: 1250, customer: "Acme" }, ...]
Agent → User: "Found 3 unpaid invoices totaling €4,200..."
No custom API integration. No documentation parsing. The agent reads tool descriptions and decides.
The official MCP C# SDK
Microsoft and Anthropic ship the official MCP C# SDK as NuGet packages:
-
ModelContextProtocol— core server/client, stdio transport -
ModelContextProtocol.AspNetCore— HTTP transport for ASP.NET Core
The SDK handles the protocol. You declare tools as annotated methods:
[McpServerToolType]
public static class WeatherTools
{
[McpServerTool, Description("Gets the current weather for a city.")]
public static string GetWeather(
[Description("City name")] string city)
=> $"Weather in {city}: 22°C, sunny";
}
Register and map:
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithToolsFromAssembly();
app.MapMcp();
That's the hello-world. But production apps need more.
What the SDK doesn't solve
When you move beyond a demo, you hit real problems:
| Problem | What happens |
|---|---|
| No auth per tool | Every tool is callable by anyone who reaches the endpoint |
| PII in responses |
JsonSerializer.Serialize(patient) sends email and SSN to the agent |
| Tool sprawl | 50+ tools confuse the agent — it picks wrong ones or wastes tokens |
| Multi-tenancy | Tenant-specific tools show up for all tenants |
| Error leakage | Stack traces with connection strings reach the agent on errors |
| No metrics | You can't tell which tools are called, how often, or by whom |
We solved all of these in Granit, an open-source modular .NET framework (Apache-2.0, 200 packages). Here's how.
Step 1: Install and register
dotnet add package Granit.Mcp.Server
// Program.cs
builder.AddGranit(granit =>
{
granit.AddModule<GranitMcpServerModule>();
});
app.MapGranitMcpServer();
This wires up:
-
Streamable HTTP transport at
/mcp(configurable) -
SDK authorization filters —
[Authorize]works on tool classes and methods - Output sanitization pipeline — PII redaction, error stripping, size limits
- Tool visibility filters — tenant scope, module scope, opt-in discovery
-
OpenTelemetry metrics —
granit.mcp.tools.invoked, request duration, active sessions -
5 RBAC permissions —
Mcp.Server.Access,Mcp.Tools.Execute, etc.
One call. Everything wired into the SDK's native filter pipeline — no parallel abstractions.
Step 2: Create a tool class
[McpServerToolType, McpExposed]
[Authorize(Policy = "Invoicing.Invoices.Read")]
public sealed class InvoiceMcpTools(IInvoiceReader reader)
{
[McpServerTool, Description("Search invoices by status and date range.")]
[McpToolOptions(ReadOnly = true)]
public async Task<string> SearchInvoicesAsync(
[Description("Status: draft, sent, paid, overdue")] string status,
[Description("Start date (ISO 8601)")] DateOnly from,
ICurrentTenant tenant, // DI-injected, invisible to agent
ClaimsPrincipal user, // DI-injected, invisible to agent
CancellationToken ct)
{
var invoices = await reader.SearchAsync(status, from, ct);
return JsonSerializer.Serialize(invoices);
}
[McpServerTool, Description("Permanently voids an invoice.")]
[Authorize(Policy = "Invoicing.Invoices.Manage")]
[McpToolOptions(Destructive = true)]
public async Task<string> VoidInvoiceAsync(
[Description("Invoice ID")] Guid invoiceId,
[Description("Reason for voiding")] string reason,
CancellationToken ct)
{
await reader.VoidAsync(invoiceId, reason, ct);
return $"Invoice {invoiceId} voided.";
}
}
Key points:
-
[McpExposed]— opt-in discovery. In production, tools without this attribute stay hidden. Prevents accidental exposure of internal services. -
[Authorize(Policy = "...")]— standard ASP.NET Core authorization. Same attribute you use on Minimal API endpoints. No custom MCP auth layer. -
ICurrentTenant,ClaimsPrincipal,CancellationToken— the SDK resolves these from DI automatically. They're invisible in the tool's JSON schema — the agent never sees them. -
[McpToolOptions(Destructive = true)]— MCP clients (Claude Desktop, Cursor) prompt for confirmation before invoking destructive tools.
Step 3: There is no step 3
GranitMcpModule auto-discovers all [McpServerToolType] classes from your module assemblies. No manual registration. Add a tool class to any module, deploy, and agents see it.
GDPR: PII never reaches the agent
This is the one that keeps security teams up at night. Your SearchInvoices tool returns customer data. What stops an LLM from ingesting email addresses?
The [McpRedact] attribute:
public sealed class InvoiceResponse
{
public Guid Id { get; init; }
public decimal Amount { get; init; }
public string CustomerName { get; init; }
[McpRedact(Strategy = RedactionStrategy.Omit)]
public string CustomerEmail { get; init; }
[McpRedact(Strategy = RedactionStrategy.Hash)]
public string TaxId { get; init; }
}
Three strategies:
| Strategy | What it does | Use for |
|---|---|---|
| Omit (default) | Removes the property entirely | Email, phone, address |
| Hash | Replaces with stable SHA-256 | Correlation IDs (tax ID, SSN) |
| Mask | j***@***.com |
UI-only scenarios |
Why Omit is the default: masked values like j***@***.com waste LLM tokens and can trigger hallucinations — the model tries to "complete" the masked value. Omit is cleaner and safer.
The sanitization runs in the SDK's AddCallToolFilter pipeline. Every tool response passes through it before leaving your app. The ErrorSanitizer also strips stack traces and connection strings from error responses.
Multi-tenancy: tools follow the tenant
Three independent visibility filters:
1. Opt-in discovery ([McpExposed])
In Explicit mode (default for production), tool classes need both [McpServerToolType] and [McpExposed]. A developer adding [McpServerTool] on an internal service for testing won't accidentally expose it.
2. Tenant scope ([McpTenantScope])
[McpServerToolType, McpExposed]
[McpTenantScope(RequireTenant = true)]
public sealed class TenantReportTools(IReportService reports)
{
// This tool only appears when a tenant context is active
}
3. Module scope (configuration)
{
"Mcp": {
"Server": {
"EnabledModules": ["Invoicing", "Scheduling", "BlobStorage"]
}
}
}
AI agents work better with fewer, well-described tools. If your app has 30 modules, expose only the 5 that matter for the use case.
Consuming external MCP servers
The flow works both ways. Granit.Mcp.Client connects to MCP servers exposed by other services:
{
"Mcp": {
"Client": {
"Connections": {
"erp": { "Url": "https://erp.internal/mcp" },
"analyzer": {
"Transport": "stdio",
"Command": "python",
"Arguments": ["-m", "data_analyzer_mcp"]
}
}
}
}
}
IMcpClientFactory factory = sp.GetRequiredService<IMcpClientFactory>();
await using var client = await factory.CreateAsync("erp", ct);
var tools = await client.ListToolsAsync(cancellationToken: ct);
Granit.AI.Mcp bridges these into AI workspaces — external MCP tools become AITool instances in your IChatClient pipeline. A SamplingGuard prevents external servers from abusing your LLM budget (disabled by default, rate-limited when enabled).
Connect your favorite MCP client
Once deployed, connect from any MCP client:
Claude Desktop (claude_desktop_config.json):
{
"mcpServers": {
"my-app": {
"type": "url",
"url": "https://localhost:5001/mcp",
"headers": { "Authorization": "Bearer <jwt>" }
}
}
}
VS Code (.vscode/mcp.json):
{
"servers": {
"my-app": {
"type": "http",
"url": "https://localhost:5001/mcp",
"headers": { "Authorization": "Bearer <jwt>" }
}
}
}
What we built
4 packages, 31 tests, zero circular dependencies:
| Package | What it does |
|---|---|
Granit.Mcp |
Auto-discovery, sanitization pipeline, OpenTelemetry metrics |
Granit.Mcp.Server |
HTTP transport, [Authorize] integration, RBAC permissions |
Granit.Mcp.Client |
Named factory for external MCP servers (HTTP + stdio) |
Granit.AI.Mcp |
Bridge MCP tools into IChatClient pipelines, sampling guard |
Everything integrates into the SDK's native filter pipeline (AddCallToolFilter, AddListToolsFilter). No wrapper abstractions — Granit adds value only where the SDK has no opinion: authorization, sanitization, multi-tenancy, observability.
Key takeaways
- MCP lets AI agents discover and invoke your tools — no custom integrations per agent
- The C# SDK handles the protocol — you declare tools as annotated methods
- Production needs more: auth, PII redaction, tenant isolation, error sanitization
-
Standard
[Authorize]works on tool methods — no custom auth attributes -
[McpRedact]with Omit protects PII without wasting LLM tokens - Opt-in discovery prevents accidental exposure of internal services
Granit is an open-source modular .NET 10 framework (Apache-2.0) with 200 packages covering persistence, auth, messaging, GDPR, multi-tenancy, and now MCP.
Full MCP documentation: granit-fx.dev/dotnet/mcp
Source code: [github.com/granit-fx/granit-dotnet](https://github.com/granit-fx/granit-dotnet
Top comments (0)