diff --git a/examples/weather-app/.gitignore b/examples/weather-app/.gitignore new file mode 100644 index 00000000..5a9eec75 --- /dev/null +++ b/examples/weather-app/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +public/ +*.log +.env +.env.local diff --git a/examples/weather-app/README.md b/examples/weather-app/README.md new file mode 100644 index 00000000..c3a80e68 --- /dev/null +++ b/examples/weather-app/README.md @@ -0,0 +1,99 @@ +# Weather App Example + +An MCP application demonstrating weather tools built with @mcp-apps-kit. Uses the [Open-Meteo API](https://open-meteo.com/) for real weather data (free, no API key required) with mock data fallback. + +## Features + +- **getCurrentWeather**: Get current weather conditions for any location +- **getForecast**: Get up to 16-day weather forecast +- **getWeatherAlerts**: Get weather alerts and warnings (mock data) + +## Tools + +| Tool | Description | +| ------------------- | ------------------------------------------------------------------------- | +| `getCurrentWeather` | Returns current temperature, humidity, wind, and conditions | +| `getForecast` | Returns daily forecast with high/low temps, precipitation, sunrise/sunset | +| `getWeatherAlerts` | Returns active weather warnings, watches, and advisories | + +## Development + +```bash +# Install dependencies (from monorepo root) +pnpm install + +# Run development server +pnpm -C examples/weather-app dev +``` + +## Build + +```bash +pnpm -C examples/weather-app build +``` + +## Testing + +```bash +# Run tests +pnpm -C examples/weather-app test + +# Run tests in watch mode +pnpm -C examples/weather-app test:watch +``` + +### Mock Mode + +Set `USE_MOCK_WEATHER=true` to use mock data instead of the real API: + +```bash +USE_MOCK_WEATHER=true pnpm -C examples/weather-app dev +``` + +## Connecting to an MCP Apps Host + +Configure your MCP Apps-compatible host to connect to the server: + +**HTTP mode (default):** + +- Endpoint: `http://localhost:3005/mcp` + +**Stdio mode (for hosts that support it):** + +```bash +npx tsx examples/weather-app/server/index.ts +``` + +## Project Structure + +``` +weather-app/ +├── server/ +│ ├── index.ts # App entry point with tool definitions +│ └── services/ +│ └── weatherService.ts # Open-Meteo API integration +├── ui/ +│ ├── src/ +│ │ ├── App.tsx # React UI components +│ │ └── styles.css # Styling +│ └── vite.config.ts +└── tests/ + ├── integration/ + │ └── server.test.ts # Integration tests + └── unit/ + └── weatherService.test.ts # Unit tests +``` + +## API Reference + +### Open-Meteo + +This example uses the [Open-Meteo API](https://open-meteo.com/), a free and open-source weather API that doesn't require authentication. + +- **Geocoding**: Converts city names to coordinates +- **Weather**: Current conditions and forecasts +- **Limits**: No rate limits for reasonable usage + +## License + +MIT diff --git a/examples/weather-app/package.json b/examples/weather-app/package.json new file mode 100644 index 00000000..7f91b7c4 --- /dev/null +++ b/examples/weather-app/package.json @@ -0,0 +1,43 @@ +{ + "name": "@mcp-apps-kit/example-weather-app", + "version": "0.1.0", + "private": true, + "description": "Weather app example demonstrating Open-Meteo API integration with @mcp-apps-kit/core", + "type": "module", + "scripts": { + "dev": "concurrently \"pnpm dev:server\" \"pnpm dev:ui\"", + "dev:server": "tsx watch server/index.ts", + "dev:ui": "vite build --watch --config ui/vite.config.ts", + "start": "tsx server/index.ts", + "build": "pnpm build:ui && tsc", + "build:ui": "vite build --config ui/vite.config.ts", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@mcp-apps-kit/core": "workspace:*", + "@mcp-apps-kit/ui": "workspace:*", + "@mcp-apps-kit/ui-react": "workspace:*", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "zod": "^4.0.0" + }, + "devDependencies": { + "@mcp-apps-kit/testing": "workspace:*", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", + "@types/node": "^25.0.3", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", + "concurrently": "^9.2.1", + "jsdom": "^27.3.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^7.3.0", + "vite-plugin-singlefile": "^2.3.0", + "vitest": "^4.0.16" + } +} diff --git a/examples/weather-app/server/index.ts b/examples/weather-app/server/index.ts new file mode 100644 index 00000000..70860453 --- /dev/null +++ b/examples/weather-app/server/index.ts @@ -0,0 +1,204 @@ +/** + * Weather App - MCP Server + * + * Demonstrates weather-related tools using Open-Meteo API (free, no API key required) + * with mock data fallback for testing and offline usage. + */ + +import { createApp, defineTool, defineUI, type ClientToolsFromCore } from "@mcp-apps-kit/core"; +import { z } from "zod"; +import { + WeatherService, + type CurrentWeather, + type WeatherForecast, + type WeatherAlertsResponse, +} from "./services/weatherService.js"; + +// Initialize weather service (uses real API by default, falls back to mock) +const weatherService = new WeatherService({ + useMock: process.env.USE_MOCK_WEATHER === "true", +}); + +// Define UIs for weather widgets +const currentWeatherUI = defineUI({ + name: "Current Weather Widget", + description: "Displays current weather conditions", + html: "./ui/dist/index.html", + prefersBorder: true, +}); + +const forecastUI = defineUI({ + name: "Weather Forecast Widget", + description: "Displays weather forecast", + html: "./ui/dist/index.html", + prefersBorder: true, +}); + +const alertsUI = defineUI({ + name: "Weather Alerts Widget", + description: "Displays weather alerts and warnings", + html: "./ui/dist/index.html", + prefersBorder: true, +}); + +// Schema definitions +const currentWeatherOutputSchema = z.object({ + location: z.object({ + name: z.string(), + latitude: z.number(), + longitude: z.number(), + country: z.string().optional(), + timezone: z.string().optional(), + }), + temperature: z.number().describe("Temperature in Celsius"), + feelsLike: z.number().describe("Feels like temperature in Celsius"), + humidity: z.number().describe("Relative humidity percentage"), + windSpeed: z.number().describe("Wind speed in km/h"), + windDirection: z.number().describe("Wind direction in degrees"), + weatherCode: z.number(), + description: z.string(), + icon: z.string(), + isDay: z.boolean(), + timestamp: z.string(), +}); + +const dailyForecastSchema = z.object({ + date: z.string(), + temperatureMax: z.number(), + temperatureMin: z.number(), + weatherCode: z.number(), + description: z.string(), + icon: z.string(), + precipitationProbability: z.number(), + windSpeedMax: z.number(), + sunrise: z.string(), + sunset: z.string(), +}); + +const forecastOutputSchema = z.object({ + location: z.object({ + name: z.string(), + latitude: z.number(), + longitude: z.number(), + country: z.string().optional(), + timezone: z.string().optional(), + }), + daily: z.array(dailyForecastSchema), + generatedAt: z.string(), +}); + +const alertSchema = z.object({ + id: z.string(), + type: z.enum(["warning", "watch", "advisory"]), + severity: z.enum(["minor", "moderate", "severe", "extreme"]), + headline: z.string(), + description: z.string(), + startTime: z.string(), + endTime: z.string(), +}); + +const alertsOutputSchema = z.object({ + location: z.object({ + name: z.string(), + latitude: z.number(), + longitude: z.number(), + country: z.string().optional(), + timezone: z.string().optional(), + }), + alerts: z.array(alertSchema), + lastChecked: z.string(), +}); + +// Create the MCP App +const app = createApp({ + name: "weather-app", + version: "0.1.0", + + config: { + protocol: "openai", + }, + + tools: { + getCurrentWeather: defineTool({ + title: "Get Current Weather", + description: + "Get the current weather conditions for a specified location. Returns temperature, humidity, wind, and conditions.", + input: z.object({ + location: z + .string() + .describe( + "City name, address, or location to get weather for (e.g., 'New York', 'Tokyo', 'London, UK')" + ), + }), + output: currentWeatherOutputSchema, + visibility: "both", + annotations: { + readOnlyHint: true, + }, + handler: async ({ location }): Promise => { + return await weatherService.getCurrentWeather(location); + }, + ui: currentWeatherUI, + }), + + getForecast: defineTool({ + title: "Get Weather Forecast", + description: + "Get a multi-day weather forecast for a specified location. Returns daily forecasts with temperatures, precipitation, and conditions.", + input: z.object({ + location: z.string().describe("City name, address, or location to get forecast for"), + days: z + .number() + .min(1) + .max(16) + .default(7) + .describe("Number of days to forecast (1-16, default: 7)"), + }), + output: forecastOutputSchema, + visibility: "both", + annotations: { + readOnlyHint: true, + }, + handler: async ({ location, days }): Promise => { + return await weatherService.getForecast(location, days); + }, + ui: forecastUI, + }), + + getWeatherAlerts: defineTool({ + title: "Get Weather Alerts", + description: + "Get active weather alerts and warnings for a specified location. Returns any watches, warnings, or advisories in effect.", + input: z.object({ + location: z.string().describe("City name, address, or location to check for alerts"), + }), + output: alertsOutputSchema, + visibility: "both", + annotations: { + readOnlyHint: true, + }, + handler: async ({ location }): Promise => { + return await weatherService.getAlerts(location); + }, + ui: alertsUI, + }), + }, +}); + +// Export types for UI components +export type AppToolsDef = { + getCurrentWeather: typeof app.tools.getCurrentWeather; + getForecast: typeof app.tools.getForecast; + getWeatherAlerts: typeof app.tools.getWeatherAlerts; +}; +export type AppTools = ClientToolsFromCore; + +// Start server (skip in test environment) +if (process.env.NODE_ENV !== "test") { + const port = parseInt(process.env.PORT || "3005", 10); + await app.start({ port }); + console.log(`Weather App MCP server running on http://localhost:${port}`); +} + +// Export app for testing +export { app }; diff --git a/examples/weather-app/server/services/weatherService.ts b/examples/weather-app/server/services/weatherService.ts new file mode 100644 index 00000000..53f4ccf1 --- /dev/null +++ b/examples/weather-app/server/services/weatherService.ts @@ -0,0 +1,565 @@ +/** + * Weather Service - Uses Open-Meteo API (free, no API key required) + * Falls back to mock data if API is unavailable + */ + +import { randomUUID } from "crypto"; +import { z } from "zod"; + +// Weather code descriptions from Open-Meteo +const WEATHER_CODES: Record = { + 0: { description: "Clear sky", icon: "☀️" }, + 1: { description: "Mainly clear", icon: "🌤️" }, + 2: { description: "Partly cloudy", icon: "⛅" }, + 3: { description: "Overcast", icon: "☁️" }, + 45: { description: "Fog", icon: "🌫️" }, + 48: { description: "Depositing rime fog", icon: "🌫️" }, + 51: { description: "Light drizzle", icon: "🌧️" }, + 53: { description: "Moderate drizzle", icon: "🌧️" }, + 55: { description: "Dense drizzle", icon: "🌧️" }, + 61: { description: "Slight rain", icon: "🌧️" }, + 63: { description: "Moderate rain", icon: "🌧️" }, + 65: { description: "Heavy rain", icon: "🌧️" }, + 66: { description: "Light freezing rain", icon: "🌨️" }, + 67: { description: "Heavy freezing rain", icon: "🌨️" }, + 71: { description: "Slight snow", icon: "❄️" }, + 73: { description: "Moderate snow", icon: "❄️" }, + 75: { description: "Heavy snow", icon: "❄️" }, + 77: { description: "Snow grains", icon: "❄️" }, + 80: { description: "Slight rain showers", icon: "🌦️" }, + 81: { description: "Moderate rain showers", icon: "🌦️" }, + 82: { description: "Violent rain showers", icon: "🌦️" }, + 85: { description: "Slight snow showers", icon: "🌨️" }, + 86: { description: "Heavy snow showers", icon: "🌨️" }, + 95: { description: "Thunderstorm", icon: "⛈️" }, + 96: { description: "Thunderstorm with slight hail", icon: "⛈️" }, + 99: { description: "Thunderstorm with heavy hail", icon: "⛈️" }, +}; + +// Zod schemas for API response validation +const GeocodingResultSchema = z.object({ + name: z.string(), + latitude: z.number(), + longitude: z.number(), + country: z.string().optional(), + timezone: z.string().optional(), +}); + +const GeocodingResponseSchema = z.object({ + results: z.array(GeocodingResultSchema).optional(), +}); + +const CurrentWeatherApiSchema = z.object({ + current: z.object({ + time: z.string(), + temperature_2m: z.number(), + relative_humidity_2m: z.number(), + apparent_temperature: z.number(), + weather_code: z.number(), + wind_speed_10m: z.number(), + wind_direction_10m: z.number(), + is_day: z.number(), + }), +}); + +const ForecastApiSchema = z.object({ + daily: z.object({ + time: z.array(z.string()), + weather_code: z.array(z.number()), + temperature_2m_max: z.array(z.number()), + temperature_2m_min: z.array(z.number()), + precipitation_probability_max: z.array(z.number()), + wind_speed_10m_max: z.array(z.number()), + sunrise: z.array(z.string()), + sunset: z.array(z.string()), + }), +}); + +export interface Location { + name: string; + latitude: number; + longitude: number; + country?: string; + timezone?: string; +} + +export interface CurrentWeather { + location: Location; + temperature: number; + feelsLike: number; + humidity: number; + windSpeed: number; + windDirection: number; + weatherCode: number; + description: string; + icon: string; + isDay: boolean; + timestamp: string; +} + +export interface DailyForecast { + date: string; + temperatureMax: number; + temperatureMin: number; + weatherCode: number; + description: string; + icon: string; + precipitationProbability: number; + windSpeedMax: number; + sunrise: string; + sunset: string; +} + +export interface WeatherForecast { + location: Location; + daily: DailyForecast[]; + generatedAt: string; +} + +export interface WeatherAlert { + id: string; + type: "warning" | "watch" | "advisory"; + severity: "minor" | "moderate" | "severe" | "extreme"; + headline: string; + description: string; + startTime: string; + endTime: string; +} + +export interface WeatherAlertsResponse { + location: Location; + alerts: WeatherAlert[]; + lastChecked: string; +} + +/** + * Geocode a location query to coordinates using Open-Meteo Geocoding API + */ +async function geocodeLocation(query: string): Promise { + try { + const url = new URL("https://geocoding-api.open-meteo.com/v1/search"); + url.searchParams.set("name", query); + url.searchParams.set("count", "1"); + url.searchParams.set("language", "en"); + url.searchParams.set("format", "json"); + + const response = await fetch(url.toString()); + + if (!response.ok) { + return null; + } + + const rawData: unknown = await response.json(); + const parseResult = GeocodingResponseSchema.safeParse(rawData); + + if (!parseResult.success || !parseResult.data.results?.length) { + return null; + } + + const result = parseResult.data.results[0]; + return { + name: result.name, + latitude: result.latitude, + longitude: result.longitude, + country: result.country, + timezone: result.timezone, + }; + } catch { + return null; + } +} + +/** + * Fetch current weather from Open-Meteo API + */ +async function fetchCurrentWeather(location: Location): Promise { + try { + const url = new URL("https://api.open-meteo.com/v1/forecast"); + url.searchParams.set("latitude", String(location.latitude)); + url.searchParams.set("longitude", String(location.longitude)); + url.searchParams.set( + "current", + "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,wind_direction_10m,is_day" + ); + url.searchParams.set("timezone", "auto"); + + const response = await fetch(url.toString()); + + if (!response.ok) { + return null; + } + + const rawData: unknown = await response.json(); + const parseResult = CurrentWeatherApiSchema.safeParse(rawData); + + if (!parseResult.success) { + return null; + } + + const { current } = parseResult.data; + const weatherInfo = WEATHER_CODES[current.weather_code] || { + description: "Unknown", + icon: "❓", + }; + + return { + location, + temperature: current.temperature_2m, + feelsLike: current.apparent_temperature, + humidity: current.relative_humidity_2m, + windSpeed: current.wind_speed_10m, + windDirection: current.wind_direction_10m, + weatherCode: current.weather_code, + description: weatherInfo.description, + icon: weatherInfo.icon, + isDay: current.is_day === 1, + timestamp: current.time, + }; + } catch { + return null; + } +} + +/** + * Fetch forecast from Open-Meteo API + */ +async function fetchForecast(location: Location, days: number): Promise { + try { + const url = new URL("https://api.open-meteo.com/v1/forecast"); + url.searchParams.set("latitude", String(location.latitude)); + url.searchParams.set("longitude", String(location.longitude)); + url.searchParams.set( + "daily", + "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max,sunrise,sunset" + ); + url.searchParams.set("timezone", "auto"); + url.searchParams.set("forecast_days", String(days)); + + const response = await fetch(url.toString()); + + if (!response.ok) { + return null; + } + + const rawData: unknown = await response.json(); + const parseResult = ForecastApiSchema.safeParse(rawData); + + if (!parseResult.success) { + return null; + } + + const { daily } = parseResult.data; + const forecasts: DailyForecast[] = daily.time.map((date, i) => { + const weatherInfo = WEATHER_CODES[daily.weather_code[i]] || { + description: "Unknown", + icon: "❓", + }; + + return { + date, + temperatureMax: daily.temperature_2m_max[i], + temperatureMin: daily.temperature_2m_min[i], + weatherCode: daily.weather_code[i], + description: weatherInfo.description, + icon: weatherInfo.icon, + precipitationProbability: daily.precipitation_probability_max[i], + windSpeedMax: daily.wind_speed_10m_max[i], + sunrise: daily.sunrise[i], + sunset: daily.sunset[i], + }; + }); + + return { + location, + daily: forecasts, + generatedAt: new Date().toISOString(), + }; + } catch { + return null; + } +} + +// Mock location lookup table for varied coordinates +const MOCK_LOCATIONS: Record = { + "new york": { lat: 40.7128, lon: -74.006, country: "United States", tz: "America/New_York" }, + london: { lat: 51.5074, lon: -0.1278, country: "United Kingdom", tz: "Europe/London" }, + tokyo: { lat: 35.6762, lon: 139.6503, country: "Japan", tz: "Asia/Tokyo" }, + paris: { lat: 48.8566, lon: 2.3522, country: "France", tz: "Europe/Paris" }, + sydney: { lat: -33.8688, lon: 151.2093, country: "Australia", tz: "Australia/Sydney" }, + berlin: { lat: 52.52, lon: 13.405, country: "Germany", tz: "Europe/Berlin" }, + chicago: { lat: 41.8781, lon: -87.6298, country: "United States", tz: "America/Chicago" }, + miami: { lat: 25.7617, lon: -80.1918, country: "United States", tz: "America/New_York" }, + seattle: { lat: 47.6062, lon: -122.3321, country: "United States", tz: "America/Los_Angeles" }, + madrid: { lat: 40.4168, lon: -3.7038, country: "Spain", tz: "Europe/Madrid" }, + rome: { lat: 41.9028, lon: 12.4964, country: "Italy", tz: "Europe/Rome" }, +}; + +/** + * Generate a hash code from a string for consistent mock data + */ +function hashCode(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash); +} + +/** + * Generate mock location with varied coordinates based on query + */ +function generateMockLocation(query: string): Location { + const normalizedQuery = query.toLowerCase().trim(); + + // Check lookup table first + const knownLocation = MOCK_LOCATIONS[normalizedQuery]; + if (knownLocation) { + return { + name: query, + latitude: knownLocation.lat, + longitude: knownLocation.lon, + country: knownLocation.country, + timezone: knownLocation.tz, + }; + } + + // Generate varied coordinates based on location name hash + const hash = hashCode(normalizedQuery); + const latRange = 140; // -70 to 70 + const lonRange = 360; // -180 to 180 + + return { + name: query, + latitude: (hash % latRange) - 70 + (hash % 100) / 100, + longitude: ((hash >> 8) % lonRange) - 180 + ((hash >> 4) % 100) / 100, + country: "Unknown", + timezone: "UTC", + }; +} + +/** + * Generate mock current weather data + */ +function generateMockCurrentWeather(location: Location): CurrentWeather { + const codes = [0, 1, 2, 3, 61, 80]; + const weatherCode = codes[Math.floor(Math.random() * codes.length)]; + const weatherInfo = WEATHER_CODES[weatherCode]; + + return { + location, + temperature: Math.round((Math.random() * 30 + 5) * 10) / 10, + feelsLike: Math.round((Math.random() * 30 + 5) * 10) / 10, + humidity: Math.floor(Math.random() * 60 + 30), + windSpeed: Math.round(Math.random() * 20 * 10) / 10, + windDirection: Math.floor(Math.random() * 360), + weatherCode, + description: weatherInfo.description, + icon: weatherInfo.icon, + isDay: new Date().getHours() >= 6 && new Date().getHours() < 20, + timestamp: new Date().toISOString(), + }; +} + +/** + * Generate mock forecast data + */ +function generateMockForecast(location: Location, days: number): WeatherForecast { + const forecasts: DailyForecast[] = []; + const now = new Date(); + + for (let i = 0; i < days; i++) { + const date = new Date(now); + date.setDate(date.getDate() + i); + + const codes = [0, 1, 2, 3, 61, 80]; + const weatherCode = codes[Math.floor(Math.random() * codes.length)]; + const weatherInfo = WEATHER_CODES[weatherCode]; + + const tempMax = Math.round((Math.random() * 15 + 15) * 10) / 10; + const tempMin = Math.round((tempMax - Math.random() * 10 - 5) * 10) / 10; + + forecasts.push({ + date: date.toISOString().split("T")[0], + temperatureMax: tempMax, + temperatureMin: tempMin, + weatherCode, + description: weatherInfo.description, + icon: weatherInfo.icon, + precipitationProbability: Math.floor(Math.random() * 100), + windSpeedMax: Math.round(Math.random() * 30 * 10) / 10, + sunrise: `${date.toISOString().split("T")[0]}T06:30:00`, + sunset: `${date.toISOString().split("T")[0]}T19:30:00`, + }); + } + + return { + location, + daily: forecasts, + generatedAt: new Date().toISOString(), + }; +} + +/** + * Generate mock weather alerts + */ +function generateMockAlerts(location: Location): WeatherAlertsResponse { + // Randomly generate 0-2 alerts for demo purposes + const alertCount = Math.floor(Math.random() * 3); + const alerts: WeatherAlert[] = []; + + const alertTypes: Array<{ + type: WeatherAlert["type"]; + severity: WeatherAlert["severity"]; + headline: string; + }> = [ + { type: "warning", severity: "moderate", headline: "Wind Advisory" }, + { type: "watch", severity: "minor", headline: "Frost Watch" }, + { type: "advisory", severity: "minor", headline: "Dense Fog Advisory" }, + { type: "warning", severity: "severe", headline: "Thunderstorm Warning" }, + ]; + + for (let i = 0; i < alertCount; i++) { + const alertInfo = alertTypes[Math.floor(Math.random() * alertTypes.length)]; + const now = new Date(); + const endTime = new Date(now); + endTime.setHours(endTime.getHours() + Math.floor(Math.random() * 24) + 6); + + alerts.push({ + id: randomUUID(), + type: alertInfo.type, + severity: alertInfo.severity, + headline: alertInfo.headline, + description: `${alertInfo.headline} in effect for ${location.name}. Take appropriate precautions.`, + startTime: now.toISOString(), + endTime: endTime.toISOString(), + }); + } + + return { + location, + alerts, + lastChecked: new Date().toISOString(), + }; +} + +/** Weather Service Configuration */ +export interface WeatherServiceConfig { + useMock?: boolean; +} + +/** + * Weather Service class providing weather data from Open-Meteo API + * with automatic fallback to mock data on failure + */ +export class WeatherService { + private useMock: boolean; + + constructor(config: WeatherServiceConfig = {}) { + this.useMock = config.useMock ?? false; + } + + /** + * Get current weather for a location + * @param locationQuery - City name, address, or location query + * @throws Error if location query is empty + */ + async getCurrentWeather(locationQuery: string): Promise { + const trimmedQuery = locationQuery?.trim(); + if (!trimmedQuery) { + throw new Error("Location query cannot be empty"); + } + + if (this.useMock) { + const location = generateMockLocation(trimmedQuery); + return generateMockCurrentWeather(location); + } + + // Try to geocode the location + const location = await geocodeLocation(trimmedQuery); + + if (!location) { + // Fallback to mock if geocoding fails + const mockLocation = generateMockLocation(trimmedQuery); + return generateMockCurrentWeather(mockLocation); + } + + // Try to fetch real weather data + const weather = await fetchCurrentWeather(location); + + if (!weather) { + // Fallback to mock if API fails + return generateMockCurrentWeather(location); + } + + return weather; + } + + /** + * Get weather forecast for a location + * @param locationQuery - City name, address, or location query + * @param days - Number of days to forecast (1-16) + * @throws Error if location query is empty + */ + async getForecast(locationQuery: string, days: number = 7): Promise { + const trimmedQuery = locationQuery?.trim(); + if (!trimmedQuery) { + throw new Error("Location query cannot be empty"); + } + + const safeDays = Math.min(Math.max(days, 1), 16); // Open-Meteo supports up to 16 days + + if (this.useMock) { + const location = generateMockLocation(trimmedQuery); + return generateMockForecast(location, safeDays); + } + + // Try to geocode the location + const location = await geocodeLocation(trimmedQuery); + + if (!location) { + const mockLocation = generateMockLocation(trimmedQuery); + return generateMockForecast(mockLocation, safeDays); + } + + // Try to fetch real forecast data + const forecast = await fetchForecast(location, safeDays); + + if (!forecast) { + return generateMockForecast(location, safeDays); + } + + return forecast; + } + + /** + * Get weather alerts for a location + * @param locationQuery - City name, address, or location query + * @throws Error if location query is empty + */ + async getAlerts(locationQuery: string): Promise { + const trimmedQuery = locationQuery?.trim(); + if (!trimmedQuery) { + throw new Error("Location query cannot be empty"); + } + + // Open-Meteo doesn't provide alerts, so we always use mock data + // In a real app, you'd integrate with a service like NWS or weather.gov + if (this.useMock) { + const location = generateMockLocation(trimmedQuery); + return generateMockAlerts(location); + } + + const location = await geocodeLocation(trimmedQuery); + const resolvedLocation = location || generateMockLocation(trimmedQuery); + + return generateMockAlerts(resolvedLocation); + } +} + +/** Default weather service instance */ +export const weatherService = new WeatherService(); + +/** Mock weather service for testing */ +export const mockWeatherService = new WeatherService({ useMock: true }); diff --git a/examples/weather-app/tests/integration/server.test.ts b/examples/weather-app/tests/integration/server.test.ts new file mode 100644 index 00000000..7317efa0 --- /dev/null +++ b/examples/weather-app/tests/integration/server.test.ts @@ -0,0 +1,311 @@ +/** + * Integration tests for weather-app MCP server + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { startTestServer, createTestClient, expectToolResult } from "@mcp-apps-kit/testing"; +import type { TestEnvironment } from "@mcp-apps-kit/testing"; +import { app } from "../../server/index.js"; + +describe("Weather App MCP Server", () => { + let env: TestEnvironment; + + beforeAll(async () => { + // Set mock mode for predictable tests + process.env.USE_MOCK_WEATHER = "true"; + + const server = await startTestServer(app, { port: 0 }); + await new Promise((r) => setTimeout(r, 100)); + + const client = await createTestClient(server.mcpUrl, { + trackHistory: true, + timeout: 15000, + }); + + env = { + server, + client, + cleanup: async () => { + await client.disconnect(); + await server.stop(); + }, + }; + }); + + afterAll(async () => { + await env.cleanup(); + delete process.env.USE_MOCK_WEATHER; + }); + + describe("Server Initialization", () => { + it("should start server and connect client", () => { + expect(env.server).toBeDefined(); + expect(env.client).toBeDefined(); + expect(env.server.url).toBeTruthy(); + }); + + it("should list all weather tools", async () => { + const tools = await env.client.listTools(); + + expect(tools.length).toBe(3); + expect(tools.some((t) => t.name === "getCurrentWeather")).toBe(true); + expect(tools.some((t) => t.name === "getForecast")).toBe(true); + expect(tools.some((t) => t.name === "getWeatherAlerts")).toBe(true); + }); + + it("should have correct tool descriptions", async () => { + const tools = await env.client.listTools(); + + const currentWeatherTool = tools.find((t) => t.name === "getCurrentWeather"); + expect(currentWeatherTool?.description).toContain("current weather conditions"); + + const forecastTool = tools.find((t) => t.name === "getForecast"); + expect(forecastTool?.description).toContain("forecast"); + + const alertsTool = tools.find((t) => t.name === "getWeatherAlerts"); + expect(alertsTool?.description).toContain("alerts"); + }); + }); + + describe("getCurrentWeather Tool", () => { + it("should return current weather for a valid location", async () => { + const result = await env.client.callTool("getCurrentWeather", { + location: "New York", + }); + + expectToolResult(result).toHaveNoError(); + + const content = result.structuredContent as { + location?: { name?: string }; + temperature?: number; + humidity?: number; + description?: string; + icon?: string; + }; + + expect(content.location).toBeDefined(); + expect(content.location?.name).toBeTruthy(); + expect(typeof content.temperature).toBe("number"); + expect(typeof content.humidity).toBe("number"); + expect(content.description).toBeTruthy(); + expect(content.icon).toBeTruthy(); + }); + + it("should include wind information", async () => { + const result = await env.client.callTool("getCurrentWeather", { + location: "London", + }); + + expectToolResult(result).toHaveNoError(); + + const content = result.structuredContent as { + windSpeed?: number; + windDirection?: number; + }; + + expect(typeof content.windSpeed).toBe("number"); + expect(typeof content.windDirection).toBe("number"); + expect(content.windDirection).toBeGreaterThanOrEqual(0); + expect(content.windDirection).toBeLessThanOrEqual(360); + }); + + it("should include feels like temperature", async () => { + const result = await env.client.callTool("getCurrentWeather", { + location: "Tokyo", + }); + + expectToolResult(result).toHaveNoError(); + + const content = result.structuredContent as { + temperature?: number; + feelsLike?: number; + }; + + expect(typeof content.temperature).toBe("number"); + expect(typeof content.feelsLike).toBe("number"); + }); + + it("should include timestamp", async () => { + const result = await env.client.callTool("getCurrentWeather", { + location: "Paris", + }); + + expectToolResult(result).toHaveNoError(); + + const content = result.structuredContent as { timestamp?: string }; + expect(content.timestamp).toBeDefined(); + expect(new Date(content.timestamp!).getTime()).not.toBeNaN(); + }); + }); + + describe("getForecast Tool", () => { + it("should return forecast with default 7 days", async () => { + const result = await env.client.callTool("getForecast", { + location: "Berlin", + }); + + expectToolResult(result).toHaveNoError(); + + const content = result.structuredContent as { + location?: { name?: string }; + daily?: Array<{ date?: string }>; + generatedAt?: string; + }; + + expect(content.location).toBeDefined(); + expect(content.daily).toBeDefined(); + expect(Array.isArray(content.daily)).toBe(true); + expect(content.daily!.length).toBe(7); + }); + + it("should return forecast for specified number of days", async () => { + const result = await env.client.callTool("getForecast", { + location: "Sydney", + days: 3, + }); + + expectToolResult(result).toHaveNoError(); + + const content = result.structuredContent as { + daily?: Array<{ date?: string }>; + }; + + expect(content.daily!.length).toBe(3); + }); + + it("should include temperature range for each day", async () => { + const result = await env.client.callTool("getForecast", { + location: "Madrid", + days: 1, + }); + + expectToolResult(result).toHaveNoError(); + + const content = result.structuredContent as { + daily?: Array<{ + temperatureMax?: number; + temperatureMin?: number; + }>; + }; + + const day = content.daily![0]; + expect(typeof day.temperatureMax).toBe("number"); + expect(typeof day.temperatureMin).toBe("number"); + expect(day.temperatureMax).toBeGreaterThanOrEqual(day.temperatureMin!); + }); + + it("should include precipitation probability", async () => { + const result = await env.client.callTool("getForecast", { + location: "Seattle", + days: 1, + }); + + expectToolResult(result).toHaveNoError(); + + const content = result.structuredContent as { + daily?: Array<{ + precipitationProbability?: number; + }>; + }; + + const day = content.daily![0]; + expect(typeof day.precipitationProbability).toBe("number"); + expect(day.precipitationProbability).toBeGreaterThanOrEqual(0); + expect(day.precipitationProbability).toBeLessThanOrEqual(100); + }); + + it("should include sunrise and sunset times", async () => { + const result = await env.client.callTool("getForecast", { + location: "Rome", + days: 1, + }); + + expectToolResult(result).toHaveNoError(); + + const content = result.structuredContent as { + daily?: Array<{ + sunrise?: string; + sunset?: string; + }>; + }; + + const day = content.daily![0]; + expect(day.sunrise).toBeDefined(); + expect(day.sunset).toBeDefined(); + }); + }); + + describe("getWeatherAlerts Tool", () => { + it("should return alerts response for a location", async () => { + const result = await env.client.callTool("getWeatherAlerts", { + location: "Miami", + }); + + expectToolResult(result).toHaveNoError(); + + const content = result.structuredContent as { + location?: { name?: string }; + alerts?: Array; + lastChecked?: string; + }; + + expect(content.location).toBeDefined(); + expect(content.alerts).toBeDefined(); + expect(Array.isArray(content.alerts)).toBe(true); + expect(content.lastChecked).toBeDefined(); + }); + + it("should return valid alert structure when alerts exist", async () => { + // Run multiple times to get alerts (random in mock) + let alertsFound = false; + for (let i = 0; i < 10 && !alertsFound; i++) { + const result = await env.client.callTool("getWeatherAlerts", { + location: "Test City", + }); + + const content = result.structuredContent as { + alerts?: Array<{ + id?: string; + type?: string; + severity?: string; + headline?: string; + description?: string; + startTime?: string; + endTime?: string; + }>; + }; + + if (content.alerts && content.alerts.length > 0) { + alertsFound = true; + const alert = content.alerts[0]; + + expect(alert.id).toBeDefined(); + expect(["warning", "watch", "advisory"]).toContain(alert.type); + expect(["minor", "moderate", "severe", "extreme"]).toContain(alert.severity); + expect(alert.headline).toBeTruthy(); + expect(alert.description).toBeTruthy(); + expect(alert.startTime).toBeDefined(); + expect(alert.endTime).toBeDefined(); + } + } + + // It's ok if no alerts were found in mock mode + expect(true).toBe(true); + }); + }); + + describe("Tool Call History", () => { + it("should track tool call history", async () => { + env.client.clearHistory(); + + await env.client.callTool("getCurrentWeather", { location: "Chicago" }); + await env.client.callTool("getForecast", { location: "Chicago", days: 3 }); + + const history = env.client.getCallHistory(); + + expect(history.length).toBe(2); + expect(history[0].name).toBe("getCurrentWeather"); + expect(history[1].name).toBe("getForecast"); + }); + }); +}); diff --git a/examples/weather-app/tests/setup.ts b/examples/weather-app/tests/setup.ts new file mode 100644 index 00000000..30db93d8 --- /dev/null +++ b/examples/weather-app/tests/setup.ts @@ -0,0 +1,7 @@ +/** + * Test setup for weather-app + */ + +import { setupVitestMatchers } from "@mcp-apps-kit/testing/vitest"; + +setupVitestMatchers(); diff --git a/examples/weather-app/tests/unit/weatherService.test.ts b/examples/weather-app/tests/unit/weatherService.test.ts new file mode 100644 index 00000000..c552b47f --- /dev/null +++ b/examples/weather-app/tests/unit/weatherService.test.ts @@ -0,0 +1,275 @@ +/** + * Unit tests for Weather Service + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { WeatherService, mockWeatherService } from "../../server/services/weatherService.js"; + +describe("WeatherService", () => { + describe("Mock Mode", () => { + const service = new WeatherService({ useMock: true }); + + describe("getCurrentWeather", () => { + it("should return current weather for any location", async () => { + const result = await service.getCurrentWeather("New York"); + + expect(result).toBeDefined(); + expect(result.location.name).toBe("New York"); + expect(typeof result.temperature).toBe("number"); + expect(typeof result.humidity).toBe("number"); + expect(typeof result.windSpeed).toBe("number"); + expect(typeof result.windDirection).toBe("number"); + expect(result.description).toBeTruthy(); + expect(result.icon).toBeTruthy(); + expect(typeof result.isDay).toBe("boolean"); + expect(result.timestamp).toBeTruthy(); + }); + + it("should return known location coordinates from lookup table", async () => { + // Using a known city from the lookup table + const result = await service.getCurrentWeather("New York"); + + expect(result.location.latitude).toBe(40.7128); + expect(result.location.longitude).toBe(-74.006); + expect(result.location.country).toBe("United States"); + expect(result.location.timezone).toBe("America/New_York"); + }); + + it("should return varied coordinates for unknown locations", async () => { + // Unknown locations get hash-based coordinates + const result1 = await service.getCurrentWeather("Atlantis"); + const result2 = await service.getCurrentWeather("Wakanda"); + const result3 = await service.getCurrentWeather("Mordor"); + + // Different locations should have different latitudes (at minimum) + const latitudes = [ + result1.location.latitude, + result2.location.latitude, + result3.location.latitude, + ]; + const uniqueLatitudes = new Set(latitudes); + expect(uniqueLatitudes.size).toBeGreaterThan(1); + + // Same location should return consistent coordinates + const result1Again = await service.getCurrentWeather("Atlantis"); + expect(result1.location.latitude).toBe(result1Again.location.latitude); + expect(result1.location.longitude).toBe(result1Again.location.longitude); + }); + + it("should return valid humidity range", async () => { + const result = await service.getCurrentWeather("Tokyo"); + + expect(result.humidity).toBeGreaterThanOrEqual(30); + expect(result.humidity).toBeLessThanOrEqual(90); + }); + + it("should return valid wind direction", async () => { + const result = await service.getCurrentWeather("London"); + + expect(result.windDirection).toBeGreaterThanOrEqual(0); + expect(result.windDirection).toBeLessThan(360); + }); + }); + + describe("getForecast", () => { + it("should return forecast for default 7 days", async () => { + const result = await service.getForecast("Paris"); + + expect(result).toBeDefined(); + expect(result.location.name).toBe("Paris"); + expect(result.daily).toHaveLength(7); + expect(result.generatedAt).toBeTruthy(); + }); + + it("should return forecast for specified number of days", async () => { + const result = await service.getForecast("Berlin", 3); + + expect(result.daily).toHaveLength(3); + }); + + it("should limit forecast to maximum 16 days", async () => { + const result = await service.getForecast("Sydney", 20); + + expect(result.daily).toHaveLength(16); + }); + + it("should ensure minimum 1 day forecast", async () => { + const result = await service.getForecast("Rome", 0); + + expect(result.daily).toHaveLength(1); + }); + + it("should return valid daily forecast data", async () => { + const result = await service.getForecast("Madrid", 1); + const day = result.daily[0]; + + expect(day.date).toBeTruthy(); + expect(typeof day.temperatureMax).toBe("number"); + expect(typeof day.temperatureMin).toBe("number"); + expect(day.temperatureMax).toBeGreaterThanOrEqual(day.temperatureMin); + expect(typeof day.precipitationProbability).toBe("number"); + expect(day.precipitationProbability).toBeGreaterThanOrEqual(0); + expect(day.precipitationProbability).toBeLessThanOrEqual(100); + expect(typeof day.windSpeedMax).toBe("number"); + expect(day.sunrise).toBeTruthy(); + expect(day.sunset).toBeTruthy(); + expect(day.description).toBeTruthy(); + expect(day.icon).toBeTruthy(); + }); + + it("should return sequential dates", async () => { + const result = await service.getForecast("Chicago", 5); + + for (let i = 1; i < result.daily.length; i++) { + const prevDate = new Date(result.daily[i - 1].date); + const currDate = new Date(result.daily[i].date); + const diffDays = (currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24); + expect(diffDays).toBe(1); + } + }); + }); + + describe("getAlerts", () => { + it("should return alerts response for any location", async () => { + const result = await service.getAlerts("Miami"); + + expect(result).toBeDefined(); + expect(result.location.name).toBe("Miami"); + expect(Array.isArray(result.alerts)).toBe(true); + expect(result.lastChecked).toBeTruthy(); + }); + + it("should return valid alert structure when alerts exist", async () => { + // Run multiple times since mock generates random alerts + let alertFound = false; + for (let i = 0; i < 20 && !alertFound; i++) { + const result = await service.getAlerts("Test City"); + + if (result.alerts.length > 0) { + alertFound = true; + const alert = result.alerts[0]; + + expect(alert.id).toBeTruthy(); + expect(["warning", "watch", "advisory"]).toContain(alert.type); + expect(["minor", "moderate", "severe", "extreme"]).toContain(alert.severity); + expect(alert.headline).toBeTruthy(); + expect(alert.description).toBeTruthy(); + expect(alert.startTime).toBeTruthy(); + expect(alert.endTime).toBeTruthy(); + + // End time should be after start time + const startTime = new Date(alert.startTime).getTime(); + const endTime = new Date(alert.endTime).getTime(); + expect(endTime).toBeGreaterThan(startTime); + } + } + }); + + it("should return 0-2 alerts randomly", async () => { + const alertCounts = new Set(); + + // Run many times to check randomness + for (let i = 0; i < 30; i++) { + const result = await service.getAlerts("Random City"); + alertCounts.add(result.alerts.length); + } + + // Should have at least 2 different counts (0, 1, or 2) + expect(alertCounts.size).toBeGreaterThan(1); + + // All counts should be 0, 1, or 2 + for (const count of alertCounts) { + expect(count).toBeGreaterThanOrEqual(0); + expect(count).toBeLessThanOrEqual(2); + } + }); + }); + }); + + describe("Exported mockWeatherService", () => { + it("should be a WeatherService instance in mock mode", async () => { + // Use a known location from lookup table + const result = await mockWeatherService.getCurrentWeather("London"); + + // Should return London coordinates from lookup + expect(result.location.latitude).toBe(51.5074); + expect(result.location.longitude).toBe(-0.1278); + expect(result.location.country).toBe("United Kingdom"); + }); + }); + + describe("Real API Mode (with fallback)", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("should fallback to mock data when geocoding fails", async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 500, + } as Response); + + const service = new WeatherService({ useMock: false }); + const result = await service.getCurrentWeather("Unknown City"); + + // Should still return mock data + expect(result).toBeDefined(); + expect(result.location.name).toBe("Unknown City"); + expect(typeof result.temperature).toBe("number"); + }); + + it("should fallback to mock data when network fails", async () => { + fetchSpy.mockRejectedValue(new Error("Network error")); + + const service = new WeatherService({ useMock: false }); + const result = await service.getCurrentWeather("Test City"); + + // Should still return mock data + expect(result).toBeDefined(); + expect(result.location.name).toBe("Test City"); + }); + + it("should fallback to mock when geocoding returns no results", async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => ({ results: [] }), + } as Response); + + const service = new WeatherService({ useMock: false }); + const result = await service.getCurrentWeather("Nonexistent City"); + + expect(result).toBeDefined(); + expect(result.location.name).toBe("Nonexistent City"); + }); + }); + + describe("Weather Code Descriptions", () => { + it("should return appropriate icons for different weather codes", async () => { + const service = new WeatherService({ useMock: true }); + + // Run multiple times to check different weather codes + const icons = new Set(); + for (let i = 0; i < 20; i++) { + const result = await service.getCurrentWeather("Test"); + icons.add(result.icon); + } + + // Should have weather-related emojis + const weatherEmojis = ["☀️", "🌤️", "⛅", "☁️", "🌧️", "🌦️"]; + let foundWeatherEmoji = false; + for (const icon of icons) { + if (weatherEmojis.includes(icon)) { + foundWeatherEmoji = true; + break; + } + } + expect(foundWeatherEmoji).toBe(true); + }); + }); +}); diff --git a/examples/weather-app/tsconfig.json b/examples/weather-app/tsconfig.json new file mode 100644 index 00000000..2b2ce634 --- /dev/null +++ b/examples/weather-app/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "dist", + "jsx": "react-jsx" + }, + "include": ["server/**/*", "ui/src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/weather-app/ui/index.html b/examples/weather-app/ui/index.html new file mode 100644 index 00000000..f03e9564 --- /dev/null +++ b/examples/weather-app/ui/index.html @@ -0,0 +1,12 @@ + + + + + + weather-app + + +
+ + + diff --git a/examples/weather-app/ui/src/App.tsx b/examples/weather-app/ui/src/App.tsx new file mode 100644 index 00000000..eb4f50b2 --- /dev/null +++ b/examples/weather-app/ui/src/App.tsx @@ -0,0 +1,249 @@ +/** + * Weather App - UI Component + * + * Displays weather data from the MCP server tools. + * Handles three different result types: current weather, forecast, and alerts. + */ + +import { + useAppsClient, + useToolResult, + useHostContext, + useDocumentTheme, + useHostStyleVariables, +} from "@mcp-apps-kit/ui-react"; +import type { AppTools } from "../../server/index.js"; +import type { + CurrentWeather, + WeatherForecast, + WeatherAlertsResponse, +} from "../../server/services/weatherService.js"; + +// Component for displaying current weather +function CurrentWeatherDisplay({ data }: { data: CurrentWeather }) { + const windDirections = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; + const windDir = windDirections[Math.round(data.windDirection / 45) % 8]; + + return ( +
+
+

