refund-mcp-server
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@refund-mcp-serverRefund customer CUST-12345 $25 for damaged item"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
refund-mcp-server
Two related pieces:
Task 1 —
MCPServer.py: a minimal, strictly-validated MCP server over stdio. Documented below.Task 2 —
gateway.py: an HTTP/JSON-RPC reverse proxy that authenticates callers and authorizes individualtools/callrequests before they reach a downstream MCP server. See Task 2 — MCP Security Gateway.
Task 1 — refund-mcp-server
A minimal, strictly-validated MCP server (Python, official mcp SDK + Pydantic v2)
exposing two tools:
get_customer_record(customer_id: "CUST-XXXXX")trigger_refund(customer_id: "CUST-XXXXX", amount: float > 0, reason: str >= 10 chars)
Run it
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python MCPServer.pyIt speaks JSON-RPC over stdio, so you normally won't run it directly — point
an MCP client (Claude Desktop, mcp dev, your own ClientSession, etc.) at
python /path/to/MCPServer.py.
Related MCP server: pogo-tb buyer MCP bridge
Test it
pip install pytest pytest-asyncio
pytest -v client.pyclient.py spins up the real server as a subprocess (over stdio, just
like a real client would) and covers:
both tools are listed correctly,
happy-path calls for both tools,
a "customer not found" business outcome (still a successful JSON-RPC response, since the request itself was well-formed),
every validation edge case (negative/zero/boolean/string amount, short
reason, malformedcustomer_id, unknown extra fields) raising a real JSON-RPC-32602 INVALID_PARAMSerror,an unknown tool name raising
-32601 METHOD_NOT_FOUND,a raw-subprocess check that every line on stdout is valid, parseable JSON (the "stdout isolation" requirement).
Design decisions worth calling out
1. Strict Pydantic schemas
class TriggerRefundInput(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
customer_id: str = Field(..., pattern=r"^CUST-[A-Z0-9]{5}$")
amount: float = Field(..., gt=0)
reason: str = Field(..., min_length=10)strict=Trueturns off Pydantic's normal type coercion, so"10.0"is rejected foramountinstead of silently parsed into10.0.extra="forbid"rejects any field not in the schema (e.g. a client accidentally — or maliciously — passing an extraadmin: truefield).boolis a subclass ofintin Python, so a plainfloatfield would otherwise acceptTrue/Falseas1.0/0.0. Afield_validatorinmode="before"explicitly rejects booleans foramount.customer_idformat is enforced by a regex (CUST-+ 5 uppercase alphanumeric characters) on both tools. I assumedXXXXXmeans 5 alphanumeric characters based on the ticket; if the real spec is digits-only, this is a one-line change ([A-Z0-9]{5}→\d{5}).
2. STDIO isolation
Logging setup is pulled out into its own module,
logging_config.py, rather than living inline inMCPServer.py. It exposesget_logger(name), which configures the root logger to stderr exactly once (safe to call from multiple modules) and returns a standardlogging.Logger. This is the one and only place logging output is configured — a future module (e.g. a separated business-logic layer) just doesfrom logging_config import get_loggerand gets stderr-only logging for free, with no risk of a second, differently-configured logger sneaking in.No
print()calls exist anywhere inMCPServer.py.As defense-in-depth,
logging_config.pyalso monkey-patchesbuiltins.printat configure-time to forcefile=sys.stderr, so even an accidental futureprint("debug:", x)anywhere in the process can't corrupt the JSON-RPC channel on stdout.test_stdout_is_pure_json_rpcinclient.pyproves this empirically: it talks to the server as a raw subprocess (no client library abstraction) and asserts every line emitted on stdout is valid JSON.
3. JSON-RPC error-code compliance (the subtle part)
The installed SDK version (mcp==1.30.0) implements @server.call_tool()
such that it catches every exception raised inside your handler
(including a manually-raised McpError) and converts it into a
successful JSON-RPC response whose result is CallToolResult(isError=True, ...).
That's the MCP spec's recommended pattern for "the tool ran but the
operation failed" (so an LLM caller sees the failure and can retry/self-correct)
— but it is not a protocol-level JSON-RPC error, and this task explicitly
asks for "standard MCP JSON-RPC error codes."
To get real top-level JSON-RPC errors, this server registers its own
tools/call handler directly:
server.request_handlers[types.CallToolRequest] = _handle_call_toolThis is the same extension point @server.call_tool() uses internally, so
it's a supported (if low-level) integration path rather than a hack. Inside
_handle_call_tool, raising McpError(ErrorData(code=..., message=...))
propagates up to Server._handle_request, which specifically catches
McpError and returns its .error as a genuine JSON-RPC error object:
Condition | JSON-RPC code |
Unknown tool name |
|
Schema/format validation failure |
|
Unhandled exception in business logic |
|
A well-formed request for a customer that doesn't exist (e.g.
get_customer_record("CUST-99999")) is not treated as a protocol error
— the request was valid, so it returns a normal, successful result with
found: false in the payload. Conflating "malformed request" with
"legitimate not-found" is a common MCP anti-pattern this implementation
avoids on purpose.
4. Mock data
Customer records are an in-memory dict (CUST-00001, CUST-00002) purely
so the tools have something to operate on; swap _CUSTOMERS for a real
data-access layer in production.
Task 2 — MCP Security Gateway Proxy
A lightweight HTTP/JSON-RPC reverse proxy that sits between an AI agent and a
downstream MCP server, turns a Bearer token into a role, and refuses
privileged tools/call requests without ever contacting the downstream.
agent ──HTTP POST──▶ gateway.py ──HTTP POST──▶ mock_downstream.py
│
├─ Authorization: Bearer <token> ─▶ Principal(subject, role)
├─ tools/list ─▶ forwarded transparently
├─ tools/call ─▶ params.name inspected:
│ admin_* requires role=admin,
│ otherwise −32001, downstream untouched
└─ everything else ─▶ forwardedFiles
File | Role |
| The proxy: ASGI app, request handler, header plumbing, forwarding, response merging, CLI. |
|
|
| Pure authorization logic: which requests may be forwarded. |
| JSON-RPC 2.0 wire-format parsing and error construction. |
| Mock MCP server over HTTP. No auth of its own, on purpose. |
| 92 tests covering the whole path. |
Run it
pip install -r requirements.txt
python mock_downstream.py --port 8081 # terminal 1: downstream MCP server
python gateway.py --port 8080 # terminal 2: the gatewayDemo tokens: admin-token → role admin, viewer-token → role viewer.
# viewer calling a privileged tool -> intercepted, downstream never called
curl -s localhost:8080/mcp \
-H 'Authorization: Bearer viewer-token' -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"admin_reset_key","arguments":{"key_id":"KEY-PRIMARY"}}}'
# HTTP 403
# {"jsonrpc":"2.0","id":1,"error":{"code":-32001,
# "message":"Unauthorized Tool Call: tool 'admin_reset_key' requires role admin,
# caller has role 'viewer'.",
# "data":{"tool":"admin_reset_key","required_roles":["admin"],"role":"viewer"}}}
# same call as admin -> forwarded and executed
curl -s localhost:8080/mcp \
-H 'Authorization: Bearer admin-token' -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"admin_reset_key","arguments":{"key_id":"KEY-PRIMARY"}}}'
# HTTP 200 -> {"jsonrpc":"2.0","id":2,"result":{...,"status":"key_reset"}}
# what actually reached the downstream
curl -s localhost:8081/_receivedGET /healthz reports liveness and the configured upstream.
Configuration
python gateway.py --host 127.0.0.1 --port 8080 \
--upstream http://127.0.0.1:8081/mcp \
--path /mcp --timeout 30 \
[--filter-tools-list]Tokens come from the MCP_GATEWAY_TOKENS environment variable — a JSON object
of {"<token>": "<role>"} or {"<token>": {"sub": "...", "role": "..."}} —
falling back to the built-in demo pair (with a warning) when unset:
export MCP_GATEWAY_TOKENS='{"tok-abc":{"sub":"svc-etl","role":"admin"}}'Test it
pytest -q test_gateway.py # 92 passed
pytest -q test_gateway.py client.py # 111 passed (both tasks)Note that Task 1's tests live in client.py, which a bare pytest does not
collect — its filename doesn't match the default test_*.py pattern, so it has
to be named explicitly.
The gateway and the mock downstream are both mounted in-process over
httpx.ASGITransport, so the full path — HTTP headers → JSON-RPC parsing →
auth → policy → forwarding → response merging — runs for real without binding a
port. The load-bearing assertion throughout is mock_downstream.RECEIVED: an
intercepted request must never appear in it. Coverage includes every
authentication failure mode, every malformed-envelope shape, id echoing
(including 0, "", null and float ids), batch splitting and merging,
notification handling, role spoofing, and downstream unavailability.
Error codes
Condition | JSON-RPC code | HTTP |
|
| 403 |
Missing / malformed / unknown bearer token |
| 401 |
Downstream unreachable or timed out |
| 502 |
Body is not valid JSON |
| 400 |
Not a valid JSON-RPC 2.0 request / empty batch |
| 400 |
|
| 200 |
-32000/-32001/-32003 sit in the -32000..-32099 range the JSON-RPC spec
reserves for implementation-defined server errors. Everything else is relayed
verbatim from the downstream (e.g. its own -32601 for an unknown tool) — the
gateway does not invent verdicts that aren't its to make.
Design decisions worth calling out
1. The security decision is a pure function
gateway_policy.authorize(principal, request) -> Decision does no I/O and knows
nothing about HTTP or proxying. Reading one small file tells you exactly which
requests can reach the downstream, and every rule is directly unit-testable
without a server. The rule the brief asks for is one row of data rather than an
if buried in the request path:
TOOL_PREFIX_RULES = (
("admin_", frozenset({"admin"})),
)Adding finance_* → {finance, admin} is a data change, not a code change.
2. Deny means the downstream is never contacted
An unauthorized tools/call is answered entirely from the gateway. This is
asserted in the tests via mock_downstream.RECEIVED, which is the only
assertion that actually proves interception happened — checking the response
body alone would still pass if the request had been forwarded and the answer
rewritten.
3. Fail closed on undecidable requests
If a tools/call arrives without a string params.name, the gateway cannot
evaluate the policy, so it returns -32602 locally instead of forwarding a
request whose authorization status is unknowable. A malformed envelope is never
a reason to pass something through.
4. Transparent when it can be, surgical when it can't
Whole envelope allowed and no response rewriting needed → the original request bytes are forwarded unmodified and the downstream's status, body and headers are relayed as-is. No re-serialisation means no chance of the proxy altering semantics.
Otherwise (a partially-denied batch, or
--filter-tools-list) the gateway re-serialises just the permitted subset as a batch, then merges the downstream responses back with its own locally-generated errors, restoring the client's original request order.
5. Batches are handled member-by-member
A JSON-RPC batch is not one authorization decision, it's n. A viewer sending
[get_customer_record, admin_reset_key, tools/list] gets three responses in
order — two results and one -32001 — while the downstream sees a two-member
batch it was allowed to execute. Notifications (no id) are honoured properly:
a denied notification gets no response body (HTTP 204), because answering
one would violate the spec.
6. The gateway is the trust boundary, and says so downstream
Forwarded requests carry X-MCP-Gateway-Subject, X-MCP-Gateway-Role and a
correlating X-MCP-Gateway-Request-Id. Any client-supplied header in the
x-mcp-gateway-* namespace is stripped before forwarding, so a caller
cannot forge its own role by setting the header the downstream trusts — there's
a test for exactly that escalation attempt. Hop-by-hop headers (RFC 7230 §6.1)
are dropped in both directions, plus content-length (the body may be
rewritten) and content-encoding (httpx hands back decoded bytes).
7. Bearer parsing follows the RFCs, not intuition
The scheme is compared case-insensitively (bEaReR admin-token works, per
RFC 7235) while the token stays case-sensitive. A 401 always carries a
WWW-Authenticate challenge (RFC 6750). Token lookup runs
secrets.compare_digest against every configured token rather than a dict
hit, so response timing doesn't leak which prefix was right.
8. HTTP status codes carry the verdict too
JSON-RPC over HTTP conventionally answers 200 and puts the failure in the
body. That's honoured for ordinary application errors, but a security gateway
is far easier to monitor and alert on when a refusal is visible at the HTTP
layer, so single-request refusals also get a semantic status (403/401/502/400).
The JSON-RPC error object is present in every case regardless, so a strict
JSON-RPC client loses nothing. Mixed batches stay 200, since no single status
can describe them.
9. Ids are echoed exactly, including the awkward ones
0, "", null and float ids all round-trip unchanged — they're easy to
break with a truthiness check. "id": null (a request that must be answered
with a null id) is distinguished from an absent id (a notification that must
not be answered at all) via an explicit MISSING sentinel rather than None.
Even authentication failures echo the id where the body permits, which is safe
because an unauthenticated request is never forwarded.
10. tools/list filtering is opt-in
The brief specifies that tools/list is forwarded transparently, so by default
it is — a viewer sees the admin_* tools in the listing and is refused only at
call time. Because "Tool Filtering" is arguably the stronger posture (an agent
that can't see a tool won't waste a turn on it, and won't be tempted by it),
--filter-tools-list redacts unauthorized tools from the response. It's a flag
rather than the default precisely because it deviates from the stated
requirement.
11. The mock downstream trusts everyone, deliberately
mock_downstream.py has no auth and will happily rotate an API key for anyone
who asks. That's what makes the tests meaningful: it stands in for a real MCP
server whose privileged tools are only as safe as the gateway in front of them,
so any request that reaches it proves the gateway let it through. It reuses
Task 1's TOOL_REGISTRY and adds admin_reset_key and admin_list_audit_log
so there is something worth guarding.
12. Auditability
Every decision is logged at INFO/WARNING with a per-request correlation id,
the principal, the method, the tool and the outcome:
[7611d51f] DENY bob@example.com (viewer) method=tools/call tool=admin_reset_key code=-32001
[28a3b360] ALLOW alice@example.com (admin) method=tools/call tool=admin_reset_key
[28a3b360] upstream http://127.0.0.1:8081/mcp -> 200 (12.3 ms, 1 forwarded, 0 intercepted)Logging reuses Task 1's logging_config.py, so everything stays on stderr.
Known limitations
Called out rather than hidden — all are deliberate scope choices:
Static tokens. Real deployments verify a signed JWT (or call an introspection endpoint) and read the role from a claim. That swap is confined to
TokenStore.resolve(); nothing downstream of it changes.One role per principal.
Principal.roleis a single string because the brief describes exactly that. Afrozensetof roles withroles & required_roleswould be the natural extension.Bodies are buffered, not streamed. MCP's Streamable HTTP transport can reply with
text/event-stream. A fully-allowed request relays such a body byte-for-byte with its original content type, but it is buffered rather than streamed, so the client sees it only once complete. The merge and filter paths need a JSON body outright; an SSE body there is logged and treated as empty. Streaming-aware merging is a larger piece of work.No session/
initializestate. The gateway is stateless per request and does not track MCP session ids or enforce initialization ordering.No rate limiting or argument inspection. The policy looks at
params.name, notparams.arguments— it would not stop an admin-authorizedtrigger_refundfor an implausible amount.
This server cannot be deployed
Maintenance
Related MCP Connectors
Search Braintree transactions, customers and payment methods, and refund or void charges.
MCP server for Recurly — accounts, subscriptions, invoices, plans; cancel & pause subs.
A paid remote MCP for CLI tool MCP, built to return verdicts, receipts, usage logs, and audit-ready
Read subscriptions, customers, charges, orders; skip charges, cancel or activate subscriptions.
Related MCP Servers
- FlicenseNot gradedqualityAmaintenanceEnables EHR Copilot operations such as order cloning, queue building, and execution trace analysis over stdio.-
- AlicenseNot gradedqualityCmaintenanceEnables access to RDW vehicle and Dutch address data packs by automatically handling x402 payments using the buyer's own wallet key, running locally as a stdio MCP server.MIT
- FlicenseNot gradedqualityAmaintenanceEnables registering cards, upserting offer rules, recording transactions, and getting deterministic reward recommendations and cap-usage status for Taiwan credit cards over stdio JSON-RPC.-
- FlicenseNot gradedqualityBmaintenanceAn MCP server that exposes customer record lookup and refund triggering over stdio, with a gateway layer for authorization, streaming PII redaction, rate limiting, and failover to a backup provider.-