@cyanheads/eia-energy-mcp-server

Browse and query the U.S. Energy Information Administration API v2 — electricity, petroleum, natural gas, coal, forecasts, and more via MCP. STDIO or Streamable HTTP.

4 core tools + 3 DataCanvas tools

[![Version](https://img.shields.io/badge/Version-0.5.0-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/eia-energy-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.4.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/eia-energy-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/eia-energy-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.4-blueviolet.svg?style=flat-square)](https://bun.sh/) [![Install in Claude Desktop](https://img.shields.io/badge/Install_in-Claude_Desktop-D97757?style=for-the-badge&logo=anthropic&logoColor=white)](https://github.com/cyanheads/eia-energy-mcp-server/releases/latest/download/eia-energy-mcp-server.mcpb) [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=eia-energy-mcp-server&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBjeWFuaGVhZHMvZWlhLWVuZXJneS1tY3Atc2VydmVyIl0sImVudiI6eyJFSUFfQVBJX0tFWSI6InlvdXItYXBpLWtleSJ9fQ==) [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=for-the-badge&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode:mcp/install?%7B%22name%22%3A%22eia-energy-mcp-server%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40cyanheads%2Feia-energy-mcp-server%22%5D%2C%22env%22%3A%7B%22EIA_API_KEY%22%3A%22your-api-key%22%7D%7D) [![Framework](https://img.shields.io/badge/Built%20on-@cyanheads/mcp--ts--core-67E8F9?style=flat-square)](https://www.npmjs.com/package/@cyanheads/mcp-ts-core)
**Public Hosted Server:** [https://eia-energy.caseyjhand.com/mcp](https://eia-energy.caseyjhand.com/mcp)
--- ## Overview US energy data from the Energy Information Administration (EIA): electricity, petroleum, natural gas, coal, and forecasts. Find datasets in plain language, query time series with facet filters, and run SQL over large results. ### Tools | Tool | Description | |:-----|:------------| | `eia_browse_routes` | Lists child routes under a path in the EIA dataset taxonomy; omit `path` for the 14 top-level categories. | | `eia_describe_route` | Returns a leaf route's facets, valid values, data columns, frequencies, and date range. | | `eia_search_routes` | Fuzzy text search across route names, descriptions, STEO and SEDS series names, and facet values — including benchmark series such as Henry Hub, WTI, and Cushing. Resolves legacy APIv1 series IDs (`PET.RWTC.W`) to their route and filters. | | `eia_query_route` | Fetches data from a leaf route with facet filters, date range, and column selection; optionally stages results for SQL. | | `eia_dataframe_describe` | Lists active DataCanvas dataframes staged by `eia_query_route`, with schema and provenance. | | `eia_dataframe_query` | Runs a read-only SQL SELECT against staged DataCanvas dataframes. | | `eia_dataframe_drop` | Drops a DataCanvas dataframe, freeing its memory. | The three `eia_dataframe_*` tools need DataCanvas, which is on by default; `eia_dataframe_drop` additionally requires `EIA_DATAFRAME_DROP_ENABLED=true`. With `CANVAS_PROVIDER_TYPE=none`, which the Claude Desktop bundle sets, they are neither callable nor listed by MCP. Disabled definitions remain in the server manifest with their enable hints. A default deployment lists the first six tools. ## Capability reference Browse accepts `route` as an alias for `path`; describe and query accept `path` as an alias for `route`. Supplying both spellings is rejected. Whitespace and slashes are normalized, then exactly one complete leading `v2` segment is removed: `/v2/electricity/retail-sales/` resolves to `electricity/retail-sales`, and `/v2/` browses root. Full URLs, `/data` suffixes, interior `v2`, and case remain unchanged. All four route tools distinguish retryable upstream failures: `rate_limited` for HTTP 429 or `OVER_RATE_LIMIT`, `upstream_unreachable` for transport failures, `upstream_unavailable` for HTTP service outages, `upstream_invalid_response` for unusable bodies or missing envelopes, and `upstream_timeout` when EIA does not answer in time — each attempt is cut at 45 s, and a call's attempts, backoffs, and chained requests share one 56 s deadline, inside the 60 s timeout MCP clients apply. Recovery hints reach both response surfaces. HTTP 501 remains definitive, 504 remains a timeout, and caller cancellation stays cancellation. ### `eia_browse_routes` tool - Omit `path` for the 14 top-level categories; pass a path to browse its children. - Each child carries `isLeaf`: describe and query leaves, or browse categories further. - A missing path returns `route_not_found`; EIA rate-limiting, being unreachable, or not answering in time returns retryable `rate_limited`, `upstream_unreachable`, or `upstream_timeout`. --- ### `eia_describe_route` tool - Pass a leaf `route` for facets, data columns, frequencies, and date range. Each facet window is capped at `EIA_FACET_VALUE_CAP` (default 50); page with `facet` and `values_offset`. - To find a value by name in a large facet (plant, entity, and mine IDs run to thousands), pass `values_query`: each facet keeps the values whose `id`, `name`, or `alias` contains it, ignoring case, and `values_offset` pages the matches. `values_query: "palo verde"` on `electricity/facility-fuel`'s `plantCode` returns `6008` and `57773` in one call. - `date_range` is metadata for the default frequency; another frequency may extend outside it. Both response surfaces carry this caveat beside the unchanged start/end values. - `value_count`, `values_truncated`, and, with `values_query`, `values_matched` describe each window. A facet whose values EIA did not return is listed in `unavailable_facets`; the next call retries it. - Errors: `route_not_found`, `route_not_queryable`, `facet_not_found`, and retryable `facet_unavailable`, `rate_limited`, `upstream_unreachable`, `upstream_timeout`. --- ### `eia_search_routes` tool - Search route names, descriptions, STEO's 1,469 and SEDS's 968 series names, and facet values. Before any describe, the facet values cover fuel, sector, technology, and coal-rank vocabularies, RTO balancing authorities (`Electric Reliability Council of Texas` → `respondent: ERCO`), and the series and product names of the daily and weekly petroleum and natural-gas routes (`Henry Hub` → `natural-gas/pri/fut`, `series: RNGWHHD`); describing a route adds its own. `limit` defaults to 10; a larger value returns at most 30 and says so, even on short or empty results. `truncated` is true when more matches exist. Only the first 256 characters of `query` are searched, with a notice when it is cut. - Matches carry `score` (0 exact, 1 no match; above 0.72 is weak) and may carry a `filter_hint` for `eia_query_route`. Check `indexComplete` and `indexGaps` before trusting a short result set. - A legacy APIv1 series ID, as EIA's website and Excel add-in write them, is resolved through EIA's `/v2/seriesid` lookup before any text search, in any case: `ELEC.PRICE.TX-RES.M` returns one result, `electricity/retail-sales` with `filter_hint` `{sectorid: "RES", stateid: "TX"}`, `frequency: "monthly"`, and `columns: ["price"]`, ready for `eia_query_route`. A hit needs no index, so it answers in under a second even on a cold server. When EIA's own lookup reports no rows for the mapping it returns (some AEO IDs), the notice says the query will come back empty. An ID EIA does not recognize (`PET.ZZZZZZ.W`, or `U.S.A`, which only looks like one) returns the text search with a notice saying so; a lookup that times out, is rate-limited, or hits an outage does the same with a retry notice, never an error. - The first search builds the index and normally takes 20–40 s (at most 45 s); later searches answer from the built index. If EIA rate-limits, cannot be reached, or does not answer in time, the call fails with retryable `rate_limited`, `upstream_unreachable`, or `upstream_timeout`. --- ### `eia_query_route` tool - Pass a leaf `route`, with optional facet-ID `filters`, `columns`, `frequency`, `start`/`end`, and `sort`. Page with `offset` and `length` (default 25; a larger value returns at most 5,000 and says so). - Returns `length` rows per page; continue at `next_offset` (= `offset` + returned count). - After describe has cached a complete facet vocabulary, and for 24 hours after that list was fetched, query notices name unmatched filter values and suggest case-sensitive IDs when possible. Filters and their echoes stay unchanged; diagnostics never fetch metadata or facet values. - With effective monthly `YYYY-MM` data, a year-only bound means January: `end: "2021"` stops at `2021-01`; use `2021-12` for the full year. Query notices disclose this for either bound without expanding it. - EIA reads each bound as the period of the effective frequency containing its first day, so `start: "2023-01", end: "2023"` returns January at monthly. A range is reported as inverted (`no_data`) only when EIA returns no rows and `start` selects a later period than `end`; the message names both periods. At `local-hourly` each bound needs its hour and UTC offset (`2024-02-01T02-06`); one without fails as `invalid_period` before any request. So does a bound naming a date or hour that does not exist in the shape its frequency reads exactly — `2024-02-30` at `daily` or `weekly`, `2024-02-01T24` at `hourly` — since EIA answers those with an HTTP 500 rather than rows. - `total` reports the full match count; values remain strings and units appear as `{col}-units`. Errors cover invalid route, facet, column, frequency, sort, or period, inverted ranges (`no_data`), a filter list too long for EIA's ~8 KB request URI (`uri_too_long`; split the values across calls), and retryable `rate_limited`, `upstream_unreachable`, and `upstream_timeout`. - `stage: true` stages up to `EIA_CANVAS_MAX_ROWS` (default 25000) as a `dataset` handle for SQL via `eia_dataframe_describe` and `eia_dataframe_query` (hyphens in column names become underscores). If staging fails, `dataset` is absent and the note says so. Staging that would run past the call deadline stops before the next page, stages the rows already fetched, and the note says time ran out and gives the offset to resume from. --- ### `eia_dataframe_describe` tool - Omit `name` to list active staged dataframes; pass a handle to inspect one. A miss returns `found: false` with `active_names`. - Entries carry `source_tool`, `query_params`, `created_at`, `expires_at`, `row_count`, `truncated`/`max_rows`, and `column_schema`. Listing does not extend expiry. --- ### `eia_dataframe_query` tool - Run one read-only SQL SELECT against staged handles. Inline rows follow `preview` (default 25; a larger value returns at most `row_limit` and says so, and `0` returns none). `row_limit` defaults to 1,000 and returns at most 10,000. Echoes (`executedSql`, `columns`) come back whole. `register_as` still stages the complete result. - `truncated: true` means `totalRows` is a cap, not a full count. `register_as` stages the complete result under an unused name with an exact count; the notice gives the `LIMIT/OFFSET` query, with no `ORDER BY`, that pages the rest in the order the preview showed. A result with no rows still lists its columns. - Cast EIA VARCHAR values for arithmetic. Writes, file reads, and system catalogs are rejected; declared failures include `missing_table`, `invalid_sql`, `non_select_statement`, `denied_function`, `multi_statement`, `sql_read_only`, `system_catalog_access`, and `register_as_clash`. --- ### `eia_dataframe_drop` tool - Pass `name` to remove a staged dataframe; requires `EIA_DATAFRAME_DROP_ENABLED=true`. - Returns `dropped: false` when nothing matched; a failed drop returns retryable `canvas_drop_failed` and the dataframe stays staged. Cleanup is optional: dataframes expire after 24 h unused. ## Features Built on [`@cyanheads/mcp-ts-core`](https://github.com/cyanheads/mcp-ts-core): stdio and Streamable HTTP transports, pluggable auth (`none` / `jwt` / `oauth`), swappable storage (`in-memory`, `filesystem`, `Supabase`, `Cloudflare KV/R2/D1`), structured logging with optional OpenTelemetry tracing. EIA-specific: - Covers all 14 top-level dataset categories of EIA API v2, with the route tree cached in process - Fuzzy search (Fuse.js) over route names/descriptions, all 1,469 STEO and 968 SEDS series names, and facet values — 1,753 benchmark petroleum and natural-gas series names and the RTO balancing authorities among them, indexed at warm time - Facet values are fetched and cached per route by `eia_describe_route` for 24 hours, after which the next describe re-fetches the route; a list EIA does not return is reported in `unavailable_facets` and retried on the next call, and a refresh EIA does not answer serves the cached values with a notice naming when they were fetched - Routes whose metadata could not be fetched are reported through `eia_search_routes` instead of dropped - DataCanvas (DuckDB) is on by default and staging is opt-in per call; with the canvas off the three dataframe tools are not callable - Count inputs (`length`, `row_limit`, `preview`, `limit`) lower an over-cap value to its cap with a notice; `EIA_CLAMP_LIMITS=false` advertises and enforces each cap as a schema maximum instead Agent skill: - `eia-energy-guide` carries the gates for answering energy questions with these tools: per-route geography codes, total rows beside their components, per-row unit columns, nulls and zeros, and forecasts mixed with history. MCP clients read it from the server as `skill://eia-energy-guide/SKILL.md`; it also ships in the npm package, the Docker image, and the `.mcpb` bundle, and plugin installs scan it from `skills/`. Agent-friendly output: - Provenance — `eia_query_route` echoes the canonical route, and staged dataframes record their `source_tool` and `query_params` - Capped-window disclosure — truncated responses report the count against the cap and a `notice` naming the next call - Typed failures — error `reason` values (e.g. `route_not_queryable`, `invalid_facet`, `missing_table`) carry a `recovery` hint ## Getting started ### Public Hosted Instance A public instance is available at `https://eia-energy.caseyjhand.com/mcp` — no installation required. Point any MCP client at it via Streamable HTTP: ```json { "mcpServers": { "eia-energy-mcp-server": { "type": "streamable-http", "url": "https://eia-energy.caseyjhand.com/mcp" } } } ``` ### Self-Hosted / Local Get a free API key at [api.eia.gov](https://www.eia.gov/opendata/), then add the following to your MCP client configuration file. ```json { "mcpServers": { "eia-energy-mcp-server": { "type": "stdio", "command": "bunx", "args": ["@cyanheads/eia-energy-mcp-server@latest"], "env": { "MCP_TRANSPORT_TYPE": "stdio", "MCP_LOG_LEVEL": "info", "EIA_API_KEY": "your-api-key" } } } } ``` Or with npx (no Bun required): ```json { "mcpServers": { "eia-energy-mcp-server": { "type": "stdio", "command": "npx", "args": ["-y", "@cyanheads/eia-energy-mcp-server@latest"], "env": { "MCP_TRANSPORT_TYPE": "stdio", "MCP_LOG_LEVEL": "info", "EIA_API_KEY": "your-api-key" } } } } ``` Or with Docker: ```json { "mcpServers": { "eia-energy-mcp-server": { "type": "stdio", "command": "docker", "args": [ "run", "-i", "--rm", "-e", "MCP_TRANSPORT_TYPE=stdio", "-e", "EIA_API_KEY=your-api-key", "ghcr.io/cyanheads/eia-energy-mcp-server:latest" ] } } } ``` For Streamable HTTP, set the transport and start the server: ```sh MCP_TRANSPORT_TYPE=http MCP_HTTP_PORT=3010 EIA_API_KEY=your-key bun run start:http # Server listens at http://localhost:3010/mcp ``` ### Prerequisites - [Bun v1.4.0](https://bun.sh/) or higher (or Node.js v24+). - A free EIA API key from [api.eia.gov](https://www.eia.gov/opendata/). The `DEMO_KEY` hits rate limits quickly; a real key is required for sustained use. ### Installation 1. **Clone the repository:** ```sh git clone https://github.com/cyanheads/eia-energy-mcp-server.git ``` 2. **Navigate into the directory:** ```sh cd eia-energy-mcp-server ``` 3. **Install dependencies:** ```sh bun install ``` 4. **Configure environment:** ```sh cp .env.example .env # edit .env and set required vars (at minimum, EIA_API_KEY) ``` ## Configuration All configuration is validated at startup via Zod schemas in `src/config/server-config.ts`. Key environment variables: | Variable | Description | Default | |:---------|:------------|:--------| | `EIA_API_KEY` | **Required.** Free API key from api.eia.gov — appended as `api_key` on every request. | — | | `EIA_BASE_URL` | EIA API base URL. | `https://api.eia.gov/v2` | | `EIA_DATASET_TTL_SECONDS` | Dataframe lifetime in seconds, at most 31,536,000 (365 days); each `eia_dataframe_query` that references a dataframe extends it. | `86400` (24 h) | | `EIA_DATAFRAME_DROP_ENABLED` | Set to `true` to expose `eia_dataframe_drop` (no effect with `CANVAS_PROVIDER_TYPE=none`). Off by default to avoid accidental canvas cleanup. | `false` | | `EIA_CANVAS_MAX_ROWS` | Row ceiling for `eia_query_route` staging, at most 250,000. Reaching it costs five more requests: about 8.5 s on narrow routes such as `electricity/retail-sales`, 30–46 s on wide ones such as `electricity/facility-fuel`. Staging that would run past the call deadline stops early and names the offset to resume from. | `25000` | | `EIA_FACET_VALUE_CAP` | Facet values `eia_describe_route` returns per facet before truncating, at most 1,000; page with `facet` and `values_offset`, or narrow with `values_query`. | `50` | | `EIA_CLAMP_LIMITS` | Lower an over-cap `length`, `row_limit`, `preview`, or `limit` to its cap with a notice; `false` advertises and enforces each cap as a schema maximum instead. | `true` | | `CANVAS_PROVIDER_TYPE` | DataCanvas provider. `duckdb` enables `eia_dataframe_describe`, `eia_dataframe_query`, and `stage: true`; drop also needs `EIA_DATAFRAME_DROP_ENABLED=true`. `none` disables DataCanvas. The Claude Desktop bundle sets `none`. | `duckdb` | | `MCP_TRANSPORT_TYPE` | Transport: `stdio` or `http`. | `stdio` | | `MCP_HTTP_PORT` | HTTP server port. | `3010` | | `MCP_HTTP_ENDPOINT_PATH` | HTTP endpoint path. | `/mcp` | | `MCP_SESSION_MODE` | HTTP sessions: `stateless`, `stateful`, or `auto`. | `stateless` | | `MCP_PUBLIC_URL` | Public origin override for TLS-terminating reverse-proxy deployments. | — | | `MCP_AUTH_MODE` | Auth mode: `none`, `jwt`, or `oauth`. | `none` | | `MCP_LOG_LEVEL` | Log level (RFC 5424). | `info` | | `LOGS_DIR` | Directory for log files (Node.js only). | `/logs` | | `STORAGE_PROVIDER_TYPE` | Storage backend: `in-memory`, `filesystem`, `supabase`, `cloudflare-kv/r2/d1`. | `in-memory` | | `OTEL_ENABLED` | Enable OpenTelemetry instrumentation. | `false` | See [`.env.example`](./.env.example) for the full list of optional overrides. ## Running the server ### Local development - **Build and run:** ```sh # One-time build bun run rebuild # Run the built server bun run start:stdio # or bun run start:http ``` - **Run checks and tests:** ```sh bun run devcheck # Lint, format, typecheck, security bun run test # Vitest test suite bun run lint:mcp # Validate MCP definitions against spec ``` ### Docker ```sh docker build -t eia-energy-mcp-server . docker run --rm -e EIA_API_KEY=your-key -p 3010:3010 eia-energy-mcp-server ``` The Dockerfile defaults to HTTP transport, stateless session mode, and logs to `/var/log/eia-energy-mcp-server`. OpenTelemetry peer dependencies are installed by default — build with `--build-arg OTEL_ENABLED=false` to omit them. ## Project structure | Directory | Purpose | |:----------|:--------| | `src/index.ts` | `createApp()` entry point — registers tools and inits services. | | `src/config` | Server-specific environment variable parsing and validation with Zod. | | `src/mcp-server/tools` | Tool definitions (`*.tool.ts`) — browse, describe, search, query, and three DataCanvas dataframe tools — plus the shared count-input cap (`count-limit.ts`). | | `skills/` | Published agent skill (`eia-energy-guide`), served over MCP and shipped in the npm package, Docker image, and `.mcpb` bundle. | | `src/services/eia` | EIA API v2 service — route tree cache, Fuse.js index, facet fan-out, HTTP client. | | `src/services/canvas-bridge` | DataCanvas bridge — registers EIA query results as DuckDB dataframes, routes SQL queries. | | `tests/` | Unit and integration tests mirroring `src/`. | | `docs/` | Design documents (`design.md`, `idea.md`). | ## Development guide See [`CLAUDE.md`](./CLAUDE.md) for development guidelines and architectural rules. The short version: - Handlers throw, framework catches — no `try/catch` in tool logic - Use `ctx.log` for request-scoped logging, `ctx.state` for tenant-scoped storage - Always call `eia_describe_route` before `eia_query_route` — facet values require a separate API fan-out and are not embedded in route metadata - Wrap EIA responses: validate raw → normalize to domain type → return output schema; data values are strings — never coerce silently ## Contributing Issues are welcome. Run checks and tests before submitting: ```sh bun run devcheck bun run test ``` ## License Apache-2.0 — see [LICENSE](LICENSE) for details.