Make the API usable by an agent, and stop the contract going stale

Someone writing a script against ShopDB had two options: read a 705 KB spec, or
guess. The spec is the reference, not an index, and handing an agent the whole
thing means either truncating it or spending a context window on 479 operations
nobody asked about.

Three additions, each feeding the next.

/api/docs/operations.txt is the index: every operation on one line with its
method, path, tag, auth and summary, at around 70 KB for all 486. A caller can
hold the entire API at once, find the four operations it needs, and only then
fetch their schemas.

/api/docs/openapi.json?tag=... narrows the spec to one of the 41 tag groups.
Components are kept intact deliberately: pruning the schema graph to match would
leave references resolving to nothing, and a generator that fails confusingly is
worse than a file that is larger than strictly necessary.

tools/mcp/ is an MCP server built on both. Five tools rather than 483, because a
tool per operation is unusable - clients degrade past a few dozen and models
start picking the wrong one. It reads everything from the live instance at call
time, so enabling a plugin adds operations it can already reach with no change
here. The token is the security boundary and the README says so plainly: every
operation the credential permits is reachable, so exploratory work wants a
read-scoped token.

The rest of this is about the contract not drifting, which is what makes any of
the above trustworthy.

Two CI jobs:

- api-contract runs the parity gates and then regenerates the spec and fails if
  anything changed. Those gates already existed and already worked - they caught
  both routes added here, unprompted - but nothing ran them except by hand, and
  the committed spec had silently drifted by three operations, including two
  setup endpoints added the day before.
- migrations-mysql is the job the header comment has been claiming for some
  time while not existing. It described "the REAL multi-site deploy path" and
  "what catches an Alembic regression on MySQL before it ships", and there was
  no such job. That is worse than no comment: it is the false confidence that
  let a migration ship which could not build a schema on a fresh MySQL database.
  The suite runs on SQLite, and SQLite accepts a foreign key onto a table that
  does not exist.

CONTRIBUTING.md now says to run gen_openapi.py --refresh after changing a route,
and that a route the sweep cannot classify belongs in PUBLIC_ENDPOINTS with a
reason rather than hand-edited into the inventory, which the next refresh would
overwrite. The new index route is declared there alongside the spec it indexes.
This commit is contained in:
cproudlock
2026-09-20 12:59:49 -04:00
parent 6c00b10618
commit 762d1b7210
11 changed files with 894 additions and 4 deletions

View File

@@ -297,12 +297,94 @@ def _spec_with_server():
@docs_bp.route('/openapi.json')
def openapi_spec():
"""The generated OpenAPI 3.1 spec (machine + LLM readable)."""
"""The generated OpenAPI 3.1 spec (machine + LLM readable).
?tag=core-assets narrows it to one tag group. The whole spec is ~700 KB
across 483 operations, which is more than a caller generating a client for
one area needs and more than most model context windows should spend. The
filtered copy keeps components intact, because a pruned schema graph is
worse than a large one: a $ref that resolves to nothing turns a working
generator into a confusing failure.
"""
if not os.path.isfile(_SPEC):
return Response('{"error":"openapi.json not generated"}',
status=404, mimetype='application/json')
return Response(json.dumps(_spec_with_server()),
mimetype='application/json')
spec = _spec_with_server()
wanted = (request.args.get('tag') or '').strip()
if wanted:
paths = {}
for path, methods in (spec.get('paths') or {}).items():
kept = {m: op for m, op in methods.items()
if not isinstance(op, dict) or wanted in (op.get('tags') or [])}
# A path whose only survivors are shared keys (parameters, summary)
# carries no operation and would read as an endpoint that exists
# and does nothing.
if any(isinstance(op, dict) and op.get('tags') for op in kept.values()):
paths[path] = kept
if not paths:
return Response(
json.dumps({'error': f'no operations tagged {wanted!r}',
'tags': sorted(_operation_tags(spec))}),
status=404, mimetype='application/json')
spec = dict(spec, paths=paths)
return Response(json.dumps(spec), mimetype='application/json')
def _operation_tags(spec):
"""Every tag that actually carries an operation."""
tags = set()
for methods in (spec.get('paths') or {}).values():
for op in methods.values():
if isinstance(op, dict):
tags.update(op.get('tags') or [])
return tags
@docs_bp.route('/operations.txt')
def operations_index():
"""Every operation, one line each: method, path, tag, auth, summary.
WHY THIS EXISTS. The spec is the reference and it is ~700 KB; this is the
index, and it is ~40 KB. A caller - a person or an agent writing a script -
can hold every one of the 483 operations at once here, find the four it
needs, and only then fetch their schemas from openapi.json?tag=... Handing
over the whole spec instead means either truncating it or spending a context
window on 479 operations nobody asked about.
Plain text on purpose: it is meant to be read and grepped, not parsed.
"""
if not os.path.isfile(_SPEC):
return Response('openapi.json not generated', status=404,
mimetype='text/plain')
spec = _spec_with_server()
servers = spec.get('servers') or [{'url': '/'}]
rows = []
for path, methods in sorted((spec.get('paths') or {}).items()):
for method, op in sorted(methods.items()):
if not isinstance(op, dict) or method.upper() not in _HTTP_METHODS:
continue
tag = (op.get('tags') or ['-'])[0]
auth = 'public' if op.get('security') == [] else (
','.join(sorted({k for s in (op.get('security') or [])
for k in s})) or 'bearer')
summary = ' '.join((op.get('summary') or '').split())
rows.append(f'{method.upper():<6} {path:<52} [{tag}] ({auth}) {summary}')
header = (
'# ShopDB API - operation index\n'
f'# {len(rows)} operations. Base URL: {servers[0].get("url", "/")}\n'
'#\n'
'# This is the INDEX. Schemas, parameters and examples are in the spec:\n'
'# /api/docs/openapi.json full (large)\n'
'# /api/docs/openapi.json?tag=<tag> one group\n'
'# Orientation for an agent, including the response envelope and auth:\n'
'# /api/docs/llms.txt\n'
'#\n'
'# METHOD path [tag] (auth) summary\n'
)
return Response(header + '\n'.join(rows) + '\n', mimetype='text/plain')
_HTTP_METHODS = {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'}
@docs_bp.route('/redoc.standalone.js')