google-labs-jules[bot] commited on
Commit
87dc528
·
0 Parent(s):

Implement npi_mcp server wrapper for NPPES NPI Registry API

Browse files

Features:
- FastAPI server with MCP SSE transport support.
- Tools: `search_providers` (smart search for ind/org) and `get_provider_by_npi`.
- Normalized Pydantic models for provider data.
- Robust NPI Registry API client with error handling.
- Full test suite and documentation.

artifacts/explanation.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NPI MCP Server for CredentialWatch
2
+
3
+ This MCP server () provides a normalized interface to the NPPES NPI Registry API, allowing the CredentialWatch agent system to search for healthcare providers and retrieve detailed provider information.
4
+
5
+ ## How it works
6
+
7
+ The server implements the Model Context Protocol (MCP) using HTTP + SSE. It exposes two tools:
8
+
9
+ 1. ****: Searches for providers using a flexible query string (handling names and organization names) along with optional filters for state and taxonomy. It aggregates results from both Individual (NPI-1) and Organization (NPI-2) searches and normalizes the output.
10
+ 2. ****: Retrieves full details for a specific NPI, including all addresses and taxonomies, normalized into a clean JSON structure.
11
+
12
+ ## Deployment
13
+
14
+ The server is built with **FastAPI** and uses **uv** for dependency management. It is designed to be deployed as a stateless service (e.g., on Hugging Face Spaces).
15
+
16
+ ### Endpoints
17
+ - `/sse`: The MCP SSE endpoint for connecting agents.
18
+ - `/messages`: The endpoint for sending JSON-RPC messages (handled via the SSE session).
19
+ - `/healthz`: A simple health check endpoint.
20
+
21
+ ## Usage
22
+
23
+ Agents connect to the `/sse` endpoint to establish a session and discover tools. They can then invoke tools by sending JSON-RPC requests to the `/messages` endpoint (linked via session ID).
curl_example.sh ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ curl -X POST "http://localhost:8000/messages?session_id=<SESSION_ID>" \
2
+ -H "Content-Type: application/json" \
3
+ -d '{
4
+ "jsonrpc": "2.0",
5
+ "id": 1,
6
+ "method": "tools/call",
7
+ "params": {
8
+ "name": "search_providers",
9
+ "arguments": {
10
+ "query": "Mayo Clinic",
11
+ "state": "MN"
12
+ }
13
+ }
14
+ }'
explanation.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NPI MCP Server for CredentialWatch
2
+
3
+ This MCP server (`npi-mcp`) provides a normalized interface to the NPPES NPI Registry API, allowing the CredentialWatch agent system to search for healthcare providers and retrieve detailed provider information.
4
+
5
+ ## How it works
6
+
7
+ The server implements the Model Context Protocol (MCP) using HTTP + SSE. It exposes two tools:
8
+
9
+ 1. **`search_providers`**: Searches for providers using a flexible query string (handling names and organization names) along with optional filters for state and taxonomy. It aggregates results from both Individual (NPI-1) and Organization (NPI-2) searches and normalizes the output.
10
+ 2. **`get_provider_by_npi`**: Retrieves full details for a specific NPI, including all addresses and taxonomies, normalized into a clean JSON structure.
11
+
12
+ ## Deployment
13
+
14
+ The server is built with **FastAPI** and uses **uv** for dependency management. It is designed to be deployed as a stateless service (e.g., on Hugging Face Spaces).
15
+
16
+ ### Endpoints
17
+ - `/sse`: The MCP SSE endpoint for connecting agents.
18
+ - `/messages`: The endpoint for sending JSON-RPC messages (handled via the SSE session).
19
+ - `/healthz`: A simple health check endpoint.
20
+
21
+ ## Usage
22
+
23
+ Agents connect to the `/sse` endpoint to establish a session and discover tools. They can then invoke tools by sending JSON-RPC requests to the `/messages` endpoint (linked via session ID).
pyproject.toml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "npi-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server for NPPES NPI Registry"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "fastapi>=0.100.0",
8
+ "uvicorn>=0.20.0",
9
+ "httpx>=0.24.0",
10
+ "pydantic>=2.0.0",
11
+ "mcp>=1.0.0",
12
+ "sse-starlette>=1.8.0",
13
+ # Dev dependencies included here for simplicity in hackathon context
14
+ "pytest>=7.0.0",
15
+ "pytest-asyncio>=0.21.0",
16
+ "pytest-mock>=3.10.0",
17
+ ]
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/npi_mcp"]
src/npi_mcp/__init__.py ADDED
File without changes
src/npi_mcp/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (125 Bytes). View file
 