{data.location.name}

+ {data.location.country && {data.location.country}} +
+ +
+ {data.icon} +
+ {Math.round(data.temperature)}°C + Feels like {Math.round(data.feelsLike)}°C +
+
+ +

{data.description}

+ +
+
+ 💧 + Humidity + {data.humidity}% +
+
+ 💨 + Wind + + {Math.round(data.windSpeed)} km/h {windDir} + +
+
+ +

Updated: {new Date(data.timestamp).toLocaleString()}

+
+ ); +} + +// Component for displaying forecast +function ForecastDisplay({ data }: { data: WeatherForecast }) { + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + if (date.toDateString() === today.toDateString()) return "Today"; + if (date.toDateString() === tomorrow.toDateString()) return "Tomorrow"; + + return date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); + }; + + return ( +
+
+

{data.location.name} Forecast

+ {data.location.country && {data.location.country}} +
+ +
+ {data.daily.map((day) => ( +
+ {formatDate(day.date)} + {day.icon} +
+ {Math.round(day.temperatureMax)}° + {Math.round(day.temperatureMin)}° +
+ + {day.precipitationProbability > 0 && `${day.precipitationProbability}%`} + +
+ ))} +
+ +

Generated: {new Date(data.generatedAt).toLocaleString()}

+
+ ); +} + +// Component for displaying alerts +function AlertsDisplay({ data }: { data: WeatherAlertsResponse }) { + const severityColors: Record = { + minor: "#ffc107", + moderate: "#fd7e14", + severe: "#dc3545", + extreme: "#6f42c1", + }; + + return ( +
+
+

{data.location.name} Alerts

+ {data.location.country && {data.location.country}} +
+ + {data.alerts.length === 0 ? ( +
+ +

No active weather alerts

+
+ ) : ( +
+ {data.alerts.map((alert) => ( +
+
+ + {alert.type.toUpperCase()} + + {alert.severity} +
+

{alert.headline}

+

{alert.description}

+

+ {new Date(alert.startTime).toLocaleString()} -{" "} + {new Date(alert.endTime).toLocaleString()} +

+
+ ))} +
+ )} + +

Last checked: {new Date(data.lastChecked).toLocaleString()}

+
+ ); +} + +// Determine which type of result we have +function isCurrentWeather(data: unknown): data is CurrentWeather { + return !!data && typeof data === "object" && "temperature" in data && "humidity" in data; +} + +function isForecast(data: unknown): data is WeatherForecast { + return ( + !!data && + typeof data === "object" && + "daily" in data && + Array.isArray((data as WeatherForecast).daily) + ); +} + +function isAlerts(data: unknown): data is WeatherAlertsResponse { + return ( + !!data && + typeof data === "object" && + "alerts" in data && + Array.isArray((data as WeatherAlertsResponse).alerts) + ); +} + +export function App() { + const client = useAppsClient(); + const result = useToolResult(); + const context = useHostContext(); + + // Apply theme and host styles + useDocumentTheme("light", "dark"); + useHostStyleVariables(); + + // Extract the actual data from the result + // Handle both wrapped ({ toolName: data }) and unwrapped (data) formats + const rawResult = + result?.getCurrentWeather ?? result?.getForecast ?? result?.getWeatherAlerts ?? result; + + // Determine what type of data we have + let content: React.ReactNode; + + if (!rawResult) { + content = ( +
+ 🌤️ +

Waiting for weather data...

+

Ask the AI to check the weather for a location!

+
+ ); + } else if (isCurrentWeather(rawResult)) { + content = ; + } else if (isForecast(rawResult)) { + content = ; + } else if (isAlerts(rawResult)) { + content = ; + } else { + content = ( +
+

Unable to display weather data

+
+ ); + } + + return ( +
+ {content} + +
+ + + +
+ +
+ Theme: {context.theme} | Locale: {context.locale} +
+
+ ); +} diff --git a/examples/weather-app/ui/src/main.tsx b/examples/weather-app/ui/src/main.tsx new file mode 100644 index 00000000..311f5c8b --- /dev/null +++ b/examples/weather-app/ui/src/main.tsx @@ -0,0 +1,20 @@ +/** + * weather-app - UI Entry Point + */ + +import React from "react"; +import { createRoot } from "react-dom/client"; +import { AppsProvider } from "@mcp-apps-kit/ui-react"; +import { App } from "./App"; +import "./styles.css"; + +const root = document.getElementById("root"); +if (!root) throw new Error("Root element not found"); + +createRoot(root).render( + + + + + +); diff --git a/examples/weather-app/ui/src/styles.css b/examples/weather-app/ui/src/styles.css new file mode 100644 index 00000000..a726cac6 --- /dev/null +++ b/examples/weather-app/ui/src/styles.css @@ -0,0 +1,419 @@ +/** + * Weather App Styles + */ + +:root { + --bg-primary: #f5f7fa; + --bg-card: #ffffff; + --text-primary: #1a1a2e; + --text-secondary: #6b7280; + --text-muted: #9ca3af; + --border-color: #e5e7eb; + --accent-color: #3b82f6; + --accent-hover: #2563eb; + --success-color: #10b981; + --warning-color: #f59e0b; + --danger-color: #ef4444; + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.dark { + --bg-primary: #111827; + --bg-card: #1f2937; + --text-primary: #f9fafb; + --text-secondary: #d1d5db; + --text-muted: #9ca3af; + --border-color: #374151; + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background-color: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; +} + +.container { + max-width: 480px; + margin: 0 auto; + padding: 20px; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Weather Card Base */ +.weather-card { + background: var(--bg-card); + border-radius: 16px; + padding: 24px; + box-shadow: var(--shadow); + margin-bottom: 16px; +} + +.weather-header { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 16px; +} + +.weather-header h2 { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); +} + +.country { + font-size: 0.875rem; + color: var(--text-muted); +} + +/* Current Weather */ +.current-weather .weather-main { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 12px; +} + +.weather-icon { + font-size: 4rem; + line-height: 1; +} + +.temperature { + display: flex; + flex-direction: column; +} + +.temp-value { + font-size: 3rem; + font-weight: 700; + line-height: 1; + color: var(--text-primary); +} + +.temp-feels { + font-size: 0.875rem; + color: var(--text-secondary); + margin-top: 4px; +} + +.weather-description { + font-size: 1.125rem; + color: var(--text-secondary); + margin-bottom: 16px; +} + +.weather-details { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding: 16px; + background: var(--bg-primary); + border-radius: 12px; + margin-bottom: 12px; +} + +.detail { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.detail-icon { + font-size: 1.5rem; + margin-bottom: 4px; +} + +.detail-label { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.detail-value { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} + +/* Forecast */ +.forecast-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.forecast-day { + display: grid; + grid-template-columns: 1fr auto auto auto; + align-items: center; + gap: 12px; + padding: 12px; + background: var(--bg-primary); + border-radius: 8px; +} + +.forecast-date { + font-weight: 500; + color: var(--text-primary); +} + +.forecast-icon { + font-size: 1.5rem; +} + +.forecast-temps { + display: flex; + gap: 8px; + font-weight: 500; +} + +.temp-high { + color: var(--text-primary); +} + +.temp-low { + color: var(--text-muted); +} + +.forecast-precip { + font-size: 0.875rem; + color: var(--accent-color); + min-width: 40px; + text-align: right; +} + +/* Alerts */ +.no-alerts { + display: flex; + flex-direction: column; + align-items: center; + padding: 32px; + text-align: center; +} + +.check-icon { + font-size: 3rem; + color: var(--success-color); + margin-bottom: 8px; +} + +.no-alerts p { + color: var(--text-secondary); +} + +.alerts-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.alert-item { + padding: 16px; + background: var(--bg-primary); + border-radius: 8px; + border-left: 4px solid var(--warning-color); +} + +.alert-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.alert-type { + font-size: 0.75rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + background: var(--warning-color); + color: white; +} + +.alert-type-warning { + background: var(--danger-color); +} + +.alert-type-watch { + background: var(--warning-color); +} + +.alert-type-advisory { + background: var(--accent-color); +} + +.alert-severity { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: capitalize; +} + +.alert-headline { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.alert-description { + font-size: 0.875rem; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.alert-times { + font-size: 0.75rem; + color: var(--text-muted); +} + +/* Timestamp */ +.timestamp { + font-size: 0.75rem; + color: var(--text-muted); + text-align: center; + margin-top: 12px; +} + +/* Waiting State */ +.waiting { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; + background: var(--bg-card); + border-radius: 16px; + box-shadow: var(--shadow); + margin-bottom: 16px; +} + +.loading-icon { + font-size: 4rem; + margin-bottom: 16px; + animation: float 3s ease-in-out infinite; +} + +@keyframes float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + +.waiting p { + color: var(--text-secondary); + margin-bottom: 4px; +} + +.waiting .hint { + font-size: 0.875rem; + color: var(--text-muted); +} + +/* Error State */ +.error { + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background: var(--bg-card); + border-radius: 16px; + box-shadow: var(--shadow); + margin-bottom: 16px; +} + +.error p { + color: var(--danger-color); +} + +/* Actions */ +.actions { + display: flex; + gap: 8px; + margin-top: auto; + padding-top: 16px; +} + +.button { + flex: 1; + padding: 12px 16px; + border: none; + border-radius: 8px; + background: var(--accent-color); + color: white; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: + background-color 0.2s, + transform 0.1s; +} + +.button:hover { + background: var(--accent-hover); +} + +.button:active { + transform: scale(0.98); +} + +/* Footer */ +.meta { + text-align: center; + font-size: 0.75rem; + color: var(--text-muted); + padding-top: 16px; + margin-top: 8px; + border-top: 1px solid var(--border-color); +} + +/* Responsive adjustments */ +@media (max-width: 400px) { + .container { + padding: 12px; + } + + .weather-card { + padding: 16px; + } + + .weather-icon { + font-size: 3rem; + } + + .temp-value { + font-size: 2.5rem; + } + + .actions { + flex-direction: column; + } + + .forecast-day { + grid-template-columns: 1fr auto auto; + } + + .forecast-precip { + display: none; + } +} diff --git a/examples/weather-app/ui/vite.config.ts b/examples/weather-app/ui/vite.config.ts new file mode 100644 index 00000000..9edae52b --- /dev/null +++ b/examples/weather-app/ui/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + root: "./ui", + build: { + outDir: "dist", + emptyOutDir: true, + }, +}); diff --git a/examples/weather-app/vitest.config.ts b/examples/weather-app/vitest.config.ts new file mode 100644 index 00000000..83cf9c54 --- /dev/null +++ b/examples/weather-app/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["tests/**/*.{test,spec}.{ts,tsx}"], + testTimeout: 30000, + hookTimeout: 30000, + setupFiles: ["./tests/setup.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b37c4cc..ac712290 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -206,6 +206,73 @@ importers: specifier: ^2.3.0 version: 2.3.0(rollup@4.54.0)(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0)(yaml@2.8.2)) + examples/weather-app: + dependencies: + '@mcp-apps-kit/core': + specifier: workspace:* + version: link:../../packages/core + '@mcp-apps-kit/ui': + specifier: workspace:* + version: link:../../packages/ui + '@mcp-apps-kit/ui-react': + specifier: workspace:* + version: link:../../packages/ui-react + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + zod: + specifier: ^4.0.0 + version: 4.2.1 + devDependencies: + '@mcp-apps-kit/testing': + specifier: workspace:* + version: link:../../packages/testing + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.1 + version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@types/node': + specifier: ^25.0.3 + version: 25.0.3 + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^5.1.2 + version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0)(yaml@2.8.2)) + concurrently: + specifier: ^9.2.1 + version: 9.2.1 + jsdom: + specifier: ^27.3.0 + version: 27.3.0 + tsx: + specifier: ^4.0.0 + version: 4.21.0 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vite: + specifier: ^7.3.0 + version: 7.3.0(@types/node@25.0.3)(tsx@4.21.0)(yaml@2.8.2) + vite-plugin-singlefile: + specifier: ^2.3.0 + version: 2.3.0(rollup@4.54.0)(vite@7.3.0(@types/node@25.0.3)(tsx@4.21.0)(yaml@2.8.2)) + vitest: + specifier: ^4.0.16 + version: 4.0.16(@types/node@25.0.3)(jsdom@27.3.0)(tsx@4.21.0)(yaml@2.8.2) + packages/core: dependencies: '@modelcontextprotocol/sdk':