Humanlearning commited on
Commit
9d3c7fc
·
1 Parent(s): 4f4a9eb

feat: Implement CredentialWatch agent with MCP client, main entry point, interactive query agent, and various debugging utilities.

Browse files
README.md CHANGED
@@ -10,3 +10,172 @@ pinned: false
10
  python_version: 3.11
11
  ---
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  python_version: 3.11
11
  ---
12
 
13
+ # CredentialWatch 🛡️
14
+
15
+ **CredentialWatch** is a demo product for the **"MCP 1st Birthday / Gradio Agents Hackathon" (Hugging Face)**.
16
+
17
+ ## 0. What I’m building (TL;DR)
18
+
19
+ - **Domain:** US-style healthcare admin / provider credentialing & expiries (state licenses, board certs, DEA/CDS, malpractice, hospital privileges, etc.).
20
+ - **Goal:** Show how **Model Context Protocol (MCP)** + **LangGraph agents** + **Gradio** + **Modal** + **SQLite** can turn messy, fragmented credential data into:
21
+ - One unified, queryable view of each provider, and
22
+ - A proactive alerting system for expiring / at-risk credentials.
23
+ - **Constraint:** Use only open / public tools & APIs (no closed vendor APIs, no real PHI).
24
+
25
+ ## 1. Problem & motivation
26
+
27
+ **Real-world (simplified):**
28
+
29
+ - Provider groups (clinics, hospitals, multi-specialty practices) have **dozens of contracts** with different health plans and institutions.
30
+ - Multiple credentials can expire or go stale:
31
+ - State licenses, board certifications, DEA/CDS numbers,
32
+ - Malpractice insurance,
33
+ - Hospital privileges, etc.
34
+ - Today:
35
+ - Each health plan / hospital sends its own emails / portal tasks asking to update credentials and directory data.
36
+ - Staff maintain local **spreadsheets, trackers, email threads**, often with inconsistent formats.
37
+ - It’s easy to miss an expiry, leading to:
38
+ - Compliance issues,
39
+ - Denied claims (revenue loss),
40
+ - Inability to schedule or bill a provider after a certain date.
41
+
42
+ **CredentialWatch acts as a central radar:**
43
+ > “For all my providers, tell me what’s expiring when, what’s high risk, and log alerts we can action.”
44
+
45
+ ## 2. Solution concept
46
+
47
+ CredentialWatch provides:
48
+
49
+ - A single, internal **“source-of-truth” SQLite DB** for providers, credentials and alerts.
50
+ - **Three separate MCP servers** (strict separation of concerns, each can be its own HF Space/repo):
51
+ 1. `npi_mcp` → read-only public provider info from **NPPES NPI Registry API**.
52
+ 2. `cred_db_mcp` → internal provider & credential DB operations.
53
+ 3. `alert_mcp` → alert logging, listing & resolution.
54
+ - A **LangGraph-based agent** that:
55
+ - Periodically runs an **expiry sweep** and logs alerts.
56
+ - Answers free-text questions like:
57
+ - “Who has credentials expiring in the next 60 days in Cardiology?”
58
+ - “Show me the credential snapshot for Dr. Jane Doe.”
59
+ - A **Gradio UI** where:
60
+ - Judges/users chat with the agent,
61
+ - They can click a “Run Nightly Sweep” button,
62
+ - They see tables for “expiring soon” and an **Alert Inbox**.
63
+
64
+ ## 3. Hackathon & design constraints
65
+
66
+ - **Event:** Hugging Face – MCP 1st Birthday / Gradio Agents Hackathon.
67
+ - **Judging goals:**
68
+ - Strong MCP usage (tools/resources as first-class interfaces).
69
+ - Agentic sophistication: planning, multi-step tool use, long-running flows.
70
+ - Clear UX/Teachability.
71
+
72
+ **Constraints & safety:**
73
+ - Only **public / open APIs** (NPPES NPI Registry, OpenAI GPT).
74
+ - No real PHI: use synthetic/internal IDs + public NPI data.
75
+ - Safety boundaries:
76
+ - Read-only to **external** systems.
77
+ - Writes only to **internal** SQLite DB.
78
+
79
+ ## 4. Tech stack 🧱
80
+
81
+ - **Language:** Python 3.11
82
+ - **Package management:** `uv`
83
+ - **Frontend / UI:** Gradio 6 (Hosted as a Hugging Face Space)
84
+ - **Agents:** LangGraph (Python)
85
+ - **LLM:** OpenAI `gpt-5.1` (or `gpt-4o`)
86
+ - **Tool protocol:** Model Context Protocol (MCP), via SSE.
87
+ - **Backend web framework:** FastAPI, running on **Modal**.
88
+ - **Database:** SQLite, persisted on a Modal volume.
89
+ - **ORM:** SQLAlchemy 2.x.
90
+
91
+ ## 5. Architecture overview 🧩
92
+
93
+ ### Logical components
94
+
95
+ **HF Space #1 – Agent UI (`credentialwatch-agent-ui`)**
96
+ - Gradio frontend.
97
+ - LangGraph agent runtime (`expiry_sweep_graph`, `interactive_query_graph`).
98
+ - MCP client configured for 3 remote MCP servers (via SSE).
99
+
100
+ **HF Space #2 – `npi_mcp`**
101
+ - MCP server for **NPI/NPPES** tools (`search_providers`, `get_provider_by_npi`).
102
+ - Calls `NPI_API` FastAPI service on Modal.
103
+
104
+ **HF Space #3 – `cred_db_mcp`**
105
+ - MCP server for internal data tools (`sync_provider_from_npi`, `add_or_update_credential`, etc.).
106
+ - Calls `CRED_API` FastAPI service on Modal.
107
+
108
+ **HF Space #4 – `alert_mcp`**
109
+ - MCP server for alert tools (`log_alert`, `get_open_alerts`, etc.).
110
+ - Calls `ALERT_API` FastAPI service on Modal.
111
+
112
+ **Modal backend**
113
+ - **FastAPI microservices**: `NPI_API`, `CRED_API`, `ALERT_API`.
114
+ - Shared **SQLite DB** on a Modal volume.
115
+
116
+ ## 6. The 3 MCP servers – separation of concerns 🧱
117
+
118
+ ### 6.1 `npi_mcp`
119
+ Read-only access to public provider data.
120
+ - `search_providers(query, state?, taxonomy?)`
121
+ - `get_provider_by_npi(npi)`
122
+
123
+ ### 6.2 `cred_db_mcp`
124
+ Interface to internal provider & credential data.
125
+ - `sync_provider_from_npi(npi)`
126
+ - `add_or_update_credential(...)`
127
+ - `list_expiring_credentials(...)`
128
+ - `get_provider_snapshot(...)`
129
+
130
+ ### 6.3 `alert_mcp`
131
+ Manage alerts generated by the agent.
132
+ - `log_alert(...)`
133
+ - `get_open_alerts(...)`
134
+ - `mark_alert_resolved(...)`
135
+
136
+ ## 7. Agent behaviors (LangGraph) 🧠
137
+
138
+ ### 7.1 `expiry_sweep_graph`
139
+ Batch / nightly graph.
140
+ 1. Call `cred_db_mcp.list_expiring_credentials`.
141
+ 2. Decide severity.
142
+ 3. Call `alert_mcp.log_alert`.
143
+
144
+ ### 7.2 `interactive_query_graph`
145
+ Chat / Q&A graph (ReAct-style).
146
+ - Plans tool calls (NPI, DB, Alerts).
147
+ - Summarizes results.
148
+
149
+ ## 8. Database model 🗄️
150
+
151
+ **DB engine:** SQLite on Modal volume.
152
+
153
+ - `providers`: Internal provider records.
154
+ - `credentials`: Licenses, certifications, etc.
155
+ - `alerts`: Generated alerts for expiries.
156
+
157
+ ---
158
+
159
+ ## How to Run Locally
160
+
161
+ To run the CredentialWatch Agent UI locally:
162
+
163
+ 1. **Prerequisites:**
164
+ - Python 3.11+
165
+ - `uv` package manager
166
+
167
+ 2. **Environment Variables:**
168
+ Create a `.env` file or set the following environment variables:
169
+ ```bash
170
+ OPENAI_API_KEY=sk-...
171
+ # URLs for your deployed MCP servers (or local if running locally)
172
+ NPI_MCP_URL=https://<your-npi-space>.hf.space/sse
173
+ CRED_DB_MCP_URL=https://<your-cred-db-space>.hf.space/sse
174
+ ALERT_MCP_URL=https://<your-alert-space>.hf.space/sse
175
+ ```
176
+
177
+ 3. **Run the Agent:**
178
+ ```bash
179
+ uv run -m credentialwatch_agent.main
180
+ ```
181
+ This will start the Gradio interface locally at `http://localhost:7860`.
debug_mcp.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import sys
4
+ import logging
5
+ from dotenv import load_dotenv
6
+
7
+ # Add src to path
8
+ sys.path.append(os.path.join(os.path.dirname(__file__), "src"))
9
+
10
+ # Load env vars
11
+ load_dotenv(".env.local")
12
+
13
+ # Configure logging
14
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
15
+ logger = logging.getLogger("debug_mcp")
16
+ logger.setLevel(logging.DEBUG)
17
+
18
+ # Silence noisy loggers
19
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
20
+ logging.getLogger("httpx").setLevel(logging.WARNING)
21
+
22
+ from credentialwatch_agent.mcp_client import mcp_client
23
+
24
+ async def debug_mcp():
25
+ logger.info("Starting MCP debug script...")
26
+
27
+ # Check environment variables
28
+ logger.info(f"NPI_MCP_URL: {os.getenv('NPI_MCP_URL')}")
29
+ logger.info(f"CRED_DB_MCP_URL: {os.getenv('CRED_DB_MCP_URL')}")
30
+ logger.info(f"ALERT_MCP_URL: {os.getenv('ALERT_MCP_URL')}")
31
+
32
+ try:
33
+ logger.info("Connecting to MCP servers...")
34
+ await mcp_client.connect()
35
+ logger.info("Connected to MCP servers.")
36
+ except Exception as e:
37
+ logger.error(f"Failed to connect to MCP servers: {e}", exc_info=True)
38
+ return
39
+
40
+ # Test NPI Tool
41
+ logger.info("\n--- Testing NPI Tool: search_providers ---")
42
+ try:
43
+ result = await mcp_client.call_tool("npi", "search_providers", {"query": "cardiology"})
44
+ logger.info(f"NPI Tool Result: {result}")
45
+ except Exception as e:
46
+ logger.error(f"NPI Tool Call Failed: {e}", exc_info=True)
47
+
48
+ # Test Cred DB Tool
49
+ logger.info("\n--- Testing Cred DB Tool: list_expiring_credentials ---")
50
+ try:
51
+ result = await mcp_client.call_tool("cred_db", "list_expiring_credentials", {"window_days": 90})
52
+ logger.info(f"Cred DB Tool Result: {result}")
53
+ except Exception as e:
54
+ logger.error(f"Cred DB Tool Call Failed: {e}", exc_info=True)
55
+
56
+ logger.info("\nClosing connections...")
57
+ await mcp_client.close()
58
+ logger.info("Done.")
59
+
60
+ if __name__ == "__main__":
61
+ asyncio.run(debug_mcp())
diagnose_url.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import asyncio
3
+ import os
4
+ from dotenv import load_dotenv
5
+
6
+ load_dotenv(".env.local")
7
+
8
+ async def diagnose():
9
+ url = os.getenv("NPI_MCP_URL")
10
+ print(f"Testing URL: {url}")
11
+
12
+ if not url:
13
+ print("URL not found in env vars.")
14
+ return
15
+
16
+ try:
17
+ async with httpx.AsyncClient() as client:
18
+ print("Sending GET request...")
19
+ response = await client.get(url, timeout=10.0)
20
+ print(f"Status Code: {response.status_code}")
21
+ print("Headers:")
22
+ for k, v in response.headers.items():
23
+ print(f" {k}: {v}")
24
+ print(f"Content (first 200 chars): {response.text[:200]}")
25
+ except httpx.TimeoutException:
26
+ print("Request timed out.")
27
+ except Exception as e:
28
+ print(f"Error: {e}")
29
+
30
+ # Try with SSE headers and streaming
31
+ try:
32
+ async with httpx.AsyncClient() as client:
33
+ print("\nSending GET request with Accept: text/event-stream (streaming)...")
34
+ headers = {"Accept": "text/event-stream"}
35
+ async with client.stream("GET", url, headers=headers, timeout=10.0) as response:
36
+ print(f"Status Code: {response.status_code}")
37
+ print("Headers:")
38
+ for k, v in response.headers.items():
39
+ print(f" {k}: {v}")
40
+
41
+ print("\nReading stream...")
42
+ count = 0
43
+ async for line in response.aiter_lines():
44
+ if line:
45
+ print(f"Received: {line}")
46
+ count += 1
47
+ if count >= 20:
48
+ print("Reached limit of 20 lines. Stopping.")
49
+ break
50
+ except httpx.TimeoutException:
51
+ print("Request timed out (SSE stream).")
52
+ except Exception as e:
53
+ print(f"Error (SSE): {e}")
54
+
55
+ if __name__ == "__main__":
56
+ asyncio.run(diagnose())
openapi.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"openapi":"3.1.0","info":{"title":"CredentialWatch Backend","version":"0.1.0"},"paths":{}}
output.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Attempting to connect to MCP servers...
2
+ NPI URL: https://humanlearning-npi-mcp.hf.space/gradio_api/mcp/
3
+ Cred DB URL: https://humanlearning-cred-db-mcp.hf.space/gradio_api/mcp/
4
+ Alert URL: https://humanlearning-alert-mcp.hf.space/gradio_api/mcp/
5
+ 2025-12-02 19:44:53,542 - mcp_client - INFO - Initializing MultiServerMCPClient...
6
+ 2025-12-02 19:44:58,019 - mcp_client - INFO - Successfully connected. Loaded 11 tools.
7
+ Mock mode: False
8
+ Connected: True
9
+ Loaded tools: ['npi_mcp_search_providers_tool', 'npi_mcp_get_provider_by_npi_tool', 'npi_mcp_run_diagnostics', 'cred_db_mcp_sync_provider_from_npi', 'cred_db_mcp_add_or_update_credential', 'cred_db_mcp_list_expiring_credentials', 'cred_db_mcp_get_provider_snapshot', 'alert_mcp_log_alert', 'alert_mcp_get_open_alerts', 'alert_mcp_mark_alert_resolved', 'alert_mcp_summarize_alerts']
10
+
11
+ Testing NPI tool call...
12
+ 2025-12-02 19:45:02,270 - mcp_client - WARNING - Tool 'search_providers' not found in loaded tools. Using mock if available.
13
+ 2025-12-02 19:45:02,270 - mcp_client - INFO - MCP connections closed.
14
+ Result: {'providers': [{'npi': '1234567890', 'name': 'Dr. Jane Doe', 'taxonomy': 'Cardiology'}]}
15
+
16
+ Closing...
output_2.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Attempting to connect to MCP servers...
2
+ NPI URL: https://humanlearning-npi-mcp.hf.space/gradio_api/mcp/
3
+ Cred DB URL: https://humanlearning-cred-db-mcp.hf.space/gradio_api/mcp/
4
+ Alert URL: https://humanlearning-alert-mcp.hf.space/gradio_api/mcp/
5
+ 2025-12-02 19:58:25,357 - mcp_client - INFO - Initializing MultiServerMCPClient...
6
+ 2025-12-02 19:58:29,753 - mcp_client - INFO - Successfully connected. Loaded 11 tools.
7
+ Mock mode: False
8
+ Connected: True
9
+ Loaded tools: ['npi_mcp_search_providers_tool', 'npi_mcp_get_provider_by_npi_tool', 'npi_mcp_run_diagnostics', 'cred_db_mcp_sync_provider_from_npi', 'cred_db_mcp_add_or_update_credential', 'cred_db_mcp_list_expiring_credentials', 'cred_db_mcp_get_provider_snapshot', 'alert_mcp_log_alert', 'alert_mcp_get_open_alerts', 'alert_mcp_mark_alert_resolved', 'alert_mcp_summarize_alerts']
10
+
11
+ Testing NPI tool call...
12
+ 2025-12-02 19:58:29,754 - mcp_client - INFO - Calling tool 'search_providers' with args: {'query': 'test'}
13
+ 2025-12-02 19:58:37,077 - mcp_client - INFO - Tool 'search_providers' returned successfully.
14
+ 2025-12-02 19:58:37,077 - mcp_client - INFO - MCP connections closed.
15
+ Result: [{'npi': '1205923075', 'full_name': 'KIM ARTER', 'enumeration_type': 'NPI-1', 'primary_taxonomy': '101Y00000X', 'primary_specialty': 'Counselor', 'primary_address': {'address_1': '11414 W CENTER RD STE 239', 'address_2': None, 'city': 'OMAHA', 'state': 'NE', 'postal_code': '681444487', 'country_code': 'US', 'telephone_number': '402-330-1633'}}, {'npi': '1255777512', 'full_name': 'KIMBERLY MORRIS', 'enumeration_type': 'NPI-1', 'primary_taxonomy': '164W00000X', 'primary_specialty': 'Licensed Practical Nurse', 'primary_address': {'address_1': '5707 N 22ND ST', 'address_2': None, 'city': 'TAMPA', 'state': 'FL', 'postal_code': '336104350', 'country_code': 'US', 'telephone_number': '813-239-8069'}}, {'npi': '1609108687', 'full_name': 'TANELL OGBEIDE', 'enumeration_type': 'NPI-1', 'primary_taxonomy': '363L00000X', 'primary_specialty': 'Nurse Practitioner', 'primary_address': {'address_1': '18010 SW MCEWAN RD', 'address_2': None, 'city': 'LAKE OSWEGO', 'state': 'OR', 'postal_code': '970357868', 'country_code': 'US', 'telephone_number': '503-525-7500'}}, {'npi': '1861557431', 'full_name': 'LYNN SCHUELLER', 'enumeration_type': 'NPI-1', 'primary_taxonomy': '363L00000X', 'primary_specialty': 'Nurse Practitioner', 'primary_address': {'address_1': '2350 N LAKE DR # 400', 'address_2': None, 'city': 'MILWAUKEE', 'state': 'WI', 'postal_code': '532114507', 'country_code': 'US', 'telephone_number': '414-271-1633'}}, {'npi': '1689378010', 'full_name': 'ALDEN TEST', 'enumeration_type': 'NPI-1', 'primary_taxonomy': '390200000X', 'primary_specialty': 'Student in an Organized Health Care Education/Training Program', 'primary_address': {'address_1': '320 E NORTH AVE', 'address_2': None, 'city': 'PITTSBURGH', 'state': 'PA', 'postal_code': '152124772', 'country_code': 'US', 'telephone_number': '412-330-4242'}}, {'npi': '1255928602', 'full_name': 'ALLISON TEST', 'enumeration_type': 'NPI-1', 'primary_taxonomy': '106S00000X', 'primary_specialty': 'Behavior Technician', 'primary_address': {'address_1': '2630 W RUMBLE RD', 'address_2': None, 'city': 'MODESTO', 'state': 'CA', 'postal_code': '953500155', 'country_code': 'US', 'telephone_number': '209-222-2378'}}, {'npi': '1528498920', 'full_name': 'CHARLOTTE TEST', 'enumeration_type': 'NPI-1', 'primary_taxonomy': '101YA0400X', 'primary_specialty': 'Counselor, Addiction (Substance Use Disorder)', 'primary_address': {'address_1': '233 W HIGH ST', 'address_2': None, 'city': 'GETTYSBURG', 'state': 'PA', 'postal_code': '173252124', 'country_code': 'US', 'telephone_number': '717-334-2433'}}, {'npi': '1164469938', 'full_name': 'DON TEST', 'enumeration_type': 'NPI-1', 'primary_taxonomy': '1223S0112X', 'primary_specialty': 'Dentist, Oral and Maxillofacial Surgery', 'primary_address': {'address_1': '6609 BLANCO RD', 'address_2': None, 'city': 'SAN ANTONIO', 'state': 'TX', 'postal_code': '782166152', 'country_code': 'US', 'telephone_number': '210-349-3161'}}, {'npi': '1033648951', 'full_name': 'JACK TEST', 'enumeration_type': 'NPI-1', 'primary_taxonomy': '207R00000X', 'primary_specialty': 'Internal Medicine', 'primary_address': {'address_1': '1923 S UTICA AVE', 'address_2': None, 'city': 'TULSA', 'state': 'OK', 'postal_code': '741046520', 'country_code': 'US', 'telephone_number': '918-748-7585'}}, {'npi': '1508193376', 'full_name': 'JULIA TEST', 'enumeration_type': 'NPI-1', 'primary_taxonomy': '390200000X', 'primary_specialty': 'Student in an Organized Health Care Education/Training Program', 'primary_address': {'address_1': '2728 DURANT AVE STE 109', 'address_2': None, 'city': 'BERKELEY', 'state': 'CA', 'postal_code': '947041725', 'country_code': 'US', 'telephone_number': '888-236-4076'}}]
16
+
17
+ Closing...
print_env.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dotenv import load_dotenv
3
+
4
+ load_dotenv(".env.local")
5
+
6
+ print(f"NPI_MCP_URL={os.getenv('NPI_MCP_URL')}")
7
+ print(f"CRED_DB_MCP_URL={os.getenv('CRED_DB_MCP_URL')}")
8
+ print(f"ALERT_MCP_URL={os.getenv('ALERT_MCP_URL')}")
pyproject.toml CHANGED
@@ -9,9 +9,10 @@ dependencies = [
9
  "langchain-openai>=0.0.5",
10
  "pydantic>=2.0.0",
11
  "mcp>=0.1.0",
12
- "gradio[mcp]>=6.0.1",
13
  "python-dotenv>=1.0.0",
14
- "httpx>=0.25.0"
 
15
  ]
16
 
17
  [build-system]
 
9
  "langchain-openai>=0.0.5",
10
  "pydantic>=2.0.0",
11
  "mcp>=0.1.0",
12
+ "gradio[mcp]>=5.0.0",
13
  "python-dotenv>=1.0.0",
14
+ "httpx>=0.25.0",
15
+ "langchain-mcp-adapters>=0.0.1"
16
  ]
17
 
18
  [build-system]
reproduce_issue.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import sys
4
+
5
+ # Add src to path
6
+ sys.path.append(os.path.join(os.path.dirname(__file__), "src"))
7
+
8
+ from credentialwatch_agent.mcp_client import mcp_client
9
+
10
+ async def reproduce():
11
+ print("Connecting using mcp_client...")
12
+ await mcp_client.connect()
13
+ print("Connected (or failed gracefully).")
14
+
15
+ # Simulate some work
16
+ await asyncio.sleep(1)
17
+
18
+ print("Closing...")
19
+ await mcp_client.close()
20
+ print("Closed.")
21
+
22
+ if __name__ == "__main__":
23
+ try:
24
+ asyncio.run(reproduce())
25
+ except Exception as e:
26
+ print(f"Top level error: {e}")
src/credentialwatch_agent/__pycache__/main.cpython-313.pyc CHANGED
Binary files a/src/credentialwatch_agent/__pycache__/main.cpython-313.pyc and b/src/credentialwatch_agent/__pycache__/main.cpython-313.pyc differ
 
