Skip to main content

MDZero

PyPI

A Markdown-based documentation site generator with a built-in Quart web frontend, live-reload dev server, in-memory search, and a Model Context Protocol (MCP) server so an AI client can read and edit your docs directly.

docs/*.md  --(mdzero dev)-->  http://127.0.0.1:8000  (nav, search, MCP)

Install

pip install mdzero

Requires Python 3.12+. To work on MDZero itself instead, clone the repo and install it editable with dev dependencies: pip install -e ".[dev]".

Quickstart

mdzero init my-docs
cd my-docs
mdzero dev

Open http://127.0.0.1:8000. Edit any file under docs/ while dev is running -- the page reloads with the change, no restart needed.

mdzero init <dir>            # scaffold a new project
mdzero init <dir> -t components  # scaffold a components-showcase project instead (see below)
mdzero dev [dir]             # dev server: draft pages visible, live-reload on
mdzero serve [dir]           # production server: draft pages 404, no watcher
mdzero build [dir] --out DIR # static export (default: <dir>/dist) -- see "Production deployment"
mdzero config [dir]          # show resolved config and where each value came from
mdzero --version

Every command accepts --debug (before or after the subcommand name, e.g. both mdzero --debug dev and mdzero dev --debug work) to show a full Python traceback instead of the friendly error panel MDZero shows by default for its own error types (bad config, malformed frontmatter, invalid content paths, etc).

Project layout

my-docs/
├── docs/                   # your content -- CONTENT_DIR
│   ├── index.md            # -> /
│   └── getting-started/
│       └── index.md        # -> /getting-started/
├── public/                 # static files served at the site root
├── mdzero.config.py        # project config (see below)
├── .env                    # optional env var overrides
├── .gitignore
├── Dockerfile              # `docker compose up` runs this project's own `mdzero serve`
└── docker-compose.yml

A page's route is derived from its path under docs/: index.md files become the section index (trailing-slash route), everything else is a leaf page. Frontmatter fields: title, description, slug (route override), order (nav sort position), hidden (routable, hidden from nav/search), draft (visible in dev mode only, with a banner; 404s in serve), aliases (a list of old/alternate routes that redirect to this page -- e.g. aliases: ["/old-path"]), icon (a single literal character/emoji, e.g. icon: 🚀, shown next to this page's -- or, on a section's own index.md, that section's -- sidebar entry; omit it and nothing changes, no icon library or lookup involved).

mdzero init scaffolds either of two starter docs/ trees, chosen with --template/-t: default (the two-page tree shown above, and the default if you omit the flag) or components -- a small real docs site under docs/components/ demonstrating every markdown component below (callouts, cards, steps) plus icon: frontmatter, useful as a live reference while you're learning the syntax.

docs/404.md, if present, is your own custom 404 page: rendered through the normal markdown/frontmatter pipeline (so it can use callouts, cards, whatever the rest of your docs use), served with a real 404 status for any unmatched route in both mdzero serve/dev and a static mdzero build export. It's never a normally-navigable page itself (no /404 route at 200) and is excluded from nav/search. No docs/404.md present falls back to a bundled minimal 404 page.

Configuration

mdzero.config.py is a plain Python module -- set only the settings you want to override, everything else falls back to MDZero's defaults. Any setting can also be overridden by an environment variable named MDZERO_<SETTING> (or the same in .env), which always wins over the config file -- including dict-typed settings like THEME/HEADER/ FOOTER/REDIRECTS, given as a JSON object (e.g. MDZERO_REDIRECTS='{"/old": "/new"}'). Run mdzero config to see the fully resolved values and where each one came from (default / file / env).

Theme-relevant settings:

THEME = {
    "accent": "#8b5cf6",     # CSS custom property --mdzero-accent
    "dark_mode": "system",   # "system" | "light" | "dark" -- initial theme
    "radius": "0.75rem",     # CSS custom property --mdzero-radius
    "logo": None,            # path/URL to a logo image, light mode (and default)
    "logo_dark": None,       # path/URL to a logo image shown in dark mode instead, if set
    "favicon": None,         # path/URL to a favicon; omitted from <head> if unset
    "custom_css": None,      # path/URL to an extra stylesheet, loaded after mdzero.css
}
SEARCH_ENABLED = True
MCP_ENABLED = True
MCP_PATH = "/_mcp/"
MCP_BACKUP_WRITES = False   # see "MCP server" below

Site-structure settings:

HEADER = {
    "links": [],   # e.g. [{"label": "GitHub", "url": "https://github.com/..."}]
}
FOOTER = {
    "enabled": False,
    "text": None,        # arbitrary text/markdown-free string shown in the footer
    "copyright": None,   # e.g. "© 2026 Acme Inc."
    "links": [],          # same shape as HEADER["links"]
}
REDIRECTS = {}   # old-route -> new-route, e.g. {"/old-page": "/new-page"}
SITE_URL = None  # e.g. "https://docs.example.com" -- see the callout below

SITE_URL is required for production SEO to actually work. Left unset (the default), every per-page canonical/OpenGraph tag is omitted entirely and sitemap.xml isn't generated/served at all (there's no meaningful absolute-URL sitemap to produce without a known public base URL) -- robots.txt is still always written, but without a Sitemap: line. Set SITE_URL to your site's real public URL before deploying to production if you care about search engines or link-preview cards.

Theming

The default theme is hand-authored static CSS/JS bundled with the package (mdzero/web/theme_static/), served at /_theme/...:

  • css/mdzero.css -- one stylesheet, using CSS custom properties for every color/spacing value. --mdzero-accent and --mdzero-radius are overridden per-project from Config.THEME via a small inline <style>:root{...}</style> block injected into base.html's <head>. --mdzero-sidebar-width/--mdzero-content-width (also named as CSS hooks by the spec) are not project-configurable in v0.1 -- they're hardcoded defaults in mdzero.css itself, since Config.THEME has no keys for them.
  • js/nav.js -- mobile nav toggle (hamburger button, off-canvas sidebar below 860px).
  • js/theme-toggle.js -- light/dark/system cycle, persisted to localStorage (mdzero-theme key), live-following the OS color scheme while set to "system".
  • js/copy-code.js -- adds a "Copy" button to every fenced code block; copies the raw source text (<code>.textContent), not the syntax-highlighted markup.
  • js/search-dialog.js -- Ctrl+K / Cmd+K opens a search dialog querying /api/search, debounced, arrow-key navigable, Enter to go, Escape to close.
  • js/toc-scrollspy.js -- highlights the current section's link in the page's table of contents while scrolling, via IntersectionObserver.

Dark-mode-flash avoidance: a small blocking <script> at the very top of base.html's <head> (before the stylesheet, before the body) resolves the initial theme synchronously -- localStorage first, then Config.THEME["dark_mode"], then prefers-color-scheme -- and sets data-theme="light"|"dark" on <html> before first paint. theme-toggle.js (loaded normally afterwards) re-derives the same preference to sync the toggle button and handles clicks from then on.

Markdown components

Three :::-fenced container syntaxes are available in page markdown, on top of plain CommonMark/tables/strikethrough. In all three, the content between the markers is itself rendered as normal markdown (bold, links, lists, code, ...).

Callouts -- 5 recognized types: note, tip, warning, danger, info.

:::tip
You can override the accent color per-project via `Config.THEME`.
:::

Renders <div class="callout callout-tip">...</div>. An unrecognized type word (e.g. :::caution) is not rendered as a callout -- the ::: markers show up as literal text, so a typo stays visible instead of silently becoming an unstyled box.

Cards -- an explicit per-card marker, nested inside a grid container:

::::card-grid
:::card /getting-started
**Getting Started**

Learn the basics.
:::
:::card
**No link**

A plain, unlinked card.
:::
::::

The outer fence uses 4 colons, the inner one 3 -- this isn't stylistic, it's how the underlying container parser tells the two apart when searching for each one's own closing fence. A :::card's text after card is an optional link target; give one and the card renders as a clickable <a class="card" href="...">, omit it and you get a plain <div class="card"> -- both look the same, styled by .card-grid/.card in the bundled CSS.

Steps -- numbered, sequential instructions, same 4-colon/3-colon nesting as cards:

::::steps
:::step
### Create your docs directory

Body, itself rendered as markdown.
:::
:::step
### Add your first page

More body.
:::
::::

Renders <div class="steps"> wrapping one <div class="step"> per :::step block. The numbered circular badge in front of each step is a pure CSS counter, not computed by MDZero itself -- adding, removing or reordering :::step blocks renumbers them correctly with no other change needed.

Fenced code block titles -- an optional title="..." attribute after a fence's language, rendered as a small terminal-style header bar above the code:

```bash title="Terminal"
npm install
```

Omit it and a fence renders exactly as a plain fence always has, with no header bar -- purely additive, nothing to opt out of.

mdzero init -t components scaffolds a small real docs site demonstrating all of the above together -- see "Project layout" above.

MCP server

When MCP_ENABLED = True (the default), mdzero dev/serve also mount an MCP server at MCP_PATH (/_mcp/ by default), backed by the exact same in-memory Site the web app renders from -- a write through an MCP tool is visible on the website (and in search) immediately, no reload step.

mdzero config --show-secrets   # get MCP_CLIENT_ID / MCP_CLIENT_SECRET
curl -H "X-MDZero-Client-Id: <id>" -H "X-MDZero-Client-Secret: <secret>" \
     http://127.0.0.1:8000/_mcp/

Connect any MCP client (e.g. the MCP Inspector) to /_mcp/ with those two headers. Five read tools (get_project_info, get_docs_structure, list_pages, get_page, search_docs) and six write tools (create_page, update_page, delete_page, move_page, update_frontmatter, patch_page) are available.

patch_page(path, find, replace) makes a small, targeted edit to an existing page's markdown body without resending the whole body: find must match the current body text exactly once, or the call fails (no match / ambiguous multi-match) with nothing written. update_page, update_frontmatter, and patch_page all include a diff field in their response -- a unified diff of the file's old content vs. new content -- so an agent (or a human reviewing MCP tool-call logs) can see exactly what changed.

Set MCP_BACKUP_WRITES = True in mdzero.config.py to have every write tool that overwrites or removes an existing file (update_page, update_frontmatter, patch_page, delete_page, and move_page's source file) copy the file's pre-write content to .mdzero/backups/<timestamp>-<random>/<path> first (best-effort -- a backup failure never blocks the actual write, but is surfaced as a warning). Off by default.

Production deployment

Two ways to run MDZero in production, depending on whether you need the MCP server:

  • mdzero serve -- the dynamic server, same process that mdzero dev runs (draft pages 404, no file watcher). Serves content live from CONTENT_DIR on every request and, if MCP_ENABLED (the default), mounts the MCP server -- an AI client can read and edit your docs directly against the running site. Needs a long-running process/host.
  • mdzero build -- a static export to dist/ (clean-URL index.html files, public/ copied in, theme assets under dist/_theme/, redirects as meta-refresh HTML, a search-index.json for client-side search). Deploy dist/ to any static host -- no Python process, no MCP server, nothing to keep running. Pick this when you don't need MCP write access to run alongside the deployed site.

Caching headers (mdzero serve only -- static hosts set their own)

Two different Cache-Control policies, on purpose:

  • Theme assets (/_theme/...) and public/ files are genuinely static bytes, so they get a long-lived Cache-Control: public, max-age=604800 (7 days) plus a real ETag. Filenames aren't cache-busted with content hashes in v0.1/v0.2 (/_theme/mdzero.css never becomes /_theme/mdzero.<hash>.css), so that ETag -- not the filename -- is the actual invalidation signal: once max-age elapses, a client revalidates with If-None-Match and gets a cheap 304 if the file is unchanged, or a fresh body if it was edited.
  • Content pages (every ordinary page, and the 404 page) get Cache-Control: no-cache instead -- they can change between one request and the next (an MCP write against the running Site, or a live-reload edit in dev), with no filename/version bump to signal a cached copy is stale, so every request revalidates with the server. no-cache still permits a cache to store the response, it just forbids serving it without that round-trip.

/api/search, /sitemap.xml, and /robots.txt currently get no caching headers at all -- not part of this pass; see mdzero/web/routes.py's module docstring if you're touching those.

Docker

mdzero init already scaffolds a Dockerfile + docker-compose.yml into every new project (see "Project layout" above) -- from inside that project directory:

docker compose up

That builds an image with just the mdzero package installed, then bind-mounts the whole project directory (docs/, public/, mdzero.config.py, ...) into it at /site -- editing content never needs a rebuild, only docker compose restart if you change mdzero.config.py (same "config changes need a restart" rule as running mdzero serve directly). Override any Config field the usual MDZERO_<FIELD> way (per Configuration) by adding an environment: entry to the scaffolded docker-compose.yml -- there's no separate Docker-specific mechanism. The scaffolded Dockerfile widens HOST to 0.0.0.0 by default (Config's own default, 127.0.0.1, only accepts connections from inside the container's own network namespace, so nothing published via ports: would otherwise be reachable) -- this also disables the MCP DNS-rebinding protection (see "v0.1 / v0.2 simplifications" below), expected and fine behind your own reverse proxy/firewall.

The MDZero package repository itself also ships its own Dockerfile at its root -- a different thing, for building an image of MDZero from source (e.g. if you're developing MDZero itself, not just using it):

git clone https://github.com/SytxLabs/MDZero.git && cd MDZero
docker build -t mdzero .
docker run -d -p 8000:8000 -e MDZERO_HOST=0.0.0.0 -v "$(pwd)/../my-docs:/site" mdzero

v0.1 / v0.2 simplifications

MDZero deliberately trades completeness for a small, understandable implementation in a few places. Each of these is a real limitation to know about, not a bug:

  1. Config-file changes require a manual dev restart. The live-reload watcher only watches CONTENT_DIR/PUBLIC_DIR and reloads content into the running Site; a change to mdzero.config.py (theme, port, MCP settings, ...) needs mdzero dev restarted to take effect.
  2. MCP auth is a static shared-secret header check (X-MDZero-Client-Id / X-MDZero-Client-Secret), not OAuth2 or dynamic client registration. Fine for a local dev server or a single-tenant deployment behind your own access control; not a substitute for a real auth layer in a multi-tenant setting.
  3. Theme CSS is hand-authored, shipped pre-built. There's no Tailwind/PostCSS pipeline and no Node/npm dependency for end users -- mdzero/web/theme_static/css/mdzero.css is plain, static CSS you can read and edit directly (or override by pointing theme_dir at your own directory, if building against MDZero as a library).
  4. Search is in-memory substring/token scoring (mdzero/content/search.py) -- term-frequency plus a title-match boost, rebuilt from content on every process start. No external search library (Elasticsearch, Lunr, etc), no on-disk index, no stemming or fuzzy matching. Fine at docs-site scale (dozens to low hundreds of pages); not a general-purpose search engine.
  5. move_page is best-effort ordered, not a transactional two-phase commit. It writes/renames the file on disk, then updates the in-memory index; a crash between those two steps could leave the filesystem and the index briefly out of sync until the process is restarted (which re-scans the filesystem from scratch).
  6. draft vs hidden behavior (left open by the spec) is resolved explicitly: hidden: true pages are excluded from nav/search but stay directly routable (200) in both dev and serve. draft: true pages render normally (with a visible banner) in dev, and 404 -- indistinguishable from a route that never existed -- in serve.
  7. HOST doubles as the MCP DNS-rebinding-protection host. HOST is passed straight through to hypercorn as the bind address and to the MCP SDK's streamable_http_app(host=...), which uses it to build the allowlist of Host: headers the MCP endpoint will accept (protection against DNS-rebinding attacks). The SDK only auto-derives that allowlist when HOST is 127.0.0.1/localhost/::1 (the default); any other value leaves the allowlist empty, i.e. the protection is silently disabled rather than reconfigured. Practical consequences:
    • Default (HOST=127.0.0.1): MCP only accepts requests whose Host: header is 127.0.0.1:*/localhost:*/[::1]:*. A request through a reverse proxy (a different Host: value) gets 421 Misdirected Request even though the proxy itself is trusted.
    • Setting HOST to a LAN IP, a public IP, or 0.0.0.0 to allow non-loopback access removes DNS-rebinding protection entirely for the MCP endpoint (any Host: header is accepted) -- acceptable behind your own reverse proxy/firewall, not a substitute for one. If you need both a non-default bind address and DNS-rebinding protection restricted to a specific external hostname, that isn't currently configurable independently of HOST -- see mdzero/mcp/mount.py::mount_mcp.
  8. mdzero build's static export is root-domain-only. Every URL it emits (page links, nav links, redirects, search-index.json's fetch path) is root-absolute (/getting-started/...), which assumes the export is deployed at the root of its domain. Deploying it under a subpath instead (e.g. a GitHub Pages project site at https://user.github.io/repo/) breaks every one of those links. A real fix would be a v0.3 BASE_PATH-style setting that prefixes every emitted URL; not implemented in v0.2 -- deploy to a root domain/custom domain, or a host that lets you map a subpath transparently to root.
  9. A project's own public/robots.txt or public/sitemap.xml is shadowed. Since v0.2 added generated /robots.txt//sitemap.xml routes (live) and output files (static build), a same-named file you already had under public/ is silently NOT served/copied -- the generated version always wins. Rename your own file, or don't rely on public/ for either of these two paths.
  10. 301 redirects (REDIRECTS/aliases:) carry no explicit Cache-Control header. A browser may cache a redirect response according to its own default heuristics; if you later repoint or remove a redirect, a visitor's browser could keep following the old target for longer than you'd like. Known tradeoff, not fixed in v0.2.

Development

pip install -e ".[dev]"
pytest -v

Release files for mdzero 0.1.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mdzero 0.1.2
File Size Uploaded
mdzero-0.1.2.tar.gz 211.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mdzero 0.1.2
File Interpreter ABI Platform
mdzero-0.1.2-py3-none-any.whl Python 3 none any Details

Total release size: 372.2 kB

Release files / mdzero-0.1.2.tar.gz

Download URL mdzero-0.1.2.tar.gz
Size 211.8 kB
Tags Source
SHA-256 checksum
How to use checksums
cab8934baf9932b14aac48029ab44cb5911c1b1a3b1a6e065fefcdb6626f0727
BLAKE2b-256 checksum
How to use checksums
b67535865ba6b81db61b84160a636b40cb31b49c7e2886c23f18cc472a40c2b5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.

Transparency log

Release files / mdzero-0.1.2-py3-none-any.whl

Download URL mdzero-0.1.2-py3-none-any.whl
Size 160.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b1abe982c6ef0e650d6db8ad47dd65b39f161a4d8f2ac606ad85230115e6fd6c
BLAKE2b-256 checksum
How to use checksums
d9a811b444ee790b46d5a440efbad073568f071886b11a96b2934df119d897dd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.

Transparency log

Release history Release notifications | RSS feed

0.1.3

2 release files

This release

0.1.2 This release

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page