35 Commits

Author SHA1 Message Date
Stéphane Tailland 4cbbabc29b Declare X-Stock-Change-Reason as a proper header parameter
The tool argument name Kong actually forwards comes from the operation's
declared parameters — a header cannot be sent as an arbitrary argument name,
it has to be in the spec like any other parameter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:21:00 +02:00
Stéphane Tailland b34619057d Fix the malformed YAML from the previous commit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:16:24 +02:00
Stéphane Tailland 934adf0cfc Expose patch-product as an MCP tool, scoped to a new restocker role
Adds the restocker KongConsumerGroup and patch-product to the tools policy,
allow: [admin, product_manager, restocker]. classic-models-api backs this the
same way — restocker may PATCH a product and nothing else.

Regenerated with generate-mcp-tools.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:12:53 +02:00
Stéphane Tailland 4246c1e62f Delete the unreferenced cache manifest 2026-08-31 08:33:14 +02:00
Stéphane Tailland 1d1bbde17e Stop caching reads on a read-write API
The proxy cache held every GET on the whole service for 60 seconds, and these
APIs accept writes. Kong's proxy cache has no invalidation on write: a POST
that changes a product passes through the same gateway, and the gateway goes on
serving the value it captured before it, for up to a minute, with nothing in the
response saying so.

It produced a real wrong answer. An agent woken by a stock event read the figure
from before that event and reported, in one sentence, "68 in stock, above the
threshold" and "it crossed the threshold, from 68".

The manifest is kept, unreferenced, carrying the reason — so reinstating it is a
decision rather than a rediscovery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 08:26:21 +02:00
Stéphane Tailland ca0a11fd94 feat(mcp): authorise tool-call parameters, not just the tool name
The ACL answers 'may this identity call create-product'. Nothing answered 'may
it call it with these arguments' — write authorises writing, not this
particular record. And the read cap counts calls, so one call asking for a
hundred thousand rows cost the same as one asking for ten.

The OPA plugin sends the decoded JSON-RPC body, so the decision point sees the
tool name and its arguments. The policy lives once in k8s-platform/opa and every
domain asks the same server.

Enforced at the gateway rather than in an agent, because an agent that has been
talked into something is the case this exists for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 18:08:00 +02:00
Stéphane Tailland 37770f39d4 feat(mcp): cap read volume per caller identity
A scope authorises a read and says nothing about volume. An agent reads a
thousand records in seconds where a person clicks ten, and until now nothing
noticed: the token is valid, the tool is permitted, the ACL passes. That gap
stopped being theoretical the day an autonomous run started reading the whole
catalog on a schedule.

identifier: credential keys the counter on the sub claim — the end user,
whoever is asking on their behalf — so an agent looping under one person's
identity spends that person's budget and cannot spend everyone's. Verified on
catalog: pm-user and ro-user hold separate counters.

Per-route namespace, so a busy domain cannot exhaust another's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 18:05:45 +02:00
Stéphane Tailland 2143c5f0bb feat(mcp): cap read volume per caller identity
A scope authorises a read and says nothing about volume. An agent reads a
thousand records in seconds where a person clicks ten, and until now nothing
noticed: the token is valid, the tool is permitted, the ACL passes.

identifier: credential keys the counter on the sub claim — the end user, whoever
is asking on their behalf — so an agent looping under one person's identity
spends that person's budget and cannot spend everyone's. Per-route namespace, so
a busy catalog cannot exhaust another domain's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 18:04:14 +02:00
Stéphane Tailland 644c013edf fix(mcp): translate OpenAPI 3.0 nullable into JSON Schema
The response schemas added yesterday made every paginated list tool fail. The
plugin's fields are documented as OpenAPI format and validated with a JSON
Schema validator, and `nullable: true` is not a JSON Schema keyword — so it is
ignored, `type: string` is enforced, and `previous: null` on the first page is
rejected as *null is not of type "string"*.

The failure lands after the backend has already done the work, surfaces to the
caller as a bare tool retry exhaustion, and appears nowhere in the gateway's own
logs. Found only because an orchestrating agent asked a question that paged.

The generator now rewrites `nullable: true` into `type: [T, "null"]` wherever it
inlines a schema, request bodies included. A 3.1 spec needs none of this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:19:29 +02:00
Stéphane Tailland 32d6014b42 refactor(mcp): generate the tool definitions from the spec
The tools: array was hand-maintained and had drifted from openapi.yaml in two
ways that only surfaced when an agent hit them: create-product omitted
productcode and could never succeed, and list-products declared no parameters
so it could not paginate — which let a model answer a product count that looked
right and had not come from the tool's output.

It is now produced by k8s-platform/tools/generate-mcp-tools.py: deck file
openapi2mcp does the conversion, and the wrapper re-injects the five things
Kong's generator cannot emit — per-tool ACLs, the MCP behavioural annotations,
the response schemas the plugin uses for structuredOutput, the real upstream
host, and JSON-only request bodies. Each is a documented gap; see
k8s-platform/MCP-TOOL-GENERATION.md.

