mcp-app-harness
by tachyurgy
README.md
# mcp-app-harness
A small, **working reference implementation** of the pattern behind an RL-training
sandbox: take a real application, run it in a container, and expose it to an agent
through a **FastMCP** server — with **populate / snapshot / restore** hooks so every
episode starts from an identical, known state and any trajectory can be checkpointed
and rewound.
It's deliberately tiny (a support-desk service + its MCP integration) so the *seams*
are visible. Swap the demo app for any web/desktop app and the harness structure is
unchanged.
```
┌────────────────┐ MCP tools/resources ┌────────────────────┐
│ Agent / RL │ ───────────────────────────────▶ │ FastMCP harness │
│ policy │ create_ticket, populate, │ (mcp_harness/) │
└────────────────┘ snapshot, restore, tickets://all └─────────┬──────────┘
│ HTTP (httpx)
▼
┌────────────────────┐
│ App under test │
│ FastAPI + SQLite │
│ (app/) │
└────────────────────┘
```
## Why this exists
Integrating an app into a sandbox has three hard parts, and this repo shows a clean
answer to each:
1. **Expose the app to an agent.** Every product operation is a typed MCP tool
(`mcp_harness/server.py`) and live state is an MCP resource. Schemas come free from
the type hints.
2. **Make state reproducible.** `populate()` seeds a deterministic baseline;
`snapshot()` captures full state; `restore()` re-applies it. `restore(snapshot(x)) == x`
is asserted in the tests (`tests/test_hooks.py`).
3. **Keep it testable locally.** The HTTP client is transport-injectable, so the whole
stack runs in-process over `httpx.ASGITransport` in CI — no sockets, no flakiness —
while production uses real HTTP unchanged.
## Layout
| Path | Role |
|---|---|
| `app/` | The **application under test** — a FastAPI + SQLite support desk. Stands in for the third-party app you'd integrate. Ships an `/admin` state surface (dump/load/reset). |
| `mcp_harness/server.py` | The **FastMCP server** — product tools + lifecycle tools + resources. |
| `mcp_harness/client.py` | Transport-injectable async HTTP client to the app. |
| `mcp_harness/hooks.py` | `populate` / `snapshot` / `restore` — the sandbox lifecycle contract. |
| `mcp_harness/config.py` | Env-driven service configuration (12-factor). |
| `fixtures/seed_baseline.json` | Deterministic starting state for `populate()`. |
| `tests/` | pytest: app, hooks, and the MCP tools driven via FastMCP's in-memory client. |
| `Dockerfile` / `docker-compose.yml` | One image, two services (app + harness), healthcheck-ordered boot. |
## Run it
**Tests (fastest way to see it work):**
```bash
uv venv --python 3.12 && source .venv/bin/activate
uv pip install -e ".[dev]"
pytest -q
```
**Locally, two processes:**
```bash
# terminal 1 — the app under test
./scripts/run_app.sh # http://localhost:8080/health
# terminal 2 — an episode: populate → act → snapshot → reset → restore
./scripts/demo_episode.sh
```
**The MCP server:**
```bash
python -m mcp_harness.server # stdio (embed in an agent runtime)
MCP_TRANSPORT=http python -m mcp_harness.server # HTTP transport on :9000
```
**In a sandbox (Docker):**
```bash
docker compose up --build
# app → :8080 | MCP harness (HTTP) → :9000
```
## The MCP surface
**Tools:** `create_ticket`, `list_tickets`, `get_ticket`, `assign_ticket`, `close_ticket`,
`populate`, `snapshot`, `restore`.
**Resources:** `tickets://all` (live ticket list), `health://app` (liveness).
An agent's loop looks like: `populate()` → read `tickets://all` → call ticket tools →
`snapshot()` to checkpoint. An RL runner scores the resulting state and `restore()`s to
branch a new rollout.
## Configuration (all via env — no code changes between environments)
| Var | Default | Meaning |
|---|---|---|
| `APP_BASE_URL` | `http://localhost:8080` | Where the harness reaches the app |
| `APP_DB_PATH` | `:memory:` | SQLite path for the app (`/data/...` to persist on a volume) |
| `MCP_TRANSPORT` | `stdio` | `stdio` (embedded) or `http` (sandboxed port) |
| `MCP_HOST` / `MCP_PORT` | `0.0.0.0` / `9000` | HTTP transport bind |
| `HARNESS_FIXTURE` | `fixtures/seed_baseline.json` | Baseline for `populate()` |
| `APP_HTTP_TIMEOUT` | `10` | Client timeout (short, so a wedged app fails fast) |
## Adapting to a real app
`app/` is the throwaway part. To integrate a real app:
- Point `AppClient` (or a subclass) at the app's actual API.
- Re-implement the three hooks against whatever state seam the app exposes — a DB
volume snapshot, a `docker commit`, an export/import endpoint, or a management
command. The tool/resource layer and the tests don't change.
## License
MIT.
TDQS
C2.8/5.0
Scored across 8 tools
Disambiguation5/5
Each tool has a clearly distinct purpose: ticket tools (create, read, update status, list) and state management tools (populate, restore, snapshot) operate on different aspects with no overlap.
Naming Consistency4/5
Ticket tools follow consistent verb_noun pattern (e.g., create_ticket), but state management tools use single-word verbs (populate, restore, snapshot), creating a minor inconsistency in naming conventions.
Tool Count5/5
8 tools is well-scoped for a ticket system with testing utilities; each tool earns its place without being excessive or insufficient.
Completeness3/5
Core ticket lifecycle is covered (create, get, list, assign, close), but missing update for other fields (e.g., priority, description) and no reopen functionality, leaving notable gaps.
Maintenance
ActivityInactive
ResponsivenessNo issues