sagaz-mcp
README.md
# Sagaz
<p align="center"><img src="docs/sagaz-banner.png" alt="Sagaz — Your agents can act. Can they undo? MCP proxy · effect ledger · reversibility gates · preview" width="100%"></p>
> **Your agents can act. Can they undo?**
Sagaz is an open source MCP proxy that records every effect your agents produce in the world — the *effect ledger*. It is being built to classify each effect by reversibility (**R**eversible / **C**ompensable / **I**rreversible) before it happens, and to give you preview, checkpoint, rollback by compensation, and a kill switch on top of that record.
The goal: one undo for everything your agent touched — including what no snapshot can reach.
## Status
**Pre-alpha.** Sagaz is under active development and **there is no undo yet** — today it records, classifies, gates and previews; it does not yet reverse anything. Do not put it in front of agents touching production systems and expect a safety net that isn't built. A gate you configured as `allow` will let the call through — the agent's actions are still the agent's actions. Use it on toy worlds and low-stakes setups while it grows. (See the MIT license for the formal no-warranty terms.)
**Phase 0 is complete** (a transparent stdio MCP proxy, a hash-chained effect ledger, a read-only CLI) and so is **Phase 1**: every call is classified R/C/I before it is forwarded — from your rules, MCP annotations or conservative name heuristics — and the class is sealed into the ledger. Since T8 Sagaz also *gates*: by default an irreversible call is held until you approve it from another terminal, and you can block or confirm any class or tool by policy. Since T9 there is *preview*: run the whole session dry and get a report of what the agent would have done to the world. See [`SPEC.md`](SPEC.md) for the vision, architecture and roadmap (in Spanish, as is [`docs/T0-recon-y-schema.md`](docs/T0-recon-y-schema.md), the ledger design and its frozen schema).
On npm, `sagaz-mcp@0.0.1` is a placeholder that reserves the name; the real package ships with the first release. Until then, run from a clone.
## Quickstart
From clone to a populated ledger, with the bundled [toybox](packages/toybox/README.md) — a deliberately dangerous MCP server simulating a CRM, email, tweets and a bank — as the downstream server. Requires Node ≥ 20 and pnpm (`corepack enable` picks the pinned version up).
```sh
git clone https://github.com/BabyKuramaDev/Sagaz.git && cd Sagaz
pnpm install && pnpm build
node packages/toybox/dist/index.js seed # deterministic sample world in ./toybox.db
claude # Claude Code: the repo's .mcp.json routes toybox through Sagaz
```
Ask the agent for anything — *"list the CRM contacts and send Ada a welcome email"* — then:
```sh
node packages/cli/dist/index.js ledger # every tools/call that crossed the proxy
node packages/cli/dist/index.js verify # walk the hash chain
node packages/toybox/dist/index.js inspect # what the world looks like now
```
To have `sagaz` on your PATH while developing: `pnpm --filter sagaz-mcp link --global`. Once released, the CLI will be `npx sagaz-mcp` (the bare `sagaz` name on npm is an unrelated package).
## Reading the ledger
```sh
sagaz ledger # effects of the last session: seq, tool, server, class, status, duration, result size
sagaz ledger --tool send_email # filters: --session <id|last>, --tool <name>, --status <ok|error|pending|…>
sagaz ledger --json # one raw row per line (NDJSON)
sagaz status # sessions, ledger location, overall state
sagaz verify # walk the hash chain of a session and report OK or the first break
sagaz pending # calls held by a confirm gate, waiting for you
sagaz approve <id> | deny <id> # decide; the agent gets the real result, or a message saying nothing ran
sagaz serve --preview # run the session dry: reads work, mutations are recorded and NOT executed
sagaz preview-report # what a dry session would have done to the world
```
Real output, unedited (the toybox reel through Sagaz):
```
$ sagaz ledger
session 01M17Y5KDNSXA0241H4C949P3N (2026-08-29 23:37:38Z, reel 1.0.0)
seq tool server class status duration result id
─── ────────────── ────── ─────── ────── ──────── ────── ────────
1 list_contacts toybox read ok 3ms 610B 18R1VY1A
2 list_timeline toybox read ok 1ms 191B 2JKEM6EZ
3 create_contact toybox R ok 2ms 196B 2ST4J635
4 send_email toybox C ok 1ms 234B WTNJWEXW
5 transfer_funds toybox I ok 1ms 222B 78HJNT9Q
6 delete_contact toybox unknown ok 1ms 196B QYT4KCFT
6 effect(s)
$ sagaz verify
verify session 01M17Y5KDNSXA0241H4C949P3N
genesis 12beb1d45575d6c62292fc23d1917ea00b82c3dd8485f26a1beba0c51cfd480e
✓ seq 1 list_contacts 12beb1d45575 → c219757aece0
✓ seq 2 list_timeline c219757aece0 → 1d4094a030ee
…
OK 6 effect(s) chained
```
Colour is used only on a TTY and honours [`NO_COLOR`](https://no-color.org).
## Preview: run the agent dry
`sagaz serve --preview` (or `"preview": true` in `sagaz.config.json`) runs the whole session without touching the world. Read-only calls are forwarded — a blind agent cannot plan — and every other call is classified, recorded with `status = 'dry'` and answered with a note written for an LLM: *recorded but NOT executed, would have been classified C, keep planning, nothing in this session reaches the real world*. The policy is evaluated but not applied in preview (nothing executes, so there is nothing to confirm); what it *would* have done is recorded instead. Dry effects are hashed into the chain like everything else — a plan is auditable history, the "what it meant to do" you later compare with "what it did".
Real output, unedited — the toybox reel through `sagaz serve --preview`, the world seeded and then inspected: three contacts, one email, `$0.00` in escrow, zero transfers, before and after.
```
$ sagaz preview-report
preview report — session 01M1809Z4TH62627ARRMVBXFMS (2026-08-30 00:14:59Z, reel 1.0.0)
Nothing reached the world. 7 call(s): 2 read(s) executed, 5 recorded dry.
what would have happened
I 1 transfer_funds
irreversible: no way back once executed; it would have waited for your approval
C 2 send_email ×2
compensable: cannot be undone, only corrected afterwards; all would have run without asking
R 1 create_contact
reversible: a deterministic inverse exists; it would have run without asking
unknown 1 delete_contact
reversibility unknown: no rule, annotation or heuristic decided; it would have run without asking
seq tool server class outside preview args
─── ────────────── ────── ─────── ─────────────────────── ────────────────────────────────────────────────
3 create_contact toybox R would run name=Alan Turing, email=alan@bletchley.uk
4 send_email toybox C would run to=alan@bletchley.uk, subject=Welcome, body=Hi
5 send_email toybox C would run to=ada@example.com, subject=Hello again, body=Hi
6 transfer_funds toybox I would wait for approval from_account=acc-payroll, to_account=acc-vendor…
7 delete_contact toybox unknown would run id=1
```
`sagaz ledger` shows the same rows with `dry` in magenta and a `preview` column. The honest edge: what counts as a read is decided by the classifier's cascade (your rules › MCP annotations › name heuristics), so a mutating tool that lies with `readOnlyHint: true` — or that you misclassify with a rule — *would* run in preview. That is why `unknown` is never forwarded: when in doubt, dry.
## Gates: the guardian
Out of the box, **a call classified `I` (irreversible) does not run until you say so**. The agent's tool call simply waits; in another terminal:
```
$ sagaz pending
id tool server class args waiting
──────── ────────────── ────── ───── ──────────────────────────────────────────────── ───────
78HJNT9Q transfer_funds toybox I from_account=acc-payroll, to_account=acc-vendor… 12s
1 call(s) held — sagaz approve <id> | sagaz deny <id>
$ sagaz approve 78HJNT9Q
approved transfer_funds 78HJNT9Q by jero
```
Approve, and the agent receives exactly what the server returned — it never learns it waited. Deny (or let `policy.confirmTimeoutMs` run out, default 2 minutes), and the agent receives an `isError` result written for an LLM: what was stopped, why, *do not retry*, the operator knows, carry on with something else. The attempt is recorded as `blocked` in the ledger — hashed like everything else — and `sagaz ledger` shows it in red with the reason.
Everything else (`read`, `R`, `C`, `unknown`) flows and is only recorded. Change that per class or per tool in `sagaz.config.json` (`allow` | `confirm` | `block`; a tool rule beats the class map):
```json
{ "servers": { "...": {} }, "policy": { "class": { "unknown": "confirm" }, "tools": [ { "tool": "transfer_*", "server": "bank", "action": "block" } ] } }
```
Details and the exact texts the agent sees: [`packages/core/README.md`](packages/core/README.md#gates-what-happens-once-a-call-has-a-class).
## Development
```sh
pnpm install && pnpm build && pnpm test
node packages/cli/dist/index.js --version
```
Workspace packages: [`packages/core`](packages/core) (proxy + ledger, `sagaz-core`), [`packages/cli`](packages/cli) (the `sagaz` CLI), [`packages/toybox`](packages/toybox) (the demo world). **Only one package is published: `sagaz-mcp`**, the CLI with `sagaz-core` bundled in (`better-sqlite3` stays a native, external dependency). `sagaz-core` and `sagaz-toybox` are private workspace packages and are never published.
## How effects get their class
`class` comes from a cascade — **your rules** in `sagaz.config.json` → MCP `readOnlyHint` → built-in name heuristics → `unknown` — and your rules always win. The heuristics are deliberately conservative: `create_*` is **R**, `send_*`/`post_*` are **C**, `transfer_*`/`pay_*`/`drop_*` are **I**, but `update_*`/`delete_*` stay `unknown` until a compensation pack or a rule of yours says otherwise, because **R means "Sagaz knows the inverse"** and a name never proves that. Table and rule format: [`packages/core/README.md`](packages/core/README.md).
```json
{ "servers": { "...": {} }, "rules": [ { "tool": "delete_contact", "server": "crm", "class": "R", "reason": "soft delete" } ] }
```
## Running Sagaz in front of your MCP servers
Sagaz is an MCP proxy: your client talks to `sagaz serve`, Sagaz talks to your servers. Declare the downstream servers in `sagaz.config.json` (same shape as an MCP client's `mcpServers` entries) and point your client at Sagaz:
`sagaz.config.json`:
```json
{ "servers": { "toybox": { "command": "node", "args": ["packages/toybox/dist/index.js"], "env": { "TOYBOX_DB": "./toybox.db" } } } }
```
`.mcp.json` (Claude Code):
```json
{ "mcpServers": { "sagaz": { "command": "node", "args": ["packages/cli/dist/index.js", "serve", "--config", "sagaz.config.json"] } } }
```
Paths: `ledger.path` and each server's `cwd` are resolved relative to the config file; `command`/`args` are passed to the child process as written, so relative paths in them are relative to wherever Sagaz is started (the example above assumes the repo root).
Every `tools/call` that crosses Sagaz is recorded in the **effect ledger**, a local SQLite file (`ledger.path`, default `./.sagaz/ledger.db`, relative to the config file). Each session (one per client `initialize`) has its own hash chain; every closed effect is `sha256(prev_hash || canonical_fields)`, so the ledger is tamper-evident. Large results are truncated to `ledger.maxResultBytes` (default 64 KB) and marked as such. The ledger holds tool arguments and results verbatim — it may contain secrets; it never leaves your machine.
```json
{ "servers": { "...": {} }, "ledger": { "path": "./.sagaz/ledger.db", "maxResultBytes": 65536 } }
```
Tool names pass through unchanged, whatever the number of servers. If two servers expose the same tool name, Sagaz refuses to start and tells you to add an explicit `"prefix": "name"` to one of them (`name__tool`). Prefixes are never applied automatically.
Current scope: `tools/list`, `tools/call` and `tools/list_changed` are forwarded. `initialize` is answered by Sagaz itself (it cannot be forwarded verbatim with N downstreams); downstream `instructions` are concatenated and passed on (known pending: label each block with its server name once multi-server setups are common). Resources and prompts are not proxied yet and are not announced in capabilities.
The repo ships a `.mcp.json` and a `sagaz.config.json` wired this way, so Claude Code opened in this directory drives the [toybox](packages/toybox/README.md) world through Sagaz.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues