Skip to content

Instantly share code, notes, and snippets.

@delineas
Created February 22, 2026 16:59
Show Gist options
  • Select an option

  • Save delineas/a5dc2819f755ac47ad1111619bb33f28 to your computer and use it in GitHub Desktop.

Select an option

Save delineas/a5dc2819f755ac47ad1111619bb33f28 to your computer and use it in GitHub Desktop.
Weather MCP App
import { MCPServer, text, widget, object, error } from "mcp-use/server";
import { z } from "zod";
const server = new MCPServer({
name: "weather-mcp",
title: "Weather Forecast",
version: "1.0.0",
baseUrl: process.env.MCP_URL || "http://localhost:3000"
});
// ── Cache simple con TTL ────────────────────────────────
const cache = new Map<string, { data: unknown; expires: number }>();
const CACHE_TTL = 10 * 60 * 1000; // 10 minutos
function getCached<T>(key: string): T | null {
const entry = cache.get(key);
if (entry && entry.expires > Date.now()) return entry.data as T;
return null;
}
function setCache(key: string, data: unknown): void {
cache.set(key, { data, expires: Date.now() + CACHE_TTL });
}
// ── Geocoding: nombre de ciudad → coordenadas ──────────
interface GeoResult {
name: string;
latitude: number;
longitude: number;
timezone: string;
country: string;
}
async function geocodeCity(city: string): Promise<GeoResult | null> {
const cacheKey = `geo:${city.toLowerCase()}`;
const cached = getCached<GeoResult>(cacheKey);
if (cached) return cached;
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=es`;
const res = await fetch(url);
if (!res.ok) return null;
const json = await res.json();
if (!json.results || json.results.length === 0) return null;
const result: GeoResult = {
name: json.results[0].name,
latitude: json.results[0].latitude,
longitude: json.results[0].longitude,
timezone: json.results[0].timezone,
country: json.results[0].country
};
setCache(cacheKey, result);
return result;
}
// ── Fetch del pronóstico de Open-Meteo ──────────────────
interface ForecastData {
currentTemp: number;
currentWeatherCode: number;
hourly: Array<{ time: string; temp: number; weatherCode: number }>;
daily: Array<{
date: string;
tempMax: number;
tempMin: number;
weatherCode: number;
precipitationSum: number;
}>;
}
async function fetchForecast(lat: number, lon: number, timezone: string): Promise<ForecastData> {
const cacheKey = `forecast:${lat},${lon}`;
const cached = getCached<ForecastData>(cacheKey);
if (cached) return cached;
const params = new URLSearchParams({
latitude: lat.toString(),
longitude: lon.toString(),
hourly: "temperature_2m,weather_code",
daily: "temperature_2m_max,temperature_2m_min,weather_code,precipitation_sum",
timezone: timezone,
forecast_days: "7",
current: "temperature_2m,weather_code"
});
const res = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`);
if (!res.ok) throw new Error(`Open-Meteo API error: ${res.status}`);
const json = await res.json();
// Tomar las próximas 12 horas desde ahora
const now = new Date();
const currentHour = now.getHours();
const todayStr = now.toISOString().split("T")[0];
const hourlyFiltered: ForecastData["hourly"] = [];
for (let i = 0; i < json.hourly.time.length && hourlyFiltered.length < 12; i++) {
const timeStr = json.hourly.time[i];
if (timeStr >= `${todayStr}T${String(currentHour).padStart(2, "0")}:00`) {
hourlyFiltered.push({
time: new Date(timeStr).toLocaleTimeString("es-ES", {
hour: "2-digit",
minute: "2-digit",
hour12: false
}),
temp: Math.round(json.hourly.temperature_2m[i]),
weatherCode: json.hourly.weather_code[i]
});
}
}
const dayNames = ["Dom", "Lun", "Mar", "Mié", "Jue", "Vie", "Sáb"];
const dailyData: ForecastData["daily"] = json.daily.time.map(
(dateStr: string, i: number) => ({
date: dayNames[new Date(dateStr).getDay()],
tempMax: Math.round(json.daily.temperature_2m_max[i]),
tempMin: Math.round(json.daily.temperature_2m_min[i]),
weatherCode: json.daily.weather_code[i],
precipitationSum: Math.round(json.daily.precipitation_sum[i] * 10) / 10
})
);
const data: ForecastData = {
currentTemp: Math.round(json.current.temperature_2m),
currentWeatherCode: json.current.weather_code,
hourly: hourlyFiltered,
daily: dailyData
};
setCache(cacheKey, data);
return data;
}
// ── Tool: Pronóstico del tiempo con Widget ──────────────
server.tool(
{
name: "get-forecast",
description: "Get weather forecast for any city in the world, including hourly and 7-day daily data",
schema: z.object({
city: z.string().describe("City name in any language (e.g., 'Madrid', 'New York', 'Tokio')")
}),
annotations: {
readOnlyHint: true,
openWorldHint: true
},
widget: {
name: "weather-forecast",
invoking: "Fetching weather forecast...",
invoked: "Forecast loaded"
}
},
async ({ city }) => {
try {
const geo = await geocodeCity(city);
if (!geo) {
return error(
`Could not find city "${city}". Try with the English name or check spelling.`
);
}
const forecast = await fetchForecast(geo.latitude, geo.longitude, geo.timezone);
return widget({
props: {
city: `${geo.name}, ${geo.country}`,
latitude: geo.latitude,
longitude: geo.longitude,
timezone: geo.timezone,
currentTemp: forecast.currentTemp,
currentWeatherCode: forecast.currentWeatherCode,
hourly: forecast.hourly,
daily: forecast.daily,
generatedAt: new Date().toISOString()
},
output: text(
`Forecast for ${geo.name}, ${geo.country}: Currently ${forecast.currentTemp}°C. ` +
`Today's range: ${forecast.daily[0].tempMin}°C – ${forecast.daily[0].tempMax}°C. ` +
`7-day outlook available with hourly details.`
)
});
} catch (err) {
console.error("Forecast error:", err);
return error(
`Failed to fetch forecast: ${err instanceof Error ? err.message : "Unknown error"}`
);
}
}
);
// ── Resource: info del servidor ─────────────────────────
server.resource(
{
name: "server_info",
uri: "weather://info",
title: "Server Info",
description: "Information about this weather forecast server"
},
async () => object({
name: "Weather Forecast MCP",
dataSource: "Open-Meteo (https://open-meteo.com)",
features: [
"Real-time weather forecast for any city worldwide",
"Hourly forecast (next 12 hours)",
"7-day daily forecast",
"Automatic geocoding (city name → coordinates)",
"10-minute response cache",
"WMO weather code interpretation with icons"
],
apiKeyRequired: false,
cacheTTL: "10 minutes"
})
);
server.listen();
{
"name": "weather-mcp",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "mcp-use dev",
"build": "mcp-use build",
"start": "mcp-use start",
"deploy": "mcp-use deploy"
},
"dependencies": {
"mcp-use": "latest",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"outDir": "dist"
},
"include": ["*.ts", "resources/**/*.tsx"]
}
import { useState } from "react";
import {
McpUseProvider,
useWidget,
useWidgetTheme,
type WidgetMetadata
} from "mcp-use/react";
import { z } from "zod";
// ── Schema de props (separado para inferencia de tipos) ─
const propsSchema = z.object({
city: z.string(),
latitude: z.number(),
longitude: z.number(),
timezone: z.string(),
currentTemp: z.number(),
currentWeatherCode: z.number(),
hourly: z.array(z.object({
time: z.string(),
temp: z.number(),
weatherCode: z.number()
})),
daily: z.array(z.object({
date: z.string(),
tempMax: z.number(),
tempMin: z.number(),
weatherCode: z.number(),
precipitationSum: z.number()
})),
generatedAt: z.string()
});
// ── Metadata del widget ─────────────────────────────────
export const widgetMetadata: WidgetMetadata = {
description: "Interactive weather forecast display with hourly and daily views",
props: propsSchema,
exposeAsTool: false
};
// ── Tipos ────────────────────────────────────────────────
type Props = z.infer<typeof propsSchema>;
// ── Mapeo de Weather Codes de Open-Meteo a iconos/texto ─
function getWeatherInfo(code: number): { icon: string; label: string } {
const map: Record<number, { icon: string; label: string }> = {
0: { icon: "☀️", label: "Despejado" },
1: { icon: "🌤️", label: "Mayormente despejado" },
2: { icon: "⛅", label: "Parcialmente nublado" },
3: { icon: "☁️", label: "Nublado" },
45: { icon: "🌫️", label: "Niebla" },
48: { icon: "🌫️", label: "Niebla helada" },
51: { icon: "🌦️", label: "Llovizna ligera" },
53: { icon: "🌦️", label: "Llovizna" },
55: { icon: "🌦️", label: "Llovizna intensa" },
61: { icon: "🌧️", label: "Lluvia ligera" },
63: { icon: "🌧️", label: "Lluvia moderada" },
65: { icon: "🌧️", label: "Lluvia fuerte" },
71: { icon: "🌨️", label: "Nevada ligera" },
73: { icon: "🌨️", label: "Nevada moderada" },
75: { icon: "🌨️", label: "Nevada fuerte" },
80: { icon: "🌦️", label: "Chubascos ligeros" },
81: { icon: "🌦️", label: "Chubascos" },
82: { icon: "⛈️", label: "Chubascos fuertes" },
95: { icon: "⛈️", label: "Tormenta" },
96: { icon: "⛈️", label: "Tormenta con granizo ligero" },
99: { icon: "⛈️", label: "Tormenta con granizo" },
};
return map[code] || { icon: "🌡️", label: `Código ${code}` };
}
// ── Hook de colores adaptable al tema ───────────────────
function useColors() {
const theme = useWidgetTheme();
return {
bg: theme === "dark" ? "#1a1a2e" : "#f0f4ff",
card: theme === "dark" ? "#16213e" : "#ffffff",
text: theme === "dark" ? "#e0e0e0" : "#1a1a2e",
secondary: theme === "dark" ? "#a0a0b0" : "#666680",
border: theme === "dark" ? "#2a2a4a" : "#d0d5e0",
primary: theme === "dark" ? "#4a9eff" : "#0055cc",
accent: theme === "dark" ? "#ffd700" : "#ff8c00",
barBg: theme === "dark" ? "#2a2a4a" : "#e0e5f0",
tempHigh: theme === "dark" ? "#ff6b6b" : "#dc3545",
tempLow: theme === "dark" ? "#4ecdc4" : "#0097a7",
};
}
// ── Componente principal ────────────────────────────────
export default function WeatherForecast() {
const { props, isPending } = useWidget<Props>();
const colors = useColors();
const [activeTab, setActiveTab] = useState<"hourly" | "daily">("hourly");
const [unit, setUnit] = useState<"C" | "F">("C");
// Conversión de unidades (sin roundtrip al servidor)
const toUnit = (tempC: number) => {
if (unit === "F") return Math.round(tempC * 9 / 5 + 32);
return tempC;
};
const unitLabel = unit === "C" ? "°C" : "°F";
// SIEMPRE comprobar isPending antes de acceder a props
if (isPending) {
return (
<McpUseProvider autoSize>
<div style={{
padding: 40,
textAlign: "center",
backgroundColor: colors.bg,
color: colors.secondary
}}>
<div style={{ fontSize: 48, marginBottom: 16 }}>🌍</div>
<p>Cargando pronóstico...</p>
</div>
</McpUseProvider>
);
}
const currentWeather = getWeatherInfo(props.currentWeatherCode);
return (
<McpUseProvider autoSize>
<div style={{
backgroundColor: colors.bg,
color: colors.text,
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
padding: 20,
maxWidth: 600
}}>
{/* ── Cabecera con ciudad y temperatura actual ── */}
<div style={{
backgroundColor: colors.card,
borderRadius: 12,
padding: 24,
marginBottom: 16,
border: `1px solid ${colors.border}`
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<h2 style={{ margin: "0 0 4px 0", fontSize: 24 }}>{props.city}</h2>
<p style={{ margin: 0, fontSize: 13, color: colors.secondary }}>
{props.latitude.toFixed(2)}°N, {Math.abs(props.longitude).toFixed(2)}°{props.longitude >= 0 ? "E" : "W"} · {props.timezone}
</p>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 48 }}>{currentWeather.icon}</div>
</div>
</div>
<div style={{ marginTop: 16, display: "flex", alignItems: "baseline", gap: 12 }}>
<span style={{ fontSize: 56, fontWeight: 700, lineHeight: 1 }}>
{toUnit(props.currentTemp)}{unitLabel}
</span>
<span style={{ fontSize: 16, color: colors.secondary }}>
{currentWeather.label}
</span>
<button
onClick={() => setUnit(u => u === "C" ? "F" : "C")}
style={{
padding: "4px 10px",
border: `1px solid ${colors.border}`,
borderRadius: 6,
backgroundColor: "transparent",
color: colors.secondary,
cursor: "pointer",
fontSize: 13,
marginLeft: 8
}}
>
°C / °F
</button>
</div>
{props.daily.length > 0 && (
<p style={{ margin: "8px 0 0", fontSize: 14, color: colors.secondary }}>
Hoy: <span style={{ color: colors.tempHigh }}>{toUnit(props.daily[0].tempMax)}{unitLabel}</span>
{" / "}
<span style={{ color: colors.tempLow }}>{toUnit(props.daily[0].tempMin)}{unitLabel}</span>
</p>
)}
</div>
{/* ── Pestañas: Por horas / Por días ── */}
<div style={{
display: "flex",
gap: 4,
marginBottom: 16,
backgroundColor: colors.barBg,
borderRadius: 8,
padding: 4
}}>
{(["hourly", "daily"] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
flex: 1,
padding: "10px 16px",
border: "none",
borderRadius: 6,
cursor: "pointer",
fontSize: 14,
fontWeight: 600,
backgroundColor: activeTab === tab ? colors.card : "transparent",
color: activeTab === tab ? colors.primary : colors.secondary,
boxShadow: activeTab === tab ? "0 1px 3px rgba(0,0,0,0.1)" : "none",
transition: "all 0.2s"
}}
>
{tab === "hourly" ? "Por horas" : "Por días"}
</button>
))}
</div>
{/* ── Vista: Pronóstico por horas ── */}
{activeTab === "hourly" && (
<div style={{
backgroundColor: colors.card,
borderRadius: 12,
padding: 16,
border: `1px solid ${colors.border}`
}}>
<div style={{
display: "flex",
overflowX: "auto",
gap: 8,
paddingBottom: 8
}}>
{props.hourly.map((hour, i) => {
const weather = getWeatherInfo(hour.weatherCode);
return (
<div key={i} style={{
flex: "0 0 auto",
textAlign: "center",
padding: "12px 14px",
borderRadius: 10,
backgroundColor: i === 0 ? colors.primary + "15" : "transparent",
border: i === 0 ? `1px solid ${colors.primary}40` : "1px solid transparent",
minWidth: 56
}}>
<div style={{ fontSize: 13, color: colors.secondary, marginBottom: 8 }}>
{hour.time}
</div>
<div style={{ fontSize: 24, marginBottom: 4 }}>
{weather.icon}
</div>
<div style={{ fontSize: 16, fontWeight: 600 }}>
{toUnit(hour.temp)}{unitLabel}
</div>
</div>
);
})}
</div>
</div>
)}
{/* ── Vista: Pronóstico diario ── */}
{activeTab === "daily" && (
<div style={{
backgroundColor: colors.card,
borderRadius: 12,
padding: 16,
border: `1px solid ${colors.border}`
}}>
{props.daily.map((day, i) => {
const weather = getWeatherInfo(day.weatherCode);
const tempRange = props.daily.reduce(
(acc, d) => ({
min: Math.min(acc.min, d.tempMin),
max: Math.max(acc.max, d.tempMax)
}),
{ min: Infinity, max: -Infinity }
);
const rangeWidth = tempRange.max - tempRange.min || 1;
const leftPct = ((day.tempMin - tempRange.min) / rangeWidth) * 100;
const widthPct = ((day.tempMax - day.tempMin) / rangeWidth) * 100;
return (
<div key={i} style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "10px 0",
borderBottom: i < props.daily.length - 1
? `1px solid ${colors.border}`
: "none"
}}>
<div style={{ width: 36, fontWeight: 500, fontSize: 14 }}>
{day.date}
</div>
<div style={{ fontSize: 22, width: 32, textAlign: "center" }}>
{weather.icon}
</div>
<div style={{
width: 36,
fontSize: 14,
color: colors.tempLow,
textAlign: "right"
}}>
{toUnit(day.tempMin)}{unitLabel}
</div>
<div style={{
flex: 1,
height: 6,
backgroundColor: colors.barBg,
borderRadius: 3,
position: "relative",
overflow: "hidden"
}}>
<div style={{
position: "absolute",
left: `${leftPct}%`,
width: `${Math.max(widthPct, 8)}%`,
height: "100%",
borderRadius: 3,
background: `linear-gradient(90deg, ${colors.tempLow}, ${colors.accent}, ${colors.tempHigh})`
}} />
</div>
<div style={{
width: 36,
fontSize: 14,
color: colors.tempHigh,
fontWeight: 600
}}>
{toUnit(day.tempMax)}{unitLabel}
</div>
{day.precipitationSum > 0 && (
<div style={{
fontSize: 12,
color: colors.primary,
width: 48,
textAlign: "right"
}}>
{day.precipitationSum} mm
</div>
)}
</div>
);
})}
</div>
)}
{/* ── Footer ── */}
<p style={{
textAlign: "center",
fontSize: 11,
color: colors.secondary,
marginTop: 16,
marginBottom: 0
}}>
Actualizado: {new Date(props.generatedAt).toLocaleString()}
</p>
</div>
</McpUseProvider>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment