Start Debugging

Cómo construir un servidor MCP personalizado en C# sobre .NET 11

Construye un servidor Model Context Protocol funcional en C# 14 / .NET 11 usando el SDK oficial ModelContextProtocol 1.2. Cubre el transporte stdio, los atributos [McpServerTool], inyección de dependencias, la trampa del logging por stderr y el registro con Claude Code, Claude Desktop y VS Code.

Si la fuente de verdad de tu equipo vive en un servicio .NET — una base de datos EF Core, una API interna, un ejecutor de jobs Hangfire, una Workspace API — entonces exponerlo a un agente de codificación a través del Model Context Protocol es más rápido de lo que el lado .NET de internet suele anunciar. El SDK oficial de C# llegó a 1.0 el 5 de marzo de 2026 y publicó 1.2.0 el 27 de marzo, ambos mantenidos conjuntamente por Microsoft y Anthropic. El boilerplate ahora es lo bastante pequeño como para que el trabajo interesante esté en tus métodos de herramienta, no en la fontanería del protocolo.

Esta guía construye un servidor MCP real y ejecutable en C# 14 sobre .NET 11, usando el paquete ModelContextProtocol 1.2.0 contra la especificación MCP 2025-11-25. Al final tendrás un servidor inventory-mcp que expone una base de datos SQLite a un agente a través de tres herramientas, con inyección de dependencias adecuada, el truco de logging por stderr que la documentación solo menciona de pasada, y los fragmentos de configuración exactos para Claude Code, Claude Desktop y mcp.json de VS Code.

Cuándo el SDK de C# es la elección correcta

Los equipos de Anthropic y MCP publican SDKs oficiales en TypeScript, Python y C#. Producen tráfico idéntico por el cable, así que la pregunta no es “cuál transporta mejor el protocolo” sino “dónde vive ya el código que quiero exponer”. Dos casos en los que C# gana:

Para contexto sobre el protocolo en sí, el resumen del cableado de Microsoft mcp para .NET 10, un poco más antiguo, cubre la mentalidad contract-first; este post es la actualización how-to concreta para .NET 11 y el SDK posterior a 1.0.

Configuración del proyecto con el SDK de .NET 11

Necesitas el SDK de .NET 11 (dotnet --version debería reportar 11.0.x o superior). El paquete ModelContextProtocol 1.2.0 apunta a net8.0 y superiores, así que net11.0 está soportado y te da las características de C# 14 gratis.

# .NET 11 SDK, ModelContextProtocol 1.2.0
dotnet new console -n InventoryMcp
cd InventoryMcp
dotnet add package ModelContextProtocol --version 1.2.0
dotnet add package Microsoft.Extensions.Hosting --version 11.0.0
dotnet add package Microsoft.Data.Sqlite --version 11.0.0

La división de paquetes se ve así, y la elección importa:

Para un servidor stdio que lanzas desde un agente de codificación, solo necesitas el primero.

El .csproj para .NET 11 termina siendo mínimo:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net11.0</TargetFramework>
    <LangVersion>14.0</LangVersion>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <RootNamespace>InventoryMcp</RootNamespace>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="ModelContextProtocol" Version="1.2.0" />
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="11.0.0" />
    <PackageReference Include="Microsoft.Data.Sqlite" Version="11.0.0" />
  </ItemGroup>
</Project>

El Program.cs que no corrompe stdout

El transporte stdio lleva mensajes JSON-RPC sobre el par stdin/stdout del proceso. El servidor lee solicitudes en stdin y escribe respuestas en stdout. Cualquier otra cosa que toque stdout — un Console.WriteLine perdido, un ILogger con configuración por defecto emitiendo a stdout, una traza de pila de excepción cayendo en stdout en lugar de stderr — se inyecta en el flujo JSON y el cliente mata la conexión con un error de parseo.

La integración de hosting del SDK de C# maneja las escrituras del protocolo, pero tienes que reenlazar el logger de consola a stderr o perderás los primeros 30 minutos de tu vida persiguiendo alertas de “MCP server disconnected” en Claude Code:

