{"slug": "how-we-built-a-shopping-mcp-server-with-oauth-on-next-js-and-vercel", "title": "How we built a shopping MCP server with OAuth on Next.js and Vercel", "summary": "Deal.ai, an AI shopping agent that compares product prices across US stores and tracks price history, launched a remote MCP server at deal.ai/mcp that works inside Claude, ChatGPT, Cursor and VS Code, and is now listed in Claude's connectors and ChatGPT's plugin directories. The company's CTO said the tools were the easy part, with most effort going into OAuth sign-in for non-developer shoppers, long-running tools that take minutes, and directory review requirements. The server runs as a single stateless Next.js route handler on Vercel using mcp-handler, with per-account rate limits of 600 requests, 30 shopping-agent questions and 10 deal checks per hour.", "body_md": "[Deal.ai](https://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](https://claude.ai/directory/deal-ai) and [ChatGPT's plugin directory](https://chatgpt.com/plugins/plugin_asdk_app_6af4a42ad6f8819187569998bce66cda).\n\nThe 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.\n\n*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.*\n\nOur 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).\n\nAn 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.\n\nThe whole server is one Next.js route handler, `app/mcp/route.ts`, built on [` mcp-handler`](https://github.com/vercel/mcp-handler). It runs Streamable HTTP in stateless mode, which suits Vercel functions: there's no session to keep on an instance.\n\n``` js\nconst handler = createMcpHandler(registerTools, {\n  serverInfo: SERVER_INFO,   // name, title, website and icons, as clients show them\n  instructions: INSTRUCTIONS, // when to call which tool\n});\n\nexport const maxDuration = 300;\nexport { handle as GET, handle as POST, handle as DELETE };\n```\n\nEach 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.\n\n```\nserver.registerTool(\n  \"check_deal\",\n  {\n    title: \"Check a deal\",\n    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. ...\",\n    inputSchema: checkDealInput,\n    // Not read-only: a check reads every big store now and publishes its result as a shareable page.\n    annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },\n  },\n  async (args, ctx) => safely(\"check_deal\", () => checkDeal(args, callerOf(ctx), deps), callerOf(ctx)),\n);\n```\n\nThe 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.\n\nA request without a token gets a 401, and the `WWW-Authenticate` header says where to look:\n\n```\ncurl -si https://deal.ai/mcp -X POST -H 'content-type: application/json' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}' | grep -i www-authenticate\n# WWW-Authenticate: Bearer resource_metadata=\"https://deal.ai/.well-known/oauth-protected-resource/mcp\"\n```\n\nFrom 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).\n\nOne 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.\n\nMCP clients identify themselves in two ways, and we support both:\n\n`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`.\n\nAccess 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.\n\nTwo rules do most of the security work:\n\nChecking 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.\n\nTwo 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.\n\nSo both work as **start, then poll**:\n\n`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.\nThe descriptions and server instructions say this plainly, and the models follow it well.\n\nThe 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()`:\n\n```\n/** Keeps the function alive until `work` settles, for work started and not awaited. */\nexport function untilDone(work: Promise<unknown>): void {\n  const settled = work.then(() => undefined, () => undefined);\n  try {\n    after(() => settled);\n  } catch {\n    // Outside a request scope (a script, a test): nothing to hold open.\n  }\n}\n```\n\nThe agent's turn runs under `untilDone`, with its own deadline well inside the route's 300 seconds.\n\nOne 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.\n\nAssistants send us arbitrary text, and our tools read arbitrary web pages, so:\n\n`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.\nIt'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](https://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.\"\n\nI'd love feedback, especially from anyone running OAuth for MCP clients: what did your clients do that surprised you?\n\n*This post was drafted with AI assistance and edited by me.*", "url": "https://wpnews.pro/news/how-we-built-a-shopping-mcp-server-with-oauth-on-next-js-and-vercel", "canonical_source": "https://dev.to/davedealai/how-we-built-a-shopping-mcp-server-with-oauth-on-nextjs-and-vercel-3k3j", "published_at": "2026-11-14 03:08:58+00:00", "updated_at": "2026-11-14 03:20:35.864222+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-tools", "ai-products"], "entities": ["Deal.ai", "Claude", "ChatGPT", "Cursor", "VS Code", "Vercel", "Next.js", "mcp-handler"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-we-built-a-shopping-mcp-server-with-oauth-on-next-js-and-vercel", "markdown": "https://wpnews.pro/news/how-we-built-a-shopping-mcp-server-with-oauth-on-next-js-and-vercel.md", "text": "https://wpnews.pro/news/how-we-built-a-shopping-mcp-server-with-oauth-on-next-js-and-vercel.txt", "jsonld": "https://wpnews.pro/news/how-we-built-a-shopping-mcp-server-with-oauth-on-next-js-and-vercel.jsonld"}}