DEV Community

JF Meyers
JF Meyers

Posted on

Let AI Agents Call Your .NET API — MCP Server in 3 Steps

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:

  1. Agent connects to https://your-app/mcp
  2. Agent calls tools/list → gets a typed catalog of available tools
  3. Agent picks the right tool based on the user's intent
  4. Agent calls tools/call with typed parameters
  5. Your app runs the tool, returns structured results
  6. 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..."
Enter fullscreen mode Exit fullscreen mode

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";
}
Enter fullscreen mode Exit fullscreen mode

Register and map:

builder.Services.AddMcpServer()
    .WithHttpTransport()
    .WithToolsFromAssembly();

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

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
Enter fullscreen mode Exit fullscreen mode
// Program.cs
builder.AddGranit(granit =>
{
    granit.AddModule<GranitMcpServerModule>();
});

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

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 metricsgranit.mcp.tools.invoked, request duration, active sessions
  • 5 RBAC permissionsMcp.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.";
    }
}
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

3. Module scope (configuration)

{
  "Mcp": {
    "Server": {
      "EnabledModules": ["Invoicing", "Scheduling", "BlobStorage"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"]
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
IMcpClientFactory factory = sp.GetRequiredService<IMcpClientFactory>();
await using var client = await factory.CreateAsync("erp", ct);
var tools = await client.ListToolsAsync(cancellationToken: ct);
Enter fullscreen mode Exit fullscreen mode

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>" }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

VS Code (.vscode/mcp.json):

{
  "servers": {
    "my-app": {
      "type": "http",
      "url": "https://localhost:5001/mcp",
      "headers": { "Authorization": "Bearer <jwt>" }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)