Beyond GET: MCP send_http_request for Write Methods
Teach an AI agent to send POST, PUT, PATCH, and DELETE through an MCP send_http_request tool, with idempotency keys, guardrails, and retry logic.
On this page
Why GET-Only Agents Break on Real APIs: The Write-Method Gap in MCP
Watch an agent demo that fails in the last ten seconds. It looks up the customer, confirms the SKU, quotes shipping. Then the user says “place the order,” and the agent produces a paragraph describing what it would do if it could. No order exists. Nobody was billed, which is the only good news.
Most MCP servers ship read-only, and it’s a rational choice: writes spend money, mutate state, and delete things. But the moment the agent needs to create a ticket, update a record, or cancel a job, the server has nothing to offer, and the human ends up copy-pasting the agent’s narration into a terminal. The escape hatch is a generic send_http_request tool — one tool that takes a method, a URL, headers, and a JSON body. The model already speaks REST; it has read more API documentation than any of us. The real work is in the schema, the guardrails, and the retry policy around the tool.
The verb semantics fit in one table:
| Method | REST operation | Example | Success status |
|---|---|---|---|
| POST | Create; server assigns the ID | POST https://store.example.com/orders | 201 Created + Location header |
| PUT | Replace the resource in full | PUT https://store.example.com/orders/123 | 200 OK |
| PATCH | Partial update | PATCH https://store.example.com/orders/123 | 200 OK |
| DELETE | Remove | DELETE https://store.example.com/orders/123 | 204 No Content |
The column that will cause all your incidents is missing from that table: replay behavior. GET is safe to repeat. PUT and DELETE are idempotent by spec — replay them and the end state doesn’t change. POST is neither safe nor idempotent, and PATCH is whatever the patch document makes it. That asymmetry drives everything below: idempotency keys, guardrails, and a retry policy that knows which failures deserve a second attempt.
Declaring send_http_request in Your MCP Server: Method, Headers, and Body Schema
The schema is the contract, so constrain it hard. Pin method to an enum — otherwise you will eventually receive "post " with a trailing space, or a creative FETCH. Headers must be a flat string map, because Idempotency-Key is a string. And body should be an object or null, never a string containing JSON; models hand you malformed JSON strings when given the option, so make them hand you structure instead.
{
"name": "send_http_request",
"description": "Send an HTTP request to the store API. POST creates a resource (always send an Idempotency-Key header). PUT replaces the ENTIRE resource — include every field, or use PATCH instead. PATCH sends only the fields that changed. DELETE removes the resource and requires prior human approval.",
"inputSchema": {
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "PATCH", "DELETE"]
},
"url": { "type": "string", "format": "uri" },
"headers": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"body": { "type": ["object", "null"] }
},
"required": ["method", "url"]
}
}
That description is doing real work. It is the only place where semantics warnings live where the model will actually read them at call time — not buried in a system prompt it has half-forgotten.
The handler is a thin forwarder with three opinions baked in:
import json
import httpx
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("store-agent")
@mcp.tool()
async def send_http_request(
method: str,
url: str,
headers: dict[str, str] | None = None,
body: dict | None = None,
) -> dict:
check_guardrails(method, url) # defined in the guardrails section
async with httpx.AsyncClient(timeout=30, follow_redirects=False) as client:
resp = await client.request(method, url, headers=headers, json=body)
raw = resp.text
return {
"status": resp.status_code,
"location": resp.headers.get("location"),
"retry_after": resp.headers.get("retry-after"),
"body": raw[:8000],
"truncated": len(raw) > 8000,
}
The three opinions: follow_redirects=False, because a redirect can bounce a request off your allowlist; an 8 KB response cap, because a two-megabyte payload in context wrecks the model’s attention and your token bill; and explicit location / retry_after fields, because models skim raw header maps and miss exactly the two headers that matter.
Teaching the Agent POST and PUT: Creating and Replacing Resources at store.example.com
Start with a plain instruction: “Create an order for customer 8841 — two TEE-BLK-M, standard shipping.” The agent’s call:
{
"method": "POST",
"url": "https://store.example.com/orders",
"headers": {
"Authorization": "Bearer st_orders_rw",
"Idempotency-Key": "b1e4b7c2-35f0-4d68-9a17-6f2c88d9e001"
},
"body": {
"customer_id": 8841,
"items": [{ "sku": "TEE-BLK-M", "quantity": 2 }],
"shipping_speed": "standard"
}
}
The result:
{
"status": 201,
"location": "/orders/61299",
"body": { "id": 61299, "status": "pending", "total_cents": 5980 }
}
Now the upgrade, expressed as a full replacement: “Make it three shirts at express speed.”
{
"method": "PUT",
"url": "https://store.example.com/orders/61299",
"headers": { "Authorization": "Bearer st_orders_rw" },
"body": {
"customer_id": 8841,
"items": [{ "sku": "TEE-BLK-M", "quantity": 3 }],
"shipping_speed": "express",
"shipping_address": {
"line1": "742 Evergreen Terrace",
"city": "Springfield",
"zip": "49007"
},
"gift_note": null
}
}
That returns 200 OK. Note what the PUT body had to carry: every field, including the ones that didn’t change. The comparison, in short:
- POST points at the collection (
/orders). The server picks the ID, reports it in the body, and names the new URL in theLocationheader. Success is201. Replaying it without a key creates a second, real order. - PUT points at one resource (
/orders/61299). You must already know the URL, and you must send the complete representation. Success is200or204. Replaying it is harmless by spec — the end state is identical.
PATCH Without Footguns: Partial Updates That Don’t Wipe Fields
Here is the stored order the agent is working against:
{
"id": 61299,
"status": "pending",
"customer_id": 8841,
"items": [{ "sku": "TEE-BLK-M", "quantity": 2 }],
"shipping_address": { "line1": "742 Evergreen Terrace", "city": "Springfield", "zip": "49007" },
"gift_note": "Happy birthday"
}
The user wants quantity 2 → 3. The correct call:
{
"method": "PATCH",
"url": "https://store.example.com/orders/61299",
"body": { "items": [{ "sku": "TEE-BLK-M", "quantity": 3 }] }
}
After the PATCH:
- "items": [{ "sku": "TEE-BLK-M", "quantity": 2 }],
+ "items": [{ "sku": "TEE-BLK-M", "quantity": 3 }],
...every other field unchanged...
Now the footgun. The agent’s context holds a fragment of the order, so it sends the same two-field body with "method": "PUT". The server does its job faithfully — PUT stores exactly the representation it received:
{
"id": 61299,
- "status": "pending",
- "customer_id": 8841,
"items": [{ "sku": "TEE-BLK-M", "quantity": 3 }],
- "shipping_address": { "line1": "742 Evergreen Terrace", "city": "Springfield", "zip": "49007" },
- "gift_note": "Happy birthday"
}
Gift note: gone. Address: gone. If the API validates required fields you get a 422 and a bruised error budget. If it doesn’t, you get silent data loss, which is worse — nobody files a ticket until the package ships to nowhere.
My policy: deny PUT at the guardrail for any host where PATCH exists. The blast radius of a merge-patch document is one field list; the blast radius of a PUT assembled from conversation fragments is the whole resource. If PUT is genuinely required, force read-modify-write in the handler — GET the current representation, merge the change locally, send the full object. Never trust a PUT body built purely from memory.
One more trap: patch media types are not interchangeable. Some APIs want JSON merge patch (application/json, the shape above); others want JSON Patch (application/json-patch+json, an operations array like {"op": "replace", "path": "/items/0/quantity", "value": 3}). Agents guess, and they guess wrong about half the time. State the expected format in the tool description.
Idempotency Keys: Making Retried POSTs Safe with the Idempotency-Key Header
The scenario that justifies this entire section: POST /payments times out at the network layer. The client never read a response. The server, meanwhile, processed the request just fine. A retry now produces a second charge, and the customer’s statement shows two identical line items for one order.
The Idempotency-Key header fixes this. The client generates a UUID once per logical operation; the server stores the first response keyed on it and replays that stored response for any subsequent request bearing the same key.
{
"method": "POST",
"url": "https://store.example.com/payments",
"headers": {
"Authorization": "Bearer st_payments_rw",
"Idempotency-Key": "7d2b0f4e-1c9a-4a3b-b8f7-2e6d5c4a1b0f"
},
"body": { "order_id": 61299, "amount_cents": 8970, "currency": "USD" }
}
The sequence when the network misbehaves:
1) POST /payments, key 7d2b0f4e… → connection reset while reading the response
(server-side reality: payment pm_881 was created and captured)
2) POST /payments, key 7d2b0f4e… → identical retry, same key, same body
201 Created
{ "id": "pm_881", "amount_cents": 8970, "replayed": true }
One charge. The second 201 is the stored first response, not a new payment.
The rules that keep this working:
- One key per logical operation, generated when the intent forms — not per HTTP attempt. Regenerating the key inside a retry loop is the classic self-inflicted bug; it deduplicates nothing.
- New operation, new key. Reusing last Tuesday’s key for today’s identical order gets you last Tuesday’s response, and no new order.
- Same key with a different body should earn a
422from a well-behaved server. That fingerprint mismatch is a bug detector, not noise. - Keys expire — commonly after 24 hours. Past the window, replays can duplicate.
Generate the key in the tool layer, never in the model. Ask a model for a key on each attempt and it will invent a fresh one every time, sometimes “helpfully” varying it.
Guardrails Before the Wire: URL Allowlists, Method Restrictions, and Dry-Run Confirmation
Anything enforced only in the system prompt is a suggestion. Prompt injection, an off-day from the model, or a misparsed URL will eventually route a DELETE somewhere you didn’t intend. Enforce in the handler, before any socket opens.
http_guardrails:
allowed_hosts: # exact match only, no suffix matching
- store.example.com
- example.com
deny_paths:
- "^/(admin|internal)/"
deny_methods_by_host:
- host: store.example.com
methods: [PUT] # PATCH covers updates here
require_confirmation:
methods: [DELETE]
max_body_bytes: 16384
dry_run: false # true during rollout: log, return synthetic 201
What enforcement looks like in the log:
[12:03:41] BLOCKED DELETE https://api.untrusted.example.net/v1/users/9
host not in allowlist
[12:04:07] BLOCKED PUT https://store.example.com/admin/settings
path matches ^/(admin|internal)/
[12:04:52] HELD DELETE https://store.example.com/orders/61299
destructive method — awaiting human confirmation
[12:05:10] ALLOWED PATCH https://store.example.com/orders/61299 (body 38 bytes)
Three details worth the extra code. First, exact host matching: notstore.example.com and store.example.com.evil.example both fail an exact comparison, which is the point — never implement allowlists with substring checks. Second, redirects: you disabled auto-follow in the handler for exactly this reason; if you ever re-enable it, re-run the allowlist check on every hop. Third, dry-run mode is the cheapest behavioral test you will ever run — return a synthetic 201, watch what the agent sends across a few dozen tasks, and only then hand it a live token.
Scope that token narrowly, too. Write methods and credentials interact in ways that deserve their own treatment; MCP tools: auth scopes and failure modes covers the ugly parts.
Retry Logic That Respects the Server: Backoff, Retry-After, and When Not to Retry
Every failure asks two questions: is the status retryable, and is the request replayable? Both answers must be yes before a retry goes out.
| Status | What it means | Retry? |
|---|---|---|
| 409 | Conflict — your write clashes with server state | No. Re-GET, resolve, recompute |
| 422 | Body rejected — the request is semantically wrong | No. Fix the payload |
| 429 | Rate limited | Yes, after Retry-After |
| 500 | Server-side error | No, by default (see below) |
| 503 | Overloaded or restarting | Yes, with backoff; honor Retry-After |
On 500: plenty of teams retry them automatically. I don’t, not from an agent. A 500 can be a deterministic crash on your exact payload; retrying hammers the endpoint and, if the crash happened after a side effect committed, duplicates it. Make it an alert, not a loop.
import asyncio
import httpx
RETRYABLE_STATUS = {429, 503, 504}
REPLAYABLE_METHOD = {"GET", "PUT", "DELETE"}
async def request_with_retry(send, method: str,
idempotency_key: str | None,
max_retries: int = 3):
for attempt in range(max_retries + 1):
status, headers = None, {}
try:
status, headers, body = await send()
except (httpx.TimeoutException, httpx.TransportError):
body = None # network-level failure; fall through to replay check
replayable = method in REPLAYABLE_METHOD or idempotency_key is not None
retryable = (status is None or status in RETRYABLE_STATUS) and replayable
if not retryable or attempt == max_retries:
return status, headers, body
wait = 2 ** attempt # 1s, 2s, 4s
ra = headers.get("retry-after", "")
if ra.isdigit():
wait = max(wait, int(ra)) # the server's hint wins ties
await asyncio.sleep(wait)
A timing log from a test run against store.example.com — a PUT, which is replayable by spec and needs no key:
12:03:41.204 PUT /orders/61299 attempt 1 → timeout after 10.0s
12:03:52.311 PUT /orders/61299 attempt 2 (waited 1.0s) → 503
12:03:54.929 PUT /orders/61299 attempt 3 (waited 2.0s) → 503, Retry-After: 5
12:04:00.512 PUT /orders/61299 attempt 4 (waited 5.0s — Retry-After beat the 4.0s backoff) → 200 OK
Three retries, then success. The whole loop took about nineteen seconds, and the server never saw more than one request per five-second window.
Keep retries in the tool, out of the prompt. An agent told to “try again” resends immediately with zero backoff, and sometimes “fixes” the body between attempts — converting a transient failure into a semantic one. On final failure, hand the model a structured result: status, safe headers, truncated body. Stack traces belong in your logs, not in its context.
Putting It Together: A Full Create-Update-Delete Agent Session Against store.example.com
One user turn: “Create an order for customer 8841, two TEE-BLK-M. Then change it to three. Then cancel it.”
Step 1 — create. The agent POSTs with a fresh idempotency key:
{
"method": "POST",
"url": "https://store.example.com/orders",
"headers": { "Idempotency-Key": "c4a9e2f1-77b3-4d5e-8a01-9f6b2d3c4e5a" },
"body": { "customer_id": 8841, "items": [{ "sku": "TEE-BLK-M", "quantity": 2 }] }
}
The server answers 201, Location: /orders/61299. The agent records the ID from the body and the location header.
Step 2 — partial update. A PATCH carries only the change:
{
"method": "PATCH",
"url": "https://store.example.com/orders/61299",
"body": { "items": [{ "sku": "TEE-BLK-M", "quantity": 3 }] }
}
200 OK, and the returned resource shows quantity 3 with the gift note and address intact.
Step 3 — the guardrail holds the DELETE:
[12:07:19] HELD DELETE /orders/61299 — awaiting human confirmation
A human approves. Only then:
{ "method": "DELETE", "url": "https://store.example.com/orders/61299" }
204 No Content, body null. The agent reports the cancellation and quotes the order ID. Four calls, three writes, one human at the only irreversible step.
Before you call this production-ready, run the checklist:
- Append-only log for every write: method, URL, idempotency key, status, latency. If you can’t answer “what did the agent change yesterday,” you’re not ready.
- Human confirmation for DELETE everywhere, staging included. Muscle memory matters more than you’d expect.
- Alert on retry storms. A workable threshold: page someone at more than five retries across all calls in a rolling minute, or when a single operation hits max retries twice in a row.
- Scoped, rotated credentials. The agent gets the orders token, never the platform token.
- Weekly reconciliation between your write log and the API’s own audit surface, if it exposes one.
Gotchas worth taping to your monitor: a 204 has no body — don’t parse it, and make sure the handler doesn’t try. Idempotency keys born inside retry loops deduplicate nothing. Assume POST retries are your problem until the API docs say otherwise. Match the patch media type the API expects; merge patch and JSON Patch are not interchangeable. And redirects can leave your allowlist — you disabled auto-follow for a reason.
Next steps: point the agent at a real, documented API where writes are cheap to undo. Async job APIs make ideal sandboxes — POST creates a job, GET polls it, DELETE cancels it, full verb coverage with almost no blast radius. The FineData async scrape endpoints work well for this; the agent can submit a job like the one below, wait on a webhook, and cancel when the user changes their mind:
{
"method": "POST",
"url": "https://api.finedata.ai/api/v1/async/scrape",
"headers": { "Authorization": "Bearer fd_your_api_key" },
"body": {
"url": "https://store.example.com/products/tee-blk-m",
"formats": ["markdown"],
"callback_url": "https://ops.example.com/hooks/scrape-done"
}
}
For job lifecycle patterns — batching, webhooks, cancellation — read Async Scraping at Scale: Jobs, Batches, Webhooks.
The verbs are the easy part. Shipping an agent that can write without making you regret it is about the boring machinery: keys, allowlists, backoff, and a human checkpoint on the delete key.
Related Articles
Slow Scrape, No Timeout: MCP Progress and Cancellation
A JS-rendered page can outlast your agent's tool call timeout. Add MCP progress notifications, handle cancellation, and stop paying for abandoned scrapes.
TechnicalThrottle MCP Tool Calls: A Token Bucket for Agents
AI agents can fire MCP scraping tools in bursts that burn quota and trip rate limits. Build a token-bucket throttle that paces tool calls without stalls.
TechnicalTest Your MCP Scraping Server Before an Agent Uses It
Run your MCP scraping tools through a local test harness: verify tool discovery, argument handling, and response shape before a live agent calls them.