|
| 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