From efb892831ca634aa0292c27875104a2b57cf1e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Tailland?= Date: Sat, 29 Aug 2026 08:51:15 +0200 Subject: [PATCH] feat(mcp): generate the domains' tool definitions, and record why Kong alone cannot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- MCP-TOOL-GENERATION.md | 124 ++++++++++++++----- tools/generate-mcp-tools.py | 233 ++++++++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+), 28 deletions(-) create mode 100755 tools/generate-mcp-tools.py diff --git a/MCP-TOOL-GENERATION.md b/MCP-TOOL-GENERATION.md index b086b2d..8cd7dc9 100644 --- a/MCP-TOOL-GENERATION.md +++ b/MCP-TOOL-GENERATION.md @@ -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. `-api/kong/mcp/ai-mcp-proxy.yaml` carries a -hand-maintained `tools:` array, committed to git and edited by hand. +Until 2026-08-29, `-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 -api # write +k8s-platform/tools/generate-mcp-tools.py -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 diff --git a/tools/generate-mcp-tools.py b/tools/generate-mcp-tools.py new file mode 100755 index 0000000..673565b --- /dev/null +++ b/tools/generate-mcp-tools.py @@ -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 \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 -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())