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>
This commit is contained in:
+96
-28
@@ -12,18 +12,20 @@ the product, and each can be deleted the day Kong closes it.
|
||||
`catalog-api/openapi.yaml` (the catalog domain's spec, extracted from the live
|
||||
backend). Every claim below was reproduced, not read in documentation.
|
||||
|
||||
## How the tool definitions are produced today
|
||||
## Why this file exists
|
||||
|
||||
They are not produced. `<domain>-api/kong/mcp/ai-mcp-proxy.yaml` carries a
|
||||
hand-maintained `tools:` array, committed to git and edited by hand.
|
||||
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 was almost certainly generated once — every tool's
|
||||
`annotations.title` is its OpenAPI `summary` verbatim, and tool names are
|
||||
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 is not theoretical. Two defects found on 2026-08-28 and 2026-08-29,
|
||||
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`,
|
||||
@@ -42,8 +44,9 @@ both invisible until an agent hit them:
|
||||
the trace shows a successful tool call, and nothing marks the number as
|
||||
ungrounded.
|
||||
|
||||
Kong's generator emits `page` and `page_size` correctly. The bug is drift, not
|
||||
Kong. What follows are the things Kong genuinely does not do.
|
||||
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
|
||||
|
||||
@@ -176,36 +179,101 @@ 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 today
|
||||
### In place
|
||||
|
||||
**The `tools:` array is hand-maintained.** That is the workaround, and it is
|
||||
also the cause of both defects above. `create-product`'s schema was repaired by
|
||||
hand on 2026-08-29 — and the repair itself was wrong: it omitted
|
||||
`quantityinstock` from `required`, because it was written from the `Product`
|
||||
response schema instead of `ProductRequest`. Kong's generator gets that right.
|
||||
The hand-maintained file is not a stable state; it is a debt accruing silently.
|
||||
`tools/generate-mcp-tools.py`, run per domain:
|
||||
|
||||
### Proposed, not yet built
|
||||
```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
|
||||
```
|
||||
|
||||
Keep `openapi2mcp` as the generator and add one post-processing step that
|
||||
re-injects what it cannot produce:
|
||||
`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` and `include_consumer_groups` | limit 1 | a per-domain policy file, `kong/mcp/tools-policy.yaml` |
|
||||
| Derive the four annotations from the HTTP method | limit 2 | the spec's method |
|
||||
| Copy `responses` into each tool | limit 3 | the spec |
|
||||
| Rewrite the Service host to `${REST_HOSTNAME}` | limit 4 | the domain's own variables |
|
||||
| Keep `application/json` only; drop operations absent from the policy | limit 5 | the policy file |
|
||||
| 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 doubles as the allow-list: an operation becomes a tool only if
|
||||
the policy grants it an ACL. Adding a tool is then a deliberate act, and the
|
||||
current 25-tool surface is preserved exactly rather than silently tripled.
|
||||
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.
|
||||
|
||||
Each row is a line item against Kong. If a future release generates
|
||||
annotations, that row is deleted and nothing else changes.
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user