From dc572805096286b2b30ac59849da3effe1eff597 Mon Sep 17 00:00:00 2001 From: Carlos Barbero Date: Tue, 27 Jan 2026 08:25:06 -0300 Subject: [PATCH 01/16] feat: implement Diagrams MCP server with Docker support and dynamic node discovery --- mcp-server/Dockerfile | 25 +++++ mcp-server/GEMINI.md | 112 ++++++++++++++++++++++ mcp-server/README.md | 136 +++++++++++++++++++++++++++ mcp-server/requirements.txt | 2 + mcp-server/src/__init__.py | 0 mcp-server/src/inspection.py | 73 +++++++++++++++ mcp-server/src/server.py | 176 +++++++++++++++++++++++++++++++++++ 7 files changed, 524 insertions(+) create mode 100644 mcp-server/Dockerfile create mode 100644 mcp-server/GEMINI.md create mode 100644 mcp-server/README.md create mode 100644 mcp-server/requirements.txt create mode 100644 mcp-server/src/__init__.py create mode 100644 mcp-server/src/inspection.py create mode 100644 mcp-server/src/server.py diff --git a/mcp-server/Dockerfile b/mcp-server/Dockerfile new file mode 100644 index 00000000..9d2eb83f --- /dev/null +++ b/mcp-server/Dockerfile @@ -0,0 +1,25 @@ +# Use a slim Python image +FROM python:3.11-slim + +# Install system dependencies (Graphviz is required for diagrams) +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + graphviz \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Copy the current directory (which contains the diagrams library source and our server code) +COPY . /app + +# Install the diagrams library from source (current dir) and other requirements +# We install 'mcp' explicitly as it might not be in the local repo's requirements +RUN pip install --no-cache-dir . mcp + +# Create an output directory for persistence if volume is mounted +RUN mkdir -p /app/output + +# Run the MCP server using absolute path +CMD ["python", "/app/src/server.py"] diff --git a/mcp-server/GEMINI.md b/mcp-server/GEMINI.md new file mode 100644 index 00000000..10944df3 --- /dev/null +++ b/mcp-server/GEMINI.md @@ -0,0 +1,112 @@ +# Project: Diagrams MCP Server + +## Overview +This project aims to implement a Model Context Protocol (MCP) server that exposes the capabilities of the [diagrams](https://diagrams.mingrammer.com/) Python library. The server will allow AI agents to dynamically discover available diagram nodes (AWS, Azure, Kubernetes, etc.) and generate architectural diagrams from Python code. + +## Architecture +The solution will be containerized to ensure isolation and consistent dependencies (specifically Graphviz). + +- **Runtime**: Python 3.9+ +- **Container**: Docker (Debian-based to support Graphviz) +- **Communication**: Standard Input/Output (stdio) via the MCP protocol. +- **Libraries**: + - `diagrams`: For generating diagrams. + - `mcp`: Official Python SDK for the Model Context Protocol. + - `graphviz`: System dependency required by the `diagrams` library. + +## File Structure +```text +. +├── Dockerfile +├── requirements.txt +├── server.py +└── src/ + └── inspection.py # Helper for dynamic node discovery +``` + +## Implementation Details + +### 1. Docker Environment (`Dockerfile`) +The environment must include Graphviz, which is a system-level dependency required for rendering. + +* **Base Image**: `python:3.11-slim` +* **System Dependencies**: `graphviz` (via `apt-get install -y graphviz`) +* **Python Dependencies**: `diagrams`, `mcp` + +### 2. MCP Server (`server.py`) +The server will define three main tools. It should use the `mcp.server.fastmcp` or `mcp.server` standard library to define the server. + +#### Tool 1: `list_icons` +**Purpose**: Dynamically discovers all available diagram nodes across all providers (AWS, Azure, GCP, SaaS, etc.) so the AI knows what classes are available to import. + +* **Logic**: + 1. Recursively walk the `diagrams` package directory. + 2. Import modules dynamically. + 3. Inspect classes in each module. + 4. Filter classes that inherit from `diagrams.Node` but are not the base `Node` class itself. + 5. Organize into a hierarchy: `Provider -> Service -> Node`. + 6. **Optimization**: Cache this result at startup as it won't change. + +* **Parameters**: + - `provider_filter` (string, optional): If provided (e.g., "aws"), only return nodes for that provider. + +* **Returns**: JSON structure: + ```json + { + "aws": { + "compute": ["EC2", "Lambda", ...], + "database": ["RDS", "DynamoDB", ...] + }, + "k8s": { ... } + } + ``` + +#### Tool 2: `generate_diagram` +**Purpose**: Executes Python code to generate a diagram image. + +* **Logic**: + 1. Accepts a string of Python code (DSL). + 2. **Security**: The code is executed via `exec()`. Since this runs inside a Docker container, it provides a layer of isolation. + 3. **Execution**: + - Set up a temporary directory. + - Change the working directory to this temp location. + - Execute the code. + - Find the generated output file (usually `.png`). + 4. **Result**: Return the path to the generated image or the base64 encoded content (depending on client capability, but path is preferred if sharing volume). *For this implementation, return the path inside the container and ensure the container mounts a shared volume if persistence is needed.* + +* **Parameters**: + - `code` (string, required): The Python code using `diagrams` DSL. + - `filename` (string, optional): Desired output filename. + +* **Returns**: + - `status`: "success" or "error" + - `message`: Path to file or error message. + +#### Tool 3: `get_diagram_examples` +**Purpose**: Provides example code snippets to help the AI understand the syntax. + +* **Logic**: Return a dictionary of static examples for common patterns (Basic, Clustered, Cloud-specific). + +* **Parameters**: + - `provider` (string, optional): specific provider example (e.g., "aws"). + +### 3. Dynamic Inspection Helper (`src/inspection.py`) +This module is crucial for `list_icons`. It must robustly handle imports without crashing the server if a specific provider has missing optional dependencies. + +* Use `pkgutil.walk_packages` to iterate over `diagrams`. +* Use `importlib.import_module` to load found modules. +* Use `inspect.getmembers` to find classes. +* Check `issubclass(obj, diagrams.Node)`. + +## Execution & Testing +To run the server: +```bash +# Build +docker build -t diagrams-mcp . + +# Run (connected to stdin/stdout for MCP) +docker run -i --rm -v $(pwd)/output:/app/output diagrams-mcp +``` + +## Security Considerations +* **Arbitrary Code Execution**: The `generate_diagram` tool executes arbitrary Python code. This is by design but dangerous. The Docker container MUST be treated as untrusted and ephemeral. Do not mount sensitive host directories into the container. diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 00000000..0174fe96 --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,136 @@ +# Diagrams MCP Server + +This is a Model Context Protocol (MCP) server that exposes the capabilities of the [Diagrams](https://diagrams.mingrammer.com/) Python library. It allows AI agents to discover available diagram nodes (AWS, Azure, K8s, etc.) and generate architectural diagrams from Python code. + +## Architecture + +```mermaid +graph TD + subgraph Host ["Host System"] + Client[MCP Client] + HostFS[Workspace / Output] + end + + subgraph Container ["Docker Container"] + MCPServer["MCP Server (server.py)"] + NodeRegistry["(Node Registry)"] + + subgraph Logic ["Core Logic"] + Inspector["src/inspection.py"] + Executor["generate_diagram"] + end + + subgraph Libs ["Dependencies"] + DiagramsLib["diagrams package"] + Graphviz["Graphviz Binary"] + end + end + + %% Startup Flow + MCPServer -- "Startup" --> Inspector + Inspector -- "Scans" --> DiagramsLib + Inspector -- "Populates" --> NodeRegistry + + %% Tool Flows + Client -- "list_icons()" --> MCPServer + MCPServer -- "Query" --> NodeRegistry + + Client -- "generate_diagram(code)" --> MCPServer + MCPServer -- "Pass Code" --> Executor + Executor -- "exec()" --> DiagramsLib + DiagramsLib -- "Render" --> Graphviz + + %% Output + Graphviz -- "Generates PNG" --> Executor + Executor -- "Writes File (Volume Mount)" --> HostFS +``` + +## Features + +- **Dynamic Icon Discovery**: `list_icons` tool scans the `diagrams` library to find all available nodes (e.g., `EC2`, `Pod`, `BlobStorage`) organized by provider and service. +- **Diagram Generation**: `generate_diagram` tool accepts Python code (DSL) and renders it into an image (PNG). +- **Examples**: `get_diagram_examples` tool provides ready-to-use snippets for common patterns. +- **Sandboxed Execution**: Runs inside a Docker container to ensure isolation and consistent dependencies (Graphviz). + +## Prerequisites + +- **Docker**: This server is designed to run as a Docker container to manage system dependencies like Graphviz. + +## Build + +Build the Docker image from the `mcp-server` directory: + +```bash +cd mcp-server +docker build -t diagrams-mcp:latest . +``` + +## Configuration + +To use this server with an MCP client (like Gemini CLI or Claude Desktop), add the following configuration. + +This configuration mounts the current project directory into the container, allowing the server to save the generated images directly to your workspace. + +```json +{ + "mcpServers": { + "diagrams": { + "command": "sh", + "args": [ + "-c", + "export PROJECT_PATH=\"$\(PROJECT_PATH:-$(pwd)\")\"; docker run -i --rm -v \"$PROJECT_PATH:$PROJECT_PATH\" -w \"$PROJECT_PATH\" diagrams-mcp:latest" + ], + "env": { + "FASTMCP_LOG_LEVEL": "ERROR" + } + } + } +} +``` + +### Explanation of the Command + +- **`docker run -i --rm`**: Runs the container interactively (for stdin/stdout communication) and removes it after exit. +- **`-v "$PROJECT_PATH:$PROJECT_PATH"`**: Mounts the project root (where you invoke the agent) to the same path inside the container. This is crucial for the `generate_diagram` tool to write the output image file back to your host filesystem. +- **`-w "$PROJECT_PATH"`**: Sets the working directory inside the container to match the host, ensuring relative paths work as expected. +- **`diagrams-mcp:latest`**: The name of the image you built. + +## Tools + +### `list_icons` +Lists available icons/nodes from the diagrams package. +- **Inputs**: `provider_filter` (optional), `service_filter` (optional). +- **Example**: List all AWS compute nodes. + +### `generate_diagram` +Generates a diagram from Python code. +- **Inputs**: `code` (Python DSL), `filename` (optional), `timeout` (default: 90s). +- **Example Code**: + ```python + from diagrams import Diagram + from diagrams.aws.compute import EC2 + + with Diagram("Simple", show=False): + EC2("web") + ``` + +### `get_diagram_examples` +Returns example code snippets. +- **Inputs**: `diagram_type` (e.g., "aws", "k8s"). + +## Development Structure + +```text +mcp-server/ +├── Dockerfile # Container definition (Python + Graphviz) +├── requirements.txt # Python deps +├── README.md # This file +└── src/ + ├── server.py # Main MCP server entrypoint + └── inspection.py # Helper for dynamic node discovery +``` + +## Autor + +- **Autor**: Carlos Barbero +- **User**: carlosrgomes diff --git a/mcp-server/requirements.txt b/mcp-server/requirements.txt new file mode 100644 index 00000000..af278fdb --- /dev/null +++ b/mcp-server/requirements.txt @@ -0,0 +1,2 @@ +mcp +graphviz diff --git a/mcp-server/src/__init__.py b/mcp-server/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mcp-server/src/inspection.py b/mcp-server/src/inspection.py new file mode 100644 index 00000000..356c2847 --- /dev/null +++ b/mcp-server/src/inspection.py @@ -0,0 +1,73 @@ +import pkgutil +import importlib +import inspect +import sys +from collections import defaultdict +import diagrams +from diagrams import Node + +def get_all_nodes(): + """ + Dynamically inspects the diagrams package and returns a dictionary of all available Nodes. + + Returns: + dict: A nested dictionary structure: + { + "provider": { + "service": ["NodeName1", "NodeName2", ...] + } + } + """ + # Initialize the structure + icons = defaultdict(lambda: defaultdict(list)) + + # We also keep a flat map for the execution context: Name -> Class + # This handles potential name collisions by favoring the last seen or explicit logic if needed. + node_registry = {} + + # Iterate through all subpackages in diagrams (e.g., aws, azure, k8s) + # We look at the path of the diagrams package + path = diagrams.__path__ + prefix = diagrams.__name__ + "." + + for _, provider_name, ispkg in pkgutil.iter_modules(path, prefix): + if not ispkg: + continue + + # e.g., provider_name = "diagrams.aws" + short_provider = provider_name.split(".")[-1] + + # Skip internal modules if any (base, etc are actually useful, but we focus on providers) + if short_provider in ['base', 'custom']: + # 'custom' and 'base' might be treated differently, but for now we scan them + pass + + try: + provider_module = importlib.import_module(provider_name) + except ImportError: + # Skip providers that might have missing system deps or issues + continue + + # Now iterate modules within the provider (e.g., diagrams.aws.compute) + if hasattr(provider_module, "__path__"): + for _, service_name, _ in pkgutil.iter_modules(provider_module.__path__, provider_name + "."): + try: + service_module = importlib.import_module(service_name) + short_service = service_name.split(".")[-1] + + # Inspect classes in this service module + for name, obj in inspect.getmembers(service_module, inspect.isclass): + # Must inherit from Node + if issubclass(obj, Node) and obj is not Node: + # Verify it belongs to this module (to avoid re-export noise) + # or at least is defined in the diagrams package + if obj.__module__.startswith("diagrams"): + icons[short_provider][short_service].append(name) + node_registry[name] = obj + + except ImportError: + continue + except Exception: + continue + + return icons, node_registry diff --git a/mcp-server/src/server.py b/mcp-server/src/server.py new file mode 100644 index 00000000..e1cb0c78 --- /dev/null +++ b/mcp-server/src/server.py @@ -0,0 +1,176 @@ +import os +import sys +import tempfile +import contextlib +import base64 +from pathlib import Path +from mcp.server.fastmcp import FastMCP +from diagrams import Diagram, Cluster, Edge, Node + +# Import our helper +from inspection import get_all_nodes + +# Initialize FastMCP +mcp = FastMCP("diagrams-mcp") + +# Pre-load nodes for quick access and for the execution context +print("Loading diagram nodes...", file=sys.stderr) +ALL_ICONS, NODE_REGISTRY = get_all_nodes() +print(f"Loaded {len(NODE_REGISTRY)} nodes.", file=sys.stderr) + +@mcp.tool() +def list_icons(provider_filter: str = None, service_filter: str = None): + """ + List available icons from the diagrams package, with optional filtering. + + Args: + provider_filter: Filter icons by provider name (e.g., "aws", "gcp", "k8s") + service_filter: Filter icons by service name (e.g., "compute", "database") + """ + if not provider_filter: + # Return list of providers + return {"providers": list(ALL_ICONS.keys())} + + if provider_filter not in ALL_ICONS: + return {"error": f"Provider '{provider_filter}' not found. Available: {list(ALL_ICONS.keys())}"} + + provider_data = ALL_ICONS[provider_filter] + + if not service_filter: + # Return all services for this provider + return provider_data + + if service_filter not in provider_data: + return {"error": f"Service '{service_filter}' not found in '{provider_filter}'. Available: {list(provider_data.keys())}"} + + return {service_filter: provider_data[service_filter]} + +@mcp.tool() +def get_diagram_examples(diagram_type: str = "all"): + """ + Get example code for different types of diagrams. + + Args: + diagram_type: Type of diagram example to return (aws, k8s, flow, etc. or 'all') + """ + examples = { + "aws": """ +from diagrams import Diagram +from diagrams.aws.compute import EC2 +from diagrams.aws.database import RDS +from diagrams.aws.network import ELB + +with Diagram("Web Service", show=False): + ELB("lb") >> EC2("web") >> RDS("userdb") +""", + "k8s": """ +from diagrams import Diagram, Cluster +from diagrams.k8s.compute import Pod +from diagrams.k8s.network import Ingress, Service + +with Diagram("K8s Cluster", show=False): + ingress = Ingress("domain.com") + + with Cluster("App"): + svc = Service("svc") + pods = [Pod("pod1"), Pod("pod2")] + + ingress >> svc >> pods +""", + "custom": """ +from diagrams import Diagram +from diagrams.custom import Custom + +with Diagram("Custom", show=False): + # Ensure you have the icon file locally if using Custom + Custom("Label", "./my-icon.png") +""" + } + + if diagram_type == "all": + return examples + + return {diagram_type: examples.get(diagram_type, "No example found for this type.")} + +@mcp.tool() +def generate_diagram(code: str, filename: str = None, timeout: int = 90): + """ + Generate a diagram from Python code using the diagrams package. + + Args: + code: Python code using the diagrams package DSL. + filename: Optional filename to save the diagram to. + timeout: Execution timeout in seconds. + """ + + # Create a temporary directory for execution + with tempfile.TemporaryDirectory() as temp_dir: + original_cwd = os.getcwd() + os.chdir(temp_dir) + + try: + # Prepare the execution context + # We inject Diagram, Cluster, Edge, and ALL discovered nodes (EC2, Pod, etc.) + # This allows the user to write code without heavy imports if they choose, + # though explicit imports are still better for clarity. + exec_globals = { + "Diagram": Diagram, + "Cluster": Cluster, + "Edge": Edge, + "Node": Node, + **NODE_REGISTRY + } + + # Execute the code + # We wrap it in a try/except block within the exec to catch runtime errors + try: + exec(code, exec_globals) + except Exception as e: + return {"status": "error", "message": f"Runtime error: {str(e)}"} + + # Find the generated file + # Diagrams generates files based on the name passed to Diagram() class + # We look for any .png file created in the temp dir + generated_files = list(Path(".").glob("*.png")) + + if not generated_files: + return {"status": "error", "message": "No diagram image was generated. Did you call with Diagram(..., show=False)?"} + + # Use the most recently modified file or the first one + generated_files.sort(key=lambda f: f.stat().st_mtime, reverse=True) + output_file = generated_files[0] + + # If a filename was requested, we might want to rename it? + # For now, we return the path. + # In a real MCP setup, we might copy this to a mounted volume. + + # Copy the generated file back to the original working directory + # This ensures that if the user mounted their project to the working directory, + # the file appears in their project. + import shutil + + target_dir = Path(original_cwd) + target_filename = filename if filename else output_file.name + target_path = target_dir / target_filename + + # Ensure extension + if not target_path.suffix: + target_path = target_path.with_suffix(".png") + + shutil.copy2(output_file, target_path) + final_path = str(target_path) + + return { + "status": "success", + "path": final_path, + "filename": target_path.name + } + + except Exception as e: + return {"status": "error", "message": f"System error: {str(e)}"} + + finally: + os.chdir(original_cwd) + +if __name__ == "__main__": + mcp.run() From 120c8d7090094db1ff77bf1acda91fe898bf0e67 Mon Sep 17 00:00:00 2001 From: Carlos Barbero Date: Tue, 27 Jan 2026 08:43:56 -0300 Subject: [PATCH 02/16] fix: resolve docker build error by installing diagrams from PyPI --- mcp-server/Dockerfile | 5 ++--- mcp-server/requirements.txt | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mcp-server/Dockerfile b/mcp-server/Dockerfile index 9d2eb83f..cb926710 100644 --- a/mcp-server/Dockerfile +++ b/mcp-server/Dockerfile @@ -14,9 +14,8 @@ WORKDIR /app # Copy the current directory (which contains the diagrams library source and our server code) COPY . /app -# Install the diagrams library from source (current dir) and other requirements -# We install 'mcp' explicitly as it might not be in the local repo's requirements -RUN pip install --no-cache-dir . mcp +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt # Create an output directory for persistence if volume is mounted RUN mkdir -p /app/output diff --git a/mcp-server/requirements.txt b/mcp-server/requirements.txt index af278fdb..b4f267b2 100644 --- a/mcp-server/requirements.txt +++ b/mcp-server/requirements.txt @@ -1,2 +1,3 @@ mcp +diagrams graphviz From 5dea6c2600154ea847e65dd3a6afa1a1d2b46076 Mon Sep 17 00:00:00 2001 From: Carlos Barbero Date: Tue, 27 Jan 2026 12:06:24 -0300 Subject: [PATCH 03/16] Fix inspection.py import logic and update docs --- mcp-server/src/inspection.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mcp-server/src/inspection.py b/mcp-server/src/inspection.py index 356c2847..222029f4 100644 --- a/mcp-server/src/inspection.py +++ b/mcp-server/src/inspection.py @@ -62,6 +62,11 @@ def get_all_nodes(): # Verify it belongs to this module (to avoid re-export noise) # or at least is defined in the diagrams package if obj.__module__.startswith("diagrams"): + # Ensure the object actually belongs to this service (or a submodule of it) + # This prevents listing imported classes from other services (e.g. Trace in operations vs devtools) + if not obj.__module__.startswith(service_name): + continue + icons[short_provider][short_service].append(name) node_registry[name] = obj From 76ddff1f77c22836dcc2b6ef696d0e435adddc86 Mon Sep 17 00:00:00 2001 From: Carlos Barbero Date: Thu, 29 Jan 2026 07:32:49 -0300 Subject: [PATCH 04/16] ci: add github actions to build and publish docker image to ghcr --- .../.github/workflows/docker-publish.yml | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 mcp-server/.github/workflows/docker-publish.yml diff --git a/mcp-server/.github/workflows/docker-publish.yml b/mcp-server/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..5de268a0 --- /dev/null +++ b/mcp-server/.github/workflows/docker-publish.yml @@ -0,0 +1,61 @@ +name: Docker Build and Publish + +on: + push: + branches: [ "main", "feat/diagrams-mcp-server" ] + # Publish semver tags as releases. + tags: [ 'v*.*.*' ] + pull_request: + branches: [ "main" ] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (only on push) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max From 05e8597048c735d6e2105c3af430060cc97d2578 Mon Sep 17 00:00:00 2001 From: Carlos Barbero Date: Thu, 29 Jan 2026 08:14:24 -0300 Subject: [PATCH 05/16] ci: move docker-publish workflow to root and update context --- .../.github => .github}/workflows/docker-publish.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) rename {mcp-server/.github => .github}/workflows/docker-publish.yml (89%) diff --git a/mcp-server/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml similarity index 89% rename from mcp-server/.github/workflows/docker-publish.yml rename to .github/workflows/docker-publish.yml index 5de268a0..0d0f75bf 100644 --- a/mcp-server/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,11 +2,15 @@ name: Docker Build and Publish on: push: - branches: [ "main", "feat/diagrams-mcp-server" ] + branches: [ "feat/diagrams-mcp-server" ] + paths: + - 'mcp-server/**' # Publish semver tags as releases. tags: [ 'v*.*.*' ] pull_request: - branches: [ "main" ] + branches: [ "feat/diagrams-mcp-server" ] + paths: + - 'mcp-server/**' env: # Use docker.io for Docker Hub if empty @@ -53,7 +57,7 @@ jobs: id: build-and-push uses: docker/build-push-action@v5 with: - context: . + context: ./mcp-server push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} From 09e3411bb3f81a91a27e201b1bf2a3d016dc1a8f Mon Sep 17 00:00:00 2001 From: Carlos Barbero Date: Thu, 29 Jan 2026 08:18:51 -0300 Subject: [PATCH 06/16] ci: add master branch to docker-publish triggers --- .github/workflows/docker-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 0d0f75bf..6d037b7c 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,13 +2,13 @@ name: Docker Build and Publish on: push: - branches: [ "feat/diagrams-mcp-server" ] + branches: [ "master", "feat/diagrams-mcp-server" ] paths: - 'mcp-server/**' # Publish semver tags as releases. tags: [ 'v*.*.*' ] pull_request: - branches: [ "feat/diagrams-mcp-server" ] + branches: [ "master", "feat/diagrams-mcp-server" ] paths: - 'mcp-server/**' From 43b3dbd2f47cc05b87e7e9dd3501ae66696ab6fd Mon Sep 17 00:00:00 2001 From: Carlos Barbero Date: Thu, 29 Jan 2026 08:23:43 -0300 Subject: [PATCH 07/16] ci: add manual trigger support (workflow_dispatch) --- .github/workflows/docker-publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6d037b7c..bb82b50e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -11,6 +11,7 @@ on: branches: [ "master", "feat/diagrams-mcp-server" ] paths: - 'mcp-server/**' + workflow_dispatch: env: # Use docker.io for Docker Hub if empty From 26e4e8cb3635064442d6e936312414207cc3f698 Mon Sep 17 00:00:00 2001 From: Carlos Barbero Date: Thu, 29 Jan 2026 08:27:48 -0300 Subject: [PATCH 08/16] ci: add setup-buildx-action to fix cache-backend error --- .github/workflows/docker-publish.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index bb82b50e..a8a71079 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -34,6 +34,11 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + # Set up Docker Buildx + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + # Login against a Docker registry except on PR # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} From 8325e6e0ccaa5731aeebf915a03374e930ec0caa Mon Sep 17 00:00:00 2001 From: Carlos Barbero Date: Thu, 29 Jan 2026 08:30:17 -0300 Subject: [PATCH 09/16] ci: update image name to diagrams-mcp --- .github/workflows/docker-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index a8a71079..ace2b74e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -16,8 +16,8 @@ on: env: # Use docker.io for Docker Hub if empty REGISTRY: ghcr.io - # github.repository as / - IMAGE_NAME: ${{ github.repository }} + # Custom image name as /diagrams-mcp + IMAGE_NAME: ${{ github.repository_owner }}/diagrams-mcp jobs: build: From 57433dc2e242c152931903f1fb605b1e1100ee87 Mon Sep 17 00:00:00 2001 From: Carlos Barbero Date: Thu, 29 Jan 2026 08:36:46 -0300 Subject: [PATCH 10/16] ci: enable multi-platform build (amd64 and arm64) --- .github/workflows/docker-publish.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ace2b74e..6e6c970b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -34,6 +34,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + # Set up QEMU for multi-platform builds + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + # Set up Docker Buildx # https://github.com/docker/setup-buildx-action - name: Set up Docker Buildx @@ -67,5 +71,6 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 cache-from: type=gha cache-to: type=gha,mode=max From adb8661cb7f41cc20888272e41afb8ed3fefa311 Mon Sep 17 00:00:00 2001 From: Carlos Barbero Date: Fri, 30 Jan 2026 09:24:09 -0300 Subject: [PATCH 11/16] chore: fix pre-commit hook failures and formatting --- .isort.cfg | 2 +- mcp-server/GEMINI.md | 2 +- mcp-server/README.md | 10 ++--- mcp-server/src/inspection.py | 25 ++++++------ mcp-server/src/server.py | 76 ++++++++++++++++++++---------------- 5 files changed, 64 insertions(+), 51 deletions(-) diff --git a/.isort.cfg b/.isort.cfg index 8112570b..10d71835 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -2,4 +2,4 @@ line_length = 120 multi_line_output = 3 include_trailing_comma = True -known_third_party = graphviz,jinja2 +known_third_party = graphviz,inspection,jinja2,mcp diff --git a/mcp-server/GEMINI.md b/mcp-server/GEMINI.md index 10944df3..5d85c146 100644 --- a/mcp-server/GEMINI.md +++ b/mcp-server/GEMINI.md @@ -78,7 +78,7 @@ The server will define three main tools. It should use the `mcp.server.fastmcp` - `code` (string, required): The Python code using `diagrams` DSL. - `filename` (string, optional): Desired output filename. -* **Returns**: +* **Returns**: - `status`: "success" or "error" - `message`: Path to file or error message. diff --git a/mcp-server/README.md b/mcp-server/README.md index 0174fe96..3263918d 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -14,12 +14,12 @@ graph TD subgraph Container ["Docker Container"] MCPServer["MCP Server (server.py)"] NodeRegistry["(Node Registry)"] - + subgraph Logic ["Core Logic"] Inspector["src/inspection.py"] Executor["generate_diagram"] end - + subgraph Libs ["Dependencies"] DiagramsLib["diagrams package"] Graphviz["Graphviz Binary"] @@ -34,12 +34,12 @@ graph TD %% Tool Flows Client -- "list_icons()" --> MCPServer MCPServer -- "Query" --> NodeRegistry - + Client -- "generate_diagram(code)" --> MCPServer MCPServer -- "Pass Code" --> Executor Executor -- "exec()" --> DiagramsLib DiagramsLib -- "Render" --> Graphviz - + %% Output Graphviz -- "Generates PNG" --> Executor Executor -- "Writes File (Volume Mount)" --> HostFS @@ -109,7 +109,7 @@ Generates a diagram from Python code. ```python from diagrams import Diagram from diagrams.aws.compute import EC2 - + with Diagram("Simple", show=False): EC2("web") ``` diff --git a/mcp-server/src/inspection.py b/mcp-server/src/inspection.py index 222029f4..3300f75e 100644 --- a/mcp-server/src/inspection.py +++ b/mcp-server/src/inspection.py @@ -1,15 +1,17 @@ -import pkgutil import importlib import inspect +import pkgutil import sys from collections import defaultdict + import diagrams from diagrams import Node + def get_all_nodes(): """ Dynamically inspects the diagrams package and returns a dictionary of all available Nodes. - + Returns: dict: A nested dictionary structure: { @@ -20,7 +22,7 @@ def get_all_nodes(): """ # Initialize the structure icons = defaultdict(lambda: defaultdict(list)) - + # We also keep a flat map for the execution context: Name -> Class # This handles potential name collisions by favoring the last seen or explicit logic if needed. node_registry = {} @@ -33,14 +35,14 @@ def get_all_nodes(): for _, provider_name, ispkg in pkgutil.iter_modules(path, prefix): if not ispkg: continue - + # e.g., provider_name = "diagrams.aws" short_provider = provider_name.split(".")[-1] - + # Skip internal modules if any (base, etc are actually useful, but we focus on providers) - if short_provider in ['base', 'custom']: - # 'custom' and 'base' might be treated differently, but for now we scan them - pass + if short_provider in ['base', 'custom']: + # 'custom' and 'base' might be treated differently, but for now we scan them + pass try: provider_module = importlib.import_module(provider_name) @@ -63,13 +65,14 @@ def get_all_nodes(): # or at least is defined in the diagrams package if obj.__module__.startswith("diagrams"): # Ensure the object actually belongs to this service (or a submodule of it) - # This prevents listing imported classes from other services (e.g. Trace in operations vs devtools) + # This prevents listing imported classes from other services (e.g. Trace + # in operations vs devtools) if not obj.__module__.startswith(service_name): continue - + icons[short_provider][short_service].append(name) node_registry[name] = obj - + except ImportError: continue except Exception: diff --git a/mcp-server/src/server.py b/mcp-server/src/server.py index e1cb0c78..7f75e966 100644 --- a/mcp-server/src/server.py +++ b/mcp-server/src/server.py @@ -1,14 +1,15 @@ +import base64 +import contextlib import os import sys import tempfile -import contextlib -import base64 from pathlib import Path -from mcp.server.fastmcp import FastMCP -from diagrams import Diagram, Cluster, Edge, Node # Import our helper from inspection import get_all_nodes +from mcp.server.fastmcp import FastMCP + +from diagrams import Cluster, Diagram, Edge, Node # Initialize FastMCP mcp = FastMCP("diagrams-mcp") @@ -18,11 +19,12 @@ print("Loading diagram nodes...", file=sys.stderr) ALL_ICONS, NODE_REGISTRY = get_all_nodes() print(f"Loaded {len(NODE_REGISTRY)} nodes.", file=sys.stderr) + @mcp.tool() def list_icons(provider_filter: str = None, service_filter: str = None): """ List available icons from the diagrams package, with optional filtering. - + Args: provider_filter: Filter icons by provider name (e.g., "aws", "gcp", "k8s") service_filter: Filter icons by service name (e.g., "compute", "database") @@ -30,26 +32,30 @@ def list_icons(provider_filter: str = None, service_filter: str = None): if not provider_filter: # Return list of providers return {"providers": list(ALL_ICONS.keys())} - + if provider_filter not in ALL_ICONS: return {"error": f"Provider '{provider_filter}' not found. Available: {list(ALL_ICONS.keys())}"} - + provider_data = ALL_ICONS[provider_filter] - + if not service_filter: # Return all services for this provider return provider_data - + if service_filter not in provider_data: - return {"error": f"Service '{service_filter}' not found in '{provider_filter}'. Available: {list(provider_data.keys())}"} - + return { + "error": f"Service '{service_filter}' not found in '{provider_filter}'. Available: { + list( + provider_data.keys())}"} + return {service_filter: provider_data[service_filter]} + @mcp.tool() def get_diagram_examples(diagram_type: str = "all"): """ Get example code for different types of diagrams. - + Args: diagram_type: Type of diagram example to return (aws, k8s, flow, etc. or 'all') """ @@ -70,11 +76,11 @@ from diagrams.k8s.network import Ingress, Service with Diagram("K8s Cluster", show=False): ingress = Ingress("domain.com") - + with Cluster("App"): svc = Service("svc") pods = [Pod("pod1"), Pod("pod2")] - + ingress >> svc >> pods """, "custom": """ @@ -86,28 +92,29 @@ with Diagram("Custom", show=False): Custom("Label", "./my-icon.png") """ } - + if diagram_type == "all": return examples - + return {diagram_type: examples.get(diagram_type, "No example found for this type.")} + @mcp.tool() def generate_diagram(code: str, filename: str = None, timeout: int = 90): """ Generate a diagram from Python code using the diagrams package. - + Args: code: Python code using the diagrams package DSL. filename: Optional filename to save the diagram to. timeout: Execution timeout in seconds. """ - + # Create a temporary directory for execution with tempfile.TemporaryDirectory() as temp_dir: original_cwd = os.getcwd() os.chdir(temp_dir) - + try: # Prepare the execution context # We inject Diagram, Cluster, Edge, and ALL discovered nodes (EC2, Pod, etc.) @@ -120,57 +127,60 @@ def generate_diagram(code: str, filename: str = None, timeout: int = 90): "Node": Node, **NODE_REGISTRY } - + # Execute the code # We wrap it in a try/except block within the exec to catch runtime errors try: exec(code, exec_globals) except Exception as e: return {"status": "error", "message": f"Runtime error: {str(e)}"} - + # Find the generated file # Diagrams generates files based on the name passed to Diagram() class # We look for any .png file created in the temp dir generated_files = list(Path(".").glob("*.png")) - + if not generated_files: - return {"status": "error", "message": "No diagram image was generated. Did you call with Diagram(..., show=False)?"} - + return { + "status": "error", + "message": "No diagram image was generated. Did you call with Diagram(..., show=False)?"} + # Use the most recently modified file or the first one generated_files.sort(key=lambda f: f.stat().st_mtime, reverse=True) output_file = generated_files[0] - + # If a filename was requested, we might want to rename it? - # For now, we return the path. + # For now, we return the path. # In a real MCP setup, we might copy this to a mounted volume. - + # Copy the generated file back to the original working directory # This ensures that if the user mounted their project to the working directory, # the file appears in their project. import shutil - + target_dir = Path(original_cwd) target_filename = filename if filename else output_file.name target_path = target_dir / target_filename - + # Ensure extension if not target_path.suffix: target_path = target_path.with_suffix(".png") - + shutil.copy2(output_file, target_path) final_path = str(target_path) - + return { - "status": "success", + "status": "success", "path": final_path, "filename": target_path.name } except Exception as e: return {"status": "error", "message": f"System error: {str(e)}"} - + finally: os.chdir(original_cwd) + if __name__ == "__main__": mcp.run() From 4eb914f2643798b857f90ccd0ae47a1d95755b29 Mon Sep 17 00:00:00 2001 From: Carlos Barbero Date: Fri, 30 Jan 2026 10:20:09 -0300 Subject: [PATCH 12/16] fix: ensure global diagram context is cleared if rendering fails --- diagrams/__init__.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/diagrams/__init__.py b/diagrams/__init__.py index db3203c8..c9517dc5 100644 --- a/diagrams/__init__.py +++ b/diagrams/__init__.py @@ -205,10 +205,13 @@ class Diagram: return self def __exit__(self, exc_type, exc_value, traceback): - self.render() - # Remove the graphviz file leaving only the image. - os.remove(self.filename) - setdiagram(None) + try: + self.render() + finally: + if os.path.exists(self.filename): + # Remove the graphviz file leaving only the image. + os.remove(self.filename) + setdiagram(None) def _repr_png_(self): return self.dot.pipe(format="png") From 14f30fcf730a498db604719ea291521e1d67e9f2 Mon Sep 17 00:00:00 2001 From: Carlos Barbero Date: Fri, 30 Jan 2026 10:47:17 -0300 Subject: [PATCH 13/16] fix: ensure global diagram and cluster contexts are cleared even on rendering failure --- diagrams/__init__.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/diagrams/__init__.py b/diagrams/__init__.py index c9517dc5..be8e2174 100644 --- a/diagrams/__init__.py +++ b/diagrams/__init__.py @@ -208,10 +208,14 @@ class Diagram: try: self.render() finally: - if os.path.exists(self.filename): - # Remove the graphviz file leaving only the image. - os.remove(self.filename) + try: + if os.path.exists(self.filename) and os.path.isfile(self.filename): + # Remove the graphviz file leaving only the image. + os.remove(self.filename) + except OSError: + pass setdiagram(None) + setcluster(None) def _repr_png_(self): return self.dot.pipe(format="png") @@ -316,11 +320,13 @@ class Cluster: return self def __exit__(self, exc_type, exc_value, traceback): - if self._parent: - self._parent.subgraph(self.dot) - else: - self._diagram.subgraph(self.dot) - setcluster(self._parent) + try: + if self._parent: + self._parent.subgraph(self.dot) + else: + self._diagram.subgraph(self.dot) + finally: + setcluster(self._parent) def _validate_direction(self, direction: str) -> bool: return direction.upper() in self.__directions From 9bd47fc3dcf0ef02a879b388ba305a03184e6b5c Mon Sep 17 00:00:00 2001 From: Carlos Barbero Date: Fri, 30 Jan 2026 11:31:47 -0300 Subject: [PATCH 14/16] fix: compatibility with python 3.9 and refined context cleanup --- diagrams/__init__.py | 1 - file1.py | 4 ++++ file2.py | 12 ++++++++++++ mcp-server/src/server.py | 4 +--- test_diagram.py | 4 ++++ 5 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 file1.py create mode 100644 file2.py create mode 100644 test_diagram.py diff --git a/diagrams/__init__.py b/diagrams/__init__.py index be8e2174..8627de23 100644 --- a/diagrams/__init__.py +++ b/diagrams/__init__.py @@ -215,7 +215,6 @@ class Diagram: except OSError: pass setdiagram(None) - setcluster(None) def _repr_png_(self): return self.dot.pipe(format="png") diff --git a/file1.py b/file1.py new file mode 100644 index 00000000..46f07282 --- /dev/null +++ b/file1.py @@ -0,0 +1,4 @@ + +from diagrams import Diagram +with Diagram(name="Test", show=False): + pass diff --git a/file2.py b/file2.py new file mode 100644 index 00000000..e6370e77 --- /dev/null +++ b/file2.py @@ -0,0 +1,12 @@ + +from diagrams import Diagram +from diagrams.aws.compute import EC2 +from diagrams.aws.database import RDS +from diagrams.aws.network import ELB + +with Diagram("test_2", show=False, direction="TB"): + ELB("lb") >> [EC2("ワーカー1"), + EC2("작업자 2를"), + EC2("робітник 3"), + EC2("worker4"), + EC2("työntekijä 4")] >> RDS("events") diff --git a/mcp-server/src/server.py b/mcp-server/src/server.py index 7f75e966..09679c6f 100644 --- a/mcp-server/src/server.py +++ b/mcp-server/src/server.py @@ -44,9 +44,7 @@ def list_icons(provider_filter: str = None, service_filter: str = None): if service_filter not in provider_data: return { - "error": f"Service '{service_filter}' not found in '{provider_filter}'. Available: { - list( - provider_data.keys())}"} + "error": f"Service '{service_filter}' not found in '{provider_filter}'. Available: {list(provider_data.keys())}"} return {service_filter: provider_data[service_filter]} diff --git a/test_diagram.py b/test_diagram.py new file mode 100644 index 00000000..46f07282 --- /dev/null +++ b/test_diagram.py @@ -0,0 +1,4 @@ + +from diagrams import Diagram +with Diagram(name="Test", show=False): + pass From f43aac4a0acc3dba22c1056d08571b5744a910c1 Mon Sep 17 00:00:00 2001 From: Carlos Barbero Date: Fri, 30 Jan 2026 11:32:07 -0300 Subject: [PATCH 15/16] chore: remove accidental test files --- file1.py | 4 ---- file2.py | 12 ------------ test_diagram.py | 4 ---- 3 files changed, 20 deletions(-) delete mode 100644 file1.py delete mode 100644 file2.py delete mode 100644 test_diagram.py diff --git a/file1.py b/file1.py deleted file mode 100644 index 46f07282..00000000 --- a/file1.py +++ /dev/null @@ -1,4 +0,0 @@ - -from diagrams import Diagram -with Diagram(name="Test", show=False): - pass diff --git a/file2.py b/file2.py deleted file mode 100644 index e6370e77..00000000 --- a/file2.py +++ /dev/null @@ -1,12 +0,0 @@ - -from diagrams import Diagram -from diagrams.aws.compute import EC2 -from diagrams.aws.database import RDS -from diagrams.aws.network import ELB - -with Diagram("test_2", show=False, direction="TB"): - ELB("lb") >> [EC2("ワーカー1"), - EC2("작업자 2를"), - EC2("робітник 3"), - EC2("worker4"), - EC2("työntekijä 4")] >> RDS("events") diff --git a/test_diagram.py b/test_diagram.py deleted file mode 100644 index 46f07282..00000000 --- a/test_diagram.py +++ /dev/null @@ -1,4 +0,0 @@ - -from diagrams import Diagram -with Diagram(name="Test", show=False): - pass From 42d6bece7467539b54c4113de541473aba0fce99 Mon Sep 17 00:00:00 2001 From: Carlos Barbero Date: Fri, 30 Jan 2026 11:40:12 -0300 Subject: [PATCH 16/16] ci: fix poetry installation and pin version to 2.1.1 --- .github/workflows/test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7b56e730..508dc209 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,11 @@ jobs: - name: Setup Graphviz uses: ts-graphviz/setup-graphviz@v2 - name: Install poetry - run: curl -sSL https://install.python-poetry.org | python3 - + run: | + curl -sSL https://install.python-poetry.org | python3 - + echo "$HOME/.local/bin" >> $GITHUB_PATH + env: + POETRY_VERSION: 2.1.1 - name: Run all tests run: | poetry install