{"slug": "render-html-mcp-tool", "title": "render-html MCP tool", "summary": "A developer has built a \"render-html-mcp\" tool, an MCP server that pops up a standalone desktop browser window to display HTML or Markdown content. The Python-based server uses the FastMCP framework and supports both Chromium-family browsers (with a chrome-less app mode) and Firefox as a fallback, launching each window with a unique user-data directory to avoid collisions. The tool returns immediately after spawning the browser process, which remains open until the user closes it.", "body_md": "| Give this to your LLM of choice. | |\n| Don't like python? ask it to build it in any language you prefer. | |\n| Note: I am running this on Linux, but same thing can be done for Windows or macOS, too. | |\n| ---- | |\n| Build a \"render-html-mcp\" — MCP Server for Rendering HTML in Desktop Popup Windows | |\n| Overview | |\n| Create a Python MCP server (using the mcp package's FastMCP framework) that pops up a standalone desktop browser window displaying HTML/markdown content and returns immediately. The window stays open until the user closes it. The browser process is fully detached (start_new_session=True, stdio to /dev/null) so it outlives the MCP call. | |\n| Project Structure | |\n| render-html-mcp/ | |\n| ├── pyproject.toml | |\n| ├── README.md | |\n| └── src/ | |\n| └── renderhtmlmcp/ | |\n| ├── __init__.py # empty | |\n| ├── server.py # all MCP logic | |\n| └── cli.py # thin CLI entry point | |\n| pyproject.toml | |\n| - Build system: setuptools (>=61.0) | |\n| - Package name: render-html-mcp, version 0.1.0 | |\n| - requires-python: >=3.10 | |\n| - Dependencies: mcp>=1.0.0, markdown>=3.5, Pygments>=2.17 | |\n| - Console script: render-html-mcp = renderhtmlmcp.cli:cli | |\n| - Setuptools packages.find: where = [\"src\"] | |\n| - Ruff config: target-version = \"py310\", line-length = 100, lint select [\"E\", \"F\", \"W\", \"I\", \"UP\"] | |\n| cli.py | |\n| A minimal entry point. The cli() function imports and calls run_server() from server.py, returns 0. Has an if __name__ == \"__main__\" guard that calls cli() via SystemExit. | |\n| server.py — Core Logic | |\n| MCP Instance | |\n| from mcp.server.fastmcp import FastMCP | |\n| mcp = FastMCP(\"render-html-mcp\") | |\n| Temp Directory | |\n| The render directory defaults to $RENDER_HTML_MCP_DIR if set, otherwise <tempfile.gettempdir()>/render-html-mcp/ (i.e., /tmp/render-html-mcp). Store it in _RENDER_DIR as a Path. | |\n| Browser Discovery | |\n| - Chromium-family preference (supports --app= for chrome-less standalone windows): | |\n| google-chrome, google-chrome-stable, chromium, chromium-browser, brave-browser, microsoft-edge | |\n| - Firefox fallback (uses --new-window, shows full chrome): | |\n| firefox, firefox-esr | |\n| - Use shutil.which() to find the first available on PATH. Return (binary_path, mode) where mode is \"chromium-app\" or \"firefox-window\", or None. | |\n| Command Building | |\n| - chromium-app mode: [browser, \"--app={url}\", \"--window-size={width},{height}\", \"--user-data-dir={_RENDER_DIR/profile/<uuid4-hex>}\", \"--no-first-run\", \"--no-default-browser-check\"] | |\n| - Each window gets a unique --user-data-dir under _RENDER_DIR/profile/ so it never collides with the user's running browser or single-instance lock. | |\n| - firefox-window mode: [browser, \"--new-window\", url] | |\n| Launch (Shared by All Tools) | |\n| The _launch_url(url, width, height) function: | |\n| 1. Calls _pick_browser(). If None, return {\"ok\": False, \"error\": \"No supported browser found on PATH...\"}. | |\n| 2. Checks os.environ for DISPLAY or WAYLAND_DISPLAY. If neither set, return {\"ok\": False, \"error\": \"No DISPLAY or WAYLAND_DISPLAY set...\"}. | |\n| 3. Builds command via _build_command(). | |\n| 4. Spawns with subprocess.Popen(cmd, stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL, start_new_session=True, close_fds=True). | |\n| 5. Returns {\"ok\": True, \"pid\": proc.pid, \"browser\": browser, \"mode\": mode}. | |\n| Markdown CSS (GitHub-ish Typography) | |\n| Inline a self-contained CSS block (_MARKDOWN_CSS) with: | |\n| - color-scheme: light dark on :root | |\n| - System font stack (-apple-system, BlinkMacSystemFont, \"Segoe UI\", system-ui, sans-serif) | |\n| - max-width: 860px, centered with margin: 2rem auto, padding: 0 1.5rem, line-height: 1.6 | |\n| - Light theme: color: #1f2328, background: #ffffff, links #0969da, code blocks #f6f8fa | |\n| - Dark theme (via @media (prefers-color-scheme: dark)): color: #e6edf3, background: #0d1117, links #4493f8, code blocks #161b22, blockquote #8b949e, borders #30363d, striped table rows #161b22 | |\n| - Headings: font-weight: 600, line-height: 1.25, margin-top: 1.6em, margin-bottom: 0.6em. H1/H2 have border-bottom: 1px solid currentColor + padding-bottom: .3em. H1 at 2em, H2 at 1.5em, both with slight opacity. | |\n| - Code: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace, 0.92em, padded, rounded, 0.9em inside <pre> | |\n| - Blockquote: border-left: 4px solid, padding: 0 1em | |\n| - Table: border-collapse: collapse, borders on th/td, striped rows, display: block with overflow-x: auto | |\n| - Images: max-width: 100% | |\n| - Lists: padding-left: 2em, li + li margin top 0.25em | |\n| - Horizontal rule: border-top, margin: 2em 0 | |\n| Markdown Rendering | |\n| _render_markdown(md, title) converts markdown to a full HTML document: | |\n| - Uses markdown.markdown() with extensions: extra, sane_lists, codehilite, toc, admonition | |\n| - codehilite config: guess_lang=False, noclasses=True, pygments_style=\"default\" | |\n| - Sanitizes title: replace & → &, < → <, > → > | |\n| - Wraps in <!doctype html> with <html lang=\"en\">, <meta charset=\"utf-8\">, title, inline <style> with _MARKDOWN_CSS, then <body> with the rendered content | |\n| HTML Fragment Wrapping | |\n| _wrap_html(html, title): | |\n| - If html.lstrip() starts with <!doctype or <html (case-insensitive), return as-is | |\n| - Otherwise wrap in a minimal document with <!doctype html>, <html lang=\"en\">, charset meta, sanitized title, and a minimal body style: font-family:system-ui,sans-serif;margin:1.5rem;line-height:1.5 | |\n| Four MCP Tools | |\n| 1. render_html(html: str, title: str = \"render-html-mcp\", width: int = 900, height: int = 700) -> dict | |\n| - Wraps the HTML string via _wrap_html(), writes to <_RENDER_DIR>/render-<uuid4>.html | |\n| - Launches browser with the file URI (file_path.as_uri()) | |\n| - On success, adds \"path\" (absolute path to the written file) to the result | |\n| - Returns {ok, path, pid, browser, mode} or {ok: False, error: ...} | |\n| 2. render_html_from_file(file_path: str, width: int = 900, height: int = 700) -> dict | |\n| - Expands ~ and resolves to absolute path via Path(file_path).expanduser().resolve() | |\n| - Validates: file exists (is_file()) and is readable (os.access(os.R_OK)) | |\n| - Opens the file directly (no copy) via its URI | |\n| - Returns {ok, path, pid, browser, mode} or {ok: False, error: ...} | |\n| 3. render_html_from_markdown(markdown: str, title: str = \"render-html-mcp\", width: int = 900, height: int = 700) -> dict | |\n| - Converts markdown via _render_markdown(), writes to <_RENDER_DIR>/render-md-<uuid4>.html | |\n| - Launches browser with the file URI | |\n| - Returns {ok, path, pid, browser, mode} or {ok: False, error: ...} | |\n| 4. render_html_from_markdown_file(file_path: str, title: str | None = None, width: int = 900, height: int = 700) -> dict | |\n| - Expands and resolves path; validates existence and readability | |\n| - Reads markdown content | |\n| - Default title: title if provided, otherwise the file's .stem | |\n| - Writes rendered HTML to <_RENDER_DIR>/render-md-<uuid4>.html | |\n| - Returns {ok, path, source, pid, browser, mode} — note \"source\" is the original .md path | |\n| - The original markdown file is never modified | |\n| run_server() | |\n| At module level, define run_server() -> None which calls mcp.run(). | |\n| Key Design Decisions to Preserve | |\n| 1. Detached process: start_new_session=True + stdio to DEVNULL + close_fds=True ensures the browser window survives the MCP server process ending | |\n| 2. Per-window user-data-dir: Unique --user-data-dir (UUID-based) prevents single-instance locking with the user's normal browser | |\n| 3. No JS rendering engine: This is NOT headless; it relies on the user's desktop browser (requires DISPLAY or WAYLAND_DISPLAY) | |\n| 4. Fragment detection: Checks for full document vs fragment to avoid double-wrapping | |\n| 5. No garbage collection: Temp files and chrome profiles are NOT cleaned up. Document this limitation in the README. |", "url": "https://wpnews.pro/news/render-html-mcp-tool", "canonical_source": "https://gist.github.com/teo-mateo/b3b1fb3c8dbc0f5d9fd250075bfd4eb3", "published_at": "2026-05-19 06:46:51+00:00", "updated_at": "2026-06-06 22:13:14.200075+00:00", "lang": "en", "topics": ["ai-tools", "ai-products", "ai-infrastructure"], "entities": ["MCP", "FastMCP", "Python", "Linux", "Windows", "macOS", "setuptools", "Pygments"], "alternates": {"html": "https://wpnews.pro/news/render-html-mcp-tool", "markdown": "https://wpnews.pro/news/render-html-mcp-tool.md", "text": "https://wpnews.pro/news/render-html-mcp-tool.txt", "jsonld": "https://wpnews.pro/news/render-html-mcp-tool.jsonld"}}