Skip to main content
Glama

education-data-mcp

An MCP server over the Urban Institute's Education Data Portal — harmonized federal education data (CCD, CRDC, IPEDS, EdFacts, SAIPE, College Scorecard, MEPS, PSEO) for schools, school districts, and colleges.

Queries the live EDP API. No API key required. Read-only.

Prerequisites

  • Python 3.11+

  • uv

Related MCP server: data-utah

Installation

git clone https://github.com/UrbanInstitute/education-data-mcp.git
cd education-data-mcp
uv sync

Tools

Six tools, following a discovery-first workflow:

Tool

Purpose

search_datasets

Find available datasets by level, source, topic, or keyword

describe_dataset

Inspect a dataset's variables, filters, and coded value formats

get_data

Fetch raw data records with human-readable labels (by default)

get_summary

Get aggregated statistics (counts, sums, averages) by group

lookup_codes

Translate between codes and labels (e.g., FIPS 6 = California)

resolve_entity

Find a school/district/college ID by name (e.g., "Harvard" → unitid 166027)

Typical workflow: search_datasets → describe_dataset → get_summary or get_data, with lookup_codes and resolve_entity as needed.

Tool reference

search_datasets

Find available datasets.

Parameter

Type

Description

level

string (optional)

"schools", "school-districts", or "college-university"

source

string (optional)

"ccd", "ipeds", "crdc", "edfacts", "saipe", "scorecard", etc.

topic

string (optional)

"enrollment", "directory", "finance", "discipline", etc.

search

string (optional)

Keyword to match against dataset URLs and descriptions

describe_dataset

Inspect a dataset's variables, filters, and coded value formats.

Parameter

Type

Description

path

string

Dataset path, raw template or filled in: "schools/ccd/enrollment/{year}/{grade}/race/" or "schools/ccd/enrollment/2022/grade-99/race/"

filterable_only

boolean (default: false)

Only show variables usable as query filters

get_data

Fetch education data. Returns human-readable labels by default.

Parameter

Type

Description

path

string

A path with every {placeholder} filled in: "schools/ccd/directory/2022/". One year per call

filters

string (optional)

Query filters: "fips=11&charter=1". Also accepts "ordering=-enrollment" to rank server-side

fields

string (optional)

Columns to return: "ncessch,school_name,enrollment"

add_labels

boolean (default: true)

Decode coded values to human-readable labels

preview

boolean (default: false)

Return a small labelled sample instead of a complete result

get_summary

Get aggregated statistics from the Education Data Portal. Use for counts, totals, or averages across groups. Much faster than fetching raw data and computing yourself.

Parameter

Type

Description

path

string

The dataset path WITHOUT {placeholders} — the static head of the template: "schools/ccd/enrollment"

var

string

Variable to aggregate: "enrollment", "teachers_fte", etc.

stat

string

Statistic: "sum", "count", "avg", "min", "max", "median", "stddev", "variance"

by

string

Grouping variables (comma-separated): "fips", "race", "fips,charter"

filters

string (optional)

Query filters: "fips=6" or "fips=6&year=2022". ordering= is not supported here

Results are always grouped by year in addition to the specified groupings.

Example: Total enrollment by state: get_summary(path="schools/ccd/enrollment", var="enrollment", stat="sum", by="fips")

lookup_codes

Look up code-to-label mappings. Use to find filter values (e.g., "California" = FIPS 6) or understand coded results.

Parameter

Type

Description

format_name

string

Format name from describe_dataset output: "fips", "race", "sex", etc.

codes

string (optional)

Comma-separated codes: "1,2,3". Omit to see all values.

Coded values are field-specific. Code meanings come from each variable's API metadata, not a fixed table, and get_data/get_summary decode them to labels automatically. Negative codes often mean -1 = Missing/not reported, -2 = Not applicable, -3 = Suppressed for privacy — but not always: for grade, -1 means Pre-K (0 = Kindergarten). Use lookup_codes to see a variable's full, authoritative code list.

resolve_entity

Resolve a school, district, or college name to its ID for filtering. Use when you need to filter data by a specific entity.

Parameter

Type

Description

name

string

Name to search for (case-insensitive substring match)

entity_type

string

"school", "district", or "college"

fips

integer (optional)

State FIPS code (e.g., 6 for California). Required for schools and districts

max_results

integer (default: 15)

Maximum number of results to return

Returns matching entities with their IDs (ncessch for schools, leaid for districts, unitid for colleges) for use in get_data filters.


Running it

MCP Inspector (interactive testing)

uv run mcp dev src/edp_mcp/server.py

Opens a browser at http://localhost:6274 — connect, open Tools, and run any tool with parameters.

Claude Desktop / Claude Code / VS Code / Copilot CLI

{
  "mcpServers": {
    "edp-mcp": {
      "command": "uv",
      "args": ["run", "--directory", "/absolute/path/to/education-data-mcp", "edp-mcp"]
    }
  }
}

Client

Where it goes

Claude Desktop

claude_desktop_config.json — macOS: ~/Library/Application Support/Claude/; Windows: %APPDATA%\Claude\

Claude Code

.claude/settings.json, or claude mcp add edp-mcp -- uv run --directory /absolute/path/to/education-data-mcp NAME

VS Code (Copilot)

.vscode/settings.json, nested as {"mcp": {"servers": {...}}}

stdio (direct)

uv run edp-mcp

Streamable HTTP (hosted)

MCP_TRANSPORT=streamable-http PORT=8080 uv run edp-mcp

MCP is served at POST /mcp; GET /health is a plain unauthenticated health check for a load balancer or orchestrator.

Variable

Default

Purpose

MCP_TRANSPORT

stdio

stdio or streamable-http

PORT

8080

Listen port

MCP_HOST

0.0.0.0

Bind address

MCP_ALLOWED_HOSTS

(unset)

Comma-separated Host allow-list

MCP_ALLOWED_ORIGINS

(unset)

Comma-separated Origin allow-list

Set MCP_ALLOWED_HOSTS to the public hostname before exposing this beyond a private network. Both are unset by default, which leaves the SDK's DNS-rebinding protection off — setting either turns it on. Note that enabling it with an allow-list that omits the real hostname rejects every request.

Hosted

Also available as a hosted streamable-HTTP server: https://educationdata.urban.org/mcp/edp

Tests

uv run pytest -q

Tests use saved API fixtures (tests/fixtures/) with mocked HTTP, so no network is needed. scripts/smoke_live.py exercises the real API end to end.

Available Tools

6 tools
describe_datasetDescribe datasetA
Read-onlyIdempotent

Inspect one dataset: every variable with its definition, data type, coded-value format, and whether it is filterable. Also returns the years covered, the source description, and equivalent R/Stata code.

This is where the [FILTER] marks come from — filtering on anything else returns UNFILTERED data rather than an error.

Args: path: Dataset path, template or filled in — both resolve to the same dataset: "schools/ccd/enrollment/{year}/{grade}/race/" or "schools/ccd/enrollment/2022/grade-99/race/" filterable_only: Show only the variables usable as filters

Inputs: path (required), filterable_only. Returns: result

get_dataGet data recordsA
Read-onlyIdempotent

Fetch raw data records from one dataset path. For totals or averages across groups use get_summary instead — it aggregates server-side.

Results are COMPLETE or refused, never truncated, so counting and averaging over the rows returned is valid. A query matching over 10,000 rows is refused with its true size and how to narrow it. When one is too big, aggregate with get_summary, narrow to a state or district, or hand the user the bulk CSV link from the refusal or the R/Stata snippet from describe_dataset — for whole-country or long multi-year analysis those beat paging through calls.

COST: this hits a live API and a broad query can take 30s+. Filter before fetching rather than issuing many wide calls in parallel.

Args: path: A dataset path from search_datasets with every {placeholder} filled in — "schools/ccd/enrollment/2022/grade-99/race/". One year per call; a leading "/api/v1/" is optional. filters: "fips=11&charter=1". Only fields marked [FILTER] in describe_dataset work; others are rejected here rather than silently returning unfiltered data. Also takes "ordering=" to rank server-side: "ordering=-enrollment" largest first, "ordering=enrollment" smallest. Ranking sorts the whole result before paging, so a ranked query answers where an unranked one is refused as too large. fields: Comma-separated columns — "ncessch,school_name,enrollment". ALWAYS PASS THIS. These tables are 50-96 columns wide; requesting only what you need is typically an 8-17x reduction and is usually the difference between an answer and a refusal. add_labels: Decode coded values to labels (default True). Decoding is per variable, so meanings are FIELD-SPECIFIC — trust the decoded label over any assumption about what a raw code means. preview: Return a small labelled SAMPLE rather than a complete result. The rows are the API's first N by ID, NOT a random sample — never count, rank or average over them.

Inputs: path (required), fields, filters, preview, +1 more. Returns: result

get_summarySummarize dataA
Read-onlyIdempotent

Aggregate a dataset server-side: counts, sums, averages by group. Use this rather than get_data whenever the question is about totals ("how many schools per state", "total enrollment by race").