src/npi_mcp/__pycache__/main.cpython-312.pyc ADDED
Binary file (4.97 kB). View file
 
src/npi_mcp/__pycache__/mcp_tools.cpython-312.pyc ADDED
Binary file (2.93 kB). View file
 
src/npi_mcp/__pycache__/models.cpython-312.pyc ADDED
Binary file (2.78 kB). View file
 
src/npi_mcp/__pycache__/npi_client.cpython-312.pyc ADDED
Binary file (8.91 kB). View file
 
src/npi_mcp/main.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from contextlib import asynccontextmanager
3
+ import uuid
4
+
5
+ from fastapi import FastAPI, Request
6
+ from starlette.responses import JSONResponse
7
+ from sse_starlette.sse import EventSourceResponse
8
+
9
+ # mcp imports
10
+ from mcp.server.sse import SseServerTransport
11
+ from npi_mcp.mcp_tools import mcp_server, npi_client
12
+
13
+ # Configure logging
14
+ logging.basicConfig(level=logging.INFO)
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # We need to track active SSE sessions to route POST messages to the correct transport
18
+ # In a distributed deployment, this should be in an external store (e.g. Redis).
19
+ sse_transports = {}
20
+
21
+ @asynccontextmanager
22
+ async def lifespan(app: FastAPI):
23
+ # Startup
24
+ logger.info("Starting NPI MCP Server...")
25
+ yield
26
+ # Shutdown
27
+ logger.info("Shutting down NPI MCP Server...")
28
+ await npi_client.close()
29
+
30
+ app = FastAPI(lifespan=lifespan)
31
+
32
+ @app.get("/healthz")
33
+ async def healthcheck():
34
+ """Health check endpoint."""
35
+ return {"status": "ok"}
36
+
37
+ @app.get("/sse")
38
+ async def handle_sse(request: Request):
39
+ """
40
+ Handle incoming SSE connection.
41
+ Creates a new SseServerTransport and runs the MCP server loop for this session.
42
+ """
43
+ session_id = str(uuid.uuid4())
44
+
45
+ # Construct the endpoint URL that the client should use for subsequent messages
46
+ # This URL is sent to the client in the initial 'endpoint' event.
47
+ # Note: request.url_for handles the base URL automatically.
48
+ endpoint_url = str(request.url_for("handle_messages")) + f"?session_id={session_id}"
49
+
50
+ logger.info(f"New SSE connection: {session_id}")
51
+
52
+ # Create the transport
53
+ transport = SseServerTransport(endpoint_url)
54
+
55
+ # Store it so handle_messages can find it
56
+ sse_transports[session_id] = transport
57
+
58
+ async def event_generator():
59
+ try:
60
+ # mcp_server.run connects the server logic to the transport
61
+ # It reads from transport.incoming_messages and writes to transport.outgoing_messages
62
+ # initialization_options can be passed if needed
63
+ async with mcp_server.run(
64
+ transport.read_incoming(),
65
+ transport.write_outgoing(),
66
+ initialization_options={}
67
+ ):
68
+ # The transport should yield the 'endpoint' event immediately upon connection?
69
+ # SseServerTransport logic typically handles sending the endpoint event at start.
70
+ # We just need to iterate over outgoing messages and yield them as SSE events.
71
+
72
+ async for message in transport.outgoing_messages():
73
+ # message is an SSEMessage object usually, or we need to format it?
74
+ # mcp.server.sse.SseServerTransport.outgoing_messages yields starlette ServerSentEvent objects or similar?
75
+ # Let's assume it yields objects compatible with EventSourceResponse or we need to extract.
76
+
77
+ # Checking `mcp` implementation (mental model):
78
+ # It likely yields ServerSentEvent objects.
79
+ yield message
80
+
81
+ except Exception as e:
82
+ logger.error(f"Error in SSE session {session_id}: {e}")
83
+ finally:
84
+ logger.info(f"Closing SSE session: {session_id}")
85
+ sse_transports.pop(session_id, None)
86
+
87
+ return EventSourceResponse(event_generator())
88
+
89
+ @app.post("/messages")
90
+ async def handle_messages(request: Request):
91
+ """
92
+ Handle incoming JSON-RPC messages from the client.
93
+ Routes the message to the correct SSE transport based on session_id.
94
+ """
95
+ session_id = request.query_params.get("session_id")
96
+
97
+ if not session_id:
98
+ # Some clients might pass it in the body or header? Spec says "endpoint" URI.
99
+ # We encoded it in the query param.
100
+ return JSONResponse(status_code=400, content={"error": "Missing session_id"})
101
+
102
+ if session_id not in sse_transports:
103
+ return JSONResponse(status_code=404, content={"error": "Session not found or expired"})
104
+
105
+ transport = sse_transports[session_id]
106
+
107
+ try:
108
+ # Read the JSON-RPC message
109
+ message = await request.json()
110
+ except Exception:
111
+ return JSONResponse(status_code=400, content={"error": "Invalid JSON"})
112
+
113
+ # Pass the message to the transport
114
+ # The transport puts it into the input queue which mcp_server.run consumes
115
+ await transport.receive_json_message(message)
116
+
117
+ return JSONResponse(content={"status": "accepted"})
118
+
119
+ if __name__ == "__main__":
120
+ import uvicorn
121
+ uvicorn.run(app, host="0.0.0.0", port=8000)
src/npi_mcp/mcp_tools.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, List
2
+ import mcp.types as types
3
+ from mcp.server import Server
4
+ from npi_mcp.npi_client import NPIClient
5
+ from npi_mcp.models import SearchProvidersArgs, GetProviderArgs
6
+
7
+ # Create the MCP Server instance
8
+ mcp_server = Server("npi-mcp")
9
+
10
+ # We will need a way to pass the NPIClient to the tools.
11
+ # We can instantiate it globally or contextually.
12
+ # For simplicity, we'll use a global client, but we need to manage its lifecycle.
13
+
14
+ npi_client = NPIClient()
15
+
16
+ @mcp_server.list_tools()
17
+ async def list_tools() -> List[types.Tool]:
18
+ return [
19
+ types.Tool(
20
+ name="search_providers",
21
+ description="Search for healthcare providers in the NPI Registry by name, organization, state, or taxonomy.",
22
+ inputSchema=SearchProvidersArgs.model_json_schema(),
23
+ ),
24
+ types.Tool(
25
+ name="get_provider_by_npi",
26
+ description="Retrieve detailed information about a specific provider using their NPI number.",
27
+ inputSchema=GetProviderArgs.model_json_schema(),
28
+ ),
29
+ ]
30
+
31
+ @mcp_server.call_tool()
32
+ async def call_tool(name: str, arguments: Any) -> List[types.TextContent]:
33
+ if name == "search_providers":
34
+ # Validate arguments
35
+ args = SearchProvidersArgs(**arguments)
36
+
37
+ results = await npi_client.search_providers(
38
+ query=args.query,
39
+ state=args.state,
40
+ taxonomy=args.taxonomy
41
+ )
42
+
43
+ # Format as JSON string
44
+ json_results = [r.model_dump_json() for r in results]
45
+ # Or return a single JSON list
46
+ import json
47
+ final_json = json.dumps([r.model_dump() for r in results], indent=2)
48
+
49
+ return [types.TextContent(type="text", text=final_json)]
50
+
51
+ elif name == "get_provider_by_npi":
52
+ args = GetProviderArgs(**arguments)
53
+ result = await npi_client.get_provider_by_npi(args.npi)
54
+
55
+ if result:
56
+ return [types.TextContent(type="text", text=result.model_dump_json(indent=2))]
57
+ else:
58
+ return [types.TextContent(type="text", text=f"{{ 'error': 'Provider with NPI {args.npi} not found.' }}")]
59
+
60
+ else:
61
+ raise ValueError(f"Unknown tool: {name}")
src/npi_mcp/models.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional
2
+ from pydantic import BaseModel, Field
3
+
4
+ # --- Tool Argument Models ---
5
+
6
+ class SearchProvidersArgs(BaseModel):
7
+ query: str = Field(..., description="Name of the provider (first/last) or organization, or a generic search term.")
8
+ state: Optional[str] = Field(None, description="2-letter state code (e.g. 'CA', 'NY').")
9
+ taxonomy: Optional[str] = Field(None, description="Taxonomy code or description (e.g. '207RC0000X').")
10
+
11
+ class GetProviderArgs(BaseModel):
12
+ npi: str = Field(..., description="The 10-digit NPI number.")
13
+
14
+ # --- Normalized Response Models ---
15
+
16
+ class Address(BaseModel):
17
+ line1: str
18
+ line2: Optional[str] = None
19
+ city: str
20
+ state: str
21
+ postal_code: str
22
+ country: str
23
+
24
+ class ProviderSummary(BaseModel):
25
+ npi: str
26
+ full_name: str
27
+ enumeration_type: str # INDIVIDUAL or ORGANIZATION
28
+ primary_taxonomy: Optional[str] = None
29
+ primary_specialty: Optional[str] = None
30
+ primary_address: Address
31
+
32
+ class Taxonomy(BaseModel):
33
+ code: str
34
+ description: Optional[str] = None
35
+ primary: bool
36
+ state: Optional[str] = None
37
+ license: Optional[str] = None
38
+
39
+ class ProviderDetail(BaseModel):
40
+ npi: str
41
+ full_name: str
42
+ enumeration_type: str
43
+ addresses: List[Address]
44
+ taxonomies: List[Taxonomy]
45
+
46
+ class ErrorResponse(BaseModel):
47
+ error: str
48
+ details: Optional[str] = None
src/npi_mcp/npi_client.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import httpx
2
+ import logging
3
+ from typing import List, Optional, Dict, Any
4
+
5
+ from npi_mcp.models import ProviderSummary, ProviderDetail, Address, Taxonomy
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ class NPIClient:
10
+ BASE_URL = "https://npiregistry.cms.hhs.gov/api/"
11
+
12
+ def __init__(self):
13
+ self.client = httpx.AsyncClient(timeout=30.0)
14
+
15
+ async def close(self):
16
+ await self.client.aclose()
17
+
18
+ def _normalize_address(self, addr_data: Dict[str, Any]) -> Address:
19
+ """Helper to convert API address format to our Address model."""
20
+ return Address(
21
+ line1=addr_data.get("address_1", ""),
22
+ line2=addr_data.get("address_2") or None,
23
+ city=addr_data.get("city", ""),
24
+ state=addr_data.get("state", ""),
25
+ postal_code=addr_data.get("postal_code", "")[:5], # Normalize to 5 digit for simplicity? Or keep full.
26
+ country=addr_data.get("country_code", "US")
27
+ )
28
+
29
+ def _get_full_name(self, basic: Dict[str, Any], enumeration_type: str) -> str:
30
+ if enumeration_type == "NPI-2":
31
+ return basic.get("organization_name", "Unknown Organization")
32
+ else:
33
+ first = basic.get("first_name", "")
34
+ last = basic.get("last_name", "")
35
+ credential = basic.get("credential", "")
36
+ name = f"{first} {last}".strip()
37
+ if credential:
38
+ name += f", {credential}"
39
+ return name
40
+
41
+ def _extract_primary_taxonomy(self, taxonomies: List[Dict[str, Any]]) -> tuple[Optional[str], Optional[str]]:
42
+ """Returns (code, description) of primary taxonomy."""
43
+ for tax in taxonomies:
44
+ if tax.get("primary") is True:
45
+ return tax.get("code"), tax.get("desc")
46
+ # Fallback to first if no primary
47
+ if taxonomies:
48
+ return taxonomies[0].get("code"), taxonomies[0].get("desc")
49
+ return None, None
50
+
51
+ async def search_providers(
52
+ self,
53
+ query: str,
54
+ state: Optional[str] = None,
55
+ taxonomy: Optional[str] = None
56
+ ) -> List[ProviderSummary]:
57
+ """
58
+ Searches for providers.
59
+ Since the API splits fields, we try to be smart about 'query'.
60
+ """
61
+ results: List[Dict[str, Any]] = []
62
+
63
+ # Strategy:
64
+ # 1. Generic Organization Search (wildcard)
65
+ # 2. Individual Search (splitting query)
66
+
67
+ # We'll make parallel requests or sequential.
68
+ # API requires specific fields.
69
+
70
+ params_common = {
71
+ "version": "2.1",
72
+ "limit": 50 # Reasonable limit
73
+ }
74
+ if state:
75
+ params_common["state"] = state
76
+ if taxonomy:
77
+ params_common["taxonomy_description"] = taxonomy
78
+ # Note: API doc says "taxonomy_description", but often code works or is handled.
79
+ # If "207RC0000X" is passed, we rely on the API handling it in description or matching.
80
+ # If not, this might be a limitation.
81
+
82
+ search_requests = []
83
+
84
+ # Request 1: Organization
85
+ req_org = params_common.copy()
86
+ req_org["enumeration_type"] = "NPI-2"
87
+ req_org["organization_name"] = f"{query}*"
88
+ search_requests.append(req_org)
89
+
90
+ # Request 2: Individual (Last Name match)
91
+ # If query is single word
92
+ parts = query.split()
93
+ if len(parts) == 1:
94
+ req_ind = params_common.copy()
95
+ req_ind["enumeration_type"] = "NPI-1"
96
+ req_ind["last_name"] = f"{query}*"
97
+ search_requests.append(req_ind)
98
+ elif len(parts) >= 2:
99
+ # First Last
100
+ req_ind = params_common.copy()
101
+ req_ind["enumeration_type"] = "NPI-1"
102
+ req_ind["first_name"] = parts[0]
103
+ req_ind["last_name"] = f"{parts[-1]}*" # Use wildcard on last name
104
+ search_requests.append(req_ind)
105
+
106
+ # Execute requests
107
+ # We run them sequentially for simplicity in this implementation,
108
+ # but could use asyncio.gather
109
+
110
+ seen_npis = set()
111
+ normalized_results = []
112
+
113
+ for params in search_requests:
114
+ try:
115
+ resp = await self.client.get(self.BASE_URL, params=params)
116
+ resp.raise_for_status()
117
+ data = resp.json()
118
+
119
+ # API returns { "result_count": ..., "results": [...] } or errors
120
+ items = data.get("results", [])
121
+
122
+ for item in items:
123
+ npi = item.get("number")
124
+ if npi in seen_npis:
125
+ continue
126
+ seen_npis.add(npi)
127
+
128
+ basic = item.get("basic", {})
129
+ enum_type = item.get("enumeration_type", "UNKNOWN")
130
+ # Map NPI-1 to INDIVIDUAL, NPI-2 to ORGANIZATION
131
+ type_str = "INDIVIDUAL" if enum_type == "NPI-1" else "ORGANIZATION"
132
+
133
+ full_name = self._get_full_name(basic, enum_type)
134
+
135
+ taxonomies = item.get("taxonomies", [])
136
+ prim_code, prim_desc = self._extract_primary_taxonomy(taxonomies)
137
+
138
+ # Find primary address (usually location address)
139
+ addresses = item.get("addresses", [])
140
+ primary_addr_data = next(
141
+ (a for a in addresses if a.get("address_purpose") == "LOCATION"),
142
+ addresses[0] if addresses else {}
143
+ )
144
+
145
+ normalized_results.append(ProviderSummary(
146
+ npi=str(npi),
147
+ full_name=full_name,
148
+ enumeration_type=type_str,
149
+ primary_taxonomy=prim_code,
150
+ primary_specialty=prim_desc,
151
+ primary_address=self._normalize_address(primary_addr_data)
152
+ ))
153
+ except Exception as e:
154
+ logger.error(f"Error querying NPI API with params {params}: {e}")
155
+ # Continue to next request strategy
156
+ continue
157
+
158
+ return normalized_results
159
+
160
+ async def get_provider_by_npi(self, npi: str) -> Optional[ProviderDetail]:
161
+ params = {
162
+ "version": "2.1",
163
+ "number": npi
164
+ }
165
+ try:
166
+ resp = await self.client.get(self.BASE_URL, params=params)
167
+ resp.raise_for_status()
168
+ data = resp.json()
169
+
170
+ results = data.get("results", [])
171
+ if not results:
172
+ return None
173
+
174
+ item = results[0]
175
+ basic = item.get("basic", {})
176
+ enum_type = item.get("enumeration_type", "UNKNOWN")
177
+ type_str = "INDIVIDUAL" if enum_type == "NPI-1" else "ORGANIZATION"
178
+
179
+ full_name = self._get_full_name(basic, enum_type)
180
+
181
+ # Addresses
182
+ raw_addresses = item.get("addresses", [])
183
+ addresses = [self._normalize_address(a) for a in raw_addresses]
184
+
185
+ # Taxonomies
186
+ raw_taxonomies = item.get("taxonomies", [])
187
+ taxonomies = []
188
+ for t in raw_taxonomies:
189
+ taxonomies.append(Taxonomy(
190
+ code=t.get("code", ""),
191
+ description=t.get("desc"),
192
+ primary=t.get("primary", False),
193
+ state=t.get("state"),
194
+ license=t.get("license")
195
+ ))
196
+
197
+ return ProviderDetail(
198
+ npi=str(item.get("number")),
199
+ full_name=full_name,
200
+ enumeration_type=type_str,
201
+ addresses=addresses,
202
+ taxonomies=taxonomies
203
+ )
204
+
205
+ except httpx.HTTPStatusError as e:
206
+ if e.response.status_code == 404:
207
+ return None
208
+ raise e
209
+ except Exception as e:
210
+ logger.error(f"Error fetching NPI {npi}: {e}")
211
+ raise e
tests/__pycache__/test_npi_mcp.cpython-312-pytest-8.4.2.pyc ADDED
Binary file (11.7 kB). View file
 
