mcp: read-only ShopDB MCP server generated from the OpenAPI spec
A separate tool (not shipped in the app) that exposes a curated set of read endpoints as MCP tools, so an LLM client can query the asset DB directly. Built with FastMCP.from_openapi over docs/openapi.json; auth via a scoped PAT (SHOPDB_TOKEN) or managed X-API-Key. Read-only: only GETs on the curated allowlist become tools, all writes excluded. Runs anywhere that can reach the API - never on the air-gapped box. Needs `pip install fastmcp` + testing in that env (not installed in this repo's venv).
This commit is contained in:
42
mcp/README.md
Normal file
42
mcp/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# ShopDB MCP server (read-only)
|
||||||
|
|
||||||
|
Lets an LLM client query ShopDB directly as tools, generated from the OpenAPI
|
||||||
|
spec (`docs/openapi.json`). Separate from the app - runs anywhere that can reach
|
||||||
|
the API; never on the air-gapped prod box.
|
||||||
|
|
||||||
|
## Install + run
|
||||||
|
```
|
||||||
|
pip install -r mcp/requirements.txt
|
||||||
|
export SHOPDB_BASE="https://tsgwp00525.wjs.geaerospace.net/shopdb"
|
||||||
|
export SHOPDB_TOKEN="<scoped READ PAT>" # mint in ShopDB: Settings > API Tokens
|
||||||
|
python mcp/shopdb_mcp.py # stdio
|
||||||
|
```
|
||||||
|
(Or `SHOPDB_API_KEY` instead of a Bearer PAT for managed-token access.)
|
||||||
|
|
||||||
|
## Claude Desktop
|
||||||
|
Add to `claude_desktop_config.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"shopdb": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["/abs/path/shopdb-flask/mcp/shopdb_mcp.py"],
|
||||||
|
"env": {
|
||||||
|
"SHOPDB_BASE": "https://tsgwp00525.wjs.geaerospace.net/shopdb",
|
||||||
|
"SHOPDB_TOKEN": "<scoped read PAT>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scope / safety
|
||||||
|
- **Read-only**: only GET endpoints on the curated allowlist (`CURATED` in
|
||||||
|
`shopdb_mcp.py`) become tools; all writes are excluded.
|
||||||
|
- Give the PAT the **least** scope needed. Tool calls hit the normal API, so
|
||||||
|
they're subject to its auth + land in the audit log.
|
||||||
|
- To add write tools later, extend `CURATED` / add `RouteMap`s for those verbs,
|
||||||
|
behind a token that actually holds the scopes.
|
||||||
|
|
||||||
|
## Keeping it current
|
||||||
|
Tools follow `docs/openapi.json`. After API changes: `python scripts/gen_openapi.py`.
|
||||||
2
mcp/requirements.txt
Normal file
2
mcp/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fastmcp>=2.9
|
||||||
|
httpx>=0.27
|
||||||
79
mcp/shopdb_mcp.py
Normal file
79
mcp/shopdb_mcp.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""ShopDB MCP server (read-only v1).
|
||||||
|
|
||||||
|
Exposes a curated set of ShopDB *read* endpoints as MCP tools so an LLM client
|
||||||
|
(Claude Desktop / Claude Code / an agent) can query the asset database directly.
|
||||||
|
Tools are generated from docs/openapi.json - keep that spec current
|
||||||
|
(scripts/gen_openapi.py) and this server tracks it.
|
||||||
|
|
||||||
|
This is a SEPARATE tool from the shopdb app: it runs wherever you can reach the
|
||||||
|
API over HTTPS and calls it with a scoped Personal Access Token. It never needs
|
||||||
|
to run on the (air-gapped) prod box.
|
||||||
|
|
||||||
|
Safety:
|
||||||
|
- Read-only. Only GET endpoints in the CURATED set below become tools; every
|
||||||
|
write (POST/PUT/PATCH/DELETE) and everything off the allowlist is excluded.
|
||||||
|
- Auth is a scoped PAT you supply. Give it the least scope needed (read).
|
||||||
|
- The app audits API calls (AuditLog), so tool activity is traceable.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
pip install -r mcp/requirements.txt
|
||||||
|
export SHOPDB_BASE="https://tsgwp00525.wjs.geaerospace.net/shopdb"
|
||||||
|
export SHOPDB_TOKEN="<scoped read PAT>" # Bearer
|
||||||
|
# or: export SHOPDB_API_KEY="<managed X-API-Key>"
|
||||||
|
python mcp/shopdb_mcp.py # stdio transport
|
||||||
|
|
||||||
|
Widen later (add write tools) by extending CURATED / adding RouteMaps for the
|
||||||
|
verbs you want, behind a token that actually has those scopes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
from fastmcp.server.openapi import MCPType, RouteMap
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_SPEC = os.path.join(_HERE, '..', 'docs', 'openapi.json')
|
||||||
|
|
||||||
|
BASE = os.environ.get('SHOPDB_BASE',
|
||||||
|
'https://tsgwp00525.wjs.geaerospace.net/shopdb')
|
||||||
|
TOKEN = os.environ.get('SHOPDB_TOKEN')
|
||||||
|
API_KEY = os.environ.get('SHOPDB_API_KEY')
|
||||||
|
|
||||||
|
# High-value read surfaces only - keeps the tool count small so the model picks
|
||||||
|
# well. Add more prefixes here as needed.
|
||||||
|
CURATED = (r'^/api/(search|assets|reports|printers|computers|machines|network|'
|
||||||
|
r'measuringtools|applications|knowledgebase|locations|vendors|'
|
||||||
|
r'businessunits|dashboard)(/|$)')
|
||||||
|
|
||||||
|
|
||||||
|
def build_server():
|
||||||
|
if not (TOKEN or API_KEY):
|
||||||
|
raise SystemExit('Set SHOPDB_TOKEN (Bearer PAT) or SHOPDB_API_KEY.')
|
||||||
|
headers = {}
|
||||||
|
if TOKEN:
|
||||||
|
headers['Authorization'] = 'Bearer ' + TOKEN
|
||||||
|
if API_KEY:
|
||||||
|
headers['X-API-Key'] = API_KEY
|
||||||
|
|
||||||
|
client = httpx.AsyncClient(base_url=BASE, headers=headers, timeout=30.0)
|
||||||
|
spec = json.load(open(_SPEC))
|
||||||
|
|
||||||
|
route_maps = [
|
||||||
|
# curated read endpoints -> tools
|
||||||
|
RouteMap(methods=['GET'], pattern=CURATED, mcp_type=MCPType.TOOL),
|
||||||
|
# everything else (writes + off-list reads) -> not exposed
|
||||||
|
RouteMap(mcp_type=MCPType.EXCLUDE),
|
||||||
|
]
|
||||||
|
|
||||||
|
return FastMCP.from_openapi(
|
||||||
|
openapi_spec=spec,
|
||||||
|
client=client,
|
||||||
|
name='ShopDB (read-only)',
|
||||||
|
route_maps=route_maps,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
build_server().run()
|
||||||
Reference in New Issue
Block a user