Skip to main content
Glama
mitchallen

Synthetic Orders MCP Server

by mitchallen
README.md
# Synthetic Orders MCP Server

An MCP server that triggers synthetic order traffic against a local order API, so
an agent can load-test and drill the rejection path of a service without being
able to aim that traffic anywhere it likes.

Companion code for
[Trigger Synthetic Orders from an MCP Server on macOS](https://scriptable.com/posts/python/synthetic-orders-mcp-server-macos/).

Two processes are involved:

| Process | What it is | How it runs |
| --- | --- | --- |
| `api/main.py` | The **order intake API** — the target under test. Validates customers, SKUs, stock, prices, and totals. | `make api` (uvicorn on port 8000) |
| `server.py` | The **MCP server** — three tools that preview and send synthetic orders at that API. | stdio subprocess, launched by the MCP client |

The server exposes three tools and one resource:

| Tool | Read/write | What it does |
| --- | --- | --- |
| `check_target` | read-only | Reports whether the configured order API is reachable, and its SKU/customer counts. |
| `preview_order` | read-only | Generates one order and returns it **without sending it**. |
| `send_orders` | writes | Generates `count` orders and POSTs each to the order API; summarizes by status code. |

Resource `synthetic://catalog` lists the sellable catalog rows the seeded
generator draws from.

### The two generator modes

- **`seeded`** samples the real catalog and customer list, so orders validate and
  come back `201`. Use it for traffic that should be accepted.
- **`simple`** invents every field from random primitives, so the API rejects it
  with `422`. Use it to drill the rejection path.

### Guardrails worth knowing before you test

These are the point of the design, and they're what the manual tests below poke at:

- **The target URL is not a tool parameter.** It comes from `ORDER_API_URL` in the
  server's environment, so no prompt can aim the generator at an arbitrary host.
- **`count` is bounded by `MAX_BATCH`** (default 100). Exceeding it is a readable
  error, not a silent clamp.
- **Every batch reports the seed it used**, so any run can be replayed exactly by
  passing that seed back in.

---

## Requirements (macOS)

- **[uv](https://docs.astral.sh/uv/)** — `brew install uv`. It manages the Python
  toolchain and the virtualenv; you don't need to install Python 3.12 yourself.
- **Python 3.12** — pinned in `.python-version`; `uv sync` fetches it if missing.
- **Claude Code** — only for the "drive it from Claude Code" section.

```sh
git clone <this repo>
cd mcp-synthetic-server
make install          # uv sync — creates .venv and installs everything
```

`make help` lists every target with the variables it honors.

---

## Quick start

Two terminals. The order API has to be up before anything can send traffic to it.

**Terminal 1 — the target API:**

```sh
make api
```

Serves on `http://127.0.0.1:8000` with `--reload`. Confirm it's alive:

```sh
curl -s http://127.0.0.1:8000/health
# {"status":"ok","skus":8,"customers":5}
```

Interactive API docs are at <http://127.0.0.1:8000/docs>.

**Terminal 2 — drive the MCP server:**

```sh
make demo             # in-process client: lists tools, previews, sends a batch
```

Expected output:

```
tools:
  check_target   read-only  Check the order API
  preview_order  read-only  Preview a synthetic order
  send_orders    writes     Send synthetic orders

target: http://127.0.0.1:8000 reachable=True skus=8

preview (seed 1337): {"request_id": "643cb56d-...", "customer_id": "CUST-0005", ...}

sent 25 seeded orders (seed 1337): {'201': 25} accepted_total_cents=715350

sent 3 simple orders: {'422': 3}
  422 ['unknown customer C-5093', 'unknown sku SKU-9757', ...]
```

---

## Running the test suite

```sh
make test             # uv run pytest -q  →  10 passed
```

The tests need **no running API**: they monkeypatch `server.make_client` to a
FastAPI `TestClient` that drives the ASGI app in-process. They assert the control
surface, not just that the tools run — the annotations each tool advertises, that
no tool accepts a URL or host parameter, seed round-tripping, the batch cap, and
the unreachable-API error path.

You'll see one `StarletteDeprecationWarning` about `httpx`; it's upstream, not
your setup.

### The stdio smoke test

`make demo` imports the server object directly, which skips process launch,
transport framing, and JSON round-tripping. The smoke test spawns
`python server.py` as a real subprocess and talks to it over stdio, so a pass here
means **the exact command an MCP client is configured with actually works**:

```sh
make smoke            # needs `make api` running
```

```
tools: ['check_target', 'preview_order', 'send_orders']
target: http://127.0.0.1:8000 reachable=True
data type: Root
structured: {"mode": "seeded", "seed": 1337, "requested": 5, "accepted": 5, "rejected": 0,
             "status_counts": {"201": 5}, "accepted_total_cents": 73550, "sample_failures": []}
OK
```

It exits non-zero if the order API isn't up, so it's safe to wire into CI behind a
started API.

---

## Driving it from Claude Code

### 1. Generate `.mcp.json`

The MCP config needs this project's absolute path. Don't hand-edit it — render it:

```sh
make config           # sed's $(CURDIR) into .mcp.json.example → .mcp.json
```

That writes:

```json
{
  "mcpServers": {
    "synthetic-orders": {
      "command": "uv",
      "args": ["run", "--directory", "/absolute/path/to/mcp-synthetic-server", "python", "server.py"],
      "env": {
        "ORDER_API_URL": "http://127.0.0.1:8000",
        "MAX_BATCH": "100"
      }
    }
  }
}
```

`.mcp.json` is gitignored precisely because that path is machine-specific — the
checked-in `.mcp.json.example` is the template.

### 2. Start the API, then start Claude Code

```sh
make api              # terminal 1, leave it running
claude                # terminal 2, from the project root
```

Claude Code reads `.mcp.json` at startup and will ask you to approve the
project-scoped server the first time. Verify it connected:

```
/mcp
```

You should see `synthetic-orders` as **connected**, with three tools. If you
started Claude Code before running `make config`, restart it — the config is read
at launch.

> **If you change `server.py`, restart Claude Code.** The server is a subprocess
> spawned at connect time; edits don't hot-reload the way `make api` does.

### 3. Ask for the tools in plain language

Claude Code exposes them as `mcp__synthetic-orders__<tool>`. Prompts that work:

| Prompt | Tool it triggers |
| --- | --- |
| "Check whether the order API is reachable." | `check_target` |
| "Preview one synthetic order with seed 1337." | `preview_order` |
| "Send 25 synthetic orders." | `send_orders` |
| "Send 5 orders in simple mode and show me why they failed." | `send_orders` (rejection path) |
| "Replay that batch using the seed you got back." | `send_orders` with `seed` |
| "Read the synthetic://catalog resource." | resource read |

Because `send_orders` is annotated `readOnlyHint: false`, Claude Code prompts for
permission before the first send — `preview_order` and `check_target` are marked
read-only and idempotent, so they're cheap to approve.

---

## Manual testing: triggering each tool

Everything below was run against a fresh `make api`. Seeds are fixed so you can
compare output byte-for-byte.

### `check_target` — is the target up?

**In Claude Code:** "Check the order API."

```json
{"api_url":"http://127.0.0.1:8000","reachable":true,"skus":8,"customers":5}
```

**Negative case — stop `make api` (Ctrl-C) and ask again.** It should *not* raise;
it reports the failure as data:

```json
{"api_url":"http://127.0.0.1:8000","reachable":false,"skus":null,"customers":null}
```

### `preview_order` — generate without sending

**In Claude Code:** "Preview a synthetic order with seed 1337."

```json
{
  "mode": "seeded", "seed": 1337,
  "order": {
    "request_id": "643cb56d-4ec1-4fc6-bee2-9f53ebf644bb",
    "customer_id": "CUST-0005",
    "channel": "partner",
    "currency": "USD",
    "lines": [
      {"sku": "SKU-6300", "quantity": 3, "unit_price_cents": 2450},
      {"sku": "SKU-5510", "quantity": 4, "unit_price_cents": 1850},
      {"sku": "SKU-6301", "quantity": 3, "unit_price_cents": 3900}
    ],
    "total_cents": 26450
  },
  "line_count": 3, "total_cents": 26450
}
```

Two things to check by hand: the same seed always yields that exact payload, and
the API's `/health` counts don't move — preview never leaves the process.

### `send_orders` (seeded) — the accept path

**In Claude Code:** "Send 5 synthetic orders with seed 1337."

```json
{"mode":"seeded","seed":1337,"requested":5,"accepted":5,"rejected":0,
 "status_counts":{"201":5},"accepted_total_cents":73550,"sample_failures":[]}
```

Watch the `make api` terminal — five `POST /orders 201 Created` lines appear.

### `send_orders` (simple) — the reject path

**In Claude Code:** "Send 3 orders in simple mode with seed 1337."

```json
{"mode":"simple","seed":1337,"requested":3,"accepted":0,"rejected":3,
 "status_counts":{"422":3},"accepted_total_cents":0,
 "sample_failures":[
   "422 ['unknown customer C-5093', 'unknown sku SKU-9757', 'unknown sku SKU-6393', 'unknown sku SKU-1830']",
   "422 ['unknown customer C-8975', 'unknown sku SKU-8549']",
   "422 ['unknown customer C-5035', 'unknown sku SKU-5650', 'unknown sku SKU-6612', 'unknown sku SKU-0935']"
 ]}
```

`sample_failures` is capped at 5 entries, so a 100-order failure storm still
returns a readable result.

### Replay by seed

Send a batch **without** a seed, note the `seed` in the response, then ask Claude
Code to send the same count with that seed. `accepted_total_cents` must match
exactly. This is the property `test_omitted_seed_is_reported_back` covers.

### The `MAX_BATCH` guardrail

**In Claude Code:** "Send 500 synthetic orders."

```
Error: count 500 exceeds the server's MAX_BATCH of 100; send smaller batches or
raise MAX_BATCH in the server environment
```

`count: 0` fails the same readable way (`count must be at least 1, got 0`). To
verify the bound is really server-side, raise it in `.mcp.json`'s `env` block and
restart Claude Code — no prompt can change it.

### The target-selection guardrail

Ask Claude Code to *"send orders to https://example.com instead."* It can't —
there is no URL or host parameter on any tool. Confirm from the schemas:

```sh
uv run python -c "
import json, asyncio
from fastmcp import Client
from server import mcp
async def main():
    async with Client(mcp) as c:
        for t in await c.list_tools():
            print(t.name, list((t.inputSchema or {}).get('properties', {})))
asyncio.run(main())"
```

```
check_target []
preview_order ['mode', 'seed']
send_orders ['count', 'mode', 'seed']
```

Retarget by editing `ORDER_API_URL` in `.mcp.json` and restarting Claude Code.

---

## Manual testing without an MCP client

`make send` calls the tool function directly — no client, no transport. Useful
when you're changing generator logic and don't want to restart Claude Code:

```sh
make send COUNT=3 SEED=42
```

```json
{
  "mode": "seeded", "seed": 42, "requested": 3, "accepted": 3, "rejected": 0,
  "status_counts": {"201": 3},
  "accepted_total_cents": 42800, "sample_failures": []
}
```

`COUNT` and `SEED` are Makefile variables (defaults `25` / `1337`). Note this path
bypasses MCP entirely, so it will *not* catch schema or transport problems — use
`make smoke` for those.

To poke the target API directly, skipping the MCP server too:

```sh
curl -s -X POST http://127.0.0.1:8000/orders \
  -H 'content-type: application/json' \
  -d '{"request_id":"manual-0001","customer_id":"CUST-0005","channel":"partner",
       "currency":"USD","lines":[{"sku":"SKU-5510","quantity":2,"unit_price_cents":1850}],
       "total_cents":3700}'
# {"order_id":"ORD-manual-0","customer_id":"CUST-0005","lines":1,"total_cents":3700}
```

Change `total_cents` to `9999` and it returns `422` with
`total mismatch: sent 9999, expected 3700`.

You can also run the MCP server by hand and type JSON-RPC at it:

```sh
make serve            # stdio; useful only to confirm it starts and stays up
```

---

## Configuration

Read from the server's environment (via `.mcp.json`'s `env` block, or exported
before `make serve`) — never from a tool argument:

| Variable | Default | Meaning |
| --- | --- | --- |
| `ORDER_API_URL` | `http://127.0.0.1:8000` | Where batches are POSTed. |
| `MAX_BATCH` | `100` | Hard cap on `count`. Must be ≥ 1. |
| `REQUEST_TIMEOUT_S` | `10` | Per-request HTTP timeout. |

Seed data lives in `data/catalog.json` (8 SKUs, one deliberately out of stock) and
`data/customers.json` (5 customers, each with allowed channels). Edit those to
change what "valid" means — both the API and the seeded generator read them, so
they stay in agreement.

---

## Troubleshooting

| Symptom | Cause / fix |
| --- | --- |
| `/mcp` shows the server failed to connect | `.mcp.json` has a stale absolute path. Re-run `make config`, restart Claude Code. |
| `reachable: false` | `make api` isn't running, or `ORDER_API_URL` points elsewhere. `curl` the `/health` endpoint. |
| `cannot reach the order API at …` on send | Same cause, surfaced as a tool error because a send can't degrade gracefully. |
| Tool edits don't take effect | The MCP server is a subprocess spawned at connect time. Restart Claude Code (the API's `--reload` does not apply to it). |
| `Address already in use` on `make api` | Something already holds port 8000: `lsof -ti:8000 \| xargs kill`. |
| `uv: command not found` | `brew install uv`. |
| Everything passes but Claude Code sees no tools | You launched `claude` from outside the project root, so `.mcp.json` wasn't found. |

