Add payments-mcp (Layer 3 MCP surface), single-route pattern from the start

list-payments/get-payment (all 5 roles) and create-payment (admin
only — matches PaymentPermission's actual write-access logic, not
guessed). One KongRoute for GET/POST/DELETE, one ai-mcp-proxy
binding — avoids the duplicate-tool-name bug found and fixed on
customers-mcp (STATUS.md section 18), built correctly here instead
of repeating the two-route mistake.

Keycloak (local): mcp-payments-audience client scope + mcp-payments
resource client, attached to librechat as an optional scope, no
dedicated protocol mappers on the client itself.
This commit is contained in:
Stéphane Tailland
2026-08-23 16:27:56 +02:00
parent bf5adadb24
commit 028a0be2a6
12 changed files with 391 additions and 18 deletions
+30 -18
View File
@@ -9,32 +9,44 @@ split. Structure mirrors `../customers-api`, not `../catalog-api` — see
has a latent bug; `customers-api`'s single-route pattern is the corrected has a latent bug; `customers-api`'s single-route pattern is the corrected
one to copy going forward). one to copy going forward).
**REST only, no MCP layer.** 6 routes over `Payment`, which uses a Two exposure surfaces, both proxying to the real `classic-models-api`
**composite key** (`customernumber` + `checknumber`, not a single id like backend:
`Customer`/`Product`) — both URI segments are captured and passed through - **REST** (`kong/rest/`) — 6 routes over `Payment`, which uses a
to the backend's own composite-key URL pattern. **composite key** (`customernumber` + `checknumber`, not a single id
like `Customer`/`Product`) — both URI segments are captured and passed
through to the backend's own composite-key URL pattern.
- **MCP** (`kong/mcp/`) — `list-payments`/`get-payment` (all 5 roles) and
`create-payment` (admin only — verified against `PaymentPermission`'s
actual logic in `authentication/permissions.py`, not guessed: writes are
admin-only in practice, `customer` can only write their own via the
REST API directly, not exposed as an MCP tool). **One `KongRoute` for
all three MCP HTTP methods**, one `ai-mcp-proxy` binding — see
`../STATUS.md` section 18 for why this matters (a second route +
binding for the same plugin creates a duplicate-tool-name bug, found
and fixed on `customers-mcp`, built correctly here from the start).
| Route | Method | Backend path | | Route | Method | Backend path |
|---|---|---| |---|---|---|
| `/payments/v1/payments/` | GET, POST | `/classic-models/api/v1/payments/` | | `/payments/v1/payments/` | GET, POST | `/classic-models/api/v1/payments/` |
| `/payments/v1/payments/{customernumber}/{checknumber}/` | GET, PUT, PATCH, DELETE | `/classic-models/api/v1/payments/{customernumber}/{checknumber}/` | | `/payments/v1/payments/{customernumber}/{checknumber}/` | GET, PUT, PATCH, DELETE | `/classic-models/api/v1/payments/{customernumber}/{checknumber}/` |
| `/payments-mcp` | GET, POST, DELETE | MCP streamable-http (tools call into the REST routes above) |
## Auth ## Auth
`openid-connect`, `audience_required`/token-exchange target = `openid-connect` on both surfaces, distinct audience each (`payments-api`
`classic-models-api` (`BACKEND_AUDIENCE`) — same second-hop-exchange for REST, `mcp-payments` for MCP) with a real RFC 8693 exchange chain:
pattern as `customers-api`'s REST layer: a caller gets a MCP → `payments-api` → `classic-models-api`, same pattern as
`payments-api`-audience token from `kong-token-exchange-broker` directly `catalog-api`/`customers-api`. IdP is the local Keycloak
(no MCP hop exists for this domain), and this layer exchanges it again to (`https://keycloak.sttlab.local/realms/classic-models`) — see
`classic-models-api` before the backend ever sees it. IdP is the local `../STATUS.md` sections 15/16. `payments-api-audience` and
Keycloak (`https://keycloak.sttlab.local/realms/classic-models`) — see `mcp-payments-audience` client scopes + `payments-api`/`mcp-payments`
`../STATUS.md` sections 15/16. **No `payments-api` Keycloak client or resource clients created on the local Keycloak, attached to
`payments-api-audience` client scope exists yet** — unlike `customers-api`, `kong-token-exchange-broker`'s default scopes (REST audience) and to
this hasn't been created on the local Keycloak (nor attached to `librechat`'s optional scopes (MCP audience) respectively — no dedicated
`kong-token-exchange-broker`'s default scopes) as of this repo's initial protocol mappers on either new client itself, same practice as every
build. Nothing can actually authenticate against these routes until that's other domain built this session. `mcp-payments`'s OAuth callback added to
done — same missing piece as `customers-api` had before its own Keycloak `librechat`'s `redirectUris`, and a `payments-mcp` server entry added to
wiring step. LibreChat's `librechat.yaml` (`infra-librechat` repo).
## Other policies ## Other policies
+1
View File
@@ -20,6 +20,7 @@ spec:
CONTROL_PLANE_REF_NAME: payments-dev-bz6vs CONTROL_PLANE_REF_NAME: payments-dev-bz6vs
BACKEND_HOST: classic-models-api.classic-models.svc.cluster.local BACKEND_HOST: classic-models-api.classic-models.svc.cluster.local
OIDC_ISSUER: https://keycloak.sttlab.local/realms/classic-models OIDC_ISSUER: https://keycloak.sttlab.local/realms/classic-models
API_AUDIENCE: '["payments-api"]'
BACKEND_AUDIENCE: '["classic-models-api"]' BACKEND_AUDIENCE: '["classic-models-api"]'
BACKEND_SCOPES: '[]' BACKEND_SCOPES: '[]'
API_RATE_LIMIT_MINUTE: "60" API_RATE_LIMIT_MINUTE: "60"
+9
View File
@@ -12,3 +12,12 @@ resources:
- rest/audit-log-body-capture.yaml - rest/audit-log-body-capture.yaml
- rest/audit-log-body-capture-bindings.yaml - rest/audit-log-body-capture-bindings.yaml
- rest/pluginbindings.yaml - rest/pluginbindings.yaml
- mcp/service.yaml
- mcp/routes.yaml
- mcp/consumergroups.yaml
- mcp/ai-mcp-proxy.yaml
- mcp/openid-connect.yaml
- mcp/audit-log.yaml
- mcp/audit-log-body-capture.yaml
- mcp/audit-log-body-capture-bindings.yaml
- mcp/pluginbindings.yaml
+94
View File
@@ -0,0 +1,94 @@
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
name: ${NAME}-payments-mcp-ai-mcp-proxy
namespace: ${NAMESPACE}
plugin: ai-mcp-proxy
config:
mode: conversion-listener
include_consumer_groups: true
tools:
- name: list-payments
description: Retrieves a paginated list of payments in the classic-models CRM.
annotations:
title: List all payments
read_only_hint: true
idempotent_hint: true
destructive_hint: false
open_world_hint: false
acl:
allow:
- admin
- read_only
- product_manager
- customer
- support
method: GET
scheme: http
host: ${REST_HOSTNAME}
path: /payments/v1/payments/
- name: get-payment
description: Retrieves a single payment by customer number and check number.
annotations:
title: Get a payment
read_only_hint: true
idempotent_hint: true
destructive_hint: false
open_world_hint: false
acl:
allow:
- admin
- read_only
- product_manager
- customer
- support
method: GET
scheme: http
host: ${REST_HOSTNAME}
path: /payments/v1/payments/{customernumber}/{checknumber}/
parameters:
- name: customernumber
in: path
required: true
description: The customer's unique number.
schema: {}
- name: checknumber
in: path
required: true
description: The payment's check number.
schema: {}
- name: create-payment
description: Records a new payment in the CRM.
annotations:
title: Create a payment
read_only_hint: false
idempotent_hint: false
destructive_hint: false
open_world_hint: false
acl:
allow:
- admin
method: POST
scheme: http
host: ${REST_HOSTNAME}
path: /payments/v1/payments/
request_body:
required: true
content:
application/json:
schema:
type: object
properties:
customernumber:
type: integer
checknumber:
type: string
paymentdate:
type: string
amount:
type: string
required:
- customernumber
- checknumber
- paymentdate
- amount
@@ -0,0 +1,35 @@
apiVersion: configuration.konghq.com/v1alpha1
kind: KongPluginBinding
metadata:
name: ${NAME}-payments-mcp-audit-capture-request-binding
namespace: ${NAMESPACE}
spec:
controlPlaneRef:
type: konnectNamespacedRef
konnectNamespacedRef:
name: ${CONTROL_PLANE_REF_NAME}
pluginRef:
name: ${NAME}-payments-mcp-audit-capture-request
targets:
serviceRef:
group: configuration.konghq.com
kind: KongService
name: ${NAME}-payments-mcp
---
apiVersion: configuration.konghq.com/v1alpha1
kind: KongPluginBinding
metadata:
name: ${NAME}-payments-mcp-audit-capture-response-binding
namespace: ${NAMESPACE}
spec:
controlPlaneRef:
type: konnectNamespacedRef
konnectNamespacedRef:
name: ${CONTROL_PLANE_REF_NAME}
pluginRef:
name: ${NAME}-payments-mcp-audit-capture-response
targets:
serviceRef:
group: configuration.konghq.com
kind: KongService
name: ${NAME}-payments-mcp
+27
View File
@@ -0,0 +1,27 @@
# dev only: captures raw request/response bodies into kong.ctx.shared for
# audit-log.yaml's custom_fields_by_lua to read back in the log phase.
# Drop this file (and its bindings) to stop body capture without touching
# audit-log.yaml itself — it degrades gracefully to metadata-only logging.
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
name: ${NAME}-payments-mcp-audit-capture-request
namespace: ${NAMESPACE}
plugin: pre-function
config:
access:
- "kong.ctx.shared.audit_request_body = kong.request.get_raw_body()"
---
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
name: ${NAME}-payments-mcp-audit-capture-response
namespace: ${NAMESPACE}
plugin: post-function
config:
body_filter:
- |
local chunk = ngx.arg[1]
if chunk then
kong.ctx.shared.audit_response_body = (kong.ctx.shared.audit_response_body or "") .. chunk
end
+17
View File
@@ -0,0 +1,17 @@
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
name: ${NAME}-payments-mcp-audit-log
namespace: ${NAMESPACE}
plugin: http-log
# dev only: the raw Authorization (JWT) header is captured in clear text,
# not acceptable as-is for a non-dev environment. request_body/response_body
# below read from kong.ctx.shared, populated by audit-log-body-capture.yaml
# — nil (and silently omitted) if that file isn't applied.
config:
http_endpoint: ${AUDIT_LOG_ENDPOINT}
method: POST
content_type: application/json
custom_fields_by_lua:
request_body: "return kong.ctx.shared.audit_request_body"
response_body: "return kong.ctx.shared.audit_response_body"
+59
View File
@@ -0,0 +1,59 @@
apiVersion: configuration.konghq.com/v1beta1
kind: KongConsumerGroup
metadata:
name: ${NAME}-admin
namespace: ${NAMESPACE}
spec:
controlPlaneRef:
type: konnectNamespacedRef
konnectNamespacedRef:
name: ${CONTROL_PLANE_REF_NAME}
name: admin
---
apiVersion: configuration.konghq.com/v1beta1
kind: KongConsumerGroup
metadata:
name: ${NAME}-read-only
namespace: ${NAMESPACE}
spec:
controlPlaneRef:
type: konnectNamespacedRef
konnectNamespacedRef:
name: ${CONTROL_PLANE_REF_NAME}
name: read_only
---
apiVersion: configuration.konghq.com/v1beta1
kind: KongConsumerGroup
metadata:
name: ${NAME}-product-manager
namespace: ${NAMESPACE}
spec:
controlPlaneRef:
type: konnectNamespacedRef
konnectNamespacedRef:
name: ${CONTROL_PLANE_REF_NAME}
name: product_manager
---
apiVersion: configuration.konghq.com/v1beta1
kind: KongConsumerGroup
metadata:
name: ${NAME}-customer
namespace: ${NAMESPACE}
spec:
controlPlaneRef:
type: konnectNamespacedRef
konnectNamespacedRef:
name: ${CONTROL_PLANE_REF_NAME}
name: customer
---
apiVersion: configuration.konghq.com/v1beta1
kind: KongConsumerGroup
metadata:
name: ${NAME}-support
namespace: ${NAMESPACE}
spec:
controlPlaneRef:
type: konnectNamespacedRef
konnectNamespacedRef:
name: ${CONTROL_PLANE_REF_NAME}
name: support
+27
View File
@@ -0,0 +1,27 @@
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
name: ${NAME}-payments-mcp-openid-connect
namespace: ${NAMESPACE}
plugin: openid-connect
config:
issuer: ${OIDC_ISSUER}
audience_required: ${API_AUDIENCE}
auth_methods:
- bearer
display_errors: false
ssl_verify: true
consumer_groups_claim:
- realm_access
- roles
client_id:
- ${TOKEN_EXCHANGE_CLIENT_ID}
client_secret:
- ${TOKEN_EXCHANGE_CLIENT_SECRET}
token_exchange:
subject_token_issuers:
- issuer: ${OIDC_ISSUER}
conditions: {}
request:
audience: ${API_AUDIENCE}
scopes: []
+53
View File
@@ -0,0 +1,53 @@
apiVersion: configuration.konghq.com/v1alpha1
kind: KongPluginBinding
metadata:
name: ${NAME}-payments-mcp-openid-connect-binding
namespace: ${NAMESPACE}
spec:
controlPlaneRef:
type: konnectNamespacedRef
konnectNamespacedRef:
name: ${CONTROL_PLANE_REF_NAME}
pluginRef:
name: ${NAME}-payments-mcp-openid-connect
targets:
serviceRef:
group: configuration.konghq.com
kind: KongService
name: ${NAME}-payments-mcp
---
apiVersion: configuration.konghq.com/v1alpha1
kind: KongPluginBinding
metadata:
name: ${NAME}-payments-mcp-ai-mcp-proxy-binding
namespace: ${NAMESPACE}
spec:
controlPlaneRef:
type: konnectNamespacedRef
konnectNamespacedRef:
name: ${CONTROL_PLANE_REF_NAME}
pluginRef:
name: ${NAME}-payments-mcp-ai-mcp-proxy
targets:
routeRef:
group: configuration.konghq.com
kind: KongRoute
name: ${NAME}-payments-mcp
---
apiVersion: configuration.konghq.com/v1alpha1
kind: KongPluginBinding
metadata:
name: ${NAME}-payments-mcp-audit-log-binding
namespace: ${NAMESPACE}
spec:
controlPlaneRef:
type: konnectNamespacedRef
konnectNamespacedRef:
name: ${CONTROL_PLANE_REF_NAME}
pluginRef:
name: ${NAME}-payments-mcp-audit-log
targets:
serviceRef:
group: configuration.konghq.com
kind: KongService
name: ${NAME}-payments-mcp
+22
View File
@@ -0,0 +1,22 @@
apiVersion: configuration.konghq.com/v1alpha1
kind: KongRoute
metadata:
name: ${NAME}-payments-mcp
namespace: ${NAMESPACE}
spec:
serviceRef:
type: namespacedRef
namespacedRef:
name: ${NAME}-payments-mcp
name: payments-mcp
hosts:
- ${REST_HOSTNAME}
methods:
- GET
- POST
- DELETE
paths:
- /payments-mcp
strip_path: false
tags:
- payments
+17
View File
@@ -0,0 +1,17 @@
apiVersion: configuration.konghq.com/v1alpha1
kind: KongService
metadata:
name: ${NAME}-payments-mcp
namespace: ${NAMESPACE}
spec:
controlPlaneRef:
type: konnectNamespacedRef
konnectNamespacedRef:
name: ${CONTROL_PLANE_REF_NAME}
name: payments-mcp
host: ${REST_HOSTNAME}
port: 80
protocol: http
path: /
tags:
- payments