src/credentialwatch_agent/__pycache__/mcp_client.cpython-313.pyc CHANGED
Binary files a/src/credentialwatch_agent/__pycache__/mcp_client.cpython-313.pyc and b/src/credentialwatch_agent/__pycache__/mcp_client.cpython-313.pyc differ
 
src/credentialwatch_agent/agents/__pycache__/common.cpython-313.pyc CHANGED
Binary files a/src/credentialwatch_agent/agents/__pycache__/common.cpython-313.pyc and b/src/credentialwatch_agent/agents/__pycache__/common.cpython-313.pyc differ
 
src/credentialwatch_agent/agents/__pycache__/interactive_query.cpython-313.pyc CHANGED
Binary files a/src/credentialwatch_agent/agents/__pycache__/interactive_query.cpython-313.pyc and b/src/credentialwatch_agent/agents/__pycache__/interactive_query.cpython-313.pyc differ
 
src/credentialwatch_agent/agents/interactive_query.py CHANGED
@@ -64,7 +64,7 @@ async def agent_node(state: AgentState):
64
  Invokes the LLM to decide the next step.
65
  """
66
  messages = state["messages"]
67
- model = ChatOpenAI(model="gpt-5.1", temperature=0) # Using gpt-5.1 as requested
68
  # Note: User requested GPT-5.1. I should probably use the model name string they asked for if it's supported,
69
  # or fallback to a standard one. I'll use "gpt-4o" as a safe high-quality default for now,
70
  # or "gpt-5.1-preview" if I want to be cheeky, but let's stick to "gpt-4o" to ensure it works.
 
64
  Invokes the LLM to decide the next step.
65
  """
