Skip to content

Commit 57a6ac6

Browse files
committed
Add v3 service abstractions and MCP endpoint config
Introduces a new 'services' module under v3/common with BaseAPIService, MCPService, and FoundryService for async HTTP and Azure AI Foundry interactions. Updates AppConfig to support an optional MCP server endpoint and fixes credential method references. These changes provide a foundation for modular service access and easier integration with MCP and Foundry APIs.
1 parent 8036305 commit 57a6ac6

5 files changed

Lines changed: 210 additions & 3 deletions

File tree

src/backend/common/config/app_config.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,17 @@ def __init__(self):
6767
# Azure Search settings
6868
self.AZURE_SEARCH_ENDPOINT = self._get_optional("AZURE_SEARCH_ENDPOINT")
6969

70+
# Optional MCP server endpoint (for local MCP server or remote)
71+
# Example: http://127.0.0.1:8000/mcp
72+
self.MCP_SERVER_ENDPOINT = self._get_optional("MCP_SERVER_ENDPOINT")
73+
7074
# Cached clients and resources
7175
self._azure_credentials = None
7276
self._cosmos_client = None
7377
self._cosmos_database = None
7478
self._ai_project_client = None
7579

76-
def get_azure_credential(cself, client_id=None):
80+
def get_azure_credential(self, client_id=None):
7781
"""
7882
Returns an Azure credential based on the application environment.
7983
@@ -96,7 +100,7 @@ def get_azure_credential(cself, client_id=None):
96100
def get_azure_credentials(self):
97101
"""Retrieve Azure credentials, either from environment variables or managed identity."""
98102
if self._azure_credentials is None:
99-
self._azure_credentials = get_azure_credential()
103+
self._azure_credentials = self.get_azure_credential()
100104
return self._azure_credentials
101105

102106
async def get_access_token(self) -> str:
@@ -168,7 +172,7 @@ def get_ai_project_client(self):
168172
return self._ai_project_client
169173

170174
try:
171-
credential = get_azure_credential()
175+
credential = self.get_azure_credential()
172176
if credential is None:
173177
raise RuntimeError(
174178
"Unable to acquire Azure credentials; ensure Managed Identity is configured"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Service abstractions for v3.
2+
3+
Exports:
4+
- BaseAPIService: minimal async HTTP wrapper using endpoints from AppConfig
5+
- MCPService: service targeting a local/remote MCP server
6+
- FoundryService: helper around Azure AI Foundry (AIProjectClient)
7+
"""
8+
9+
from .base_api_service import BaseAPIService
10+
from .mcp_service import MCPService
11+
from .foundry_service import FoundryService
12+
13+
__all__ = [
14+
"BaseAPIService",
15+
"MCPService",
16+
"FoundryService",
17+
]
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import asyncio
2+
from typing import Any, Dict, Optional, Union
3+
4+
import aiohttp
5+
6+
from common.config.app_config import config
7+
8+
9+
class BaseAPIService:
10+
"""Minimal async HTTP API service.
11+
12+
- Reads base endpoints from AppConfig using `from_config` factory.
13+
- Provides simple GET/POST helpers with JSON payloads.
14+
- Designed to be subclassed (e.g., MCPService, FoundryService).
15+
"""
16+
17+
def __init__(
18+
self,
19+
base_url: str,
20+
*,
21+
default_headers: Optional[Dict[str, str]] = None,
22+
timeout_seconds: int = 30,
23+
session: Optional[aiohttp.ClientSession] = None,
24+
) -> None:
25+
if not base_url:
26+
raise ValueError("base_url is required")
27+
self.base_url = base_url.rstrip("/")
28+
self.default_headers = default_headers or {}
29+
self.timeout = aiohttp.ClientTimeout(total=timeout_seconds)
30+
self._session_external = session is not None
31+
self._session: Optional[aiohttp.ClientSession] = session
32+
33+
@classmethod
34+
def from_config(
35+
cls,
36+
endpoint_attr: str,
37+
*,
38+
default: Optional[str] = None,
39+
**kwargs: Any,
40+
) -> "BaseAPIService":
41+
"""Create a service using an endpoint attribute from AppConfig.
42+
43+
Args:
44+
endpoint_attr: Name of the attribute on AppConfig (e.g., 'AZURE_AI_AGENT_ENDPOINT').
45+
default: Optional default if attribute missing or empty.
46+
**kwargs: Passed through to the constructor.
47+
"""
48+
base_url = getattr(config, endpoint_attr, None) or default
49+
if not base_url:
50+
raise ValueError(
51+
f"Endpoint '{endpoint_attr}' not configured in AppConfig and no default provided"
52+
)
53+
return cls(base_url, **kwargs)
54+
55+
async def _ensure_session(self) -> aiohttp.ClientSession:
56+
if self._session is None or self._session.closed:
57+
self._session = aiohttp.ClientSession(timeout=self.timeout)
58+
return self._session
59+
60+
def _url(self, path: str) -> str:
61+
path = path or ""
62+
if not path:
63+
return self.base_url
64+
return f"{self.base_url}/{path.lstrip('/')}"
65+
66+
async def _request(
67+
self,
68+
method: str,
69+
path: str = "",
70+
*,
71+
headers: Optional[Dict[str, str]] = None,
72+
params: Optional[Dict[str, Union[str, int, float]]] = None,
73+
json: Optional[Dict[str, Any]] = None,
74+
) -> aiohttp.ClientResponse:
75+
session = await self._ensure_session()
76+
url = self._url(path)
77+
merged_headers = {**self.default_headers, **(headers or {})}
78+
return await session.request(
79+
method.upper(), url, headers=merged_headers, params=params, json=json
80+
)
81+
82+
async def get_json(
83+
self,
84+
path: str = "",
85+
*,
86+
headers: Optional[Dict[str, str]] = None,
87+
params: Optional[Dict[str, Union[str, int, float]]] = None,
88+
) -> Any:
89+
resp = await self._request("GET", path, headers=headers, params=params)
90+
resp.raise_for_status()
91+
return await resp.json()
92+
93+
async def post_json(
94+
self,
95+
path: str = "",
96+
*,
97+
headers: Optional[Dict[str, str]] = None,
98+
params: Optional[Dict[str, Union[str, int, float]]] = None,
99+
json: Optional[Dict[str, Any]] = None,
100+
) -> Any:
101+
resp = await self._request(
102+
"POST", path, headers=headers, params=params, json=json
103+
)
104+
resp.raise_for_status()
105+
return await resp.json()
106+
107+
async def close(self) -> None:
108+
if self._session and not self._session.closed and not self._session_external:
109+
await self._session.close()
110+
111+
async def __aenter__(self) -> "BaseAPIService":
112+
await self._ensure_session()
113+
return self
114+
115+
async def __aexit__(self, exc_type, exc, tb) -> None:
116+
await self.close()
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from typing import Any, Dict
2+
3+
from azure.ai.projects.aio import AIProjectClient
4+
5+
from common.config.app_config import config
6+
7+
8+
class FoundryService:
9+
"""Helper around Azure AI Foundry's AIProjectClient.
10+
11+
Uses AppConfig.get_ai_project_client() to obtain a properly configured
12+
asynchronous client. Provides a small set of convenience methods and
13+
can be extended for specific project operations.
14+
"""
15+
16+
def __init__(self, client: AIProjectClient | None = None) -> None:
17+
self._client = client
18+
19+
async def get_client(self) -> AIProjectClient:
20+
if self._client is None:
21+
self._client = config.get_ai_project_client()
22+
return self._client
23+
24+
# Example convenience wrappers – adjust as your project needs evolve
25+
async def list_connections(self) -> list[Dict[str, Any]]:
26+
client = await self.get_client()
27+
conns = await client.connections.list()
28+
return [c.as_dict() if hasattr(c, "as_dict") else dict(c) for c in conns]
29+
30+
async def get_connection(self, name: str) -> Dict[str, Any]:
31+
client = await self.get_client()
32+
conn = await client.connections.get(name=name)
33+
return conn.as_dict() if hasattr(conn, "as_dict") else dict(conn)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from typing import Any, Dict, Optional
2+
3+
from common.config.app_config import config
4+
5+
from .base_api_service import BaseAPIService
6+
7+
8+
class MCPService(BaseAPIService):
9+
"""Service for interacting with an MCP server.
10+
11+
Base URL is taken from AppConfig.MCP_SERVER_ENDPOINT if present,
12+
otherwise falls back to v3 MCP default in settings or localhost.
13+
"""
14+
15+
def __init__(self, base_url: str, *, token: Optional[str] = None, **kwargs):
16+
headers = {"Content-Type": "application/json"}
17+
if token:
18+
headers["Authorization"] = f"Bearer {token}"
19+
super().__init__(base_url, default_headers=headers, **kwargs)
20+
21+
@classmethod
22+
def from_app_config(cls, **kwargs) -> "MCPService":
23+
# Prefer explicit MCP endpoint if defined; otherwise use the v3 settings default.
24+
endpoint = config.MCP_SERVER_ENDPOINT
25+
if not endpoint:
26+
# fall back to typical local dev default
27+
return None # or handle the error appropriately
28+
token = None # add token retrieval if you enable auth later
29+
return cls(endpoint, token=token, **kwargs)
30+
31+
async def health(self) -> Dict[str, Any]:
32+
return await self.get_json("health")
33+
34+
async def invoke_tool(
35+
self, tool_name: str, payload: Dict[str, Any]
36+
) -> Dict[str, Any]:
37+
return await self.post_json(f"tools/{tool_name}", json=payload)

0 commit comments

Comments
 (0)