5b0108fe43
The audience table now names token-exchange-broker, and the blocker that recorded the leak is closed. What remains in that section is the missing employeeNumber claim, which is unrelated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
226 lines
9.5 KiB
Markdown
226 lines
9.5 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 token-exchange-broker-audience` |
|
|
| customers | `https://customers-dev.sttlab.local/customers-mcp` | `mcp-customers-audience token-exchange-broker-audience` |
|
|
| payments | `https://payments-dev.sttlab.local/payments-mcp` | `mcp-payments-audience token-exchange-broker-audience` |
|
|
| sales | `https://sales-dev.sttlab.local/sales-mcp` | `mcp-sales-audience token-exchange-broker-audience` |
|
|
| workforce | `https://workforce-dev.sttlab.local/workforce-mcp` | `mcp-workforce-audience 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. If
|
|
the middleware serving these URLs is replaced by something that does not
|
|
re-exchange, the second scope simply becomes unnecessary.
|
|
|
|
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.
|
|
|
|
## 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.
|