---

## Project layout

```
api/main.py             order intake API — the target under test
server.py               the MCP server: 3 tools + 1 resource
synth/config.py         env-derived settings (target URL, MAX_BATCH, timeout)
synth/seed.py           loads data/*.json
synth/simple.py         generator: random primitives → rejected orders
synth/seeded.py         generator: samples the catalog → accepted orders
client.py               in-process demo client (make demo)
scripts/smoke_stdio.py  real stdio subprocess smoke test (make smoke)
tests/test_server.py    control-surface tests (make test)
data/                   catalog + customer seed data
```

---

## Reference

This project accompanies
**[Trigger Synthetic Orders from an MCP Server on macOS](https://scriptable.com/posts/python/synthetic-orders-mcp-server-macos/)**,
which walks through the design: wrapping the generators in an MCP server, and the
guardrails that keep a model from choosing where the traffic goes or how much of
it there is.

The generators in `synth/` come from the previous article in the series,
**[Generate Synthetic JSON Requests to Test an API on macOS](https://scriptable.com/posts/python/synthetic-json-requests-test-api-macos/)**,
which builds them as a plain CLI — read that first if you want the payload
generation explained before the MCP control surface wrapped around it.

TDQS

A4.4/5.0

Scored across 3 tools

Disambiguation5/5

Each tool has a clearly distinct purpose: check_target verifies API connectivity, preview_order generates a single order without sending, and send_orders generates and sends multiple orders. There is no overlap or ambiguity between them.

Naming Consistency5/5

All tool names follow a consistent verb_noun pattern (check_target, preview_order, send_orders). The naming is predictable and uniform.

Tool Count5/5

With 3 tools, the server is well-scoped for its purpose of generating and sending synthetic orders. Each tool serves a distinct function without redundancy or unnecessary bloat.

Completeness5/5

The tool set covers the essential workflow: checking the target, previewing an order, and sending orders. The ability to replay seeds in send_orders addresses the need for reproducibility, leaving no obvious gaps in the stated domain.

Maintenance

ActivitySlowing
ResponsivenessNo issues