From 761180f1056d16c8a6beb3b91877a565fdfbc7fd Mon Sep 17 00:00:00 2001 From: alexz Date: Fri, 24 Jul 2026 15:15:07 -0700 Subject: [PATCH] sap-diagrams-mcp v0.1.0 (mirror of sap-architecture-diagrams/mcp-server) --- README.md | 17 ++ pyproject.toml | 19 ++ src/sap_diagrams_mcp/__init__.py | 1 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 277 bytes .../__pycache__/server.cpython-314.pyc | Bin 0 -> 17462 bytes src/sap_diagrams_mcp/server.py | 203 ++++++++++++++++++ 6 files changed, 240 insertions(+) create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/sap_diagrams_mcp/__init__.py create mode 100644 src/sap_diagrams_mcp/__pycache__/__init__.cpython-314.pyc create mode 100644 src/sap_diagrams_mcp/__pycache__/server.cpython-314.pyc create mode 100644 src/sap_diagrams_mcp/server.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e9fdca --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# sap-diagrams-mcp + +MCP server exposing SAP BTP solution-diagram generation (the sap-drawio app's +backend) to any MCP client. Thin REST wrapper — the backend owns the icon DB, +SAP service catalog, landscape renderer and QA scorer. + +Tools: `render_sketch` (deterministic, bring your own architecture), +`generate_diagram` (full pipeline from prose), `convert_mermaid`, +`get_diagram`, `list_diagrams`. + +Canonical source lives in the `sap-architecture-diagrams` repo (`mcp-server/`); +this repo is the public install mirror for the MCP hub. + +``` +uv tool install git+https://git.alexzaw.dev/alexz/sap-diagrams-mcp +SAP_DIAGRAMS_API=http:///api sap-diagrams-mcp +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f275e8c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "sap-diagrams-mcp" +version = "0.1.0" +description = "MCP server exposing SAP BTP solution-diagram generation (sap-drawio backend) to any MCP client" +requires-python = ">=3.10" +dependencies = [ + "mcp>=1.2.0", + "httpx>=0.27", +] + +[project.scripts] +sap-diagrams-mcp = "sap_diagrams_mcp.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sap_diagrams_mcp"] diff --git a/src/sap_diagrams_mcp/__init__.py b/src/sap_diagrams_mcp/__init__.py new file mode 100644 index 0000000..e79197e --- /dev/null +++ b/src/sap_diagrams_mcp/__init__.py @@ -0,0 +1 @@ +"""sap-diagrams-mcp — thin MCP wrapper over the sap-drawio backend API.""" diff --git a/src/sap_diagrams_mcp/__pycache__/__init__.cpython-314.pyc b/src/sap_diagrams_mcp/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1cbc23eb499373e32be2ca84e59d9ba74e54605e GIT binary patch literal 277 zcmX|+UrGZp5XKV~ErOmQ;8T%pE+B#kf?!MOTYVW~c5qX>n=r}tk54^>m+(sKTYCYa z7jPohdH5z@@-e^6_2tF6;F|92Y{-3=&7tJqVV@%#F%d~j&n-t%^X+nI*^~CO;P6ohHT{$+m`Sjt{GlLBqr%dHV5JGG2k~1;2M_z24UUxZfak) XbG)TfM&%7-kLCMVO8F(8=!DG=^;A)} literal 0 HcmV?d00001 diff --git a/src/sap_diagrams_mcp/__pycache__/server.cpython-314.pyc b/src/sap_diagrams_mcp/__pycache__/server.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7706c74bdad782c6b5f1a9263c3f6eaa5e987d99 GIT binary patch literal 17462 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 BQY8QY literal 0 HcmV?d00001 diff --git a/src/sap_diagrams_mcp/server.py b/src/sap_diagrams_mcp/server.py new file mode 100644 index 0000000..37ee5aa --- /dev/null +++ b/src/sap_diagrams_mcp/server.py @@ -0,0 +1,203 @@ +"""MCP server for SAP BTP solution-diagram generation. + +Thin client over the sap-drawio backend REST API — no pipeline logic, no LLM +calls of its own. The backend owns the icon DB, service catalog, landscape +renderer, QA scorer, and conversion history; this server wraps its job-based +endpoints as MCP tools and compacts the results (full draw.io XML / SVG are +only returned by get_diagram on explicit request — they run ~300 KB because +every icon is an embedded data URI). + +Env: + SAP_DIAGRAMS_API backend API base (default http://192.168.0.150:8016/api) + SAP_DIAGRAMS_PUBLIC_URL user-facing app URL for links (default https://sap-drawio.alexzaw.dev) +""" +import asyncio +import os +from typing import Literal, Optional + +import httpx +from mcp.server.fastmcp import FastMCP +from pydantic import BaseModel, Field + +API = os.environ.get("SAP_DIAGRAMS_API", "http://192.168.0.150:8016/api").rstrip("/") +PUBLIC_URL = os.environ.get("SAP_DIAGRAMS_PUBLIC_URL", "https://sap-drawio.alexzaw.dev").rstrip("/") +POLL_INTERVAL_S = 3 +POLL_CAP_S = 360 # describe path can run up to 3 refine rounds with a vision judge + +mcp = FastMCP("sap-diagrams") + + +class Zone(BaseModel): + """Ownership boundary drawn as a bordered area.""" + id: str + name: str + kind: Literal["btp", "s4", "non-sap"] + + +class Group(BaseModel): + """Sub-frame inside a zone (e.g. 'Financial', 'Via CPI'). Needs >= 2 members.""" + id: str + name: str + zone: str = Field(description="id of the zone this group lives in") + + +class Component(BaseModel): + id: str + label: str = Field(description="display name, e.g. 'SAP Integration Suite' or 'Salesforce CRM'") + raw_type: str = Field( + default="service", + description="actor | mobile | ui | erp | service | db — actors/devices get the users lane") + zone: Optional[str] = Field(default=None, description="zone id; omit to let the backend assign heuristically") + group: Optional[str] = Field(default=None, description="group id within the same zone") + sublabel: Optional[str] = Field(default=None, description="API/product code shown under the label, e.g. 'SAP_COM_0002 · SOAP'") + description: Optional[str] = None + + +class Connection(BaseModel): + from_id: str + to_id: str + label: str = Field(description="protocol label, e.g. 'OData POST', 'SFTP ISO 20022', 'IDoc SOAP'") + style: Literal["solid", "dashed"] = Field( + default="solid", description="dashed for file/batch/async transfers") + + +class Sketch(BaseModel): + """Architecture sketch, schema v2 — the same shape the app's own pipeline uses.""" + title: str + zones: list[Zone] = Field(default_factory=list) + groups: list[Group] = Field(default_factory=list) + components: list[Component] + connections: list[Connection] = Field(default_factory=list) + + +def _summary(doc: dict) -> dict: + qa = (doc.get("validation") or {}).get("qa") or {} + return { + "conversion_id": doc["id"], + "title": doc["title"], + "qa_score": qa.get("score"), + "qa_errors": qa.get("errors") or [], + "review_warnings": (doc.get("validation") or {}).get("warnings") or [], + "components": [ + {"label": c.get("label"), "sap_service": c.get("sap_service_name"), + "zone": c.get("zone"), "icon": c.get("icon_id")} + for c in doc.get("components") or [] + ], + "connection_count": len(doc.get("connections") or []), + "open_in_app": f"{PUBLIC_URL} (History tab, id {doc['id']})", + "next_steps": "Call get_diagram(conversion_id, format='xml') for the draw.io file " + "or format='svg' for a preview.", + } + + +async def _post_and_poll(path: str, payload: dict, params: dict | None = None) -> dict: + async with httpx.AsyncClient(timeout=60) as client: + try: + resp = await client.post(f"{API}{path}", json=payload, params=params or {}) + except httpx.HTTPError as e: + raise RuntimeError(f"diagram backend unavailable: {e.__class__.__name__}") + if resp.status_code == 422: + raise RuntimeError(f"rejected: {resp.json().get('detail', resp.text[:300])}") + if resp.status_code != 200: + raise RuntimeError(f"diagram backend error HTTP {resp.status_code}") + job_id = resp.json()["job_id"] + + waited = 0 + while waited < POLL_CAP_S: + await asyncio.sleep(POLL_INTERVAL_S) + waited += POLL_INTERVAL_S + job = (await client.get(f"{API}/jobs/{job_id}")).json() + if job.get("status") == "done": + return job["result"] + if job.get("status") == "error": + raise RuntimeError(job.get("error") or "conversion failed") + raise RuntimeError( + f"still processing after {POLL_CAP_S}s — the diagram may finish shortly; " + f"call list_diagrams to find it once complete (job {job_id})") + + +@mcp.tool() +async def render_sketch(sketch: Sketch, title: str = "") -> dict: + """Render an architecture sketch you have already designed into a polished SAP BTP + solution diagram (draw.io XML + SVG, official SAP style: zones as bordered areas, + services on grey circle icon tiles with real SAP/brand icons, orthogonal + protocol-labeled connectors) with a structural QA score. + + Deterministic and fast (~2 s): the backend maps labels to its SAP service catalog + and icon database and renders — no AI runs server-side, so YOU are responsible for + the architecture quality. Give every component a zone, label every connection with + its protocol, use style='dashed' for file/batch/async flows. Prefer this tool when + you can design the architecture yourself; use generate_diagram to delegate instead. + The result is saved to the app's History and can be refined there later.""" + payload = {"sketch": sketch.model_dump(exclude_none=True), "title": title} + return _summary(await _post_and_poll("/render-sketch", payload)) + + +@mcp.tool() +async def generate_diagram(description: str, title: str = "", use_flagship: bool = False) -> dict: + """Generate a complete SAP BTP solution diagram from a plain-text description of a + landscape or integration scenario. The backend's own pipeline extracts components, + zones and protocols from the text, maps them to SAP services and icons, renders, + and iteratively refines against a QA gate (up to ~3 rounds; typically 1-4 minutes). + + Use when you have prose, not a structured design. Set use_flagship=true for the + highest-quality extraction on complex scenarios. Mention every system, the + integration middleware, protocols per flow, and the target SAP system explicitly — + the pipeline never invents components that are not in the text.""" + return _summary(await _post_and_poll( + "/convert-describe", {"description": description, "title": title}, + params={"useFlagship": int(use_flagship)})) + + +@mcp.tool() +async def convert_mermaid(mermaid: str, title: str = "", use_flagship: bool = False) -> dict: + """Convert a Mermaid flowchart/graph definition into a polished SAP BTP solution + diagram. The Mermaid text is parsed structurally (nodes, edges, subgraphs, edge + labels); the backend pipeline then maps, renders and QA-refines it like any other + conversion. Edge labels become protocol labels — include them.""" + return _summary(await _post_and_poll( + "/convert-mermaid", {"mermaid": mermaid, "title": title}, + params={"useFlagship": int(use_flagship)})) + + +@mcp.tool() +async def get_diagram(conversion_id: str, format: Literal["summary", "xml", "svg"] = "summary") -> dict: + """Fetch a previously created diagram by conversion_id. format='summary' returns the + compact metadata; format='xml' returns the full draw.io file content and 'svg' the + inline preview — both are LARGE (~100-300 KB, icons embedded as data URIs), request + them only when you actually need the file content.""" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(f"{API}/conversions/{conversion_id}") + if resp.status_code == 404: + raise RuntimeError(f"no conversion with id {conversion_id}") + resp.raise_for_status() + doc = resp.json() + if format == "xml": + return {"conversion_id": doc["id"], "filename": doc.get("filename"), "xml": doc["xml"]} + if format == "svg": + return {"conversion_id": doc["id"], "svg": doc["svg"]} + return _summary(doc) + + +@mcp.tool() +async def list_diagrams(limit: int = 10) -> list[dict]: + """List recent diagrams (newest first): conversion_id, title, source type, creation + time and QA score. Use to find an existing diagram to fetch with get_diagram.""" + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(f"{API}/conversions") + resp.raise_for_status() + rows = resp.json() + return [ + {"conversion_id": r["id"], "title": r.get("title"), + "source_type": r.get("source_type"), "created_at": r.get("created_at"), + "qa_score": ((r.get("validation") or {}).get("qa") or {}).get("score")} + for r in rows[:max(1, min(limit, 50))] + ] + + +def main() -> None: + mcp.run() + + +if __name__ == "__main__": + main()