diff --git a/CLAUDE.md b/CLAUDE.md index 0e2a34e..96e5fdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,3 +53,18 @@ blockers. This file only covers what is specific to this agent. - **A2A calls return answers, not data.** Expose narrow decision-shaped skills, never a generic `query`. - Deliverables in English. + +## Running it against the lab from macOS + +macOS resolves every `.local` name through mDNS before falling back to +`/etc/hosts`, and that costs a **flat ~5.0s per hostname** — `dscacheutil -q host +-a name catalog-dev.sttlab.local` shows it, and so does `curl -w +%{time_namelookup}`. httpx's default connect timeout is 5s, so the very first +request to each host lands on the boundary and fails intermittently. fastmcp +reports that as `Failed to initialize server session` and swallows the cause, +which is why it read as an MCP protocol problem for a long time. `curl --resolve` +hides it completely — do not use `--resolve` to "prove" DNS is fine. + +`Settings.http_timeout` (60s) is the fix, applied to the shared httpx2 client, +to the client fastmcp builds, and to `MCPToolset(init_timeout=...)`. In the +cluster these names resolve instantly and the timeout never binds. diff --git a/pyproject.toml b/pyproject.toml index ef079b6..1eb18ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,9 @@ dependencies = [ # OpenAI protocol, [mcp] for the MCP client. "pydantic-ai-slim[openai,mcp]>=2.35.3,<3", "httpx2>=2.12.0,<3", + # httpx 0.x as well, and not redundantly: fastmcp builds its own + # transport client and hands us an httpx.AsyncClient to configure. + "httpx>=0.28.1,<1", "pydantic-settings>=2.7", "uvicorn>=0.52.4", ] diff --git a/src/catalogue_agent/a2a_server.py b/src/catalogue_agent/a2a_server.py index 6b26608..a4e0fe3 100644 --- a/src/catalogue_agent/a2a_server.py +++ b/src/catalogue_agent/a2a_server.py @@ -157,7 +157,7 @@ def _subject_token(context: RequestContext) -> str | None: def build_app(settings: Settings) -> Starlette: # httpx2, not httpx: PydanticAI deprecates httpx clients for # OpenAI-compatible providers and drops them in v3. - http_client = httpx2.AsyncClient(verify=settings.verify, timeout=60.0) + http_client = httpx2.AsyncClient(verify=settings.verify, timeout=settings.http_timeout) card = build_agent_card(settings) handler = DefaultRequestHandlerV2( agent_executor=CatalogueExecutor(CatalogueAgent(settings, http_client)), diff --git a/src/catalogue_agent/agent.py b/src/catalogue_agent/agent.py index d11a952..ae807f6 100644 --- a/src/catalogue_agent/agent.py +++ b/src/catalogue_agent/agent.py @@ -31,19 +31,27 @@ that writes are not enabled yet. """ -def build_model(settings: Settings, http_client: httpx2.AsyncClient) -> OpenAIChatModel: +def build_model( + settings: Settings, http_client: httpx2.AsyncClient, access_token: str +) -> OpenAIChatModel: """Inference through the Kong AI gateway, never a provider directly. The gateway speaks the OpenAI protocol, hence this provider. Going straight to OpenRouter would bypass the token rate limits, the prompt guards and the audit trail that records what was sent to the model — all of which are the point of routing inference through Kong. + + `api_key` carries an exchanged token, not a static secret: the provider + sends it as `Authorization: Bearer`, which is exactly what the gateway's + openid-connect plugin reads. So the model call is attributable to the same + (agent, end user) pair as the tool calls, and is rate-limited per client + rather than per application. """ return OpenAIChatModel( settings.llm_model, provider=OpenAIProvider( base_url=settings.llm_base_url, - api_key=settings.llm_api_key, + api_key=access_token, http_client=http_client, ), ) @@ -60,7 +68,7 @@ class CatalogueAgent: client_id=settings.client_id, client_secret=settings.client_secret, ) - self._model = build_model(settings, http_client) + self._http_client = http_client async def run(self, prompt: str, subject_token: str) -> str: """One turn: exchange tokens, build the toolsets, answer, drop them. @@ -70,7 +78,9 @@ class CatalogueAgent: agent holding a long-lived token is not. """ toolsets = await build_toolsets(self._settings, self._exchanger, subject_token) - agent = Agent(self._model, instructions=SYSTEM_PROMPT, toolsets=toolsets) + llm_token = await self._exchanger.exchange(subject_token, self._settings.llm_scope) + model = build_model(self._settings, self._http_client, llm_token) + agent = Agent(model, instructions=SYSTEM_PROMPT, toolsets=toolsets) async with agent: result = await agent.run(prompt) return result.output diff --git a/src/catalogue_agent/config.py b/src/catalogue_agent/config.py index 55b828d..cf674af 100644 --- a/src/catalogue_agent/config.py +++ b/src/catalogue_agent/config.py @@ -40,12 +40,24 @@ class Settings(BaseSettings): # protocol, which is why the OpenAI provider is used against it. llm_base_url: str = "https://llm-dev.sttlab.local/llm/v1" llm_model: str = "google/gemini-3.7-flash" - llm_api_key: str = Field("unused", description="Kong authenticates the caller, not this key") + # The gateway's openid-connect plugin requires this audience. The bearer it + # validates is an exchanged token, not a static key, so inference carries + # the same `azp`/`sub` pair as every tool call and lands in the same audit + # trail. The key-auth path alongside it exists only for callers that cannot + # do OIDC outbound (LibreChat) and proves nothing about the end user. + llm_scope: str = "llm-proxy-audience" # --- transport ---------------------------------------------------------- # The lab uses a private CA (sttlab-local-ca). Point this at the CA bundle # rather than disabling verification. ca_bundle: str | None = None + # Generous on purpose, and it is not paranoia. macOS resolves any `.local` + # name through mDNS before falling back to /etc/hosts, and that costs a flat + # ~5s per hostname — right on httpx's 5s default connect timeout. The result + # is an intermittent ConnectTimeout that fastmcp reports only as + # "Failed to initialize server session", with the real cause swallowed. In + # the cluster the names resolve instantly and this timeout never binds. + http_timeout: float = 60.0 host: str = "0.0.0.0" port: int = 8080 # Advertised in the Agent Card, so it must be the URL other agents reach, diff --git a/src/catalogue_agent/mcp_clients.py b/src/catalogue_agent/mcp_clients.py index d4c8ee3..55a01cb 100644 --- a/src/catalogue_agent/mcp_clients.py +++ b/src/catalogue_agent/mcp_clients.py @@ -14,7 +14,9 @@ issues #172/#194/#235, CrewAI's MCPServerAdapter) has the same answer. from __future__ import annotations from dataclasses import dataclass +from typing import Any +import httpx from pydantic_ai.mcp import MCPToolset, StreamableHttpTransport from .config import Settings @@ -55,6 +57,15 @@ async def build_toolsets( injection that talks the agent into reaching the wrong domain fails at Kong rather than succeeding quietly. """ + def client_factory(**kwargs: Any) -> httpx.AsyncClient: + # fastmcp builds this client itself and would otherwise take httpx's + # 5s default connect timeout. See Settings.http_timeout for why that is + # not survivable against `.local` names, and what the failure looks like + # when it bites: a bare "Failed to initialize server session". + kwargs["verify"] = settings.verify + kwargs["timeout"] = httpx.Timeout(settings.http_timeout, connect=settings.http_timeout) + return httpx.AsyncClient(**kwargs) + toolsets: list[MCPToolset] = [] for target in targets(settings): token = await exchanger.exchange(subject_token, target.audience_scope) @@ -64,8 +75,10 @@ async def build_toolsets( url=target.url, headers={"Authorization": f"Bearer {token}"}, verify=settings.verify, + httpx_client_factory=client_factory, ), id=target.name, + init_timeout=settings.http_timeout, ) ) return toolsets