VibecoderMcSwaggins commited on
Commit
f5b2917
·
1 Parent(s): 0d84878

chore: update Gradio dependency to include MCP extras and version bump

Browse files

- Modified `pyproject.toml` to specify Gradio with MCP extras and updated the version to 6.0.1.
- Updated `uv.lock` to reflect the new Gradio dependency structure and version.
- Made minor formatting adjustments in the MCP server documentation for clarity.

Files modified:
- pyproject.toml
- uv.lock
- docs/implementation/12_phase_mcp_server.md

docs/implementation/12_phase_mcp_server.md CHANGED
@@ -808,12 +808,12 @@ Phase 12 is **COMPLETE** when:
808
 
809
  ```
810
  ┌────────────────────────────────────────────────────────────────┐
811
- │ Claude Desktop / Cursor
812
- │ (MCP Client)
813
  └─────────────────────────────┬──────────────────────────────────┘
814
  │ MCP Protocol
815
 
816
- ┌────────────────────────────────────────────────────────────────┐
817
  │ Gradio MCP Server │
818
  │ /gradio_api/mcp/ │
819
  │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────┐ │
 
808
 
809
  ```
810
  ┌────────────────────────────────────────────────────────────────┐
811
+ │ Claude Desktop / Cursor
812
+ │ (MCP Client)
813
  └─────────────────────────────┬──────────────────────────────────┘
814
  │ MCP Protocol
815
 
816
+ ┌─────────────────────────────────────────────────────────────────┐
817
  │ Gradio MCP Server │
818
  │ /gradio_api/mcp/ │
819
  │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────┐ │