// Program.cs, .NET 11, ModelContextProtocol 1.2.0
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Data.Sqlite;
using InventoryMcp;

var builder = Host.CreateApplicationBuilder(args);

// All log output goes to stderr. Stdout is reserved for MCP traffic.
builder.Logging.AddConsole(o =>
{
    o.LogToStandardErrorThreshold = LogLevel.Trace;
});

builder.Services.AddSingleton<ProductRepository>(_ =>
{
    var dbPath = Environment.GetEnvironmentVariable("INVENTORY_DB_PATH")
                 ?? Path.Combine(AppContext.BaseDirectory, "inventory.db");
    return new ProductRepository($"Data Source={dbPath}");
});

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

await builder.Build().RunAsync();

Tres cosas que vale la pena fijar:

Definir las herramientas

Cada herramienta es un método sobre una clase decorada con [McpServerToolType]. El método en sí lleva [McpServerTool, Description("...")]. Los parámetros del método se convierten en el esquema de entrada; [Description] sobre cada parámetro acaba en el JSON Schema que el agente ve cuando decide si llamar a la herramienta.

El repositorio es ADO.NET puro con Microsoft.Data.Sqlite para que el ejemplo se lea de cabo a rabo sin baile de ORM. El patrón funciona igual con EF Core 11 — inyecta el DbContext y el bucle de registro es idéntico:

// ProductRepository.cs, .NET 11
using Microsoft.Data.Sqlite;

namespace InventoryMcp;

public sealed record Product(string Sku, string Name, int Stock, decimal Price);

public sealed class ProductRepository
{
    private readonly string _connectionString;

    public ProductRepository(string connectionString)
    {
        _connectionString = connectionString;
        EnsureSchema();
    }

    public IReadOnlyList<Product> List(bool lowStockOnly, int limit)
    {
        using var conn = new SqliteConnection(_connectionString);
        conn.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = lowStockOnly
            ? "SELECT sku, name, stock, price FROM products WHERE stock < 10 ORDER BY name LIMIT $limit"
            : "SELECT sku, name, stock, price FROM products ORDER BY name LIMIT $limit";
        cmd.Parameters.AddWithValue("$limit", limit);

        var results = new List<Product>();
        using var reader = cmd.ExecuteReader();
        while (reader.Read())
        {
            results.Add(new Product(
                reader.GetString(0),
                reader.GetString(1),
                reader.GetInt32(2),
                reader.GetDecimal(3)));
        }
        return results;
    }

    public Product? Get(string sku)
    {
        using var conn = new SqliteConnection(_connectionString);
        conn.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = "SELECT sku, name, stock, price FROM products WHERE sku = $sku";
        cmd.Parameters.AddWithValue("$sku", sku);

        using var reader = cmd.ExecuteReader();
        return reader.Read()
            ? new Product(reader.GetString(0), reader.GetString(1), reader.GetInt32(2), reader.GetDecimal(3))
            : null;
    }

    public int Adjust(string sku, int delta)
    {
        using var conn = new SqliteConnection(_connectionString);
        conn.Open();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = """
            UPDATE products SET stock = stock + $delta
            WHERE sku = $sku AND stock + $delta >= 0
            RETURNING stock
            """;
        cmd.Parameters.AddWithValue("$sku", sku);
        cmd.Parameters.AddWithValue("$delta", delta);

        var result = cmd.ExecuteScalar();
        if (result is null)
        {
            throw new InvalidOperationException(
                $"Cannot adjust stock for SKU '{sku}': product not found or stock would go negative.");
        }
        return Convert.ToInt32(result);
    }

    private void EnsureSchema() { /* CREATE TABLE IF NOT EXISTS ... and seed */ }
}

La clase de herramientas es la superficie que ve el agente:

// InventoryTools.cs, ModelContextProtocol 1.2.0
using System.ComponentModel;
using ModelContextProtocol.Server;

