From ddc0f24cb9abb9b1bbda74b54a06f58928460fbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Tailland?= Date: Wed, 26 Aug 2026 15:14:41 +0200 Subject: [PATCH] Add implementation instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design agreed in discussion, nothing built yet. Records the agent's role, its scopes and MCP servers, its A2A surface, and the framework choice with its reason — so the next session starts from the decisions rather than re-deriving them. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8b8017a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,180 @@ +# 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 scope | +|---|---|---| +| catalog | `https://catalog-dev.sttlab.local/catalog-mcp` | `mcp-catalog-audience` | +| customers | `https://customers-dev.sttlab.local/customers-mcp` | `mcp-customers-audience` | +| payments | `https://payments-dev.sttlab.local/payments-mcp` | `mcp-payments-audience` | +| sales | `https://sales-dev.sttlab.local/sales-mcp` | `mcp-sales-audience` | +| workforce | `https://workforce-dev.sttlab.local/workforce-mcp` | `mcp-workforce-audience` | + +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. + +## Known blockers + +**Konnect is not persisting configuration.** The operator loops on the same +objects without converging and the dataplanes stay behind the control plane, so +no new Kong config reaches the gateways — including any new agent audience. +Resolve this before expecting an end-to-end run. + +**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. + +**The `kong-token-exchange-broker` secret does not match the realm** — its +`client_credentials` returns `unauthorized_client`. + +## 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.