cd /news/developer-tools/building-my-first-mcp-server-spain-s… · home topics developer-tools article
[ARTICLE · art-73749] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A backend engineer built and published a Model Context Protocol (MCP) server for Spain's AEMET weather API, exposing three read-only tools. The API uses a two-step pattern where the first call returns a pointer URL instead of data, and the engineer isolated both hops behind a single client function. Key lessons included validating the body's estado field separately from the HTTP status and handling ISO-8859-1 encoding to avoid mangled Spanish characters.

read5 min views1 publishedJul 25, 2026

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:

json

`{
  "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:

ts

`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:

bash

`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:

json

{
  "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.

── more in #developer-tools 4 stories · sorted by recency
── more on @aemet 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/building-my-first-mc…] indexed:0 read:5min 2026-07-25 ·