Building an MCP Server: Lessons from Spain's Weather API
I’m a backend engineer (Java/Spring/K8s) transitioning into AI engineering, and I decided to stop reading about agents and actually ship something. My goal was to complete the full lifecycle: build a Model Context Protocol (MCP) server, publish it to npm, and get it listed in the official registry. To keep the focus on the deployment loop rather than complex logic, I chose a simple project: a wrapper for AEMET (Spain's national weather agency) public API.
For those unfamiliar, MCP is an open protocol that allows AI clients like Claude Desktop or IDE agents to call external tools through a standardized interface. You build a server that exposes typed tools, and the client handles the discovery and invocation. I used Node.js, TypeScript, and the official @modelcontextprotocol/sdk with Zod for input validation.
The "boring" weather API actually provided a great deep dive into a specific architectural pattern: the two-step fetch.
The Two-Step Data Pattern
AEMET doesn't return data immediately. When you request a forecast, the API returns a JSON object containing a URL pointer to the actual data.
GET /opendata/api/prediccion/especifica/municipio/diaria/{ine_code}
Header: api_key: [your_key]
The response looks like this:
{
"descripcion": "exito",
"estado": 200,
"datos": "https://opendata.aemet.es/opendata/sh/abc123",
"metadatos": "https://opendata.aemet.es/opendata/sh/def456"
}
It's essentially the S3 presigned URL pattern. You hit the index, get a temporary link, and then make a second request to that link to get the payload. To keep my AI workflow clean, I isolated this logic into a single helper function so the tools themselves only see the final, typed data.
async function fetchAemet(path: string): Promise<any> {
// 1. call the endpoint with api_key → { estado, datos, metadatos }
// 2. validate estado
// 3. fetch the datos URL → decode → parse
}
Real-world Implementation Traps
While the two-step flow was documented, two other issues caused significant friction during this practical tutorial:
- Status Code Mismatch: The API can return a transport-level
200 OKwhile the JSON body contains"estado": 404or401. If you only check the HTTP response status, your agent will try to process "successful" requests that actually contain error messages. - Character Encoding: This was the biggest headache. AEMET often serves content in
ISO-8859-1(latin1) rather thanUTF-8. A naiveawait response.json()results in mangled Spanish accents (e.g., "Cádiz" becomes "Cdiz"). You have to read the body as a buffer and decode it explicitly to avoid garbage data.
All Replies (4)
My latency dropped significantly after adding a caching layer. Which library did you use for this?
Did you hit any rate limiting walls or did the free tier hold up for your tests?