DEV Community

Building my first MCP server: Spain's weather API and its two-step catch

I'm a backend engineer (Java/Spring, Kubernetes, that world) moving toward AI engineering, and I wanted to actually ship something in the agent ecosystem rather than read about it. So I built and published a small Model Context Protocol (MCP) server. This is the first of a planned series that gets progressively harder; this one was deliberately trivial in scope, because the real goal was to close the full loop: build β†’ publish to npm β†’ list in the official MCP registry β†’ get discovered.

The subject is intentionally boring: AEMET, Spain's national weather agency, has a free public API. No auth headaches beyond an API key, no legal grey area, nothing from my day job. A clean sandbox to learn the mechanics. What I did not expect was that the "boring" API had the two most interesting engineering lessons of the whole exercise.

What MCP is, in two sentences

MCP is an open protocol that lets AI clients (Claude Desktop, IDE agents, etc.) call external tools through a standard interface. You write a server that exposes a few typed "tools"; any MCP-compatible client can then discover and invoke them.

Mine exposes three, all read-only:

  • get_municipality_forecast - forecast by municipality (INE code)
  • get_station_observation - observation data from a weather station
  • get_weather_warnings - active weather warnings by region

Node.js + TypeScript, the official @modelcontextprotocol/sdk, stdio transport, inputs validated with Zod. Nothing exotic.

The two-step pattern (the interesting part)

AEMET's OpenData API does something I hadn't seen before, and it trips up everyone who touches it for the first time. The first call doesn't return your data - it returns a pointer to your data.

You ask for a forecast:

GET /opendata/api/prediccion/especifica/municipio/diaria/{ine_code}
Header: api_key: <key>

And you get back this:

{
  "descripcion": "exito",
  "estado": 200,
  "datos": "https://opendata.aemet.es/opendata/sh/abc123",
  "metadatos": "https://opendata.aemet.es/opendata/sh/def456"
}

Not a single temperature. The datos field is a URL pointing to where AEMET has actually placed your response. You then make a second request to that URL - no API key this time - and that's where the real payload lives.

If you know AWS, this is the S3 presigned URL pattern: you request a resource, get back a temporary link, and fetch the content from the link. The API endpoint is an index that tells you where your file is; the file is served from static storage. It's also a bit like an HTTP 302 you have to follow manually, since your HTTP client won't follow it for you - it's a field in a JSON body, not a Location header.

The design lesson: isolate this in one place. I put both hops behind a single client function so the tools never know the pattern exists:

async function fetchAemet<T>(path: string): Promise<T> {
  // 1. call the endpoint with api_key β†’ { estado, datos, metadatos }
  // 2. validate estado
  // 3. fetch the datos URL β†’ decode β†’ parse
}

Each tool calls fetchAemet(...) and gets clean, typed data. If AEMET ever changes the pattern, I touch one file.

The traps that actually cost me time

The two-step flow is documented (barely). These were not:

  • The estado field can disagree with the HTTP status. You can get a transport-level 200 OK while the JSON body says "estado": 404 (no data for that municipality) or 401 (bad key). So you validate the body's estado, not just the response status. Easy to miss until a "successful" request returns nonsense.

  • The encoding. This one cost me the most. AEMET serves a lot of its content as ISO-8859-1 (latin1), not UTF-8. If you do a naive await response.json(), every Spanish accent comes back mangled - CΓ‘diz becomes Cdiz, maΓ±ana becomes maana. You have to read the body as a buffer and decode it explicitly. Nothing in the obvious docs warns you; you just get garbage and have to figure out why.

  • The datos URL is ephemeral. Don't cache it for hours. If you need to retry, repeat step one from scratch.

  • Rate limiting is per key, per minute. Not a problem for a few tool calls, but chain requests too fast and some fail - so handle the error instead of retrying in a loop.

The other lesson: packaging is where npm servers break

The single most common way a published MCP server fails is that it installs but won't start via npx. Two things fix it, and both are easy to forget:

  • a bin field in package.json pointing at your compiled dist/index.js
  • a shebang (#!/usr/bin/env node) as the very first line of your entry file

And one runtime gotcha specific to stdio transport: nothing goes to stdout except the JSON-RPC protocol. A stray console.log corrupts the message stream and breaks the server silently. Logs go to stderr.

I verified the published package the way a stranger would install it, from outside the repo:

echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' \
  | AEMET_API_KEY=<key> npx -y @mmillan76/aemet-mcp

If it answers with serverInfo and capabilities, it's alive and speaking MCP.

Using it

Grab a free API key from AEMET's OpenData portal, then add the server to any MCP client:

{
  "mcpServers": {
    "aemet": {
      "command": "npx",
      "args": ["-y", "@mmillan76/aemet-mcp"],
      "env": {
        "AEMET_API_KEY": "your-key-here"
      }
    }
  }
}
  • npm: @mmillan76/aemet-mcp
  • MCP directory: mcp.so listing is pending review - I'll add the link here once it's approved

What's next

This was step one of a series I'm building toward a bigger goal: an autonomous incident-investigation agent running entirely on MCP servers I've published myself. The next steps move into my actual domain - read-only Kubernetes diagnostics, then Helm and ArgoCD tooling - before rebuilding that agent on top of them.

The AEMET server was never the point. Closing the loop was. If you're thinking about building your first MCP server, pick something trivial, ship it end to end, and pay attention to packaging and encoding - that's where the real lessons hide.


Top comments (1)

The stdout/stderr point is the one that quietly ruins a lot of otherwise-fine MCP packages. I like treating the server like a protocol adapter, not a little CLI app: stdout is wire format only, stderr is diagnostics, and the final test runs from outside the repo with npx -y so packaging bugs have nowhere to hide. For the AEMET two-step API, did you also wrap the body-level estado into typed domain errors? That boundary tends to pay off later when the agent has to decide whether to retry, ask for a different municipality, or stop.

Comments

No comments yet. Start the discussion.