@cyanheads/openfda-mcp-server
Query FDA data on drugs, food, devices, and recalls via openFDA. STDIO or Streamable HTTP.
15 Tools
Overview
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.
Tools
Capability reference
openfda_drug_profile tool
- One
drug name, brand or generic, resolved once to an identity (generic name, NDC, RxCUI, SPL set ID); recalls, approval, and shortage key on meta.fanOutKey — the typed name when it is an ingredient of the resolved generic, else that generic; meta.resolvedVia (label, ndc, none) names the source, and a single-ingredient product is preferred over combinations
- Recalls and approval also search records openFDA never harmonized (
product_description, products.active_ingredients.name); recalls_total gives the match count beside the newest 5 recalls
- Sections
label, adverse_events, recalls, approval, and shortage are best-effort and come back null (or an empty recalls) on a miss; degraded[] names each section whose sub-query failed upstream, so a listed section is unknown rather than absent
- Auth, configuration, and cancellation failures fail the whole call instead of degrading a section
openfda_search_adverse_events tool
category (drug, food, device) selects the endpoint and its record schema; up to 1000 records per page
- Sortable date fields differ by category:
receivedate (drug), date_created (food), date_received (device); another category's field fails as query_error
- Text output decodes drug administration route, reaction outcome, and patient age group codes beside the raw value (
via oral (048), fatal (5), elderly (6)); structured output keeps the codes
openfda_search_animal_events tool
- Optional
search over veterinary reports, e.g. animal.species, drug.active_ingredients.name, reaction.veddra_term_name, serious_ae; up to 1000 records per page
- Brand and manufacturer names are masked as
MSK on every record, so search by active ingredient; drug.manufacturer.registration_number narrows to one approval
- Records carry animal species, breed, age, and weight, the drug and route, VeDDRA reaction terms, and outcome
openfda_search_drug_shortages tool
- Optional
search over status (Current / To Be Discontinued / Resolved), therapeutic_category, generic_name, or company_name; up to 1000 records per page
- Each record's
openfda block carries brand_name, product_ndc, and rxcui for chaining into openfda_get_drug_label or openfda_lookup_ndc
openfda_search_tobacco_reports tool
- Optional
search over tobacco_products, reported_health_problems, reported_product_problems, or nonuser_affected; up to 1000 records per page
- Reports carry the problem arrays plus
number_tobacco_products, number_health_problems, and number_product_problems counts
openfda_search_recalls tool
category (drug, food, device) plus endpoint: enforcement (default, every category) or recall (devices only; other categories fail as recall_endpoint_non_device); up to 1000 records per page
- Enforcement records carry
recall_number, classification (Class I/II/III), and status; device recall records carry product_res_number and recall_status instead, with no hazard classification
openfda_count_values tool
endpoint (any of the 20 openFDA endpoints), a count field expression, and an optional search; returns up to 1000 top terms (default 100), ranked by count; a date field returns one term per distinct YYYYMMDD date, ranked the same way
truncated flags more distinct terms past limit, except at the 1000-term maximum, where a notice says completeness can't be known (a date count is exact at any limit, since openFDA sends every date); an expression openFDA can't aggregate fails as not_aggregatable (take the countAs form from openfda_describe_fields)
- Always runs against the live API, even with the bulk mirror enabled
openfda_describe_fields tool
endpoint: the same 20 endpoints openfda_count_values accepts
- Field paths grouped by category, each with
type, a description, and countAs (the verified count expression, or null when the field can't be aggregated), plus queryTips on quoting, AND/OR, .exact, and date ranges
openfda_get_drug_label tool
- Required
search on label fields (openfda.brand_name, openfda.generic_name, openfda.manufacturer_name, set_id); limit defaults to 5, max 1000
- A page over the 24,000-byte inline budget returns
kind: "outline" (section names and sizes, largest first) instead of label text; re-call with sections: [...] to get those sections plus identity metadata (openfda, set_id, id, effective_time, version), returned whole even when over budget
*_table sections render as Markdown tables in the text output; structured results keep the raw SPL markup
openfda_search_drug_approvals tool
- Optional
search over openfda.brand_name, sponsor_name (stored uppercase, so a lowercase quoted value matches nothing), submissions.submission_type, or submissions.review_priority; up to 1000 records per page
- Records carry
application_number, sponsor_name, products[], and the application's full submissions[] history
openfda_search_device_clearances tool
pathway (510k or pma, one per call) plus an optional search over applicant, product_code, advisory_committee_description, or openfda.device_name; up to 1000 records per page
- 510(k) records carry
k_number, device_name, and decision_date; PMA records carry pma_number, trade_name, and supplement_number
openfda_lookup_ndc tool
- Required
search over product_ndc, brand_name, generic_name, openfda.manufacturer_name, or active_ingredients.name; up to 1000 records per page
- Records carry
product_ndc, labeler_name, dosage_form, route, marketing_category, active_ingredients[], and packaging[]
openfda_dataframe_describe tool
canvas_id from a staged search; lists each table's name, kind, full staged row_count, and columns with DuckDB type and nullability
- Fails as
canvas_disabled when CANVAS_PROVIDER_TYPE=none, or canvas_not_found when the canvas_id has expired or never existed
openfda_dataframe_query tool
canvas_id plus one read-only SELECT; DDL, DML, COPY, and file-reading functions are rejected. Scalars are stored as text (CAST for math) and nested openFDA blocks as JSON columns
- Rows are capped at the canvas row limit, and
truncated: true means page on with ORDER BY plus LIMIT/OFFSET; failures are canvas_disabled, canvas_not_found, missing_table, or invalid_query
openfda_dataframe_drop tool
- Off by default: set
OPENFDA_DATAFRAME_DROP_ENABLED=true to make it callable; otherwise it is listed as disabled on the landing page and absent from tools/list
canvas_id plus the table name from openfda_dataframe_describe; deletes that one table or view and returns remaining_tables, leaving the canvas and its other tables in place
- Failures are
canvas_disabled, canvas_not_found, or missing_table (already dropped, expired, or mistyped)
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.
openFDA-specific:
- One client for all 20 openFDA endpoints, with retry on 429 and 5xx. No API key is needed (1K requests/day); a free
OPENFDA_API_KEY raises that to 120K/day
- Queries are
field:value over dotted paths joined by AND/OR (a URL-style + between clauses reads as a space); openfda_describe_fields lists the paths, and a malformed query fails as malformed_search before any request
- Paging:
limit up to 1000 and skip up to openFDA's 25,000-record ceiling (pagination_limit_reached past it); each page is held to a 24,000-byte budget
fields on any openfda_search_* tool or openfda_lookup_ndc trims each record to the dotted paths you name, so large records fit inline
- DataCanvas staging (on by default;
CANVAS_PROVIDER_TYPE=none turns it off) with stage: true or a canvas_id, and an optional local bulk mirror (OPENFDA_MIRROR_ENABLED=true) that answers exact-key lookups without spending API budget
Agent-friendly output:
- Disclosed bounds:
page_omitted, page_bytes, page_largest_fields, projected_fields, the label outline, and staged_rows report every cut
- Typed failures: callers branch on
error.data.reason (rate_limited, query_error, not_aggregatable, ...) instead of parsing messages
- Unknown vs. absent:
openfda_drug_profile returns null for a section with no FDA record and lists failed sections in degraded[]
- Empty-result guidance: a no-match search points at
openfda_describe_fields and broader terms
Getting started
Connect to the hosted instance, no install:
Or run it locally:
For other clients, add this to the MCP config file:
No API key is needed. A free openFDA API key set as OPENFDA_API_KEY raises the limit from 1K to 120K requests per day. For Docker or a source build, see Running the server.
Configuration
See .env.example for the full list of optional overrides.
The Claude Desktop MCPB bundle disables DataCanvas because it excludes platform-specific DuckDB bindings. Use npm or Docker for staging and SQL.
Local bulk mirror
With OPENFDA_MIRROR_ENABLED=true the server keeps a local SQLite copy of four openFDA bulk dumps (drug/label, drug/ndc, drug/enforcement, drug/drugsfda). Only exact lookups are answered locally: a single quoted field:"value" term on id, set_id, product_id, product_ndc, recall_number, event_id, or application_number, with no count or sort, skip 0, and one matching record. Everything else goes to the API.
The initial harvest runs separately, never at startup:
A refresh re-reads the whole dump. Set OPENFDA_MIRROR_REFRESH_CRON to run it in-process on the HTTP transport; on stdio, run bun run mirror:refresh from the host. A mirrored response's meta.lastUpdated is the served dump's stamp, which can differ from the live API's.
On Node, install the optional better-sqlite3 peer dependency (Bun uses its built-in bun:sqlite). OPENFDA_MIRROR_REFRESH_CRON also needs the optional node-cron peer dependency; without it the server fails at startup.
Running the server
Local development
Requires Bun v1.4.0 or higher (or Node.js v24+).
-
Clone and install:
-
Build and run:
-
Run checks and tests:
Docker
The image defaults to HTTP transport, stateless session mode, and logs to /var/log/openfda-mcp-server. To run it over stdio from an MCP client, use "command": "docker" with "args": ["run", "-i", "--rm", "-e", "MCP_TRANSPORT_TYPE=stdio", "ghcr.io/cyanheads/openfda-mcp-server:latest"]. To build locally, docker build -t openfda-mcp-server .; OpenTelemetry peer dependencies are installed by default — build with --build-arg OTEL_ENABLED=false to omit them. To keep a mirror harvest across container replacement, mount a volume over /usr/src/app/data/openfda-mirror.
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
- Register new tools in the barrel at
src/mcp-server/tools/definitions/index.ts
- Wrap external API calls: validate raw → normalize to domain type → return output schema; never fabricate a field openFDA didn't return
Data attribution
Data comes 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.
That is why the local mirror 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.
FDA does not endorse this project. Do not rely on openFDA to make decisions regarding medical care.
Contributing
Issues are welcome. Run checks and tests before submitting:
License
This project is licensed under the Apache 2.0 License. See the LICENSE file for details.