DEV Community

Cover image for Criando um servidor MCP com o .NET
Henrique Mauri
Henrique Mauri

Posted on Originally published at henriquemauri.net

Criando um servidor MCP com o .NET

No último artigo eu mostrei como construir um agente de IA com o Microsoft Agent Framework. O agente consultava pedidos e calculava prazo de entrega usando ferramentas escritas em C#. Funcionou bem, e logo veio a pergunta que eu já esperava: "dá para usar essas mesmas ferramentas no Claude? E no Copilot do time?"

Do jeito que estava, não. As ferramentas moravam dentro do agente. Para levar a mesma regra para outro cliente de IA, eu teria que reescrever a integração em cada lugar. É exatamente o tipo de acoplamento que a gente passou anos combatendo em arquitetura de software, e que muita empresa está recriando agora com IA.

A resposta para isso tem nome: MCP, o Model Context Protocol. Neste artigo eu pego o mesmo cenário de logística e transformo as regras de negócio em um servidor MCP com .NET 10 e o SDK oficial em C#. No final, o mesmo servidor atende Claude Desktop, Claude Code, VS Code e o agente do Agent Framework, sem mudar uma linha de regra.

O que é MCP, sem enrolação

MCP é um protocolo aberto que padroniza como uma aplicação de IA conversa com sistemas externos. Em vez de cada ferramenta de IA inventar o seu jeito de chamar uma API, todo mundo fala o mesmo contrato, baseado em JSON-RPC.

São três papéis:

  • Host: a aplicação que o usuário usa (Claude Desktop, VS Code, o seu agente).
  • Cliente: o componente dentro do host que mantém a conexão com um servidor.
  • Servidor: o seu código, expondo capacidades para qualquer host compatível.

E o servidor pode expor basicamente três coisas:

  • Tools: ações que o modelo pode executar (consultar um pedido, registrar uma ocorrência).
  • Resources: conteúdo que o host pode anexar como contexto (uma política, um documento).
  • Prompts: modelos de prompt prontos que o usuário escolhe.

O ponto que importa para quem é dev .NET: o servidor MCP é só mais uma porta de entrada para o seu domínio. Assim como uma API REST ou uma fila. A regra continua onde sempre deveria estar.

A arquitetura do exemplo

O código completo está no meu GitHub: hgmauri/sample-mcp-server. São três projetos:

  • Logistica.Dominio: pedidos e cálculo de prazo. Zero dependência de IA, zero dependência de MCP.
  • Logistica.McpServer: a camada fina que traduz o protocolo para o domínio. Roda em stdio ou HTTP.
  • Logistica.Cliente: um cliente MCP sem LLM, para testar o servidor de forma determinística.

Repare que o domínio não sabe que existe IA. Isso é proposital. Se amanhã o MCP for substituído por outra coisa, o que muda é uma camada de algumas dezenas de linhas.

Criando o projeto

Uso o template web porque o mesmo executável vai rodar em stdio e em HTTP. O pacote ModelContextProtocol.AspNetCore já traz o ModelContextProtocol como dependência.

dotnet new web -n Logistica.McpServer
cd Logistica.McpServer
dotnet add package ModelContextProtocol.AspNetCore --version 2.3.0
Enter fullscreen mode Exit fullscreen mode

O domínio continua sendo o dono da regra

A calculadora de prazo é uma classe comum. Recebe um TimeProvider para ser testável e decide o prazo pela região do CEP.

public sealed class CalculadoraPrazo(TimeProvider relogio)
{
    public PrevisaoEntrega Calcular(string cep)
    {
        var cepNormalizado = Normalizar(cep);
        var (regiao, diasUteis) = PrazoPorRegiao(cepNormalizado[0]);
        var hoje = DateOnly.FromDateTime(relogio.GetLocalNow().DateTime);

        return new PrevisaoEntrega(
            Cep: $"{cepNormalizado[..5]}-{cepNormalizado[5..]}",
            Regiao: regiao,
            DiasUteis: diasUteis,
            DataPrevista: SomarDiasUteis(hoje, diasUteis));
    }

    public static string Normalizar(string cep)
    {
        var somenteDigitos = new string(cep.Where(char.IsAsciiDigit).ToArray());

        if (somenteDigitos.Length != 8)
            throw new CepInvalidoException(cep);

        return somenteDigitos;
    }

