Spaces:
Sleeping
Sleeping
google-labs-jules[bot]
commited on
Commit
·
87dc528
0
Parent(s):
Implement npi_mcp server wrapper for NPPES NPI Registry API
Browse filesFeatures:
- 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 +23 -0
- curl_example.sh +14 -0
- explanation.md +23 -0
- pyproject.toml +24 -0
- src/npi_mcp/__init__.py +0 -0
- src/npi_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
- src/npi_mcp/__pycache__/main.cpython-312.pyc +0 -0
- src/npi_mcp/__pycache__/mcp_tools.cpython-312.pyc +0 -0
- src/npi_mcp/__pycache__/models.cpython-312.pyc +0 -0
- src/npi_mcp/__pycache__/npi_client.cpython-312.pyc +0 -0
- src/npi_mcp/main.py +121 -0
- src/npi_mcp/mcp_tools.py +61 -0
- src/npi_mcp/models.py +48 -0
- src/npi_mcp/npi_client.py +211 -0
- tests/__pycache__/test_npi_mcp.cpython-312-pytest-8.4.2.pyc +0 -0
- tests/__pycache__/test_npi_mcp.cpython-312-pytest-9.0.1.pyc +0 -0
- tests/test_npi_mcp.py +112 -0
- uv.lock +0 -0
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
|
|
|