Air Quality Comparison MCP Server
README.md
# Air Quality Comparison MCP Server
A NestJS [Model Context Protocol](https://modelcontextprotocol.io) 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](https://open-meteo.com) 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.
---
## 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`).
```bash
# 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:
```bash
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):
```bash
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.
```bash
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)
```bash
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
```text
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 toggle** — *Best 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`:
```jsonc
{
"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)
```bash
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`.
```
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues