Query FDA data on drugs, food, devices, and recalls via openFDA. STDIO or Streamable HTTP.
Query FDA data on drugs, food, devices, and recalls via openFDA. STDIO or Streamable HTTP.
Public Hosted Server: https://openfda.caseyjhand.com/mcp
FDA data on drugs, food, devices, and recalls from the openFDA public API. Search adverse events, recalls, drug approvals, and device clearances; look up NDC codes and drug labels; aggregate field counts across any endpoint. Runs as a stdio process, a local Streamable HTTP server, or the public hosted endpoint above.
| Tool | Description |
|---|---|
openfda_drug_profile | One drug name → consolidated FDA profile: identity, label, adverse events, recalls, approval, shortage |
openfda_search_adverse_events | Search adverse event reports across drugs, food, and devices |
openfda_search_animal_events | Search adverse event reports for veterinary drugs and devices |
openfda_search_drug_shortages | Search FDA drug shortage records — status, availability, therapeutic category, manufacturer |
openfda_search_tobacco_reports | Search problem reports for tobacco products, e-cigarettes, and vaping devices |
openfda_search_recalls | Search enforcement reports and recall actions across drugs, food, and devices |
openfda_count_values | Aggregate and tally unique values for any field across any openFDA endpoint |
openfda_describe_fields | Return searchable field paths for an openFDA endpoint, grouped by category |
openfda_get_drug_label | Look up FDA drug labeling (package inserts / SPL documents) |
openfda_search_drug_approvals | Search the Drugs@FDA database for NDA/ANDA application approvals |
openfda_search_device_clearances | Search FDA device premarket notifications — 510(k) clearances and PMA approvals |
openfda_lookup_ndc | Look up drugs in the NDC (National Drug Code) Directory |
openfda_dataframe_query | Run read-only SQL over a result set staged on a DataCanvas (opt-in) |
openfda_dataframe_describe | List tables and column schemas staged on a DataCanvas (opt-in) |
openfda_drug_profile tooldrug/label, drug/event, drug/enforcement, drug/drugsfda, and drug/shortages; each section (label, adverse_events, recalls, approval, shortage) is best-effort and returns null on a miss rather than failing the calldegraded[] names any section whose sub-query failed upstream (rate limit, 5xx, query error) — a section listed there is unknown, not confirmed absentdrug_name or name in place of drugopenfda_search_adverse_events toolcategory selects drug, food, or device — each returns a different field schemalimit up to 1000 and skip up to openFDA's 25000-record ceiling (past it: typed pagination_limit_reached); the page is also bounded by a shared ~24 KB serialized-byte budget — drug/event reports run tens of KB each against a few hundred bytes for food/event, so an oversized page returns fewer records than requested and reports the cut via page_omittedreceivedate for drug, date_created for food, date_received for device) — a field from another category causes a query errorstage: true (or canvas_id) drains the matched set onto a DataCanvas table for SQL via openfda_dataframe_queryopenfda_search_animal_events toollimit up to 1000, bounded by the shared ~24 KB page-byte budget (page_omitted reports any cut); skip capped at 25000stage: true (or canvas_id) stages the matched set for SQL via openfda_dataframe_queryanimal.species, drug.brand_name, reaction.veddra_term_name, serious_aeopenfda_search_drug_shortages toolstatus (Current/Resolved), therapeutic_category, generic_name, or company_nameopenfda block carries brand_name, product_ndc, and rxcui for chaining into openfda_get_drug_label or openfda_lookup_ndclimit up to 1000, bounded by the shared ~24 KB page-byte budget; skip capped at 25000stage: true (or canvas_id) for DataCanvas SQL via openfda_dataframe_queryopenfda_search_tobacco_reports tooltobacco_products, reported_health_problems, reported_product_problems, or nonuser_affectednumber_tobacco_products / number_health_problems / number_product_problems counts alongside the arrayslimit up to 1000, bounded by the shared ~24 KB page-byte budget; skip capped at 25000stage: true (or canvas_id) for DataCanvas SQL via openfda_dataframe_queryopenfda_search_recalls toolcategory (drug/food/device) plus endpoint — enforcement covers all categories, recall is device-only and rejects a non-device category as a typed recall_endpoint_non_device errorclassification (Class I/II/III), recalling_firm, reason_for_recall, statusproduct_res_number, recall_status) and no hazard classification; the text output renders each record with its endpoint's fieldslimit up to 1000, bounded by the shared ~24 KB page-byte budget — a device record runs several KB against roughly one for drug/foodstage: true (or canvas_id) for DataCanvas SQL via openfda_dataframe_queryopenfda_count_values toolopenfda_describe_fields coverscount takes a dotted field path — openfda_describe_fields lists the verified expression for each field (countAs); outside the catalog, append .exact on analyzed text fields and count keyword identifiers (product_ndc, application_number, pma_number) barenot_aggregatable, naming the expression that does count or saying the field has none; a search whose matched records lack the field returns an empty tally with a noticesearch scopes the aggregation; returns up to 1000 top terms ranked by count descendingtruncated is set only when more distinct terms exist past limit (the tool reads one term ahead); at openFDA's 1000-term count maximum no look-ahead is possible, so a full list carries a notice that more may exist insteadopenfda_describe_fields toolopenfda_count_values acceptscountAs — the live-verified openfda_count_values expression (bare or .exact), or null when the field can't be aggregated — plus queryTips covering quoting, AND/OR, .exact, and date-range syntaxsearch query — field paths differ per endpoint and aren't derivable from a tool's own schemaopenfda_get_drug_label toolsearch targets label fields (openfda.brand_name, openfda.generic_name, openfda.manufacturer_name, or set_id for a specific SPL revision); default limit 5, up to 1000kind: "outline" — section names and their serialized size, largest first — instead of label text; re-call with sections: [...] for the ones neededlimit; a sections selection is always returned whole even when it overflows the budget, with its size disclosedsections narrows each record to the requested keys plus identity metadata (openfda, set_id, id, effective_time, version)*_table sections render as Markdown tables in the text output (caption, spans, footnotes, and footer rows preserved); structured results keep the raw SPL table markupskip capped at openFDA's 25000-record pagination ceilingopenfda_search_drug_approvals toolopenfda.brand_name), sponsor_name (stored uppercase — a lowercase quoted value matches nothing), or submissions.submission_type / submissions.review_prioritylimit up to 1000 is bounded by the shared ~24 KB page-byte budget — a long-running application is an order of magnitude larger than a recent onepage_omitted reports any cut with the routes to the rest; skip capped at 25000stage: true (or canvas_id) for DataCanvas SQL via openfda_dataframe_queryopenfda_search_device_clearances toolpathway selects 510k (174K+ records, most common) or pma (higher-risk devices) — one pathway per callapplicant, product_code, advisory_committee_description, or openfda.device_namelimit up to 1000, bounded by the shared ~24 KB page-byte budget — a 510(k) record carries a summary narrative and runs several times the size of a PMA recordstage: true (or canvas_id) for DataCanvas SQL via openfda_dataframe_queryopenfda_lookup_ndc toolproduct_ndc, brand_name, generic_name, openfda.manufacturer_name, or active_ingredients.nameopenfda_get_drug_label via the returned brand_name or set_id to read the package insertlimit up to 1000, bounded by the shared ~24 KB page-byte budget — a product with many packaging configurations is several times the size of one with a single packagestage: true (or canvas_id) for DataCanvas SQL via openfda_dataframe_query; skip capped at 25000openfda_dataframe_query toolSELECT against a table staged by a search tool's stage: true — DDL, DML, COPY, and file-reading functions are rejectedCAST for numeric math); nested openFDA objects/arrays are JSON columns, queryable with DuckDB JSON functionstruncated: true means page the rest with ORDER BY plus LIMIT/OFFSETCANVAS_PROVIDER_TYPE=duckdb and the optional @duckdb/node-api dependency — errors canvas_disabled otherwiseopenfda_dataframe_describe toolcanvas_id — name, kind (table/view), full staged row count (not the inline preview count), and column name/DuckDB-type/nullable for eachopenfda_dataframe_query to get exact table and column namescanvas_disabled when CANVAS_PROVIDER_TYPE is unset, canvas_not_found when the canvas_id has expired or never existedBuilt 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.
openFDA-specific:
CANVAS_PROVIDER_TYPE=duckdb, per call with stage: true) — stage large result sets as DuckDB tables, list their columns with openfda_dataframe_describe, and run SQL via openfda_dataframe_queryOPENFDA_MIRROR_ENABLED=true) — a self-refreshing SQLite copy of four drug datasets that answers exact-key lookups without spending API budget, with live fallbackAgent-friendly output:
page_omitted/page_bytes on both content[] and structuredContent, never silently truncated and never emptied to zero recordserrors[] declarations key ctx.fail by reason (rate_limited, query_error, pagination_limit_reached, canvas_disabled, ...) so callers can branch on error.data.reason instead of parsing messagesopenfda_drug_profile returns null per section on a miss rather than failing the whole call, and names which sections failed upstream (vs. genuinely absent) in degraded[]openfda_describe_fields and broader query terms rather than a bare empty array; a page requested past the end of its results reports the real match count and says the offset overshotA public instance is available at https://openfda.caseyjhand.com/mcp — no installation required. Point any MCP client at it via Streamable HTTP:
{
"mcpServers": {
"openfda-mcp-server": {
"type": "streamable-http",
"url": "https://openfda.caseyjhand.com/mcp"
}
}
}
Add the following to your MCP client configuration file:
{
"mcpServers": {
"openfda-mcp-server": {
"type": "stdio",
"command": "bunx",
"args": ["@cyanheads/openfda-mcp-server@latest"],
"env": {
"MCP_TRANSPORT_TYPE": "stdio",
"MCP_LOG_LEVEL": "info",
"OPENFDA_API_KEY": "your-key-here"
}
}
}
}
Or with npx (no Bun required):
{
"mcpServers": {
"openfda-mcp-server": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@cyanheads/openfda-mcp-server@latest"],
"env": {
"MCP_TRANSPORT_TYPE": "stdio",
"MCP_LOG_LEVEL": "info",
"OPENFDA_API_KEY": "your-key-here"
}
}
}
}
Or with Docker:
{
"mcpServers": {
"openfda-mcp-server": {
"type": "stdio",
"command": "docker",
"args": ["run", "-i", "--rm", "-e", "MCP_TRANSPORT_TYPE=stdio", "ghcr.io/cyanheads/openfda-mcp-server:latest"]
}
}
}
For Streamable HTTP, set the transport and start the server:
MCP_TRANSPORT_TYPE=http MCP_HTTP_PORT=3010 bun run start:http
# Server listens at http://localhost:3010/mcp
git clone https://github.com/cyanheads/openfda-mcp-server.git
cd openfda-mcp-server
bun install
All configuration is validated at startup via Zod schemas in src/config/server-config.ts. Key environment variables:
| Variable | Description | Default |
|---|---|---|
MCP_TRANSPORT_TYPE | Transport: stdio or http | stdio |
MCP_HTTP_PORT | HTTP server port | 3010 |
MCP_SESSION_MODE | HTTP session handling: stateless, stateful, or auto. No tool asks the caller for input mid-call, so no session store is needed. | stateless |
MCP_AUTH_MODE | Authentication: none, jwt, or oauth | none |
MCP_LOG_LEVEL | Log level (debug, info, warning, error, etc.) | info |
LOGS_DIR | Directory for log files (Node.js only). | <project-root>/logs |
STORAGE_PROVIDER_TYPE | Storage backend: in-memory, filesystem, supabase, cloudflare-kv/r2/d1 | in-memory |
OPENFDA_API_KEY | Free API key from open.fda.gov. Increases daily limit from 1K to 120K requests. | none |
OPENFDA_BASE_URL | Base URL override for testing against a proxy or mock. | https://api.fda.gov |
OPENFDA_MIRROR_ENABLED | Answer exact-key lookups from a local copy of the openFDA bulk downloads instead of the API. See Local bulk mirror. | false |
OPENFDA_MIRROR_PATH | Directory holding one SQLite file per mirrored dataset. | ./data/openfda-mirror |
OPENFDA_MIRROR_REFRESH_CRON | Cron expression for the in-process mirror refresh (HTTP transport only). Unset means no scheduled refresh. | none |
OPENFDA_MIRROR_FALLBACK_LIVE | Fall back to the live API when the mirror is cold, missing the record, or failing. | true |
OPENFDA_MIRROR_REFRESH_TIMEOUT_MS | Wall-clock budget for one refresh before it is aborted. | 21600000 (6h) |
OPENFDA_MIRROR_BASE_URL | Host serving the bulk download manifest (download.json). | https://api.fda.gov |
CANVAS_PROVIDER_TYPE | Set to duckdb to enable DataCanvas staging — analytical SQL over result sets staged with stage: true and queried via openfda_dataframe_query. Requires the optional @duckdb/node-api dependency. | none (disabled) |
OTEL_ENABLED | Enable OpenTelemetry | false |
See .env.example for the full list of optional overrides.
openFDA publishes whole-dataset JSON dumps alongside the API. With OPENFDA_MIRROR_ENABLED=true the server keeps a local SQLite copy of four of them — drug/label, drug/ndc, drug/enforcement, drug/drugsfda — and answers eligible lookups from it, leaving the API budget for everything else.
The mirror is deliberately narrow. openFDA's search runs server-side in Elasticsearch, which tokenises and ranks; a local corpus cannot reproduce that. A query is answered locally only when all of the following hold, and is sent to the API otherwise:
field:"value" term — no boolean operators, wildcards, or ranges;id, set_id, product_id, product_ndc, recall_number, event_id, application_number, and the value is a whole identifier in its canonical spelling and case;count and no sort, and skip is 0;The last condition is what keeps a mirrored answer identical to the API's rather than merely equivalent. Four of the seven lookup fields are primary keys and always match one record. The other three — set_id, product_ndc, event_id — can address several, and openFDA returns those in relevance order, which a local corpus cannot recompute; such a lookup routes to the API whatever the requested page size.
openfda_count_values therefore always runs against the API — a partial mirror would return plausible but incomplete aggregates.
The initial harvest runs out-of-band, never at startup:
bun run mirror:init # all four datasets
bun run mirror:init drug/enforcement # one dataset (~3.8 MB compressed)
bun run mirror:status # sync state per dataset
bun run mirror:verify # integrity check + row counts
bun run mirror:refresh # re-harvest datasets whose dump has advanced
openFDA publishes no incremental API for these endpoints, so a refresh re-reads the whole dump and tombstones records the new export no longer carries. It is idempotent and resumable — re-running after an interrupt continues from the persisted cursor. Set OPENFDA_MIRROR_REFRESH_CRON to run it in-process on the HTTP transport; on stdio, run bun run mirror:refresh from the host.
meta.lastUpdated on a mirrored response reports the last_updated stamp of the dump being served, which can differ from the live API's — the API index and the published dumps advance on separate schedules.
On Node, install the optional better-sqlite3 peer dependency; Bun uses its built-in bun:sqlite. OPENFDA_MIRROR_REFRESH_CRON additionally needs the optional node-cron peer dependency — without it the server refuses to start rather than run with a schedule it cannot honour.
Build and run the production version:
# One-time build
bun run rebuild
# Run the built server
bun run start:http
# or
bun run start:stdio
Run checks and tests:
bun run devcheck # Lints, formats, type-checks, and more
bun run test # Runs the test suite
docker build -t openfda-mcp-server .
docker run --rm -p 3010:3010 openfda-mcp-server
The Dockerfile defaults to HTTP transport, stateless session mode, and logs to /var/log/openfda-mcp-server. OpenTelemetry peer dependencies are installed by default — build with --build-arg OTEL_ENABLED=false to omit them. Mount a volume over /usr/src/app/data/openfda-mirror to persist an OPENFDA_MIRROR_ENABLED=true harvest across container replacement.
| Directory | Purpose |
|---|---|
src/index.ts | Entry point — createApp() with tool registration and service setup. |
src/config/ | Server-specific env var parsing and validation with Zod. |
src/services/openfda/ | openFDA API client with retry, rate-limit handling, and error normalization. |
src/services/openfda/mirror/ | Opt-in local bulk mirror — dataset registry, dump reader, sync ingester, and the query gate that decides mirror vs live. |
src/services/canvas/ | DataCanvas accessor — resolves the active canvas provider for staging and SQL. |
src/mcp-server/tools/definitions/ | Tool definitions (*.tool.ts). Fourteen openFDA tools. |
tests/ | Unit and integration tests mirroring src/. |
See CLAUDE.md for development guidelines and architectural rules. The short version:
try/catch in tool logicctx.log for request-scoped loggingsrc/mcp-server/tools/definitions/index.tsData is served from openFDA, a U.S. Food and Drug Administration service. Under the openFDA license the data is dedicated to the public domain under CC0 1.0, with one exception: GMDN® device-classification content — Term Code, Term Name, and Term Definition — is licensed from The GMDN Agency, and redistributing it or using it to train AI requires a separate licence from the Agency.
The local mirror therefore covers drug datasets only. device/classification and every other device endpoint are excluded from it, and the ingester rejects any record carrying a GMDN-bearing field rather than writing it to disk. Extending the mirror to device data requires clearing that licence first.
FDA does not endorse this project. Do not rely on openFDA to make decisions regarding medical care.
Issues are welcome. Run checks and tests before submitting:
bun run devcheck
bun run test
This project is licensed under the Apache 2.0 License. See the LICENSE file for details.
Source-derived launch command. Check the maintainer’s required arguments and credentials before running:
npx -y @cyanheads/openfda-mcp-serverMerge this template into ~/Library/Application Support/Claude/claude_desktop_config.json. Keep existing servers. Add any arguments, credentials, and permissions required by the maintainer; this template has not been install-tested.
{
"mcpServers": {
"io-github-cyanheads-openfda-mcp-server": {
"command": "npx",
"args": [
"-y",
"@cyanheads/openfda-mcp-server"
]
}
}
}Restart Claude Desktop completely for changes to take effect. Confirm the server appears connected in the client’s tool list, then try a read-only example from its documentation.
Claude Desktop setup referenceio.github.cyanheads/openfda-mcp-server works with any MCP-compatible client. Copy the config snippet from the Configuration section above and add it to the file shown for your client, then restart the application.
~/Library/Application Support/Claude/claude_desktop_config.jsonRestart Claude Desktop completely for changes to take effect.~/.cursor/mcp.jsonRestart Cursor for changes to take effect..vscode/mcp.jsonReload VS Code window for changes to take effect.~/.codeium/windsurf/mcp_config.jsonRestart Windsurf for changes to take effect..mcp.jsonSave at the project root, then start Claude Code in that project and review the MCP server approval prompt. Keep real credentials out of shared files.