mcp-mrtr-devops-demo
by caldeirav
README.md
# mcp-mrtr-devops-demo
End-to-end demo of **Multi Round-Trip Requests (MRTR)** under **MCP SEP-2322**, using LangGraph, agentgateway, LM Studio, and Spec-Kit in Cursor IDE.
Stateless human-in-the-loop (HITL) DevOps agent: yield for confirmation, resume on any server instance, no sticky SSE sessions.
## Executive summary
This repository demonstrates a production-shaped pattern for mid-call HITL under the **2026-07-28 MCP specification**. When an agent hits a destructive operation, the MCP server returns a signed continuation handle and closes the connection. The client pauses, collects operator input, then resubmits—routable to any backend behind a load balancer.
## Use case: emergency database migration agent
An autonomous DevOps AI agent receives a prompt to run an emergency migration on production cluster `prod-db-01`. The migration file `V004__drop_legacy_users.sql` contains destructive operations (`DROP TABLE`).
Before executing, the agent must obtain human authorization. Under MRTR, that pause is **stateless**: no open SSE GET stream and no in-memory paused thread on a specific pod.
## Legacy bottleneck (pre-2026 / e.g. MCP 2025-11-25)
Older MCP HITL patterns typically required:
- Open, persistent **Server-Sent Events (SSE) GET** streams for mid-call authorization
- An MCP server that **held the execution thread** in memory while waiting for user confirmation
**Failure mode:** If an API gateway or load balancer dropped the idle SSE socket—or server pods auto-scaled—the paused thread died, agent state was lost, and operators had to restart the whole flow manually.
## How MRTR (SEP-2322) resolves it
Under the **2026-07-28** MCP specification (SEP-2322):
1. **Stateless yield** — On a destructive operation, the MCP server does **not** hold a connection open. It packages the user input schema (`inputRequests`) and an **HMAC-signed** continuation token (`requestState`), returns HTTP 200 with `resultType: "input_required"`, and terminates the socket immediately.
2. **Non-blocking client pause** — The LangGraph client receives `input_required`, pauses its graph, and releases system resources.
3. **Stateless resubmission** — The operator completes the confirmation prompt (UI/terminal). LangGraph re-issues `tools/call` as an HTTP POST with `inputResponses` and the echoed `requestState`.
4. **Unpinned load balancing** — Round-robin via **agentgateway** can route the retry to **any** available server instance. That instance validates the HMAC, runs the migration, and returns `resultType: "complete"`.
`Mcp-Session-Id` and sticky session headers are **not** used.
## Technical stack
| Layer | Choice |
| --- | --- |
| IDE / orchestration | Cursor IDE + GitHub Spec-Kit (`specify-cli`) |
| Agent framework | LangGraph (Python) — workflow state, non-blocking pauses, retry loops |
| LLM | LM Studio — `qwen/qwen3.6-35b-a3b` at `http://127.0.0.1:1234/v1` |
| API gateway / L7 | agentgateway — Streamable HTTP proxy on port **8080** (`statefulMode: stateless`) |
| MCP protocol | MCP **2026-07-28** stateless core + SEP-2322 MRTR payloads |
## Architecture (logical)
```text
Operator terminal
│
▼
main.py (harness)
├── validates LM Studio :1234
├── starts MCP server :8000
└── starts agentgateway :8080
│
▼
LangGraph agent ── tools/call ──► agentgateway :8080 ──► MCP server :8000
│ │
│◄── input_required + requestState ───────────────┤
│ │
terminal HITL (confirm_drop + environment_tag) │
│ │
└── retry + inputResponses + requestState ────────┘
│
▼
resultType: complete
```
## End-to-end setup and run
Follow these steps in order on macOS or Linux.
### Step 0 — Prerequisites checklist
| Requirement | Why |
| --- | --- |
| Python **3.11+** | Runtime for MCP server, agent, and harness |
| [`uv`](https://github.com/astral-sh/uv) | Installs deps and runs the project |
| [LM Studio](https://lmstudio.ai/) | Local OpenAI-compatible LLM |
| Model `qwen/qwen3.6-35b-a3b` (or matching `MODEL_NAME`) | Used by the LangGraph agent |
| [`agentgateway`](https://agentgateway.dev/docs/standalone/latest/deployment/binary) on `PATH` | L7 proxy for tool calls (constitution: no gateway bypass) |
| Free local ports **1234**, **8000**, **8080** | LLM, MCP server, agentgateway |
| Port **15000** free (optional) | agentgateway admin UI / LLM playground |
| [Podman](https://podman.io/) or [Docker](https://docs.docker.com/get-docker/) (optional) | Jaeger traces for pause/resume screenshots (harness prefers Podman) |
### Step 1 — Clone the repository
```bash
git clone https://github.com/caldeirav/mcp-mrtr-devops-demo.git
cd mcp-mrtr-devops-demo
```
(Or open the existing checkout and `cd` into the repo root.)
### Step 2 — Install `uv` (if needed)
```bash
# macOS (Homebrew)
brew install uv
# or official installer
curl -LsSf https://astral.sh/uv/install.sh | sh
```
Confirm:
```bash
uv --version
python3 --version # should be 3.11+
```
### Step 3 — Install agentgateway
```bash
curl -sL https://agentgateway.dev/install | bash
agentgateway --version
```
Ensure the binary is on your `PATH` (the installer typically places it in `/usr/local/bin`).
### Step 4 — Start LM Studio and load the model
1. Open **LM Studio**.
2. Download / select model **`qwen/qwen3.6-35b-a3b`** (or the id you will put in `.env` as `MODEL_NAME`).
3. Start the **local server** (OpenAI-compatible API) on **`http://127.0.0.1:1234`**.
4. Confirm the server is up (LM Studio UI shows listening, or):
```bash
curl -s http://127.0.0.1:1234/v1/models | head
```
You should see JSON listing available models. Leave LM Studio running for the rest of the demo.
### Step 5 — Configure environment
```bash
cp .env.example .env
```
Edit `.env` and set at least:
```bash
OPENAI_API_BASE=http://127.0.0.1:1234/v1
OPENAI_API_KEY=lm-studio
MODEL_NAME=qwen/qwen3.6-35b-a3b
AGENTGATEWAY_PORT=8080
MCP_SERVER_PORT=8000
MCP_HMAC_SECRET=<long-random-secret>
ENABLE_JAEGER=0
# CONTAINER_RUNTIME=podman # optional; default prefers podman, then docker
```
Keep `MODEL_NAME` aligned with `llm.models[].params.model` in `agentgateway.yaml`.
LangGraph chat uses LM Studio **directly** (`OPENAI_API_BASE`); the gateway `llm` block is for the **admin UI playground**, not agent chat.
Generate a secret if you do not have one:
```bash
# example: 64 hex chars
openssl rand -hex 32
```
Paste the value into `MCP_HMAC_SECRET`. Do **not** commit `.env` (it is gitignored).
### Step 6 — Install Python dependencies
From the repo root:
```bash
uv sync --group dev
```
This creates `.venv` and installs `langgraph`, `fastapi`, `httpx`, and the rest of the stack (see `pyproject.toml`).
### Step 7 — (Optional) Run automated tests
These do **not** require agentgateway or a live demo session (integration test skips if gateway is down):
```bash
uv run pytest
```
Expect unit/contract tests to pass.
### Step 8 — Run the end-to-end demo
One command starts MCP, agentgateway, validates the LLM, and runs the agent:
```bash
uv run python main.py
```
What the harness does automatically:
1. Fail-fast check that LM Studio answers at `OPENAI_API_BASE`
2. Ensures `agentgateway.yaml` exists (`statefulMode: stateless`, `:8080` → MCP `:8000/mcp`, plus `llm` + OTLP tracing)
3. If `ENABLE_JAEGER=1`, starts or reuses Jaeger via **Podman** (preferred) or Docker (`:16686` UI / `:4317` OTLP); warns and continues if the engine/Jaeger fails
4. Starts the MCP server on `MCP_SERVER_PORT` (default **8000**)
5. Starts `agentgateway -f agentgateway.yaml` on **8080** with `OPENAI_API_KEY` in the process env (logs → `.demo_logs/`)
6. Runs the LangGraph agent for defaults **`prod-db-01`** / **`V004__drop_legacy_users.sql`**
7. On Ctrl+C / exit, stops MCP + agentgateway; if the harness started Jaeger for this run, it runs `podman stop` / `docker stop` (does not `rm`) that container
Terminal output is banded so you can tell layers apart:
| Band | Meaning |
| --- | --- |
| `TRACE` | Harness / LangGraph / HTTP execution |
| `AGENT` | LLM narrative and packaged answers |
| `HITL` | Your operator prompts (no open SSE socket) |
| `SEP` | Detailed request/response + ★ what changed in 2026-07-28 / SEP-2322 |
`SEP` panels highlight new fields (`resultType`, `requestState`, `inputRequests` / `inputResponses`, per-request `_meta`, `Mcp-Method` / `Mcp-Name`) and call out removed sticky-session behavior (`Mcp-Session-Id` absent). Set `NO_COLOR=1` to disable ANSI colors.
While the harness is running (or with MCP + agentgateway started manually), you can also drive the same gateway from the **admin UI** — see [Demo steps in the agentgateway UI](#demo-steps-in-the-agentgateway-ui) below. That path is ideal for screenshots; the LangGraph terminal path remains the primary HITL story.
### Step 9 — Complete the human-in-the-loop prompts
When the destructive migration is detected you will see a terminal block similar to:
```text
=== Human-in-the-loop authorization required ===
Confirm destructive migration ...
confirm_drop [true/false]:
environment_tag ['dev', 'staging', 'prod']:
```
Happy-path answers:
```text
confirm_drop [true/false]: true
environment_tag ['dev', 'staging', 'prod']: prod
```
Then the agent retries through agentgateway with `inputResponses` + echoed `requestState` and prints a **`complete`** summary (simulated apply).
**Other useful answers to try**
| Input | Expected outcome |
| --- | --- |
| `confirm_drop=false` | `complete` with denied/cancelled text; migration not applied |
| `environment_tag=qa` | `complete` with denied text (tag outside allow-list) |
| Wait longer than **5 minutes** before answering | Resume fails (expired `requestState`) |
### Step 10 — Shut down
Press `Ctrl+C` if the process is still running. The harness tears down MCP and agentgateway. You can quit LM Studio when finished.
---
## Demo steps in the agentgateway UI
Use this walkthrough for **screenshots and audience demos** of the gateway layer. It complements (does not replace) the LangGraph terminal HITL in Steps 8–9.
**Prerequisites for UI demos**
| Need | Check |
| --- | --- |
| MCP + agentgateway up | `uv run python main.py` **or** start MCP + `agentgateway -f agentgateway.yaml` with `OPENAI_API_KEY` in the gateway env |
| LM Studio serving the demo model | `curl -s http://127.0.0.1:1234/v1/models` returns your model |
| Config valid | `export OPENAI_API_KEY=lm-studio && agentgateway -f agentgateway.yaml --validate-only` |
| CORS for the admin origin | `agentgateway.yaml` allows `http://localhost:15000` |
**Ports**
| Port | Service |
| --- | --- |
| `1234` | LM Studio OpenAI API |
| `8000` | MCP server |
| `8080` | agentgateway MCP proxy |
| `15000` | agentgateway admin UI |
| `4317` | OTLP gRPC (Jaeger, optional) |
| `16686` | Jaeger UI (optional) |
Contract / quickstart detail: [specs/002-gateway-llm-tracing/quickstart.md](specs/002-gateway-llm-tracing/quickstart.md).
---
### UI Step 1 — Open the admin console
1. Browse to **[http://localhost:15000/ui/](http://localhost:15000/ui/)**.
2. Confirm the UI loads (blank/error page usually means agentgateway is not running or port `15000` is blocked).
3. Optionally open the architecture / targets view and confirm target **`devops-migration`** points at the local MCP upstream (`http://127.0.0.1:8000/mcp`) with **stateless** MCP mode (no sticky session pinning).
---
### UI Step 2 — Tool Playground: list `apply_db_migration`
This shows the gateway can discover tools from the MCP server (same path the LangGraph agent uses via `:8080`).
1. In the admin UI, open **Tool Playground** (or the MCP tools / playground section — label may vary slightly by agentgateway version).
2. Select target / server **`devops-migration`** if prompted.
3. Refresh or list tools.
4. Confirm tool **`apply_db_migration`** appears with arguments `cluster_id` and `script_name`.
If the list is empty or you see a protocol-version error, restart agentgateway with the repo `agentgateway.yaml` and ensure the MCP server is healthy on `:8000`.
---
### UI Step 3 — Tool Playground: destructive call → `input_required`
Demonstrate SEP-2322 MRTR **without** the LangGraph terminal (great for a slide).
1. In Tool Playground, choose **`apply_db_migration`**.
2. Set arguments:
- `cluster_id`: `prod-db-01`
- `script_name`: `V004__drop_legacy_users.sql`
3. Send / invoke the tool.
4. Inspect the JSON result. You should see:
- top-level **`resultType`: `"input_required"`**
- **`requestState`** (HMAC continuation handle)
- **`inputRequests`** with a form asking for `confirm_drop` and `environment_tag`
5. Call out to the audience: the HTTP response finished; there is **no** open SSE GET and **no** `Mcp-Session-Id` required to continue later.
**Screenshot tip:** Capture the `input_required` payload with `resultType` and `requestState` visible.
---
### UI Step 4 — Tool Playground: resume → `complete` (optional)
If the UI supports filling elicitation / `inputResponses` on retry:
1. Resubmit the same tool with the same `cluster_id` / `script_name`.
2. Include the echoed **`requestState`** from Step 3.
3. Provide acceptance answers, e.g. `confirm_drop: true`, `environment_tag: "prod"`.
4. Confirm **`resultType`: `"complete"`** and a simulated apply summary.
If the playground UI does not yet expose a clean resume form, switch to the **LangGraph terminal** (Step 9) or the `curl` example under [Manual component checks](#manual-component-checks-optional) for the resume round-trip—then return to Jaeger (UI Step 6) to show both spans.
**Non-destructive contrast (optional):** call the tool with a script name that does **not** contain `drop` / `destructive` (e.g. `V001__init.sql`) and show an immediate **`resultType`: `"complete"`** with no pause.
---
### UI Step 5 — LLM playground: probe LM Studio via the gateway
Gateway `llm` targets LM Studio at `http://127.0.0.1:1234/v1`. The LangGraph agent still chats with LM Studio **directly**; this step is for console/demo proof that the gateway LLM path works.
1. In the admin UI, open **LLM** / **LLM playground** (agentgateway 1.3+).
2. Select model **`qwen/qwen3.6-35b-a3b`** (or whichever model LM Studio currently serves — keep it aligned with `.env` `MODEL_NAME` and `agentgateway.yaml` `llm.params.model`).
3. Optionally set a short system prompt (e.g. “You are a concise demo assistant.”).
4. Send a user message such as: `Reply with the single word: pong`.
5. Confirm a successful model reply and, if shown, latency / token metrics.
If the model list is empty: verify LM Studio is up, `OPENAI_API_KEY` is present in the agentgateway process environment, and re-validate the config (`--validate-only`).
---
### UI Step 6 — Optional: inspect traces in Jaeger after UI / terminal runs
Tracing is configured in `agentgateway.yaml` (`frontendPolicies.tracing` → `localhost:4317`, full sampling). The core HITL demo does **not** require Jaeger.
**Start Jaeger**
```bash
# Option A — harness (.env); prefers podman, then docker
ENABLE_JAEGER=1
# CONTAINER_RUNTIME=podman # optional explicit override
uv run python main.py
# Option B — Podman (recommended if you do not use Docker)
# Ensure the engine is up first, e.g.: podman machine start
podman run -d --name jaeger \
-p 16686:16686 \
-p 4317:4317 \
jaegertracing/all-in-one:latest
# or: podman start jaeger
# Option C — Docker
docker run -d --name jaeger \
-p 16686:16686 \
-p 4317:4317 \
jaegertracing/all-in-one:latest
```
**After** Tool Playground pause+resume and/or a full LangGraph HITL run:
1. Open **[http://localhost:16686](http://localhost:16686)**.
2. Find recent traces for gateway / MCP traffic.
3. Confirm **two** tool round-trips are distinguishable (initial `input_required` path and resume/`complete` path).
If Jaeger is down, the migration demo still succeeds; the UI will simply show no new spans.
---
### Suggested presenter order
| Order | Where | What you show |
| --- | --- | --- |
| 1 | Terminal | `uv run python main.py` — harness + TRACE/AGENT bands |
| 2 | Admin UI | Tool Playground → `apply_db_migration` → `input_required` |
| 3 | Terminal | HITL answers (`confirm_drop` / `environment_tag`) → `complete` |
| 4 | Admin UI | LLM playground → short LM Studio probe |
| 5 | Jaeger (optional) | Two MCP round-trips for pause + resume |
---
## What you just demonstrated
1. Destructive tool call → `resultType: input_required` + HMAC `requestState` (terminal agent and/or admin **Tool Playground**)
2. Socket closed; no sticky `Mcp-Session-Id`
3. Operator input in the terminal (or playground resume, if supported)
4. Retry via **agentgateway** → any-ready MCP instance can verify state and finish with `resultType: complete`
5. (Optional) Gateway **LLM playground** reaches LM Studio; Jaeger shows pause + resume spans
## Manual component checks (optional)
Use these if you want to inspect layers separately (stop `main.py` first to free ports):
```bash
# Terminal A — MCP server
export $(grep -v '^#' .env | xargs) # or set MCP_HMAC_SECRET in the shell
uv run uvicorn mcp_server.server:app --host 127.0.0.1 --port 8000
# Terminal B — gateway
agentgateway -f agentgateway.yaml
# Terminal C — smoke tools/call (should return input_required)
curl -s http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: apply_db_migration' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"apply_db_migration","arguments":{"cluster_id":"prod-db-01","script_name":"V004__drop_legacy_users.sql"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}'
```
## Repository layout
```text
main.py # one-command harness (+ optional Jaeger)
agentgateway.yaml # L7 proxy (stateless MCP + llm + OTLP tracing)
mcp_server/ # FastAPI MCP tool + HMAC crypto
agent/ # LangGraph client + httpx MCP client
.env.example # config template (incl. ENABLE_JAEGER)
specs/001-mrtr-db-migration/ # MRTR HITL Spec Kit artifacts
specs/002-gateway-llm-tracing/ # LLM playground + Jaeger Spec Kit artifacts
```
## Governing principles
Project rules live in [`.specify/memory/constitution.md`](.specify/memory/constitution.md):
1. **Connection Statelessness** — no persistent SSE GET sockets; `Mcp-Session-Id` prohibited
2. **Protocol Precision** — top-level `resultType`: `complete` \| `input_required`
3. **HMAC Integrity** — HMAC-SHA256 continuation handles (`requestState`)
4. **Infrastructure Integration** — tools via agentgateway `:8080`; LM Studio as above
5. **Modular Verification** — separable MCP server, agentgateway, and LangGraph runloop
More detail: [specs/001-mrtr-db-migration/quickstart.md](specs/001-mrtr-db-migration/quickstart.md) and [specs/002-gateway-llm-tracing/quickstart.md](specs/002-gateway-llm-tracing/quickstart.md).
## Troubleshooting
| Symptom | What to check |
| --- | --- |
| Fail-fast: LLM unreachable | LM Studio server running on `:1234`; model loaded; `OPENAI_API_BASE` / `MODEL_NAME` match |
| `agentgateway` not found | Re-run install script; confirm `which agentgateway` |
| Port already in use | Stop other listeners on 8000/8080/1234 (also 15000/4317/16686 if using UI/Jaeger) |
| HMAC / invalid `requestState` | Same `MCP_HMAC_SECRET` for the whole process; answer within 5 minutes |
| Invalid environment tag | Exactly `dev`, `staging`, or `prod` (case-sensitive) |
| Gateway returns unexpected session behavior | `agentgateway.yaml` must keep `statefulMode: stateless` |
| HTTP 406 from gateway | Client must send `Accept: application/json, text/event-stream` |
| `_meta.protocolVersion is required` | Include MCP 2026-07-28 `_meta` keys under `params` (see contracts) |
| LLM playground empty / errors | `OPENAI_API_KEY` exported for agentgateway; LM Studio up; `llm.params.model` matches loaded model |
| No traces in Jaeger | Collector on `:4317`; `frontendPolicies.tracing` present; re-run a tool call; `ENABLE_JAEGER=1` + Podman/Docker engine up (`podman machine start`); or `podman start jaeger` |
| `ENABLE_JAEGER=1` but no UI on `:16686` | `podman`/`docker` on PATH; engine ready (`podman info`); ports free; check harness WARN lines |
## License
See [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues