Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions idempotency-demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# MCP Idempotency Demo

A minimal example showing how `ctx.idempotency_key` on the server side, combined
with `max_timeout_retries` on the client side, prevents duplicate side-effects when
a tool call is retried after a timeout.

## What it demonstrates

`Client.call_tool` automatically generates a UUID idempotency key and attaches it
to every `tools/call` request. When `max_timeout_retries` is set and a call times
out, the SDK retries with the **same** key — no key management needed in user code.

On the server side, `ctx.idempotency_key` exposes the key to any `@server.tool()`
handler that accepts a `Context` parameter. The `make_payment` tool stores processed
keys and returns `"already_processed"` when it sees a key it has handled before,
ensuring the account is only debited once.

```text
Client Server
| |
|-- make_payment (key=abc, 2s) ---->|
| |-- debit account
| |-- store key=abc
| |-- sleep 5s ...
|<-- timeout (2s elapsed) ----------|
| |
|-- make_payment (key=abc, retry) ->| ← same key
| |-- key=abc already seen → skip debit
|<-- {"status":"already_processed"}-|
```

## Setup

```bash
cd idempotency-demo
uv sync
```

## Running

In one terminal, start the server:

```bash
uv run server.py
```

In another terminal, run the client:

```bash
uv run client.py
```

## Expected output

**Server terminal:**

```text
[server] Payment processed (key=<uuid>, call=0)
[server] Sleeping 5 s to trigger client timeout...
[server] Duplicate payment detected (key=<uuid>) — returning cached result
```

**Client terminal:**

```text
Initial balance:
{
"balanceMinorUnits": 10000
}

Calling make_payment (2 s timeout, 1 retry)...
The server will process the payment then sleep 5 s.
The SDK will time out, then retry with the same idempotency key.

make_payment result (after retry):
{
"status": "already_processed",
"message": "Payment already applied. Returning cached result."
}

Final balance:
{
"balanceMinorUnits": 7500
}

Final transactions:
{
"transactions": [
{
"IBAN": "DE89370400440532013000",
"BIC": "COBADEFFXXX",
"amountMinorUnits": 2500,
"currency": "EUR"
}
]
}
```

The final balance shows a single 25.00 EUR debit (10000 → 7500 minor units) and a
single transaction, even though the tool was called twice. Without idempotency the
account would be debited twice.
80 changes: 80 additions & 0 deletions idempotency-demo/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Idempotency demo client.

Demonstrates how the MCP SDK's built-in retry mechanism (max_timeout_retries)
combined with automatic idempotency keys protects against double-charging.

Flow:
1. Get initial balance.
2. Call make_payment with a 2 s timeout and max_timeout_retries=1.
- The server processes the payment then sleeps 5 s, causing the client to
time out. The SDK retries automatically with the *same* idempotency key.
- The server detects the duplicate and returns "already_processed".
3. Get final balance and transactions — only a single debit is recorded.

Run with:
uv run client.py
"""

from __future__ import annotations

import asyncio
import json

from mcp.client.client import Client

SERVER_URL = "http://127.0.0.1:8000/mcp"
ACCOUNT_UID = "b4d8ada9-74a1-4c64-9ba3-a1af8c8307eb"


def _print_result(label: str, text: str) -> None:
try:
parsed = json.loads(text)
formatted = json.dumps(parsed, indent=2)
except (json.JSONDecodeError, TypeError):
formatted = text
print(f"\n{label}:\n{formatted}")


async def main() -> None:
async with Client(SERVER_URL) as client:
# 1. Initial balance.
result = await client.call_tool("get_balance", {"account_uid": ACCOUNT_UID})
_print_result("Initial balance", result.content[0].text) # type: ignore[union-attr]

print("\nCalling make_payment (2 s timeout, 1 retry)...")
print("The server will process the payment then sleep 5 s.")
print("The SDK will time out, then retry with the same idempotency key.\n")

async with Client(SERVER_URL) as client:
# 2. make_payment — SDK generates one idempotency key and reuses it on retry.
#
# First attempt: server processes payment, sleeps 5 s → client times out.
# Retry: server detects duplicate key → returns "already_processed".
#
# The caller does not need to manage the key; max_timeout_retries signals
# that the tool is safe to retry and the SDK handles deduplication.
result = await client.call_tool(
"make_payment",
{
"account_uid": ACCOUNT_UID,
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"amount_in_minor_units": 25_00,
"currency": "EUR",
},
read_timeout_seconds=2.0,
max_timeout_retries=1,
)
_print_result("make_payment result (after retry)", result.content[0].text) # type: ignore[union-attr]

async with Client(SERVER_URL) as client:
# 3. Final state — should show a single 25.00 EUR debit.
balance = await client.call_tool("get_balance", {"account_uid": ACCOUNT_UID})
_print_result("Final balance", balance.content[0].text) # type: ignore[union-attr]

transactions = await client.call_tool("get_transactions", {"account_uid": ACCOUNT_UID})
_print_result("Final transactions", transactions.content[0].text) # type: ignore[union-attr]


if __name__ == "__main__":
asyncio.run(main())
12 changes: 12 additions & 0 deletions idempotency-demo/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[project]
name = "mcp-idempotency-demo"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["mcp", "anyio", "uvicorn", "httpx"]

[tool.uv.sources]
mcp = { path = "..", editable = true }

[tool.ruff]
line-length = 120
target-version = "py310"
123 changes: 123 additions & 0 deletions idempotency-demo/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Idempotency demo server.

Demonstrates idempotent tool calls using MCPServer and ctx.idempotency_key.

Run with:
uv run server.py
"""

