diff --git a/mcp-server/server.py b/mcp-server/server.py new file mode 100644 index 0000000..6edf72e --- /dev/null +++ b/mcp-server/server.py @@ -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 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()