sap-diagrams-mcp v0.1.0 (mirror of sap-architecture-diagrams/mcp-server)
This commit is contained in:
@@ -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://<backend>/api sap-diagrams-mcp
|
||||
```
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
"""sap-diagrams-mcp — thin MCP wrapper over the sap-drawio backend API."""
|
||||
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
Reference in New Issue
Block a user