Last active
June 6, 2026 21:55
-
-
Save teo-mateo/b3b1fb3c8dbc0f5d9fd250075bfd4eb3 to your computer and use it in GitHub Desktop.
render-html MCP tool
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment