# classic-models-agent-platform Shared ground for the classic-models agent estate: templates, conventions, and the identity plumbing every agent repeats. Plays the role `kong-k8s-platform` plays for the gateways. **Nothing runs from this repo.** It holds the `${VAR}`-parameterised manifests a domain's Flux `Kustomization` builds, plus the reference documentation below. ## The estate this plugs into The agents replace LibreChat as the MCP client and orchestrator. Everything below them already exists and does not change: Kong gateways per domain, MCP servers served by Kong's `ai-mcp-proxy`, Keycloak, and the domain APIs. | Agent | Repo | Framework | Mode | |---|---|---|---| | Assistant | `classic-models-agent-assistant` | LangGraph | conversational | | Ventes | `classic-models-agent-ventes` | CrewAI | conversational | | Catalogue | `classic-models-agent-catalogue` | PydanticAI + FastA2A | conversational | | Support | `classic-models-agent-support` | Google ADK | conversational | | Approvisionnement | `classic-models-agent-flows` | n8n | autonomous | | Recouvrement | `classic-models-agent-flows` | n8n | autonomous | Five runtimes is deliberate. The point is proving A2A interoperability between stacks that do not know each other; a single framework would prove nothing. ## Topology A2A between agents, MCP between an agent and data. ``` Assistant ──→ every agent (orchestration; holds no scopes itself) Ventes ──→ Approvisionnement, Recouvrement Support ──→ Ventes Catalogue ──→ Approvisionnement ``` **An A2A call returns an answer, not data.** Ventes asks Approvisionnement "can I promise delivery on 15 March?" and receives yes/no — it never gains read access to the catalog domain. Agent Cards therefore expose narrow, decision-shaped skills (`check_availability`, `check_creditworthiness`), never a generic `query`. An agent that exposes "ask me anything about my domain" has become a data proxy and defeats the scoping. The Assistant holds **no scopes at all**. It is the component that sees raw user text, so it is the prompt-injection target, and it must be unable to exfiltrate anything. ## Identity Each agent is its own Keycloak client. That is what makes the actor identifiable: the exchanged token carries `azp` = the agent's client id alongside `sub` = the end user. ```json { "sub": "alice", "azp": "classic-models-agent-ventes", "aud": ["mcp-catalog"], "scope": "openid profile email mcp-catalog-audience" } ``` Two measured facts that constrain the implementation: **`actor_token` is silently ignored.** Keycloak 26.6's Standard Token Exchange accepts the parameter, returns a valid token, and emits **no `act` claim**. No error. Do not build delegation on RFC 8693 `act` — use `azp`. **The audience is requested via a scope, not the `audience` parameter.** Passing `audience=mcp-catalog` returns `Requested audience not available`. Pass `scope=openid mcp-catalog-audience`. ### The exchange Do what LibreChat already does — a POST per target audience, roughly twenty lines. Do not build a sidecar or move the exchange into Kong; both were considered and rejected as over-engineering, the second also because it would leave the agent holding one broad token instead of several narrow ones. ``` POST https://keycloak.sttlab.local/realms/classic-models/protocol/openid-connect/token grant_type=urn:ietf:params:oauth:grant-type:token-exchange client_id= client_secret= subject_token= subject_token_type=urn:ietf:params:oauth:token-type:access_token scope=openid mcp--audience ``` Neither LangGraph nor CrewAI injects a per-call MCP token natively (langchain-mcp-adapters issues #172/#194/#235; CrewAI's `MCPServerAdapter` fixes headers at construction). This is a non-issue: build the MCP client **once per conversation turn** with freshly exchanged tokens. A turn lasts seconds, an access token five minutes. ## Endpoints MCP servers, one per domain, served by Kong: | Domain | URL | Audience scopes to request | |---|---|---| | catalog | `https://catalog-dev.sttlab.local/catalog-mcp` | `mcp-catalog-audience kong-token-exchange-broker-audience` | | customers | `https://customers-dev.sttlab.local/customers-mcp` | `mcp-customers-audience kong-token-exchange-broker-audience` | | payments | `https://payments-dev.sttlab.local/payments-mcp` | `mcp-payments-audience kong-token-exchange-broker-audience` | | sales | `https://sales-dev.sttlab.local/sales-mcp` | `mcp-sales-audience kong-token-exchange-broker-audience` | | workforce | `https://workforce-dev.sttlab.local/workforce-mcp` | `mcp-workforce-audience kong-token-exchange-broker-audience` | **Two scopes per server, not one.** The gateway's own `openid-connect` plugin exchanges the token again before reaching the backend, so the token must also name `kong-token-exchange-broker`. Without it the call fails with a bare `401` and the real cause appears only in the gateway log — *invalid status code received from the token endpoint (403)*. Measured on catalog: with both scopes the MCP server answers `200`, with `mcp-catalog-audience` alone it answers `401`. LibreChat has always requested both. LLM inference goes through the Kong AI gateway, never direct to a provider: ``` https://llm-dev.sttlab.local/llm/v1/chat/completions ``` It applies rate limiting, prompt guards and semantic guards, and routes to OpenRouter. Sending inference anywhere else bypasses all of them. These hostnames are lab-only with a self-signed certificate. `/etc/hosts`: ``` 192.168.139.103 catalog-dev.sttlab.local 192.168.139.105 llm-dev.sttlab.local 192.168.139.106 customers-dev.sttlab.local 192.168.139.107 payments-dev.sttlab.local 192.168.139.108 sales-dev.sttlab.local 192.168.139.109 workforce-dev.sttlab.local 192.168.139.101 keycloak.sttlab.local ``` The CA is `sttlab-local-ca` (cert-manager ClusterIssuer). Trust that one CA rather than disabling verification. ## Adding an agent An agent is a declaration first. Put it here, let the generator produce the rest — the Keycloak client is generated from this, never hand-written in the agent's own repo, so the realm stays the single source of truth. ```yaml name: ventes client_id: classic-models-agent-ventes framework: crewai surfaces: [conversational] mcp_servers: [catalog, sales] scopes: [catalog:read, sales:read, sales:write] a2a_calls: [approvisionnement, recouvrement] limits: max_tool_calls_per_run: 20 max_records_read_per_run: 500 ``` `scopes` becomes the client's `optionalClientScopes`; `mcp_servers` becomes the audience scopes it may request; `a2a_calls` becomes the A2A authorisations. The realm actually deployed is `infra-keycloak/k8s/keycloak/configmap-realm.yaml` — note that `kong-v2/classic-models-realm.yaml` is an older partial copy and is **not** authoritative. **The realm file is re-imported with `--override true` on every pod start.** An `import-realm` initContainer replays it before Keycloak serves traffic, so the file wins over the database and nothing created through the admin console survives a restart. Add clients here, never in the console. **Client secrets and user passwords in that file are `${KC_SECRET_*}` / `${KC_USER_*}` placeholders**, substituted from the `keycloak-realm-secrets` Secret (Vault + ExternalSecret). Never commit a literal secret back into it. ## Serving A2A The `a2a-sdk` 1.x server rejects a request that does not carry the protocol version header: ``` A2A-Version: 1.0 ``` `DefaultRequestHandler` speaks the older 0.3 shape; use `DefaultRequestHandlerV2`, and mount both `create_agent_card_routes(...)` and `create_jsonrpc_routes(handler, "/", enable_v0_3_compat=True)`. An executor must enqueue the `Task` before any status update, or the client blocks waiting for a task it never saw. `AgentInterface` names the field `protocol_binding`, not `transport`. ## Known blockers **No `employeeNumber` in tokens.** Sales territory scoping (a rep sees their territory, a manager sees all) resolves through `employee → officeCode → offices.territory`, but the token carries no employee identity, so the API cannot filter server-side. The realm's users (`admin-user`, `pm-user`, …) are role placeholders, not real employees. **PydanticAI's MCP client cannot open a session against Kong's `ai-mcp-proxy`.** It fails with `Failed to initialize server session` and swallows the cause. Everything else was eliminated: identity, TLS, DNS and the token are all fine, and the three handshake steps — `initialize` (200 with a session id), the SSE `GET` (200), `notifications/initialized` (202) — all succeed with curl using the very token the agent minted. This blocks the Catalogue agent end to end, and with it the Kong AI gateway path, which has therefore never been exercised. ## Conventions - Deliverables in English: code, comments, READMEs, Agent Cards. Conversation with the maintainer is in French. - GitOps throughout. Never `kubectl apply` an object carrying `kustomize.toolkit.fluxcd.io/name` — Flux reverts it within its interval, and tests run against config that no longer exists. - Secrets never committed. Follow the `secret.example.yaml` pattern used by the `infra-*` repos.