Jonas commited on
Commit
dd19932
·
1 Parent(s): 344a2d8

Add initial implementation of ADE Explorer with requirements, plotting, and API client

Browse files
README.md CHANGED
@@ -1 +1,49 @@
1
- # ADE-Explorer-MCP
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Medication Adverse-Event Explorer (ADE Explorer)
2
+
3
+ A Gradio app that exposes a drug-event query tool as an MCP server. It has two core workflows:
4
+ (A) finding top adverse events for a drug, and
5
+ (B) checking the frequency of a specific drug-event pair.
6
+
7
+ It uses public data from OpenFDA.
8
+
9
+ **Hackathon Track:** `mcp-server-track`
10
+
11
+ ---
12
+
13
+ ## Usage
14
+
15
+ This application provides two tools, accessible via a tabbed interface or as MCP endpoints:
16
+
17
+ 1. **Top Events**: For a given drug name, it returns a list and a bar chart of the top 10 most frequently reported adverse events.
18
+ 2. **Event Frequency**: For a given drug and adverse event pair, it returns the total number of reports found.
19
+
20
+ ## MCP Server Configuration
21
+
22
+ To use this application as a tool with an MCP client (like Cursor or Claude Desktop), you first need to run it and get the server URL.
23
+
24
+ 1. **Run the App**:
25
+ ```bash
26
+ python src/app.py
27
+ ```
28
+ 2. **Find the MCP URL**: The server will start and print the MCP URL in the console. It will look something like this:
29
+ `http://127.0.0.1:7860/gradio_api/mcp/sse`
30
+ 3. **Configure Your Client**: Add the following configuration to your MCP client's settings, replacing the URL with the one from the previous step.
31
+
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "ade_explorer": {
36
+ "url": "http://127.0.0.1:7860/gradio_api/mcp/sse"
37
+ }
38
+ }
39
+ }
40
+ ```
41
+
42
+ The server exposes two tools: `top_adverse_events_tool(drug_name)` and `drug_event_stats_tool(drug_name, event_name)`.
43
+
44
+ ---
45
+
46
+ ## Data Source and Disclaimer
47
+
48
+ * **Source**: All data is sourced from the public [FDA Adverse Event Reporting System (FAERS)](https://open.fda.gov/data/faers/) via the OpenFDA API.
49
+ * **Disclaimer**: The information provided by this tool is for informational purposes only and is based on spontaneous reports. It does not represent verified medical data or clinical proof of a causal relationship. **Always consult a qualified healthcare professional for medical advice.**
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio[mcp]>=5.0
2
+ pandas
3
+ requests
4
+ plotly
5
+ cachetools
6
+ pytest
src/app.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from src.openfda_client import get_top_adverse_events, get_drug_event_pair_frequency
3
+ from src.plotting import create_bar_chart
4
+ import pandas as pd
5
+
6
+ # --- Formatting Functions ---
7
+
8
+ def format_top_events_results(data: dict, drug_name: str) -> str:
9
+ """Formats the results for the top adverse events tool."""
10
+ if "error" in data:
11
+ return f"An error occurred: {data['error']}"
12
+
13
+ if "results" not in data or not data["results"]:
14
+ return f"No adverse event data found for '{drug_name}'. The drug may not be in the database or it might be misspelled."
15
+
16
+ header = f"Top Adverse Events for '{drug_name.title()}'\n"
17
+ header += "Source: FDA FAERS via OpenFDA\n"
18
+ header += "Disclaimer: Spontaneous reports do not prove causation. Consult a healthcare professional.\n"
19
+ header += "---------------------------------------------------\n"
20
+
21
+ try:
22
+ df = pd.DataFrame(data["results"])
23
+ df = df.rename(columns={"term": "Adverse Event", "count": "Report Count"})
24
+ result_string = df.to_string(index=False)
25
+ return header + result_string
26
+ except Exception as e:
27
+ return f"An error occurred while formatting the data: {e}"
28
+
29
+ def format_pair_frequency_results(data: dict, drug_name: str, event_name: str) -> str:
30
+ """Formats the results for the drug-event pair frequency tool."""
31
+ if "error" in data:
32
+ return f"An error occurred: {data['error']}"
33
+
34
+ total_reports = data.get("meta", {}).get("results", {}).get("total", 0)
35
+
36
+ result = (
37
+ f"Found {total_reports:,} reports for the combination of "
38
+ f"'{drug_name.title()}' and '{event_name.title()}'.\n\n"
39
+ "Source: FDA FAERS via OpenFDA\n"
40
+ "Disclaimer: Spontaneous reports do not prove causation. Consult a healthcare professional."
41
+ )
42
+ return result
43
+
44
+ # --- Tool Functions ---
45
+
46
+ def top_adverse_events_tool(drug_name: str):
47
+ """
48
+ MCP Tool: Finds the top reported adverse events for a given drug.
49
+
50
+ Args:
51
+ drug_name (str): The name of the drug to search for.
52
+
53
+ Returns:
54
+ tuple: A Plotly figure and a formatted string with the top adverse events.
55
+ """
56
+ data = get_top_adverse_events(drug_name)
57
+ chart = create_bar_chart(data, drug_name)
58
+ text_summary = format_top_events_results(data, drug_name)
59
+ return chart, text_summary
60
+
61
+ def drug_event_stats_tool(drug_name: str, event_name: str):
62
+ """
63
+ MCP Tool: Gets the total number of reports for a specific drug and adverse event pair.
64
+
65
+ Args:
66
+ drug_name (str): The name of the drug to search for.
67
+ event_name (str): The name of the adverse event to search for.
68
+
69
+ Returns:
70
+ str: A formatted string with the total count of reports.
71
+ """
72
+ data = get_drug_event_pair_frequency(drug_name, event_name)
73
+ return format_pair_frequency_results(data, drug_name, event_name)
74
+
75
+ # --- Gradio Interface ---
76
+
77
+ interface1 = gr.Interface(
78
+ fn=top_adverse_events_tool,
79
+ inputs=[
80
+ gr.Textbox(
81
+ label="Drug Name",
82
+ info="Enter a brand or generic drug name (e.g., 'Aspirin', 'Lisinopril')."
83
+ )
84
+ ],
85
+ outputs=[
86
+ gr.Plot(label="Top Adverse Events Chart"),
87
+ gr.Textbox(label="Top Adverse Events (Raw Data)", lines=15)
88
+ ],
89
+ title="Top Adverse Events by Drug",
90
+ description="Find the most frequently reported adverse events for a specific medication.",
91
+ examples=[["Lisinopril"], ["Ozempic"], ["Metformin"]],
92
+ )
93
+
94
+ interface2 = gr.Interface(
95
+ fn=drug_event_stats_tool,
96
+ inputs=[
97
+ gr.Textbox(label="Drug Name", info="e.g., 'Ibuprofen'"),
98
+ gr.Textbox(label="Adverse Event", info="e.g., 'Headache'")
99
+ ],
100
+ outputs=[gr.Textbox(label="Report Count", lines=5)],
101
+ title="Drug/Event Pair Frequency",
102
+ description="Get the total number of reports for a specific drug and adverse event combination.",
103
+ examples=[["Lisinopril", "Cough"], ["Ozempic", "Nausea"]],
104
+ )
105
+
106
+ demo = gr.TabbedInterface(
107
+ [interface1, interface2],
108
+ ["Top Events", "Event Frequency"],
109
+ title="Medication Adverse-Event Explorer"
110
+ )
111
+
112
+ if __name__ == "__main__":
113
+ demo.launch(mcp_server=True, server_name="0.0.0.0")
src/openfda_client.py CHANGED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from cachetools import TTLCache, cached
3
+ import time
4
+
5
+ API_BASE_URL = "https://api.fda.gov/drug/event.json"
6
+ # Cache with a TTL of 10 minutes (600 seconds)
7
+ cache = TTLCache(maxsize=256, ttl=600)
8
+
9
+ # 240 requests per minute per IP. A 0.25s delay is a simple way to stay under.
10
+ REQUEST_DELAY_SECONDS = 0.25
11
+
12
+ def get_top_adverse_events(drug_name: str, limit: int = 10) -> dict:
13
+ """
14
+ Query OpenFDA to get the top adverse events for a given drug.
15
+
16
+ Args:
17
+ drug_name (str): The name of the drug to search for (brand or generic).
18
+ limit (int): The maximum number of adverse events to return.
19
+
20
+ Returns:
21
+ dict: The JSON response from the API, or an error dictionary.
22
+ """
23
+ if not drug_name:
24
+ return {"error": "Drug name cannot be empty."}
25
+
26
+ drug_name_processed = drug_name.lower().strip()
27
+
28
+ # Using a simple cache key
29
+ cache_key = f"top_events_{drug_name_processed}_{limit}"
30
+
31
+ if cache_key in cache:
32
+ return cache[cache_key]
33
+
34
+ query = (
35
+ f'search=patient.drug.medicinalproduct:"{drug_name_processed}"'
36
+ f'&count=patient.reaction.reactionmeddrapt.exact&limit={limit}'
37
+ )
38
+
39
+ try:
40
+ # Respect rate limits
41
+ time.sleep(REQUEST_DELAY_SECONDS)
42
+
43
+ response = requests.get(f"{API_BASE_URL}?{query}")
44
+ response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
45
+
46
+ data = response.json()
47
+ cache[cache_key] = data
48
+ return data
49
+
50
+ except requests.exceptions.HTTPError as http_err:
51
+ if response.status_code == 404:
52
+ return {"error": f"No data found for drug: '{drug_name}'. It might be misspelled or not in the database."}
53
+ return {"error": f"HTTP error occurred: {http_err}"}
54
+ except requests.exceptions.RequestException as req_err:
55
+ return {"error": f"A network request error occurred: {req_err}"}
56
+ except Exception as e:
57
+ return {"error": f"An unexpected error occurred: {e}"}
58
+
59
+ def get_drug_event_pair_frequency(drug_name: str, event_name: str) -> dict:
60
+ """
61
+ Query OpenFDA to get the total number of reports for a specific
62
+ drug-adverse event pair.
63
+
64
+ Args:
65
+ drug_name (str): The name of the drug.
66
+ event_name (str): The name of the adverse event.
67
+
68
+ Returns:
69
+ dict: The JSON response from the API, or an error dictionary.
70
+ """
71
+ if not drug_name or not event_name:
72
+ return {"error": "Drug name and event name cannot be empty."}
73
+
74
+ drug_name_processed = drug_name.lower().strip()
75
+ event_name_processed = event_name.lower().strip()
76
+
77
+ cache_key = f"pair_freq_{drug_name_processed}_{event_name_processed}"
78
+ if cache_key in cache:
79
+ return cache[cache_key]
80
+
81
+ query = (
82
+ f'search=patient.drug.medicinalproduct:"{drug_name_processed}"'
83
+ f'+AND+patient.reaction.reactionmeddrapt:"{event_name_processed}"'
84
+ )
85
+
86
+ try:
87
+ time.sleep(REQUEST_DELAY_SECONDS)
88
+
89
+ response = requests.get(f"{API_BASE_URL}?{query}")
90
+ response.raise_for_status()
91
+
92
+ data = response.json()
93
+ cache[cache_key] = data
94
+ return data
95
+
96
+ except requests.exceptions.HTTPError as http_err:
97
+ if response.status_code == 404:
98
+ return {"error": f"No data found for drug '{drug_name}' and event '{event_name}'. They may be misspelled or not in the database."}
99
+ return {"error": f"HTTP error occurred: {http_err}"}
100
+ except requests.exceptions.RequestException as req_err:
101
+ return {"error": f"A network request error occurred: {req_err}"}
102
+ except Exception as e:
103
+ return {"error": f"An unexpected error occurred: {e}"}
src/plotting.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import plotly.graph_objects as go
2
+ import pandas as pd
3
+
4
+ def create_bar_chart(data: dict, drug_name: str):
5
+ """
6
+ Creates a Plotly bar chart from the OpenFDA data.
7
+
8
+ Args:
9
+ data (dict): The data from the OpenFDA client.
10
+ drug_name (str): The name of the drug.
11
+
12
+ Returns:
13
+ A Plotly Figure object if data is valid, otherwise None.
14
+ """
15
+ if "error" in data or "results" not in data or not data["results"]:
16
+ return None
17
+
18
+ try:
19
+ df = pd.DataFrame(data["results"])
20
+ df = df.rename(columns={"term": "Adverse Event", "count": "Report Count"})
21
+
22
+ # Ensure 'Report Count' is numeric
23
+ df['Report Count'] = pd.to_numeric(df['Report Count'])
24
+
25
+ # Sort for better visualization
26
+ df = df.sort_values(by="Report Count", ascending=True)
27
+
28
+ fig = go.Figure(
29
+ go.Bar(
30
+ x=df["Report Count"],
31
+ y=df["Adverse Event"],
32
+ orientation='h',
33
+ marker=dict(color='skyblue')
34
+ )
35
+ )
36
+
37
+ fig.update_layout(
38
+ title_text=f"Top Reported Adverse Events for {drug_name.title()}",
39
+ xaxis_title="Number of Reports",
40
+ yaxis_title="Adverse Event",
41
+ yaxis=dict(automargin=True),
42
+ height=max(400, len(df) * 30) # Dynamically adjust height
43
+ )
44
+
45
+ return fig
46
+ except Exception:
47
+ return None
tests/test_openfda_client.py CHANGED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from unittest.mock import patch, MagicMock
3
+ import requests
4
+ from src.openfda_client import get_top_adverse_events, get_drug_event_pair_frequency, cache
5
+
6
+ @pytest.fixture(autouse=True)
7
+ def clear_cache():
8
+ """Fixture to clear the cache before each test."""
9
+ cache.clear()
10
+
11
+ def mock_response(status_code=200, json_data=None, raise_for_status=None):
12
+ """Helper function to create a mock response object."""
13
+ mock_resp = MagicMock()
14
+ mock_resp.status_code = status_code
15
+ mock_resp.json.return_value = json_data
16
+ if raise_for_status:
17
+ mock_resp.raise_for_status.side_effect = raise_for_status
18
+ return mock_resp
19
+
20
+ @patch('requests.get')
21
+ def test_get_top_adverse_events_success(mock_get):
22
+ """Test successful API call for top adverse events."""
23
+ mock_json = {"results": [{"term": "Nausea", "count": 100}]}
24
+ mock_get.return_value = mock_response(json_data=mock_json)
25
+
26
+ result = get_top_adverse_events("testdrug")
27
+
28
+ assert result == mock_json
29
+ mock_get.assert_called_once()
30
+
31
+ @patch('requests.get')
32
+ def test_get_top_adverse_events_404(mock_get):
33
+ """Test 404 Not Found error for top adverse events."""
34
+ http_error = requests.exceptions.HTTPError("404 Client Error")
35
+ mock_get.return_value = mock_response(status_code=404, raise_for_status=http_error)
36
+
37
+ result = get_top_adverse_events("nonexistentdrug")
38
+
39
+ assert "error" in result
40
+ assert "No data found" in result["error"]
41
+
42
+ @patch('requests.get')
43
+ def test_get_drug_event_pair_frequency_success(mock_get):
44
+ """Test successful API call for drug-event pair frequency."""
45
+ mock_json = {"meta": {"results": {"total": 50}}}
46
+ mock_get.return_value = mock_response(json_data=mock_json)
47
+
48
+ result = get_drug_event_pair_frequency("testdrug", "testevent")
49
+
50
+ assert result == mock_json
51
+ mock_get.assert_called_once()
52
+
53
+ def test_empty_drug_name_returns_error():
54
+ """Test that empty inputs are handled correctly without calling the API."""
55
+ result = get_top_adverse_events("")
56
+ assert "error" in result
57
+
58
+ result2 = get_drug_event_pair_frequency("", "testevent")
59
+ assert "error" in result2
60
+
61
+ @patch('requests.get')
62
+ def test_caching_works(mock_get):
63
+ """Test that results are cached to avoid repeated API calls."""
64
+ mock_json = {"results": [{"term": "Headache", "count": 200}]}
65
+ mock_get.return_value = mock_response(json_data=mock_json)
66
+
67
+ # First call - should call the API
68
+ get_top_adverse_events("cacheddrug")
69
+ assert mock_get.call_count == 1
70
+
71
+ # Second call - should hit the cache
72
+ get_top_adverse_events("cacheddrug")
73
+ assert mock_get.call_count == 1 # Still 1, not 2
74
+
75
+ # Call with different params - should trigger a new API call
76
+ get_top_adverse_events("cacheddrug", limit=20)
77
+ assert mock_get.call_count == 2