tools-policy.yaml carries the ACLs and doubles as the allow-list: an operation
becomes a tool only if the policy names it. Kong would otherwise expose every
operation in the spec, DELETE included, and can express no authorization at all
from a bearer-token spec — silently, exit 0, no warning.

Same three tools as before, same ACLs. What changed: pagination is now
callable, create-product's required list matches the API (quantityinstock
included, which the hand-written repair had missed), each tool declares its
response schema, and the request body is no longer tripled across three
identical content types.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 08:43:36 +02:00
Stéphane Tailland dcc93f0f68 refactor: rename the token-exchange broker to drop the middleware's name
Every agent has to name this client in the audience scopes it requests, so the
one thing an agent cannot avoid naming was the very thing it should know
nothing about. The gateway is replaceable; the contract should not say
otherwise.

token-exchange-broker, same credential, same behaviour. The old client stays in
the realm until every consumer has moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:40:03 +02:00
Stéphane Tailland c583d28cff fix(catalog-mcp): create-product could never succeed
The tool's request schema omitted productcode, which catalog-api requires
(ProductRequest in openapi.yaml lists it among the eight required fields).
Since the declared schema is the only thing an MCP caller sees, there was no
input that worked: omit it and the API answers
{"productcode":["This field is required."]}; supply it and ai-mcp-proxy rejects
the call first with "Additional properties are not allowed ('productcode' was
unexpected)".

The schema now mirrors ProductRequest — productcode added, the same eight
fields marked required, and the length and format constraints carried over so
a model reads them rather than guessing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:34:06 +02:00
Stéphane Tailland 20c72929a8 Follow the gateway Kustomization into the domain namespace
dependsOn still named flux-system, where the gateway Kustomization used to
live. It moved into the domain namespace so the feature team can reach it,
and a dependency naming the old location resolves to nothing — Flux
reported "dependency not found" and held this Kustomization not ready
rather than failing loudly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:45:20 +02:00
Stéphane Tailland 41f7a996c5 Reconcile this repo's Flux entrypoints instead of applying them by hand
dev/ declares this repo's GitRepository, the Kustomization that deploys
./kong, and its ExternalSecrets — but nothing watched dev/, so those files
were applied once by hand and never re-read. Editing them in git changed
nothing until someone remembered to kubectl apply, which is what silently
swallowed the CONTROL_PLANE_REF_NAME rollout: correct in git for hours
while the cluster still held the old value.

Add the bootstrap Kustomization that watches ./dev and, living in ./dev,
manages itself. This is what `flux bootstrap` would have created;
flux-operator installs the controllers and no root.

It sits in the domain namespace rather than flux-system, because sourceRef
resolves in the Kustomization's own namespace and because this is the
team's to own.

Needs one kubectl apply to exist. That is the last one this repo needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:32:55 +02:00
Stéphane Tailland c210ffbaae Regenerate the spec against the gateway, not the backend
This spec predated the other four and documented the wrong surface: the
paths were the backend's (/classic-models/api/v1/...) and the server was
http://localhost:8000. Neither is reachable from the developer portal,
so every operation it published failed as written — the gateway serves
/catalog/v1/... and rewrites onto the backend path upstream.

Regenerated the same way as the other four domains: from the backend's
drf-spectacular schema, filtered to the operations this domain's gateway
exposes, derived from the Kong routes and their request-transformer
rewrites in Konnect. All 14 REST routes resolved to a backend operation.
Same six paths as before, now expressed as the gateway's, with
https://catalog-dev.sttlab.local as the server and path parameters
renamed to the gateway's capture names.

The five published specs now follow one convention.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:42:02 +02:00
Stéphane Tailland 454c4c66e2 Restore SSE on the catalog MCP route
catalog was the only domain that split its MCP route in two: the main
route on POST/DELETE, and a separate -stream route matching GET, whose
only purpose was to carry a request-termination plugin answering 405
"SSE stream is not offered on this endpoint". The other four domains
keep one route with GET, POST and DELETE.

That opt-out is not a fix the other four missed — it gives up a feature
the plugin implements. ai-mcp-proxy handles non-POST itself: its handler
carries an explicit non-POST branch, MCP session handling, an SSE
subscription waiting on notifications/tools/list_changed, and Accept
header validation for application/json and text/event-stream. GET is the
server-to-client channel of MCP Streamable HTTP, and on the other four
domains it reaches the plugin and works.

Nothing in git or in any comment records why catalog gave it up, and the
plugin and binding behind it were applied by hand three days after the
route landed — they were never in git at all. So on a cluster rebuilt
from git the split had no guard behind it: GET fell through to a route
with no plugin whose service points back at the gateway itself.

Drop the stream route and put GET back on the MCP route, so the five
domains are identical again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:50:52 +02:00
Stéphane Tailland 2351ad0bc3 Point the control-plane ref at the stable mirror CR
CONTROL_PLANE_REF_NAME held the random suffix the Gateway operator gives
its auto-created mirror CR, looked up by hand. Replacing the Konnect org
recreated those CRs under new suffixes, so the ref named an object that
no longer existed and the domain's Kong entities sat Programmed=False.

