| 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. |