index.ts•13.7 kB
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
// 获取当前文件路径和目录
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
// wttr.in API 的基础 URL
// wttr.in API base URL
const WTTR_IN_API_URL = "https://wttr.in";
// 缓存配置
const cache = new Map<string, { data: any; expiry: number }>();
const CACHE_TTL = 10 * 60 * 1000; // 10分钟
// ---------------------------------------------------------------------------------------------------------------------
// 工具函数 / Utility Functions
// ---------------------------------------------------------------------------------------------------------------------
/**
* 日志工具
* Logger utility
*/
const logger = {
info: (message: string, ...args: any[]) => console.error(`[INFO] ${message}`, ...args),
error: (message: string, ...args: any[]) => console.error(`[ERROR] ${message}`, ...args),
warn: (message: string, ...args: any[]) => console.error(`[WARN] ${message}`, ...args),
};
/**
* 带超时的 fetch 请求
* Fetch with timeout
*/
async function fetchWithTimeout(url: string, timeout = 5000): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
if ((error as Error).name === 'AbortError') {
throw new Error(`请求超时: ${url}`);
}
throw error;
}
}
/**
* 获取缓存数据
* Get cached data
*/
function getCachedData(key: string): any | null {
const cached = cache.get(key);
if (cached && cached.expiry > Date.now()) {
return cached.data;
}
cache.delete(key);
return null;
}
/**
* 设置缓存数据
* Set cached data
*/
function setCachedData(key: string, data: any): void {
cache.set(key, { data, expiry: Date.now() + CACHE_TTL });
}
/**
* 格式化实时天气数据
* Format current weather data
*/
function formatCurrentWeather(current: WttrInCurrentCondition, area: string): string {
return `地点: ${area}\n` +
`当前天气:\n` +
` 天气描述: ${current.weatherDesc[0].value}\n` +
` 温度: ${current.temp_C}°C (体感温度: ${current.FeelsLikeC}°C)\n` +
` 风速: ${current.windspeedKmph} km/h, 风向: ${current.winddir16Point}\n` +
` 湿度: ${current.humidity}%, 气压: ${current.pressure}hPa\n` +
` 紫外线指数: ${current.uvIndex}`;
}
/**
* 格式化三天天气预报数据
* Format 3-day weather forecast data
*/
function formatForecast(weather: WttrInDayForecast[], area: string): string {
let text = `地点: ${area}\n三天天气预报:\n`;
weather.slice(0, 3).forEach(day => {
text += `------------------------\n` +
`日期: ${day.date} (日出: ${day.sun_rise}, 日落: ${day.sun_set})\n` +
` 最高温: ${day.maxtempC}°C, 最低温: ${day.mintempC}°C\n` +
` 平均温度: ${day.avgtempC}°C\n` +
` 天气: ${day.hourly[0].weatherDesc[0].value}\n` +
` 降水概率: ${day.hourly[0].chanceofrain}%\n`;
});
return text + `------------------------`;
}
// ---------------------------------------------------------------------------------------------------------------------
// 定义 wttr.in API 响应体的 TypeScript 接口
// Define TypeScript interfaces for wttr.in API response body
// ---------------------------------------------------------------------------------------------------------------------
interface WttrInFeelsLike {
value: string;
}
interface WttrInCurrentCondition {
temp_C: string;
weatherDesc: [{ value: string }];
windspeedKmph: string;
winddir16Point: string;
humidity: string;
pressure: string;
uvIndex: string;
FeelsLikeC: string;
}
interface WttrInDayForecast {
date: string;
maxtempC: string;
mintempC: string;
avgtempC: string;
sun_rise: string;
sun_set: string;
moon_rise: string;
moon_set: string;
hourly: Array<{
time: string;
tempC: string;
weatherDesc: [{ value: string }];
chanceofrain: string;
}>;
}
interface WttrInResponse {
current_condition?: WttrInCurrentCondition[];
weather?: WttrInDayForecast[];
nearest_area?: Array<{
areaName: [{ value: string }];
country: [{ value: string }];
region: [{ value: string }];
}>;
}
// ---------------------------------------------------------------------------------------------------------------------
// 集中定义工具的 Schema 和元数据
// Centralized definition of tool schemas and metadata
// ---------------------------------------------------------------------------------------------------------------------
// 定义 wttr.in 天气查询参数的 Zod schema
const getWeatherSchema = z.object({
location: z.string().describe("需要查询的城市名称、邮政编码或经纬度坐标。例如: 'beijing', '90210', '40.71,-74.00'。支持中文城市名称,如'北京'、'上海'等。"),
days: z.enum(['now', '3d']).default('3d').describe("预报类型。必须是以下值之一:'now' 表示获取实时天气,'3d' 表示获取未来三天预报。默认值为 '3d'。注意:请严格使用 'now' 或 '3d',不要使用数字 '3'。"),
});
// 定义获取日期时间工具的参数 Zod schema
const getDatetimeSchema = z.object({
timezone: z.string().optional().describe("可选的时区,例如 'America/New_York', 'Europe/London'。如果未提供,默认为 'Asia/Shanghai'。"),
});
// 统一管理所有工具的定义
const tools = {
get_weather_wttr: {
description: "获取指定地点的天气预报。支持城市名称、邮政编码或经纬度坐标作为输入。可以获取实时天气(days='now')或未来三天预报(days='3d')。注意:days参数必须严格使用 'now' 或 '3d' 字符串,不要使用数字。",
schema: getWeatherSchema,
},
get_datetime: {
description: "获取当前的日期和时间。可以提供一个可选的时区参数(如 'America/New_York', 'Europe/London'),默认为 'Asia/Shanghai'。",
schema: getDatetimeSchema,
},
};
/**
* 辅助函数:从 Zod schema 生成 OpenAPI 格式的 inputSchema
* Helper function: Generate OpenAPI format inputSchema from Zod schema
*/
function createInputSchemaFromZod(schema: z.AnyZodObject) {
const properties: { [key: string]: any } = {};
const required: string[] = [];
for (const key in schema.shape) {
let zodType = schema.shape[key];
const originalType = zodType;
// 处理可选和默认值包装
const isOptional = zodType.isOptional();
if (zodType._def.typeName === 'ZodOptional' || zodType._def.typeName === 'ZodDefault') {
zodType = zodType._def.innerType || zodType;
}
const property: any = {
type: 'string',
description: zodType.description,
};
// 处理枚举类型
if (zodType._def.typeName === 'ZodEnum') {
property.enum = zodType._def.values;
}
// 正确处理 ZodDefault 的默认值
if (originalType._def.typeName === 'ZodDefault') {
const defaultValue = originalType._def.defaultValue;
property.default = typeof defaultValue === 'function' ? defaultValue() : defaultValue;
}
properties[key] = property;
// 只有非可选且非默认值的字段才是必填的
if (!isOptional && originalType._def.typeName !== 'ZodOptional') {
required.push(key);
}
}
return {
type: "object",
properties,
required,
};
}
// ---------------------------------------------------------------------------------------------------------------------
// Server Setup
// ---------------------------------------------------------------------------------------------------------------------
// 创建服务器实例
// Create server instance
const server = new Server(
{
name: packageJson.name,
version: packageJson.version,
},
{
capabilities: {
tools: {},
},
}
);
// 列出可用工具
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
// 动态生成工具列表
const toolList = Object.entries(tools).map(([name, tool]) => ({
name: name,
description: tool.description,
inputSchema: createInputSchemaFromZod(tool.schema),
}));
return { tools: toolList };
});
// 处理工具执行
// Handle tool execution
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
if (name === "get_weather_wttr") {
const { location, days } = getWeatherSchema.parse(args);
// 检查缓存
const cacheKey = `weather:${location}:${days}`;
const cachedData = getCachedData(cacheKey);
if (cachedData) {
logger.info(`返回缓存的天气数据: ${location} (${days})`);
return { content: [{ type: "text", text: cachedData }] };
}
// 构建 wttr.in 的 API URL,请求 JSON 格式
const fullUrl = `${WTTR_IN_API_URL}/${encodeURIComponent(location)}?format=j1`;
logger.info(`Fetching weather from wttr.in: ${fullUrl}`);
const response = await fetchWithTimeout(fullUrl);
if (!response.ok) {
return {
content: [{ type: "text", text: `从 wttr.in 获取天气数据失败,状态码: ${response.status}` }],
};
}
const data: WttrInResponse = await response.json();
if (!data.current_condition) {
return {
content: [{ type: "text", text: `未找到 ${location} 的天气数据,请检查输入是否正确。` }],
};
}
const area = data.nearest_area?.[0]?.areaName?.[0]?.value || location;
let weatherText: string;
// 处理实时天气
if (days === 'now') {
const current = data.current_condition[0];
weatherText = formatCurrentWeather(current, area);
}
// 处理三天预报
else if (days === '3d' && data.weather) {
weatherText = formatForecast(data.weather, area);
} else {
weatherText = `无法获取 ${location} 的天气预报数据`;
}
// 缓存结果
setCachedData(cacheKey, weatherText);
return { content: [{ type: "text", text: weatherText }] };
} else if (name === "get_datetime") {
const { timezone } = getDatetimeSchema.parse(args);
const currentDateTime = new Date();
let dateTimeString: string;
const targetTimezone = timezone || 'Asia/Shanghai'; // Default to Asia/Shanghai
try {
// Use toLocaleString with options to get time in the target timezone
dateTimeString = currentDateTime.toLocaleString('zh-CN', { timeZone: targetTimezone });
} catch (error) {
// Fallback to a known good default if specific timezone fails
logger.warn(`无效的时区 "${targetTimezone}" 或格式化错误:`, error);
const fallbackTimezone = 'Asia/Shanghai';
dateTimeString = currentDateTime.toLocaleString('zh-CN', { timeZone: fallbackTimezone });
dateTimeString += ` (无法识别提供的时区 "${timezone}", 已回退到 ${fallbackTimezone})`;
}
return {
content: [{ type: "text", text: `当前日期时间 (${targetTimezone}): ${dateTimeString}` }],
};
}
else {
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
if (error instanceof z.ZodError) {
logger.error("参数校验错误:", error.errors);
const errorMessages = error.errors.map(e => `${e.path.join(".")}: ${e.message}`).join("; ");
return {
content: [{ type: "text", text: `输入参数无效: ${errorMessages}` }],
};
}
logger.error(`工具 "${name}" 执行出错:`, error);
return {
content: [{ type: "text", text: `执行工具 "${name}" 时发生内部错误: ${error instanceof Error ? error.message : String(error)}` }],
};
}
});
// 启动服务器
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
logger.info("wttr-mcp-server MCP Server running on stdio. Waiting for requests...");
}
main().catch((error) => {
logger.error("主程序发生严重错误:", error);
process.exit(1);
});