Skip to main content
Glama
GitMohamedShakir

Air Quality Comparison MCP Server

Air Quality Comparison MCP Server

A NestJS Model Context Protocol server, compatible with the Athena AI Agent SDK (OpenAI Apps / Skybridge widget contract). It exposes a public /mcp endpoint over Streamable HTTP, pulls real air quality data from the free Open-Meteo APIs (no API key), and renders an interactive comparison dashboard widget inside Athena.

Ask: “Compare air quality between Curitiba and São Paulo” → the agent calls compare_air_quality → Athena renders the dashboard with live data → you switch pollutant focus and sorting right in the widget.


What it exposes

  • Tool compare_air_quality(cityA, cityB) — geocodes both cities, fetches current hourly pollutant readings, computes which city has cleaner air, and returns structured data for the widget.

  • Resource ui://widget/air-quality.html — the Skybridge widget (mimeType: text/html+skybridge).

  • Endpoint POST/GET/DELETE/OPTIONS /mcp — Streamable HTTP transport with permissive CORS.

  • Health GET /Air Quality MCP Server is running. MCP endpoint: /mcp

Data source

  • Geocoding: https://geocoding-api.open-meteo.com/v1/search

  • Air Quality: https://air-quality-api.open-meteo.com/v1/air-quality

No keys, no accounts. All values are fetched live — nothing is hardcoded.


Related MCP server: EPA Air Quality System (AQS) MCP Server

Project structure

src/
  main.ts                           # Nest bootstrap
  app.module.ts                     # root module
  config/
    app.config.ts                   # port + CORS options
  common/
    errors/
      domain.errors.ts              # CityNotFoundError, UpstreamError
  air-quality/                      # feature module
    air-quality.module.ts           # provides + exports AirQualityService
    air-quality.service.ts          # orchestrator (geocode → fetch → compare)
    air-quality.constants.ts        # endpoints, hourly fields, source metadata
    clients/
      open-meteo.client.ts          # HTTP client (network only)
    domain/
      comparator.ts                 # pure "which city is cleaner" logic
      pollutant-extractor.ts        # pure parsing of hourly responses
    interfaces/
      air-quality.types.ts          # shared interfaces / types
  mcp/                              # transport module
    mcp.module.ts                   # imports AirQualityModule, wires controller
    mcp.controller.ts               # /mcp Streamable HTTP + GET / health + CORS
    air-quality-mcp.server.ts       # createAirQualityMcpServer(): tool + widget resource
test/
  unit/                             # unit tests (kept out of src)
    comparator.spec.ts
    pollutant-extractor.spec.ts
    open-meteo.client.spec.ts
    air-quality.service.spec.ts
public/
  air-quality-widget.html           # self-contained Skybridge widget

Layered / feature-based layout. Cross-cutting code lives in common/ and config/; each feature is a self-contained Nest module (air-quality/, mcp/) that exposes a narrow surface. Inside the feature, concerns are isolated: networking (clients/open-meteo.client), response parsing (domain/pollutant-extractor) and business logic (domain/comparator) are pure, single-responsibility units; the service only orchestrates them. Tests live in test/, separate from source, so dist/ ships no spec files.


Setup

Requires Node 18+ (uses native fetch).

# 1. install dependencies
pnpm install

# 2. run locally (dev, with watch)
pnpm run start:dev
# or production build
pnpm run build && pnpm run start:prod

Server listens on http://localhost:3000 (override with PORT).

Quick sanity check:

curl http://localhost:3000/
# -> Air Quality MCP Server is running. MCP endpoint: /mcp

Tests

Unit tests (Jest + ts-jest) cover the pure logic and the orchestrator (client mocked, no network):

pnpm test          # run once
pnpm run test:watch
pnpm run test:cov  # with coverage

Covered: pollutant extraction (most-recent / null fallback), the comparison rule (PM2.5 → European AQI fallback, ties, missing data), the HTTP client (mapping, not-found, upstream errors), and the service (full payload assembly + graceful error payload).

End-to-end smoke test

test/e2e/smoke.sh builds the server, boots it, and drives a full Streamable HTTP MCP session against the real Open-Meteo APIs — asserting the health text, CORS headers, initialize session id, the widget resource (text/html+skybridge + widgetPrefersBorder), the tool _meta (openai/outputTemplate, invoking/invoked), the full structuredContent shape with live numbers, and the graceful error path for an unknown city.

pnpm run test:e2e
# E2E result: 12 passed, 0 failed

Expose a public URL

Athena needs a publicly reachable HTTPS MCP URL.

Option A — ngrok (fastest for the demo)

ngrok http 3000

Copy the forwarded HTTPS host. Your MCP URL is:

https://<your-ngrok-subdomain>.ngrok-free.app/mcp

Option B — deploy

Deploy to any Node host (Render, Railway, Fly.io, a VM, etc.). Build with pnpm run build, start with pnpm run start:prod, and make sure the platform port maps to the app (PORT env). Your MCP URL is always:

https://your-domain.com/mcp