Name the ${NAME}-cp mirror declared in k8s-platform's dataplane-template
instead — derived from NAME, so it stays valid across Gateway
recreations and Konnect org changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:30:15 +02:00
Stéphane Tailland 0bd9fda837 Route gateway audit logs to Loki
Post http-log to /catalog instead of /logs. Fluent Bit's http input derives the
record tag from the request URI, so the path is what identifies this domain
in the collector and becomes the Loki label domain=catalog.

The collector keeps writing every domain to the on-disk audit trail as
well, so this is reversible by restoring the previous path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 07:25:43 +02:00
Stéphane Tailland 3a27e13c8b Switch to local keycloak 2026-08-23 14:40:22 +02:00
Stéphane Tailland a8459f7ecf Fix invalid K8s resource names for read_only/product_manager consumer groups
metadata.name must be an RFC 1123 subdomain (no underscores); spec.name
(the value actually matched against the JWT role claim) stays read_only/
product_manager to match the realm role names exactly.
2026-08-21 18:39:30 +02:00
Stéphane Tailland fc6b60c582 Rebuild catalog-mcp consumer groups on the real 5-role model
KongConsumerGroups were still named sttlab-user/sttlab-admin, a leftover
from before the classic-models realm's role model existed. Real users'
JWTs carry realm roles (admin, read_only, product_manager, customer,
support) via consumer_groups_claim, so no group ever matched and every
tool call was rejected with 403 "consumer groups were not found".

Groups now match the realm roles directly; tool ACLs gate on each role's
catalog:read/catalog:write grant from security-model.yaml.
2026-08-21 18:35:02 +02:00
Stéphane Tailland 95f9b611b3 Capture request/response bodies for audit-log via pre/post-function
Body capture lives in dedicated audit-log-body-capture(-bindings).yaml
files, separate from audit-log.yaml itself, so they can be dropped
without touching the base logging plugin (degrades gracefully to
metadata-only via nil custom_fields). Response body captured in
body_filter (chunk-accumulated) rather than the log phase, which
cannot call kong.response.get_raw_body() directly.
2026-08-21 14:53:52 +02:00
Stéphane Tailland 1c46f9b447 Revert custom_fields_by_lua on audit-log: broke logging entirely
kong.request.get_raw_body()/kong.response.get_raw_body() cannot be
called from the log phase (confirmed via dataplane error logs:
"function cannot be called in log phase") — this silently killed
log_by_lua for every plugin on the request, not just body capture.
Back to metadata-only logging while body capture gets redone properly
(pre-function/post-function storing into kong.ctx.shared, read back
in custom_fields_by_lua).
2026-08-21 14:46:49 +02:00
Stéphane Tailland 6fa122207e Capture request/response bodies in audit-log, extend it to MCP
custom_fields_by_lua adds request_body/response_body to the http-log
payload. Bodies and the raw Authorization header are now both logged
in clear text — dev only, flagged in the plugin file itself.
2026-08-21 14:40:20 +02:00
Stéphane Tailland fa7abfe5bd Drop the manual KongCertificate/KongSNI workaround
Turned out unnecessary: the real blocker was the missing
konghq.com/secret=true label on the Vault-sourced Secret, not a gap
in the Gateway API TLS translation itself. Once that label was added
(k8s-platform), the operator's own auto-generated KongCertificate/SNI
started working and this manual one just collided with it.
2026-08-21 09:51:23 +02:00
Stéphane Tailland 01f873b773 Bind the Gateway TLS secret to Konnect via KongCertificate/KongSNI
Gateway API's listeners[].tls.certificateRefs never gets synced to
Konnect by the gateway operator in this setup — same working pattern
as every other resource here (KongService, KongRoute, ...): a
Kong-native CRD, not the Gateway API's own translation.
2026-08-21 09:47:29 +02:00
Stéphane Tailland 466c657240 Point catalog-api at the classic-models Keycloak realm, drop separate MCP hostname
MCP now shares the REST hostname (routed by path), matching the
/catalog/v1 rework — no second TLS listener/certificate needed.
2026-08-21 09:24:18 +02:00
Stéphane Tailland 4a1c772b80 Expose catalog-api under /catalog/v1, decoupled from the backend path
Public routes no longer leak classic-models-api's own /api/v1 prefix.
request-transformer-advanced rewrites to the real backend path per route.
2026-08-21 09:22:33 +02:00
Stéphane Tailland dfb01f4c54 MCP server 2026-08-20 17:27:09 +02:00
Stéphane Tailland f929a0f7e4 MCP server 2026-08-20 16:26:48 +02:00
Stéphane Tailland 8b7ab3b846 Additional plugins 2026-08-20 15:03:25 +02:00
Stéphane Tailland 9546953b97 Flux refactoring 2026-08-20 14:37:08 +02:00
Stéphane Tailland b69a57e771 Documentation 2026-08-20 13:47:09 +02:00
Stéphane Tailland 89add4914a First commit 2026-08-20 13:24:10 +02:00
Stéphane Tailland 1275748655 Scaffold catalog-api (routing placeholder for classic-models-api split) 2026-08-18 14:59:46 +02:00