Skip to main content
Glama
tachyurgy
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