open-meteo-mcp-server
Version:
Model Context Protocol server for Open-Meteo weather APIs
396 lines • 20.4 kB
JavaScript
import 'dotenv/config';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
import { z } from 'zod';
import { OpenMeteoClient } from './client.js';
import { createAcceptNormalizer, createAuthMiddleware, createOriginValidator, createRateLimiter, generateSessionId, getClientIp, sanitizeErrorMessage, } from './security.js';
import { AIR_QUALITY_TOOL, CLIMATE_PROJECTION_TOOL, DWD_ICON_FORECAST_TOOL, ECMWF_FORECAST_TOOL, ELEVATION_TOOL, ENSEMBLE_FORECAST_TOOL, FLOOD_FORECAST_TOOL, GEM_FORECAST_TOOL, GEOCODING_TOOL, GFS_FORECAST_TOOL, JMA_FORECAST_TOOL, MARINE_WEATHER_TOOL, METEOFRANCE_FORECAST_TOOL, METNO_FORECAST_TOOL, SEASONAL_FORECAST_TOOL, WEATHER_ARCHIVE_TOOL, WEATHER_FORECAST_TOOL, } from './tools.js';
import { serializeToolResponse } from './truncation.js';
import { AirQualityParamsSchema, ArchiveParamsSchema, ClimateParamsSchema, DwdIconParamsSchema, EcmwfParamsSchema, ElevationParamsSchema, EnsembleParamsSchema, FloodParamsSchema, ForecastParamsSchema, GemParamsSchema, GeocodingParamsSchema, GfsParamsSchema, JmaParamsSchema, MarineParamsSchema, MeteoFranceParamsSchema, MetnoParamsSchema, SeasonalParamsSchema, } from './types.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
// Structured JSON logger — writes to stderr to stay out of MCP stdio protocol
function log(level, event, data = {}) {
process.stderr.write(`${JSON.stringify({ timestamp: new Date().toISOString(), level, event, ...data })}\n`);
}
export class OpenMeteoMCPServer {
client;
sessionServers = new Map();
static SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour idle timeout
static MAX_SESSIONS = 100;
static CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // run cleanup every 5 minutes
constructor() {
const baseURL = process.env.OPEN_METEO_API_URL || 'https://api.open-meteo.com';
this.client = new OpenMeteoClient(baseURL, pkg.version);
}
// Registers a read-only tool: wires the Zod schema (validation + generated
// JSON schema), and wraps the handler with logging, response truncation,
// and MCP-style error results shared across all 17 tools.
//
// The schemas arrive here as a common `z.ZodTypeAny` rather than the concrete
// per-tool types registerTool's generic overloads expect, so the call below
// goes through an untyped signature; each call site's `handler` still gets a
// precise, explicitly-annotated params type. Note this cast also silences the
// SDK's compile-time check on `inputSchema` — see the ZodEffects handling
// below for what that check would otherwise have caught.
registerReadOnlyTool(server, meta, schema, handler) {
const registerToolUntyped = server.registerTool.bind(server);
// `.refine()` wraps the object in a ZodEffects, which the SDK cannot
// introspect: it would publish an empty `{}` input schema while still
// validating strictly, leaving clients with no idea what to send. Publish
// the underlying object and re-apply the effects below.
const objectSchema = schema instanceof z.ZodEffects ? schema.innerType() : schema;
registerToolUntyped(meta.name, {
title: meta.title,
description: meta.description,
inputSchema: objectSchema,
annotations: meta.annotations,
}, async (params) => {
const start = Date.now();
log('info', 'tool_call', { tool: meta.name, args: params });
// Cross-field rules (e.g. start_date <= end_date) live in the effects
// the SDK never sees, so they are enforced here.
const refined = schema.safeParse(params);
if (!refined.success) {
const message = refined.error.issues.map((issue) => issue.message).join('; ');
log('error', 'tool_error', { tool: meta.name, error: message, duration_ms: 0 });
return {
content: [{ type: 'text', text: `Error: ${message}` }],
isError: true,
};
}
try {
const result = await handler(refined.data);
const responseText = serializeToolResponse(result);
log('info', 'tool_success', {
tool: meta.name,
response_size: responseText.length,
duration_ms: Date.now() - start,
});
return { content: [{ type: 'text', text: responseText }] };
}
catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
log('error', 'tool_error', {
tool: meta.name,
error: message,
duration_ms: Date.now() - start,
});
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
}
});
}
createServer() {
const server = new McpServer({
name: 'open-meteo-mcp-server',
version: pkg.version,
}, {
capabilities: {
tools: {},
},
});
this.registerReadOnlyTool(server, WEATHER_FORECAST_TOOL, ForecastParamsSchema, (params) => this.client.getForecast(params));
this.registerReadOnlyTool(server, WEATHER_ARCHIVE_TOOL, ArchiveParamsSchema, (params) => this.client.getArchive(params));
this.registerReadOnlyTool(server, AIR_QUALITY_TOOL, AirQualityParamsSchema, (params) => this.client.getAirQuality(params));
this.registerReadOnlyTool(server, MARINE_WEATHER_TOOL, MarineParamsSchema, (params) => this.client.getMarine(params));
this.registerReadOnlyTool(server, ELEVATION_TOOL, ElevationParamsSchema, (params) => this.client.getElevation(params));
this.registerReadOnlyTool(server, FLOOD_FORECAST_TOOL, FloodParamsSchema, (params) => this.client.getFlood(params));
this.registerReadOnlyTool(server, GEOCODING_TOOL, GeocodingParamsSchema, (params) => this.client.getGeocoding(params));
this.registerReadOnlyTool(server, DWD_ICON_FORECAST_TOOL, DwdIconParamsSchema, (params) => this.client.getDwdIcon(params));
this.registerReadOnlyTool(server, GFS_FORECAST_TOOL, GfsParamsSchema, (params) => this.client.getGfs(params));
this.registerReadOnlyTool(server, METEOFRANCE_FORECAST_TOOL, MeteoFranceParamsSchema, (params) => this.client.getMeteoFrance(params));
this.registerReadOnlyTool(server, ECMWF_FORECAST_TOOL, EcmwfParamsSchema, (params) => this.client.getEcmwf(params));
this.registerReadOnlyTool(server, JMA_FORECAST_TOOL, JmaParamsSchema, (params) => this.client.getJma(params));
this.registerReadOnlyTool(server, METNO_FORECAST_TOOL, MetnoParamsSchema, (params) => this.client.getMetno(params));
this.registerReadOnlyTool(server, GEM_FORECAST_TOOL, GemParamsSchema, (params) => this.client.getGem(params));
this.registerReadOnlyTool(server, SEASONAL_FORECAST_TOOL, SeasonalParamsSchema, (params) => this.client.getSeasonal(params));
this.registerReadOnlyTool(server, CLIMATE_PROJECTION_TOOL, ClimateParamsSchema, (params) => this.client.getClimate(params));
this.registerReadOnlyTool(server, ENSEMBLE_FORECAST_TOOL, EnsembleParamsSchema, (params) => this.client.getEnsemble(params));
return server;
}
getSession(sessionId) {
const session = this.sessionServers.get(sessionId);
if (session) {
session.lastActivity = Date.now();
}
return session;
}
startCleanupTimer() {
const timer = setInterval(() => {
const now = Date.now();
for (const [id, session] of this.sessionServers) {
if (now - session.lastActivity > OpenMeteoMCPServer.SESSION_TTL_MS) {
session.server.close().catch(() => { });
this.sessionServers.delete(id);
log('info', 'session_expired', { session_id: id.substring(0, 8) });
}
}
}, OpenMeteoMCPServer.CLEANUP_INTERVAL_MS);
// Don't keep the process alive just for cleanup
timer.unref();
}
buildExpressApp() {
const app = express();
app.use(express.json());
// Health check endpoint
app.get('/health', (_req, res) => {
res.status(200).json({ status: 'ok' });
});
app.use(createAcceptNormalizer());
// Every guard below must be registered BEFORE the routes it protects:
// Express runs middleware in declaration order, so anything mounted after a
// route never runs for it. These three previously sat between the DELETE and
// POST handlers, leaving GET and DELETE unauthenticated and unthrottled.
app.use(createOriginValidator());
app.use(createRateLimiter());
app.use(createAuthMiddleware());
// GET /mcp — SSE streaming for server-to-client notifications
app.get('/mcp', async (req, res) => {
const remoteIp = getClientIp(req);
const userAgent = req.headers['user-agent'] ?? 'unknown';
try {
const sessionId = req.headers['mcp-session-id'];
log('info', 'http_request', {
method: 'GET',
session_id: sessionId ? sessionId.substring(0, 8) : null,
remote_ip: remoteIp,
user_agent: userAgent,
});
if (!sessionId) {
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32600, message: 'Invalid Request: Session ID required' },
id: null,
});
return;
}
const session = this.getSession(sessionId);
if (!session) {
log('warn', 'session_not_found', {
session_id: sessionId.substring(0, 8),
remote_ip: remoteIp,
});
res.status(404).json({
jsonrpc: '2.0',
error: { code: -32600, message: 'Session not found' },
id: null,
});
return;
}
await session.transport.handleRequest(req, res);
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log('error', 'request_error', { error: errorMessage, remote_ip: remoteIp });
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: sanitizeErrorMessage(err) },
id: null,
});
}
}
});
// DELETE /mcp — session termination
app.delete('/mcp', async (req, res) => {
const remoteIp = getClientIp(req);
const userAgent = req.headers['user-agent'] ?? 'unknown';
try {
const sessionId = req.headers['mcp-session-id'];
log('info', 'http_request', {
method: 'DELETE',
session_id: sessionId ? sessionId.substring(0, 8) : null,
remote_ip: remoteIp,
user_agent: userAgent,
});
if (!sessionId) {
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32600, message: 'Invalid Request: Session ID required' },
id: null,
});
return;
}
// Use direct map access — lastActivity is irrelevant for a session about to be destroyed
const session = this.sessionServers.get(sessionId);
if (!session) {
log('warn', 'session_not_found', {
session_id: sessionId.substring(0, 8),
remote_ip: remoteIp,
});
res.status(404).json({
jsonrpc: '2.0',
error: { code: -32600, message: 'Session not found' },
id: null,
});
return;
}
await session.transport.close();
res.status(200).json({ message: 'Session terminated' });
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log('error', 'request_error', { error: errorMessage, remote_ip: remoteIp });
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: sanitizeErrorMessage(err) },
id: null,
});
}
}
});
app.post('/mcp', async (req, res) => {
const remoteIp = getClientIp(req);
const userAgent = req.headers['user-agent'] ?? 'unknown';
try {
const method = req.body?.method || 'unknown';
// Extract session ID from headers (Express normalises headers to lowercase)
const sessionId = req.headers['mcp-session-id'];
log('info', 'http_request', {
method,
session_id: sessionId ? sessionId.substring(0, 8) : null,
remote_ip: remoteIp,
user_agent: userAgent,
});
// If no session ID and it's an initialize request, create a new session
if (!sessionId && req.body?.method === 'initialize') {
if (this.sessionServers.size >= OpenMeteoMCPServer.MAX_SESSIONS) {
log('warn', 'session_limit_reached', {
current: this.sessionServers.size,
max: OpenMeteoMCPServer.MAX_SESSIONS,
remote_ip: remoteIp,
});
res.status(503).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Server at session capacity, try again later' },
id: req.body?.id || null,
});
return;
}
// Generate a new session ID
const newSessionId = generateSessionId();
log('info', 'session_created', { session_id: newSessionId.substring(0, 8) });
// Create server and transport for this new session
const mcpServer = this.createServer();
const transport = new StreamableHTTPServerTransport({
enableJsonResponse: true,
sessionIdGenerator: () => newSessionId,
});
mcpServer.server.oninitialized = () => {
log('info', 'session_initialized', { session_id: newSessionId.substring(0, 8) });
};
mcpServer.server.onclose = () => {
this.sessionServers.delete(newSessionId);
log('info', 'session_closed', { session_id: newSessionId.substring(0, 8) });
};
await mcpServer.connect(transport);
this.sessionServers.set(newSessionId, {
server: mcpServer,
transport,
lastActivity: Date.now(),
});
// Set session ID in response header before handling request
res.setHeader('mcp-session-id', newSessionId);
// Handle the initialize request
await transport.handleRequest(req, res, req.body);
return;
}
if (sessionId) {
const session = this.getSession(sessionId);
if (!session) {
log('warn', 'session_not_found', {
session_id: sessionId.substring(0, 8),
remote_ip: remoteIp,
});
res.status(404).json({
jsonrpc: '2.0',
error: { code: -32600, message: 'Session not found' },
id: req.body?.id || null,
});
return;
}
const { transport } = session;
await transport.handleRequest(req, res, req.body);
}
else {
// No session ID and not an initialize request - error
log('warn', 'invalid_request', {
reason: 'missing_session_id',
method,
remote_ip: remoteIp,
});
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32600,
message: 'Invalid Request: Session ID required for non-initialize requests',
},
id: req.body?.id || null,
});
}
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log('error', 'request_error', { error: errorMessage, remote_ip: remoteIp });
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: sanitizeErrorMessage(err),
},
id: req.body?.id || null,
});
}
});
return app;
}
startHttpTransport() {
const app = this.buildExpressApp();
const port = parseInt(process.env.PORT || '3000', 10);
// Loopback by default so a local server is not silently exposed on the LAN.
// Container images set HOST=0.0.0.0 explicitly, since the container boundary
// is what limits reachability there.
const host = process.env.HOST || '127.0.0.1';
app
.listen(port, host, () => {
log('info', 'server_start', { transport: 'http', host, port });
})
.on('error', (err) => {
log('error', 'server_error', { error: err instanceof Error ? err.message : String(err) });
process.exit(1);
});
}
async run() {
const transport = process.env.TRANSPORT || 'stdio';
if (transport === 'http') {
this.startCleanupTimer();
this.startHttpTransport();
}
else {
// For stdio mode, create a single server instance
const mcpServer = this.createServer();
const stdioTransport = new StdioServerTransport();
mcpServer.server.oninitialized = () => {
log('info', 'session_initialized', { transport: 'stdio' });
};
await mcpServer.connect(stdioTransport);
log('info', 'server_start', { transport: 'stdio' });
}
}
}
if (process.env.NODE_ENV !== 'test') {
const server = new OpenMeteoMCPServer();
server.run().catch((err) => {
log('error', 'server_error', { error: err instanceof Error ? err.message : String(err) });
process.exit(1);
});
}
//# sourceMappingURL=index.js.map