pyproject.toml CHANGED
@@ -17,7 +17,7 @@ dependencies = [
17
  "beautifulsoup4>=4.12", # HTML parsing
18
  "xmltodict>=0.13", # PubMed XML -> dict
19
  # UI
20
- "gradio>=5.0", # Chat interface
21
  # Utils
22
  "python-dotenv>=1.0", # .env loading
23
  "tenacity>=8.2", # Retry logic
 
17
  "beautifulsoup4>=4.12", # HTML parsing
18
  "xmltodict>=0.13", # PubMed XML -> dict
19
  # UI
20
+ "gradio[mcp]>=5.0.0", # Chat interface
21
  # Utils
22
  "python-dotenv>=1.0", # .env loading
23
  "tenacity>=8.2", # Retry logic
tests/unit/test_mcp_tools.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for MCP tool wrappers."""
2
+
3
+ from unittest.mock import AsyncMock, patch
4
+
5
+ import pytest
6
+
7
+ from src.mcp_tools import (
8
+ search_all_sources,
9
+ search_biorxiv,
10
+ search_clinical_trials,
11
+ search_pubmed,
12
+ )
13
+ from src.utils.models import Citation, Evidence
14
+
15
+
16
+ @pytest.fixture
17
+ def mock_evidence() -> Evidence:
18
+ """Sample evidence for testing."""
19
+ return Evidence(
20
+ content="Metformin shows neuroprotective effects in preclinical models.",
21
+ citation=Citation(
22
+ source="pubmed",
23
+ title="Metformin and Alzheimer's Disease",
24
+ url="https://pubmed.ncbi.nlm.nih.gov/12345678/",
25
+ date="2024-01-15",
26
+ authors=["Smith J", "Jones M", "Brown K"],
27
+ ),
28
+ relevance=0.85,
29
+ )
30
+
31
+
32
+ class TestSearchPubMed:
33
+ """Tests for search_pubmed MCP tool."""
34
+
35
+ @pytest.mark.asyncio
36
+ async def test_returns_formatted_string(self, mock_evidence: Evidence) -> None:
37
+ """Should return formatted markdown string."""
38
+ with patch("src.mcp_tools._pubmed") as mock_tool:
39
+ mock_tool.search = AsyncMock(return_value=[mock_evidence])
40
+
41
+ result = await search_pubmed("metformin alzheimer", 10)
42
+
43
+ assert isinstance(result, str)
44
+ assert "PubMed Results" in result
45
+ assert "Metformin and Alzheimer's Disease" in result
46
+ assert "Smith J" in result
47
+
48
+ @pytest.mark.asyncio
49
+ async def test_clamps_max_results(self) -> None:
50
+ """Should clamp max_results to valid range (1-50)."""
51
+ with patch("src.mcp_tools._pubmed") as mock_tool:
52
+ mock_tool.search = AsyncMock(return_value=[])
53
+
54
+ # Test lower bound
55
+ await search_pubmed("test", 0)
56
+ mock_tool.search.assert_called_with("test", 1)
57
+
58
+ # Test upper bound
59
+ await search_pubmed("test", 100)
60
+ mock_tool.search.assert_called_with("test", 50)
61
+
62
+ @pytest.mark.asyncio
63
+ async def test_handles_no_results(self) -> None:
64
+ """Should return appropriate message when no results."""
65
+ with patch("src.mcp_tools._pubmed") as mock_tool:
66
+ mock_tool.search = AsyncMock(return_value=[])
67
+
68
+ result = await search_pubmed("xyznonexistent", 10)
69
+
70
+ assert "No PubMed results found" in result
71
+
72
+
73
+ class TestSearchClinicalTrials:
74
+ """Tests for search_clinical_trials MCP tool."""
75
+
76
+ @pytest.mark.asyncio
77
+ async def test_returns_formatted_string(self, mock_evidence: Evidence) -> None:
78
+ """Should return formatted markdown string."""
79
+ mock_evidence.citation.source = "clinicaltrials" # type: ignore
80
+
81
+ with patch("src.mcp_tools._trials") as mock_tool:
82
+ mock_tool.search = AsyncMock(return_value=[mock_evidence])
83
+
84
+ result = await search_clinical_trials("diabetes", 10)
85
+
86
+ assert isinstance(result, str)
87
+ assert "Clinical Trials" in result
88
+
89
+
90
+ class TestSearchBiorxiv:
91
+ """Tests for search_biorxiv MCP tool."""
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_returns_formatted_string(self, mock_evidence: Evidence) -> None:
95
+ """Should return formatted markdown string."""
96
+ mock_evidence.citation.source = "biorxiv" # type: ignore
97
+
98
+ with patch("src.mcp_tools._biorxiv") as mock_tool:
99
+ mock_tool.search = AsyncMock(return_value=[mock_evidence])
100
+
101
+ result = await search_biorxiv("preprint search", 10)
102
+
103
+ assert isinstance(result, str)
104
+ assert "Preprint Results" in result
105
+
106
+
107
+ class TestSearchAllSources:
108
+ """Tests for search_all_sources MCP tool."""
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_combines_all_sources(self, mock_evidence: Evidence) -> None:
112
+ """Should combine results from all sources."""
113
+ with (
114
+ patch("src.mcp_tools.search_pubmed", new_callable=AsyncMock) as mock_pubmed,
115
+ patch("src.mcp_tools.search_clinical_trials", new_callable=AsyncMock) as mock_trials,
116
+ patch("src.mcp_tools.search_biorxiv", new_callable=AsyncMock) as mock_biorxiv,
117
+ ):
118
+ mock_pubmed.return_value = "## PubMed Results"
119
+ mock_trials.return_value = "## Clinical Trials"
120
+ mock_biorxiv.return_value = "## Preprints"
121
+
122
+ result = await search_all_sources("metformin", 5)
123
+
124
+ assert "Comprehensive Search" in result
125
+ assert "PubMed" in result
126
+ assert "Clinical Trials" in result
127
+ assert "Preprints" in result
128
+
129
+ @pytest.mark.asyncio
130
+ async def test_handles_partial_failures(self) -> None:
131
+ """Should handle partial failures gracefully."""
132
+ with (
133
+ patch("src.mcp_tools.search_pubmed", new_callable=AsyncMock) as mock_pubmed,
134
+ patch("src.mcp_tools.search_clinical_trials", new_callable=AsyncMock) as mock_trials,
135
+ patch("src.mcp_tools.search_biorxiv", new_callable=AsyncMock) as mock_biorxiv,
136
+ ):
137
+ mock_pubmed.return_value = "## PubMed Results"
138
+ mock_trials.side_effect = Exception("API Error")
139
+ mock_biorxiv.return_value = "## Preprints"
140
+
141
+ result = await search_all_sources("metformin", 5)
142
+
143
+ # Should still contain working sources
144
+ assert "PubMed" in result
145
+ assert "Preprints" in result
146
+ # Should show error for failed source
147
+ assert "Error" in result
148
+
149
+
150
+ class TestMCPDocstrings:
151
+ """Tests that docstrings follow MCP format."""
152
+
153
+ def test_search_pubmed_has_args_section(self) -> None:
154
+ """Docstring must have Args section for MCP schema generation."""
155
+ assert search_pubmed.__doc__ is not None
156
+ assert "Args:" in search_pubmed.__doc__
157
+ assert "query:" in search_pubmed.__doc__
158
+ assert "max_results:" in search_pubmed.__doc__
159
+ assert "Returns:" in search_pubmed.__doc__
160
+
161
+ def test_search_clinical_trials_has_args_section(self) -> None:
162
+ """Docstring must have Args section for MCP schema generation."""
163
+ assert search_clinical_trials.__doc__ is not None
164
+ assert "Args:" in search_clinical_trials.__doc__
165
+
166
+ def test_search_biorxiv_has_args_section(self) -> None:
167
+ """Docstring must have Args section for MCP schema generation."""
168
+ assert search_biorxiv.__doc__ is not None
169
+ assert "Args:" in search_biorxiv.__doc__
170
+
171
+ def test_search_all_sources_has_args_section(self) -> None:
172
+ """Docstring must have Args section for MCP schema generation."""
173
+ assert search_all_sources.__doc__ is not None
174
+ assert "Args:" in search_all_sources.__doc__
175
+
176
+
177
+ class TestMCPTypeHints:
178
+ """Tests that type hints are complete for MCP."""
179
+
180
+ def test_search_pubmed_type_hints(self) -> None:
181
+ """All parameters and return must have type hints."""
182
+ import inspect
183
+
184
+ sig = inspect.signature(search_pubmed)
185
+
186
+ # Check parameter hints
187
+ assert sig.parameters["query"].annotation == str
188
+ assert sig.parameters["max_results"].annotation == int
189
+
190
+ # Check return hint
191
+ assert sig.return_annotation == str
192
+
193
+ def test_search_clinical_trials_type_hints(self) -> None:
194
+ """All parameters and return must have type hints."""
195
+ import inspect
196
+
197
+ sig = inspect.signature(search_clinical_trials)
198
+ assert sig.parameters["query"].annotation == str
199
+ assert sig.parameters["max_results"].annotation == int
200
+ assert sig.return_annotation == str
uv.lock CHANGED
@@ -1063,7 +1063,7 @@ source = { editable = "." }
1063
  dependencies = [
1064
  { name = "anthropic" },
1065
  { name = "beautifulsoup4" },
1066
- { name = "gradio" },
1067
  { name = "httpx" },
1068
  { name = "openai" },
1069
  { name = "pydantic" },
@@ -1111,7 +1111,7 @@ requires-dist = [
1111
  { name = "beautifulsoup4", specifier = ">=4.12" },
1112
  { name = "chromadb", marker = "extra == 'embeddings'", specifier = ">=0.4.0" },
1113
  { name = "chromadb", marker = "extra == 'modal'", specifier = ">=0.4.0" },
1114
- { name = "gradio", specifier = ">=5.0" },
1115
  { name = "httpx", specifier = ">=0.27" },
1116
  { name = "llama-index", marker = "extra == 'modal'", specifier = ">=0.11.0" },
1117
  { name = "llama-index-embeddings-openai", marker = "extra == 'modal'" },
@@ -1568,7 +1568,7 @@ wheels = [
1568
 
1569
  [[package]]
1570
  name = "gradio"
1571
- version = "5.50.0"
1572
  source = { registry = "https://pypi.org/simple" }
1573
  dependencies = [
1574
  { name = "aiofiles" },
@@ -1592,7 +1592,6 @@ dependencies = [
1592
  { name = "pydub" },
1593
  { name = "python-multipart" },
1594
  { name = "pyyaml" },
1595
- { name = "ruff" },
1596
  { name = "safehttpx" },
1597
  { name = "semantic-version" },
1598
  { name = "starlette" },
@@ -1601,13 +1600,20 @@ dependencies = [
1601
  { name = "typing-extensions" },
1602
  { name = "uvicorn" },
1603
  ]
 
1604
  wheels = [
1605
- { url = "https://files.pythonhosted.org/packages/22/04/8daf96bd6d2470f03e2a15a9fc900c7ecf6549619173f16c5944c7ec15a7/gradio-5.50.0-py3-none-any.whl", hash = "sha256:d06770d57cdda9b703ef9cf767ac93a890a0e12d82679a310eef74203a3673f4", size = 63530991 },
 
 
 
 
 
 
1606
  ]
1607
 
1608
  [[package]]
1609
  name = "gradio-client"
1610
- version = "1.14.0"
1611
  source = { registry = "https://pypi.org/simple" }
1612
  dependencies = [
1613
  { name = "fsspec" },
@@ -1615,10 +1621,10 @@ dependencies = [
1615
  { name = "huggingface-hub" },
1616
  { name = "packaging" },
1617
  { name = "typing-extensions" },
1618
- { name = "websockets" },
1619
  ]
 
1620
  wheels = [
1621
- { url = "https://files.pythonhosted.org/packages/be/8a/f2a47134c5b5a7f3bad27eae749589a80d81efaaad8f59af47c136712bf6/gradio_client-1.14.0-py3-none-any.whl", hash = "sha256:9a2f5151978411e0f8b55a2d38cddd0a94491851149d14db4af96f5a09774825", size = 325555 },
1622
  ]
1623
 
1624
  [[package]]
 
1063
  dependencies = [
1064
  { name = "anthropic" },
1065
  { name = "beautifulsoup4" },
1066
+ { name = "gradio", extra = ["mcp"] },
1067
  { name = "httpx" },
1068
  { name = "openai" },
1069
  { name = "pydantic" },
 
1111
  { name = "beautifulsoup4", specifier = ">=4.12" },
1112
  { name = "chromadb", marker = "extra == 'embeddings'", specifier = ">=0.4.0" },
1113
  { name = "chromadb", marker = "extra == 'modal'", specifier = ">=0.4.0" },
1114
+ { name = "gradio", extras = ["mcp"], specifier = ">=5.0.0" },
1115
  { name = "httpx", specifier = ">=0.27" },
1116
  { name = "llama-index", marker = "extra == 'modal'", specifier = ">=0.11.0" },
1117
  { name = "llama-index-embeddings-openai", marker = "extra == 'modal'" },
 
1568
 
1569
  [[package]]
1570
  name = "gradio"
1571
+ version = "6.0.1"
1572
  source = { registry = "https://pypi.org/simple" }
1573
  dependencies = [
1574
  { name = "aiofiles" },
 
1592
  { name = "pydub" },
1593
  { name = "python-multipart" },
1594
  { name = "pyyaml" },
 
1595
  { name = "safehttpx" },
1596
  { name = "semantic-version" },
1597
  { name = "starlette" },
 
1600
  { name = "typing-extensions" },
1601
  { name = "uvicorn" },
1602
  ]
1603
+ sdist = { url = "https://files.pythonhosted.org/packages/65/13/f2bfe1237b8700f63e21c5e39f2843ac8346f7ba4525b582f30f40249863/gradio-6.0.1.tar.gz", hash = "sha256:5d02e6ac34c67aea26b938b8628c8f9f504871392e71f2db559ab8d6799bdf69", size = 36440914 }
1604
  wheels = [
1605
+ { url = "https://files.pythonhosted.org/packages/09/21/27ae5f4b2191a5d58707fc610e67453781a2b948a675a7cf06c99497ffa1/gradio-6.0.1-py3-none-any.whl", hash = "sha256:0f98dc8b414a3f3773cbf3caf5a354507c8ae309ed8266e2f30ca9fa53f379b8", size = 21559963 },
1606
+ ]
1607
+
1608
+ [package.optional-dependencies]
1609
+ mcp = [
1610
+ { name = "mcp" },
1611
+ { name = "pydantic" },
1612
  ]
1613
 
1614
  [[package]]
1615
  name = "gradio-client"
1616
+ version = "2.0.0"
1617
  source = { registry = "https://pypi.org/simple" }
1618
  dependencies = [
1619
  { name = "fsspec" },
 
1621
  { name = "huggingface-hub" },
1622
  { name = "packaging" },
1623
  { name = "typing-extensions" },
 
1624
  ]
1625
+ sdist = { url = "https://files.pythonhosted.org/packages/cf/0a/906062fe0577c62ea6e14044ba74268ff9266fdc75d0e69257bddb7400b3/gradio_client-2.0.0.tar.gz", hash = "sha256:56b462183cb8741bd3e69b21db7d3b62c5abb03c2c2bb925223f1eb18f950e89", size = 315906 }
1626
  wheels = [
1627
+ { url = "https://files.pythonhosted.org/packages/07/5b/789403564754f1eba0273400c1cea2c155f984d82458279154977a088509/gradio_client-2.0.0-py3-none-any.whl", hash = "sha256:77bedf20edcc232d8e7986c1a22165b2bbca1c7c7df10ba808a093d5180dae18", size = 315180 },
1628
  ]
1629
 
1630
  [[package]]