@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
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
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. On the balancing-authority facets of electricity routes (respondent, fromba, toba, parent, balancing_authority_code), a value also matches by its ISO/RTO acronym: ERCOT returns ERCO, SPP returns SWPP and SWPW, and CAISO on stateid matches nothing.
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.
- A data column with
units: "" has no unit in the route metadata. The notice names the eia_query_route row field its unit is in when a row has one ({col}-units, or units or unit on routes whose only column is value), since the unit can differ by row; a column whose rows have no such field, like county or latitude on electricity/operating-generator-capacity, has no unit. The notice also names facet lists EIA publishes partial: international's productId, activityId, and unit cover only its monthly and quarterly series.
- 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, the data columns a route publishes (
nameplate capacity → electricity/operating-generator-capacity), STEO's 1,469, SEDS's 968, and the Monthly Energy Review's 984 series names (primary energy consumption total or its ID TETCBUS → total-energy, msn: TETCBUS), and facet values. Before any describe, the facet values cover fuel, sector, technology, and coal-rank vocabularies, RTO balancing authorities by name, EIA code, or ISO/RTO acronym (Electric Reliability Council of Texas, ERCO, or ERCOT → 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. A value a returned row carries is never named, and lists EIA publishes partial are never used. 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 EIA would not read as written: a month outside 01–12 or an hour that is not two digits at any frequency, and a day its month lacks or an hour past 23 that rolls into another period at the effective frequency — 2024-02-30 at daily or monthly, 2024-01-31T24 at monthly. At weekly and four-week-average only the day is checked; an hour past 23 is sent, since whether it rolls into another week depends on the route's weekly calendar. A rollover that stays inside the period it names is sent, since EIA answers with that period (2024-02-30 at quarterly is 2024-Q1).
total reports the full match count; values remain strings and units appear per row as {col}-units, or as units or unit on routes whose only column is value; a route that puts no unit on its rows states it in describe's data_columns[].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: 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, data-column names, all 1,469 STEO, 968 SEDS, and 984 Monthly Energy Review series names, and facet values — 1,753 benchmark petroleum and natural-gas series names and the RTO balancing authorities (by name, code, or ISO/RTO acronym) 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:
Self-Hosted / Local
Get a free API key at api.eia.gov, then add the following to your MCP client configuration file.
Or with npx (no Bun required):
Or with Docker:
For Streamable HTTP, set the transport and start the server:
Prerequisites
- Bun v1.4.0 or higher (or Node.js v24+).
- A free EIA API key from api.eia.gov. The
DEMO_KEY hits rate limits quickly; a real key is required for sustained use.
Installation
- Clone the repository:
- Navigate into the directory:
- Install dependencies:
- Configure environment:
Configuration
All configuration is validated at startup via Zod schemas in src/config/server-config.ts. Key environment variables:
See .env.example for the full list of optional overrides.
Running the server
Local development
-
Build and run:
-
Run checks and tests:
Docker
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
Development guide
See 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:
License
Apache-2.0 — see LICENSE for details.