namespace InventoryMcp;

[McpServerToolType]
public sealed class InventoryTools
{
    private readonly ProductRepository _repo;
    private readonly ILogger<InventoryTools> _logger;

    public InventoryTools(ProductRepository repo, ILogger<InventoryTools> logger)
    {
        _repo = repo;
        _logger = logger;
    }

    [McpServerTool, Description("List products in the inventory database. Optionally filter to low-stock items (under 10 units).")]
    public IReadOnlyList<Product> ListProducts(
        [Description("If true, return only products with fewer than 10 units in stock.")] bool lowStockOnly = false,
        [Description("Maximum number of rows to return. Default 50, hard cap 500.")] int limit = 50)
    {
        limit = Math.Clamp(limit, 1, 500);
        return _repo.List(lowStockOnly, limit);
    }

    [McpServerTool, Description("Get a single product by its SKU. Returns null if no product matches.")]
    public Product? GetProduct(
        [Description("Stock-keeping unit, e.g. 'SKU-001'. Case-sensitive exact match.")] string sku)
        => _repo.Get(sku);

    [McpServerTool, Description("Adjust stock for a SKU by a positive or negative delta. Returns the new stock level. Errors if the SKU does not exist or the result would be negative.")]
    public int AdjustStock(
        [Description("SKU to adjust, e.g. 'SKU-001'.")] string sku,
        [Description("Signed integer delta. Use positive numbers to receive stock, negative to ship.")] int delta)
    {
        _logger.LogInformation("AdjustStock sku={Sku} delta={Delta}", sku, delta);
        return _repo.Adjust(sku, delta);
    }
}

Algunos detalles que importan una vez que un agente realmente empieza a llamar esto:

Conectarlo a Claude Code, Claude Desktop y VS Code

Una vez que dotnet run arranca el proceso, regístralo con el agente. Tres formatos, el mismo binario.

Claude Code tiene un comando incorporado para servidores stdio. Desde la raíz del proyecto:

# Claude Code 2.x
claude mcp add inventory -- dotnet run --project ./InventoryMcp.csproj

Para una compilación publicada, cambia al binario:

dotnet publish -c Release -o publish
claude mcp add inventory -- ./publish/InventoryMcp

Claude Desktop usa claude_desktop_config.json. En Windows vive en %AppData%\Claude\claude_desktop_config.json; en macOS en ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "inventory": {
      "command": "dotnet",
      "args": [
        "run",
        "--project",
        "C:\\src\\InventoryMcp\\InventoryMcp.csproj",
        "--no-launch-profile"
      ],
      "env": {
        "INVENTORY_DB_PATH": "C:\\data\\inventory.db"
      }
    }
  }
}

Reinicia Claude Desktop, y el indicador MCP debería listar list_products, get_product y adjust_stock. Pregúntale “¿Qué productos están bajos de stock?” y míralo llamar a list_products(lowStockOnly: true).

VS Code usa .vscode/mcp.json para servidores con alcance de workspace:

{
  "inputs": [],
  "servers": {
    "inventory": {
      "type": "stdio",
      "command": "dotnet",
      "args": ["run", "--project", "${workspaceFolder}/InventoryMcp/InventoryMcp.csproj"]
    }
  }
}

Para hacerte una idea de cómo un IDE empaqueta servidores MCP nativamente en lugar de pasar por la configuración de usuario, el Azure MCP Server dentro de Visual Studio 2022 17.14.30 es un buen punto de referencia.

Cuándo stdio está mal: la forma del transporte HTTP

Stdio es correcto para “agente en mi máquina, servidor en mi máquina, un cliente por proceso”. En el momento en que quieras un servidor de larga vida al que otros desarrolladores se conecten remotamente, cambia el paquete y el registro:

// dotnet add package ModelContextProtocol.AspNetCore --version 1.2.0
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ProductRepository>(/* ... */);
builder.Services
    .AddMcpServer(o => o.ServerInfo = new() { Name = "inventory", Version = "1.0.0" })
    .WithHttpTransport()
    .WithToolsFromAssembly();

var app = builder.Build();
app.MapMcp();
app.Run();

MapMcp() expone los endpoints HTTP-streameable y SSE que define la spec. Ponlo detrás de tu pipeline habitual de auth de ASP.NET Core y obtienes consentimiento incremental de scope con OAuth 2.0, descubrimiento well-known de autorización, y el polling de solicitudes de larga duración que aterrizó en la versión 1.0 gratis.

Trampas de producción que la documentación minimiza

No envíes una sola conexión Microsoft.Data.Sqlite. El ejemplo de arriba abre una conexión nueva por llamada, que es el valor por defecto correcto para una demo de SDK. Para cargas más allá de una base de datos hobby, registra SqliteConnection como servicio transient o cablea EF Core 11 con pooling. SQLite serializa las escrituras por defecto; si dos llamadas a AdjustStock disparan simultáneamente verás SQLITE_BUSY cuando la contención de bloqueo cruce unos pocos cientos de ms.

Tokens de cancelación. Los métodos de herramienta pueden tomar un parámetro final CancellationToken y el SDK conectará el token por solicitud. Si tu herramienta llama a HttpClient, EF Core, o cualquier I/O, acepta el token y pásalo. Si no, un modelo mal portado que se queda sin tiempo deja una transacción de SQLite o solicitud HTTP colgada en el servidor.

IHttpClientFactory para llamadas salientes. Cuando una herramienta hace fetch desde una API externa, inyecta IHttpClientFactory y crea clientes con nombre. Las mismas reglas de tiempo de vida que muerden a las apps ASP.NET Core — agotamiento de sockets por new HttpClient(), fijación de DNS — muerden a los servidores MCP más fuerte, porque tienden a quedarse corriendo a través de muchas sesiones de agente.

Volumen de logging. Un LogInformation charlatán por llamada de herramienta está bien. Loggear toda la entrada de la herramienta en cada llamada filtra PII a stderr y acaba en la transcripción de Claude Code, que el usuario puede no darse cuenta que está siendo capturada. Trata los logs de llamadas de herramienta igual que tratas los logs de solicitud web: redacta secretos, resume entradas.

Sorpresas de serialización JSON. El SDK usa System.Text.Json con las opciones por defecto. Si tus tipos de dominio se apoyan en atributos de Newtonsoft.Json o casing no por defecto, configura las opciones JSON sobre el host o convierte a records planos en la frontera de la herramienta. Un tipo que se serializa de una forma a tus clientes REST y de otra a clientes MCP es una pesadilla de depuración.

Native AOT. El paquete ModelContextProtocol no es totalmente AOT-friendly todavía porque el descubrimiento de herramientas dirigido por atributos usa reflexión. Si necesitas un ejecutable AOT de archivo único para distribución, usa ModelContextProtocol.Core y registra herramientas manualmente con MapTool en lugar de WithToolsFromAssembly.

Lo que este patrón desbloquea para una tienda .NET

El movimiento central — decora un método, devuelve un record, lanza en errores — escala a cada integración C# que tu equipo ya tiene. Algunos siguientes pasos obvios:

Si principalmente trabajas en otro lenguaje, el servidor equivalente en TypeScript que envuelve un CLI cubre Node.js con @modelcontextprotocol/sdk, y la guía de Python usando el SDK oficial mcp cubre el patrón FastMCP. Y si miras más allá de MCP hacia orquestación multi-agente en C#, Microsoft Agent Framework 1.0 recoge donde MCP termina, con planificadores, traspaso multi-agente y estado de ejecución durable.

Al servidor MCP en sí no le importa si tu herramienta envuelve una base de datos SQLite, un hub de SignalR o un servicio de dominio de 500 líneas. Solo necesita parámetros tipados (los atributos de C# te dan eso gratis), un valor de retorno que el SDK pueda serializar y un flujo stdio que no tenga bytes perdidos.

Enlaces de fuente

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Volver