Transporte STDIO, guía estructurada de errores, caché en memoria para llamadas AI, y carga paralela de esquemas — cuatro mejoras concretas que separan un demo de un sistema en el que puedes confiar en producción.
Los servidores MCP (Model Context Protocol) son engañosamente simples de arrancar, pero sorprendentemente difíciles de endurecer para producción. Después de publicar la versión inicial del MCP Server de PeopleworksGPT, realizamos un sprint enfocado para atacar cuatro puntos de dolor que cualquier despliegue serio eventualmente enfrenta: fricción en el transporte local, mensajes de error opacos, llamadas AI redundantes y cuellos de botella de I/O secuencial. Este artículo recorre cada solución — el problema, la solución y el código real.
Opción D · Capa de Transporte · Cero Configuración
El MCP Server de PeopleworksGPT nació como un servidor HTTP — diseñado para desplegarse en IIS o una VM en la nube para que ChatGPT, Microsoft Copilot Studio y Google Gemini puedan acceder a él por HTTPS. Esa es la arquitectura correcta para despliegues multi-tenant. Pero los desarrolladores que quieren usar Claude Desktop localmente no necesitan un servidor web corriendo, un puerto configurado ni un flujo JWT. Quieren hacer doble clic en la aplicación y empezar a chatear.
El transporte STDIO del protocolo MCP resuelve exactamente esto. Claude Desktop lanza el proceso del servidor directamente, se comunica por stdin/stdout usando JSON-RPC, y todo permanece en la máquina local. Sin red, sin ceremonia de autenticación, sin reglas de firewall.
Aquí está el problema que la mayoría de los tutoriales omiten: si tu servidor usa Serilog con un sink de Consola, esas líneas de log se escriben en stdout — el mismo canal que el SDK de MCP usa para sus mensajes JSON-RPC. El resultado es un stream de transporte corrupto y Claude Desktop mostrando "server disconnected".
La corrección requiere detectar el modo STDIO antes de configurar Serilog — lo que significa antes de WebApplication.CreateBuilder(args), ya que appsettings.json aún no está cargado. En ese punto solo args y las variables de entorno están disponibles.
// ── Paso 1: Detectar ANTES de Serilog — nunca dejar que logs lleguen a stdout en modo STDIO ──
var isStdio = args.Contains("--stdio", StringComparer.OrdinalIgnoreCase) ||
(Environment.GetEnvironmentVariable("MCP_TRANSPORT") ?? string.Empty)
.Equals("stdio", StringComparison.OrdinalIgnoreCase);
// ── Paso 2: Sink de Consola condicional ──
var logConfig = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.FromLogContext();
if (!isStdio)
{
// Modo HTTP: logs a consola Y archivo
logConfig = logConfig.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}");
}
Log.Logger = logConfig
.WriteTo.File("logs/mcp-server-.log", rollingInterval: RollingInterval.Day)
.CreateLogger();
// ── Paso 3: Suprimir Kestrel en modo STDIO (no se necesita puerto TCP) ──
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog();
if (isStdio)
{
// UseSetting en lugar de UseUrls — la API correcta en .NET 9 ConfigureWebHostBuilder
builder.WebHost.UseSetting("urls", string.Empty);
}
// ── Paso 4: Elegir el transporte MCP correcto ──
var mcpBuilder = builder.Services.AddMcpServer(options =>
{
options.ServerInfo = new Implementation { Name = "PeopleWorksGPT", Version = "1.0.0" };
});
if (isStdio)
mcpBuilder.WithStdioServerTransport(); // Claude Desktop / herramientas AI locales
else
mcpBuilder.WithHttpTransport(o => o.Stateless = true); // ChatGPT / Copilot / Gemini
mcpBuilder.WithToolsFromAssembly()
.WithPromptsFromAssembly()
.WithResourcesFromAssembly();
Después de compilar en modo Release, registra el servidor en %APPDATA%\Claude\claude_desktop_config.json:
{
"mcpServers": {
"peopleworks-gpt": {
"command": "C:\\publish\\PeopleworksGPT.MCP.Server.exe",
"args": ["--stdio"],
"env": {}
}
}
}
Alternativamente, usa la variable de entorno MCP_TRANSPORT=stdio en lugar del flag --stdio. Ambas opciones se verifican al arrancar — el flag tiene prioridad.
| Característica | Modo HTTP | Modo STDIO |
|---|---|---|
| Claude Desktop | ✗ No soportado | ✔ Proceso directo |
| ChatGPT / Copilot / Gemini | ✔ Requiere HTTPS | ✗ Solo local |
| Listener TCP Kestrel | ✔ Activo | ✗ Suprimido |
| Logs en consola | ✔ Habilitado | ✗ Solo a archivo |
| Flujo JWT | ✔ Requerido | ✗ Omitido |
| Multi-cliente | ✔ Muchos a la vez | ✗ Un proceso |
| Rate limiting / CORS | ✔ Activos | ✗ No aplica |
Opción A · UX para Agentes · 20+ métodos enriquecidos
Cuando un agente de IA llama a una herramienta MCP y recibe un error, tiene dos opciones: reportar el fallo al usuario, o intentar corregirlo y reintentar. La diferencia entre esos dos resultados muchas veces depende de cuánta información contiene el mensaje de error.
Un simple string "Invalid session token" deja al agente sin guía. Pero si el mismo error incluye error_type: "authentication_failed" y next_steps: ["Llama a authenticate() con tu usuario y API key"], el agente puede inmediatamente llamar a authenticate() y reintentar — todo sin exponer el fallo al usuario.
Estandarizamos todas las respuestas de las herramientas alrededor de un vocabulario consistente de siete tipos de error, cubriendo toda la superficie de modos de fallo posibles en un servidor MCP real:
El valor real está en next_steps — un array JSON de strings accionables que varía según el contexto. Para execute_query, la guía cambia dependiendo del resultado:
// next_steps consciente del contexto — el agente sabe exactamente qué hacer a continuación
NextSteps = rowsReturned == 0
? new[]
{
"Sin filas devueltas — intenta reformular la pregunta con filtros diferentes",
"Llama a get_schema() para verificar nombres de tablas y columnas",
"Llama a explain_query() para entender por qué la consulta no devolvió resultados"
}
: totalCount > offset + maxRows
? new[]
{
$"Hay más filas disponibles — llama a execute_query() con page={page + 1} para continuar",
"Llama a analyze_query_results() para un resumen AI de estas filas",
"Llama a execute_query_with_export() para descargar todas las páginas como CSV/Excel"
}
: new[]
{
"Llama a analyze_query_results() para obtener insights AI sobre estos datos",
"Haz una pregunta de seguimiento para profundizar en una fila o patrón específico",
"Llama a execute_query_with_export() para exportar los resultados"
}
Cada clase de respuesta tipada recibe dos nuevas propiedades opcionales — nullables para que las respuestas de éxito existentes no estén obligadas a poblarlas:
public sealed class QueryExecutionResult
{
[JsonPropertyName("success")] public bool Success { get; set; }
[JsonPropertyName("rows")] public List<Dictionary<string, object?>> Rows { get; set; } = new();
[JsonPropertyName("total_count")] public int TotalCount { get; set; }
// ... otros campos ...
// ── Nuevos en este sprint ──
[JsonPropertyName("error_type")] public string? ErrorType { get; set; }
[JsonPropertyName("next_steps")] public string[]? NextSteps { get; set; }
}
Opción B · Rendimiento · Reducción de Costos
Dos de las herramientas MCP que más consumen AI en PeopleworksGPT son get_suggested_questions y explain_query. Ambas llaman a un LLM sincrónicamente como parte de la respuesta — lo que significa que cada llamada cuesta tokens, agrega 1-3 segundos de latencia, y compite por los límites de la API.
El patrón de uso revela una optimización obvia: ambas herramientas producen resultados deterministas para los mismos inputs. Si el esquema no cambió, las preguntas sugeridas para la conexión #5 en español serán las mismas esta llamada que hace cinco minutos. El caché es dinero gratis.
La clave de caché codifica conexión, idioma y cantidad. La verificación del caché ocurre después de validar la conexión (aún necesitamos verificar que el usuario tiene acceso) pero antes de la costosa secuencia de carga de esquema + llamada AI:
// Inyectar IMemoryCache — ya registrado vía AddMemoryCache() en Program.cs
public SuggestedQuestionsTool(
ApplicationDbContext context,
// ... otras dependencias ...
IMemoryCache cache)
{
_cache = cache;
}
// Dentro de GetSuggestedQuestionsAsync():
// Conexión validada ↑ — ahora verificar caché antes de cargar el esquema
var cacheKey = $"pwgpt:suggest:{connectionId}:{language}:{count}";
if (_cache.TryGetValue(cacheKey, out List<string>? cached) && cached != null)
{
return new SuggestedQuestionsResult
{
Success = true,
ConnectionId = connectionId,
ConnectionName = connection.DbName,
Suggestions = cached,
Count = cached.Count,
NextSteps = new[] { "Elige una sugerencia y llama a execute_query() con ella" }
};
}
// Cache miss — cargar esquema + llamar AI
var suggestions = await CallAiForSuggestionsAsync(prompt, count);
_cache.Set(cacheKey, suggestions, TimeSpan.FromMinutes(30));
Para explain_query, los inputs incluyen la pregunta original, el SQL generado y el tipo de explicación — un espacio de entrada más amplio. Hasheamos la clave raw en lugar de incrustarla directamente para mantener las longitudes de clave predecibles:
// Hashear los inputs para formar una clave de caché estable y compacta
var rawKey = $"{connectionId}:{explanationType}:{language}:{originalQuestion}:{generatedSql}";
var cacheKey = $"pwgpt:explain:{Math.Abs(rawKey.GetHashCode())}";
if (_cache.TryGetValue(cacheKey, out string? cachedExplanation) && cachedExplanation != null)
{
return JsonSerializer.Serialize(new
{
success = true,
explanation = cachedExplanation,
next_steps = explanationType == "error"
? new[] { "Reformula la pregunta basándote en la explicación y reintenta execute_query()" }
: new[] { "Continúa explorando los datos con preguntas de seguimiento" }
});
}
var explanation = await CallAiForExplanationAsync(systemPrompt, userPrompt);
_cache.Set(cacheKey, explanation, TimeSpan.FromHours(1));
Para sugerencias y explicaciones, los datos son de ámbito de usuario (el acceso a la conexión se valida por request) y la pérdida del caché al reiniciar el proceso es aceptable — la AI simplemente los regenera. IMemoryCache no tiene dependencias de infraestructura y ya está registrado en el contenedor DI vía builder.Services.AddMemoryCache(). Redis tiene sentido cuando necesitas compartir caché entre instancias o persistencia; aquí gana la simplicidad.
Opción C · Optimización Async · Rendimiento Interno
Cuando un usuario hace una pregunta en lenguaje natural, el servidor MCP necesita recopilar varias piezas de contexto antes de poder llamar a la AI: el esquema de la base de datos, los hints MCP (descripciones de tablas, reglas de negocio) y la configuración del servidor. La implementación ingenua hace esto secuencialmente — tres round trips a la base de datos antes de que la llamada AI siquiera comience.
Las obtenciones del esquema y los hints son independientes entre sí — pueden ejecutarse concurrentemente. Iniciamos la tarea de hints inmediatamente después de iniciar la carga del esquema, luego hacemos await a ambas antes de continuar:
// Antes — secuencial:
var schema = await GetSchemaAsync(connection);
var hints = await GetHintsAsync(connectionId);
var result = await CallAiAsync(schema, hints, question);
// Después — paralelo:
var schemaTask = GetSchemaAsync(connection); // ← inicia de inmediato
var hintsTask = GetHintsAsync(connectionId); // ← inicia de inmediato, sin await
await Task.WhenAll(schemaTask, hintsTask); // ← esperar a los dos
var schema = await schemaTask;
var hints = await hintsTask;
var result = await CallAiAsync(schema, hints, question);
Cuatro mejoras, un sprint enfocado — el servidor que sale al otro lado maneja más escenarios de despliegue, guía a los agentes durante los fallos automáticamente, gasta menos dinero en APIs de AI, y responde más rápido bajo carga.
Resultado del build después de los cuatro cambios: 0 errores · 0 warnings — el paquete NuGet ModelContextProtocol 0.9.0-preview.1 soporta limpiamente tanto WithHttpTransport() como WithStdioServerTransport() como métodos de cadena separados en IMcpServerBuilder.
Estas cuatro mejoras atacan las brechas de producción más inmediatas. En el roadmap: caché distribuido (Redis para despliegues multi-instancia), respuestas en streaming para resultados de consultas grandes, y una señal retry_after en respuestas con rate limiting para que los agentes puedan retroceder elegantemente.
Si estás construyendo tu propio servidor MCP en .NET, los cuatro patrones de este artículo — detección de transporte antes de Serilog, taxonomía de errores estructurada, IMemoryCache para llamadas AI, y I/O paralelo — valen la pena trasladarlos directamente a cualquier codebase de producción. Los problemas que resuelven son universales.