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 

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 } 

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); } } 

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. """; } 

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>(); } 

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(); } 

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" }); 

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. 

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"] } } } 

No Claude Code, uma linha resolve:

claude mcp add logistica -- dotnet run --project src/Logistica.McpServer 

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]); 

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.