Athena agent setup

  1. Create a new Athena agent.

  2. Add the MCP server URL: https://your-domain.com/mcp (or the ngrok URL above).

  3. Paste the agent prompt below.

  4. Test with: Compare air quality between Curitiba and São Paulo

Athena agent prompt

You are AirCompare, an air quality comparison assistant.

Your job is to compare air quality between two cities using real public data through the MCP tool.

When the user asks to compare cities, call compare_air_quality with cityA and cityB.
Always render the interactive air quality dashboard widget.

Briefly explain:
- which city currently has better air quality
- the main reason
- the key pollutant values

Use PM2.5 as the primary comparison metric when available.
If PM2.5 is unavailable, use European AQI.

Encourage the user to interact with the dashboard by switching pollutant focus and changing the sort mode.
Keep answers concise and practical.

The widget

public/air-quality-widget.html is a single self-contained file (no build step). It:

  • Reads initial data from window.openai.toolOutput.

  • Re-renders on openai:set_globals (and openai:tool_output) when Athena pushes new output.

  • Shows two city cards: name + country, PM2.5, PM10, NO₂, O₃, European AQI, US AQI, timestamp, and source attribution.

  • Highlights the city with cleaner air.

  • Degrades gracefully: friendly empty state, error state, and defensive null handling so missing values never crash it.

Interactions

  1. Pollutant focus tabs — PM2.5 / PM10 / NO₂ / O₃. Changes the headline value, color band, and comparison bars in both cards.

  2. Sort toggleBest air first / Worst air first. Reorders the two cards by the focused pollutant.

  3. Swap — locally flips the displayed city order.

  4. Refresh — calls window.openai.callTool("compare_air_quality", { cityA, cityB }) when the Athena bridge is present; otherwise just re-renders local state.

All interactions work standalone (outside Athena) using local state.


Tool output shape

structuredContent returned by compare_air_quality:

{
  "subject": "Air quality comparison dashboard",
  "cityA": {
    "inputName": "Curitiba",
    "name": "Curitiba",
    "country": "Brazil",
    "latitude": -25.42,
    "longitude": -49.27,
    "timezone": "America/Sao_Paulo",
    "timestamp": "2026-06-19T15:00",
    "pollutants": {
      "pm2_5": 7.3, "pm10": 12.1, "nitrogen_dioxide": 4.2, "ozone": 60.0,
      "carbon_monoxide": 120.0, "sulphur_dioxide": 1.1,
      "european_aqi": 22, "us_aqi": 30
    }
  },
  "cityB": { "...": "same shape" },
  "comparison": {
    "betterCity": "Curitiba",
    "worseCity": "São Paulo",
    "reason": "Curitiba has cleaner air with a lower PM2.5 (7.3 µg/m³ vs 20.4 µg/m³).",
    "primaryMetric": "pm2_5",
    "primaryMetricLabel": "PM2.5"
  },
  "source": {
    "name": "Open-Meteo Air Quality API",
    "url": "https://open-meteo.com/en/docs/air-quality-api",
    "retrievedAt": "2026-06-19T15:36:00.000Z"
  }
}

Comparison rule: lower is better. PM2.5 is the primary metric; if PM2.5 is missing for either city or tied, it falls back to European AQI. If a city can't be resolved, the payload includes an error string (and the widget shows a friendly error state).


Demo checklist

  • Ask Athena: Compare air quality between Curitiba and São Paulo

  • Confirm the agent retrieves real data (values change over time)

  • Confirm the widget renders inside Athena

  • Click the pollutant tabs (PM2.5 / PM10 / NO₂ / O₃)

  • Toggle Best air first / Worst air first

  • Show the source attribution

  • Record a ≤ 1 minute video


Local MCP smoke test (no Athena)

pnpm run start:dev    # in one terminal

# initialize → capture the Mcp-Session-Id header, then call the tool
curl -s -D - -o /dev/null -X POST http://localhost:3000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}'

Use the returned mcp-session-id on subsequent notifications/initialized and tools/call requests.


Notes

  • This SDK version (@modelcontextprotocol/sdk@^1.29) ships the Streamable HTTP transport at @modelcontextprotocol/sdk/server/streamableHttp.js (camelCase). The MCP server lives at @modelcontextprotocol/sdk/server/mcp.js.

  • dist/ is a build artifact and is git-ignored — generate it with pnpm run build.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables interaction with the World Air Quality Index to fetch real-time air quality data for cities and coordinates worldwide via Model Context Protocol (MCP).
    1
    MIT
  • F
    license
    Not graded
    quality
    Not graded
    maintenance
    Provides real-time air quality monitoring and historical data analysis for InBiot MICA sensors with integrated WELL Building Standard compliance checks. It allows users to compare indoor conditions with outdoor weather and receive actionable health recommendations based on global air quality standards.
    -
  • A
    license
    Not graded
    quality
    Not graded
    maintenance
    Provides natural language access to environmental data including air quality measurements, greenhouse gas emissions, and facility records. It enables users to perform geographic searches, trend analysis, and proximity-based queries using data from sources like OpenAQ and Climate TRACE.
    MIT