Skip to content

Commit bdc7f27

Browse files
committed
wip: support tool retry on timeout via idempotencyKey param in CallToolRequestParams
1 parent 7ba4fb8 commit bdc7f27

File tree

11 files changed

+1698
-13
lines changed

11 files changed

+1698
-13
lines changed

idempotency-demo/README.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# MCP Idempotency Demo
2+
3+
A minimal example showing how `ctx.idempotency_key` on the server side, combined
4+
with `max_timeout_retries` on the client side, prevents duplicate side-effects when
5+
a tool call is retried after a timeout.
6+
7+
## What it demonstrates
8+
9+
`Client.call_tool` automatically generates a UUID idempotency key and attaches it
10+
to every `tools/call` request. When `max_timeout_retries` is set and a call times
11+
out, the SDK retries with the **same** key — no key management needed in user code.
12+
13+
On the server side, `ctx.idempotency_key` exposes the key to any `@server.tool()`
14+
handler that accepts a `Context` parameter. The `make_payment` tool stores processed
15+
keys and returns `"already_processed"` when it sees a key it has handled before,
16+
ensuring the account is only debited once.
17+
18+
```
19+
Client Server
20+
| |
21+
|-- make_payment (key=abc, 2s) ---->|
22+
| |-- debit account
23+
| |-- store key=abc
24+
| |-- sleep 5s ...
25+
|<-- timeout (2s elapsed) ----------|
26+
| |
27+
|-- make_payment (key=abc, retry) ->| ← same key
28+
| |-- key=abc already seen → skip debit
29+
|<-- {"status":"already_processed"}-|
30+
```
31+
32+
## Setup
33+
34+
```bash
35+
cd idempotency-demo
36+
uv sync
37+
```
38+
39+
## Running
40+
41+
In one terminal, start the server:
42+
43+
```bash
44+
uv run server.py
45+
```
46+
47+
In another terminal, run the client:
48+
49+
```bash
50+
uv run client.py
51+
```
52+
53+
## Expected output
54+
55+
**Server terminal:**
56+
57+
```
58+
[server] Payment processed (key=<uuid>, call=0)
59+
[server] Sleeping 5 s to trigger client timeout...
60+
[server] Duplicate payment detected (key=<uuid>) — returning cached result
61+
```
62+
63+
**Client terminal:**
64+
65+
```
66+
Initial balance:
67+
{
68+
"balanceMinorUnits": 10000
69+
}
70+
71+
Calling make_payment (2 s timeout, 1 retry)...
72+
The server will process the payment then sleep 5 s.
73+
The SDK will time out, then retry with the same idempotency key.
74+
75+
make_payment result (after retry):
76+
{
77+
"status": "already_processed",
78+
"message": "Payment already applied. Returning cached result."
79+
}
80+
81+
Final balance:
82+
{
83+
"balanceMinorUnits": 7500
84+
}
85+
86+
Final transactions:
87+
{
88+
"transactions": [
89+
{
90+
"IBAN": "DE89370400440532013000",
91+
"BIC": "COBADEFFXXX",
92+
"amountMinorUnits": 2500,
93+
"currency": "EUR"
94+
}
95+
]
96+
}
97+
```
98+
99+
The final balance shows a single 25.00 EUR debit (10000 → 7500 minor units) and a
100+
single transaction, even though the tool was called twice. Without idempotency the
101+
account would be debited twice.

