Skip to main content
Glama
mirastacklabs-ai

Mirador MCP

Official
README.md
# MIRASTACK Mirador MCP Server

**Mirador Core is a zero-vendor-knowledge observability analysis engine.** Pointed at a
Prometheus-compatible metrics backend and (optionally) a log backend belonging to a
deployment it has never seen, it discovers the signal inventory, resolves the entity
topology, accepts anchors that focus an analysis, correlates metrics and logs, runs
root-cause analysis and capacity forecasting inside that scope, and reports every finding
with a four-dimensional confidence vector plus an explicit statement of what input would
improve it. It ships as an MCP server, an importable Python library, and a CLI, under
Apache-2.0.

## Zero vendor knowledge

Mirador contains **no metric names, no label names, and no vendor vocabulary** — the rule is
mechanically enforced by `scripts/check_no_vendor_vocabulary.sh`, which rejects any
metric-name-shaped string literal in `src/mirador/**`.

Everything vendor-specific is either **discovered from the data** or **supplied at query
time as an anchor**:

- **Signals** — metric families are grouped from the backend's own series index; type, unit,
  role, cadence, discriminator dimensions, and log fields/templates are *inferred*
  (`src/mirador/catalog/`), never read from a built-in table.
- **Entities** — identifier keys are profiled from label cardinality and cross-family reuse,
  and edges come from FK/overlap/affix/conservation/component evidence with named guards for
  every rejection (`src/mirador/entity/`). A fixture designed to look joinable but that is
  not (`tests/fixtures/negative`) must yield **zero** edges.
- **Topology** — user-supplied topology is a *merge input* (`entity/usertopo.py`) with
  conflicts surfaced, not a required configuration file.
- **Anchors** — a user- or agent-supplied term is resolved across five lexical channels into
  the exact families, label keys, label values, and log fields an analysis may touch. See
  [docs/anchors.md](docs/anchors.md).

The practical consequence: a new backend needs no onboarding config. The practical cost:
early findings are honestly labelled low-confidence rather than confidently wrong — see
[docs/confidence.md](docs/confidence.md).

## Getting started

This section assumes no prior knowledge of the project. Every command below is copy-pasteable.

### 1. Prerequisites

| You need | Why | Check it |
|---|---|---|
| **Python 3.11 or newer** | `requires-python = ">=3.11"` | `python3 --version` |
| **git** | to clone the repo | `git --version` |
| ~2 GB free disk | virtualenv + the committed test fixtures | — |
| *(optional)* Docker | only for the container deployment in §6 | `docker --version` |

You do **not** need Prometheus, Kubernetes, or any monitoring system to build, test, and try
the engine — four synthetic estates are committed under `tests/fixtures/`.

### 2. Build it

```bash
git clone https://github.com/mirastacklabs-ai/mirastack-mirador-mcp
cd mirastack-mirador-mcp

python3 -m venv .venv          # create an isolated Python environment
source .venv/bin/activate      # Windows: .venv\Scripts\activate
pip install -e ".[dev]"        # editable install + dev tools (pytest, ruff, mypy)
```

`-e` means *editable*: your source edits take effect without reinstalling.

Confirm the console script is on your PATH:

```bash
mirastack-mirador-mcp --help
```

### 3. Prove the build is healthy

```bash
make check
```

That runs, in order: `ruff` (lint) → `mypy` (types) → the 9 gate scripts → the test suite →
a determinism check that runs the engine twice and diffs the bytes. Expect
**every test to pass, with 8 skipped** (the skips are live-backend tests that need env vars from §5).

Individual pieces, if you prefer:

```bash
make lint          # ruff
make typecheck     # mypy
make gates         # the 9 scripts/check_*.sh invariant guards
make test          # pytest
make determinism   # byte-identical output across two runs
```

### 4. First run, with no backend at all

Mirador stores everything it learns in one embedded DuckDB file. Two steps: **build a
catalog**, then **ask a question**.

```bash
# (a) Learn the estate from a committed synthetic fixture.
mirastack-mirador-mcp \
  --fixture-dir tests/fixtures/vmware_pure \
  --duckdb /tmp/mirador.duckdb \
  --json catalog build

# (b) Confirm it is ready.
mirastack-mirador-mcp \
  --fixture-dir tests/fixtures/vmware_pure \
  --duckdb /tmp/mirador.duckdb \
  --json catalog state
```

