如何用 .NET 10 建立 MCP 伺服器,讓多個 AI 客戶端共用物流工具
Criando um servidor MCP com o .NET
文章示範以 .NET 10 和官方 C# SDK 建立物流 MCP 伺服器,讓 Claude Desktop、Claude Code、VS Code 及 Agent Framework 共用同一套業務規則。伺服器以工具和資源暴露功能,支援 stdio 或 HTTP,並可先用不接入 LLM 的 MCP 客戶端作確定性測試。文中亦説明輸入驗證、權限控制及觀測性等部署注意事項。
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 comoconsultar_pedido. Se quiser controlar, useNameno atributo. -
As anotações importam.
ReadOnly,DestructiveeOpenWorldvã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ó
McpExceptionchega 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 paraMcpExceptioncom 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.WriteLineesquecido corrompe a comunicação. Log vai para o stderr, sempre. É isso que oLogToStandardErrorThresholdgarante. - 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()noMapMcp. - 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.
來源:DEV Community · dev.to