idempotency-demo/client.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Idempotency demo client.
2+
3+
Demonstrates how the MCP SDK's built-in retry mechanism (max_timeout_retries)
4+
combined with automatic idempotency keys protects against double-charging.
5+
6+
Flow:
7+
1. Get initial balance.
8+
2. Call make_payment with a 2 s timeout and max_timeout_retries=1.
9+
- The server processes the payment then sleeps 5 s, causing the client to
10+
time out. The SDK retries automatically with the *same* idempotency key.
11+
- The server detects the duplicate and returns "already_processed".
12+
3. Get final balance and transactions — only a single debit is recorded.
13+
14+
Run with:
15+
uv run client.py
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import asyncio
21+
import json
22+
23+
from mcp.client.client import Client
24+
25+
SERVER_URL = "http://127.0.0.1:8000/mcp"
26+
ACCOUNT_UID = "b4d8ada9-74a1-4c64-9ba3-a1af8c8307eb"
27+
28+
29+
def _print_result(label: str, text: str) -> None:
30+
try:
31+
parsed = json.loads(text)
32+
formatted = json.dumps(parsed, indent=2)
33+
except (json.JSONDecodeError, TypeError):
34+
formatted = text
35+
print(f"\n{label}:\n{formatted}")
36+
37+
38+
async def main() -> None:
39+
async with Client(SERVER_URL) as client:
40+
# 1. Initial balance.
41+
result = await client.call_tool("get_balance", {"account_uid": ACCOUNT_UID})
42+
_print_result("Initial balance", result.content[0].text) # type: ignore[union-attr]
43+
44+
print("\nCalling make_payment (2 s timeout, 1 retry)...")
45+
print("The server will process the payment then sleep 5 s.")
46+
print("The SDK will time out, then retry with the same idempotency key.\n")
47+
48+
async with Client(SERVER_URL) as client:
49+
# 2. make_payment — SDK generates one idempotency key and reuses it on retry.
50+
#
51+
# First attempt: server processes payment, sleeps 5 s → client times out.
52+
# Retry: server detects duplicate key → returns "already_processed".
53+
#
54+
# The caller does not need to manage the key; max_timeout_retries signals
55+
# that the tool is safe to retry and the SDK handles deduplication.
56+
result = await client.call_tool(
57+
"make_payment",
58+
{
59+
"account_uid": ACCOUNT_UID,
60+
"iban": "DE89370400440532013000",
61+
"bic": "COBADEFFXXX",
62+
"amount_in_minor_units": 25_00,
63+
"currency": "EUR",
64+
},
65+
read_timeout_seconds=2.0,
66+
max_timeout_retries=1,
67+
)
68+
_print_result("make_payment result (after retry)", result.content[0].text) # type: ignore[union-attr]
69+
70+
async with Client(SERVER_URL) as client:
71+
# 3. Final state — should show a single 25.00 EUR debit.
72+
balance = await client.call_tool("get_balance", {"account_uid": ACCOUNT_UID})
73+
_print_result("Final balance", balance.content[0].text) # type: ignore[union-attr]
74+
75+
transactions = await client.call_tool("get_transactions", {"account_uid": ACCOUNT_UID})
76+
_print_result("Final transactions", transactions.content[0].text) # type: ignore[union-attr]
77+
78+
79+
if __name__ == "__main__":
80+
asyncio.run(main())

idempotency-demo/pyproject.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[project]
2+
name = "mcp-idempotency-demo"
3+
version = "0.1.0"
4+
requires-python = ">=3.10"
5+
dependencies = ["mcp", "anyio", "uvicorn", "httpx"]
6+
7+
[tool.uv.sources]
8+
mcp = { path = "..", editable = true }
9+
10+
[tool.ruff]
11+
line-length = 120
12+
target-version = "py310"

