A deal search answers "what is on offer right now?". A deal alert answers a better question: "what changed since I last looked?". In this tutorial we build a small Node.js script that watches one product type on the open SecretPass MCP server, saves a snapshot, and on the next run reports new offers, price drops, offers that are no longer listed and offers that end soon. It uses no packages, only fetch and JSON-RPC.
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 with Node.js 24 on 12 November 2026.
What the alert compares
Each run makes the same calls:
-
initialize, then thenotifications/initializednotification, as the MCP lifecycle describes. -
tools/callwithsearch_offers, followingnext_offsetuntil every matching offer is read.
Then it compares the result with deals-state.json from the previous run:
| Change | How it is detected |
|---|---|
| New offer | The offer id was not in the last snapshot |
| Price drop | price_now is lower than the saved price for the same id |
| No longer listed | An id from the last snapshot is missing now |
| Ending soon | expires_at falls within the next 21 days |
Offer IDs are stable, such as uk/48505185, so they make a good key.
Pick what to watch
list_filters returns the exact product types, brands and category slugs with current offer counts. On 12 November 2026 it listed Electric scooter with 46 offers across the three countries, so we watch that product type in the UK. A product type is a better watch filter than a keyword, because search_offers needs every query word to match.
The JSON-RPC helper and paging
The Accept header must list both application/json and text/event-stream, as the Streamable HTTP transport requires. Also send your own User-Agent: the site's firewall answered Python's default Python-urllib agent with 403 in our tests, while a named agent such as deal-watch/0.1 worked.
// deal-watch.mjs: report new offers, price drops and ending offers since the last run import { readFile, writeFile } from "node:fs/promises"; const MCP_URL = "https://secretpass.com/mcp"; const STATE_FILE = "deals-state.json"; const ENDING_SOON_DAYS = 21; const WATCH = { region: "uk", product_type: "Electric scooter" }; const HEADERS = { "Content-Type": "application/json", Accept: "application/json, text/event-stream", "User-Agent": "deal-watch/0.1", }; let nextId = 0; async function rpc(method, params) { const response = await fetch(MCP_URL, { method: "POST", headers: HEADERS, body: JSON.stringify({ jsonrpc: "2.0", id: ++nextId, method, params }), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); let raw = await response.text(); if (response.headers.get("content-type")?.startsWith("text/event-stream")) { raw = raw.split("\n").filter((l) => l.startsWith("data:")).pop().slice(5); } const message = JSON.parse(raw); if (message.error) throw new Error(message.error.message); return message.result; } async function searchAll(filters) { const offers = []; let offset = 0; while (offset !== null) { const result = await rpc("tools/call", { name: "search_offers", arguments: { ...filters, limit: 100, offset }, }); if (result.isError) throw new Error(result.content[0].text); offers.push(...result.structuredContent.offers); offset = result.structuredContent.next_offset; } return offers; } search_offers returns at most 100 offers per call. searchAll() keeps asking until next_offset is null, so the snapshot always holds the full list. Otherwise an offer on page two would look "no longer listed" on every run.
Comparing two snapshots
The snapshot keeps only what the alert needs. price_now is a formatted label such as £569.00, and many offers have no price at all, only a badge such as "15% off". So amount() turns a label into a number and returns null when there is nothing to compare:
// "£1099.99" -> 1099.99; empty or missing -> null const amount = (label) => { const n = parseFloat(String(label ?? "").replace(/[^0-9.]/g, "")); return Number.isFinite(n) ? n : null; }; function snapshot(offer) { return { shop: offer.business, title: offer.title, badge: offer.offer_badge, price: offer.price_now || null, expires: offer.expires_at, url: offer.url, }; } function compare(before, now, today) { const report = { added: [], dropped: [], ending: [], gone: [] }; const soon = today.getTime() + ENDING_SOON_DAYS * 86400000; for (const [id, offer] of Object.entries(now)) { const old = before[id]; if (!old) report.added.push(offer); else if (amount(offer.price) !== null && amount(old.price) !== null && amount(offer.price) < amount(old.price)) { report.dropped.push({ ...offer, was: old.price }); } if (offer.expires && new Date(offer.expires).getTime() < soon) report.ending.push(offer); } for (const [id, offer] of Object.entries(before)) if (!now[id]) report.gone.push(offer); return report; } A price only counts as a drop when both the old and the new price are numbers. Offers without a price can still show up as new, gone or ending soon.
Printing the report and saving the state
const line = (o) => ` ${o.shop}: ${o.title} (${o.badge || o.price || "see offer"})\n ${o.url}`; async function connect() { await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "deal-watch", version: "0.1" }, }); // A notification has no id; the server answers 202 with no body await fetch(MCP_URL, { method: "POST", headers: HEADERS, body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), }); } async function main() { await connect(); const before = JSON.parse(await readFile(STATE_FILE, "utf8").catch(() => "{}")); const firstRun = Object.keys(before).length === 0; const offers = await searchAll(WATCH); const now = Object.fromEntries(offers.map((o) => [o.id, snapshot(o)])); const report = compare(before, now, new Date()); console.log(`${offers.length} offers match ${JSON.stringify(WATCH)}`); if (firstRun) { console.log("First run: saved a baseline, nothing to compare yet."); } else { console.log(`\nNew offers (${report.added.length})`); report.added.forEach((o) => console.log(line(o))); console.log(`\nPrice drops (${report.dropped.length})`); report.dropped.forEach((o) => console.log(`${line(o)}\n was ${o.was}, now ${o.price}`)); console.log(`\nNo longer listed (${report.gone.length})`); report.gone.forEach((o) => console.log(line(o))); } console.log(`\nEnding within ${ENDING_SOON_DAYS} days (${report.ending.length})`); report.ending .sort((a, b) => a.expires.localeCompare(b.expires)) .forEach((o) => console.log(`${line(o)}\n ends ${o.expires.slice(0, 16).replace("T", " ")} UTC`)); await writeFile(STATE_FILE, JSON.stringify(now, null, 2)); } main().catch((error) => { console.error(error.message); process.exit(1); }); Save the three blocks in order as deal-watch.mjs. It needs Node.js 18 or later for the built-in fetch.
Run it
The first run has nothing to compare, so it saves a baseline and lists the offers that end soon:
node deal-watch.mjs 38 offers match {"region":"uk","product_type":"Electric scooter"} First run: saved a baseline, nothing to compare yet. Ending within 21 days (2) iHoverboard: F7Ultra All-Terrain Electric Scooter (Now £399.99, was £1,099.99) https://secretpass.com/offer-item/uk/48505185 ends 2026-11-27 23:59 UTC iscooter: iScooter F7Ultra All-Terrain Electric Scooter (Now £399.99, was £499.99) https://secretpass.com/offer-item/uk/48480011 ends 2026-11-28 00:59 UTC Prices only change when a shop changes an offer, so two runs a minute apart show no changes. To test the alert without waiting, we edited deals-state.json by hand: we removed one offer and raised one saved price from £569.00 to £609.00. The second run then reported both:
38 offers match {"region":"uk","product_type":"Electric scooter"} New offers (1) isinwheel UK: S4 Electric Scooter for Kids (15% off the S4 Electric Scooter for Kids) https://secretpass.com/offer-item/uk/48639589 Price drops (1) iSinwheel: Commuting electric scooter (6.57% off (£40 off)) https://secretpass.com/offer-item/uk/48291637 was £609.00, now £569.00 No longer listed (0) Ending within 21 days (2) iHoverboard: F7Ultra All-Terrain Electric Scooter (Now £399.99, was £1,099.99) https://secretpass.com/offer-item/uk/48505185 ends 2026-11-27 23:59 UTC iscooter: iScooter F7Ultra All-Terrain Electric Scooter (Now £399.99, was £499.99) https://secretpass.com/offer-item/uk/48480011 ends 2026-11-28 00:59 UTC A third run, with the state now up to date, printed New offers (0), Price drops (0) and No longer listed (0), plus the same two ending offers. Offers change and expire, so your results will differ. You can compare them with the UK electric scooter offers on the website, which also showed 38 offers.
What we learned building it
- Page through everything. A partial snapshot turns every unseen offer into a false "no longer listed" alert.
- Not every offer has a price. In this UK list, 6 of the 38 offers had a
price_now. The rest describe the deal withoffer_badge, so the alert also tracks new and ending offers, not only prices. - A lower price is a change, not a promise.
price_wasandprice_noware the prices listed with the offer. Show the shop, the offer page and the end time, and let the shopper check the final price at the checkout. - End times are UTC.
expires_atis an ISO timestamp with an offset, so2026-11-27T23:59:00+00:00is late evening on 27 November 2026 in London and the morning of 28 November 2026 in Sydney. - Voucher codes are not in the results. Each offer has
membership_required_to_unlock. Search is free, and voucher codes are for members on the offer page.
To run it every morning, schedule it with cron or a CI job and send the report anywhere you already read alerts. To watch more than one thing, turn WATCH into a list and keep one state file per watch. Setup steps for Claude, ChatGPT, Cursor and other MCP clients are at secretpass.com/mcp.