    // Primeiro dígito do CEP indica a região postal
    private static (string Regiao, int DiasUteis) PrazoPorRegiao(char primeiroDigito) => primeiroDigito switch
    {
        >= '0' and <= '3' => ("Sudeste", 2),
        '4' or '5' => ("Nordeste (BA, SE, PE, AL, PB, RN)", 5),
        '6' => ("Norte e Nordeste (CE, PI, MA, PA, AM, AP, RR)", 7),
        '7' => ("Centro-Oeste, TO, RO e AC", 4),
        _ => ("Sul", 3)
    };

    // SomarDiasUteis(...) pula sábado e domingo
}
Enter fullscreen mode Exit fullscreen mode

Nada aqui é novidade para quem trabalha com .NET. E é justamente esse o ponto.

As tools: uma camada fina sobre o domínio

Aqui entra o SDK. Uma classe marcada com [McpServerToolType], métodos marcados com [McpServerTool] e descrições em [Description]. As dependências chegam pelo construtor, via injeção de dependência, como em qualquer controller.

[McpServerToolType]
public sealed class PedidoTools(IPedidoRepositorio repositorio, CalculadoraPrazo calculadora)
{
    [McpServerTool(ReadOnly = true, OpenWorld = false)]
    [Description("Consulta status, cliente, destino e ocorrências de um pedido pelo número.")]
    public PedidoResposta ConsultarPedido(
        [Description("Número do pedido no formato PED-0000. Exemplo: PED-1001.")] string numeroPedido)
    {
        var pedido = repositorio.ObterPorNumero(numeroPedido)
            ?? throw new McpException($"Pedido {numeroPedido} não encontrado.");

        return PedidoResposta.De(pedido, repositorio.ListarOcorrencias(pedido.Numero));
    }

    [McpServerTool(ReadOnly = true, OpenWorld = false)]
    [Description("Calcula o prazo de entrega em dias úteis e a data prevista para um CEP de destino.")]
    public PrevisaoEntrega CalcularPrazoEntrega(
        [Description("CEP de destino com 8 dígitos. Aceita com ou sem hífen.")] string cep)
    {
        try
        {
            return calculadora.Calcular(cep);
        }
        catch (CepInvalidoException ex)
        {
            // McpException: a mensagem chega ao modelo. Qualquer outra exceção vira erro genérico.
            throw new McpException(ex.Message);
        }
    }

    // listar_pedidos_por_status segue o mesmo padrão

    [McpServerTool(ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false)]
    [Description("Registra uma ocorrência de atendimento em um pedido existente. Use somente quando o usuário pedir.")]
    public Ocorrencia RegistrarOcorrencia(
        [Description("Número do pedido no formato PED-0000.")] string numeroPedido,
        [Description("Descrição objetiva da ocorrência, até 280 caracteres.")] string descricao)
    {
        if (repositorio.ObterPorNumero(numeroPedido) is null)
            throw new McpException($"Pedido {numeroPedido} não encontrado.");

        if (string.IsNullOrWhiteSpace(descricao) || descricao.Length > 280)
            throw new McpException("A descrição deve ter entre 1 e 280 caracteres.");

        return repositorio.RegistrarOcorrencia(numeroPedido, descricao);
    }
}
Enter fullscreen mode Exit fullscreen mode

Alguns detalhes que fazem diferença na prática:

  • A descrição é prompt. O modelo decide qual tool chamar lendo o [Description]. Escreva como se estivesse explicando para um colega novo no time: o que faz, formato esperado, quando usar.
  • O nome vira snake_case automaticamente. ConsultarPedido é publicado como consultar_pedido. Se quiser controlar, use Name no atributo.
  • As anotações importam. ReadOnly, Destructive e OpenWorld vão para o cliente, e hosts como o Claude Desktop e o VS Code usam isso para decidir quando pedir confirmação ao usuário. Uma tool que escreve dados não deve se passar por leitura.
  • Só McpException chega ao modelo. Qualquer outra exceção vira uma mensagem genérica. Isso evita vazar stack trace e detalhe interno para dentro de uma conversa com IA. Por isso eu traduzo a exceção de domínio para McpException com uma mensagem útil.

Um resource para dar contexto

Nem tudo precisa ser tool. A política de entrega é um texto que o host pode anexar à conversa. Para isso existe o resource:

