DEV Community

SecretPass
SecretPass

Posted on Fully Autonomous

Build a Python deal checker on an MCP server with plain JSON-RPC

Most MCP examples connect a server to Claude or Cursor and stop there. Under the hood, a remote MCP server is JSON-RPC over HTTP POST, so any script can call it too. In this tutorial we build a small command-line deal checker in Python on the open SecretPass MCP server, using only the standard library.

SecretPass is a shopping club with coupon codes and deals for online shopping stores in the United States, the United Kingdom and Australia. Searching its offers through MCP needs no account or API key.

Disclosure: this is written by the team behind SecretPass, using an AI agent that drafted the post and ran every code sample against the live server on 10 November 2026.

What the script does

The deal checker makes four kinds of MCP call:

Step JSON-RPC method Why
1 initialize Agree a protocol version and read the server's name
2 tools/list Discover which tools the server offers
3 tools/call with search_offers Find offers by region, category or keyword
4 tools/call with get_offer Read the full public details of each offer

For each result, it prints the shop, the listed saving, the conditions, the expiry date and the offer page.

A small JSON-RPC helper

Two headers matter. The Accept header must list both application/json and text/event-stream, as the Streamable HTTP transport requires. Without it, the server answered 406. Also set your own User-Agent: the site's firewall rejected Python's default Python-urllib agent with 403 in our tests, while a named agent such as deal-checker/0.1 worked.

import argparse
import json
import urllib.request

MCP_URL = "https://secretpass.com/mcp"
HEADERS = {
    "Content-Type": "application/json",
    "Accept": "application/json, text/event-stream",
    "User-Agent": "deal-checker/0.1",
}
_next_id = 0


def rpc(method, params=None):
    """Send one JSON-RPC request and return its result."""
    global _next_id
    _next_id += 1
    body = {"jsonrpc": "2.0", "id": _next_id, "method": method}
    if params is not None:
        body["params"] = params
    req = urllib.request.Request(
        MCP_URL, data=json.dumps(body).encode(), headers=HEADERS
    )
    with urllib.request.urlopen(req, timeout=60) as resp:
        raw = resp.read().decode()
        if resp.headers.get_content_type() == "text/event-stream":
            # Streamable HTTP servers may answer as SSE: take the last data line
            raw = [l[5:] for l in raw.splitlines() if l.startswith("data:")][-1]
    message = json.loads(raw)
    if "error" in message:
        raise RuntimeError(message["error"]["message"])
    return message["result"]


def notify(method):
    """Send a JSON-RPC notification: no id, and the server answers 202."""
    body = {"jsonrpc": "2.0", "method": method}
    req = urllib.request.Request(
        MCP_URL, data=json.dumps(body).encode(), headers=HEADERS
    )
    urllib.request.urlopen(req, timeout=60).close()


def call_tool(name, arguments):
    result = rpc("tools/call", {"name": name, "arguments": arguments})
    if result.get("isError"):
        raise RuntimeError(result["content"][0]["text"])
    return result["structuredContent"]
Enter fullscreen mode Exit fullscreen mode

A few details are worth knowing:

  • The SecretPass server answered every request with plain JSON and sent no session ID, so the script needs no session handling. Other Streamable HTTP servers can answer as a server-sent event stream, which is why rpc() handles both.
  • After initialize, a client sends the notifications/initialized notification, as the MCP lifecycle describes. A notification has no id, and the server answered it with 202 and an empty body.
  • A failed tool call comes back as a normal result with isError: true, not as a JSON-RPC error. A successful one includes structuredContent, a typed copy of the result that matches the tool's output schema.

Turning an offer into one line each

Each offer has an offer_display object with structured fields: savings_amount, savings_percent, minimum_spend and features. The labels are already formatted, such as £75.00. get_offer also adds a description.

Not every offer fills every field. Some describe the deal only in features, and the description often repeats a feature, so these two functions fall back and remove duplicates:

def listed_saving(offer):
    display = offer["offer_display"]
    labels = [s["label"] for s in display["savings_amount"]]
    labels += [s["label"] for s in display["savings_percent"]]
    return ", ".join(labels) or offer["offer_badge"] or "see conditions"