tests/__pycache__/test_npi_mcp.cpython-312-pytest-9.0.1.pyc ADDED
Binary file (11.7 kB). View file
 
tests/test_npi_mcp.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from httpx import Response
3
+ from npi_mcp.npi_client import NPIClient
4
+ from npi_mcp.models import ProviderSummary, ProviderDetail
5
+
6
+ # Mock data
7
+ MOCK_SEARCH_RESPONSE_IND = {
8
+ "result_count": 1,
9
+ "results": [
10
+ {
11
+ "number": "1234567890",
12
+ "basic": {
13
+ "first_name": "John",
14
+ "last_name": "Doe",
15
+ "credential": "MD"
16
+ },
17
+ "enumeration_type": "NPI-1",
18
+ "taxonomies": [
19
+ {"code": "207RC0000X", "desc": "Cardiology", "primary": True}
20
+ ],
21
+ "addresses": [
22
+ {
23
+ "address_purpose": "LOCATION",
24
+ "address_1": "123 Main St",
25
+ "city": "Anytown",
26
+ "state": "CA",
27
+ "postal_code": "90210",
28
+ "country_code": "US"
29
+ }
30
+ ]
31
+ }
32
+ ]
33
+ }
34
+
35
+ MOCK_SEARCH_RESPONSE_ORG = {
36
+ "result_count": 1,
37
+ "results": [
38
+ {
39
+ "number": "9876543210",
40
+ "basic": {
41
+ "organization_name": "General Hospital"
42
+ },
43
+ "enumeration_type": "NPI-2",
44
+ "taxonomies": [],
45
+ "addresses": [
46
+ {
47
+ "address_purpose": "LOCATION",
48
+ "address_1": "456 Health Blvd",
49
+ "city": "Metropolis",
50
+ "state": "NY",
51
+ "postal_code": "10001",
52
+ "country_code": "US"
53
+ }
54
+ ]
55
+ }
56
+ ]
57
+ }
58
+
59
+ import httpx
60
+
61
+ @pytest.mark.asyncio
62
+ async def test_search_providers_individual(mocker):
63
+ # Mock httpx client
64
+ # Note: raise_for_status requires a request object
65
+ resp = Response(200, json=MOCK_SEARCH_RESPONSE_IND)
66
+ resp._request = httpx.Request("GET", "https://mock")
67
+ mock_get = mocker.patch("httpx.AsyncClient.get", return_value=resp)
68
+
69
+ client = NPIClient()
70
+ results = await client.search_providers(query="John Doe")
71
+
72
+ assert len(results) >= 1
73
+ p = results[0]
74
+ assert p.full_name == "John Doe, MD"
75
+ assert p.enumeration_type == "INDIVIDUAL"
76
+ assert p.primary_address.city == "Anytown"
77
+
78
+ await client.close()
79
+
80
+ @pytest.mark.asyncio
81
+ async def test_search_providers_org(mocker):
82
+ # Mock httpx client
83
+ resp = Response(200, json=MOCK_SEARCH_RESPONSE_ORG)
84
+ resp._request = httpx.Request("GET", "https://mock")
85
+ mock_get = mocker.patch("httpx.AsyncClient.get", return_value=resp)
86
+
87
+ client = NPIClient()
88
+ results = await client.search_providers(query="General Hospital")
89
+
90
+ assert len(results) >= 1
91
+ p = results[0]
92
+ assert p.full_name == "General Hospital"
93
+ assert p.enumeration_type == "ORGANIZATION"
94
+
95
+ await client.close()
96
+
97
+ @pytest.mark.asyncio
98
+ async def test_get_provider_by_npi(mocker):
99
+ resp = Response(200, json=MOCK_SEARCH_RESPONSE_IND)
100
+ resp._request = httpx.Request("GET", "https://mock")
101
+ mock_get = mocker.patch("httpx.AsyncClient.get", return_value=resp)
102
+
103
+ client = NPIClient()
104
+ result = await client.get_provider_by_npi("1234567890")
105
+
106
+ assert result is not None
107
+ assert result.npi == "1234567890"
108
+ assert result.full_name == "John Doe, MD"
109
+ assert len(result.taxonomies) == 1
110
+ assert result.taxonomies[0].code == "207RC0000X"
111
+
112
+ await client.close()
uv.lock ADDED
The diff for this file is too large to render. See raw diff