Skip to main content
Glama
main.py14 kB
import codecs import os import sys import json import asyncio import requests import logging from typing import Dict, Any, List, Optional # Assuming mcp framework and pydantic are installed from pydantic import BaseModel, Field from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent # --- Configuration --- BASE_URL = "https://www.mermaidchart.com" MERMAID_ACCESS_TOKEN = "MERMAID_ACCESS_TOKEN" DEFAULT_THEME = "light" # Or "dark" # --- Logging Setup --- # Log to stderr to avoid interfering with stdio JSON communication logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", stream=sys.stderr, ) logger = logging.getLogger(__name__) # --- Pydantic Input Model --- class RenderMermaidChartInput(BaseModel): mermaid_code: str = Field(..., description="The Mermaid diagram code.") document_id: Optional[str] = Field( default=None, # Make default None explicitly description="Optional: The Document ID of an existing diagram to update. If omitted, a new diagram will be created.", ) output_path: str = Field( ..., description="The file path where the PNG image should be saved (e.g., 'output/diagram.png').", ) theme: Optional[str] = Field( default=DEFAULT_THEME, description="Theme for rendering ('light' or 'dark')." ) # --- Service Class for API Interaction (remains mostly the same) --- class MermaidChartService: """Handles communication with the Mermaid Chart API.""" # ... (Keep the MermaidChartService class exactly as defined in the previous version) ... # Including: __init__, _request, get_projects, create_document, get_png def __init__(self, access_token: str): if not access_token: # Log error to stderr logger.error("Access token cannot be empty.") raise ValueError("Access token cannot be empty.") self.access_token = access_token self.session = requests.Session() self.session.headers.update({"Authorization": f"Bearer {self.access_token}"}) self.base_url = BASE_URL def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response: """Helper method for making API requests.""" url = f"{self.base_url}{endpoint}" try: response = self.session.request(method, url, **kwargs) response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) return response except requests.exceptions.RequestException as e: logger.error(f"API request failed: {method} {url} - {e}") error_detail = "No specific error detail provided." if e.response is not None: try: error_data = e.response.json() error_detail = error_data.get("message", json.dumps(error_data)) except json.JSONDecodeError: error_detail = e.response.text logger.error(f"Error detail: {error_detail}") raise # Re-raise the exception after logging def get_projects(self) -> List[Dict[str, Any]]: """Fetches the list of projects for the user.""" logger.info("Fetching projects...") endpoint = "/rest-api/projects" response = self._request("GET", endpoint) projects = response.json() logger.info(f"Found {len(projects)} projects.") return projects def create_document(self, code: str, project_id: str) -> Dict[str, Any]: """Creates a new document with the given Mermaid code in a project.""" logger.info(f"Creating document in project {project_id}...") endpoint = f"/rest-api/projects/{project_id}/documents" payload = {"code": code} response = self._request("POST", endpoint, json=payload) document = response.json() logger.info(f"Document created successfully: ID {document.get('documentID')}") return document def get_png( self, document_id: str, major: str, minor: str, theme: str = DEFAULT_THEME ) -> bytes: """Retrieves the PNG image data for a specific document version.""" logger.info( f"Fetching PNG for document {document_id} v{major}.{minor} (theme: {theme})..." ) major_str = str(major) minor_str = str(minor) endpoint = f"/raw/{document_id}?version=v{major_str}.{minor_str}&theme={theme}&format=png" response = self._request("GET", endpoint) logger.info(f"PNG data received ({len(response.content)} bytes).") return response.content def patch_document(self, document_id: str, code: str) -> Dict[str, Any]: """Updates an existing document with the given Mermaid code.""" logger.info(f"Patching document {document_id}...") endpoint = f"/rest-api/documents/{document_id}" payload = {"code": code} response = self._request("PATCH", endpoint, json=payload) document = response.json() logger.info( f"Document {document_id} patched successfully: New version v{document.get('major')}.{document.get('minor')}" ) logger.info(f"{document}") return document # --- MCP Server Implementation --- async def serve() -> None: logger.info("Initializing Mermaid MCP server...") server = Server("mcp-mermaid") # Use a unique name for the server # Tool implementation function async def _render_mermaid_chart_impl( mermaid_code: str, output_path: str, theme: str, document_id: Optional[str] = None, ) -> Dict[str, Any]: """Internal logic for rendering or updating the chart.""" access_token = os.environ.get(MERMAID_ACCESS_TOKEN) if not access_token: raise ValueError( f"{MERMAID_ACCESS_TOKEN} environment variable is required." ) # --- Process escaped characters in mermaid_code --- try: # Use codecs.decode to handle escapes like \\n -> \n # This will leave existing \n untouched. processed_mermaid_code = codecs.decode(mermaid_code, "unicode_escape") logger.debug( "Processed mermaid code (first 100 chars): %s", processed_mermaid_code[:100], ) except Exception as e: logger.error("Failed to process mermaid code escapes: %s", e) # Fallback or re-raise depending on desired behavior. # Here, we'll proceed with the original code but log a warning. logger.warning( "Proceeding with original mermaid code due to processing error." ) processed_mermaid_code = mermaid_code # Or raise ValueError("Invalid escape sequences in mermaid_code") # --- End of processing --- service = MermaidChartService(access_token) # Use processed_mermaid_code from now on if not processed_mermaid_code: raise ValueError("Mermaid code cannot be empty (after processing escapes).") if not output_path: raise ValueError("Output path cannot be empty.") if not output_path.lower().endswith(".png"): logger.warning("Output path does not end with .png, appending it.") output_path += ".png" major = None minor = None if document_id: # 1. Patch existing document logger.info(f"Attempting to update existing document: {document_id}") # Use processed_mermaid_code document = service.patch_document(document_id, processed_mermaid_code) major = document.get("major", 0) minor = document.get("minor", 1) else: # 1. Get projects and select the first one projects = service.get_projects() if not projects: raise RuntimeError( "No projects found. Please create a project in Mermaid Chart." ) project_id = projects[0].get("id") if not project_id: raise RuntimeError("Could not determine Project ID from Mermaid Chart.") logger.info(f"Using project ID: {project_id}") # 2. Create the document # Use processed_mermaid_code document = service.create_document(processed_mermaid_code, project_id) document_id = document.get("documentID") # Assign the newly created ID major = document.get("major") minor = document.get("minor") if not all([document_id, major is not None, minor is not None]): raise RuntimeError( f"Failed to create/update document or get necessary details (ID, version).({document_id}, {major}, {minor})" ) # 3. Get the PNG data png_data = service.get_png( document_id, str(major), str(minor), theme ) # Synchronous # 4. Save the PNG data to the output file abs_output_path = os.path.abspath(output_path) logger.info(f"Saving PNG image to: {abs_output_path}") os.makedirs(os.path.dirname(abs_output_path), exist_ok=True) # File I/O is blocking, run in executor for better async performance loop = asyncio.get_running_loop() await loop.run_in_executor(None, _save_file, abs_output_path, png_data) # with open(abs_output_path, "wb") as f: # f.write(png_data) # Blocking I/O logger.info("Mermaid chart rendered/updated successfully.") return {"output_path": abs_output_path, "document_id": document_id} def _save_file(path: str, data: bytes): """Helper function to run blocking file write in executor.""" with open(path, "wb") as f: f.write(data) @server.list_tools() async def list_tools() -> list[Tool]: logger.info("Executing list_tools request") return [ Tool( name="render_mermaid_chart", # Update description description="Renders Mermaid code using the Mermaid Chart API and saves it as a PNG image. If a 'document_id' is provided, it updates the existing diagram; otherwise, it creates a new one. Returns the output path and the document ID. If the result has status code 400 Bad Request, the diagram likely has syntax errors; review and try again.", inputSchema=RenderMermaidChartInput.model_json_schema(), ) ] @server.call_tool() async def call_tool(name: str, arguments: dict) -> list[TextContent]: logger.info( f"Executing call_tool request for '{name}' with arguments: {arguments}" ) result_data = {} try: if name == "render_mermaid_chart": # Validate input using Pydantic model validated_input = RenderMermaidChartInput(**arguments) # Call the implementation function # Run the potentially blocking network/file operations result_data = await _render_mermaid_chart_impl( mermaid_code=validated_input.mermaid_code, output_path=validated_input.output_path, theme=validated_input.theme or DEFAULT_THEME, document_id=validated_input.document_id, ) logger.info(f"Tool '{name}' executed successfully.") # Wrap successful result in TextContent return [TextContent(type="text", text=json.dumps(result_data))] else: logger.error(f"Unknown tool name: {name}") raise ValueError(f"Unknown tool: {name}") except (ValueError, RuntimeError, requests.exceptions.RequestException) as e: logger.error(f"Error executing tool '{name}': {e}", exc_info=True) # Wrap error in TextContent error_result = {"error": str(e)} return [TextContent(type="text", text=json.dumps(error_result))] except Exception as e: logger.exception( f"Unexpected error executing tool '{name}'." ) # Logs traceback error_result = {"error": f"An unexpected error occurred: {e}"} return [TextContent(type="text", text=json.dumps(error_result))] # --- Run the server using stdio --- logger.info("Starting stdio server loop...") options = server.create_initialization_options() try: async with stdio_server() as (read_stream, write_stream): logger.info("stdio streams opened. Running server...") await server.run( read_stream, write_stream, options, raise_exceptions=False ) # Set raise_exceptions=False to handle errors gracefully within call_tool except Exception as e: logger.exception( "Critical error during server setup or run." ) # Log any error during setup/run finally: logger.info("Mermaid MCP server finished.") # --- Main execution --- def main(): # Ensure access token is checked early, though actual use is in call_tool token = os.environ.get(MERMAID_ACCESS_TOKEN) if not token: logger.error( f"CRITICAL: Environment variable {MERMAID_ACCESS_TOKEN} is not set. The MCP will not be able to function." ) # Exit early if the token is missing, as initialization within call_tool will fail anyway. sys.exit(f"Error: {MERMAID_ACCESS_TOKEN} not set.") # Run the asyncio event loop try: asyncio.run(serve()) except KeyboardInterrupt: logger.info("Shutdown requested via KeyboardInterrupt.") except Exception as e: logger.exception( "Application level error." ) # Catch errors during asyncio.run if any if __name__ == "__main__": main()

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/daltonnyx/userful-mcps'

If you have feedback or need assistance with the MCP directory API, please join our Discord server