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:
Stéphane Tailland
2026-08-29 08:51:15 +02:00
parent d4008e5e38
commit efb892831c
2 changed files with 329 additions and 28 deletions
+96 -28
View File
@@ -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 `catalog-api/openapi.yaml` (the catalog domain's spec, extracted from the live
backend). Every claim below was reproduced, not read in documentation. 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 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. 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 The array had almost certainly been generated once: every tool's
`annotations.title` is its OpenAPI `summary` verbatim, and tool names are `annotations.title` was its OpenAPI `summary` verbatim, and tool names were
`operationId` with underscores turned to hyphens, which is exactly what `operationId` with underscores turned to hyphens, which is exactly what
`deck file openapi2mcp` produces. It then stopped being regenerated while the `deck file openapi2mcp` produces. It then stopped being regenerated while the
specs kept moving. 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: both invisible until an agent hit them:
- **`create-product` could never succeed.** Its schema omitted `productcode`, - **`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 the trace shows a successful tool call, and nothing marks the number as
ungrounded. ungrounded.
Kong's generator emits `page` and `page_size` correctly. The bug is drift, not Kong's generator emits `page` and `page_size` correctly. Both defects were
Kong. What follows are the things Kong genuinely does not do. 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 ## 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; FastMCP's `Tool.from_tool` allows composing higher-level tools by hand;
generating them is nobody's feature yet. 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 ## Workarounds
### In place today ### In place
**The `tools:` array is hand-maintained.** That is the workaround, and it is `tools/generate-mcp-tools.py`, run per domain:
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.
### 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 `deck file openapi2mcp` does the conversion; the wrapper re-injects what Kong
re-injects what it cannot produce: cannot emit. Each step maps to one limit above, and each is deletable the day
Kong closes it:
| Step | Closes | Source of truth | | 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` | | Inject per-tool `acl.allow`, keep `include_consumer_groups` | 1 | `kong/mcp/tools-policy.yaml` |
| Derive the four annotations from the HTTP method | limit 2 | the spec's method | | Derive the four annotations from the HTTP method | 2 | the spec's method |
| Copy `responses` into each tool | limit 3 | the spec | | Inline the lowest 2xx response into `responses` | 3 | the spec |
| Rewrite the Service host to `${REST_HOSTNAME}` | limit 4 | the domain's own variables | | Rewrite the upstream to `${REST_HOSTNAME}` | 4 | the domain's variables |
| Keep `application/json` only; drop operations absent from the policy | limit 5 | the policy file | | 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 file is the allow-list as well as the ACL: an operation becomes a
the policy grants it an ACL. Adding a tool is then a deliberate act, and the tool only if the policy names it. A policy key matching no generated tool is a
current 25-tool surface is preserved exactly rather than silently tripled. 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 Rolled out across the five domains on 2026-08-29. Same 25 tools, same ACLs,
annotations, that row is deleted and nothing else changes. 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 ## Reproducing the measurements
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env -S uv run --quiet --with pyyaml --script
"""Generate a domain's ai-mcp-proxy KongPlugin from its OpenAPI spec.
./generate-mcp-tools.py ../../catalog-api # write
./generate-mcp-tools.py ../../catalog-api --check # diff only, exit 1 if stale
`deck file openapi2mcp` does the conversion. This wraps it and re-injects the
five things it cannot produce. Every step below exists because of a specific
gap in Kong's generator, documented with its evidence in MCP-TOOL-GENERATION.md
— when a Kong release closes one, delete that step and nothing else changes.
Inputs are the domain's `openapi.yaml` and `kong/mcp/tools-policy.yaml`. The
policy is the allow-list *and* the ACL: an operation becomes a tool only if the
policy names it. That is deliberate — the specs are extracted from the live
backend, so anything expressed in the spec (`x-kong-mcp-exclude`) would be
overwritten at the next extraction, and a spec that grows silently must not
grow the MCP surface with it.
Requires `deck` on PATH. Run it from anywhere; paths are resolved from the
domain directory you pass.
"""
from __future__ import annotations
import argparse
import difflib
import json
import subprocess
import sys
from pathlib import Path
from typing import Any
import yaml
PLUGIN_CONFIG = {"mode": "conversion-listener", "include_consumer_groups": True}
# Gap 4: `servers` in the spec is the gateway's own public URL — it has to be,
# the spec is also the developer portal's documentation. Kong takes it as the
# upstream target, which would make the MCP proxy loop back into the gateway.
UPSTREAM_HOST = "${REST_HOSTNAME}"
UPSTREAM_SCHEME = "http"
# Gap 5: DRF declares json, form-urlencoded and multipart for every write, with
# the identical schema. Kong copies all three, tripling the definition the model
# has to read on every turn, for a body the plugin sends as JSON.
KEEP_CONTENT_TYPE = "application/json"
class Stale(Exception):
"""--check found a difference."""
def run_deck(spec: Path) -> list[dict[str, Any]]:
out = subprocess.run(
["deck", "file", "openapi2mcp", "-s", str(spec), "--no-id", "-o", "-", "--format", "json"],
capture_output=True,
text=True,
check=True,
).stdout
doc = json.loads(out)
for service in doc.get("services", []):
for route in service.get("routes", []):
for plugin in route.get("plugins", []):
if plugin.get("name") == "ai-mcp-proxy":
return plugin["config"]["tools"]
raise SystemExit("deck produced no ai-mcp-proxy plugin — check the spec's paths")
def annotations_for(method: str, title: str) -> dict[str, Any]:
"""Gap 2: Kong emits only `title`.
The other four are in the MCP specification and follow mechanically from the
method. They are not decoration — a client reads `destructive_hint` to decide
whether to ask its user before calling.
"""
m = method.upper()
return {
"title": title,
"read_only_hint": m == "GET",
"idempotent_hint": m in {"GET", "PUT", "DELETE"},
"destructive_hint": m == "DELETE",
"open_world_hint": False,
}
def responses_for(spec: dict[str, Any], method: str, path: str) -> dict[str, Any] | None:
"""Gap 3: the plugin validates the upstream response against this and maps it
to structuredOutput. Kong's generator never emits it, so every tool returns
raw JSON today and a backend that changes shape is caught by nobody.
The plugin accepts one non-error response and does not resolve `$ref`, so
take the lowest 2xx and inline it.
"""
op = (spec.get("paths", {}).get(path, {}) or {}).get(method.lower())
if not op:
return None
ok = sorted(c for c in (op.get("responses") or {}) if c.isdigit() and int(c) < 400)
if not ok:
return None
body = resolve_refs(op["responses"][ok[0]], spec)
content = body.get("content")
if not content:
return None
kept = {k: v for k, v in content.items() if k == KEEP_CONTENT_TYPE}
return {ok[0]: {"content": kept}} if kept else None
def resolve_refs(node: Any, spec: dict[str, Any], seen: frozenset[str] = frozenset()) -> Any:
"""Inline `$ref`s. The plugin's schema states it does not resolve them.
Circular references are cut rather than followed — a self-referencing schema
would otherwise expand forever, and no spec in this estate has one.
"""
if isinstance(node, dict):
ref = node.get("$ref")
if ref:
if ref in seen:
return {"type": "object"}
target = spec
for part in ref.lstrip("#/").split("/"):
target = target[part]
return resolve_refs(target, spec, seen | {ref})
return {k: resolve_refs(v, spec, seen) for k, v in node.items()}
if isinstance(node, list):
return [resolve_refs(v, spec, seen) for v in node]
return node
def build(domain: Path) -> str:
spec_path = domain / "openapi.yaml"
policy_path = domain / "kong" / "mcp" / "tools-policy.yaml"
for p in (spec_path, policy_path):
if not p.exists():
raise SystemExit(f"missing {p}")
spec = yaml.safe_load(spec_path.read_text())
policy = yaml.safe_load(policy_path.read_text())
wanted: dict[str, Any] = policy["tools"]
generated = {t["name"]: t for t in run_deck(spec_path)}
# Loud rather than silent: a policy key that matches nothing means Kong's
# naming changed or an operation left the spec. Either way the ACL it
# carried is no longer applied, and that must not pass quietly.
unknown = sorted(set(wanted) - set(generated))
if unknown:
raise SystemExit(
f"{domain.name}: policy names tools that the spec does not produce: {unknown}\n"
f" produced: {sorted(generated)}"
)
tools = []
for name in wanted:
tool = dict(generated[name])
method, path = tool["method"], tool["path"]
tool["annotations"] = annotations_for(method, tool.get("annotations", {}).get("title", name))
# Gap 1: Kong derives per-tool ACLs only from an oauth2 scheme carrying
# x-kong-mcp-acl, keyed on OAuth scopes. Our authorization is realm roles
# mapped to consumer groups. Kong produces nothing, warns about nothing,
# and exits 0 — a regeneration without this line publishes every write
# operation to every caller.
tool["acl"] = {"allow": list(wanted[name]["allow"])}
tool["scheme"] = UPSTREAM_SCHEME
tool["host"] = UPSTREAM_HOST
if body := tool.get("request_body"):
kept = {k: v for k, v in body["content"].items() if k == KEEP_CONTENT_TYPE}
if not kept:
raise SystemExit(f"{name}: no {KEEP_CONTENT_TYPE} request body in the spec")
body["content"] = kept
if responses := responses_for(spec, method, path):
tool["responses"] = responses
tools.append(tool)
plugin = {
"apiVersion": "configuration.konghq.com/v1",
"kind": "KongPlugin",
"metadata": {
"name": "${NAME}-" + policy["mcp_name"] + "-ai-mcp-proxy",
"namespace": "${NAMESPACE}",
},
"plugin": "ai-mcp-proxy",
"config": {**PLUGIN_CONFIG, "tools": tools},
}
header = (
"# GENERATED — do not edit. Edit openapi.yaml or tools-policy.yaml, then:\n"
"# k8s-platform/tools/generate-mcp-tools.py <this domain>\n"
"#\n"
"# Produced by `deck file openapi2mcp`, then post-processed to add the ACLs,\n"
"# the MCP behavioural annotations, the response schemas, the real upstream\n"
"# host and the JSON-only bodies that Kong's generator does not emit. Each of\n"
"# those is a documented gap — see k8s-platform/MCP-TOOL-GENERATION.md.\n"
)
return header + yaml.safe_dump(plugin, sort_keys=False, width=100, allow_unicode=True)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("domain", type=Path, help="a <domain>-api directory")
ap.add_argument("--check", action="store_true", help="diff only; exit 1 if the file is stale")
args = ap.parse_args()
domain = args.domain.resolve()
target = domain / "kong" / "mcp" / "ai-mcp-proxy.yaml"
new = build(domain)
old = target.read_text() if target.exists() else ""
if new == old:
print(f"{domain.name}: up to date")
return 0
if args.check:
sys.stdout.writelines(
difflib.unified_diff(
old.splitlines(keepends=True), new.splitlines(keepends=True),
fromfile=str(target), tofile="generated",
)
)
print(f"{domain.name}: STALE", file=sys.stderr)
return 1
target.write_text(new)
print(f"{domain.name}: written ({new.count(chr(10))} lines)")
return 0
if __name__ == "__main__":
sys.exit(main())