Metadata-Version: 2.4
Name: mdzero
Version: 0.1.1
Summary: Markdown-based docs generator with a Quart web frontend and an MCP server.
Author: Erik
License-Expression: MIT
Project-URL: Homepage, https://github.com/SytxLabs/MDZero
Project-URL: Repository, https://github.com/SytxLabs/MDZero
Project-URL: Issues, https://github.com/SytxLabs/MDZero/issues
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Documentation
Classifier: Topic :: Software Development :: Documentation
Classifier: Topic :: Text Processing :: Markup :: Markdown
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: typer
Requires-Dist: quart
Requires-Dist: hypercorn
Requires-Dist: markdown-it-py
Requires-Dist: mdit-py-plugins
Requires-Dist: pygments
Requires-Dist: pyyaml
Requires-Dist: watchfiles
Requires-Dist: mcp
Requires-Dist: python-dotenv
Requires-Dist: rich
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Dynamic: license-file

# MDZero

[![PyPI](https://img.shields.io/pypi/v/mdzero.svg)](https://pypi.org/project/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

```bash
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

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

```bash
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:

```python
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:

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

```markdown
:::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:

```markdown
::::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:

```markdown
::::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:

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

```bash
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:

```bash
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](#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):

```bash
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

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