from __future__ import annotations

import anyio
import uvicorn

from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.exceptions import MCPError
from mcp.types import INVALID_PARAMS, ToolAnnotations

server = MCPServer("idempotency-demo")

# In-memory account store — reset on each server restart.
accounts: dict[str, dict[str, int | list[dict[str, str | int]]]] = {
"b4d8ada9-74a1-4c64-9ba3-a1af8c8307eb": {
"balance_minor_units": 100_00,
"transactions": [],
},
"1a57e024-09db-4402-801b-4f75b1a05a8d": {
"balance_minor_units": 200_00,
"transactions": [],
},
}

# Idempotency key store — tracks processed payment keys.
processed_keys: set[str] = set()

# Call counter — used to trigger a slow response on every other call.
num_calls: int = 0


@server.tool()
def get_balance(account_uid: str) -> str:
"""Return the current balance in minor units for the specified account."""
account = accounts.get(account_uid)
if account is None:
raise MCPError(INVALID_PARAMS, f"Account {account_uid} not found")
balance = account["balance_minor_units"]
return f'{{"balanceMinorUnits": {balance}}}'


@server.tool()
def get_transactions(account_uid: str) -> str:
"""Return the list of processed transactions for the specified account."""
import json

account = accounts.get(account_uid)
if account is None:
raise MCPError(INVALID_PARAMS, f"Account {account_uid} not found")
return json.dumps({"transactions": account["transactions"]}, indent=2)


@server.tool(annotations=ToolAnnotations(idempotentHint=True))
async def make_payment(
account_uid: str,
iban: str,
bic: str,
amount_in_minor_units: int,
currency: str,
ctx: Context,
) -> str:
"""Idempotent payment tool.

Uses ctx.idempotency_key to deduplicate retries. A retry carrying the same
key returns "already_processed" without charging the account again.

The first call deliberately sleeps for 5 seconds after processing, simulating
a slow response that causes the client to time out. When the client retries
with the same idempotency key the request is recognised as a duplicate and
returns immediately.
"""
global num_calls

key = ctx.idempotency_key
if not key:
raise MCPError(INVALID_PARAMS, "idempotency_key is required for make_payment")

# Duplicate request — return cached result without side effects.
if key in processed_keys:
print(f"[server] Duplicate payment detected (key={key}) — returning cached result")
return '{"status": "already_processed", "message": "Payment already applied. Returning cached result."}'

account = accounts.get(account_uid)
if account is None:
raise MCPError(INVALID_PARAMS, f"Account {account_uid} not found")

balance = int(account["balance_minor_units"])
if balance < amount_in_minor_units:
raise MCPError(INVALID_PARAMS, f"Insufficient funds: balance {balance} < {amount_in_minor_units}")

# Apply payment and record the idempotency key *before* sleeping so that a
# retry arriving while we are still sleeping gets the "already_processed" path.
account["balance_minor_units"] = balance - amount_in_minor_units
account["transactions"].append( # type: ignore[union-attr]
{"IBAN": iban, "BIC": bic, "amountMinorUnits": amount_in_minor_units, "currency": currency}
)
processed_keys.add(key)

call_number = num_calls
num_calls += 1

print(f"[server] Payment processed (key={key}, call={call_number})")

if call_number % 2 == 0:
# Simulate a slow response on even-numbered calls. The client times out
# after 2 s and retries with the same idempotency key; this sleep means
# the retry will always arrive after processing is committed.
print("[server] Sleeping 5 s to trigger client timeout...")
await anyio.sleep(5)

return '{"status": "processed", "message": "Payment applied."}'


if __name__ == "__main__":
app = server.streamable_http_app()
uvicorn.run(app, host="127.0.0.1", port=8000)
Loading
Loading