<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Manuel Millan</title>
    <description>The latest articles on DEV Community by Manuel Millan (@mmillan76).</description>
    <link>https://dev.to/mmillan76</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4047337%2Fdd45b1b7-0464-4c16-93eb-3e7226015369.png</url>
      <title>DEV Community: Manuel Millan</title>
      <link>https://dev.to/mmillan76</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mmillan76"/>
    <language>en</language>
    <item>
      <title>Building my first MCP server: Spain's weather API and its two-step catch</title>
      <dc:creator>Manuel Millan</dc:creator>
      <pubDate>Sat, 25 Jul 2026 22:40:20 +0000</pubDate>
      <link>https://dev.to/mmillan76/building-my-first-mcp-server-spains-weather-api-and-its-two-step-catch-44fj</link>
      <guid>https://dev.to/mmillan76/building-my-first-mcp-server-spains-weather-api-and-its-two-step-catch-44fj</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;What I did not expect was that the "boring" API had the two most interesting engineering lessons of the whole exercise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What MCP is, in two sentences&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Mine exposes three, all read-only:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;get_municipality_forecast — forecast by municipality (INE code)&lt;br&gt;
get_station_observation — observation data from a weather station&lt;br&gt;
get_weather_warnings — active weather warnings by region&lt;/code&gt;&lt;/p&gt;

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

&lt;p&gt;The two-step pattern (the interesting part)&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;You ask for a forecast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;`GET /opendata/api/prediccion/especifica/municipio/diaria/{ine_code}
Header: api_key: &amp;lt;key&amp;gt;`
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And you get back this:&lt;/p&gt;

&lt;p&gt;json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"descripcion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"exito"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"estado"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"datos"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://opendata.aemet.es/opendata/sh/abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metadatos"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://opendata.aemet.es/opendata/sh/def456"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;ts&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="s2"&gt;`async function fetchAemet&amp;lt;T&amp;gt;(path: string): Promise&amp;lt;T&amp;gt; {
  // 1. call the endpoint with api_key → { estado, datos, metadatos }
  // 2. validate estado
  // 3. fetch the datos URL → decode → parse
}`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each tool calls fetchAemet(...) and gets clean, typed data. If AEMET ever changes the pattern, I touch one file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The traps that actually cost me time&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The two-step flow is documented (barely). These were not:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The datos URL is ephemeral. Don't cache it for hours. If you need to retry, repeat step one from scratch.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;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.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The other lesson: packaging is where npm servers break&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I verified the published package the way a stranger would install it, from outside the repo:&lt;/p&gt;

&lt;p&gt;bash&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nv"&gt;AEMET_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;key&amp;gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; @mmillan76/aemet-mcp&lt;span class="sb"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it answers with serverInfo and capabilities, it's alive and speaking MCP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Using it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Grab a free API key from AEMET's OpenData portal, then add the server to any MCP client:&lt;/p&gt;

&lt;p&gt;json&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"aemet"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@mmillan76/aemet-mcp"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"AEMET_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-key-here"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;npm: &lt;a href="https://www.npmjs.com/package/@mmillan76/aemet-mcp" rel="noopener noreferrer"&gt;@mmillan76/aemet-mcp&lt;/a&gt;&lt;br&gt;
MCP directory: mcp.so listing is pending review — I'll add the link here once it's approved&lt;br&gt;
What's next&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This was step one of a series I'm building toward a bigger goal&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>typescript</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
