# render-html MCP tool

> Source: <https://gist.github.com/teo-mateo/b3b1fb3c8dbc0f5d9fd250075bfd4eb3>
> Published: 2026-05-19 06:46:51+00:00

| Give this to your LLM of choice. | |
| Don't like python? ask it to build it in any language you prefer. | |
| Note: I am running this on Linux, but same thing can be done for Windows or macOS, too. | |
| ---- | |
| Build a "render-html-mcp" — MCP Server for Rendering HTML in Desktop Popup Windows | |
| Overview | |
| 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. | |
| Project Structure | |
| render-html-mcp/ | |
| ├── pyproject.toml | |
| ├── README.md | |
| └── src/ | |
| └── renderhtmlmcp/ | |
| ├── __init__.py # empty | |
| ├── server.py # all MCP logic | |
| └── cli.py # thin CLI entry point | |
| pyproject.toml | |
| - Build system: setuptools (>=61.0) | |
| - Package name: render-html-mcp, version 0.1.0 | |
| - requires-python: >=3.10 | |
| - Dependencies: mcp>=1.0.0, markdown>=3.5, Pygments>=2.17 | |
| - Console script: render-html-mcp = renderhtmlmcp.cli:cli | |
| - Setuptools packages.find: where = ["src"] | |
| - Ruff config: target-version = "py310", line-length = 100, lint select ["E", "F", "W", "I", "UP"] | |
| cli.py | |
| 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. | |
| server.py — Core Logic | |
| MCP Instance | |
| from mcp.server.fastmcp import FastMCP | |
| mcp = FastMCP("render-html-mcp") | |
| Temp Directory | |
| 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. | |
| Browser Discovery | |
| - Chromium-family preference (supports --app= for chrome-less standalone windows): | |
| google-chrome, google-chrome-stable, chromium, chromium-browser, brave-browser, microsoft-edge | |
| - Firefox fallback (uses --new-window, shows full chrome): | |
| firefox, firefox-esr | |
| - Use shutil.which() to find the first available on PATH. Return (binary_path, mode) where mode is "chromium-app" or "firefox-window", or None. | |
| Command Building | |
| - 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"] | |
| - 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. | |
| - firefox-window mode: [browser, "--new-window", url] | |
| Launch (Shared by All Tools) | |
| The _launch_url(url, width, height) function: | |
| 1. Calls _pick_browser(). If None, return {"ok": False, "error": "No supported browser found on PATH..."}. | |
| 2. Checks os.environ for DISPLAY or WAYLAND_DISPLAY. If neither set, return {"ok": False, "error": "No DISPLAY or WAYLAND_DISPLAY set..."}. | |
| 3. Builds command via _build_command(). | |
| 4. Spawns with subprocess.Popen(cmd, stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL, start_new_session=True, close_fds=True). | |
| 5. Returns {"ok": True, "pid": proc.pid, "browser": browser, "mode": mode}. | |
| Markdown CSS (GitHub-ish Typography) | |
| Inline a self-contained CSS block (_MARKDOWN_CSS) with: | |
| - color-scheme: light dark on :root | |
| - System font stack (-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif) | |
| - max-width: 860px, centered with margin: 2rem auto, padding: 0 1.5rem, line-height: 1.6 | |
| - Light theme: color: #1f2328, background: #ffffff, links #0969da, code blocks #f6f8fa | |
| - 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 | |
| - 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. | |
| - Code: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace, 0.92em, padded, rounded, 0.9em inside <pre> | |
| - Blockquote: border-left: 4px solid, padding: 0 1em | |
| - Table: border-collapse: collapse, borders on th/td, striped rows, display: block with overflow-x: auto | |
| - Images: max-width: 100% | |
| - Lists: padding-left: 2em, li + li margin top 0.25em | |
| - Horizontal rule: border-top, margin: 2em 0 | |
| Markdown Rendering | |
| _render_markdown(md, title) converts markdown to a full HTML document: | |
| - Uses markdown.markdown() with extensions: extra, sane_lists, codehilite, toc, admonition | |
| - codehilite config: guess_lang=False, noclasses=True, pygments_style="default" | |
| - Sanitizes title: replace & → &, < → <, > → > | |
| - Wraps in <!doctype html> with <html lang="en">, <meta charset="utf-8">, title, inline <style> with _MARKDOWN_CSS, then <body> with the rendered content | |
| HTML Fragment Wrapping | |
| _wrap_html(html, title): | |
| - If html.lstrip() starts with <!doctype or <html (case-insensitive), return as-is | |
| - 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 | |
| Four MCP Tools | |
| 1. render_html(html: str, title: str = "render-html-mcp", width: int = 900, height: int = 700) -> dict | |
| - Wraps the HTML string via _wrap_html(), writes to <_RENDER_DIR>/render-<uuid4>.html | |
| - Launches browser with the file URI (file_path.as_uri()) | |
| - On success, adds "path" (absolute path to the written file) to the result | |
| - Returns {ok, path, pid, browser, mode} or {ok: False, error: ...} | |
| 2. render_html_from_file(file_path: str, width: int = 900, height: int = 700) -> dict | |
| - Expands ~ and resolves to absolute path via Path(file_path).expanduser().resolve() | |
| - Validates: file exists (is_file()) and is readable (os.access(os.R_OK)) | |
| - Opens the file directly (no copy) via its URI | |
| - Returns {ok, path, pid, browser, mode} or {ok: False, error: ...} | |
| 3. render_html_from_markdown(markdown: str, title: str = "render-html-mcp", width: int = 900, height: int = 700) -> dict | |
| - Converts markdown via _render_markdown(), writes to <_RENDER_DIR>/render-md-<uuid4>.html | |
| - Launches browser with the file URI | |
| - Returns {ok, path, pid, browser, mode} or {ok: False, error: ...} | |
| 4. render_html_from_markdown_file(file_path: str, title: str | None = None, width: int = 900, height: int = 700) -> dict | |
| - Expands and resolves path; validates existence and readability | |
| - Reads markdown content | |
| - Default title: title if provided, otherwise the file's .stem | |
| - Writes rendered HTML to <_RENDER_DIR>/render-md-<uuid4>.html | |
| - Returns {ok, path, source, pid, browser, mode} — note "source" is the original .md path | |
| - The original markdown file is never modified | |
| run_server() | |
| At module level, define run_server() -> None which calls mcp.run(). | |
| Key Design Decisions to Preserve | |
| 1. Detached process: start_new_session=True + stdio to DEVNULL + close_fds=True ensures the browser window survives the MCP server process ending | |
| 2. Per-window user-data-dir: Unique --user-data-dir (UUID-based) prevents single-instance locking with the user's normal browser | |
| 3. No JS rendering engine: This is NOT headless; it relies on the user's desktop browser (requires DISPLAY or WAYLAND_DISPLAY) | |
| 4. Fragment detection: Checks for full document vs fragment to avoid double-wrapping | |
| 5. No garbage collection: Temp files and chrome profiles are NOT cleaned up. Document this limitation in the README. |
