STI Surveillance MCP
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@STI Surveillance MCPCompare California gonorrhea cases in 2024-W52 with the same week last year"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
π¬π§ English Β· πͺπΈ EspaΓ±ol
sti-surveillance π§¬
Can an AI assistant answer questions about STI surveillance data using the same governed model that feeds a Power BI report?
This project builds both. A weekly pipeline brings CDC's national notifications (NNDSS) and Oregon's reportable-disease data (county, sex, age, race) into Postgres (Neon); dbt turns them into a star schema; and on top of that one model sit a 7-page Power BI report with row- and object-level security, and a read-only MCP server so an AI can query the same tables and definitions without changing them or reading restricted columns. The public web demo is only an example of how this would work (see Live demo).
Sources: CDC NNDSS Weekly, Oregon Monthly Reportable Disease and Census PEP 2025. All free, no API key.
Power BI report π
The main deliverable: seven pages (Overview, Map, Ranking, Corridor, Oregon, Demographics, Quality) over a single semantic model, with an accessible dark theme.

Composite model. The two facts (
fct_nndss_weekly,fct_oregon_monthly) stay in DirectQuery against Neon, five dimensions are Dual, and a small Import aggregation (agg_weekly_national) answers the national weekly views without touching the database.Row-level security (RLS). Each user sees only their rows:
dim_accessmaps the sign-in (USERPRINCIPALNAME()) to a state or county, and the rolesState_Analyst,County_AnalystandAdminfilterdim_geographyfrom it. An unknown user sees nothing.Object-level security (OLS).
Restricted_State_AnalystandRestricted_Epidemiologistcannot seerace_group,age_group,demographic_levelor themart_revisionstable; the Demographics page says so instead of failing.MCP. The same model is also open to an AI assistant; see MCP over the model.
Honest about gaps. Rates are annualised per 100,000, masked cells stay empty, and the Oregon pages state the suppression rule (fewer than 6 cases).
Related MCP server: PopHIVE MCP Server
MCP over the model π€
The report answers the questions someone built a visual for; the MCP lets an AI answer the rest in plain language. It reads the model's definition (the TMDL, exposed as a resource) and queries the same star schema, so its figures use the report's definitions and match them (its totals equal an independent query for all five diseases). It is read-only, has a 10 s timeout, and its database role cannot read the restricted columns or the revision history. Examples: "Which five states have the most primary and secondary syphilis cases in 2025?", "Is California above its usual range for gonorrhea this week?". Details in How it works; to try a public version, see Live demo.
Results π
dbt build: 70 of 70 nodes pass.Cross-checks against the APIs: California gonorrhea in week 2024-W52 = 499 cases and Multnomah chlamydia in 2026-M01 = 294, both matching the source.
The MCP view gives the same totals as an independent query for all five diseases.
Ingestion is idempotent: two consecutive runs leave identical row counts.
The weekly workflow runs green end to end (pytest, ingest, seed, snapshot, build, freshness) in about 1 min 17 s.
Power BI model checked against SQL anchors: 12 table counts, NNDSS and Oregon totals, annualised rates (CA chlamydia 2024 = 259.434 per 100k), year-over-year in two forms (MMWR-aligned and
SAMEPERIODLASTYEAR, which differ by exactly the 2,833 cases of week 2025-W1), the endemic corridor and the Oregon cuts (chlamydia 80,587 by county, 82,066 statewide).Aggregation hit confirmed three ways: Performance Analyzer, DAX Studio (
RewriteAttemptedwith a match and a scan ofagg_weekly_national, no SQL) and revokingSELECTon the fact frompbi_reader: the aggregated visual kept working and the other failed.Row-level security: the 10-case matrix in View as matches, and an unknown user sees nothing. Object-level security blocks
race_group,age_group,demographic_levelandmart_revisionsin View as whilesexkeeps working.Report: 7 pages over one semantic model, with the accessible dark theme (contrast tests in
tests/test_theme_contrast.py).MCP guardrails verified from Python: a valid query returns JSON, DML and restricted columns are blocked, and the database refuses writes and the
rawschema on its own.
Live demo π
Live demo β Β· English Β· EspaΓ±ol Β· Remote MCP

An example of how this would work, public and free, with no online database or keys:
Static site (GitHub Pages,
site/): a 3-view replica of the report (Overview, Map, Corridor), with 5 example questions computed in the browser.?tour=1rotates the views automatically.Read-only remote MCP (Cloudflare Workers,
worker/): 6 tools with validated parameters and no free SQL. It reads the same aggregated JSON as the site. Connect it as a custom connector in Claude (available on every plan; the free plan allows one) or in any client that supports remote MCP:https://sti-surveillance-mcp.jcbatman.workers.dev/mcp.What it contains: NNDSS only (chlamydia, gonorrhea, primary/secondary and congenital syphilis) by state and week, with the model's definitions. The Oregon Health Authority dataset (counties, demographics) and revisions are not published (Oregon's state-level NNDSS rows are); they stay in the Power BI report and in the local lab MCP (
mcp/,mcp_readerrole).How it stays fresh: the weekly workflow exports
site/data/*.jsonfrom Neon and republishes Pages; the Worker reads that JSON, so it never needs a redeploy. Tests compare the site's figures with the SQL anchors before publishing.Sources and notices: CDC NNDSS (provisional data) and U.S. Census Bureau. Independent project, not affiliated with these entities.
How to use it, step by step
A. In your browser (no account needed)
Open https://datakrdo.github.io/sti-surveillance/. Switch language with ES / EN at the top right.
Pick a disease and a year in the filters. Each disease has its own color (see the legend on the right of the filters).
Overview tab: four cards (cases, rate per 100,000 people, change versus the same period last year, and how many states are above their usual range) and a line with the weekly cases of the selected year against the previous year.
Map tab: each state is colored by its rate of cases per 100,000 people. The bar under the map explains the scale: blue-gray = lower rate, yellow = higher rate, dark gray = no data. It compares states with each other; it is not a measure of clinical severity. Next to it you will find the 10 states with the most cases.
Corridor tab: choose a state. The gray band is the usual range of earlier years for each week and the dashed line is their median. If the colored line (the year you picked) goes above the band, that week had more cases than usual.
Ask the data (right panel): click any example question to see its answer. They are computed in your browser from the same data the MCP uses.
Optional: add
?tour=1to the address and the page rotates through the three views by itself (it stops when you click).
B. Ask your own questions with Claude (free-form)
You need a Claude account (any plan; the free plan allows one custom connector) on claude.ai or Claude Desktop. Menu names may vary slightly between versions.
Copy the server address:
https://sti-surveillance-mcp.jcbatman.workers.dev/mcpIn Claude, open Customize β Connectors and choose Add custom connector. On Team and Enterprise plans an organization owner adds it under Organization settings β Connectors.
Name it (for example "STI Surveillance"), paste the address and save. The server is public and read-only, so leave any sign-in or advanced options empty.
Start a new chat, make sure the connector is switched on, and ask in plain language. If Claude asks permission to use a tool, allow it. Try:
"Using the STI Surveillance connector, how many gonorrhea cases were reported in California in 2024?"
"What was the national chlamydia rate per 100,000 people in 2025?"
"Is gonorrhea in Texas up or down versus the same period last year?"
"Is California above its usual range for gonorrhea in week 38 of 2026?"
"Which five states have the most primary and secondary syphilis cases in 2025?"
"What does this data cover, and what are its limits?"
Any other client that supports remote MCP servers works with the same address.
What it cannot do, on purpose: run free SQL, change anything, or show the Oregon Health Authority county and demographic data. Weekly figures are provisional and the data refreshes every week. If an answer looks odd, ask Claude to list the available diseases, states and weeks (list_dimensions).
How it works ποΈ
CDC NNDSS (Socrata) ββ
Oregon (Socrata) ββΌββΊ scripts/ingest.py ββΊ Neon raw.* ββΊ dbt ββΊ analytics_marts.* ββΊ Power BI
Census PEP (CSV) ββ (GitHub Actions, weekly) β βββΊ MCP server (read-only)
ββ snapshot of NNDSS m1 β mart_revisionsanalytics_marts holds dim_disease, dim_geography, dim_mmwr_week, dim_epi_month, dim_demographic, the facts fct_nndss_weekly and fct_oregon_monthly, agg_weekly_national, mart_revisions, mart_data_quality and the MCP view. Fact ids are text md5 keys.
The MCP server (mcp/mcp_server.py) exposes one SQL tool and the TMDL as a resource. Protection comes in three layers: the Postgres role mcp_reader (read-only, 10 s timeout, grants only on analytics_marts), no access to the multi-cut fact, dim_demographic or mart_revisions, and a regex on DML and restricted names.
Data sources π
Source | Endpoint | Grain | Rows loaded |
CDC NNDSS Weekly |
| week Γ state Γ disease | 69,720 |
Oregon Monthly Reportable Disease |
| month Γ (county, sex, age or race) Γ disease | 25,125 |
Census PEP 2025 |
| state Γ year | 396 |
Cadence seen on 2026-10-14: NNDSS weekly, Oregon declared monthly (it regenerated alongside its weekly dataset that day), Census annual. Only one publication date per source was observed, so the NNDSS weekday is not confirmed.
Design decisions π§
Two sources, not one. No single free API has recent STI data with demographics: NNDSS is national and weekly but has no demographics or HIV; Oregon has both. Other portals were ruled out by freshness (California ends in 2021, Illinois in 2016, NYC HIV is annual to 2022) or lack of an API (AtlasPlus).
Full load with upsert, not incremental. Each run pulls about 95,000 rows in around a minute and upserts by key, so reruns are idempotent. Incremental logic would add complexity for no gain at this size. Only
snap_nndss_weeklykeeps history between runs.Weekly, not daily. NNDSS publishes weekly, Oregon declares monthly and Census is annual. The workflow runs Thursdays 06:00 UTC; dbt freshness warns at 8 days and errors at 15.
Masked cells stay
NULL. They are never imputed, and rates must use visible cells only.The MCP sees one cut.
mcp_oregon_county_monthlyholds counties and theGeographycut only, so anySUMis additive. The role has no access to the multi-cut fact or todim_demographic.New York City is folded into NY to match Census;
max_prev_52wfor NY is a sum and informative only.Composite Power BI model. Facts in DirectQuery, five dimensions in Dual, a small Import aggregation (
agg_weekly_national).dim_geographyis Import, not Dual: the role editor rejects row-level security on a Dual table that references the Importdim_access.Rates in person-years. Population is annual and Oregon periods last 28 or 35 days, so rates divide by summed population Γ time (annualised), never by a single population.
Zero is not missing. In NNDSS a
-means no cases reported and loads as 0; onlyUstaysNULL(213 rows, Maryland and Ohio). Oregon's statewide total is its ownStatewidecut, so county sums do not double count.One role per user. Row- and object-level security live together in
Restricted_State_Analyst;demographic_levelis restricted too because it repeats the values ofage_groupandrace_group.Read-only
pbi_readerrole for Power BI; grants are reapplied by the same dbt hook as the MCP's.Pinned versions. Without a
dbt-corepin, pip picked a betadbtwith no postgres support and the first CI run failed.
Tools π οΈ
Python 3.12, requests, psycopg2, pytest
Neon Postgres, dbt-core 1.12 + dbt-postgres 1.11
GitHub Actions (weekly), MCP Python SDK 2.3
Power BI (PBIP/TMDL), Tabular Editor 2, DAX Studio
Static site (vanilla JS, SVG), GitHub Pages, Cloudflare Workers, Node 22
Cloudflare Workers (
agentsSDK, zod, Wrangler), Node 22 test runner
Skills π§
Ingestion from open APIs into a warehouse: paginated SoQL, idempotent upsert on natural keys, an empty-source guard that aborts before touching the database
Dimensional modeling with dbt:
unique/relationshipstests, singular tests that catch double counting, a snapshot that tracks revisions, an exposure for lineageStatistical disclosure control: small-cell suppression kept as
NULLplus a flagSafe AI access to a database: read-only role, grants re-applied by a dbt hook, a single-cut view, and a regex as a second line only
CI debugging: traced a failing run to an unpinned dependency that resolved to a beta
Composite Power BI modelling (DirectQuery, Dual and an Import aggregation) with the aggregation hit proven three ways, and epidemiological DAX: person-year rates, MMWR-aligned year-over-year and endemic corridors
Row- and object-level security in TMDL, tested role by role, including how overlapping roles combine
Accessible report design generated from code: 8 px grid, synced slicers, WCAG contrast and layout tests
Static data sites: one shared calculation module used by the page and the API, checked against SQL anchors in CI
A free, safe public API for an AI: a read-only remote MCP on Cloudflare Workers with validated parameters, no free SQL and no secrets
CI/CD with least-privilege GitHub Actions: weekly export, anchor check and Pages deploy
Structure
sti-surveillance/
βββ .github/workflows/weekly_ingest.yml # pytest β ingest β dbt seed/snapshot/build β freshness β Pages
βββ .github/workflows/pages.yml # export public aggregates, check anchors, deploy site/
βββ scripts/ingest.py # fetch, normalise, upsert
βββ sql/ # raw schema, mcp_reader role
βββ dbt/
β βββ models/staging, models/marts # views and tables, with tests
β βββ seeds/ # disease_map, state_codes, dim_access
β βββ snapshots/snap_nndss_weekly.sql
β βββ tests/ # singular grain tests
βββ mcp/mcp_server.py
βββ data_model/ # Power BI: one semantic model (TMDL), one report per language
βββ theme/dark_accessible.json # accessible dark theme
βββ scripts/export_public.py # public aggregates for the demo
βββ site/ # static dashboard (GitHub Pages)
βββ worker/ # remote read-only MCP (Cloudflare Workers)
βββ tests/ # ingest, theme contrast, report layout, exporter, no real DB host
βββ docs/
βββ PROJECT.md # design, decisions, anchors, demo, pending workQuick start π
Needs Python 3.12 or newer and a Neon project (without the connection pooler).
cp .env.example .env # fill DATABASE_URL, PGHOST, PGUSER, PGPASSWORD, PGDATABASE
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt -r mcp/requirements.txt
# load .env (Neon URLs can contain '&', so `source` breaks)
while IFS='=' read -r k v || [[ -n "$k" ]]; do [[ -z "$k" || "$k" == \#* ]] && continue; export "$k=$v"; done < .env
psql "$DATABASE_URL" -f sql/raw_schema.sql
.venv/bin/python -m scripts.ingest
cd dbt && ../.venv/bin/dbt seed --profiles-dir . && ../.venv/bin/dbt snapshot --profiles-dir . && ../.venv/bin/dbt build --profiles-dir .MCP role, once: psql "$DATABASE_URL" -v pw="'<password>'" -f sql/mcp_reader_role.sql, then store its URL in .env as MCP_DATABASE_URL. The grants are applied by the dbt on-run-end hook at every build.
Public demo, locally: python -m scripts.export_public (writes site/data/), then python3 -m http.server -d site 8000. The remote MCP: cd worker && npm install && npx wrangler dev.
CI needs the repo secrets NEON_DATABASE_URL, PGHOST, PGUSER, PGPASSWORD and PGDATABASE.
Tests
.venv/bin/pytest -q # 62 tests: ingest, theme contrast, report layout, exporter, no real DB host
node --test "site/js/*.test.mjs" # 17 tests: metrics against the SQL anchors, site
(cd worker && npm test) # 11 tests: remote MCP tools
cd dbt && ../.venv/bin/dbt build --profiles-dir .Out of scope (deliberately)
A national HIV series: the only free source with an API and recent data is Oregon.
Incremental loading and an orchestrator: the full load takes about a minute.
Publishing the report with Power BI Publish to web: it exposes the data and does not support row-level security.
Imputing masked cells.
Limitations β οΈ
Before using the numbers
Oregon publishes one cut per row (county, or sex, or age, or race), never a cross of them. Adding all cuts counts each case about 5 times: chlamydia sums to 500,312 across every cut and to 80,587 across counties only.
70.7% of Oregon rows are masked (fewer than 6 cases).
casesstaysNULL, never zero, so county sums understate the state total.NNDSS counts are provisional and get revised after publication; the snapshot records every change.
HIV exists only in Oregon. NNDSS does not publish it, so there is no national HIV series here.
Primary and secondary syphilis (NNDSS) and early syphilis (Oregon) are different definitions and are not comparable.
Other limitations
No demographic crosses (sex Γ age Γ race); Oregon does not publish them.
NNDSS STI series start in 2022, so an endemic corridor would have only the available years.
2026 population uses the 2025 Census estimate.
dim_access.csvhas fictional users on aTENANTdomain; replace it before testing row-level security.Asymptomatic_Ratiofrom the original plan was dropped: no source has it.The service test of row- and object-level security ran in a private workspace of a work tenant with real test accounts. It needs a PostgreSQL cloud connection with the read-only
pbi_readerrole; the free Neon tier may suspend and slow the first query.Assigning a user one role with row filters and another that only restricts objects makes the row filters union:
State_Analyst+Restricted_Epidemiologistshowed 93 geographies instead of 38, while the object restriction held.SAMEPERIODLASTYEARon a single week returns blank because the data is weekly, so the report uses the MMWR-aligned comparison.The endemic corridor uses the years with data (2022 onwards) and the last week of CA gonorrhea (2026-W40) looks like a partial report (hypothesis).
Person-Years Oregoniterates months to stay DirectQuery-friendly and no longer drops the 34 visible rows with an emptycases, so it can differ slightly on other cuts.The MCP
[OLS-HIPAA]error comes from a text filter inmcp_server.py(it looks forrace_group,age_group,race_method,mart_revisions), so it is a demo guard, not a permission. The real barrier is themcp_readerrole: it has noSELECTondim_demographic,fct_oregon_monthlyormart_revisions(checked:permission denied), and it is read-only with a 10 s timeout.
License
MIT β see the LICENSE file. Data: CDC (Office of Public Health Data, Surveillance, and Technology), Oregon Health Authority (attributed as "OHA PHD ACDP") and U.S. Census Bureau. The two Socrata datasets declare no license in their metadata, so check each portal's terms before redistributing the data.
This server cannot be deployed
Maintenance
Related MCP Connectors
Provides access to Civic Plus - See Click Fix, allowing you to interact with your data via an LLM.β¦
SEO & marketing toolkit for AI agents: GA4, Search Console, AdSense, GTM, PageSpeed, Trends.
Read-only airport delay, weather, and 24h forecast tools for AI assistants. Airport-level only.
US government data as clean JSON for AI agents: SAM.gov contract opportunities, USAspending awards, Grants.gov grants, House STOCK Act trades, and SEC EDGAR filings (Form 4 insider trades, 8-K events, 13F holdings, 13D/G stakes, XBRL fundamentals, 10-K/10-Q sections). 19 read-only tools. Data is as fresh as each source publishes; congressional trades lag up to 45 days and report dollar ranges (House only). Free tier, no card.
Related MCP Servers
- AlicenseBqualityDmaintenanceProvides access to the World Health Organization's Global Health Observatory data, enabling AI assistants to search, retrieve, and analyze comprehensive health indicators, country statistics, disease burden data, and regional health trends through WHO's OData API.1MIT
- AlicenseBqualityDmaintenanceProvides access to comprehensive public health data from Yale's Population Health Information Visual Explorer, including metrics on immunizations, respiratory diseases, and chronic conditions. It enables users to perform state-level comparisons, time-series analysis, and data filtering across authoritative sources like the CDC and Epic Cosmos.524MIT
- AlicenseNot gradedqualityBmaintenanceDiscover and query CDC public health datasets via the Socrata SODA API. Provides tools for dataset discovery, schema inspection, and SoQL queries, plus resources and a guided prompt for health trend analysis.264 npm5Apache 2.0
- AlicenseNot gradedqualityBmaintenanceEnables AI agents to query U.S. public-health datasets such as disease surveillance, mortality, behavioral risk factors, and flu activity through the Socrata API, using natural language or direct tool calls.16 npmMIT