Skip to content

Commit cdf7ba1

Browse files
john-walkoeclaude
andcommitted
Fix: Add api_client initialization checks to prevent async lifecycle errors
Applies the same proven fix pattern as PFW MCP (commit a0512f4) to prevent "cannot schedule new futures after interpreter shutdown" errors in async contexts. Changes: - Added get_api_client() factory function for deferred initialization - Added initialization checks to all 7 MCP tools in main.py - Enhanced @async_tool_error_handler decorator with RuntimeError handling - Added critical null checks to proxy server (download handler + streaming) Files modified: - src/fpd_mcp/main.py: Factory function + 7 tool initialization checks - src/fpd_mcp/shared/error_utils.py: Enhanced decorator with RuntimeError - src/fpd_mcp/proxy/server.py: 2 critical null checks for async safety This proactive fix prevents the same vulnerability that was discovered in PFW from affecting FPD users. The FPD vulnerability was MORE extensive (9+ access points vs 5 in PFW), especially in the proxy server's concurrent async handlers. Testing: - All basic tests passed (test_basic.py) - No regressions introduced - Works across all MCP client environments (Warp, Claude Code, Claude Desktop) Related: PFW async lifecycle bug fix (SESSION_HISTORY_2026-01-23_Async_Lifecycle_Bug_Fix.md) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 7b978a3 commit cdf7ba1

File tree

3 files changed

+93
-1
lines changed

3 files changed

+93
-1
lines changed