idempotency-demo/server.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Idempotency demo server.
2+
3+
Demonstrates idempotent tool calls using MCPServer and ctx.idempotency_key.
4+
5+
Run with:
6+
uv run server.py
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import anyio
12+
import uvicorn
13+
14+
from mcp.server.mcpserver import Context, MCPServer
15+
from mcp.shared.exceptions import MCPError
16+
from mcp.types import INVALID_PARAMS, ToolAnnotations
17+
18+
server = MCPServer("idempotency-demo")
19+
20+
# In-memory account store — reset on each server restart.
21+
accounts: dict[str, dict[str, int | list[dict[str, str | int]]]] = {
22+
"b4d8ada9-74a1-4c64-9ba3-a1af8c8307eb": {
23+
"balance_minor_units": 100_00,
24+
"transactions": [],
25+
},
26+
"1a57e024-09db-4402-801b-4f75b1a05a8d": {
27+
"balance_minor_units": 200_00,
28+
"transactions": [],
29+
},
30+
}
31+
32+
# Idempotency key store — tracks processed payment keys.
33+
processed_keys: set[str] = set()
34+
35+
# Call counter — used to trigger a slow response on every other call.
36+
num_calls: int = 0
37+
38+
39+
@server.tool()
40+
def get_balance(account_uid: str) -> str:
41+
"""Return the current balance in minor units for the specified account."""
42+
account = accounts.get(account_uid)
43+
if account is None:
44+
raise MCPError(INVALID_PARAMS, f"Account {account_uid} not found")
45+
balance = account["balance_minor_units"]
46+
return f'{{"balanceMinorUnits": {balance}}}'
47+
48+
49+
@server.tool()
50+
def get_transactions(account_uid: str) -> str:
51+
"""Return the list of processed transactions for the specified account."""
52+
import json
53+
54+
account = accounts.get(account_uid)
55+
if account is None:
56+
raise MCPError(INVALID_PARAMS, f"Account {account_uid} not found")
57+
return json.dumps({"transactions": account["transactions"]}, indent=2)
58+
59+
60+
@server.tool(annotations=ToolAnnotations(idempotentHint=True))
61+
async def make_payment(
62+
account_uid: str,
63+
iban: str,
64+
bic: str,
65+
amount_in_minor_units: int,
66+
currency: str,
67+
ctx: Context,
68+
) -> str:
69+
"""Idempotent payment tool.
70+
71+
Uses ctx.idempotency_key to deduplicate retries. A retry carrying the same
72+
key returns "already_processed" without charging the account again.
73+
74+
The first call deliberately sleeps for 5 seconds after processing, simulating
75+
a slow response that causes the client to time out. When the client retries
76+
with the same idempotency key the request is recognised as a duplicate and
77+
returns immediately.
78+
"""
79+
global num_calls
80+
81+
key = ctx.idempotency_key
82+
if not key:
83+
raise MCPError(INVALID_PARAMS, "idempotency_key is required for make_payment")
84+
85+
# Duplicate request — return cached result without side effects.
86+
if key in processed_keys:
87+
print(f"[server] Duplicate payment detected (key={key}) — returning cached result")
88+
return '{"status": "already_processed", "message": "Payment already applied. Returning cached result."}'
89+
90+
account = accounts.get(account_uid)
91+
if account is None:
92+
raise MCPError(INVALID_PARAMS, f"Account {account_uid} not found")
93+
94+
balance = int(account["balance_minor_units"])
95+
if balance < amount_in_minor_units:
96+
raise MCPError(INVALID_PARAMS, f"Insufficient funds: balance {balance} < {amount_in_minor_units}")
97+
98+
# Apply payment and record the idempotency key *before* sleeping so that a
99+
# retry arriving while we are still sleeping gets the "already_processed" path.
100+
account["balance_minor_units"] = balance - amount_in_minor_units
101+
account["transactions"].append( # type: ignore[union-attr]
102+
{"IBAN": iban, "BIC": bic, "amountMinorUnits": amount_in_minor_units, "currency": currency}
103+
)
104+
processed_keys.add(key)
105+
106+
call_number = num_calls
107+
num_calls += 1
108+
109+
print(f"[server] Payment processed (key={key}, call={call_number})")
110+
111+
if call_number % 2 == 0:
112+
# Simulate a slow response on even-numbered calls. The client times out
113+
# after 2 s and retries with the same idempotency key; this sleep means
114+
# the retry will always arrive after processing is committed.
115+
print(f"[server] Sleeping 5 s to trigger client timeout...")
116+
await anyio.sleep(5)
117+
118+
return '{"status": "processed", "message": "Payment applied."}'
119+
120+
121+
if __name__ == "__main__":
122+
app = server.streamable_http_app()
123+
uvicorn.run(app, host="127.0.0.1", port=8000)

0 commit comments

Comments
 (0)