#!/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 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()