3e4e8e363f
An agent talks to URLs that honour a contract: an MCP endpoint, an OIDC token endpoint, an OpenAI-shaped LLM endpoint. Whatever serves those URLs is replaceable, and naming it in an agent's code or docs implies a coupling that does not exist. Every product name is gone from prose, comments and identifiers, replaced by what the agent actually depends on. Where a behaviour of the current middleware leaks into the contract — the second audience scope, the tool ACL — the text now says what the caller must do and why, not who imposes it. One string survives and cannot be fixed here: the broker client an agent must name in its audience scopes is itself named after the middleware. Closing that means renaming it in Keycloak and following the rename through the gateway configuration and LibreChat. Recorded as a blocker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
234 lines
10 KiB
Markdown
234 lines
10 KiB
Markdown
# classic-models-agent-platform
|
|
|
|
Shared ground for the classic-models agent estate: templates, conventions, and
|
|
the identity plumbing every agent repeats. What `k8s-platform` is to the
|
|
gateway estate, this is to the agent estate.
|
|
|
|
**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: an MCP server per domain, an
|
|
OpenAI-shaped LLM endpoint, Keycloak, and the domain APIs behind them.
|
|
|
|
**Name none of the middleware.** An agent talks to URLs that honour a contract.
|
|
Whatever serves those URLs today is replaceable — swap it and nothing in an
|
|
agent changes, as long as the contract holds. So write "the MCP server", "the
|
|
LLM endpoint", "the gateway"; never a product name, in code, comments, commit
|
|
messages or docs. Where a behaviour of the current middleware leaks into the
|
|
contract, say what the caller must do and why, not who imposes it.
|
|
|
|
| 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, and do not push the exchange down into the
|
|
gateway; 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=<the agent's client id>
|
|
client_secret=<from the agent's Secret>
|
|
subject_token=<the end user's access token>
|
|
subject_token_type=urn:ietf:params:oauth:token-type:access_token
|
|
scope=openid mcp-<domain>-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:
|
|
|
|
| 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 MCP endpoint exchanges the token again
|
|
on its own account before reaching the domain API, so the token must also name
|
|
the broker client that performs that second exchange. Without it the call fails
|
|
with a bare `401` and no usable detail — the real cause (*invalid status code
|
|
received from the token endpoint (403)*) appears only in the server's own log.
|
|
Measured on catalog: with both scopes the MCP server answers `200`, with
|
|
`mcp-catalog-audience` alone it answers `401`. LibreChat has always requested
|
|
both.
|
|
|
|
This is a property of the contract as it stands, not of any agent's design. The
|
|
broker client is still named after the middleware that introduced it, which is
|
|
a leak worth closing — see the blockers.
|
|
|
|
LLM inference goes through the estate's LLM endpoint, never direct to a model
|
|
provider:
|
|
|
|
```
|
|
https://llm-dev.sttlab.local/llm/v1/chat/completions
|
|
```
|
|
|
|
It speaks the OpenAI protocol, applies rate limiting and prompt guards, and
|
|
routes onward to a provider. Sending inference anywhere else bypasses all of
|
|
that, and loses the audit trail with it.
|
|
|
|
Authenticate to it with an exchanged token carrying the `llm-proxy` audience,
|
|
not a static API key. A key proves which application is calling; the token
|
|
carries `azp` and `sub`, so inference is attributable to the same (agent, end
|
|
user) pair as every tool call.
|
|
|
|
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 the copy under the gateway estate is older, partial, and **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.
|
|
|
|
**The broker client is named after the middleware.** Every agent must request
|
|
`kong-token-exchange-broker-audience` alongside the server's own audience, so
|
|
the one thing an agent cannot avoid naming is the very thing it should not know
|
|
about. Closing it means renaming the client and its scope in Keycloak, and
|
|
following that rename through the gateway configuration, LibreChat and each
|
|
agent's config. Until then, treat the string as an opaque identifier owned by
|
|
the realm.
|
|
|
|
## 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.
|