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:
101
.github/workflows/ci.yml
vendored
101
.github/workflows/ci.yml
vendored
@@ -6,6 +6,7 @@
|
|||||||
# gates - proves those checks actually RAN rather than skipping
|
# gates - proves those checks actually RAN rather than skipping
|
||||||
# frontend - vitest + Vue build
|
# frontend - vitest + Vue build
|
||||||
# lean-build - ADR-013: a per-site build carries only its plugins
|
# lean-build - ADR-013: a per-site build carries only its plugins
|
||||||
|
# api-contract - the published spec matches the code that serves it
|
||||||
# migrations-mysql - the REAL multi-site deploy path: fresh flask db upgrade
|
# migrations-mysql - the REAL multi-site deploy path: fresh flask db upgrade
|
||||||
# + every plugin's chain on utf8mb4 MySQL 8, idempotent on
|
# + every plugin's chain on utf8mb4 MySQL 8, idempotent on
|
||||||
# a second run. The pytest suite only exercises SQLite
|
# a second run. The pytest suite only exercises SQLite
|
||||||
@@ -53,6 +54,106 @@ jobs:
|
|||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
- run: bash scripts/check-naming-and-style.sh
|
- run: bash scripts/check-naming-and-style.sh
|
||||||
|
|
||||||
|
api-contract:
|
||||||
|
# The published API contract has to match the code that serves it. These
|
||||||
|
# gates catch a route that exists and is undocumented, a query parameter the
|
||||||
|
# handler reads that the spec omits, and a public route nobody declared
|
||||||
|
# public. They already work; until now nothing ran them except by hand, and
|
||||||
|
# the spec had silently drifted by three operations.
|
||||||
|
runs-on: arc-runner-set
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: '3.14'
|
||||||
|
cache: pip
|
||||||
|
- run: pip install -r requirements-dev.txt
|
||||||
|
- run: |
|
||||||
|
python -m pytest -q \
|
||||||
|
tests/test_api_inventory_parity.py \
|
||||||
|
tests/test_openapi_spec.py \
|
||||||
|
tests/test_openapi_examples.py \
|
||||||
|
tests/test_core/test_public_read_surface.py
|
||||||
|
- name: The spec must be regenerated, not just passing
|
||||||
|
# --refresh is derived from the code, so if it changes anything the
|
||||||
|
# committed spec was stale. Fail here rather than let a generated client
|
||||||
|
# be built from a file that no longer describes the API.
|
||||||
|
run: |
|
||||||
|
python scripts/gen_openapi.py --refresh
|
||||||
|
if ! git diff --quiet docs/openapi.json docs/api-inventory.json; then
|
||||||
|
echo "docs/openapi.json is stale. Run:"
|
||||||
|
echo " python scripts/gen_openapi.py --refresh"
|
||||||
|
git --no-pager diff --stat docs/openapi.json docs/api-inventory.json
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
migrations-mysql:
|
||||||
|
# THE JOB THE HEADER PROMISED AND NOBODY WROTE. Until now this existed only
|
||||||
|
# as a comment claiming to cover "the REAL multi-site deploy path", which is
|
||||||
|
# worse than no comment: it is exactly the false confidence that let a
|
||||||
|
# migration ship which could not build a schema on a fresh MySQL database.
|
||||||
|
#
|
||||||
|
# The pytest suite runs on SQLite, and SQLite accepts a foreign key onto a
|
||||||
|
# table that does not exist. MySQL does not. That difference is invisible to
|
||||||
|
# every other job here.
|
||||||
|
runs-on: arc-runner-set
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
env:
|
||||||
|
MYSQL_ROOT_PASSWORD: ci
|
||||||
|
MYSQL_DATABASE: shopdb_flask
|
||||||
|
options: >-
|
||||||
|
--health-cmd="mysqladmin ping -h 127.0.0.1 -u root -pci"
|
||||||
|
--health-interval=10s --health-timeout=5s --health-retries=10
|
||||||
|
ports:
|
||||||
|
- 3306:3306
|
||||||
|
env:
|
||||||
|
DATABASE_URL: mysql+pymysql://root:ci@127.0.0.1:3306/shopdb_flask?charset=utf8mb4
|
||||||
|
FLASK_APP: shopdb
|
||||||
|
SECRET_KEY: ci-not-a-secret
|
||||||
|
JWT_SECRET_KEY: ci-not-a-secret
|
||||||
|
CORS_ORIGINS: http://localhost
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: '3.14'
|
||||||
|
cache: pip
|
||||||
|
- run: pip install -r requirements.txt
|
||||||
|
- name: Fresh install, exactly as the README tells a site to do it
|
||||||
|
run: |
|
||||||
|
flask db upgrade
|
||||||
|
flask plugin upgrade-all
|
||||||
|
flask seed permissions
|
||||||
|
flask seed settings
|
||||||
|
flask seed reference-data
|
||||||
|
- name: Enable every bundled plugin and migrate it
|
||||||
|
# A plugin's chain only runs once the plugin is ENABLED, so a core
|
||||||
|
# migration that depends on a plugin-owned table passes an empty install
|
||||||
|
# and fails a real one. Enabling them here is what reproduces that.
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import json, os, pathlib, datetime
|
||||||
|
root = pathlib.Path('plugins')
|
||||||
|
names = sorted(d.name for d in root.iterdir()
|
||||||
|
if (d / 'manifest.json').is_file())
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
state = {'plugins': {n: {'name': n, 'version': '1.0.0',
|
||||||
|
'installed_at': now, 'enabled': True,
|
||||||
|
'migrations_applied': [], 'config': {}}
|
||||||
|
for n in names}}
|
||||||
|
out = pathlib.Path('instance'); out.mkdir(exist_ok=True)
|
||||||
|
(out / 'plugins.json').write_text(json.dumps(state, indent=2))
|
||||||
|
print('enabled', len(names), 'plugins')
|
||||||
|
PY
|
||||||
|
flask plugin upgrade-all
|
||||||
|
- name: Re-run to prove it is idempotent
|
||||||
|
run: |
|
||||||
|
flask db upgrade
|
||||||
|
flask plugin upgrade-all
|
||||||
|
flask seed permissions
|
||||||
|
|
||||||
gates:
|
gates:
|
||||||
# Prove the gates RAN. Several skip themselves when the file they read is
|
# Prove the gates RAN. Several skip themselves when the file they read is
|
||||||
# absent, which is correct in this checkout (docs/ becomes the wiki) and was
|
# absent, which is correct in this checkout (docs/ becomes the wiki) and was
|
||||||
|
|||||||
@@ -169,6 +169,32 @@ Tests are required for:
|
|||||||
- New plugin hooks (contract test)
|
- New plugin hooks (contract test)
|
||||||
- Schema migrations (before/after data integrity)
|
- Schema migrations (before/after data integrity)
|
||||||
|
|
||||||
|
## Adding or changing an API route
|
||||||
|
|
||||||
|
The published contract is generated from the code, so it goes stale the moment
|
||||||
|
a route changes and nobody regenerates it. A stale spec is worse than no spec:
|
||||||
|
it is what a client generator, an agent, or the MCP server in `tools/mcp/` will
|
||||||
|
believe.
|
||||||
|
|
||||||
|
After adding or changing a route:
|
||||||
|
|
||||||
|
python scripts/gen_openapi.py --refresh
|
||||||
|
|
||||||
|
That rewrites `docs/api-inventory.json` and `docs/openapi.json` from the live
|
||||||
|
app. It fills in what is derived and leaves prose you have written alone, so run
|
||||||
|
it freely and then replace any seeded summary with the reason the route exists.
|
||||||
|
|
||||||
|
If it prints `review` against your route, it could not find a gate on the view.
|
||||||
|
Do not hand-edit the inventory - the next refresh overwrites it. Either gate the
|
||||||
|
view, or, if the route is genuinely public, name it in `PUBLIC_ENDPOINTS` in
|
||||||
|
`tests/test_core/test_public_read_surface.py` with the reason. The generator
|
||||||
|
reads that list, so the published contract and the enforced gate cannot
|
||||||
|
disagree.
|
||||||
|
|
||||||
|
The `api-contract` CI job runs the parity gates and then regenerates and fails
|
||||||
|
if anything changed, so a stale spec fails the pull request rather than being
|
||||||
|
discovered months later.
|
||||||
|
|
||||||
## Branches: what belongs on main, and what does not
|
## Branches: what belongs on main, and what does not
|
||||||
|
|
||||||
`main` is the curated product other sites adopt, and what the publication
|
`main` is the curated product other sites adopt, and what the publication
|
||||||
|
|||||||
@@ -2445,7 +2445,22 @@
|
|||||||
"auth": "none (reviewed: named in the public-read or mutation sweep)",
|
"auth": "none (reviewed: named in the public-read or mutation sweep)",
|
||||||
"params": "not documented",
|
"params": "not documented",
|
||||||
"purpose": "The generated OpenAPI 3.1 spec (machine + LLM readable).",
|
"purpose": "The generated OpenAPI 3.1 spec (machine + LLM readable).",
|
||||||
"example": "curl http://localhost:5001/api/docs/openapi.json"
|
"example": "curl http://localhost:5001/api/docs/openapi.json",
|
||||||
|
"queryparams": [
|
||||||
|
{
|
||||||
|
"name": "tag",
|
||||||
|
"type": "string",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/docs/operations.txt",
|
||||||
|
"auth": "none (reviewed: named in the public-read or mutation sweep)",
|
||||||
|
"params": "not documented",
|
||||||
|
"purpose": "Every operation, one line each: method, path, tag, auth, summary.",
|
||||||
|
"example": "curl http://localhost:5001/api/docs/operations.txt"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
@@ -4780,6 +4795,14 @@
|
|||||||
{
|
{
|
||||||
"surface": "core-setup",
|
"surface": "core-setup",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/setup/checklist",
|
||||||
|
"auth": "jwt + role:admin",
|
||||||
|
"params": "not documented",
|
||||||
|
"purpose": "What is configured and what is not, with a link to each fix.",
|
||||||
|
"example": "curl http://localhost:5001/api/setup/checklist -H \"Authorization: Bearer $TOK\""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/setup/complete",
|
"path": "/api/setup/complete",
|
||||||
@@ -4797,6 +4820,14 @@
|
|||||||
"example": "curl -X POST http://localhost:5001/api/setup/create-admin -H 'Content-Type: application/json' -d '{}'",
|
"example": "curl -X POST http://localhost:5001/api/setup/create-admin -H 'Content-Type: application/json' -d '{}'",
|
||||||
"readsbody": true
|
"readsbody": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/setup/health",
|
||||||
|
"auth": "jwt + role:admin",
|
||||||
|
"params": "not documented",
|
||||||
|
"purpose": "Post-setup reality check, including whether a restart is outstanding.",
|
||||||
|
"example": "curl http://localhost:5001/api/setup/health -H \"Authorization: Bearer $TOK\""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/setup/needs-admin",
|
"path": "/api/setup/needs-admin",
|
||||||
|
|||||||
@@ -6574,6 +6574,46 @@
|
|||||||
"summary": "The generated OpenAPI 3.1 spec (machine + LLM readable).",
|
"summary": "The generated OpenAPI 3.1 spec (machine + LLM readable).",
|
||||||
"description": "The generated OpenAPI 3.1 spec (machine + LLM readable).\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/docs/openapi.json\n```",
|
"description": "The generated OpenAPI 3.1 spec (machine + LLM readable).\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/docs/openapi.json\n```",
|
||||||
"security": [],
|
"security": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tag",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/docs/operations.txt": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"core-docs"
|
||||||
|
],
|
||||||
|
"summary": "Every operation, one line each: method, path, tag, auth, summary.",
|
||||||
|
"description": "Every operation, one line each: method, path, tag, auth, summary.\n\n**Auth:** none (reviewed: named in the public-read or mutation sweep)\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/docs/operations.txt\n```",
|
||||||
|
"security": [],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
@@ -12774,6 +12814,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/setup/checklist": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"core-setup"
|
||||||
|
],
|
||||||
|
"summary": "What is configured and what is not, with a link to each fix.",
|
||||||
|
"description": "What is configured and what is not, with a link to each fix.\n\n**Auth:** jwt + role:admin\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/setup/checklist -H \"Authorization: Bearer $TOK\"\n```",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid credentials."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Authenticated, but not permitted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/setup/complete": {
|
"/api/setup/complete": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12868,6 +12948,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/setup/health": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"core-setup"
|
||||||
|
],
|
||||||
|
"summary": "Post-setup reality check, including whether a restart is outstanding.",
|
||||||
|
"description": "Post-setup reality check, including whether a restart is outstanding.\n\n**Auth:** jwt + role:admin\n\n**Params:** not documented\n\n**Example:**\n```\ncurl http://localhost:5001/api/setup/health -H \"Authorization: Bearer $TOK\"\n```",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid credentials."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Authenticated, but not permitted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/setup/needs-admin": {
|
"/api/setup/needs-admin": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -297,12 +297,94 @@ def _spec_with_server():
|
|||||||
|
|
||||||
@docs_bp.route('/openapi.json')
|
@docs_bp.route('/openapi.json')
|
||||||
def openapi_spec():
|
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):
|
if not os.path.isfile(_SPEC):
|
||||||
return Response('{"error":"openapi.json not generated"}',
|
return Response('{"error":"openapi.json not generated"}',
|
||||||
status=404, mimetype='application/json')
|
status=404, mimetype='application/json')
|
||||||
return Response(json.dumps(_spec_with_server()),
|
spec = _spec_with_server()
|
||||||
mimetype='application/json')
|
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')
|
@docs_bp.route('/redoc.standalone.js')
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ PUBLIC_ENDPOINTS = {
|
|||||||
|
|
||||||
# API documentation: shapes, not data. The docs page loads its own script.
|
# API documentation: shapes, not data. The docs page loads its own script.
|
||||||
'docs.docs_index', 'docs.openapi_spec', 'docs.redoc_js', 'docs.llms_txt',
|
'docs.docs_index', 'docs.openapi_spec', 'docs.redoc_js', 'docs.llms_txt',
|
||||||
|
# The operation index is the same shape-not-data as the spec it indexes:
|
||||||
|
# method, path, tag and summary for routes the spec already publishes.
|
||||||
|
'docs.operations_index',
|
||||||
|
|
||||||
# Collector ingest, authenticated by the per-plugin collector key.
|
# Collector ingest, authenticated by the per-plugin collector key.
|
||||||
'collector.collector_schemas', 'collector.collector_status',
|
'collector.collector_schemas', 'collector.collector_status',
|
||||||
|
|||||||
86
tools/mcp/README.md
Normal file
86
tools/mcp/README.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# ShopDB MCP server
|
||||||
|
|
||||||
|
Exposes a ShopDB instance to an LLM client (Claude Code, Claude Desktop, or any
|
||||||
|
other MCP client) so it can discover and call the API directly, instead of
|
||||||
|
someone generating a client by hand from the spec.
|
||||||
|
|
||||||
|
## The token is the security boundary
|
||||||
|
|
||||||
|
**Read this before pointing it at production.**
|
||||||
|
|
||||||
|
Every operation the credential is allowed to perform is reachable through this
|
||||||
|
server, including writes and deletes. There is no separate allow-list here: the
|
||||||
|
scope of the personal access token decides what can happen, and nothing else
|
||||||
|
does.
|
||||||
|
|
||||||
|
That is a deliberate choice, and it puts the decision where it can be enforced
|
||||||
|
server-side rather than in a client that a model is talking to. It also means:
|
||||||
|
|
||||||
|
- For anything exploratory, create a **read-scoped token**. A read-scoped token
|
||||||
|
cannot be talked into a write, whatever the model decides to call.
|
||||||
|
- Give it the narrowest scopes that cover the task, not your admin session.
|
||||||
|
- `DELETE` against an asset database is irreversible. The tool descriptions say
|
||||||
|
so, which makes a model markedly more careful, but a token that permits it is
|
||||||
|
the only thing standing in the way.
|
||||||
|
|
||||||
|
Create a token under Settings > API Tokens. `shopdb_whoami` reports what the
|
||||||
|
token can do; call it first.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
pip install -e tools/mcp
|
||||||
|
|
||||||
|
Then register it with your client. For Claude Code:
|
||||||
|
|
||||||
|
claude mcp add shopdb \
|
||||||
|
--env SHOPDB_URL=https://shopdb.example.net/shopdb \
|
||||||
|
--env SHOPDB_TOKEN=<token> \
|
||||||
|
-- shopdb-mcp
|
||||||
|
|
||||||
|
`SHOPDB_URL` must include the mount path if the site is served under one
|
||||||
|
(`/shopdb`), because that is what the instance's own spec declares.
|
||||||
|
|
||||||
|
| Variable | Required | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `SHOPDB_URL` | yes | Base URL including any mount path |
|
||||||
|
| `SHOPDB_TOKEN` | yes | Personal access token |
|
||||||
|
| `SHOPDB_VERIFY_TLS` | no | `0` to accept a self-signed certificate. Defaults to verifying |
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `shopdb_whoami` | Instance, identity, scopes, operation count. Call first |
|
||||||
|
| `shopdb_search_operations` | Find operations by keyword or tag. Start here |
|
||||||
|
| `shopdb_describe_operation` | Parameters, request body and responses for one operation |
|
||||||
|
| `shopdb_call` | Call any operation |
|
||||||
|
| `shopdb_llms_guide` | The instance's own orientation guide |
|
||||||
|
|
||||||
|
## Why five tools and not 483
|
||||||
|
|
||||||
|
ShopDB publishes 483 operations. One tool each would be unusable: MCP clients
|
||||||
|
degrade badly past a few dozen tools and models start choosing wrong ones. So
|
||||||
|
the surface is small and generic, and the instance's own published index is what
|
||||||
|
makes it navigable - `search` reads `/api/docs/operations.txt` (about 70 KB for
|
||||||
|
every operation) and `describe` fetches only the tag group it needs rather than
|
||||||
|
the 700 KB spec.
|
||||||
|
|
||||||
|
Nothing is generated ahead of time. Enabling a plugin adds operations this
|
||||||
|
server can already reach, with no change here and no reinstall.
|
||||||
|
|
||||||
|
## Staying current
|
||||||
|
|
||||||
|
The server reads everything from the live instance, so it describes whatever
|
||||||
|
that instance actually serves. The staleness risk is upstream: `docs/openapi.json`
|
||||||
|
is generated and committed, and if it drifts, the instance serves a stale spec
|
||||||
|
and this server faithfully reports stale information.
|
||||||
|
|
||||||
|
That is guarded by the `api-contract` CI job and by
|
||||||
|
`python scripts/gen_openapi.py --refresh`. See CONTRIBUTING.md.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Air-gapped sites need `mcp` vendored; it is not bundled here.
|
||||||
|
- No streaming or subscriptions - every tool is a request and a response.
|
||||||
|
- Responses over 100 KB are truncated with a note. Paginate instead
|
||||||
|
(`perpage` is silently capped at 100 by the API).
|
||||||
16
tools/mcp/pyproject.toml
Normal file
16
tools/mcp/pyproject.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[project]
|
||||||
|
name = "shopdb-mcp"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "MCP server for a ShopDB instance"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = ["mcp>=1.2"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
shopdb-mcp = "shopdb_mcp:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["shopdb_mcp*"]
|
||||||
8
tools/mcp/shopdb_mcp/__init__.py
Normal file
8
tools/mcp/shopdb_mcp/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""MCP server exposing a ShopDB instance to an LLM client."""
|
||||||
|
|
||||||
|
__all__ = ['main']
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
from .server import main as _main
|
||||||
|
_main()
|
||||||
191
tools/mcp/shopdb_mcp/client.py
Normal file
191
tools/mcp/shopdb_mcp/client.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"""HTTP access to a ShopDB instance, and the operation index it publishes.
|
||||||
|
|
||||||
|
Everything the server knows about the API is fetched from the instance at
|
||||||
|
runtime: the operation index, the schemas, the mount path. Nothing is generated
|
||||||
|
ahead of time and nothing is pinned, so a site running an older or newer build
|
||||||
|
is described accurately rather than according to whatever was true when this
|
||||||
|
package was written.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from urllib import error, parse, request
|
||||||
|
|
||||||
|
# How long a fetched index or spec stays usable before it is re-read. Long
|
||||||
|
# enough that a burst of tool calls does not re-download a 700 KB document,
|
||||||
|
# short enough that enabling a plugin shows up without restarting the server.
|
||||||
|
CACHE_SECONDS = 300
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 30
|
||||||
|
|
||||||
|
# Verbs that change or destroy data. Named so the tool descriptions can say so:
|
||||||
|
# a model told that DELETE is irreversible behaves better than one left to infer
|
||||||
|
# it from the verb.
|
||||||
|
WRITE_METHODS = {'POST', 'PUT', 'PATCH', 'DELETE'}
|
||||||
|
DESTRUCTIVE_METHODS = {'DELETE'}
|
||||||
|
|
||||||
|
|
||||||
|
class ShopdbError(RuntimeError):
|
||||||
|
"""An error worth showing the caller verbatim."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Operation:
|
||||||
|
method: str
|
||||||
|
path: str
|
||||||
|
tag: str
|
||||||
|
auth: str
|
||||||
|
summary: str
|
||||||
|
|
||||||
|
def matches(self, needle: str) -> bool:
|
||||||
|
blob = f'{self.method} {self.path} {self.tag} {self.summary}'.lower()
|
||||||
|
return all(word in blob for word in needle.lower().split())
|
||||||
|
|
||||||
|
|
||||||
|
class ShopdbClient:
|
||||||
|
"""Talks to one ShopDB instance with one credential."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str | None = None, token: str | None = None,
|
||||||
|
verify_tls: bool | None = None, timeout: int = DEFAULT_TIMEOUT):
|
||||||
|
self.base_url = (base_url or os.environ.get('SHOPDB_URL') or '').rstrip('/')
|
||||||
|
self.token = token or os.environ.get('SHOPDB_TOKEN') or ''
|
||||||
|
if verify_tls is None:
|
||||||
|
# Sites commonly run a self-signed certificate on an internal name,
|
||||||
|
# so this has to be settable - but it defaults to verifying, and the
|
||||||
|
# caller has to say otherwise deliberately.
|
||||||
|
verify_tls = (os.environ.get('SHOPDB_VERIFY_TLS', '1').strip().lower()
|
||||||
|
not in ('0', 'false', 'no'))
|
||||||
|
self.verify_tls = verify_tls
|
||||||
|
self.timeout = timeout
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._cache: dict[str, tuple[float, Any]] = {}
|
||||||
|
|
||||||
|
if not self.base_url:
|
||||||
|
raise ShopdbError(
|
||||||
|
'SHOPDB_URL is not set. Point it at the instance, including any '
|
||||||
|
'mount path, for example https://shopdb.example.net/shopdb')
|
||||||
|
|
||||||
|
# ---- plumbing --------------------------------------------------------
|
||||||
|
|
||||||
|
def _opener(self):
|
||||||
|
if self.verify_tls:
|
||||||
|
return request.build_opener()
|
||||||
|
import ssl
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
return request.build_opener(request.HTTPSHandler(context=ctx))
|
||||||
|
|
||||||
|
def request(self, method: str, path: str, params: dict | None = None,
|
||||||
|
body: Any = None) -> tuple[int, Any]:
|
||||||
|
"""One HTTP call. Returns (status, parsed-body-or-text)."""
|
||||||
|
url = self.base_url + '/' + path.lstrip('/')
|
||||||
|
if params:
|
||||||
|
# Drop empty values rather than sending key= , which several list
|
||||||
|
# endpoints treat as a filter on the empty string.
|
||||||
|
clean = {k: v for k, v in params.items() if v not in (None, '')}
|
||||||
|
if clean:
|
||||||
|
url += ('&' if '?' in url else '?') + parse.urlencode(clean, doseq=True)
|
||||||
|
|
||||||
|
data = None
|
||||||
|
headers = {'Accept': 'application/json'}
|
||||||
|
if self.token:
|
||||||
|
headers['Authorization'] = f'Bearer {self.token}'
|
||||||
|
if body is not None:
|
||||||
|
data = json.dumps(body).encode('utf-8')
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
|
||||||
|
req = request.Request(url, data=data, headers=headers, method=method.upper())
|
||||||
|
try:
|
||||||
|
with self._opener().open(req, timeout=self.timeout) as response:
|
||||||
|
return response.status, _decode(response.read(),
|
||||||
|
response.headers.get('Content-Type'))
|
||||||
|
except error.HTTPError as exc:
|
||||||
|
# An error body from this API is the useful part (it carries the
|
||||||
|
# error code and message), so return it rather than raising.
|
||||||
|
return exc.code, _decode(exc.read(), exc.headers.get('Content-Type'))
|
||||||
|
except error.URLError as exc:
|
||||||
|
raise ShopdbError(f'could not reach {self.base_url}: {exc.reason}') from exc
|
||||||
|
|
||||||
|
def _cached(self, key: str, fetch):
|
||||||
|
with self._lock:
|
||||||
|
hit = self._cache.get(key)
|
||||||
|
if hit and (time.time() - hit[0]) < CACHE_SECONDS:
|
||||||
|
return hit[1]
|
||||||
|
value = fetch()
|
||||||
|
with self._lock:
|
||||||
|
self._cache[key] = (time.time(), value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
# ---- what the instance publishes about itself ------------------------
|
||||||
|
|
||||||
|
def operations(self) -> list[Operation]:
|
||||||
|
"""The published operation index, parsed.
|
||||||
|
|
||||||
|
Read from /api/docs/operations.txt rather than the full spec: the index
|
||||||
|
is around 70 KB against 700 KB, and listing what exists does not need
|
||||||
|
the schemas.
|
||||||
|
"""
|
||||||
|
def fetch():
|
||||||
|
status, text = self.request('GET', '/api/docs/operations.txt')
|
||||||
|
if status != 200:
|
||||||
|
raise ShopdbError(
|
||||||
|
f'operation index unavailable (HTTP {status}). This instance '
|
||||||
|
'may predate /api/docs/operations.txt.')
|
||||||
|
return _parse_index(text if isinstance(text, str) else str(text))
|
||||||
|
return self._cached('operations', fetch)
|
||||||
|
|
||||||
|
def spec(self, tag: str | None = None) -> dict:
|
||||||
|
"""The OpenAPI spec, optionally narrowed to one tag group."""
|
||||||
|
def fetch():
|
||||||
|
path = '/api/docs/openapi.json'
|
||||||
|
status, payload = self.request('GET', path, params={'tag': tag} if tag else None)
|
||||||
|
if status != 200:
|
||||||
|
raise ShopdbError(f'spec unavailable (HTTP {status}): {payload}')
|
||||||
|
return payload
|
||||||
|
return self._cached(f'spec:{tag or "*"}', fetch)
|
||||||
|
|
||||||
|
def find_operation(self, method: str, path: str) -> dict | None:
|
||||||
|
"""The spec entry for one operation, fetched by its tag to stay small."""
|
||||||
|
method = method.lower()
|
||||||
|
for op in self.operations():
|
||||||
|
if op.method.lower() == method and op.path == path:
|
||||||
|
spec = self.spec(op.tag)
|
||||||
|
return (spec.get('paths', {}).get(path, {}) or {}).get(method)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_INDEX_LINE = re.compile(
|
||||||
|
r'^(?P<method>[A-Z]+)\s+(?P<path>\S+)\s+\[(?P<tag>[^\]]*)\]\s+'
|
||||||
|
r'\((?P<auth>[^)]*)\)\s*(?P<summary>.*)$')
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_index(text: str) -> list[Operation]:
|
||||||
|
out = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
m = _INDEX_LINE.match(line)
|
||||||
|
if m:
|
||||||
|
out.append(Operation(m.group('method'), m.group('path'),
|
||||||
|
m.group('tag'), m.group('auth'),
|
||||||
|
m.group('summary').strip()))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(raw: bytes, content_type: str | None):
|
||||||
|
text = raw.decode('utf-8', errors='replace')
|
||||||
|
if content_type and 'json' in content_type.lower():
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return text
|
||||||
|
return text
|
||||||
226
tools/mcp/shopdb_mcp/server.py
Normal file
226
tools/mcp/shopdb_mcp/server.py
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
"""MCP server for a ShopDB instance.
|
||||||
|
|
||||||
|
WHY IT IS SHAPED LIKE THIS. ShopDB publishes 483 operations. Exposing one tool
|
||||||
|
per operation would be useless: MCP clients degrade badly past a few dozen tools
|
||||||
|
and a model starts picking the wrong one. So the surface here is small and
|
||||||
|
generic - find an operation, read its contract, call it - and the instance's own
|
||||||
|
published index is what makes that navigable. Adding a plugin adds operations
|
||||||
|
that this server can already reach, with no change here.
|
||||||
|
|
||||||
|
SECURITY MODEL: the token IS the boundary. Every operation the credential is
|
||||||
|
allowed to perform is reachable, including writes and deletes. That is a
|
||||||
|
deliberate choice, and it means the scope of the personal access token decides
|
||||||
|
the blast radius, not this code. Create a read-scoped token for anything
|
||||||
|
exploratory. See README.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# The SDK renamed FastMCP to MCPServer in 2.0. The two have the same shape for
|
||||||
|
# what this server uses (a .tool() decorator and .run()), so support both rather
|
||||||
|
# than forcing a pin: a site may already have either installed.
|
||||||
|
try:
|
||||||
|
from mcp.server.mcpserver import MCPServer as _Server # mcp >= 2
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
from mcp.server.fastmcp import FastMCP as _Server # mcp 1.x
|
||||||
|
|
||||||
|
from .client import (DESTRUCTIVE_METHODS, WRITE_METHODS, ShopdbClient,
|
||||||
|
ShopdbError)
|
||||||
|
|
||||||
|
mcp = _Server('shopdb')
|
||||||
|
|
||||||
|
_client: ShopdbClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def client() -> ShopdbClient:
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = ShopdbClient()
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(message: str) -> str:
|
||||||
|
"""Errors come back as text the model can read and act on.
|
||||||
|
|
||||||
|
Raising would surface a stack trace the model cannot do anything with; a
|
||||||
|
sentence telling it what went wrong and what to try next is more useful.
|
||||||
|
"""
|
||||||
|
return f'ERROR: {message}'
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def shopdb_whoami() -> str:
|
||||||
|
"""Check which ShopDB instance this server targets and what the token can do.
|
||||||
|
|
||||||
|
Call this first. It confirms the instance is reachable, the credential is
|
||||||
|
valid, and - importantly - what the token is scoped to, which determines
|
||||||
|
which other calls will succeed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
c = client()
|
||||||
|
except ShopdbError as exc:
|
||||||
|
return _fail(str(exc))
|
||||||
|
try:
|
||||||
|
status, body = c.request('GET', '/api/auth/me')
|
||||||
|
except ShopdbError as exc:
|
||||||
|
return _fail(str(exc))
|
||||||
|
if status == 401:
|
||||||
|
return _fail('token rejected (HTTP 401). Check SHOPDB_TOKEN.')
|
||||||
|
if status != 200:
|
||||||
|
return _fail(f'HTTP {status} from /api/auth/me: {body}')
|
||||||
|
user = (body or {}).get('data') or {}
|
||||||
|
try:
|
||||||
|
count = len(c.operations())
|
||||||
|
except ShopdbError as exc:
|
||||||
|
count = f'unknown ({exc})'
|
||||||
|
return json.dumps({
|
||||||
|
'instance': c.base_url,
|
||||||
|
'username': user.get('username'),
|
||||||
|
'roles': user.get('roles'),
|
||||||
|
'permissions': len(user.get('permissions') or []),
|
||||||
|
'operations_published': count,
|
||||||
|
'tls_verified': c.verify_tls,
|
||||||
|
}, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def shopdb_search_operations(query: str = '', tag: str = '',
|
||||||
|
limit: int = 40) -> str:
|
||||||
|
"""Find API operations by keyword. START HERE to discover what exists.
|
||||||
|
|
||||||
|
Searches method, path, tag and summary. `query` words are ANDed, so
|
||||||
|
"printer supplies" matches an operation mentioning both. `tag` narrows to
|
||||||
|
one group (for example plugin-printers, core-assets).
|
||||||
|
|
||||||
|
Returns one line per operation. Use shopdb_describe_operation for the
|
||||||
|
parameters and body schema of anything you intend to call.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ops = client().operations()
|
||||||
|
except ShopdbError as exc:
|
||||||
|
return _fail(str(exc))
|
||||||
|
if tag:
|
||||||
|
ops = [o for o in ops if o.tag == tag]
|
||||||
|
if query:
|
||||||
|
ops = [o for o in ops if o.matches(query)]
|
||||||
|
if not ops:
|
||||||
|
tags = sorted({o.tag for o in client().operations()})
|
||||||
|
return (f'No operations matched query={query!r} tag={tag!r}.\n'
|
||||||
|
f'Available tags: {", ".join(tags)}')
|
||||||
|
lines = [f'{o.method} {o.path} [{o.tag}] ({o.auth}) {o.summary}'
|
||||||
|
for o in ops[:limit]]
|
||||||
|
more = len(ops) - len(lines)
|
||||||
|
if more > 0:
|
||||||
|
lines.append(f'... and {more} more; narrow the query or raise limit')
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def shopdb_describe_operation(method: str, path: str) -> str:
|
||||||
|
"""Full contract for one operation: parameters, request body, responses.
|
||||||
|
|
||||||
|
`path` must be the templated form exactly as shopdb_search_operations
|
||||||
|
printed it, for example /api/machines/{machineid} - not a filled-in URL.
|
||||||
|
|
||||||
|
Read this before calling anything you have not called before. It also warns
|
||||||
|
when the operation writes or deletes.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
op = client().find_operation(method, path)
|
||||||
|
except ShopdbError as exc:
|
||||||
|
return _fail(str(exc))
|
||||||
|
if op is None:
|
||||||
|
return _fail(f'no operation {method.upper()} {path}. '
|
||||||
|
'Use shopdb_search_operations to find the exact path.')
|
||||||
|
out = {
|
||||||
|
'method': method.upper(),
|
||||||
|
'path': path,
|
||||||
|
'summary': op.get('summary'),
|
||||||
|
'description': op.get('description'),
|
||||||
|
'parameters': op.get('parameters'),
|
||||||
|
'requestBody': op.get('requestBody'),
|
||||||
|
'responses': {k: {'description': v.get('description')}
|
||||||
|
for k, v in (op.get('responses') or {}).items()},
|
||||||
|
}
|
||||||
|
warning = ''
|
||||||
|
if method.upper() in DESTRUCTIVE_METHODS:
|
||||||
|
warning = ('\n\nWARNING: this DELETES data and cannot be undone. '
|
||||||
|
'Confirm with the user before calling it.')
|
||||||
|
elif method.upper() in WRITE_METHODS:
|
||||||
|
warning = '\n\nNote: this MODIFIES data.'
|
||||||
|
return json.dumps(out, indent=2, default=str) + warning
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def shopdb_call(method: str, path: str, params: dict[str, Any] | None = None,
|
||||||
|
body: dict[str, Any] | None = None) -> str:
|
||||||
|
"""Call any ShopDB operation. Use shopdb_describe_operation first.
|
||||||
|
|
||||||
|
`path` may contain filled-in ids (/api/machines/42). `params` become the
|
||||||
|
query string; `body` is sent as JSON.
|
||||||
|
|
||||||
|
Every response is the envelope {status, data, meta} - the payload you want
|
||||||
|
is under "data", not at the top level. List endpoints paginate with page and
|
||||||
|
perpage, and perpage is silently capped at 100, so asking for 500 returns
|
||||||
|
100 without an error.
|
||||||
|
|
||||||
|
This can write and delete if the token allows it. For DELETE, confirm with
|
||||||
|
the user first.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
c = client()
|
||||||
|
except ShopdbError as exc:
|
||||||
|
return _fail(str(exc))
|
||||||
|
verb = method.upper()
|
||||||
|
try:
|
||||||
|
status, payload = c.request(verb, path, params=params, body=body)
|
||||||
|
except ShopdbError as exc:
|
||||||
|
return _fail(str(exc))
|
||||||
|
|
||||||
|
if status == 401:
|
||||||
|
return _fail('HTTP 401. The token is missing, expired or rejected.')
|
||||||
|
if status == 403:
|
||||||
|
return _fail('HTTP 403. The token lacks the scope or role for this '
|
||||||
|
'operation - check shopdb_whoami.')
|
||||||
|
if status == 404 and isinstance(payload, str):
|
||||||
|
return _fail(f'HTTP 404 and a non-JSON body, which usually means the '
|
||||||
|
f'path is wrong rather than the record missing: {path}')
|
||||||
|
text = payload if isinstance(payload, str) else json.dumps(payload, indent=2,
|
||||||
|
default=str)
|
||||||
|
if len(text) > 100_000:
|
||||||
|
text = text[:100_000] + '\n... truncated; narrow with params or paginate'
|
||||||
|
return f'HTTP {status}\n{text}'
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def shopdb_llms_guide() -> str:
|
||||||
|
"""The instance's own orientation guide for agents.
|
||||||
|
|
||||||
|
Short. Covers the response envelope, authentication and how the rest of the
|
||||||
|
documentation is laid out. Worth reading once at the start of a session.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
status, text = client().request('GET', '/api/docs/llms.txt')
|
||||||
|
except ShopdbError as exc:
|
||||||
|
return _fail(str(exc))
|
||||||
|
if status != 200:
|
||||||
|
return _fail(f'guide unavailable (HTTP {status})')
|
||||||
|
return text if isinstance(text, str) else str(text)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if not os.environ.get('SHOPDB_URL'):
|
||||||
|
raise SystemExit(
|
||||||
|
'SHOPDB_URL is not set.\n'
|
||||||
|
' SHOPDB_URL=https://shopdb.example.net/shopdb\n'
|
||||||
|
' SHOPDB_TOKEN=<personal access token>\n'
|
||||||
|
'See tools/mcp/README.md.')
|
||||||
|
mcp.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user