FENCODE
Desarrollo Web
Marketing Digital & IA

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

Jesús Blanco

Autor

23 min
Cómo crear tu primer servidor MCP con TypeScript desde cero (y hostearlo gratis)

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:

ToolQué hace
get_current_weatherConsulta temperatura, viento y lluvia en cualquier ciudad
get_weekly_forecastDevuelve el pronóstico de 7 días
compare_cities_weatherCompara el clima entre dos ciudades

Usamos la API pública de Open-Meteosin 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:

bash
      mkdir mcp-weather-server
cd mcp-weather-server
npm init -y
    

Instala las dependencias:

bash
      npm install @modelcontextprotocol/sdk zod express
npm install -D typescript @types/node @types/express tsx
    

Crea el tsconfig.json:

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:

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

text
      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:

typescript
      // 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:

typescript
      // 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}&current=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:

typescript
      // 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:

typescript
      // 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:

bash
      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
json
      {
  "mcpServers": {
    "fencode-weather": {
      "command": "node",
      "args": ["/ruta/absoluta/mcp-weather-server/dist/index.js"]
    }
  }
}
    

💡 Usa la ruta absoluta. En macOS ejecuta pwd dentro 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:

typescript
      // 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:

bash
      npm run dev:http
    

Deberías ver en consola:

text
      ✅ 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/health

Respuesta esperada:

json
      { "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:

text
      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:
json
      {
  "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

json
      {
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "get_current_weather",
    "arguments": { "city": "Monterrey" }
  }
}
    
Postman MCP tool test

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

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

  1. Sube el proyecto a GitHub
  2. Entra a railway.app y crea una cuenta gratuita
  3. "New Project" → "Deploy from GitHub repo"
  4. Selecciona tu repositorio
  5. Railway detecta el Dockerfile y despliega automáticamente

En 2-3 minutos tendrás una URL pública. Verifica:

bash
      curl https://tu-app.up.railway.app/health
    

9.3 Conecta el servidor remoto a Claude Desktop

json
      {
  "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:

#ProblemaCausaSolución
1Clientes recibían 404 al conectarseSolo teníamos endpoint POST, pero MCP requiere un GET inicial para abrir el stream SSECambiar de StreamableHTTP a SSEServerTransport con endpoints /sse + /messages
2SDK lanzaba stream is not readableexpress.json() consumía el body antes que el SDKEliminar app.use(express.json())
3Problemas de estado con múltiples mensajesEl McpServer se creaba dentro de cada request handlerMoverlo a nivel global como Singleton
4Debugging confuso en RailwayLos endpoints no se imprimían al iniciarAgregar logs explícitos al arrancar el servidor

Estructura final del proyecto

text
      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


Artículos relacionados

Tags:

#ChatGPT
#Claude
#MCP
#Gemini
#Railway

Artículos relacionados