`catalog build` is **synchronous** — it returns only when the sweep has finished, so it is
safe to script. `catalog state` should report `ready`.

`vmware_pure` is a VMware + Pure Storage estate (a classic datacenter shape, no Kubernetes).
From it Mirador discovers ~16 metric families, ~780 entities of types `vm`, `host`, `volume`,
`disk`, `backing_serial`, and the cross-source joins that link compute to storage — without
being told a single metric or label name.

Now ask it something:

```bash
BASE="--fixture-dir tests/fixtures/vmware_pure --duckdb /tmp/mirador.duckdb --json"

# --lookback-s sizes the analysis window. 900 = 15 minutes, the usual shape of an
# outage investigation. Without it the tools search a full day, which is far slower.
mirastack-mirador-mcp $BASE --lookback-s 900 detect vm      # ranked anomalies
mirastack-mirador-mcp $BASE --lookback-s 900 rca vm         # causal chains + evidence
mirastack-mirador-mcp $BASE --lookback-s 900 correlate vm   # signal pairs with q-values
mirastack-mirador-mcp $BASE topology get                    # discovered entity graph
mirastack-mirador-mcp $BASE capacity vm                     # exhaustion ETA distributions
```

`capacity` deliberately takes no `--lookback-s`: a forecast needs long history, so an
outage-sized window would be meaningless for it.

**Pick the window to match the question.** 15 minutes for an active incident, an hour for a
slow degradation, a day for a trend. The cost difference is large — per-series analysis over a
day of fine-cadence data can take minutes, where 15 minutes of it takes seconds.

The word `vm` is an **anchor** — a plain-language hint about what you care about. You are not
naming a metric; Mirador resolves the term against the catalog it discovered. Anchors also
accept explicit globs when you want precision:

```bash
mirastack-mirador-mcp $BASE detect '{"term": "memory", "metrics": ["arr_volume_*"]}'
```

### 5. Point it at a real backend

Any Prometheus-compatible endpoint works (Prometheus, VictoriaMetrics, Thanos, Mimir). Logs
are optional.

```bash
# VictoriaMetrics cluster (note the /select/0/prometheus path prefix)
METRICS="http://localhost:8481/select/0/prometheus"

mirastack-mirador-mcp \
  --metrics-url "$METRICS" \
  --logs-url http://localhost:9471 --logs-kind vlogs \
  --duckdb ~/mirador-prod.duckdb \
  --json catalog build
```

`--logs-kind` accepts `vlogs`, `loki`, `file`, or `none`.

**Expect the first build to take minutes**, not seconds — it profiles every metric family in
the estate. On a ~700-family estate it takes about 10 minutes. Re-runs are incremental.

Global flags worth knowing:

| Flag | Meaning |
|---|---|
| `--metrics-url URL` | Prometheus-compatible query endpoint (required unless `--fixture-dir`) |
| `--fixture-dir DIR` | use committed fixture data instead of a live backend |
| `--logs-url` / `--logs-kind` | optional log backend |
| `--duckdb PATH` | where the learned catalog lives (default `~/.mirador/mirador.duckdb`) |
| `--config FILE` | YAML config; precedence is **CLI > environment > file > default** |
| `--json` | machine-readable output (use this when scripting) |
| `--max-points-per-run N` | raise the query budget for very large estates |
| `--lookback-s N` | analysis window in seconds (900 = 15 min). Default is a full day |
| `--fail-on-insufficient` | exit non-zero when confidence is `insufficient` (good for CI) |

Environment variables use the `MIRADOR_` prefix with `__` for nesting, e.g.
`MIRADOR_BUDGET__MAX_POINTS_PER_RUN=50000000`. Note the **double** underscore: it is the nesting
delimiter, so `MIRADOR_METRICS__KIND` configures the backend and `MIRADOR_METRICS_KIND` does
nothing. Flags are optional — the environment alone is a complete configuration, which is how MCP
clients and orchestrators (which pass no arguments) run this server:

```bash
MIRADOR_METRICS__KIND=prometheus \
MIRADOR_METRICS__BASE_URL="$METRICS" \
mirastack-mirador-mcp serve
```

A config file version of the same thing:

```yaml
# mirador.yaml
metrics:
  kind: prometheus
  base_url: http://localhost:8481/select/0/prometheus
logs:
  kind: vlogs
  base_url: http://localhost:9471
duckdb_path: /var/lib/mirador/mirador.duckdb
budget:
  max_points_per_run: 20000000
  max_concurrent: 8
```

```bash
mirastack-mirador-mcp --config mirador.yaml --json catalog build
```

### 6. Deploy it

**As an MCP server** (this is the primary mode — an AI agent calls the tools):

```bash
# stdio: the transport MCP clients such as Claude Desktop expect (the default)
mirastack-mirador-mcp --metrics-url "$METRICS" --duckdb ~/mirador.duckdb serve

# streamable HTTP, for remote clients and for a governing proxy
mirastack-mirador-mcp --metrics-url "$METRICS" serve \
  --transport streamable-http --listen 127.0.0.1:9001
```

`serve` flags: `--transport {stdio,streamable-http,sse}` (`--http` is shorthand for
streamable-http), `--listen HOST:PORT` (or `--host`/`--port`), `--path` (default `/mcp`), and
`--json-response` / `--stateless` (both **on** by default; `--no-json-response` restores SSE
framing and `--no-stateless` restores server-side sessions). Each has an environment equivalent —
`MIRADOR_SERVER__TRANSPORT`, `MIRADOR_SERVER__LISTEN`, and so on — plus the cross-server aliases
`MCP_TRANSPORT`, `MCP_LISTEN_ADDR` / `MCP_HTTP_LISTEN`, `MCP_HTTP_HOST`, `MCP_HTTP_PORT`,
`MCP_HTTP_PATH`, so one operator template configures this server and its siblings identically.

In `serve` mode a scheduler keeps the catalog fresh: one sweep immediately at startup, then
incremental sweeps every 15 minutes and a full sweep daily.

**Traces (optional).** Point Mirador at a VictoriaTraces instance with `--traces-url
http://vt:10428` (or `MIRADOR_TRACES__KIND=vtraces` + `MIRADOR_TRACES__BASE_URL=...`; auth/TLS
mirror the metrics/logs blocks as `MIRADOR_TRACES__AUTH__*` / `MIRADOR_TRACES__TLS_VERIFY`).
Mirador queries the Jaeger-compatible API (`/select/jaeger/api/...`) and derives three per-service
signals — span rate, error rate, and a span-duration percentile — that participate in `correlate`,
`rca`, and `detect_anomalies` alongside metrics and log-template signals. Trace series join the
entity graph through the `service` label. An unreachable trace backend degrades honestly to
metrics+logs; trace metadata is never persisted to DuckDB and never feeds anchor lexical search
(scope boundary, documented here on purpose).

MCP client configuration (e.g. Claude Desktop's `claude_desktop_config.json`):

```json
{
  "mcpServers": {
    "mirador": {
      "command": "/absolute/path/to/.venv/bin/mirastack-mirador-mcp",
      "args": ["--metrics-url", "http://localhost:8481/select/0/prometheus",
               "--duckdb", "/absolute/path/to/mirador.duckdb", "serve"]
    }
  }
}
```

**In Docker:**

```bash
docker build -t mirador-mcp .
docker run --rm -it \
  -e MIRADOR_DUCKDB_PATH=/home/mirador/mirador.duckdb \
  mirador-mcp --metrics-url "$METRICS" serve
```

The image runs as a non-root user (uid 10001) and excludes the test fixtures.

### 7. Reading the output

Every response is an **envelope** with the same shape, so one reading habit covers all tools:

```json
{
  "resolved": { "anchors": [ { "term": "vm", "families": ["cmp_vm_info"],
                              "confidence": 0.9, "via": ["family_name"] } ] },
  "result":   { "chains": [], "windows": [] },
  "confidence": { "scope": 0.9, "structure": 1.0, "statistical": 0.4,
                  "data": 0.8, "tier": "indicative" },
  "missing_inputs": [ { "what": "longer window, more samples" } ],
  "next_actions": [ ]
}
```

Read it in this order:

1. **`resolved.anchors[].via`** — *how* your term was understood. `family_name` (0.9) is a
   confident match; `lexical_unconfirmed` (0.55) means "this is my best evidence-backed
   guess, please confirm"; `structural_fallback` means the term matched nothing and the shape
   of the data was used instead.
2. **`confidence.tier`** — `insufficient` → `indicative` → `probable` → `strong`.
   **`insufficient` is a feature, not an error.** Mirador refuses to sound certain when the
   evidence is thin.
3. **`missing_inputs`** — the concrete thing that would raise the tier (a longer window, a
   confirmed anchor, retention metadata).
4. **`result`** — the findings themselves.

An empty `result` with an honest low tier is a valid, deliberate answer.

### 8. When something looks wrong

| Symptom | Cause and fix |
|---|---|
| `no metrics backend configured: pass --fixture-dir/--metrics-url, use --config, or set MIRADOR_METRICS__KIND (and MIRADOR_METRICS__BASE_URL)` | Give one of them, or use `--config`. |
| `catalog_state: absent` | Run `catalog build` first, or wait for `serve`'s startup sweep. |
| Every result is `insufficient` | Usually genuine. Check `missing_inputs`; try `confirm_anchor`, or a longer window. |
| A command seems to hang | You are probably on the default one-day window. Add `--lookback-s 900`. |
| `BudgetExhausted: point budget exhausted` | Very large estate: raise `--max-points-per-run`. |
| `ConnectError: All connection attempts failed` | The metrics URL is unreachable (dead port-forward, wrong path prefix). |
| Anchor resolves to unrelated families | Use the explicit form: `'{"term": "x", "metrics": ["prefix_*"]}'`. |
| Results differ between two identical runs | Should be impossible — `make determinism` guards it. Please file a bug. |

### 9. Using it as a Python library

The CLI and the MCP server are thin wrappers; the engine is importable.

```python
import asyncio
from pathlib import Path

from mirador.catalog.build import build_catalog
from mirador.datasource.base import BudgetLedger, QueryBudget
from mirador.datasource.file import FileDataSource
from mirador.store.repo import Repo

budget = QueryBudget()
ds = FileDataSource(Path("tests/fixtures/vmware_pure"), budget)
repo = Repo(Path("mirador.duckdb"))

result = asyncio.run(
    build_catalog(ds, repo, now=1_730_000_000.0, ledger=BudgetLedger(budget), logs=ds)
)
print(len(result.families), "families,", f"coverage {result.coverage:.2%}")
for signal in result.signals[:5]:
    print(signal.signal_id, signal.mtype, signal.unit, signal.role)
```

Note the injected `now`: Mirador never reads the wall clock inside the analysis path, which
is what makes runs bit-for-bit reproducible (`scripts/check_no_wallclock.sh`,
`scripts/check_determinism.sh`). Regenerate fixtures with `make fixtures`.

## Onboarding into MIRASTACK

MIRASTACK does not talk to this server directly. It runs the **MCP sidecar** (`mira-mcp-wrapper`)
as the pod's only listener; the sidecar spawns this server on loopback and governs every tool call
(RBAC, audit, serialization). Two things must line up: the Helm entry (plumbing) and the target env
template (configuration). They are edited in different places on purpose.

### Why the container ships a runtime tree

The sidecar runs on `gcr.io/distroless/base-debian12` and `fork/exec`s the child **inside its own
container**, after an initContainer copies the server out of this image. That container has no Python
and no shell. The pip console script `mirastack-mirador-mcp` cannot work there: it is a text file
whose `#!/usr/local/bin/python` shebang and `mirador` package both stay behind, so exec fails with

```
start http child: fork/exec /shared/mcp-server: no such file or directory
```

which is misleading — the file *is* there; the missing thing is the shebang interpreter.

The sibling Go and Bun servers answer this with a single self-contained binary, but Python has no
equivalent: PyInstaller and Nuitka **cannot cross-compile**, so building one would force them to run
under emulation for a foreign architecture, which is not dependable. Instead this image ships a
**relocatable CPython at `/opt/mirador-runtime`** with Mirador `pip install`ed into it. The
initContainer copies that tree to `/shared/runtime` and the wrapper runs it directly, so the chart
uses `serverDir` + `childCmd` rather than `serverBinary`.

Nothing is compiled to achieve this, which is what keeps the image cleanly multi-architecture: the
interpreter and every wheel (duckdb, numpy, scipy, pandas, statsmodels, ruptures) are downloads
published for both `x86_64` and `aarch64`. The tree also vendors the three libraries distroless does
not ship — `libz.so.1`, `libstdc++.so.6`, `libgcc_s.so.1` — so **the shared sidecar image needs no
modification** for this server.

### 1. Helm entry (plumbing only)

In `deployments/reference/kubernetes/helm/mirastack/values.yaml` under `mcpServers.servers`:

```yaml
- name: mirastack-mirador-mcp
  enabled: true
  serverImage: docker.io/mirastack/mirastack-mirador-mcp
  serverTag: "0.1.0"                                  # must match the tag you built/pushed; the package version is 0.1.0 (pyproject.toml)
  serverDir: /opt/mirador-runtime                     # the relocatable tree, not a single binary
  childCmd: /shared/runtime/bin/python3 -m mirador serve
  backend: http
  childHTTP: http://127.0.0.1:9003/mcp                # loopback; must be unique per pod
  endpoint: /mcp
  integrationID: ""
  resources: {}
  registrationTokenSecretKey: ""
```

`childCmd` carries arguments because the wrapper splits the value, so no launcher script is needed —
which matters, since distroless has no shell.

The chart carries **no target configuration** — no endpoints, no credentials, no target-facing env
var names. That is deliberate and enforced by comments in `values.yaml`.

### 2. Target env template (configuration)

Set these in the MIRASTACK UI at **Admin → MIRA → MCP Ecosystem → Runtimes →**
`mirastack-mirador-mcp` **→ target env template**, then bind an Integration to the runtime.

| Variable | Value | Why it is required |
|---|---|---|
| `MCP_TRANSPORT` | `streamable-http` | Transport defaults to `stdio`, which never opens a listener. **Must be exactly `streamable-http`** — the value is validated against `Literal["stdio", "streamable-http", "sse"]`, so `http` is rejected. |
| `MCP_LISTEN_ADDR` | `${{runtime.child_listen}}` | Resolves to `127.0.0.1:9003`. The sidecar refuses to spawn the child unless some env value contains exactly the `host:port` it probes. |
| `MIRADOR_METRICS__KIND` | `prometheus` | Required field with no default. **Note the double underscore** — the nesting delimiter is `__`, so `MIRADOR_METRICS_KIND` configures nothing and is silently ignored. |
| `MIRADOR_METRICS__BASE_URL` | `${{url}}` | Comes from the bound Integration. `kind: prometheus` is rejected without it. |
| `MIRADOR_DUCKDB_PATH` | `/tmp/mirador/mirador.duckdb` | The default is `~/.mirador/…`, and `$HOME` does not exist in the distroless sidecar. `/tmp` is present and `1777`. The parent directory is created automatically. |

Optional, only if you also want log-based analysis:

| Variable | Value |
|---|---|
| `MIRADOR_LOGS__KIND` | `vlogs` (or `loki`) |
| `MIRADOR_LOGS__BASE_URL` | the logs endpoint |

You do **not** need to set `MCP_HTTP_JSON_RESPONSE`, `MCP_HTTP_STATELESS`, or `MCP_HTTP_PATH`.
JSON responses and stateless mode are already the defaults because the sidecar issues its own
`Mcp-Session-Id` and relabels responses `application/json`; the default path is already `/mcp`.

### 3. Two vocabularies that look alike

The commonest onboarding mistake is mixing them up:

| Setting | Owner | Value |
|---|---|---|
| `backend` in `values.yaml` | the **wrapper** | `http` |
| `MCP_TRANSPORT` in the env template | **Mirador** | `streamable-http` |

Equally, `VM_INSTANCE_ENTRYPOINT` / `VL_INSTANCE_ENTRYPOINT` belong to the **VictoriaMetrics and
VictoriaLogs** MCP servers. Mirador does not read them; it uses `MIRADOR_METRICS__BASE_URL`.

### 4. Verifying it worked

```bash
kubectl -n mirastack logs deploy/mirastack-mirador-mcp -c sidecar --tail=20
```

Expected: `runtime registered with engine`, then no `child start failed`.

| Sidecar error | Meaning |
|---|---|
| `fork/exec /shared/mcp-server: no such file or directory` | The chart is using `serverBinary` for this server. It needs `serverDir: /opt/mirador-runtime` plus `childCmd`. |
| `error while loading shared libraries: libz.so.1` | An old image built before the runtime tree vendored `libz`/`libstdc++`/`libgcc_s`. Pull a current tag. |
| `nothing tells the MCP server to listen on 127.0.0.1:<port>` | `MCP_LISTEN_ADDR` missing, or not set to `${{runtime.child_listen}}`. |
| `no target env template declared` | The runtime has no env template yet. |
| `no integration bound` | Bind an Integration to the runtime. |
| child starts, then exits complaining about a missing backend | `MIRADOR_METRICS__KIND` was written with one underscore. |
| child never becomes ready | `MCP_TRANSPORT` missing or set to `http` instead of `streamable-http`. |

## The 15 MCP tools

Full parameter documentation: [docs/mcp-tools.md](docs/mcp-tools.md).

| Tool | Tier | Purpose |
|------|------|---------|
| `rca` | composite | Chains, per-step evidence, confidence. Self-sufficient. |
| `capacity_forecast` | composite | ETA distributions, steps/month, cross-layer divergence. |
| `correlate` | composite | Signal pairs with q-values, effect sizes, change-point order. |
| `detect_anomalies` | composite | Ranked anomalies with a baseline-quality flag. |
| `get_catalog_state` | inspection | `absent \| building \| partial \| ready \| stale`. |
| `discover_signals` | inspection | Catalog summary, coverage percentage, maturity. |
| `get_signal_catalog` | inspection | Families with inferred type/unit/role/cadence. |
| `resolve_scope` | inspection | Dry run — inspect the scope manifest before analysing. |
| `get_topology` | inspection | Entity subgraph with per-edge provenance. |
| `explain` | inspection | Narrative for a finding — tier-gated. |
| `confirm_anchor` | mutation | Persist a learned anchor alias. |
| `set_topology` | mutation | Merge user topology; conflicts surfaced. |
| `get_job` | utility | Job state plus partial output. |
| `cancel_job` | utility | Cancel a running job. |
| `raw_query` | utility | Escape hatch, budget-capped. |

The four **composite** tools are self-sufficient: each performs scope resolution, window
localization, detection, and analysis internally, so no prerequisite call is required.

## Confidence tiers

Confidence is reported as four independent dimensions (`scope`, `structure`, `statistical`,
`data`). `rank()` is their **product** and exists only to sort and to tier. Thresholds come
from `src/mirador/constants.py` (`TIER_SUPPORTED`, `TIER_INDICATED`, `TIER_WEAK`):

| Tier | `rank()` threshold | Behaviour it triggers |
|------|--------------------|-----------------------|
| `supported` | `>= 0.80` (`TIER_SUPPORTED`) | Names a root cause; a single chain is led; LLM narration is permitted. |
| `indicated` | `>= 0.50` (`TIER_INDICATED`) and `< 0.80` | Leading hypothesis **plus alternatives, always**; template narration only. |
| `weak` | `>= 0.30` (`TIER_WEAK`) and `< 0.50` | Ranked candidates only — **no causal language** anywhere in the output. |
| `insufficient` | `< 0.30` | Reports what is missing **instead of** a finding, with remediation steps. |

A day-one deployment with little history will legitimately report `weak`. That is the system
being honest, not a defect — the reasoning is spelled out in
[docs/confidence.md](docs/confidence.md).

## Scale envelope

Mirador is designed for a single container on **2–4 vCPU / 8 GB** with embedded DuckDB and
one environment per instance. It is comfortable with backends up to roughly 5 M active
series (the catalog tracks *families*, on the order of thousands, never every series), up to
about 100 k entities in the `networkx` graph, 200–2 000 signals per analysis, and up to about
5 000 capacity signals at 90 days / 1 h resolution. A warm RCA takes seconds to tens of
seconds and a full background catalog sweep takes minutes. The bottleneck is backend I/O
against the customer's Prometheus, not Mirador's arithmetic — which is exactly why
`QueryBudget` is a first-class object (tuning guide: [docs/operations.md](docs/operations.md)).
For more than one environment, run more instances.

## Documentation

- [docs/architecture.md](docs/architecture.md) — the L0–L5b layer pipeline, module by module
- [docs/anchors.md](docs/anchors.md) — anchor resolution and the five lexical channels
- [docs/confidence.md](docs/confidence.md) — the four-dimensional confidence vector
- [docs/mcp-tools.md](docs/mcp-tools.md) — the 15 tools with parameters
- [docs/fixtures.md](docs/fixtures.md) — the four committed synthetic fixtures
- [docs/operations.md](docs/operations.md) — `QueryBudget` tuning and scheduler intervals
- `developer/MIRADOR_CORE_ENGINEERING_SPEC.md` — the authoritative design document

## Development

```bash
make install-dev
make check   # lint + typecheck + gates + tests + verify-done + determinism
```

See [CONTRIBUTING.md](CONTRIBUTING.md) for the non-negotiable engineering rules and
[SECURITY.md](SECURITY.md) for the security posture.

## License

Apache-2.0. See [LICENSE](LICENSE).