fix: unblock the MCP session, and authenticate to the AI gateway with OIDC

Two things, both discovered by instrumenting the failure rather than trusting
the message it printed.

The MCP session never failed on protocol. macOS resolves any `.local` name
through mDNS first and takes a flat ~5.0s doing it, which sits exactly on
httpx's 5s default connect timeout; fastmcp reported the resulting
ConnectTimeout only as "Failed to initialize server session". Earlier tests
used `curl --resolve`, which bypasses DNS entirely and made it look like DNS
had been eliminated. Settings.http_timeout (60s) now covers the shared client,
the client fastmcp builds, and MCPToolset's init_timeout. list-products,
get-product and create-product now enumerate over the real gateway.

Inference used a static "unused" api_key against a gateway whose
openid-connect plugin requires audience ["llm-proxy"]. The model call now
carries a token exchanged for llm-proxy-audience, so it is attributable to the
same (agent, end user) pair as every tool call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Stéphane Tailland
2026-08-28 11:01:18 +02:00
parent 4fd6e21a40
commit 0bc06512f5
6 changed files with 59 additions and 6 deletions
+15
View File
@@ -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.
+3
View File
@@ -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",
]
+1 -1
View File
@@ -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)),
+14 -4
View File
@@ -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
+13 -1
View File
@@ -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,
+13
View File
@@ -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