-
-
Save delineas/a5dc2819f755ac47ad1111619bb33f28 to your computer and use it in GitHub Desktop.
Weather MCP App
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "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" | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Show hidden characters
| { | |
| "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"] | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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