66
  messages = state["messages"]
67
+ model = ChatOpenAI(model="gpt-5-nano", temperature=0) # Using gpt-5.1 as requested
68
  # Note: User requested GPT-5.1. I should probably use the model name string they asked for if it's supported,
69
  # or fallback to a standard one. I'll use "gpt-4o" as a safe high-quality default for now,
70
  # or "gpt-5.1-preview" if I want to be cheeky, but let's stick to "gpt-4o" to ensure it works.
src/credentialwatch_agent/main.py CHANGED
@@ -1,21 +1,27 @@
1
  import asyncio
2
  import os
 
3
  from typing import Dict, Any, List
4
  import gradio as gr
5
  from dotenv import load_dotenv
 
 
 
6
  from langchain_core.messages import HumanMessage, AIMessage
7
 
 
 
 
 
8
  from credentialwatch_agent.mcp_client import mcp_client
9
  from credentialwatch_agent.agents.expiry_sweep import expiry_sweep_graph
10
  from credentialwatch_agent.agents.interactive_query import interactive_query_graph
11
 
12
- # Load environment variables
13
- load_dotenv()
14
-
15
  async def run_expiry_sweep(window_days: int = 90) -> Dict[str, Any]:
16
  """
17
  Runs the expiry sweep workflow.
18
  """
 