EVERY year comes back in one call — results are grouped by year on top of by, so a trend needs one call, never one per year. Filter years only to narrow a large result. Results are COMPLETE or refused. The API cannot rank aggregates, and refuses groupings that produce too many groups (by=leaid nationally, for instance).

var and by are drawn from two different pools — describe_dataset lists both for any dataset. Guessing costs a slow round trip; reading them does not.

COST: this hits a live API and a broad aggregation can take 30s+.

NO COUNTY AGGREGATION for schools or districts. Several datasets return county_code as a column, but it is neither filterable nor groupable, so county totals cannot be computed here — fetch the rows with get_data and aggregate them yourself, or say the portal does not support it. Do not retry with county in by.

Args: path: The SUMMARY path — no year, no {placeholders}, e.g. "schools/ccd/enrollment". describe_dataset prints the right one for any dataset. Some differ from the data path ("college-university/ipeds/fall-enrollment/race"). var: The measure to aggregate — a numeric, non-filter variable stat: sum, count, avg, min, max, median, stddev, or variance by: Comma-separated groupings — "fips", "fips,race". May also use variables from the source's directory ("school_level", "sector"). filters: "fips=6" — omit year to get every year; "year=2018,2019,2020" to restrict the range

Inputs: by (required), var (required), path (required), stat (required), +1 more. Returns: result

lookup_codesLook up coded valuesA
Read-onlyIdempotent

Look up the code-to-label mapping for a coded variable — mainly to find a filter value for get_data ("California" -> fips=6).

Args: format_name: The "Format" shown for a variable in describe_dataset — "fips", "race", "sex", "school_level", "charter", … codes: Comma-separated codes to look up — "6,48". Omit for all values.

Inputs: format_name (required), codes. Returns: result

resolve_entityResolve entity name to IDA
Read-onlyIdempotent

Resolve a school/district/college NAME to the ID used in get_data filters.

Returns CANDIDATES with their city and level, not one authoritative answer — names repeat, so the right row is the one whose city and level fit what the user meant. Type words ("Elementary", "High", "ISD") are ignored when matching, which is what lets natural phrasings reach the directory's abbreviations ("Lakewood Elementary" finds "LAKEWOOD EL").

Schools and districts require fips: without it this asks which state rather than guessing, since "Homestead High" exists in CA, WI, IN and FL.

COST: downloads a whole state's directory, so the first call for a state often takes 10-30s and later ones are cached. Resolve names in the same state together.

Args: name: Name to search for; distinctive words matter most. entity_type: "school", "district", or "college" fips: State FIPS code (6 = California). Required for schools and districts. See lookup_codes(format_name="fips"). max_results: Maximum candidates to return (default 15)

Inputs: name (required), entity_type (required), fips, max_results. Returns: result

search_datasetsSearch datasetsA
Read-onlyIdempotent

Find datasets in the Education Data Portal. Returns each one's path template, description and years available; pass a template to describe_dataset for its variables and its summary call. All arguments are optional filters.

NOT COVERED by this portal at all: NAEP scores (see the NAEP Data Explorer), teacher salaries (BLS), private K-12 (limited; CRDC covers some), and curriculum data. Say so rather than searching repeatedly.

Args: level: "schools", "school-districts", or "college-university" source: "ccd", "ipeds", "crdc", "edfacts", "saipe", "scorecard", … topic: "enrollment", "directory", "finance", "discipline", … search: Words matched per-token against dataset paths and descriptions, not as a phrase — so drop specific terms like grade numbers ("8th") if a query returns nothing, since datasets rarely spell those out verbatim.

Inputs: level, topic, search, source. Returns: result

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A4.8/5.0

    Scored across 6 tools

    Disambiguation5/5

    Each tool has a clearly distinct role: discover datasets, inspect schema, fetch raw rows, aggregate, decode codes, and resolve names to IDs. The potentially confusing get_data/get_summary pair is explicitly separated and cross-referenced.

    Naming Consistency5/5

    All tool names follow a consistent snake_case verb_noun convention: describe_dataset, search_datasets, get_data, get_summary, lookup_codes, resolve_entity. No mixed casing or vague generic verbs appear.

    Tool Count5/5

    Six tools form a compact, non-redundant set that covers the read-only data-portal workflow well. Each tool earns its place, and the count is well within the ideal range for an agent to navigate comfortably.

    Completeness5/5

    The tool surface covers the full query lifecycle: find datasets, inspect variables and filter fields, resolve codes and entity names, fetch raw data, and aggregate server-side. Known unsupported operations are explicitly documented with workarounds, leaving no obvious dead ends.

    Maintenance

    ActivityStale
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers