{"slug": "adding-a-remote-mcp-server-to-a-next-js-app-oauth-and-all", "title": "Adding a remote MCP server to a Next.js app, OAuth and all", "summary": "A developer shipped a remote MCP server for Deoochform, a form builder that lets users create forms by prompting an assistant, built on Next.js 16's App Router with the official Model Context Protocol SDK. The implementation covers the full OAuth discovery chain — protected-resource and authorization-server metadata endpoints plus /authorize, /token, and /register — so clients like Claude and ChatGPT can connect over HTTPS with a browser sign-in instead of a pasted API key. The developer notes the server is constructed per request with the caller's identity baked in, and that stateless operation is required on serverless hosts like Vercel.", "body_md": "Most \"add MCP to your app\" posts stop at stdio: a local process, a config file,\n\na token you paste in. That works on your laptop and nowhere else. A *remote*\n\nMCP server, the kind Claude or ChatGPT connects to over HTTPS with a browser\n\nsign-in, needs OAuth, and the interesting parts are the ones no tutorial covers.\n\nI shipped one for [Deoochform](https://deoochform.com), a form builder where the\n\nwhole point is that you can build the form by asking an assistant instead of\n\ndragging fields around. So the MCP server is not a side feature, it is the\n\nproduct surface. That forced me to get the auth story right rather than\n\nhand-waving it with a pasted API key.\n\nHere is the whole thing, Next.js 16 App Router, no framework beyond the official\n\nSDK.\n\nFour HTTP surfaces:\n\n`POST /api/mcp`: the MCP endpoint itself.` GET /.well-known/oauth-protected-resource/api/mcp`: \"here is who authorizes me\".` GET /.well-known/oauth-authorization-server`: \"here are my OAuth endpoints\".`/authorize`, `/token`, `/register`: the OAuth endpoints themselves.\nA client that has never seen your server walks all four in order, unprompted.\n\nThat discovery chain is the whole reason a user can type a URL into Claude and\n\nget a browser sign-in instead of a token prompt.\n\nThe SDK ships a transport that speaks Web-standard `Request`/` Response`, which is\n\nexactly what an App Router route handler deals in:\n\n``` js\nimport { WebStandardStreamableHTTPServerTransport } from \"@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js\";\n\nasync function handle(request: Request) {\n  const actor = await resolveActor(request);\n  if (!actor) return unauthorized(request);\n\n  const server = createMcpServer(actor);\n  const transport = new WebStandardStreamableHTTPServerTransport({\n    sessionIdGenerator: undefined,\n    enableJsonResponse: true,\n  });\n\n  await server.connect(transport);\n  return transport.handleRequest(request);\n}\n\nexport { handle as GET, handle as POST, handle as DELETE };\n```\n\nTwo things worth pausing on.\n\n`sessionIdGenerator: undefined` makes the server stateless. On Vercel or any\n\nserverless host, consecutive requests land on different instances, so there is\n\nnowhere for a session to live. Stateless is not a downgrade here, it is the only\n\nthing that works.\n\nThe server is constructed **per request, with the actor baked in**. Not a\n\nmodule-level singleton with the user passed into each tool call. Every tool\n\ncloses over the caller's identity, so there is no code path where a tool can\n\nread a row belonging to somebody else. It costs one object allocation per\n\nrequest and removes an entire category of bug.\n\nBrowser-based clients (ChatGPT, Claude on the web) call your endpoint\n\ncross-origin. The browser fires a preflight `OPTIONS` first, and if that fails\n\nthe real request never happens. You see nothing in your logs, and the client\n\nsays something unhelpful about being unable to connect.\n\n``` js\nconst CORS_HEADERS = {\n  \"Access-Control-Allow-Origin\": \"*\",\n  \"Access-Control-Allow-Methods\": \"GET, POST, DELETE, OPTIONS\",\n  \"Access-Control-Allow-Headers\":\n    \"Content-Type, Authorization, mcp-session-id, mcp-protocol-version, Last-Event-ID\",\n  \"Access-Control-Expose-Headers\": \"mcp-session-id, mcp-protocol-version\",\n};\n```\n\n`Expose-Headers` is the one people miss. Without it the browser hides the MCP\n\nprotocol headers from the client even though your server sent them.\n\nAn unauthenticated call must not just return 401. It has to say where to go,\n\nusing `WWW-Authenticate` per RFC 9728:\n\n``` js\nconst metadataUrl =\n  `${origin}/.well-known/oauth-protected-resource/api/mcp`;\n\nreturn Response.json(\n  { error: \"Unauthorized\" },\n  {\n    status: 401,\n    headers: { \"WWW-Authenticate\": `Bearer resource_metadata=\"${metadataUrl}\"` },\n  },\n);\n```\n\nThis single header is the trigger for the entire browser sign-in flow. Return a\n\nbare 401 and the client concludes your server is broken rather than\n\npassword-protected.\n\nNote the metadata path: RFC 9728 nests it under the resource's own path. If you\n\nserve two MCP endpoints, `/api/mcp` and `/api/mcp/v2`, each needs its own\n\ndocument at its own nested path. They are not interchangeable.\n\n```\n// /.well-known/oauth-protected-resource/api/mcp/route.ts\nexport async function GET() {\n  return NextResponse.json({\n    resource: `${origin}/api/mcp`,\n    authorization_servers: [origin],\n  });\n}\n```\n\nYou are probably already sitting on a session system. You do not need Auth0 for\n\nthis. The metadata document is a static JSON file plus three routes:\n\n```\nexport async function GET() {\n  return NextResponse.json({\n    issuer: origin,\n    authorization_endpoint: `${origin}/authorize`,\n    token_endpoint: `${origin}/token`,\n    registration_endpoint: `${origin}/register`,\n    response_types_supported: [\"code\"],\n    grant_types_supported: [\"authorization_code\"],\n    code_challenge_methods_supported: [\"S256\"],\n    token_endpoint_auth_methods_supported: [\"none\"],\n  });\n}\n```\n\n`token_endpoint_auth_method: \"none\"` is correct and not a shortcut. An MCP\n\nconnector is a *public* client. It ships to end users and cannot keep a secret,\n\nso PKCE, not a client secret, is what proves the token exchange came from the\n\nsame client that started the flow. (If you also serve confidential clients, say\n\na Zapier integration, add `client_secret_basic` and `client_secret_post`\n\nalongside it.)\n\nRFC 7591 says a client can register itself. In practice, for a public client\n\nauthenticated by PKCE, there is nothing meaningful to store:\n\n``` js\nexport async function POST(request: Request) {\n  const body = await request.json().catch(() => ({}));\n  return NextResponse.json({\n    client_id: \"dfc_\" + randomBytes(9).toString(\"base64url\"),\n    client_name: body.client_name ?? \"MCP Client\",\n    redirect_uris: body.redirect_uris ?? [],\n    grant_types: [\"authorization_code\"],\n    response_types: [\"code\"],\n    token_endpoint_auth_method: \"none\",\n  });\n}\n```\n\nThat is the entire endpoint. It mints an id and hands it back. A clients table\n\nwould be ceremony: nothing downstream consults it.\n\nBut be careful about what you conclude from that. \"We do not register clients,\n\nPKCE covers it\" is the sentence I would have written before I thought it\n\nthrough, and it is wrong. See the next section.\n\nPKCE binds an authorization code to whoever started the flow. The usual mental\n\nmodel is that this makes an unregistered `redirect_uri` safe, because a\n\nstolen code is useless without the verifier.\n\nThat model breaks when **the attacker is the one who started the flow**. They\n\ncraft an `/authorize` link with their own `redirect_uri` and their own\n\n`code_challenge`, and send it to a signed-in victim. The victim's browser\n\nfollows it. The code is minted against the victim's session, redirects to the\n\nattacker's callback, and the attacker exchanges it with the verifier they chose.\n\nAn access token for someone else's account, from one click. PKCE did its job\n\nperfectly and protected nobody, because the attacker held the verifier all\n\nalong.\n\nSo the redirect cannot be automatic. `GET /authorize` renders a consent page\n\ninstead, and the code is only minted by a `POST` from that page:\n\n``` js\nexport async function POST(request: Request) {\n  const origin = request.headers.get(\"origin\");\n  if (origin !== url.origin) {\n    return NextResponse.json(\n      { error: \"invalid_request\", error_description: \"Cross-origin approval refused.\" },\n      { status: 403 },\n    );\n  }\n  // ... re-read params, re-read the session, then mint\n}\n```\n\nTwo properties do the work. The approval is a step a crafted link cannot\n\nperform on the victim's behalf. And `Origin` is sent on every form POST and\n\ncannot be forged by page script, so a cross-site auto-submitting form is not an\n\napproval either.\n\nThe consent page names the host the code is about to go to, not the full URI.\n\nThe host is the part that actually matters to the decision, and a long URI just\n\ngives someone something to skim past.\n\nRe-read the session inside the POST rather than trusting a hidden field. The\n\nsession is the only thing that says whose account the code is for.\n\nOne caveat on the redirect itself: use a **303**, not a 307. Approval arrives as\n\na POST, and 307 preserves the method, so the browser would POST the code to a\n\ncallback that only answers GET. The client reports a bare \"Bad Request\" and it\n\nlooks like the client's bug rather than yours.\n\nA confidential client with a pre-registered `redirect_uri` (a Zapier\n\nintegration, say) can skip the prompt. There is no third party to consent to,\n\nbecause the caller could not have changed the destination.\n\nTwelve lines, all of it standard library:\n\n``` js\nimport { randomBytes, createHash } from \"crypto\";\n\nconst CODE_TTL_MS = 5 * 60 * 1000;\n\nexport const generateAuthCode = () =>\n  \"dfc_\" + randomBytes(24).toString(\"base64url\");\n\nexport const codeExpiry = () =>\n  new Date(Date.now() + CODE_TTL_MS).toISOString();\n\nexport function verifyPkce(verifier: string, challenge: string) {\n  return createHash(\"sha256\").update(verifier).digest(\"base64url\") === challenge;\n}\n```\n\nFive-minute code TTL, single use, deleted on exchange. `/authorize` runs behind\n\nyour normal session check, so an unauthenticated user hits your existing login\n\nfirst and comes back. That is the whole reason the user never sees a token.\n\nIf you also compare client secrets anywhere, use `timingSafeEqual`, and check\n\nlengths yourself first. `timingSafeEqual` throws on a length mismatch, and an\n\nuncaught throw becomes a 500 that leaks the secret's length.\n\nThe token you issue can just be a row. Store a hash, never the token:\n\n```\nconst { data: token } = await admin\n  .from(\"api_tokens\")\n  .select(\"id, user_id, revoked_at, profiles(id, email, role, plan)\")\n  .eq(\"token_hash\", hashToken(raw))\n  .is(\"revoked_at\", null)\n  .maybeSingle();\n```\n\nOne query gets you validity and the caller's identity, role, and plan. The\n\nconnection then acts as that user, with their permissions, which means your\n\nexisting row-level security applies to MCP traffic for free. No parallel\n\npermission model to keep in sync.\n\nI originally planned one endpoint with a capability flag, so a listed directory\n\nconnector could be restricted while my own stayed full-featured. It does not\n\nwork: an app directory registers its OAuth client against a base URL and then\n\nfreezes it. Whatever a listed URL is allowed to do has to be settled *before*\n\nyou list it.\n\nSo there are two endpoints, `/api/mcp` and `/api/mcp/v2`, same server behind\n\ndifferent options. The public one cannot see or create payment fields at all,\n\nwhich means nothing built through the directory listing can collect money. Two\n\nroutes, five lines each:\n\n```\nexport const { GET, POST, DELETE, OPTIONS } =\n  mcpHandlers({ path: \"/api/mcp/v2\", payments: false });\n```\n\nCapability decisions are URL-shaped, not runtime-shaped. Plan for that before\n\nyou submit anywhere.\n\n`sessionIdGenerator: undefined`.` Expose-Headers`.` WWW-Authenticate` with the resource metadata URL.\nNone of this is much code. It is roughly 150 lines across six files. The hard\n\npart was working out which pieces of the OAuth spec actually apply to a public\n\nclient that a user connects by pasting a URL, and which are ceremony.\n\nThe result is what I wanted: you paste\n\n`https://deoochform.com/api/mcp/v2` into Claude, a browser tab opens, you sign\n\nin, and you are done. No token to copy, nothing to store. The\n\n[MCP server docs](https://deoochform.com/docs/mcp-server) shows it from the user\n\nside if you want to see what the flow feels like before building your own.", "url": "https://wpnews.pro/news/adding-a-remote-mcp-server-to-a-next-js-app-oauth-and-all", "canonical_source": "https://dev.to/musaib_khan_acf62e837beb3/adding-a-remote-mcp-server-to-a-nextjs-app-oauth-and-all-jn8", "published_at": "2026-09-10 10:44:53+00:00", "updated_at": "2026-09-10 10:58:29.188092+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-products"], "entities": ["Deoochform", "Next.js", "Model Context Protocol", "Claude", "ChatGPT", "Vercel"], "alternates": {"html": "https://wpnews.pro/news/adding-a-remote-mcp-server-to-a-next-js-app-oauth-and-all", "markdown": "https://wpnews.pro/news/adding-a-remote-mcp-server-to-a-next-js-app-oauth-and-all.md", "text": "https://wpnews.pro/news/adding-a-remote-mcp-server-to-a-next-js-app-oauth-and-all.txt", "jsonld": "https://wpnews.pro/news/adding-a-remote-mcp-server-to-a-next-js-app-oauth-and-all.jsonld"}}