Cómo crear tu primer servidor MCP con TypeScript desde cero (y hostearlo gratis)
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:
- Node.js 18 o superior
- Conocimiento básico de TypeScript / JavaScript
- Claude Desktop o Postman instalado (para probar el servidor localmente)
¿Qué vamos a construir?
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.
Paso 1: Inicializa el proyecto
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"
}
}
Paso 2: Estructura del proyecto
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
Paso 3: Define los tipos
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[];
}
Paso 4: Crea el cliente de la API del clima
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 };
}
Paso 5: Extrae las tools a un archivo compartido
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,
};
}
}
);
}
Paso 6: Servidor local con transporte stdio
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:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%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?"
Paso 7: Servidor remoto con transporte SSE
Aquí viene el punto más importante del artículo — y donde la mayoría de los tutoriales te van a fallar.
¿Por qué SSE y no un POST simple?
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
Paso 8: Prueba tu servidor con Postman
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.
8.1 Verifica el health check
GET http://localhost:3000/healthRespuesta esperada:
{ "status": "ok", "server": "fencode-weather-mcp", "version": "1.0.0", "activeSessions": 0 }
8.2 Abre la conexión SSE
Crea una nueva request en Postman:
- Método:
GET - URL:
http://localhost:3000/sse - Header:
Accept: text/event-stream - Haz clic en Send
Postman 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.
8.3 Lista las tools disponibles
Abre otra pestaña en Postman:
- Método:
POST - URL:
http://localhost:3000/messages?sessionId=abc123-def456-... - Header:
Content-Type: application/json - Body:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}
En el stream SSE verás aparecer la lista de tus 3 tools.
8.4 Llama una tool
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_current_weather",
"arguments": { "city": "Monterrey" }
}
}
Si recibes datos del clima en la respuesta SSE, tu servidor está 100% funcional y listo para producción.
💡 Alternativa: MCP Inspector es la herramienta oficial de Anthropic para probar servidores MCP con una interfaz visual más amigable que Postman.
Paso 9: Despliega en Railway
9.1 Crea el Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
9.2 Despliega desde GitHub
- Sube el proyecto a GitHub
- Entra a railway.app y crea una cuenta gratuita
- "New Project" → "Deploy from GitHub repo"
- Selecciona tu repositorio
- Railway detecta el Dockerfile y despliega automáticamente
En 2-3 minutos tendrás una URL pública. Verifica:
curl https://tu-app.up.railway.app/health
9.3 Conecta el servidor remoto a Claude Desktop
{
"mcpServers": {
"fencode-weather-remoto": {
"type": "sse",
"url": "https://tu-app.up.railway.app/sse"
}
}
}
Reinicia Claude Desktop y el servidor remoto ya está disponible.
Los 4 errores que tuvimos que corregir (y tú no tendrás que repetir)
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 |
Estructura final del proyecto
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
¿Qué aprendiste y qué sigue?
Con este servidor tienes en práctica todos los conceptos clave de MCP:
- Registrar tools con Zod para validación de inputs tipada
- Manejar errores de forma que el modelo los entienda
- Usar transporte stdio para Claude Desktop en local
- Usar transporte SSE con los endpoints correctos para producción
- Probar tu servidor con Postman o MCP Inspector sin necesitar un cliente MCP
- Desplegar en Railway con Docker en minutos
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 👇
Código completo en GitHub
Puedes clonar el repositorio completo de este tutorial en: https://github.com/Fencode-dev/mcp-weather-server


