El Secreto Detrás de una Página Atractiva
Más allá de lo visual: lo que hace que un sitio funcione, no solo que se vea bien.
Mariana Fernández
Tutorial con código real y errores resueltos: crea tu primer servidor MCP con TypeScript, pruébalo con Postman y despliégalo gratis en Railway con SSEServerTransport.

Jesús Blanco
Autor

En el artículo anterior explicamos qué es MCP y por qué se está convirtiendo en el estándar de conexión entre modelos de IA y sistemas reales. Ahora vamos al código.
En este tutorial vas a construir un servidor MCP funcional desde cero usando TypeScript. El ejemplo es un servidor de consulta de clima en tiempo real que cualquier persona puede probar con Claude Desktop, Cursor o incluso Postman — sin necesidad de API keys ni cuentas de pago. Al final también te mostramos cómo hostearlo en Railway para que sea accesible remotamente.
⚠️ Nota de producción: este artículo incluye los ajustes reales que tuvimos que hacer para que el servidor funcionara con clientes MCP estándar. Si en otros tutoriales ves código diferente y no funciona, probablemente están usando un transporte incorrecto. Aquí va la versión que sí corre.
Lo que necesitas antes de empezar:
Un servidor MCP con 3 tools:
| Tool | Qué hace |
|---|---|
get_current_weather | Consulta temperatura, viento y lluvia en cualquier ciudad |
get_weekly_forecast | Devuelve el pronóstico de 7 días |
compare_cities_weather | Compara el clima entre dos ciudades |
Usamos la API pública de Open-Meteo — sin API key, completamente gratis — y su API de geocodificación para convertir nombres de ciudades a coordenadas.
Este es exactamente el tipo de servidor que en fencode.dev construimos para clientes que necesitan que sus agentes tomen decisiones basadas en datos externos en tiempo real.
Crea una carpeta nueva e inicializa el proyecto:
mkdir mcp-weather-server
cd mcp-weather-server
npm init -y
Instala las dependencias:
npm install @modelcontextprotocol/sdk zod express
npm install -D typescript @types/node @types/express tsx
Crea el tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Y actualiza package.json:
{
"name": "mcp-weather-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"dev:stdio": "tsx src/index.ts",
"dev:http": "tsx src/server-http.ts",
"start": "node dist/server-http.js",
"start:stdio": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"express": "^4.18.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/express": "^4.17.0",
"@types/node": "^20.0.0",
"tsx": "^4.0.0",
"typescript": "^5.3.0"
}
}
mcp-weather-server/
├── src/
│ ├── index.ts # Servidor MCP con transporte stdio (local / Claude Desktop)
│ ├── server-http.ts # Servidor MCP con transporte SSE (remoto / Postman / Railway)
│ ├── tools.ts # Registro de tools compartidas (evita duplicar código)
│ ├── weather.ts # Lógica de consulta a la API del clima
│ └── types.ts # Tipos TypeScript
├── Dockerfile
├── package.json
└── tsconfig.json
Crea src/types.ts:
// src/types.ts
export interface GeoLocation {
name: string;
latitude: number;
longitude: number;
country: string;
timezone: string;
}
export interface CurrentWeather {
city: string;
country: string;
temperature: number;
feelsLike: number;
humidity: number;
windSpeed: number;
weatherDescription: string;
isDay: boolean;
}
export interface DailyForecast {
date: string;
maxTemp: number;
minTemp: number;
precipitationProbability: number;
weatherDescription: string;
}
export interface WeeklyForecast {
city: string;
country: string;
forecast: DailyForecast[];
}
Crea src/weather.ts:
// src/weather.ts
import type { GeoLocation, CurrentWeather, WeeklyForecast, DailyForecast } from "./types.js";
const WMO_CODES: Record = {
0: "Despejado",
1: "Mayormente despejado",
2: "Parcialmente nublado",
3: "Nublado",
45: "Niebla",
48: "Niebla con escarcha",
51: "Llovizna ligera",
53: "Llovizna moderada",
55: "Llovizna intensa",
61: "Lluvia ligera",
63: "Lluvia moderada",
65: "Lluvia fuerte",
71: "Nevada ligera",
73: "Nevada moderada",
75: "Nevada fuerte",
80: "Chubascos ligeros",
81: "Chubascos moderados",
82: "Chubascos fuertes",
95: "Tormenta eléctrica",
99: "Tormenta con granizo",
};
export async function geocodeCity(cityName: string): Promise {
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cityName)}&count=1&language=es&format=json`;
const response = await fetch(url);
if (!response.ok) throw new Error(`Error al buscar la ciudad: ${response.statusText}`);
const data = await response.json() as { results?: GeoLocation[] };
if (!data.results || data.results.length === 0) {
throw new Error(`No se encontró la ciudad: "${cityName}". Intenta con un nombre más específico.`);
}
return data.results[0];
}
export async function getCurrentWeather(cityName: string): Promise {
const location = await geocodeCity(cityName);
const url = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}¤t=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code,is_day&timezone=auto`;
const response = await fetch(url);
if (!response.ok) throw new Error(`Error al consultar el clima: ${response.statusText}`);
const data = await response.json() as {
current: {
temperature_2m: number;
apparent_temperature: number;
relative_humidity_2m: number;
wind_speed_10m: number;
weather_code: number;
is_day: number;
};
};
const c = data.current;
return {
city: location.name,
country: location.country,
temperature: Math.round(c.temperature_2m),
feelsLike: Math.round(c.apparent_temperature),
humidity: c.relative_humidity_2m,
windSpeed: Math.round(c.wind_speed_10m),
weatherDescription: WMO_CODES[c.weather_code] ?? "Condición desconocida",
isDay: c.is_day === 1,
};
}
export async function getWeeklyForecast(cityName: string): Promise {
const location = await geocodeCity(cityName);
const url = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max,weather_code&timezone=auto&forecast_days=7`;
const response = await fetch(url);
if (!response.ok) throw new Error(`Error al consultar el pronóstico: ${response.statusText}`);
const data = await response.json() as {
daily: {
time: string[];
temperature_2m_max: number[];
temperature_2m_min: number[];
precipitation_probability_max: number[];
weather_code: number[];
};
};
const daily = data.daily;
const forecast: DailyForecast[] = daily.time.map((date, i) => ({
date,
maxTemp: Math.round(daily.temperature_2m_max[i]),
minTemp: Math.round(daily.temperature_2m_min[i]),
precipitationProbability: daily.precipitation_probability_max[i],
weatherDescription: WMO_CODES[daily.weather_code[i]] ?? "Condición desconocida",
}));
return { city: location.name, country: location.country, forecast };
}
Para no duplicar código entre index.ts y server-http.ts, crea src/tools.ts:
// src/tools.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { getCurrentWeather, getWeeklyForecast } from "./weather.js";
export function registerTools(server: McpServer) {
// ─── TOOL 1: Clima actual ───────────────────────────────────
server.tool(
"get_current_weather",
"Obtiene el clima actual de cualquier ciudad del mundo: temperatura, sensación térmica, humedad, viento y condición climática.",
{ city: z.string().describe("Nombre de la ciudad (ej: 'Monterrey', 'Ciudad de México', 'Buenos Aires')") },
async ({ city }) => {
try {
const w = await getCurrentWeather(city);
const icon = w.isDay ? "☀️" : "🌙";
const text = `
${icon} Clima actual en ${w.city}, ${w.country}
🌡️ Temperatura: ${w.temperature}°C (sensación: ${w.feelsLike}°C)
🌤️ Condición: ${w.weatherDescription}
💧 Humedad: ${w.humidity}%
💨 Viento: ${w.windSpeed} km/h
`.trim();
return { content: [{ type: "text", text }] };
} catch (error) {
return {
content: [{ type: "text", text: `❌ Error: ${error instanceof Error ? error.message : "Error desconocido"}` }],
isError: true,
};
}
}
);
// ─── TOOL 2: Pronóstico semanal ─────────────────────────────
server.tool(
"get_weekly_forecast",
"Obtiene el pronóstico del clima para los próximos 7 días de cualquier ciudad.",
{ city: z.string().describe("Nombre de la ciudad") },
async ({ city }) => {
try {
const { city: cityName, country, forecast } = await getWeeklyForecast(city);
const rows = forecast.map((day) => {
const date = new Date(day.date + "T12:00:00").toLocaleDateString("es-MX", {
weekday: "short", month: "short", day: "numeric",
});
return ` ${date.padEnd(15)} ${String(day.maxTemp).padStart(3)}°C / ${String(day.minTemp).padStart(3)}°C 💧${day.precipitationProbability}% ${day.weatherDescription}`;
}).join("\n");
const text = `
📅 Pronóstico 7 días — ${cityName}, ${country}
Día Máx Mín Lluvia Condición
─────────────────────────────────────────────────
${rows}
`.trim();
return { content: [{ type: "text", text }] };
} catch (error) {
return {
content: [{ type: "text", text: `❌ Error: ${error instanceof Error ? error.message : "Error desconocido"}` }],
isError: true,
};
}
}
);
// ─── TOOL 3: Comparar ciudades ──────────────────────────────
server.tool(
"compare_cities_weather",
"Compara el clima actual entre dos ciudades.",
{
city1: z.string().describe("Primera ciudad a comparar"),
city2: z.string().describe("Segunda ciudad a comparar"),
},
async ({ city1, city2 }) => {
try {
const [a, b] = await Promise.all([getCurrentWeather(city1), getCurrentWeather(city2)]);
const diff = a.temperature - b.temperature;
const warmer = diff > 0 ? a.city : b.city;
const text = `
🆚 Comparación de clima
📍 ${a.city}, ${a.country}
🌡️ ${a.temperature}°C (sensación ${a.feelsLike}°C)
🌤️ ${a.weatherDescription}
💧 Humedad: ${a.humidity}% | 💨 Viento: ${a.windSpeed} km/h
📍 ${b.city}, ${b.country}
🌡️ ${b.temperature}°C (sensación ${b.feelsLike}°C)
🌤️ ${b.weatherDescription}
💧 Humedad: ${b.humidity}% | 💨 Viento: ${b.windSpeed} km/h
📊 ${warmer} está ${Math.abs(diff)}°C más cálida.
`.trim();
return { content: [{ type: "text", text }] };
} catch (error) {
return {
content: [{ type: "text", text: `❌ Error: ${error instanceof Error ? error.message : "Error desconocido"}` }],
isError: true,
};
}
}
);
}
Este modo es ideal para Claude Desktop. Crea src/index.ts:
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerTools } from "./tools.js";
const server = new McpServer({
name: "fencode-weather-server",
version: "1.0.0",
});
registerTools(server);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("✅ Servidor MCP de clima iniciado con stdio");
}
main().catch((error) => {
console.error("❌ Error fatal:", error);
process.exit(1);
});
Compila y conecta a Claude Desktop:
npm run build
Abre el archivo de configuración de Claude Desktop:
~/Library/Application Support/Claude/claude_desktop_config.json%APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"fencode-weather": {
"command": "node",
"args": ["/ruta/absoluta/mcp-weather-server/dist/index.js"]
}
}
}
💡 Usa la ruta absoluta. En macOS ejecuta
pwddentro de la carpeta del proyecto para obtenerla.
Reinicia Claude Desktop y prueba preguntando: "¿Qué clima hace hoy en Monterrey?"
Aquí viene el punto más importante del artículo — y donde la mayoría de los tutoriales te van a fallar.
El estándar MCP sobre red requiere un "apretón de manos" inicial: el cliente (Claude, Postman, Cursor) hace primero un GET para abrir un flujo de eventos (Server-Sent Events). Solo después empieza a enviar mensajes por POST. Si tu servidor solo acepta POST, el cliente recibe un 404 en ese primer GET y nunca llega a conectarse.
Hay otro error sutil muy común: usar app.use(express.json()) junto con el SDK de MCP. Express lee el body de la petición para parsearlo, pero el SDK también necesita leer ese mismo stream para procesar JSON-RPC. Un stream solo se puede leer una vez, así que el SDK lanza stream is not readable. La solución es simple: no uses express.json() — el SDK lo maneja directamente.
Crea src/server-http.ts:
// src/server-http.ts
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { registerTools } from "./tools.js";
const app = express();
// ⚠️ NO uses app.use(express.json()) aquí.
// Express consumiría el stream del body antes de que el SDK pueda leerlo.
// El SDK de MCP se encarga de parsear el JSON-RPC directamente.
// ─── Singleton del servidor MCP ─────────────────────────────────
// Se instancia una sola vez a nivel global, no dentro de cada request.
// Evita problemas de memoria y de estado con múltiples conexiones.
const mcpServer = new McpServer({
name: "fencode-weather-server",
version: "1.0.0",
});
registerTools(mcpServer);
// Mapa de transportes activos por sesión
const transports = new Map();
// ─── GET /sse — El cliente abre el flujo de eventos ────────────
// Este es el "apretón de manos" inicial del protocolo MCP.
// Sin este endpoint, cualquier cliente MCP estándar falla con 404.
app.get("/sse", async (req, res) => {
const transport = new SSEServerTransport("/messages", res);
transports.set(transport.sessionId, transport);
res.on("close", () => {
transports.delete(transport.sessionId);
console.log(`🔌 Sesión cerrada: ${transport.sessionId}`);
});
await mcpServer.connect(transport);
console.log(`🔗 Nueva sesión SSE: ${transport.sessionId}`);
});
// ─── POST /messages — El cliente envía mensajes JSON-RPC ────────
app.post("/messages", async (req, res) => {
const sessionId = req.query.sessionId as string;
const transport = transports.get(sessionId);
if (!transport) {
res.status(404).json({ error: "Sesión no encontrada. Conecta primero al endpoint /sse." });
return;
}
await transport.handlePostMessage(req, res);
});
// ─── GET /health — Para Railway y monitoreo ─────────────────────
app.get("/health", (_req, res) => {
res.json({
status: "ok",
server: "fencode-weather-mcp",
version: "1.0.0",
activeSessions: transports.size,
});
});
const PORT = process.env.PORT ?? 3000;
app.listen(PORT, () => {
console.log(`✅ Servidor MCP SSE corriendo en puerto ${PORT}`);
console.log(`🔗 Endpoint SSE: http://localhost:${PORT}/sse`);
console.log(`📩 Endpoint Messages: http://localhost:${PORT}/messages`);
console.log(`❤️ Health check: http://localhost:${PORT}/health`);
});
Inicia en modo desarrollo:
npm run dev:http
Deberías ver en consola:
✅ Servidor MCP SSE corriendo en puerto 3000
🔗 Endpoint SSE: http://localhost:3000/sse
📩 Endpoint Messages: http://localhost:3000/messages
❤️ Health check: http://localhost:3000/health
Antes de desplegar en la nube, valida que todo funciona localmente. Postman soporta SSE de forma nativa y es la forma más rápida de verificar tu servidor sin necesitar Claude Desktop.
GET http://localhost:3000/healthRespuesta esperada:
{ "status": "ok", "server": "fencode-weather-mcp", "version": "1.0.0", "activeSessions": 0 }
Crea una nueva request en Postman:
GEThttp://localhost:3000/sseAccept: text/event-streamPostman se queda en estado "Receiving" — eso es correcto, el stream está abierto. En la respuesta verás:
event: endpoint
data: /messages?sessionId=abc123-def456-...
Copia ese sessionId.
Abre otra pestaña en Postman:
POSThttp://localhost:3000/messages?sessionId=abc123-def456-...Content-Type: application/json
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}
En el stream SSE verás aparecer la lista de tus 3 tools.
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_current_weather",
"arguments": { "city": "Monterrey" }
}
}
💡 Alternativa: MCP Inspector es la herramienta oficial de Anthropic para probar servidores MCP con una interfaz visual más amigable que Postman.
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
En 2-3 minutos tendrás una URL pública. Verifica:
curl https://tu-app.up.railway.app/health
{
"mcpServers": {
"fencode-weather-remoto": {
"type": "sse",
"url": "https://tu-app.up.railway.app/sse"
}
}
}
Reinicia Claude Desktop y el servidor remoto ya está disponible.
Esta sección resume exactamente qué falló en la primera versión y por qué lo cambiamos:
| # | Problema | Causa | Solución |
|---|---|---|---|
| 1 | Clientes recibían 404 al conectarse | Solo teníamos endpoint POST, pero MCP requiere un GET inicial para abrir el stream SSE | Cambiar de StreamableHTTP a SSEServerTransport con endpoints /sse + /messages |
| 2 | SDK lanzaba stream is not readable | express.json() consumía el body antes que el SDK | Eliminar app.use(express.json()) |
| 3 | Problemas de estado con múltiples mensajes | El McpServer se creaba dentro de cada request handler | Moverlo a nivel global como Singleton |
| 4 | Debugging confuso en Railway | Los endpoints no se imprimían al iniciar | Agregar logs explícitos al arrancar el servidor |
mcp-weather-server/
├── src/
│ ├── index.ts # Servidor MCP · stdio (Claude Desktop local)
│ ├── server-http.ts # Servidor MCP · SSE (remoto / Postman / Railway)
│ ├── tools.ts # Tools compartidas (Singleton)
│ ├── weather.ts # Lógica de consulta a Open-Meteo
│ └── types.ts # Tipos TypeScript
├── dist/ # Código compilado (generado por tsc)
├── Dockerfile
├── package.json
└── tsconfig.json
Con este servidor tienes en práctica todos los conceptos clave de MCP:
El patrón es idéntico sin importar qué quieras conectar: sustituye las llamadas a Open-Meteo por tu base de datos Supabase, tu ERP, tu CRM o cualquier API interna. La arquitectura no cambia.
En el próximo artículo conectamos este mismo patrón con Supabase para que un agente de IA pueda consultar y actualizar datos de tu aplicación en tiempo real.
¿Ya lo probaste? Cuéntanos en los comentarios qué ciudad consultaste primero 👇
Puedes clonar el repositorio completo de este tutorial en: https://github.com/Fencode-dev/mcp-weather-server