Synthetic Orders MCP Server
# 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
Scored across 3 tools
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.
All tool names follow a consistent verb_noun pattern (check_target, preview_order, send_orders). The naming is predictable and uniform.
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.
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.