Deal.ai is an AI shopping agent: it compares the same product's price across US stores, keeps each product's price history, and checks whether a deal's "was" price holds up. This month we made it usable inside Claude, ChatGPT, Cursor and VS Code as a remote MCP server at https://deal.ai/mcp. It's now listed in Claude's connectors directory and ChatGPT's plugin directory.
The tools were the easy part. Most of the work went into three things: sign-in for people who will never paste an API key, tools that take minutes rather than milliseconds, and getting through directory reviews. Here's how it fits together on Next.js and Vercel, and what we learned.
Disclosure: I'm the CTO of Deal.ai. Some store links in the server's answers may earn us a commission; that never changes which price is shown first.
Our users are shoppers, not developers. A local npx server with an API key in a JSON file was never going to work for them. So it had to be a remote server, and people had to sign in with an account they already have (or make one in ten seconds).
An account also gives the server something to hang limits on. Each account gets 600 requests, 30 shopping-agent questions and 10 deal checks an hour. That matters because the big assistants call from shared IP addresses: limiting by IP would let one heavy user lock everyone out.
The whole server is one Next.js route handler, app/mcp/route.ts, built on mcp-handler. It runs Streamable HTTP in stateless mode, which suits Vercel functions: there's no session to keep on an instance.
const handler = createMcpHandler(registerTools, {
serverInfo: SERVER_INFO, // name, title, website and icons, as clients show them
instructions: INSTRUCTIONS, // when to call which tool
});
export const maxDuration = 300;
export { handle as GET, handle as POST, handle as DELETE };
Each tool is registered with a zod input schema, a human title, and all four annotation hints. Directories check those hints closely, so they have to be honest. Most of our tools only read, but four change something in the person's own account: asking the shopping agent saves the conversation, a deal check publishes a shareable result page, and watching or unwatching a product edits their wish list.
server.registerTool(
"check_deal",
{
title: "Check a deal",
description: "Starts a deal check on a product link from any store, or a product's name, and optionally the price the shopper was offered. ...",
inputSchema: checkDealInput,
// Not read-only: a check reads every big store now and publishes its result as a shareable page.
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
},
async (args, ctx) => safely("check_deal", () => checkDeal(args, callerOf(ctx), deps), callerOf(ctx)),
);
The server's instructions turned out to matter as much as the tool descriptions. They tell the model which tool fits which question ("for what should I buy, use ask_deal_ai; it takes 1 to 3 minutes, so poll get_deal_ai_answer"), to cite the time each price was seen, and to link the Deal.ai page each answer comes from.
A request without a token gets a 401, and the WWW-Authenticate header says where to look:
curl -si https://deal.ai/mcp -X POST -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | grep -i www-authenticate
From the protected-resource metadata, the client finds our authorization server's metadata at /.well-known/oauth-authorization-server, and from there the authorize, token and registration endpoints. withMcpAuth from mcp-handler produces the 401 for us; we verify the token ourselves first (see section 5).
One detail we got wrong at first: if the database is slow, the check should answer 503, not 401. A 401 sends the client back through sign-in, which is the last thing you want during a blip.
MCP clients identify themselves in two ways, and we support both:
client_id is an https URL pointing to a JSON document that describes the client. Claude and VS Code do this. We fetch that document server-side with an SSRF-safe fetcher: no redirects, an 8 KB size cap and a 5-second timeout, and we cache it briefly./api/oauth/register and gets a Every client is public and must use PKCE with S256; there are no client secrets. A client can call itself anything, so the consent screen never trusts the name alone: for a metadata-document client it shows the host that published the document, so "Claude" really means claude.ai.
Access tokens and refresh tokens are opaque random values, a prefix plus 32 random bytes, and we store only their SHA-256. Access tokens last an hour. Refresh tokens last 90 days, rotate on every use, and slide forward while they're used.
Two rules do most of the security work:
Checking a token on each request is two indexed reads (the token, then its grant and whether the account still stands), each with a 5-second limit, and we only write "last used" once every few minutes.
Two of our tools are slow by nature. The shopping agent researches a request for one to three minutes, and a deal check reads every big store live, which takes 20 to 70 seconds. MCP clients and models get unhappy long before that.
So both work as start, then poll:
check_deal starts the check and returns its id at once; ask_deal_ai starts the agent and waits up to 20 seconds in case the answer is quick, then returns a search id.get_deal_ai_answer (or get_deal_check) with that id until the answer is ready.
The descriptions and server instructions say this plainly, and the models follow it well.
The catch is Vercel. When a response is sent, Vercel freezes the function unless something holds it open, and work still running is frozen mid-flight. Its timers only fire when the next request happens to wake that instance, possibly minutes later. We learned this the hard way: a model call with a 16-second limit got logged as a 10-minute timeout inside some unrelated request. The fix is to hand every piece of work that outlives the response to Next's after():
/** Keeps the function alive until `work` settles, for work started and not awaited. */
export function untilDone(work: Promise<unknown>): void {
const settled = work.then(() => undefined, () => undefined);
try {
after(() => settled);
} catch {
// Outside a request scope (a script, a test): nothing to hold open.
}
}
The agent's turn runs under untilDone, with its own deadline well inside the route's 300 seconds.
One more lesson from production: give every tool call an overall deadline. We had a client retrying a call every five minutes that never finished, so each attempt sat until Vercel killed it at 300 seconds, with nothing in the logs. Now any tool call past 90 seconds answers "try again" and logs which tool it was.
Assistants send us arbitrary text, and our tools read arbitrary web pages, so:
server.json as ai.deal/deal-ai. Ownership of the domain is proved over HTTP: /.well-known/mcp-registry-auth serves the public half of an Ed25519 key, and you sign a timestamp with the private half to get a publish token. Several directories copy their listings from the registry.
It's free with a Deal.ai account. In Claude or ChatGPT, search their directories for Deal.ai; Cursor and VS Code have one-click installs on deal.ai/mcp-server. Try "Ask Deal.ai for the best robot vacuum under $400" or "Is $199 a good price for AirPods Pro 3? Check it with Deal.ai."
I'd love feedback, especially from anyone running OAuth for MCP clients: what did your clients do that surprised you?
This post was drafted with AI assistance and edited by me.