From cc3dc35c0ef5afdc7c5ec9c634f74c51010634b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Tailland?= Date: Sat, 29 Aug 2026 18:05:48 +0200 Subject: [PATCH] feat(mcp): cap read volume per caller identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- dev/flux-kustomization.yaml | 2 ++ kong/kustomization.yaml | 1 + kong/mcp/pluginbindings.yaml | 18 ++++++++++++++++++ kong/mcp/rate-limiting.yaml | 37 ++++++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 kong/mcp/rate-limiting.yaml diff --git a/dev/flux-kustomization.yaml b/dev/flux-kustomization.yaml index 03f7b8b..a0fafb1 100644 --- a/dev/flux-kustomization.yaml +++ b/dev/flux-kustomization.yaml @@ -24,6 +24,8 @@ spec: BACKEND_HOST: classic-models-api.classic-models.svc.cluster.local OIDC_ISSUER: https://keycloak.sttlab.local/realms/classic-models API_AUDIENCE: '["customers-api"]' + # Read volume for an MCP caller, per identity per minute. + MCP_READS_PER_MINUTE: "60" # Unused today — same dead variable already present in catalog-api's # flux-kustomization.yaml (no file under kong/rest/ or kong/mcp/ # actually reads API_SCOPES_REQUIRED). Kept for parity; fix in both diff --git a/kong/kustomization.yaml b/kong/kustomization.yaml index afc03db..1d7e17a 100644 --- a/kong/kustomization.yaml +++ b/kong/kustomization.yaml @@ -17,6 +17,7 @@ resources: - mcp/consumergroups.yaml - mcp/ai-mcp-proxy.yaml - mcp/openid-connect.yaml + - mcp/rate-limiting.yaml - mcp/audit-log.yaml - mcp/audit-log-body-capture.yaml - mcp/audit-log-body-capture-bindings.yaml diff --git a/kong/mcp/pluginbindings.yaml b/kong/mcp/pluginbindings.yaml index 0fa96a4..22eca25 100644 --- a/kong/mcp/pluginbindings.yaml +++ b/kong/mcp/pluginbindings.yaml @@ -51,3 +51,21 @@ spec: group: configuration.konghq.com kind: KongService name: ${NAME}-customers-mcp +--- +apiVersion: configuration.konghq.com/v1alpha1 +kind: KongPluginBinding +metadata: + name: ${NAME}-customers-mcp-rate-limiting-binding + namespace: ${NAMESPACE} +spec: + controlPlaneRef: + type: konnectNamespacedRef + konnectNamespacedRef: + name: ${CONTROL_PLANE_REF_NAME} + pluginRef: + name: ${NAME}-customers-mcp-rate-limiting + targets: + routeRef: + group: configuration.konghq.com + kind: KongRoute + name: ${NAME}-customers-mcp diff --git a/kong/mcp/rate-limiting.yaml b/kong/mcp/rate-limiting.yaml new file mode 100644 index 0000000..f6ae139 --- /dev/null +++ b/kong/mcp/rate-limiting.yaml @@ -0,0 +1,37 @@ +# A read-volume cap 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 the difference is +# invisible to every control the estate had until now — the token is valid, the +# tool is permitted, the ACL passes. +# +# `identifier: credential` keys the counter on what the openid-connect plugin +# published as the caller's credential, which is 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. +apiVersion: configuration.konghq.com/v1 +kind: KongPlugin +metadata: + name: ${NAME}-customers-mcp-rate-limiting + namespace: ${NAMESPACE} +plugin: rate-limiting-advanced +config: + identifier: credential + # Per-route counters. Sharing a namespace across domains would let a busy + # domain exhaust the budget for another. + namespace: ${NAME}-customers-mcp + strategy: local + sync_rate: -1 + window_type: sliding + window_size: + - 60 + limit: + - ${MCP_READS_PER_MINUTE} + # Loud rather than silent: a caller that hits the cap is told, and the headers + # say when it resets. An agent that only sees a 429 with no Retry-After + # retries immediately and makes it worse. + hide_client_headers: false + error_code: 429 + error_message: >- + Read volume limit reached for this identity. This is a cap on how much can + be read per minute, not a permission problem.