Skip to main content
Glama

word2img-mcp

by AdolphNB
render.py33.7 kB
from __future__ import annotations import asyncio import base64 import json import os import re import subprocess import tempfile from dataclasses import dataclass from pathlib import Path from typing import List, Optional, Tuple, Dict, Any, Union from datetime import datetime # 延迟导入 requests,避免在未安装时阻断其他后端 try: import requests # type: ignore REQUESTS_AVAILABLE = True except Exception: REQUESTS_AVAILABLE = False # 尝试导入PIL作为备选方案 try: from PIL import Image, ImageDraw, ImageFont PIL_AVAILABLE = True except ImportError: PIL_AVAILABLE = False # 尝试导入imgkit和markdown try: import imgkit import markdown from markdown.extensions import codehilite, fenced_code, tables IMGKIT_AVAILABLE = True except ImportError: IMGKIT_AVAILABLE = False # 默认配置 ASPECT_RATIO = (3, 4) DEFAULT_WIDTH = 1200 DEFAULT_HEIGHT = 1600 DEFAULT_BG = (255, 255, 255) # 纯白色 DEFAULT_TEXT_COLOR = (0, 0, 0) # 黑色 DEFAULT_ACCENT_COLOR = (70, 130, 180) # 钢蓝色 # 边距比例 SIDE_MARGIN_RATIO = 0.08 TOP_BOTTOM_MARGIN_RATIO = 0.08 # 渲染后端优先级 BACKEND_PRIORITY = [ "imgkit-wkhtmltopdf", "markdown-pdf-cli", "md-to-image", "pil" ] # 支持的输出格式 SUPPORTED_FORMATS = ["png", "jpg", "jpeg", "webp"] # 最大尺寸限制 MAX_WIDTH = 4000 MAX_HEIGHT = 6000 @dataclass class RenderOptions: """Options for rendering markdown to image with detailed configuration.""" width: int = 1200 height: int = 1600 align: str = "center" bold: bool = False background_color: str = "#FFFFFF" text_color: str = "#000000" accent_color: str = "#4682B4" font_family: str = "Microsoft YaHei, PingFang SC, Helvetica Neue, Arial, sans-serif" font_size: int = 20 line_height: float = 1.6 header_scale: float = 1.5 theme: str = "default" shadow: bool = True watermark: bool = False watermark_text: str = "Generated by word2img-mcp" output_format: str = "png" quality: int = 95 backend_preference: str = "auto" backend_used: Optional[str] = None def get_available_backends() -> List[str]: """Get list of available rendering backends.""" available = [] # Check imgkit-wkhtmltopdf try: import imgkit available.append("imgkit-wkhtmltopdf") except ImportError: pass # Check markdown-pdf-cli try: result = subprocess.run(["npx", "markdown-pdf", "--version"], capture_output=True, text=True, timeout=5) if result.returncode == 0: available.append("markdown-pdf-cli") except (subprocess.TimeoutExpired, FileNotFoundError): pass # Check md-to-image try: import md_to_image available.append("md-to-image") except ImportError: pass # Check PIL (always available) available.append("pil") return available def get_renderer_status() -> Dict[str, bool]: """Get status of all rendering backends.""" backends = get_available_backends() return { "imgkit-wkhtmltopdf": "imgkit-wkhtmltopdf" in backends, "markdown-pdf-cli": "markdown-pdf-cli" in backends, "md-to-image": "md-to-image" in backends, "pil": True, # PIL is always available "total_available": len(backends) } def get_backend_details() -> Dict[str, Dict]: """Get detailed information about each backend.""" details = {} # imgkit-wkhtmltopdf details try: import imgkit details["imgkit-wkhtmltopdf"] = { "available": True, "type": "html-to-image", "quality": "high", "speed": "medium", "features": ["css_styling", "custom_fonts", "complex_layouts"] } except ImportError: details["imgkit-wkhtmltopdf"] = {"available": False} # markdown-pdf-cli details try: result = subprocess.run(["npx", "markdown-pdf", "--version"], capture_output=True, text=True, timeout=5) details["markdown-pdf-cli"] = { "available": result.returncode == 0, "type": "markdown-to-pdf", "quality": "medium", "speed": "slow", "features": ["markdown_support", "basic_styling"] } except (subprocess.TimeoutExpired, FileNotFoundError): details["markdown-pdf-cli"] = {"available": False} # md-to-image details try: import md_to_image details["md-to-image"] = { "available": True, "type": "markdown-to-image", "quality": "medium", "speed": "fast", "features": ["simple_rendering", "fast_processing"] } except ImportError: details["md-to-image"] = {"available": False} # PIL details details["pil"] = { "available": True, "type": "text-to-image", "quality": "basic", "speed": "very_fast", "features": ["basic_text", "simple_graphics", "fallback"] } return details class MarkdownRenderer: """Markdown渲染器 - 支持多种后端""" def __init__(self): self.backends = ['imgkit-wkhtmltopdf', 'markdown-pdf-cli', 'md-to-image-cli', 'md-to-image-api', 'pil-fallback'] self.md_to_image_api_url = "http://localhost:3000/convert" # 可配置的API地址 def render(self, text: str, options: RenderOptions) -> str: """渲染Markdown为图片""" for backend in self.backends: try: if backend == 'imgkit-wkhtmltopdf': return self._render_with_imgkit(text, options) elif backend == 'markdown-pdf-cli': return self._render_with_markdown_pdf(text, options) elif backend == 'md-to-image-cli': return self._render_with_cli(text, options) elif backend == 'md-to-image-api': return self._render_with_api(text, options) elif backend == 'pil-fallback': return self._render_with_pil(text, options) except Exception as e: print(f"⚠️ {backend} 渲染失败: {e}") continue raise RuntimeError("所有渲染后端都失败了") def _render_with_imgkit(self, text: str, options: RenderOptions) -> str: """使用imgkit/wkhtmltopdf渲染""" if not IMGKIT_AVAILABLE: raise RuntimeError("imgkit不可用") # 将Markdown转换为HTML html_content = self._markdown_to_html(text, options) # 配置 wkhtmltopdf 可执行文件路径 config = imgkit.config() # 尝试常见的 wkhtmltopdf 安装路径 possible_paths = [ r"C:\Program Files\wkhtmltopdf\bin\wkhtmltoimage.exe", r"C:\Program Files (x86)\wkhtmltopdf\bin\wkhtmltoimage.exe", "/usr/local/bin/wkhtmltoimage", "/usr/bin/wkhtmltoimage", "wkhtmltoimage" # 如果在 PATH 中 ] wkhtmltoimage_path = None for path in possible_paths: if os.path.exists(path): wkhtmltoimage_path = path break if wkhtmltoimage_path: config = imgkit.config(wkhtmltoimage=wkhtmltoimage_path) # 设置wkhtmltoimage选项 (注意:wkhtmltoimage 支持的参数与 wkhtmltopdf 不同) wkhtmltoimage_options = { 'width': options.width, 'height': options.height, 'quality': 95, 'format': options.output_format.upper() if options.output_format.lower() in ['png', 'jpg', 'jpeg'] else 'PNG', } # 生成输出文件路径 output_dir = Path("outputs") output_dir.mkdir(exist_ok=True) output_file = output_dir / f"imgkit_{os.getpid()}_{hash(text[:100]) % 10000}.{options.output_format}" try: # 使用imgkit渲染为图片 imgkit.from_string( html_content, str(output_file), options=wkhtmltoimage_options, config=config ) if output_file.exists(): return str(output_file) else: raise RuntimeError("图片文件未生成") except Exception as e: raise RuntimeError(f"imgkit渲染失败: {e}") def _markdown_to_html(self, text: str, options: RenderOptions) -> str: """将Markdown转换为带样式的HTML""" # 配置markdown扩展 extensions = [ 'markdown.extensions.tables', 'markdown.extensions.fenced_code', 'markdown.extensions.codehilite', 'markdown.extensions.nl2br', 'markdown.extensions.toc', 'markdown.extensions.attr_list', ] # 转换markdown为HTML md = markdown.Markdown(extensions=extensions) html_body = md.convert(text) # 生成完整的HTML文档 html_template = f""" <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Markdown to Image</title> <style> {self._generate_css(options)} </style> </head> <body> <div class="container"> {html_body} </div> {self._generate_watermark(options) if options.watermark else ''} </body> </html> """ return html_template.strip() def _generate_css(self, options: RenderOptions) -> str: """生成CSS样式""" bg_color = options.background_color text_color = options.text_color accent_color = options.accent_color text_align = options.align if text_align == "center": text_align = "center" elif text_align == "right": text_align = "right" else: text_align = "left" css = f""" * {{ margin: 0; padding: 0; box-sizing: border-box; }} body {{ font-family: {options.font_family}; font-size: {options.font_size}px; line-height: {options.line_height}; color: {text_color}; background-color: {bg_color}; width: {options.width}px; min-height: {options.height}px; margin: 0; padding: 0; }} .container {{ padding: {int(options.height * TOP_BOTTOM_MARGIN_RATIO)}px {int(options.width * SIDE_MARGIN_RATIO)}px; text-align: {text_align}; height: 100%; width: 100%; box-sizing: border-box; }} h1, h2, h3, h4, h5, h6 {{ color: {accent_color}; margin: 0.5em 0; font-weight: bold; }} h1 {{ font-size: {int(options.font_size * options.header_scale * 2)}px; }} h2 {{ font-size: {int(options.font_size * options.header_scale * 1.7)}px; }} h3 {{ font-size: {int(options.font_size * options.header_scale * 1.4)}px; }} h4 {{ font-size: {int(options.font_size * options.header_scale * 1.2)}px; }} h5 {{ font-size: {int(options.font_size * options.header_scale * 1.1)}px; }} h6 {{ font-size: {int(options.font_size * options.header_scale)}px; }} p {{ margin: 0.6em 0; text-align: {text_align}; }} strong, b {{ font-weight: bold; color: {accent_color}; }} em, i {{ font-style: italic; }} ul, ol {{ margin: 0.5em 0; padding-left: 2em; }} li {{ margin: 0.3em 0; }} blockquote {{ border-left: 4px solid {accent_color}; margin: 1em 0; padding: 0.5em 1em; background: rgba({options.accent_color[0]}, {options.accent_color[1]}, {options.accent_color[2]}, 0.1); font-style: italic; }} code {{ background-color: rgba(0, 0, 0, 0.1); padding: 2px 4px; border-radius: 3px; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: {int(options.font_size * 0.9)}px; }} pre {{ background-color: rgba(0, 0, 0, 0.1); padding: 1em; border-radius: 5px; overflow-x: auto; margin: 1em 0; }} pre code {{ background: none; padding: 0; }} table {{ border-collapse: collapse; width: 100%; margin: 1em 0; }} th, td {{ border: 1px solid {accent_color}; padding: 0.5em 1em; text-align: left; }} th {{ background-color: {accent_color}; color: {bg_color}; font-weight: bold; }} hr {{ border: none; height: 2px; background-color: {accent_color}; margin: 1.5em 0; }} a {{ color: {accent_color}; text-decoration: underline; }} img {{ max-width: 100%; height: auto; margin: 1em 0; }} """ if options.shadow: css += f""" h1, h2, h3, h4, h5, h6 {{ text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); }} """ return css def _generate_watermark(self, options: RenderOptions) -> str: """生成水印HTML""" if not options.watermark: return "" return f""" <div style=" position: fixed; bottom: 20px; right: 20px; font-size: 12px; color: rgba(128, 128, 128, 0.7); font-family: Arial, sans-serif; "> {options.watermark_text} </div> """ def _render_with_markdown_pdf(self, text: str, options: RenderOptions) -> str: """使用markdown-pdf CLI渲染""" # 创建临时Markdown文件 with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False, encoding='utf-8') as f: f.write(text) md_file = f.name try: # 生成输出文件路径 output_dir = Path("outputs") output_dir.mkdir(exist_ok=True) # 先生成PDF pdf_file = output_dir / f"md_pdf_{os.getpid()}_{hash(text[:100]) % 10000}.pdf" # 构建命令 cmd = [ 'npx', 'markdown-pdf', md_file, '--out', str(pdf_file), '--paper-format', 'A4', '--paper-orientation', 'portrait' ] # 执行命令 result = subprocess.run( cmd, capture_output=True, text=True, check=True, timeout=60 # 60秒超时 ) if not pdf_file.exists(): raise RuntimeError("PDF文件未生成") # 如果输出格式是PDF,直接返回 if options.output_format.lower() == 'pdf': return str(pdf_file) # 否则需要将PDF转换为图片 # 这里可以使用PIL来转换PDF到图片 if PIL_AVAILABLE: try: from pdf2image import convert_from_path # type: ignore images = convert_from_path(str(pdf_file)) if images: # 取第一页 img = images[0] # 调整尺寸 img = img.resize((options.width, options.height), Image.Resampling.LANCZOS) # 保存为指定格式 output_file = output_dir / f"md_pdf_{os.getpid()}_{hash(text[:100]) % 10000}.{options.output_format}" if options.output_format.lower() == 'png': img.save(output_file, format="PNG", optimize=True) else: img.save(output_file, format="JPEG", quality=95, optimize=True) # 清理PDF文件 try: os.unlink(pdf_file) except: pass return str(output_file) except ImportError: print("⚠️ pdf2image不可用,无法转换PDF到图片") # 如果没有pdf2image,返回PDF文件路径 return str(pdf_file) # 如果PIL不可用,返回PDF文件路径 return str(pdf_file) except subprocess.TimeoutExpired: raise RuntimeError("渲染超时") except subprocess.CalledProcessError as e: raise RuntimeError(f"CLI执行失败: {e.stderr}") finally: # 清理临时文件 try: os.unlink(md_file) except: pass def _render_with_cli(self, text: str, options: RenderOptions) -> str: """使用md-to-image CLI渲染""" # 创建临时Markdown文件 with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False, encoding='utf-8') as f: f.write(text) md_file = f.name try: # 生成输出文件路径 output_dir = Path("outputs") output_dir.mkdir(exist_ok=True) output_file = output_dir / f"md_cli_{os.getpid()}_{hash(text[:100]) % 10000}.{options.output_format}" # 构建命令 cmd = [ 'md-to-image', md_file, '--output', str(output_file), '--width', str(options.width), '--height', str(options.height) ] # 添加主题选项 if options.theme != "default": cmd.extend(['--theme', options.theme]) # 执行命令 result = subprocess.run( cmd, capture_output=True, text=True, check=True, timeout=60 # 60秒超时 ) if output_file.exists(): return str(output_file) else: raise RuntimeError("输出文件未生成") except subprocess.TimeoutExpired: raise RuntimeError("渲染超时") except subprocess.CalledProcessError as e: raise RuntimeError(f"CLI执行失败: {e.stderr}") finally: # 清理临时文件 try: os.unlink(md_file) except: pass def _render_with_api(self, text: str, options: RenderOptions) -> str: """使用HTTP API渲染""" if not REQUESTS_AVAILABLE: raise RuntimeError("requests 不可用,无法使用 md-to-image API 后端") try: # 准备请求数据 payload = { 'markdown': text, 'format': options.output_format, 'width': options.width, 'height': options.height, 'theme': options.theme, 'background': options.background, 'text_color': options.text_color, 'accent_color': options.accent_color } # 发送请求 response = requests.post( self.md_to_image_api_url, json=payload, timeout=60 ) if response.status_code == 200: # 保存返回的图片 output_dir = Path("outputs") output_dir.mkdir(exist_ok=True) output_file = output_dir / f"md_api_{os.getpid()}_{hash(text[:100]) % 10000}.{options.output_format}" with open(output_file, 'wb') as f: f.write(response.content) return str(output_file) else: raise RuntimeError(f"API调用失败: {response.status_code} - {response.text}") except Exception as e: raise RuntimeError(f"API请求失败: {e}") def _render_with_pil(self, text: str, options: RenderOptions) -> str: """使用PIL作为备选方案""" if not PIL_AVAILABLE: raise RuntimeError("PIL不可用") # 解析Markdown segments = self._parse_markdown(text) # 创建图片 img = Image.new("RGB", (options.width, options.height), options.background) draw = ImageDraw.Draw(img) # 计算基础字体大小 base_font_size = max(16, min(80, options.font_size)) y_offset = int(options.height * TOP_BOTTOM_MARGIN_RATIO) x_left = int(options.width * SIDE_MARGIN_RATIO) max_width = int(options.width * (1 - 2 * SIDE_MARGIN_RATIO)) for segment in segments: # 处理表格 if segment.get('is_table'): font_size = base_font_size - 4 font = self._load_font(font_size, False) table_height = self._render_table(draw, segment['table_data'], font, x_left, y_offset, max_width, options.text_color) y_offset += table_height + int(base_font_size * 0.6) continue # 根据段落类型确定字体大小和样式 if segment.get('is_header'): font_size = min(base_font_size + (4 - segment['header_level']) * 8, 80) is_bold = True elif segment.get('is_bold'): font_size = base_font_size is_bold = True else: font_size = base_font_size is_bold = False # 加载字体 font = self._load_font(font_size, is_bold) # 文字换行处理 wrapped_lines = self._wrap_text(draw, segment['text'], font, max_width) # 绘制文本 line_height = font.getbbox("Hg")[3] - font.getbbox("Hg")[1] line_spacing = int(font_size * 0.3) if options.align == "center": # 计算整个文本块的宽度,以此居中整个段落 max_line_width = max(self._measure_text(draw, line, font)[0] for line in wrapped_lines) if wrapped_lines else 0 block_x = (options.width - max_line_width) // 2 for line in wrapped_lines: draw.text((block_x, y_offset), line, fill=options.text_color, font=font) y_offset += line_height + line_spacing else: # 左对齐 for line in wrapped_lines: draw.text((x_left, y_offset), line, fill=options.text_color, font=font) y_offset += line_height + line_spacing # 段落间距 y_offset += int(font_size * 0.4) # 添加水印 if options.watermark: watermark_font = self._load_font(12, False) draw.text((options.width - 150, options.height - 40), options.watermark_text, fill=(128, 128, 128), font=watermark_font) # 保存图片 output_dir = Path("outputs") output_dir.mkdir(exist_ok=True) output_file = output_dir / f"pil_{os.getpid()}_{hash(text[:100]) % 10000}.{options.output_format}" if options.output_format.lower() == 'png': img.save(output_file, format="PNG", optimize=True) else: img.save(output_file, format="JPEG", quality=95, optimize=True) return str(output_file) def _load_font(self, size: int, bold: bool = False): """加载字体""" font_candidates = [ "C:\\Windows\\Fonts\\msyh.ttc", # 微软雅黑 "C:\\Windows\\Fonts\\msyhbd.ttc", # 微软雅黑粗体 "C:\\Windows\\Fonts\\simhei.ttf", # 黑体 "C:\\Windows\\Fonts\\simsun.ttc", # 宋体 "C:\\Windows\\Fonts\\simkai.ttf", # 楷体 "C:\\Windows\\Fonts\\simfang.ttf", # 仿宋 "/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/STHeiti Light.ttc", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", ] if bold: bold_candidates = [ "C:\\Windows\\Fonts\\msyhbd.ttc", "C:\\Windows\\Fonts\\simhei.ttf", ] for path in bold_candidates: if os.path.exists(path): try: return ImageFont.truetype(path, size) except Exception: continue for path in font_candidates: if os.path.exists(path): try: return ImageFont.truetype(path, size) except Exception: continue try: return ImageFont.load_default(size=size) except: return ImageFont.load_default() def _parse_markdown(self, text: str) -> List[Dict]: """解析Markdown文本""" segments = [] lines = text.split('\n') i = 0 while i < len(lines): line = lines[i].strip() if not line: i += 1 continue # 处理标题 if line.startswith('#'): header_match = re.match(r'^(#{1,6})\s*(.*)', line) if header_match: level = len(header_match.group(1)) text_content = header_match.group(2) segments.append({ 'text': text_content, 'is_header': True, 'header_level': level }) i += 1 continue # 检测表格 if '|' in line: table_data = [] while i < len(lines) and lines[i].strip(): table_line = lines[i].strip() if '|' in table_line: cells = [cell.strip() for cell in table_line.split('|')] if cells and not cells[0]: cells = cells[1:] if cells and not cells[-1]: cells = cells[:-1] if not all('-' in cell or not cell.strip() for cell in cells): table_data.append(cells) else: break i += 1 if table_data: segments.append({ 'text': "", 'is_table': True, 'table_data': table_data }) continue # 处理加粗文本 parts = re.split(r'(\*\*[^*]+\*\*)', line) for part in parts: if part.startswith('**') and part.endswith('**') and len(part) > 4: bold_text = part[2:-2] if bold_text.strip(): segments.append({ 'text': bold_text, 'is_bold': True }) elif part.strip(): segments.append({ 'text': part }) i += 1 return segments def _wrap_text(self, draw, text: str, font, max_width: int) -> List[str]: """文本换行""" lines = [] current = "" for ch in text.replace('\r', ''): if ch == '\n': lines.append(current) current = "" continue probe = current + ch w, _ = self._measure_text(draw, probe, font) if w <= max_width: current = probe else: if current: lines.append(current) current = ch else: lines.append(probe) current = "" if current: lines.append(current) return lines def _measure_text(self, draw, text: str, font) -> Tuple[int, int]: """测量文本尺寸""" try: bbox = draw.textbbox((0, 0), text, font=font) return bbox[2] - bbox[0], bbox[3] - bbox[1] except Exception: bbox = font.getbbox(text) return bbox[2] - bbox[0], bbox[3] - bbox[1] def _render_table(self, draw, table_data: List[List[str]], font, start_x: int, start_y: int, max_width: int, text_color: Tuple[int, int, int]) -> int: """渲染表格""" if not table_data: return 0 col_count = max(len(row) for row in table_data) if table_data else 0 col_widths = [] for col in range(col_count): max_col_width = 0 for row in table_data: if col < len(row): cell_text = row[col] w, _ = self._measure_text(draw, cell_text, font) max_col_width = max(max_col_width, w) col_widths.append(max_col_width) padding = 20 total_table_width = sum(col_widths) + padding * (col_count - 1) if total_table_width > max_width: scale_factor = max_width / total_table_width col_widths = [int(w * scale_factor) for w in col_widths] total_table_width = max_width line_height = font.getbbox("Hg")[3] - font.getbbox("Hg")[1] row_height = line_height + 10 current_y = start_y for row_idx, row in enumerate(table_data): current_x = start_x for col_idx in range(col_count): if col_idx < len(row): cell_text = row[col_idx] if row_idx == 0: cell_font = self._load_font(font.size, bold=True) else: cell_font = font draw.text((current_x, current_y), cell_text, fill=text_color, font=cell_font) if col_idx < len(col_widths): current_x += col_widths[col_idx] + padding current_y += row_height return current_y - start_y def render_markdown_text_to_image(md_text: str, options: Optional[RenderOptions] = None) -> str: """渲染Markdown文本为图片文件""" if options is None: options = RenderOptions() # 创建渲染器 renderer = MarkdownRenderer() # 渲染图片 return renderer.render(md_text, options) # 保留原有的接口兼容性 def render_markdown_text_to_image_legacy(md_text: str, options: Optional[RenderOptions] = None): """兼容原有接口的渲染函数""" return render_markdown_text_to_image(md_text, options) def get_available_backends(): """获取可用的渲染后端""" backends = {} # 检查 imgkit try: import imgkit backends['imgkit'] = {'available': True, 'version': 'installed'} except ImportError: backends['imgkit'] = {'available': False, 'error': 'not installed'} # 检查 PIL backends['pil'] = {'available': PIL_AVAILABLE} # 检查 markdown backends['markdown'] = {'available': IMGKIT_AVAILABLE} return backends def get_renderer_status(): """获取渲染器状态""" return { 'default_backend': 'imgkit-wkhtmltopdf', 'fallback_available': PIL_AVAILABLE, 'total_backends': len(['imgkit-wkhtmltopdf', 'markdown-pdf-cli', 'md-to-image-cli', 'md-to-image-api', 'pil-fallback']) } def get_backend_details(): """获取后端详细信息""" return { 'imgkit': { 'name': 'imgkit/wkhtmltopdf', 'priority': 1, 'available': IMGKIT_AVAILABLE, 'description': 'HTML rendering engine with wkhtmltopdf' }, 'pil': { 'name': 'PIL/Pillow', 'priority': 4, 'available': PIL_AVAILABLE, 'description': 'Python Imaging Library fallback' } }

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/AdolphNB/md2img-mcp'

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