main.ts•19.8 kB
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from "zod";
// Type definitions for better type safety
interface GeocodingResult {
id: number;
name: string;
latitude: number;
longitude: number;
country: string;
admin1?: string;
}
interface GeocodingResponse {
results: GeocodingResult[];
generationtime_ms: number;
}
interface WeatherData {
current: {
time: string;
temperature_2m: number;
relative_humidity_2m: number;
apparent_temperature: number;
precipitation: number;
weather_code: number;
};
hourly: {
time: string[];
temperature_2m: number[];
precipitation: number[];
};
}
interface WeatherResponse {
latitude: number;
longitude: number;
current: WeatherData['current'];
hourly: WeatherData['hourly'];
}
interface DailyWeatherData {
time: string[];
temperature_2m_max: number[];
temperature_2m_min: number[];
precipitation_sum: number[];
weather_code: number[];
sunrise: string[];
sunset: string[];
}
interface ExtendedWeatherResponse {
latitude: number;
longitude: number;
daily: DailyWeatherData;
daily_units: {
temperature_2m_max: string;
temperature_2m_min: string;
precipitation_sum: string;
};
}
interface AirQualityData {
time: string[];
european_aqi: number[];
european_aqi_pm2_5: number[];
european_aqi_pm10: number[];
european_aqi_no2: number[];
european_aqi_o3: number[];
european_aqi_so2: number[];
}
interface AirQualityResponse {
latitude: number;
longitude: number;
current: {
time: string;
european_aqi: number;
european_aqi_pm2_5: number;
european_aqi_pm10: number;
european_aqi_no2: number;
european_aqi_o3: number;
european_aqi_so2: number;
};
hourly: AirQualityData;
}
// Configuration
const CONFIG = {
GEOCODING_API: 'https://geocoding-api.open-meteo.com/v1/search',
WEATHER_API: 'https://api.open-meteo.com/v1/forecast',
AIR_QUALITY_API: 'https://air-quality-api.open-meteo.com/v1/air-quality',
REQUEST_TIMEOUT: 10000, // 10 seconds
MAX_RETRIES: 3,
RETRY_DELAY: 1000, // 1 second
} as const;
// Weather code mapping for human-readable descriptions
const WEATHER_CODES: Record<number, string> = {
0: 'Clear sky',
1: 'Mainly clear',
2: 'Partly cloudy',
3: 'Overcast',
45: 'Foggy',
48: 'Depositing rime fog',
51: 'Light drizzle',
53: 'Moderate drizzle',
55: 'Dense drizzle',
61: 'Slight rain',
63: 'Moderate rain',
65: 'Heavy rain',
71: 'Slight snow',
73: 'Moderate snow',
75: 'Heavy snow',
77: 'Snow grains',
80: 'Slight rain showers',
81: 'Moderate rain showers',
82: 'Violent rain showers',
85: 'Slight snow showers',
86: 'Heavy snow showers',
95: 'Thunderstorm',
96: 'Thunderstorm with slight hail',
99: 'Thunderstorm with heavy hail',
};
// Utility functions
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
function getWeatherDescription(code: number): string {
return WEATHER_CODES[code] || 'Unknown weather condition';
}
function formatTemperature(temp: number): string {
return `${temp.toFixed(1)}°C`;
}
function formatPrecipitation(precip: number): string {
if (precip === 0) return 'No precipitation';
return `${precip.toFixed(1)}mm`;
}
function formatTime(timeString: string): string {
return new Date(timeString).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
weekday: 'long',
month: 'short',
day: 'numeric'
});
}
function getAqiLevel(aqi: number): { level: string; description: string } {
if (aqi <= 20) return { level: 'Good', description: 'Air quality is good' };
if (aqi <= 40) return { level: 'Fair', description: 'Air quality is acceptable' };
if (aqi <= 60) return { level: 'Moderate', description: 'Air quality is moderate' };
if (aqi <= 80) return { level: 'Poor', description: 'Air quality is poor' };
if (aqi <= 100) return { level: 'Very Poor', description: 'Air quality is very poor' };
return { level: 'Hazardous', description: 'Air quality is hazardous' };
}
// Enhanced fetch with timeout and retry logic
async function fetchWithRetry(url: string, options: RequestInit = {}, retries: number = CONFIG.MAX_RETRIES): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CONFIG.REQUEST_TIMEOUT);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response;
} catch (error) {
clearTimeout(timeoutId);
if (retries > 0 && (error instanceof Error && (error.name === 'AbortError' || error.message.includes('HTTP 5')))) {
await delay(CONFIG.RETRY_DELAY);
return fetchWithRetry(url, options, retries - 1);
}
throw error;
}
}
// Enhanced weather data interface with processed information
interface ProcessedWeatherData {
location: {
name: string;
fullName: string;
latitude: number;
longitude: number;
country: string;
admin1?: string;
};
current: {
time: string;
formattedTime: string;
temperature: number;
formattedTemperature: string;
feelsLike: number;
formattedFeelsLike: string;
humidity: number;
precipitation: number;
formattedPrecipitation: string;
weatherCode: number;
weatherDescription: string;
};
hourly: Array<{
time: string;
formattedTime: string;
temperature: number;
formattedTemperature: string;
precipitation: number;
formattedPrecipitation: string;
}>;
raw: WeatherResponse; // Keep original data for compatibility
}
interface ProcessedForecastData {
location: {
name: string;
fullName: string;
latitude: number;
longitude: number;
country: string;
admin1?: string;
};
daily: Array<{
date: string;
formattedDate: string;
maxTemperature: number;
formattedMaxTemperature: string;
minTemperature: number;
formattedMinTemperature: string;
precipitation: number;
formattedPrecipitation: string;
weatherCode: number;
weatherDescription: string;
sunrise: string;
formattedSunrise: string;
sunset: string;
formattedSunset: string;
}>;
raw: ExtendedWeatherResponse;
}
interface ProcessedAirQualityData {
location: {
name: string;
fullName: string;
latitude: number;
longitude: number;
country: string;
admin1?: string;
};
current: {
time: string;
formattedTime: string;
europeanAqi: number;
aqiLevel: string;
aqiDescription: string;
pm25: number;
pm10: number;
no2: number;
o3: number;
so2: number;
};
hourly: Array<{
time: string;
formattedTime: string;
europeanAqi: number;
aqiLevel: string;
pm25: number;
pm10: number;
no2: number;
o3: number;
so2: number;
}>;
raw: AirQualityResponse;
}
// Main weather fetching function
async function getWeatherForCity(city: string): Promise<ProcessedWeatherData | string> {
// Step 1: Get coordinates for the city
const geoUrl = `${CONFIG.GEOCODING_API}?name=${encodeURIComponent(city)}&count=1&language=en&format=json`;
const geoResponse = await fetchWithRetry(geoUrl);
const geoData: GeocodingResponse = await geoResponse.json();
// Handle city not found
if (!geoData.results || geoData.results.length === 0) {
return `❌ Sorry, I couldn't find a city named "${city}". Please check the spelling and try again.`;
}
const location = geoData.results[0];
// Step 2: Get weather data using coordinates
const weatherUrl = `${CONFIG.WEATHER_API}?latitude=${location.latitude}&longitude=${location.longitude}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,weather_code&hourly=temperature_2m,precipitation&forecast_days=1&timezone=auto`;
const weatherResponse = await fetchWithRetry(weatherUrl);
const weatherData: WeatherResponse = await weatherResponse.json();
// Process and structure the weather data
const current = weatherData.current;
const locationName = location.admin1 ? `${location.name}, ${location.admin1}, ${location.country}` : `${location.name}, ${location.country}`;
const processedData: ProcessedWeatherData = {
location: {
name: location.name,
fullName: locationName,
latitude: location.latitude,
longitude: location.longitude,
country: location.country,
admin1: location.admin1
},
current: {
time: current.time,
formattedTime: formatTime(current.time),
temperature: current.temperature_2m,
formattedTemperature: formatTemperature(current.temperature_2m),
feelsLike: current.apparent_temperature,
formattedFeelsLike: formatTemperature(current.apparent_temperature),
humidity: current.relative_humidity_2m,
precipitation: current.precipitation,
formattedPrecipitation: formatPrecipitation(current.precipitation),
weatherCode: current.weather_code,
weatherDescription: getWeatherDescription(current.weather_code)
},
hourly: weatherData.hourly.time.slice(0, 24).map((time, index) => ({
time: time,
formattedTime: formatTime(time),
temperature: weatherData.hourly.temperature_2m[index],
formattedTemperature: formatTemperature(weatherData.hourly.temperature_2m[index]),
precipitation: weatherData.hourly.precipitation[index],
formattedPrecipitation: formatPrecipitation(weatherData.hourly.precipitation[index])
})),
raw: weatherData
};
return processedData;
}
// Get 7-day weather forecast
async function getForecastForCity(city: string): Promise<ProcessedForecastData | string> {
// Step 1: Get coordinates for the city
const geoUrl = `${CONFIG.GEOCODING_API}?name=${encodeURIComponent(city)}&count=1&language=en&format=json`;
const geoResponse = await fetchWithRetry(geoUrl);
const geoData: GeocodingResponse = await geoResponse.json();
// Handle city not found
if (!geoData.results || geoData.results.length === 0) {
return `❌ Sorry, I couldn't find a city named "${city}". Please check the spelling and try again.`;
}
const location = geoData.results[0];
// Step 2: Get 7-day forecast data
const forecastUrl = `${CONFIG.WEATHER_API}?latitude=${location.latitude}&longitude=${location.longitude}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code,sunrise,sunset&timezone=auto`;
const forecastResponse = await fetchWithRetry(forecastUrl);
const forecastData: ExtendedWeatherResponse = await forecastResponse.json();
// Process and structure the forecast data
const locationName = location.admin1 ? `${location.name}, ${location.admin1}, ${location.country}` : `${location.name}, ${location.country}`;
const processedData: ProcessedForecastData = {
location: {
name: location.name,
fullName: locationName,
latitude: location.latitude,
longitude: location.longitude,
country: location.country,
admin1: location.admin1
},
daily: forecastData.daily.time.map((date, index) => ({
date: date,
formattedDate: formatDate(date),
maxTemperature: forecastData.daily.temperature_2m_max[index],
formattedMaxTemperature: formatTemperature(forecastData.daily.temperature_2m_max[index]),
minTemperature: forecastData.daily.temperature_2m_min[index],
formattedMinTemperature: formatTemperature(forecastData.daily.temperature_2m_min[index]),
precipitation: forecastData.daily.precipitation_sum[index],
formattedPrecipitation: formatPrecipitation(forecastData.daily.precipitation_sum[index]),
weatherCode: forecastData.daily.weather_code[index],
weatherDescription: getWeatherDescription(forecastData.daily.weather_code[index]),
sunrise: forecastData.daily.sunrise[index],
formattedSunrise: formatTime(forecastData.daily.sunrise[index]),
sunset: forecastData.daily.sunset[index],
formattedSunset: formatTime(forecastData.daily.sunset[index])
})),
raw: forecastData
};
return processedData;
}
// Get air quality data
async function getAirQualityForCity(city: string): Promise<ProcessedAirQualityData | string> {
// Step 1: Get coordinates for the city
const geoUrl = `${CONFIG.GEOCODING_API}?name=${encodeURIComponent(city)}&count=1&language=en&format=json`;
const geoResponse = await fetchWithRetry(geoUrl);
const geoData: GeocodingResponse = await geoResponse.json();
// Handle city not found
if (!geoData.results || geoData.results.length === 0) {
return `❌ Sorry, I couldn't find a city named "${city}". Please check the spelling and try again.`;
}
const location = geoData.results[0];
// Step 2: Get air quality data
const aqiUrl = `${CONFIG.AIR_QUALITY_API}?latitude=${location.latitude}&longitude=${location.longitude}&hourly=european_aqi,european_aqi_pm2_5,european_aqi_pm10,european_aqi_no2,european_aqi_o3,european_aqi_so2¤t=european_aqi,european_aqi_pm2_5,european_aqi_pm10,european_aqi_no2,european_aqi_o3,european_aqi_so2`;
const aqiResponse = await fetchWithRetry(aqiUrl);
const aqiData: AirQualityResponse = await aqiResponse.json();
// Process and structure the air quality data
const locationName = location.admin1 ? `${location.name}, ${location.admin1}, ${location.country}` : `${location.name}, ${location.country}`;
const current = aqiData.current;
const aqiInfo = getAqiLevel(current.european_aqi);
const processedData: ProcessedAirQualityData = {
location: {
name: location.name,
fullName: locationName,
latitude: location.latitude,
longitude: location.longitude,
country: location.country,
admin1: location.admin1
},
current: {
time: current.time,
formattedTime: formatTime(current.time),
europeanAqi: current.european_aqi,
aqiLevel: aqiInfo.level,
aqiDescription: aqiInfo.description,
pm25: current.european_aqi_pm2_5,
pm10: current.european_aqi_pm10,
no2: current.european_aqi_no2,
o3: current.european_aqi_o3,
so2: current.european_aqi_so2
},
hourly: aqiData.hourly.time.slice(0, 24).map((time, index) => {
const aqi = aqiData.hourly.european_aqi[index];
const aqiInfo = getAqiLevel(aqi);
return {
time: time,
formattedTime: formatTime(time),
europeanAqi: aqi,
aqiLevel: aqiInfo.level,
pm25: aqiData.hourly.european_aqi_pm2_5[index],
pm10: aqiData.hourly.european_aqi_pm10[index],
no2: aqiData.hourly.european_aqi_no2[index],
o3: aqiData.hourly.european_aqi_o3[index],
so2: aqiData.hourly.european_aqi_so2[index]
};
}),
raw: aqiData
};
return processedData;
}
// Create MCP server
const server = new McpServer({
name: "Weather Server",
version: "1.0.0"
});
// Register the weather tool
server.tool(
'get-weather',
'Get detailed weather information for any city including current conditions and hourly forecast',
{
city: z.string()
.min(1, "City name cannot be empty")
.max(100, "City name is too long")
.describe("The name of the city to get weather information for (e.g., 'New York', 'London', 'Tokyo')")
},
async({ city }) => {
try {
const result = await getWeatherForCity(city);
// If it's an error string, return it as text
if (typeof result === 'string') {
return {
content: [
{
type: "text",
text: result
}
]
};
}
// If it's processed data, return it as JSON string for structured access
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
]
};
} catch (error) {
console.error('Weather fetch error:', error);
const errorMessage = error instanceof Error && error.message.includes('fetch')
? `❌ Unable to fetch weather data. Please check your internet connection and try again.`
: `❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
return {
content: [
{
type: "text",
text: errorMessage
}
]
};
}
}
);
// Register the forecast tool
server.tool(
'get-forecast',
'Get 7-day weather forecast for any city including daily temperatures, precipitation, and sunrise/sunset times',
{
city: z.string()
.min(1, "City name cannot be empty")
.max(100, "City name is too long")
.describe("The name of the city to get forecast information for (e.g., 'New York', 'London', 'Tokyo')")
},
async({ city }) => {
try {
const result = await getForecastForCity(city);
// If it's an error string, return it as text
if (typeof result === 'string') {
return {
content: [
{
type: "text",
text: result
}
]
};
}
// If it's processed data, return it as JSON string for structured access
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
]
};
} catch (error) {
console.error('Forecast fetch error:', error);
const errorMessage = error instanceof Error && error.message.includes('fetch')
? `❌ Unable to fetch forecast data. Please check your internet connection and try again.`
: `❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
return {
content: [
{
type: "text",
text: errorMessage
}
]
};
}
}
);
// Register the air quality tool
server.tool(
'get-air-quality',
'Get air pollution data for any city including European Air Quality Index and pollutant levels',
{
city: z.string()
.min(1, "City name cannot be empty")
.max(100, "City name is too long")
.describe("The name of the city to get air quality information for (e.g., 'New York', 'London', 'Tokyo')")
},
async({ city }) => {
try {
const result = await getAirQualityForCity(city);
// If it's an error string, return it as text
if (typeof result === 'string') {
return {
content: [
{
type: "text",
text: result
}
]
};
}
// If it's processed data, return it as JSON string for structured access
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
]
};
} catch (error) {
console.error('Air quality fetch error:', error);
const errorMessage = error instanceof Error && error.message.includes('fetch')
? `❌ Unable to fetch air quality data. Please check your internet connection and try again.`
: `❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
return {
content: [
{
type: "text",
text: errorMessage
}
]
};
}
}
);
// Start the server
const transport = new StdioServerTransport();
server.connect(transport);