Files
kong-k8s-platform/MCP-TOOL-GENERATION.md
Stéphane Tailland efb892831c feat(mcp): generate the domains' tool definitions, and record why Kong alone cannot
tools/generate-mcp-tools.py wraps deck file openapi2mcp and re-injects the five
things it 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. A per-domain tools-policy.yaml carries the ACLs
and doubles as the allow-list.

MCP-TOOL-GENERATION.md now records the chain as built rather than proposed, and
answers whether the ACLs could live in the spec instead: the OpenAPI Overlay
Specification 1.0.0 solves where to put them without an extraction erasing
them, but OpenAPI models authorization as OAuth scopes, which say what a client
application may do — our ACL says what this end user may do. Moving to scopes
needs Keycloak to grant them conditionally on the user's roles, a realm change,
not a spec change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 08:51:15 +02:00

295 lines
14 KiB
Markdown

# MCP tool generation — what Kong does, what it does not, and what we do about it
The five domain gateways expose their REST APIs as MCP servers through the
`ai-mcp-proxy` plugin. This file records how those tool definitions are produced,
the limits of Kong's generator, and the workarounds each limit forces on us.
It exists because those workarounds are indistinguishable from design choices
once they are in the repo. Written down, each one is a measurement of a gap in
the product, and each can be deleted the day Kong closes it.
**Measured 2026-08-29** against decK v1.64.0, Kong Gateway 3.15, and
`catalog-api/openapi.yaml` (the catalog domain's spec, extracted from the live
backend). Every claim below was reproduced, not read in documentation.
## Why this file exists
Until 2026-08-29, `<domain>-api/kong/mcp/ai-mcp-proxy.yaml` carried a
hand-maintained `tools:` array, committed to git and edited by hand. It is now
generated — see *Workarounds* below — and this section records what that cost,
because the failure mode is the argument for keeping it generated.
The array had almost certainly been generated once: every tool's
`annotations.title` was its OpenAPI `summary` verbatim, and tool names were
`operationId` with underscores turned to hyphens, which is exactly what
`deck file openapi2mcp` produces. It then stopped being regenerated while the
specs kept moving.
That drift was not theoretical. Two defects found on 2026-08-28 and 2026-08-29,
both invisible until an agent hit them:
- **`create-product` could never succeed.** Its schema omitted `productcode`,
which the API requires. 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)*. No input worked.
- **`list-products` cannot paginate.** The API accepts `page` and `page_size`;
the tool declares no parameters at all. The caller gets the first page, 50 of
110 products, with nothing to say so. An agent asked how many products are in
a product line answered *38* — the true figure — while the tool had returned
a page containing 22. The number did not come from the data. The dataset is
public and the model knows it.
This is the worst failure mode for an observable estate: the answer is right,
the trace shows a successful tool call, and nothing marks the number as
ungrounded.
Kong's generator emits `page` and `page_size` correctly. Both defects were
drift, not Kong. What follows are the things Kong genuinely does not do — the
reason a bare `deck file openapi2mcp` is not enough on its own.
## The generator
`deck file openapi2mcp` is the only generally available OAS → MCP tool in the
Kong ecosystem, confirmed three ways:
- the `ai-mcp-proxy` plugin schema (read from the compiled bytecode in the
gateway image) has no spec-ingestion field — `parameters`, `request_body` and
`responses` are per-tool OpenAPI *fragments* you supply;
- the Konnect API returns 404 for every MCP-related path
(`v3/mcp-servers`, `v2/mcp-servers`, `v3/mcp`, `v3/ai`, `v3/agents`,
`v3/tools`, `v3/composer`, `v3/ai-composer`, `v3/mcp-composer`) with a token
that returns 200 on `v2/control-planes`, `v3/apis` and `v3/portals`;
- **MCP Composer and MCP Runner never shipped.** Announced for early 2026, the
product page that carried the AI Composer now describes *Context Mesh, in
tech preview*, with a registration form and no GA date. MCP Runner is no
longer named anywhere. What did land from that roadmap is the MCP Registry —
tech preview since February 2026, and a catalogue of existing MCP servers,
not a generator.
Caveat on the Konnect probe: the token used belongs to org
`b4531006-…`, not `2254dc3b-…` where the six domains live. Entitlements can
differ per org, so the 404s prove absence from that org, not absence from the
product.
What it gets right, on our spec: `operationId` → kebab-case tool name matching
our convention, `summary` → `annotations.title`, `description` → description,
path *and* query parameters (including the pagination we are missing today),
and `requestBody` with `$ref`s resolved and inlined. Chained through
`deck file kong2kic` it produces a `KongPlugin` — the estate's own format.
## The limits
### 1. Per-tool ACLs cannot express our authorization model, and fail silently
Kong generates per-tool ACLs only from an `oauth2` security scheme carrying the
`x-kong-mcp-acl` extension, keyed on **OAuth scopes**.
Our authorization is realm roles → consumer groups
(`consumer_groups_claim: [realm_access, roles]`), and our specs declare
`ApiKeyAuth` and `JWTAuth` (http bearer). Neither is `oauth2`, so no ACL is
produced — and no `include_consumer_groups` either.
**Nothing warns.** Verified at `--verbose 3`: no error, no warning, exit 0. A
regeneration would silently publish an MCP server with every operation open to
every caller, `create-*` and `delete-*` included.
This is the most dangerous of the five, because the failure is silent and the
blast radius is the whole domain.
Note that the *plugin* supports our model perfectly well — its schema carries
`include_consumer_groups`, `acl_attribute_type`, `access_token_claim_field`,
`acl_list_field` and `default_acl`. The gap is in the generator only, and it
matters: the ACL is what makes the estate work. Measured today, `pm-user` sees
`create-product` in `tools/list` and `ro-user` does not. Kong filters at
**discovery**, so a read-only user's turn contains no write tool for a prompt
injection to reach.
### 2. MCP behavioural annotations are not generated
Kong emits `annotations.title` and nothing else. The MCP specification defines
four more — `readOnlyHint`, `idempotentHint`, `destructiveHint`,
`openWorldHint` — and all four are mechanically derivable from the HTTP method.
The committed configuration has them; a regeneration would silently drop them.
They are not decoration. An MCP client reads `destructiveHint` to decide
whether to ask its user before calling a tool.
### 3. Response schemas are not generated, though the plugin consumes them
The plugin schema documents a per-tool `responses` field: *"This specification
will be used to validate the upstream response and map it back to the
structuredOutput."*
So `ai-mcp-proxy` can validate the backend's response and return structured MCP
output. Our specs contain the `responses` needed. `openapi2mcp` does not emit
them, and neither does our hand-written configuration. Every tool therefore
returns raw JSON for the model to interpret, and a backend that changes shape
is caught by nobody.
### 4. `servers` is taken as the upstream target
The generated Kong Service points at `catalog-dev.sttlab.local:443` — the
gateway's own public hostname, because that is what the spec documents for the
developer portal. Applied as-is, the MCP proxy would loop back into the gateway
instead of reaching the REST service (`${REST_HOSTNAME}`).
The generator has no notion of *documented URL* versus *upstream target*. Any
spec written for a portal — which is to say any spec that is also API
documentation — hits this.
### 5. Every content type is copied, and every operation is exposed
DRF declares `application/json`, `application/x-www-form-urlencoded` and
`multipart/form-data` for each write operation. Kong inlines the identical
schema three times. `create-product`'s definition triples in size, for a
payload the plugin will send as JSON. There is no flag to restrict it.
And generation is all-or-nothing: 56 tools across the five domains against the
25 we expose today, `PUT`, `PATCH` and `DELETE` included. `x-kong-mcp-exclude`
exists but lives *in the spec*, and our specs are extracted from the live
backend — the exclusions would be overwritten at the next extraction.
## The comparison point
FastMCP 3.4.7, run on the same `catalog-api/openapi.yaml`:
```
outils exposés: 4 -> ['create_product', 'get_product', 'list_product_lines', 'list_products']
create_product readOnly=False idempotent=False destructive=False outputSchema=oui
get_product readOnly=True idempotent=True destructive=False outputSchema=oui
list_products readOnly=True idempotent=True destructive=False outputSchema=oui
```
Allow-listing through `route_map_fn`, the four behavioural annotations through a
six-line `mcp_component_fn` deriving them from the HTTP method, and **output
schemas generated from the OpenAPI `responses`** with no work at all. Limits 2,
3 and 5 do not exist there.
This is a comparison point, not a proposal. FastMCP produces a standalone MCP
server, so adopting it would not swap one generator for another — it would take
Kong out of the MCP server role and put five new services in its place, moving
per-tool authorization from the realm into application code in each of them.
That is the one thing the current architecture gets right, and the reason the
recommendation below keeps Kong.
What no tool does, Kong and FastMCP alike, is produce **decision-shaped** tools.
Every generator maps one operation to one tool. An agent asking "can I promise
delivery on 15 March?" wants one tool, not eleven CRUD endpoints to orchestrate.
FastMCP's `Tool.from_tool` allows composing higher-level tools by hand;
generating them is nobody's feature yet.
## Is there a standard way to put the ACLs in the spec?
Two separate questions, and they have different answers.
**Where to put it, so an extraction does not erase it: yes, and it is an OAI
standard.** The [Overlay Specification 1.0.0](https://spec.openapis.org/overlay/v1.0.0.html)
(released October 2024, stable) is a companion document that describes changes
to apply on top of an OpenAPI description without touching it — an ordered list
of actions, each targeting nodes by JSONPath (RFC 9535) and merging an `update`
into them. It can add `x-` extensions to operations. Vendor-agnostic, and it is
exactly the shape our problem has: regenerate `openapi.yaml` from the backend,
apply the overlay, get a spec carrying the authorization metadata.
**How to express the ACL itself: only partly.** The vendor-agnostic way to say
"this operation requires this authorization" in OpenAPI is `security` on the
operation, referencing an `oauth2` or `openIdConnect` scheme, with **scopes**.
There is no standard for role-based authorization — OpenAPI does not model
roles, and `x-roles` or `x-required-roles` are conventions, not specifications.
Kong's `x-kong-mcp-acl` is a vendor extension bridging scopes to its own ACL.
The realm already carries the ten scopes this would need — `catalog:read`,
`catalog:write`, `sales:read` and so on — granted to `librechat` as optional
client scopes and never requested by anything, because nothing enforces them.
But the semantics differ in a way that matters here. **OAuth scopes say what a
client application may do; our ACL says what this end user may do.** The
consumer group comes from `realm_access.roles` in the *user's* token, which is
why `pm-user` sees `create-product` and `ro-user` does not, through the same
agent holding the same client. Move that to scopes as they stand and every user
of LibreChat inherits all ten, so every user sees every tool.
Making it work would mean Keycloak granting the scopes conditionally on the
user's roles, so the exchanged token carries only the scopes that user is
entitled to. That is a realm design change, not a spec change, and it is the
real prerequisite — the Overlay is only the delivery mechanism.
Until that is decided, `tools-policy.yaml` holds the ACLs. It is a local
convention, and this section is why.
## Workarounds
### In place
`tools/generate-mcp-tools.py`, run per domain:
```bash
k8s-platform/tools/generate-mcp-tools.py <domain>-api # write
k8s-platform/tools/generate-mcp-tools.py <domain>-api --check # diff, exit 1 if stale
```
`deck file openapi2mcp` does the conversion; the wrapper re-injects what Kong
cannot emit. Each step maps to one limit above, and each is deletable the day
Kong closes it:
| Step | Closes | Source of truth |
|---|---|---|
| Inject per-tool `acl.allow`, keep `include_consumer_groups` | 1 | `kong/mcp/tools-policy.yaml` |
| Derive the four annotations from the HTTP method | 2 | the spec's method |
| Inline the lowest 2xx response into `responses` | 3 | the spec |
| Rewrite the upstream to `${REST_HOSTNAME}` | 4 | the domain's variables |
| Keep `application/json`; drop operations absent from the policy | 5 | the spec, the policy |
The policy file is the allow-list as well as the ACL: an operation becomes a
tool only if the policy names it. A policy key matching no generated tool is a
hard error, not a warning — it means Kong's naming changed or the operation left
the spec, and either way an ACL has stopped being applied.
Rolled out across the five domains on 2026-08-29. Same 25 tools, same ACLs,
verified against the live gateways:
```
list-products {"query_page": 2, "query_page_size": 5}
-> count: 110 | returned: 5 | structured_content: yes
pm-user catalog ['create-product', 'get-product', 'list-products']
ro-user catalog ['get-product', 'list-products']
```
Three things changed for callers. Pagination is callable at all. Every tool now
carries an `outputSchema` and returns structured content instead of raw JSON.
And `create-product`'s required list matches the API — including
`quantityinstock`, which the hand-written repair had missed because it was
written from the `Product` response schema rather than `ProductRequest`.
Worth knowing when calling by hand: the plugin prefixes arguments by their
location. `list-products` takes `query_page`, `get-product` takes
`path_productcode`, and a request body goes under `body`. Nothing documents this
outside the generated `inputSchema`.
### Not addressed
Tool granularity. Every generator maps one operation to one tool, so an agent
that wants to ask one decision-shaped question gets a pile of CRUD endpoints to
orchestrate instead. Kong's answer to this was MCP Composer, which never
shipped. FastMCP's `Tool.from_tool` composes higher-level tools by hand.
Generating them is nobody's feature.
## Reproducing the measurements
```bash
cd <domain>-api
deck file openapi2mcp -s openapi.yaml --no-id -o /tmp/gen.yaml # generate
deck file kong2kic -s /tmp/gen.yaml # to KongPlugin
deck file openapi2mcp -s openapi.yaml --no-id --verbose 3 -o /dev/null | grep -i warn
```
The plugin's own schema, if the field list is ever in doubt:
```bash
POD=$(kubectl -n catalog get pods -o name | grep dataplane | head -1)
kubectl -n catalog exec ${POD#pod/} -- \
cat /usr/local/share/lua/5.1/kong/plugins/ai-mcp-proxy/schema.ljbc > schema.ljbc
LC_ALL=C strings schema.ljbc | LC_ALL=C grep -E '^[a-z][a-z_]{2,30}$' | sort -u
```