Add MCP server exposing the sap-architecture skill pipeline as tools

This commit is contained in:
2026-07-24 21:32:15 +00:00
parent 7e855013e5
commit cf1d9ad975
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
sap-arch-mcp — MCP server exposing the sap-architecture skill pipeline.
Runs next to a clone of the sap-architecture repo and wraps its bundled
scripts (scaffold, validate, autofix, score, icons) as MCP tools so an
LLM agent (e.g. via OpenWebUI + mcpo) can produce SAP Architecture
Center-style .drawio diagrams with the full deterministic pipeline.
Usage:
pip install "mcp[cli]"
export SKILL_DIR=/opt/sap-architecture # repo root (contains scripts/, assets/)
export WORK_DIR=/tmp/sap-arch-work # writable scratch dir
python3 server.py # stdio transport
With mcpo for OpenWebUI:
uvx mcpo --port 8600 -- python3 /opt/sap-architecture/mcp-server/server.py
"""
import json
import os
import subprocess
import tempfile
import uuid
from pathlib import Path
from mcp.server.fastmcp import FastMCP
SKILL_DIR = Path(os.environ.get("SKILL_DIR", Path(__file__).resolve().parent.parent))
WORK_DIR = Path(os.environ.get("WORK_DIR", "/tmp/sap-arch-work"))
WORK_DIR.mkdir(parents=True, exist_ok=True)
SCRIPTS = SKILL_DIR / "scripts"
TEMPLATES = SKILL_DIR / "assets" / "reference-examples"
mcp = FastMCP("sap-architecture")
def _run(args: list[str], timeout: int = 120) -> dict:
"""Run a bundled script and capture output."""
proc = subprocess.run(
["python3", *args],
capture_output=True, text=True, timeout=timeout, cwd=str(WORK_DIR),
)
return {
"returncode": proc.returncode,
"stdout": proc.stdout[-20000:],
"stderr": proc.stderr[-5000:],
}
def _tmpfile(xml: str, suffix: str = ".drawio") -> Path:
p = WORK_DIR / f"{uuid.uuid4().hex[:12]}{suffix}"
p.write_text(xml, encoding="utf-8")
return p
@mcp.tool()
def list_templates(query: str = "") -> str:
"""List the bundled official SAP reference templates. Optional free-text
filter matched against titles, aliases, domains and tags from
template-metadata.json."""
meta = json.loads((TEMPLATES / "template-metadata.json").read_text())
out = []
q = query.lower()
for name, info in meta.get("templates", {}).items():
hay = " ".join([
name, info.get("title", ""), info.get("domain", ""),
" ".join(info.get("aliases", [])), " ".join(info.get("tags", [])),
]).lower()
if not q or all(tok in hay for tok in q.split()):
out.append({
"file": name,
"title": info.get("title"),
"domain": info.get("domain"),
"level": info.get("level"),
"primary": info.get("primary", False),
})
return json.dumps(out, indent=2)
@mcp.tool()
def scaffold(request: str, template: str = "", diagram_name: str = "") -> str:
"""MANDATORY first step for any new diagram. Ranks the 71 bundled SAP
templates against the natural-language request, copies the best match,
and returns the pristine template XML plus the SAP design recipe.
Optionally pin a specific template filename."""
out = WORK_DIR / f"scaffold-{uuid.uuid4().hex[:8]}.drawio"
args = [str(SCRIPTS / "scaffold_diagram.py"), request,
"--include-external-sap-references", "--out", str(out)]
if template:
args += ["--template", template]
if diagram_name:
args += ["--diagram-name", diagram_name]
res = _run(args)
xml = out.read_text(encoding="utf-8") if out.exists() else ""
return json.dumps({"recipe_and_ranking": res["stdout"],
"errors": res["stderr"], "xml": xml})
@mcp.tool()
def extract_icon(service_name: str, x: int = 0, y: int = 0,
cell_id: str = "", parent: str = "1") -> str:
"""Look up an official SAP BTP service icon by fuzzy name and return a
ready-to-paste <mxCell> snippet with the exact SVG data URI, snapped to
the 10-px grid at 32x32."""
args = [str(SCRIPTS / "extract_icon.py"), service_name,
"--x", str(x), "--y", str(y), "--parent", parent]
if cell_id:
args += ["--id", cell_id]
res = _run(args)
return res["stdout"] or res["stderr"]
@mcp.tool()
def extract_asset(query: str, kind: str = "generic-icon",
x: int = 0, y: int = 0, cell_id: str = "") -> str:
"""Fetch any SAP starter-kit asset (connector presets, area shapes,
number markers, brand-name text, generic icons...) as an mxCell snippet.
kinds: connector, generic-icon, sap-brand-name, area-shape, number,
text-element, annotation."""
args = [str(SCRIPTS / "extract_asset.py"), query, "--kind", kind,
"--x", str(x), "--y", str(y)]
if cell_id:
args += ["--id", cell_id]
res = _run(args)
return res["stdout"] or res["stderr"]
@mcp.tool()
def autofix(xml: str) -> str:
"""Apply mechanical SAP-style fixes (grid snapping, hex case,
absoluteArcSize, strokeWidth, fontFamily) and return the fixed XML."""
p = _tmpfile(xml)
res = _run([str(SCRIPTS / "autofix.py"), "--write", str(p)])
fixed = p.read_text(encoding="utf-8")
p.unlink(missing_ok=True)
return json.dumps({"log": res["stdout"] + res["stderr"], "xml": fixed})
@mcp.tool()
def validate(xml: str) -> str:
"""Run the full SAP-style validator: XML well-formedness, duplicate ids,
bent arrows, label overflow, palette deviations, icon overlap, dark
background, novelty pill verbs, orphaned edges. Fix every ERROR before
delivering."""
p = _tmpfile(xml)
res = _run([str(SCRIPTS / "validate.py"), str(p)])
p.unlink(missing_ok=True)
return res["stdout"] + ("\n" + res["stderr"] if res["stderr"] else "")
@mcp.tool()
def score(xml: str, min_score: int = 90, sap_like: bool = False) -> str:
"""Score the candidate against the SAP reference corpus. Use corpus
similarity for template-derived diagrams (default) or SAP-likeness
(sap_like=true) for semantic-fallback diagrams. PASS >= min_score."""
p = _tmpfile(xml)
flag = "--min-sap-like" if sap_like else "--min-score"
res = _run([str(SCRIPTS / "score_corpus.py"), flag, str(min_score), str(p)])
p.unlink(missing_ok=True)
return res["stdout"] + ("\n" + res["stderr"] if res["stderr"] else "")
@mcp.tool()
def compare(xml: str, template: str) -> str:
"""Pairwise fingerprint diff of the candidate against one named bundled
template — shows exactly which dimensions drifted (zones, palette,
pills, edge anchors, icon sizes)."""
p = _tmpfile(xml)
res = _run([str(SCRIPTS / "compare.py"), str(TEMPLATES / template), str(p)])
p.unlink(missing_ok=True)
return res["stdout"] + ("\n" + res["stderr"] if res["stderr"] else "")
@mcp.tool()
def render_png(xml: str, scale: int = 2) -> str:
"""Render the diagram to PNG via headless draw.io (requires drawio +
xvfb installed, DRAWIO_CLI env set). Returns the output file path in
WORK_DIR, or an error if the renderer is unavailable."""
p = _tmpfile(xml)
out = p.with_suffix(".png")
res = _run([str(SCRIPTS / "render.py"), str(p), "--format", "png",
"--scale", str(scale)], timeout=300)
p.unlink(missing_ok=True)
if out.exists():
return json.dumps({"png_path": str(out), "log": res["stdout"]})
return json.dumps({"error": "renderer unavailable or failed",
"log": res["stdout"] + res["stderr"]})
@mcp.tool()
def get_reference_doc(name: str) -> str:
"""Read a skill reference doc by name, e.g. 'drawio-gotchas',
'palette-and-typography', 'shapes-and-edges', 'layout', 'levels',
'do-and-dont', 'nudge-workflow'."""
p = SKILL_DIR / "references" / f"{name.removesuffix('.md')}.md"
if not p.exists():
available = [f.stem for f in (SKILL_DIR / "references").glob("*.md")]
return f"Not found. Available: {', '.join(available)}"
return p.read_text(encoding="utf-8")
if __name__ == "__main__":
mcp.run()