MDZero
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 -e ".[dev]"
Requires Python 3.12+.
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-accentand--mdzero-radiusare overridden per-project fromConfig.THEMEvia a small inline<style>:root{...}</style>block injected intobase.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 inmdzero.cssitself, sinceConfig.THEMEhas 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 tolocalStorage(mdzero-themekey), 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, viaIntersectionObserver.
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 thatmdzero devruns (draft pages 404, no file watcher). Serves content live fromCONTENT_DIRon every request and, ifMCP_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 todist/(clean-URLindex.htmlfiles,public/copied in, theme assets underdist/_theme/, redirects as meta-refresh HTML, asearch-index.jsonfor client-side search). Deploydist/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/...) andpublic/files are genuinely static bytes, so they get a long-livedCache-Control: public, max-age=604800(7 days) plus a realETag. Filenames aren't cache-busted with content hashes in v0.1/v0.2 (/_theme/mdzero.cssnever becomes/_theme/mdzero.<hash>.css), so thatETag-- not the filename -- is the actual invalidation signal: oncemax-ageelapses, a client revalidates withIf-None-Matchand 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-cacheinstead -- they can change between one request and the next (an MCP write against the runningSite, or a live-reload edit indev), with no filename/version bump to signal a cached copy is stale, so every request revalidates with the server.no-cachestill 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:
- Config-file changes require a manual
devrestart. The live-reload watcher only watchesCONTENT_DIR/PUBLIC_DIRand reloads content into the runningSite; a change tomdzero.config.py(theme, port, MCP settings, ...) needsmdzero devrestarted to take effect. - 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. - 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.cssis plain, static CSS you can read and edit directly (or override by pointingtheme_dirat your own directory, if building against MDZero as a library). - 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. move_pageis 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).draftvshiddenbehavior (left open by the spec) is resolved explicitly:hidden: truepages are excluded from nav/search but stay directly routable (200) in bothdevandserve.draft: truepages render normally (with a visible banner) indev, and 404 -- indistinguishable from a route that never existed -- inserve.HOSTdoubles as the MCP DNS-rebinding-protection host.HOSTis passed straight through to hypercorn as the bind address and to the MCP SDK'sstreamable_http_app(host=...), which uses it to build the allowlist ofHost:headers the MCP endpoint will accept (protection against DNS-rebinding attacks). The SDK only auto-derives that allowlist whenHOSTis127.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 whoseHost:header is127.0.0.1:*/localhost:*/[::1]:*. A request through a reverse proxy (a differentHost:value) gets421 Misdirected Requesteven though the proxy itself is trusted. - Setting
HOSTto a LAN IP, a public IP, or0.0.0.0to allow non-loopback access removes DNS-rebinding protection entirely for the MCP endpoint (anyHost: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 ofHOST-- seemdzero/mcp/mount.py::mount_mcp.
- Default (
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 athttps://user.github.io/repo/) breaks every one of those links. A real fix would be a v0.3BASE_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.- A project's own
public/robots.txtorpublic/sitemap.xmlis shadowed. Since v0.2 added generated/robots.txt//sitemap.xmlroutes (live) and output files (static build), a same-named file you already had underpublic/is silently NOT served/copied -- the generated version always wins. Rename your own file, or don't rely onpublic/for either of these two paths. - 301 redirects (
REDIRECTS/aliases:) carry no explicitCache-Controlheader. 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.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mdzero-0.1.0.tar.gz | 211.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mdzero-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 372.2 kB
Release files / mdzero-0.1.0.tar.gz
| Download URL | mdzero-0.1.0.tar.gz |
|---|---|
| Size | 211.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1f4d8d8012204901a8fce0dcdf32f4571a62abb7912822f70d8defd2493d3b16
|
|
BLAKE2b-256 checksum How to use checksums |
f721fb342ad70e31c66a4cdddaab9536fcfb5dec00d68034136bd484e04934ec
|
| 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 logRelease files / mdzero-0.1.0-py3-none-any.whl
| Download URL | mdzero-0.1.0-py3-none-any.whl |
|---|---|
| Size | 160.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0e5593e5a4e157583baaaa4a6825f1b0ca2422c79b8e3a2c22aba947907ac0c7
|
|
BLAKE2b-256 checksum How to use checksums |
d87f351d3a9e7db1d6baa75424dfd2cc2c69d2d740c2f7e69317ab354d029bfa
|
| 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