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:
@@ -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,
|
- **A2A calls return answers, not data.** Expose narrow decision-shaped skills,
|
||||||
never a generic `query`.
|
never a generic `query`.
|
||||||
- Deliverables in English.
|
- 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.
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ dependencies = [
|
|||||||
# OpenAI protocol, [mcp] for the MCP client.
|
# OpenAI protocol, [mcp] for the MCP client.
|
||||||
"pydantic-ai-slim[openai,mcp]>=2.35.3,<3",
|
"pydantic-ai-slim[openai,mcp]>=2.35.3,<3",
|
||||||
"httpx2>=2.12.0,<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",
|
"pydantic-settings>=2.7",
|
||||||
"uvicorn>=0.52.4",
|
"uvicorn>=0.52.4",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ def _subject_token(context: RequestContext) -> str | None:
|
|||||||
def build_app(settings: Settings) -> Starlette:
|
def build_app(settings: Settings) -> Starlette:
|
||||||
# httpx2, not httpx: PydanticAI deprecates httpx clients for
|
# httpx2, not httpx: PydanticAI deprecates httpx clients for
|
||||||
# OpenAI-compatible providers and drops them in v3.
|
# 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)
|
card = build_agent_card(settings)
|
||||||
handler = DefaultRequestHandlerV2(
|
handler = DefaultRequestHandlerV2(
|
||||||
agent_executor=CatalogueExecutor(CatalogueAgent(settings, http_client)),
|
agent_executor=CatalogueExecutor(CatalogueAgent(settings, http_client)),
|
||||||
|
|||||||
@@ -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.
|
"""Inference through the Kong AI gateway, never a provider directly.
|
||||||
|
|
||||||
The gateway speaks the OpenAI protocol, hence this provider. Going straight
|
The gateway speaks the OpenAI protocol, hence this provider. Going straight
|
||||||
to OpenRouter would bypass the token rate limits, the prompt guards and the
|
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
|
audit trail that records what was sent to the model — all of which are the
|
||||||
point of routing inference through Kong.
|
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(
|
return OpenAIChatModel(
|
||||||
settings.llm_model,
|
settings.llm_model,
|
||||||
provider=OpenAIProvider(
|
provider=OpenAIProvider(
|
||||||
base_url=settings.llm_base_url,
|
base_url=settings.llm_base_url,
|
||||||
api_key=settings.llm_api_key,
|
api_key=access_token,
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -60,7 +68,7 @@ class CatalogueAgent:
|
|||||||
client_id=settings.client_id,
|
client_id=settings.client_id,
|
||||||
client_secret=settings.client_secret,
|
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:
|
async def run(self, prompt: str, subject_token: str) -> str:
|
||||||
"""One turn: exchange tokens, build the toolsets, answer, drop them.
|
"""One turn: exchange tokens, build the toolsets, answer, drop them.
|
||||||
@@ -70,7 +78,9 @@ class CatalogueAgent:
|
|||||||
agent holding a long-lived token is not.
|
agent holding a long-lived token is not.
|
||||||
"""
|
"""
|
||||||
toolsets = await build_toolsets(self._settings, self._exchanger, subject_token)
|
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:
|
async with agent:
|
||||||
result = await agent.run(prompt)
|
result = await agent.run(prompt)
|
||||||
return result.output
|
return result.output
|
||||||
|
|||||||
@@ -40,12 +40,24 @@ class Settings(BaseSettings):
|
|||||||
# protocol, which is why the OpenAI provider is used against it.
|
# protocol, which is why the OpenAI provider is used against it.
|
||||||
llm_base_url: str = "https://llm-dev.sttlab.local/llm/v1"
|
llm_base_url: str = "https://llm-dev.sttlab.local/llm/v1"
|
||||||
llm_model: str = "google/gemini-3.7-flash"
|
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 ----------------------------------------------------------
|
# --- transport ----------------------------------------------------------
|
||||||
# The lab uses a private CA (sttlab-local-ca). Point this at the CA bundle
|
# The lab uses a private CA (sttlab-local-ca). Point this at the CA bundle
|
||||||
# rather than disabling verification.
|
# rather than disabling verification.
|
||||||
ca_bundle: str | None = None
|
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"
|
host: str = "0.0.0.0"
|
||||||
port: int = 8080
|
port: int = 8080
|
||||||
# Advertised in the Agent Card, so it must be the URL other agents reach,
|
# Advertised in the Agent Card, so it must be the URL other agents reach,
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ issues #172/#194/#235, CrewAI's MCPServerAdapter) has the same answer.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
from pydantic_ai.mcp import MCPToolset, StreamableHttpTransport
|
from pydantic_ai.mcp import MCPToolset, StreamableHttpTransport
|
||||||
|
|
||||||
from .config import Settings
|
from .config import Settings
|
||||||
@@ -55,6 +57,15 @@ async def build_toolsets(
|
|||||||
injection that talks the agent into reaching the wrong domain fails at
|
injection that talks the agent into reaching the wrong domain fails at
|
||||||
Kong rather than succeeding quietly.
|
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] = []
|
toolsets: list[MCPToolset] = []
|
||||||
for target in targets(settings):
|
for target in targets(settings):
|
||||||
token = await exchanger.exchange(subject_token, target.audience_scope)
|
token = await exchanger.exchange(subject_token, target.audience_scope)
|
||||||
@@ -64,8 +75,10 @@ async def build_toolsets(
|
|||||||
url=target.url,
|
url=target.url,
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
verify=settings.verify,
|
verify=settings.verify,
|
||||||
|
httpx_client_factory=client_factory,
|
||||||
),
|
),
|
||||||
id=target.name,
|
id=target.name,
|
||||||
|
init_timeout=settings.http_timeout,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return toolsets
|
return toolsets
|
||||||
|
|||||||
Reference in New Issue
Block a user