DEV Community

Consumir servidores MCP desde .NET: cuando tu app es el cliente

Consumir servidores MCP desde .NET: cuando tu app es el cliente La última vez construimos un servidor MCP en C# y lo conectamos a Claude Desktop y a Claude Code. Ambas son aplicaciones de otra persona. Tú escribiste las herramientas, las entregaste, y un cliente que no escribiste se llevó todo el beneficio. Así que aquí va la otra mitad de la historia: ¿qué pasa cuando tu app en .NET es la que necesita esas herramientas? No Claude Desktop - tu consola interna de soporte, tu worker service, tu CLI. Este post escribe el lado cliente, lo conecta al mismo servidor de Aurora Coffee Co. de la última vez, y termina en un punto satisfactorio: el bucle de agente que escribimos a mano en el post de tool use - ese de unas sesenta líneas - se reduce a tres. El lado cliente del protocolo Un post sobre servidores habla de exponer capacidades. Un post sobre clientes es la imagen espejo, y son solo tres verbos: - Conectar - a través de un transporte. O lanzas el servidor como proceso hijo (stdio) o apuntas a una URL (HTTP). - Descubrir - preguntarle al servidor qué tiene. Las herramientas, y sus esquemas de entrada, llegan como datos. Nada está compilado dentro. - Llamar - invocar una herramienta por nombre con un conjunto de argumentos, y recibir contenido de vuelta. Vale la pena detenerse en ese tercer paso, porque es la misma frontera de seguridad del post de tool use vista desde el lado opuesto. Antes, tu código era dueño de la ejecución y Claude solo podía pedir. Ahora el que pide eres tú, y el servidor es dueño de la ejecución. Envías un nombre y unos argumentos; lo que realmente se ejecuta es asunto exclusivo del servidor. El paso de descubrimiento es lo que diferencia esto de simplemente llamar a un cliente de API. No tienes una clase proxy generada con un GetOrderStatus(string) encima. Tienes una lista de herramientas que llegó en tiempo de ejecución, y código que tiene que estar cómodo con eso. Conectando con el servidor de Aurora Arranca una app de consola y agrega el SDK. El cliente vive en ModelContextProtocol.Core si quieres dependencias mínimas, o en el paquete completo ModelContextProtocol si esa misma app también hospeda un servidor: dotnet new console -o AuroraCoffee.Support cd AuroraCoffee.Support dotnet add package ModelContextProtocol.Core Para un servidor local, el transporte stdio lo lanza como proceso hijo - exactamente lo que Claude Desktop hacía por ti con aquel archivo JSON de configuración la última vez, solo que ahora el que lo lanza eres tú: using ModelContextProtocol.Client; var transport = new StdioClientTransport(new StdioClientTransportOptions { Name = "aurora-coffee", Command = "dotnet", Arguments = ["run", "--project", "../AuroraCoffee.Mcp"], }); await using var client = await McpClient.CreateAsync(transport); Esa es toda la conexión. McpClient.CreateAsync arranca el proceso, hace el handshake del protocolo y te entrega un cliente vivo. Fíjate en el await using - el cliente es dueño de un proceso hijo, y dejarlo sin liberar te deja un dotnet huérfano corriendo. No preguntes cuántos procesos sueltos hicieron falta para que se me grabara. Descubre antes de llamar Ahora la parte que la gente se salta y luego depura durante una hora. Imprime la lista de herramientas antes de escribir un solo CallToolAsync : foreach (var tool in await client.ListToolsAsync()) { Console.WriteLine($"{tool.Name} - {tool.Description}"); } La razón no es ceremonia. El SDK deriva los nombres de las herramientas a partir de los nombres de los métodos de tu servidor, así que la cadena exacta que expone el protocolo puede no ser el identificador de C# que escribiste. El descubrimiento es el contrato; el código fuente en C# del lado servidor es un detalle de implementación. Imprime la lista, copia los nombres reales, y escribe tus llamadas contra esos. Adivinar el nombre te da un error en tiempo de ejecución sin ningún compilador que te salve. Llamando a una herramienta Con un nombre real en mano, invocarla es un nombre más un diccionario de argumentos: using ModelContextProtocol.Protocol; var result = await client.CallToolAsync( "get_order_status", new Dictionary { ["orderId"] = "A-1001" }, cancellationToken: CancellationToken.None); Console.WriteLine(result.Content.OfType ().First().Text); Pedido A-1001: shipped, ETA 2026-08-03. Content es una lista de bloques, no una cadena, porque una herramienta puede devolver texto, imágenes o varios bloques a la vez. Filtrar con OfType () es la forma honesta de leerlo - y si haces .First() sobre una herramienta que no devolvió texto, eso es una excepción, así que usa FirstOrDefault() cuando no controles el servidor. Fíjate en lo que no escribiste: ni JSON Schema, ni fontanería HTTP, ni encuadre de mensajes. Tampoco referenciaste el proyecto del servidor. Order y OrdersStore no existen en esta app - el único contrato es el cable. La recompensa: entrégale las herramientas a Claude Todo lo anterior es un cliente manejando herramientas a mano. La versión interesante es dejar que un modelo decida cuál llamar - que es exactamente para lo que escribimos un bucle a mano en el post de tool use. McpClientTool hereda de AIFunction en Microsoft.Extensions.AI. Esa única herencia es todo el truco: las herramientas descubiertas de un servidor MCP entran directo en cualquier IChatClient como funciones invocables, sin código adaptador. dotnet add package Anthropic dotnet add package Microsoft.Extensions.AI using Anthropic; using Microsoft.Extensions.AI; IChatClient chatClient = new AnthropicClient() // lee ANTHROPIC_API_KEY .AsIChatClient("claude-opus-4-8") .AsBuilder() .UseFunctionInvocation() .Build(); var tools = await client.ListToolsAsync(); var response = await chatClient.GetResponseAsync( "¿El pedido A-1001 está enviado, y todavía te quedan ETH-250 en stock?", new ChatOptions { Tools = [.. tools] }); Console.WriteLine(response.Text); El pedido A-1001 ya fue enviado y está en camino para llegar el 2026-08-03. Y sí - los granos de Etiopía (ETH-250) están en stock, con 42 unidades disponibles. Lee esa salida, y luego vuelve a mirar el bucle que escribimos a mano. La misma pregunta, las mismas dos llamadas a herramientas, la misma respuesta - solo que el while (true) , la contabilidad de la lista de mensajes, la reconstrucción de bloques y la trampa de "devuelve el turno del asistente tal cual" desaparecieron. UseFunctionInvocation() es el middleware que corre ese bucle por ti: Claude pide una herramienta, él invoca la AIFunction correspondiente, devuelve el resultado y repite hasta que haya una respuesta. Las herramientas en sí viven en un proceso completamente separado, escrito por alguien que nunca oyó hablar de esta app. Esa es la parte que vale la pena dejar reposar. Conectando con un servidor remoto stdio funciona cuando el servidor es un proceso hijo local. Para un servidor compartido - la versión HTTP con ASP.NET Core del final del post anterior - cambia el transporte y no toques nada más: var transport = new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri("https://tools.auroracoffee.example/mcp"), TransportMode = HttpTransportMode.StreamableHttp, }); await using var client = await McpClient.CreateAsync(transport); ListToolsAsync , CallToolAsync y el cableado del IChatClient son idénticos de aquí en adelante - el transporte es lo único que nota la diferencia. TransportMode usa AutoDetect por defecto, que intenta Streamable HTTP y cae a SSE heredado, así que puedes omitirlo si no sabes qué soporta el otro extremo. Ponlo explícito cuando sí lo sepas; la autodetección cuesta un viaje de ida y vuelta. Algo que cambió desde el post anterior Vale la pena señalarlo si estás construyendo sobre el post del servidor: el SDK de C# publicó la v2.0 el 28 de julio de 2026, implementando la revisión de especificación 2026-07-28 - el cambio de protocolo más grande desde que MCP salió. Los atributos del lado servidor ([McpServerToolType] , [McpServerTool] ) y las APIs de cliente de arriba siguen igual, pero dos cosas se movieron: - El transporte HTTP es sin estado (stateless) por defecto. HttpServerTransportOptions.Stateless ahora valetrue por defecto, el handshake deinitialize desapareció, y la cabeceraMcp-Session-Id se fue. Excelente para escalar un servidor compartido detrás de un balanceador; un cambio de comportamiento si asumías sesiones. - Las peticiones iniciadas por el servidor (sampling, roots) quedaron obsoletas bajo el diagnóstico MCP9005 y lanzan excepción en modo sin estado. Los flujos interactivos pasan a Multi Round-Trip Requests. Si tu servidor hace herramientas de petición/respuesta simples - que es la mayoría, incluido Aurora - no vas a notar nada. Si dependía de sesiones, lee las notas de migración antes de actualizar. Cuándo escribir un cliente - y cuándo no Escribe un cliente MCP cuando tu app necesita capacidades que viven fuera de ella: un servidor que mantiene tu equipo de plataforma, un servidor de terceros que no controlas, o tu propio servidor que varias apps comparten. La ganancia es que la lista de herramientas puede cambiar en el servidor sin un redespliegue de tu lado - aparece una herramienta nueva, ListToolsAsync la devuelve, y si hay un modelo al volante, empieza a usarla sin que tú publiques una sola línea de código. Sáltatelo cuando las herramientas son simplemente... tus propios métodos, en tu propia app, llamados por tu propio bucle de modelo. Pasar por un protocolo y una frontera de proceso para llamar a una función que podrías haber llamado directo es puro teatro arquitectónico. El enfoque de tool use es más simple y más rápido ahí, y siempre lo fue. Regla general: cliente MCP cuando las herramientas cruzan una frontera que no controlas; tool use dentro del proceso cuando no la cruzan. Puntos Clave - Un cliente son tres verbos - conectar por un transporte, descubrir qué hay, y llamar por nombre. Todo lo demás es problema del SDK. - El contrato es el descubrimiento, no el código C# de tu servidor - imprime ListToolsAsync() y usa los nombres

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.