def conditions(offer):
    display = offer["offer_display"]
    notes = []
    for text in display["features"] + [offer.get("description", "")]:
        text = text.strip().rstrip(".")
        if text and text not in notes:
            notes.append(text)
    if not notes:
        notes = [m["label"] for m in display["minimum_spend"]]
    return "; ".join(notes) or "none listed"
Enter fullscreen mode Exit fullscreen mode

Putting it together

The main function connects, lists the tools, runs one search and reads each result with get_offer:

def main():
    parser = argparse.ArgumentParser(description="Check SecretPass offers")
    parser.add_argument("query", nargs="?", default="")
    parser.add_argument("--region", choices=["au", "uk", "us"], default="us")
    parser.add_argument("--category")
    parser.add_argument("--limit", type=int, default=3)
    args = parser.parse_args()

    info = rpc("initialize", {
        "protocolVersion": "2025-06-18",
        "capabilities": {},
        "clientInfo": {"name": "deal-checker", "version": "0.1"},
    })
    notify("notifications/initialized")
    print("Connected to", info["serverInfo"]["title"])
    tools = rpc("tools/list")["tools"]
    print("Tools:", ", ".join(t["name"] for t in tools))

    search = {"region": args.region, "limit": args.limit}
    if args.query:
        search["query"] = args.query
    if args.category:
        search["category"] = args.category
    found = call_tool("search_offers", search)
    print(f"{found['total']} offers match, showing {len(found['offers'])}\n")

    for hit in found["offers"]:
        offer = call_tool("get_offer", {"id": hit["id"]})
        print(f"{offer['business']}: {offer['title']}")
        print("  Saving:    ", listed_saving(offer))
        print("  Conditions:", conditions(offer))
        expires = offer["expires_at"]
        print("  Expires:   ", expires[:10] if expires else "no date listed")
        print("  Offer page:", offer["url"])
        print()


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Save the three blocks in order as deal_checker.py. It needs Python 3 and nothing else.

Run it

Category slugs come from the list_filters tool. On 10 November 2026 it listed 12 categories, such as home-and-living and tech-and-electronics. Here is a search for home and living offers in the UK:

python3 deal_checker.py --region uk --category home-and-living
Enter fullscreen mode Exit fullscreen mode

This is the output we got on 10 November 2026:

Connected to SecretPass public offers
Tools: search_offers, get_offer, list_filters
131 offers match, showing 3

Appliances Direct: Meaco dehumidifiers
  Saving:     £20.00
  Conditions: none listed
  Expires:    2026-12-31
  Offer page: https://secretpass.com/offer-item/uk/48405768

Appliances Direct: AEG hobs and hoods
  Saving:     £75.00
  Conditions: Spend over £600
  Expires:    2027-01-02
  Offer page: https://secretpass.com/offer-item/uk/48603126

Appliances Direct: Selected Robot Coupe catering equipment
  Saving:     £100.00
  Conditions: Selected items only
  Expires:    2026-12-02
  Offer page: https://secretpass.com/offer-item/uk/48637414
Enter fullscreen mode Exit fullscreen mode

Offers change and expire, so your results will differ. You can compare them with the home and living offers page on the website.

What we learned building it

  • Every query word must match. A UK search for headphones returned 0 offers, while browsing the category returned 131. Use 1 or 2 short singular keywords, or filter by category and brand instead.
  • Structured savings can be empty. Some US offers had no savings_amount or savings_percent and described the deal only in features, so always keep a fallback.
  • Expiry dates can be missing. Several results had expires_at: null. Print "no date listed" rather than guessing.
  • Voucher codes are not in the results. Each offer has membership_required_to_unlock: true. Search is free, and voucher codes are for members on the offer page. Show the user the offer page and its conditions instead of promising a saving.

To take it further, page through results with offset and next_offset, or pass the same JSON to an agent so it can compare conditions across shops. Setup steps for Claude, ChatGPT, Cursor and other clients are at secretpass.com/mcp.

Top comments (0)