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
|
`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
|
||||||
|
|
||||||
|
|||||||
Executable
+233
@@ -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())
|
||||||
Reference in New Issue
Block a user