[McpServerResourceType]
public sealed class PoliticaEntregaResource
{
    [McpServerResource(UriTemplate = "logistica://politicas/entrega", Name = "politica_entrega", MimeType = "text/markdown")]
    [Description("Política oficial de prazos e ocorrências de entrega.")]
    public static string PoliticaDeEntrega() => """
        # Política de entrega

        - Prazos contados em dias úteis a partir da data da consulta.
        - Sudeste: 2 dias úteis. Sul: 3. Centro-Oeste, TO, RO e AC: 4.
        - Nordeste (BA, SE, PE, AL, PB, RN): 5. Demais estados do Norte e Nordeste: 7.
        - Pedido extraviado: abrir ocorrência e acionar o time de sinistros em até 24h.
        - Nunca prometer data diferente da calculada pelo sistema.
        """;
}
Enter fullscreen mode Exit fullscreen mode

Registrando o servidor e escolhendo o transporte

Concentrei o registro em uma extensão. O transporte fica de fora, porque quem decide como o servidor vai rodar é o Program.cs.

public static IMcpServerBuilder AddLogisticaMcp(this IServiceCollection services)
{
    services.AddSingleton(TimeProvider.System);
    services.AddSingleton<IPedidoRepositorio, PedidoRepositorioEmMemoria>();
    services.AddSingleton<CalculadoraPrazo>();

    return services
        .AddMcpServer(options =>
        {
            options.ServerInfo = new() { Name = "logistica-mcp", Version = "1.0.0" };
            options.ServerInstructions = "Servidor de logística da Dotcode. Use as ferramentas para consultar pedidos e prazos. Nunca invente dados.";
        })
        .WithTools<PedidoTools>()
        .WithResources<PoliticaEntregaResource>();
}
Enter fullscreen mode Exit fullscreen mode

E o Program.cs sobe em stdio por padrão, ou em HTTP com --http:

using Logistica.McpServer;

// stdio (padrão): o cliente (Claude Desktop, VS Code, Claude Code) sobe o processo localmente.
// --http: o servidor roda como API, pronto para container, App Service ou Container Apps.
if (args.Contains("--http"))
{
    var builder = WebApplication.CreateBuilder(args);

    builder.Services
        .AddLogisticaMcp()
        .WithHttpTransport(); // Streamable HTTP, stateless por padrão no SDK 2.x

    var app = builder.Build();

    // Em produção: autenticação (Entra ID/OAuth) e .RequireAuthorization() aqui.
    app.MapMcp("/mcp");

    await app.RunAsync();
}
else
{
    var builder = Host.CreateApplicationBuilder(args);

    // No stdio, o stdout é do protocolo. Log vai para o stderr, sempre.
    builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);

    builder.Services
        .AddLogisticaMcp()
        .WithStdioServerTransport();

    await builder.Build().RunAsync();
}

Enter fullscreen mode Exit fullscreen mode

Dois cuidados que já vi derrubar muito servidor MCP:

  • No stdio, o stdout pertence ao protocolo. Um Console.WriteLine esquecido corrompe a comunicação. Log vai para o stderr, sempre. É isso que o LogToStandardErrorThreshold garante.
  • No HTTP, o SDK 2.x é stateless por padrão, alinhado com a revisão mais recente da especificação. Na prática, o servidor escala horizontalmente atrás de um load balancer sem afinidade de sessão, o que facilita muito rodar em Container Apps ou App Service.

Testando sem LLM

Antes de plugar em qualquer IA, eu testo o servidor como testo qualquer API: de forma determinística. O SDK também tem a parte de cliente, então o projeto Logistica.Cliente sobe o servidor, lista as tools e chama cada uma:

await using var cliente = await McpClient.CreateAsync(transporte);

Console.WriteLine($"Conectado a {cliente.ServerInfo?.Name} {cliente.ServerInfo?.Version}\n");

// 1. Descoberta: o cliente não conhece as ferramentas de antemão, ele pergunta ao servidor
var ferramentas = await cliente.ListToolsAsync();

foreach (var ferramenta in ferramentas)
{
    var somenteLeitura = ferramenta.ProtocolTool.Annotations?.ReadOnlyHint == true ? "leitura" : "escrita";
    Console.WriteLine($"- {ferramenta.Name} [{somenteLeitura}]: {ferramenta.Description}");
}

var resultado = await cliente.CallToolAsync("calcular_prazo_entrega", new Dictionary<string, object?> { ["cep"] = "29050-000" });
Enter fullscreen mode Exit fullscreen mode

A saída real, rodando no .NET 10:

Conectado a logistica-mcp 1.0.0

