Spaces:
Sleeping
Sleeping
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 +169 -0
- debug_mcp.py +61 -0
- diagnose_url.py +56 -0
- openapi.json +1 -0
- output.txt +16 -0
- output_2.txt +17 -0
- print_env.py +8 -0
- pyproject.toml +3 -2
- reproduce_issue.py +26 -0
- src/credentialwatch_agent/__pycache__/main.cpython-313.pyc +0 -0
- src/credentialwatch_agent/__pycache__/mcp_client.cpython-313.pyc +0 -0
- src/credentialwatch_agent/agents/__pycache__/common.cpython-313.pyc +0 -0
- src/credentialwatch_agent/agents/__pycache__/interactive_query.cpython-313.pyc +0 -0
- src/credentialwatch_agent/agents/interactive_query.py +1 -1
- src/credentialwatch_agent/main.py +16 -3
- src/credentialwatch_agent/mcp_client.py +123 -58
- uv.lock +17 -1
- verify_connection.py +43 -0
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]>=
|
| 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
|
| 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
|
| 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.
|
| 22 |
-
self.
|
|
|
|
| 23 |
self._connected = False
|
| 24 |
self._connect_lock = asyncio.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
async def connect(self):
|
| 27 |
-
"""Establishes connections to all MCP servers.
|
| 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 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
self._connected = True
|
| 43 |
return
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
)
|
| 66 |
-
|
| 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 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
| 80 |
except Exception as e:
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
self._connected = True
|
| 87 |
|
| 88 |
async def close(self):
|
| 89 |
"""Closes all connections."""
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
async def call_tool(self, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> Any:
|
| 93 |
-
"""Calls a tool
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
return self._get_mock_response(server_name, tool_name, arguments)
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = ">=
|
| 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())
|