Skip to content

Commit 9d7a723

Browse files
authored
Merge pull request #198 from Qiskit/feat/197-code-assistant-httpx-lifespan
feat(code-assistant): add lifespan for httpx client lifecycle management
2 parents 4c8919d + 6b2ecf5 commit 9d7a723

4 files changed

Lines changed: 69 additions & 33 deletions

File tree

qiskit-code-assistant-mcp-server/src/qiskit_code_assistant_mcp_server/server.py

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,17 @@
2323
"""
2424

2525
import logging
26+
from collections.abc import AsyncIterator
27+
from contextlib import asynccontextmanager
2628
from typing import Any
2729

30+
import httpx
2831
from fastmcp import FastMCP
2932

3033
from qiskit_code_assistant_mcp_server.constants import (
3134
QCA_MCP_DEBUG_LEVEL,
35+
QCA_REQUEST_TIMEOUT,
36+
QCA_TOOL_X_CALLER,
3237
validate_configuration,
3338
)
3439
from qiskit_code_assistant_mcp_server.qca import (
@@ -41,13 +46,36 @@
4146
get_service_status,
4247
list_models,
4348
)
44-
from qiskit_code_assistant_mcp_server.utils import close_http_client
49+
from qiskit_code_assistant_mcp_server.utils import (
50+
_get_token,
51+
clear_http_client,
52+
set_http_client,
53+
)
4554

4655

4756
# Configure logging
4857
logging.basicConfig(level=getattr(logging, QCA_MCP_DEBUG_LEVEL, logging.INFO))
4958
logger = logging.getLogger(__name__)
5059

60+
61+
@asynccontextmanager
62+
async def lifespan(server: FastMCP) -> AsyncIterator[None]:
63+
"""Manage the httpx client lifecycle."""
64+
headers = {
65+
"x-caller": QCA_TOOL_X_CALLER,
66+
"Accept": "application/json",
67+
"Authorization": f"Bearer {_get_token()}",
68+
}
69+
async with httpx.AsyncClient(
70+
headers=headers,
71+
timeout=httpx.Timeout(QCA_REQUEST_TIMEOUT),
72+
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
73+
) as client:
74+
set_http_client(client)
75+
yield
76+
clear_http_client()
77+
78+
5179
# Initialize FastMCP server
5280
mcp = FastMCP(
5381
"Qiskit Code Assistant",
@@ -72,6 +100,7 @@
72100
Browse qca://models to discover available models and qca://status to check \
73101
service health.\
74102
""",
103+
lifespan=lifespan,
75104
)
76105

77106
logger.info("Qiskit Code Assistant MCP Server initialized")
@@ -267,28 +296,8 @@ def setup_model(model_id: str) -> str:
267296

268297

269298
if __name__ == "__main__":
270-
import atexit
271-
272299
logger.info("Starting Qiskit Code Assistant MCP Server")
273-
274-
# Register cleanup function
275-
def cleanup() -> None:
276-
import asyncio
277-
278-
try:
279-
asyncio.run(close_http_client())
280-
logger.info("HTTP client closed successfully")
281-
except Exception as e:
282-
logger.error(f"Error closing HTTP client: {e}")
283-
284-
atexit.register(cleanup)
285-
286-
try:
287-
mcp.run(transport="stdio", show_banner=False)
288-
except KeyboardInterrupt:
289-
logger.info("Server interrupted, shutting down...")
290-
finally:
291-
cleanup()
300+
mcp.run(transport="stdio", show_banner=False)
292301

293302

294303
# Assisted by watsonx Code Assistant

qiskit-code-assistant-mcp-server/src/qiskit_code_assistant_mcp_server/utils.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,18 @@ def _get_token() -> str:
155155
_client: httpx.AsyncClient | None = None
156156

157157

158+
def set_http_client(client: httpx.AsyncClient) -> None:
159+
"""Set the shared HTTP client (called by server lifespan)."""
160+
global _client
161+
_client = client
162+
163+
164+
def clear_http_client() -> None:
165+
"""Clear the shared HTTP client (called on server shutdown)."""
166+
global _client
167+
_client = None
168+
169+
158170
def get_http_client() -> httpx.AsyncClient:
159171
"""Get or create the shared HTTP client."""
160172
global _client

qiskit-code-assistant-mcp-server/tests/conftest.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
"""Test configuration and fixtures for Qiskit Code Assistant MCP Server tests."""
1414

15+
import contextlib
1516
import os
1617
from unittest.mock import patch
1718

@@ -42,21 +43,16 @@ async def reset_http_client():
4243

4344
# Reset before test - force clear without trying to close
4445
# (might already be closed or in invalid state)
45-
utils_module._client = None
46+
utils_module.clear_http_client()
4647
utils_module._cached_token = None # Reset cached token for fresh state
4748
utils_module._token_checked = False # Reset token check flag
4849

4950
yield
5051

5152
# Reset after test - properly close if still open
52-
if utils_module._client is not None:
53-
try:
54-
if not utils_module._client.is_closed:
55-
await utils_module._client.aclose()
56-
except Exception:
57-
pass # Ignore errors during cleanup
58-
finally:
59-
utils_module._client = None
53+
with contextlib.suppress(Exception):
54+
await utils_module.close_http_client()
55+
utils_module.clear_http_client() # Ensure cleared even if close failed
6056
utils_module._cached_token = None # Reset cached token after test
6157
utils_module._token_checked = False # Reset token check flag after test
6258

@@ -85,13 +81,14 @@ async def http_client_for_tests(mock_env_vars):
8581
"Accept": "application/json",
8682
"Authorization": f"Bearer {token}",
8783
}
88-
utils_module._client = httpx.AsyncClient(
84+
client = httpx.AsyncClient(
8985
headers=headers,
9086
timeout=httpx.Timeout(test_timeout),
9187
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
9288
)
89+
utils_module.set_http_client(client)
9390

94-
yield utils_module._client
91+
yield client
9592

9693
# Cleanup is handled by reset_http_client autouse fixture
9794

qiskit-code-assistant-mcp-server/tests/test_utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@
2020
import respx
2121

2222
from qiskit_code_assistant_mcp_server.utils import (
23+
clear_http_client,
2324
close_http_client,
2425
get_error_message,
2526
get_http_client,
2627
make_qca_request,
28+
set_http_client,
2729
)
2830

2931

@@ -181,6 +183,22 @@ async def test_close_http_client(self, mock_env_vars, mock_async_client):
181183
await close_http_client()
182184
assert client.is_closed
183185

186+
@pytest.mark.asyncio
187+
async def test_set_http_client(self, mock_async_client):
188+
"""Test setting the shared HTTP client."""
189+
set_http_client(mock_async_client)
190+
client = get_http_client()
191+
assert client is mock_async_client
192+
193+
def test_clear_http_client(self, mock_env_vars, mock_async_client):
194+
"""Test clearing the shared HTTP client."""
195+
set_http_client(mock_async_client)
196+
clear_http_client()
197+
# After clearing, get_http_client should create a new instance
198+
with patch.object(httpx, "AsyncClient", return_value=mock_async_client):
199+
client = get_http_client()
200+
assert client is mock_async_client
201+
184202
@pytest.mark.asyncio
185203
async def test_get_client_after_close(self, mock_env_vars):
186204
"""Test getting client after closure creates new instance."""

0 commit comments

Comments
 (0)