- consultar_pedido [leitura]: Consulta status, cliente, destino e ocorrências de um pedido pelo número.
- calcular_prazo_entrega [leitura]: Calcula o prazo de entrega em dias úteis e a data prevista para um CEP de destino.
- listar_pedidos_por_status [leitura]: Lista os pedidos em um status. Valores aceitos: AguardandoColeta, EmTransito, Entregue, Extraviado.
- registrar_ocorrencia [escrita]: Registra uma ocorrência de atendimento em um pedido existente. Use somente quando o usuário pedir.

>> calcular_prazo_entrega(cep: 29050-000)
{"cep":"29050-000","regiao":"Sudeste","diasUteis":2,"dataPrevista":"2026-11-03"}

>> calcular_prazo_entrega(cep: 123)
[erro] An error occurred invoking 'calcular_prazo_entrega': CEP '123' inválido. Informe 8 dígitos.

>> consultar_pedido(numeroPedido: PED-9999)
[erro] An error occurred invoking 'consultar_pedido': Pedido PED-9999 não encontrado.
Enter fullscreen mode Exit fullscreen mode

Repare no CEP inválido e no pedido inexistente. O erro volta com IsError e uma mensagem que o modelo consegue usar para se corrigir ou explicar ao usuário. Esse mesmo cliente serve para rodar no pipeline de CI.

Plugando no Claude Desktop, Claude Code e VS Code

Com o servidor funcionando, conectar em um host é configuração. No Claude Desktop, edite o claude_desktop_config.json:

{
  "mcpServers": {
    "logistica": {
      "command": "dotnet",
      "args": ["run", "--project", "C:/caminho/sample-mcp-server/src/Logistica.McpServer"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

No Claude Code, uma linha resolve:

claude mcp add logistica -- dotnet run --project src/Logistica.McpServer
Enter fullscreen mode Exit fullscreen mode

E o repositório já traz o .vscode/mcp.json para o VS Code. Abriu a pasta, o Copilot no modo agente enxerga as tools.

Depois é só perguntar em linguagem natural: "Qual o status do pedido PED-1004 e quando chega uma entrega para o CEP 29050-000?". O modelo escolhe as tools, chama o seu código e responde com dado real.

E o agente do Agent Framework?

Aqui fecha o ciclo com o artigo anterior. O McpClientTool herda de AIFunction, então as tools do servidor entram direto no agente, no lugar das funções que antes estavam escritas dentro dele:

await using var mcp = await McpClient.CreateAsync(new StdioClientTransport(new()
{
    Name = "logistica",
    Command = "dotnet",
    Arguments = ["run", "--project", "src/Logistica.McpServer"]
}));

var ferramentas = await mcp.ListToolsAsync();

AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
    .AsAIAgent(
        model: deploymentName,
        name: "AgenteLogistica",
        instructions: "Você atende clientes de uma empresa de logística. Nunca invente dados.",
        tools: [.. ferramentas]);
Enter fullscreen mode Exit fullscreen mode

O agente não sabe mais como o prazo é calculado. Ele só sabe que existe uma tool que calcula. A regra ficou em um lugar só.

O que eu cuido antes de colocar em produção

Servidor MCP é porta de entrada para o seu sistema. Trate com o mesmo rigor de uma API pública:

  • Autenticação no HTTP. O SDK tem suporte a OAuth no pacote AspNetCore. Em Azure, Entra ID e RequireAuthorization() no MapMcp.
  • Menor privilégio. Exponha só o que o caso de uso precisa. Não existe motivo para uma tool de atendimento ter acesso de escrita no cadastro inteiro.
  • Validação de argumentos. O modelo pode mandar qualquer coisa. Valide como valida input de usuário.
  • Observabilidade. Log de cada chamada de tool, com quem chamou e quanto tempo levou. Sem isso, você não sabe o que a IA está fazendo no seu sistema.
  • Descrições versionadas. Mudou a descrição, mudou o comportamento do modelo. Trate como código, com revisão.

Conclusão

MCP não é mágica e não substitui arquitetura. Ele padroniza a conversa entre a IA e o seu sistema, e isso só gera valor quando do outro lado existe um domínio bem resolvido, com dados confiáveis e regras claras.

É o que eu repito para os clientes da Dotcode: primeiro os dados e as APIs, depois a IA. Quem já tem o domínio organizado transforma regra de negócio em servidor MCP em uma tarde. Quem não tem vai descobrir que o problema nunca foi o modelo.

O código completo está em github.com/hgmauri/sample-mcp-server. Clona, roda o cliente e me conta nos comentários como você está expondo o seu domínio para IA.

Top comments (0)