pollenbrief
README.md
# pollenbrief-mcp
A small MCP server and CLI that turns a **Google Pollen API** forecast into an agent-ready daily brief with
**alert thresholds you set** (for example `GRASS>=4`), per day and per pollen type or plant, as compact JSON
and Markdown.
## The problem
Allergy-sensitive people and outdoor operations teams (landscaping, field crews, events) need to know which
of the next few days cross *their* pollen threshold at a given place. The Google Pollen API returns a detailed
forecast per day, pollen type and plant on the Universal Pollen Index (UPI, 0 to 5), but an agent still has to
geocode the place, page through the response and apply thresholds itself. This lab does that in one tool call.
## Quickstart
```bash
bun install --frozen-lockfile
# Keyless demo: synthetic data in the documented response shape (fictional address, invented values)
bun run src/cli.ts brief --address "100 Example Ave, Sampletown" --profile grass-allergy --fixtures
bun run src/cli.ts brief --at 47.25,8.5 --days 3 --alert "GRASS>=3" --format json --fixtures
# Live: put GOOGLE_MAPS_API_KEY in .env (see .env.example), then
bun run src/cli.ts brief --address "<your address>" --alert "GRASS>=4" --alert "BIRCH>=High: keep windows closed"
bun run src/cli.ts brief --at 40.71,-74.01 --profile outdoor-crew --exit-on-alert # exit 3 when a rule fires
# MCP server (stdio)
bun run src/cli.ts serve # live
bun run src/cli.ts serve --fixtures # keyless demo
```
Quote rules in the shell: an unquoted `GRASS>=4` is a redirect to a file named `=4`.
MCP client config (Claude Code `.mcp.json`, Cursor `mcp.json`):
```json
{
"mcpServers": {
"pollenbrief": {
"command": "bun",
"args": ["run", "/path/to/pollenbrief-mcp/src/cli.ts", "serve"],
"env": { "GOOGLE_MAPS_API_KEY": "${GOOGLE_MAPS_API_KEY}", "POLLENBRIEF_BUDGET": "20" }
}
}
}
```
The key's Cloud project must have the Pollen API (`pollen.googleapis.com`) enabled, and the Geocoding API for
`address` lookups. Restrict the key to those APIs.
## Tools
| Tool | What it does | Google calls |
| --- | --- | --- |
| `pollen_brief` | `address` or `lat`+`lng`, `days` 1-5 (default 5), `rules[]` and/or a bundled `profile`, `language_code`, `plants` (`indexed`/`all`/`none`), `advice`, `format` (`json`/`markdown`/`both`), `phrase` | 1 Geocoding v4 call for an address, plus 1 `forecast:lookup` call per page |
| `pollen_rules` | Rule grammar, valid targets, UPI levels, bundled profiles | none |
| `session_status` | Budget used/remaining, refused calls | none |
CLI: `pollenbrief brief | profiles | serve`. Exit codes: 0 ok, 2 error, 3 alert (with `--exit-on-alert`), 64 usage.
### Rules and profiles
`TARGET>=LEVEL[: action]`. TARGET is `GRASS`, `TREE`, `WEED`, a plant code from the API's Plant enum (`ALDER`,
`ASH`, `BIRCH`, `COTTONWOOD`, `ELM`, `MAPLE`, `OLIVE`, `JUNIPER`, `OAK`, `PINE`, `CYPRESS_PINE`, `HAZEL`,
`GRAMINALES`, `RAGWEED`, `MUGWORT`), or `ANY_TYPE`, `ANY_PLANT`, `ANY`. LEVEL is `0`-`5` or a UPI category name
(`None`, `Very Low`, `Low`, `Moderate`, `High`, `Very High`).
A rule **alerts** on a day when that entry's UPI value is at least LEVEL, and puts the day on **watch** when the
value is exactly one below. Entries the API returns without `indexInfo` (out of season or a low count) never fire;
the brief adds a note when a rule cannot fire at all in the forecast.
Bundled profiles in `profiles/`: `grass-allergy`, `tree-allergy`, `outdoor-crew`, `ragweed-season`. The CLI also
accepts a path to your own profile JSON; the MCP tool accepts bundled names only.
### Output
JSON (`brief`): `location`, `rules`, `summary` (`status` alert/watch/clear, `alertDays`, `watchDays`,
`firstAlert`, `peak`, `text`, `phrasedBy`), `daily[]` (`types`, `plants`, `alerts`, `watch`, `advice`), `notes`,
`attribution`, `source`. Markdown: a status line, a day table (Grass / Tree / Weed / Status / Alerts), a plants
table, alerts grouped by day with your action text and the API's health recommendations, and the attribution line.
A full sample is in [`reports/pollenbrief-mcp.md`](reports/pollenbrief-mcp.md).
`phrase: true` (CLI `--phrase`) asks Gemini to reword `summary.text`. Gemini receives only dates, codes, UPI values,
categories and rule ids, never the API's free text or your action notes. A reply containing any number that is not
in that input is rejected and the template wording stays. Without `GEMINI_API_KEY`, or on HTTP 402, the template is
used and `phrasedBy` says why.
## Measured
Keyless run of the real MCP server (in-memory MCP client) over `fixtures/synthetic/`: invented values in the
response shape documented in Google's API reference. Byte counts depend on the invented values, so read them as
illustrative. "MCP = service" checks that the tool's JSON and Markdown equal the service output for the same input.
Produced by `bun run report`; full output in `reports/pollenbrief-mcp.md` and `reports/pollenbrief-mcp.json`.
<!-- measured:start -->
| Scenario | Input | Days | Upstream calls | Pollen response bytes (synthetic) | Brief JSON bytes | Markdown bytes | Alert days | Watch days | Status | MCP = service |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- |
| levels only | address, no rules | 5 | 2 | 11782 | 2679 | 1235 | 0 | 0 | clear | true |
| grass-allergy | address, profile | 5 | 2 | 11782 | 4861 | 2764 | 2 | 2 | alert | true |
| tree-allergy | address, profile | 5 | 2 | 11782 | 5032 | 2995 | 3 | 1 | alert | true |
| outdoor-crew | address, profile | 5 | 2 | 11782 | 6020 | 3530 | 4 | 1 | alert | true |
| ragweed-season | address, profile (weeds out of season) | 5 | 2 | 11782 | 3379 | 1851 | 0 | 0 | clear | true |
| calm point | lat/lng, 3 days, GRASS>=3 | 3 | 1 | 4628 | 1569 | 974 | 0 | 1 | watch | true |
- Pagination: `pageSize=2` over 5 days took **3 pages / 4 upstream calls** (geocode included) and produced the same days as one page: **true**.
- `plantsDescription`: the brief asks for `false` by default; the same synthetic forecast with descriptions is 25437 bytes vs 11782 (invented description text, so the ratio is illustrative).
- 403 SERVICE_DISABLED (synthetic copy of the error shape): tool error **google_PERMISSION_DENIED**, reason **SERVICE_DISABLED**.
- Budget 2, two address briefs (2 calls each): ok, budget_exceeded.
- Gemini mocked 402: `template (gemini gemini-3.1-flash-lite skipped: HTTP 402 RESOURCE_EXHAUSTED)`.
- Gemini mocked reply with invented numbers: `template (gemini gemini-3.1-flash-lite skipped: rejected: numbers not in the input (7, 10))`.
- ragweed-season note: "WEED has no index in this forecast (out of season or a low count), so WEED>=3 cannot fire".
<!-- measured:end -->
### Live status (2026-10-21)
Calls made with our Maps Platform key in project `dev-test-461117` while building this lab:
| Call | Result |
| --- | --- |
| Geocoding API v4 `geocode/address` (public address of Google's HQ) | HTTP 200; `tests/live` passes |
| Pollen API `forecast:lookup` | HTTP 403 `PERMISSION_DENIED`, reason `SERVICE_DISABLED`: the API is not enabled in that project, and we did not enable it. `tests/live` prints a `skipped:` line |
| Gemini `generateContent` (`gemini-3.1-flash-lite`) | HTTP 402 `RESOURCE_EXHAUSTED` (no prepaid credit). The model id is listed by the Gemini `models` endpoint for the key |
So **no real Pollen API response has been observed by this lab**, and no Gemini-written brief has been produced.
The Pollen path is tested only against synthetic responses built from the reference. When the API is enabled,
`bun run record` saves one real response to the gitignored `.recorded-local/`, and `tests/live` then checks it
against `src/schema.ts`.
## What we checked in Google's docs
Checked on 2026-10-21 (the policies page showed "Last updated 2026-10-20 UTC").
| Item | What the docs say | Source |
| --- | --- | --- |
| Endpoint | `GET https://pollen.googleapis.com/v1/forecast:lookup`, empty body | [forecast.lookup reference](https://developers.google.com/maps/documentation/pollen/reference/rest/v1/forecast/lookup) |
| Parameters | `location.latitude`, `location.longitude`, `days` (1-5, required), `pageSize` (default and max 5), `pageToken`, `languageCode` (BCP-47, default `en`), `plantsDescription` (default true) | same |
| Response | `regionCode`, `dailyInfo[]` (`date`, `pollenTypeInfo[]` up to 3, `plantInfo[]` up to 15), `nextPageToken` | same |
| Index | `indexInfo`: `code` (`UPI`), `displayName`, `category`, `indexDescription`, `color` (RGBA 0-1), `value` 0-5 | same |
| UPI categories | 0 None, 1 Very Low, 2 Low, 3 Moderate, 4 High, 5 Very High | [Pollen index](https://developers.google.com/maps/documentation/pollen/pollen-index) |
| Omitted fields | `indexInfo` omitted when out of season with a low count; `plantDescription` omitted when `plantsDescription=false`; color channels optional | [Forecast guide](https://developers.google.com/maps/documentation/pollen/forecast) |
| Pagination | `pageSize` + `pageToken` examples | same |
| Geocoding v4 | `geocode.googleapis.com/v4/geocode/address/{address}` with `X-Goog-FieldMask` | [Geocoding v4 overview](https://developers.google.com/maps/documentation/geocoding/geocoding-v4-overview) |
| "Agents lack a small MCP for this" | Not verified: market claim from the idea, not measured | none |
The forecast guide's examples pass the key as a `key=` query parameter. This lab sends it in the
`X-Goog-Api-Key` header instead; the Pollen API accepted that header far enough to answer `SERVICE_DISABLED` for our
project, but we have not seen a successful response with it.
## Attribution and usage requirements
From the [Pollen API policies](https://developers.google.com/maps/documentation/pollen/policies):
- **Attribution.** Data shown as is, without modifications, uses the Google Maps logo or text attribution. Content
that aggregates or interprets the data (a custom score, thresholds, statuses) is attributed with
**"Includes data from Google Maps"**. The page also lists the older string "Source: Includes pollen data from
Google" and says Google will publish timelines for moving existing "Google" attributions to "Google Maps".
This brief interprets the data, so every JSON brief carries `"attribution": "Includes data from Google Maps"` and
every Markdown brief ends with that line. Keep it next to the numbers wherever you show them. The text
attribution rules also cover font, size, color and placement, and ask for `translate="no"` in HTML.
- **No caching.** Content pre-fetching, caching or storage is generally prohibited (place IDs excepted). This
server keeps nothing between calls, and the repo commits no Google responses.
- **Maps.** If you put Pollen results on a map, it must be a Google Map.
- **Terms.** Your app needs public Terms of Use and a Privacy Policy that meet Google's requirements; use is governed
by your agreement with Google, and EEA billing addresses get different terms.
## Limits
- **Pollen responses unverified live.** See Live status: the Pollen API is not enabled in our project. Synthetic
fixtures follow the reference field by field (`src/types.ts`, strict check in `src/schema.ts`), but real responses
may carry fields or value formats we have not seen.
- **Not medical advice.** The brief repeats the API's health recommendations and your own action text; it adds no
advice of its own. The UPI is Google's index, not a pollen count.
- **Thresholds are integer UPI levels.** No per-hour data (the API gives one value per day) and no `>`/`<=` operators.
- **Budget scope.** One upstream call budget per process / MCP connection (default 20), not per key or per day. Use
Cloud quotas and budget alerts for hard limits. No prices are quoted here.
- **Keyless mode answers only the demo inputs** in `fixtures/synthetic/INDEX.json`; anything else returns
`google_NO_FIXTURE`. Fixture output is never a real forecast and says so in the Markdown footer.
- **Gemini guard is numeric only.** It rejects invented numbers, not a wrong pairing of real ones.
- **Not included:** any Pollen API endpoint other than `forecast:lookup`, multi-location batches, scheduling or
notifications (use `--exit-on-alert` from cron or CI), and storage of past briefs.
## Development
```bash
bun run check # install, skills layout, strict YAML, biome, tsc, unit + e2e tests, live tests (skip
# cleanly), report, bun audit, actionlint (if installed), gitleaks
bun test tests/unit tests/e2e
bun test tests/live # needs keys; prints "skipped: ..." otherwise (POLLENBRIEF_SKIP_LIVE=1 forces skip)
bun run fixtures:synthetic # regenerate fixtures/synthetic from src/synthetic.ts
bun run record # live: save real responses to gitignored .recorded-local/ for the shape check
```
## Not affiliated
Not affiliated with, endorsed by, or sponsored by Google LLC. Google Maps Platform, Pollen API and Gemini are
trademarks of Google LLC.
---
Built by [SkilledDragon LLC](https://skilleddragon.com). Questions: hello@skilleddragon.com. Apache-2.0 (see LICENSE
and NOTICE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues