tessera-mcp
# tessera-mcp
A **Model Context Protocol (MCP) server** that gives an AI agent seven support-ops tools for **Tessera**, a fictional B2B workspace SaaS. It runs over **stdio** with a **bundled SQLite database** — no hosted runtime, no API keys, no LLM calls anywhere in this repo.
> **Why this exists (60-second pitch).** Wiring an LLM to tools is easy; wiring it *safely* is the hard part. This server demonstrates the pattern I ship for agentic work: **typed tools with LLM-readable descriptions, a dry-run/confirm human-in-the-loop gate on every mutation, and a deterministic test suite that proves the tools behave** — 20/20, no model in the loop. Point Claude Code at it and watch it run a realistic morning ops workflow: spot a double-billed customer, investigate, refund with approval, rescue a churning trial.
---
## Quickstart
```bash
# 1. Create + seed the local database (idempotent — safe to re-run)
npx tessera-mcp --seed
# 2. Register the server with Claude Code
claude mcp add tessera -- npx tessera-mcp
```
That's it. Claude Code now has the seven tools below. No environment variables required — the server uses a local SQLite file at `~/.tessera-mcp/data.db`. The path is home-anchored (not relative to the current directory) so the server finds the same database no matter where the host launches it from, and it **auto-seeds on first run** if you skip step 1.
Requires **Node 20+**.
### Claude Desktop
Add this to your `claude_desktop_config.json` (Settings → Developer → Edit Config):
```json
{
"mcpServers": {
"tessera": { "command": "npx", "args": ["tessera-mcp"] }
}
}
```
Claude Desktop launches MCP servers from the filesystem root, so a working-directory-relative database would be empty. This server sidesteps that by using the home-anchored `~/.tessera-mcp/data.db` and seeding it on first run — no `--seed` step required.
---
## The tools
| Tool | Kind | What it does |
|------|------|--------------|
| `list_open_tickets({ priority?, limit? })` | read | Open tickets, worst priority first; optional priority filter + limit |
| `get_customer({ idOrEmail })` | read | One customer by id or email, with their invoices + tickets summary |
| `search_invoices({ customerId?, status?, minAmount? })` | read | Filter invoices — spot duplicates, overdue balances, history |
| `daily_summary()` | read | Start-of-day snapshot: open tickets by priority, overdue invoices ($), trials expiring ≤7d |
| `update_ticket_status({ ticketId, status, confirm? })` | **mutation** | Move a ticket to open / pending / solved |
| `extend_trial({ customerId, days, confirm? })` | **mutation** | Push a trial customer's end date out by N days |
| `issue_refund({ invoiceId, reason, confirm? })` | **mutation** | Refund a paid invoice (rejects already-refunded / non-paid) |
Every input field carries a description — that text is what the client LLM reads to call the tool correctly. Results come back as concise text tables, not JSON dumps. Invalid ids return clear error strings, never exceptions.
---
## The confirm / dry-run pattern (human-in-the-loop)
MCP has no built-in "approve this action" UI — the protocol hands a tool call straight to your code. So every **mutation** here is gated by a `confirm` flag that **defaults to false**:
- **Without `confirm: true`** the tool changes *nothing* and returns a preview:
```
DRY RUN — would refund INV-1013 ($2,400.00, Atlas Freight) — reason: "double billed".
Re-call with confirm:true to execute.
```
- **With `confirm: true`** it executes atomically (a single write batch that also appends an audit-log row) and reports what changed:
```
Done — refunded INV-1013 ($2,400.00, Atlas Freight). Reason: "double billed".
```
The agent shows the human the dry-run preview, waits for a yes, then re-calls with `confirm:true`. It turns an irreversible action into a two-step, reviewable one. See [DECISIONS.md](./DECISIONS.md) for why this beats an ungated tool.
---
## Example Claude Code session (illustrative)
> Transcript below is an *illustration* of the intended workflow, lightly trimmed. The deterministic suite (`npm run eval`) is the actual correctness metric.
```
You: Good morning — anything on fire?
Claude: [calls daily_summary]
Open tickets: 10 (3 urgent). 4 overdue invoices ($2,847.00). 2 trials expiring within 7 days.
One thing stands out: Atlas Freight has two identical $2,400 invoices issued the same day.
You: Dig into Atlas Freight.
Claude: [calls get_customer({ idOrEmail: "CU-1010" })]
Atlas Freight (CU-1010), enterprise. INV-1013 and INV-1014 are both $2,400.00,
both issued Jul 14, 2026 — that's a double charge.
You: Refund one of them.
Claude: [calls issue_refund({ invoiceId: "INV-1013", reason: "duplicate charge — double billed" })]
DRY RUN — would refund INV-1013 ($2,400.00, Atlas Freight). Re-call with confirm:true to execute.
Want me to go ahead?
You: Yes.
Claude: [calls issue_refund({ ..., confirm: true })]
Done — refunded INV-1013 ($2,400.00, Atlas Freight).
You: Also, Paperkite's trial is about to lapse and they're angry — give them two more weeks and close their ticket.
Claude: [extend_trial CU-1003 +14 (dry-run → confirm), then update_ticket_status TK-1003 solved (dry-run → confirm)]
Done — Paperkite's trial now ends Aug 3, 2026, and TK-1003 is solved.
You: Re-run the summary.
Claude: [calls daily_summary]
Open tickets down to 9 (2 urgent). Overdue unchanged. Trials expiring within 7 days: down to 1.
```
---
## Eval — the honest metric
`npm run eval` connects an **in-process MCP client** to the server over the SDK's linked in-memory transport (no child process, no network, no LLM) and replays **23 scripted cases** from [`data/eval-cases.json`](./data/eval-cases.json) against a freshly seeded, isolated database:
- valid reads, filtered reads, and invalid-id errors
- each mutation as **dry-run** and then **confirmed**
- **post-mutation state assertions** (a re-read proves the change landed)
- a **same-status no-op** that returns success, not an error
- **direct `audit_log` assertions** (exactly one row per confirmed mutation)
- **refund-twice rejection** (a second refund on an already-refunded invoice errors)
- a **concurrency check**: two overlapping confirmed refunds race via `Promise.all`; exactly one wins and exactly one audit row is written (proving the TOCTOU fix)
The eval is **isolated** — it runs against its own `file:eval.db` and never inherits `TURSO_DATABASE_URL`, so `npm run eval` can never touch your real or hosted data.
Because there is no model in the loop, the suite is fully deterministic: the gate is **100%**. Anything less is a real bug and the process exits non-zero.
```
23/23 cases passed.
All 23 eval cases + concurrency check passed (deterministic gate: 100%).
```
---
## Configuration
Zero config by default. Optional environment variables:
| Variable | Effect |
|----------|--------|
| `TURSO_DATABASE_URL` | Use a hosted libSQL/Turso database instead of the default `~/.tessera-mcp/data.db` |
| `TURSO_AUTH_TOKEN` | Auth token for the hosted database above |
| `TESSERA_NOW` | Override the demo's fixed reference clock (ISO 8601); defaults to `2026-07-16T12:00:00Z` so the eval stays deterministic. An invalid value is ignored with a stderr warning |
| `EVAL_DB_URL` | Database the **eval** uses (default `file:eval.db`). The eval ignores `TURSO_DATABASE_URL` entirely so it can never wipe real data |
| `EVAL_ALLOW_REMOTE` | Set to `1` to allow the eval to target a remote (non-`file:`) URL; otherwise remote eval URLs are refused |
CLI flags: `--seed` (create + seed, then exit), `--db <path>` (use a specific SQLite file), `--help`. The default database is `~/.tessera-mcp/data.db`, auto-seeded on first run.
See [`env.example`](./env.example). This project never reads or writes `.env` files.
---
## The Tessera universe
Tessera is a fictional B2B workspace SaaS used across a family of portfolio demos. Sibling repos:
- [docs-chat](https://github.com/NDilanka/docs-chat) — RAG over Tessera's docs
- [tessera-ops-agent](https://github.com/NDilanka/tessera-ops-agent) — the ops dashboard + copilot this data is modeled on
- [tessera-invoice-inbox](https://github.com/NDilanka/tessera-invoice-inbox) — invoice-processing workflow
All data here is synthetic. See [DECISIONS.md](./DECISIONS.md) for architecture rationale and [VIDEO-SCRIPT.md](./VIDEO-SCRIPT.md) for the demo narration.
## License
MIT
TDQS
Scored across 7 tools
Each tool targets a distinct resource and action: tickets, customers, invoices, and trial management. No two tools overlap in purpose; daily_summary aggregates but does not duplicate list_open_tickets.
Most tools follow a clear verb_noun pattern (list_open_tickets, get_customer, update_ticket_status). The outlier is daily_summary, which uses a noun phrase rather than a verb, creating a minor inconsistency.
With 7 tools, the server is well-scoped for a support and billing domain. Each tool covers a distinct function without bloat or redundancy.
The tool set covers ticket status updates, customer lookups, invoice search, refunds, and trial extensions. Minor gaps exist, such as no way to list closed tickets or update customer details, but these are not essential to the core workflow.