19
  await mcp_client.connect()
20
  print(f"Starting expiry sweep for {window_days} days...")
21
  # Initialize state
@@ -35,7 +41,9 @@ async def run_expiry_sweep(window_days: int = 90) -> Dict[str, Any]:
35
  # For this hackathon, we'll assume the graph handles it or we pass it via a modified state if needed.
36
  # The current implementation of fetch_expiring_credentials uses a hardcoded 90 or tool default.
37
 
 
38
  final_state = await expiry_sweep_graph.ainvoke(initial_state)
 
39
  return {
40
  "summary": final_state.get("summary"),
41
  "alerts_created": final_state.get("alerts_created"),
@@ -46,6 +54,7 @@ async def run_chat_turn(message: str, history: List[List[str]]) -> str:
46
  """
47
  Runs a turn of the interactive query agent.
48
  """
 
49
  await mcp_client.connect()
50
  # Convert history to LangChain format
51
  messages = []
@@ -63,7 +72,9 @@ async def run_chat_turn(message: str, history: List[List[str]]) -> str:
63
  initial_state = {"messages": messages}
64
 
65
  # Run the graph
 
66
  final_state = await interactive_query_graph.ainvoke(initial_state)
 
67
 
68
  # Extract the last message
69
  last_message = final_state["messages"][-1]
@@ -74,11 +85,13 @@ async def run_chat_turn(message: str, history: List[List[str]]) -> str:
74
  async def start_app():
75
  """Initializes the app and connects to MCP servers."""
76
  print("Connecting to MCP servers...")
 
77
  await mcp_client.connect()
78
 
79
  async def stop_app():
80
  """Closes connections."""
81
  print("Closing MCP connections...")
 
82
  await mcp_client.close()
83
 
84
  with gr.Blocks(title="CredentialWatch") as demo:
 
1
  import asyncio
2
  import os
3
+ import logging
4
  from typing import Dict, Any, List
5
  import gradio as gr
6
  from dotenv import load_dotenv
7
+ load_dotenv(".env.local")
8
+ load_dotenv()
9
+
10
  from langchain_core.messages import HumanMessage, AIMessage
11
 
12
+ # Configure logging for main
13
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
14
+ logger = logging.getLogger("credentialwatch_agent")
15
+
16
  from credentialwatch_agent.mcp_client import mcp_client
17
  from credentialwatch_agent.agents.expiry_sweep import expiry_sweep_graph
18
  from credentialwatch_agent.agents.interactive_query import interactive_query_graph
19
 
 
 
 
20
  async def run_expiry_sweep(window_days: int = 90) -> Dict[str, Any]:
21
  """
22
  Runs the expiry sweep workflow.
23
  """
24
+ logger.info(f"Starting expiry sweep for {window_days} days...")
25
  await mcp_client.connect()
26
  print(f"Starting expiry sweep for {window_days} days...")
27
  # Initialize state
 
41
  # For this hackathon, we'll assume the graph handles it or we pass it via a modified state if needed.
42
  # The current implementation of fetch_expiring_credentials uses a hardcoded 90 or tool default.
43
 
44
+ logger.info("Invoking expiry_sweep_graph...")
45
  final_state = await expiry_sweep_graph.ainvoke(initial_state)
46
+ logger.info("Expiry sweep graph completed.")
47
  return {
48
  "summary": final_state.get("summary"),
49
  "alerts_created": final_state.get("alerts_created"),
 
54
  """
55
  Runs a turn of the interactive query agent.
56
  """
57
+ logger.info(f"Starting chat turn with message: {message}")
58
  await mcp_client.connect()
59
  # Convert history to LangChain format
60
  messages = []
 
72
  initial_state = {"messages": messages}
73
 
74
  # Run the graph
75
+ logger.info("Invoking interactive_query_graph...")
76
  final_state = await interactive_query_graph.ainvoke(initial_state)
77
+ logger.info("Interactive query graph completed.")
78
 
79
  # Extract the last message
80
  last_message = final_state["messages"][-1]
 
85
  async def start_app():
86
  """Initializes the app and connects to MCP servers."""
87
  print("Connecting to MCP servers...")
88
+ logger.info("Initializing app and connecting to MCP servers...")
89
  await mcp_client.connect()
90
 
91
  async def stop_app():
92
  """Closes connections."""
93
  print("Closing MCP connections...")
94
+ logger.info("Stopping app and closing MCP connections...")
95
  await mcp_client.close()
96
 
97
  with gr.Blocks(title="CredentialWatch") as demo:
src/credentialwatch_agent/mcp_client.py CHANGED
@@ -1,11 +1,8 @@
1
  import os
2
  import asyncio
 
3
  from typing import Any, Dict, List, Optional
4
- from contextlib import AsyncExitStack
5
-
6
- from mcp import ClientSession, StdioServerParameters
7
- from mcp.client.sse import sse_client
8
- from mcp.client.stdio import stdio_client
9
 
10
  class MCPClient:
11
  """
@@ -18,13 +15,23 @@ class MCPClient:
18
  self.cred_db_url = os.getenv("CRED_DB_MCP_URL", "http://localhost:8002/sse")
19
  self.alert_url = os.getenv("ALERT_MCP_URL", "http://localhost:8003/sse")
20
 
21
- self._exit_stack = AsyncExitStack()
22
- self._sessions: Dict[str, ClientSession] = {}
 
23
  self._connected = False
24
  self._connect_lock = asyncio.Lock()
 
 
 
 
 
 
 
 
 
25
 
26
  async def connect(self):
27
- """Establishes connections to all MCP servers. Idempotent."""
28
  async with self._connect_lock:
29
  if self._connected:
30
  return
@@ -36,69 +43,127 @@ class MCPClient:
36
  def is_localhost(url):
37
  return "localhost" in url or "127.0.0.1" in url
38
 
39
- if is_hf and (is_localhost(self.npi_url) or is_localhost(self.cred_db_url) or is_localhost(self.alert_url)):
40
- print("Detected Hugging Face Spaces environment with localhost URLs.")
41
- print("Skipping actual MCP connections and defaulting to mock data.")
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  self._connected = True
43
  return
44
 
45
- # Connect to NPI MCP
46
- try:
47
- # Note: mcp.client.sse.sse_client is a context manager that yields (read_stream, write_stream)
48
- # We need to keep the context open.
49
- npi_transport = await self._exit_stack.enter_async_context(sse_client(self.npi_url))
50
- self._sessions["npi"] = await self._exit_stack.enter_async_context(
51
- ClientSession(npi_transport[0], npi_transport[1])
52
- )
53
- await self._sessions["npi"].initialize()
54
- except Exception as e:
55
- import traceback
56
- print(f"Warning: Failed to connect to NPI MCP at {self.npi_url}. Using mock data.")
57
- print(f"Error details: {e}")
58
- # traceback.print_exc() # Reduce noise
59
-
60
- # Connect to Cred DB MCP
61
- try:
62
- cred_transport = await self._exit_stack.enter_async_context(sse_client(self.cred_db_url))
63
- self._sessions["cred_db"] = await self._exit_stack.enter_async_context(
64
- ClientSession(cred_transport[0], cred_transport[1])
65
- )
66
- await self._sessions["cred_db"].initialize()
67
- except Exception as e:
68
- import traceback
69
- print(f"Warning: Failed to connect to Cred DB MCP at {self.cred_db_url}. Using mock data.")
70
- print(f"Error details: {e}")
71
- # traceback.print_exc()
72
 
73
- # Connect to Alert MCP
74
  try:
75
- alert_transport = await self._exit_stack.enter_async_context(sse_client(self.alert_url))
76
- self._sessions["alert"] = await self._exit_stack.enter_async_context(
77
- ClientSession(alert_transport[0], alert_transport[1])
78
- )
79
- await self._sessions["alert"].initialize()
 
80
  except Exception as e:
81
- import traceback
82
- print(f"Warning: Failed to connect to Alert MCP at {self.alert_url}. Using mock data.")
83
- print(f"Error details: {e}")
84
- # traceback.print_exc()
85
-
86
- self._connected = True
87
 
88
  async def close(self):
89
  """Closes all connections."""
90
- await self._exit_stack.aclose()
 
 
 
91
 
92
  async def call_tool(self, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> Any:
93
- """Calls a tool on a specific MCP server."""
94
- session = self._sessions.get(server_name)
95
- if not session:
96
- # Fallback for testing/mocking if connection failed
97
- print(f"Warning: No active session for {server_name}. Returning mock data.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  return self._get_mock_response(server_name, tool_name, arguments)
99
 
100
- result = await session.call_tool(tool_name, arguments)
101
- return result
 
 
 
 
 
 
 
102
 
103
  def _get_mock_response(self, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> Any:
104
  """Returns mock data when MCP server is unavailable."""
 
1
  import os
2
  import asyncio
3
+ import logging
4
  from typing import Any, Dict, List, Optional
5
+ from langchain_mcp_adapters.client import MultiServerMCPClient
 
 
 
 
6
 
7
  class MCPClient:
8
  """
 
15
  self.cred_db_url = os.getenv("CRED_DB_MCP_URL", "http://localhost:8002/sse")
16
  self.alert_url = os.getenv("ALERT_MCP_URL", "http://localhost:8003/sse")
17
 
18
+ self._client: Optional[MultiServerMCPClient] = None
19
+ self._tools: Dict[str, Any] = {} # Cache tools
20
+ self._mock_mode = False
21
  self._connected = False
22
  self._connect_lock = asyncio.Lock()
23
+
24
+ # Configure logger
25
+ self.logger = logging.getLogger("mcp_client")
26
+ if not self.logger.handlers:
27
+ handler = logging.StreamHandler()
28
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
29
+ handler.setFormatter(formatter)
30
+ self.logger.addHandler(handler)
31
+ self.logger.setLevel(logging.INFO)
32
 
33
  async def connect(self):
34
+ """Establishes connections to all MCP servers."""
35
  async with self._connect_lock:
36
  if self._connected:
37
  return
 
43
  def is_localhost(url):
44
  return "localhost" in url or "127.0.0.1" in url
45
 
46
+ # Normalize URLs for SSE
47
+ def normalize_sse_url(url):
48
+ if url.endswith("/"):
49
+ url = url[:-1]
50
+ if not url.endswith("/sse"):
51
+ url += "/sse"
52
+ return url
53
+
54
+ npi_url = normalize_sse_url(self.npi_url)
55
+ cred_db_url = normalize_sse_url(self.cred_db_url)
56
+ alert_url = normalize_sse_url(self.alert_url)
57
+
58
+ if is_hf and (is_localhost(npi_url) or is_localhost(cred_db_url) or is_localhost(alert_url)):
59
+ self.logger.info("Detected Hugging Face Spaces environment with localhost URLs.")
60
+ self.logger.info("Skipping actual MCP connections and defaulting to mock data.")
61
+ self._mock_mode = True
62
  self._connected = True
63
  return
64
 
65
+ self.logger.info("Initializing MultiServerMCPClient...")
66
+
67
+ servers = {
68
+ "npi": {
69
+ "transport": "sse",
70
+ "url": npi_url,
71
+ },
72
+ "cred_db": {
73
+ "transport": "sse",
74
+ "url": cred_db_url,
75
+ },
76
+ "alert": {
77
+ "transport": "sse",
78
+ "url": alert_url,
79
+ }
80
+ }
81
+
82
+ # Add auth headers if needed
83
+ if os.getenv("HF_TOKEN"):
84
+ headers = {"Authorization": f"Bearer {os.getenv('HF_TOKEN')}"}
85
+ for server in servers.values():
86
+ server["headers"] = headers
 
 
 
 
 
87
 
 
88
  try:
89
+ self._client = MultiServerMCPClient(servers)
90
+ # Pre-fetch tools to verify connection and cache them
91
+ tools_list = await self._client.get_tools()
92
+ self._tools = {tool.name: tool for tool in tools_list}
93
+ self.logger.info(f"Successfully connected. Loaded {len(self._tools)} tools.")
94
+ self._connected = True
95
  except Exception as e:
96
+ self.logger.error(f"Failed to initialize MCP client: {e}", exc_info=True)
97
+ # If initialization fails, we might want to fallback to mock mode or just fail
98
+ # For now, let's allow retry or fail gracefully
99
+ pass
 
 
100
 
101
  async def close(self):
102
  """Closes all connections."""
103
+ # MultiServerMCPClient might not have an explicit close, but we can clear it
104
+ self._client = None
105
+ self._connected = False
106
+ self.logger.info("MCP connections closed.")
107
 
108
  async def call_tool(self, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> Any:
109
+ """Calls a tool. server_name is mostly for compatibility/mocking, as tools are flattened."""
110
+ if not self._connected:
111
+ await self.connect()
112
+
113
+ if self._mock_mode:
114
+ return self._get_mock_response(server_name, tool_name, arguments)
115
+
116
+ # In MultiServerMCPClient, tools are flattened.
117
+ tool = self._tools.get(tool_name)
118
+
119
+ # Fuzzy match if exact match fails
120
+ if not tool:
121
+ # Try to find a tool that contains the tool_name
122
+ # We prioritize matches that end with the tool_name or tool_name_tool
123
+ for name, t in self._tools.items():
124
+ if name == tool_name:
125
+ tool = t
126
+ break
127
+ if name.endswith(f"_{tool_name}") or name.endswith(f"_{tool_name}_tool") or name == f"{tool_name}_tool":
128
+ tool = t
129
+ break
130
+ # Fallback: check if tool_name is in the name (less safe but helpful)
131
+ if tool_name in name:
132
+ tool = t
133
+ # Keep searching for a better match (suffix)
134
+ continue
135
+
136
+ if not tool:
137
+ # Try to refresh tools
138
+ if self._client:
139
+ try:
140
+ tools_list = await self._client.get_tools()
141
+ self._tools = {t.name: t for t in tools_list}
142
+ tool = self._tools.get(tool_name)
143
+ # Retry fuzzy match after refresh
144
+ if not tool:
145
+ for name, t in self._tools.items():
146
+ if name.endswith(f"_{tool_name}") or name.endswith(f"_{tool_name}_tool") or name == f"{tool_name}_tool":
147
+ tool = t
148
+ break
149
+ if tool_name in name:
150
+ tool = t
151
+ except Exception as e:
152
+ self.logger.error(f"Error refreshing tools: {e}")
153
+
154
+ if not tool:
155
+ self.logger.warning(f"Tool '{tool_name}' not found in loaded tools. Using mock if available.")
156
  return self._get_mock_response(server_name, tool_name, arguments)
157
 
158
+ try:
159
+ self.logger.info(f"Calling tool '{tool_name}' with args: {arguments}")
160
+ # LangChain tools are callable or have .invoke
161
+ result = await tool.ainvoke(arguments)
162
+ self.logger.info(f"Tool '{tool_name}' returned successfully.")
163
+ return result
164
+ except Exception as e:
165
+ self.logger.error(f"Error calling tool '{tool_name}': {e}", exc_info=True)
166
+ raise
167
 
168
  def _get_mock_response(self, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> Any:
169
  """Returns mock data when MCP server is unavailable."""
uv.lock CHANGED
@@ -340,6 +340,7 @@ source = { editable = "." }
340
  dependencies = [
341
  { name = "gradio", extra = ["mcp"] },
342
  { name = "httpx" },
 
343
  { name = "langchain-openai" },
344
  { name = "langgraph" },
345
  { name = "mcp" },
@@ -349,8 +350,9 @@ dependencies = [
349
 
350
  [package.metadata]
351
  requires-dist = [
352
- { name = "gradio", extras = ["mcp"], specifier = ">=4.0.0" },
353
  { name = "httpx", specifier = ">=0.25.0" },
 
354
  { name = "langchain-openai", specifier = ">=0.0.5" },
355
  { name = "langgraph", specifier = ">=0.0.10" },
356
  { name = "mcp", specifier = ">=0.1.0" },
@@ -809,6 +811,20 @@ wheels = [
809
  { url = "https://files.pythonhosted.org/packages/71/1e/e129fc471a2d2a7b3804480a937b5ab9319cab9f4142624fcb115f925501/langchain_core-1.1.0-py3-none-any.whl", hash = "sha256:2c9f27dadc6d21ed4aa46506a37a56e6a7e2d2f9141922dc5c251ba921822ee6", size = 473752, upload-time = "2025-11-21T21:01:25.841Z" },
810
  ]
811
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
812
  [[package]]
813
  name = "langchain-openai"
814
  version = "1.1.0"
 
340
  dependencies = [
341
  { name = "gradio", extra = ["mcp"] },
342
  { name = "httpx" },
343
+ { name = "langchain-mcp-adapters" },
344
  { name = "langchain-openai" },
345
  { name = "langgraph" },
346
  { name = "mcp" },
 
350
 
351
  [package.metadata]
352
  requires-dist = [
353
+ { name = "gradio", extras = ["mcp"], specifier = ">=5.0.0" },
354
  { name = "httpx", specifier = ">=0.25.0" },
355
+ { name = "langchain-mcp-adapters", specifier = ">=0.0.1" },
356
  { name = "langchain-openai", specifier = ">=0.0.5" },
357
  { name = "langgraph", specifier = ">=0.0.10" },
358
  { name = "mcp", specifier = ">=0.1.0" },
 
811
  { url = "https://files.pythonhosted.org/packages/71/1e/e129fc471a2d2a7b3804480a937b5ab9319cab9f4142624fcb115f925501/langchain_core-1.1.0-py3-none-any.whl", hash = "sha256:2c9f27dadc6d21ed4aa46506a37a56e6a7e2d2f9141922dc5c251ba921822ee6", size = 473752, upload-time = "2025-11-21T21:01:25.841Z" },
812
  ]
813
 
814
+ [[package]]
815
+ name = "langchain-mcp-adapters"
816
+ version = "0.1.14"
817
+ source = { registry = "https://pypi.org/simple" }
818
+ dependencies = [
819
+ { name = "langchain-core" },
820
+ { name = "mcp" },
821
+ { name = "typing-extensions" },
822
+ ]
823
+ sdist = { url = "https://files.pythonhosted.org/packages/8a/36/0179462acf344ad18cf6d7190b7a6797e015386a3b4518fcd960bb831c61/langchain_mcp_adapters-0.1.14.tar.gz", hash = "sha256:36f8131d0b3b5ca28df03440be4dc2a3636e2f73e3e4a0a266d606ffaa12adda", size = 31119, upload-time = "2025-11-24T15:00:54.591Z" }
824
+ wheels = [
825
+ { url = "https://files.pythonhosted.org/packages/17/e0/eee23ea4d651d2b2dd5b1a5e1b7f66ee212940a595917a280663d5f745b9/langchain_mcp_adapters-0.1.14-py3-none-any.whl", hash = "sha256:b900d37d1a82261e408e663b7176a1a74cf0072e17ae9e4241c068093912394a", size = 21133, upload-time = "2025-11-24T15:00:53.569Z" },
826
+ ]
827
+
828
  [[package]]
829
  name = "langchain-openai"
830
  version = "1.1.0"
verify_connection.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import sys
4
+ from dotenv import load_dotenv
5
+
6
+ # Add src to path
7
+ sys.path.append(os.path.join(os.path.dirname(__file__), "src"))
8
+
9
+ # Load env vars
10
+ load_dotenv(".env.local")
11
+
12
+ from credentialwatch_agent.mcp_client import mcp_client
13
+
14
+ async def verify_connection():
15
+ print("Attempting to connect to MCP servers...")
16
+ print(f"NPI URL: {os.getenv('NPI_MCP_URL')}")
17
+ print(f"Cred DB URL: {os.getenv('CRED_DB_MCP_URL')}")
18
+ print(f"Alert URL: {os.getenv('ALERT_MCP_URL')}")
19
+
20
+ await mcp_client.connect()
21
+
22
+ # Check internal state if possible, or try to call a tool
23
+ # We can check if we are using mock data by inspecting the client's state if we exposed it,
24
+ # but mcp_client.py doesn't expose a "is_mock" flag directly other than printing.
25
+ # However, we can try to call a tool and see if we get a real response or the mock response.
26
+ # The mock response for "npi" -> "search_providers" returns "Dr. Jane Doe".
27
+
28
+ print(f"Mock mode: {mcp_client._mock_mode}")
29
+ print(f"Connected: {mcp_client._connected}")
30
+ print(f"Loaded tools: {list(mcp_client._tools.keys())}")
31
+
32
+ print("\nTesting NPI tool call...")
33
+ try:
34
+ result = await mcp_client.call_tool("npi", "search_providers", {"query": "test"})
35
+ print(f"Result: {result}")
36
+ except Exception as e:
37
+ print(f"Tool call failed: {e}")
38
+
39
+ print("\nClosing...")
40
+ await mcp_client.close()
41
+
42
+ if __name__ == "__main__":
43
+ asyncio.run(verify_connection())