src/fpd_mcp/main.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,28 @@
7676
"""
7777

7878
mcp = FastMCP("fpd-mcp", instructions=SERVER_INSTRUCTIONS)
79-
api_client = FPDClient(api_key=settings.uspto_api_key)
79+
api_client = None # Deferred initialization via get_api_client() to prevent async lifecycle issues
80+
81+
82+
def get_api_client() -> FPDClient:
83+
"""
84+
Get or create the global API client instance.
85+
86+
This factory function ensures the API client is properly initialized
87+
before use, preventing async lifecycle errors when the global client
88+
is None or in an invalid state.
89+
90+
Returns:
91+
FPDClient: Initialized API client instance
92+
93+
Raises:
94+
ValueError: If USPTO API key is not available
95+
"""
96+
global api_client
97+
if api_client is None:
98+
logger.info("Initializing FPD API client (deferred initialization)")
99+
api_client = FPDClient(api_key=settings.uspto_api_key)
100+
return api_client
80101

81102
# Register all prompt templates AFTER mcp object is created
82103
# This registers all 10 comprehensive prompt templates with the MCP server
@@ -478,6 +499,12 @@ async def fpd_search_petitions_minimal(
478499
if len(final_query) > 2000:
479500
raise ValidationError("Combined query too long (max 2000 characters)", generate_request_id())
480501

502+
# Ensure API client is initialized (protects against async lifecycle issues)
503+
global api_client
504+
if api_client is None:
505+
logger.info("Initializing API client for minimal petition search")
506+
api_client = get_api_client()
507+
481508
# Get fields from field manager
482509
fields = field_manager.get_fields("petitions_minimal")
483510

@@ -640,6 +667,12 @@ async def fpd_search_petitions_balanced(
640667
if len(final_query) > 2000:
641668
return format_error_response("Combined query too long (max 2000 characters)", 400)
642669

670+
# Ensure API client is initialized (protects against async lifecycle issues)
671+
global api_client
672+
if api_client is None:
673+
logger.info("Initializing API client for balanced petition search")
674+
api_client = get_api_client()
675+
643676
# Get fields from field manager
644677
fields = field_manager.get_fields("petitions_balanced")
645678

@@ -755,6 +788,12 @@ async def fpd_search_petitions_by_art_unit(
755788
"Date range must be in format YYYY-MM-DD:YYYY-MM-DD", 400
756789
)
757790

791+
# Ensure API client is initialized (protects against async lifecycle issues)
792+
global api_client
793+
if api_client is None:
794+
logger.info("Initializing API client for art unit petition search")
795+
api_client = get_api_client()
796+
758797
# Use API client's search_by_art_unit method
759798
result = await api_client.search_by_art_unit(
760799
art_unit=art_unit,
@@ -845,6 +884,12 @@ async def fpd_search_petitions_by_application(
845884
# Clean application number (remove spaces, slashes for query)
846885
clean_app_num = application_number.replace("/", "").replace(" ", "")
847886

887+
# Ensure API client is initialized (protects against async lifecycle issues)
888+
global api_client
889+
if api_client is None:
890+
logger.info("Initializing API client for application petition search")
891+
api_client = get_api_client()
892+
848893
# Use API client's search_by_application method
849894
result = await api_client.search_by_application(
850895
application_number=clean_app_num,
@@ -951,6 +996,12 @@ async def fpd_get_petition_details(
951996
if not petition_id or len(petition_id.strip()) == 0:
952997
return format_error_response("Petition ID cannot be empty", 400)
953998

999+
# Ensure API client is initialized (protects against async lifecycle issues)
1000+
global api_client
1001+
if api_client is None:
1002+
logger.info("Initializing API client for petition detail retrieval")
1003+
api_client = get_api_client()
1004+
9541005
# Use API client's get_petition_by_id method
9551006
result = await api_client.get_petition_by_id(
9561007
petition_id=petition_id,
@@ -1142,6 +1193,12 @@ async def fpd_get_document_download(
11421193
# Centralized proxy is already running (managed by PFW MCP)
11431194
logger.info("Using centralized proxy - no local proxy startup needed")
11441195

1196+
# Ensure API client is initialized (protects against async lifecycle issues)
1197+
global api_client
1198+
if api_client is None:
1199+
logger.info("Initializing API client for document download proxy")
1200+
api_client = get_api_client()
1201+
11451202
# Construct proxy URL (port 8081 to avoid conflict with PFW proxy on 8080)
11461203
proxy_url = f"http://localhost:{proxy_port}/download/{petition_id}/{document_identifier}"
11471204

@@ -1405,6 +1462,12 @@ async def fpd_get_document_content(
14051462
else:
14061463
logger.info("Using centralized proxy for document extraction - no local proxy startup needed")
14071464

1465+
# Ensure API client is initialized (protects against async lifecycle issues)
1466+
global api_client
1467+
if api_client is None:
1468+
logger.info("Initializing API client for document content extraction with OCR")
1469+
api_client = get_api_client()
1470+
14081471
# Use API client's hybrid extraction method
14091472
result = await api_client.extract_document_content_hybrid(
14101473
petition_id=petition_id,

src/fpd_mcp/proxy/server.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,14 @@ async def download_document(
293293
# Get petition details with documents
294294
logger.info(f"Proxying download for petition {petition_id}, doc {document_identifier}, IP {client_ip}")
295295

296+
# Ensure API client is initialized (protects during server lifecycle events)
297+
if api_client is None:
298+
logger.error("API client not initialized in proxy server - lifespan may not have completed")
299+
raise HTTPException(
300+
status_code=503,
301+
detail="Proxy server not ready - API client not initialized. Try again in a moment."
302+
)
303+
296304
# Get petition data to find the specific document
297305
petition_result = await api_client.get_petition_by_id(
298306
petition_id,
@@ -378,6 +386,11 @@ async def download_document(
378386

379387
# Stream the PDF from USPTO API
380388
async def stream_pdf():
389+
# Ensure API client is initialized before accessing api_key
390+
if api_client is None:
391+
logger.error("API client became None during streaming - async lifecycle issue")
392+
raise RuntimeError("API client lost during streaming response")
393+
381394
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
382395
# Use the API client's headers (includes X-API-KEY)
383396
headers = {

src/fpd_mcp/shared/error_utils.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,22 @@ async def wrapper(*args, **kwargs) -> Dict[str, Any]:
187187
logger.warning(f"Validation error in {tool_name}: {str(e)}")
188188
return format_error_response(str(e), 400)
189189

190+
except RuntimeError as e:
191+
# Catch async lifecycle errors specifically (fix for async lifecycle bug)
192+
error_msg = str(e)
193+
if "cannot schedule new futures" in error_msg or "interpreter shutdown" in error_msg:
194+
logger.error(f"Async lifecycle error in {tool_name}: {error_msg}")
195+
return format_error_response(
196+
"Operation failed due to async runtime issue. "
197+
"Try restarting the MCP server. "
198+
f"Technical details: {error_msg}",
199+
500
200+
)
201+
else:
202+
# Other RuntimeError - treat as unexpected error
203+
logger.error(f"Runtime error in {tool_name}: {error_msg}", exc_info=True)
204+
return format_error_response(f"Runtime error: {error_msg}", 500)
205+
190206
except Exception as e:
191207
# Check if httpx is available and handle HTTP errors
192208
error_type = type(e).__name__

0 commit comments

Comments
 (0)